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,475 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
/// `--self-test` exercises the real conversion paths end to end and exits
|
||||
/// non-zero on failure. Kept in the shipped binary so a build can be verified
|
||||
/// without opening the UI.
|
||||
enum SelfTest {
|
||||
private static var passes = 0
|
||||
private static var failures = 0
|
||||
private static var directory = FileManager.default.temporaryDirectory
|
||||
|
||||
static func run() -> Int32 {
|
||||
directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("konverter-selftest-\(UUID().uuidString)", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
print("Konverter self-test")
|
||||
print("Working in \(directory.path)")
|
||||
print("Engines: \(Toolchain.summary)")
|
||||
print("")
|
||||
|
||||
testImages()
|
||||
testPDF()
|
||||
testSubtitles()
|
||||
testSpreadsheets()
|
||||
testArchives()
|
||||
testDocuments()
|
||||
testAudio()
|
||||
testEbook()
|
||||
testGeometry()
|
||||
|
||||
print("")
|
||||
print("\(passes) passed, \(failures) failed")
|
||||
return failures == 0 ? 0 : 1
|
||||
}
|
||||
|
||||
// MARK: - Suites
|
||||
|
||||
private static func testImages() {
|
||||
section("Images")
|
||||
let source = directory.appendingPathComponent("sample.png")
|
||||
check("create sample png") {
|
||||
try makeImage(at: source, width: 320, height: 200)
|
||||
try requireFile(source)
|
||||
}
|
||||
|
||||
for format in [Format.jpg, .tiff, .gif, .pdf, .webp, .heic, .avif] {
|
||||
let availability = Converter.availability(target: format, source: .image)
|
||||
guard availability.isAvailable else {
|
||||
print("SKIP png → \(format.displayName) (\(availability.reason ?? "unavailable"))")
|
||||
continue
|
||||
}
|
||||
check("png → \(format.displayName)") {
|
||||
let output = directory.appendingPathComponent("out.\(format.ext)")
|
||||
try ImageConverter.convert(source: source, to: format, at: output)
|
||||
try requireFile(output)
|
||||
}
|
||||
}
|
||||
|
||||
check("image compress") {
|
||||
let output = directory.appendingPathComponent("compressed.jpg")
|
||||
try ImageTools.compress(source, at: output, format: .jpg, quality: 0.4)
|
||||
try requireFile(output)
|
||||
}
|
||||
|
||||
// PNG has no quality knob, so "compress" trades colour depth for size.
|
||||
check("png compress shrinks a lossless file") {
|
||||
let output = directory.appendingPathComponent("compressed.png")
|
||||
try ImageTools.compress(source, at: output, format: .png, quality: 0.3)
|
||||
try requireFile(output)
|
||||
let before = try fileSize(source)
|
||||
let after = try fileSize(output)
|
||||
guard after < before else {
|
||||
throw TestFailure("expected a smaller png, got \(before) → \(after) bytes")
|
||||
}
|
||||
}
|
||||
|
||||
check("image resize") {
|
||||
let output = directory.appendingPathComponent("resized.png")
|
||||
try ImageTools.resize(source, at: output, maxWidth: 100, maxHeight: 100, percent: 100)
|
||||
guard let image = Raster.firstImage(at: output), image.width <= 100 else {
|
||||
throw TestFailure("resize did not shrink the image")
|
||||
}
|
||||
}
|
||||
|
||||
check("image rotate") {
|
||||
let output = directory.appendingPathComponent("rotated.png")
|
||||
try ImageTools.rotate(source, at: output, degrees: 90)
|
||||
guard let image = Raster.firstImage(at: output), image.height > image.width else {
|
||||
throw TestFailure("rotation did not swap dimensions")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func testPDF() {
|
||||
section("PDF")
|
||||
let image = directory.appendingPathComponent("sample.png")
|
||||
let pdf = directory.appendingPathComponent("sample.pdf")
|
||||
|
||||
check("image → pdf") {
|
||||
try PDFConverter.imageToPDF(image, at: pdf)
|
||||
try requireFile(pdf)
|
||||
}
|
||||
|
||||
check("pdf text extraction") {
|
||||
let textPDF = directory.appendingPathComponent("text.pdf")
|
||||
try TextDocument.writePDF(fromText: "Hello Konverter", to: textPDF)
|
||||
let text = PDFConverter.extractText(textPDF)
|
||||
guard text.contains("Hello") else { throw TestFailure("no text found in \(text)") }
|
||||
}
|
||||
|
||||
check("pdf → png pages") {
|
||||
let folder = directory.appendingPathComponent("pages")
|
||||
let written = try PDFConverter.renderPages(pdf, to: .png, into: folder, dpi: 72)
|
||||
guard !written.isEmpty else { throw TestFailure("no pages rendered") }
|
||||
try requireFile(written[0])
|
||||
}
|
||||
|
||||
check("pdf split and merge") {
|
||||
let split = try PDFConverter.split(pdf, pagesPerFile: 1, into: directory.appendingPathComponent("split"))
|
||||
guard split.count == 1 else { throw TestFailure("expected one part, got \(split.count)") }
|
||||
let merged = directory.appendingPathComponent("merged.pdf")
|
||||
try PDFConverter.merge(split, at: merged)
|
||||
guard PDFConverter.pageCount(merged) == 1 else { throw TestFailure("merge lost pages") }
|
||||
}
|
||||
}
|
||||
|
||||
private static func testSubtitles() {
|
||||
section("Subtitles")
|
||||
let srt = directory.appendingPathComponent("captions.srt")
|
||||
try? """
|
||||
1
|
||||
00:00:01,000 --> 00:00:03,500
|
||||
First line
|
||||
|
||||
2
|
||||
00:00:04,000 --> 00:00:06,000
|
||||
Second line
|
||||
|
||||
""".write(to: srt, atomically: true, encoding: .utf8)
|
||||
|
||||
check("srt → vtt") {
|
||||
let vtt = directory.appendingPathComponent("captions.vtt")
|
||||
try SubtitleConverter.convert(source: srt, to: .vtt, at: vtt)
|
||||
let text = try String(contentsOf: vtt, encoding: .utf8)
|
||||
guard text.hasPrefix("WEBVTT"), text.contains("00:00:01.000") else {
|
||||
throw TestFailure("unexpected vtt output")
|
||||
}
|
||||
}
|
||||
|
||||
check("srt → txt") {
|
||||
let text = directory.appendingPathComponent("captions.txt")
|
||||
try SubtitleConverter.convert(source: srt, to: .txt, at: text)
|
||||
let contents = try String(contentsOf: text, encoding: .utf8)
|
||||
guard contents.contains("First line") else { throw TestFailure("missing cue text") }
|
||||
}
|
||||
|
||||
check("subtitle offset") {
|
||||
let cues = try SubtitleConverter.parse(srt)
|
||||
let shifted = SubtitleConverter.shift(cues, by: 2)
|
||||
guard let first = shifted.first, abs(first.start - 3) < 0.01 else {
|
||||
throw TestFailure("offset was not applied")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func testSpreadsheets() {
|
||||
section("Spreadsheets")
|
||||
let csv = directory.appendingPathComponent("table.csv")
|
||||
try? "Name,Score\nAda,42\nGrace,\"7,5\"\n".write(to: csv, atomically: true, encoding: .utf8)
|
||||
|
||||
check("csv parse") {
|
||||
let rows = try SpreadsheetConverter.read(csv)
|
||||
guard rows.count == 3, rows[1][0] == "Ada", rows[2][1] == "7,5" else {
|
||||
throw TestFailure("parsed \(rows)")
|
||||
}
|
||||
}
|
||||
|
||||
check("csv → xlsx → csv") {
|
||||
let xlsx = directory.appendingPathComponent("table.xlsx")
|
||||
let rows = try SpreadsheetConverter.read(csv)
|
||||
try XLSX.write(rows, to: xlsx)
|
||||
try requireFile(xlsx)
|
||||
let roundTrip = try XLSX.read(xlsx)
|
||||
guard roundTrip.count == rows.count, roundTrip[1][0] == "Ada" else {
|
||||
throw TestFailure("round trip produced \(roundTrip)")
|
||||
}
|
||||
}
|
||||
|
||||
check("csv → tsv") {
|
||||
let tsv = directory.appendingPathComponent("table.tsv")
|
||||
try SpreadsheetConverter.convert(source: csv, to: .tsv, at: tsv)
|
||||
let text = try String(contentsOf: tsv, encoding: .utf8)
|
||||
guard text.contains("\t") else { throw TestFailure("no tabs in output") }
|
||||
}
|
||||
}
|
||||
|
||||
private static func testArchives() {
|
||||
section("Archives")
|
||||
let folder = directory.appendingPathComponent("bundle", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
try? "archive me".write(to: folder.appendingPathComponent("note.txt"), atomically: true, encoding: .utf8)
|
||||
|
||||
check("folder → zip") {
|
||||
let zip = directory.appendingPathComponent("bundle.zip")
|
||||
try ArchiveConverter.convert(source: folder, to: .zip, at: zip)
|
||||
try requireFile(zip)
|
||||
}
|
||||
|
||||
check("zip → extract") {
|
||||
let zip = directory.appendingPathComponent("bundle.zip")
|
||||
let target = directory.appendingPathComponent("unpacked", isDirectory: true)
|
||||
try ArchiveConverter.extract(source: zip, into: target)
|
||||
let note = target.appendingPathComponent("bundle/note.txt")
|
||||
guard FileManager.default.fileExists(atPath: note.path) else {
|
||||
throw TestFailure("note.txt missing after extraction")
|
||||
}
|
||||
}
|
||||
|
||||
check("folder → tar") {
|
||||
let tar = directory.appendingPathComponent("bundle.tar")
|
||||
try ArchiveConverter.convert(source: folder, to: .tar, at: tar)
|
||||
try requireFile(tar)
|
||||
}
|
||||
}
|
||||
|
||||
private static func testDocuments() {
|
||||
section("Documents")
|
||||
let text = directory.appendingPathComponent("note.txt")
|
||||
try? "Hello from Konverter.\n\nSecond paragraph.\n".write(to: text, atomically: true, encoding: .utf8)
|
||||
|
||||
check("txt → md") {
|
||||
let markdown = directory.appendingPathComponent("note.md")
|
||||
try DocumentConverter.convert(source: text, to: .md, at: markdown)
|
||||
let contents = try String(contentsOf: markdown, encoding: .utf8)
|
||||
guard contents.contains("Hello from Konverter") else { throw TestFailure("text lost") }
|
||||
}
|
||||
|
||||
check("md → html") {
|
||||
let markdown = directory.appendingPathComponent("note.md")
|
||||
try? "# Title\n\nSome **bold** text.\n".write(to: markdown, atomically: true, encoding: .utf8)
|
||||
let html = directory.appendingPathComponent("note.html")
|
||||
try DocumentConverter.convert(source: markdown, to: .html, at: html)
|
||||
let contents = try String(contentsOf: html, encoding: .utf8)
|
||||
guard contents.contains("<strong>bold</strong>") else { throw TestFailure("markdown not rendered") }
|
||||
}
|
||||
|
||||
check("txt → pdf") {
|
||||
let pdf = directory.appendingPathComponent("note.pdf")
|
||||
try DocumentConverter.convert(source: text, to: .pdf, at: pdf)
|
||||
try requireFile(pdf)
|
||||
guard PDFConverter.pageCount(pdf) >= 1 else { throw TestFailure("empty pdf") }
|
||||
}
|
||||
|
||||
check("txt → docx") {
|
||||
let docx = directory.appendingPathComponent("note.docx")
|
||||
try DocumentConverter.convert(source: text, to: .docx, at: docx)
|
||||
try requireFile(docx)
|
||||
}
|
||||
|
||||
check("html → md") {
|
||||
let markdown = Markdown.markdown(fromHTML: "<h1>Title</h1><p>Hello <strong>world</strong></p>")
|
||||
guard markdown.contains("# Title"), markdown.contains("**world**") else {
|
||||
throw TestFailure("html not converted: \(markdown)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func testAudio() {
|
||||
section("Audio")
|
||||
let wav = directory.appendingPathComponent("tone.wav")
|
||||
|
||||
check("create wav") {
|
||||
try makeWAV(at: wav)
|
||||
try requireFile(wav)
|
||||
}
|
||||
|
||||
check("wav → m4a") {
|
||||
let m4a = directory.appendingPathComponent("tone.m4a")
|
||||
try AVConverter.convert(source: wav, to: .m4a, at: m4a)
|
||||
try requireFile(m4a)
|
||||
}
|
||||
|
||||
check("wav → aiff") {
|
||||
let aiff = directory.appendingPathComponent("tone.aiff")
|
||||
try CoreAudio.convert(source: wav, to: .aiff, at: aiff)
|
||||
try requireFile(aiff)
|
||||
}
|
||||
|
||||
check("media info") {
|
||||
let info = MediaInfo.describe(wav)
|
||||
guard info.contains("Duration") else { throw TestFailure("no duration in \(info)") }
|
||||
}
|
||||
}
|
||||
|
||||
private static func testEbook() {
|
||||
section("EPUB")
|
||||
let html = "<h1>Chapter</h1><p>An ebook built from scratch.</p>"
|
||||
let epub = directory.appendingPathComponent("book.epub")
|
||||
|
||||
check("build epub") {
|
||||
try EbookConverter.write(
|
||||
title: "Test Book",
|
||||
author: "Konverter",
|
||||
chapters: [("One", html), ("Two", "<p>More text.</p>")],
|
||||
at: epub
|
||||
)
|
||||
try requireFile(epub)
|
||||
}
|
||||
|
||||
check("epub → txt") {
|
||||
let text = try EbookConverter.plainText(epub)
|
||||
guard text.contains("ebook built from scratch") else {
|
||||
throw TestFailure("chapter text missing: \(text)")
|
||||
}
|
||||
}
|
||||
|
||||
check("epub → md") {
|
||||
let markdown = directory.appendingPathComponent("book.md")
|
||||
try EbookConverter.convert(source: epub, to: .md, at: markdown)
|
||||
let contents = try String(contentsOf: markdown, encoding: .utf8)
|
||||
guard contents.contains("ebook built") else { throw TestFailure("markdown lost the text") }
|
||||
}
|
||||
}
|
||||
|
||||
private static func testGeometry() {
|
||||
section("Wheel geometry")
|
||||
let frame = CGRect(x: 0, y: 0, width: 400, height: 400)
|
||||
let items = (0..<8).map { index in
|
||||
WheelItem(
|
||||
id: "item-\(index)",
|
||||
payload: .format(.png),
|
||||
title: "Item \(index)",
|
||||
subtitle: nil,
|
||||
symbol: "circle",
|
||||
availability: .ok
|
||||
)
|
||||
}
|
||||
let radius: CGFloat = 160
|
||||
|
||||
check("top hit selects the first item") {
|
||||
let index = WheelController.selectionIndex(
|
||||
for: CGPoint(x: 200, y: 340),
|
||||
in: frame,
|
||||
items: items,
|
||||
radius: radius
|
||||
)
|
||||
guard index == 0 else { throw TestFailure("got \(String(describing: index))") }
|
||||
}
|
||||
|
||||
check("dead zone selects nothing") {
|
||||
let index = WheelController.selectionIndex(
|
||||
for: CGPoint(x: 200, y: 200),
|
||||
in: frame,
|
||||
items: items,
|
||||
radius: radius
|
||||
)
|
||||
guard index == nil else { throw TestFailure("got \(String(describing: index))") }
|
||||
}
|
||||
|
||||
check("clockwise hit selects item 2") {
|
||||
let index = WheelController.selectionIndex(
|
||||
for: CGPoint(x: 360, y: 200),
|
||||
in: frame,
|
||||
items: items,
|
||||
radius: radius
|
||||
)
|
||||
guard index == 2 else { throw TestFailure("got \(String(describing: index))") }
|
||||
}
|
||||
|
||||
check("conflict naming") {
|
||||
let existing = directory.appendingPathComponent("taken.txt")
|
||||
try? "x".write(to: existing, atomically: true, encoding: .utf8)
|
||||
let candidate = OutputNaming.availableURL(for: existing)
|
||||
guard candidate.lastPathComponent == "taken 2.txt" else {
|
||||
throw TestFailure("got \(candidate.lastPathComponent)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Harness
|
||||
|
||||
private struct TestFailure: LocalizedError {
|
||||
let message: String
|
||||
init(_ message: String) { self.message = message }
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
private static func section(_ name: String) {
|
||||
print("— \(name)")
|
||||
}
|
||||
|
||||
private static func check(_ name: String, _ body: () throws -> Void) {
|
||||
do {
|
||||
try body()
|
||||
passes += 1
|
||||
print("PASS \(name)")
|
||||
} catch {
|
||||
failures += 1
|
||||
let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
||||
print("FAIL \(name): \(message)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func requireFile(_ url: URL) throws {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
throw TestFailure("\(url.lastPathComponent) was not created")
|
||||
}
|
||||
let size = try fileSize(url)
|
||||
guard size > 0 else {
|
||||
throw TestFailure("\(url.lastPathComponent) is empty")
|
||||
}
|
||||
}
|
||||
|
||||
private static func fileSize(_ url: URL) throws -> Int {
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||
return attributes[.size] as? Int ?? 0
|
||||
}
|
||||
|
||||
private static func makeImage(at url: URL, width: Int, height: Int) throws {
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else { throw TestFailure("could not create a bitmap") }
|
||||
|
||||
context.setFillColor(CGColor(srgbRed: 0.98, green: 0.52, blue: 0.18, alpha: 1))
|
||||
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
||||
context.setFillColor(CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 1))
|
||||
context.fillEllipse(in: CGRect(x: width / 4, y: height / 4, width: width / 2, height: height / 2))
|
||||
|
||||
guard let image = context.makeImage() else { throw TestFailure("could not render the bitmap") }
|
||||
try Raster.write(image, to: url, format: .png, quality: 1, metadata: nil)
|
||||
}
|
||||
|
||||
private static func makeWAV(at url: URL) throws {
|
||||
let sampleRate = 22050
|
||||
let seconds = 0.6
|
||||
let sampleCount = Int(Double(sampleRate) * seconds)
|
||||
var samples = Data()
|
||||
for index in 0..<sampleCount {
|
||||
let value = sin(2 * Double.pi * 440 * Double(index) / Double(sampleRate)) * 0.3
|
||||
var little = Int16(value * Double(Int16.max)).littleEndian
|
||||
samples.append(Data(bytes: &little, count: 2))
|
||||
}
|
||||
|
||||
var data = Data()
|
||||
func append(_ string: String) { data.append(Data(string.utf8)) }
|
||||
func append(_ value: UInt32) { var v = value.littleEndian; data.append(Data(bytes: &v, count: 4)) }
|
||||
func append(_ value: UInt16) { var v = value.littleEndian; data.append(Data(bytes: &v, count: 2)) }
|
||||
|
||||
append("RIFF")
|
||||
append(UInt32(36 + samples.count))
|
||||
append("WAVE")
|
||||
append("fmt ")
|
||||
append(UInt32(16))
|
||||
append(UInt16(1))
|
||||
append(UInt16(1))
|
||||
append(UInt32(sampleRate))
|
||||
append(UInt32(sampleRate * 2))
|
||||
append(UInt16(2))
|
||||
append(UInt16(16))
|
||||
append("data")
|
||||
append(UInt32(samples.count))
|
||||
data.append(samples)
|
||||
|
||||
try data.write(to: url)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user