import Foundation import PDFKit /// Whether an output format can actually be produced on this Mac, and why not /// when it cannot. The wheel uses this to mark options as unavailable instead /// of failing after the user has already committed to a drop. struct Availability { let isAvailable: Bool let reason: String? static let ok = Availability(isAvailable: true, reason: nil) static func unavailable(_ reason: String) -> Availability { Availability(isAvailable: false, reason: reason) } } /// Front door for format conversions: picks the engine, handles formats that /// produce more than one file, and reports a useful reason when nothing can. enum Converter { @discardableResult static func convert( source: URL, to format: Format, at destination: URL, quality: Double = 0.9, mediaQuality: String = "Medium", audioBitrate: String = "192", cancellation: Cancellation? = nil, progress: ((Double) -> Void)? = nil ) throws -> [URL] { try cancellation?.check() let category = FileKind.category(of: source) switch category { case .image: try ImageConverter.convert( source: source, to: format, at: destination, quality: quality, cancellation: cancellation ) progress?(1) return [destination] case .video, .audio: try AVConverter.convert( source: source, to: format, at: destination, quality: mediaQuality, bitrate: audioBitrate, cancellation: cancellation, progress: progress ) return [destination] case .pdf: switch format { case .jpg, .png: let directory = OutputNaming.artifactDirectory(for: source, suffix: " \(format.displayName)") return try PDFConverter.renderPages( source, to: format, into: directory, dpi: 300, cancellation: cancellation, progress: progress ) case .pptx, .xlsx: try Office.convert(source, to: format, at: destination, cancellation: cancellation) progress?(1) return [destination] case .pdf: guard let document = PDFDocument(url: source) else { throw ConversionError.unreadable(source.lastPathComponent) } guard document.write(to: destination) else { throw ConversionError.failed("Could not rewrite the PDF.") } progress?(1) return [destination] default: try DocumentConverter.convert( source: source, to: format, at: destination, cancellation: cancellation, progress: progress ) return [destination] } case .document, .spreadsheet: try DocumentConverter.convert( source: source, to: format, at: destination, cancellation: cancellation, progress: progress ) return [destination] case .slides: try SlidesConverter.convert( source: source, to: format, at: destination, cancellation: cancellation, progress: progress ) if format == .png || format == .jpg { let directory = OutputNaming.artifactDirectory(for: source, suffix: " \(format.displayName)") let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] return files.sorted { $0.lastPathComponent < $1.lastPathComponent } } return [destination] case .ebook: try EbookConverter.convert( source: source, to: format, at: destination, cancellation: cancellation, progress: progress ) return [destination] case .subtitle: try SubtitleConverter.convert(source: source, to: format, at: destination, cancellation: cancellation) progress?(1) return [destination] case .archive: try ArchiveConverter.convert( source: source, to: format, at: destination, cancellation: cancellation, progress: progress ) return [destination] case .folder, .unknown: throw ConversionError.unsupported("\(source.lastPathComponent) has no conversion options.") } } // MARK: - Availability static func availability(target: Format, source: FileCategory) -> Availability { if let offered = ConversionCatalog.targets[source], !offered.contains(target) { return .unavailable("\(source.title) cannot become \(target.displayName).") } if target.requiresFFmpeg, !Toolchain.hasFFmpeg { return .unavailable("Needs ffmpeg — brew install ffmpeg") } if target.requiresLibreOffice, !Toolchain.hasLibreOffice { return .unavailable("Needs LibreOffice — brew install --cask libreoffice") } if !Settings.shared.useExternalTools, target.requiresFFmpeg || target.requiresLibreOffice { return .unavailable("External tools are turned off in Settings.") } if target.category == .image, !Raster.canEncode(target) { if target == .webp, WebPEncoder.isAvailable { return .ok } return .unavailable("This Mac cannot write \(target.displayName) files.") } if source == .spreadsheet, !["xlsx", "ods", "csv", "tsv"].contains(target.ext), target != .pdf { return .unavailable("Spreadsheets cannot become \(target.displayName).") } return .ok } }