commit 2d031e58a4ce72b991e650945166262dafa6bccb Author: TZGyn Date: Tue Oct 20 00:36:40 2026 +0800 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a53013 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# SwiftPM +.build/ +.swiftpm/ + +# App bundle (build artifact, regenerate with ./build-app.sh) +Konverter.app/ + +# Secrets +*.p8 +notary-result.json + +# macOS +.DS_Store + +# Xcode +xcuserdata/ +DerivedData/ +dist/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f6aa90d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,155 @@ +# AGENTS.md + +Working notes for `Konverter` — a menu bar file converter for macOS, written in +Swift with SwiftPM and no external dependencies. Read this before changing the +drag machinery or the conversion catalog. + +## Commands + +```bash +swift build # debug +swift build -c release +./.build/debug/Konverter --self-test # 41 checks, exits non-zero on failure +./.build/debug/Konverter --convert --to +./.build/debug/Konverter --convert --tool --param k=v +./build-app.sh # → Konverter.app (signed) +./release.sh # notarize + staple + zip + sha256 +open Konverter.app +``` + +`--self-test` and `--convert` are handled in `AppDelegate.applicationDidFinishLaunching` +*before* any UI is installed, so they work headlessly and never steal focus. The +self-test writes its fixtures into a temp directory and cleans up. A `SKIP` line +means an optional engine (ffmpeg / LibreOffice / `cwebp`) is absent, not a bug; +a `FAIL` line is a bug. + +Keep the self-test green and add a check whenever you add a converter or a +tool — it is the only automated coverage in the repo. + +## Layout + +``` +Sources/Konverter/ + App.swift @main, AppDelegate, monitor wiring, Services handler + CLI.swift --convert mode + SelfTest.swift the verification suite + Core/ + Format.swift Format, FileCategory, FileKind, ConversionCatalog + Tools.swift ToolID, ToolField, ToolParams, Timecode + Actions.swift ActionID + FileActions (AirDrop, Float, Copy, …) + Settings.swift UserDefaults-backed preferences, LoginItem + JobStore.swift job queue, progress, reveal, notifications + OutputNaming.swift destination paths, conflict policy + Shell.swift Process wrapper, tool lookup + Toolchain.swift optional engine detection (ffmpeg, soffice, cwebp) + WheelOption.swift WheelItem/WheelKind and wheel construction + Drag/ + DragMonitor.swift global mouse monitors + drag pasteboard reading + WheelController.swift overlay window, geometry, DropCatcher, commit + WheelView.swift SwiftUI radial wheel and list views + Convert/ + Converter.swift dispatch table + Availability rules + ToolRunner.swift every ToolID implementation + ImageConverter.swift, ImageTools.swift + PDFConverter.swift, DocumentConverter.swift, TextDocument.swift, Markdown.swift + AVConverter.swift, MediaTools.swift, FFmpeg.swift + ArchiveConverter.swift, SpreadsheetConverter.swift, EbookConverter.swift + SubtitleConverter.swift, Office.swift + UI/ + MenuBar.swift, QuickMenu.swift, PreferencesWindow.swift, OptionsWindow.swift + ResultWindow.swift, JobPanel.swift, FloatPanel.swift, Notifier.swift +``` + +`Converter.convert(source:to:at:)` handles plain format changes; +`ToolRunner.run(tool:inputs:params:)` handles transforms. Both funnel through +`Availability` for the "is the engine present?" question, and both report +errors as `ConversionError` (a `LocalizedError`) so the CLI and the UI show the +same wording. + +## How the drag wheel works + +This is the part with real constraints, so it is worth stating plainly. + +1. **Detecting the drag.** `DragMonitor` installs global (and local) monitors for + `.leftMouseDragged` etc. Mouse events need **no** permission. On the first + drag event it reads `NSPasteboard(name: .drag)` for file URLs. If there are + none, it is not a file drag and we ignore it. +2. **Showing the overlay.** `WheelController.present` creates a borderless + `OverlayWindow` at `.screenSaver - 1`, non-key, `ignoresMouseEvents = false`, + with a full-screen `DropCatcher` view. A 60 Hz timer polls + `NSEvent.mouseLocation` and `NSEvent.modifierFlags` to decide which wheel to + show and what is highlighted — because a non-key window cannot read modifier + flags from the event stream. +3. **Consuming the drop.** `DropCatcher` is a real `NSDraggingDestination`. That + is what stops Finder from moving the file: Finder hands the drop to us and + considers the drag handled. Never replace this with a borderless window that + merely *looks* like it is on top — Finder will happily move the file out from + under you. +4. **Ending.** Drop → `commitDrop()` runs the job. Escape, dead-zone drop, or a + mouse-up outside the wheel → `cancel()`, which hides the overlay and lets the + drag continue wherever it landed. +5. **Keys are optional.** Escape/arrows/Return come from a *global key* monitor, + which does require Accessibility. All of them are also reachable by pointer, + so the app must never *require* that permission. Do not move essential + behaviour into the key path. + +Two ordering traps live here. `NSEvent.modifierFlags` is global state, not the +event's flags — read it during the tick, not once at drag start. And the wheel +geometry (`WheelController.geometry`/hit testing) is unit-tested by the +"Wheel geometry" section of the self-test; if you change the radius or segment +maths, update those expectations rather than deleting them. + +## Conversion gotchas + +- **WebP** has no ImageIO encoder on macOS. `WebPEncoder` shells out to `cwebp`, + falls back to an ffmpeg with libwebp, and marks the format unavailable + otherwise. `sips` cannot write WebP — verified, do not try. +- **SVG, PSD, RAW** are often undecodable by ImageIO. `Raster.firstImage` falls + back to `qlmanage`/Quick Look rasterisation. +- **Lossless "compression"** (PNG/BMP/TIFF) ignores ImageIO's quality property, so + `ImageTools.compress` posterises instead. If you ever reach for `pngquant`, + remember it is not a dependency and must stay optional. +- **PPTX/EPUB/XLSX/DOCX/RTF** are hand-written OOXML/EPUB/zip writers. They are + deliberately minimal: enough structure for Word/Numbers/Preview to open them + cleanly, no styling round-trip. LibreOffice is used when real layout fidelity + matters (PAGES/KEY/NUMBERS/PPT/XLS). +- **`track.formatDescriptions` is `[Any]`** — you need `as! CMFormatDescription` + (`as?` is rejected as always-succeeds). The `load(.formatDescriptions)` API + would be nicer but is async and the media-info path is synchronous. +- Optional engines must always degrade, never crash: absence of ffmpeg or + LibreOffice should surface as an unavailable wheel item with a reason, and + `Toolchain.missingToolAdvice` supplies the `brew install …` line for the UI. + +## Build/bundle notes + +- The app is an `LSUIElement` menu bar app; the bundle is assembled by + `build-app.sh` with a handwritten `Info.plist`. Do not try to generate it from + `Package.swift`, and do not commit `Konverter.app` or `dist/`. +- `CFBundleIdentifier` is `dev.tzgyn.konverter`. `AppDelegate.terminateOtherInstances()` + kills stale copies at launch so the monitors never double up. +- `Settings` (our class) collides with SwiftUI's `Settings` scene — the scene + must be written `SwiftUI.Settings { EmptyView() }`. +- The Finder Quick Action is an `NSServices` entry calling `convertFiles:userData:error:` + on the app delegate. It follows the same path as the menu bar item. +- `SMAppService.mainApp` powers "open at login". + +## Releasing + +Notarization is mandatory: an unnotarized build is not a release. `release.sh` +will not print a sha256 unless Apple accepted the submission, the ticket is +stapled, and `spctl -a -vv Konverter.app` reports `source=Notarized Developer +ID`. Never suggest `xattr -dr com.apple.quarantine` or +`brew install --cask --no-quarantine` as a workaround — fix the signing problem. + +Push `main` before `tea releases create` (the tag must point at shipped source), +then bump `Casks/konverter.rb` in `TZGyn/homebrew-tap` with the new version and +sha256, and print the upgrade script: + +```bash +brew update +brew upgrade --cask konverter +``` + +Credentials: `~/.config/kapture/AuthKey_K5KXMMSB28.p8`, key id `K5KXMMSB28`, +issuer `3e25e5dc-8b75-453f-a71c-c7f3feaac628`, team `RX6767S6BW`. Never commit +the key. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..c193df3 --- /dev/null +++ b/Package.swift @@ -0,0 +1,13 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "Konverter", + platforms: [.macOS(.v13)], + targets: [ + .executableTarget( + name: "Konverter", + path: "Sources/Konverter" + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..814dc89 --- /dev/null +++ b/README.md @@ -0,0 +1,180 @@ +# Konverter + +A macOS menu bar file converter in the spirit of [Tangerine](https://tangerineformac.com): +drag a file in Finder, hold a modifier key, get a wheel of outputs around the +cursor, drop on one, and the converted file appears next to the original. +Everything runs locally — no uploads, no accounts, no analytics. + +

+ Shift = convert · Command (+Shift) = tools · Control = actions +

+ +## Using it + +1. Launch Konverter. It lives in the menu bar (`LSUIElement`, no Dock icon). +2. In Finder, drag one or more files (folders work for archives). +3. **Hold a trigger key while dragging** — the wheel fades in under the pointer: + - **Shift ⇧** — conversion targets (`PNG`, `JPG`, `PDF`, `MP4`, `ZIP`, …). + - **Command ⌘ + Shift** — file tools (`Compress`, `Resize`, `Trim`, + `Watermark`, `Merge`, `Extract`, …), each with its own small form. + - **Control ⌃** — actions (`AirDrop`, `Float`, `Copy`, `Move`, `Copy Path`, + `Terminal`, `Reveal`). +4. Move the pointer onto a segment (or use ←/→ and Return) and **drop**. + Dropping on the dead zone in the middle, pressing Escape, or dropping the + file anywhere else cancels and lets Finder do its normal thing. + +The wheel also works without dragging: the menu bar item and the Finder Quick +Action **Convert with Konverter…** open the same options as a menu. + +Keyboard/pointer alone is enough to use it. The optional keys (Escape, arrows, +Return) come from a global key monitor, so they need Accessibility permission — +without it the wheel still works by pointer, it just cannot read those keys. + +### Preferences + +Trigger keys (Shift/Command/Control/Option/fn), wheel style (radial/list), +radius, labels, dimming, output folder (beside the original or a chosen +folder), conflict policy (keep both / replace), progress window, reveal when +finished, sound, use of external tools, and open at login. Stored in +`UserDefaults` under the `konverter.` prefix. + +## What it converts + +94 input extensions are recognised. Outputs offered per kind of file: + +| Input kind | Outputs | +| --- | --- | +| 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 / folder | ZIP, TAR, GZIP | + +Plus 38 tools: + +| Group | Tools | +| --- | --- | +| Image | Compress, Resize, Crop, Rotate, Flip, Grayscale, Strip Metadata, Watermark, Read QR | +| Video | Compress, Trim, Mute, Speed, Snapshot, To GIF, Resize | +| Audio | Trim, Normalize, Channels, Speed, Bleep, Compress | +| PDF | Merge, Split, Rotate, Compress, Encrypt, Decrypt, Images to PDF, Collage, Extract Text | +| Document | To PDF, Statistics | +| Spreadsheet | To CSV | +| Subtitle | Shift Time | +| Archive | Extract, List Contents | +| Any | Info | + +### Engines and fallbacks + +| Layer | Used for | Ships with macOS | +| --- | --- | --- | +| ImageIO / Core Graphics | raster image decode/encode | yes | +| PDFKit / Core Text | PDF read/write, text extraction | yes | +| AVFoundation | MP4/MOV/M4A/WAV/AIFF/GIF | yes | +| `textutil`, `afconvert`, `ditto`, `tar`, `gzip`, `zip` | documents, audio, archives | yes | +| Hand-written XLSX / EPUB / Markdown / CSV / TSV / SRT-VTT | office and ebook plumbing | yes | +| ffmpeg / ffprobe | MKV, WebM, AVI, WMV, MP3, FLAC, OGG, Opus, WMA, bleep | optional | +| LibreOffice | PAGES, KEY, NUMBERS, PPT, PPTX, XLS, ODS | optional | +| `cwebp` | WebP encoding | optional | + +Optional helpers are detected once at launch and are only ever *additive*: a +format whose engine is missing shows as unavailable in the wheel (with the +reason) instead of failing after you drop. Install them with: + +```bash +brew install ffmpeg webp # cwebp comes from the webp formula +brew install --cask libreoffice +``` + +Notes on specific formats: + +- **WebP out** needs `cwebp` or an ffmpeg built with libwebp. ImageIO on macOS + has no WebP encoder, and `sips` cannot write it either. +- **SVG / PSD / RAW in** are rasterised through Quick Look (`qlmanage`) when + ImageIO refuses them. +- **PAGES / KEY / NUMBERS out** only exist through LibreOffice. In Safari-style + document conversion, the Apple formats keep their layout via LibreOffice's + filter, not a hand-rolled writer. +- **Compressing a PNG** has no lossy quality knob, so the slider drives colour + depth (posterisation) instead — a real size drop, at the cost of banding at + low settings. + +## Building + +Requires Xcode (Swift 6 toolchain) and macOS 13+. + +```bash +swift build # debug binary +swift build -c release + +./.build/debug/Konverter --self-test # 41 checks over every engine +./.build/debug/Konverter --convert photo.heic --to png +./.build/debug/Konverter --convert clip.mov --tool trimVideo \ + --param start=0:02 --param end=0:05 +./.build/debug/Konverter --convert image.png --tool compressImage --param quality=50 +``` + +`--self-test` builds its own fixtures in a temp directory and asserts that each +engine produces a non-empty, plausible file. Skips (printed as `SKIP`) mean an +optional engine is absent on this machine. + +Build the app bundle (ad-hoc signed when no Developer ID identity is present): + +```bash +./build-app.sh 0.1.0 # → Konverter.app, signed with Developer ID +open Konverter.app +``` + +## Releasing + +```bash +./release.sh 0.1.0 # build → sign → notarize → staple → zip + sha256 +``` + +`release.sh` refuses to hand back a build that is not notarized and stapled; +it also verifies `spctl -a -vv Konverter.app` reports +`source=Notarized Developer ID` before printing the sha256. Notary credentials +default to the shared App Store Connect key and can be overridden with +`KONVERTER_NOTARY_API_KEY_PATH`, `KONVERTER_NOTARY_API_KEY_ID`, and +`KONVERTER_NOTARY_API_ISSUER`. + +Then tag, publish, and attach the zip: + +```bash +git tag v0.1.0 && git push origin v0.1.0 +tea releases create --tag v0.1.0 --title "Konverter 0.1.0" \ + --note "" --asset dist/Konverter.zip \ + --repo TZGyn/konverter --login gitea.tzgyn.dev +``` + +## Install (Homebrew) + +```bash +brew tap TZGyn/tap https://gitea.tzgyn.dev/TZGyn/homebrew-tap.git +brew trust TZGyn/tap +brew install --cask konverter +``` + +Upgrade script: + +```bash +brew update +brew upgrade --cask konverter +``` + +## Privacy + +Nothing leaves the machine. There is no networking code in the app, no +telemetry, and no third-party dependencies in `Package.swift`. Conversions are +performed by Apple frameworks, the bundled CLI tools, and (optionally) local +`ffmpeg` / LibreOffice. + +## Development docs + +`AGENTS.md` covers the architecture, the drag/wheel mechanics, and the sharp +edges worth knowing before changing the code. diff --git a/Sources/Konverter/App.swift b/Sources/Konverter/App.swift new file mode 100644 index 0000000..245b90c --- /dev/null +++ b/Sources/Konverter/App.swift @@ -0,0 +1,91 @@ +import AppKit +import SwiftUI + +@main +struct KonverterApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + + var body: some Scene { + // A menu bar app: no windows of its own. Settings are a custom window + // so the app can be opened from the status item on any space. + SwiftUI.Settings { EmptyView() } + } +} + +final class AppDelegate: NSObject, NSApplicationDelegate { + private let menuBar = MenuBarController() + + func applicationDidFinishLaunching(_ notification: Notification) { + if CommandLine.arguments.contains("--self-test") { + exit(SelfTest.run()) + } + if CommandLine.arguments.contains("--convert") { + exit(CLI.run()) + } + + terminateOtherInstances() + + menuBar.install() + + DragMonitor.shared.onBegan = { urls in + WheelController.shared.begin(urls: urls) + } + DragMonitor.shared.onTick = { _, location, modifiers in + guard Settings.shared.enabled else { return } + WheelController.shared.tick(location: location, modifiers: modifiers) + } + DragMonitor.shared.onEnded = { _ in + WheelController.shared.end() + } + DragMonitor.shared.onKey = { keyCode, modifiers in + WheelController.shared.handleKey(keyCode, modifiers: modifiers) + } + DragMonitor.shared.start() + + NSApp.servicesProvider = self + NSUpdateDynamicServices() + + // Warm the Quick Look machinery so the first Float is instant. + DispatchQueue.main.async { + _ = NSWorkspace.shared + } + } + + func applicationWillTerminate(_ notification: Notification) { + DragMonitor.shared.stop() + WheelController.shared.cancel() + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + false + } + + private func terminateOtherInstances() { + let identifier = Bundle.main.bundleIdentifier ?? "dev.tzgyn.konverter" + let mine = ProcessInfo.processInfo.processIdentifier + for other in NSRunningApplication.runningApplications(withBundleIdentifier: identifier) + where other.processIdentifier != mine { + other.forceTerminate() + } + } + + /// Finder Quick Action / Services entry. The wheel needs a pointer to sit + /// under, so this opens the same options as a native menu. + @objc func convertFiles( + _ pasteboard: NSPasteboard, + userData: String, + error: AutoreleasingUnsafeMutablePointer + ) { + let urls = (pasteboard.readObjects( + forClasses: [NSURL.self], + options: [.urlReadingFileURLsOnly: true] + ) as? [URL]) ?? [] + guard !urls.isEmpty else { + error.pointee = "No files were passed to Konverter." as NSString + return + } + DispatchQueue.main.async { + QuickMenu.show(urls: urls, at: NSEvent.mouseLocation) + } + } +} diff --git a/Sources/Konverter/CLI.swift b/Sources/Konverter/CLI.swift new file mode 100644 index 0000000..c428103 --- /dev/null +++ b/Sources/Konverter/CLI.swift @@ -0,0 +1,80 @@ +import AppKit +import Foundation + +/// Headless conversion, used for verification and scripting: +/// +/// Konverter --convert a.png b.pdf --to jpg +/// Konverter --convert clip.mov --tool trimVideo --param start=0:02 --param end=0:05 +enum CLI { + static func run() -> Int32 { + let arguments = Array(CommandLine.arguments.dropFirst()) + + guard let convertIndex = arguments.firstIndex(of: "--convert") else { + FileHandle.standardError.write(Data("usage: Konverter --convert --to \n".utf8)) + return 2 + } + + var files: [URL] = [] + var target: Format? + var tool: ToolID? + var params = ToolParams() + + var index = convertIndex + 1 + while index < arguments.count { + let argument = arguments[index] + switch argument { + case "--to": + index += 1 + if index < arguments.count { target = Format(rawValue: arguments[index]) } + case "--tool": + index += 1 + if index < arguments.count { tool = ToolID(rawValue: arguments[index]) } + case "--param": + index += 1 + if index < arguments.count { + let pair = arguments[index].split(separator: "=", maxSplits: 1).map(String.init) + if pair.count == 2 { params[pair[0]] = pair[1] } + } + default: + if !argument.hasPrefix("--") { + files.append(URL(fileURLWithPath: argument)) + } + } + index += 1 + } + + guard !files.isEmpty else { + FileHandle.standardError.write(Data("no input files\n".utf8)) + return 2 + } + + do { + if let tool { + let outcome = try ToolRunner.run(tool: tool, inputs: files, params: params) + if let message = outcome.message { + print(message) + } + for output in outcome.outputs { print(output.path) } + return outcome.outputs.isEmpty && outcome.message == nil ? 1 : 0 + } + + guard let target else { + FileHandle.standardError.write(Data("--to is required\n".utf8)) + return 2 + } + + var written: [URL] = [] + for file in files { + let destination = OutputNaming.destination(for: file, format: target) + let outputs = try Converter.convert(source: file, to: target, at: destination) + written.append(contentsOf: outputs) + } + for output in written { print(output.path) } + return written.isEmpty ? 1 : 0 + } catch { + let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + FileHandle.standardError.write(Data((message + "\n").utf8)) + return 1 + } + } +} diff --git a/Sources/Konverter/Convert/AVConverter.swift b/Sources/Konverter/Convert/AVConverter.swift new file mode 100644 index 0000000..c49fa31 --- /dev/null +++ b/Sources/Konverter/Convert/AVConverter.swift @@ -0,0 +1,368 @@ +import AVFoundation +import CoreGraphics +import Foundation +import ImageIO + +/// Audio and video, mostly through AVFoundation. Formats Apple cannot write +/// (MKV, WebM, AVI, WMV, MP3, FLAC, OGG, Opus, WMA) are delegated to ffmpeg +/// when it is installed — see `FFmpeg`. +enum AVConverter { + + // MARK: - Probing + + static func duration(_ url: URL) -> Double? { + let asset = AVURLAsset(url: url, options: [AVURLAssetPreferPreciseDurationAndTimingKey: true]) + let seconds = CMTimeGetSeconds(asset.duration) + return seconds.isFinite && seconds > 0 ? seconds : nil + } + + /// Uses the synchronously loaded track list; called only while building the + /// drag wheel, so it must not await. + static func trackSummary(_ url: URL) -> (video: Bool, audio: Bool) { + let asset = AVURLAsset(url: url) + var video = false + var audio = false + for track in asset.tracks { + switch track.mediaType { + case .video where track.naturalSize.height > 0: video = true + case .audio: audio = true + default: break + } + } + return (video, audio) + } + + // MARK: - Conversion + + static func convert( + source: URL, + to format: Format, + at destination: URL, + quality: String = "Medium", + bitrate: String = "128", + cancellation: Cancellation? = nil, + progress: ((Double) -> Void)? = nil + ) throws { + try cancellation?.check() + + switch format { + case .gif: + try makeGIF(source: source, at: destination, fps: 12, maxWidth: 480, cancellation: cancellation, progress: progress) + return + case .mp4, .mov, .m4v: + try exportWithSession( + source: source, + destination: destination, + fileType: format == .mov ? .mov : .mp4, + preset: videoPreset(for: quality), + cancellation: cancellation, + progress: progress + ) + return + case .m4a, .mp3: + if format == .m4a { + try exportWithSession( + source: source, + destination: destination, + fileType: .m4a, + preset: AVAssetExportPresetAppleM4A, + cancellation: cancellation, + progress: progress + ) + return + } + try FFmpeg.convert(source: source, to: format, at: destination, bitrate: bitrate, cancellation: cancellation, progress: progress) + return + default: + // Everything else: try ffmpeg, then CoreAudio, then AVFoundation. + if Toolchain.hasFFmpeg, Settings.shared.useExternalTools { + try FFmpeg.convert(source: source, to: format, at: destination, bitrate: bitrate, cancellation: cancellation, progress: progress) + return + } + if format == .wav || format == .aiff { + try CoreAudio.convert(source: source, to: format, at: destination, cancellation: cancellation) + return + } + throw ConversionError.missingTool( + "\(format.displayName) needs ffmpeg. Install it with: brew install ffmpeg" + ) + } + } + + static func videoPreset(for quality: String) -> String { + switch quality { + case "Low": return AVAssetExportPreset960x540 + case "High": return AVAssetExportPreset1920x1080 + default: return AVAssetExportPreset1280x720 + } + } + + // MARK: - Tools + + static func trim( + source: URL, + at destination: URL, + start: Double, + end: Double?, + cancellation: Cancellation? = nil, + progress: ((Double) -> Void)? = nil + ) throws { + let asset = AVURLAsset(url: source) + guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else { + throw ConversionError.failed("Could not prepare a trim for \(source.lastPathComponent).") + } + let duration = CMTimeGetSeconds(asset.duration) + let startTime = max(0, min(start, duration)) + let endTime = min(end ?? duration, duration) + guard endTime > startTime else { + throw ConversionError.failed("The trim range is empty.") + } + session.timeRange = CMTimeRange( + start: CMTime(seconds: startTime, preferredTimescale: 600), + end: CMTime(seconds: endTime, preferredTimescale: 600) + ) + try run(session: session, destination: destination, fileType: destination.pathExtension == "mov" ? .mov : .mp4, cancellation: cancellation, progress: progress) + } + + /// Rebuilds the file with the audio track dropped. + static func mute( + source: URL, + at destination: URL, + cancellation: Cancellation? = nil, + progress: ((Double) -> Void)? = nil + ) throws { + let asset = AVURLAsset(url: source) + let composition = AVMutableComposition() + var inserted = false + for track in asset.tracks where track.mediaType == .video { + guard let target = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else { continue } + try target.insertTimeRange(CMTimeRange(start: .zero, duration: asset.duration), of: track, at: .zero) + target.preferredTransform = track.preferredTransform + inserted = true + } + guard inserted else { throw ConversionError.failed("No video track to keep.") } + try exportComposition(composition, at: destination, cancellation: cancellation, progress: progress) + } + + static func changeSpeed( + source: URL, + at destination: URL, + rate: Double, + cancellation: Cancellation? = nil, + progress: ((Double) -> Void)? = nil + ) throws { + guard rate > 0 else { throw ConversionError.failed("Speed must be greater than zero.") } + let asset = AVURLAsset(url: source) + let composition = AVMutableComposition() + let duration = asset.duration + for track in asset.tracks { + guard track.mediaType == .video || track.mediaType == .audio else { continue } + guard let target = composition.addMutableTrack(withMediaType: track.mediaType, preferredTrackID: kCMPersistentTrackID_Invalid) else { continue } + try target.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: track, at: .zero) + if track.mediaType == .video { + target.preferredTransform = track.preferredTransform + } else { + target.scaleTimeRange(CMTimeRange(start: .zero, duration: duration), toDuration: CMTimeMultiplyByFloat64(duration, multiplier: 1 / rate)) + } + } + for track in composition.tracks where track.mediaType == .video { + track.scaleTimeRange(CMTimeRange(start: .zero, duration: duration), toDuration: CMTimeMultiplyByFloat64(duration, multiplier: 1 / rate)) + } + try exportComposition(composition, at: destination, cancellation: cancellation, progress: progress, audioPitch: .varispeed) + } + + static func resize( + source: URL, + at destination: URL, + width: Int, + cancellation: Cancellation? = nil, + progress: ((Double) -> Void)? = nil + ) throws { + let asset = AVURLAsset(url: source) + guard let track = asset.tracks(withMediaType: .video).first else { + throw ConversionError.failed("No video track to resize.") + } + let natural = track.naturalSize.applying(track.preferredTransform) + let sourceSize = CGSize(width: abs(natural.width), height: abs(natural.height)) + guard sourceSize.width > 0 else { throw ConversionError.failed("Unknown video size.") } + let scale = CGFloat(width) / sourceSize.width + let renderSize = CGSize(width: CGFloat(width).rounded(), height: (sourceSize.height * scale).rounded()) + + let composition = AVMutableComposition() + guard let videoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else { + throw ConversionError.failed("Could not build the resize composition.") + } + let range = CMTimeRange(start: .zero, duration: asset.duration) + try videoTrack.insertTimeRange(range, of: track, at: .zero) + if let audio = asset.tracks(withMediaType: .audio).first, + let audioTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) { + try audioTrack.insertTimeRange(range, of: audio, at: .zero) + } + + let instruction = AVMutableVideoCompositionInstruction() + instruction.timeRange = range + let layer = AVMutableVideoCompositionLayerInstruction(assetTrack: videoTrack) + let transform = track.preferredTransform.concatenating(CGAffineTransform(scaleX: scale, y: scale)) + layer.setTransform(transform, at: .zero) + instruction.layerInstructions = [layer] + + let videoComposition = AVMutableVideoComposition() + videoComposition.renderSize = renderSize + videoComposition.frameDuration = CMTime(value: 1, timescale: 30) + videoComposition.instructions = [instruction] + + try exportComposition(composition, at: destination, cancellation: cancellation, progress: progress, videoComposition: videoComposition) + } + + static func snapshot( + source: URL, + at destination: URL, + time: Double, + cancellation: Cancellation? = nil + ) throws { + let asset = AVURLAsset(url: source) + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + generator.requestedTimeToleranceBefore = .zero + generator.requestedTimeToleranceAfter = .zero + try cancellation?.check() + let image = try generator.copyCGImage(at: CMTime(seconds: time, preferredTimescale: 600), actualTime: nil) + try Raster.write(image, to: destination, format: .png, quality: 1, metadata: nil) + } + + /// Frame-by-frame GIF. Long clips are capped so the result stays small. + static func makeGIF( + source: URL, + at destination: URL, + fps: Int, + maxWidth: Int, + maxDuration: Double = 20, + cancellation: Cancellation? = nil, + progress: ((Double) -> Void)? = nil + ) throws { + let asset = AVURLAsset(url: source) + let duration = min(CMTimeGetSeconds(asset.duration), maxDuration) + guard duration > 0 else { throw ConversionError.failed("Nothing to animate.") } + + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + generator.maximumSize = CGSize(width: maxWidth, height: maxWidth) + generator.requestedTimeToleranceBefore = CMTime(seconds: 0.05, preferredTimescale: 600) + generator.requestedTimeToleranceAfter = CMTime(seconds: 0.05, preferredTimescale: 600) + + guard let output = CGImageDestinationCreateWithURL(destination as CFURL, "com.compuserve.gif" as CFString, 0, nil) else { + throw ConversionError.failed("Could not create \(destination.lastPathComponent).") + } + CGImageDestinationSetProperties(output, [ + kCGImagePropertyGIFDictionary: [kCGImagePropertyGIFLoopCount: 0] + ] as CFDictionary) + + let frameCount = max(1, Int(duration * Double(fps))) + let frameDuration = 1.0 / Double(fps) + for index in 0.. Void)?, + audioPitch: AVAudioTimePitchAlgorithm? = nil, + videoComposition: AVVideoComposition? = nil + ) throws { + let fileType: AVFileType = destination.pathExtension == "mov" + ? .mov + : (destination.pathExtension == "m4a" ? .m4a : .mp4) + guard let session = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetHighestQuality) else { + throw ConversionError.failed("Could not prepare the export.") + } + if let audioPitch { session.audioTimePitchAlgorithm = audioPitch } + if let videoComposition { session.videoComposition = videoComposition } + try run(session: session, destination: destination, fileType: fileType, cancellation: cancellation, progress: progress) + } + + private static func exportWithSession( + source: URL, + destination: URL, + fileType: AVFileType, + preset: String, + cancellation: Cancellation?, + progress: ((Double) -> Void)? + ) throws { + let asset = AVURLAsset(url: source) + guard let session = AVAssetExportSession(asset: asset, presetName: preset) else { + throw ConversionError.failed("This file cannot be exported with the \(preset) preset.") + } + try run(session: session, destination: destination, fileType: fileType, cancellation: cancellation, progress: progress) + } + + private final class ErrorBox: @unchecked Sendable { + private let lock = NSLock() + private var value: Error? + func set(_ error: Error) { lock.lock(); value = error; lock.unlock() } + func get() -> Error? { lock.lock(); defer { lock.unlock() }; return value } + } + + private static func run( + session: AVAssetExportSession, + destination: URL, + fileType: AVFileType, + cancellation: Cancellation?, + progress: ((Double) -> Void)? + ) throws { + if FileManager.default.fileExists(atPath: destination.path) { + try? FileManager.default.removeItem(at: destination) + } + + let box = ErrorBox() + let ready = DispatchSemaphore(value: 0) + + if #available(macOS 15.0, *) { + let session = session + Task.detached(priority: .userInitiated) { + do { + try await session.export(to: destination, as: fileType) + } catch { + box.set(error) + } + ready.signal() + } + } else { + session.outputURL = destination + session.outputFileType = fileType + session.shouldOptimizeForNetworkUse = false + session.exportAsynchronously { ready.signal() } + } + + // Poll for progress and cancellation while the export runs. + while ready.wait(timeout: .now() + 0.2) == .timedOut { + if cancellation?.isCancelled == true { + session.cancelExport() + throw CancellationError() + } + progress?(Double(session.progress)) + } + + try cancellation?.check() + if let error = box.get() { throw ConversionError.failed(error.localizedDescription) } + if session.status == .failed { + throw ConversionError.failed(session.error?.localizedDescription ?? "Export failed.") + } + if session.status == .cancelled { throw CancellationError() } + progress?(1) + } +} diff --git a/Sources/Konverter/Convert/ArchiveConverter.swift b/Sources/Konverter/Convert/ArchiveConverter.swift new file mode 100644 index 0000000..aba6285 --- /dev/null +++ b/Sources/Konverter/Convert/ArchiveConverter.swift @@ -0,0 +1,142 @@ +import Foundation + +/// ZIP/TAR/GZIP via the system's own tools (`ditto`, `tar`, `gzip`), which is +/// both faster and more correct than rolling our own containers. +enum ArchiveConverter { + static func convert( + source: URL, + to format: Format, + at destination: URL, + cancellation: Cancellation? = nil, + progress: ((Double) -> Void)? = nil + ) throws { + try cancellation?.check() + progress?(0.1) + switch format { + case .zip: + try makeZip(source: source, at: destination, cancellation: cancellation) + case .tar: + try Shell.run( + Toolchain.tar, + ["-cf", destination.path, "-C", source.deletingLastPathComponent().path, source.lastPathComponent], + cancellation: cancellation + ) + case .gzip: + try makeGzip(source: source, at: destination, cancellation: cancellation) + default: + throw ConversionError.unsupported("\(format.displayName) is not an archive format Konverter writes.") + } + progress?(1) + } + + private static func makeZip(source: URL, at destination: URL, cancellation: Cancellation?) throws { + var arguments = ["-c", "-k", "--sequesterRsrc"] + if source.hasDirectoryPath || isDirectory(source) { + arguments.append("--keepParent") + } + arguments += [source.path, destination.path] + try Shell.run(Toolchain.ditto, arguments, cancellation: cancellation) + } + + private static func makeGzip(source: URL, at destination: URL, cancellation: Cancellation?) throws { + // gzip only compresses a single stream, so a folder is tarred first and + // the pair is squeezed into one .gz — the classic .tar.gz shape. + if isDirectory(source) { + let staging = FileManager.default.temporaryDirectory + .appendingPathComponent("konverter-\(UUID().uuidString).tar") + defer { try? FileManager.default.removeItem(at: staging) } + try Shell.run( + Toolchain.tar, + ["-cf", staging.path, "-C", source.deletingLastPathComponent().path, source.lastPathComponent], + cancellation: cancellation + ) + let data = try Shell.runData(Toolchain.gzip, ["-n", "-9", "-c", staging.path], cancellation: cancellation) + try data.write(to: destination) + } else { + let data = try Shell.runData(Toolchain.gzip, ["-n", "-9", "-c", source.path], cancellation: cancellation) + try data.write(to: destination) + } + } + + /// Expands an archive into `directory`, returning the folder that callers + /// should reveal. + @discardableResult + static func extract( + source: URL, + into directory: URL, + cancellation: Cancellation? = nil, + progress: ((Double) -> Void)? = nil + ) throws -> URL { + try cancellation?.check() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + progress?(0.2) + + let ext = source.pathExtension.lowercased() + switch ext { + case "zip": + try Shell.run(Toolchain.ditto, ["-x", "-k", source.path, directory.path], cancellation: cancellation) + case "tar", "tgz": + try Shell.run(Toolchain.tar, ["-xf", source.path, "-C", directory.path], cancellation: cancellation) + case "gz": + let inner = source.deletingPathExtension().lastPathComponent + if inner.lowercased().hasSuffix(".tar") { + try Shell.run(Toolchain.tar, ["-xzf", source.path, "-C", directory.path], cancellation: cancellation) + } else { + let data = try Shell.runData(Toolchain.gunzip, ["-c", source.path], cancellation: cancellation) + try data.write(to: directory.appendingPathComponent(inner)) + } + default: + try extractWithLibarchive(source: source, into: directory, cancellation: cancellation) + } + progress?(1) + return directory + } + + /// RAR/7z/BZip2/XZ: libarchive (bsdtar) reads most of them; `unar` is used + /// when present because it handles RAR5 and encrypted archives better. + private static func extractWithLibarchive(source: URL, into directory: URL, cancellation: Cancellation?) throws { + if let unar = Shell.find("unar", extraPaths: ["/opt/homebrew/bin", "/usr/local/bin"]) { + try Shell.run(unar, ["-quiet", "-force-overwrite", "-output-directory", directory.path, source.path], cancellation: cancellation) + return + } + do { + try Shell.run(Toolchain.tar, ["-xf", source.path, "-C", directory.path], cancellation: cancellation) + return + } catch { + throw ConversionError.missingTool( + "Konverter cannot open .\(source.pathExtension) archives. Install unar (brew install unar) and try again." + ) + } + } + + static func list(_ source: URL) throws -> String { + switch source.pathExtension.lowercased() { + case "zip": + let result = try Shell.run(Toolchain.unzip, ["-l", source.path]) + return result.stdout + case "tar", "tgz": + return try Shell.run(Toolchain.tar, ["-tf", source.path]).stdout + case "gz": + let size = (try? FileManager.default.attributesOfItem(atPath: source.path)[.size] as? Int) ?? 0 + return """ + GZIP stream + Compressed size: \(ByteCountFormatter.string(fromByteCount: Int64(size ?? 0), countStyle: .file)) + Inner name: \(source.deletingPathExtension().lastPathComponent) + """ + default: + let result = try? Shell.run(Toolchain.tar, ["-tf", source.path]) + guard let stdout = result?.stdout else { + throw ConversionError.missingTool("Cannot list .\(source.pathExtension) archives without unar.") + } + return stdout + } + } + + static func isArchive(_ url: URL) -> Bool { + FileKind.category(of: url) == .archive + } + + private static func isDirectory(_ url: URL) -> Bool { + (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true + } +} diff --git a/Sources/Konverter/Convert/Converter.swift b/Sources/Konverter/Convert/Converter.swift new file mode 100644 index 0000000..bc7ebad --- /dev/null +++ b/Sources/Konverter/Convert/Converter.swift @@ -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 + } +} diff --git a/Sources/Konverter/Convert/DocumentConverter.swift b/Sources/Konverter/Convert/DocumentConverter.swift new file mode 100644 index 0000000..9b2a4bc --- /dev/null +++ b/Sources/Konverter/Convert/DocumentConverter.swift @@ -0,0 +1,113 @@ +import AppKit +import Foundation +import PDFKit + +/// Document conversions. `textutil` handles the Office formats; Markdown and +/// EPUB are ours; PDF output lays text out with CoreText unless LibreOffice is +/// available for pixel-perfect fidelity. +enum DocumentConverter { + static func convert( + source: URL, + to format: Format, + at destination: URL, + cancellation: Cancellation? = nil, + progress: ((Double) -> Void)? = nil + ) throws { + try cancellation?.check() + let sourceExtension = FileKind.canonicalExtension(source.pathExtension) + + switch format { + case .txt: + guard let text = TextDocument.plainText(from: source) else { + throw ConversionError.unreadable(source.lastPathComponent) + } + try TextDocument.write(text, to: destination) + + case .md: + if sourceExtension == "md" || sourceExtension == "txt" { + let text = TextDocument.plainText(from: source) ?? "" + try TextDocument.write(text, to: destination) + } else { + let html = try TextDocument.html(from: source) + try TextDocument.write(Markdown.markdown(fromHTML: html), to: destination) + } + + case .html: + try TextDocument.write(try TextDocument.html(from: source), to: destination) + + case .pdf: + try makePDF(from: source, at: destination, cancellation: cancellation, progress: progress) + + case .epub: + let title = source.deletingPathExtension().lastPathComponent + let html = try TextDocument.html(from: source) + let chapterTitle = title + try EbookConverter.write( + title: title, + author: "Konverter", + chapters: [(chapterTitle, html)], + at: destination + ) + + case .docx, .doc, .rtf, .odt: + if sourceExtension == "pdf" || sourceExtension == "epub" { + // No importer for these: start from extracted text/HTML. + let html = try TextDocument.html(from: source) + try TextDocument.textutilConvert(sourceHTML: html, to: format, at: destination, cancellation: cancellation) + } else if sourceExtension == "md" { + let html = try TextDocument.html(from: source) + try TextDocument.textutilConvert(sourceHTML: html, to: format, at: destination, cancellation: cancellation) + } else { + try TextDocument.textutilConvert(source, to: format, at: destination, cancellation: cancellation) + } + + case .pptx, .xlsx, .ods, .pages: + try Office.convert(source, to: format, at: destination, cancellation: cancellation) + + default: + throw ConversionError.unsupported("Documents cannot become \(format.displayName).") + } + } + + private static func makePDF( + from source: URL, + at destination: URL, + cancellation: Cancellation?, + progress: ((Double) -> Void)? + ) throws { + let sourceExtension = FileKind.canonicalExtension(source.pathExtension) + if sourceExtension == "pdf" { + // "PDF → PDF" means a normalised, linearised copy. + if let document = PDFDocument(url: source) { + guard document.write(to: destination) else { + throw ConversionError.failed("Could not rewrite the PDF.") + } + return + } + } + + if Toolchain.hasLibreOffice, Settings.shared.useExternalTools { + do { + try Office.convert(source, to: .pdf, at: destination, cancellation: cancellation) + return + } catch is CancellationError { + throw CancellationError() + } catch { + // Fall through to the CoreText layout rather than failing the + // whole job because LibreOffice refused one file. + } + } + + if let attributed = TextDocument.attributedString(from: source) { + try TextDocument.writePDF(attributed, to: destination) + progress?(1) + return + } + if let text = TextDocument.plainText(from: source), !text.isEmpty { + try TextDocument.writePDF(fromText: text, to: destination) + progress?(1) + return + } + throw ConversionError.failed("Could not lay out \(source.lastPathComponent) as a PDF.") + } +} diff --git a/Sources/Konverter/Convert/EbookConverter.swift b/Sources/Konverter/Convert/EbookConverter.swift new file mode 100644 index 0000000..5c56403 --- /dev/null +++ b/Sources/Konverter/Convert/EbookConverter.swift @@ -0,0 +1,230 @@ +import Foundation + +/// EPUB in and out. Reading follows `META-INF/container.xml` → OPF → spine so +/// chapters come out in reading order; writing produces an EPUB 3 package +/// whose `mimetype` entry is stored uncompressed, as the spec requires. +enum EbookConverter { + static func convert( + source: URL, + to format: Format, + at destination: URL, + cancellation: Cancellation? = nil, + progress: ((Double) -> Void)? = nil + ) throws { + try cancellation?.check() + switch format { + case .txt: + try TextDocument.write(try plainText(source), to: destination) + case .html: + try TextDocument.write(try html(source), to: destination) + case .md: + try TextDocument.write(Markdown.markdown(fromHTML: try html(source)), to: destination) + case .pdf: + try TextDocument.writePDF(fromText: try plainText(source), to: destination) + case .epub: + let title = source.deletingPathExtension().lastPathComponent + try write( + title: title, + author: "Konverter", + chapters: [(title, try html(source))], + at: destination + ) + default: + throw ConversionError.unsupported("EPUB cannot become \(format.displayName).") + } + progress?(1) + } + + // MARK: - Reading + + static func plainText(_ url: URL) throws -> String { + let chapters = try chapters(url) + return chapters + .map { Markdown.decodeEntities(Markdown.stripTags($0.html)).trimmed } + .filter { !$0.isEmpty } + .joined(separator: "\n\n") + } + + static func html(_ url: URL) throws -> String { + let chapters = try chapters(url) + let title = url.deletingPathExtension().lastPathComponent + let body = chapters.map { chapter in + "
" + (chapter.title.isEmpty ? "" : "

\(Markdown.escape(chapter.title))

") + chapter.body + "
" + }.joined(separator: "\n") + return """ + \(Markdown.escape(title)) + + \(body) + """ + } + + private struct Chapter { + var title: String + var html: String + + var body: String { + // Keep the inner markup, drop the document scaffolding. + var inner = html + if let bodyRange = inner.range(of: "(?is)]*>", options: .regularExpression), + let endRange = inner.range(of: "(?is)", options: .regularExpression) { + inner = String(inner[bodyRange.upperBound..]*>|"#, in: inner) { _ in "" } + } + } + + private static func chapters(_ url: URL) throws -> [Chapter] { + let staging = try Unzip.expand(url) + defer { try? FileManager.default.removeItem(at: staging) } + + let opf = try packageDocument(in: staging) + let ordered = opf.map { spineOrder(opfPath: $0, staging: staging) } ?? [] + let files = ordered.isEmpty ? looseContentFiles(in: staging) : ordered + + var chapters: [Chapter] = [] + for file in files { + guard let content = try? String(contentsOf: file, encoding: .utf8) else { continue } + if content.trimmed.isEmpty { continue } + let title = Markdown.replace(#"(?is)^.*?]*>(.*?).*$"#, in: content) { $0[1] }.trimmed + chapters.append(Chapter(title: title.count < 120 ? title : "", html: content)) + } + guard !chapters.isEmpty else { throw ConversionError.failed("No chapters found in the EPUB.") } + return chapters + } + + private static func packageDocument(in staging: URL) throws -> URL? { + let container = staging.appendingPathComponent("META-INF/container.xml") + guard let data = try? Data(contentsOf: container), + let text = String(data: data, encoding: .utf8), + let match = text.range(of: #"full-path="([^"]+)""#, options: .regularExpression) else { + return nil + } + let path = String(text[match]).replacingOccurrences(of: "full-path=\"", with: "").replacingOccurrences(of: "\"", with: "") + return staging.appendingPathComponent(path) + } + + private static func spineOrder(opfPath: URL, staging: URL) -> [URL] { + guard let delegate = OPFDelegate(path: opfPath) else { return [] } + let parser = XMLParser(data: (try? Data(contentsOf: opfPath)) ?? Data()) + parser.delegate = delegate + parser.parse() + let base = opfPath.deletingLastPathComponent() + return delegate.spine + .compactMap { delegate.manifest[$0] } + .map { base.appendingPathComponent($0) } + .filter { FileManager.default.fileExists(atPath: $0.path) } + } + + private static func looseContentFiles(in staging: URL) -> [URL] { + let enumerator = FileManager.default.enumerator(at: staging, includingPropertiesForKeys: nil) + var files: [URL] = [] + while let file = enumerator?.nextObject() as? URL { + let ext = file.pathExtension.lowercased() + if ext == "xhtml" || ext == "html" || ext == "htm" { files.append(file) } + } + return files.sorted { $0.path < $1.path } + } + + private final class OPFDelegate: NSObject, XMLParserDelegate { + var manifest: [String: String] = [:] + var spine: [String] = [] + private let basePath: String + + init?(path: URL) { + basePath = path.path + super.init() + } + + func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?, attributes: [String: String]) { + switch elementName { + case "item": + if let id = attributes["id"], let href = attributes["href"] { + let type = attributes["media-type"] ?? "" + if type.contains("xhtml") || href.hasSuffix(".html") || href.hasSuffix(".xhtml") || href.hasSuffix(".htm") { + manifest[id] = href.removingPercentEncoding ?? href + } + } + case "itemref": + if let idref = attributes["idref"] { spine.append(idref) } + default: + break + } + } + } + + // MARK: - Writing + + static func write( + title: String, + author: String, + chapters: [(String, String)], + at destination: URL + ) throws { + guard !chapters.isEmpty else { throw ConversionError.failed("An EPUB needs at least one chapter.") } + + let staging = FileManager.default.temporaryDirectory + .appendingPathComponent("konverter-epub-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: staging) } + try FileManager.default.createDirectory(at: staging.appendingPathComponent("META-INF"), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: staging.appendingPathComponent("OEBPS"), withIntermediateDirectories: true) + + let identifier = "urn:uuid:" + UUID().uuidString.lowercased() + try "application/epub+zip".write(to: staging.appendingPathComponent("mimetype"), atomically: true, encoding: .utf8) + + try """ + + + + + """.write(to: staging.appendingPathComponent("META-INF/container.xml"), atomically: true, encoding: .utf8) + + var manifest = "" + var spine = "" + var navItems = "" + for (index, chapter) in chapters.enumerated() { + let fileName = "chapter\(index + 1).xhtml" + let chapterTitle = chapter.0.isEmpty ? "Chapter \(index + 1)" : chapter.0 + let body = chapter.1.contains("\(Markdown.escape(chapterTitle))\(chapter.1)" + let xhtml = body.contains("xmlns=\"http://www.w3.org/1999/xhtml\"") + ? body + : body.replacingOccurrences(of: "\n" + spine += "\n" + navItems += "
  • \(Markdown.escape(chapterTitle))
  • \n" + } + + try """ + + + + \(identifier) + \(Markdown.escape(title)) + \(Markdown.escape(author)) + en + \(ISO8601DateFormatter().string(from: Date())) + + + \(manifest) + + + \(spine) + + """.write(to: staging.appendingPathComponent("OEBPS/content.opf"), atomically: true, encoding: .utf8) + + try """ + + + \(Markdown.escape(title)) + + """.write(to: staging.appendingPathComponent("OEBPS/nav.xhtml"), atomically: true, encoding: .utf8) + + try? FileManager.default.removeItem(at: destination) + // mimetype must be the first entry and must not be compressed. + try Shell.run(Toolchain.zip, ["-X0", destination.path, "mimetype"], currentDirectory: staging) + try Shell.run(Toolchain.zip, ["-Xr9", destination.path, "META-INF", "OEBPS"], currentDirectory: staging) + } +} diff --git a/Sources/Konverter/Convert/FFmpeg.swift b/Sources/Konverter/Convert/FFmpeg.swift new file mode 100644 index 0000000..80c7c8e --- /dev/null +++ b/Sources/Konverter/Convert/FFmpeg.swift @@ -0,0 +1,156 @@ +import Foundation + +/// ffmpeg back end. Used for containers and codecs Apple's frameworks cannot +/// write. Progress is parsed from `-progress pipe:1`. +enum FFmpeg { + static func convert( + source: URL, + to format: Format, + at destination: URL, + bitrate: String = "128", + quality: String = "Medium", + cancellation: Cancellation? = nil, + progress: ((Double) -> Void)? = nil + ) throws { + guard let ffmpeg = Toolchain.ffmpeg, Settings.shared.useExternalTools else { + throw ConversionError.missingTool("\(format.displayName) requires ffmpeg — install it with: brew install ffmpeg") + } + + var arguments = ["-y", "-hide_banner", "-loglevel", "error", "-i", source.path] + arguments += encodingArguments(for: format, bitrate: bitrate, quality: quality) + arguments += ["-progress", "pipe:1", "-nostats"] + arguments.append(destination.path) + + let duration = AVConverter.duration(source) ?? 0 + try Shell.runReportingProgress(ffmpeg, arguments, cancellation: cancellation) { _, line in + guard let progress, duration > 0 else { return } + if let range = line.range(of: "out_time_us="), + let microseconds = Double(line[range.upperBound...].trimmingCharacters(in: .whitespaces)) { + progress(min(1, microseconds / 1_000_000 / duration)) + } + } + progress?(1) + } + + /// Down-mixes any media to audio. + static func extractAudio( + source: URL, + to format: Format, + at destination: URL, + bitrate: String = "192", + cancellation: Cancellation? = nil, + progress: ((Double) -> Void)? = nil + ) throws { + try convert(source: source, to: format, at: destination, bitrate: bitrate, cancellation: cancellation, progress: progress) + } + + /// Progress-reporting pass-through for tools (trim, speed, gain, …). + static func filter( + source: URL, + at destination: URL, + arguments: [String], + cancellation: Cancellation? = nil, + progress: ((Double) -> Void)? = nil + ) throws { + guard let ffmpeg = Toolchain.ffmpeg, Settings.shared.useExternalTools else { + throw ConversionError.missingTool("This tool requires ffmpeg — install it with: brew install ffmpeg") + } + var full = ["-y", "-hide_banner", "-loglevel", "error", "-i", source.path] + full += arguments + full += ["-progress", "pipe:1", "-nostats", destination.path] + + let duration = AVConverter.duration(source) ?? 0 + try Shell.runReportingProgress(ffmpeg, full, cancellation: cancellation) { _, line in + guard let progress, duration > 0 else { return } + if let range = line.range(of: "out_time_us="), + let microseconds = Double(line[range.upperBound...].trimmingCharacters(in: .whitespaces)) { + progress(min(1, microseconds / 1_000_000 / duration)) + } + } + progress?(1) + } + + private static func encodingArguments(for format: Format, bitrate: String, quality: String) -> [String] { + let audioRate = "\(bitrate)k" + switch format { + case .mp3: return ["-vn", "-c:a", "libmp3lame", "-b:a", audioRate] + case .m4a: return ["-vn", "-c:a", "aac", "-b:a", audioRate] + case .wav: return ["-vn", "-c:a", "pcm_s16le"] + case .aiff: return ["-vn", "-c:a", "pcm_s16be"] + case .flac: return ["-vn", "-c:a", "flac"] + case .ogg: return ["-vn", "-c:a", "libvorbis", "-q:a", "5"] + case .opus: return ["-vn", "-c:a", "libopus", "-b:a", audioRate] + case .wma: return ["-vn", "-c:a", "wmav2", "-b:a", audioRate] + case .mkv: return videoArguments(codec: "libx264", quality: quality) + ["-c:a", "aac", "-b:a", audioRate] + case .webm: return ["-c:v", "libvpx-vp9", "-crf", "32", "-b:v", "0", "-c:a", "libopus", "-b:a", audioRate] + case .avi: return ["-c:v", "mpeg4", "-q:v", "5", "-c:a", "libmp3lame", "-b:a", audioRate] + case .wmv: return ["-c:v", "wmv2", "-b:v", "2M", "-c:a", "wmav2", "-b:a", audioRate] + case .mp4, .mov, .m4v, .gif: return videoArguments(codec: "libx264", quality: quality) + ["-c:a", "aac", "-b:a", audioRate] + default: return [] + } + } + + private static func videoArguments(codec: String, quality: String) -> [String] { + let crf: String + switch quality { + case "Low": crf = "30" + case "High": crf = "20" + default: crf = "25" + } + return ["-c:v", codec, "-preset", "medium", "-crf", crf, "-pix_fmt", "yuv420p"] + } +} + +/// CoreAudio's `afconvert`: always present, handles PCM containers and +/// channel remapping without any third-party install. +enum CoreAudio { + static func convert( + source: URL, + to format: Format, + at destination: URL, + cancellation: Cancellation? = nil + ) throws { + guard let fileFormat = coreAudioFileFormat(for: format) else { + throw ConversionError.unsupported("CoreAudio cannot write \(format.displayName).") + } + do { + try Shell.run( + Toolchain.afconvert, + ["-f", fileFormat, "-d", dataFormat(for: fileFormat), source.path, destination.path], + cancellation: cancellation + ) + } catch { + throw ConversionError.failed("CoreAudio could not convert \(source.lastPathComponent): \((error as? LocalizedError)?.errorDescription ?? error.localizedDescription)") + } + } + + static func remix( + source: URL, + at destination: URL, + mono: Bool, + cancellation: Cancellation? = nil + ) throws { + try Shell.run( + Toolchain.afconvert, + ["-f", "caff", "-d", "LEI16", "-c", mono ? "1" : "2", source.path, destination.path], + cancellation: cancellation + ) + } + + private static func coreAudioFileFormat(for format: Format) -> String? { + switch format { + case .wav: return "WAVE" + case .aiff: return "AIFF" + case .m4a: return "m4af" + default: return nil + } + } + + private static func dataFormat(for fileFormat: String) -> String { + switch fileFormat { + case "WAVE": return "LEI16" + case "AIFF": return "BEI16" + default: return "aac" + } + } +} diff --git a/Sources/Konverter/Convert/ImageConverter.swift b/Sources/Konverter/Convert/ImageConverter.swift new file mode 100644 index 0000000..ee52afc --- /dev/null +++ b/Sources/Konverter/Convert/ImageConverter.swift @@ -0,0 +1,328 @@ +import CoreGraphics +import Foundation +import ImageIO +import AppKit +import UniformTypeIdentifiers + +enum ConversionError: LocalizedError { + case unreadable(String) + case unsupported(String) + case failed(String) + case missingTool(String) + + var errorDescription: String? { + switch self { + case .unreadable(let name): return "Could not read \(name)." + case .unsupported(let reason): return reason + case .failed(let reason): return reason + case .missingTool(let reason): return reason + } + } +} + +/// Bitmap plumbing shared by the image converter and the image tools. +enum Raster { + static let encodableTypes: Set = Set( + (CGImageDestinationCopyTypeIdentifiers() as? [String]) ?? [] + ) + + static func canEncode(_ format: Format) -> Bool { + guard let uti = format.uti else { return false } + return encodableTypes.contains(uti) + } + + static func imageSource(at url: URL) -> CGImageSource? { + CGImageSourceCreateWithURL(url as CFURL, [kCGImageSourceShouldCacheImmediately: true] as CFDictionary) + } + + /// Decodes the first frame. Falls back to AppKit for formats ImageIO + /// cannot decode but the system can (SVG, some RAW, exotic TIFFs), and to + /// Quick Look as a last resort. + static func firstImage(at url: URL) -> CGImage? { + if let source = imageSource(at: url), let image = CGImageSourceCreateImageAtIndex(source, 0, nil) { + return image + } + if let image = NSImage(contentsOf: url), + let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) { + return cgImage + } + return quickLookRaster(url) + } + + static func frameCount(at url: URL) -> Int { + guard let source = imageSource(at: url) else { return 1 } + return max(CGImageSourceGetCount(source), 1) + } + + private static func quickLookRaster(_ url: URL) -> CGImage? { + guard let qlmanage = Toolchain.qlmanage else { return nil } + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("konverter-ql-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + _ = try? Shell.run(qlmanage, ["-t", "-s", "2048", "-o", directory.path, url.path]) + guard let produced = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + .first(where: { $0.pathExtension == "png" }) else { return nil } + return NSImage(contentsOf: produced)?.cgImage(forProposedRect: nil, context: nil, hints: nil) + } + + /// Paints the image onto an opaque background, dropping the alpha channel. + /// JPEG cannot store alpha and would otherwise render black. + static func flattened(_ image: CGImage, background: CGColor = CGColor(gray: 1, alpha: 1)) -> CGImage { + let width = image.width + let height = image.height + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + ) else { return image } + context.setFillColor(background) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + return context.makeImage() ?? image + } + + /// Reduces the number of distinct colours in an image. Lossless formats + /// have no quality knob, so a "compress" of a PNG has to trade colour + /// depth for size — which is exactly what this does. `quality` is the + /// 0…1 slider: 1.0 leaves the pixels untouched. + static func posterized(_ image: CGImage, quality: Double) -> CGImage { + let bits = max(1.0, min(8.0, quality * 8.0)) + let levels = min(256, max(2, Int(pow(2.0, bits)))) + guard levels < 256 else { return image } + + let width = image.width + let height = image.height + let bytesPerRow = width * 4 + var buffer = [UInt8](repeating: 0, count: bytesPerRow * height) + let space = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB() + + let drawn: Bool = buffer.withUnsafeMutableBytes { raw -> Bool in + guard let context = CGContext( + data: raw.baseAddress, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: space, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return false } + context.interpolationQuality = .high + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + return true + } + guard drawn else { return image } + + let step = 256 / levels + let half = step / 2 + for index in stride(from: 0, to: buffer.count, by: 4) { + for channel in 0..<3 { + let value = Int(buffer[index + channel]) + buffer[index + channel] = UInt8(min(255, (value / step) * step + half)) + } + } + + let result: CGImage? = buffer.withUnsafeMutableBytes { raw -> CGImage? in + guard let context = CGContext( + data: raw.baseAddress, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: space, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return nil } + return context.makeImage() + } + return result ?? image + } + + /// Bakes an EXIF orientation into the pixels so the result needs no + /// metadata to look right. + static func oriented(_ image: CGImage, orientation: CGImagePropertyOrientation) -> CGImage { + guard orientation != .up else { return image } + let swapsDimensions = orientation == .left || orientation == .right + || orientation == .leftMirrored || orientation == .rightMirrored + let width = swapsDimensions ? image.height : image.width + let height = swapsDimensions ? image.width : image.height + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: image.colorSpace ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return image } + + switch orientation { + case .up: break + case .upMirrored: context.translateBy(x: CGFloat(width), y: 0); context.scaleBy(x: -1, y: 1) + case .down: context.translateBy(x: CGFloat(width), y: CGFloat(height)); context.rotate(by: .pi) + case .downMirrored: context.translateBy(x: 0, y: CGFloat(height)); context.scaleBy(x: 1, y: -1) + case .left: context.translateBy(x: 0, y: CGFloat(height)); context.rotate(by: -.pi / 2) + case .leftMirrored: context.translateBy(x: CGFloat(width), y: CGFloat(height)); context.rotate(by: -.pi / 2); context.scaleBy(x: 1, y: -1) + case .right: context.translateBy(x: CGFloat(width), y: 0); context.rotate(by: .pi / 2) + case .rightMirrored: context.rotate(by: .pi / 2); context.scaleBy(x: 1, y: -1) + @unknown default: break + } + context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + return context.makeImage() ?? image + } + + static func orientation(of source: CGImageSource) -> CGImagePropertyOrientation { + guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let raw = properties[kCGImagePropertyOrientation] as? UInt32, + let orientation = CGImagePropertyOrientation(rawValue: raw) else { return .up } + return orientation + } + + /// Encodes a single image. `metadata == nil` writes a clean file with no + /// EXIF/GPS/XMP at all. + static func write( + _ image: CGImage, + to url: URL, + format: Format, + quality: Double, + metadata: [CFString: Any]? + ) throws { + guard let uti = format.uti else { + throw ConversionError.unsupported("\(format.displayName) is not an image format.") + } + guard canEncode(format) else { + throw ConversionError.unsupported("This Mac cannot write \(format.displayName) images.") + } + + var properties: [CFString: Any] = metadata ?? [kCGImagePropertyOrientation: CGImagePropertyOrientation.up.rawValue] + if format.isLossy { + properties[kCGImageDestinationLossyCompressionQuality] = max(0, min(1, quality)) + } + + guard let destination = CGImageDestinationCreateWithURL(url as CFURL, uti as CFString, 1, nil) else { + throw ConversionError.failed("Could not create \(format.displayName) at \(url.lastPathComponent).") + } + CGImageDestinationAddImage(destination, image, properties as CFDictionary) + guard CGImageDestinationFinalize(destination) else { + throw ConversionError.failed("Writing \(url.lastPathComponent) failed.") + } + } +} + +/// WebP has no encoder in ImageIO on macOS. `cwebp` (from libwebp) is the +/// best path; ffmpeg works when it was built with libwebp. Both are optional. +enum WebPEncoder { + static let cwebp: String? = Shell.find("cwebp", extraPaths: ["/opt/homebrew/bin", "/usr/local/bin"]) + + static let ffmpegHasLibwebp: Bool = { + guard let ffmpeg = Toolchain.ffmpeg else { return false } + let encoders = Shell.output(ffmpeg, ["-hide_banner", "-encoders"]) ?? "" + return encoders.contains("libwebp") + }() + + static var isAvailable: Bool { + Settings.shared.useExternalTools && (cwebp != nil || ffmpegHasLibwebp) + } + + static func encode(_ image: CGImage, to url: URL, quality: Double) throws { + let staging = FileManager.default.temporaryDirectory + .appendingPathComponent("konverter-webp-\(UUID().uuidString).png") + defer { try? FileManager.default.removeItem(at: staging) } + try Raster.write(image, to: staging, format: .png, quality: 1, metadata: nil) + + if let cwebp { + try Shell.run(cwebp, ["-quiet", "-q", String(Int(quality * 100)), staging.path, "-o", url.path]) + return + } + if let ffmpeg = Toolchain.ffmpeg, ffmpegHasLibwebp { + try Shell.run( + ffmpeg, + ["-y", "-hide_banner", "-loglevel", "error", "-i", staging.path, + "-frames:v", "1", "-c:v", "libwebp", "-quality", String(Int(quality * 100)), url.path] + ) + return + } + throw ConversionError.unsupported("WebP needs cwebp — brew install webp") + } +} + +/// Format → format for still and animated bitmaps. +enum ImageConverter { + static func convert( + source: URL, + to format: Format, + at destination: URL, + quality: Double = 0.9, + keepMetadata: Bool = true, + cancellation: Cancellation? = nil + ) throws { + try cancellation?.check() + + if format == .pdf { + try PDFConverter.imageToPDF(source, at: destination, cancellation: cancellation) + return + } + + // WebP has no ImageIO encoder on macOS: cwebp or ffmpeg fills in. + if !Raster.canEncode(format) { + if format == .webp, let image = Raster.firstImage(at: source), WebPEncoder.isAvailable { + try WebPEncoder.encode(image, to: destination, quality: quality) + return + } + throw ConversionError.unsupported("This Mac cannot write \(format.displayName) images.") + } + + guard let imageSource = Raster.imageSource(at: source) else { + // Formats ImageIO cannot even open (SVG, PSD …): rasterise once. + guard let image = Raster.firstImage(at: source) else { + throw ConversionError.unreadable(source.lastPathComponent) + } + let prepared = format == .jpg ? Raster.flattened(image) : image + try Raster.write(prepared, to: destination, format: format, quality: quality, metadata: nil) + return + } + + let frameCount = max(CGImageSourceGetCount(imageSource), 1) + guard let uti = format.uti, Raster.canEncode(format) else { + throw ConversionError.unsupported("This Mac cannot write \(format.displayName) images.") + } + + var properties: [CFString: Any] = [:] + if keepMetadata, let sourceProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any] { + properties = sourceProperties + } + // JPEG has no alpha channel: re-encode through a white background. + let needsFlatten = format == .jpg + + guard let destinationRef = CGImageDestinationCreateWithURL( + destination as CFURL, + uti as CFString, + frameCount, + nil + ) else { + throw ConversionError.failed("Could not create \(format.displayName) at \(destination.lastPathComponent).") + } + + if format.isLossy { + properties[kCGImageDestinationLossyCompressionQuality] = quality + } + + for index in 0.. ratio { + let width = CGFloat(image.height) * ratio + rect = CGRect(x: (CGFloat(image.width) - width) / 2, y: 0, width: width, height: CGFloat(image.height)) + } else { + let height = CGFloat(image.width) / ratio + rect = CGRect(x: 0, y: (CGFloat(image.height) - height) / 2, width: CGFloat(image.width), height: height) + } + } + if inset > 0 { + rect = rect.insetBy(dx: CGFloat(inset), dy: CGFloat(inset)) + } + rect = rect.integral + guard rect.width > 1, rect.height > 1 else { + throw ConversionError.failed("The crop values remove the whole image.") + } + guard let cropped = image.cropping(to: rect) else { + throw ConversionError.failed("Cropping failed.") + } + try write(cropped, to: destination, matching: source) + } + + private static func aspectRatio(_ aspect: String) -> CGFloat? { + let parts = aspect.split(separator: ":").compactMap { Double($0) } + guard parts.count == 2, parts[1] != 0 else { return nil } + return CGFloat(parts[0] / parts[1]) + } + + static func rotate(_ source: URL, at destination: URL, degrees: Int) throws { + guard let image = Raster.firstImage(at: source) else { + throw ConversionError.unreadable(source.lastPathComponent) + } + let radians = CGFloat(degrees) * .pi / 180 + let swaps = degrees % 180 != 0 + let width = swaps ? image.height : image.width + let height = swaps ? image.width : image.height + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: image.colorSpace ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { throw ConversionError.failed("Rotating failed.") } + + context.translateBy(x: CGFloat(width) / 2, y: CGFloat(height) / 2) + context.rotate(by: radians) + context.draw( + image, + in: CGRect( + x: -CGFloat(image.width) / 2, + y: -CGFloat(image.height) / 2, + width: CGFloat(image.width), + height: CGFloat(image.height) + ) + ) + guard let rotated = context.makeImage() else { throw ConversionError.failed("Rotating failed.") } + try write(rotated, to: destination, matching: source) + } + + static func flip(_ source: URL, at destination: URL, horizontal: Bool) throws { + guard let image = Raster.firstImage(at: source) else { + throw ConversionError.unreadable(source.lastPathComponent) + } + guard let context = CGContext( + data: nil, + width: image.width, + height: image.height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: image.colorSpace ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { throw ConversionError.failed("Flipping failed.") } + + if horizontal { + context.translateBy(x: CGFloat(image.width), y: 0) + context.scaleBy(x: -1, y: 1) + } else { + context.translateBy(x: 0, y: CGFloat(image.height)) + context.scaleBy(x: 1, y: -1) + } + context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + guard let flipped = context.makeImage() else { throw ConversionError.failed("Flipping failed.") } + try write(flipped, to: destination, matching: source) + } + + static func grayscale(_ source: URL, at destination: URL) throws { + guard let image = Raster.firstImage(at: source) else { + throw ConversionError.unreadable(source.lastPathComponent) + } + guard let space = CGColorSpace(name: CGColorSpace.linearGray), + let context = CGContext( + data: nil, + width: image.width, + height: image.height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: space, + bitmapInfo: CGImageAlphaInfo.none.rawValue + ) else { throw ConversionError.failed("Grayscale conversion failed.") } + context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + guard let gray = context.makeImage() else { throw ConversionError.failed("Grayscale conversion failed.") } + try write(gray, to: destination, matching: source) + } + + static func stripMetadata(_ source: URL, at destination: URL, format: Format) throws { + guard let image = Raster.firstImage(at: source) else { + throw ConversionError.unreadable(source.lastPathComponent) + } + let prepared = format == .jpg ? Raster.flattened(image) : image + try Raster.write(prepared, to: destination, format: format, quality: 0.95, metadata: nil) + } + + static func watermark( + _ source: URL, + at destination: URL, + text: String, + position: String, + opacity: Double, + sizePercent: Double + ) throws { + guard let image = Raster.firstImage(at: source) else { + throw ConversionError.unreadable(source.lastPathComponent) + } + let width = image.width + let height = image.height + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: image.colorSpace ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { throw ConversionError.failed("Watermarking failed.") } + + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + + let fontSize = max(8, CGFloat(sizePercent) / 100 * CGFloat(width)) + let font = NSFont.systemFont(ofSize: fontSize, weight: .semibold) + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: NSColor.white.withAlphaComponent(opacity / 100) + ] + let attributed = NSAttributedString(string: text, attributes: attributes) + let line = CTLineCreateWithAttributedString(attributed) + let bounds = CTLineGetBoundsWithOptions(line, .useOpticalBounds) + let margin = fontSize * 0.6 + + let origin: CGPoint + switch position { + case "Bottom Left": origin = CGPoint(x: margin, y: margin) + case "Top Right": origin = CGPoint(x: CGFloat(width) - bounds.width - margin, y: CGFloat(height) - bounds.height - margin) + case "Top Left": origin = CGPoint(x: margin, y: CGFloat(height) - bounds.height - margin) + case "Center": origin = CGPoint(x: (CGFloat(width) - bounds.width) / 2, y: (CGFloat(height) - bounds.height) / 2) + default: origin = CGPoint(x: CGFloat(width) - bounds.width - margin, y: margin) + } + + // A soft shadow keeps light text readable over light photos. + context.setShadow(offset: CGSize(width: 0, height: -fontSize * 0.05), blur: fontSize * 0.2, color: CGColor(gray: 0, alpha: opacity / 140)) + context.textPosition = origin + CTLineDraw(line, context) + + guard let stamped = context.makeImage() else { throw ConversionError.failed("Watermarking failed.") } + try write(stamped, to: destination, matching: source) + } + + /// QR payloads, via Vision so it also reads the newer formats CIDetector + /// misses. + static func readQR(_ source: URL) -> String? { + guard let image = Raster.firstImage(at: source) else { return nil } + let request = VNDetectBarcodesRequest() + request.symbologies = [.qr, .aztec, .pdf417, .code128, .code39, .ean13, .dataMatrix] + let handler = VNImageRequestHandler(cgImage: image, options: [:]) + do { + try handler.perform([request]) + } catch { + return nil + } + let payloads = (request.results ?? []).compactMap(\.payloadStringValue) + return payloads.isEmpty ? nil : payloads.joined(separator: "\n") + } + + // MARK: - Helpers + + private static func draw(_ image: CGImage, size: CGSize) -> CGImage? { + guard let context = CGContext( + data: nil, + width: Int(size.width), + height: Int(size.height), + bitsPerComponent: 8, + bytesPerRow: 0, + space: image.colorSpace ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return nil } + context.interpolationQuality = .high + context.draw(image, in: CGRect(origin: .zero, size: size)) + return context.makeImage() + } + + private static func write(_ image: CGImage, to destination: URL, matching source: URL) throws { + let format = FileKind.format(of: destination) ?? FileKind.format(of: source) ?? .png + let prepared = format == .jpg ? Raster.flattened(image) : image + try Raster.write(prepared, to: destination, format: format, quality: 0.92, metadata: nil) + } +} diff --git a/Sources/Konverter/Convert/Markdown.swift b/Sources/Konverter/Convert/Markdown.swift new file mode 100644 index 0000000..c310c2f --- /dev/null +++ b/Sources/Konverter/Convert/Markdown.swift @@ -0,0 +1,282 @@ +import AppKit +import Foundation + +/// A deliberately small Markdown renderer/parser. Enough for the formats the +/// app offers (headings, emphasis, code, lists, quotes, links, rules, images) +/// without pulling in a dependency. +enum Markdown { + // MARK: - Markdown → HTML + + static func html(from markdown: String, title: String = "Document") -> String { + let lines = markdown.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n") + var body: [String] = [] + var paragraph: [String] = [] + var listType: String? + var inCode = false + var codeLines: [String] = [] + + func flushParagraph() { + guard !paragraph.isEmpty else { return } + body.append("

    " + inline(paragraph.joined(separator: " ")) + "

    ") + paragraph = [] + } + + func closeList() { + if let type = listType { + body.append("") + listType = nil + } + } + + for rawLine in lines { + let line = rawLine + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.hasPrefix("```") { + if inCode { + body.append("
    " + escape(codeLines.joined(separator: "\n")) + "
    ") + codeLines = [] + inCode = false + } else { + flushParagraph() + closeList() + inCode = true + } + continue + } + if inCode { + codeLines.append(line) + continue + } + + if trimmed.isEmpty { + flushParagraph() + closeList() + continue + } + + if let heading = headingLevel(trimmed) { + flushParagraph() + closeList() + let text = trimmed.dropFirst(heading).trimmingCharacters(in: .whitespaces) + body.append("" + inline(text) + "") + continue + } + + if trimmed == "---" || trimmed == "***" || trimmed == "___" { + flushParagraph() + closeList() + body.append("
    ") + continue + } + + if trimmed.hasPrefix(">") { + flushParagraph() + closeList() + let text = trimmed.dropFirst().trimmingCharacters(in: .whitespaces) + body.append("

    " + inline(text) + "

    ") + continue + } + + if let item = listItem(trimmed) { + flushParagraph() + if listType != item.type { + closeList() + listType = item.type + body.append("<\(item.type)>") + } + body.append("
  • " + inline(item.text) + "
  • ") + continue + } + + if trimmed.hasPrefix("|") && trimmed.hasSuffix("|") { + flushParagraph() + closeList() + let cells = trimmed.dropFirst().dropLast().split(separator: "|").map { + inline($0.trimmingCharacters(in: .whitespaces)) + } + if !cells.allSatisfy({ $0.allSatisfy { $0 == "-" || $0 == ":" } }) { + body.append("" + cells.map { "\($0)" }.joined() + "") + } + continue + } + + paragraph.append(trimmed) + } + + if inCode { + body.append("
    " + escape(codeLines.joined(separator: "\n")) + "
    ") + } + flushParagraph() + closeList() + + return """ + + \(escape(title)) + + \(body.joined(separator: "\n")) + + """ + } + + private static func headingLevel(_ line: String) -> Int? { + let hashes = line.prefix { $0 == "#" } + guard (1...6).contains(hashes.count) else { return nil } + let rest = line.dropFirst(hashes.count) + guard rest.isEmpty || rest.first == " " else { return nil } + return hashes.count + } + + private static func listItem(_ line: String) -> (type: String, text: String)? { + for marker in ["- ", "* ", "+ "] where line.hasPrefix(marker) { + return ("ul", String(line.dropFirst(2))) + } + let digits = line.prefix { $0.isNumber } + if !digits.isEmpty, line.dropFirst(digits.count).hasPrefix(". ") { + return ("ol", String(line.dropFirst(digits.count + 1))) + } + return nil + } + + /// Inline spans: bold, italic, strikethrough, code, links, images. + static func inline(_ raw: String) -> String { + var text = escape(raw) + text = replace(#"!\[([^\]]*)\]\(([^)]+)\)"#, in: text) { match in + "\"\(match[1])\"/" + } + text = replace(#"\[([^\]]+)\]\(([^)]+)\)"#, in: text) { match in + "\(match[1])" + } + text = replace(#"`([^`]+)`"#, in: text) { "\($0[1])" } + text = replace(#"\*\*([^*]+)\*\*"#, in: text) { "\($0[1])" } + text = replace(#"__([^_]+)__"#, in: text) { "\($0[1])" } + text = replace(#"(?\($0[1])" } + text = replace(#"(?\($0[1])" } + text = replace(#"~~([^~]+)~~"#, in: text) { "\($0[1])" } + return text + } + + // MARK: - HTML → Markdown + + static func markdown(fromHTML html: String) -> String { + var text = html + // Drop anything that cannot be represented as text. + text = replace(#"(?is)<(head|script|style|noscript)[^>]*>.*?"#, in: text) { _ in "" } + text = replace(#"(?is)"#, in: text) { _ in "" } + + // Block level first. + for level in (1...6).reversed() { + text = replace(#"(?is)]*>(.*?)"#, in: text) { match in + "\n\n" + String(repeating: "#", count: level) + " " + stripTags(match[1]).trimmed + "\n\n" + } + } + text = replace(#"(?is)]*>(.*?)"#, in: text) { match in + "\n\n```\n" + stripTags(match[1]).trimmingCharacters(in: .newlines) + "\n```\n\n" + } + text = replace(#"(?is)]*>(.*?)"#, in: text) { match in + "\n\n" + stripTags(match[1]).trimmed + .split(separator: "\n").map { "> " + $0 }.joined(separator: "\n") + "\n\n" + } + text = replace(#"(?is)]*>(.*?)"#, in: text) { match in + "\n- " + stripTags(match[1]).trimmed + } + text = replace(#"(?is)<(ul|ol)[^>]*>"#, in: text) { _ in "\n" } + text = replace(#"(?is)"#, in: text) { _ in "\n" } + text = replace(#"(?is)]*/?>"#, in: text) { _ in "\n\n---\n\n" } + text = replace(#"(?is)]*>(.*?)"#, in: text) { match in + " " + stripTags(match[1]).trimmed + " |" + } + text = replace(#"(?is)]*>"#, in: text) { _ in "\n|" } + text = replace(#"(?is)]*/?>"#, in: text) { _ in " \n" } + text = replace(#"(?is)

    "#, in: text) { _ in "\n\n" } + text = replace(#"(?is)]*>"#, in: text) { _ in "\n\n" } + text = replace(#"(?is)"#, in: text) { _ in "\n" } + + // Inline. + text = replace(#"(?is)<(strong|b)[^>]*>(.*?)"#, in: text) { "**\(stripTags($0[2]).trimmed)**" } + text = replace(#"(?is)<(em|i)[^>]*>(.*?)"#, in: text) { "*\(stripTags($0[2]).trimmed)*" } + text = replace(#"(?is)]*>(.*?)"#, in: text) { "~~\(stripTags($0[1]).trimmed)~~" } + text = replace(#"(?is)]*>(.*?)"#, in: text) { "`\(stripTags($0[1]))`" } + text = replace(#"(?is)]*alt=["']([^"']*)["'][^>]*src=["']([^"']+)["'][^>]*/?>"#, in: text) { + "![\($0[1])](\($0[2]))" + } + text = replace(#"(?is)]*src=["']([^"']+)["'][^>]*/?>"#, in: text) { match in "![](\(match[1]))" } + text = replace(#"(?is)]*href=["']([^"']+)["'][^>]*>(.*?)"#, in: text) { + "[\(stripTags($0[2]).trimmed)](\($0[1]))" + } + + text = stripTags(text) + text = decodeEntities(text) + // Collapse the whitespace the block replacements left behind. + text = replace(#"[ \t]+\n"#, in: text) { _ in "\n" } + text = replace(#"\n{3,}"#, in: text) { _ in "\n\n" } + return text.trimmingCharacters(in: .whitespacesAndNewlines) + "\n" + } + + // MARK: - Helpers + + static func escape(_ string: String) -> String { + string + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + } + + static func stripTags(_ html: String) -> String { + replace(#"(?is)<[^>]+>"#, in: html) { _ in "" } + } + + static func decodeEntities(_ text: String) -> String { + var result = text + let map = [ + " ": " ", "&": "&", "<": "<", ">": ">", """: "\"", + "'": "'", "'": "'", "—": "—", "–": "–", "…": "…", + "’": "’", "‘": "‘", "“": "“", "”": "”" + ] + for (entity, value) in map { + result = result.replacingOccurrences(of: entity, with: value) + } + // Numeric entities. + result = replace(#"&#(\d+);"#, in: result) { match in + guard let code = UInt32(match[1]), let scalar = Unicode.Scalar(code) else { return match[0] } + return String(Character(scalar)) + } + return result + } + + /// Regex replacement where the closure receives the full match plus groups. + static func replace(_ pattern: String, in string: String, _ transform: ([String]) -> String) -> String { + guard let regex = try? NSRegularExpression(pattern: pattern) else { return string } + let range = NSRange(string.startIndex..., in: string) + let matches = regex.matches(in: string, range: range) + guard !matches.isEmpty else { return string } + + var output = string + for match in matches.reversed() { + guard let matchRange = Range(match.range, in: output) else { continue } + var groups: [String] = [] + for index in 0.. URL { + let resolved = format ?? FileKind.format(of: source) ?? .m4a + return OutputNaming.sibling(for: source, suffix: suffix, extension: resolved.ext) + } + + private static func codecForContainer(_ url: URL) -> String { + switch FileKind.canonicalExtension(url.pathExtension) { + case "mp3": return "libmp3lame" + case "ogg": return "libvorbis" + case "opus": return "libopus" + case "flac": return "flac" + case "wav": return "pcm_s16le" + case "aiff": return "pcm_s16be" + case "wma": return "wmav2" + case "caf": return "pcm_s16le" + default: return "aac" + } + } +} + +/// Serialisable, human readable descriptions for the info tools. +enum MediaInfo { + static func describe(_ url: URL) -> String { + switch FileKind.category(of: url) { + case .image: return describeImage(url) + case .video, .audio: return describeMedia(url) + case .pdf: return describePDF(url) + case .archive: return describeArchive(url) + case .spreadsheet, .document, .ebook, .subtitle: return describeText(url) + default: return describeFile(url) + } + } + + static func describeFile(_ url: URL) -> String { + describeFileBasics(url).joined(separator: "\n") + } + + private static func describeFileBasics(_ url: URL) -> [String] { + var lines: [String] = ["Name: \(url.lastPathComponent)"] + let values = try? url.resourceValues(forKeys: [.fileSizeKey, .creationDateKey, .contentModificationDateKey]) + if let size = values?.fileSize { + lines.append("Size: \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file))") + } + if let created = values?.creationDate { + lines.append("Created: \(DateFormatter.localizedString(from: created, dateStyle: .medium, timeStyle: .short))") + } + if let modified = values?.contentModificationDate { + lines.append("Modified: \(DateFormatter.localizedString(from: modified, dateStyle: .medium, timeStyle: .short))") + } + return lines + } + + private static func describeImage(_ url: URL) -> String { + var lines = describeFileBasics(url) + guard let source = Raster.imageSource(at: url) else { return lines.joined(separator: "\n") } + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] ?? [:] + if let width = properties[kCGImagePropertyPixelWidth] as? Int, + let height = properties[kCGImagePropertyPixelHeight] as? Int { + lines.append("Dimensions: \(width) × \(height)") + } + if let dpiWidth = properties[kCGImagePropertyDPIWidth] as? Double { + lines.append("Resolution: \(Int(dpiWidth)) dpi") + } + if let colorModel = properties[kCGImagePropertyColorModel] as? String { + lines.append("Color: \(colorModel)") + } + if properties[kCGImagePropertyGIFDictionary] != nil { + lines.append("Format: animated GIF") + } + if let exif = properties[kCGImagePropertyExifDictionary] as? [CFString: Any] { + if let date = exif[kCGImagePropertyExifDateTimeOriginal] as? String { + lines.append("Captured: \(date)") + } + } + if let tiff = properties[kCGImagePropertyTIFFDictionary] as? [CFString: Any] { + if let make = tiff[kCGImagePropertyTIFFMake] as? String, + let model = tiff[kCGImagePropertyTIFFModel] as? String { + lines.append("Camera: \(make) \(model)") + } + } + if let gps = properties[kCGImagePropertyGPSDictionary] as? [CFString: Any], + let latitude = gps[kCGImagePropertyGPSLatitude] as? Double, + let longitude = gps[kCGImagePropertyGPSLongitude] as? Double { + lines.append("Location: \(String(format: "%.4f", latitude)), \(String(format: "%.4f", longitude))") + } + lines.append("Frames: \(Raster.frameCount(at: url))") + return lines.joined(separator: "\n") + } + + private static func describeMedia(_ url: URL) -> String { + var lines = describeFileBasics(url) + let asset = AVURLAsset(url: url) + let duration = CMTimeGetSeconds(asset.duration) + if duration.isFinite, duration > 0 { + let minutes = Int(duration) / 60 + let remaining = duration - Double(minutes * 60) + lines.append("Duration: \(minutes):\(String(format: "%05.2f", remaining))") + } + for track in asset.tracks { + switch track.mediaType { + case .video: + let size = track.naturalSize.applying(track.preferredTransform) + let frameRate = track.nominalFrameRate + lines.append("Video: \(Int(abs(size.width)))×\(Int(abs(size.height))) @ \(String(format: "%.2f", frameRate)) fps") + lines.append("Codec: \(codecName(track) ?? "unknown")") + case .audio: + let rate = track.nominalFrameRate + lines.append("Audio: \(codecName(track) ?? "unknown") @ \(Int(rate)) Hz") + default: + break + } + } + if let ffprobe = Toolchain.ffprobe, + let output = Shell.output(ffprobe, ["-v", "error", "-show_entries", "format=bit_rate:stream=bit_rate", "-of", "default=nw=1", url.path]) { + let rates = output.split(separator: "\n").compactMap { $0.split(separator: "=").last }.compactMap { Int($0) }.filter { $0 > 0 } + if let bitrate = rates.first { + lines.append("Bitrate: \(bitrate / 1000) kbps") + } + } + return lines.joined(separator: "\n") + } + + private static func codecName(_ track: AVAssetTrack) -> String? { + guard let raw = track.formatDescriptions.first else { return nil } + let description = raw as! CMFormatDescription + return describeFormat(description) + } + + private static func describeFormat(_ description: CMFormatDescription) -> String? { + let codec = CMFormatDescriptionGetMediaSubType(description) + let bytes = [ + UInt8((codec >> 24) & 0xFF), UInt8((codec >> 16) & 0xFF), + UInt8((codec >> 8) & 0xFF), UInt8(codec & 0xFF) + ] + let name = String(bytes: bytes, encoding: .ascii)?.trimmingCharacters(in: .whitespaces) + return name?.isEmpty == false ? name : nil + } + + private static func describePDF(_ url: URL) -> String { + var lines = describeFileBasics(url) + lines.append("Pages: \(PDFConverter.pageCount(url))") + if let size = PDFConverter.pageSize(url) { + lines.append("Page size: \(Int(size.width)) × \(Int(size.height)) pt") + } + let text = PDFConverter.extractText(url) + lines.append("Words: \(text.split { $0 == " " || $0.isNewline }.count)") + return lines.joined(separator: "\n") + } + + private static func describeArchive(_ url: URL) -> String { + var lines = describeFileBasics(url) + let listing = (try? ArchiveConverter.list(url)) ?? "" + let entries = listing.split(separator: "\n").count + lines.append("Entries: \(entries)") + return lines.joined(separator: "\n") + } + + private static func describeText(_ url: URL) -> String { + var lines = describeFileBasics(url) + let text = TextDocument.plainText(from: url) ?? "" + let words = text.split { $0 == " " || $0.isNewline }.count + lines.append("Words: \(words)") + lines.append("Characters: \(text.count)") + lines.append("Lines: \(text.components(separatedBy: "\n").count)") + return lines.joined(separator: "\n") + } +} diff --git a/Sources/Konverter/Convert/Office.swift b/Sources/Konverter/Convert/Office.swift new file mode 100644 index 0000000..292cfab --- /dev/null +++ b/Sources/Konverter/Convert/Office.swift @@ -0,0 +1,110 @@ +import Foundation + +/// LibreOffice bridge for formats Apple's frameworks cannot render (PPT/PPTX, +/// XLS/XLSX/ODS when they need layout, PAGES, Keynote). +enum Office { + /// LibreOffice cannot run two headless conversions at once in the same + /// profile, so every call is serialised. + private static let lock = NSLock() + + static func convert( + _ source: URL, + to format: Format, + at destination: URL, + cancellation: Cancellation? = nil + ) throws { + guard let soffice = Toolchain.soffice, Settings.shared.useExternalTools else { + throw ConversionError.missingTool( + "\(format.displayName) needs LibreOffice. Install it with: brew install --cask libreoffice" + ) + } + let filter = filterName(for: format) + let staging = FileManager.default.temporaryDirectory + .appendingPathComponent("konverter-office-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: staging) } + try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true) + + lock.lock() + defer { lock.unlock() } + try cancellation?.check() + + try Shell.run( + soffice, + [ + "--headless", "--norestore", "--nolockcheck", "--nodefault", + "-env:UserInstallation=file://" + staging.path + "/profile", + "--convert-to", filter, + "--outdir", staging.path, + source.path + ], + cancellation: cancellation + ) + + let produced = try FileManager.default + .contentsOfDirectory(at: staging, includingPropertiesForKeys: nil) + .first { $0.pathExtension.lowercased() == destination.pathExtension.lowercased() } + guard let produced else { + throw ConversionError.failed("LibreOffice did not produce a \(format.displayName) file.") + } + try? FileManager.default.removeItem(at: destination) + try FileManager.default.moveItem(at: produced, to: destination) + } + + static func filterName(for format: Format) -> String { + switch format { + case .pdf: return "pdf" + case .docx: return "docx" + case .odt: return "odt" + case .rtf: return "rtf" + case .txt: return "txt" + case .html: return "html" + case .csv: return "csv" + case .xlsx: return "xlsx" + case .ods: return "ods" + case .pptx: return "pptx" + case .epub: return "epub" + case .png: return "png" + case .jpg: return "jpg" + default: return format.ext + } + } +} + +/// PPT/PPTX/KEY → PDF or images. Everything funnels through a PDF so multi +/// slide decks come out with one image per slide. +enum SlidesConverter { + static func convert( + source: URL, + to format: Format, + at destination: URL, + cancellation: Cancellation? = nil, + progress: ((Double) -> Void)? = nil + ) throws { + switch format { + case .pdf: + try Office.convert(source, to: .pdf, at: destination, cancellation: cancellation) + case .png, .jpg: + let staging = FileManager.default.temporaryDirectory + .appendingPathComponent("konverter-slides-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: staging) } + try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true) + let pdf = staging.appendingPathComponent("deck.pdf") + try Office.convert(source, to: .pdf, at: pdf, cancellation: cancellation) + let directory = OutputNaming.artifactDirectory( + for: destination, + suffix: "" + ) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + _ = try PDFConverter.renderPages( + pdf, + to: format, + into: directory, + dpi: 200, + cancellation: cancellation, + progress: progress + ) + default: + throw ConversionError.unsupported("Slides become PDF or images.") + } + } +} diff --git a/Sources/Konverter/Convert/PDFConverter.swift b/Sources/Konverter/Convert/PDFConverter.swift new file mode 100644 index 0000000..9935c10 --- /dev/null +++ b/Sources/Konverter/Convert/PDFConverter.swift @@ -0,0 +1,381 @@ +import AppKit +import CoreGraphics +import CoreText +import Foundation +import PDFKit + +/// Everything PDF: rasterising pages, building PDFs from images, text +/// extraction, splitting, merging, rotating and password handling. +enum PDFConverter { + static let defaultDPI: CGFloat = 300 + + // MARK: - Images → PDF + + static func imageToPDF(_ source: URL, at destination: URL, cancellation: Cancellation? = nil) throws { + guard let image = Raster.firstImage(at: source) else { + throw ConversionError.unreadable(source.lastPathComponent) + } + try imagesToPDF([(image, nil)], at: destination, cancellation: cancellation) + } + + /// Each image becomes one page, sized so the image keeps its aspect ratio + /// at 72 dpi (a 1440×900 screenshot becomes a 1440×900 pt page). + static func imagesToPDF( + _ pages: [(image: CGImage, name: String?)], + at destination: URL, + pageSize: CGSize? = nil, + cancellation: Cancellation? = nil + ) throws { + guard !pages.isEmpty else { throw ConversionError.failed("Nothing to put in the PDF.") } + + var mediaBox = CGRect(origin: .zero, size: pages[0].name == nil + ? CGSize(width: pages[0].image.width, height: pages[0].image.height) + : CGSize(width: 595, height: 842)) + if let pageSize { mediaBox = CGRect(origin: .zero, size: pageSize) } + + guard let context = CGContext(destination as CFURL, mediaBox: &mediaBox, nil) else { + throw ConversionError.failed("Could not create \(destination.lastPathComponent).") + } + + for page in pages { + try cancellation?.check() + let image = page.image + var box = mediaBox + if page.name == nil || pageSize == nil { + box = CGRect(x: 0, y: 0, width: image.width, height: image.height) + } + context.beginPDFPage([kCGPDFContextMediaBox: CGRectToData(box)] as CFDictionary) + + let scale = min(box.width / CGFloat(image.width), box.height / CGFloat(image.height)) + let size = CGSize(width: CGFloat(image.width) * scale, height: CGFloat(image.height) * scale) + let rect = CGRect( + x: (box.width - size.width) / 2, + y: (box.height - size.height) / 2, + width: size.width, + height: size.height + ) + context.draw(image, in: rect) + context.endPDFPage() + } + context.closePDF() + } + + private static func CGRectToData(_ rect: CGRect) -> CFData { + var rect = rect + return CFDataCreate(nil, withUnsafeBytes(of: &rect) { $0.bindMemory(to: UInt8.self).baseAddress }, MemoryLayout.size) + } + + // MARK: - PDF → images + + /// Renders each page to `format` at `dpi`. Returns the files written. + @discardableResult + static func renderPages( + _ source: URL, + to format: Format, + into directory: URL, + dpi: CGFloat = 300, + pageNumbers: [Int]? = nil, + quality: Double = 0.85, + cancellation: Cancellation? = nil, + progress: ((Double) -> Void)? = nil + ) throws -> [URL] { + guard let document = PDFDocument(url: source) else { + throw ConversionError.unreadable(source.lastPathComponent) + } + let base = source.deletingPathExtension().lastPathComponent + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let indices = pageNumbers ?? Array(0.. 0, pixelHeight > 0 else { continue } + + guard let context = CGContext( + data: nil, + width: pixelWidth, + height: pixelHeight, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + ) else { continue } + + context.setFillColor(CGColor(gray: 1, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: pixelWidth, height: pixelHeight)) + context.scaleBy(x: scale, y: scale) + context.translateBy(x: -bounds.origin.x, y: -bounds.origin.y) + page.draw(with: .mediaBox, to: context) + + guard let image = context.makeImage() else { continue } + let number = String(pageIndex + 1).leftPadded(to: padding) + let target = OutputNaming.availableURL( + for: directory.appendingPathComponent("\(base)-\(number)").appendingPathExtension(format.ext) + ) + try Raster.write(image, to: target, format: format, quality: quality, metadata: nil) + written.append(target) + progress?(Double(offset + 1) / Double(indices.count)) + } + return written + } + + // MARK: - Text + + static func extractText(_ source: URL, pageBreak: String = "\n\n") -> String { + guard let document = PDFDocument(url: source) else { return "" } + return (0.. 0 else { throw ConversionError.failed("No pages to merge.") } + guard merged.write(to: destination) else { + throw ConversionError.failed("Could not write \(destination.lastPathComponent).") + } + } + + /// Splits into chunks of `pagesPerFile`, writing into a sibling folder. + static func split( + _ source: URL, + pagesPerFile: Int, + into directory: URL, + cancellation: Cancellation? = nil + ) throws -> [URL] { + guard let document = PDFDocument(url: source) else { + throw ConversionError.unreadable(source.lastPathComponent) + } + let base = source.deletingPathExtension().lastPathComponent + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + var written: [URL] = [] + var start = 0 + var part = 1 + while start < document.pageCount { + try cancellation?.check() + let chunk = PDFDocument() + var cursor = 0 + for pageIndex in start.. Void)? = nil + ) throws { + guard let document = PDFDocument(url: source) else { + throw ConversionError.unreadable(source.lastPathComponent) + } + guard let firstPage = document.page(at: 0) else { return } + var mediaBox = firstPage.bounds(for: .mediaBox) + guard let context = CGContext(destination as CFURL, mediaBox: &mediaBox, nil) else { + throw ConversionError.failed("Could not create the compressed PDF.") + } + for index in 0.. 0, height > 0, + let bitmap = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + ) { + bitmap.setFillColor(CGColor(gray: 1, alpha: 1)) + bitmap.fill(CGRect(x: 0, y: 0, width: width, height: height)) + bitmap.scaleBy(x: scale, y: scale) + bitmap.translateBy(x: -bounds.origin.x, y: -bounds.origin.y) + page.draw(with: .mediaBox, to: bitmap) + if let image = bitmap.makeImage(), + let jpeg = JPEGEncoder.encode(image, quality: quality) { + box = bounds + context.draw(jpeg, in: bounds) + } + } + context.endPDFPage() + progress?(Double(index + 1) / Double(document.pageCount)) + } + context.closePDF() + } + + /// Lays images out in a grid on a single page (Tangerine's "collage"). + static func collage( + _ entries: [(image: CGImage, name: String)], + at destination: URL, + columns: Int, + page: String, + margin: CGFloat, + captions: Bool, + cancellation: Cancellation? = nil + ) throws { + guard !entries.isEmpty else { throw ConversionError.failed("Nothing to lay out.") } + let pageSize: CGSize + switch page { + case "Letter": pageSize = CGSize(width: 612, height: 792) + case "Square": pageSize = CGSize(width: 800, height: 800) + default: pageSize = CGSize(width: 595, height: 842) + } + var mediaBox = CGRect(origin: .zero, size: pageSize) + guard let context = CGContext(destination as CFURL, mediaBox: &mediaBox, nil) else { + throw ConversionError.failed("Could not create \(destination.lastPathComponent).") + } + context.beginPDFPage([kCGPDFContextMediaBox: CGRectToData(mediaBox)] as CFDictionary) + + let columnCount = max(1, columns) + let rowCount = Int(ceil(Double(entries.count) / Double(columnCount))) + let usableWidth = pageSize.width - margin * 2 + let usableHeight = pageSize.height - margin * 2 + let cellWidth = usableWidth / CGFloat(columnCount) + let cellHeight = usableHeight / CGFloat(max(1, rowCount)) + let captionHeight: CGFloat = captions ? 14 : 0 + + for (index, entry) in entries.enumerated() { + try cancellation?.check() + let column = index % columnCount + let row = index / columnCount + let cell = CGRect( + x: margin + CGFloat(column) * cellWidth, + y: pageSize.height - margin - CGFloat(row + 1) * cellHeight, + width: cellWidth, + height: cellHeight + ).insetBy(dx: 6, dy: 6) + + let imageArea = cell.insetBy(dx: 0, dy: captionHeight) + let scale = min(imageArea.width / CGFloat(entry.image.width), imageArea.height / CGFloat(entry.image.height)) + let size = CGSize(width: CGFloat(entry.image.width) * scale, height: CGFloat(entry.image.height) * scale) + context.draw(entry.image, in: CGRect( + x: imageArea.midX - size.width / 2, + y: imageArea.midY - size.height / 2, + width: size.width, + height: size.height + )) + + if captions, let escaped = entry.name as CFString? { + let font = CTFontCreateWithName("Helvetica" as CFString, 9, nil) + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: CGColor(gray: 0.35, alpha: 1) + ] + let line = CTLineCreateWithAttributedString( + NSAttributedString(string: escaped as String, attributes: attributes) + ) + context.textPosition = CGPoint(x: cell.minX, y: cell.minY) + CTLineDraw(line, context) + } + } + context.endPDFPage() + context.closePDF() + } + + /// Rasterises a PDF page range to a single contact sheet, used by the + /// "Info" summary. + static func pageSize(_ source: URL) -> CGSize? { + PDFDocument(url: source)?.page(at: 0)?.bounds(for: .mediaBox).size + } + + static func pageCount(_ source: URL) -> Int { + PDFDocument(url: source)?.pageCount ?? 0 + } +} + +/// Small helper so the PDF compressor can produce JPEG bytes in memory. +enum JPEGEncoder { + static func encode(_ image: CGImage, quality: Double) -> CGImage? { + let flattened = Raster.flattened(image) + guard let data = CFDataCreateMutable(nil, 0), + let destination = CGImageDestinationCreateWithData(data, "public.jpeg" as CFString, 1, nil) else { + return nil + } + CGImageDestinationAddImage( + destination, + flattened, + [kCGImageDestinationLossyCompressionQuality: quality] as CFDictionary + ) + guard CGImageDestinationFinalize(destination) else { return nil } + guard let source = CGImageSourceCreateWithData(data, nil) else { return nil } + return CGImageSourceCreateImageAtIndex(source, 0, nil) + } +} + +extension String { + func leftPadded(to width: Int, with character: Character = "0") -> String { + count >= width ? self : String(repeating: String(character), count: width - count) + self + } +} diff --git a/Sources/Konverter/Convert/SpreadsheetConverter.swift b/Sources/Konverter/Convert/SpreadsheetConverter.swift new file mode 100644 index 0000000..3e8e3a0 --- /dev/null +++ b/Sources/Konverter/Convert/SpreadsheetConverter.swift @@ -0,0 +1,495 @@ +import AppKit +import Foundation + +/// CSV/TSV/XLSX/ODS. The delimited formats are parsed in-process; XLSX is read +/// and written as OOXML by hand so no dependency is needed; everything else +/// goes through LibreOffice. +enum SpreadsheetConverter { + typealias Rows = [[String]] + + static func convert( + source: URL, + to format: Format, + at destination: URL, + cancellation: Cancellation? = nil, + progress: ((Double) -> Void)? = nil + ) throws { + try cancellation?.check() + switch format { + case .csv, .tsv: + let rows = try read(source, cancellation: cancellation) + try write(rows, to: destination, delimiter: format == .tsv ? "\t" : ",") + case .xlsx: + let rows = try read(source, cancellation: cancellation) + try XLSX.write(rows, to: destination) + case .pdf: + let rows = try read(source, cancellation: cancellation) + try writePDF(rows, at: destination) + case .ods, .numbers, .xls: + try Office.convert(source, to: format, at: destination, cancellation: cancellation) + default: + throw ConversionError.unsupported("Spreadsheets cannot become \(format.displayName).") + } + progress?(1) + } + + // MARK: - Reading + + static func read(_ url: URL, cancellation: Cancellation? = nil) throws -> Rows { + switch FileKind.canonicalExtension(url.pathExtension) { + case "csv": + let text = try string(from: url) + return parseDelimited(text, delimiter: ",") + case "tsv": + let text = try string(from: url) + return parseDelimited(text, delimiter: "\t") + case "xlsx": + return try XLSX.read(url, cancellation: cancellation) + case "ods": + return try ODS.read(url, cancellation: cancellation) + case "pdf": + // Tables in a PDF: best effort, one row per line. + let text = PDFConverter.extractText(url) + return text + .components(separatedBy: "\n") + .filter { !$0.trimmed.isEmpty } + .map { [$0] } + default: + throw ConversionError.unsupported("Konverter cannot read .\(url.pathExtension) spreadsheets without LibreOffice.") + } + } + + private static func string(from url: URL) throws -> String { + if let utf8 = try? String(contentsOf: url, encoding: .utf8) { return utf8 } + if let latin = try? String(contentsOf: url, encoding: .isoLatin1) { return latin } + throw ConversionError.unreadable(url.lastPathComponent) + } + + /// RFC 4180-ish: quotes, doubled quotes, embedded newlines. + static func parseDelimited(_ text: String, delimiter: Character) -> Rows { + var rows: Rows = [] + var row: [String] = [] + var field = "" + var inQuotes = false + var iterator = text.makeIterator() + var pending: Character? + + while let character = pending ?? iterator.next() { + pending = nil + if inQuotes { + if character == "\"" { + if let next = iterator.next() { + if next == "\"" { + field.append("\"") + } else { + inQuotes = false + pending = next + } + } else { + inQuotes = false + } + } else { + field.append(character) + } + continue + } + switch character { + case "\"": + inQuotes = true + case delimiter: + row.append(field) + field = "" + case "\r": + break + case "\n": + row.append(field) + field = "" + rows.append(row) + row = [] + default: + field.append(character) + } + } + if !field.isEmpty || !row.isEmpty { + row.append(field) + rows.append(row) + } + // Drop a trailing empty row caused by a final newline. + if let last = rows.last, last.allSatisfy({ $0.isEmpty }) { rows.removeLast() } + return rows + } + + // MARK: - Writing + + static func write(_ rows: Rows, to url: URL, delimiter: String) throws { + let text = rows.map { row in + row.map { escape($0, delimiter: delimiter) }.joined(separator: delimiter) + }.joined(separator: "\n") + try text.write(to: url, atomically: true, encoding: .utf8) + } + + private static func escape(_ field: String, delimiter: String) -> String { + let needsQuotes = field.contains(delimiter) || field.contains("\"") || field.contains("\n") || field.contains("\r") + guard needsQuotes else { return field } + return "\"" + field.replacingOccurrences(of: "\"", with: "\"\"") + "\"" + } + + static func writePDF(_ rows: Rows, at url: URL) throws { + let columnCount = rows.map(\.count).max() ?? 0 + guard columnCount > 0 else { throw ConversionError.failed("Nothing to write.") } + var html = """ + + """ + for (index, row) in rows.enumerated() { + let tag = index == 0 ? "th" : "td" + html += "" + (0..\(Markdown.escape(value))" + }.joined() + "" + } + html += "
    " + let attributed = try? NSAttributedString( + data: Data(html.utf8), + options: [.documentType: NSAttributedString.DocumentType.html], + documentAttributes: nil + ) + if let attributed { + try TextDocument.writePDF(attributed, to: url, pageSize: CGSize(width: 842, height: 595)) + } else { + try TextDocument.writePDF(fromText: rows.map { $0.joined(separator: "\t") }.joined(separator: "\n"), to: url) + } + } +} + +/// Minimal OOXML spreadsheet reader/writer. +enum XLSX { + static func read(_ url: URL, cancellation: Cancellation? = nil) throws -> SpreadsheetConverter.Rows { + let staging = try Unzip.expand(url, cancellation: cancellation) + defer { try? FileManager.default.removeItem(at: staging) } + + let sharedStrings = parseSharedStrings( + at: staging.appendingPathComponent("xl/sharedStrings.xml") + ) + let sheetURL = firstSheet(at: staging) + guard let sheetURL else { throw ConversionError.failed("This workbook has no sheets.") } + return try parseSheet(at: sheetURL, sharedStrings: sharedStrings) + } + + static func write(_ rows: SpreadsheetConverter.Rows, to url: URL) throws { + let staging = FileManager.default.temporaryDirectory + .appendingPathComponent("konverter-xlsx-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: staging) } + try FileManager.default.createDirectory( + at: staging.appendingPathComponent("xl/_rels"), + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: staging.appendingPathComponent("_rels"), + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: staging.appendingPathComponent("xl/worksheets"), + withIntermediateDirectories: true + ) + + try contentTypes.write(to: staging.appendingPathComponent("[Content_Types].xml"), atomically: true, encoding: .utf8) + try packageRels.write(to: staging.appendingPathComponent("_rels/.rels"), atomically: true, encoding: .utf8) + try workbookRels.write(to: staging.appendingPathComponent("xl/_rels/workbook.xml.rels"), atomically: true, encoding: .utf8) + try styles.write(to: staging.appendingPathComponent("xl/styles.xml"), atomically: true, encoding: .utf8) + try workbook.write(to: staging.appendingPathComponent("xl/workbook.xml"), atomically: true, encoding: .utf8) + try sheetXML(rows).write(to: staging.appendingPathComponent("xl/worksheets/sheet1.xml"), atomically: true, encoding: .utf8) + + let zip = Toolchain.zip + try? FileManager.default.removeItem(at: url) + try Shell.run(zip, ["-r", "-X", "-q", url.path, "[Content_Types].xml", "_rels", "xl"], currentDirectory: staging) + } + + private static func sheetXML(_ rows: SpreadsheetConverter.Rows) -> String { + var xml = """ + + + """ + for (rowIndex, row) in rows.enumerated() { + xml += "" + for (columnIndex, value) in row.enumerated() { + let reference = "\(columnName(columnIndex))\(rowIndex + 1)" + if let number = numberValue(value) { + xml += "\(number)" + } else { + xml += "\(Markdown.escape(value))" + } + } + xml += "" + } + xml += "" + return xml + } + + /// Writes a number only when the value really is one, so phone numbers and + /// zero-padded ids stay text. + private static func numberValue(_ value: String) -> String? { + let trimmed = value.trimmed + guard !trimmed.isEmpty, trimmed == value else { return nil } + guard trimmed.rangeOfCharacter(from: CharacterSet(charactersIn: "0123456789")) != nil else { return nil } + guard let number = Double(trimmed), number.isFinite else { return nil } + if trimmed.count > 1, trimmed.hasPrefix("0"), !trimmed.hasPrefix("0.") { return nil } + return trimmed + } + + static func columnName(_ index: Int) -> String { + var index = index + var name = "" + repeat { + name = String(UnicodeScalar(UInt8(65 + index % 26))) + name + index = index / 26 - 1 + } while index >= 0 + return name + } + + static func columnIndex(_ reference: String) -> Int { + var index = 0 + for character in reference.uppercased() { + guard let ascii = character.asciiValue, ascii >= 65, ascii <= 90 else { break } + index = index * 26 + Int(ascii - 64) + } + return max(0, index - 1) + } + + // MARK: - Parsing helpers + + private static func firstSheet(at staging: URL) -> URL? { + let worksheets = staging.appendingPathComponent("xl/worksheets") + let files = (try? FileManager.default.contentsOfDirectory(at: worksheets, includingPropertiesForKeys: nil)) ?? [] + return files.filter { $0.pathExtension == "xml" }.sorted { $0.lastPathComponent < $1.lastPathComponent }.first + } + + private static func parseSharedStrings(at url: URL) -> [String] { + guard let data = try? Data(contentsOf: url) else { return [] } + let parser = XMLParser(data: data) + let delegate = SharedStringsDelegate() + parser.delegate = delegate + parser.parse() + return delegate.strings + } + + private static func parseSheet(at url: URL, sharedStrings: [String]) throws -> SpreadsheetConverter.Rows { + guard let data = try? Data(contentsOf: url) else { + throw ConversionError.failed("Could not read the worksheet.") + } + let parser = XMLParser(data: data) + let delegate = SheetDelegate(sharedStrings: sharedStrings) + parser.delegate = delegate + parser.parse() + return delegate.rows + } + + private final class SharedStringsDelegate: NSObject, XMLParserDelegate { + var strings: [String] = [] + private var current: String? + private var buffer = "" + + func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?, attributes: [String: String]) { + if elementName == "si" { current = ""; buffer = "" } + if elementName == "t" { buffer = "" } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + buffer += string + } + + func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName: String?) { + if elementName == "t" { current = (current ?? "") + buffer } + if elementName == "si" { strings.append(current ?? ""); current = nil } + } + } + + private final class SheetDelegate: NSObject, XMLParserDelegate { + let sharedStrings: [String] + var rows: SpreadsheetConverter.Rows = [] + private var row: [String] = [] + private var pendingColumn = 0 + private var valueBuffer = "" + private var cellType = "" + private var isInline = false + private var inCell = false + + init(sharedStrings: [String]) { + self.sharedStrings = sharedStrings + } + + func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?, attributes: [String: String]) { + switch elementName { + case "row": + row = [] + case "c": + inCell = true + cellType = attributes["t"] ?? "" + isInline = false + valueBuffer = "" + if let reference = attributes["r"] { + pendingColumn = XLSX.columnIndex(reference) + } + case "is": + isInline = true + case "v", "t": + valueBuffer = "" + default: + break + } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + if inCell { valueBuffer += string } + } + + func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName: String?) { + switch elementName { + case "c": + let value: String + if cellType == "s", let index = Int(valueBuffer.trimmed), index < sharedStrings.count { + value = sharedStrings[index] + } else { + value = isInline ? valueBuffer : valueBuffer.trimmed + } + while row.count < pendingColumn { row.append("") } + row.append(value) + pendingColumn = row.count + inCell = false + valueBuffer = "" + case "row": + rows.append(row) + row = [] + default: + break + } + } + } + + // MARK: - Static OOXML scaffolding + + private static let contentTypes = """ + + + + + + + + + """ + + private static let packageRels = """ + + + + + """ + + private static let workbookRels = """ + + + + + + """ + + private static let workbook = """ + + + + """ + + private static let styles = """ + + + + + + + + + """ +} + +/// ODS is a zip of `content.xml`; a light parser is enough for flat tables. +enum ODS { + static func read(_ url: URL, cancellation: Cancellation? = nil) throws -> SpreadsheetConverter.Rows { + let staging = try Unzip.expand(url, cancellation: cancellation) + defer { try? FileManager.default.removeItem(at: staging) } + guard let data = try? Data(contentsOf: staging.appendingPathComponent("content.xml")) else { + throw ConversionError.failed("This ODS file has no content.xml.") + } + let parser = XMLParser(data: data) + let delegate = TableDelegate() + parser.delegate = delegate + parser.parse() + return delegate.rows + } + + private final class TableDelegate: NSObject, XMLParserDelegate { + private(set) var rows: SpreadsheetConverter.Rows = [] + private var row: [String] = [] + private var buffer = "" + private var inCell = false + private var inRow = false + private var repeatColumns = 1 + + func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?, attributes: [String: String]) { + switch elementName { + case "table-row": + inRow = true + row = [] + case "table-cell": + inCell = true + buffer = "" + repeatColumns = Int(attributes["table:number-columns-repeated"] ?? "1") ?? 1 + case "p": + if inCell, !buffer.isEmpty { buffer += " " } + default: + break + } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + if inCell { buffer += string } + } + + func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName: String?) { + switch elementName { + case "table-cell": + let value = buffer.trimmed + for _ in 0.. URL { + let staging = FileManager.default.temporaryDirectory + .appendingPathComponent("konverter-unzip-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true) + do { + try Shell.run(Toolchain.unzip, ["-qq", "-o", url.path, "-d", staging.path], cancellation: cancellation) + } catch { + try? FileManager.default.removeItem(at: staging) + throw error + } + return staging + } +} diff --git a/Sources/Konverter/Convert/SubtitleConverter.swift b/Sources/Konverter/Convert/SubtitleConverter.swift new file mode 100644 index 0000000..95e7be7 --- /dev/null +++ b/Sources/Konverter/Convert/SubtitleConverter.swift @@ -0,0 +1,146 @@ +import Foundation + +struct SubtitleCue { + var start: Double + var end: Double + var text: String +} + +/// SRT ↔ VTT ↔ TXT, plus the offset tool. +enum SubtitleConverter { + static func convert( + source: URL, + to format: Format, + at destination: URL, + cancellation: Cancellation? = nil + ) throws { + var cues = try parse(source) + try cancellation?.check() + cues = cues.map { cue in + var copy = cue + copy.text = SubtitleConverter.normalized(copy.text) + return copy + } + switch format { + case .srt, .vtt: + try write(cues, to: destination, format: format) + case .txt: + try TextDocument.write(cues.map(\.text).joined(separator: "\n"), to: destination) + default: + throw ConversionError.unsupported("Subtitles cannot become \(format.displayName).") + } + } + + static func parse(_ url: URL) throws -> [SubtitleCue] { + guard let raw = try? String(contentsOf: url, encoding: .utf8) + ?? (try? String(contentsOf: url, encoding: .isoLatin1)) else { + throw ConversionError.unreadable(url.lastPathComponent) + } + let ext = FileKind.canonicalExtension(url.pathExtension) + switch ext { + case "vtt": + return parseBlocks(raw, timeSeparator: ".") + case "ass", "ssa": + return parseASS(raw) + default: + return parseBlocks(raw, timeSeparator: nil) + } + } + + private static func parseBlocks(_ raw: String, timeSeparator: Character?) -> [SubtitleCue] { + let normalized = raw + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + .replacingOccurrences(of: "\u{FEFF}", with: "") + let blocks = normalized.components(separatedBy: "\n\n") + var cues: [SubtitleCue] = [] + + for block in blocks { + let lines = block.components(separatedBy: "\n").map { $0.trimmed }.filter { !$0.isEmpty } + guard let timeLineIndex = lines.firstIndex(where: { $0.contains("-->") }) else { continue } + let timeLine = lines[timeLineIndex] + let parts = timeLine.components(separatedBy: "-->") + guard parts.count == 2, + let start = seconds(from: parts[0], separator: timeSeparator), + let end = seconds(from: parts[1], separator: timeSeparator) else { continue } + let text = lines.dropFirst(timeLineIndex + 1).joined(separator: "\n") + cues.append(SubtitleCue(start: start, end: max(end, start), text: text.isEmpty ? " " : text)) + } + return cues + } + + private static func parseASS(_ raw: String) -> [SubtitleCue] { + var cues: [SubtitleCue] = [] + for line in raw.components(separatedBy: "\n") where line.hasPrefix("Dialogue:") { + let fields = line.dropFirst("Dialogue:".count).components(separatedBy: ",") + guard fields.count >= 10, + let start = seconds(from: fields[1], separator: nil), + let end = seconds(from: fields[2], separator: nil) else { continue } + let text = fields[9...].joined(separator: ",") + .replacingOccurrences(of: "\\N", with: "\n") + .replacingOccurrences(of: "\\n", with: "\n") + cues.append(SubtitleCue(start: start, end: end, text: text)) + } + return cues + } + + static func write(_ cues: [SubtitleCue], to url: URL, format: Format) throws { + var output = format == .vtt ? "WEBVTT\n\n" : "" + for (index, cue) in cues.enumerated() { + if format == .srt { + output += "\(index + 1)\n" + output += "\(timecode(cue.start, separator: ",")) --> \(timecode(cue.end, separator: ","))\n" + } else { + output += "\(timecode(cue.start, separator: ".")) --> \(timecode(cue.end, separator: "."))\n" + } + output += cue.text + "\n\n" + } + try TextDocument.write(output, to: url) + } + + static func shift(_ cues: [SubtitleCue], by offset: Double) -> [SubtitleCue] { + cues.map { cue in + var copy = cue + let shift = max(0, cue.start + offset) - cue.start + copy.start = max(0, cue.start + offset) + copy.end = max(copy.start, cue.end + shift) + return copy + } + } + + private static func normalized(_ text: String) -> String { + var value = text + for tag in ["", "", "", "", "", "", "", ""] { + value = value.replacingOccurrences(of: tag, with: "") + } + return value + } + + // MARK: - Timecodes + + static func timecode(_ seconds: Double, separator: Character = ",") -> String { + let clamped = max(0, seconds) + let hours = Int(clamped) / 3600 + let minutes = (Int(clamped) % 3600) / 60 + let wholeSeconds = Int(clamped) % 60 + let millis = Int(((clamped - Double(Int(clamped))) * 1000).rounded()) + return String(format: "%02d:%02d:%02d%@%03d", hours, minutes, wholeSeconds, String(separator), millis) + } + + private static func seconds(from text: String, separator: Character?) -> Double? { + let cleaned = text.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: ",", with: ".") + let components = cleaned.components(separatedBy: ":") + var value: Double = 0 + for (index, component) in components.enumerated() { + guard let number = Double(component) else { return nil } + switch components.count - index { + case 1: value += number + case 2: value += number * 60 + case 3: value += number * 3600 + default: return nil + } + } + _ = separator + return value + } +} diff --git a/Sources/Konverter/Convert/TextDocument.swift b/Sources/Konverter/Convert/TextDocument.swift new file mode 100644 index 0000000..9195771 --- /dev/null +++ b/Sources/Konverter/Convert/TextDocument.swift @@ -0,0 +1,217 @@ +import AppKit +import CoreText +import Foundation + +/// Text extraction and rendering shared by the document, ebook, PDF and +/// spreadsheet paths. `textutil` does the heavy lifting for Office formats; +/// CoreText lays text out when we have to make a PDF ourselves. +enum TextDocument { + static func plainText(from url: URL) -> String? { + switch FileKind.canonicalExtension(url.pathExtension) { + case "txt", "md", "csv", "tsv", "srt", "vtt": + return (try? String(contentsOf: url, encoding: .utf8)) + ?? (try? String(contentsOf: url, encoding: .isoLatin1)) + case "html": + if let html = try? String(contentsOf: url, encoding: .utf8) { + return textFromHTML(html) + } + return nil + case "epub": + return try? EbookConverter.plainText(url) + case "pdf": + return PDFConverter.extractText(url) + default: + return textutilText(from: url) + } + } + + static func textFromHTML(_ html: String) -> String { + guard let data = html.data(using: .utf8), + let attributed = try? NSAttributedString( + data: data, + options: [.documentType: NSAttributedString.DocumentType.html, + .characterEncoding: String.Encoding.utf8.rawValue], + documentAttributes: nil + ) else { + return Markdown.decodeEntities(Markdown.stripTags(html)) + } + return attributed.string + } + + static func textutilText(from url: URL) -> String? { + guard let result = try? Shell.run(Toolchain.textutil, ["-convert", "txt", "-stdout", url.path]) else { + return nil + } + return result.stdout + } + + static func html(from url: URL) throws -> String { + switch FileKind.canonicalExtension(url.pathExtension) { + case "md": + let source = try String(contentsOf: url, encoding: .utf8) + return Markdown.html(from: source, title: url.deletingPathExtension().lastPathComponent) + case "html": + return try String(contentsOf: url, encoding: .utf8) + case "txt": + let source = try String(contentsOf: url, encoding: .utf8) + let escaped = Markdown.escape(source) + return "
    \(escaped)
    " + case "pdf": + let text = PDFConverter.extractText(url) + return htmlDocument(fromPlainText: text, title: url.deletingPathExtension().lastPathComponent) + case "epub": + return try EbookConverter.html(url) + default: + let result = try Shell.run(Toolchain.textutil, ["-convert", "html", "-stdout", url.path]) + return result.stdout + } + } + + static func attributedString(from url: URL) -> NSAttributedString? { + if let data = try? Data(contentsOf: url), + let attributed = try? NSAttributedString( + data: data, + options: [.documentType: documentType(for: url), .characterEncoding: String.Encoding.utf8.rawValue], + documentAttributes: nil + ) { + return attributed + } + // Fall back to HTML produced by textutil, which every Office format can + // be converted to. + if let html = try? html(from: url), let data = html.data(using: .utf8) { + return try? NSAttributedString( + data: data, + options: [.documentType: NSAttributedString.DocumentType.html, + .characterEncoding: String.Encoding.utf8.rawValue], + documentAttributes: nil + ) + } + return nil + } + + private static func documentType(for url: URL) -> NSAttributedString.DocumentType { + switch FileKind.canonicalExtension(url.pathExtension) { + case "rtf": return .rtf + case "rtfd": return .rtfd + case "html": return .html + case "doc": return .docFormat + case "docx": return .officeOpenXML + case "odt": return .openDocument + default: return .plain + } + } + + static func htmlDocument(fromPlainText text: String, title: String) -> String { + let paragraphs = text + .components(separatedBy: "\n\n") + .map { $0.trimmed } + .filter { !$0.isEmpty } + .map { "

    " + Markdown.escape($0).replacingOccurrences(of: "\n", with: "
    ") + "

    " } + .joined(separator: "\n") + return """ + \(Markdown.escape(title)) + + \(paragraphs) + """ + } + + static func write(_ text: String, to url: URL) throws { + try text.write(to: url, atomically: true, encoding: .utf8) + } + + /// Converts text/HTML to an Office format with `textutil`. + static func textutilConvert(_ source: URL, to format: Format, at destination: URL, cancellation: Cancellation? = nil) throws { + guard let identifier = textutilIdentifier(for: format) else { + throw ConversionError.unsupported("textutil cannot write \(format.displayName).") + } + try Shell.run( + Toolchain.textutil, + ["-convert", identifier, "-output", destination.path, source.path], + cancellation: cancellation + ) + } + + static func textutilConvert(sourceHTML: String, to format: Format, at destination: URL, cancellation: Cancellation? = nil) throws { + let temporary = FileManager.default.temporaryDirectory + .appendingPathComponent("konverter-\(UUID().uuidString).html") + defer { try? FileManager.default.removeItem(at: temporary) } + try sourceHTML.write(to: temporary, atomically: true, encoding: .utf8) + try textutilConvert(temporary, to: format, at: destination, cancellation: cancellation) + } + + private static func textutilIdentifier(for format: Format) -> String? { + switch format { + case .txt: return "txt" + case .html: return "html" + case .rtf: return "rtf" + case .docx: return "docx" + case .doc: return "doc" + case .odt: return "odt" + default: return nil + } + } + + // MARK: - PDF layout + + /// Paginates an attributed string into a PDF at A4 with 56 pt margins. + static func writePDF(_ attributed: NSAttributedString, to url: URL, pageSize: CGSize = CGSize(width: 595, height: 842)) throws { + let margin: CGFloat = 56 + var mediaBox = CGRect(origin: .zero, size: pageSize) + guard let context = CGContext(url as CFURL, mediaBox: &mediaBox, nil) else { + throw ConversionError.failed("Could not create \(url.lastPathComponent).") + } + + let text = NSMutableAttributedString(attributedString: attributed) + if text.length == 0 { + text.append(NSAttributedString(string: " ")) + } + // A readable default for documents that carry no fonts. + text.enumerateAttribute(.font, in: NSRange(location: 0, length: text.length)) { value, range, _ in + if value == nil { + text.addAttribute(.font, value: NSFont.systemFont(ofSize: 12), range: range) + } + } + + let framesetter = CTFramesetterCreateWithAttributedString(text as CFAttributedString) + let textRect = CGRect( + x: margin, + y: margin, + width: pageSize.width - margin * 2, + height: pageSize.height - margin * 2 + ) + let path = CGPath(rect: textRect, transform: nil) + + var cursor = 0 + let total = text.length + repeat { + context.beginPDFPage(nil) + let frame = CTFramesetterCreateFrame( + framesetter, + CFRange(location: cursor, length: 0), + path, + nil + ) + CTFrameDraw(frame, context) + context.endPDFPage() + + let visible = CTFrameGetVisibleStringRange(frame) + if visible.length <= 0 { break } + cursor = visible.location + visible.length + } while cursor < total + + context.closePDF() + } + + static func writePDF(fromText text: String, to url: URL, pageSize: CGSize = CGSize(width: 595, height: 842)) throws { + let style = NSMutableParagraphStyle() + style.lineSpacing = 3 + let attributed = NSAttributedString( + string: text, + attributes: [ + .font: NSFont.systemFont(ofSize: 12), + .paragraphStyle: style + ] + ) + try writePDF(attributed, to: url, pageSize: pageSize) + } +} diff --git a/Sources/Konverter/Convert/ToolRunner.swift b/Sources/Konverter/Convert/ToolRunner.swift new file mode 100644 index 0000000..2a2cc9a --- /dev/null +++ b/Sources/Konverter/Convert/ToolRunner.swift @@ -0,0 +1,446 @@ +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") + ) + try PDFConverter.merge(inputs, 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") + } + } + + // 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") + } +} diff --git a/Sources/Konverter/Core/Actions.swift b/Sources/Konverter/Core/Actions.swift new file mode 100644 index 0000000..6fb44df --- /dev/null +++ b/Sources/Konverter/Core/Actions.swift @@ -0,0 +1,144 @@ +import AppKit +import Foundation + +/// The Control wheel: things you can do with a file that are not conversions. +enum ActionID: String, CaseIterable, Identifiable, Codable { + case airdrop, float, copy, move, copyPath, terminal, revealInFinder + + var id: String { rawValue } + + var title: String { + switch self { + case .airdrop: return "AirDrop" + case .float: return "Float" + case .copy: return "Copy" + case .move: return "Move" + case .copyPath: return "Copy Path" + case .terminal: return "Terminal" + case .revealInFinder: return "Reveal" + } + } + + var symbol: String { + switch self { + case .airdrop: return "airdrop" + case .float: return "macwindow.on.rectangle" + case .copy: return "doc.on.doc" + case .move: return "arrow.right.doc.on.clipboard" + case .copyPath: return "text.cursor" + case .terminal: return "terminal" + case .revealInFinder: return "folder" + } + } +} + +enum ActionError: LocalizedError { + case needsSingleFile + case noDestination + case unavailable(String) + + var errorDescription: String? { + switch self { + case .needsSingleFile: return "This action works on one file at a time." + case .noDestination: return "No destination folder was chosen." + case .unavailable(let reason): return reason + } + } +} + +/// File actions never touch the file's contents, and never use a conversion +/// slot — matching Tangerine's behaviour. +enum FileActions { + @MainActor + static func perform(_ action: ActionID, urls: [URL], settings: Settings = .shared) async throws { + guard !urls.isEmpty else { throw ActionError.noDestination } + switch action { + case .airdrop: + try performAirDrop(urls: urls) + case .float: + FloatPanel.shared.show(urls: urls) + case .copy: + if let destination = try await chooseDestination() { + try transfer(urls: urls, to: destination, moving: false) + } + case .move: + if let destination = try await chooseDestination() { + try transfer(urls: urls, to: destination, moving: true) + } + case .copyPath: + copyPaths(urls: urls) + case .terminal: + try openTerminal(urls: urls) + case .revealInFinder: + NSWorkspace.shared.activateFileViewerSelecting(urls) + } + } + + @MainActor + private static func performAirDrop(urls: [URL]) throws { + guard let service = NSSharingService(named: .sendViaAirDrop) else { + throw ActionError.unavailable("AirDrop is not available on this Mac.") + } + guard service.canPerform(withItems: urls) else { + throw ActionError.unavailable("AirDrop cannot send this selection.") + } + service.perform(withItems: urls) + } + + @MainActor + private static func chooseDestination() async throws -> URL? { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.canCreateDirectories = true + panel.prompt = "Choose" + panel.message = "Choose a destination folder" + panel.directoryURL = Settings.shared.resolvedOutputFolder + NSApp.activate(ignoringOtherApps: true) + return panel.runModal() == .OK ? panel.url : nil + } + + private static func transfer(urls: [URL], to destination: URL, moving: Bool) throws { + let manager = FileManager.default + for url in urls { + var target = destination.appendingPathComponent(url.lastPathComponent) + target = OutputNaming.availableURL(for: target) + do { + if moving { + try manager.moveItem(at: url, to: target) + } else { + try manager.copyItem(at: url, to: target) + } + } catch { + throw ActionError.unavailable("Could not \(moving ? "move" : "copy") \(url.lastPathComponent): \(error.localizedDescription)") + } + } + } + + @MainActor + private static func copyPaths(urls: [URL]) { + let text = urls.map(\.path).joined(separator: "\n") + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + Notifier.flash( + title: urls.count == 1 ? "Path copied" : "\(urls.count) paths copied", + body: text.count > 120 ? String(text.prefix(120)) + "…" : text + ) + } + + @MainActor + private static func openTerminal(urls: [URL]) throws { + let directory = urls[0].deletingLastPathComponent() + let terminal = URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app") + let configuration = NSWorkspace.OpenConfiguration() + NSWorkspace.shared.open([directory], withApplicationAt: terminal, configuration: configuration) { _, error in + if let error { + DispatchQueue.main.async { + Notifier.error("Terminal", error.localizedDescription) + } + } + } + } +} diff --git a/Sources/Konverter/Core/Format.swift b/Sources/Konverter/Core/Format.swift new file mode 100644 index 0000000..5dc6a0a --- /dev/null +++ b/Sources/Konverter/Core/Format.swift @@ -0,0 +1,296 @@ +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 } + } +} diff --git a/Sources/Konverter/Core/JobStore.swift b/Sources/Konverter/Core/JobStore.swift new file mode 100644 index 0000000..8fbc167 --- /dev/null +++ b/Sources/Konverter/Core/JobStore.swift @@ -0,0 +1,285 @@ +import AppKit +import Foundation + +enum JobTarget { + case format(Format) + case tool(ToolID) + case action(ActionID) +} + +enum JobStatus { + case running + case finished + case failed + case cancelled + + var isTerminal: Bool { self != .running } +} + +struct Job: Identifiable { + let id = UUID() + var title: String + var detail: String = "" + var progress: Double = 0 + var status: JobStatus = .running + var outputs: [URL] = [] + var errorMessage: String? + var message: String? + var messageTitle: String? + var startedAt = Date() + var origin: CGPoint? + var target: JobTarget + var sourceCount: Int +} + +/// Runs conversions off the main thread and publishes their state to the +/// progress panel. Two jobs run at once; the queue keeps heavy video work from +/// starving the UI. +final class JobStore: ObservableObject { + static let shared = JobStore() + + @Published private(set) var jobs: [Job] = [] + @Published var panelVisible = false + + private var tokens: [UUID: Cancellation] = [:] + private let queue: OperationQueue = { + let queue = OperationQueue() + queue.name = "dev.tzgyn.konverter.jobs" + queue.maxConcurrentOperationCount = 2 + queue.qualityOfService = .userInitiated + return queue + }() + + private init() {} + + var activeCount: Int { + jobs.filter { !$0.status.isTerminal }.count + } + + func submit(urls: [URL], target: JobTarget, params: ToolParams? = nil, origin: CGPoint? = nil) { + guard !urls.isEmpty else { return } + let token = Cancellation() + let job = Job( + title: title(for: target, urls: urls), + origin: origin, + target: target, + sourceCount: urls.count + ) + jobs.append(job) + tokens[job.id] = token + + if Settings.shared.showProgressWindow { + panelVisible = true + JobPanelController.shared.refresh(store: self) + } + + queue.addOperation { [weak self] in + self?.execute(jobID: job.id, urls: urls, target: target, params: params, token: token) + } + } + + func cancel(_ id: UUID) { + tokens[id]?.cancel() + mutate(id) { job in + if job.status == .running { job.detail = "Cancelling…" } + } + } + + func cancelAll() { + for (id, token) in tokens { + token.cancel() + mutate(id) { job in + if job.status == .running { job.detail = "Cancelling…" } + } + } + } + + func dismissFinished() { + jobs.removeAll { $0.status.isTerminal } + if jobs.isEmpty { panelVisible = false } + JobPanelController.shared.refresh(store: self) + } + + func remove(_ id: UUID) { + jobs.removeAll { $0.id == id } + if jobs.isEmpty { panelVisible = false } + JobPanelController.shared.refresh(store: self) + } + + // MARK: - Execution + + private func execute(jobID: UUID, urls: [URL], target: JobTarget, params: ToolParams?, token: Cancellation) { + do { + switch target { + case .action(let action): + try performAction(action, urls: urls, token: token, jobID: jobID) + + case .format(let format): + var outputs: [URL] = [] + var firstError: Error? + for (index, url) in urls.enumerated() { + try token.check() + let destination = OutputNaming.destination(for: url, format: format) + mutate(jobID) { job in + job.detail = urls.count > 1 ? "\(url.lastPathComponent) → \(format.displayName)" : "Converting to \(format.displayName)" + } + do { + let produced = try Converter.convert( + source: url, + to: format, + at: destination, + cancellation: token, + progress: { [weak self] fraction in + self?.setProgress(jobID, (Double(index) + fraction) / Double(urls.count)) + } + ) + outputs.append(contentsOf: produced) + } catch is CancellationError { + throw CancellationError() + } catch { + if firstError == nil { firstError = error } + } + } + if outputs.isEmpty, let firstError { throw firstError } + if let firstError { + finish(jobID, outputs: outputs, error: firstError) + } else { + finish(jobID, outputs: outputs, error: nil) + } + + case .tool(let tool): + let resolved = params ?? Settings.shared.params(for: tool) + let outcome = try ToolRunner.run( + tool: tool, + inputs: urls, + params: resolved, + cancellation: token, + progress: { [weak self] fraction in + self?.setProgress(jobID, fraction) + } + ) + finish(jobID, outputs: outcome.outputs, error: nil, outcome: outcome) + } + } catch is CancellationError { + mutate(jobID) { job in + job.status = .cancelled + job.detail = "Cancelled" + } + tokens.removeValue(forKey: jobID) + DispatchQueue.main.async { JobPanelController.shared.refresh(store: self) } + } catch { + finish(jobID, outputs: [], error: error) + } + } + + private func performAction(_ action: ActionID, urls: [URL], token: Cancellation, jobID: UUID) throws { + var failure: Error? + let ready = DispatchSemaphore(value: 0) + Task { @MainActor in + do { + try await FileActions.perform(action, urls: urls) + } catch { + failure = error + } + ready.signal() + } + ready.wait() + try token.check() + if let failure { throw failure } + mutate(jobID) { job in + job.detail = "Done" + } + } + + // MARK: - State updates + + private func title(for target: JobTarget, urls: [URL]) -> String { + let name = urls.count == 1 + ? urls[0].lastPathComponent + : "\(urls.count) files" + switch target { + case .format(let format): return "\(name) → \(format.displayName)" + case .tool(let tool): return "\(tool.title) · \(name)" + case .action(let action): return "\(action.title) · \(name)" + } + } + + private func setProgress(_ id: UUID, _ value: Double) { + mutate(id, throttle: true) { job in + job.progress = max(job.progress, min(1, value)) + } + } + + private func finish(_ id: UUID, outputs: [URL], error: Error?, outcome: ToolOutcome? = nil) { + tokens.removeValue(forKey: id) + mutate(id) { job in + job.outputs = outputs + job.progress = 1 + if let error { + job.status = .failed + job.errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + job.detail = "Failed" + } else { + job.status = .finished + job.detail = outputs.count > 1 ? "\(outputs.count) files" : "Done" + job.message = outcome?.message + job.messageTitle = outcome?.messageTitle + } + } + + guard let job = jobs.first(where: { $0.id == id }) else { return } + + if let error = job.errorMessage { + let title = job.title + DispatchQueue.main.async { + Notifier.error(title, error) + } + } else if let message = job.message { + let title = job.messageTitle ?? "Result" + DispatchQueue.main.async { + ResultWindowController.shared.show(title: title, body: message) + } + } + + if Settings.shared.playSound, job.status == .finished, error == nil { + DispatchQueue.main.async { + NSSound(named: "Glass")?.play() + } + } + + let revealable = job.outputs + if Settings.shared.revealWhenFinished, !revealable.isEmpty, error == nil { + DispatchQueue.main.async { + NSWorkspace.shared.activateFileViewerSelecting(revealable) + // The progress panel would otherwise stay on top of Finder. + JobPanelController.shared.dismissIfAllFinished(store: self) + } + } else { + DispatchQueue.main.async { + JobPanelController.shared.refresh(store: self) + } + } + } + + /// Applies a mutation on the main thread; `throttle` skips updates that + /// would only nudge the progress bar by a hair. + private func mutate(_ id: UUID, throttle: Bool = false, _ body: @escaping (inout Job) -> Void) { + let apply = { [weak self] in + guard let self, let index = self.jobs.firstIndex(where: { $0.id == id }) else { return } + if throttle { + let before = self.jobs[index].progress + body(&self.jobs[index]) + let after = self.jobs[index].progress + if abs(after - before) < 0.01, after < 1 { return } + } else { + body(&self.jobs[index]) + } + JobPanelController.shared.refresh(store: self) + } + if Thread.isMainThread { + apply() + } else { + DispatchQueue.main.async(execute: apply) + } + } +} diff --git a/Sources/Konverter/Core/OutputNaming.swift b/Sources/Konverter/Core/OutputNaming.swift new file mode 100644 index 0000000..d2cc962 --- /dev/null +++ b/Sources/Konverter/Core/OutputNaming.swift @@ -0,0 +1,66 @@ +import Foundation + +/// Where a produced file goes, and what it is called. +enum OutputNaming { + /// ``name.pdf`` for ``photo.jpg`` — appends ``-2``, ``-3`` … when the + /// target already exists (unless the user asked to overwrite). + static func destination(for source: URL, format: Format, settings: Settings = .shared) -> URL { + let base = source.deletingPathExtension().lastPathComponent + let directory: URL + switch settings.outputLocation { + case .besideOriginal: + directory = source.deletingLastPathComponent() + case .chosenFolder: + let folder = settings.resolvedOutputFolder + directory = FileManager.default.fileExists(atPath: folder.path) + ? folder + : source.deletingLastPathComponent() + } + let candidate = directory.appendingPathComponent(base).appendingPathExtension(format.ext) + return settings.conflictPolicy == .overwrite ? candidate : availableURL(for: candidate) + } + + /// A sibling of any output a tool produces (e.g. ``photo-compressed.jpg``). + static func sibling(for source: URL, suffix: String, extension ext: String? = nil) -> URL { + let base = source.deletingPathExtension().lastPathComponent + let directory = source.deletingLastPathComponent() + let resolvedExtension = ext ?? source.pathExtension + return availableURL( + for: directory.appendingPathComponent(base + suffix).appendingPathExtension(resolvedExtension) + ) + } + + /// A working directory for multi-file outputs (split pages, extracted + /// images, unarchived trees). + static func artifactDirectory(for source: URL, suffix: String) -> URL { + let base = source.deletingPathExtension().lastPathComponent + let directory = source.deletingLastPathComponent() + let url = directory.appendingPathComponent(base + suffix, isDirectory: true) + var candidate = url + var index = 2 + while FileManager.default.fileExists(atPath: candidate.path) { + candidate = directory.appendingPathComponent("\(base + suffix) \(index)", isDirectory: true) + index += 1 + } + return candidate + } + + /// ``foo 2.txt``, ``foo 3.txt`` … until the name is free. + static func availableURL(for url: URL) -> URL { + guard FileManager.default.fileExists(atPath: url.path) else { return url } + let directory = url.deletingLastPathComponent() + let ext = url.pathExtension + let base = url.deletingPathExtension().lastPathComponent + var index = 2 + while true { + let name = ext.isEmpty ? "\(base) \(index)" : "\(base) \(index).\(ext)" + let candidate = directory.appendingPathComponent(name) + if !FileManager.default.fileExists(atPath: candidate.path) { return candidate } + index += 1 + } + } + + static func uniqueChild(of directory: URL, suggestedName: String) -> URL { + availableURL(for: directory.appendingPathComponent(suggestedName)) + } +} diff --git a/Sources/Konverter/Core/Settings.swift b/Sources/Konverter/Core/Settings.swift new file mode 100644 index 0000000..4365837 --- /dev/null +++ b/Sources/Konverter/Core/Settings.swift @@ -0,0 +1,201 @@ +import AppKit +import Foundation +import ServiceManagement + +/// Trigger keys. Tangerine's split: Shift converts, Command fills the wheel +/// with file tools, Control with file actions. +enum TriggerKey: String, CaseIterable, Identifiable, Codable { + case shift, control, option, command, function + + var id: String { rawValue } + + var title: String { + switch self { + case .shift: return "Shift ⇧" + case .control: return "Control ⌃" + case .option: return "Option ⌥" + case .command: return "Command ⌘" + case .function: return "Function fn" + } + } + + var flag: NSEvent.ModifierFlags { + switch self { + case .shift: return .shift + case .control: return .control + case .option: return .option + case .command: return .command + case .function: return .function + } + } +} + +enum OutputLocation: String, CaseIterable, Identifiable, Codable { + case besideOriginal, chosenFolder + + var id: String { rawValue } + var title: String { + switch self { + case .besideOriginal: return "Beside the original" + case .chosenFolder: return "In a folder I choose" + } + } +} + +enum ConflictPolicy: String, CaseIterable, Identifiable, Codable { + case uniqueName, overwrite + + var id: String { rawValue } + var title: String { + switch self { + case .uniqueName: return "Keep both (add a number)" + case .overwrite: return "Replace the existing file" + } + } +} + +enum WheelStyle: String, CaseIterable, Identifiable, Codable { + case radial, list + + var id: String { rawValue } + var title: String { + switch self { + case .radial: return "Wheel" + case .list: return "List" + } + } +} + +/// All preferences. Stored in `UserDefaults` under `konverter.*` so the app +/// stays a single self-contained bundle with no sandbox container. +final class Settings: ObservableObject { + static let shared = Settings() + + private let defaults = UserDefaults.standard + + @Published var convertKey: TriggerKey { didSet { write(convertKey, .convertKey) } } + @Published var toolsKey: TriggerKey { didSet { write(toolsKey, .toolsKey) } } + @Published var actionsKey: TriggerKey { didSet { write(actionsKey, .actionsKey) } } + + /// When on, the tools wheel needs Shift + toolsKey so a plain ⌘-drag in + /// other apps does not pop the wheel. + @Published var requireShiftForTools: Bool { didSet { write(requireShiftForTools, .requireShiftForTools) } } + + @Published var enabled: Bool { didSet { write(enabled, .enabled) } } + @Published var allowActionsOnFolders: Bool { didSet { write(allowActionsOnFolders, .allowActionsOnFolders) } } + + @Published var outputLocation: OutputLocation { didSet { write(outputLocation.rawValue, .outputLocation) } } + @Published var outputFolderPath: String { didSet { write(outputFolderPath, .outputFolderPath) } } + @Published var conflictPolicy: ConflictPolicy { didSet { write(conflictPolicy.rawValue, .conflictPolicy) } } + + @Published var wheelStyle: WheelStyle { didSet { write(wheelStyle.rawValue, .wheelStyle) } } + @Published var wheelRadius: Double { didSet { write(wheelRadius, .wheelRadius) } } + @Published var showLabels: Bool { didSet { write(showLabels, .showLabels) } } + @Published var dimBackground: Bool { didSet { write(dimBackground, .dimBackground) } } + + @Published var showProgressWindow: Bool { didSet { write(showProgressWindow, .showProgressWindow) } } + @Published var revealWhenFinished: Bool { didSet { write(revealWhenFinished, .revealWhenFinished) } } + @Published var playSound: Bool { didSet { write(playSound, .playSound) } } + @Published var useExternalTools: Bool { didSet { write(useExternalTools, .useExternalTools) } } + + @Published var openAtLogin: Bool { didSet { write(openAtLogin, .openAtLogin) } } + + /// Remembers per-tool form values so repeat jobs are still one drop. + private var toolParamsCache: [String: [String: String]] = [:] + + private init() { + convertKey = Settings.read(.convertKey, default: .shift) + toolsKey = Settings.read(.toolsKey, default: .command) + actionsKey = Settings.read(.actionsKey, default: .control) + requireShiftForTools = Settings.readBool(.requireShiftForTools, default: true) + enabled = Settings.readBool(.enabled, default: true) + allowActionsOnFolders = Settings.readBool(.allowActionsOnFolders, default: true) + outputLocation = Settings.read(.outputLocation, default: .besideOriginal) + outputFolderPath = Settings.readString(.outputFolderPath, default: "") + conflictPolicy = Settings.read(.conflictPolicy, default: .uniqueName) + wheelStyle = Settings.read(.wheelStyle, default: .radial) + wheelRadius = Settings.readDouble(.wheelRadius, default: 168) + showLabels = Settings.readBool(.showLabels, default: true) + dimBackground = Settings.readBool(.dimBackground, default: false) + showProgressWindow = Settings.readBool(.showProgressWindow, default: true) + revealWhenFinished = Settings.readBool(.revealWhenFinished, default: true) + playSound = Settings.readBool(.playSound, default: true) + useExternalTools = Settings.readBool(.useExternalTools, default: true) + openAtLogin = LoginItem.isEnabled + } + + var resolvedOutputFolder: URL { + if outputLocation == .chosenFolder, !outputFolderPath.isEmpty { + return URL(fileURLWithPath: outputFolderPath) + } + return FileManager.default.homeDirectoryForCurrentUser + } + + // MARK: - Tool parameter memory + + func params(for tool: ToolID) -> ToolParams { + var params = ToolParams.defaults(for: tool) + if let stored = toolParamsCache[tool.rawValue] { + for (key, value) in stored { params[key] = value } + } + return params + } + + func store(params: ToolParams, for tool: ToolID) { + toolParamsCache[tool.rawValue] = params.dictionary + } + + // MARK: - Persistence + + private enum Key: String { + case convertKey, toolsKey, actionsKey, requireShiftForTools, enabled, allowActionsOnFolders + case outputLocation, outputFolderPath, conflictPolicy + case wheelStyle, wheelRadius, showLabels, dimBackground + case showProgressWindow, revealWhenFinished, playSound, useExternalTools, openAtLogin + } + + private func write(_ value: T, _ key: Key) { + defaults.set(value, forKey: "konverter." + key.rawValue) + } + + private static func read(_ key: Key, default fallback: T) -> T where T.RawValue == String { + guard let raw = UserDefaults.standard.string(forKey: "konverter." + key.rawValue), + let value = T(rawValue: raw) else { return fallback } + return value + } + + private static func readString(_ key: Key, default fallback: String) -> String { + UserDefaults.standard.string(forKey: "konverter." + key.rawValue) ?? fallback + } + + private static func readBool(_ key: Key, default fallback: Bool) -> Bool { + guard UserDefaults.standard.object(forKey: "konverter." + key.rawValue) != nil else { return fallback } + return UserDefaults.standard.bool(forKey: "konverter." + key.rawValue) + } + + private static func readDouble(_ key: Key, default fallback: Double) -> Double { + guard UserDefaults.standard.object(forKey: "konverter." + key.rawValue) != nil else { return fallback } + return UserDefaults.standard.double(forKey: "konverter." + key.rawValue) + } +} + +enum LoginItem { + static var isEnabled: Bool { + SMAppService.mainApp.status == .enabled + } + + @discardableResult + static func set(_ enabled: Bool) -> Bool { + let service = SMAppService.mainApp + do { + if enabled { + if service.status != .enabled { try service.register() } + } else { + if service.status == .enabled { try service.unregister() } + } + return true + } catch { + return false + } + } +} diff --git a/Sources/Konverter/Core/Shell.swift b/Sources/Konverter/Core/Shell.swift new file mode 100644 index 0000000..d418d88 --- /dev/null +++ b/Sources/Konverter/Core/Shell.swift @@ -0,0 +1,256 @@ +import Foundation + +/// A cooperative cancellation token shared between a job and the external +/// processes it spawns. Converters run off the main thread, so every mutation +/// is lock protected. +final class Cancellation { + private let lock = NSLock() + private var cancelled = false + private var processes: [Process] = [] + + var isCancelled: Bool { + lock.lock() + defer { lock.unlock() } + return cancelled + } + + func cancel() { + lock.lock() + cancelled = true + let running = processes + lock.unlock() + for process in running where process.isRunning { + process.terminate() + } + } + + func register(_ process: Process) { + lock.lock() + let alreadyCancelled = cancelled + if !alreadyCancelled { processes.append(process) } + lock.unlock() + if alreadyCancelled, process.isRunning { process.terminate() } + } + + func check() throws { + if isCancelled { throw CancellationError() } + } +} + +struct ShellError: LocalizedError { + let command: String + let status: Int32 + let stderr: String + + var errorDescription: String? { + let detail = stderr.trimmingCharacters(in: .whitespacesAndNewlines) + if detail.isEmpty { + return "\(command) exited with status \(status)." + } + // External tools can be very chatty; keep the first meaningful line. + let line = detail + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .last { !$0.isEmpty } ?? detail + return "\(command): \(line.prefix(400))" + } +} + +/// Thin wrapper around `Process` with deadlock-free pipe draining. +enum Shell { + struct Result { + let status: Int32 + let stdout: String + let stderr: String + } + + private final class Box { + var value: T + init(_ value: T) { self.value = value } + } + + /// Locate an executable by name, searching `extraPaths` first and then + /// `$PATH`. Returns the last match so Homebrew's copies win over system + /// stubs (e.g. the `/usr/bin/ffmpeg` shim that only opens the App Store). + static func find(_ name: String, extraPaths: [String] = []) -> String? { + var search = extraPaths + if let path = ProcessInfo.processInfo.environment["PATH"] { + search += path.split(separator: ":").map(String.init) + } + var found: String? + for directory in search { + let full = directory.hasSuffix("/") ? directory + name : directory + "/" + name + if FileManager.default.isExecutableFile(atPath: full) { + found = full + } + } + return found + } + + @discardableResult + static func run( + _ executable: String, + _ arguments: [String], + currentDirectory: URL? = nil, + cancellation: Cancellation? = nil + ) throws -> Result { + try cancellation?.check() + + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + if let currentDirectory { process.currentDirectoryURL = currentDirectory } + + let outPipe = Pipe() + let errPipe = Pipe() + process.standardOutput = outPipe + process.standardError = errPipe + process.standardInput = FileHandle.nullDevice + + do { + try process.run() + } catch { + throw ShellError( + command: (arguments.first ?? executable), + status: -1, + stderr: error.localizedDescription + ) + } + cancellation?.register(process) + + // Drain both pipes on separate queues: reading them in sequence can + // deadlock when a tool fills the buffer we are not reading yet. + let outBox = Box(Data()) + let errBox = Box(Data()) + let group = DispatchGroup() + DispatchQueue.global(qos: .utility).async(group: group) { + outBox.value = (try? outPipe.fileHandleForReading.readToEnd()) ?? Data() + } + DispatchQueue.global(qos: .utility).async(group: group) { + errBox.value = (try? errPipe.fileHandleForReading.readToEnd()) ?? Data() + } + group.wait() + process.waitUntilExit() + + let result = Result( + status: process.terminationStatus, + stdout: String(data: outBox.value, encoding: .utf8) ?? "", + stderr: String(data: errBox.value, encoding: .utf8) ?? "" + ) + + try cancellation?.check() + if result.status != 0 { + throw ShellError( + command: (arguments.first ?? executable), + status: result.status, + stderr: result.stderr.isEmpty ? result.stdout : result.stderr + ) + } + return result + } + + /// Best-effort variant used for optional probes (`ffprobe`, `mdls`, …). + static func output(_ executable: String, _ arguments: [String]) -> String? { + (try? run(executable, arguments))?.stdout + } + + /// Runs a tool whose stdout is binary (compressed streams) and returns it. + static func runData( + _ executable: String, + _ arguments: [String], + cancellation: Cancellation? = nil + ) throws -> Data { + try cancellation?.check() + + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + let outPipe = Pipe() + let errPipe = Pipe() + process.standardOutput = outPipe + process.standardError = errPipe + process.standardInput = FileHandle.nullDevice + + do { + try process.run() + } catch { + throw ShellError(command: arguments.first ?? executable, status: -1, stderr: error.localizedDescription) + } + cancellation?.register(process) + + let outBox = Box(Data()) + let errBox = Box(Data()) + let group = DispatchGroup() + DispatchQueue.global(qos: .utility).async(group: group) { + outBox.value = (try? outPipe.fileHandleForReading.readToEnd()) ?? Data() + } + DispatchQueue.global(qos: .utility).async(group: group) { + errBox.value = (try? errPipe.fileHandleForReading.readToEnd()) ?? Data() + } + group.wait() + process.waitUntilExit() + + let stderr = String(data: errBox.value, encoding: .utf8) ?? "" + try cancellation?.check() + if process.terminationStatus != 0 { + throw ShellError(command: arguments.first ?? executable, status: process.terminationStatus, stderr: stderr) + } + return outBox.value + } + + /// Runs a tool and reports progress parsed from `key=value` lines on + /// stdout (what `ffmpeg -progress pipe:1` emits). + static func runReportingProgress( + _ executable: String, + _ arguments: [String], + cancellation: Cancellation? = nil, + onProgress: @escaping (Double, String) -> Void + ) throws { + try cancellation?.check() + + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + let outPipe = Pipe() + let errPipe = Pipe() + process.standardOutput = outPipe + process.standardError = errPipe + process.standardInput = FileHandle.nullDevice + + let errBox = Box(Data()) + DispatchQueue.global(qos: .utility).async { + errBox.value = (try? errPipe.fileHandleForReading.readToEnd()) ?? Data() + } + + let buffer = Box(Data()) + let lock = NSLock() + outPipe.fileHandleForReading.readabilityHandler = { handle in + let chunk = handle.availableData + guard !chunk.isEmpty else { return } + lock.lock() + buffer.value.append(chunk) + let text = String(data: buffer.value, encoding: .utf8) ?? "" + var lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + let remainder = lines.popLast() ?? "" + buffer.value = Data(remainder.utf8) + lock.unlock() + for line in lines { onProgress(0, line) } + } + + do { + try process.run() + } catch { + outPipe.fileHandleForReading.readabilityHandler = nil + throw ShellError(command: arguments.first ?? executable, status: -1, stderr: error.localizedDescription) + } + cancellation?.register(process) + process.waitUntilExit() + outPipe.fileHandleForReading.readabilityHandler = nil + + let stderr = String(data: errBox.value, encoding: .utf8) ?? "" + try cancellation?.check() + if process.terminationStatus != 0 { + throw ShellError(command: arguments.first ?? executable, status: process.terminationStatus, stderr: stderr) + } + } +} diff --git a/Sources/Konverter/Core/Toolchain.swift b/Sources/Konverter/Core/Toolchain.swift new file mode 100644 index 0000000..5f9a899 --- /dev/null +++ b/Sources/Konverter/Core/Toolchain.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Optional external helpers. Konverter works without them — they only widen +/// the conversion matrix (formats Apple's frameworks cannot write) and are +/// detected once at launch. +enum Toolchain { + private static let homebrewPaths = ["/opt/homebrew/bin", "/usr/local/bin"] + + /// ffmpeg/ffprobe: MKV, WebM, AVI, WMV, MP3, FLAC, OGG, Opus, WMA. + static let ffmpeg: String? = Shell.find("ffmpeg", extraPaths: homebrewPaths) + static let ffprobe: String? = Shell.find("ffprobe", extraPaths: homebrewPaths) + + /// LibreOffice: PPT/PPTX rendering, XLS/XLSX/ODS, PAGES, Keynote. + static let soffice: String? = { + let candidates = [ + "/Applications/LibreOffice.app/Contents/MacOS/soffice", + "/Applications/LibreOffice.app/Contents/MacOS/soffice.bin" + ] + for candidate in candidates where FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + return Shell.find("soffice", extraPaths: homebrewPaths) + }() + + static var hasFFmpeg: Bool { ffmpeg != nil } + static var hasLibreOffice: Bool { soffice != nil } + + /// `curl`-free, sandbox-free best-effort image rasteriser for SVG and other + /// formats ImageIO refuses to decode but Quick Look understands. + static let qlmanage: String? = Shell.find("qlmanage", extraPaths: ["/usr/bin"]) + + static let ditto = "/usr/bin/ditto" + static let tar = "/usr/bin/tar" + static let gzip = "/usr/bin/gzip" + static let gunzip = "/usr/bin/gunzip" + static let textutil = "/usr/bin/textutil" + static let afconvert = "/usr/bin/afconvert" + static let zip = "/usr/bin/zip" + static let unzip = "/usr/bin/unzip" + + /// Human readable summary for the About/Settings pane. + static var summary: String { + var parts: [String] = ["Core (ImageIO, PDFKit, AVFoundation)"] + if hasFFmpeg { parts.append("ffmpeg") } + if hasLibreOffice { parts.append("LibreOffice") } + return parts.joined(separator: " · ") + } + + static var missingToolAdvice: String? { + var advice: [String] = [] + if !hasFFmpeg { advice.append("brew install ffmpeg") } + if !hasLibreOffice { advice.append("brew install --cask libreoffice") } + return advice.isEmpty ? nil : advice.joined(separator: " · ") + } +} diff --git a/Sources/Konverter/Core/Tools.swift b/Sources/Konverter/Core/Tools.swift new file mode 100644 index 0000000..ef35d60 --- /dev/null +++ b/Sources/Konverter/Core/Tools.swift @@ -0,0 +1,333 @@ +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 + } + } +} diff --git a/Sources/Konverter/Core/WheelOption.swift b/Sources/Konverter/Core/WheelOption.swift new file mode 100644 index 0000000..6df3c98 --- /dev/null +++ b/Sources/Konverter/Core/WheelOption.swift @@ -0,0 +1,141 @@ +import AppKit +import Foundation + +/// One wedge of the wheel. +struct WheelItem: Identifiable { + enum Payload { + case format(Format) + case tool(ToolID) + case action(ActionID) + } + + let id: String + let payload: Payload + let title: String + let subtitle: String? + let symbol: String + let availability: Availability + + var isEnabled: Bool { availability.isAvailable } +} + +enum WheelKind { + case convert + case tools + case actions + + var title: String { + switch self { + case .convert: return "Convert" + case .tools: return "Tools" + case .actions: return "Actions" + } + } +} + +/// Builds the wheel contents for whatever is being dragged. +enum WheelBuilder { + static func kind(for modifiers: NSEvent.ModifierFlags, settings: Settings = .shared) -> WheelKind? { + if modifiers.contains(settings.actionsKey.flag) { return .actions } + if modifiers.contains(settings.toolsKey.flag) { + if settings.requireShiftForTools, !modifiers.contains(.shift) { return nil } + return .tools + } + if modifiers.contains(settings.convertKey.flag) { return .convert } + return nil + } + + static func items(kind: WheelKind, urls: [URL], settings: Settings = .shared) -> [WheelItem] { + switch kind { + case .convert: + return conversionItems(urls: urls) + case .tools: + return toolItems(urls: urls) + case .actions: + return actionItems(urls: urls, settings: settings) + } + } + + private static func conversionItems(urls: [URL]) -> [WheelItem] { + let categories = urls.map { FileKind.category(of: $0) } + let formats = ConversionCatalog.outputs(for: categories) + guard let category = categories.first, !formats.isEmpty else { return [] } + + return formats.compactMap { format in + guard !ConversionCatalog.isRedundant(format, urls: urls) else { return nil } + let availability = Converter.availability(target: format, source: category) + return WheelItem( + id: "format-\(format.ext)", + payload: .format(format), + title: format.displayName, + subtitle: availability.isAvailable ? nil : availability.reason, + symbol: format.symbol, + availability: availability + ) + } + } + + private static func toolItems(urls: [URL]) -> [WheelItem] { + let categories = Set(urls.map { FileKind.category(of: $0) }) + guard categories.count == 1, let category = categories.first else { return [] } + + return ToolID.tools(for: category).compactMap { tool in + guard urls.count >= tool.minimumFileCount else { return nil } + var availability = Availability.ok + if tool.producesArtifacts, urls.count > 1 { + availability = .ok + } + if requiresFFmpeg(tool), !Toolchain.hasFFmpeg { + availability = .unavailable("Needs ffmpeg — brew install ffmpeg") + } + return WheelItem( + id: "tool-\(tool.rawValue)", + payload: .tool(tool), + title: tool.title, + subtitle: availability.isAvailable ? nil : availability.reason, + symbol: tool.symbol, + availability: availability + ) + } + } + + private static func requiresFFmpeg(_ tool: ToolID) -> Bool { + switch tool { + case .normalizeAudio: return true + default: return false + } + } + + private static func actionItems(urls: [URL], settings: Settings) -> [WheelItem] { + let allFolders = urls.allSatisfy { FileKind.category(of: $0) == .folder } + return ActionID.allCases.compactMap { action in + if allFolders, action != .airdrop, action != .copy, action != .move, + action != .copyPath, action != .terminal, action != .revealInFinder { + return nil + } + _ = settings + return WheelItem( + id: "action-\(action.rawValue)", + payload: .action(action), + title: action.title, + subtitle: nil, + symbol: action.symbol, + availability: .ok + ) + } + } + + /// A one-line description of the selection for the centre of the wheel. + static func summary(for urls: [URL]) -> String { + guard let first = urls.first else { return "" } + if urls.count > 1 { return "\(urls.count) files" } + return first.lastPathComponent + } + + static func icon(for urls: [URL]) -> NSImage { + guard let first = urls.first else { + return NSImage(systemSymbolName: "doc", accessibilityDescription: nil) ?? NSImage() + } + return NSWorkspace.shared.icon(forFile: first.path) + } +} diff --git a/Sources/Konverter/Drag/DragMonitor.swift b/Sources/Konverter/Drag/DragMonitor.swift new file mode 100644 index 0000000..e21043d --- /dev/null +++ b/Sources/Konverter/Drag/DragMonitor.swift @@ -0,0 +1,127 @@ +import AppKit +import Foundation + +/// Watches for drags anywhere on the system and reports them. Mouse events do +/// not require any privacy permission; key events (Escape, arrows) do, so the +/// wheel stays fully usable without them. +final class DragMonitor { + static let shared = DragMonitor() + + /// Called on every poll while a drag is in flight. + var onTick: (([URL], CGPoint, NSEvent.ModifierFlags) -> Void)? + /// Called once when a drag starts, with the files being dragged. + var onBegan: (([URL]) -> Void)? + /// Called when the drag finishes (drop or cancel). + var onEnded: ((CGPoint) -> Void)? + /// Called for keys pressed during a drag. + var onKey: ((UInt16, NSEvent.ModifierFlags) -> Bool)? + + private var globalMouse: Any? + private var localMouse: Any? + private var keyMonitor: Any? + private var timer: Timer? + private var isDragging = false + private var urls: [URL] = [] + + private init() {} + + func start() { + guard globalMouse == nil else { return } + + let mask: NSEvent.EventTypeMask = [.leftMouseDragged, .rightMouseDragged, .otherMouseDragged] + globalMouse = NSEvent.addGlobalMonitorForEvents(matching: mask) { [weak self] event in + self?.handleDrag(event) + } + // Drags that end up over our own window are delivered to us, so the + // global monitor never sees them. + localMouse = NSEvent.addLocalMonitorForEvents(matching: mask) { [weak self] event in + self?.handleDrag(event) + return event + } + + let upMask: NSEvent.EventTypeMask = [.leftMouseUp, .rightMouseUp, .otherMouseUp] + NSEvent.addGlobalMonitorForEvents(matching: upMask) { [weak self] _ in + self?.handleEnd() + } + NSEvent.addLocalMonitorForEvents(matching: upMask) { [weak self] event in + self?.handleEnd() + return event + } + + keyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self, self.isDragging else { return } + _ = self.onKey?(event.keyCode, event.modifierFlags) + } + } + + func stop() { + if let globalMouse { NSEvent.removeMonitor(globalMouse) } + if let localMouse { NSEvent.removeMonitor(localMouse) } + if let keyMonitor { NSEvent.removeMonitor(keyMonitor) } + globalMouse = nil + localMouse = nil + keyMonitor = nil + stopTimer() + } + + private func handleDrag(_ event: NSEvent) { + if !isDragging { + let dragged = DragMonitor.draggedFileURLs() + guard !dragged.isEmpty else { return } + isDragging = true + urls = dragged + onBegan?(dragged) + startTimer() + } + } + + private func handleEnd() { + guard isDragging else { return } + onEnded?(NSEvent.mouseLocation) + isDragging = false + urls = [] + stopTimer() + } + + private func startTimer() { + stopTimer() + let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in + guard let self, self.isDragging else { return } + self.onTick?(self.urls, NSEvent.mouseLocation, NSEvent.modifierFlags) + } + // `.common` keeps it firing even if a tracking loop is running. + RunLoop.main.add(timer, forMode: .common) + self.timer = timer + } + + private func stopTimer() { + timer?.invalidate() + timer = nil + } + + /// The files currently on the global drag pasteboard. Finder, Mail and most + /// apps publish real file URLs; apps that only promise a file are read too. + static func draggedFileURLs() -> [URL] { + let pasteboard = NSPasteboard(name: .drag) + guard let types = pasteboard.types else { return [] } + + let fileTypes: Set = [ + .fileURL, + .filePromise, + NSPasteboard.PasteboardType("NSFilenamesPboardType") + ] + guard types.contains(where: { fileTypes.contains($0) }) else { return [] } + + var urls: [URL] = [] + if let objects = pasteboard.readObjects( + forClasses: [NSURL.self], + options: [.urlReadingFileURLsOnly: true] + ) as? [URL] { + urls = objects + } + if urls.isEmpty, let paths = pasteboard.propertyList(forType: NSPasteboard.PasteboardType("NSFilenamesPboardType")) as? [String] { + urls = paths.map { URL(fileURLWithPath: $0) } + } + return urls.filter { FileManager.default.fileExists(atPath: $0.path) } + } +} diff --git a/Sources/Konverter/Drag/WheelController.swift b/Sources/Konverter/Drag/WheelController.swift new file mode 100644 index 0000000..aa78a24 --- /dev/null +++ b/Sources/Konverter/Drag/WheelController.swift @@ -0,0 +1,354 @@ +import AppKit +import Combine +import Foundation +import SwiftUI + +/// State behind the wheel: what is offered, what is highlighted, and what the +/// centre of the wheel says. +final class WheelModel: ObservableObject { + @Published var kind: WheelKind = .convert + @Published var items: [WheelItem] = [] + @Published var selection: Int? + @Published var summary: String = "" + @Published var subtitle: String = "" + @Published var icon: NSImage = NSImage() + @Published var radius: CGFloat = 168 + + var selectedItem: WheelItem? { + guard let selection, items.indices.contains(selection) else { return nil } + return items[selection] + } +} + +/// Accepts the drop. Being a real drag destination is what stops Finder from +/// moving the file, and it is how the wheel learns about the drop without +/// needing an Accessibility event tap. +final class DropCatcher: NSView { + var onMoved: ((CGPoint) -> Void)? + var onExit: (() -> Void)? + var onDrop: (() -> Bool)? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + registerForDraggedTypes([.fileURL, .filePromise, NSPasteboard.PasteboardType("NSFilenamesPboardType")]) + } + + required init?(coder: NSCoder) { nil } + + private func screenPoint(_ sender: NSDraggingInfo) -> CGPoint { + guard let window else { return NSEvent.mouseLocation } + let inWindow = sender.draggingLocation + return window.convertPoint(toScreen: inWindow) + } + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + onMoved?(screenPoint(sender)) + return .generic + } + + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + onMoved?(screenPoint(sender)) + return .generic + } + + override func draggingExited(_ sender: NSDraggingInfo?) { + onExit?() + } + + override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { true } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + onMoved?(screenPoint(sender)) + return onDrop?() ?? false + } +} + +/// Owns the overlay window and turns drags into jobs. +final class WheelController { + static let shared = WheelController() + + private let model = WheelModel() + private var window: NSWindow? + private var catcher: DropCatcher? + private var urls: [URL] = [] + private var isShowing = false + private var didHandleDrop = false + private var lastLocation: CGPoint = .zero + private var pendingDismiss: DispatchWorkItem? + + private init() {} + + // MARK: - Drag lifecycle + + func begin(urls: [URL]) { + self.urls = urls + didHandleDrop = false + } + + func tick(location: CGPoint, modifiers: NSEvent.ModifierFlags) { + guard !urls.isEmpty else { return } + guard let kind = WheelBuilder.kind(for: modifiers) else { + // No trigger key held: stay out of the way until one is pressed. + if !isShowing { model.selection = nil } + return + } + let items = WheelBuilder.items(kind: kind, urls: urls) + guard !items.isEmpty else { + dismiss() + return + } + if !isShowing || model.kind != kind || model.items.map(\.id) != items.map(\.id) { + present(kind: kind, items: items, at: location) + } else { + move(to: location) + } + } + + /// Drop committed by the drag destination. + @discardableResult + func commitDrop() -> Bool { + guard let item = model.selectedItem else { + dismiss() + return false + } + guard item.isEnabled else { + let reason = item.availability.reason ?? "Unavailable" + DispatchQueue.main.async { + Notifier.error(item.title, reason) + } + dismiss() + return true + } + didHandleDrop = true + let captured = urls + let origin = lastLocation + dismiss(animated: false) + WheelController.dispatch(item: item, urls: captured, origin: origin) + return true + } + + func end() { + guard isShowing else { return } + // Give the drag destination a beat to deliver the drop first. + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + if self.didHandleDrop { return } + if let item = self.model.selectedItem, item.isEnabled { + let captured = self.urls + let origin = self.lastLocation + self.dismiss(animated: false) + WheelController.dispatch(item: item, urls: captured, origin: origin) + } else { + self.dismiss() + } + } + pendingDismiss = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.22, execute: work) + } + + func cancel() { + pendingDismiss?.cancel() + dismiss() + } + + func handleKey(_ keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Bool { + guard isShowing else { return false } + switch keyCode { + case 53: // Escape + cancel() + return true + case 123, 126: // Left / Up + step(-1) + return true + case 124, 125: // Right / Down + step(1) + return true + case 36, 76: // Return / Enter + commitDrop() + return true + default: + return false + } + } + + private func step(_ delta: Int) { + guard !model.items.isEmpty else { return } + let count = model.items.count + let current = model.selection ?? 0 + model.selection = ((current + delta) % count + count) % count + } + + // MARK: - Window + + private func present(kind: WheelKind, items: [WheelItem], at location: CGPoint) { + pendingDismiss?.cancel() + + let radius = Self.radius(forItemCount: items.count) + let itemExtent: CGFloat = 86 + let size = (radius + itemExtent) * 2 + + model.kind = kind + model.items = items + model.radius = radius + model.summary = WheelBuilder.summary(for: urls) + model.subtitle = kind.title + model.icon = WheelBuilder.icon(for: urls) + + let frame = CGRect( + x: location.x - size / 2, + y: location.y - size / 2, + width: size, + height: size + ) + + let window: NSWindow + if let existing = self.window { + window = existing + } else { + let created = OverlayWindow( + contentRect: frame, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + created.isOpaque = false + created.backgroundColor = .clear + created.hasShadow = false + created.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.screenSaverWindow)) - 1) + created.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle] + created.ignoresMouseEvents = false + created.isMovable = false + created.animationBehavior = .none + + let catcher = DropCatcher(frame: CGRect(origin: .zero, size: CGSize(width: size, height: size))) + catcher.autoresizingMask = [.width, .height] + catcher.wantsLayer = true + let hosting = NSHostingView(rootView: WheelView(model: model)) + hosting.frame = catcher.bounds + hosting.autoresizingMask = [.width, .height] + catcher.addSubview(hosting) + + catcher.onMoved = { [weak self] point in + self?.move(to: point) + } + catcher.onExit = { [weak self] in + self?.model.selection = nil + } + catcher.onDrop = { [weak self] in + self?.commitDrop() ?? false + } + + created.contentView = catcher + self.catcher = catcher + self.window = created + window = created + } + + if !isShowing { + window.alphaValue = 0 + window.orderFrontRegardless() + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.12 + window.animator().alphaValue = 1 + } + } else { + window.setFrame(frame, display: true) + } + isShowing = true + move(to: location) + } + + private func move(to location: CGPoint) { + guard let window else { return } + lastLocation = location + var frame = window.frame + frame.origin = CGPoint(x: location.x - frame.width / 2, y: location.y - frame.height / 2) + window.setFrame(frame, display: true) + model.selection = Self.selectionIndex(for: location, in: frame, items: model.items, radius: model.radius) + } + + private func dismiss(animated: Bool = true) { + pendingDismiss?.cancel() + guard let window, isShowing else { + isShowing = false + return + } + isShowing = false + model.selection = nil + if animated { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.1 + window.animator().alphaValue = 0 + } completionHandler: { + window.orderOut(nil) + } + } else { + window.orderOut(nil) + } + } + + func reset() { + urls = [] + dismiss(animated: false) + } + + // MARK: - Geometry + + /// Items are spread on a ring; the radius grows with the item count so the + /// pills never touch. + static func radius(forItemCount count: Int) -> CGFloat { + let base = Settings.shared.wheelRadius + guard count > 2 else { return base } + let arc = CGFloat(count) * 84 / (2 * .pi) + return max(base, arc + 40) + } + + /// Nearest item to the pointer, or nil when the pointer is off the ring or + /// in the dead zone at the centre. + static func selectionIndex(for location: CGPoint, in frame: CGRect, items: [WheelItem], radius: CGFloat) -> Int? { + guard !items.isEmpty else { return nil } + let center = CGPoint(x: frame.midX, y: frame.midY) + let dx = location.x - center.x + let dy = location.y - center.y + let distance = (dx * dx + dy * dy).squareRoot() + + guard distance > radius * 0.34 else { return nil } + if items.count == 1 { return 0 } + guard distance < radius * 1.75 else { return nil } + + var angle = atan2(dy, dx) * 180 / .pi + if angle < 0 { angle += 360 } + let step = 360 / Double(items.count) + let firstItemAngle = 90.0 + var offset = (firstItemAngle - angle).truncatingRemainder(dividingBy: 360) + if offset < 0 { offset += 360 } + let index = Int((offset / step).rounded()) % items.count + return index + } + + // MARK: - Dispatch + + private static func dispatch(item: WheelItem, urls: [URL], origin: CGPoint?) { + switch item.payload { + case .format(let format): + JobStore.shared.submit(urls: urls, target: .format(format), origin: origin) + + case .tool(let tool): + if tool.fields.isEmpty { + JobStore.shared.submit(urls: urls, target: .tool(tool), origin: origin) + } else { + OptionsWindowController.shared.present(tool: tool, urls: urls, origin: origin) + } + + case .action(let action): + JobStore.shared.submit(urls: urls, target: .action(action), origin: origin) + } + } +} + +/// A window that never steals focus and never becomes the key window, so the +/// user's drag and the frontmost app keep working. +final class OverlayWindow: NSWindow { + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } +} diff --git a/Sources/Konverter/Drag/WheelView.swift b/Sources/Konverter/Drag/WheelView.swift new file mode 100644 index 0000000..ff24b96 --- /dev/null +++ b/Sources/Konverter/Drag/WheelView.swift @@ -0,0 +1,170 @@ +import AppKit +import SwiftUI + +/// The wheel itself: a ring of output formats with the dragged file in the +/// middle. Rendered into a transparent, non-activating overlay window. +struct WheelView: View { + @ObservedObject var model: WheelModel + + var body: some View { + GeometryReader { proxy in + let size = proxy.size + let center = CGPoint(x: size.width / 2, y: size.height / 2) + + ZStack { + if Settings.shared.dimBackground { + Circle() + .fill(Color.black.opacity(0.22)) + .frame(width: model.radius * 3.4, height: model.radius * 3.4) + .position(center) + .blur(radius: 24) + } + + disc(center: center) + + centerBadge(center: center) + + ForEach(Array(model.items.enumerated()), id: \.element.id) { index, item in + itemView(item, isSelected: model.selection == index) + .position(position(for: index, count: model.items.count, center: center)) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Pieces + + private func disc(center: CGPoint) -> some View { + let diameter = (model.radius + 46) * 2 + return ZStack { + Circle() + .fill(.ultraThinMaterial) + Circle() + .strokeBorder(Color.white.opacity(0.14), lineWidth: 1) + Circle() + .strokeBorder(Color.white.opacity(0.06), lineWidth: 1) + .frame(width: diameter - 26, height: diameter - 26) + } + .frame(width: diameter, height: diameter) + .position(center) + .shadow(color: .black.opacity(0.28), radius: 22, y: 6) + } + + private func centerBadge(center: CGPoint) -> some View { + VStack(spacing: 5) { + Image(nsImage: model.icon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40, height: 40) + Text(model.summary) + .font(.system(size: 11, weight: .medium)) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: max(90, model.radius * 0.95)) + if let item = model.selectedItem, !item.isEnabled, let reason = item.availability.reason { + Text(reason) + .font(.system(size: 9)) + .foregroundStyle(.orange) + .multilineTextAlignment(.center) + .lineLimit(2) + .frame(maxWidth: max(110, model.radius * 1.1)) + } else { + Text(model.subtitle) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + } + } + .padding(10) + .background( + Circle() + .fill(Color(nsColor: .windowBackgroundColor).opacity(0.55)) + ) + .position(center) + } + + private func itemView(_ item: WheelItem, isSelected: Bool) -> some View { + VStack(spacing: 4) { + ZStack { + Circle() + .fill(isSelected ? Color.accentColor.opacity(0.92) : Color.black.opacity(0.34)) + Circle() + .strokeBorder(Color.white.opacity(isSelected ? 0.85 : 0.16), lineWidth: isSelected ? 1.5 : 1) + Image(systemName: item.symbol) + .font(.system(size: 19, weight: .semibold)) + .foregroundStyle(.white) + if !item.isEnabled { + Image(systemName: "lock.fill") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .padding(3) + .background(Circle().fill(Color.orange)) + .offset(x: 16, y: -16) + } + } + .frame(width: 46, height: 46) + + if Settings.shared.showLabels { + Text(item.title) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.75) + .shadow(color: .black.opacity(0.65), radius: 2, y: 1) + } + } + .frame(width: 84) + .opacity(item.isEnabled ? 1 : 0.62) + .scaleEffect(isSelected ? 1.14 : 1) + .animation(.spring(response: 0.18, dampingFraction: 0.8), value: isSelected) + } + + private func position(for index: Int, count: Int, center: CGPoint) -> CGPoint { + guard count > 0 else { return center } + let step = 360.0 / Double(count) + let degrees = 90.0 - Double(index) * step + let radians = degrees * .pi / 180 + return CGPoint( + x: center.x + CGFloat(cos(radians)) * model.radius, + y: center.y - CGFloat(sin(radians)) * model.radius + ) + } +} + +/// Compact list rendering, for users who prefer a straight menu. +struct WheelListView: View { + @ObservedObject var model: WheelModel + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(model.summary) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .lineLimit(1) + ForEach(Array(model.items.enumerated()), id: \.element.id) { index, item in + HStack(spacing: 8) { + Image(systemName: item.symbol) + .font(.system(size: 11)) + .frame(width: 16) + Text(item.title) + .font(.system(size: 12)) + Spacer(minLength: 8) + if !item.isEnabled { + Image(systemName: "lock.fill").font(.system(size: 9)) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(model.selection == index ? Color.accentColor.opacity(0.85) : Color.clear) + ) + .foregroundStyle(model.selection == index ? Color.white : Color.primary) + } + } + .padding(10) + .frame(width: 220) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + } +} diff --git a/Sources/Konverter/SelfTest.swift b/Sources/Konverter/SelfTest.swift new file mode 100644 index 0000000..d3020cb --- /dev/null +++ b/Sources/Konverter/SelfTest.swift @@ -0,0 +1,475 @@ +import AppKit +import CoreGraphics +import Foundation + +/// `--self-test` exercises the real conversion paths end to end and exits +/// non-zero on failure. Kept in the shipped binary so a build can be verified +/// without opening the UI. +enum SelfTest { + private static var passes = 0 + private static var failures = 0 + private static var directory = FileManager.default.temporaryDirectory + + static func run() -> Int32 { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("konverter-selftest-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + print("Konverter self-test") + print("Working in \(directory.path)") + print("Engines: \(Toolchain.summary)") + print("") + + testImages() + testPDF() + testSubtitles() + testSpreadsheets() + testArchives() + testDocuments() + testAudio() + testEbook() + testGeometry() + + print("") + print("\(passes) passed, \(failures) failed") + return failures == 0 ? 0 : 1 + } + + // MARK: - Suites + + private static func testImages() { + section("Images") + let source = directory.appendingPathComponent("sample.png") + check("create sample png") { + try makeImage(at: source, width: 320, height: 200) + try requireFile(source) + } + + for format in [Format.jpg, .tiff, .gif, .pdf, .webp, .heic, .avif] { + let availability = Converter.availability(target: format, source: .image) + guard availability.isAvailable else { + print("SKIP png → \(format.displayName) (\(availability.reason ?? "unavailable"))") + continue + } + check("png → \(format.displayName)") { + let output = directory.appendingPathComponent("out.\(format.ext)") + try ImageConverter.convert(source: source, to: format, at: output) + try requireFile(output) + } + } + + check("image compress") { + let output = directory.appendingPathComponent("compressed.jpg") + try ImageTools.compress(source, at: output, format: .jpg, quality: 0.4) + try requireFile(output) + } + + // PNG has no quality knob, so "compress" trades colour depth for size. + check("png compress shrinks a lossless file") { + let output = directory.appendingPathComponent("compressed.png") + try ImageTools.compress(source, at: output, format: .png, quality: 0.3) + try requireFile(output) + let before = try fileSize(source) + let after = try fileSize(output) + guard after < before else { + throw TestFailure("expected a smaller png, got \(before) → \(after) bytes") + } + } + + check("image resize") { + let output = directory.appendingPathComponent("resized.png") + try ImageTools.resize(source, at: output, maxWidth: 100, maxHeight: 100, percent: 100) + guard let image = Raster.firstImage(at: output), image.width <= 100 else { + throw TestFailure("resize did not shrink the image") + } + } + + check("image rotate") { + let output = directory.appendingPathComponent("rotated.png") + try ImageTools.rotate(source, at: output, degrees: 90) + guard let image = Raster.firstImage(at: output), image.height > image.width else { + throw TestFailure("rotation did not swap dimensions") + } + } + } + + private static func testPDF() { + section("PDF") + let image = directory.appendingPathComponent("sample.png") + let pdf = directory.appendingPathComponent("sample.pdf") + + check("image → pdf") { + try PDFConverter.imageToPDF(image, at: pdf) + try requireFile(pdf) + } + + check("pdf text extraction") { + let textPDF = directory.appendingPathComponent("text.pdf") + try TextDocument.writePDF(fromText: "Hello Konverter", to: textPDF) + let text = PDFConverter.extractText(textPDF) + guard text.contains("Hello") else { throw TestFailure("no text found in \(text)") } + } + + check("pdf → png pages") { + let folder = directory.appendingPathComponent("pages") + let written = try PDFConverter.renderPages(pdf, to: .png, into: folder, dpi: 72) + guard !written.isEmpty else { throw TestFailure("no pages rendered") } + try requireFile(written[0]) + } + + check("pdf split and merge") { + let split = try PDFConverter.split(pdf, pagesPerFile: 1, into: directory.appendingPathComponent("split")) + guard split.count == 1 else { throw TestFailure("expected one part, got \(split.count)") } + let merged = directory.appendingPathComponent("merged.pdf") + try PDFConverter.merge(split, at: merged) + guard PDFConverter.pageCount(merged) == 1 else { throw TestFailure("merge lost pages") } + } + } + + private static func testSubtitles() { + section("Subtitles") + let srt = directory.appendingPathComponent("captions.srt") + try? """ + 1 + 00:00:01,000 --> 00:00:03,500 + First line + + 2 + 00:00:04,000 --> 00:00:06,000 + Second line + + """.write(to: srt, atomically: true, encoding: .utf8) + + check("srt → vtt") { + let vtt = directory.appendingPathComponent("captions.vtt") + try SubtitleConverter.convert(source: srt, to: .vtt, at: vtt) + let text = try String(contentsOf: vtt, encoding: .utf8) + guard text.hasPrefix("WEBVTT"), text.contains("00:00:01.000") else { + throw TestFailure("unexpected vtt output") + } + } + + check("srt → txt") { + let text = directory.appendingPathComponent("captions.txt") + try SubtitleConverter.convert(source: srt, to: .txt, at: text) + let contents = try String(contentsOf: text, encoding: .utf8) + guard contents.contains("First line") else { throw TestFailure("missing cue text") } + } + + check("subtitle offset") { + let cues = try SubtitleConverter.parse(srt) + let shifted = SubtitleConverter.shift(cues, by: 2) + guard let first = shifted.first, abs(first.start - 3) < 0.01 else { + throw TestFailure("offset was not applied") + } + } + } + + private static func testSpreadsheets() { + section("Spreadsheets") + let csv = directory.appendingPathComponent("table.csv") + try? "Name,Score\nAda,42\nGrace,\"7,5\"\n".write(to: csv, atomically: true, encoding: .utf8) + + check("csv parse") { + let rows = try SpreadsheetConverter.read(csv) + guard rows.count == 3, rows[1][0] == "Ada", rows[2][1] == "7,5" else { + throw TestFailure("parsed \(rows)") + } + } + + check("csv → xlsx → csv") { + let xlsx = directory.appendingPathComponent("table.xlsx") + let rows = try SpreadsheetConverter.read(csv) + try XLSX.write(rows, to: xlsx) + try requireFile(xlsx) + let roundTrip = try XLSX.read(xlsx) + guard roundTrip.count == rows.count, roundTrip[1][0] == "Ada" else { + throw TestFailure("round trip produced \(roundTrip)") + } + } + + check("csv → tsv") { + let tsv = directory.appendingPathComponent("table.tsv") + try SpreadsheetConverter.convert(source: csv, to: .tsv, at: tsv) + let text = try String(contentsOf: tsv, encoding: .utf8) + guard text.contains("\t") else { throw TestFailure("no tabs in output") } + } + } + + private static func testArchives() { + section("Archives") + let folder = directory.appendingPathComponent("bundle", isDirectory: true) + try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + try? "archive me".write(to: folder.appendingPathComponent("note.txt"), atomically: true, encoding: .utf8) + + check("folder → zip") { + let zip = directory.appendingPathComponent("bundle.zip") + try ArchiveConverter.convert(source: folder, to: .zip, at: zip) + try requireFile(zip) + } + + check("zip → extract") { + let zip = directory.appendingPathComponent("bundle.zip") + let target = directory.appendingPathComponent("unpacked", isDirectory: true) + try ArchiveConverter.extract(source: zip, into: target) + let note = target.appendingPathComponent("bundle/note.txt") + guard FileManager.default.fileExists(atPath: note.path) else { + throw TestFailure("note.txt missing after extraction") + } + } + + check("folder → tar") { + let tar = directory.appendingPathComponent("bundle.tar") + try ArchiveConverter.convert(source: folder, to: .tar, at: tar) + try requireFile(tar) + } + } + + private static func testDocuments() { + section("Documents") + let text = directory.appendingPathComponent("note.txt") + try? "Hello from Konverter.\n\nSecond paragraph.\n".write(to: text, atomically: true, encoding: .utf8) + + check("txt → md") { + let markdown = directory.appendingPathComponent("note.md") + try DocumentConverter.convert(source: text, to: .md, at: markdown) + let contents = try String(contentsOf: markdown, encoding: .utf8) + guard contents.contains("Hello from Konverter") else { throw TestFailure("text lost") } + } + + check("md → html") { + let markdown = directory.appendingPathComponent("note.md") + try? "# Title\n\nSome **bold** text.\n".write(to: markdown, atomically: true, encoding: .utf8) + let html = directory.appendingPathComponent("note.html") + try DocumentConverter.convert(source: markdown, to: .html, at: html) + let contents = try String(contentsOf: html, encoding: .utf8) + guard contents.contains("bold") else { throw TestFailure("markdown not rendered") } + } + + check("txt → pdf") { + let pdf = directory.appendingPathComponent("note.pdf") + try DocumentConverter.convert(source: text, to: .pdf, at: pdf) + try requireFile(pdf) + guard PDFConverter.pageCount(pdf) >= 1 else { throw TestFailure("empty pdf") } + } + + check("txt → docx") { + let docx = directory.appendingPathComponent("note.docx") + try DocumentConverter.convert(source: text, to: .docx, at: docx) + try requireFile(docx) + } + + check("html → md") { + let markdown = Markdown.markdown(fromHTML: "

    Title

    Hello world

    ") + guard markdown.contains("# Title"), markdown.contains("**world**") else { + throw TestFailure("html not converted: \(markdown)") + } + } + } + + private static func testAudio() { + section("Audio") + let wav = directory.appendingPathComponent("tone.wav") + + check("create wav") { + try makeWAV(at: wav) + try requireFile(wav) + } + + check("wav → m4a") { + let m4a = directory.appendingPathComponent("tone.m4a") + try AVConverter.convert(source: wav, to: .m4a, at: m4a) + try requireFile(m4a) + } + + check("wav → aiff") { + let aiff = directory.appendingPathComponent("tone.aiff") + try CoreAudio.convert(source: wav, to: .aiff, at: aiff) + try requireFile(aiff) + } + + check("media info") { + let info = MediaInfo.describe(wav) + guard info.contains("Duration") else { throw TestFailure("no duration in \(info)") } + } + } + + private static func testEbook() { + section("EPUB") + let html = "

    Chapter

    An ebook built from scratch.

    " + let epub = directory.appendingPathComponent("book.epub") + + check("build epub") { + try EbookConverter.write( + title: "Test Book", + author: "Konverter", + chapters: [("One", html), ("Two", "

    More text.

    ")], + at: epub + ) + try requireFile(epub) + } + + check("epub → txt") { + let text = try EbookConverter.plainText(epub) + guard text.contains("ebook built from scratch") else { + throw TestFailure("chapter text missing: \(text)") + } + } + + check("epub → md") { + let markdown = directory.appendingPathComponent("book.md") + try EbookConverter.convert(source: epub, to: .md, at: markdown) + let contents = try String(contentsOf: markdown, encoding: .utf8) + guard contents.contains("ebook built") else { throw TestFailure("markdown lost the text") } + } + } + + private static func testGeometry() { + section("Wheel geometry") + let frame = CGRect(x: 0, y: 0, width: 400, height: 400) + let items = (0..<8).map { index in + WheelItem( + id: "item-\(index)", + payload: .format(.png), + title: "Item \(index)", + subtitle: nil, + symbol: "circle", + availability: .ok + ) + } + let radius: CGFloat = 160 + + check("top hit selects the first item") { + let index = WheelController.selectionIndex( + for: CGPoint(x: 200, y: 340), + in: frame, + items: items, + radius: radius + ) + guard index == 0 else { throw TestFailure("got \(String(describing: index))") } + } + + check("dead zone selects nothing") { + let index = WheelController.selectionIndex( + for: CGPoint(x: 200, y: 200), + in: frame, + items: items, + radius: radius + ) + guard index == nil else { throw TestFailure("got \(String(describing: index))") } + } + + check("clockwise hit selects item 2") { + let index = WheelController.selectionIndex( + for: CGPoint(x: 360, y: 200), + in: frame, + items: items, + radius: radius + ) + guard index == 2 else { throw TestFailure("got \(String(describing: index))") } + } + + check("conflict naming") { + let existing = directory.appendingPathComponent("taken.txt") + try? "x".write(to: existing, atomically: true, encoding: .utf8) + let candidate = OutputNaming.availableURL(for: existing) + guard candidate.lastPathComponent == "taken 2.txt" else { + throw TestFailure("got \(candidate.lastPathComponent)") + } + } + } + + // MARK: - Harness + + private struct TestFailure: LocalizedError { + let message: String + init(_ message: String) { self.message = message } + var errorDescription: String? { message } + } + + private static func section(_ name: String) { + print("— \(name)") + } + + private static func check(_ name: String, _ body: () throws -> Void) { + do { + try body() + passes += 1 + print("PASS \(name)") + } catch { + failures += 1 + let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + print("FAIL \(name): \(message)") + } + } + + private static func requireFile(_ url: URL) throws { + guard FileManager.default.fileExists(atPath: url.path) else { + throw TestFailure("\(url.lastPathComponent) was not created") + } + let size = try fileSize(url) + guard size > 0 else { + throw TestFailure("\(url.lastPathComponent) is empty") + } + } + + private static func fileSize(_ url: URL) throws -> Int { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + return attributes[.size] as? Int ?? 0 + } + + private static func makeImage(at url: URL, width: Int, height: Int) throws { + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { throw TestFailure("could not create a bitmap") } + + context.setFillColor(CGColor(srgbRed: 0.98, green: 0.52, blue: 0.18, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + context.setFillColor(CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)) + context.fillEllipse(in: CGRect(x: width / 4, y: height / 4, width: width / 2, height: height / 2)) + + guard let image = context.makeImage() else { throw TestFailure("could not render the bitmap") } + try Raster.write(image, to: url, format: .png, quality: 1, metadata: nil) + } + + private static func makeWAV(at url: URL) throws { + let sampleRate = 22050 + let seconds = 0.6 + let sampleCount = Int(Double(sampleRate) * seconds) + var samples = Data() + for index in 0.. NSWindow { + if let window { return window } + let window = OverlayWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 320), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.isOpaque = false + window.backgroundColor = .clear + window.hasShadow = true + window.level = .floating + window.isMovableByWindowBackground = true + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + window.contentView = NSHostingView(rootView: FloatView(controller: self)) + self.window = window + return window + } + + @MainActor + func render() { + guard let window, let content = window.contentView as? NSHostingView else { return } + content.rootView = FloatView(controller: self) + } + + @MainActor + func close() { + guard let window else { return } + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.15 + window.animator().alphaValue = 0 + } completionHandler: { + window.orderOut(nil) + } + urls = [] + } + + @MainActor + func step(_ delta: Int) { + guard !urls.isEmpty else { return } + index = ((index + delta) % urls.count + urls.count) % urls.count + render() + } + + var currentURL: URL? { + urls.indices.contains(index) ? urls[index] : nil + } + + var counter: String? { + urls.count > 1 ? "\(index + 1) of \(urls.count)" : nil + } + + @MainActor + func openInDefaultApp() { + guard let url = currentURL else { return } + NSWorkspace.shared.open(url) + } + + @MainActor + func reveal() { + guard let url = currentURL else { return } + NSWorkspace.shared.activateFileViewerSelecting([url]) + } +} + +struct FloatView: View { + let controller: FloatPanel + + @State private var image: NSImage? + @State private var failure: String? + @State private var isLoading = true + + var body: some View { + VStack(spacing: 0) { + Group { + if let image { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .padding(10) + } else if let failure { + VStack(spacing: 6) { + Image(systemName: "eye.slash") + .font(.system(size: 22)) + Text(failure) + .font(.system(size: 11)) + .multilineTextAlignment(.center) + } + .foregroundStyle(.secondary) + .padding() + } else { + ProgressView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Divider().opacity(0.35) + + HStack(spacing: 10) { + Image(systemName: "doc") + .font(.system(size: 11)) + Text(controller.currentURL?.lastPathComponent ?? "") + .font(.system(size: 11, weight: .medium)) + .lineLimit(1) + .truncationMode(.middle) + if let counter = controller.counter { + Text(counter) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + Spacer() + Button { + controller.step(-1) + } label: { Image(systemName: "chevron.left") } + .buttonStyle(.plain) + Button { + controller.step(1) + } label: { Image(systemName: "chevron.right") } + .buttonStyle(.plain) + Button("Open") { controller.openInDefaultApp() } + .buttonStyle(.plain) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Color.accentColor) + Button { + controller.close() + } label: { Image(systemName: "xmark") } + .buttonStyle(.plain) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(Color.primary.opacity(0.1)) + ) + .task(id: controller.currentURL) { + await load() + } + } + + private func load() async { + guard let url = controller.currentURL else { + image = nil + failure = "Nothing to preview" + isLoading = false + return + } + isLoading = true + failure = nil + + // Images and PDFs get a real render; everything else falls back to a + // Quick Look thumbnail. + if FileKind.category(of: url) == .image, let loaded = NSImage(contentsOf: url) { + image = loaded + isLoading = false + return + } + if url.pathExtension.lowercased() == "pdf", let page = PDFKitPreview.image(for: url) { + image = page + isLoading = false + return + } + + let request = QLThumbnailGenerator.Request( + fileAt: url, + size: CGSize(width: 900, height: 700), + scale: NSScreen.main?.backingScaleFactor ?? 2, + representationTypes: .all + ) + let representation = try? await QLThumbnailGenerator.shared.generateBestRepresentation(for: request) + if let cgImage = representation?.cgImage { + image = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width / 2, height: cgImage.height / 2)) + } else { + image = nil + failure = "No preview available for .\(url.pathExtension)" + } + isLoading = false + } +} + +enum PDFKitPreview { + static func image(for url: URL) -> NSImage? { + guard let document = PDFDocument(url: url), let page = document.page(at: 0) else { return nil } + let bounds = page.bounds(for: .mediaBox) + let scale: CGFloat = 1.6 + let size = CGSize(width: bounds.width * scale, height: bounds.height * scale) + let thumbnail = page.thumbnail(of: size, for: .mediaBox) + return thumbnail + } +} diff --git a/Sources/Konverter/UI/JobPanel.swift b/Sources/Konverter/UI/JobPanel.swift new file mode 100644 index 0000000..75c75dc --- /dev/null +++ b/Sources/Konverter/UI/JobPanel.swift @@ -0,0 +1,254 @@ +import AppKit +import SwiftUI + +/// The small job window. Tangerine opens one per job; a single stack that +/// shows every job is less noisy and can be dismissed in one click. +final class JobPanelController: NSObject { + static let shared = JobPanelController() + + private var panel: NSPanel? + + private override init() { super.init() } + + func refresh(store: JobStore) { + guard Thread.isMainThread else { + DispatchQueue.main.async { self.refresh(store: store) } + return + } + guard Settings.shared.showProgressWindow else { return } + guard !store.jobs.isEmpty else { + panel?.orderOut(nil) + panel = nil + return + } + + let panel = ensurePanel() + if !panel.isVisible { + position(panel, near: store.jobs.last?.origin) + panel.alphaValue = 0 + panel.orderFrontRegardless() + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.18 + panel.animator().alphaValue = 1 + } + } + } + + func dismissIfAllFinished(store: JobStore) { + guard store.activeCount == 0 else { return } + let work = DispatchWorkItem { [weak self] in + guard let self, let panel = self.panel, JobStore.shared.activeCount == 0 else { return } + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.2 + panel.animator().alphaValue = 0 + } completionHandler: { + panel.orderOut(nil) + JobStore.shared.dismissFinished() + self.panel = nil + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.6, execute: work) + } + + func show() { + refresh(store: .shared) + } + + private func ensurePanel() -> NSPanel { + if let panel { return panel } + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 120), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.isFloatingPanel = true + panel.level = .floating + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.hidesOnDeactivate = false + panel.becomesKeyOnlyIfNeeded = true + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.contentView = NSHostingView(rootView: JobsView(store: .shared)) + self.panel = panel + return panel + } + + private func position(_ panel: NSPanel, near origin: CGPoint?) { + let size = panel.contentView?.fittingSize ?? CGSize(width: 320, height: 140) + panel.setContentSize(size) + + let screen = NSScreen.screens.first { $0.frame.contains(origin ?? .zero) } ?? NSScreen.main + guard let frame = screen?.visibleFrame else { return } + + var point = CGPoint(x: frame.midX - size.width / 2, y: frame.minY + 80) + if let origin { + point = CGPoint(x: origin.x + 26, y: origin.y - size.height / 2) + } + point.x = min(max(frame.minX + 12, point.x), frame.maxX - size.width - 12) + point.y = min(max(frame.minY + 12, point.y), frame.maxY - size.height - 12) + panel.setFrameOrigin(point) + } + + /// Re-centres and resizes once the content changes. + func layout() { + guard let panel, panel.isVisible else { return } + let size = panel.contentView?.fittingSize ?? panel.frame.size + let anchor = CGPoint(x: panel.frame.minX, y: panel.frame.midY) + panel.setContentSize(size) + panel.setFrameOrigin(CGPoint(x: anchor.x, y: anchor.y - size.height / 2)) + } +} + +struct JobsView: View { + @ObservedObject var store: JobStore + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 6) { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 11, weight: .semibold)) + Text(store.activeCount > 0 ? "Working…" : "Finished") + .font(.system(size: 11, weight: .semibold)) + Spacer() + Button { + store.cancelAll() + } label: { + Image(systemName: "stop.circle") + } + .buttonStyle(.plain) + .help("Cancel everything") + .opacity(store.activeCount > 0 ? 1 : 0) + Button { + store.dismissFinished() + JobPanelController.shared.performClose() + } label: { + Image(systemName: "xmark.circle") + } + .buttonStyle(.plain) + .help("Close") + } + .padding(.horizontal, 12) + .padding(.top, 10) + .padding(.bottom, 6) + + Divider().opacity(0.4) + + ScrollView { + VStack(alignment: .leading, spacing: 8) { + ForEach(store.jobs) { job in + JobRow(job: job, store: store) + } + } + .padding(12) + } + .frame(maxHeight: 260) + } + .frame(width: 320) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(Color.primary.opacity(0.09)) + ) + .onReceive(store.$jobs) { _ in + DispatchQueue.main.async { + JobPanelController.shared.layout() + } + } + } +} + +private struct JobRow: View { + let job: Job + @ObservedObject var store: JobStore + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: symbol) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(tint) + Text(job.title) + .font(.system(size: 11.5, weight: .medium)) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 4) + if job.status == .running { + Button { + store.cancel(job.id) + } label: { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .bold)) + } + .buttonStyle(.plain) + .help("Cancel this job") + } else { + Button { + store.remove(job.id) + } label: { + Image(systemName: "trash") + .font(.system(size: 9)) + } + .buttonStyle(.plain) + .help("Remove") + } + } + + if job.status == .running { + ProgressView(value: max(0.02, job.progress)) + .progressViewStyle(.linear) + } + + HStack(spacing: 6) { + Text(detail) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .lineLimit(2) + Spacer(minLength: 4) + if !job.outputs.isEmpty, job.status == .finished { + Button("Reveal") { + NSWorkspace.shared.activateFileViewerSelecting(job.outputs) + } + .buttonStyle(.plain) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(Color.accentColor) + } + } + } + } + + private var detail: String { + if let error = job.errorMessage { return error } + if job.sourceCount > 1, job.status == .finished, !job.outputs.isEmpty { + return "\(job.outputs.count) files written" + } + return job.detail.isEmpty ? job.title : job.detail + } + + private var symbol: String { + switch job.status { + case .running: return "hourglass" + case .finished: return "checkmark.circle.fill" + case .failed: return "exclamationmark.triangle.fill" + case .cancelled: return "slash.circle" + } + } + + private var tint: Color { + switch job.status { + case .running: return .secondary + case .finished: return .green + case .failed: return .orange + case .cancelled: return .secondary + } + } +} + +extension JobPanelController { + func performClose() { + panel?.orderOut(nil) + panel = nil + JobStore.shared.dismissFinished() + } +} diff --git a/Sources/Konverter/UI/MenuBar.swift b/Sources/Konverter/UI/MenuBar.swift new file mode 100644 index 0000000..3b8088a --- /dev/null +++ b/Sources/Konverter/UI/MenuBar.swift @@ -0,0 +1,113 @@ +import AppKit +import Foundation + +/// The menu bar item: the app's only permanent UI, and the place to turn the +/// wheel off or fall back to picking files by hand. +final class MenuBarController: NSObject, NSMenuDelegate { + private var statusItem: NSStatusItem? + + func install() { + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + if let button = item.button { + let image = NSImage(systemSymbolName: "arrow.triangle.2.circlepath", accessibilityDescription: "Konverter") + image?.isTemplate = true + button.image = image + button.setAccessibilityLabel("Konverter") + } + let menu = NSMenu() + menu.delegate = self + item.menu = menu + statusItem = item + rebuild(menu) + } + + func menuNeedsUpdate(_ menu: NSMenu) { + rebuild(menu) + } + + private func rebuild(_ menu: NSMenu) { + menu.removeAllItems() + + let enabled = Settings.shared.enabled + let toggle = NSMenuItem( + title: enabled ? "Konverter Active" : "Konverter Paused", + action: #selector(toggleEnabled), + keyEquivalent: "" + ) + toggle.target = self + toggle.state = enabled ? .on : .off + menu.addItem(toggle) + + menu.addItem(.separator()) + + let convert = NSMenuItem(title: "Konvert Files…", action: #selector(convertFiles), keyEquivalent: "o") + convert.target = self + menu.addItem(convert) + + let jobs = NSMenuItem(title: "Show Jobs", action: #selector(showJobs), keyEquivalent: "j") + jobs.target = self + menu.addItem(jobs) + + menu.addItem(.separator()) + + let settings = NSMenuItem(title: "Settings…", action: #selector(showSettings), keyEquivalent: ",") + settings.target = self + menu.addItem(settings) + + let about = NSMenuItem(title: "About Konverter", action: #selector(showAbout), keyEquivalent: "") + about.target = self + menu.addItem(about) + + menu.addItem(.separator()) + + let quit = NSMenuItem(title: "Quit Konverter", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + quit.target = NSApp + menu.addItem(quit) + } + + @objc private func toggleEnabled() { + Settings.shared.enabled.toggle() + if !Settings.shared.enabled { + WheelController.shared.cancel() + } + if let menu = statusItem?.menu { + rebuild(menu) + } + } + + @objc private func convertFiles() { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = true + panel.allowsMultipleSelection = true + panel.prompt = "Choose" + panel.message = "Pick files to convert, then choose an output format" + NSApp.activate(ignoringOtherApps: true) + guard panel.runModal() == .OK, !panel.urls.isEmpty else { return } + // Let the panel close before the menu opens over it. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + QuickMenu.show(urls: panel.urls, at: NSEvent.mouseLocation) + } + } + + @objc private func showJobs() { + JobStore.shared.panelVisible = true + JobPanelController.shared.show() + } + + @objc private func showSettings() { + PreferencesWindowController.shared.show() + } + + @objc private func showAbout() { + NSApp.activate(ignoringOtherApps: true) + NSApp.orderFrontStandardAboutPanel(options: [ + .applicationName: "Konverter", + .applicationVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0", + .credits: NSAttributedString( + string: "Drag a file in Finder, then hold a key.\nShift converts · Command shows tools · Control shows file actions.\nEverything runs on this Mac.", + attributes: [.font: NSFont.systemFont(ofSize: 11)] + ) + ]) + } +} diff --git a/Sources/Konverter/UI/Notifier.swift b/Sources/Konverter/UI/Notifier.swift new file mode 100644 index 0000000..7c0746f --- /dev/null +++ b/Sources/Konverter/UI/Notifier.swift @@ -0,0 +1,88 @@ +import AppKit +import Foundation +import SwiftUI + +/// Small, non-modal confirmations ("Path copied", "3 files converted"). +/// Nothing here blocks the user's flow, which is the whole point of the app. +enum Notifier { + private static var toastWindow: NSWindow? + private static var dismissWorkItem: DispatchWorkItem? + + @MainActor + static func flash(title: String, body: String? = nil, symbol: String = "checkmark.circle.fill") { + guard Settings.shared.showProgressWindow else { return } + show(title: title, body: body, symbol: symbol, tone: .success, duration: 2.0) + } + + @MainActor + static func error(_ title: String, _ message: String) { + show(title: title, body: message, symbol: "exclamationmark.triangle.fill", tone: .failure, duration: 6.0) + } + + private enum Tone { case success, failure } + + @MainActor + private static func show(title: String, body: String?, symbol: String, tone: Tone, duration: TimeInterval) { + dismissWorkItem?.cancel() + toastWindow?.close() + + let accent: Color = tone == .success ? .green : .orange + let view = HStack(spacing: 10) { + Image(systemName: symbol) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(accent) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 12, weight: .semibold)) + .lineLimit(1) + if let body, !body.isEmpty { + Text(body) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(2) + .truncationMode(.middle) + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .frame(minWidth: 220, maxWidth: 420, alignment: .leading) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.primary.opacity(0.08)) + ) + + let controller = NSHostingController(rootView: view) + let window = NSWindow(contentViewController: controller) + window.styleMask = [.borderless] + window.isOpaque = false + window.backgroundColor = .clear + window.hasShadow = true + window.level = .statusBar + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + window.ignoresMouseEvents = true + window.appearance = NSApp.effectiveAppearance + + let size = controller.view.fittingSize + let screen = NSScreen.main ?? NSScreen.screens.first + if let frame = screen?.visibleFrame { + let origin = CGPoint(x: frame.midX - size.width / 2, y: frame.minY + 96) + window.setFrame(CGRect(origin: origin, size: size), display: true) + } + window.orderFrontRegardless() + toastWindow = window + + let work = DispatchWorkItem { [weak window] in + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.25 + window?.animator().alphaValue = 0 + } completionHandler: { + window?.close() + } + if toastWindow === window { toastWindow = nil } + } + dismissWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: work) + } +} diff --git a/Sources/Konverter/UI/OptionsWindow.swift b/Sources/Konverter/UI/OptionsWindow.swift new file mode 100644 index 0000000..60842f5 --- /dev/null +++ b/Sources/Konverter/UI/OptionsWindow.swift @@ -0,0 +1,190 @@ +import AppKit +import SwiftUI + +/// The form a tool needs before it can run (quality, trim points, password …). +/// Values are remembered per tool, so the second time is still one drop. +final class OptionsWindowController: NSObject { + static let shared = OptionsWindowController() + + private var window: NSWindow? + private var tool: ToolID? + private var urls: [URL] = [] + private var origin: CGPoint? + private var params = ToolParams() + + private override init() { super.init() } + + func present(tool: ToolID, urls: [URL], origin: CGPoint?) { + self.tool = tool + self.urls = urls + self.origin = origin + params = Settings.shared.params(for: tool) + params.fillMissing(from: tool) + + let view = OptionsView( + tool: tool, + params: params, + fileCount: urls.count, + onApply: { [weak self] values in self?.apply(values) }, + onCancel: { [weak self] in self?.close() } + ) + + close() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 340, height: 120), + styleMask: [.titled, .closable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.title = tool.title + window.titlebarAppearsTransparent = true + window.isMovableByWindowBackground = true + window.level = .floating + window.contentView = NSHostingView(rootView: view) + window.center() + if let origin, let screen = NSScreen.screens.first(where: { $0.frame.contains(origin) }) ?? NSScreen.main { + let frame = screen.visibleFrame + window.setFrameOrigin(CGPoint( + x: min(max(frame.minX + 20, origin.x - 170), frame.maxX - 360), + y: min(max(frame.minY + 20, origin.y - 180), frame.maxY - 220) + )) + } + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + self.window = window + } + + private func apply(_ values: ToolParams) { + guard let tool else { return } + Settings.shared.store(params: values, for: tool) + let captured = urls + let point = origin + close() + JobStore.shared.submit(urls: captured, target: .tool(tool), params: values, origin: point) + } + + private func close() { + window?.orderOut(nil) + window = nil + } +} + +struct OptionsView: View { + let tool: ToolID + let fileCount: Int + let onApply: (ToolParams) -> Void + let onCancel: () -> Void + + @State private var values: [String: String] = [:] + + init( + tool: ToolID, + params: ToolParams, + fileCount: Int, + onApply: @escaping (ToolParams) -> Void, + onCancel: @escaping () -> Void + ) { + self.tool = tool + self.fileCount = fileCount + self.onApply = onApply + self.onCancel = onCancel + var initial: [String: String] = [:] + for field in tool.fields { + initial[field.id] = params[field.id] ?? field.defaultValue + } + _values = State(initialValue: initial) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Image(systemName: tool.symbol) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color.accentColor) + VStack(alignment: .leading, spacing: 1) { + Text(tool.title) + .font(.system(size: 13, weight: .semibold)) + Text(fileCount == 1 ? "1 file" : "\(fileCount) files") + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + } + } + + ForEach(tool.fields) { field in + fieldView(field) + } + + HStack { + Spacer() + Button("Cancel", action: onCancel) + .keyboardShortcut(.cancelAction) + Button("Apply") { + var params = ToolParams() + for (key, value) in values { params[key] = value } + onApply(params) + } + .keyboardShortcut(.defaultAction) + } + } + .padding(16) + .frame(width: 340) + } + + @ViewBuilder + private func fieldView(_ field: ToolField) -> some View { + VStack(alignment: .leading, spacing: 3) { + HStack { + Text(field.label) + .font(.system(size: 11, weight: .medium)) + Spacer() + if let unit = field.unit { + Text(unit) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + } + control(for: field) + if let help = field.help { + Text(help) + .font(.system(size: 9.5)) + .foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private func control(for field: ToolField) -> some View { + let binding = Binding( + get: { values[field.id] ?? field.defaultValue }, + set: { values[field.id] = $0 } + ) + + switch field.kind { + case .text: + TextField("", text: binding) + .textFieldStyle(.roundedBorder) + case .secret: + SecureField("", text: binding) + .textFieldStyle(.roundedBorder) + case .toggle: + Toggle("", isOn: Binding( + get: { (values[field.id] ?? field.defaultValue) == "true" }, + set: { values[field.id] = $0 ? "true" : "false" } + )) + .labelsHidden() + .toggleStyle(.switch) + case .choice(let options): + Picker("", selection: binding) { + ForEach(options, id: \.self) { option in + Text(option).tag(option) + } + } + .labelsHidden() + .pickerStyle(.menu) + case .integer, .decimal, .timecode: + TextField("", text: binding) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12, design: .monospaced)) + } + } +} diff --git a/Sources/Konverter/UI/PreferencesWindow.swift b/Sources/Konverter/UI/PreferencesWindow.swift new file mode 100644 index 0000000..d1c94a1 --- /dev/null +++ b/Sources/Konverter/UI/PreferencesWindow.swift @@ -0,0 +1,224 @@ +import AppKit +import SwiftUI + +/// Settings window. Also the place where the active conversion engines are +/// listed, so "why can't I export MKV?" has an answer in the app itself. +final class PreferencesWindowController { + static let shared = PreferencesWindowController() + + private var window: NSWindow? + + private init() {} + + func show() { + if let window { + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + return + } + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 620), + styleMask: [.titled, .closable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.title = "Konverter Settings" + window.titlebarAppearsTransparent = true + window.contentView = NSHostingView(rootView: PreferencesView()) + window.center() + window.isReleasedWhenClosed = false + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + self.window = window + } +} + +struct PreferencesView: View { + @ObservedObject private var settings = Settings.shared + @State private var loginItemError: String? + + var body: some View { + TabView { + general.tabItem { Label("General", systemImage: "gearshape") } + wheel.tabItem { Label("Wheel", systemImage: "circle.hexagongrid") } + engines.tabItem { Label("Engines", systemImage: "cpu") } + } + .padding(16) + .frame(width: 520, height: 600) + } + + private var general: some View { + Form { + Section { + Toggle("Konverter is active", isOn: $settings.enabled) + Toggle("Enable at login", isOn: Binding( + get: { settings.openAtLogin }, + set: { value in + if LoginItem.set(value) { + settings.openAtLogin = value + loginItemError = nil + } else { + settings.openAtLogin = LoginItem.isEnabled + loginItemError = "Could not update the login item. Move Konverter to /Applications and try again." + } + } + )) + if let loginItemError { + Text(loginItemError) + .font(.system(size: 10.5)) + .foregroundStyle(.orange) + } + } header: { + Text("Startup") + } + + Section { + Picker("Save converted files", selection: $settings.outputLocation) { + ForEach(OutputLocation.allCases) { location in + Text(location.title).tag(location) + } + } + if settings.outputLocation == .chosenFolder { + HStack { + Text(settings.outputFolderPath.isEmpty ? "No folder chosen" : settings.outputFolderPath) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Button("Choose…") { chooseFolder() } + } + } + Picker("When a file already exists", selection: $settings.conflictPolicy) { + ForEach(ConflictPolicy.allCases) { policy in + Text(policy.title).tag(policy) + } + } + } header: { + Text("Output") + } + + Section { + Toggle("Show the job window", isOn: $settings.showProgressWindow) + Toggle("Reveal files in Finder when finished", isOn: $settings.revealWhenFinished) + Toggle("Play a sound when finished", isOn: $settings.playSound) + } header: { + Text("Feedback") + } + } + .formStyle(.grouped) + } + + private var wheel: some View { + Form { + Section { + Picker("Convert", selection: $settings.convertKey) { + ForEach(TriggerKey.allCases) { key in Text(key.title).tag(key) } + } + Picker("Tools", selection: $settings.toolsKey) { + ForEach(TriggerKey.allCases) { key in Text(key.title).tag(key) } + } + Picker("Actions", selection: $settings.actionsKey) { + ForEach(TriggerKey.allCases) { key in Text(key.title).tag(key) } + } + Toggle("Tools also require Shift", isOn: $settings.requireShiftForTools) + } header: { + Text("Trigger keys") + } footer: { + Text("Start dragging a file in Finder, then hold a key. The wheel stays open until you drop or press Escape. Keys must differ, otherwise the first match wins (Actions, then Tools, then Convert).") + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + } + + Section { + Picker("Style", selection: $settings.wheelStyle) { + ForEach(WheelStyle.allCases) { style in Text(style.title).tag(style) } + } + HStack { + Text("Radius") + Slider(value: $settings.wheelRadius, in: 120...260, step: 4) + Text("\(Int(settings.wheelRadius))") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 28) + } + Toggle("Show labels", isOn: $settings.showLabels) + Toggle("Dim the background", isOn: $settings.dimBackground) + } header: { + Text("Appearance") + } + } + .formStyle(.grouped) + } + + private var engines: some View { + Form { + Section { + Toggle("Use external tools", isOn: $settings.useExternalTools) + Text("Konverter always works with Apple's frameworks. ffmpeg and LibreOffice only widen the format list.") + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + } header: { + Text("Conversion engines") + } + + Section { + engineRow(name: "Core (ImageIO, PDFKit, AVFoundation, textutil)", available: true, detail: "Always available") + engineRow( + name: "ffmpeg", + available: Toolchain.hasFFmpeg, + detail: Toolchain.ffmpeg ?? "Install with: brew install ffmpeg" + ) + engineRow( + name: "LibreOffice", + available: Toolchain.hasLibreOffice, + detail: Toolchain.soffice ?? "Install with: brew install --cask libreoffice" + ) + } header: { + Text("Detected") + } + + Section { + Text("Active: \(Toolchain.summary)") + .font(.system(size: 11)) + if let advice = Toolchain.missingToolAdvice { + Text(advice) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } header: { + Text("Summary") + } + } + .formStyle(.grouped) + } + + private func engineRow(name: String, available: Bool, detail: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: available ? "checkmark.circle.fill" : "circle.dashed") + .foregroundStyle(available ? Color.green : Color.secondary) + .font(.system(size: 12)) + VStack(alignment: .leading, spacing: 1) { + Text(name).font(.system(size: 12, weight: .medium)) + Text(detail) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + } + + private func chooseFolder() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.canCreateDirectories = true + panel.allowsMultipleSelection = false + panel.prompt = "Use Folder" + NSApp.activate(ignoringOtherApps: true) + if panel.runModal() == .OK, let url = panel.url { + settings.outputFolderPath = url.path + } + } +} diff --git a/Sources/Konverter/UI/QuickMenu.swift b/Sources/Konverter/UI/QuickMenu.swift new file mode 100644 index 0000000..a88734f --- /dev/null +++ b/Sources/Konverter/UI/QuickMenu.swift @@ -0,0 +1,110 @@ +import AppKit +import Foundation + +/// Native menu fallback for the paths where there is no drag to follow: the +/// menu bar's "Konvert Files…" item and the Finder Quick Action. +enum QuickMenu { + private final class Choice: NSObject { + let handler: () -> Void + + init(_ handler: @escaping () -> Void) { + self.handler = handler + } + + @objc func fire() { + handler() + } + } + + /// The menu items hold their target weakly, so the choices are retained + /// here for the duration of the (synchronous) popup. + private static var retained: [Choice] = [] + + static func show(urls: [URL], at point: CGPoint) { + guard !urls.isEmpty else { return } + retained.removeAll() + + let menu = NSMenu() + menu.autoenablesItems = false + + let header = NSMenuItem( + title: WheelBuilder.summary(for: urls), + action: nil, + keyEquivalent: "" + ) + header.isEnabled = false + menu.addItem(header) + menu.addItem(.separator()) + + let category = FileKind.category(of: urls[0]) + + if ConversionCatalog.outputs(for: urls.map { FileKind.category(of: $0) }).isEmpty { + let item = NSMenuItem(title: "No conversions for this selection", action: nil, keyEquivalent: "") + item.isEnabled = false + menu.addItem(item) + } else { + let convert = NSMenu() + for item in WheelBuilder.items(kind: .convert, urls: urls) { + let entry = NSMenuItem(title: item.title, action: #selector(Choice.fire), keyEquivalent: "") + entry.target = makeChoice { + JobStore.shared.submit(urls: urls, target: .format(format(from: item))) + } + entry.isEnabled = item.isEnabled + if let reason = item.availability.reason { entry.toolTip = reason } + convert.addItem(entry) + } + let submenu = NSMenuItem(title: "Convert", action: nil, keyEquivalent: "") + submenu.submenu = convert + menu.addItem(submenu) + } + + let tools = WheelBuilder.items(kind: .tools, urls: urls) + if category != .unknown, !tools.isEmpty { + let submenu = NSMenu() + for item in tools { + guard case .tool(let tool) = item.payload else { continue } + let entry = NSMenuItem(title: tool.title, action: #selector(Choice.fire), keyEquivalent: "") + entry.target = makeChoice { + if tool.fields.isEmpty { + JobStore.shared.submit(urls: urls, target: .tool(tool)) + } else { + OptionsWindowController.shared.present(tool: tool, urls: urls, origin: point) + } + } + entry.isEnabled = item.isEnabled + if let reason = item.availability.reason { entry.toolTip = reason } + submenu.addItem(entry) + } + let wrapper = NSMenuItem(title: "Tools", action: nil, keyEquivalent: "") + wrapper.submenu = submenu + menu.addItem(wrapper) + } + + let actions = NSMenu() + for item in WheelBuilder.items(kind: .actions, urls: urls) { + guard case .action(let action) = item.payload else { continue } + let entry = NSMenuItem(title: action.title, action: #selector(Choice.fire), keyEquivalent: "") + entry.target = makeChoice { + JobStore.shared.submit(urls: urls, target: .action(action)) + } + actions.addItem(entry) + } + let actionsWrapper = NSMenuItem(title: "Actions", action: nil, keyEquivalent: "") + actionsWrapper.submenu = actions + menu.addItem(actionsWrapper) + + menu.popUp(positioning: nil, at: point, in: nil) + retained.removeAll() + } + + private static func makeChoice(_ handler: @escaping () -> Void) -> Choice { + let choice = Choice(handler) + retained.append(choice) + return choice + } + + private static func format(from item: WheelItem) -> Format { + if case .format(let format) = item.payload { return format } + return .png + } +} diff --git a/Sources/Konverter/UI/ResultWindow.swift b/Sources/Konverter/UI/ResultWindow.swift new file mode 100644 index 0000000..5c4ef91 --- /dev/null +++ b/Sources/Konverter/UI/ResultWindow.swift @@ -0,0 +1,66 @@ +import AppKit +import SwiftUI + +/// Longer text results (Info, Read QR, Statistics, archive listings). +final class ResultWindowController { + static let shared = ResultWindowController() + + private var window: NSWindow? + + private init() {} + + func show(title: String, body: String) { + let view = ResultView(title: title, text: body, onClose: { [weak self] in self?.close() }) + close() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 460, height: 320), + styleMask: [.titled, .closable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.title = title + window.titlebarAppearsTransparent = true + window.level = .floating + window.contentView = NSHostingView(rootView: view) + window.center() + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + self.window = window + } + + func close() { + window?.orderOut(nil) + window = nil + } +} + +struct ResultView: View { + let title: String + let text: String + let onClose: () -> Void + + var body: some View { + VStack(spacing: 0) { + ScrollView { + Text(text) + .font(.system(size: 11.5, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + } + Divider().opacity(0.4) + HStack { + Button("Copy") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + Spacer() + Button("Done", action: onClose) + .keyboardShortcut(.defaultAction) + } + .padding(10) + } + .frame(minWidth: 420, minHeight: 260) + } +} diff --git a/build-app.sh b/build-app.sh new file mode 100755 index 0000000..f528496 --- /dev/null +++ b/build-app.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# Builds Konverter.app. +# +# ./build-app.sh # version from git tag, else 0.1.0 +# ./build-app.sh 1.2.0 # explicit version +# +# Signs with the first Developer ID Application identity in the keychain when +# one exists (needed for notarization), otherwise ad-hoc signs so the bundle +# still runs locally. +set -euo pipefail +cd "$(dirname "$0")" + +if [ "${1:-}" != "" ]; then + VERSION="$1" +else + VERSION=$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || true) + VERSION="${VERSION:-0.1.0}" +fi + +swift build -c release + +BIN=".build/release/Konverter" +APP="Konverter.app" + +if [ ! -x "$BIN" ]; then + echo "build-app.sh: $BIN not found after swift build." >&2 + exit 1 +fi + +IDENT="" +if command -v security >/dev/null 2>&1; then + IDENT=$(security find-identity -v -p codesigning 2>/dev/null \ + | awk '/Developer ID Application:/ { print $2; exit }') +fi + +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp "$BIN" "$APP/Contents/MacOS/Konverter" +printf 'APPL????' > "$APP/Contents/PkgInfo" + +cat > "$APP/Contents/Info.plist" < + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + Konverter + CFBundleIconFile + Icon + CFBundleIdentifier + dev.tzgyn.konverter + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Konverter + CFBundleDisplayName + Konverter + CFBundlePackageType + APPL + CFBundleShortVersionString + ${VERSION} + CFBundleVersion + ${VERSION} + LSMinimumSystemVersion + 13.0 + LSUIElement + + LSApplicationCategoryType + public.app-category.utilities + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + NSHumanReadableCopyright + Copyright © 2026 Teh Zi Gyn. All rights reserved. + NSServices + + + NSMenuItem + + default + Convert with Konverter… + + NSMessage + convertFiles + NSPortName + Konverter + NSSendFileTypes + + public.item + + NSRequiredContext + + + + + +PLIST + +chmod +x scripts/make-icon.sh +./scripts/make-icon.sh + +if [ -n "$IDENT" ]; then + security unlock-keychain "$HOME/Library/Keychains/login.keychain-db" 2>/dev/null || true + codesign --force --options runtime --timestamp --sign "$IDENT" "$APP" + echo "Built $APP $VERSION (signed with Developer ID '$IDENT')" +else + codesign --force --sign - "$APP" + echo "Built $APP $VERSION (ad-hoc signed; not distributable)" +fi diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..cf9cf8a --- /dev/null +++ b/release.sh @@ -0,0 +1,95 @@ +#!/bin/bash +# Builds, signs, notarizes, staples, and zips Konverter for release. +# +# ./release.sh 0.1.0 +# +# Notarization is required. If it fails, this script fails: do not publish the +# tag, release, or cask from an unnotarized build. +set -euo pipefail +cd "$(dirname "$0")" + +if [ "$#" -ne 1 ]; then + echo "usage: $0 e.g. ./release.sh 0.1.0" >&2 + exit 1 +fi +VERSION="$1" + +APP="Konverter.app" +ZIP="dist/Konverter.zip" + +IDENT=$(security find-identity -v -p codesigning 2>/dev/null \ + | awk '/Developer ID Application:/ { print $2; exit }') +if [ -z "$IDENT" ]; then + echo "release.sh: no Developer ID Application identity in the keychain." >&2 + echo "An ad-hoc signed build cannot be notarized; install the certificate first." >&2 + exit 1 +fi + +KEY_PATH="${KONVERTER_NOTARY_API_KEY_PATH:-$HOME/.config/kapture/AuthKey_K5KXMMSB28.p8}" +KEY_ID="${KONVERTER_NOTARY_API_KEY_ID:-K5KXMMSB28}" +ISSUER="${KONVERTER_NOTARY_API_ISSUER:-3e25e5dc-8b75-453f-a71c-c7f3feaac628}" + +if [ ! -f "$KEY_PATH" ]; then + echo "release.sh: notary API key not found at $KEY_PATH" >&2 + exit 1 +fi +if ! xcrun --find notarytool >/dev/null 2>&1; then + echo "release.sh: xcrun notarytool not found (requires Xcode 13+)." >&2 + exit 1 +fi + +./build-app.sh "$VERSION" + +# The zip submitted to Apple must contain the signed bundle only. +mkdir -p dist +rm -f "$ZIP" +ditto -c -k --keepParent "$APP" "$ZIP" + +echo "Submitting to Apple for notarization (first run can take minutes)..." +RESULT=$(mktemp) +trap 'rm -f "$RESULT"' EXIT + +xcrun notarytool submit "$ZIP" \ + --key "$KEY_PATH" \ + --key-id "$KEY_ID" \ + --issuer "$ISSUER" \ + --wait \ + --output-format json > "$RESULT" + +STATUS=$(/usr/bin/python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("status","unknown"))' "$RESULT" 2>/dev/null || echo unknown) +if [ "$STATUS" != "Accepted" ]; then + echo "release.sh: notarization returned '$STATUS' — release aborted." >&2 + cat "$RESULT" >&2 + SUBMIT_ID=$(/usr/bin/python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("id",""))' "$RESULT" 2>/dev/null || true) + if [ -n "$SUBMIT_ID" ]; then + echo "Detail:" >&2 + xcrun notarytool log "$SUBMIT_ID" --key "$KEY_PATH" --key-id "$KEY_ID" --issuer "$ISSUER" >&2 || true + fi + exit 1 +fi + +echo "Notarization accepted — stapling ticket onto the app..." +xcrun stapler staple "$APP" +xcrun stapler validate "$APP" + +# Re-zip so the distributed archive carries the stapled ticket. +rm -f "$ZIP" +ditto -c -k --keepParent "$APP" "$ZIP" + +echo "Verifying Gatekeeper assessment..." +if ! spctl -a -vv "$APP" 2>&1 | grep -q 'source=Notarized Developer ID'; then + echo "release.sh: spctl does not report 'source=Notarized Developer ID' — release aborted." >&2 + spctl -a -vv "$APP" >&2 || true + exit 1 +fi +spctl -a -vv "$APP" 2>&1 | sed 's/^/ /' + +echo +echo "Done — notarized and stapled." +shasum -a 256 "$ZIP" +echo +echo "Publish with:" +echo " git tag v$VERSION && git push origin v$VERSION" +echo " tea releases create --tag v$VERSION --title \"Konverter $VERSION\" \\" +echo " --note \"\" --asset $ZIP \\" +echo " --repo TZGyn/konverter --login gitea.tzgyn.dev" diff --git a/scripts/gen-icon.swift b/scripts/gen-icon.swift new file mode 100644 index 0000000..b863b64 --- /dev/null +++ b/scripts/gen-icon.swift @@ -0,0 +1,113 @@ +import AppKit + +// Draws the Konverter icon: a warm tangerine squircle with a radial conversion +// wheel on it. Everything is vector-drawn so the script has no dependencies. + +let canvas: CGFloat = 1024 +let size = NSSize(width: canvas, height: canvas) +guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: Int(canvas), pixelsHigh: Int(canvas), + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0 +) else { exit(1) } + +NSGraphicsContext.saveGraphicsState() +NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) +NSGraphicsContext.current?.imageInterpolation = .high + +let rect = NSRect(origin: .zero, size: size) + +// macOS icons live inside a ~10% margin; the squircle fills the rest. +let body = rect.insetBy(dx: 96, dy: 96) +let squircle = NSBezierPath(roundedRect: body, xRadius: 200, yRadius: 200) + +let gradient = NSGradient(colors: [ + NSColor(calibratedRed: 1.00, green: 0.66, blue: 0.20, alpha: 1), + NSColor(calibratedRed: 0.94, green: 0.33, blue: 0.11, alpha: 1), +])! +gradient.draw(in: squircle, angle: -60) + +// Soft top highlight. +NSGraphicsContext.saveGraphicsState() +squircle.addClip() +NSGradient(colors: [ + NSColor.white.withAlphaComponent(0.28), + NSColor.white.withAlphaComponent(0.00), +])!.draw(in: NSRect(x: body.minX, y: body.midY, width: body.width, height: body.height / 2), angle: -90) +NSGraphicsContext.restoreGraphicsState() + +// Radial wheel: eight segments, one of them highlighted as the selection. +let center = NSPoint(x: rect.midX, y: rect.midY + 24) +let outer: CGFloat = 296 +let inner: CGFloat = 150 +let segmentCount = 8 +let gap: CGFloat = 3.2 + +func polar(_ radius: CGFloat, _ degrees: CGFloat) -> NSPoint { + let radians = degrees * .pi / 180 + return NSPoint(x: center.x + radius * cos(radians), y: center.y + radius * sin(radians)) +} + +func segment(_ index: Int, from start: CGFloat, to end: CGFloat) -> NSBezierPath { + let path = NSBezierPath() + path.move(to: polar(inner, start)) + path.line(to: polar(outer, start)) + path.appendArc(withCenter: center, radius: outer, startAngle: start, endAngle: end) + path.line(to: polar(inner, end)) + path.appendArc(withCenter: center, radius: inner, startAngle: end, endAngle: start, clockwise: true) + path.close() + return path +} + +for index in 0..= 2 else { exit(1) } +guard let data = rep.representation(using: .png, properties: [:]) else { exit(1) } +do { + try data.write(to: URL(fileURLWithPath: CommandLine.arguments[1])) +} catch { + exit(1) +} diff --git a/scripts/make-icon.sh b/scripts/make-icon.sh new file mode 100755 index 0000000..a679518 --- /dev/null +++ b/scripts/make-icon.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Builds Icon.icns from scripts/gen-icon.swift and drops it into Konverter.app. +# Run ./build-app.sh first (this expects the bundle to exist). +set -euo pipefail + +cd "$(dirname "$0")/.." + +APP="Konverter.app" +if [ ! -d "$APP" ]; then + echo "make-icon.sh: $APP not found — run ./build-app.sh first." >&2 + exit 1 +fi + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +swift scripts/gen-icon.swift "$TMP/icon-1024.png" + +mkdir -p "$TMP/Icon.iconset" +for s in 16 32 128 256 512; do + d=$((s * 2)) + sips -z "$s" "$s" "$TMP/icon-1024.png" --out "$TMP/Icon.iconset/icon_${s}x${s}.png" >/dev/null + sips -z "$d" "$d" "$TMP/icon-1024.png" --out "$TMP/Icon.iconset/icon_${s}x${s}@2x.png" >/dev/null +done + +iconutil -c icns "$TMP/Icon.iconset" -o "$TMP/Icon.icns" +mkdir -p "$APP/Contents/Resources" +cp "$TMP/Icon.icns" "$APP/Contents/Resources/Icon.icns" +echo "Generated $APP/Contents/Resources/Icon.icns"