import AppKit import Foundation import PDFKit /// What a tool produced. `message` is for the read-only tools (Info, Read QR, /// List Contents, Statistics) which show a result instead of writing a file. struct ToolOutcome { var outputs: [URL] = [] var message: String? var messageTitle: String? var revealInFinder: Bool = true } /// Runs a `ToolID` over one or more files. Every tool is written so that a /// failure on one file does not silently swallow the rest: the first error is /// reported after the successful outputs are collected. enum ToolRunner { static func run( tool: ToolID, inputs: [URL], params: ToolParams, cancellation: Cancellation? = nil, progress: ((Double) -> Void)? = nil ) throws -> ToolOutcome { guard inputs.count >= tool.minimumFileCount else { throw ConversionError.failed("\(tool.title) needs at least \(tool.minimumFileCount) files.") } try cancellation?.check() switch tool { case .compressImage: let quality = Double(params.int("quality", default: 70)) / 100 return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let format = FileKind.format(of: source) ?? .jpg let destination = OutputNaming.sibling(for: source, suffix: "-compressed") try ImageTools.compress(source, at: destination, format: format, quality: quality) return destination } case .resizeImage: let width = params.int("width", default: 1920) let height = params.int("height", default: 1920) let percent = params.int("percent", default: 100) return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let destination = OutputNaming.sibling(for: source, suffix: "-resized") try ImageTools.resize(source, at: destination, maxWidth: width, maxHeight: height, percent: percent) return destination } case .cropImage: let inset = params.int("inset", default: 0) let aspect = params.string("aspect", default: "Keep") return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let destination = OutputNaming.sibling(for: source, suffix: "-cropped") try ImageTools.crop(source, at: destination, inset: inset, aspect: aspect) return destination } case .rotateImage: let degrees = params.string("angle", default: "90° right").hasPrefix("90° r") ? 90 : (params.string("angle", default: "").hasPrefix("180") ? 180 : -90) return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let destination = OutputNaming.sibling(for: source, suffix: "-rotated") try ImageTools.rotate(source, at: destination, degrees: degrees) return destination } case .flipImage: let horizontal = params.string("axis", default: "Horizontal") == "Horizontal" return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let destination = OutputNaming.sibling(for: source, suffix: "-flipped") try ImageTools.flip(source, at: destination, horizontal: horizontal) return destination } case .grayscaleImage: return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let destination = OutputNaming.sibling(for: source, suffix: "-gray", extension: "png") try ImageTools.grayscale(source, at: destination) return destination } case .stripMetadata: return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let format = FileKind.format(of: source) ?? .jpg let destination = OutputNaming.sibling(for: source, suffix: "-clean") try ImageTools.stripMetadata(source, at: destination, format: format) return destination } case .watermarkImage: let text = params.string("text", default: "©") let position = params.string("position", default: "Bottom Right") let opacity = Double(params.int("opacity", default: 45)) let size = Double(params.int("size", default: 16)) return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let destination = OutputNaming.sibling(for: source, suffix: "-watermark") try ImageTools.watermark(source, at: destination, text: text, position: position, opacity: opacity, sizePercent: size) return destination } case .readQR: var results: [String] = [] for source in inputs { try cancellation?.check() let payload = ImageTools.readQR(source) results.append("\(source.lastPathComponent):\n\(payload ?? "No barcode found")") } return ToolOutcome(message: results.joined(separator: "\n\n"), messageTitle: "Barcode") case .compressVideo: let quality = params.string("preset", default: "Medium") return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let ext = container(for: source, fallback: "mp4") let destination = OutputNaming.sibling(for: source, suffix: "-compressed", extension: ext) try MediaTools.compressVideo(source, at: destination, quality: quality) return destination } case .trimVideo, .trimAudio: let start = Timecode.seconds(from: params.string("start", default: "0")) ?? 0 let end = Timecode.seconds(from: params.string("end", default: "")) return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let ext = container(for: source, fallback: tool == .trimAudio ? "m4a" : "mp4") let destination = OutputNaming.sibling(for: source, suffix: "-trimmed", extension: ext) try trim(source, at: destination, start: start, end: end, cancellation: cancellation) return destination } case .muteVideo: return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let ext = container(for: source, fallback: "mp4") let destination = OutputNaming.sibling(for: source, suffix: "-muted", extension: ext) try AVConverter.mute(source: source, at: destination, cancellation: cancellation) return destination } case .speedVideo, .speedAudio: let rate = params.double("rate", default: 2) return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let ext = container(for: source, fallback: tool == .speedAudio ? "m4a" : "mp4") let destination = OutputNaming.sibling(for: source, suffix: "-\(rateString(rate))x", extension: ext) try AVConverter.changeSpeed(source: source, at: destination, rate: rate, cancellation: cancellation) return destination } case .snapshotVideo: let at = Timecode.seconds(from: params.string("at", default: "1")) ?? 1 return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let destination = OutputNaming.sibling(for: source, suffix: "-frame", extension: "png") try AVConverter.snapshot(source: source, at: destination, time: at, cancellation: cancellation) return destination } case .videoToGIF: let fps = params.int("fps", default: 12) let width = params.int("width", default: 480) return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let destination = OutputNaming.sibling(for: source, suffix: "", extension: "gif") try AVConverter.makeGIF(source: source, at: destination, fps: fps, maxWidth: width, cancellation: cancellation) return destination } case .resizeVideo: let width = params.int("width", default: 1280) return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let ext = container(for: source, fallback: "mp4") let destination = OutputNaming.sibling(for: source, suffix: "-resized", extension: ext) try AVConverter.resize(source: source, at: destination, width: width, cancellation: cancellation) return destination } case .normalizeAudio: return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let destination = MediaTools.audioSibling(for: source, suffix: "-normalized") try MediaTools.normalizeAudio(source, at: destination) return destination } case .channelsAudio: let mono = params.string("mode", default: "Mono") == "Mono" return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let destination = MediaTools.audioSibling(for: source, suffix: mono ? "-mono" : "-stereo") try MediaTools.channels(source, at: destination, mono: mono) return destination } case .bleepAudio: let start = Timecode.seconds(from: params.string("start", default: "0")) ?? 0 let end = Timecode.seconds(from: params.string("end", default: "1")) ?? 1 return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let destination = MediaTools.audioSibling(for: source, suffix: "-bleeped") try MediaTools.bleep(source, at: destination, start: start, end: max(end, start + 0.05)) return destination } case .compressAudio: let bitrate = params.string("bitrate", default: "128") return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let destination = MediaTools.audioSibling(for: source, suffix: "-compressed") try MediaTools.compressAudio(source, at: destination, bitrate: bitrate) return destination } case .mergePDF: let first = inputs[0] let destination = OutputNaming.availableURL( for: first.deletingLastPathComponent() .appendingPathComponent(first.deletingPathExtension().lastPathComponent + " merged.pdf") ) // A mixed selection is merged by turning each non-PDF into a PDF // first, so "3 photos + 2 PDFs" is one drop instead of two jobs. var staged: [URL] = [] let staging = FileManager.default.temporaryDirectory .appendingPathComponent("konverter-merge-\(UUID().uuidString)", isDirectory: true) defer { if !staged.isEmpty { try? FileManager.default.removeItem(at: staging) } } var sources: [URL] = [] for (index, input) in inputs.enumerated() { try cancellation?.check() if FileKind.category(of: input) == .pdf { sources.append(input) continue } if staged.isEmpty { try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true) } let name = String(format: "%03d", index) + "-" + input.deletingPathExtension().lastPathComponent + ".pdf" let temp = staging.appendingPathComponent(name) try Converter.convert( source: input, to: .pdf, at: temp, cancellation: cancellation, progress: { fraction in progress?(fraction * 0.5) } ) staged.append(temp) sources.append(temp) } try PDFConverter.merge(sources, at: destination, cancellation: cancellation) progress?(1) return ToolOutcome(outputs: [destination]) case .splitPDF: let perFile = max(1, params.int("pages", default: 1)) return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let directory = OutputNaming.artifactDirectory(for: source, suffix: " split") let written = try PDFConverter.split(source, pagesPerFile: perFile, into: directory, cancellation: cancellation) guard let first = written.first else { throw ConversionError.failed("Nothing to split.") } return first } case .rotatePDF: let angle = params.string("angle", default: "90° right") let degrees = angle.hasPrefix("180") ? 180 : (angle.hasPrefix("90° l") ? -90 : 90) return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let destination = OutputNaming.sibling(for: source, suffix: "-rotated", extension: "pdf") try PDFConverter.rotate(source, degrees: degrees, at: destination) return destination } case .compressPDF: let quality = Double(params.int("quality", default: 55)) / 100 return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let destination = OutputNaming.sibling(for: source, suffix: "-compressed", extension: "pdf") try PDFConverter.compress(source, at: destination, quality: quality, cancellation: cancellation) return destination } case .encryptPDF: let password = params.string("password", default: "") guard !password.isEmpty else { throw ConversionError.failed("Enter a password first.") } let owner = params.string("owner", default: "") return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let destination = OutputNaming.sibling(for: source, suffix: "-encrypted", extension: "pdf") try PDFConverter.encrypt(source, at: destination, password: password, ownerPassword: owner) return destination } case .decryptPDF: let password = params.string("password", default: "") return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let destination = OutputNaming.sibling(for: source, suffix: "-decrypted", extension: "pdf") guard let document = PDFDocument(url: source), document.unlock(withPassword: password) || !document.isLocked else { throw ConversionError.failed("Wrong password or the PDF is not encrypted.") } guard document.write(to: destination) else { throw ConversionError.failed("Could not write the decrypted PDF.") } return destination } case .imagesToPDF: let images: [(CGImage, String?)] = try inputs.map { source in guard let image = Raster.firstImage(at: source) else { throw ConversionError.unreadable(source.lastPathComponent) } return (image, source.lastPathComponent) } let first = inputs[0] let destination = OutputNaming.availableURL( for: first.deletingLastPathComponent() .appendingPathComponent(first.deletingPathExtension().lastPathComponent + " images.pdf") ) try PDFConverter.imagesToPDF(images, at: destination, cancellation: cancellation) progress?(1) return ToolOutcome(outputs: [destination]) case .collageImages: let columns = max(1, params.int("columns", default: 3)) let page = params.string("page", default: "A4") let margin = Double(params.int("margin", default: 24)) let captions = params.bool("captions", default: false) let entries: [(CGImage, String)] = try inputs.map { source in guard let image = Raster.firstImage(at: source) else { throw ConversionError.unreadable(source.lastPathComponent) } return (image, source.lastPathComponent) } let first = inputs[0] let destination = OutputNaming.availableURL( for: first.deletingLastPathComponent() .appendingPathComponent(first.deletingPathExtension().lastPathComponent + " collage.pdf") ) try PDFConverter.collage( entries, at: destination, columns: columns, page: page, margin: margin, captions: captions, cancellation: cancellation ) progress?(1) return ToolOutcome(outputs: [destination]) case .extractPDFText: return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let text = PDFConverter.extractText(source) guard !text.trimmed.isEmpty else { throw ConversionError.failed("No text layer in \(source.lastPathComponent).") } let destination = OutputNaming.sibling(for: source, suffix: "", extension: "txt") try TextDocument.write(text, to: destination) return destination } case .documentToPDF: return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let destination = OutputNaming.sibling(for: source, suffix: "", extension: "pdf") try DocumentConverter.convert(source: source, to: .pdf, at: destination, cancellation: cancellation) return destination } case .wordCount: var results: [String] = [] for source in inputs { try cancellation?.check() let text = TextDocument.plainText(from: source) ?? "" let words = text.split { $0 == " " || $0.isNewline }.count let lines = text.components(separatedBy: "\n").count results.append(""" \(source.lastPathComponent) \(words) words · \(text.count) characters · \(lines) lines """) } return ToolOutcome(message: results.joined(separator: "\n\n"), messageTitle: "Statistics") case .sheetsToCSV: return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let rows = try SpreadsheetConverter.read(source, cancellation: cancellation) let destination = OutputNaming.sibling(for: source, suffix: "", extension: "csv") try SpreadsheetConverter.write(rows, to: destination, delimiter: ",") return destination } case .shiftSubtitle: let offset = params.double("offset", default: 0) return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let format = FileKind.format(of: source) ?? .srt let cues = SubtitleConverter.shift(try SubtitleConverter.parse(source), by: offset) let destination = OutputNaming.sibling(for: source, suffix: "-shifted") try SubtitleConverter.write(cues, to: destination, format: format == .vtt ? .vtt : .srt) return destination } case .extractArchive: return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in let directory = OutputNaming.artifactDirectory(for: source, suffix: " extracted") let result = try ArchiveConverter.extract(source: source, into: directory, cancellation: cancellation, progress: nil) return result } case .listArchive: var results: [String] = [] for source in inputs { try cancellation?.check() results.append("\(source.lastPathComponent)\n\((try? ArchiveConverter.list(source)) ?? "Unreadable")") } return ToolOutcome(message: results.joined(separator: "\n\n"), messageTitle: "Archive") case .mediaInfo: var results: [String] = [] for source in inputs { try cancellation?.check() results.append(MediaInfo.describe(source)) } return ToolOutcome(message: results.joined(separator: "\n\n"), messageTitle: "Info") case .createArchive: let folder = inputs[0].deletingLastPathComponent() let name = params.string("name", default: "Archive") let base = name.hasSuffix(".zip") ? String(name.dropLast(4)) : name let destination = OutputNaming.availableURL( for: folder.appendingPathComponent(base).appendingPathExtension("zip") ) try ArchiveConverter.createArchive( from: inputs, at: destination, cancellation: cancellation, progress: progress ) return ToolOutcome(outputs: [destination]) } } // MARK: - Helpers private static func mapFiles( _ inputs: [URL], cancellation: Cancellation?, progress: ((Double) -> Void)?, _ transform: (URL) throws -> URL ) throws -> ToolOutcome { var outputs: [URL] = [] var firstError: Error? for (index, source) in inputs.enumerated() { try cancellation?.check() do { let output = try transform(source) outputs.append(output) } catch is CancellationError { throw CancellationError() } catch { if firstError == nil { firstError = error } } progress?(Double(index + 1) / Double(inputs.count)) } if outputs.isEmpty, let firstError { throw firstError } return ToolOutcome(outputs: outputs) } /// Keeps the source container when it is one we can write, otherwise uses /// the fallback (`mp4` for video, `m4a` for audio). private static func container(for source: URL, fallback: String) -> String { let ext = FileKind.canonicalExtension(source.pathExtension) let videoContainers = ["mp4", "mov", "m4v"] let audioContainers = ["m4a", "mp3", "wav", "aiff", "flac", "ogg", "opus", "wma"] if videoContainers.contains(ext) || audioContainers.contains(ext) { return ext } return fallback } private static func rateString(_ rate: Double) -> String { rate == rate.rounded() ? String(Int(rate)) : String(format: "%.2f", rate) } private static func trim(_ source: URL, at destination: URL, start: Double, end: Double?, cancellation: Cancellation?) throws { let ext = FileKind.canonicalExtension(source.pathExtension) if ["mp4", "mov", "m4v", "m4a"].contains(ext) { try AVConverter.trim(source: source, at: destination, start: start, end: end, cancellation: cancellation) return } if Toolchain.hasFFmpeg, Settings.shared.useExternalTools { var arguments = ["-ss", String(format: "%.3f", start)] if let end, end > start { arguments += ["-t", String(format: "%.3f", end - start)] } arguments += ["-c:a", "copy"] try FFmpeg.filter(source: source, at: destination, arguments: arguments) return } if ["wav", "aiff"].contains(ext) { let staged = FileManager.default.temporaryDirectory .appendingPathComponent("konverter-trim-\(UUID().uuidString).m4a") defer { try? FileManager.default.removeItem(at: staged) } try AVConverter.trim(source: source, at: staged, start: start, end: end, cancellation: cancellation) try CoreAudio.convert(source: staged, to: FileKind.format(of: destination) ?? .wav, at: destination, cancellation: cancellation) return } throw ConversionError.missingTool("Trimming .\(ext) needs ffmpeg — brew install ffmpeg") } }