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.") } } }