Add Konverter: a local-only menu bar file converter

Drag files in Finder, hold a modifier, and drop on a wheel of outputs.
Conversions run entirely on-device through ImageIO, PDFKit, AVFoundation,
the bundled CLI tools, and optional ffmpeg/LibreOffice.

Includes a --self-test suite (41 checks) and a --convert CLI mode so the
conversion matrix can be verified without the UI.
This commit is contained in:
2026-10-20 00:36:40 +08:00
commit 2d031e58a4
47 changed files with 9149 additions and 0 deletions
@@ -0,0 +1,328 @@
import CoreGraphics
import Foundation
import ImageIO
import AppKit
import UniformTypeIdentifiers
enum ConversionError: LocalizedError {
case unreadable(String)
case unsupported(String)
case failed(String)
case missingTool(String)
var errorDescription: String? {
switch self {
case .unreadable(let name): return "Could not read \(name)."
case .unsupported(let reason): return reason
case .failed(let reason): return reason
case .missingTool(let reason): return reason
}
}
}
/// Bitmap plumbing shared by the image converter and the image tools.
enum Raster {
static let encodableTypes: Set<String> = Set(
(CGImageDestinationCopyTypeIdentifiers() as? [String]) ?? []
)
static func canEncode(_ format: Format) -> Bool {
guard let uti = format.uti else { return false }
return encodableTypes.contains(uti)
}
static func imageSource(at url: URL) -> CGImageSource? {
CGImageSourceCreateWithURL(url as CFURL, [kCGImageSourceShouldCacheImmediately: true] as CFDictionary)
}
/// Decodes the first frame. Falls back to AppKit for formats ImageIO
/// cannot decode but the system can (SVG, some RAW, exotic TIFFs), and to
/// Quick Look as a last resort.
static func firstImage(at url: URL) -> CGImage? {
if let source = imageSource(at: url), let image = CGImageSourceCreateImageAtIndex(source, 0, nil) {
return image
}
if let image = NSImage(contentsOf: url),
let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) {
return cgImage
}
return quickLookRaster(url)
}
static func frameCount(at url: URL) -> Int {
guard let source = imageSource(at: url) else { return 1 }
return max(CGImageSourceGetCount(source), 1)
}
private static func quickLookRaster(_ url: URL) -> CGImage? {
guard let qlmanage = Toolchain.qlmanage else { return nil }
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("konverter-ql-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: directory) }
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
_ = try? Shell.run(qlmanage, ["-t", "-s", "2048", "-o", directory.path, url.path])
guard let produced = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)
.first(where: { $0.pathExtension == "png" }) else { return nil }
return NSImage(contentsOf: produced)?.cgImage(forProposedRect: nil, context: nil, hints: nil)
}
/// Paints the image onto an opaque background, dropping the alpha channel.
/// JPEG cannot store alpha and would otherwise render black.
static func flattened(_ image: CGImage, background: CGColor = CGColor(gray: 1, alpha: 1)) -> CGImage {
let width = image.width
let height = image.height
guard let context = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue
) else { return image }
context.setFillColor(background)
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
return context.makeImage() ?? image
}
/// Reduces the number of distinct colours in an image. Lossless formats
/// have no quality knob, so a "compress" of a PNG has to trade colour
/// depth for size — which is exactly what this does. `quality` is the
/// 0…1 slider: 1.0 leaves the pixels untouched.
static func posterized(_ image: CGImage, quality: Double) -> CGImage {
let bits = max(1.0, min(8.0, quality * 8.0))
let levels = min(256, max(2, Int(pow(2.0, bits))))
guard levels < 256 else { return image }
let width = image.width
let height = image.height
let bytesPerRow = width * 4
var buffer = [UInt8](repeating: 0, count: bytesPerRow * height)
let space = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
let drawn: Bool = buffer.withUnsafeMutableBytes { raw -> Bool in
guard let context = CGContext(
data: raw.baseAddress,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: bytesPerRow,
space: space,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return false }
context.interpolationQuality = .high
context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
return true
}
guard drawn else { return image }
let step = 256 / levels
let half = step / 2
for index in stride(from: 0, to: buffer.count, by: 4) {
for channel in 0..<3 {
let value = Int(buffer[index + channel])
buffer[index + channel] = UInt8(min(255, (value / step) * step + half))
}
}
let result: CGImage? = buffer.withUnsafeMutableBytes { raw -> CGImage? in
guard let context = CGContext(
data: raw.baseAddress,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: bytesPerRow,
space: space,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return nil }
return context.makeImage()
}
return result ?? image
}
/// Bakes an EXIF orientation into the pixels so the result needs no
/// metadata to look right.
static func oriented(_ image: CGImage, orientation: CGImagePropertyOrientation) -> CGImage {
guard orientation != .up else { return image }
let swapsDimensions = orientation == .left || orientation == .right
|| orientation == .leftMirrored || orientation == .rightMirrored
let width = swapsDimensions ? image.height : image.width
let height = swapsDimensions ? image.width : image.height
guard let context = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: image.colorSpace ?? CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return image }
switch orientation {
case .up: break
case .upMirrored: context.translateBy(x: CGFloat(width), y: 0); context.scaleBy(x: -1, y: 1)
case .down: context.translateBy(x: CGFloat(width), y: CGFloat(height)); context.rotate(by: .pi)
case .downMirrored: context.translateBy(x: 0, y: CGFloat(height)); context.scaleBy(x: 1, y: -1)
case .left: context.translateBy(x: 0, y: CGFloat(height)); context.rotate(by: -.pi / 2)
case .leftMirrored: context.translateBy(x: CGFloat(width), y: CGFloat(height)); context.rotate(by: -.pi / 2); context.scaleBy(x: 1, y: -1)
case .right: context.translateBy(x: CGFloat(width), y: 0); context.rotate(by: .pi / 2)
case .rightMirrored: context.rotate(by: .pi / 2); context.scaleBy(x: 1, y: -1)
@unknown default: break
}
context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
return context.makeImage() ?? image
}
static func orientation(of source: CGImageSource) -> CGImagePropertyOrientation {
guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
let raw = properties[kCGImagePropertyOrientation] as? UInt32,
let orientation = CGImagePropertyOrientation(rawValue: raw) else { return .up }
return orientation
}
/// Encodes a single image. `metadata == nil` writes a clean file with no
/// EXIF/GPS/XMP at all.
static func write(
_ image: CGImage,
to url: URL,
format: Format,
quality: Double,
metadata: [CFString: Any]?
) throws {
guard let uti = format.uti else {
throw ConversionError.unsupported("\(format.displayName) is not an image format.")
}
guard canEncode(format) else {
throw ConversionError.unsupported("This Mac cannot write \(format.displayName) images.")
}
var properties: [CFString: Any] = metadata ?? [kCGImagePropertyOrientation: CGImagePropertyOrientation.up.rawValue]
if format.isLossy {
properties[kCGImageDestinationLossyCompressionQuality] = max(0, min(1, quality))
}
guard let destination = CGImageDestinationCreateWithURL(url as CFURL, uti as CFString, 1, nil) else {
throw ConversionError.failed("Could not create \(format.displayName) at \(url.lastPathComponent).")
}
CGImageDestinationAddImage(destination, image, properties as CFDictionary)
guard CGImageDestinationFinalize(destination) else {
throw ConversionError.failed("Writing \(url.lastPathComponent) failed.")
}
}
}
/// WebP has no encoder in ImageIO on macOS. `cwebp` (from libwebp) is the
/// best path; ffmpeg works when it was built with libwebp. Both are optional.
enum WebPEncoder {
static let cwebp: String? = Shell.find("cwebp", extraPaths: ["/opt/homebrew/bin", "/usr/local/bin"])
static let ffmpegHasLibwebp: Bool = {
guard let ffmpeg = Toolchain.ffmpeg else { return false }
let encoders = Shell.output(ffmpeg, ["-hide_banner", "-encoders"]) ?? ""
return encoders.contains("libwebp")
}()
static var isAvailable: Bool {
Settings.shared.useExternalTools && (cwebp != nil || ffmpegHasLibwebp)
}
static func encode(_ image: CGImage, to url: URL, quality: Double) throws {
let staging = FileManager.default.temporaryDirectory
.appendingPathComponent("konverter-webp-\(UUID().uuidString).png")
defer { try? FileManager.default.removeItem(at: staging) }
try Raster.write(image, to: staging, format: .png, quality: 1, metadata: nil)
if let cwebp {
try Shell.run(cwebp, ["-quiet", "-q", String(Int(quality * 100)), staging.path, "-o", url.path])
return
}
if let ffmpeg = Toolchain.ffmpeg, ffmpegHasLibwebp {
try Shell.run(
ffmpeg,
["-y", "-hide_banner", "-loglevel", "error", "-i", staging.path,
"-frames:v", "1", "-c:v", "libwebp", "-quality", String(Int(quality * 100)), url.path]
)
return
}
throw ConversionError.unsupported("WebP needs cwebp — brew install webp")
}
}
/// Format → format for still and animated bitmaps.
enum ImageConverter {
static func convert(
source: URL,
to format: Format,
at destination: URL,
quality: Double = 0.9,
keepMetadata: Bool = true,
cancellation: Cancellation? = nil
) throws {
try cancellation?.check()
if format == .pdf {
try PDFConverter.imageToPDF(source, at: destination, cancellation: cancellation)
return
}
// WebP has no ImageIO encoder on macOS: cwebp or ffmpeg fills in.
if !Raster.canEncode(format) {
if format == .webp, let image = Raster.firstImage(at: source), WebPEncoder.isAvailable {
try WebPEncoder.encode(image, to: destination, quality: quality)
return
}
throw ConversionError.unsupported("This Mac cannot write \(format.displayName) images.")
}
guard let imageSource = Raster.imageSource(at: source) else {
// Formats ImageIO cannot even open (SVG, PSD …): rasterise once.
guard let image = Raster.firstImage(at: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
let prepared = format == .jpg ? Raster.flattened(image) : image
try Raster.write(prepared, to: destination, format: format, quality: quality, metadata: nil)
return
}
let frameCount = max(CGImageSourceGetCount(imageSource), 1)
guard let uti = format.uti, Raster.canEncode(format) else {
throw ConversionError.unsupported("This Mac cannot write \(format.displayName) images.")
}
var properties: [CFString: Any] = [:]
if keepMetadata, let sourceProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any] {
properties = sourceProperties
}
// JPEG has no alpha channel: re-encode through a white background.
let needsFlatten = format == .jpg
guard let destinationRef = CGImageDestinationCreateWithURL(
destination as CFURL,
uti as CFString,
frameCount,
nil
) else {
throw ConversionError.failed("Could not create \(format.displayName) at \(destination.lastPathComponent).")
}
if format.isLossy {
properties[kCGImageDestinationLossyCompressionQuality] = quality
}
for index in 0..<frameCount {
try cancellation?.check()
if needsFlatten || !keepMetadata {
guard let frame = CGImageSourceCreateImageAtIndex(imageSource, index, nil) else { continue }
let prepared = needsFlatten ? Raster.flattened(frame) : frame
CGImageDestinationAddImage(destinationRef, prepared, properties as CFDictionary)
} else {
CGImageDestinationAddImageFromSource(destinationRef, imageSource, index, properties as CFDictionary)
}
}
guard CGImageDestinationFinalize(destinationRef) else {
throw ConversionError.failed("Writing \(destination.lastPathComponent) failed.")
}
}
}