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.
111 lines
4.2 KiB
Swift
111 lines
4.2 KiB
Swift
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.")
|
|
}
|
|
}
|
|
}
|