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.
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user