Files
TZGyn 2d031e58a4 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.
2026-10-20 00:36:40 +08:00

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
}
}