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:
2026-10-20 00:36:40 +08:00
commit 2d031e58a4
47 changed files with 9149 additions and 0 deletions
@@ -0,0 +1,113 @@
import AppKit
import Foundation
import PDFKit
/// Document conversions. `textutil` handles the Office formats; Markdown and
/// EPUB are ours; PDF output lays text out with CoreText unless LibreOffice is
/// available for pixel-perfect fidelity.
enum DocumentConverter {
static func convert(
source: URL,
to format: Format,
at destination: URL,
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws {
try cancellation?.check()
let sourceExtension = FileKind.canonicalExtension(source.pathExtension)
switch format {
case .txt:
guard let text = TextDocument.plainText(from: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
try TextDocument.write(text, to: destination)
case .md:
if sourceExtension == "md" || sourceExtension == "txt" {
let text = TextDocument.plainText(from: source) ?? ""
try TextDocument.write(text, to: destination)
} else {
let html = try TextDocument.html(from: source)
try TextDocument.write(Markdown.markdown(fromHTML: html), to: destination)
}
case .html:
try TextDocument.write(try TextDocument.html(from: source), to: destination)
case .pdf:
try makePDF(from: source, at: destination, cancellation: cancellation, progress: progress)
case .epub:
let title = source.deletingPathExtension().lastPathComponent
let html = try TextDocument.html(from: source)
let chapterTitle = title
try EbookConverter.write(
title: title,
author: "Konverter",
chapters: [(chapterTitle, html)],
at: destination
)
case .docx, .doc, .rtf, .odt:
if sourceExtension == "pdf" || sourceExtension == "epub" {
// No importer for these: start from extracted text/HTML.
let html = try TextDocument.html(from: source)
try TextDocument.textutilConvert(sourceHTML: html, to: format, at: destination, cancellation: cancellation)
} else if sourceExtension == "md" {
let html = try TextDocument.html(from: source)
try TextDocument.textutilConvert(sourceHTML: html, to: format, at: destination, cancellation: cancellation)
} else {
try TextDocument.textutilConvert(source, to: format, at: destination, cancellation: cancellation)
}
case .pptx, .xlsx, .ods, .pages:
try Office.convert(source, to: format, at: destination, cancellation: cancellation)
default:
throw ConversionError.unsupported("Documents cannot become \(format.displayName).")
}
}
private static func makePDF(
from source: URL,
at destination: URL,
cancellation: Cancellation?,
progress: ((Double) -> Void)?
) throws {
let sourceExtension = FileKind.canonicalExtension(source.pathExtension)
if sourceExtension == "pdf" {
// "PDF → PDF" means a normalised, linearised copy.
if let document = PDFDocument(url: source) {
guard document.write(to: destination) else {
throw ConversionError.failed("Could not rewrite the PDF.")
}
return
}
}
if Toolchain.hasLibreOffice, Settings.shared.useExternalTools {
do {
try Office.convert(source, to: .pdf, at: destination, cancellation: cancellation)
return
} catch is CancellationError {
throw CancellationError()
} catch {
// Fall through to the CoreText layout rather than failing the
// whole job because LibreOffice refused one file.
}
}
if let attributed = TextDocument.attributedString(from: source) {
try TextDocument.writePDF(attributed, to: destination)
progress?(1)
return
}
if let text = TextDocument.plainText(from: source), !text.isEmpty {
try TextDocument.writePDF(fromText: text, to: destination)
progress?(1)
return
}
throw ConversionError.failed("Could not lay out \(source.lastPathComponent) as a PDF.")
}
}