Files
TZGyn 2d031e58a4 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.
2026-10-20 00:36:40 +08:00

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)
}
}