Files
konverter/Sources/Konverter/Core/Tools.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

334 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
/// A tool: an in-place transform that is not a plain format change.
enum ToolID: String, CaseIterable, Identifiable, Codable {
// Images
case compressImage, resizeImage, cropImage, rotateImage, flipImage
case grayscaleImage, stripMetadata, watermarkImage, readQR
// Video
case compressVideo, trimVideo, muteVideo, speedVideo, snapshotVideo
case videoToGIF, resizeVideo
// Audio
case trimAudio, normalizeAudio, channelsAudio, speedAudio, bleepAudio, compressAudio
// PDF
case mergePDF, splitPDF, rotatePDF, compressPDF, encryptPDF, decryptPDF
case imagesToPDF, collageImages, extractPDFText
// Documents
case documentToPDF, wordCount
// Spreadsheets
case sheetsToCSV
// Subtitles
case shiftSubtitle
// Archives
case extractArchive, listArchive
// Media metadata
case mediaInfo
var id: String { rawValue }
var title: String {
switch self {
case .compressImage: return "Compress"
case .resizeImage: return "Resize"
case .cropImage: return "Crop"
case .rotateImage: return "Rotate"
case .flipImage: return "Flip"
case .grayscaleImage: return "Grayscale"
case .stripMetadata: return "Strip Metadata"
case .watermarkImage: return "Watermark"
case .readQR: return "Read QR"
case .compressVideo: return "Compress"
case .trimVideo: return "Trim"
case .muteVideo: return "Mute"
case .speedVideo: return "Speed"
case .snapshotVideo: return "Snapshot"
case .videoToGIF: return "To GIF"
case .resizeVideo: return "Resize"
case .trimAudio: return "Trim"
case .normalizeAudio: return "Normalize"
case .channelsAudio: return "Channels"
case .speedAudio: return "Speed"
case .bleepAudio: return "Bleep"
case .compressAudio: return "Compress"
case .mergePDF: return "Merge"
case .splitPDF: return "Split"
case .rotatePDF: return "Rotate"
case .compressPDF: return "Compress"
case .encryptPDF: return "Encrypt"
case .decryptPDF: return "Decrypt"
case .imagesToPDF: return "Images to PDF"
case .collageImages: return "Collage"
case .extractPDFText: return "Extract Text"
case .documentToPDF: return "To PDF"
case .wordCount: return "Statistics"
case .sheetsToCSV: return "To CSV"
case .shiftSubtitle: return "Shift Time"
case .extractArchive: return "Extract"
case .listArchive: return "List Contents"
case .mediaInfo: return "Info"
}
}
var symbol: String {
switch self {
case .compressImage, .compressVideo, .compressAudio, .compressPDF: return "arrow.down.right.and.arrow.up.left"
case .resizeImage, .resizeVideo: return "arrow.up.left.and.arrow.down.right"
case .cropImage: return "crop"
case .rotateImage, .rotatePDF: return "rotate.right"
case .flipImage: return "arrow.left.and.right.righttriangle.left.righttriangle.right"
case .grayscaleImage: return "circle.lefthalf.filled"
case .stripMetadata, .mediaInfo: return "info.circle"
case .watermarkImage: return "signature"
case .readQR: return "qrcode.viewfinder"
case .trimVideo, .trimAudio: return "scissors"
case .muteVideo, .bleepAudio: return "speaker.slash"
case .speedVideo, .speedAudio: return "gauge.with.dots.needle.67percent"
case .snapshotVideo: return "camera"
case .videoToGIF: return "rectangle.stack"
case .normalizeAudio: return "waveform.path.ecg"
case .channelsAudio: return "speaker.wave.2"
case .mergePDF: return "square.stack.3d.down.right"
case .splitPDF: return "scissors"
case .encryptPDF: return "lock"
case .decryptPDF: return "lock.open"
case .imagesToPDF: return "doc.badge.plus"
case .collageImages: return "rectangle.grid.2x2"
case .extractPDFText: return "text.viewfinder"
case .documentToPDF: return "doc.richtext"
case .wordCount: return "number"
case .sheetsToCSV: return "tablecells.badge.ellipsis"
case .shiftSubtitle: return "clock.arrow.circlepath"
case .extractArchive: return "archivebox"
case .listArchive: return "list.bullet.rectangle"
}
}
var category: FileCategory {
switch self {
case .compressImage, .resizeImage, .cropImage, .rotateImage, .flipImage,
.grayscaleImage, .stripMetadata, .watermarkImage, .readQR:
return .image
case .compressVideo, .trimVideo, .muteVideo, .speedVideo, .snapshotVideo,
.videoToGIF, .resizeVideo:
return .video
case .trimAudio, .normalizeAudio, .channelsAudio, .speedAudio, .bleepAudio, .compressAudio:
return .audio
case .mergePDF, .splitPDF, .rotatePDF, .compressPDF, .encryptPDF, .decryptPDF,
.imagesToPDF, .collageImages, .extractPDFText:
return .pdf
case .documentToPDF, .wordCount:
return .document
case .sheetsToCSV:
return .spreadsheet
case .shiftSubtitle:
return .subtitle
case .extractArchive, .listArchive:
return .archive
case .mediaInfo:
return .video
}
}
/// Tools that only make sense for a multi-file selection.
var requiresMultipleFiles: Bool {
self == .mergePDF || self == .imagesToPDF || self == .collageImages
}
var minimumFileCount: Int { requiresMultipleFiles ? 2 : 1 }
/// Tools that produce a derived file instead of a transformed copy.
var producesArtifacts: Bool {
switch self {
case .readQR, .extractPDFText, .listArchive, .mediaInfo, .wordCount:
return true
default:
return false
}
}
/// Some tools can only work on narrower inputs (PDF tools on PDFs already,
/// video-only tools on video).
static func tools(for category: FileCategory) -> [ToolID] {
allCases.filter { $0.category == category }
}
}
/// One editable field in a tool's options form.
struct ToolField: Identifiable, Hashable {
enum Kind: Hashable {
case text
case secret
case integer(min: Int, max: Int)
case decimal(min: Double, max: Double)
case toggle
case choice([String])
case timecode
}
let id: String
let label: String
let kind: Kind
let defaultValue: String
let unit: String?
let help: String?
init(_ id: String, _ label: String, _ kind: Kind, default value: String, unit: String? = nil, help: String? = nil) {
self.id = id
self.label = label
self.kind = kind
self.defaultValue = value
self.unit = unit
self.help = help
}
}
extension ToolID {
var fields: [ToolField] {
switch self {
case .compressImage:
return [ToolField("quality", "Quality", .integer(min: 1, max: 100), default: "70", unit: "%")]
case .resizeImage:
return [
ToolField("width", "Max width", .integer(min: 16, max: 20000), default: "1920", unit: "px"),
ToolField("height", "Max height", .integer(min: 16, max: 20000), default: "1920", unit: "px"),
ToolField("percent", "…or scale", .integer(min: 1, max: 400), default: "100", unit: "%")
]
case .cropImage:
return [
ToolField("inset", "Inset from each edge", .integer(min: 0, max: 4000), default: "0", unit: "px"),
ToolField("aspect", "Aspect", .choice(["Keep", "1:1", "4:3", "3:2", "16:9", "9:16"]), default: "Keep")
]
case .rotateImage, .rotatePDF:
return [ToolField("angle", "Rotation", .choice(["90° right", "180°", "90° left"]), default: "90° right")]
case .flipImage:
return [ToolField("axis", "Axis", .choice(["Horizontal", "Vertical"]), default: "Horizontal")]
case .watermarkImage:
return [
ToolField("text", "Text", .text, default: "©"),
ToolField("position", "Position", .choice(["Bottom Right", "Bottom Left", "Top Right", "Top Left", "Center"]), default: "Bottom Right"),
ToolField("opacity", "Opacity", .integer(min: 5, max: 100), default: "45", unit: "%"),
ToolField("size", "Size", .integer(min: 8, max: 40), default: "16", unit: "% of width")
]
case .compressVideo:
return [ToolField("preset", "Quality", .choice(["Low", "Medium", "High"]), default: "Medium")]
case .trimVideo, .trimAudio:
return [
ToolField("start", "Start", .timecode, default: "00:00:00"),
ToolField("end", "End", .timecode, default: "", help: "Leave empty to keep the tail")
]
case .speedVideo, .speedAudio:
return [ToolField("rate", "Speed", .decimal(min: 0.25, max: 4), default: "2", unit: "×")]
case .snapshotVideo:
return [ToolField("at", "Timestamp", .timecode, default: "00:00:01")]
case .videoToGIF:
return [
ToolField("fps", "Frame rate", .integer(min: 5, max: 30), default: "12", unit: "fps"),
ToolField("width", "Width", .integer(min: 120, max: 1600), default: "480", unit: "px")
]
case .resizeVideo:
return [ToolField("width", "Width", .integer(min: 160, max: 7680), default: "1280", unit: "px")]
case .bleepAudio:
return [
ToolField("start", "Mute from", .timecode, default: "00:00:00"),
ToolField("end", "Mute until", .timecode, default: "00:00:01")
]
case .compressAudio:
return [ToolField("bitrate", "Bitrate", .choice(["64", "96", "128", "192", "256", "320"]), default: "128", unit: "kbps")]
case .channelsAudio:
return [ToolField("mode", "Channels", .choice(["Mono", "Stereo"]), default: "Mono")]
case .splitPDF:
return [ToolField("pages", "Pages per file", .integer(min: 1, max: 500), default: "1")]
case .compressPDF:
return [ToolField("quality", "Image quality", .integer(min: 10, max: 100), default: "55", unit: "%")]
case .encryptPDF:
return [
ToolField("password", "Password", .secret, default: ""),
ToolField("owner", "Owner password", .secret, default: "", help: "Optional, defaults to the password")
]
case .decryptPDF:
return [ToolField("password", "Password", .secret, default: "")]
case .collageImages:
return [
ToolField("columns", "Columns", .integer(min: 1, max: 8), default: "3"),
ToolField("page", "Page", .choice(["A4", "Letter", "Square"]), default: "A4"),
ToolField("margin", "Margin", .integer(min: 0, max: 120), default: "24", unit: "pt"),
ToolField("captions", "File names", .toggle, default: "false")
]
case .shiftSubtitle:
return [ToolField("offset", "Shift by", .decimal(min: -600, max: 600), default: "1", unit: "s")]
case .listArchive, .extractArchive, .wordCount, .mediaInfo, .readQR, .stripMetadata,
.grayscaleImage, .normalizeAudio, .extractPDFText, .documentToPDF, .sheetsToCSV,
.muteVideo, .mergePDF, .imagesToPDF:
return []
}
}
}
/// Values collected from a tool's options form. Round-trips through
/// `UserDefaults` per tool so repeat use is one drop.
struct ToolParams {
private var storage: [String: String] = [:]
subscript(key: String) -> String? {
get { storage[key] }
set { storage[key] = newValue }
}
func string(_ key: String, default fallback: String = "") -> String {
let value = storage[key]?.trimmingCharacters(in: .whitespaces) ?? ""
return value.isEmpty ? fallback : value
}
func int(_ key: String, default fallback: Int) -> Int {
Int(string(key)) ?? fallback
}
func double(_ key: String, default fallback: Double) -> Double {
Double(string(key)) ?? fallback
}
func bool(_ key: String, default fallback: Bool) -> Bool {
guard let value = storage[key] else { return fallback }
return value == "true" || value == "1"
}
static func defaults(for tool: ToolID) -> ToolParams {
var params = ToolParams()
for field in tool.fields {
params[field.id] = field.defaultValue
}
return params
}
mutating func fillMissing(from tool: ToolID) {
for field in tool.fields where storage[field.id] == nil {
storage[field.id] = field.defaultValue
}
}
var isEmpty: Bool { storage.isEmpty }
var dictionary: [String: String] { storage }
}
/// Timecodes come in as `mm:ss`, `hh:mm:ss` or plain seconds.
enum Timecode {
static func seconds(from string: String) -> Double? {
let trimmed = string.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty { return nil }
let parts = trimmed.split(separator: ":").map(String.init)
switch parts.count {
case 1:
return Double(parts[0])
case 2:
guard let minutes = Double(parts[0]), let seconds = Double(parts[1]) else { return nil }
return minutes * 60 + seconds
case 3:
guard let h = Double(parts[0]), let m = Double(parts[1]), let s = Double(parts[2]) else { return nil }
return h * 3600 + m * 60 + s
default:
return nil
}
}
}