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.
This commit is contained in:
+18
@@ -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/
|
||||||
@@ -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 <files…> --to <ext>
|
||||||
|
./.build/debug/Konverter --convert <file> --tool <toolID> --param k=v
|
||||||
|
./build-app.sh <version> # → Konverter.app (signed)
|
||||||
|
./release.sh <version> # 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.
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<em>Shift = convert · Command (+Shift) = tools · Control = actions</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## 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 "<what changed>" --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.
|
||||||
@@ -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<NSString>
|
||||||
|
) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <files…> --to <ext>\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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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..<frameCount {
|
||||||
|
try cancellation?.check()
|
||||||
|
let time = CMTime(seconds: Double(index) * frameDuration, preferredTimescale: 600)
|
||||||
|
if let image = try? generator.copyCGImage(at: time, actualTime: nil) {
|
||||||
|
CGImageDestinationAddImage(output, image, [
|
||||||
|
kCGImagePropertyGIFDictionary: [kCGImagePropertyGIFDelayTime: frameDuration]
|
||||||
|
] as CFDictionary)
|
||||||
|
}
|
||||||
|
progress?(Double(index + 1) / Double(frameCount))
|
||||||
|
}
|
||||||
|
guard CGImageDestinationFinalize(output) else {
|
||||||
|
throw ConversionError.failed("Writing the GIF failed.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private static func exportComposition(
|
||||||
|
_ composition: AVComposition,
|
||||||
|
at destination: URL,
|
||||||
|
cancellation: Cancellation?,
|
||||||
|
progress: ((Double) -> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
"<section>" + (chapter.title.isEmpty ? "" : "<h2>\(Markdown.escape(chapter.title))</h2>") + chapter.body + "</section>"
|
||||||
|
}.joined(separator: "\n")
|
||||||
|
return """
|
||||||
|
<!DOCTYPE html><html><head><meta charset="utf-8"><title>\(Markdown.escape(title))</title>
|
||||||
|
<style>body{font:16px/1.6 -apple-system,Georgia,serif;max-width:720px;margin:48px auto;padding:0 24px;color:#1d1d1f}</style>
|
||||||
|
</head><body>\(body)</body></html>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
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)<body[^>]*>", options: .regularExpression),
|
||||||
|
let endRange = inner.range(of: "(?is)</body>", options: .regularExpression) {
|
||||||
|
inner = String(inner[bodyRange.upperBound..<endRange.lowerBound])
|
||||||
|
}
|
||||||
|
return Markdown.replace(#"(?is)<(html|head|body|title|meta|link|script|style)[^>]*>|</(html|head|body|title|script|style)>"#, 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)^.*?<title[^>]*>(.*?)</title>.*$"#, 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 """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||||
|
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
|
||||||
|
</container>
|
||||||
|
""".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("<body") || chapter.1.contains("<html")
|
||||||
|
? chapter.1
|
||||||
|
: "<html xmlns=\"http://www.w3.org/1999/xhtml\"><head><title>\(Markdown.escape(chapterTitle))</title></head><body>\(chapter.1)</body></html>"
|
||||||
|
let xhtml = body.contains("xmlns=\"http://www.w3.org/1999/xhtml\"")
|
||||||
|
? body
|
||||||
|
: body.replacingOccurrences(of: "<html", with: "<html xmlns=\"http://www.w3.org/1999/xhtml\"", options: [], range: body.range(of: "<html"))
|
||||||
|
try xhtml.write(to: staging.appendingPathComponent("OEBPS/" + fileName), atomically: true, encoding: .utf8)
|
||||||
|
manifest += "<item id=\"chapter\(index + 1)\" href=\"\(fileName)\" media-type=\"application/xhtml+xml\"/>\n"
|
||||||
|
spine += "<itemref idref=\"chapter\(index + 1)\"/>\n"
|
||||||
|
navItems += "<li><a href=\"\(fileName)\">\(Markdown.escape(chapterTitle))</a></li>\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
try """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid">
|
||||||
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<dc:identifier id="bookid">\(identifier)</dc:identifier>
|
||||||
|
<dc:title>\(Markdown.escape(title))</dc:title>
|
||||||
|
<dc:creator>\(Markdown.escape(author))</dc:creator>
|
||||||
|
<dc:language>en</dc:language>
|
||||||
|
<meta property="dcterms:modified">\(ISO8601DateFormatter().string(from: Date()))</meta>
|
||||||
|
</metadata>
|
||||||
|
<manifest>
|
||||||
|
\(manifest)<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
|
||||||
|
</manifest>
|
||||||
|
<spine>
|
||||||
|
\(spine)</spine>
|
||||||
|
</package>
|
||||||
|
""".write(to: staging.appendingPathComponent("OEBPS/content.opf"), atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
try """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||||
|
<head><title>\(Markdown.escape(title))</title></head>
|
||||||
|
<body><nav epub:type="toc" id="toc"><h1>\(Markdown.escape(title))</h1><ol>
|
||||||
|
\(navItems)</ol></nav></body></html>
|
||||||
|
""".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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> = 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..<frameCount {
|
||||||
|
try cancellation?.check()
|
||||||
|
if needsFlatten || !keepMetadata {
|
||||||
|
guard let frame = CGImageSourceCreateImageAtIndex(imageSource, index, nil) else { continue }
|
||||||
|
let prepared = needsFlatten ? Raster.flattened(frame) : frame
|
||||||
|
CGImageDestinationAddImage(destinationRef, prepared, properties as CFDictionary)
|
||||||
|
} else {
|
||||||
|
CGImageDestinationAddImageFromSource(destinationRef, imageSource, index, properties as CFDictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard CGImageDestinationFinalize(destinationRef) else {
|
||||||
|
throw ConversionError.failed("Writing \(destination.lastPathComponent) failed.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import AppKit
|
||||||
|
import CoreGraphics
|
||||||
|
import CoreText
|
||||||
|
import Foundation
|
||||||
|
import Vision
|
||||||
|
|
||||||
|
/// In-place image transforms.
|
||||||
|
enum ImageTools {
|
||||||
|
static func compress(_ source: URL, at destination: URL, format: Format, quality: Double) throws {
|
||||||
|
guard let image = Raster.firstImage(at: source) else {
|
||||||
|
throw ConversionError.unreadable(source.lastPathComponent)
|
||||||
|
}
|
||||||
|
let outputFormat = format
|
||||||
|
var prepared = format == .jpg ? Raster.flattened(image) : image
|
||||||
|
// PNG/BMP/TIFF are lossless: ImageIO ignores the quality knob, so the
|
||||||
|
// slider drives colour depth instead. JPEG-style quality is meaningless
|
||||||
|
// there, and shipping a "compressed" file that got bigger is worse than
|
||||||
|
// a visibly banded one that actually got smaller.
|
||||||
|
switch outputFormat {
|
||||||
|
case .png, .bmp, .tiff:
|
||||||
|
prepared = Raster.posterized(prepared, quality: quality)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
try Raster.write(prepared, to: destination, format: outputFormat, quality: quality, metadata: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resize(
|
||||||
|
_ source: URL,
|
||||||
|
at destination: URL,
|
||||||
|
maxWidth: Int,
|
||||||
|
maxHeight: Int,
|
||||||
|
percent: Int
|
||||||
|
) throws {
|
||||||
|
guard let image = Raster.firstImage(at: source) else {
|
||||||
|
throw ConversionError.unreadable(source.lastPathComponent)
|
||||||
|
}
|
||||||
|
var targetWidth = image.width
|
||||||
|
var targetHeight = image.height
|
||||||
|
if percent != 100 {
|
||||||
|
targetWidth = Int(Double(image.width) * Double(percent) / 100)
|
||||||
|
targetHeight = Int(Double(image.height) * Double(percent) / 100)
|
||||||
|
} else {
|
||||||
|
let scale = min(Double(maxWidth) / Double(image.width), Double(maxHeight) / Double(image.height), 1)
|
||||||
|
targetWidth = Int(Double(image.width) * scale)
|
||||||
|
targetHeight = Int(Double(image.height) * scale)
|
||||||
|
}
|
||||||
|
targetWidth = max(1, targetWidth)
|
||||||
|
targetHeight = max(1, targetHeight)
|
||||||
|
guard let resized = draw(image, size: CGSize(width: targetWidth, height: targetHeight)) else {
|
||||||
|
throw ConversionError.failed("Resizing failed.")
|
||||||
|
}
|
||||||
|
try write(resized, to: destination, matching: source)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func crop(_ source: URL, at destination: URL, inset: Int, aspect: String) throws {
|
||||||
|
guard let image = Raster.firstImage(at: source) else {
|
||||||
|
throw ConversionError.unreadable(source.lastPathComponent)
|
||||||
|
}
|
||||||
|
var rect = CGRect(x: 0, y: 0, width: image.width, height: image.height)
|
||||||
|
if aspect != "Keep", let ratio = aspectRatio(aspect) {
|
||||||
|
let current = CGFloat(image.width) / CGFloat(image.height)
|
||||||
|
if current > 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("<p>" + inline(paragraph.joined(separator: " ")) + "</p>")
|
||||||
|
paragraph = []
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeList() {
|
||||||
|
if let type = listType {
|
||||||
|
body.append("</\(type)>")
|
||||||
|
listType = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for rawLine in lines {
|
||||||
|
let line = rawLine
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
|
if trimmed.hasPrefix("```") {
|
||||||
|
if inCode {
|
||||||
|
body.append("<pre><code>" + escape(codeLines.joined(separator: "\n")) + "</code></pre>")
|
||||||
|
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("<h\(heading)>" + inline(text) + "</h\(heading)>")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmed == "---" || trimmed == "***" || trimmed == "___" {
|
||||||
|
flushParagraph()
|
||||||
|
closeList()
|
||||||
|
body.append("<hr/>")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmed.hasPrefix(">") {
|
||||||
|
flushParagraph()
|
||||||
|
closeList()
|
||||||
|
let text = trimmed.dropFirst().trimmingCharacters(in: .whitespaces)
|
||||||
|
body.append("<blockquote><p>" + inline(text) + "</p></blockquote>")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if let item = listItem(trimmed) {
|
||||||
|
flushParagraph()
|
||||||
|
if listType != item.type {
|
||||||
|
closeList()
|
||||||
|
listType = item.type
|
||||||
|
body.append("<\(item.type)>")
|
||||||
|
}
|
||||||
|
body.append("<li>" + inline(item.text) + "</li>")
|
||||||
|
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("<tr>" + cells.map { "<td>\($0)</td>" }.joined() + "</tr>")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
paragraph.append(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if inCode {
|
||||||
|
body.append("<pre><code>" + escape(codeLines.joined(separator: "\n")) + "</code></pre>")
|
||||||
|
}
|
||||||
|
flushParagraph()
|
||||||
|
closeList()
|
||||||
|
|
||||||
|
return """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8"><title>\(escape(title))</title>
|
||||||
|
<style>
|
||||||
|
body { font: 16px/1.6 -apple-system, "Helvetica Neue", sans-serif; max-width: 720px; margin: 48px auto; padding: 0 24px; color: #1d1d1f; }
|
||||||
|
h1,h2,h3,h4 { line-height: 1.25; margin-top: 1.6em; }
|
||||||
|
code { background: #f2f2f4; padding: 2px 5px; border-radius: 4px; font-size: 0.9em; }
|
||||||
|
pre { background: #f2f2f4; padding: 14px; border-radius: 8px; overflow-x: auto; }
|
||||||
|
blockquote { border-left: 3px solid #d0d0d5; margin-left: 0; padding-left: 16px; color: #555; }
|
||||||
|
table { border-collapse: collapse; } td { border: 1px solid #ddd; padding: 6px 10px; }
|
||||||
|
hr { border: none; border-top: 1px solid #ddd; margin: 2em 0; }
|
||||||
|
</style></head><body>
|
||||||
|
\(body.joined(separator: "\n"))
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
"<img src=\"\(match[2])\" alt=\"\(match[1])\"/>"
|
||||||
|
}
|
||||||
|
text = replace(#"\[([^\]]+)\]\(([^)]+)\)"#, in: text) { match in
|
||||||
|
"<a href=\"\(match[2])\">\(match[1])</a>"
|
||||||
|
}
|
||||||
|
text = replace(#"`([^`]+)`"#, in: text) { "<code>\($0[1])</code>" }
|
||||||
|
text = replace(#"\*\*([^*]+)\*\*"#, in: text) { "<strong>\($0[1])</strong>" }
|
||||||
|
text = replace(#"__([^_]+)__"#, in: text) { "<strong>\($0[1])</strong>" }
|
||||||
|
text = replace(#"(?<!\*)\*([^*]+)\*(?!\*)"#, in: text) { "<em>\($0[1])</em>" }
|
||||||
|
text = replace(#"(?<!_)_([^_]+)_(?!_)"#, in: text) { "<em>\($0[1])</em>" }
|
||||||
|
text = replace(#"~~([^~]+)~~"#, in: text) { "<del>\($0[1])</del>" }
|
||||||
|
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)[^>]*>.*?</\1>"#, in: text) { _ in "" }
|
||||||
|
text = replace(#"(?is)<!--.*?-->"#, in: text) { _ in "" }
|
||||||
|
|
||||||
|
// Block level first.
|
||||||
|
for level in (1...6).reversed() {
|
||||||
|
text = replace(#"(?is)<h\#(level)[^>]*>(.*?)</h\#(level)>"#, in: text) { match in
|
||||||
|
"\n\n" + String(repeating: "#", count: level) + " " + stripTags(match[1]).trimmed + "\n\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text = replace(#"(?is)<pre[^>]*>(.*?)</pre>"#, in: text) { match in
|
||||||
|
"\n\n```\n" + stripTags(match[1]).trimmingCharacters(in: .newlines) + "\n```\n\n"
|
||||||
|
}
|
||||||
|
text = replace(#"(?is)<blockquote[^>]*>(.*?)</blockquote>"#, in: text) { match in
|
||||||
|
"\n\n" + stripTags(match[1]).trimmed
|
||||||
|
.split(separator: "\n").map { "> " + $0 }.joined(separator: "\n") + "\n\n"
|
||||||
|
}
|
||||||
|
text = replace(#"(?is)<li[^>]*>(.*?)</li>"#, in: text) { match in
|
||||||
|
"\n- " + stripTags(match[1]).trimmed
|
||||||
|
}
|
||||||
|
text = replace(#"(?is)<(ul|ol)[^>]*>"#, in: text) { _ in "\n" }
|
||||||
|
text = replace(#"(?is)</(ul|ol)>"#, in: text) { _ in "\n" }
|
||||||
|
text = replace(#"(?is)<hr[^>]*/?>"#, in: text) { _ in "\n\n---\n\n" }
|
||||||
|
text = replace(#"(?is)<t[dh][^>]*>(.*?)</t[dh]>"#, in: text) { match in
|
||||||
|
" " + stripTags(match[1]).trimmed + " |"
|
||||||
|
}
|
||||||
|
text = replace(#"(?is)<tr[^>]*>"#, in: text) { _ in "\n|" }
|
||||||
|
text = replace(#"(?is)<br[^>]*/?>"#, in: text) { _ in " \n" }
|
||||||
|
text = replace(#"(?is)</p>"#, in: text) { _ in "\n\n" }
|
||||||
|
text = replace(#"(?is)<p[^>]*>"#, in: text) { _ in "\n\n" }
|
||||||
|
text = replace(#"(?is)</div>"#, in: text) { _ in "\n" }
|
||||||
|
|
||||||
|
// Inline.
|
||||||
|
text = replace(#"(?is)<(strong|b)[^>]*>(.*?)</\1>"#, in: text) { "**\(stripTags($0[2]).trimmed)**" }
|
||||||
|
text = replace(#"(?is)<(em|i)[^>]*>(.*?)</\1>"#, in: text) { "*\(stripTags($0[2]).trimmed)*" }
|
||||||
|
text = replace(#"(?is)<del[^>]*>(.*?)</del>"#, in: text) { "~~\(stripTags($0[1]).trimmed)~~" }
|
||||||
|
text = replace(#"(?is)<code[^>]*>(.*?)</code>"#, in: text) { "`\(stripTags($0[1]))`" }
|
||||||
|
text = replace(#"(?is)<img[^>]*alt=["']([^"']*)["'][^>]*src=["']([^"']+)["'][^>]*/?>"#, in: text) {
|
||||||
|
"![\($0[1])](\($0[2]))"
|
||||||
|
}
|
||||||
|
text = replace(#"(?is)<img[^>]*src=["']([^"']+)["'][^>]*/?>"#, in: text) { match in ")" }
|
||||||
|
text = replace(#"(?is)<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>"#, 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..<match.numberOfRanges {
|
||||||
|
if let groupRange = Range(match.range(at: index), in: output) {
|
||||||
|
groups.append(String(output[groupRange]))
|
||||||
|
} else {
|
||||||
|
groups.append("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.replaceSubrange(matchRange, with: transform(groups))
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import CoreMedia
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Video and audio tools. Where AVFoundation cannot express an edit (loudness
|
||||||
|
/// normalisation, silence ranges, bitrate targets) ffmpeg is used instead.
|
||||||
|
enum MediaTools {
|
||||||
|
static func compressVideo(_ source: URL, at destination: URL, quality: String) throws {
|
||||||
|
if Toolchain.hasFFmpeg, Settings.shared.useExternalTools {
|
||||||
|
let crf = quality == "Low" ? "30" : (quality == "High" ? "21" : "26")
|
||||||
|
try FFmpeg.filter(
|
||||||
|
source: source,
|
||||||
|
at: destination,
|
||||||
|
arguments: ["-c:v", "libx264", "-preset", "slow", "-crf", crf, "-c:a", "aac", "-b:a", "128k"]
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try AVConverter.convert(source: source, to: destination.pathExtension == "mov" ? .mov : .mp4, at: destination, quality: quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func normalizeAudio(_ source: URL, at destination: URL) throws {
|
||||||
|
try FFmpeg.filter(
|
||||||
|
source: source,
|
||||||
|
at: destination,
|
||||||
|
arguments: ["-af", "loudnorm=I=-16:TP=-1.5:LRA=11", "-c:a", codecForContainer(destination)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func channels(_ source: URL, at destination: URL, mono: Bool) throws {
|
||||||
|
if Toolchain.hasFFmpeg, Settings.shared.useExternalTools {
|
||||||
|
try FFmpeg.filter(
|
||||||
|
source: source,
|
||||||
|
at: destination,
|
||||||
|
arguments: ["-ac", mono ? "1" : "2", "-c:a", codecForContainer(destination)]
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try CoreAudio.remix(source: source, at: destination, mono: mono)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func bleep(_ source: URL, at destination: URL, start: Double, end: Double) throws {
|
||||||
|
let filter = "volume=enable='between(t,\(start),\(end))':volume=0"
|
||||||
|
try FFmpeg.filter(
|
||||||
|
source: source,
|
||||||
|
at: destination,
|
||||||
|
arguments: ["-af", filter, "-c:a", codecForContainer(destination)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func compressAudio(_ source: URL, at destination: URL, bitrate: String) throws {
|
||||||
|
guard let format = FileKind.format(of: destination) else {
|
||||||
|
throw ConversionError.failed("Unknown audio container.")
|
||||||
|
}
|
||||||
|
try FFmpeg.convert(source: source, to: format, at: destination, bitrate: bitrate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Audio edits keep the source container where possible so the output name
|
||||||
|
/// and the file itself stay recognisable.
|
||||||
|
static func audioSibling(for source: URL, suffix: String, forcing format: Format? = nil) -> 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CGRect>.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..<document.pageCount)
|
||||||
|
var written: [URL] = []
|
||||||
|
let scale = dpi / 72
|
||||||
|
let padding = String(indices.count).count
|
||||||
|
|
||||||
|
for (offset, pageIndex) in indices.enumerated() {
|
||||||
|
try cancellation?.check()
|
||||||
|
guard let page = document.page(at: pageIndex) else { continue }
|
||||||
|
let bounds = page.bounds(for: .mediaBox)
|
||||||
|
let pixelWidth = Int((bounds.width * scale).rounded())
|
||||||
|
let pixelHeight = Int((bounds.height * scale).rounded())
|
||||||
|
guard pixelWidth > 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..<document.pageCount)
|
||||||
|
.compactMap { document.page(at: $0)?.string }
|
||||||
|
.joined(separator: pageBreak)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Document surgery
|
||||||
|
|
||||||
|
static func merge(_ sources: [URL], at destination: URL, cancellation: Cancellation? = nil) throws {
|
||||||
|
let merged = PDFDocument()
|
||||||
|
var index = 0
|
||||||
|
for source in sources {
|
||||||
|
try cancellation?.check()
|
||||||
|
guard let document = PDFDocument(url: source) else {
|
||||||
|
throw ConversionError.unreadable(source.lastPathComponent)
|
||||||
|
}
|
||||||
|
for pageIndex in 0..<document.pageCount {
|
||||||
|
try cancellation?.check()
|
||||||
|
guard let page = document.page(at: pageIndex)?.copy() as? PDFPage else { continue }
|
||||||
|
merged.insert(page, at: index)
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard merged.pageCount > 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..<min(start + pagesPerFile, document.pageCount) {
|
||||||
|
guard let page = document.page(at: pageIndex)?.copy() as? PDFPage else { continue }
|
||||||
|
chunk.insert(page, at: cursor)
|
||||||
|
cursor += 1
|
||||||
|
}
|
||||||
|
let target = OutputNaming.availableURL(
|
||||||
|
for: directory.appendingPathComponent("\(base)-\(String(format: "%02d", part)).pdf")
|
||||||
|
)
|
||||||
|
guard chunk.write(to: target) else {
|
||||||
|
throw ConversionError.failed("Could not write \(target.lastPathComponent).")
|
||||||
|
}
|
||||||
|
written.append(target)
|
||||||
|
start += pagesPerFile
|
||||||
|
part += 1
|
||||||
|
}
|
||||||
|
return written
|
||||||
|
}
|
||||||
|
|
||||||
|
static func rotate(_ source: URL, degrees: Int, at destination: URL) throws {
|
||||||
|
guard let document = PDFDocument(url: source) else {
|
||||||
|
throw ConversionError.unreadable(source.lastPathComponent)
|
||||||
|
}
|
||||||
|
for index in 0..<document.pageCount {
|
||||||
|
guard let page = document.page(at: index) else { continue }
|
||||||
|
page.rotation = (page.rotation + degrees) % 360
|
||||||
|
}
|
||||||
|
guard document.write(to: destination) else {
|
||||||
|
throw ConversionError.failed("Could not write \(destination.lastPathComponent).")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func encrypt(_ source: URL, at destination: URL, password: String, ownerPassword: String?) throws {
|
||||||
|
guard let document = PDFDocument(url: source) else {
|
||||||
|
throw ConversionError.unreadable(source.lastPathComponent)
|
||||||
|
}
|
||||||
|
let options: [PDFDocumentWriteOption: Any] = [
|
||||||
|
.userPasswordOption: password,
|
||||||
|
.ownerPasswordOption: ownerPassword?.isEmpty == false ? ownerPassword! : password
|
||||||
|
]
|
||||||
|
guard document.write(to: destination, withOptions: options) else {
|
||||||
|
throw ConversionError.failed("Could not write the encrypted PDF.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrites through an image pipeline, which is what "compress a PDF"
|
||||||
|
/// means in practice: re-encode each page as a JPEG at lower quality.
|
||||||
|
static func compress(
|
||||||
|
_ source: URL,
|
||||||
|
at destination: URL,
|
||||||
|
quality: Double,
|
||||||
|
cancellation: Cancellation? = nil,
|
||||||
|
progress: ((Double) -> 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..<document.pageCount {
|
||||||
|
try cancellation?.check()
|
||||||
|
guard let page = document.page(at: index) else { continue }
|
||||||
|
let bounds = page.bounds(for: .mediaBox)
|
||||||
|
let scale: CGFloat = 150 / 72
|
||||||
|
let width = Int(bounds.width * scale)
|
||||||
|
let height = Int(bounds.height * scale)
|
||||||
|
var box = bounds
|
||||||
|
context.beginPDFPage([kCGPDFContextMediaBox: CGRectToData(box)] as CFDictionary)
|
||||||
|
if width > 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = """
|
||||||
|
<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
||||||
|
body{font:11px -apple-system,sans-serif;margin:24px}
|
||||||
|
table{border-collapse:collapse;width:100%}
|
||||||
|
td,th{border:1px solid #d8d8dd;padding:4px 8px;vertical-align:top}
|
||||||
|
th{background:#f4f4f7;text-align:left}
|
||||||
|
</style></head><body><table>
|
||||||
|
"""
|
||||||
|
for (index, row) in rows.enumerated() {
|
||||||
|
let tag = index == 0 ? "th" : "td"
|
||||||
|
html += "<tr>" + (0..<columnCount).map { column in
|
||||||
|
let value = column < row.count ? row[column] : ""
|
||||||
|
return "<\(tag)>\(Markdown.escape(value))</\(tag)>"
|
||||||
|
}.joined() + "</tr>"
|
||||||
|
}
|
||||||
|
html += "</table></body></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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>
|
||||||
|
"""
|
||||||
|
for (rowIndex, row) in rows.enumerated() {
|
||||||
|
xml += "<row r=\"\(rowIndex + 1)\">"
|
||||||
|
for (columnIndex, value) in row.enumerated() {
|
||||||
|
let reference = "\(columnName(columnIndex))\(rowIndex + 1)"
|
||||||
|
if let number = numberValue(value) {
|
||||||
|
xml += "<c r=\"\(reference)\"><v>\(number)</v></c>"
|
||||||
|
} else {
|
||||||
|
xml += "<c r=\"\(reference)\" t=\"inlineStr\"><is><t xml:space=\"preserve\">\(Markdown.escape(value))</t></is></c>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
xml += "</row>"
|
||||||
|
}
|
||||||
|
xml += "</sheetData></worksheet>"
|
||||||
|
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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||||
|
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||||
|
<Default Extension="xml" ContentType="application/xml"/>
|
||||||
|
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
||||||
|
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
||||||
|
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
|
||||||
|
</Types>
|
||||||
|
"""
|
||||||
|
|
||||||
|
private static let packageRels = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||||
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||||
|
</Relationships>
|
||||||
|
"""
|
||||||
|
|
||||||
|
private static let workbookRels = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||||
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
|
||||||
|
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||||
|
</Relationships>
|
||||||
|
"""
|
||||||
|
|
||||||
|
private static let workbook = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||||
|
<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>
|
||||||
|
"""
|
||||||
|
|
||||||
|
private static let styles = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||||
|
<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
|
||||||
|
<fills count="1"><fill><patternFill patternType="none"/></fill></fills>
|
||||||
|
<borders count="1"><border/></borders>
|
||||||
|
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
|
||||||
|
<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>
|
||||||
|
</styleSheet>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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..<min(repeatColumns, 256) { row.append(value) }
|
||||||
|
inCell = false
|
||||||
|
case "table-row":
|
||||||
|
if !row.allSatisfy({ $0.isEmpty }) { rows.append(row) }
|
||||||
|
inRow = false
|
||||||
|
case "table":
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Unzip {
|
||||||
|
/// Expands an archive and returns the staging directory the caller must
|
||||||
|
/// delete.
|
||||||
|
static func expand(_ url: URL, cancellation: Cancellation? = nil) throws -> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ["<i>", "</i>", "<b>", "</b>", "<u>", "</u>", "<font>", "</font>"] {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body><pre>\(escaped)</pre></body></html>"
|
||||||
|
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 { "<p>" + Markdown.escape($0).replacingOccurrences(of: "\n", with: "<br/>") + "</p>" }
|
||||||
|
.joined(separator: "\n")
|
||||||
|
return """
|
||||||
|
<!DOCTYPE html><html><head><meta charset="utf-8"><title>\(Markdown.escape(title))</title>
|
||||||
|
<style>body{font:16px/1.6 -apple-system,sans-serif;max-width:720px;margin:48px auto;padding:0 24px}</style>
|
||||||
|
</head><body>\(paragraphs)</body></html>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T>(_ value: T, _ key: Key) {
|
||||||
|
defaults.set(value, forKey: "konverter." + key.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func read<T: RawRepresentable>(_ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: " · ")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<NSPasteboard.PasteboardType> = [
|
||||||
|
.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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("<strong>bold</strong>") 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: "<h1>Title</h1><p>Hello <strong>world</strong></p>")
|
||||||
|
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 = "<h1>Chapter</h1><p>An ebook built from scratch.</p>"
|
||||||
|
let epub = directory.appendingPathComponent("book.epub")
|
||||||
|
|
||||||
|
check("build epub") {
|
||||||
|
try EbookConverter.write(
|
||||||
|
title: "Test Book",
|
||||||
|
author: "Konverter",
|
||||||
|
chapters: [("One", html), ("Two", "<p>More text.</p>")],
|
||||||
|
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..<sampleCount {
|
||||||
|
let value = sin(2 * Double.pi * 440 * Double(index) / Double(sampleRate)) * 0.3
|
||||||
|
var little = Int16(value * Double(Int16.max)).littleEndian
|
||||||
|
samples.append(Data(bytes: &little, count: 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = Data()
|
||||||
|
func append(_ string: String) { data.append(Data(string.utf8)) }
|
||||||
|
func append(_ value: UInt32) { var v = value.littleEndian; data.append(Data(bytes: &v, count: 4)) }
|
||||||
|
func append(_ value: UInt16) { var v = value.littleEndian; data.append(Data(bytes: &v, count: 2)) }
|
||||||
|
|
||||||
|
append("RIFF")
|
||||||
|
append(UInt32(36 + samples.count))
|
||||||
|
append("WAVE")
|
||||||
|
append("fmt ")
|
||||||
|
append(UInt32(16))
|
||||||
|
append(UInt16(1))
|
||||||
|
append(UInt16(1))
|
||||||
|
append(UInt32(sampleRate))
|
||||||
|
append(UInt32(sampleRate * 2))
|
||||||
|
append(UInt16(2))
|
||||||
|
append(UInt16(16))
|
||||||
|
append("data")
|
||||||
|
append(UInt32(samples.count))
|
||||||
|
data.append(samples)
|
||||||
|
|
||||||
|
try data.write(to: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import PDFKit
|
||||||
|
import QuickLookThumbnailing
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// "Float": a borderless preview that hovers over other windows, the same
|
||||||
|
/// gesture Tangerine offers from the Control wheel.
|
||||||
|
final class FloatPanel: NSObject {
|
||||||
|
static let shared = FloatPanel()
|
||||||
|
|
||||||
|
private var window: NSWindow?
|
||||||
|
private var urls: [URL] = []
|
||||||
|
private var index = 0
|
||||||
|
|
||||||
|
private override init() { super.init() }
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func show(urls: [URL]) {
|
||||||
|
self.urls = urls
|
||||||
|
index = 0
|
||||||
|
let window = ensureWindow()
|
||||||
|
if let screen = NSScreen.screens.first(where: { $0.frame.contains(NSEvent.mouseLocation) }) ?? NSScreen.main {
|
||||||
|
let frame = screen.visibleFrame
|
||||||
|
let size = CGSize(width: 420, height: 320)
|
||||||
|
window.setContentSize(size)
|
||||||
|
window.setFrameOrigin(CGPoint(
|
||||||
|
x: min(max(frame.minX + 16, NSEvent.mouseLocation.x - size.width / 2), frame.maxX - size.width - 16),
|
||||||
|
y: min(max(frame.minY + 16, NSEvent.mouseLocation.y - size.height - 24), frame.maxY - size.height - 16)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
window.alphaValue = 0
|
||||||
|
window.orderFrontRegardless()
|
||||||
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = 0.18
|
||||||
|
window.animator().alphaValue = 1
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func ensureWindow() -> 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<FloatView> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)]
|
||||||
|
)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>(
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+112
@@ -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" <<PLIST
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>Konverter</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>Icon</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>dev.tzgyn.konverter</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>Konverter</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Konverter</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>${VERSION}</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>${VERSION}</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>13.0</string>
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
|
<key>LSApplicationCategoryType</key>
|
||||||
|
<string>public.app-category.utilities</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrincipalClass</key>
|
||||||
|
<string>NSApplication</string>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>Copyright © 2026 Teh Zi Gyn. All rights reserved.</string>
|
||||||
|
<key>NSServices</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>NSMenuItem</key>
|
||||||
|
<dict>
|
||||||
|
<key>default</key>
|
||||||
|
<string>Convert with Konverter…</string>
|
||||||
|
</dict>
|
||||||
|
<key>NSMessage</key>
|
||||||
|
<string>convertFiles</string>
|
||||||
|
<key>NSPortName</key>
|
||||||
|
<string>Konverter</string>
|
||||||
|
<key>NSSendFileTypes</key>
|
||||||
|
<array>
|
||||||
|
<string>public.item</string>
|
||||||
|
</array>
|
||||||
|
<key>NSRequiredContext</key>
|
||||||
|
<dict/>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
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
|
||||||
Executable
+95
@@ -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 <version> 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 \"<what changed>\" --asset $ZIP \\"
|
||||||
|
echo " --repo TZGyn/konverter --login gitea.tzgyn.dev"
|
||||||
@@ -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..<segmentCount {
|
||||||
|
let centre = 90 - CGFloat(index) * (360 / CGFloat(segmentCount))
|
||||||
|
let start = centre - (360 / CGFloat(segmentCount)) / 2 + gap
|
||||||
|
let end = centre + (360 / CGFloat(segmentCount)) / 2 - gap
|
||||||
|
let path = segment(index, from: start, to: end)
|
||||||
|
// Index 4 sits at -90°, i.e. straight down — where the arrow points.
|
||||||
|
let selected = index == 4
|
||||||
|
(selected ? NSColor.white : NSColor.white.withAlphaComponent(0.34)).setFill()
|
||||||
|
path.fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hub with a downward arrow: the drop that performs the conversion.
|
||||||
|
let hubRadius: CGFloat = 118
|
||||||
|
let hubRect = NSRect(
|
||||||
|
x: center.x - hubRadius, y: center.y - hubRadius,
|
||||||
|
width: hubRadius * 2, height: hubRadius * 2
|
||||||
|
)
|
||||||
|
NSColor(calibratedRed: 0.16, green: 0.10, blue: 0.10, alpha: 0.92).setFill()
|
||||||
|
NSBezierPath(ovalIn: hubRect).fill()
|
||||||
|
|
||||||
|
NSColor.white.setFill()
|
||||||
|
NSBezierPath(ovalIn: hubRect.insetBy(dx: 14, dy: 14)).fill()
|
||||||
|
|
||||||
|
// Arrow glyph, pointing at the selected segment.
|
||||||
|
let arrow = NSBezierPath()
|
||||||
|
let up = center.y + 58
|
||||||
|
let down = center.y - 58
|
||||||
|
let shaftWidth: CGFloat = 26
|
||||||
|
arrow.move(to: NSPoint(x: center.x - shaftWidth / 2, y: up))
|
||||||
|
arrow.line(to: NSPoint(x: center.x + shaftWidth / 2, y: up))
|
||||||
|
arrow.line(to: NSPoint(x: center.x + shaftWidth / 2, y: down + 30))
|
||||||
|
arrow.line(to: NSPoint(x: center.x + 62, y: down + 30))
|
||||||
|
arrow.line(to: NSPoint(x: center.x, y: down - 32))
|
||||||
|
arrow.line(to: NSPoint(x: center.x - 62, y: down + 30))
|
||||||
|
arrow.line(to: NSPoint(x: center.x - shaftWidth / 2, y: down + 30))
|
||||||
|
arrow.close()
|
||||||
|
arrow.lineJoinStyle = .round
|
||||||
|
arrow.lineWidth = 6
|
||||||
|
NSColor(calibratedRed: 0.86, green: 0.28, blue: 0.09, alpha: 1).setFill()
|
||||||
|
NSColor(calibratedRed: 0.86, green: 0.28, blue: 0.09, alpha: 1).setStroke()
|
||||||
|
arrow.fill()
|
||||||
|
arrow.stroke()
|
||||||
|
|
||||||
|
NSGraphicsContext.restoreGraphicsState()
|
||||||
|
|
||||||
|
guard CommandLine.arguments.count >= 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)
|
||||||
|
}
|
||||||
Executable
+29
@@ -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"
|
||||||
Reference in New Issue
Block a user