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,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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user