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 = 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..