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.
329 lines
14 KiB
Swift
329 lines
14 KiB
Swift
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.")
|
|
}
|
|
}
|
|
}
|