Files
konverter/Sources/Konverter/Core/Format.swift
T
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

297 lines
12 KiB
Swift

import Foundation
/// The broad family a file belongs to. Drives which wheel is shown and which
/// converter runs.
enum FileCategory: String, CaseIterable, Identifiable, Codable {
case image, video, audio, pdf, document, slides, spreadsheet, ebook, subtitle, archive, folder, unknown
var id: String { rawValue }
var title: String {
switch self {
case .image: return "Images"
case .video: return "Video"
case .audio: return "Audio"
case .pdf: return "PDF"
case .document: return "Documents"
case .slides: return "Slides"
case .spreadsheet: return "Spreadsheets"
case .ebook: return "EPUB"
case .subtitle: return "Subtitles"
case .archive: return "Archives"
case .folder: return "Folders"
case .unknown: return "Files"
}
}
var symbol: String {
switch self {
case .image: return "photo"
case .video: return "film"
case .audio: return "waveform"
case .pdf: return "doc.richtext"
case .document: return "doc.text"
case .slides: return "rectangle.on.rectangle.angled"
case .spreadsheet: return "tablecells"
case .ebook: return "book"
case .subtitle: return "captions.bubble"
case .archive: return "doc.zipper"
case .folder: return "folder"
case .unknown: return "doc"
}
}
/// Wheels only make sense for these.
static var convertible: [FileCategory] {
[.image, .video, .audio, .pdf, .document, .slides, .spreadsheet, .ebook, .subtitle, .archive]
}
}
/// A canonical output extension.
enum Format: String, CaseIterable, Identifiable, Codable, Hashable {
// Images
case jpg, png, heic, webp, tiff, bmp, gif, avif, svg, ico
// Documents
case pdf
case docx, doc, pages, rtf, txt, md, html, odt, epub
// Slides
case pptx, ppt, key
// Spreadsheets
case xlsx, xls, ods, csv, tsv, numbers
// Audio
case mp3, m4a, wav, flac, ogg, opus, aiff, wma
// Video
case mp4, mov, mkv, webm, avi, wmv, m4v
// Subtitles
case srt, vtt
// Archives
case zip, tar, gzip
var id: String { rawValue }
var ext: String { rawValue }
var displayName: String { rawValue.uppercased() }
/// Which wheel this format belongs to when offered as an output.
var category: FileCategory {
switch self {
case .jpg, .png, .heic, .webp, .tiff, .bmp, .gif, .avif, .svg, .ico: return .image
case .pdf: return .pdf
case .docx, .doc, .pages, .rtf, .txt, .md, .html, .odt: return .document
case .epub: return .ebook
case .pptx, .ppt, .key: return .slides
case .xlsx, .xls, .ods, .csv, .tsv, .numbers: return .spreadsheet
case .mp3, .m4a, .wav, .flac, .ogg, .opus, .aiff, .wma: return .audio
case .mp4, .mov, .mkv, .webm, .avi, .wmv, .m4v: return .video
case .srt, .vtt: return .subtitle
case .zip, .tar, .gzip: return .archive
}
}
var symbol: String {
switch self {
case .jpg, .png, .heic, .webp, .tiff, .bmp, .gif, .avif, .ico: return "photo"
case .svg: return "scribble.variable"
case .pdf: return "doc.richtext"
case .md: return "text.alignleft"
case .txt: return "doc.plaintext"
case .html: return "chevron.left.forwardslash.chevron.right"
case .docx, .doc: return "doc.text"
case .rtf: return "doc.richtext"
case .odt: return "doc.text"
case .pages: return "doc.text"
case .epub: return "book"
case .pptx, .ppt: return "rectangle.on.rectangle.angled"
case .key: return "rectangle.on.rectangle.angled"
case .xlsx, .xls: return "tablecells"
case .ods: return "tablecells"
case .csv, .tsv: return "tablecells.badge.ellipsis"
case .numbers: return "tablecells"
case .mp3, .m4a: return "waveform"
case .wav, .flac, .aiff: return "waveform"
case .ogg, .opus, .wma: return "waveform"
case .mp4, .mov, .mkv, .webm, .avi, .wmv, .m4v: return "film"
case .srt, .vtt: return "captions.bubble"
case .zip, .tar, .gzip: return "doc.zipper"
}
}
/// Uniform type identifier, also used for ImageIO destinations.
var uti: String? {
switch self {
case .jpg: return "public.jpeg"
case .png: return "public.png"
case .heic: return "public.heic"
case .webp: return "org.webmproject.webp"
case .tiff: return "public.tiff"
case .bmp: return "com.microsoft.bmp"
case .gif: return "com.compuserve.gif"
case .avif: return "public.avif"
case .svg: return "public.svg-image"
case .ico: return "com.microsoft.ico"
case .pdf: return "com.adobe.pdf"
case .txt: return "public.plain-text"
case .html: return "public.html"
case .rtf: return "public.rtf"
case .docx: return "org.openxmlformats.wordprocessingml.document"
case .doc: return "com.microsoft.word.doc"
case .odt: return "org.oasis-open.opendocument.text"
case .epub: return "org.idpf.epub-container"
case .pptx: return "org.openxmlformats.presentationml.presentation"
case .xlsx: return "org.openxmlformats.spreadsheetml.sheet"
case .csv: return "public.comma-separated-values-text"
case .tsv: return "public.tab-separated-values-text"
case .mp3: return "public.mp3"
case .m4a: return "public.mpeg-4-audio"
case .wav: return "com.microsoft.waveform-audio"
case .aiff: return "public.aiff-audio"
case .flac: return "org.xiph.flac"
case .ogg: return "org.xiph.ogg"
case .opus: return "org.xiph.opus"
case .mp4: return "public.mpeg-4"
case .mov: return "com.apple.quicktime-movie"
case .mkv: return "org.matroska.mkv"
case .srt: return "public.srt-subtitle"
case .vtt: return "public.vtt-subtitle"
case .zip: return "public.zip-archive"
case .tar: return "public.tar-archive"
case .gzip: return "org.gnu.gnu-zip-archive"
default: return nil
}
}
/// Formats Apple's frameworks cannot *write*; ffmpeg is required.
var requiresFFmpeg: Bool {
switch self {
case .mkv, .webm, .avi, .wmv, .mp3, .flac, .ogg, .opus, .wma: return true
default: return false
}
}
/// Formats that need LibreOffice to write with real layout fidelity.
var requiresLibreOffice: Bool {
switch self {
case .pages, .key, .numbers, .ppt, .pptx, .xls: return true
default: return false
}
}
/// Lossy formats take a quality knob.
var isLossy: Bool {
switch self {
case .jpg, .heic, .avif, .webp, .mp3, .m4a, .ogg, .opus, .wma, .mp4, .mkv, .webm, .avi, .wmv, .m4v:
return true
default:
return false
}
}
static func from(extension ext: String) -> Format? {
Format(rawValue: FileKind.canonicalExtension(ext))
}
}
/// Input side: figure out what a dropped file is.
enum FileKind {
private static let extensionMap: [String: FileCategory] = [
// Images
"jpg": .image, "jpeg": .image, "png": .image, "heic": .image, "heif": .image,
"webp": .image, "tif": .image, "tiff": .image, "bmp": .image, "gif": .image,
"avif": .image, "svg": .image, "ico": .image, "jp2": .image, "psd": .image,
"exr": .image, "tga": .image, "raw": .image, "dng": .image, "cr2": .image,
// Video
"mp4": .video, "mov": .video, "m4v": .video, "mkv": .video, "webm": .video,
"avi": .video, "wmv": .video, "flv": .video, "mpg": .video, "mpeg": .video,
"3gp": .video, "ts": .video, "mts": .video,
// Audio
"mp3": .audio, "m4a": .audio, "m4b": .audio, "m4r": .audio, "wav": .audio,
"flac": .audio, "ogg": .audio, "oga": .audio, "opus": .audio, "aiff": .audio,
"aif": .audio, "aifc": .audio, "wma": .audio, "caf": .audio, "aac": .audio,
"ape": .audio,
// PDF
"pdf": .pdf,
// Documents
"docx": .document, "doc": .document, "rtf": .document, "rtfd": .document,
"odt": .document, "txt": .document, "text": .document, "md": .document,
"markdown": .document, "html": .document, "htm": .document, "xml": .document,
"webarchive": .document, "wordml": .document, "tex": .document, "log": .document,
"pages": .document,
// Slides
"ppt": .slides, "pptx": .slides, "key": .slides, "odp": .slides,
// Spreadsheets
"xlsx": .spreadsheet, "xls": .spreadsheet, "ods": .spreadsheet, "csv": .spreadsheet,
"tsv": .spreadsheet, "numbers": .spreadsheet,
// Ebook
"epub": .ebook, "mobi": .ebook, "azw3": .ebook,
// Subtitles
"srt": .subtitle, "vtt": .subtitle, "ass": .subtitle, "ssa": .subtitle, "sub": .subtitle,
// Archives
"zip": .archive, "tar": .archive, "gz": .archive, "tgz": .archive, "bz2": .archive,
"xz": .archive, "rar": .archive, "7z": .archive, "zst": .archive
]
private static let canonicalMap: [String: String] = [
"jpeg": "jpg", "tif": "tiff", "markdown": "md", "htm": "html", "text": "txt",
"tgz": "tar", "aif": "aiff", "aifc": "aiff", "oga": "ogg", "m4b": "m4a",
"heif": "heic", "wordml": "odt"
]
static func canonicalExtension(_ ext: String) -> String {
let lower = ext.lowercased()
return canonicalMap[lower] ?? lower
}
static func category(of url: URL) -> FileCategory {
if (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true {
return .folder
}
return category(forExtension: url.pathExtension)
}
static func category(forExtension ext: String) -> FileCategory {
extensionMap[ext.lowercased()] ?? .unknown
}
static func format(of url: URL) -> Format? {
Format(rawValue: canonicalExtension(url.pathExtension))
}
static func isSupportedInput(_ url: URL) -> Bool {
category(of: url) != .unknown
}
}
/// What a wheel can offer for a given selection. Kept separate from the UI so
/// the same table drives the drag wheel, the menu bar fallback and the
/// Quick Action.
enum ConversionCatalog {
static let targets: [FileCategory: [Format]] = [
.image: [.png, .jpg, .heic, .webp, .tiff, .bmp, .gif, .avif, .pdf],
.video: [.mp4, .mov, .mkv, .webm, .avi, .wmv, .gif, .mp3, .m4a, .wav],
.audio: [.mp3, .m4a, .wav, .flac, .ogg, .opus, .aiff, .wma],
.pdf: [.docx, .pptx, .xlsx, .jpg, .png, .epub, .txt, .md, .html],
.document: [.docx, .md, .txt, .rtf, .html, .epub, .pdf],
.slides: [.pdf, .jpg, .png],
.spreadsheet: [.xlsx, .ods, .csv, .tsv, .pdf],
.ebook: [.txt, .html, .md, .pdf],
.subtitle: [.srt, .vtt, .txt],
.archive: [.zip, .tar, .gzip]
]
/// Outputs to show for a selection: the category's list minus the format
/// the file already is. A mixed selection falls back to the intersection.
static func outputs(for categories: [FileCategory]) -> [Format] {
let unique = Set(categories)
guard !unique.isEmpty, unique.count == 1, let category = unique.first else {
return []
}
return targets[category] ?? []
}
/// True when every input already has this extension (so it is pointless to
/// offer it).
static func isRedundant(_ format: Format, urls: [URL]) -> Bool {
guard !urls.isEmpty else { return false }
return urls.allSatisfy { FileKind.canonicalExtension($0.pathExtension) == format.ext }
}
}