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,176 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user