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.
269 lines
12 KiB
Swift
269 lines
12 KiB
Swift
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)
|
|
}
|
|
}
|