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.
476 lines
18 KiB
Swift
476 lines
18 KiB
Swift
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)
|
|
}
|
|
}
|