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.
177 lines
6.3 KiB
Swift
177 lines
6.3 KiB
Swift
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
|
|
}
|
|
}
|