import AppKit import CoreGraphics import CoreText import Foundation import Vision /// In-place image transforms. enum ImageTools { static func compress(_ source: URL, at destination: URL, format: Format, quality: Double) throws { guard let image = Raster.firstImage(at: source) else { throw ConversionError.unreadable(source.lastPathComponent) } let outputFormat = format var prepared = format == .jpg ? Raster.flattened(image) : image // PNG/BMP/TIFF are lossless: ImageIO ignores the quality knob, so the // slider drives colour depth instead. JPEG-style quality is meaningless // there, and shipping a "compressed" file that got bigger is worse than // a visibly banded one that actually got smaller. switch outputFormat { case .png, .bmp, .tiff: prepared = Raster.posterized(prepared, quality: quality) default: break } try Raster.write(prepared, to: destination, format: outputFormat, quality: quality, metadata: nil) } static func resize( _ source: URL, at destination: URL, maxWidth: Int, maxHeight: Int, percent: Int ) throws { guard let image = Raster.firstImage(at: source) else { throw ConversionError.unreadable(source.lastPathComponent) } var targetWidth = image.width var targetHeight = image.height if percent != 100 { targetWidth = Int(Double(image.width) * Double(percent) / 100) targetHeight = Int(Double(image.height) * Double(percent) / 100) } else { let scale = min(Double(maxWidth) / Double(image.width), Double(maxHeight) / Double(image.height), 1) targetWidth = Int(Double(image.width) * scale) targetHeight = Int(Double(image.height) * scale) } targetWidth = max(1, targetWidth) targetHeight = max(1, targetHeight) guard let resized = draw(image, size: CGSize(width: targetWidth, height: targetHeight)) else { throw ConversionError.failed("Resizing failed.") } try write(resized, to: destination, matching: source) } static func crop(_ source: URL, at destination: URL, inset: Int, aspect: String) throws { guard let image = Raster.firstImage(at: source) else { throw ConversionError.unreadable(source.lastPathComponent) } var rect = CGRect(x: 0, y: 0, width: image.width, height: image.height) if aspect != "Keep", let ratio = aspectRatio(aspect) { let current = CGFloat(image.width) / CGFloat(image.height) if current > ratio { let width = CGFloat(image.height) * ratio rect = CGRect(x: (CGFloat(image.width) - width) / 2, y: 0, width: width, height: CGFloat(image.height)) } else { let height = CGFloat(image.width) / ratio rect = CGRect(x: 0, y: (CGFloat(image.height) - height) / 2, width: CGFloat(image.width), height: height) } } if inset > 0 { rect = rect.insetBy(dx: CGFloat(inset), dy: CGFloat(inset)) } rect = rect.integral guard rect.width > 1, rect.height > 1 else { throw ConversionError.failed("The crop values remove the whole image.") } guard let cropped = image.cropping(to: rect) else { throw ConversionError.failed("Cropping failed.") } try write(cropped, to: destination, matching: source) } private static func aspectRatio(_ aspect: String) -> CGFloat? { let parts = aspect.split(separator: ":").compactMap { Double($0) } guard parts.count == 2, parts[1] != 0 else { return nil } return CGFloat(parts[0] / parts[1]) } static func rotate(_ source: URL, at destination: URL, degrees: Int) throws { guard let image = Raster.firstImage(at: source) else { throw ConversionError.unreadable(source.lastPathComponent) } let radians = CGFloat(degrees) * .pi / 180 let swaps = degrees % 180 != 0 let width = swaps ? image.height : image.width let height = swaps ? 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 { throw ConversionError.failed("Rotating failed.") } context.translateBy(x: CGFloat(width) / 2, y: CGFloat(height) / 2) context.rotate(by: radians) context.draw( image, in: CGRect( x: -CGFloat(image.width) / 2, y: -CGFloat(image.height) / 2, width: CGFloat(image.width), height: CGFloat(image.height) ) ) guard let rotated = context.makeImage() else { throw ConversionError.failed("Rotating failed.") } try write(rotated, to: destination, matching: source) } static func flip(_ source: URL, at destination: URL, horizontal: Bool) throws { guard let image = Raster.firstImage(at: source) else { throw ConversionError.unreadable(source.lastPathComponent) } guard let context = CGContext( data: nil, width: image.width, height: image.height, bitsPerComponent: 8, bytesPerRow: 0, space: image.colorSpace ?? CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ) else { throw ConversionError.failed("Flipping failed.") } if horizontal { context.translateBy(x: CGFloat(image.width), y: 0) context.scaleBy(x: -1, y: 1) } else { context.translateBy(x: 0, y: CGFloat(image.height)) context.scaleBy(x: 1, y: -1) } context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) guard let flipped = context.makeImage() else { throw ConversionError.failed("Flipping failed.") } try write(flipped, to: destination, matching: source) } static func grayscale(_ source: URL, at destination: URL) throws { guard let image = Raster.firstImage(at: source) else { throw ConversionError.unreadable(source.lastPathComponent) } guard let space = CGColorSpace(name: CGColorSpace.linearGray), let context = CGContext( data: nil, width: image.width, height: image.height, bitsPerComponent: 8, bytesPerRow: 0, space: space, bitmapInfo: CGImageAlphaInfo.none.rawValue ) else { throw ConversionError.failed("Grayscale conversion failed.") } context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) guard let gray = context.makeImage() else { throw ConversionError.failed("Grayscale conversion failed.") } try write(gray, to: destination, matching: source) } static func stripMetadata(_ source: URL, at destination: URL, format: Format) throws { 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: 0.95, metadata: nil) } static func watermark( _ source: URL, at destination: URL, text: String, position: String, opacity: Double, sizePercent: Double ) throws { guard let image = Raster.firstImage(at: source) else { throw ConversionError.unreadable(source.lastPathComponent) } let width = image.width let height = 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 { throw ConversionError.failed("Watermarking failed.") } context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) let fontSize = max(8, CGFloat(sizePercent) / 100 * CGFloat(width)) let font = NSFont.systemFont(ofSize: fontSize, weight: .semibold) let attributes: [NSAttributedString.Key: Any] = [ .font: font, .foregroundColor: NSColor.white.withAlphaComponent(opacity / 100) ] let attributed = NSAttributedString(string: text, attributes: attributes) let line = CTLineCreateWithAttributedString(attributed) let bounds = CTLineGetBoundsWithOptions(line, .useOpticalBounds) let margin = fontSize * 0.6 let origin: CGPoint switch position { case "Bottom Left": origin = CGPoint(x: margin, y: margin) case "Top Right": origin = CGPoint(x: CGFloat(width) - bounds.width - margin, y: CGFloat(height) - bounds.height - margin) case "Top Left": origin = CGPoint(x: margin, y: CGFloat(height) - bounds.height - margin) case "Center": origin = CGPoint(x: (CGFloat(width) - bounds.width) / 2, y: (CGFloat(height) - bounds.height) / 2) default: origin = CGPoint(x: CGFloat(width) - bounds.width - margin, y: margin) } // A soft shadow keeps light text readable over light photos. context.setShadow(offset: CGSize(width: 0, height: -fontSize * 0.05), blur: fontSize * 0.2, color: CGColor(gray: 0, alpha: opacity / 140)) context.textPosition = origin CTLineDraw(line, context) guard let stamped = context.makeImage() else { throw ConversionError.failed("Watermarking failed.") } try write(stamped, to: destination, matching: source) } /// QR payloads, via Vision so it also reads the newer formats CIDetector /// misses. static func readQR(_ source: URL) -> String? { guard let image = Raster.firstImage(at: source) else { return nil } let request = VNDetectBarcodesRequest() request.symbologies = [.qr, .aztec, .pdf417, .code128, .code39, .ean13, .dataMatrix] let handler = VNImageRequestHandler(cgImage: image, options: [:]) do { try handler.perform([request]) } catch { return nil } let payloads = (request.results ?? []).compactMap(\.payloadStringValue) return payloads.isEmpty ? nil : payloads.joined(separator: "\n") } // MARK: - Helpers private static func draw(_ image: CGImage, size: CGSize) -> CGImage? { guard let context = CGContext( data: nil, width: Int(size.width), height: Int(size.height), bitsPerComponent: 8, bytesPerRow: 0, space: image.colorSpace ?? CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ) else { return nil } context.interpolationQuality = .high context.draw(image, in: CGRect(origin: .zero, size: size)) return context.makeImage() } private static func write(_ image: CGImage, to destination: URL, matching source: URL) throws { let format = FileKind.format(of: destination) ?? FileKind.format(of: source) ?? .png let prepared = format == .jpg ? Raster.flattened(image) : image try Raster.write(prepared, to: destination, format: format, quality: 0.92, metadata: nil) } }