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:
2026-10-20 00:36:40 +08:00
commit 2d031e58a4
47 changed files with 9149 additions and 0 deletions
+91
View File
@@ -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)
}
}
}
+80
View File
@@ -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
}
}
}
+368
View File
@@ -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
}
}
+176
View File
@@ -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)
}
}
+156
View File
@@ -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.")
}
}
}
+268
View File
@@ -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)
}
}
+282
View File
@@ -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 "![](\(match[1]))" }
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: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
}
static func stripTags(_ html: String) -> String {
replace(#"(?is)<[^>]+>"#, in: html) { _ in "" }
}
static func decodeEntities(_ text: String) -> String {
var result = text
let map = [
"&nbsp;": " ", "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": "\"",
"&#39;": "'", "&apos;": "'", "&mdash;": "—", "&ndash;": "–", "&hellip;": "…",
"&rsquo;": "’", "&lsquo;": "‘", "&ldquo;": "“", "&rdquo;": "”"
]
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) }
}
+225
View File
@@ -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")
}
}
+110
View File
@@ -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)
}
}
+446
View File
@@ -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")
}
}
+144
View File
@@ -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)
}
}
}
}
}
+296
View File
@@ -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 }
}
}
+285
View File
@@ -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)
}
}
}
+66
View File
@@ -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))
}
}
+201
View File
@@ -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
}
}
}
+256
View File
@@ -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)
}
}
}
+55
View File
@@ -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: " · ")
}
}
+333
View File
@@ -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
}
}
}
+141
View File
@@ -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)
}
}
+127
View File
@@ -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 }
}
+170
View File
@@ -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))
}
}
+475
View File
@@ -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)
}
}
+232
View File
@@ -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
}
}
+254
View File
@@ -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()
}
}
+113
View File
@@ -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)]
)
])
}
}
+88
View File
@@ -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)
}
}
+190
View File
@@ -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
}
}
}
+110
View File
@@ -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
}
}
+66
View File
@@ -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)
}
}