Files
TZGyn a01d5def89 Give mixed selections something to do
A Drag of different kinds showed an empty wheel, because the catalog only
answers for a single category. Filee's answer is a mixed profile, so add
Zip (one archive, flat names) and Merge (non-PDFs are converted to PDF
first); a multi-file drag of one kind gets Zip too.
2026-10-20 01:30:42 +08:00

550 lines
22 KiB
Swift

import AppKit
import CoreGraphics
import Foundation
import PDFKit
/// `--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()
testCombining()
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)
}
check("several files → one zip") {
let first = directory.appendingPathComponent("note.txt")
let second = directory.appendingPathComponent("spreadsheet.csv")
try? "one".write(to: first, atomically: true, encoding: .utf8)
try? "a,b\n1,2\n".write(to: second, atomically: true, encoding: .utf8)
let zip = directory.appendingPathComponent("selection.zip")
try ArchiveConverter.createArchive(from: [first, second], at: zip)
try requireFile(zip)
let target = directory.appendingPathComponent("selection-unpacked", isDirectory: true)
try ArchiveConverter.extract(source: zip, into: target)
for name in ["note.txt", "spreadsheet.csv"] {
let entry = target.appendingPathComponent(name)
guard FileManager.default.fileExists(atPath: entry.path) else {
throw TestFailure("\(name) missing from the archive")
}
}
}
}
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") }
}
}
/// Filee's "mixed profile": a drag of different kinds still has something
/// useful to do, instead of an empty wheel.
private static func testCombining() {
section("Mixed selections")
let photo = directory.appendingPathComponent("sample.png")
let pdf = directory.appendingPathComponent("sample.pdf")
let note = directory.appendingPathComponent("note.txt")
check("a mixed drag still gets a wheel") {
let items = WheelBuilder.items(kind: .convert, urls: [photo, pdf])
let tools = items.compactMap { item -> ToolID? in
if case .tool(let tool) = item.payload { return tool }
return nil
}
guard !items.isEmpty else { throw TestFailure("the convert wheel was empty") }
guard tools.contains(.mergePDF), tools.contains(.createArchive) else {
throw TestFailure("expected Merge and Zip, got \(tools.map(\.rawValue))")
}
}
check("a multi-file wheel can always be zipped") {
let items = WheelBuilder.items(kind: .tools, urls: [photo, directory.appendingPathComponent("note.txt")])
let tools = items.compactMap { item -> ToolID? in
if case .tool(let tool) = item.payload { return tool }
return nil
}
guard tools.contains(.createArchive) else {
throw TestFailure("Zip is missing from a two-file image wheel")
}
}
check("photos and pdfs merge into one pdf") {
let outcome = try ToolRunner.run(tool: .mergePDF, inputs: [photo, pdf], params: ToolParams())
guard let merged = outcome.outputs.first,
let document = PDFDocument(url: merged) else {
throw TestFailure("no merged pdf was produced")
}
guard document.pageCount >= 2 else {
throw TestFailure("merged pdf has \(document.pageCount) page(s)")
}
}
check("any files → one zip") {
let outcome = try ToolRunner.run(tool: .createArchive, inputs: [note, photo], params: ToolParams())
guard let zip = outcome.outputs.first else { throw TestFailure("no archive was produced") }
try requireFile(zip)
let entries = try ArchiveConverter.list(zip)
for name in ["note.txt", "sample.png"] where !entries.contains(name) {
throw TestFailure("\(name) is missing from the archive: \(entries)")
}
}
}
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)
}
}