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
+110
View File
@@ -0,0 +1,110 @@
import Foundation
/// LibreOffice bridge for formats Apple's frameworks cannot render (PPT/PPTX,
/// XLS/XLSX/ODS when they need layout, PAGES, Keynote).
enum Office {
/// LibreOffice cannot run two headless conversions at once in the same
/// profile, so every call is serialised.
private static let lock = NSLock()
static func convert(
_ source: URL,
to format: Format,
at destination: URL,
cancellation: Cancellation? = nil
) throws {
guard let soffice = Toolchain.soffice, Settings.shared.useExternalTools else {
throw ConversionError.missingTool(
"\(format.displayName) needs LibreOffice. Install it with: brew install --cask libreoffice"
)
}
let filter = filterName(for: format)
let staging = FileManager.default.temporaryDirectory
.appendingPathComponent("konverter-office-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: staging) }
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
lock.lock()
defer { lock.unlock() }
try cancellation?.check()
try Shell.run(
soffice,
[
"--headless", "--norestore", "--nolockcheck", "--nodefault",
"-env:UserInstallation=file://" + staging.path + "/profile",
"--convert-to", filter,
"--outdir", staging.path,
source.path
],
cancellation: cancellation
)
let produced = try FileManager.default
.contentsOfDirectory(at: staging, includingPropertiesForKeys: nil)
.first { $0.pathExtension.lowercased() == destination.pathExtension.lowercased() }
guard let produced else {
throw ConversionError.failed("LibreOffice did not produce a \(format.displayName) file.")
}
try? FileManager.default.removeItem(at: destination)
try FileManager.default.moveItem(at: produced, to: destination)
}
static func filterName(for format: Format) -> String {
switch format {
case .pdf: return "pdf"
case .docx: return "docx"
case .odt: return "odt"
case .rtf: return "rtf"
case .txt: return "txt"
case .html: return "html"
case .csv: return "csv"
case .xlsx: return "xlsx"
case .ods: return "ods"
case .pptx: return "pptx"
case .epub: return "epub"
case .png: return "png"
case .jpg: return "jpg"
default: return format.ext
}
}
}
/// PPT/PPTX/KEY → PDF or images. Everything funnels through a PDF so multi
/// slide decks come out with one image per slide.
enum SlidesConverter {
static func convert(
source: URL,
to format: Format,
at destination: URL,
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws {
switch format {
case .pdf:
try Office.convert(source, to: .pdf, at: destination, cancellation: cancellation)
case .png, .jpg:
let staging = FileManager.default.temporaryDirectory
.appendingPathComponent("konverter-slides-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: staging) }
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
let pdf = staging.appendingPathComponent("deck.pdf")
try Office.convert(source, to: .pdf, at: pdf, cancellation: cancellation)
let directory = OutputNaming.artifactDirectory(
for: destination,
suffix: ""
)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
_ = try PDFConverter.renderPages(
pdf,
to: format,
into: directory,
dpi: 200,
cancellation: cancellation,
progress: progress
)
default:
throw ConversionError.unsupported("Slides become PDF or images.")
}
}
}