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:
@@ -0,0 +1,91 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct KonverterApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
// A menu bar app: no windows of its own. Settings are a custom window
|
||||
// so the app can be opened from the status item on any space.
|
||||
SwiftUI.Settings { EmptyView() }
|
||||
}
|
||||
}
|
||||
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private let menuBar = MenuBarController()
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
if CommandLine.arguments.contains("--self-test") {
|
||||
exit(SelfTest.run())
|
||||
}
|
||||
if CommandLine.arguments.contains("--convert") {
|
||||
exit(CLI.run())
|
||||
}
|
||||
|
||||
terminateOtherInstances()
|
||||
|
||||
menuBar.install()
|
||||
|
||||
DragMonitor.shared.onBegan = { urls in
|
||||
WheelController.shared.begin(urls: urls)
|
||||
}
|
||||
DragMonitor.shared.onTick = { _, location, modifiers in
|
||||
guard Settings.shared.enabled else { return }
|
||||
WheelController.shared.tick(location: location, modifiers: modifiers)
|
||||
}
|
||||
DragMonitor.shared.onEnded = { _ in
|
||||
WheelController.shared.end()
|
||||
}
|
||||
DragMonitor.shared.onKey = { keyCode, modifiers in
|
||||
WheelController.shared.handleKey(keyCode, modifiers: modifiers)
|
||||
}
|
||||
DragMonitor.shared.start()
|
||||
|
||||
NSApp.servicesProvider = self
|
||||
NSUpdateDynamicServices()
|
||||
|
||||
// Warm the Quick Look machinery so the first Float is instant.
|
||||
DispatchQueue.main.async {
|
||||
_ = NSWorkspace.shared
|
||||
}
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
DragMonitor.shared.stop()
|
||||
WheelController.shared.cancel()
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
private func terminateOtherInstances() {
|
||||
let identifier = Bundle.main.bundleIdentifier ?? "dev.tzgyn.konverter"
|
||||
let mine = ProcessInfo.processInfo.processIdentifier
|
||||
for other in NSRunningApplication.runningApplications(withBundleIdentifier: identifier)
|
||||
where other.processIdentifier != mine {
|
||||
other.forceTerminate()
|
||||
}
|
||||
}
|
||||
|
||||
/// Finder Quick Action / Services entry. The wheel needs a pointer to sit
|
||||
/// under, so this opens the same options as a native menu.
|
||||
@objc func convertFiles(
|
||||
_ pasteboard: NSPasteboard,
|
||||
userData: String,
|
||||
error: AutoreleasingUnsafeMutablePointer<NSString>
|
||||
) {
|
||||
let urls = (pasteboard.readObjects(
|
||||
forClasses: [NSURL.self],
|
||||
options: [.urlReadingFileURLsOnly: true]
|
||||
) as? [URL]) ?? []
|
||||
guard !urls.isEmpty else {
|
||||
error.pointee = "No files were passed to Konverter." as NSString
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
QuickMenu.show(urls: urls, at: NSEvent.mouseLocation)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// Headless conversion, used for verification and scripting:
|
||||
///
|
||||
/// Konverter --convert a.png b.pdf --to jpg
|
||||
/// Konverter --convert clip.mov --tool trimVideo --param start=0:02 --param end=0:05
|
||||
enum CLI {
|
||||
static func run() -> Int32 {
|
||||
let arguments = Array(CommandLine.arguments.dropFirst())
|
||||
|
||||
guard let convertIndex = arguments.firstIndex(of: "--convert") else {
|
||||
FileHandle.standardError.write(Data("usage: Konverter --convert <files…> --to <ext>\n".utf8))
|
||||
return 2
|
||||
}
|
||||
|
||||
var files: [URL] = []
|
||||
var target: Format?
|
||||
var tool: ToolID?
|
||||
var params = ToolParams()
|
||||
|
||||
var index = convertIndex + 1
|
||||
while index < arguments.count {
|
||||
let argument = arguments[index]
|
||||
switch argument {
|
||||
case "--to":
|
||||
index += 1
|
||||
if index < arguments.count { target = Format(rawValue: arguments[index]) }
|
||||
case "--tool":
|
||||
index += 1
|
||||
if index < arguments.count { tool = ToolID(rawValue: arguments[index]) }
|
||||
case "--param":
|
||||
index += 1
|
||||
if index < arguments.count {
|
||||
let pair = arguments[index].split(separator: "=", maxSplits: 1).map(String.init)
|
||||
if pair.count == 2 { params[pair[0]] = pair[1] }
|
||||
}
|
||||
default:
|
||||
if !argument.hasPrefix("--") {
|
||||
files.append(URL(fileURLWithPath: argument))
|
||||
}
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
|
||||
guard !files.isEmpty else {
|
||||
FileHandle.standardError.write(Data("no input files\n".utf8))
|
||||
return 2
|
||||
}
|
||||
|
||||
do {
|
||||
if let tool {
|
||||
let outcome = try ToolRunner.run(tool: tool, inputs: files, params: params)
|
||||
if let message = outcome.message {
|
||||
print(message)
|
||||
}
|
||||
for output in outcome.outputs { print(output.path) }
|
||||
return outcome.outputs.isEmpty && outcome.message == nil ? 1 : 0
|
||||
}
|
||||
|
||||
guard let target else {
|
||||
FileHandle.standardError.write(Data("--to is required\n".utf8))
|
||||
return 2
|
||||
}
|
||||
|
||||
var written: [URL] = []
|
||||
for file in files {
|
||||
let destination = OutputNaming.destination(for: file, format: target)
|
||||
let outputs = try Converter.convert(source: file, to: target, at: destination)
|
||||
written.append(contentsOf: outputs)
|
||||
}
|
||||
for output in written { print(output.path) }
|
||||
return written.isEmpty ? 1 : 0
|
||||
} catch {
|
||||
let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
||||
FileHandle.standardError.write(Data((message + "\n").utf8))
|
||||
return 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import AVFoundation
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ImageIO
|
||||
|
||||
/// Audio and video, mostly through AVFoundation. Formats Apple cannot write
|
||||
/// (MKV, WebM, AVI, WMV, MP3, FLAC, OGG, Opus, WMA) are delegated to ffmpeg
|
||||
/// when it is installed — see `FFmpeg`.
|
||||
enum AVConverter {
|
||||
|
||||
// MARK: - Probing
|
||||
|
||||
static func duration(_ url: URL) -> Double? {
|
||||
let asset = AVURLAsset(url: url, options: [AVURLAssetPreferPreciseDurationAndTimingKey: true])
|
||||
let seconds = CMTimeGetSeconds(asset.duration)
|
||||
return seconds.isFinite && seconds > 0 ? seconds : nil
|
||||
}
|
||||
|
||||
/// Uses the synchronously loaded track list; called only while building the
|
||||
/// drag wheel, so it must not await.
|
||||
static func trackSummary(_ url: URL) -> (video: Bool, audio: Bool) {
|
||||
let asset = AVURLAsset(url: url)
|
||||
var video = false
|
||||
var audio = false
|
||||
for track in asset.tracks {
|
||||
switch track.mediaType {
|
||||
case .video where track.naturalSize.height > 0: video = true
|
||||
case .audio: audio = true
|
||||
default: break
|
||||
}
|
||||
}
|
||||
return (video, audio)
|
||||
}
|
||||
|
||||
// MARK: - Conversion
|
||||
|
||||
static func convert(
|
||||
source: URL,
|
||||
to format: Format,
|
||||
at destination: URL,
|
||||
quality: String = "Medium",
|
||||
bitrate: String = "128",
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws {
|
||||
try cancellation?.check()
|
||||
|
||||
switch format {
|
||||
case .gif:
|
||||
try makeGIF(source: source, at: destination, fps: 12, maxWidth: 480, cancellation: cancellation, progress: progress)
|
||||
return
|
||||
case .mp4, .mov, .m4v:
|
||||
try exportWithSession(
|
||||
source: source,
|
||||
destination: destination,
|
||||
fileType: format == .mov ? .mov : .mp4,
|
||||
preset: videoPreset(for: quality),
|
||||
cancellation: cancellation,
|
||||
progress: progress
|
||||
)
|
||||
return
|
||||
case .m4a, .mp3:
|
||||
if format == .m4a {
|
||||
try exportWithSession(
|
||||
source: source,
|
||||
destination: destination,
|
||||
fileType: .m4a,
|
||||
preset: AVAssetExportPresetAppleM4A,
|
||||
cancellation: cancellation,
|
||||
progress: progress
|
||||
)
|
||||
return
|
||||
}
|
||||
try FFmpeg.convert(source: source, to: format, at: destination, bitrate: bitrate, cancellation: cancellation, progress: progress)
|
||||
return
|
||||
default:
|
||||
// Everything else: try ffmpeg, then CoreAudio, then AVFoundation.
|
||||
if Toolchain.hasFFmpeg, Settings.shared.useExternalTools {
|
||||
try FFmpeg.convert(source: source, to: format, at: destination, bitrate: bitrate, cancellation: cancellation, progress: progress)
|
||||
return
|
||||
}
|
||||
if format == .wav || format == .aiff {
|
||||
try CoreAudio.convert(source: source, to: format, at: destination, cancellation: cancellation)
|
||||
return
|
||||
}
|
||||
throw ConversionError.missingTool(
|
||||
"\(format.displayName) needs ffmpeg. Install it with: brew install ffmpeg"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static func videoPreset(for quality: String) -> String {
|
||||
switch quality {
|
||||
case "Low": return AVAssetExportPreset960x540
|
||||
case "High": return AVAssetExportPreset1920x1080
|
||||
default: return AVAssetExportPreset1280x720
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tools
|
||||
|
||||
static func trim(
|
||||
source: URL,
|
||||
at destination: URL,
|
||||
start: Double,
|
||||
end: Double?,
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws {
|
||||
let asset = AVURLAsset(url: source)
|
||||
guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
|
||||
throw ConversionError.failed("Could not prepare a trim for \(source.lastPathComponent).")
|
||||
}
|
||||
let duration = CMTimeGetSeconds(asset.duration)
|
||||
let startTime = max(0, min(start, duration))
|
||||
let endTime = min(end ?? duration, duration)
|
||||
guard endTime > startTime else {
|
||||
throw ConversionError.failed("The trim range is empty.")
|
||||
}
|
||||
session.timeRange = CMTimeRange(
|
||||
start: CMTime(seconds: startTime, preferredTimescale: 600),
|
||||
end: CMTime(seconds: endTime, preferredTimescale: 600)
|
||||
)
|
||||
try run(session: session, destination: destination, fileType: destination.pathExtension == "mov" ? .mov : .mp4, cancellation: cancellation, progress: progress)
|
||||
}
|
||||
|
||||
/// Rebuilds the file with the audio track dropped.
|
||||
static func mute(
|
||||
source: URL,
|
||||
at destination: URL,
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws {
|
||||
let asset = AVURLAsset(url: source)
|
||||
let composition = AVMutableComposition()
|
||||
var inserted = false
|
||||
for track in asset.tracks where track.mediaType == .video {
|
||||
guard let target = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else { continue }
|
||||
try target.insertTimeRange(CMTimeRange(start: .zero, duration: asset.duration), of: track, at: .zero)
|
||||
target.preferredTransform = track.preferredTransform
|
||||
inserted = true
|
||||
}
|
||||
guard inserted else { throw ConversionError.failed("No video track to keep.") }
|
||||
try exportComposition(composition, at: destination, cancellation: cancellation, progress: progress)
|
||||
}
|
||||
|
||||
static func changeSpeed(
|
||||
source: URL,
|
||||
at destination: URL,
|
||||
rate: Double,
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws {
|
||||
guard rate > 0 else { throw ConversionError.failed("Speed must be greater than zero.") }
|
||||
let asset = AVURLAsset(url: source)
|
||||
let composition = AVMutableComposition()
|
||||
let duration = asset.duration
|
||||
for track in asset.tracks {
|
||||
guard track.mediaType == .video || track.mediaType == .audio else { continue }
|
||||
guard let target = composition.addMutableTrack(withMediaType: track.mediaType, preferredTrackID: kCMPersistentTrackID_Invalid) else { continue }
|
||||
try target.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: track, at: .zero)
|
||||
if track.mediaType == .video {
|
||||
target.preferredTransform = track.preferredTransform
|
||||
} else {
|
||||
target.scaleTimeRange(CMTimeRange(start: .zero, duration: duration), toDuration: CMTimeMultiplyByFloat64(duration, multiplier: 1 / rate))
|
||||
}
|
||||
}
|
||||
for track in composition.tracks where track.mediaType == .video {
|
||||
track.scaleTimeRange(CMTimeRange(start: .zero, duration: duration), toDuration: CMTimeMultiplyByFloat64(duration, multiplier: 1 / rate))
|
||||
}
|
||||
try exportComposition(composition, at: destination, cancellation: cancellation, progress: progress, audioPitch: .varispeed)
|
||||
}
|
||||
|
||||
static func resize(
|
||||
source: URL,
|
||||
at destination: URL,
|
||||
width: Int,
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws {
|
||||
let asset = AVURLAsset(url: source)
|
||||
guard let track = asset.tracks(withMediaType: .video).first else {
|
||||
throw ConversionError.failed("No video track to resize.")
|
||||
}
|
||||
let natural = track.naturalSize.applying(track.preferredTransform)
|
||||
let sourceSize = CGSize(width: abs(natural.width), height: abs(natural.height))
|
||||
guard sourceSize.width > 0 else { throw ConversionError.failed("Unknown video size.") }
|
||||
let scale = CGFloat(width) / sourceSize.width
|
||||
let renderSize = CGSize(width: CGFloat(width).rounded(), height: (sourceSize.height * scale).rounded())
|
||||
|
||||
let composition = AVMutableComposition()
|
||||
guard let videoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else {
|
||||
throw ConversionError.failed("Could not build the resize composition.")
|
||||
}
|
||||
let range = CMTimeRange(start: .zero, duration: asset.duration)
|
||||
try videoTrack.insertTimeRange(range, of: track, at: .zero)
|
||||
if let audio = asset.tracks(withMediaType: .audio).first,
|
||||
let audioTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) {
|
||||
try audioTrack.insertTimeRange(range, of: audio, at: .zero)
|
||||
}
|
||||
|
||||
let instruction = AVMutableVideoCompositionInstruction()
|
||||
instruction.timeRange = range
|
||||
let layer = AVMutableVideoCompositionLayerInstruction(assetTrack: videoTrack)
|
||||
let transform = track.preferredTransform.concatenating(CGAffineTransform(scaleX: scale, y: scale))
|
||||
layer.setTransform(transform, at: .zero)
|
||||
instruction.layerInstructions = [layer]
|
||||
|
||||
let videoComposition = AVMutableVideoComposition()
|
||||
videoComposition.renderSize = renderSize
|
||||
videoComposition.frameDuration = CMTime(value: 1, timescale: 30)
|
||||
videoComposition.instructions = [instruction]
|
||||
|
||||
try exportComposition(composition, at: destination, cancellation: cancellation, progress: progress, videoComposition: videoComposition)
|
||||
}
|
||||
|
||||
static func snapshot(
|
||||
source: URL,
|
||||
at destination: URL,
|
||||
time: Double,
|
||||
cancellation: Cancellation? = nil
|
||||
) throws {
|
||||
let asset = AVURLAsset(url: source)
|
||||
let generator = AVAssetImageGenerator(asset: asset)
|
||||
generator.appliesPreferredTrackTransform = true
|
||||
generator.requestedTimeToleranceBefore = .zero
|
||||
generator.requestedTimeToleranceAfter = .zero
|
||||
try cancellation?.check()
|
||||
let image = try generator.copyCGImage(at: CMTime(seconds: time, preferredTimescale: 600), actualTime: nil)
|
||||
try Raster.write(image, to: destination, format: .png, quality: 1, metadata: nil)
|
||||
}
|
||||
|
||||
/// Frame-by-frame GIF. Long clips are capped so the result stays small.
|
||||
static func makeGIF(
|
||||
source: URL,
|
||||
at destination: URL,
|
||||
fps: Int,
|
||||
maxWidth: Int,
|
||||
maxDuration: Double = 20,
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws {
|
||||
let asset = AVURLAsset(url: source)
|
||||
let duration = min(CMTimeGetSeconds(asset.duration), maxDuration)
|
||||
guard duration > 0 else { throw ConversionError.failed("Nothing to animate.") }
|
||||
|
||||
let generator = AVAssetImageGenerator(asset: asset)
|
||||
generator.appliesPreferredTrackTransform = true
|
||||
generator.maximumSize = CGSize(width: maxWidth, height: maxWidth)
|
||||
generator.requestedTimeToleranceBefore = CMTime(seconds: 0.05, preferredTimescale: 600)
|
||||
generator.requestedTimeToleranceAfter = CMTime(seconds: 0.05, preferredTimescale: 600)
|
||||
|
||||
guard let output = CGImageDestinationCreateWithURL(destination as CFURL, "com.compuserve.gif" as CFString, 0, nil) else {
|
||||
throw ConversionError.failed("Could not create \(destination.lastPathComponent).")
|
||||
}
|
||||
CGImageDestinationSetProperties(output, [
|
||||
kCGImagePropertyGIFDictionary: [kCGImagePropertyGIFLoopCount: 0]
|
||||
] as CFDictionary)
|
||||
|
||||
let frameCount = max(1, Int(duration * Double(fps)))
|
||||
let frameDuration = 1.0 / Double(fps)
|
||||
for index in 0..<frameCount {
|
||||
try cancellation?.check()
|
||||
let time = CMTime(seconds: Double(index) * frameDuration, preferredTimescale: 600)
|
||||
if let image = try? generator.copyCGImage(at: time, actualTime: nil) {
|
||||
CGImageDestinationAddImage(output, image, [
|
||||
kCGImagePropertyGIFDictionary: [kCGImagePropertyGIFDelayTime: frameDuration]
|
||||
] as CFDictionary)
|
||||
}
|
||||
progress?(Double(index + 1) / Double(frameCount))
|
||||
}
|
||||
guard CGImageDestinationFinalize(output) else {
|
||||
throw ConversionError.failed("Writing the GIF failed.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private static func exportComposition(
|
||||
_ composition: AVComposition,
|
||||
at destination: URL,
|
||||
cancellation: Cancellation?,
|
||||
progress: ((Double) -> Void)?,
|
||||
audioPitch: AVAudioTimePitchAlgorithm? = nil,
|
||||
videoComposition: AVVideoComposition? = nil
|
||||
) throws {
|
||||
let fileType: AVFileType = destination.pathExtension == "mov"
|
||||
? .mov
|
||||
: (destination.pathExtension == "m4a" ? .m4a : .mp4)
|
||||
guard let session = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetHighestQuality) else {
|
||||
throw ConversionError.failed("Could not prepare the export.")
|
||||
}
|
||||
if let audioPitch { session.audioTimePitchAlgorithm = audioPitch }
|
||||
if let videoComposition { session.videoComposition = videoComposition }
|
||||
try run(session: session, destination: destination, fileType: fileType, cancellation: cancellation, progress: progress)
|
||||
}
|
||||
|
||||
private static func exportWithSession(
|
||||
source: URL,
|
||||
destination: URL,
|
||||
fileType: AVFileType,
|
||||
preset: String,
|
||||
cancellation: Cancellation?,
|
||||
progress: ((Double) -> Void)?
|
||||
) throws {
|
||||
let asset = AVURLAsset(url: source)
|
||||
guard let session = AVAssetExportSession(asset: asset, presetName: preset) else {
|
||||
throw ConversionError.failed("This file cannot be exported with the \(preset) preset.")
|
||||
}
|
||||
try run(session: session, destination: destination, fileType: fileType, cancellation: cancellation, progress: progress)
|
||||
}
|
||||
|
||||
private final class ErrorBox: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var value: Error?
|
||||
func set(_ error: Error) { lock.lock(); value = error; lock.unlock() }
|
||||
func get() -> Error? { lock.lock(); defer { lock.unlock() }; return value }
|
||||
}
|
||||
|
||||
private static func run(
|
||||
session: AVAssetExportSession,
|
||||
destination: URL,
|
||||
fileType: AVFileType,
|
||||
cancellation: Cancellation?,
|
||||
progress: ((Double) -> Void)?
|
||||
) throws {
|
||||
if FileManager.default.fileExists(atPath: destination.path) {
|
||||
try? FileManager.default.removeItem(at: destination)
|
||||
}
|
||||
|
||||
let box = ErrorBox()
|
||||
let ready = DispatchSemaphore(value: 0)
|
||||
|
||||
if #available(macOS 15.0, *) {
|
||||
let session = session
|
||||
Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
try await session.export(to: destination, as: fileType)
|
||||
} catch {
|
||||
box.set(error)
|
||||
}
|
||||
ready.signal()
|
||||
}
|
||||
} else {
|
||||
session.outputURL = destination
|
||||
session.outputFileType = fileType
|
||||
session.shouldOptimizeForNetworkUse = false
|
||||
session.exportAsynchronously { ready.signal() }
|
||||
}
|
||||
|
||||
// Poll for progress and cancellation while the export runs.
|
||||
while ready.wait(timeout: .now() + 0.2) == .timedOut {
|
||||
if cancellation?.isCancelled == true {
|
||||
session.cancelExport()
|
||||
throw CancellationError()
|
||||
}
|
||||
progress?(Double(session.progress))
|
||||
}
|
||||
|
||||
try cancellation?.check()
|
||||
if let error = box.get() { throw ConversionError.failed(error.localizedDescription) }
|
||||
if session.status == .failed {
|
||||
throw ConversionError.failed(session.error?.localizedDescription ?? "Export failed.")
|
||||
}
|
||||
if session.status == .cancelled { throw CancellationError() }
|
||||
progress?(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import Foundation
|
||||
|
||||
/// ZIP/TAR/GZIP via the system's own tools (`ditto`, `tar`, `gzip`), which is
|
||||
/// both faster and more correct than rolling our own containers.
|
||||
enum ArchiveConverter {
|
||||
static func convert(
|
||||
source: URL,
|
||||
to format: Format,
|
||||
at destination: URL,
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws {
|
||||
try cancellation?.check()
|
||||
progress?(0.1)
|
||||
switch format {
|
||||
case .zip:
|
||||
try makeZip(source: source, at: destination, cancellation: cancellation)
|
||||
case .tar:
|
||||
try Shell.run(
|
||||
Toolchain.tar,
|
||||
["-cf", destination.path, "-C", source.deletingLastPathComponent().path, source.lastPathComponent],
|
||||
cancellation: cancellation
|
||||
)
|
||||
case .gzip:
|
||||
try makeGzip(source: source, at: destination, cancellation: cancellation)
|
||||
default:
|
||||
throw ConversionError.unsupported("\(format.displayName) is not an archive format Konverter writes.")
|
||||
}
|
||||
progress?(1)
|
||||
}
|
||||
|
||||
private static func makeZip(source: URL, at destination: URL, cancellation: Cancellation?) throws {
|
||||
var arguments = ["-c", "-k", "--sequesterRsrc"]
|
||||
if source.hasDirectoryPath || isDirectory(source) {
|
||||
arguments.append("--keepParent")
|
||||
}
|
||||
arguments += [source.path, destination.path]
|
||||
try Shell.run(Toolchain.ditto, arguments, cancellation: cancellation)
|
||||
}
|
||||
|
||||
private static func makeGzip(source: URL, at destination: URL, cancellation: Cancellation?) throws {
|
||||
// gzip only compresses a single stream, so a folder is tarred first and
|
||||
// the pair is squeezed into one .gz — the classic .tar.gz shape.
|
||||
if isDirectory(source) {
|
||||
let staging = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("konverter-\(UUID().uuidString).tar")
|
||||
defer { try? FileManager.default.removeItem(at: staging) }
|
||||
try Shell.run(
|
||||
Toolchain.tar,
|
||||
["-cf", staging.path, "-C", source.deletingLastPathComponent().path, source.lastPathComponent],
|
||||
cancellation: cancellation
|
||||
)
|
||||
let data = try Shell.runData(Toolchain.gzip, ["-n", "-9", "-c", staging.path], cancellation: cancellation)
|
||||
try data.write(to: destination)
|
||||
} else {
|
||||
let data = try Shell.runData(Toolchain.gzip, ["-n", "-9", "-c", source.path], cancellation: cancellation)
|
||||
try data.write(to: destination)
|
||||
}
|
||||
}
|
||||
|
||||
/// Expands an archive into `directory`, returning the folder that callers
|
||||
/// should reveal.
|
||||
@discardableResult
|
||||
static func extract(
|
||||
source: URL,
|
||||
into directory: URL,
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws -> URL {
|
||||
try cancellation?.check()
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
progress?(0.2)
|
||||
|
||||
let ext = source.pathExtension.lowercased()
|
||||
switch ext {
|
||||
case "zip":
|
||||
try Shell.run(Toolchain.ditto, ["-x", "-k", source.path, directory.path], cancellation: cancellation)
|
||||
case "tar", "tgz":
|
||||
try Shell.run(Toolchain.tar, ["-xf", source.path, "-C", directory.path], cancellation: cancellation)
|
||||
case "gz":
|
||||
let inner = source.deletingPathExtension().lastPathComponent
|
||||
if inner.lowercased().hasSuffix(".tar") {
|
||||
try Shell.run(Toolchain.tar, ["-xzf", source.path, "-C", directory.path], cancellation: cancellation)
|
||||
} else {
|
||||
let data = try Shell.runData(Toolchain.gunzip, ["-c", source.path], cancellation: cancellation)
|
||||
try data.write(to: directory.appendingPathComponent(inner))
|
||||
}
|
||||
default:
|
||||
try extractWithLibarchive(source: source, into: directory, cancellation: cancellation)
|
||||
}
|
||||
progress?(1)
|
||||
return directory
|
||||
}
|
||||
|
||||
/// RAR/7z/BZip2/XZ: libarchive (bsdtar) reads most of them; `unar` is used
|
||||
/// when present because it handles RAR5 and encrypted archives better.
|
||||
private static func extractWithLibarchive(source: URL, into directory: URL, cancellation: Cancellation?) throws {
|
||||
if let unar = Shell.find("unar", extraPaths: ["/opt/homebrew/bin", "/usr/local/bin"]) {
|
||||
try Shell.run(unar, ["-quiet", "-force-overwrite", "-output-directory", directory.path, source.path], cancellation: cancellation)
|
||||
return
|
||||
}
|
||||
do {
|
||||
try Shell.run(Toolchain.tar, ["-xf", source.path, "-C", directory.path], cancellation: cancellation)
|
||||
return
|
||||
} catch {
|
||||
throw ConversionError.missingTool(
|
||||
"Konverter cannot open .\(source.pathExtension) archives. Install unar (brew install unar) and try again."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static func list(_ source: URL) throws -> String {
|
||||
switch source.pathExtension.lowercased() {
|
||||
case "zip":
|
||||
let result = try Shell.run(Toolchain.unzip, ["-l", source.path])
|
||||
return result.stdout
|
||||
case "tar", "tgz":
|
||||
return try Shell.run(Toolchain.tar, ["-tf", source.path]).stdout
|
||||
case "gz":
|
||||
let size = (try? FileManager.default.attributesOfItem(atPath: source.path)[.size] as? Int) ?? 0
|
||||
return """
|
||||
GZIP stream
|
||||
Compressed size: \(ByteCountFormatter.string(fromByteCount: Int64(size ?? 0), countStyle: .file))
|
||||
Inner name: \(source.deletingPathExtension().lastPathComponent)
|
||||
"""
|
||||
default:
|
||||
let result = try? Shell.run(Toolchain.tar, ["-tf", source.path])
|
||||
guard let stdout = result?.stdout else {
|
||||
throw ConversionError.missingTool("Cannot list .\(source.pathExtension) archives without unar.")
|
||||
}
|
||||
return stdout
|
||||
}
|
||||
}
|
||||
|
||||
static func isArchive(_ url: URL) -> Bool {
|
||||
FileKind.category(of: url) == .archive
|
||||
}
|
||||
|
||||
private static func isDirectory(_ url: URL) -> Bool {
|
||||
(try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import Foundation
|
||||
import PDFKit
|
||||
|
||||
/// Whether an output format can actually be produced on this Mac, and why not
|
||||
/// when it cannot. The wheel uses this to mark options as unavailable instead
|
||||
/// of failing after the user has already committed to a drop.
|
||||
struct Availability {
|
||||
let isAvailable: Bool
|
||||
let reason: String?
|
||||
|
||||
static let ok = Availability(isAvailable: true, reason: nil)
|
||||
|
||||
static func unavailable(_ reason: String) -> Availability {
|
||||
Availability(isAvailable: false, reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
/// Front door for format conversions: picks the engine, handles formats that
|
||||
/// produce more than one file, and reports a useful reason when nothing can.
|
||||
enum Converter {
|
||||
@discardableResult
|
||||
static func convert(
|
||||
source: URL,
|
||||
to format: Format,
|
||||
at destination: URL,
|
||||
quality: Double = 0.9,
|
||||
mediaQuality: String = "Medium",
|
||||
audioBitrate: String = "192",
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws -> [URL] {
|
||||
try cancellation?.check()
|
||||
let category = FileKind.category(of: source)
|
||||
|
||||
switch category {
|
||||
case .image:
|
||||
try ImageConverter.convert(
|
||||
source: source,
|
||||
to: format,
|
||||
at: destination,
|
||||
quality: quality,
|
||||
cancellation: cancellation
|
||||
)
|
||||
progress?(1)
|
||||
return [destination]
|
||||
|
||||
case .video, .audio:
|
||||
try AVConverter.convert(
|
||||
source: source,
|
||||
to: format,
|
||||
at: destination,
|
||||
quality: mediaQuality,
|
||||
bitrate: audioBitrate,
|
||||
cancellation: cancellation,
|
||||
progress: progress
|
||||
)
|
||||
return [destination]
|
||||
|
||||
case .pdf:
|
||||
switch format {
|
||||
case .jpg, .png:
|
||||
let directory = OutputNaming.artifactDirectory(for: source, suffix: " \(format.displayName)")
|
||||
return try PDFConverter.renderPages(
|
||||
source,
|
||||
to: format,
|
||||
into: directory,
|
||||
dpi: 300,
|
||||
cancellation: cancellation,
|
||||
progress: progress
|
||||
)
|
||||
case .pptx, .xlsx:
|
||||
try Office.convert(source, to: format, at: destination, cancellation: cancellation)
|
||||
progress?(1)
|
||||
return [destination]
|
||||
case .pdf:
|
||||
guard let document = PDFDocument(url: source) else {
|
||||
throw ConversionError.unreadable(source.lastPathComponent)
|
||||
}
|
||||
guard document.write(to: destination) else {
|
||||
throw ConversionError.failed("Could not rewrite the PDF.")
|
||||
}
|
||||
progress?(1)
|
||||
return [destination]
|
||||
default:
|
||||
try DocumentConverter.convert(
|
||||
source: source,
|
||||
to: format,
|
||||
at: destination,
|
||||
cancellation: cancellation,
|
||||
progress: progress
|
||||
)
|
||||
return [destination]
|
||||
}
|
||||
|
||||
case .document, .spreadsheet:
|
||||
try DocumentConverter.convert(
|
||||
source: source,
|
||||
to: format,
|
||||
at: destination,
|
||||
cancellation: cancellation,
|
||||
progress: progress
|
||||
)
|
||||
return [destination]
|
||||
|
||||
case .slides:
|
||||
try SlidesConverter.convert(
|
||||
source: source,
|
||||
to: format,
|
||||
at: destination,
|
||||
cancellation: cancellation,
|
||||
progress: progress
|
||||
)
|
||||
if format == .png || format == .jpg {
|
||||
let directory = OutputNaming.artifactDirectory(for: source, suffix: " \(format.displayName)")
|
||||
let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? []
|
||||
return files.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
||||
}
|
||||
return [destination]
|
||||
|
||||
case .ebook:
|
||||
try EbookConverter.convert(
|
||||
source: source,
|
||||
to: format,
|
||||
at: destination,
|
||||
cancellation: cancellation,
|
||||
progress: progress
|
||||
)
|
||||
return [destination]
|
||||
|
||||
case .subtitle:
|
||||
try SubtitleConverter.convert(source: source, to: format, at: destination, cancellation: cancellation)
|
||||
progress?(1)
|
||||
return [destination]
|
||||
|
||||
case .archive:
|
||||
try ArchiveConverter.convert(
|
||||
source: source,
|
||||
to: format,
|
||||
at: destination,
|
||||
cancellation: cancellation,
|
||||
progress: progress
|
||||
)
|
||||
return [destination]
|
||||
|
||||
case .folder, .unknown:
|
||||
throw ConversionError.unsupported("\(source.lastPathComponent) has no conversion options.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Availability
|
||||
|
||||
static func availability(target: Format, source: FileCategory) -> Availability {
|
||||
if let offered = ConversionCatalog.targets[source], !offered.contains(target) {
|
||||
return .unavailable("\(source.title) cannot become \(target.displayName).")
|
||||
}
|
||||
if target.requiresFFmpeg, !Toolchain.hasFFmpeg {
|
||||
return .unavailable("Needs ffmpeg — brew install ffmpeg")
|
||||
}
|
||||
if target.requiresLibreOffice, !Toolchain.hasLibreOffice {
|
||||
return .unavailable("Needs LibreOffice — brew install --cask libreoffice")
|
||||
}
|
||||
if !Settings.shared.useExternalTools, target.requiresFFmpeg || target.requiresLibreOffice {
|
||||
return .unavailable("External tools are turned off in Settings.")
|
||||
}
|
||||
if target.category == .image, !Raster.canEncode(target) {
|
||||
if target == .webp, WebPEncoder.isAvailable {
|
||||
return .ok
|
||||
}
|
||||
return .unavailable("This Mac cannot write \(target.displayName) files.")
|
||||
}
|
||||
if source == .spreadsheet, !["xlsx", "ods", "csv", "tsv"].contains(target.ext), target != .pdf {
|
||||
return .unavailable("Spreadsheets cannot become \(target.displayName).")
|
||||
}
|
||||
return .ok
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import PDFKit
|
||||
|
||||
/// Document conversions. `textutil` handles the Office formats; Markdown and
|
||||
/// EPUB are ours; PDF output lays text out with CoreText unless LibreOffice is
|
||||
/// available for pixel-perfect fidelity.
|
||||
enum DocumentConverter {
|
||||
static func convert(
|
||||
source: URL,
|
||||
to format: Format,
|
||||
at destination: URL,
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws {
|
||||
try cancellation?.check()
|
||||
let sourceExtension = FileKind.canonicalExtension(source.pathExtension)
|
||||
|
||||
switch format {
|
||||
case .txt:
|
||||
guard let text = TextDocument.plainText(from: source) else {
|
||||
throw ConversionError.unreadable(source.lastPathComponent)
|
||||
}
|
||||
try TextDocument.write(text, to: destination)
|
||||
|
||||
case .md:
|
||||
if sourceExtension == "md" || sourceExtension == "txt" {
|
||||
let text = TextDocument.plainText(from: source) ?? ""
|
||||
try TextDocument.write(text, to: destination)
|
||||
} else {
|
||||
let html = try TextDocument.html(from: source)
|
||||
try TextDocument.write(Markdown.markdown(fromHTML: html), to: destination)
|
||||
}
|
||||
|
||||
case .html:
|
||||
try TextDocument.write(try TextDocument.html(from: source), to: destination)
|
||||
|
||||
case .pdf:
|
||||
try makePDF(from: source, at: destination, cancellation: cancellation, progress: progress)
|
||||
|
||||
case .epub:
|
||||
let title = source.deletingPathExtension().lastPathComponent
|
||||
let html = try TextDocument.html(from: source)
|
||||
let chapterTitle = title
|
||||
try EbookConverter.write(
|
||||
title: title,
|
||||
author: "Konverter",
|
||||
chapters: [(chapterTitle, html)],
|
||||
at: destination
|
||||
)
|
||||
|
||||
case .docx, .doc, .rtf, .odt:
|
||||
if sourceExtension == "pdf" || sourceExtension == "epub" {
|
||||
// No importer for these: start from extracted text/HTML.
|
||||
let html = try TextDocument.html(from: source)
|
||||
try TextDocument.textutilConvert(sourceHTML: html, to: format, at: destination, cancellation: cancellation)
|
||||
} else if sourceExtension == "md" {
|
||||
let html = try TextDocument.html(from: source)
|
||||
try TextDocument.textutilConvert(sourceHTML: html, to: format, at: destination, cancellation: cancellation)
|
||||
} else {
|
||||
try TextDocument.textutilConvert(source, to: format, at: destination, cancellation: cancellation)
|
||||
}
|
||||
|
||||
case .pptx, .xlsx, .ods, .pages:
|
||||
try Office.convert(source, to: format, at: destination, cancellation: cancellation)
|
||||
|
||||
default:
|
||||
throw ConversionError.unsupported("Documents cannot become \(format.displayName).")
|
||||
}
|
||||
}
|
||||
|
||||
private static func makePDF(
|
||||
from source: URL,
|
||||
at destination: URL,
|
||||
cancellation: Cancellation?,
|
||||
progress: ((Double) -> Void)?
|
||||
) throws {
|
||||
let sourceExtension = FileKind.canonicalExtension(source.pathExtension)
|
||||
if sourceExtension == "pdf" {
|
||||
// "PDF → PDF" means a normalised, linearised copy.
|
||||
if let document = PDFDocument(url: source) {
|
||||
guard document.write(to: destination) else {
|
||||
throw ConversionError.failed("Could not rewrite the PDF.")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if Toolchain.hasLibreOffice, Settings.shared.useExternalTools {
|
||||
do {
|
||||
try Office.convert(source, to: .pdf, at: destination, cancellation: cancellation)
|
||||
return
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
// Fall through to the CoreText layout rather than failing the
|
||||
// whole job because LibreOffice refused one file.
|
||||
}
|
||||
}
|
||||
|
||||
if let attributed = TextDocument.attributedString(from: source) {
|
||||
try TextDocument.writePDF(attributed, to: destination)
|
||||
progress?(1)
|
||||
return
|
||||
}
|
||||
if let text = TextDocument.plainText(from: source), !text.isEmpty {
|
||||
try TextDocument.writePDF(fromText: text, to: destination)
|
||||
progress?(1)
|
||||
return
|
||||
}
|
||||
throw ConversionError.failed("Could not lay out \(source.lastPathComponent) as a PDF.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import Foundation
|
||||
|
||||
/// EPUB in and out. Reading follows `META-INF/container.xml` → OPF → spine so
|
||||
/// chapters come out in reading order; writing produces an EPUB 3 package
|
||||
/// whose `mimetype` entry is stored uncompressed, as the spec requires.
|
||||
enum EbookConverter {
|
||||
static func convert(
|
||||
source: URL,
|
||||
to format: Format,
|
||||
at destination: URL,
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws {
|
||||
try cancellation?.check()
|
||||
switch format {
|
||||
case .txt:
|
||||
try TextDocument.write(try plainText(source), to: destination)
|
||||
case .html:
|
||||
try TextDocument.write(try html(source), to: destination)
|
||||
case .md:
|
||||
try TextDocument.write(Markdown.markdown(fromHTML: try html(source)), to: destination)
|
||||
case .pdf:
|
||||
try TextDocument.writePDF(fromText: try plainText(source), to: destination)
|
||||
case .epub:
|
||||
let title = source.deletingPathExtension().lastPathComponent
|
||||
try write(
|
||||
title: title,
|
||||
author: "Konverter",
|
||||
chapters: [(title, try html(source))],
|
||||
at: destination
|
||||
)
|
||||
default:
|
||||
throw ConversionError.unsupported("EPUB cannot become \(format.displayName).")
|
||||
}
|
||||
progress?(1)
|
||||
}
|
||||
|
||||
// MARK: - Reading
|
||||
|
||||
static func plainText(_ url: URL) throws -> String {
|
||||
let chapters = try chapters(url)
|
||||
return chapters
|
||||
.map { Markdown.decodeEntities(Markdown.stripTags($0.html)).trimmed }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: "\n\n")
|
||||
}
|
||||
|
||||
static func html(_ url: URL) throws -> String {
|
||||
let chapters = try chapters(url)
|
||||
let title = url.deletingPathExtension().lastPathComponent
|
||||
let body = chapters.map { chapter in
|
||||
"<section>" + (chapter.title.isEmpty ? "" : "<h2>\(Markdown.escape(chapter.title))</h2>") + chapter.body + "</section>"
|
||||
}.joined(separator: "\n")
|
||||
return """
|
||||
<!DOCTYPE html><html><head><meta charset="utf-8"><title>\(Markdown.escape(title))</title>
|
||||
<style>body{font:16px/1.6 -apple-system,Georgia,serif;max-width:720px;margin:48px auto;padding:0 24px;color:#1d1d1f}</style>
|
||||
</head><body>\(body)</body></html>
|
||||
"""
|
||||
}
|
||||
|
||||
private struct Chapter {
|
||||
var title: String
|
||||
var html: String
|
||||
|
||||
var body: String {
|
||||
// Keep the inner markup, drop the document scaffolding.
|
||||
var inner = html
|
||||
if let bodyRange = inner.range(of: "(?is)<body[^>]*>", options: .regularExpression),
|
||||
let endRange = inner.range(of: "(?is)</body>", options: .regularExpression) {
|
||||
inner = String(inner[bodyRange.upperBound..<endRange.lowerBound])
|
||||
}
|
||||
return Markdown.replace(#"(?is)<(html|head|body|title|meta|link|script|style)[^>]*>|</(html|head|body|title|script|style)>"#, in: inner) { _ in "" }
|
||||
}
|
||||
}
|
||||
|
||||
private static func chapters(_ url: URL) throws -> [Chapter] {
|
||||
let staging = try Unzip.expand(url)
|
||||
defer { try? FileManager.default.removeItem(at: staging) }
|
||||
|
||||
let opf = try packageDocument(in: staging)
|
||||
let ordered = opf.map { spineOrder(opfPath: $0, staging: staging) } ?? []
|
||||
let files = ordered.isEmpty ? looseContentFiles(in: staging) : ordered
|
||||
|
||||
var chapters: [Chapter] = []
|
||||
for file in files {
|
||||
guard let content = try? String(contentsOf: file, encoding: .utf8) else { continue }
|
||||
if content.trimmed.isEmpty { continue }
|
||||
let title = Markdown.replace(#"(?is)^.*?<title[^>]*>(.*?)</title>.*$"#, in: content) { $0[1] }.trimmed
|
||||
chapters.append(Chapter(title: title.count < 120 ? title : "", html: content))
|
||||
}
|
||||
guard !chapters.isEmpty else { throw ConversionError.failed("No chapters found in the EPUB.") }
|
||||
return chapters
|
||||
}
|
||||
|
||||
private static func packageDocument(in staging: URL) throws -> URL? {
|
||||
let container = staging.appendingPathComponent("META-INF/container.xml")
|
||||
guard let data = try? Data(contentsOf: container),
|
||||
let text = String(data: data, encoding: .utf8),
|
||||
let match = text.range(of: #"full-path="([^"]+)""#, options: .regularExpression) else {
|
||||
return nil
|
||||
}
|
||||
let path = String(text[match]).replacingOccurrences(of: "full-path=\"", with: "").replacingOccurrences(of: "\"", with: "")
|
||||
return staging.appendingPathComponent(path)
|
||||
}
|
||||
|
||||
private static func spineOrder(opfPath: URL, staging: URL) -> [URL] {
|
||||
guard let delegate = OPFDelegate(path: opfPath) else { return [] }
|
||||
let parser = XMLParser(data: (try? Data(contentsOf: opfPath)) ?? Data())
|
||||
parser.delegate = delegate
|
||||
parser.parse()
|
||||
let base = opfPath.deletingLastPathComponent()
|
||||
return delegate.spine
|
||||
.compactMap { delegate.manifest[$0] }
|
||||
.map { base.appendingPathComponent($0) }
|
||||
.filter { FileManager.default.fileExists(atPath: $0.path) }
|
||||
}
|
||||
|
||||
private static func looseContentFiles(in staging: URL) -> [URL] {
|
||||
let enumerator = FileManager.default.enumerator(at: staging, includingPropertiesForKeys: nil)
|
||||
var files: [URL] = []
|
||||
while let file = enumerator?.nextObject() as? URL {
|
||||
let ext = file.pathExtension.lowercased()
|
||||
if ext == "xhtml" || ext == "html" || ext == "htm" { files.append(file) }
|
||||
}
|
||||
return files.sorted { $0.path < $1.path }
|
||||
}
|
||||
|
||||
private final class OPFDelegate: NSObject, XMLParserDelegate {
|
||||
var manifest: [String: String] = [:]
|
||||
var spine: [String] = []
|
||||
private let basePath: String
|
||||
|
||||
init?(path: URL) {
|
||||
basePath = path.path
|
||||
super.init()
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?, attributes: [String: String]) {
|
||||
switch elementName {
|
||||
case "item":
|
||||
if let id = attributes["id"], let href = attributes["href"] {
|
||||
let type = attributes["media-type"] ?? ""
|
||||
if type.contains("xhtml") || href.hasSuffix(".html") || href.hasSuffix(".xhtml") || href.hasSuffix(".htm") {
|
||||
manifest[id] = href.removingPercentEncoding ?? href
|
||||
}
|
||||
}
|
||||
case "itemref":
|
||||
if let idref = attributes["idref"] { spine.append(idref) }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Writing
|
||||
|
||||
static func write(
|
||||
title: String,
|
||||
author: String,
|
||||
chapters: [(String, String)],
|
||||
at destination: URL
|
||||
) throws {
|
||||
guard !chapters.isEmpty else { throw ConversionError.failed("An EPUB needs at least one chapter.") }
|
||||
|
||||
let staging = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("konverter-epub-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: staging) }
|
||||
try FileManager.default.createDirectory(at: staging.appendingPathComponent("META-INF"), withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(at: staging.appendingPathComponent("OEBPS"), withIntermediateDirectories: true)
|
||||
|
||||
let identifier = "urn:uuid:" + UUID().uuidString.lowercased()
|
||||
try "application/epub+zip".write(to: staging.appendingPathComponent("mimetype"), atomically: true, encoding: .utf8)
|
||||
|
||||
try """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
|
||||
</container>
|
||||
""".write(to: staging.appendingPathComponent("META-INF/container.xml"), atomically: true, encoding: .utf8)
|
||||
|
||||
var manifest = ""
|
||||
var spine = ""
|
||||
var navItems = ""
|
||||
for (index, chapter) in chapters.enumerated() {
|
||||
let fileName = "chapter\(index + 1).xhtml"
|
||||
let chapterTitle = chapter.0.isEmpty ? "Chapter \(index + 1)" : chapter.0
|
||||
let body = chapter.1.contains("<body") || chapter.1.contains("<html")
|
||||
? chapter.1
|
||||
: "<html xmlns=\"http://www.w3.org/1999/xhtml\"><head><title>\(Markdown.escape(chapterTitle))</title></head><body>\(chapter.1)</body></html>"
|
||||
let xhtml = body.contains("xmlns=\"http://www.w3.org/1999/xhtml\"")
|
||||
? body
|
||||
: body.replacingOccurrences(of: "<html", with: "<html xmlns=\"http://www.w3.org/1999/xhtml\"", options: [], range: body.range(of: "<html"))
|
||||
try xhtml.write(to: staging.appendingPathComponent("OEBPS/" + fileName), atomically: true, encoding: .utf8)
|
||||
manifest += "<item id=\"chapter\(index + 1)\" href=\"\(fileName)\" media-type=\"application/xhtml+xml\"/>\n"
|
||||
spine += "<itemref idref=\"chapter\(index + 1)\"/>\n"
|
||||
navItems += "<li><a href=\"\(fileName)\">\(Markdown.escape(chapterTitle))</a></li>\n"
|
||||
}
|
||||
|
||||
try """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:identifier id="bookid">\(identifier)</dc:identifier>
|
||||
<dc:title>\(Markdown.escape(title))</dc:title>
|
||||
<dc:creator>\(Markdown.escape(author))</dc:creator>
|
||||
<dc:language>en</dc:language>
|
||||
<meta property="dcterms:modified">\(ISO8601DateFormatter().string(from: Date()))</meta>
|
||||
</metadata>
|
||||
<manifest>
|
||||
\(manifest)<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
|
||||
</manifest>
|
||||
<spine>
|
||||
\(spine)</spine>
|
||||
</package>
|
||||
""".write(to: staging.appendingPathComponent("OEBPS/content.opf"), atomically: true, encoding: .utf8)
|
||||
|
||||
try """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||
<head><title>\(Markdown.escape(title))</title></head>
|
||||
<body><nav epub:type="toc" id="toc"><h1>\(Markdown.escape(title))</h1><ol>
|
||||
\(navItems)</ol></nav></body></html>
|
||||
""".write(to: staging.appendingPathComponent("OEBPS/nav.xhtml"), atomically: true, encoding: .utf8)
|
||||
|
||||
try? FileManager.default.removeItem(at: destination)
|
||||
// mimetype must be the first entry and must not be compressed.
|
||||
try Shell.run(Toolchain.zip, ["-X0", destination.path, "mimetype"], currentDirectory: staging)
|
||||
try Shell.run(Toolchain.zip, ["-Xr9", destination.path, "META-INF", "OEBPS"], currentDirectory: staging)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import Foundation
|
||||
|
||||
/// ffmpeg back end. Used for containers and codecs Apple's frameworks cannot
|
||||
/// write. Progress is parsed from `-progress pipe:1`.
|
||||
enum FFmpeg {
|
||||
static func convert(
|
||||
source: URL,
|
||||
to format: Format,
|
||||
at destination: URL,
|
||||
bitrate: String = "128",
|
||||
quality: String = "Medium",
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws {
|
||||
guard let ffmpeg = Toolchain.ffmpeg, Settings.shared.useExternalTools else {
|
||||
throw ConversionError.missingTool("\(format.displayName) requires ffmpeg — install it with: brew install ffmpeg")
|
||||
}
|
||||
|
||||
var arguments = ["-y", "-hide_banner", "-loglevel", "error", "-i", source.path]
|
||||
arguments += encodingArguments(for: format, bitrate: bitrate, quality: quality)
|
||||
arguments += ["-progress", "pipe:1", "-nostats"]
|
||||
arguments.append(destination.path)
|
||||
|
||||
let duration = AVConverter.duration(source) ?? 0
|
||||
try Shell.runReportingProgress(ffmpeg, arguments, cancellation: cancellation) { _, line in
|
||||
guard let progress, duration > 0 else { return }
|
||||
if let range = line.range(of: "out_time_us="),
|
||||
let microseconds = Double(line[range.upperBound...].trimmingCharacters(in: .whitespaces)) {
|
||||
progress(min(1, microseconds / 1_000_000 / duration))
|
||||
}
|
||||
}
|
||||
progress?(1)
|
||||
}
|
||||
|
||||
/// Down-mixes any media to audio.
|
||||
static func extractAudio(
|
||||
source: URL,
|
||||
to format: Format,
|
||||
at destination: URL,
|
||||
bitrate: String = "192",
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws {
|
||||
try convert(source: source, to: format, at: destination, bitrate: bitrate, cancellation: cancellation, progress: progress)
|
||||
}
|
||||
|
||||
/// Progress-reporting pass-through for tools (trim, speed, gain, …).
|
||||
static func filter(
|
||||
source: URL,
|
||||
at destination: URL,
|
||||
arguments: [String],
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws {
|
||||
guard let ffmpeg = Toolchain.ffmpeg, Settings.shared.useExternalTools else {
|
||||
throw ConversionError.missingTool("This tool requires ffmpeg — install it with: brew install ffmpeg")
|
||||
}
|
||||
var full = ["-y", "-hide_banner", "-loglevel", "error", "-i", source.path]
|
||||
full += arguments
|
||||
full += ["-progress", "pipe:1", "-nostats", destination.path]
|
||||
|
||||
let duration = AVConverter.duration(source) ?? 0
|
||||
try Shell.runReportingProgress(ffmpeg, full, cancellation: cancellation) { _, line in
|
||||
guard let progress, duration > 0 else { return }
|
||||
if let range = line.range(of: "out_time_us="),
|
||||
let microseconds = Double(line[range.upperBound...].trimmingCharacters(in: .whitespaces)) {
|
||||
progress(min(1, microseconds / 1_000_000 / duration))
|
||||
}
|
||||
}
|
||||
progress?(1)
|
||||
}
|
||||
|
||||
private static func encodingArguments(for format: Format, bitrate: String, quality: String) -> [String] {
|
||||
let audioRate = "\(bitrate)k"
|
||||
switch format {
|
||||
case .mp3: return ["-vn", "-c:a", "libmp3lame", "-b:a", audioRate]
|
||||
case .m4a: return ["-vn", "-c:a", "aac", "-b:a", audioRate]
|
||||
case .wav: return ["-vn", "-c:a", "pcm_s16le"]
|
||||
case .aiff: return ["-vn", "-c:a", "pcm_s16be"]
|
||||
case .flac: return ["-vn", "-c:a", "flac"]
|
||||
case .ogg: return ["-vn", "-c:a", "libvorbis", "-q:a", "5"]
|
||||
case .opus: return ["-vn", "-c:a", "libopus", "-b:a", audioRate]
|
||||
case .wma: return ["-vn", "-c:a", "wmav2", "-b:a", audioRate]
|
||||
case .mkv: return videoArguments(codec: "libx264", quality: quality) + ["-c:a", "aac", "-b:a", audioRate]
|
||||
case .webm: return ["-c:v", "libvpx-vp9", "-crf", "32", "-b:v", "0", "-c:a", "libopus", "-b:a", audioRate]
|
||||
case .avi: return ["-c:v", "mpeg4", "-q:v", "5", "-c:a", "libmp3lame", "-b:a", audioRate]
|
||||
case .wmv: return ["-c:v", "wmv2", "-b:v", "2M", "-c:a", "wmav2", "-b:a", audioRate]
|
||||
case .mp4, .mov, .m4v, .gif: return videoArguments(codec: "libx264", quality: quality) + ["-c:a", "aac", "-b:a", audioRate]
|
||||
default: return []
|
||||
}
|
||||
}
|
||||
|
||||
private static func videoArguments(codec: String, quality: String) -> [String] {
|
||||
let crf: String
|
||||
switch quality {
|
||||
case "Low": crf = "30"
|
||||
case "High": crf = "20"
|
||||
default: crf = "25"
|
||||
}
|
||||
return ["-c:v", codec, "-preset", "medium", "-crf", crf, "-pix_fmt", "yuv420p"]
|
||||
}
|
||||
}
|
||||
|
||||
/// CoreAudio's `afconvert`: always present, handles PCM containers and
|
||||
/// channel remapping without any third-party install.
|
||||
enum CoreAudio {
|
||||
static func convert(
|
||||
source: URL,
|
||||
to format: Format,
|
||||
at destination: URL,
|
||||
cancellation: Cancellation? = nil
|
||||
) throws {
|
||||
guard let fileFormat = coreAudioFileFormat(for: format) else {
|
||||
throw ConversionError.unsupported("CoreAudio cannot write \(format.displayName).")
|
||||
}
|
||||
do {
|
||||
try Shell.run(
|
||||
Toolchain.afconvert,
|
||||
["-f", fileFormat, "-d", dataFormat(for: fileFormat), source.path, destination.path],
|
||||
cancellation: cancellation
|
||||
)
|
||||
} catch {
|
||||
throw ConversionError.failed("CoreAudio could not convert \(source.lastPathComponent): \((error as? LocalizedError)?.errorDescription ?? error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
static func remix(
|
||||
source: URL,
|
||||
at destination: URL,
|
||||
mono: Bool,
|
||||
cancellation: Cancellation? = nil
|
||||
) throws {
|
||||
try Shell.run(
|
||||
Toolchain.afconvert,
|
||||
["-f", "caff", "-d", "LEI16", "-c", mono ? "1" : "2", source.path, destination.path],
|
||||
cancellation: cancellation
|
||||
)
|
||||
}
|
||||
|
||||
private static func coreAudioFileFormat(for format: Format) -> String? {
|
||||
switch format {
|
||||
case .wav: return "WAVE"
|
||||
case .aiff: return "AIFF"
|
||||
case .m4a: return "m4af"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func dataFormat(for fileFormat: String) -> String {
|
||||
switch fileFormat {
|
||||
case "WAVE": return "LEI16"
|
||||
case "AIFF": return "BEI16"
|
||||
default: return "aac"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum ConversionError: LocalizedError {
|
||||
case unreadable(String)
|
||||
case unsupported(String)
|
||||
case failed(String)
|
||||
case missingTool(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unreadable(let name): return "Could not read \(name)."
|
||||
case .unsupported(let reason): return reason
|
||||
case .failed(let reason): return reason
|
||||
case .missingTool(let reason): return reason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bitmap plumbing shared by the image converter and the image tools.
|
||||
enum Raster {
|
||||
static let encodableTypes: Set<String> = Set(
|
||||
(CGImageDestinationCopyTypeIdentifiers() as? [String]) ?? []
|
||||
)
|
||||
|
||||
static func canEncode(_ format: Format) -> Bool {
|
||||
guard let uti = format.uti else { return false }
|
||||
return encodableTypes.contains(uti)
|
||||
}
|
||||
|
||||
static func imageSource(at url: URL) -> CGImageSource? {
|
||||
CGImageSourceCreateWithURL(url as CFURL, [kCGImageSourceShouldCacheImmediately: true] as CFDictionary)
|
||||
}
|
||||
|
||||
/// Decodes the first frame. Falls back to AppKit for formats ImageIO
|
||||
/// cannot decode but the system can (SVG, some RAW, exotic TIFFs), and to
|
||||
/// Quick Look as a last resort.
|
||||
static func firstImage(at url: URL) -> CGImage? {
|
||||
if let source = imageSource(at: url), let image = CGImageSourceCreateImageAtIndex(source, 0, nil) {
|
||||
return image
|
||||
}
|
||||
if let image = NSImage(contentsOf: url),
|
||||
let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) {
|
||||
return cgImage
|
||||
}
|
||||
return quickLookRaster(url)
|
||||
}
|
||||
|
||||
static func frameCount(at url: URL) -> Int {
|
||||
guard let source = imageSource(at: url) else { return 1 }
|
||||
return max(CGImageSourceGetCount(source), 1)
|
||||
}
|
||||
|
||||
private static func quickLookRaster(_ url: URL) -> CGImage? {
|
||||
guard let qlmanage = Toolchain.qlmanage else { return nil }
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("konverter-ql-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
_ = try? Shell.run(qlmanage, ["-t", "-s", "2048", "-o", directory.path, url.path])
|
||||
guard let produced = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)
|
||||
.first(where: { $0.pathExtension == "png" }) else { return nil }
|
||||
return NSImage(contentsOf: produced)?.cgImage(forProposedRect: nil, context: nil, hints: nil)
|
||||
}
|
||||
|
||||
/// Paints the image onto an opaque background, dropping the alpha channel.
|
||||
/// JPEG cannot store alpha and would otherwise render black.
|
||||
static func flattened(_ image: CGImage, background: CGColor = CGColor(gray: 1, alpha: 1)) -> CGImage {
|
||||
let width = image.width
|
||||
let height = image.height
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue
|
||||
) else { return image }
|
||||
context.setFillColor(background)
|
||||
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
||||
context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||
return context.makeImage() ?? image
|
||||
}
|
||||
|
||||
/// Reduces the number of distinct colours in an image. Lossless formats
|
||||
/// have no quality knob, so a "compress" of a PNG has to trade colour
|
||||
/// depth for size — which is exactly what this does. `quality` is the
|
||||
/// 0…1 slider: 1.0 leaves the pixels untouched.
|
||||
static func posterized(_ image: CGImage, quality: Double) -> CGImage {
|
||||
let bits = max(1.0, min(8.0, quality * 8.0))
|
||||
let levels = min(256, max(2, Int(pow(2.0, bits))))
|
||||
guard levels < 256 else { return image }
|
||||
|
||||
let width = image.width
|
||||
let height = image.height
|
||||
let bytesPerRow = width * 4
|
||||
var buffer = [UInt8](repeating: 0, count: bytesPerRow * height)
|
||||
let space = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
||||
|
||||
let drawn: Bool = buffer.withUnsafeMutableBytes { raw -> Bool in
|
||||
guard let context = CGContext(
|
||||
data: raw.baseAddress,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: bytesPerRow,
|
||||
space: space,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else { return false }
|
||||
context.interpolationQuality = .high
|
||||
context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||
return true
|
||||
}
|
||||
guard drawn else { return image }
|
||||
|
||||
let step = 256 / levels
|
||||
let half = step / 2
|
||||
for index in stride(from: 0, to: buffer.count, by: 4) {
|
||||
for channel in 0..<3 {
|
||||
let value = Int(buffer[index + channel])
|
||||
buffer[index + channel] = UInt8(min(255, (value / step) * step + half))
|
||||
}
|
||||
}
|
||||
|
||||
let result: CGImage? = buffer.withUnsafeMutableBytes { raw -> CGImage? in
|
||||
guard let context = CGContext(
|
||||
data: raw.baseAddress,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: bytesPerRow,
|
||||
space: space,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else { return nil }
|
||||
return context.makeImage()
|
||||
}
|
||||
return result ?? image
|
||||
}
|
||||
|
||||
/// Bakes an EXIF orientation into the pixels so the result needs no
|
||||
/// metadata to look right.
|
||||
static func oriented(_ image: CGImage, orientation: CGImagePropertyOrientation) -> CGImage {
|
||||
guard orientation != .up else { return image }
|
||||
let swapsDimensions = orientation == .left || orientation == .right
|
||||
|| orientation == .leftMirrored || orientation == .rightMirrored
|
||||
let width = swapsDimensions ? image.height : image.width
|
||||
let height = swapsDimensions ? image.width : image.height
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: image.colorSpace ?? CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else { return image }
|
||||
|
||||
switch orientation {
|
||||
case .up: break
|
||||
case .upMirrored: context.translateBy(x: CGFloat(width), y: 0); context.scaleBy(x: -1, y: 1)
|
||||
case .down: context.translateBy(x: CGFloat(width), y: CGFloat(height)); context.rotate(by: .pi)
|
||||
case .downMirrored: context.translateBy(x: 0, y: CGFloat(height)); context.scaleBy(x: 1, y: -1)
|
||||
case .left: context.translateBy(x: 0, y: CGFloat(height)); context.rotate(by: -.pi / 2)
|
||||
case .leftMirrored: context.translateBy(x: CGFloat(width), y: CGFloat(height)); context.rotate(by: -.pi / 2); context.scaleBy(x: 1, y: -1)
|
||||
case .right: context.translateBy(x: CGFloat(width), y: 0); context.rotate(by: .pi / 2)
|
||||
case .rightMirrored: context.rotate(by: .pi / 2); context.scaleBy(x: 1, y: -1)
|
||||
@unknown default: break
|
||||
}
|
||||
context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
|
||||
return context.makeImage() ?? image
|
||||
}
|
||||
|
||||
static func orientation(of source: CGImageSource) -> CGImagePropertyOrientation {
|
||||
guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
|
||||
let raw = properties[kCGImagePropertyOrientation] as? UInt32,
|
||||
let orientation = CGImagePropertyOrientation(rawValue: raw) else { return .up }
|
||||
return orientation
|
||||
}
|
||||
|
||||
/// Encodes a single image. `metadata == nil` writes a clean file with no
|
||||
/// EXIF/GPS/XMP at all.
|
||||
static func write(
|
||||
_ image: CGImage,
|
||||
to url: URL,
|
||||
format: Format,
|
||||
quality: Double,
|
||||
metadata: [CFString: Any]?
|
||||
) throws {
|
||||
guard let uti = format.uti else {
|
||||
throw ConversionError.unsupported("\(format.displayName) is not an image format.")
|
||||
}
|
||||
guard canEncode(format) else {
|
||||
throw ConversionError.unsupported("This Mac cannot write \(format.displayName) images.")
|
||||
}
|
||||
|
||||
var properties: [CFString: Any] = metadata ?? [kCGImagePropertyOrientation: CGImagePropertyOrientation.up.rawValue]
|
||||
if format.isLossy {
|
||||
properties[kCGImageDestinationLossyCompressionQuality] = max(0, min(1, quality))
|
||||
}
|
||||
|
||||
guard let destination = CGImageDestinationCreateWithURL(url as CFURL, uti as CFString, 1, nil) else {
|
||||
throw ConversionError.failed("Could not create \(format.displayName) at \(url.lastPathComponent).")
|
||||
}
|
||||
CGImageDestinationAddImage(destination, image, properties as CFDictionary)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
throw ConversionError.failed("Writing \(url.lastPathComponent) failed.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// WebP has no encoder in ImageIO on macOS. `cwebp` (from libwebp) is the
|
||||
/// best path; ffmpeg works when it was built with libwebp. Both are optional.
|
||||
enum WebPEncoder {
|
||||
static let cwebp: String? = Shell.find("cwebp", extraPaths: ["/opt/homebrew/bin", "/usr/local/bin"])
|
||||
|
||||
static let ffmpegHasLibwebp: Bool = {
|
||||
guard let ffmpeg = Toolchain.ffmpeg else { return false }
|
||||
let encoders = Shell.output(ffmpeg, ["-hide_banner", "-encoders"]) ?? ""
|
||||
return encoders.contains("libwebp")
|
||||
}()
|
||||
|
||||
static var isAvailable: Bool {
|
||||
Settings.shared.useExternalTools && (cwebp != nil || ffmpegHasLibwebp)
|
||||
}
|
||||
|
||||
static func encode(_ image: CGImage, to url: URL, quality: Double) throws {
|
||||
let staging = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("konverter-webp-\(UUID().uuidString).png")
|
||||
defer { try? FileManager.default.removeItem(at: staging) }
|
||||
try Raster.write(image, to: staging, format: .png, quality: 1, metadata: nil)
|
||||
|
||||
if let cwebp {
|
||||
try Shell.run(cwebp, ["-quiet", "-q", String(Int(quality * 100)), staging.path, "-o", url.path])
|
||||
return
|
||||
}
|
||||
if let ffmpeg = Toolchain.ffmpeg, ffmpegHasLibwebp {
|
||||
try Shell.run(
|
||||
ffmpeg,
|
||||
["-y", "-hide_banner", "-loglevel", "error", "-i", staging.path,
|
||||
"-frames:v", "1", "-c:v", "libwebp", "-quality", String(Int(quality * 100)), url.path]
|
||||
)
|
||||
return
|
||||
}
|
||||
throw ConversionError.unsupported("WebP needs cwebp — brew install webp")
|
||||
}
|
||||
}
|
||||
|
||||
/// Format → format for still and animated bitmaps.
|
||||
enum ImageConverter {
|
||||
static func convert(
|
||||
source: URL,
|
||||
to format: Format,
|
||||
at destination: URL,
|
||||
quality: Double = 0.9,
|
||||
keepMetadata: Bool = true,
|
||||
cancellation: Cancellation? = nil
|
||||
) throws {
|
||||
try cancellation?.check()
|
||||
|
||||
if format == .pdf {
|
||||
try PDFConverter.imageToPDF(source, at: destination, cancellation: cancellation)
|
||||
return
|
||||
}
|
||||
|
||||
// WebP has no ImageIO encoder on macOS: cwebp or ffmpeg fills in.
|
||||
if !Raster.canEncode(format) {
|
||||
if format == .webp, let image = Raster.firstImage(at: source), WebPEncoder.isAvailable {
|
||||
try WebPEncoder.encode(image, to: destination, quality: quality)
|
||||
return
|
||||
}
|
||||
throw ConversionError.unsupported("This Mac cannot write \(format.displayName) images.")
|
||||
}
|
||||
|
||||
guard let imageSource = Raster.imageSource(at: source) else {
|
||||
// Formats ImageIO cannot even open (SVG, PSD …): rasterise once.
|
||||
guard let image = Raster.firstImage(at: source) else {
|
||||
throw ConversionError.unreadable(source.lastPathComponent)
|
||||
}
|
||||
let prepared = format == .jpg ? Raster.flattened(image) : image
|
||||
try Raster.write(prepared, to: destination, format: format, quality: quality, metadata: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let frameCount = max(CGImageSourceGetCount(imageSource), 1)
|
||||
guard let uti = format.uti, Raster.canEncode(format) else {
|
||||
throw ConversionError.unsupported("This Mac cannot write \(format.displayName) images.")
|
||||
}
|
||||
|
||||
var properties: [CFString: Any] = [:]
|
||||
if keepMetadata, let sourceProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any] {
|
||||
properties = sourceProperties
|
||||
}
|
||||
// JPEG has no alpha channel: re-encode through a white background.
|
||||
let needsFlatten = format == .jpg
|
||||
|
||||
guard let destinationRef = CGImageDestinationCreateWithURL(
|
||||
destination as CFURL,
|
||||
uti as CFString,
|
||||
frameCount,
|
||||
nil
|
||||
) else {
|
||||
throw ConversionError.failed("Could not create \(format.displayName) at \(destination.lastPathComponent).")
|
||||
}
|
||||
|
||||
if format.isLossy {
|
||||
properties[kCGImageDestinationLossyCompressionQuality] = quality
|
||||
}
|
||||
|
||||
for index in 0..<frameCount {
|
||||
try cancellation?.check()
|
||||
if needsFlatten || !keepMetadata {
|
||||
guard let frame = CGImageSourceCreateImageAtIndex(imageSource, index, nil) else { continue }
|
||||
let prepared = needsFlatten ? Raster.flattened(frame) : frame
|
||||
CGImageDestinationAddImage(destinationRef, prepared, properties as CFDictionary)
|
||||
} else {
|
||||
CGImageDestinationAddImageFromSource(destinationRef, imageSource, index, properties as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
guard CGImageDestinationFinalize(destinationRef) else {
|
||||
throw ConversionError.failed("Writing \(destination.lastPathComponent) failed.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import CoreText
|
||||
import Foundation
|
||||
import Vision
|
||||
|
||||
/// In-place image transforms.
|
||||
enum ImageTools {
|
||||
static func compress(_ source: URL, at destination: URL, format: Format, quality: Double) throws {
|
||||
guard let image = Raster.firstImage(at: source) else {
|
||||
throw ConversionError.unreadable(source.lastPathComponent)
|
||||
}
|
||||
let outputFormat = format
|
||||
var prepared = format == .jpg ? Raster.flattened(image) : image
|
||||
// PNG/BMP/TIFF are lossless: ImageIO ignores the quality knob, so the
|
||||
// slider drives colour depth instead. JPEG-style quality is meaningless
|
||||
// there, and shipping a "compressed" file that got bigger is worse than
|
||||
// a visibly banded one that actually got smaller.
|
||||
switch outputFormat {
|
||||
case .png, .bmp, .tiff:
|
||||
prepared = Raster.posterized(prepared, quality: quality)
|
||||
default:
|
||||
break
|
||||
}
|
||||
try Raster.write(prepared, to: destination, format: outputFormat, quality: quality, metadata: nil)
|
||||
}
|
||||
|
||||
static func resize(
|
||||
_ source: URL,
|
||||
at destination: URL,
|
||||
maxWidth: Int,
|
||||
maxHeight: Int,
|
||||
percent: Int
|
||||
) throws {
|
||||
guard let image = Raster.firstImage(at: source) else {
|
||||
throw ConversionError.unreadable(source.lastPathComponent)
|
||||
}
|
||||
var targetWidth = image.width
|
||||
var targetHeight = image.height
|
||||
if percent != 100 {
|
||||
targetWidth = Int(Double(image.width) * Double(percent) / 100)
|
||||
targetHeight = Int(Double(image.height) * Double(percent) / 100)
|
||||
} else {
|
||||
let scale = min(Double(maxWidth) / Double(image.width), Double(maxHeight) / Double(image.height), 1)
|
||||
targetWidth = Int(Double(image.width) * scale)
|
||||
targetHeight = Int(Double(image.height) * scale)
|
||||
}
|
||||
targetWidth = max(1, targetWidth)
|
||||
targetHeight = max(1, targetHeight)
|
||||
guard let resized = draw(image, size: CGSize(width: targetWidth, height: targetHeight)) else {
|
||||
throw ConversionError.failed("Resizing failed.")
|
||||
}
|
||||
try write(resized, to: destination, matching: source)
|
||||
}
|
||||
|
||||
static func crop(_ source: URL, at destination: URL, inset: Int, aspect: String) throws {
|
||||
guard let image = Raster.firstImage(at: source) else {
|
||||
throw ConversionError.unreadable(source.lastPathComponent)
|
||||
}
|
||||
var rect = CGRect(x: 0, y: 0, width: image.width, height: image.height)
|
||||
if aspect != "Keep", let ratio = aspectRatio(aspect) {
|
||||
let current = CGFloat(image.width) / CGFloat(image.height)
|
||||
if current > ratio {
|
||||
let width = CGFloat(image.height) * ratio
|
||||
rect = CGRect(x: (CGFloat(image.width) - width) / 2, y: 0, width: width, height: CGFloat(image.height))
|
||||
} else {
|
||||
let height = CGFloat(image.width) / ratio
|
||||
rect = CGRect(x: 0, y: (CGFloat(image.height) - height) / 2, width: CGFloat(image.width), height: height)
|
||||
}
|
||||
}
|
||||
if inset > 0 {
|
||||
rect = rect.insetBy(dx: CGFloat(inset), dy: CGFloat(inset))
|
||||
}
|
||||
rect = rect.integral
|
||||
guard rect.width > 1, rect.height > 1 else {
|
||||
throw ConversionError.failed("The crop values remove the whole image.")
|
||||
}
|
||||
guard let cropped = image.cropping(to: rect) else {
|
||||
throw ConversionError.failed("Cropping failed.")
|
||||
}
|
||||
try write(cropped, to: destination, matching: source)
|
||||
}
|
||||
|
||||
private static func aspectRatio(_ aspect: String) -> CGFloat? {
|
||||
let parts = aspect.split(separator: ":").compactMap { Double($0) }
|
||||
guard parts.count == 2, parts[1] != 0 else { return nil }
|
||||
return CGFloat(parts[0] / parts[1])
|
||||
}
|
||||
|
||||
static func rotate(_ source: URL, at destination: URL, degrees: Int) throws {
|
||||
guard let image = Raster.firstImage(at: source) else {
|
||||
throw ConversionError.unreadable(source.lastPathComponent)
|
||||
}
|
||||
let radians = CGFloat(degrees) * .pi / 180
|
||||
let swaps = degrees % 180 != 0
|
||||
let width = swaps ? image.height : image.width
|
||||
let height = swaps ? image.width : image.height
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: image.colorSpace ?? CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else { throw ConversionError.failed("Rotating failed.") }
|
||||
|
||||
context.translateBy(x: CGFloat(width) / 2, y: CGFloat(height) / 2)
|
||||
context.rotate(by: radians)
|
||||
context.draw(
|
||||
image,
|
||||
in: CGRect(
|
||||
x: -CGFloat(image.width) / 2,
|
||||
y: -CGFloat(image.height) / 2,
|
||||
width: CGFloat(image.width),
|
||||
height: CGFloat(image.height)
|
||||
)
|
||||
)
|
||||
guard let rotated = context.makeImage() else { throw ConversionError.failed("Rotating failed.") }
|
||||
try write(rotated, to: destination, matching: source)
|
||||
}
|
||||
|
||||
static func flip(_ source: URL, at destination: URL, horizontal: Bool) throws {
|
||||
guard let image = Raster.firstImage(at: source) else {
|
||||
throw ConversionError.unreadable(source.lastPathComponent)
|
||||
}
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: image.colorSpace ?? CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else { throw ConversionError.failed("Flipping failed.") }
|
||||
|
||||
if horizontal {
|
||||
context.translateBy(x: CGFloat(image.width), y: 0)
|
||||
context.scaleBy(x: -1, y: 1)
|
||||
} else {
|
||||
context.translateBy(x: 0, y: CGFloat(image.height))
|
||||
context.scaleBy(x: 1, y: -1)
|
||||
}
|
||||
context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
|
||||
guard let flipped = context.makeImage() else { throw ConversionError.failed("Flipping failed.") }
|
||||
try write(flipped, to: destination, matching: source)
|
||||
}
|
||||
|
||||
static func grayscale(_ source: URL, at destination: URL) throws {
|
||||
guard let image = Raster.firstImage(at: source) else {
|
||||
throw ConversionError.unreadable(source.lastPathComponent)
|
||||
}
|
||||
guard let space = CGColorSpace(name: CGColorSpace.linearGray),
|
||||
let context = CGContext(
|
||||
data: nil,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: space,
|
||||
bitmapInfo: CGImageAlphaInfo.none.rawValue
|
||||
) else { throw ConversionError.failed("Grayscale conversion failed.") }
|
||||
context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
|
||||
guard let gray = context.makeImage() else { throw ConversionError.failed("Grayscale conversion failed.") }
|
||||
try write(gray, to: destination, matching: source)
|
||||
}
|
||||
|
||||
static func stripMetadata(_ source: URL, at destination: URL, format: Format) throws {
|
||||
guard let image = Raster.firstImage(at: source) else {
|
||||
throw ConversionError.unreadable(source.lastPathComponent)
|
||||
}
|
||||
let prepared = format == .jpg ? Raster.flattened(image) : image
|
||||
try Raster.write(prepared, to: destination, format: format, quality: 0.95, metadata: nil)
|
||||
}
|
||||
|
||||
static func watermark(
|
||||
_ source: URL,
|
||||
at destination: URL,
|
||||
text: String,
|
||||
position: String,
|
||||
opacity: Double,
|
||||
sizePercent: Double
|
||||
) throws {
|
||||
guard let image = Raster.firstImage(at: source) else {
|
||||
throw ConversionError.unreadable(source.lastPathComponent)
|
||||
}
|
||||
let width = image.width
|
||||
let height = image.height
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: image.colorSpace ?? CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else { throw ConversionError.failed("Watermarking failed.") }
|
||||
|
||||
context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||
|
||||
let fontSize = max(8, CGFloat(sizePercent) / 100 * CGFloat(width))
|
||||
let font = NSFont.systemFont(ofSize: fontSize, weight: .semibold)
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: font,
|
||||
.foregroundColor: NSColor.white.withAlphaComponent(opacity / 100)
|
||||
]
|
||||
let attributed = NSAttributedString(string: text, attributes: attributes)
|
||||
let line = CTLineCreateWithAttributedString(attributed)
|
||||
let bounds = CTLineGetBoundsWithOptions(line, .useOpticalBounds)
|
||||
let margin = fontSize * 0.6
|
||||
|
||||
let origin: CGPoint
|
||||
switch position {
|
||||
case "Bottom Left": origin = CGPoint(x: margin, y: margin)
|
||||
case "Top Right": origin = CGPoint(x: CGFloat(width) - bounds.width - margin, y: CGFloat(height) - bounds.height - margin)
|
||||
case "Top Left": origin = CGPoint(x: margin, y: CGFloat(height) - bounds.height - margin)
|
||||
case "Center": origin = CGPoint(x: (CGFloat(width) - bounds.width) / 2, y: (CGFloat(height) - bounds.height) / 2)
|
||||
default: origin = CGPoint(x: CGFloat(width) - bounds.width - margin, y: margin)
|
||||
}
|
||||
|
||||
// A soft shadow keeps light text readable over light photos.
|
||||
context.setShadow(offset: CGSize(width: 0, height: -fontSize * 0.05), blur: fontSize * 0.2, color: CGColor(gray: 0, alpha: opacity / 140))
|
||||
context.textPosition = origin
|
||||
CTLineDraw(line, context)
|
||||
|
||||
guard let stamped = context.makeImage() else { throw ConversionError.failed("Watermarking failed.") }
|
||||
try write(stamped, to: destination, matching: source)
|
||||
}
|
||||
|
||||
/// QR payloads, via Vision so it also reads the newer formats CIDetector
|
||||
/// misses.
|
||||
static func readQR(_ source: URL) -> String? {
|
||||
guard let image = Raster.firstImage(at: source) else { return nil }
|
||||
let request = VNDetectBarcodesRequest()
|
||||
request.symbologies = [.qr, .aztec, .pdf417, .code128, .code39, .ean13, .dataMatrix]
|
||||
let handler = VNImageRequestHandler(cgImage: image, options: [:])
|
||||
do {
|
||||
try handler.perform([request])
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
let payloads = (request.results ?? []).compactMap(\.payloadStringValue)
|
||||
return payloads.isEmpty ? nil : payloads.joined(separator: "\n")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func draw(_ image: CGImage, size: CGSize) -> CGImage? {
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: Int(size.width),
|
||||
height: Int(size.height),
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: image.colorSpace ?? CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else { return nil }
|
||||
context.interpolationQuality = .high
|
||||
context.draw(image, in: CGRect(origin: .zero, size: size))
|
||||
return context.makeImage()
|
||||
}
|
||||
|
||||
private static func write(_ image: CGImage, to destination: URL, matching source: URL) throws {
|
||||
let format = FileKind.format(of: destination) ?? FileKind.format(of: source) ?? .png
|
||||
let prepared = format == .jpg ? Raster.flattened(image) : image
|
||||
try Raster.write(prepared, to: destination, format: format, quality: 0.92, metadata: nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// A deliberately small Markdown renderer/parser. Enough for the formats the
|
||||
/// app offers (headings, emphasis, code, lists, quotes, links, rules, images)
|
||||
/// without pulling in a dependency.
|
||||
enum Markdown {
|
||||
// MARK: - Markdown → HTML
|
||||
|
||||
static func html(from markdown: String, title: String = "Document") -> String {
|
||||
let lines = markdown.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n")
|
||||
var body: [String] = []
|
||||
var paragraph: [String] = []
|
||||
var listType: String?
|
||||
var inCode = false
|
||||
var codeLines: [String] = []
|
||||
|
||||
func flushParagraph() {
|
||||
guard !paragraph.isEmpty else { return }
|
||||
body.append("<p>" + inline(paragraph.joined(separator: " ")) + "</p>")
|
||||
paragraph = []
|
||||
}
|
||||
|
||||
func closeList() {
|
||||
if let type = listType {
|
||||
body.append("</\(type)>")
|
||||
listType = nil
|
||||
}
|
||||
}
|
||||
|
||||
for rawLine in lines {
|
||||
let line = rawLine
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
if trimmed.hasPrefix("```") {
|
||||
if inCode {
|
||||
body.append("<pre><code>" + escape(codeLines.joined(separator: "\n")) + "</code></pre>")
|
||||
codeLines = []
|
||||
inCode = false
|
||||
} else {
|
||||
flushParagraph()
|
||||
closeList()
|
||||
inCode = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if inCode {
|
||||
codeLines.append(line)
|
||||
continue
|
||||
}
|
||||
|
||||
if trimmed.isEmpty {
|
||||
flushParagraph()
|
||||
closeList()
|
||||
continue
|
||||
}
|
||||
|
||||
if let heading = headingLevel(trimmed) {
|
||||
flushParagraph()
|
||||
closeList()
|
||||
let text = trimmed.dropFirst(heading).trimmingCharacters(in: .whitespaces)
|
||||
body.append("<h\(heading)>" + inline(text) + "</h\(heading)>")
|
||||
continue
|
||||
}
|
||||
|
||||
if trimmed == "---" || trimmed == "***" || trimmed == "___" {
|
||||
flushParagraph()
|
||||
closeList()
|
||||
body.append("<hr/>")
|
||||
continue
|
||||
}
|
||||
|
||||
if trimmed.hasPrefix(">") {
|
||||
flushParagraph()
|
||||
closeList()
|
||||
let text = trimmed.dropFirst().trimmingCharacters(in: .whitespaces)
|
||||
body.append("<blockquote><p>" + inline(text) + "</p></blockquote>")
|
||||
continue
|
||||
}
|
||||
|
||||
if let item = listItem(trimmed) {
|
||||
flushParagraph()
|
||||
if listType != item.type {
|
||||
closeList()
|
||||
listType = item.type
|
||||
body.append("<\(item.type)>")
|
||||
}
|
||||
body.append("<li>" + inline(item.text) + "</li>")
|
||||
continue
|
||||
}
|
||||
|
||||
if trimmed.hasPrefix("|") && trimmed.hasSuffix("|") {
|
||||
flushParagraph()
|
||||
closeList()
|
||||
let cells = trimmed.dropFirst().dropLast().split(separator: "|").map {
|
||||
inline($0.trimmingCharacters(in: .whitespaces))
|
||||
}
|
||||
if !cells.allSatisfy({ $0.allSatisfy { $0 == "-" || $0 == ":" } }) {
|
||||
body.append("<tr>" + cells.map { "<td>\($0)</td>" }.joined() + "</tr>")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
paragraph.append(trimmed)
|
||||
}
|
||||
|
||||
if inCode {
|
||||
body.append("<pre><code>" + escape(codeLines.joined(separator: "\n")) + "</code></pre>")
|
||||
}
|
||||
flushParagraph()
|
||||
closeList()
|
||||
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>\(escape(title))</title>
|
||||
<style>
|
||||
body { font: 16px/1.6 -apple-system, "Helvetica Neue", sans-serif; max-width: 720px; margin: 48px auto; padding: 0 24px; color: #1d1d1f; }
|
||||
h1,h2,h3,h4 { line-height: 1.25; margin-top: 1.6em; }
|
||||
code { background: #f2f2f4; padding: 2px 5px; border-radius: 4px; font-size: 0.9em; }
|
||||
pre { background: #f2f2f4; padding: 14px; border-radius: 8px; overflow-x: auto; }
|
||||
blockquote { border-left: 3px solid #d0d0d5; margin-left: 0; padding-left: 16px; color: #555; }
|
||||
table { border-collapse: collapse; } td { border: 1px solid #ddd; padding: 6px 10px; }
|
||||
hr { border: none; border-top: 1px solid #ddd; margin: 2em 0; }
|
||||
</style></head><body>
|
||||
\(body.joined(separator: "\n"))
|
||||
</body></html>
|
||||
"""
|
||||
}
|
||||
|
||||
private static func headingLevel(_ line: String) -> Int? {
|
||||
let hashes = line.prefix { $0 == "#" }
|
||||
guard (1...6).contains(hashes.count) else { return nil }
|
||||
let rest = line.dropFirst(hashes.count)
|
||||
guard rest.isEmpty || rest.first == " " else { return nil }
|
||||
return hashes.count
|
||||
}
|
||||
|
||||
private static func listItem(_ line: String) -> (type: String, text: String)? {
|
||||
for marker in ["- ", "* ", "+ "] where line.hasPrefix(marker) {
|
||||
return ("ul", String(line.dropFirst(2)))
|
||||
}
|
||||
let digits = line.prefix { $0.isNumber }
|
||||
if !digits.isEmpty, line.dropFirst(digits.count).hasPrefix(". ") {
|
||||
return ("ol", String(line.dropFirst(digits.count + 1)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Inline spans: bold, italic, strikethrough, code, links, images.
|
||||
static func inline(_ raw: String) -> String {
|
||||
var text = escape(raw)
|
||||
text = replace(#"!\[([^\]]*)\]\(([^)]+)\)"#, in: text) { match in
|
||||
"<img src=\"\(match[2])\" alt=\"\(match[1])\"/>"
|
||||
}
|
||||
text = replace(#"\[([^\]]+)\]\(([^)]+)\)"#, in: text) { match in
|
||||
"<a href=\"\(match[2])\">\(match[1])</a>"
|
||||
}
|
||||
text = replace(#"`([^`]+)`"#, in: text) { "<code>\($0[1])</code>" }
|
||||
text = replace(#"\*\*([^*]+)\*\*"#, in: text) { "<strong>\($0[1])</strong>" }
|
||||
text = replace(#"__([^_]+)__"#, in: text) { "<strong>\($0[1])</strong>" }
|
||||
text = replace(#"(?<!\*)\*([^*]+)\*(?!\*)"#, in: text) { "<em>\($0[1])</em>" }
|
||||
text = replace(#"(?<!_)_([^_]+)_(?!_)"#, in: text) { "<em>\($0[1])</em>" }
|
||||
text = replace(#"~~([^~]+)~~"#, in: text) { "<del>\($0[1])</del>" }
|
||||
return text
|
||||
}
|
||||
|
||||
// MARK: - HTML → Markdown
|
||||
|
||||
static func markdown(fromHTML html: String) -> String {
|
||||
var text = html
|
||||
// Drop anything that cannot be represented as text.
|
||||
text = replace(#"(?is)<(head|script|style|noscript)[^>]*>.*?</\1>"#, in: text) { _ in "" }
|
||||
text = replace(#"(?is)<!--.*?-->"#, in: text) { _ in "" }
|
||||
|
||||
// Block level first.
|
||||
for level in (1...6).reversed() {
|
||||
text = replace(#"(?is)<h\#(level)[^>]*>(.*?)</h\#(level)>"#, in: text) { match in
|
||||
"\n\n" + String(repeating: "#", count: level) + " " + stripTags(match[1]).trimmed + "\n\n"
|
||||
}
|
||||
}
|
||||
text = replace(#"(?is)<pre[^>]*>(.*?)</pre>"#, in: text) { match in
|
||||
"\n\n```\n" + stripTags(match[1]).trimmingCharacters(in: .newlines) + "\n```\n\n"
|
||||
}
|
||||
text = replace(#"(?is)<blockquote[^>]*>(.*?)</blockquote>"#, in: text) { match in
|
||||
"\n\n" + stripTags(match[1]).trimmed
|
||||
.split(separator: "\n").map { "> " + $0 }.joined(separator: "\n") + "\n\n"
|
||||
}
|
||||
text = replace(#"(?is)<li[^>]*>(.*?)</li>"#, in: text) { match in
|
||||
"\n- " + stripTags(match[1]).trimmed
|
||||
}
|
||||
text = replace(#"(?is)<(ul|ol)[^>]*>"#, in: text) { _ in "\n" }
|
||||
text = replace(#"(?is)</(ul|ol)>"#, in: text) { _ in "\n" }
|
||||
text = replace(#"(?is)<hr[^>]*/?>"#, in: text) { _ in "\n\n---\n\n" }
|
||||
text = replace(#"(?is)<t[dh][^>]*>(.*?)</t[dh]>"#, in: text) { match in
|
||||
" " + stripTags(match[1]).trimmed + " |"
|
||||
}
|
||||
text = replace(#"(?is)<tr[^>]*>"#, in: text) { _ in "\n|" }
|
||||
text = replace(#"(?is)<br[^>]*/?>"#, in: text) { _ in " \n" }
|
||||
text = replace(#"(?is)</p>"#, in: text) { _ in "\n\n" }
|
||||
text = replace(#"(?is)<p[^>]*>"#, in: text) { _ in "\n\n" }
|
||||
text = replace(#"(?is)</div>"#, in: text) { _ in "\n" }
|
||||
|
||||
// Inline.
|
||||
text = replace(#"(?is)<(strong|b)[^>]*>(.*?)</\1>"#, in: text) { "**\(stripTags($0[2]).trimmed)**" }
|
||||
text = replace(#"(?is)<(em|i)[^>]*>(.*?)</\1>"#, in: text) { "*\(stripTags($0[2]).trimmed)*" }
|
||||
text = replace(#"(?is)<del[^>]*>(.*?)</del>"#, in: text) { "~~\(stripTags($0[1]).trimmed)~~" }
|
||||
text = replace(#"(?is)<code[^>]*>(.*?)</code>"#, in: text) { "`\(stripTags($0[1]))`" }
|
||||
text = replace(#"(?is)<img[^>]*alt=["']([^"']*)["'][^>]*src=["']([^"']+)["'][^>]*/?>"#, in: text) {
|
||||
"![\($0[1])](\($0[2]))"
|
||||
}
|
||||
text = replace(#"(?is)<img[^>]*src=["']([^"']+)["'][^>]*/?>"#, in: text) { match in ")" }
|
||||
text = replace(#"(?is)<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>"#, in: text) {
|
||||
"[\(stripTags($0[2]).trimmed)](\($0[1]))"
|
||||
}
|
||||
|
||||
text = stripTags(text)
|
||||
text = decodeEntities(text)
|
||||
// Collapse the whitespace the block replacements left behind.
|
||||
text = replace(#"[ \t]+\n"#, in: text) { _ in "\n" }
|
||||
text = replace(#"\n{3,}"#, in: text) { _ in "\n\n" }
|
||||
return text.trimmingCharacters(in: .whitespacesAndNewlines) + "\n"
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
static func escape(_ string: String) -> String {
|
||||
string
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
}
|
||||
|
||||
static func stripTags(_ html: String) -> String {
|
||||
replace(#"(?is)<[^>]+>"#, in: html) { _ in "" }
|
||||
}
|
||||
|
||||
static func decodeEntities(_ text: String) -> String {
|
||||
var result = text
|
||||
let map = [
|
||||
" ": " ", "&": "&", "<": "<", ">": ">", """: "\"",
|
||||
"'": "'", "'": "'", "—": "—", "–": "–", "…": "…",
|
||||
"’": "’", "‘": "‘", "“": "“", "”": "”"
|
||||
]
|
||||
for (entity, value) in map {
|
||||
result = result.replacingOccurrences(of: entity, with: value)
|
||||
}
|
||||
// Numeric entities.
|
||||
result = replace(#"&#(\d+);"#, in: result) { match in
|
||||
guard let code = UInt32(match[1]), let scalar = Unicode.Scalar(code) else { return match[0] }
|
||||
return String(Character(scalar))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Regex replacement where the closure receives the full match plus groups.
|
||||
static func replace(_ pattern: String, in string: String, _ transform: ([String]) -> String) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return string }
|
||||
let range = NSRange(string.startIndex..., in: string)
|
||||
let matches = regex.matches(in: string, range: range)
|
||||
guard !matches.isEmpty else { return string }
|
||||
|
||||
var output = string
|
||||
for match in matches.reversed() {
|
||||
guard let matchRange = Range(match.range, in: output) else { continue }
|
||||
var groups: [String] = []
|
||||
for index in 0..<match.numberOfRanges {
|
||||
if let groupRange = Range(match.range(at: index), in: output) {
|
||||
groups.append(String(output[groupRange]))
|
||||
} else {
|
||||
groups.append("")
|
||||
}
|
||||
}
|
||||
output.replaceSubrange(matchRange, with: transform(groups))
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import AVFoundation
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
|
||||
/// Video and audio tools. Where AVFoundation cannot express an edit (loudness
|
||||
/// normalisation, silence ranges, bitrate targets) ffmpeg is used instead.
|
||||
enum MediaTools {
|
||||
static func compressVideo(_ source: URL, at destination: URL, quality: String) throws {
|
||||
if Toolchain.hasFFmpeg, Settings.shared.useExternalTools {
|
||||
let crf = quality == "Low" ? "30" : (quality == "High" ? "21" : "26")
|
||||
try FFmpeg.filter(
|
||||
source: source,
|
||||
at: destination,
|
||||
arguments: ["-c:v", "libx264", "-preset", "slow", "-crf", crf, "-c:a", "aac", "-b:a", "128k"]
|
||||
)
|
||||
return
|
||||
}
|
||||
try AVConverter.convert(source: source, to: destination.pathExtension == "mov" ? .mov : .mp4, at: destination, quality: quality)
|
||||
}
|
||||
|
||||
static func normalizeAudio(_ source: URL, at destination: URL) throws {
|
||||
try FFmpeg.filter(
|
||||
source: source,
|
||||
at: destination,
|
||||
arguments: ["-af", "loudnorm=I=-16:TP=-1.5:LRA=11", "-c:a", codecForContainer(destination)]
|
||||
)
|
||||
}
|
||||
|
||||
static func channels(_ source: URL, at destination: URL, mono: Bool) throws {
|
||||
if Toolchain.hasFFmpeg, Settings.shared.useExternalTools {
|
||||
try FFmpeg.filter(
|
||||
source: source,
|
||||
at: destination,
|
||||
arguments: ["-ac", mono ? "1" : "2", "-c:a", codecForContainer(destination)]
|
||||
)
|
||||
return
|
||||
}
|
||||
try CoreAudio.remix(source: source, at: destination, mono: mono)
|
||||
}
|
||||
|
||||
static func bleep(_ source: URL, at destination: URL, start: Double, end: Double) throws {
|
||||
let filter = "volume=enable='between(t,\(start),\(end))':volume=0"
|
||||
try FFmpeg.filter(
|
||||
source: source,
|
||||
at: destination,
|
||||
arguments: ["-af", filter, "-c:a", codecForContainer(destination)]
|
||||
)
|
||||
}
|
||||
|
||||
static func compressAudio(_ source: URL, at destination: URL, bitrate: String) throws {
|
||||
guard let format = FileKind.format(of: destination) else {
|
||||
throw ConversionError.failed("Unknown audio container.")
|
||||
}
|
||||
try FFmpeg.convert(source: source, to: format, at: destination, bitrate: bitrate)
|
||||
}
|
||||
|
||||
/// Audio edits keep the source container where possible so the output name
|
||||
/// and the file itself stay recognisable.
|
||||
static func audioSibling(for source: URL, suffix: String, forcing format: Format? = nil) -> URL {
|
||||
let resolved = format ?? FileKind.format(of: source) ?? .m4a
|
||||
return OutputNaming.sibling(for: source, suffix: suffix, extension: resolved.ext)
|
||||
}
|
||||
|
||||
private static func codecForContainer(_ url: URL) -> String {
|
||||
switch FileKind.canonicalExtension(url.pathExtension) {
|
||||
case "mp3": return "libmp3lame"
|
||||
case "ogg": return "libvorbis"
|
||||
case "opus": return "libopus"
|
||||
case "flac": return "flac"
|
||||
case "wav": return "pcm_s16le"
|
||||
case "aiff": return "pcm_s16be"
|
||||
case "wma": return "wmav2"
|
||||
case "caf": return "pcm_s16le"
|
||||
default: return "aac"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialisable, human readable descriptions for the info tools.
|
||||
enum MediaInfo {
|
||||
static func describe(_ url: URL) -> String {
|
||||
switch FileKind.category(of: url) {
|
||||
case .image: return describeImage(url)
|
||||
case .video, .audio: return describeMedia(url)
|
||||
case .pdf: return describePDF(url)
|
||||
case .archive: return describeArchive(url)
|
||||
case .spreadsheet, .document, .ebook, .subtitle: return describeText(url)
|
||||
default: return describeFile(url)
|
||||
}
|
||||
}
|
||||
|
||||
static func describeFile(_ url: URL) -> String {
|
||||
describeFileBasics(url).joined(separator: "\n")
|
||||
}
|
||||
|
||||
private static func describeFileBasics(_ url: URL) -> [String] {
|
||||
var lines: [String] = ["Name: \(url.lastPathComponent)"]
|
||||
let values = try? url.resourceValues(forKeys: [.fileSizeKey, .creationDateKey, .contentModificationDateKey])
|
||||
if let size = values?.fileSize {
|
||||
lines.append("Size: \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file))")
|
||||
}
|
||||
if let created = values?.creationDate {
|
||||
lines.append("Created: \(DateFormatter.localizedString(from: created, dateStyle: .medium, timeStyle: .short))")
|
||||
}
|
||||
if let modified = values?.contentModificationDate {
|
||||
lines.append("Modified: \(DateFormatter.localizedString(from: modified, dateStyle: .medium, timeStyle: .short))")
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
private static func describeImage(_ url: URL) -> String {
|
||||
var lines = describeFileBasics(url)
|
||||
guard let source = Raster.imageSource(at: url) else { return lines.joined(separator: "\n") }
|
||||
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] ?? [:]
|
||||
if let width = properties[kCGImagePropertyPixelWidth] as? Int,
|
||||
let height = properties[kCGImagePropertyPixelHeight] as? Int {
|
||||
lines.append("Dimensions: \(width) × \(height)")
|
||||
}
|
||||
if let dpiWidth = properties[kCGImagePropertyDPIWidth] as? Double {
|
||||
lines.append("Resolution: \(Int(dpiWidth)) dpi")
|
||||
}
|
||||
if let colorModel = properties[kCGImagePropertyColorModel] as? String {
|
||||
lines.append("Color: \(colorModel)")
|
||||
}
|
||||
if properties[kCGImagePropertyGIFDictionary] != nil {
|
||||
lines.append("Format: animated GIF")
|
||||
}
|
||||
if let exif = properties[kCGImagePropertyExifDictionary] as? [CFString: Any] {
|
||||
if let date = exif[kCGImagePropertyExifDateTimeOriginal] as? String {
|
||||
lines.append("Captured: \(date)")
|
||||
}
|
||||
}
|
||||
if let tiff = properties[kCGImagePropertyTIFFDictionary] as? [CFString: Any] {
|
||||
if let make = tiff[kCGImagePropertyTIFFMake] as? String,
|
||||
let model = tiff[kCGImagePropertyTIFFModel] as? String {
|
||||
lines.append("Camera: \(make) \(model)")
|
||||
}
|
||||
}
|
||||
if let gps = properties[kCGImagePropertyGPSDictionary] as? [CFString: Any],
|
||||
let latitude = gps[kCGImagePropertyGPSLatitude] as? Double,
|
||||
let longitude = gps[kCGImagePropertyGPSLongitude] as? Double {
|
||||
lines.append("Location: \(String(format: "%.4f", latitude)), \(String(format: "%.4f", longitude))")
|
||||
}
|
||||
lines.append("Frames: \(Raster.frameCount(at: url))")
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private static func describeMedia(_ url: URL) -> String {
|
||||
var lines = describeFileBasics(url)
|
||||
let asset = AVURLAsset(url: url)
|
||||
let duration = CMTimeGetSeconds(asset.duration)
|
||||
if duration.isFinite, duration > 0 {
|
||||
let minutes = Int(duration) / 60
|
||||
let remaining = duration - Double(minutes * 60)
|
||||
lines.append("Duration: \(minutes):\(String(format: "%05.2f", remaining))")
|
||||
}
|
||||
for track in asset.tracks {
|
||||
switch track.mediaType {
|
||||
case .video:
|
||||
let size = track.naturalSize.applying(track.preferredTransform)
|
||||
let frameRate = track.nominalFrameRate
|
||||
lines.append("Video: \(Int(abs(size.width)))×\(Int(abs(size.height))) @ \(String(format: "%.2f", frameRate)) fps")
|
||||
lines.append("Codec: \(codecName(track) ?? "unknown")")
|
||||
case .audio:
|
||||
let rate = track.nominalFrameRate
|
||||
lines.append("Audio: \(codecName(track) ?? "unknown") @ \(Int(rate)) Hz")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
if let ffprobe = Toolchain.ffprobe,
|
||||
let output = Shell.output(ffprobe, ["-v", "error", "-show_entries", "format=bit_rate:stream=bit_rate", "-of", "default=nw=1", url.path]) {
|
||||
let rates = output.split(separator: "\n").compactMap { $0.split(separator: "=").last }.compactMap { Int($0) }.filter { $0 > 0 }
|
||||
if let bitrate = rates.first {
|
||||
lines.append("Bitrate: \(bitrate / 1000) kbps")
|
||||
}
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private static func codecName(_ track: AVAssetTrack) -> String? {
|
||||
guard let raw = track.formatDescriptions.first else { return nil }
|
||||
let description = raw as! CMFormatDescription
|
||||
return describeFormat(description)
|
||||
}
|
||||
|
||||
private static func describeFormat(_ description: CMFormatDescription) -> String? {
|
||||
let codec = CMFormatDescriptionGetMediaSubType(description)
|
||||
let bytes = [
|
||||
UInt8((codec >> 24) & 0xFF), UInt8((codec >> 16) & 0xFF),
|
||||
UInt8((codec >> 8) & 0xFF), UInt8(codec & 0xFF)
|
||||
]
|
||||
let name = String(bytes: bytes, encoding: .ascii)?.trimmingCharacters(in: .whitespaces)
|
||||
return name?.isEmpty == false ? name : nil
|
||||
}
|
||||
|
||||
private static func describePDF(_ url: URL) -> String {
|
||||
var lines = describeFileBasics(url)
|
||||
lines.append("Pages: \(PDFConverter.pageCount(url))")
|
||||
if let size = PDFConverter.pageSize(url) {
|
||||
lines.append("Page size: \(Int(size.width)) × \(Int(size.height)) pt")
|
||||
}
|
||||
let text = PDFConverter.extractText(url)
|
||||
lines.append("Words: \(text.split { $0 == " " || $0.isNewline }.count)")
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private static func describeArchive(_ url: URL) -> String {
|
||||
var lines = describeFileBasics(url)
|
||||
let listing = (try? ArchiveConverter.list(url)) ?? ""
|
||||
let entries = listing.split(separator: "\n").count
|
||||
lines.append("Entries: \(entries)")
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private static func describeText(_ url: URL) -> String {
|
||||
var lines = describeFileBasics(url)
|
||||
let text = TextDocument.plainText(from: url) ?? ""
|
||||
let words = text.split { $0 == " " || $0.isNewline }.count
|
||||
lines.append("Words: \(words)")
|
||||
lines.append("Characters: \(text.count)")
|
||||
lines.append("Lines: \(text.components(separatedBy: "\n").count)")
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Foundation
|
||||
|
||||
/// LibreOffice bridge for formats Apple's frameworks cannot render (PPT/PPTX,
|
||||
/// XLS/XLSX/ODS when they need layout, PAGES, Keynote).
|
||||
enum Office {
|
||||
/// LibreOffice cannot run two headless conversions at once in the same
|
||||
/// profile, so every call is serialised.
|
||||
private static let lock = NSLock()
|
||||
|
||||
static func convert(
|
||||
_ source: URL,
|
||||
to format: Format,
|
||||
at destination: URL,
|
||||
cancellation: Cancellation? = nil
|
||||
) throws {
|
||||
guard let soffice = Toolchain.soffice, Settings.shared.useExternalTools else {
|
||||
throw ConversionError.missingTool(
|
||||
"\(format.displayName) needs LibreOffice. Install it with: brew install --cask libreoffice"
|
||||
)
|
||||
}
|
||||
let filter = filterName(for: format)
|
||||
let staging = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("konverter-office-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: staging) }
|
||||
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
|
||||
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
try cancellation?.check()
|
||||
|
||||
try Shell.run(
|
||||
soffice,
|
||||
[
|
||||
"--headless", "--norestore", "--nolockcheck", "--nodefault",
|
||||
"-env:UserInstallation=file://" + staging.path + "/profile",
|
||||
"--convert-to", filter,
|
||||
"--outdir", staging.path,
|
||||
source.path
|
||||
],
|
||||
cancellation: cancellation
|
||||
)
|
||||
|
||||
let produced = try FileManager.default
|
||||
.contentsOfDirectory(at: staging, includingPropertiesForKeys: nil)
|
||||
.first { $0.pathExtension.lowercased() == destination.pathExtension.lowercased() }
|
||||
guard let produced else {
|
||||
throw ConversionError.failed("LibreOffice did not produce a \(format.displayName) file.")
|
||||
}
|
||||
try? FileManager.default.removeItem(at: destination)
|
||||
try FileManager.default.moveItem(at: produced, to: destination)
|
||||
}
|
||||
|
||||
static func filterName(for format: Format) -> String {
|
||||
switch format {
|
||||
case .pdf: return "pdf"
|
||||
case .docx: return "docx"
|
||||
case .odt: return "odt"
|
||||
case .rtf: return "rtf"
|
||||
case .txt: return "txt"
|
||||
case .html: return "html"
|
||||
case .csv: return "csv"
|
||||
case .xlsx: return "xlsx"
|
||||
case .ods: return "ods"
|
||||
case .pptx: return "pptx"
|
||||
case .epub: return "epub"
|
||||
case .png: return "png"
|
||||
case .jpg: return "jpg"
|
||||
default: return format.ext
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// PPT/PPTX/KEY → PDF or images. Everything funnels through a PDF so multi
|
||||
/// slide decks come out with one image per slide.
|
||||
enum SlidesConverter {
|
||||
static func convert(
|
||||
source: URL,
|
||||
to format: Format,
|
||||
at destination: URL,
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws {
|
||||
switch format {
|
||||
case .pdf:
|
||||
try Office.convert(source, to: .pdf, at: destination, cancellation: cancellation)
|
||||
case .png, .jpg:
|
||||
let staging = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("konverter-slides-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: staging) }
|
||||
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
|
||||
let pdf = staging.appendingPathComponent("deck.pdf")
|
||||
try Office.convert(source, to: .pdf, at: pdf, cancellation: cancellation)
|
||||
let directory = OutputNaming.artifactDirectory(
|
||||
for: destination,
|
||||
suffix: ""
|
||||
)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
_ = try PDFConverter.renderPages(
|
||||
pdf,
|
||||
to: format,
|
||||
into: directory,
|
||||
dpi: 200,
|
||||
cancellation: cancellation,
|
||||
progress: progress
|
||||
)
|
||||
default:
|
||||
throw ConversionError.unsupported("Slides become PDF or images.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import CoreText
|
||||
import Foundation
|
||||
import PDFKit
|
||||
|
||||
/// Everything PDF: rasterising pages, building PDFs from images, text
|
||||
/// extraction, splitting, merging, rotating and password handling.
|
||||
enum PDFConverter {
|
||||
static let defaultDPI: CGFloat = 300
|
||||
|
||||
// MARK: - Images → PDF
|
||||
|
||||
static func imageToPDF(_ source: URL, at destination: URL, cancellation: Cancellation? = nil) throws {
|
||||
guard let image = Raster.firstImage(at: source) else {
|
||||
throw ConversionError.unreadable(source.lastPathComponent)
|
||||
}
|
||||
try imagesToPDF([(image, nil)], at: destination, cancellation: cancellation)
|
||||
}
|
||||
|
||||
/// Each image becomes one page, sized so the image keeps its aspect ratio
|
||||
/// at 72 dpi (a 1440×900 screenshot becomes a 1440×900 pt page).
|
||||
static func imagesToPDF(
|
||||
_ pages: [(image: CGImage, name: String?)],
|
||||
at destination: URL,
|
||||
pageSize: CGSize? = nil,
|
||||
cancellation: Cancellation? = nil
|
||||
) throws {
|
||||
guard !pages.isEmpty else { throw ConversionError.failed("Nothing to put in the PDF.") }
|
||||
|
||||
var mediaBox = CGRect(origin: .zero, size: pages[0].name == nil
|
||||
? CGSize(width: pages[0].image.width, height: pages[0].image.height)
|
||||
: CGSize(width: 595, height: 842))
|
||||
if let pageSize { mediaBox = CGRect(origin: .zero, size: pageSize) }
|
||||
|
||||
guard let context = CGContext(destination as CFURL, mediaBox: &mediaBox, nil) else {
|
||||
throw ConversionError.failed("Could not create \(destination.lastPathComponent).")
|
||||
}
|
||||
|
||||
for page in pages {
|
||||
try cancellation?.check()
|
||||
let image = page.image
|
||||
var box = mediaBox
|
||||
if page.name == nil || pageSize == nil {
|
||||
box = CGRect(x: 0, y: 0, width: image.width, height: image.height)
|
||||
}
|
||||
context.beginPDFPage([kCGPDFContextMediaBox: CGRectToData(box)] as CFDictionary)
|
||||
|
||||
let scale = min(box.width / CGFloat(image.width), box.height / CGFloat(image.height))
|
||||
let size = CGSize(width: CGFloat(image.width) * scale, height: CGFloat(image.height) * scale)
|
||||
let rect = CGRect(
|
||||
x: (box.width - size.width) / 2,
|
||||
y: (box.height - size.height) / 2,
|
||||
width: size.width,
|
||||
height: size.height
|
||||
)
|
||||
context.draw(image, in: rect)
|
||||
context.endPDFPage()
|
||||
}
|
||||
context.closePDF()
|
||||
}
|
||||
|
||||
private static func CGRectToData(_ rect: CGRect) -> CFData {
|
||||
var rect = rect
|
||||
return CFDataCreate(nil, withUnsafeBytes(of: &rect) { $0.bindMemory(to: UInt8.self).baseAddress }, MemoryLayout<CGRect>.size)
|
||||
}
|
||||
|
||||
// MARK: - PDF → images
|
||||
|
||||
/// Renders each page to `format` at `dpi`. Returns the files written.
|
||||
@discardableResult
|
||||
static func renderPages(
|
||||
_ source: URL,
|
||||
to format: Format,
|
||||
into directory: URL,
|
||||
dpi: CGFloat = 300,
|
||||
pageNumbers: [Int]? = nil,
|
||||
quality: Double = 0.85,
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws -> [URL] {
|
||||
guard let document = PDFDocument(url: source) else {
|
||||
throw ConversionError.unreadable(source.lastPathComponent)
|
||||
}
|
||||
let base = source.deletingPathExtension().lastPathComponent
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
|
||||
let indices = pageNumbers ?? Array(0..<document.pageCount)
|
||||
var written: [URL] = []
|
||||
let scale = dpi / 72
|
||||
let padding = String(indices.count).count
|
||||
|
||||
for (offset, pageIndex) in indices.enumerated() {
|
||||
try cancellation?.check()
|
||||
guard let page = document.page(at: pageIndex) else { continue }
|
||||
let bounds = page.bounds(for: .mediaBox)
|
||||
let pixelWidth = Int((bounds.width * scale).rounded())
|
||||
let pixelHeight = Int((bounds.height * scale).rounded())
|
||||
guard pixelWidth > 0, pixelHeight > 0 else { continue }
|
||||
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: pixelWidth,
|
||||
height: pixelHeight,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue
|
||||
) else { continue }
|
||||
|
||||
context.setFillColor(CGColor(gray: 1, alpha: 1))
|
||||
context.fill(CGRect(x: 0, y: 0, width: pixelWidth, height: pixelHeight))
|
||||
context.scaleBy(x: scale, y: scale)
|
||||
context.translateBy(x: -bounds.origin.x, y: -bounds.origin.y)
|
||||
page.draw(with: .mediaBox, to: context)
|
||||
|
||||
guard let image = context.makeImage() else { continue }
|
||||
let number = String(pageIndex + 1).leftPadded(to: padding)
|
||||
let target = OutputNaming.availableURL(
|
||||
for: directory.appendingPathComponent("\(base)-\(number)").appendingPathExtension(format.ext)
|
||||
)
|
||||
try Raster.write(image, to: target, format: format, quality: quality, metadata: nil)
|
||||
written.append(target)
|
||||
progress?(Double(offset + 1) / Double(indices.count))
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
// MARK: - Text
|
||||
|
||||
static func extractText(_ source: URL, pageBreak: String = "\n\n") -> String {
|
||||
guard let document = PDFDocument(url: source) else { return "" }
|
||||
return (0..<document.pageCount)
|
||||
.compactMap { document.page(at: $0)?.string }
|
||||
.joined(separator: pageBreak)
|
||||
}
|
||||
|
||||
// MARK: - Document surgery
|
||||
|
||||
static func merge(_ sources: [URL], at destination: URL, cancellation: Cancellation? = nil) throws {
|
||||
let merged = PDFDocument()
|
||||
var index = 0
|
||||
for source in sources {
|
||||
try cancellation?.check()
|
||||
guard let document = PDFDocument(url: source) else {
|
||||
throw ConversionError.unreadable(source.lastPathComponent)
|
||||
}
|
||||
for pageIndex in 0..<document.pageCount {
|
||||
try cancellation?.check()
|
||||
guard let page = document.page(at: pageIndex)?.copy() as? PDFPage else { continue }
|
||||
merged.insert(page, at: index)
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
guard merged.pageCount > 0 else { throw ConversionError.failed("No pages to merge.") }
|
||||
guard merged.write(to: destination) else {
|
||||
throw ConversionError.failed("Could not write \(destination.lastPathComponent).")
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits into chunks of `pagesPerFile`, writing into a sibling folder.
|
||||
static func split(
|
||||
_ source: URL,
|
||||
pagesPerFile: Int,
|
||||
into directory: URL,
|
||||
cancellation: Cancellation? = nil
|
||||
) throws -> [URL] {
|
||||
guard let document = PDFDocument(url: source) else {
|
||||
throw ConversionError.unreadable(source.lastPathComponent)
|
||||
}
|
||||
let base = source.deletingPathExtension().lastPathComponent
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
|
||||
var written: [URL] = []
|
||||
var start = 0
|
||||
var part = 1
|
||||
while start < document.pageCount {
|
||||
try cancellation?.check()
|
||||
let chunk = PDFDocument()
|
||||
var cursor = 0
|
||||
for pageIndex in start..<min(start + pagesPerFile, document.pageCount) {
|
||||
guard let page = document.page(at: pageIndex)?.copy() as? PDFPage else { continue }
|
||||
chunk.insert(page, at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
let target = OutputNaming.availableURL(
|
||||
for: directory.appendingPathComponent("\(base)-\(String(format: "%02d", part)).pdf")
|
||||
)
|
||||
guard chunk.write(to: target) else {
|
||||
throw ConversionError.failed("Could not write \(target.lastPathComponent).")
|
||||
}
|
||||
written.append(target)
|
||||
start += pagesPerFile
|
||||
part += 1
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
static func rotate(_ source: URL, degrees: Int, at destination: URL) throws {
|
||||
guard let document = PDFDocument(url: source) else {
|
||||
throw ConversionError.unreadable(source.lastPathComponent)
|
||||
}
|
||||
for index in 0..<document.pageCount {
|
||||
guard let page = document.page(at: index) else { continue }
|
||||
page.rotation = (page.rotation + degrees) % 360
|
||||
}
|
||||
guard document.write(to: destination) else {
|
||||
throw ConversionError.failed("Could not write \(destination.lastPathComponent).")
|
||||
}
|
||||
}
|
||||
|
||||
static func encrypt(_ source: URL, at destination: URL, password: String, ownerPassword: String?) throws {
|
||||
guard let document = PDFDocument(url: source) else {
|
||||
throw ConversionError.unreadable(source.lastPathComponent)
|
||||
}
|
||||
let options: [PDFDocumentWriteOption: Any] = [
|
||||
.userPasswordOption: password,
|
||||
.ownerPasswordOption: ownerPassword?.isEmpty == false ? ownerPassword! : password
|
||||
]
|
||||
guard document.write(to: destination, withOptions: options) else {
|
||||
throw ConversionError.failed("Could not write the encrypted PDF.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites through an image pipeline, which is what "compress a PDF"
|
||||
/// means in practice: re-encode each page as a JPEG at lower quality.
|
||||
static func compress(
|
||||
_ source: URL,
|
||||
at destination: URL,
|
||||
quality: Double,
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws {
|
||||
guard let document = PDFDocument(url: source) else {
|
||||
throw ConversionError.unreadable(source.lastPathComponent)
|
||||
}
|
||||
guard let firstPage = document.page(at: 0) else { return }
|
||||
var mediaBox = firstPage.bounds(for: .mediaBox)
|
||||
guard let context = CGContext(destination as CFURL, mediaBox: &mediaBox, nil) else {
|
||||
throw ConversionError.failed("Could not create the compressed PDF.")
|
||||
}
|
||||
for index in 0..<document.pageCount {
|
||||
try cancellation?.check()
|
||||
guard let page = document.page(at: index) else { continue }
|
||||
let bounds = page.bounds(for: .mediaBox)
|
||||
let scale: CGFloat = 150 / 72
|
||||
let width = Int(bounds.width * scale)
|
||||
let height = Int(bounds.height * scale)
|
||||
var box = bounds
|
||||
context.beginPDFPage([kCGPDFContextMediaBox: CGRectToData(box)] as CFDictionary)
|
||||
if width > 0, height > 0,
|
||||
let bitmap = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue
|
||||
) {
|
||||
bitmap.setFillColor(CGColor(gray: 1, alpha: 1))
|
||||
bitmap.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
||||
bitmap.scaleBy(x: scale, y: scale)
|
||||
bitmap.translateBy(x: -bounds.origin.x, y: -bounds.origin.y)
|
||||
page.draw(with: .mediaBox, to: bitmap)
|
||||
if let image = bitmap.makeImage(),
|
||||
let jpeg = JPEGEncoder.encode(image, quality: quality) {
|
||||
box = bounds
|
||||
context.draw(jpeg, in: bounds)
|
||||
}
|
||||
}
|
||||
context.endPDFPage()
|
||||
progress?(Double(index + 1) / Double(document.pageCount))
|
||||
}
|
||||
context.closePDF()
|
||||
}
|
||||
|
||||
/// Lays images out in a grid on a single page (Tangerine's "collage").
|
||||
static func collage(
|
||||
_ entries: [(image: CGImage, name: String)],
|
||||
at destination: URL,
|
||||
columns: Int,
|
||||
page: String,
|
||||
margin: CGFloat,
|
||||
captions: Bool,
|
||||
cancellation: Cancellation? = nil
|
||||
) throws {
|
||||
guard !entries.isEmpty else { throw ConversionError.failed("Nothing to lay out.") }
|
||||
let pageSize: CGSize
|
||||
switch page {
|
||||
case "Letter": pageSize = CGSize(width: 612, height: 792)
|
||||
case "Square": pageSize = CGSize(width: 800, height: 800)
|
||||
default: pageSize = CGSize(width: 595, height: 842)
|
||||
}
|
||||
var mediaBox = CGRect(origin: .zero, size: pageSize)
|
||||
guard let context = CGContext(destination as CFURL, mediaBox: &mediaBox, nil) else {
|
||||
throw ConversionError.failed("Could not create \(destination.lastPathComponent).")
|
||||
}
|
||||
context.beginPDFPage([kCGPDFContextMediaBox: CGRectToData(mediaBox)] as CFDictionary)
|
||||
|
||||
let columnCount = max(1, columns)
|
||||
let rowCount = Int(ceil(Double(entries.count) / Double(columnCount)))
|
||||
let usableWidth = pageSize.width - margin * 2
|
||||
let usableHeight = pageSize.height - margin * 2
|
||||
let cellWidth = usableWidth / CGFloat(columnCount)
|
||||
let cellHeight = usableHeight / CGFloat(max(1, rowCount))
|
||||
let captionHeight: CGFloat = captions ? 14 : 0
|
||||
|
||||
for (index, entry) in entries.enumerated() {
|
||||
try cancellation?.check()
|
||||
let column = index % columnCount
|
||||
let row = index / columnCount
|
||||
let cell = CGRect(
|
||||
x: margin + CGFloat(column) * cellWidth,
|
||||
y: pageSize.height - margin - CGFloat(row + 1) * cellHeight,
|
||||
width: cellWidth,
|
||||
height: cellHeight
|
||||
).insetBy(dx: 6, dy: 6)
|
||||
|
||||
let imageArea = cell.insetBy(dx: 0, dy: captionHeight)
|
||||
let scale = min(imageArea.width / CGFloat(entry.image.width), imageArea.height / CGFloat(entry.image.height))
|
||||
let size = CGSize(width: CGFloat(entry.image.width) * scale, height: CGFloat(entry.image.height) * scale)
|
||||
context.draw(entry.image, in: CGRect(
|
||||
x: imageArea.midX - size.width / 2,
|
||||
y: imageArea.midY - size.height / 2,
|
||||
width: size.width,
|
||||
height: size.height
|
||||
))
|
||||
|
||||
if captions, let escaped = entry.name as CFString? {
|
||||
let font = CTFontCreateWithName("Helvetica" as CFString, 9, nil)
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: font,
|
||||
.foregroundColor: CGColor(gray: 0.35, alpha: 1)
|
||||
]
|
||||
let line = CTLineCreateWithAttributedString(
|
||||
NSAttributedString(string: escaped as String, attributes: attributes)
|
||||
)
|
||||
context.textPosition = CGPoint(x: cell.minX, y: cell.minY)
|
||||
CTLineDraw(line, context)
|
||||
}
|
||||
}
|
||||
context.endPDFPage()
|
||||
context.closePDF()
|
||||
}
|
||||
|
||||
/// Rasterises a PDF page range to a single contact sheet, used by the
|
||||
/// "Info" summary.
|
||||
static func pageSize(_ source: URL) -> CGSize? {
|
||||
PDFDocument(url: source)?.page(at: 0)?.bounds(for: .mediaBox).size
|
||||
}
|
||||
|
||||
static func pageCount(_ source: URL) -> Int {
|
||||
PDFDocument(url: source)?.pageCount ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Small helper so the PDF compressor can produce JPEG bytes in memory.
|
||||
enum JPEGEncoder {
|
||||
static func encode(_ image: CGImage, quality: Double) -> CGImage? {
|
||||
let flattened = Raster.flattened(image)
|
||||
guard let data = CFDataCreateMutable(nil, 0),
|
||||
let destination = CGImageDestinationCreateWithData(data, "public.jpeg" as CFString, 1, nil) else {
|
||||
return nil
|
||||
}
|
||||
CGImageDestinationAddImage(
|
||||
destination,
|
||||
flattened,
|
||||
[kCGImageDestinationLossyCompressionQuality: quality] as CFDictionary
|
||||
)
|
||||
guard CGImageDestinationFinalize(destination) else { return nil }
|
||||
guard let source = CGImageSourceCreateWithData(data, nil) else { return nil }
|
||||
return CGImageSourceCreateImageAtIndex(source, 0, nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
func leftPadded(to width: Int, with character: Character = "0") -> String {
|
||||
count >= width ? self : String(repeating: String(character), count: width - count) + self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// CSV/TSV/XLSX/ODS. The delimited formats are parsed in-process; XLSX is read
|
||||
/// and written as OOXML by hand so no dependency is needed; everything else
|
||||
/// goes through LibreOffice.
|
||||
enum SpreadsheetConverter {
|
||||
typealias Rows = [[String]]
|
||||
|
||||
static func convert(
|
||||
source: URL,
|
||||
to format: Format,
|
||||
at destination: URL,
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws {
|
||||
try cancellation?.check()
|
||||
switch format {
|
||||
case .csv, .tsv:
|
||||
let rows = try read(source, cancellation: cancellation)
|
||||
try write(rows, to: destination, delimiter: format == .tsv ? "\t" : ",")
|
||||
case .xlsx:
|
||||
let rows = try read(source, cancellation: cancellation)
|
||||
try XLSX.write(rows, to: destination)
|
||||
case .pdf:
|
||||
let rows = try read(source, cancellation: cancellation)
|
||||
try writePDF(rows, at: destination)
|
||||
case .ods, .numbers, .xls:
|
||||
try Office.convert(source, to: format, at: destination, cancellation: cancellation)
|
||||
default:
|
||||
throw ConversionError.unsupported("Spreadsheets cannot become \(format.displayName).")
|
||||
}
|
||||
progress?(1)
|
||||
}
|
||||
|
||||
// MARK: - Reading
|
||||
|
||||
static func read(_ url: URL, cancellation: Cancellation? = nil) throws -> Rows {
|
||||
switch FileKind.canonicalExtension(url.pathExtension) {
|
||||
case "csv":
|
||||
let text = try string(from: url)
|
||||
return parseDelimited(text, delimiter: ",")
|
||||
case "tsv":
|
||||
let text = try string(from: url)
|
||||
return parseDelimited(text, delimiter: "\t")
|
||||
case "xlsx":
|
||||
return try XLSX.read(url, cancellation: cancellation)
|
||||
case "ods":
|
||||
return try ODS.read(url, cancellation: cancellation)
|
||||
case "pdf":
|
||||
// Tables in a PDF: best effort, one row per line.
|
||||
let text = PDFConverter.extractText(url)
|
||||
return text
|
||||
.components(separatedBy: "\n")
|
||||
.filter { !$0.trimmed.isEmpty }
|
||||
.map { [$0] }
|
||||
default:
|
||||
throw ConversionError.unsupported("Konverter cannot read .\(url.pathExtension) spreadsheets without LibreOffice.")
|
||||
}
|
||||
}
|
||||
|
||||
private static func string(from url: URL) throws -> String {
|
||||
if let utf8 = try? String(contentsOf: url, encoding: .utf8) { return utf8 }
|
||||
if let latin = try? String(contentsOf: url, encoding: .isoLatin1) { return latin }
|
||||
throw ConversionError.unreadable(url.lastPathComponent)
|
||||
}
|
||||
|
||||
/// RFC 4180-ish: quotes, doubled quotes, embedded newlines.
|
||||
static func parseDelimited(_ text: String, delimiter: Character) -> Rows {
|
||||
var rows: Rows = []
|
||||
var row: [String] = []
|
||||
var field = ""
|
||||
var inQuotes = false
|
||||
var iterator = text.makeIterator()
|
||||
var pending: Character?
|
||||
|
||||
while let character = pending ?? iterator.next() {
|
||||
pending = nil
|
||||
if inQuotes {
|
||||
if character == "\"" {
|
||||
if let next = iterator.next() {
|
||||
if next == "\"" {
|
||||
field.append("\"")
|
||||
} else {
|
||||
inQuotes = false
|
||||
pending = next
|
||||
}
|
||||
} else {
|
||||
inQuotes = false
|
||||
}
|
||||
} else {
|
||||
field.append(character)
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch character {
|
||||
case "\"":
|
||||
inQuotes = true
|
||||
case delimiter:
|
||||
row.append(field)
|
||||
field = ""
|
||||
case "\r":
|
||||
break
|
||||
case "\n":
|
||||
row.append(field)
|
||||
field = ""
|
||||
rows.append(row)
|
||||
row = []
|
||||
default:
|
||||
field.append(character)
|
||||
}
|
||||
}
|
||||
if !field.isEmpty || !row.isEmpty {
|
||||
row.append(field)
|
||||
rows.append(row)
|
||||
}
|
||||
// Drop a trailing empty row caused by a final newline.
|
||||
if let last = rows.last, last.allSatisfy({ $0.isEmpty }) { rows.removeLast() }
|
||||
return rows
|
||||
}
|
||||
|
||||
// MARK: - Writing
|
||||
|
||||
static func write(_ rows: Rows, to url: URL, delimiter: String) throws {
|
||||
let text = rows.map { row in
|
||||
row.map { escape($0, delimiter: delimiter) }.joined(separator: delimiter)
|
||||
}.joined(separator: "\n")
|
||||
try text.write(to: url, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
private static func escape(_ field: String, delimiter: String) -> String {
|
||||
let needsQuotes = field.contains(delimiter) || field.contains("\"") || field.contains("\n") || field.contains("\r")
|
||||
guard needsQuotes else { return field }
|
||||
return "\"" + field.replacingOccurrences(of: "\"", with: "\"\"") + "\""
|
||||
}
|
||||
|
||||
static func writePDF(_ rows: Rows, at url: URL) throws {
|
||||
let columnCount = rows.map(\.count).max() ?? 0
|
||||
guard columnCount > 0 else { throw ConversionError.failed("Nothing to write.") }
|
||||
var html = """
|
||||
<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
||||
body{font:11px -apple-system,sans-serif;margin:24px}
|
||||
table{border-collapse:collapse;width:100%}
|
||||
td,th{border:1px solid #d8d8dd;padding:4px 8px;vertical-align:top}
|
||||
th{background:#f4f4f7;text-align:left}
|
||||
</style></head><body><table>
|
||||
"""
|
||||
for (index, row) in rows.enumerated() {
|
||||
let tag = index == 0 ? "th" : "td"
|
||||
html += "<tr>" + (0..<columnCount).map { column in
|
||||
let value = column < row.count ? row[column] : ""
|
||||
return "<\(tag)>\(Markdown.escape(value))</\(tag)>"
|
||||
}.joined() + "</tr>"
|
||||
}
|
||||
html += "</table></body></html>"
|
||||
let attributed = try? NSAttributedString(
|
||||
data: Data(html.utf8),
|
||||
options: [.documentType: NSAttributedString.DocumentType.html],
|
||||
documentAttributes: nil
|
||||
)
|
||||
if let attributed {
|
||||
try TextDocument.writePDF(attributed, to: url, pageSize: CGSize(width: 842, height: 595))
|
||||
} else {
|
||||
try TextDocument.writePDF(fromText: rows.map { $0.joined(separator: "\t") }.joined(separator: "\n"), to: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal OOXML spreadsheet reader/writer.
|
||||
enum XLSX {
|
||||
static func read(_ url: URL, cancellation: Cancellation? = nil) throws -> SpreadsheetConverter.Rows {
|
||||
let staging = try Unzip.expand(url, cancellation: cancellation)
|
||||
defer { try? FileManager.default.removeItem(at: staging) }
|
||||
|
||||
let sharedStrings = parseSharedStrings(
|
||||
at: staging.appendingPathComponent("xl/sharedStrings.xml")
|
||||
)
|
||||
let sheetURL = firstSheet(at: staging)
|
||||
guard let sheetURL else { throw ConversionError.failed("This workbook has no sheets.") }
|
||||
return try parseSheet(at: sheetURL, sharedStrings: sharedStrings)
|
||||
}
|
||||
|
||||
static func write(_ rows: SpreadsheetConverter.Rows, to url: URL) throws {
|
||||
let staging = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("konverter-xlsx-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: staging) }
|
||||
try FileManager.default.createDirectory(
|
||||
at: staging.appendingPathComponent("xl/_rels"),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: staging.appendingPathComponent("_rels"),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: staging.appendingPathComponent("xl/worksheets"),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
try contentTypes.write(to: staging.appendingPathComponent("[Content_Types].xml"), atomically: true, encoding: .utf8)
|
||||
try packageRels.write(to: staging.appendingPathComponent("_rels/.rels"), atomically: true, encoding: .utf8)
|
||||
try workbookRels.write(to: staging.appendingPathComponent("xl/_rels/workbook.xml.rels"), atomically: true, encoding: .utf8)
|
||||
try styles.write(to: staging.appendingPathComponent("xl/styles.xml"), atomically: true, encoding: .utf8)
|
||||
try workbook.write(to: staging.appendingPathComponent("xl/workbook.xml"), atomically: true, encoding: .utf8)
|
||||
try sheetXML(rows).write(to: staging.appendingPathComponent("xl/worksheets/sheet1.xml"), atomically: true, encoding: .utf8)
|
||||
|
||||
let zip = Toolchain.zip
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
try Shell.run(zip, ["-r", "-X", "-q", url.path, "[Content_Types].xml", "_rels", "xl"], currentDirectory: staging)
|
||||
}
|
||||
|
||||
private static func sheetXML(_ rows: SpreadsheetConverter.Rows) -> String {
|
||||
var xml = """
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>
|
||||
"""
|
||||
for (rowIndex, row) in rows.enumerated() {
|
||||
xml += "<row r=\"\(rowIndex + 1)\">"
|
||||
for (columnIndex, value) in row.enumerated() {
|
||||
let reference = "\(columnName(columnIndex))\(rowIndex + 1)"
|
||||
if let number = numberValue(value) {
|
||||
xml += "<c r=\"\(reference)\"><v>\(number)</v></c>"
|
||||
} else {
|
||||
xml += "<c r=\"\(reference)\" t=\"inlineStr\"><is><t xml:space=\"preserve\">\(Markdown.escape(value))</t></is></c>"
|
||||
}
|
||||
}
|
||||
xml += "</row>"
|
||||
}
|
||||
xml += "</sheetData></worksheet>"
|
||||
return xml
|
||||
}
|
||||
|
||||
/// Writes a number only when the value really is one, so phone numbers and
|
||||
/// zero-padded ids stay text.
|
||||
private static func numberValue(_ value: String) -> String? {
|
||||
let trimmed = value.trimmed
|
||||
guard !trimmed.isEmpty, trimmed == value else { return nil }
|
||||
guard trimmed.rangeOfCharacter(from: CharacterSet(charactersIn: "0123456789")) != nil else { return nil }
|
||||
guard let number = Double(trimmed), number.isFinite else { return nil }
|
||||
if trimmed.count > 1, trimmed.hasPrefix("0"), !trimmed.hasPrefix("0.") { return nil }
|
||||
return trimmed
|
||||
}
|
||||
|
||||
static func columnName(_ index: Int) -> String {
|
||||
var index = index
|
||||
var name = ""
|
||||
repeat {
|
||||
name = String(UnicodeScalar(UInt8(65 + index % 26))) + name
|
||||
index = index / 26 - 1
|
||||
} while index >= 0
|
||||
return name
|
||||
}
|
||||
|
||||
static func columnIndex(_ reference: String) -> Int {
|
||||
var index = 0
|
||||
for character in reference.uppercased() {
|
||||
guard let ascii = character.asciiValue, ascii >= 65, ascii <= 90 else { break }
|
||||
index = index * 26 + Int(ascii - 64)
|
||||
}
|
||||
return max(0, index - 1)
|
||||
}
|
||||
|
||||
// MARK: - Parsing helpers
|
||||
|
||||
private static func firstSheet(at staging: URL) -> URL? {
|
||||
let worksheets = staging.appendingPathComponent("xl/worksheets")
|
||||
let files = (try? FileManager.default.contentsOfDirectory(at: worksheets, includingPropertiesForKeys: nil)) ?? []
|
||||
return files.filter { $0.pathExtension == "xml" }.sorted { $0.lastPathComponent < $1.lastPathComponent }.first
|
||||
}
|
||||
|
||||
private static func parseSharedStrings(at url: URL) -> [String] {
|
||||
guard let data = try? Data(contentsOf: url) else { return [] }
|
||||
let parser = XMLParser(data: data)
|
||||
let delegate = SharedStringsDelegate()
|
||||
parser.delegate = delegate
|
||||
parser.parse()
|
||||
return delegate.strings
|
||||
}
|
||||
|
||||
private static func parseSheet(at url: URL, sharedStrings: [String]) throws -> SpreadsheetConverter.Rows {
|
||||
guard let data = try? Data(contentsOf: url) else {
|
||||
throw ConversionError.failed("Could not read the worksheet.")
|
||||
}
|
||||
let parser = XMLParser(data: data)
|
||||
let delegate = SheetDelegate(sharedStrings: sharedStrings)
|
||||
parser.delegate = delegate
|
||||
parser.parse()
|
||||
return delegate.rows
|
||||
}
|
||||
|
||||
private final class SharedStringsDelegate: NSObject, XMLParserDelegate {
|
||||
var strings: [String] = []
|
||||
private var current: String?
|
||||
private var buffer = ""
|
||||
|
||||
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?, attributes: [String: String]) {
|
||||
if elementName == "si" { current = ""; buffer = "" }
|
||||
if elementName == "t" { buffer = "" }
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||
buffer += string
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName: String?) {
|
||||
if elementName == "t" { current = (current ?? "") + buffer }
|
||||
if elementName == "si" { strings.append(current ?? ""); current = nil }
|
||||
}
|
||||
}
|
||||
|
||||
private final class SheetDelegate: NSObject, XMLParserDelegate {
|
||||
let sharedStrings: [String]
|
||||
var rows: SpreadsheetConverter.Rows = []
|
||||
private var row: [String] = []
|
||||
private var pendingColumn = 0
|
||||
private var valueBuffer = ""
|
||||
private var cellType = ""
|
||||
private var isInline = false
|
||||
private var inCell = false
|
||||
|
||||
init(sharedStrings: [String]) {
|
||||
self.sharedStrings = sharedStrings
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?, attributes: [String: String]) {
|
||||
switch elementName {
|
||||
case "row":
|
||||
row = []
|
||||
case "c":
|
||||
inCell = true
|
||||
cellType = attributes["t"] ?? ""
|
||||
isInline = false
|
||||
valueBuffer = ""
|
||||
if let reference = attributes["r"] {
|
||||
pendingColumn = XLSX.columnIndex(reference)
|
||||
}
|
||||
case "is":
|
||||
isInline = true
|
||||
case "v", "t":
|
||||
valueBuffer = ""
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||
if inCell { valueBuffer += string }
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName: String?) {
|
||||
switch elementName {
|
||||
case "c":
|
||||
let value: String
|
||||
if cellType == "s", let index = Int(valueBuffer.trimmed), index < sharedStrings.count {
|
||||
value = sharedStrings[index]
|
||||
} else {
|
||||
value = isInline ? valueBuffer : valueBuffer.trimmed
|
||||
}
|
||||
while row.count < pendingColumn { row.append("") }
|
||||
row.append(value)
|
||||
pendingColumn = row.count
|
||||
inCell = false
|
||||
valueBuffer = ""
|
||||
case "row":
|
||||
rows.append(row)
|
||||
row = []
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Static OOXML scaffolding
|
||||
|
||||
private static let contentTypes = """
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
||||
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
||||
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
|
||||
</Types>
|
||||
"""
|
||||
|
||||
private static let packageRels = """
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||
</Relationships>
|
||||
"""
|
||||
|
||||
private static let workbookRels = """
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
|
||||
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||
</Relationships>
|
||||
"""
|
||||
|
||||
private static let workbook = """
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>
|
||||
"""
|
||||
|
||||
private static let styles = """
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
|
||||
<fills count="1"><fill><patternFill patternType="none"/></fill></fills>
|
||||
<borders count="1"><border/></borders>
|
||||
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
|
||||
<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>
|
||||
</styleSheet>
|
||||
"""
|
||||
}
|
||||
|
||||
/// ODS is a zip of `content.xml`; a light parser is enough for flat tables.
|
||||
enum ODS {
|
||||
static func read(_ url: URL, cancellation: Cancellation? = nil) throws -> SpreadsheetConverter.Rows {
|
||||
let staging = try Unzip.expand(url, cancellation: cancellation)
|
||||
defer { try? FileManager.default.removeItem(at: staging) }
|
||||
guard let data = try? Data(contentsOf: staging.appendingPathComponent("content.xml")) else {
|
||||
throw ConversionError.failed("This ODS file has no content.xml.")
|
||||
}
|
||||
let parser = XMLParser(data: data)
|
||||
let delegate = TableDelegate()
|
||||
parser.delegate = delegate
|
||||
parser.parse()
|
||||
return delegate.rows
|
||||
}
|
||||
|
||||
private final class TableDelegate: NSObject, XMLParserDelegate {
|
||||
private(set) var rows: SpreadsheetConverter.Rows = []
|
||||
private var row: [String] = []
|
||||
private var buffer = ""
|
||||
private var inCell = false
|
||||
private var inRow = false
|
||||
private var repeatColumns = 1
|
||||
|
||||
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?, attributes: [String: String]) {
|
||||
switch elementName {
|
||||
case "table-row":
|
||||
inRow = true
|
||||
row = []
|
||||
case "table-cell":
|
||||
inCell = true
|
||||
buffer = ""
|
||||
repeatColumns = Int(attributes["table:number-columns-repeated"] ?? "1") ?? 1
|
||||
case "p":
|
||||
if inCell, !buffer.isEmpty { buffer += " " }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||
if inCell { buffer += string }
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName: String?) {
|
||||
switch elementName {
|
||||
case "table-cell":
|
||||
let value = buffer.trimmed
|
||||
for _ in 0..<min(repeatColumns, 256) { row.append(value) }
|
||||
inCell = false
|
||||
case "table-row":
|
||||
if !row.allSatisfy({ $0.isEmpty }) { rows.append(row) }
|
||||
inRow = false
|
||||
case "table":
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Unzip {
|
||||
/// Expands an archive and returns the staging directory the caller must
|
||||
/// delete.
|
||||
static func expand(_ url: URL, cancellation: Cancellation? = nil) throws -> URL {
|
||||
let staging = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("konverter-unzip-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
|
||||
do {
|
||||
try Shell.run(Toolchain.unzip, ["-qq", "-o", url.path, "-d", staging.path], cancellation: cancellation)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: staging)
|
||||
throw error
|
||||
}
|
||||
return staging
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import Foundation
|
||||
|
||||
struct SubtitleCue {
|
||||
var start: Double
|
||||
var end: Double
|
||||
var text: String
|
||||
}
|
||||
|
||||
/// SRT ↔ VTT ↔ TXT, plus the offset tool.
|
||||
enum SubtitleConverter {
|
||||
static func convert(
|
||||
source: URL,
|
||||
to format: Format,
|
||||
at destination: URL,
|
||||
cancellation: Cancellation? = nil
|
||||
) throws {
|
||||
var cues = try parse(source)
|
||||
try cancellation?.check()
|
||||
cues = cues.map { cue in
|
||||
var copy = cue
|
||||
copy.text = SubtitleConverter.normalized(copy.text)
|
||||
return copy
|
||||
}
|
||||
switch format {
|
||||
case .srt, .vtt:
|
||||
try write(cues, to: destination, format: format)
|
||||
case .txt:
|
||||
try TextDocument.write(cues.map(\.text).joined(separator: "\n"), to: destination)
|
||||
default:
|
||||
throw ConversionError.unsupported("Subtitles cannot become \(format.displayName).")
|
||||
}
|
||||
}
|
||||
|
||||
static func parse(_ url: URL) throws -> [SubtitleCue] {
|
||||
guard let raw = try? String(contentsOf: url, encoding: .utf8)
|
||||
?? (try? String(contentsOf: url, encoding: .isoLatin1)) else {
|
||||
throw ConversionError.unreadable(url.lastPathComponent)
|
||||
}
|
||||
let ext = FileKind.canonicalExtension(url.pathExtension)
|
||||
switch ext {
|
||||
case "vtt":
|
||||
return parseBlocks(raw, timeSeparator: ".")
|
||||
case "ass", "ssa":
|
||||
return parseASS(raw)
|
||||
default:
|
||||
return parseBlocks(raw, timeSeparator: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseBlocks(_ raw: String, timeSeparator: Character?) -> [SubtitleCue] {
|
||||
let normalized = raw
|
||||
.replacingOccurrences(of: "\r\n", with: "\n")
|
||||
.replacingOccurrences(of: "\r", with: "\n")
|
||||
.replacingOccurrences(of: "\u{FEFF}", with: "")
|
||||
let blocks = normalized.components(separatedBy: "\n\n")
|
||||
var cues: [SubtitleCue] = []
|
||||
|
||||
for block in blocks {
|
||||
let lines = block.components(separatedBy: "\n").map { $0.trimmed }.filter { !$0.isEmpty }
|
||||
guard let timeLineIndex = lines.firstIndex(where: { $0.contains("-->") }) else { continue }
|
||||
let timeLine = lines[timeLineIndex]
|
||||
let parts = timeLine.components(separatedBy: "-->")
|
||||
guard parts.count == 2,
|
||||
let start = seconds(from: parts[0], separator: timeSeparator),
|
||||
let end = seconds(from: parts[1], separator: timeSeparator) else { continue }
|
||||
let text = lines.dropFirst(timeLineIndex + 1).joined(separator: "\n")
|
||||
cues.append(SubtitleCue(start: start, end: max(end, start), text: text.isEmpty ? " " : text))
|
||||
}
|
||||
return cues
|
||||
}
|
||||
|
||||
private static func parseASS(_ raw: String) -> [SubtitleCue] {
|
||||
var cues: [SubtitleCue] = []
|
||||
for line in raw.components(separatedBy: "\n") where line.hasPrefix("Dialogue:") {
|
||||
let fields = line.dropFirst("Dialogue:".count).components(separatedBy: ",")
|
||||
guard fields.count >= 10,
|
||||
let start = seconds(from: fields[1], separator: nil),
|
||||
let end = seconds(from: fields[2], separator: nil) else { continue }
|
||||
let text = fields[9...].joined(separator: ",")
|
||||
.replacingOccurrences(of: "\\N", with: "\n")
|
||||
.replacingOccurrences(of: "\\n", with: "\n")
|
||||
cues.append(SubtitleCue(start: start, end: end, text: text))
|
||||
}
|
||||
return cues
|
||||
}
|
||||
|
||||
static func write(_ cues: [SubtitleCue], to url: URL, format: Format) throws {
|
||||
var output = format == .vtt ? "WEBVTT\n\n" : ""
|
||||
for (index, cue) in cues.enumerated() {
|
||||
if format == .srt {
|
||||
output += "\(index + 1)\n"
|
||||
output += "\(timecode(cue.start, separator: ",")) --> \(timecode(cue.end, separator: ","))\n"
|
||||
} else {
|
||||
output += "\(timecode(cue.start, separator: ".")) --> \(timecode(cue.end, separator: "."))\n"
|
||||
}
|
||||
output += cue.text + "\n\n"
|
||||
}
|
||||
try TextDocument.write(output, to: url)
|
||||
}
|
||||
|
||||
static func shift(_ cues: [SubtitleCue], by offset: Double) -> [SubtitleCue] {
|
||||
cues.map { cue in
|
||||
var copy = cue
|
||||
let shift = max(0, cue.start + offset) - cue.start
|
||||
copy.start = max(0, cue.start + offset)
|
||||
copy.end = max(copy.start, cue.end + shift)
|
||||
return copy
|
||||
}
|
||||
}
|
||||
|
||||
private static func normalized(_ text: String) -> String {
|
||||
var value = text
|
||||
for tag in ["<i>", "</i>", "<b>", "</b>", "<u>", "</u>", "<font>", "</font>"] {
|
||||
value = value.replacingOccurrences(of: tag, with: "")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// MARK: - Timecodes
|
||||
|
||||
static func timecode(_ seconds: Double, separator: Character = ",") -> String {
|
||||
let clamped = max(0, seconds)
|
||||
let hours = Int(clamped) / 3600
|
||||
let minutes = (Int(clamped) % 3600) / 60
|
||||
let wholeSeconds = Int(clamped) % 60
|
||||
let millis = Int(((clamped - Double(Int(clamped))) * 1000).rounded())
|
||||
return String(format: "%02d:%02d:%02d%@%03d", hours, minutes, wholeSeconds, String(separator), millis)
|
||||
}
|
||||
|
||||
private static func seconds(from text: String, separator: Character?) -> Double? {
|
||||
let cleaned = text.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: ",", with: ".")
|
||||
let components = cleaned.components(separatedBy: ":")
|
||||
var value: Double = 0
|
||||
for (index, component) in components.enumerated() {
|
||||
guard let number = Double(component) else { return nil }
|
||||
switch components.count - index {
|
||||
case 1: value += number
|
||||
case 2: value += number * 60
|
||||
case 3: value += number * 3600
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
_ = separator
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import AppKit
|
||||
import CoreText
|
||||
import Foundation
|
||||
|
||||
/// Text extraction and rendering shared by the document, ebook, PDF and
|
||||
/// spreadsheet paths. `textutil` does the heavy lifting for Office formats;
|
||||
/// CoreText lays text out when we have to make a PDF ourselves.
|
||||
enum TextDocument {
|
||||
static func plainText(from url: URL) -> String? {
|
||||
switch FileKind.canonicalExtension(url.pathExtension) {
|
||||
case "txt", "md", "csv", "tsv", "srt", "vtt":
|
||||
return (try? String(contentsOf: url, encoding: .utf8))
|
||||
?? (try? String(contentsOf: url, encoding: .isoLatin1))
|
||||
case "html":
|
||||
if let html = try? String(contentsOf: url, encoding: .utf8) {
|
||||
return textFromHTML(html)
|
||||
}
|
||||
return nil
|
||||
case "epub":
|
||||
return try? EbookConverter.plainText(url)
|
||||
case "pdf":
|
||||
return PDFConverter.extractText(url)
|
||||
default:
|
||||
return textutilText(from: url)
|
||||
}
|
||||
}
|
||||
|
||||
static func textFromHTML(_ html: String) -> String {
|
||||
guard let data = html.data(using: .utf8),
|
||||
let attributed = try? NSAttributedString(
|
||||
data: data,
|
||||
options: [.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue],
|
||||
documentAttributes: nil
|
||||
) else {
|
||||
return Markdown.decodeEntities(Markdown.stripTags(html))
|
||||
}
|
||||
return attributed.string
|
||||
}
|
||||
|
||||
static func textutilText(from url: URL) -> String? {
|
||||
guard let result = try? Shell.run(Toolchain.textutil, ["-convert", "txt", "-stdout", url.path]) else {
|
||||
return nil
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
static func html(from url: URL) throws -> String {
|
||||
switch FileKind.canonicalExtension(url.pathExtension) {
|
||||
case "md":
|
||||
let source = try String(contentsOf: url, encoding: .utf8)
|
||||
return Markdown.html(from: source, title: url.deletingPathExtension().lastPathComponent)
|
||||
case "html":
|
||||
return try String(contentsOf: url, encoding: .utf8)
|
||||
case "txt":
|
||||
let source = try String(contentsOf: url, encoding: .utf8)
|
||||
let escaped = Markdown.escape(source)
|
||||
return "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body><pre>\(escaped)</pre></body></html>"
|
||||
case "pdf":
|
||||
let text = PDFConverter.extractText(url)
|
||||
return htmlDocument(fromPlainText: text, title: url.deletingPathExtension().lastPathComponent)
|
||||
case "epub":
|
||||
return try EbookConverter.html(url)
|
||||
default:
|
||||
let result = try Shell.run(Toolchain.textutil, ["-convert", "html", "-stdout", url.path])
|
||||
return result.stdout
|
||||
}
|
||||
}
|
||||
|
||||
static func attributedString(from url: URL) -> NSAttributedString? {
|
||||
if let data = try? Data(contentsOf: url),
|
||||
let attributed = try? NSAttributedString(
|
||||
data: data,
|
||||
options: [.documentType: documentType(for: url), .characterEncoding: String.Encoding.utf8.rawValue],
|
||||
documentAttributes: nil
|
||||
) {
|
||||
return attributed
|
||||
}
|
||||
// Fall back to HTML produced by textutil, which every Office format can
|
||||
// be converted to.
|
||||
if let html = try? html(from: url), let data = html.data(using: .utf8) {
|
||||
return try? NSAttributedString(
|
||||
data: data,
|
||||
options: [.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue],
|
||||
documentAttributes: nil
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func documentType(for url: URL) -> NSAttributedString.DocumentType {
|
||||
switch FileKind.canonicalExtension(url.pathExtension) {
|
||||
case "rtf": return .rtf
|
||||
case "rtfd": return .rtfd
|
||||
case "html": return .html
|
||||
case "doc": return .docFormat
|
||||
case "docx": return .officeOpenXML
|
||||
case "odt": return .openDocument
|
||||
default: return .plain
|
||||
}
|
||||
}
|
||||
|
||||
static func htmlDocument(fromPlainText text: String, title: String) -> String {
|
||||
let paragraphs = text
|
||||
.components(separatedBy: "\n\n")
|
||||
.map { $0.trimmed }
|
||||
.filter { !$0.isEmpty }
|
||||
.map { "<p>" + Markdown.escape($0).replacingOccurrences(of: "\n", with: "<br/>") + "</p>" }
|
||||
.joined(separator: "\n")
|
||||
return """
|
||||
<!DOCTYPE html><html><head><meta charset="utf-8"><title>\(Markdown.escape(title))</title>
|
||||
<style>body{font:16px/1.6 -apple-system,sans-serif;max-width:720px;margin:48px auto;padding:0 24px}</style>
|
||||
</head><body>\(paragraphs)</body></html>
|
||||
"""
|
||||
}
|
||||
|
||||
static func write(_ text: String, to url: URL) throws {
|
||||
try text.write(to: url, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
/// Converts text/HTML to an Office format with `textutil`.
|
||||
static func textutilConvert(_ source: URL, to format: Format, at destination: URL, cancellation: Cancellation? = nil) throws {
|
||||
guard let identifier = textutilIdentifier(for: format) else {
|
||||
throw ConversionError.unsupported("textutil cannot write \(format.displayName).")
|
||||
}
|
||||
try Shell.run(
|
||||
Toolchain.textutil,
|
||||
["-convert", identifier, "-output", destination.path, source.path],
|
||||
cancellation: cancellation
|
||||
)
|
||||
}
|
||||
|
||||
static func textutilConvert(sourceHTML: String, to format: Format, at destination: URL, cancellation: Cancellation? = nil) throws {
|
||||
let temporary = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("konverter-\(UUID().uuidString).html")
|
||||
defer { try? FileManager.default.removeItem(at: temporary) }
|
||||
try sourceHTML.write(to: temporary, atomically: true, encoding: .utf8)
|
||||
try textutilConvert(temporary, to: format, at: destination, cancellation: cancellation)
|
||||
}
|
||||
|
||||
private static func textutilIdentifier(for format: Format) -> String? {
|
||||
switch format {
|
||||
case .txt: return "txt"
|
||||
case .html: return "html"
|
||||
case .rtf: return "rtf"
|
||||
case .docx: return "docx"
|
||||
case .doc: return "doc"
|
||||
case .odt: return "odt"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PDF layout
|
||||
|
||||
/// Paginates an attributed string into a PDF at A4 with 56 pt margins.
|
||||
static func writePDF(_ attributed: NSAttributedString, to url: URL, pageSize: CGSize = CGSize(width: 595, height: 842)) throws {
|
||||
let margin: CGFloat = 56
|
||||
var mediaBox = CGRect(origin: .zero, size: pageSize)
|
||||
guard let context = CGContext(url as CFURL, mediaBox: &mediaBox, nil) else {
|
||||
throw ConversionError.failed("Could not create \(url.lastPathComponent).")
|
||||
}
|
||||
|
||||
let text = NSMutableAttributedString(attributedString: attributed)
|
||||
if text.length == 0 {
|
||||
text.append(NSAttributedString(string: " "))
|
||||
}
|
||||
// A readable default for documents that carry no fonts.
|
||||
text.enumerateAttribute(.font, in: NSRange(location: 0, length: text.length)) { value, range, _ in
|
||||
if value == nil {
|
||||
text.addAttribute(.font, value: NSFont.systemFont(ofSize: 12), range: range)
|
||||
}
|
||||
}
|
||||
|
||||
let framesetter = CTFramesetterCreateWithAttributedString(text as CFAttributedString)
|
||||
let textRect = CGRect(
|
||||
x: margin,
|
||||
y: margin,
|
||||
width: pageSize.width - margin * 2,
|
||||
height: pageSize.height - margin * 2
|
||||
)
|
||||
let path = CGPath(rect: textRect, transform: nil)
|
||||
|
||||
var cursor = 0
|
||||
let total = text.length
|
||||
repeat {
|
||||
context.beginPDFPage(nil)
|
||||
let frame = CTFramesetterCreateFrame(
|
||||
framesetter,
|
||||
CFRange(location: cursor, length: 0),
|
||||
path,
|
||||
nil
|
||||
)
|
||||
CTFrameDraw(frame, context)
|
||||
context.endPDFPage()
|
||||
|
||||
let visible = CTFrameGetVisibleStringRange(frame)
|
||||
if visible.length <= 0 { break }
|
||||
cursor = visible.location + visible.length
|
||||
} while cursor < total
|
||||
|
||||
context.closePDF()
|
||||
}
|
||||
|
||||
static func writePDF(fromText text: String, to url: URL, pageSize: CGSize = CGSize(width: 595, height: 842)) throws {
|
||||
let style = NSMutableParagraphStyle()
|
||||
style.lineSpacing = 3
|
||||
let attributed = NSAttributedString(
|
||||
string: text,
|
||||
attributes: [
|
||||
.font: NSFont.systemFont(ofSize: 12),
|
||||
.paragraphStyle: style
|
||||
]
|
||||
)
|
||||
try writePDF(attributed, to: url, pageSize: pageSize)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import PDFKit
|
||||
|
||||
/// What a tool produced. `message` is for the read-only tools (Info, Read QR,
|
||||
/// List Contents, Statistics) which show a result instead of writing a file.
|
||||
struct ToolOutcome {
|
||||
var outputs: [URL] = []
|
||||
var message: String?
|
||||
var messageTitle: String?
|
||||
var revealInFinder: Bool = true
|
||||
}
|
||||
|
||||
/// Runs a `ToolID` over one or more files. Every tool is written so that a
|
||||
/// failure on one file does not silently swallow the rest: the first error is
|
||||
/// reported after the successful outputs are collected.
|
||||
enum ToolRunner {
|
||||
static func run(
|
||||
tool: ToolID,
|
||||
inputs: [URL],
|
||||
params: ToolParams,
|
||||
cancellation: Cancellation? = nil,
|
||||
progress: ((Double) -> Void)? = nil
|
||||
) throws -> ToolOutcome {
|
||||
guard inputs.count >= tool.minimumFileCount else {
|
||||
throw ConversionError.failed("\(tool.title) needs at least \(tool.minimumFileCount) files.")
|
||||
}
|
||||
try cancellation?.check()
|
||||
|
||||
switch tool {
|
||||
case .compressImage:
|
||||
let quality = Double(params.int("quality", default: 70)) / 100
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let format = FileKind.format(of: source) ?? .jpg
|
||||
let destination = OutputNaming.sibling(for: source, suffix: "-compressed")
|
||||
try ImageTools.compress(source, at: destination, format: format, quality: quality)
|
||||
return destination
|
||||
}
|
||||
|
||||
case .resizeImage:
|
||||
let width = params.int("width", default: 1920)
|
||||
let height = params.int("height", default: 1920)
|
||||
let percent = params.int("percent", default: 100)
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let destination = OutputNaming.sibling(for: source, suffix: "-resized")
|
||||
try ImageTools.resize(source, at: destination, maxWidth: width, maxHeight: height, percent: percent)
|
||||
return destination
|
||||
}
|
||||
|
||||
case .cropImage:
|
||||
let inset = params.int("inset", default: 0)
|
||||
let aspect = params.string("aspect", default: "Keep")
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let destination = OutputNaming.sibling(for: source, suffix: "-cropped")
|
||||
try ImageTools.crop(source, at: destination, inset: inset, aspect: aspect)
|
||||
return destination
|
||||
}
|
||||
|
||||
case .rotateImage:
|
||||
let degrees = params.string("angle", default: "90° right").hasPrefix("90° r") ? 90 : (params.string("angle", default: "").hasPrefix("180") ? 180 : -90)
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let destination = OutputNaming.sibling(for: source, suffix: "-rotated")
|
||||
try ImageTools.rotate(source, at: destination, degrees: degrees)
|
||||
return destination
|
||||
}
|
||||
|
||||
case .flipImage:
|
||||
let horizontal = params.string("axis", default: "Horizontal") == "Horizontal"
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let destination = OutputNaming.sibling(for: source, suffix: "-flipped")
|
||||
try ImageTools.flip(source, at: destination, horizontal: horizontal)
|
||||
return destination
|
||||
}
|
||||
|
||||
case .grayscaleImage:
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let destination = OutputNaming.sibling(for: source, suffix: "-gray", extension: "png")
|
||||
try ImageTools.grayscale(source, at: destination)
|
||||
return destination
|
||||
}
|
||||
|
||||
case .stripMetadata:
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let format = FileKind.format(of: source) ?? .jpg
|
||||
let destination = OutputNaming.sibling(for: source, suffix: "-clean")
|
||||
try ImageTools.stripMetadata(source, at: destination, format: format)
|
||||
return destination
|
||||
}
|
||||
|
||||
case .watermarkImage:
|
||||
let text = params.string("text", default: "©")
|
||||
let position = params.string("position", default: "Bottom Right")
|
||||
let opacity = Double(params.int("opacity", default: 45))
|
||||
let size = Double(params.int("size", default: 16))
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let destination = OutputNaming.sibling(for: source, suffix: "-watermark")
|
||||
try ImageTools.watermark(source, at: destination, text: text, position: position, opacity: opacity, sizePercent: size)
|
||||
return destination
|
||||
}
|
||||
|
||||
case .readQR:
|
||||
var results: [String] = []
|
||||
for source in inputs {
|
||||
try cancellation?.check()
|
||||
let payload = ImageTools.readQR(source)
|
||||
results.append("\(source.lastPathComponent):\n\(payload ?? "No barcode found")")
|
||||
}
|
||||
return ToolOutcome(message: results.joined(separator: "\n\n"), messageTitle: "Barcode")
|
||||
|
||||
case .compressVideo:
|
||||
let quality = params.string("preset", default: "Medium")
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let ext = container(for: source, fallback: "mp4")
|
||||
let destination = OutputNaming.sibling(for: source, suffix: "-compressed", extension: ext)
|
||||
try MediaTools.compressVideo(source, at: destination, quality: quality)
|
||||
return destination
|
||||
}
|
||||
|
||||
case .trimVideo, .trimAudio:
|
||||
let start = Timecode.seconds(from: params.string("start", default: "0")) ?? 0
|
||||
let end = Timecode.seconds(from: params.string("end", default: ""))
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let ext = container(for: source, fallback: tool == .trimAudio ? "m4a" : "mp4")
|
||||
let destination = OutputNaming.sibling(for: source, suffix: "-trimmed", extension: ext)
|
||||
try trim(source, at: destination, start: start, end: end, cancellation: cancellation)
|
||||
return destination
|
||||
}
|
||||
|
||||
case .muteVideo:
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let ext = container(for: source, fallback: "mp4")
|
||||
let destination = OutputNaming.sibling(for: source, suffix: "-muted", extension: ext)
|
||||
try AVConverter.mute(source: source, at: destination, cancellation: cancellation)
|
||||
return destination
|
||||
}
|
||||
|
||||
case .speedVideo, .speedAudio:
|
||||
let rate = params.double("rate", default: 2)
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let ext = container(for: source, fallback: tool == .speedAudio ? "m4a" : "mp4")
|
||||
let destination = OutputNaming.sibling(for: source, suffix: "-\(rateString(rate))x", extension: ext)
|
||||
try AVConverter.changeSpeed(source: source, at: destination, rate: rate, cancellation: cancellation)
|
||||
return destination
|
||||
}
|
||||
|
||||
case .snapshotVideo:
|
||||
let at = Timecode.seconds(from: params.string("at", default: "1")) ?? 1
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let destination = OutputNaming.sibling(for: source, suffix: "-frame", extension: "png")
|
||||
try AVConverter.snapshot(source: source, at: destination, time: at, cancellation: cancellation)
|
||||
return destination
|
||||
}
|
||||
|
||||
case .videoToGIF:
|
||||
let fps = params.int("fps", default: 12)
|
||||
let width = params.int("width", default: 480)
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let destination = OutputNaming.sibling(for: source, suffix: "", extension: "gif")
|
||||
try AVConverter.makeGIF(source: source, at: destination, fps: fps, maxWidth: width, cancellation: cancellation)
|
||||
return destination
|
||||
}
|
||||
|
||||
case .resizeVideo:
|
||||
let width = params.int("width", default: 1280)
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let ext = container(for: source, fallback: "mp4")
|
||||
let destination = OutputNaming.sibling(for: source, suffix: "-resized", extension: ext)
|
||||
try AVConverter.resize(source: source, at: destination, width: width, cancellation: cancellation)
|
||||
return destination
|
||||
}
|
||||
|
||||
case .normalizeAudio:
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let destination = MediaTools.audioSibling(for: source, suffix: "-normalized")
|
||||
try MediaTools.normalizeAudio(source, at: destination)
|
||||
return destination
|
||||
}
|
||||
|
||||
case .channelsAudio:
|
||||
let mono = params.string("mode", default: "Mono") == "Mono"
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let destination = MediaTools.audioSibling(for: source, suffix: mono ? "-mono" : "-stereo")
|
||||
try MediaTools.channels(source, at: destination, mono: mono)
|
||||
return destination
|
||||
}
|
||||
|
||||
case .bleepAudio:
|
||||
let start = Timecode.seconds(from: params.string("start", default: "0")) ?? 0
|
||||
let end = Timecode.seconds(from: params.string("end", default: "1")) ?? 1
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let destination = MediaTools.audioSibling(for: source, suffix: "-bleeped")
|
||||
try MediaTools.bleep(source, at: destination, start: start, end: max(end, start + 0.05))
|
||||
return destination
|
||||
}
|
||||
|
||||
case .compressAudio:
|
||||
let bitrate = params.string("bitrate", default: "128")
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let destination = MediaTools.audioSibling(for: source, suffix: "-compressed")
|
||||
try MediaTools.compressAudio(source, at: destination, bitrate: bitrate)
|
||||
return destination
|
||||
}
|
||||
|
||||
case .mergePDF:
|
||||
let first = inputs[0]
|
||||
let destination = OutputNaming.availableURL(
|
||||
for: first.deletingLastPathComponent()
|
||||
.appendingPathComponent(first.deletingPathExtension().lastPathComponent + " merged.pdf")
|
||||
)
|
||||
try PDFConverter.merge(inputs, at: destination, cancellation: cancellation)
|
||||
progress?(1)
|
||||
return ToolOutcome(outputs: [destination])
|
||||
|
||||
case .splitPDF:
|
||||
let perFile = max(1, params.int("pages", default: 1))
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let directory = OutputNaming.artifactDirectory(for: source, suffix: " split")
|
||||
let written = try PDFConverter.split(source, pagesPerFile: perFile, into: directory, cancellation: cancellation)
|
||||
guard let first = written.first else { throw ConversionError.failed("Nothing to split.") }
|
||||
return first
|
||||
}
|
||||
|
||||
case .rotatePDF:
|
||||
let angle = params.string("angle", default: "90° right")
|
||||
let degrees = angle.hasPrefix("180") ? 180 : (angle.hasPrefix("90° l") ? -90 : 90)
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let destination = OutputNaming.sibling(for: source, suffix: "-rotated", extension: "pdf")
|
||||
try PDFConverter.rotate(source, degrees: degrees, at: destination)
|
||||
return destination
|
||||
}
|
||||
|
||||
case .compressPDF:
|
||||
let quality = Double(params.int("quality", default: 55)) / 100
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let destination = OutputNaming.sibling(for: source, suffix: "-compressed", extension: "pdf")
|
||||
try PDFConverter.compress(source, at: destination, quality: quality, cancellation: cancellation)
|
||||
return destination
|
||||
}
|
||||
|
||||
case .encryptPDF:
|
||||
let password = params.string("password", default: "")
|
||||
guard !password.isEmpty else { throw ConversionError.failed("Enter a password first.") }
|
||||
let owner = params.string("owner", default: "")
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let destination = OutputNaming.sibling(for: source, suffix: "-encrypted", extension: "pdf")
|
||||
try PDFConverter.encrypt(source, at: destination, password: password, ownerPassword: owner)
|
||||
return destination
|
||||
}
|
||||
|
||||
case .decryptPDF:
|
||||
let password = params.string("password", default: "")
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let destination = OutputNaming.sibling(for: source, suffix: "-decrypted", extension: "pdf")
|
||||
guard let document = PDFDocument(url: source),
|
||||
document.unlock(withPassword: password) || !document.isLocked else {
|
||||
throw ConversionError.failed("Wrong password or the PDF is not encrypted.")
|
||||
}
|
||||
guard document.write(to: destination) else {
|
||||
throw ConversionError.failed("Could not write the decrypted PDF.")
|
||||
}
|
||||
return destination
|
||||
}
|
||||
|
||||
case .imagesToPDF:
|
||||
let images: [(CGImage, String?)] = try inputs.map { source in
|
||||
guard let image = Raster.firstImage(at: source) else {
|
||||
throw ConversionError.unreadable(source.lastPathComponent)
|
||||
}
|
||||
return (image, source.lastPathComponent)
|
||||
}
|
||||
let first = inputs[0]
|
||||
let destination = OutputNaming.availableURL(
|
||||
for: first.deletingLastPathComponent()
|
||||
.appendingPathComponent(first.deletingPathExtension().lastPathComponent + " images.pdf")
|
||||
)
|
||||
try PDFConverter.imagesToPDF(images, at: destination, cancellation: cancellation)
|
||||
progress?(1)
|
||||
return ToolOutcome(outputs: [destination])
|
||||
|
||||
case .collageImages:
|
||||
let columns = max(1, params.int("columns", default: 3))
|
||||
let page = params.string("page", default: "A4")
|
||||
let margin = Double(params.int("margin", default: 24))
|
||||
let captions = params.bool("captions", default: false)
|
||||
let entries: [(CGImage, String)] = try inputs.map { source in
|
||||
guard let image = Raster.firstImage(at: source) else {
|
||||
throw ConversionError.unreadable(source.lastPathComponent)
|
||||
}
|
||||
return (image, source.lastPathComponent)
|
||||
}
|
||||
let first = inputs[0]
|
||||
let destination = OutputNaming.availableURL(
|
||||
for: first.deletingLastPathComponent()
|
||||
.appendingPathComponent(first.deletingPathExtension().lastPathComponent + " collage.pdf")
|
||||
)
|
||||
try PDFConverter.collage(
|
||||
entries,
|
||||
at: destination,
|
||||
columns: columns,
|
||||
page: page,
|
||||
margin: margin,
|
||||
captions: captions,
|
||||
cancellation: cancellation
|
||||
)
|
||||
progress?(1)
|
||||
return ToolOutcome(outputs: [destination])
|
||||
|
||||
case .extractPDFText:
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let text = PDFConverter.extractText(source)
|
||||
guard !text.trimmed.isEmpty else {
|
||||
throw ConversionError.failed("No text layer in \(source.lastPathComponent).")
|
||||
}
|
||||
let destination = OutputNaming.sibling(for: source, suffix: "", extension: "txt")
|
||||
try TextDocument.write(text, to: destination)
|
||||
return destination
|
||||
}
|
||||
|
||||
case .documentToPDF:
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let destination = OutputNaming.sibling(for: source, suffix: "", extension: "pdf")
|
||||
try DocumentConverter.convert(source: source, to: .pdf, at: destination, cancellation: cancellation)
|
||||
return destination
|
||||
}
|
||||
|
||||
case .wordCount:
|
||||
var results: [String] = []
|
||||
for source in inputs {
|
||||
try cancellation?.check()
|
||||
let text = TextDocument.plainText(from: source) ?? ""
|
||||
let words = text.split { $0 == " " || $0.isNewline }.count
|
||||
let lines = text.components(separatedBy: "\n").count
|
||||
results.append("""
|
||||
\(source.lastPathComponent)
|
||||
\(words) words · \(text.count) characters · \(lines) lines
|
||||
""")
|
||||
}
|
||||
return ToolOutcome(message: results.joined(separator: "\n\n"), messageTitle: "Statistics")
|
||||
|
||||
case .sheetsToCSV:
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let rows = try SpreadsheetConverter.read(source, cancellation: cancellation)
|
||||
let destination = OutputNaming.sibling(for: source, suffix: "", extension: "csv")
|
||||
try SpreadsheetConverter.write(rows, to: destination, delimiter: ",")
|
||||
return destination
|
||||
}
|
||||
|
||||
case .shiftSubtitle:
|
||||
let offset = params.double("offset", default: 0)
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let format = FileKind.format(of: source) ?? .srt
|
||||
let cues = SubtitleConverter.shift(try SubtitleConverter.parse(source), by: offset)
|
||||
let destination = OutputNaming.sibling(for: source, suffix: "-shifted")
|
||||
try SubtitleConverter.write(cues, to: destination, format: format == .vtt ? .vtt : .srt)
|
||||
return destination
|
||||
}
|
||||
|
||||
case .extractArchive:
|
||||
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
|
||||
let directory = OutputNaming.artifactDirectory(for: source, suffix: " extracted")
|
||||
let result = try ArchiveConverter.extract(source: source, into: directory, cancellation: cancellation, progress: nil)
|
||||
return result
|
||||
}
|
||||
|
||||
case .listArchive:
|
||||
var results: [String] = []
|
||||
for source in inputs {
|
||||
try cancellation?.check()
|
||||
results.append("\(source.lastPathComponent)\n\((try? ArchiveConverter.list(source)) ?? "Unreadable")")
|
||||
}
|
||||
return ToolOutcome(message: results.joined(separator: "\n\n"), messageTitle: "Archive")
|
||||
|
||||
case .mediaInfo:
|
||||
var results: [String] = []
|
||||
for source in inputs {
|
||||
try cancellation?.check()
|
||||
results.append(MediaInfo.describe(source))
|
||||
}
|
||||
return ToolOutcome(message: results.joined(separator: "\n\n"), messageTitle: "Info")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func mapFiles(
|
||||
_ inputs: [URL],
|
||||
cancellation: Cancellation?,
|
||||
progress: ((Double) -> Void)?,
|
||||
_ transform: (URL) throws -> URL
|
||||
) throws -> ToolOutcome {
|
||||
var outputs: [URL] = []
|
||||
var firstError: Error?
|
||||
for (index, source) in inputs.enumerated() {
|
||||
try cancellation?.check()
|
||||
do {
|
||||
let output = try transform(source)
|
||||
outputs.append(output)
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
if firstError == nil { firstError = error }
|
||||
}
|
||||
progress?(Double(index + 1) / Double(inputs.count))
|
||||
}
|
||||
if outputs.isEmpty, let firstError { throw firstError }
|
||||
return ToolOutcome(outputs: outputs)
|
||||
}
|
||||
|
||||
/// Keeps the source container when it is one we can write, otherwise uses
|
||||
/// the fallback (`mp4` for video, `m4a` for audio).
|
||||
private static func container(for source: URL, fallback: String) -> String {
|
||||
let ext = FileKind.canonicalExtension(source.pathExtension)
|
||||
let videoContainers = ["mp4", "mov", "m4v"]
|
||||
let audioContainers = ["m4a", "mp3", "wav", "aiff", "flac", "ogg", "opus", "wma"]
|
||||
if videoContainers.contains(ext) || audioContainers.contains(ext) { return ext }
|
||||
return fallback
|
||||
}
|
||||
|
||||
private static func rateString(_ rate: Double) -> String {
|
||||
rate == rate.rounded() ? String(Int(rate)) : String(format: "%.2f", rate)
|
||||
}
|
||||
|
||||
private static func trim(_ source: URL, at destination: URL, start: Double, end: Double?, cancellation: Cancellation?) throws {
|
||||
let ext = FileKind.canonicalExtension(source.pathExtension)
|
||||
if ["mp4", "mov", "m4v", "m4a"].contains(ext) {
|
||||
try AVConverter.trim(source: source, at: destination, start: start, end: end, cancellation: cancellation)
|
||||
return
|
||||
}
|
||||
if Toolchain.hasFFmpeg, Settings.shared.useExternalTools {
|
||||
var arguments = ["-ss", String(format: "%.3f", start)]
|
||||
if let end, end > start { arguments += ["-t", String(format: "%.3f", end - start)] }
|
||||
arguments += ["-c:a", "copy"]
|
||||
try FFmpeg.filter(source: source, at: destination, arguments: arguments)
|
||||
return
|
||||
}
|
||||
if ["wav", "aiff"].contains(ext) {
|
||||
let staged = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("konverter-trim-\(UUID().uuidString).m4a")
|
||||
defer { try? FileManager.default.removeItem(at: staged) }
|
||||
try AVConverter.trim(source: source, at: staged, start: start, end: end, cancellation: cancellation)
|
||||
try CoreAudio.convert(source: staged, to: FileKind.format(of: destination) ?? .wav, at: destination, cancellation: cancellation)
|
||||
return
|
||||
}
|
||||
throw ConversionError.missingTool("Trimming .\(ext) needs ffmpeg — brew install ffmpeg")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// The Control wheel: things you can do with a file that are not conversions.
|
||||
enum ActionID: String, CaseIterable, Identifiable, Codable {
|
||||
case airdrop, float, copy, move, copyPath, terminal, revealInFinder
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .airdrop: return "AirDrop"
|
||||
case .float: return "Float"
|
||||
case .copy: return "Copy"
|
||||
case .move: return "Move"
|
||||
case .copyPath: return "Copy Path"
|
||||
case .terminal: return "Terminal"
|
||||
case .revealInFinder: return "Reveal"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .airdrop: return "airdrop"
|
||||
case .float: return "macwindow.on.rectangle"
|
||||
case .copy: return "doc.on.doc"
|
||||
case .move: return "arrow.right.doc.on.clipboard"
|
||||
case .copyPath: return "text.cursor"
|
||||
case .terminal: return "terminal"
|
||||
case .revealInFinder: return "folder"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ActionError: LocalizedError {
|
||||
case needsSingleFile
|
||||
case noDestination
|
||||
case unavailable(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .needsSingleFile: return "This action works on one file at a time."
|
||||
case .noDestination: return "No destination folder was chosen."
|
||||
case .unavailable(let reason): return reason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// File actions never touch the file's contents, and never use a conversion
|
||||
/// slot — matching Tangerine's behaviour.
|
||||
enum FileActions {
|
||||
@MainActor
|
||||
static func perform(_ action: ActionID, urls: [URL], settings: Settings = .shared) async throws {
|
||||
guard !urls.isEmpty else { throw ActionError.noDestination }
|
||||
switch action {
|
||||
case .airdrop:
|
||||
try performAirDrop(urls: urls)
|
||||
case .float:
|
||||
FloatPanel.shared.show(urls: urls)
|
||||
case .copy:
|
||||
if let destination = try await chooseDestination() {
|
||||
try transfer(urls: urls, to: destination, moving: false)
|
||||
}
|
||||
case .move:
|
||||
if let destination = try await chooseDestination() {
|
||||
try transfer(urls: urls, to: destination, moving: true)
|
||||
}
|
||||
case .copyPath:
|
||||
copyPaths(urls: urls)
|
||||
case .terminal:
|
||||
try openTerminal(urls: urls)
|
||||
case .revealInFinder:
|
||||
NSWorkspace.shared.activateFileViewerSelecting(urls)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func performAirDrop(urls: [URL]) throws {
|
||||
guard let service = NSSharingService(named: .sendViaAirDrop) else {
|
||||
throw ActionError.unavailable("AirDrop is not available on this Mac.")
|
||||
}
|
||||
guard service.canPerform(withItems: urls) else {
|
||||
throw ActionError.unavailable("AirDrop cannot send this selection.")
|
||||
}
|
||||
service.perform(withItems: urls)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func chooseDestination() async throws -> URL? {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canCreateDirectories = true
|
||||
panel.prompt = "Choose"
|
||||
panel.message = "Choose a destination folder"
|
||||
panel.directoryURL = Settings.shared.resolvedOutputFolder
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
return panel.runModal() == .OK ? panel.url : nil
|
||||
}
|
||||
|
||||
private static func transfer(urls: [URL], to destination: URL, moving: Bool) throws {
|
||||
let manager = FileManager.default
|
||||
for url in urls {
|
||||
var target = destination.appendingPathComponent(url.lastPathComponent)
|
||||
target = OutputNaming.availableURL(for: target)
|
||||
do {
|
||||
if moving {
|
||||
try manager.moveItem(at: url, to: target)
|
||||
} else {
|
||||
try manager.copyItem(at: url, to: target)
|
||||
}
|
||||
} catch {
|
||||
throw ActionError.unavailable("Could not \(moving ? "move" : "copy") \(url.lastPathComponent): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func copyPaths(urls: [URL]) {
|
||||
let text = urls.map(\.path).joined(separator: "\n")
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(text, forType: .string)
|
||||
Notifier.flash(
|
||||
title: urls.count == 1 ? "Path copied" : "\(urls.count) paths copied",
|
||||
body: text.count > 120 ? String(text.prefix(120)) + "…" : text
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func openTerminal(urls: [URL]) throws {
|
||||
let directory = urls[0].deletingLastPathComponent()
|
||||
let terminal = URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app")
|
||||
let configuration = NSWorkspace.OpenConfiguration()
|
||||
NSWorkspace.shared.open([directory], withApplicationAt: terminal, configuration: configuration) { _, error in
|
||||
if let error {
|
||||
DispatchQueue.main.async {
|
||||
Notifier.error("Terminal", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import Foundation
|
||||
|
||||
/// The broad family a file belongs to. Drives which wheel is shown and which
|
||||
/// converter runs.
|
||||
enum FileCategory: String, CaseIterable, Identifiable, Codable {
|
||||
case image, video, audio, pdf, document, slides, spreadsheet, ebook, subtitle, archive, folder, unknown
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .image: return "Images"
|
||||
case .video: return "Video"
|
||||
case .audio: return "Audio"
|
||||
case .pdf: return "PDF"
|
||||
case .document: return "Documents"
|
||||
case .slides: return "Slides"
|
||||
case .spreadsheet: return "Spreadsheets"
|
||||
case .ebook: return "EPUB"
|
||||
case .subtitle: return "Subtitles"
|
||||
case .archive: return "Archives"
|
||||
case .folder: return "Folders"
|
||||
case .unknown: return "Files"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .image: return "photo"
|
||||
case .video: return "film"
|
||||
case .audio: return "waveform"
|
||||
case .pdf: return "doc.richtext"
|
||||
case .document: return "doc.text"
|
||||
case .slides: return "rectangle.on.rectangle.angled"
|
||||
case .spreadsheet: return "tablecells"
|
||||
case .ebook: return "book"
|
||||
case .subtitle: return "captions.bubble"
|
||||
case .archive: return "doc.zipper"
|
||||
case .folder: return "folder"
|
||||
case .unknown: return "doc"
|
||||
}
|
||||
}
|
||||
|
||||
/// Wheels only make sense for these.
|
||||
static var convertible: [FileCategory] {
|
||||
[.image, .video, .audio, .pdf, .document, .slides, .spreadsheet, .ebook, .subtitle, .archive]
|
||||
}
|
||||
}
|
||||
|
||||
/// A canonical output extension.
|
||||
enum Format: String, CaseIterable, Identifiable, Codable, Hashable {
|
||||
// Images
|
||||
case jpg, png, heic, webp, tiff, bmp, gif, avif, svg, ico
|
||||
// Documents
|
||||
case pdf
|
||||
case docx, doc, pages, rtf, txt, md, html, odt, epub
|
||||
// Slides
|
||||
case pptx, ppt, key
|
||||
// Spreadsheets
|
||||
case xlsx, xls, ods, csv, tsv, numbers
|
||||
// Audio
|
||||
case mp3, m4a, wav, flac, ogg, opus, aiff, wma
|
||||
// Video
|
||||
case mp4, mov, mkv, webm, avi, wmv, m4v
|
||||
// Subtitles
|
||||
case srt, vtt
|
||||
// Archives
|
||||
case zip, tar, gzip
|
||||
|
||||
var id: String { rawValue }
|
||||
var ext: String { rawValue }
|
||||
var displayName: String { rawValue.uppercased() }
|
||||
|
||||
/// Which wheel this format belongs to when offered as an output.
|
||||
var category: FileCategory {
|
||||
switch self {
|
||||
case .jpg, .png, .heic, .webp, .tiff, .bmp, .gif, .avif, .svg, .ico: return .image
|
||||
case .pdf: return .pdf
|
||||
case .docx, .doc, .pages, .rtf, .txt, .md, .html, .odt: return .document
|
||||
case .epub: return .ebook
|
||||
case .pptx, .ppt, .key: return .slides
|
||||
case .xlsx, .xls, .ods, .csv, .tsv, .numbers: return .spreadsheet
|
||||
case .mp3, .m4a, .wav, .flac, .ogg, .opus, .aiff, .wma: return .audio
|
||||
case .mp4, .mov, .mkv, .webm, .avi, .wmv, .m4v: return .video
|
||||
case .srt, .vtt: return .subtitle
|
||||
case .zip, .tar, .gzip: return .archive
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .jpg, .png, .heic, .webp, .tiff, .bmp, .gif, .avif, .ico: return "photo"
|
||||
case .svg: return "scribble.variable"
|
||||
case .pdf: return "doc.richtext"
|
||||
case .md: return "text.alignleft"
|
||||
case .txt: return "doc.plaintext"
|
||||
case .html: return "chevron.left.forwardslash.chevron.right"
|
||||
case .docx, .doc: return "doc.text"
|
||||
case .rtf: return "doc.richtext"
|
||||
case .odt: return "doc.text"
|
||||
case .pages: return "doc.text"
|
||||
case .epub: return "book"
|
||||
case .pptx, .ppt: return "rectangle.on.rectangle.angled"
|
||||
case .key: return "rectangle.on.rectangle.angled"
|
||||
case .xlsx, .xls: return "tablecells"
|
||||
case .ods: return "tablecells"
|
||||
case .csv, .tsv: return "tablecells.badge.ellipsis"
|
||||
case .numbers: return "tablecells"
|
||||
case .mp3, .m4a: return "waveform"
|
||||
case .wav, .flac, .aiff: return "waveform"
|
||||
case .ogg, .opus, .wma: return "waveform"
|
||||
case .mp4, .mov, .mkv, .webm, .avi, .wmv, .m4v: return "film"
|
||||
case .srt, .vtt: return "captions.bubble"
|
||||
case .zip, .tar, .gzip: return "doc.zipper"
|
||||
}
|
||||
}
|
||||
|
||||
/// Uniform type identifier, also used for ImageIO destinations.
|
||||
var uti: String? {
|
||||
switch self {
|
||||
case .jpg: return "public.jpeg"
|
||||
case .png: return "public.png"
|
||||
case .heic: return "public.heic"
|
||||
case .webp: return "org.webmproject.webp"
|
||||
case .tiff: return "public.tiff"
|
||||
case .bmp: return "com.microsoft.bmp"
|
||||
case .gif: return "com.compuserve.gif"
|
||||
case .avif: return "public.avif"
|
||||
case .svg: return "public.svg-image"
|
||||
case .ico: return "com.microsoft.ico"
|
||||
case .pdf: return "com.adobe.pdf"
|
||||
case .txt: return "public.plain-text"
|
||||
case .html: return "public.html"
|
||||
case .rtf: return "public.rtf"
|
||||
case .docx: return "org.openxmlformats.wordprocessingml.document"
|
||||
case .doc: return "com.microsoft.word.doc"
|
||||
case .odt: return "org.oasis-open.opendocument.text"
|
||||
case .epub: return "org.idpf.epub-container"
|
||||
case .pptx: return "org.openxmlformats.presentationml.presentation"
|
||||
case .xlsx: return "org.openxmlformats.spreadsheetml.sheet"
|
||||
case .csv: return "public.comma-separated-values-text"
|
||||
case .tsv: return "public.tab-separated-values-text"
|
||||
case .mp3: return "public.mp3"
|
||||
case .m4a: return "public.mpeg-4-audio"
|
||||
case .wav: return "com.microsoft.waveform-audio"
|
||||
case .aiff: return "public.aiff-audio"
|
||||
case .flac: return "org.xiph.flac"
|
||||
case .ogg: return "org.xiph.ogg"
|
||||
case .opus: return "org.xiph.opus"
|
||||
case .mp4: return "public.mpeg-4"
|
||||
case .mov: return "com.apple.quicktime-movie"
|
||||
case .mkv: return "org.matroska.mkv"
|
||||
case .srt: return "public.srt-subtitle"
|
||||
case .vtt: return "public.vtt-subtitle"
|
||||
case .zip: return "public.zip-archive"
|
||||
case .tar: return "public.tar-archive"
|
||||
case .gzip: return "org.gnu.gnu-zip-archive"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats Apple's frameworks cannot *write*; ffmpeg is required.
|
||||
var requiresFFmpeg: Bool {
|
||||
switch self {
|
||||
case .mkv, .webm, .avi, .wmv, .mp3, .flac, .ogg, .opus, .wma: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats that need LibreOffice to write with real layout fidelity.
|
||||
var requiresLibreOffice: Bool {
|
||||
switch self {
|
||||
case .pages, .key, .numbers, .ppt, .pptx, .xls: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Lossy formats take a quality knob.
|
||||
var isLossy: Bool {
|
||||
switch self {
|
||||
case .jpg, .heic, .avif, .webp, .mp3, .m4a, .ogg, .opus, .wma, .mp4, .mkv, .webm, .avi, .wmv, .m4v:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func from(extension ext: String) -> Format? {
|
||||
Format(rawValue: FileKind.canonicalExtension(ext))
|
||||
}
|
||||
}
|
||||
|
||||
/// Input side: figure out what a dropped file is.
|
||||
enum FileKind {
|
||||
private static let extensionMap: [String: FileCategory] = [
|
||||
// Images
|
||||
"jpg": .image, "jpeg": .image, "png": .image, "heic": .image, "heif": .image,
|
||||
"webp": .image, "tif": .image, "tiff": .image, "bmp": .image, "gif": .image,
|
||||
"avif": .image, "svg": .image, "ico": .image, "jp2": .image, "psd": .image,
|
||||
"exr": .image, "tga": .image, "raw": .image, "dng": .image, "cr2": .image,
|
||||
// Video
|
||||
"mp4": .video, "mov": .video, "m4v": .video, "mkv": .video, "webm": .video,
|
||||
"avi": .video, "wmv": .video, "flv": .video, "mpg": .video, "mpeg": .video,
|
||||
"3gp": .video, "ts": .video, "mts": .video,
|
||||
// Audio
|
||||
"mp3": .audio, "m4a": .audio, "m4b": .audio, "m4r": .audio, "wav": .audio,
|
||||
"flac": .audio, "ogg": .audio, "oga": .audio, "opus": .audio, "aiff": .audio,
|
||||
"aif": .audio, "aifc": .audio, "wma": .audio, "caf": .audio, "aac": .audio,
|
||||
"ape": .audio,
|
||||
// PDF
|
||||
"pdf": .pdf,
|
||||
// Documents
|
||||
"docx": .document, "doc": .document, "rtf": .document, "rtfd": .document,
|
||||
"odt": .document, "txt": .document, "text": .document, "md": .document,
|
||||
"markdown": .document, "html": .document, "htm": .document, "xml": .document,
|
||||
"webarchive": .document, "wordml": .document, "tex": .document, "log": .document,
|
||||
"pages": .document,
|
||||
// Slides
|
||||
"ppt": .slides, "pptx": .slides, "key": .slides, "odp": .slides,
|
||||
// Spreadsheets
|
||||
"xlsx": .spreadsheet, "xls": .spreadsheet, "ods": .spreadsheet, "csv": .spreadsheet,
|
||||
"tsv": .spreadsheet, "numbers": .spreadsheet,
|
||||
// Ebook
|
||||
"epub": .ebook, "mobi": .ebook, "azw3": .ebook,
|
||||
// Subtitles
|
||||
"srt": .subtitle, "vtt": .subtitle, "ass": .subtitle, "ssa": .subtitle, "sub": .subtitle,
|
||||
// Archives
|
||||
"zip": .archive, "tar": .archive, "gz": .archive, "tgz": .archive, "bz2": .archive,
|
||||
"xz": .archive, "rar": .archive, "7z": .archive, "zst": .archive
|
||||
]
|
||||
|
||||
private static let canonicalMap: [String: String] = [
|
||||
"jpeg": "jpg", "tif": "tiff", "markdown": "md", "htm": "html", "text": "txt",
|
||||
"tgz": "tar", "aif": "aiff", "aifc": "aiff", "oga": "ogg", "m4b": "m4a",
|
||||
"heif": "heic", "wordml": "odt"
|
||||
]
|
||||
|
||||
static func canonicalExtension(_ ext: String) -> String {
|
||||
let lower = ext.lowercased()
|
||||
return canonicalMap[lower] ?? lower
|
||||
}
|
||||
|
||||
static func category(of url: URL) -> FileCategory {
|
||||
if (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true {
|
||||
return .folder
|
||||
}
|
||||
return category(forExtension: url.pathExtension)
|
||||
}
|
||||
|
||||
static func category(forExtension ext: String) -> FileCategory {
|
||||
extensionMap[ext.lowercased()] ?? .unknown
|
||||
}
|
||||
|
||||
static func format(of url: URL) -> Format? {
|
||||
Format(rawValue: canonicalExtension(url.pathExtension))
|
||||
}
|
||||
|
||||
static func isSupportedInput(_ url: URL) -> Bool {
|
||||
category(of: url) != .unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// What a wheel can offer for a given selection. Kept separate from the UI so
|
||||
/// the same table drives the drag wheel, the menu bar fallback and the
|
||||
/// Quick Action.
|
||||
enum ConversionCatalog {
|
||||
static let targets: [FileCategory: [Format]] = [
|
||||
.image: [.png, .jpg, .heic, .webp, .tiff, .bmp, .gif, .avif, .pdf],
|
||||
.video: [.mp4, .mov, .mkv, .webm, .avi, .wmv, .gif, .mp3, .m4a, .wav],
|
||||
.audio: [.mp3, .m4a, .wav, .flac, .ogg, .opus, .aiff, .wma],
|
||||
.pdf: [.docx, .pptx, .xlsx, .jpg, .png, .epub, .txt, .md, .html],
|
||||
.document: [.docx, .md, .txt, .rtf, .html, .epub, .pdf],
|
||||
.slides: [.pdf, .jpg, .png],
|
||||
.spreadsheet: [.xlsx, .ods, .csv, .tsv, .pdf],
|
||||
.ebook: [.txt, .html, .md, .pdf],
|
||||
.subtitle: [.srt, .vtt, .txt],
|
||||
.archive: [.zip, .tar, .gzip]
|
||||
]
|
||||
|
||||
/// Outputs to show for a selection: the category's list minus the format
|
||||
/// the file already is. A mixed selection falls back to the intersection.
|
||||
static func outputs(for categories: [FileCategory]) -> [Format] {
|
||||
let unique = Set(categories)
|
||||
guard !unique.isEmpty, unique.count == 1, let category = unique.first else {
|
||||
return []
|
||||
}
|
||||
return targets[category] ?? []
|
||||
}
|
||||
|
||||
/// True when every input already has this extension (so it is pointless to
|
||||
/// offer it).
|
||||
static func isRedundant(_ format: Format, urls: [URL]) -> Bool {
|
||||
guard !urls.isEmpty else { return false }
|
||||
return urls.allSatisfy { FileKind.canonicalExtension($0.pathExtension) == format.ext }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
enum JobTarget {
|
||||
case format(Format)
|
||||
case tool(ToolID)
|
||||
case action(ActionID)
|
||||
}
|
||||
|
||||
enum JobStatus {
|
||||
case running
|
||||
case finished
|
||||
case failed
|
||||
case cancelled
|
||||
|
||||
var isTerminal: Bool { self != .running }
|
||||
}
|
||||
|
||||
struct Job: Identifiable {
|
||||
let id = UUID()
|
||||
var title: String
|
||||
var detail: String = ""
|
||||
var progress: Double = 0
|
||||
var status: JobStatus = .running
|
||||
var outputs: [URL] = []
|
||||
var errorMessage: String?
|
||||
var message: String?
|
||||
var messageTitle: String?
|
||||
var startedAt = Date()
|
||||
var origin: CGPoint?
|
||||
var target: JobTarget
|
||||
var sourceCount: Int
|
||||
}
|
||||
|
||||
/// Runs conversions off the main thread and publishes their state to the
|
||||
/// progress panel. Two jobs run at once; the queue keeps heavy video work from
|
||||
/// starving the UI.
|
||||
final class JobStore: ObservableObject {
|
||||
static let shared = JobStore()
|
||||
|
||||
@Published private(set) var jobs: [Job] = []
|
||||
@Published var panelVisible = false
|
||||
|
||||
private var tokens: [UUID: Cancellation] = [:]
|
||||
private let queue: OperationQueue = {
|
||||
let queue = OperationQueue()
|
||||
queue.name = "dev.tzgyn.konverter.jobs"
|
||||
queue.maxConcurrentOperationCount = 2
|
||||
queue.qualityOfService = .userInitiated
|
||||
return queue
|
||||
}()
|
||||
|
||||
private init() {}
|
||||
|
||||
var activeCount: Int {
|
||||
jobs.filter { !$0.status.isTerminal }.count
|
||||
}
|
||||
|
||||
func submit(urls: [URL], target: JobTarget, params: ToolParams? = nil, origin: CGPoint? = nil) {
|
||||
guard !urls.isEmpty else { return }
|
||||
let token = Cancellation()
|
||||
let job = Job(
|
||||
title: title(for: target, urls: urls),
|
||||
origin: origin,
|
||||
target: target,
|
||||
sourceCount: urls.count
|
||||
)
|
||||
jobs.append(job)
|
||||
tokens[job.id] = token
|
||||
|
||||
if Settings.shared.showProgressWindow {
|
||||
panelVisible = true
|
||||
JobPanelController.shared.refresh(store: self)
|
||||
}
|
||||
|
||||
queue.addOperation { [weak self] in
|
||||
self?.execute(jobID: job.id, urls: urls, target: target, params: params, token: token)
|
||||
}
|
||||
}
|
||||
|
||||
func cancel(_ id: UUID) {
|
||||
tokens[id]?.cancel()
|
||||
mutate(id) { job in
|
||||
if job.status == .running { job.detail = "Cancelling…" }
|
||||
}
|
||||
}
|
||||
|
||||
func cancelAll() {
|
||||
for (id, token) in tokens {
|
||||
token.cancel()
|
||||
mutate(id) { job in
|
||||
if job.status == .running { job.detail = "Cancelling…" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dismissFinished() {
|
||||
jobs.removeAll { $0.status.isTerminal }
|
||||
if jobs.isEmpty { panelVisible = false }
|
||||
JobPanelController.shared.refresh(store: self)
|
||||
}
|
||||
|
||||
func remove(_ id: UUID) {
|
||||
jobs.removeAll { $0.id == id }
|
||||
if jobs.isEmpty { panelVisible = false }
|
||||
JobPanelController.shared.refresh(store: self)
|
||||
}
|
||||
|
||||
// MARK: - Execution
|
||||
|
||||
private func execute(jobID: UUID, urls: [URL], target: JobTarget, params: ToolParams?, token: Cancellation) {
|
||||
do {
|
||||
switch target {
|
||||
case .action(let action):
|
||||
try performAction(action, urls: urls, token: token, jobID: jobID)
|
||||
|
||||
case .format(let format):
|
||||
var outputs: [URL] = []
|
||||
var firstError: Error?
|
||||
for (index, url) in urls.enumerated() {
|
||||
try token.check()
|
||||
let destination = OutputNaming.destination(for: url, format: format)
|
||||
mutate(jobID) { job in
|
||||
job.detail = urls.count > 1 ? "\(url.lastPathComponent) → \(format.displayName)" : "Converting to \(format.displayName)"
|
||||
}
|
||||
do {
|
||||
let produced = try Converter.convert(
|
||||
source: url,
|
||||
to: format,
|
||||
at: destination,
|
||||
cancellation: token,
|
||||
progress: { [weak self] fraction in
|
||||
self?.setProgress(jobID, (Double(index) + fraction) / Double(urls.count))
|
||||
}
|
||||
)
|
||||
outputs.append(contentsOf: produced)
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
if firstError == nil { firstError = error }
|
||||
}
|
||||
}
|
||||
if outputs.isEmpty, let firstError { throw firstError }
|
||||
if let firstError {
|
||||
finish(jobID, outputs: outputs, error: firstError)
|
||||
} else {
|
||||
finish(jobID, outputs: outputs, error: nil)
|
||||
}
|
||||
|
||||
case .tool(let tool):
|
||||
let resolved = params ?? Settings.shared.params(for: tool)
|
||||
let outcome = try ToolRunner.run(
|
||||
tool: tool,
|
||||
inputs: urls,
|
||||
params: resolved,
|
||||
cancellation: token,
|
||||
progress: { [weak self] fraction in
|
||||
self?.setProgress(jobID, fraction)
|
||||
}
|
||||
)
|
||||
finish(jobID, outputs: outcome.outputs, error: nil, outcome: outcome)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
mutate(jobID) { job in
|
||||
job.status = .cancelled
|
||||
job.detail = "Cancelled"
|
||||
}
|
||||
tokens.removeValue(forKey: jobID)
|
||||
DispatchQueue.main.async { JobPanelController.shared.refresh(store: self) }
|
||||
} catch {
|
||||
finish(jobID, outputs: [], error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func performAction(_ action: ActionID, urls: [URL], token: Cancellation, jobID: UUID) throws {
|
||||
var failure: Error?
|
||||
let ready = DispatchSemaphore(value: 0)
|
||||
Task { @MainActor in
|
||||
do {
|
||||
try await FileActions.perform(action, urls: urls)
|
||||
} catch {
|
||||
failure = error
|
||||
}
|
||||
ready.signal()
|
||||
}
|
||||
ready.wait()
|
||||
try token.check()
|
||||
if let failure { throw failure }
|
||||
mutate(jobID) { job in
|
||||
job.detail = "Done"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - State updates
|
||||
|
||||
private func title(for target: JobTarget, urls: [URL]) -> String {
|
||||
let name = urls.count == 1
|
||||
? urls[0].lastPathComponent
|
||||
: "\(urls.count) files"
|
||||
switch target {
|
||||
case .format(let format): return "\(name) → \(format.displayName)"
|
||||
case .tool(let tool): return "\(tool.title) · \(name)"
|
||||
case .action(let action): return "\(action.title) · \(name)"
|
||||
}
|
||||
}
|
||||
|
||||
private func setProgress(_ id: UUID, _ value: Double) {
|
||||
mutate(id, throttle: true) { job in
|
||||
job.progress = max(job.progress, min(1, value))
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(_ id: UUID, outputs: [URL], error: Error?, outcome: ToolOutcome? = nil) {
|
||||
tokens.removeValue(forKey: id)
|
||||
mutate(id) { job in
|
||||
job.outputs = outputs
|
||||
job.progress = 1
|
||||
if let error {
|
||||
job.status = .failed
|
||||
job.errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
||||
job.detail = "Failed"
|
||||
} else {
|
||||
job.status = .finished
|
||||
job.detail = outputs.count > 1 ? "\(outputs.count) files" : "Done"
|
||||
job.message = outcome?.message
|
||||
job.messageTitle = outcome?.messageTitle
|
||||
}
|
||||
}
|
||||
|
||||
guard let job = jobs.first(where: { $0.id == id }) else { return }
|
||||
|
||||
if let error = job.errorMessage {
|
||||
let title = job.title
|
||||
DispatchQueue.main.async {
|
||||
Notifier.error(title, error)
|
||||
}
|
||||
} else if let message = job.message {
|
||||
let title = job.messageTitle ?? "Result"
|
||||
DispatchQueue.main.async {
|
||||
ResultWindowController.shared.show(title: title, body: message)
|
||||
}
|
||||
}
|
||||
|
||||
if Settings.shared.playSound, job.status == .finished, error == nil {
|
||||
DispatchQueue.main.async {
|
||||
NSSound(named: "Glass")?.play()
|
||||
}
|
||||
}
|
||||
|
||||
let revealable = job.outputs
|
||||
if Settings.shared.revealWhenFinished, !revealable.isEmpty, error == nil {
|
||||
DispatchQueue.main.async {
|
||||
NSWorkspace.shared.activateFileViewerSelecting(revealable)
|
||||
// The progress panel would otherwise stay on top of Finder.
|
||||
JobPanelController.shared.dismissIfAllFinished(store: self)
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
JobPanelController.shared.refresh(store: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a mutation on the main thread; `throttle` skips updates that
|
||||
/// would only nudge the progress bar by a hair.
|
||||
private func mutate(_ id: UUID, throttle: Bool = false, _ body: @escaping (inout Job) -> Void) {
|
||||
let apply = { [weak self] in
|
||||
guard let self, let index = self.jobs.firstIndex(where: { $0.id == id }) else { return }
|
||||
if throttle {
|
||||
let before = self.jobs[index].progress
|
||||
body(&self.jobs[index])
|
||||
let after = self.jobs[index].progress
|
||||
if abs(after - before) < 0.01, after < 1 { return }
|
||||
} else {
|
||||
body(&self.jobs[index])
|
||||
}
|
||||
JobPanelController.shared.refresh(store: self)
|
||||
}
|
||||
if Thread.isMainThread {
|
||||
apply()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: apply)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
|
||||
/// Where a produced file goes, and what it is called.
|
||||
enum OutputNaming {
|
||||
/// ``name.pdf`` for ``photo.jpg`` — appends ``-2``, ``-3`` … when the
|
||||
/// target already exists (unless the user asked to overwrite).
|
||||
static func destination(for source: URL, format: Format, settings: Settings = .shared) -> URL {
|
||||
let base = source.deletingPathExtension().lastPathComponent
|
||||
let directory: URL
|
||||
switch settings.outputLocation {
|
||||
case .besideOriginal:
|
||||
directory = source.deletingLastPathComponent()
|
||||
case .chosenFolder:
|
||||
let folder = settings.resolvedOutputFolder
|
||||
directory = FileManager.default.fileExists(atPath: folder.path)
|
||||
? folder
|
||||
: source.deletingLastPathComponent()
|
||||
}
|
||||
let candidate = directory.appendingPathComponent(base).appendingPathExtension(format.ext)
|
||||
return settings.conflictPolicy == .overwrite ? candidate : availableURL(for: candidate)
|
||||
}
|
||||
|
||||
/// A sibling of any output a tool produces (e.g. ``photo-compressed.jpg``).
|
||||
static func sibling(for source: URL, suffix: String, extension ext: String? = nil) -> URL {
|
||||
let base = source.deletingPathExtension().lastPathComponent
|
||||
let directory = source.deletingLastPathComponent()
|
||||
let resolvedExtension = ext ?? source.pathExtension
|
||||
return availableURL(
|
||||
for: directory.appendingPathComponent(base + suffix).appendingPathExtension(resolvedExtension)
|
||||
)
|
||||
}
|
||||
|
||||
/// A working directory for multi-file outputs (split pages, extracted
|
||||
/// images, unarchived trees).
|
||||
static func artifactDirectory(for source: URL, suffix: String) -> URL {
|
||||
let base = source.deletingPathExtension().lastPathComponent
|
||||
let directory = source.deletingLastPathComponent()
|
||||
let url = directory.appendingPathComponent(base + suffix, isDirectory: true)
|
||||
var candidate = url
|
||||
var index = 2
|
||||
while FileManager.default.fileExists(atPath: candidate.path) {
|
||||
candidate = directory.appendingPathComponent("\(base + suffix) \(index)", isDirectory: true)
|
||||
index += 1
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
/// ``foo 2.txt``, ``foo 3.txt`` … until the name is free.
|
||||
static func availableURL(for url: URL) -> URL {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return url }
|
||||
let directory = url.deletingLastPathComponent()
|
||||
let ext = url.pathExtension
|
||||
let base = url.deletingPathExtension().lastPathComponent
|
||||
var index = 2
|
||||
while true {
|
||||
let name = ext.isEmpty ? "\(base) \(index)" : "\(base) \(index).\(ext)"
|
||||
let candidate = directory.appendingPathComponent(name)
|
||||
if !FileManager.default.fileExists(atPath: candidate.path) { return candidate }
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
static func uniqueChild(of directory: URL, suggestedName: String) -> URL {
|
||||
availableURL(for: directory.appendingPathComponent(suggestedName))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import ServiceManagement
|
||||
|
||||
/// Trigger keys. Tangerine's split: Shift converts, Command fills the wheel
|
||||
/// with file tools, Control with file actions.
|
||||
enum TriggerKey: String, CaseIterable, Identifiable, Codable {
|
||||
case shift, control, option, command, function
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .shift: return "Shift ⇧"
|
||||
case .control: return "Control ⌃"
|
||||
case .option: return "Option ⌥"
|
||||
case .command: return "Command ⌘"
|
||||
case .function: return "Function fn"
|
||||
}
|
||||
}
|
||||
|
||||
var flag: NSEvent.ModifierFlags {
|
||||
switch self {
|
||||
case .shift: return .shift
|
||||
case .control: return .control
|
||||
case .option: return .option
|
||||
case .command: return .command
|
||||
case .function: return .function
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum OutputLocation: String, CaseIterable, Identifiable, Codable {
|
||||
case besideOriginal, chosenFolder
|
||||
|
||||
var id: String { rawValue }
|
||||
var title: String {
|
||||
switch self {
|
||||
case .besideOriginal: return "Beside the original"
|
||||
case .chosenFolder: return "In a folder I choose"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ConflictPolicy: String, CaseIterable, Identifiable, Codable {
|
||||
case uniqueName, overwrite
|
||||
|
||||
var id: String { rawValue }
|
||||
var title: String {
|
||||
switch self {
|
||||
case .uniqueName: return "Keep both (add a number)"
|
||||
case .overwrite: return "Replace the existing file"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum WheelStyle: String, CaseIterable, Identifiable, Codable {
|
||||
case radial, list
|
||||
|
||||
var id: String { rawValue }
|
||||
var title: String {
|
||||
switch self {
|
||||
case .radial: return "Wheel"
|
||||
case .list: return "List"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All preferences. Stored in `UserDefaults` under `konverter.*` so the app
|
||||
/// stays a single self-contained bundle with no sandbox container.
|
||||
final class Settings: ObservableObject {
|
||||
static let shared = Settings()
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
|
||||
@Published var convertKey: TriggerKey { didSet { write(convertKey, .convertKey) } }
|
||||
@Published var toolsKey: TriggerKey { didSet { write(toolsKey, .toolsKey) } }
|
||||
@Published var actionsKey: TriggerKey { didSet { write(actionsKey, .actionsKey) } }
|
||||
|
||||
/// When on, the tools wheel needs Shift + toolsKey so a plain ⌘-drag in
|
||||
/// other apps does not pop the wheel.
|
||||
@Published var requireShiftForTools: Bool { didSet { write(requireShiftForTools, .requireShiftForTools) } }
|
||||
|
||||
@Published var enabled: Bool { didSet { write(enabled, .enabled) } }
|
||||
@Published var allowActionsOnFolders: Bool { didSet { write(allowActionsOnFolders, .allowActionsOnFolders) } }
|
||||
|
||||
@Published var outputLocation: OutputLocation { didSet { write(outputLocation.rawValue, .outputLocation) } }
|
||||
@Published var outputFolderPath: String { didSet { write(outputFolderPath, .outputFolderPath) } }
|
||||
@Published var conflictPolicy: ConflictPolicy { didSet { write(conflictPolicy.rawValue, .conflictPolicy) } }
|
||||
|
||||
@Published var wheelStyle: WheelStyle { didSet { write(wheelStyle.rawValue, .wheelStyle) } }
|
||||
@Published var wheelRadius: Double { didSet { write(wheelRadius, .wheelRadius) } }
|
||||
@Published var showLabels: Bool { didSet { write(showLabels, .showLabels) } }
|
||||
@Published var dimBackground: Bool { didSet { write(dimBackground, .dimBackground) } }
|
||||
|
||||
@Published var showProgressWindow: Bool { didSet { write(showProgressWindow, .showProgressWindow) } }
|
||||
@Published var revealWhenFinished: Bool { didSet { write(revealWhenFinished, .revealWhenFinished) } }
|
||||
@Published var playSound: Bool { didSet { write(playSound, .playSound) } }
|
||||
@Published var useExternalTools: Bool { didSet { write(useExternalTools, .useExternalTools) } }
|
||||
|
||||
@Published var openAtLogin: Bool { didSet { write(openAtLogin, .openAtLogin) } }
|
||||
|
||||
/// Remembers per-tool form values so repeat jobs are still one drop.
|
||||
private var toolParamsCache: [String: [String: String]] = [:]
|
||||
|
||||
private init() {
|
||||
convertKey = Settings.read(.convertKey, default: .shift)
|
||||
toolsKey = Settings.read(.toolsKey, default: .command)
|
||||
actionsKey = Settings.read(.actionsKey, default: .control)
|
||||
requireShiftForTools = Settings.readBool(.requireShiftForTools, default: true)
|
||||
enabled = Settings.readBool(.enabled, default: true)
|
||||
allowActionsOnFolders = Settings.readBool(.allowActionsOnFolders, default: true)
|
||||
outputLocation = Settings.read(.outputLocation, default: .besideOriginal)
|
||||
outputFolderPath = Settings.readString(.outputFolderPath, default: "")
|
||||
conflictPolicy = Settings.read(.conflictPolicy, default: .uniqueName)
|
||||
wheelStyle = Settings.read(.wheelStyle, default: .radial)
|
||||
wheelRadius = Settings.readDouble(.wheelRadius, default: 168)
|
||||
showLabels = Settings.readBool(.showLabels, default: true)
|
||||
dimBackground = Settings.readBool(.dimBackground, default: false)
|
||||
showProgressWindow = Settings.readBool(.showProgressWindow, default: true)
|
||||
revealWhenFinished = Settings.readBool(.revealWhenFinished, default: true)
|
||||
playSound = Settings.readBool(.playSound, default: true)
|
||||
useExternalTools = Settings.readBool(.useExternalTools, default: true)
|
||||
openAtLogin = LoginItem.isEnabled
|
||||
}
|
||||
|
||||
var resolvedOutputFolder: URL {
|
||||
if outputLocation == .chosenFolder, !outputFolderPath.isEmpty {
|
||||
return URL(fileURLWithPath: outputFolderPath)
|
||||
}
|
||||
return FileManager.default.homeDirectoryForCurrentUser
|
||||
}
|
||||
|
||||
// MARK: - Tool parameter memory
|
||||
|
||||
func params(for tool: ToolID) -> ToolParams {
|
||||
var params = ToolParams.defaults(for: tool)
|
||||
if let stored = toolParamsCache[tool.rawValue] {
|
||||
for (key, value) in stored { params[key] = value }
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func store(params: ToolParams, for tool: ToolID) {
|
||||
toolParamsCache[tool.rawValue] = params.dictionary
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private enum Key: String {
|
||||
case convertKey, toolsKey, actionsKey, requireShiftForTools, enabled, allowActionsOnFolders
|
||||
case outputLocation, outputFolderPath, conflictPolicy
|
||||
case wheelStyle, wheelRadius, showLabels, dimBackground
|
||||
case showProgressWindow, revealWhenFinished, playSound, useExternalTools, openAtLogin
|
||||
}
|
||||
|
||||
private func write<T>(_ value: T, _ key: Key) {
|
||||
defaults.set(value, forKey: "konverter." + key.rawValue)
|
||||
}
|
||||
|
||||
private static func read<T: RawRepresentable>(_ key: Key, default fallback: T) -> T where T.RawValue == String {
|
||||
guard let raw = UserDefaults.standard.string(forKey: "konverter." + key.rawValue),
|
||||
let value = T(rawValue: raw) else { return fallback }
|
||||
return value
|
||||
}
|
||||
|
||||
private static func readString(_ key: Key, default fallback: String) -> String {
|
||||
UserDefaults.standard.string(forKey: "konverter." + key.rawValue) ?? fallback
|
||||
}
|
||||
|
||||
private static func readBool(_ key: Key, default fallback: Bool) -> Bool {
|
||||
guard UserDefaults.standard.object(forKey: "konverter." + key.rawValue) != nil else { return fallback }
|
||||
return UserDefaults.standard.bool(forKey: "konverter." + key.rawValue)
|
||||
}
|
||||
|
||||
private static func readDouble(_ key: Key, default fallback: Double) -> Double {
|
||||
guard UserDefaults.standard.object(forKey: "konverter." + key.rawValue) != nil else { return fallback }
|
||||
return UserDefaults.standard.double(forKey: "konverter." + key.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
enum LoginItem {
|
||||
static var isEnabled: Bool {
|
||||
SMAppService.mainApp.status == .enabled
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func set(_ enabled: Bool) -> Bool {
|
||||
let service = SMAppService.mainApp
|
||||
do {
|
||||
if enabled {
|
||||
if service.status != .enabled { try service.register() }
|
||||
} else {
|
||||
if service.status == .enabled { try service.unregister() }
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import Foundation
|
||||
|
||||
/// A cooperative cancellation token shared between a job and the external
|
||||
/// processes it spawns. Converters run off the main thread, so every mutation
|
||||
/// is lock protected.
|
||||
final class Cancellation {
|
||||
private let lock = NSLock()
|
||||
private var cancelled = false
|
||||
private var processes: [Process] = []
|
||||
|
||||
var isCancelled: Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return cancelled
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
lock.lock()
|
||||
cancelled = true
|
||||
let running = processes
|
||||
lock.unlock()
|
||||
for process in running where process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
func register(_ process: Process) {
|
||||
lock.lock()
|
||||
let alreadyCancelled = cancelled
|
||||
if !alreadyCancelled { processes.append(process) }
|
||||
lock.unlock()
|
||||
if alreadyCancelled, process.isRunning { process.terminate() }
|
||||
}
|
||||
|
||||
func check() throws {
|
||||
if isCancelled { throw CancellationError() }
|
||||
}
|
||||
}
|
||||
|
||||
struct ShellError: LocalizedError {
|
||||
let command: String
|
||||
let status: Int32
|
||||
let stderr: String
|
||||
|
||||
var errorDescription: String? {
|
||||
let detail = stderr.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if detail.isEmpty {
|
||||
return "\(command) exited with status \(status)."
|
||||
}
|
||||
// External tools can be very chatty; keep the first meaningful line.
|
||||
let line = detail
|
||||
.split(separator: "\n")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.last { !$0.isEmpty } ?? detail
|
||||
return "\(command): \(line.prefix(400))"
|
||||
}
|
||||
}
|
||||
|
||||
/// Thin wrapper around `Process` with deadlock-free pipe draining.
|
||||
enum Shell {
|
||||
struct Result {
|
||||
let status: Int32
|
||||
let stdout: String
|
||||
let stderr: String
|
||||
}
|
||||
|
||||
private final class Box<T> {
|
||||
var value: T
|
||||
init(_ value: T) { self.value = value }
|
||||
}
|
||||
|
||||
/// Locate an executable by name, searching `extraPaths` first and then
|
||||
/// `$PATH`. Returns the last match so Homebrew's copies win over system
|
||||
/// stubs (e.g. the `/usr/bin/ffmpeg` shim that only opens the App Store).
|
||||
static func find(_ name: String, extraPaths: [String] = []) -> String? {
|
||||
var search = extraPaths
|
||||
if let path = ProcessInfo.processInfo.environment["PATH"] {
|
||||
search += path.split(separator: ":").map(String.init)
|
||||
}
|
||||
var found: String?
|
||||
for directory in search {
|
||||
let full = directory.hasSuffix("/") ? directory + name : directory + "/" + name
|
||||
if FileManager.default.isExecutableFile(atPath: full) {
|
||||
found = full
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func run(
|
||||
_ executable: String,
|
||||
_ arguments: [String],
|
||||
currentDirectory: URL? = nil,
|
||||
cancellation: Cancellation? = nil
|
||||
) throws -> Result {
|
||||
try cancellation?.check()
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: executable)
|
||||
process.arguments = arguments
|
||||
if let currentDirectory { process.currentDirectoryURL = currentDirectory }
|
||||
|
||||
let outPipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
process.standardOutput = outPipe
|
||||
process.standardError = errPipe
|
||||
process.standardInput = FileHandle.nullDevice
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
throw ShellError(
|
||||
command: (arguments.first ?? executable),
|
||||
status: -1,
|
||||
stderr: error.localizedDescription
|
||||
)
|
||||
}
|
||||
cancellation?.register(process)
|
||||
|
||||
// Drain both pipes on separate queues: reading them in sequence can
|
||||
// deadlock when a tool fills the buffer we are not reading yet.
|
||||
let outBox = Box(Data())
|
||||
let errBox = Box(Data())
|
||||
let group = DispatchGroup()
|
||||
DispatchQueue.global(qos: .utility).async(group: group) {
|
||||
outBox.value = (try? outPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
}
|
||||
DispatchQueue.global(qos: .utility).async(group: group) {
|
||||
errBox.value = (try? errPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
}
|
||||
group.wait()
|
||||
process.waitUntilExit()
|
||||
|
||||
let result = Result(
|
||||
status: process.terminationStatus,
|
||||
stdout: String(data: outBox.value, encoding: .utf8) ?? "",
|
||||
stderr: String(data: errBox.value, encoding: .utf8) ?? ""
|
||||
)
|
||||
|
||||
try cancellation?.check()
|
||||
if result.status != 0 {
|
||||
throw ShellError(
|
||||
command: (arguments.first ?? executable),
|
||||
status: result.status,
|
||||
stderr: result.stderr.isEmpty ? result.stdout : result.stderr
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Best-effort variant used for optional probes (`ffprobe`, `mdls`, …).
|
||||
static func output(_ executable: String, _ arguments: [String]) -> String? {
|
||||
(try? run(executable, arguments))?.stdout
|
||||
}
|
||||
|
||||
/// Runs a tool whose stdout is binary (compressed streams) and returns it.
|
||||
static func runData(
|
||||
_ executable: String,
|
||||
_ arguments: [String],
|
||||
cancellation: Cancellation? = nil
|
||||
) throws -> Data {
|
||||
try cancellation?.check()
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: executable)
|
||||
process.arguments = arguments
|
||||
let outPipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
process.standardOutput = outPipe
|
||||
process.standardError = errPipe
|
||||
process.standardInput = FileHandle.nullDevice
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
throw ShellError(command: arguments.first ?? executable, status: -1, stderr: error.localizedDescription)
|
||||
}
|
||||
cancellation?.register(process)
|
||||
|
||||
let outBox = Box(Data())
|
||||
let errBox = Box(Data())
|
||||
let group = DispatchGroup()
|
||||
DispatchQueue.global(qos: .utility).async(group: group) {
|
||||
outBox.value = (try? outPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
}
|
||||
DispatchQueue.global(qos: .utility).async(group: group) {
|
||||
errBox.value = (try? errPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
}
|
||||
group.wait()
|
||||
process.waitUntilExit()
|
||||
|
||||
let stderr = String(data: errBox.value, encoding: .utf8) ?? ""
|
||||
try cancellation?.check()
|
||||
if process.terminationStatus != 0 {
|
||||
throw ShellError(command: arguments.first ?? executable, status: process.terminationStatus, stderr: stderr)
|
||||
}
|
||||
return outBox.value
|
||||
}
|
||||
|
||||
/// Runs a tool and reports progress parsed from `key=value` lines on
|
||||
/// stdout (what `ffmpeg -progress pipe:1` emits).
|
||||
static func runReportingProgress(
|
||||
_ executable: String,
|
||||
_ arguments: [String],
|
||||
cancellation: Cancellation? = nil,
|
||||
onProgress: @escaping (Double, String) -> Void
|
||||
) throws {
|
||||
try cancellation?.check()
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: executable)
|
||||
process.arguments = arguments
|
||||
let outPipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
process.standardOutput = outPipe
|
||||
process.standardError = errPipe
|
||||
process.standardInput = FileHandle.nullDevice
|
||||
|
||||
let errBox = Box(Data())
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
errBox.value = (try? errPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
}
|
||||
|
||||
let buffer = Box(Data())
|
||||
let lock = NSLock()
|
||||
outPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let chunk = handle.availableData
|
||||
guard !chunk.isEmpty else { return }
|
||||
lock.lock()
|
||||
buffer.value.append(chunk)
|
||||
let text = String(data: buffer.value, encoding: .utf8) ?? ""
|
||||
var lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
|
||||
let remainder = lines.popLast() ?? ""
|
||||
buffer.value = Data(remainder.utf8)
|
||||
lock.unlock()
|
||||
for line in lines { onProgress(0, line) }
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
outPipe.fileHandleForReading.readabilityHandler = nil
|
||||
throw ShellError(command: arguments.first ?? executable, status: -1, stderr: error.localizedDescription)
|
||||
}
|
||||
cancellation?.register(process)
|
||||
process.waitUntilExit()
|
||||
outPipe.fileHandleForReading.readabilityHandler = nil
|
||||
|
||||
let stderr = String(data: errBox.value, encoding: .utf8) ?? ""
|
||||
try cancellation?.check()
|
||||
if process.terminationStatus != 0 {
|
||||
throw ShellError(command: arguments.first ?? executable, status: process.terminationStatus, stderr: stderr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
|
||||
/// Optional external helpers. Konverter works without them — they only widen
|
||||
/// the conversion matrix (formats Apple's frameworks cannot write) and are
|
||||
/// detected once at launch.
|
||||
enum Toolchain {
|
||||
private static let homebrewPaths = ["/opt/homebrew/bin", "/usr/local/bin"]
|
||||
|
||||
/// ffmpeg/ffprobe: MKV, WebM, AVI, WMV, MP3, FLAC, OGG, Opus, WMA.
|
||||
static let ffmpeg: String? = Shell.find("ffmpeg", extraPaths: homebrewPaths)
|
||||
static let ffprobe: String? = Shell.find("ffprobe", extraPaths: homebrewPaths)
|
||||
|
||||
/// LibreOffice: PPT/PPTX rendering, XLS/XLSX/ODS, PAGES, Keynote.
|
||||
static let soffice: String? = {
|
||||
let candidates = [
|
||||
"/Applications/LibreOffice.app/Contents/MacOS/soffice",
|
||||
"/Applications/LibreOffice.app/Contents/MacOS/soffice.bin"
|
||||
]
|
||||
for candidate in candidates where FileManager.default.isExecutableFile(atPath: candidate) {
|
||||
return candidate
|
||||
}
|
||||
return Shell.find("soffice", extraPaths: homebrewPaths)
|
||||
}()
|
||||
|
||||
static var hasFFmpeg: Bool { ffmpeg != nil }
|
||||
static var hasLibreOffice: Bool { soffice != nil }
|
||||
|
||||
/// `curl`-free, sandbox-free best-effort image rasteriser for SVG and other
|
||||
/// formats ImageIO refuses to decode but Quick Look understands.
|
||||
static let qlmanage: String? = Shell.find("qlmanage", extraPaths: ["/usr/bin"])
|
||||
|
||||
static let ditto = "/usr/bin/ditto"
|
||||
static let tar = "/usr/bin/tar"
|
||||
static let gzip = "/usr/bin/gzip"
|
||||
static let gunzip = "/usr/bin/gunzip"
|
||||
static let textutil = "/usr/bin/textutil"
|
||||
static let afconvert = "/usr/bin/afconvert"
|
||||
static let zip = "/usr/bin/zip"
|
||||
static let unzip = "/usr/bin/unzip"
|
||||
|
||||
/// Human readable summary for the About/Settings pane.
|
||||
static var summary: String {
|
||||
var parts: [String] = ["Core (ImageIO, PDFKit, AVFoundation)"]
|
||||
if hasFFmpeg { parts.append("ffmpeg") }
|
||||
if hasLibreOffice { parts.append("LibreOffice") }
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
static var missingToolAdvice: String? {
|
||||
var advice: [String] = []
|
||||
if !hasFFmpeg { advice.append("brew install ffmpeg") }
|
||||
if !hasLibreOffice { advice.append("brew install --cask libreoffice") }
|
||||
return advice.isEmpty ? nil : advice.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import Foundation
|
||||
|
||||
/// A tool: an in-place transform that is not a plain format change.
|
||||
enum ToolID: String, CaseIterable, Identifiable, Codable {
|
||||
// Images
|
||||
case compressImage, resizeImage, cropImage, rotateImage, flipImage
|
||||
case grayscaleImage, stripMetadata, watermarkImage, readQR
|
||||
// Video
|
||||
case compressVideo, trimVideo, muteVideo, speedVideo, snapshotVideo
|
||||
case videoToGIF, resizeVideo
|
||||
// Audio
|
||||
case trimAudio, normalizeAudio, channelsAudio, speedAudio, bleepAudio, compressAudio
|
||||
// PDF
|
||||
case mergePDF, splitPDF, rotatePDF, compressPDF, encryptPDF, decryptPDF
|
||||
case imagesToPDF, collageImages, extractPDFText
|
||||
// Documents
|
||||
case documentToPDF, wordCount
|
||||
// Spreadsheets
|
||||
case sheetsToCSV
|
||||
// Subtitles
|
||||
case shiftSubtitle
|
||||
// Archives
|
||||
case extractArchive, listArchive
|
||||
// Media metadata
|
||||
case mediaInfo
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .compressImage: return "Compress"
|
||||
case .resizeImage: return "Resize"
|
||||
case .cropImage: return "Crop"
|
||||
case .rotateImage: return "Rotate"
|
||||
case .flipImage: return "Flip"
|
||||
case .grayscaleImage: return "Grayscale"
|
||||
case .stripMetadata: return "Strip Metadata"
|
||||
case .watermarkImage: return "Watermark"
|
||||
case .readQR: return "Read QR"
|
||||
case .compressVideo: return "Compress"
|
||||
case .trimVideo: return "Trim"
|
||||
case .muteVideo: return "Mute"
|
||||
case .speedVideo: return "Speed"
|
||||
case .snapshotVideo: return "Snapshot"
|
||||
case .videoToGIF: return "To GIF"
|
||||
case .resizeVideo: return "Resize"
|
||||
case .trimAudio: return "Trim"
|
||||
case .normalizeAudio: return "Normalize"
|
||||
case .channelsAudio: return "Channels"
|
||||
case .speedAudio: return "Speed"
|
||||
case .bleepAudio: return "Bleep"
|
||||
case .compressAudio: return "Compress"
|
||||
case .mergePDF: return "Merge"
|
||||
case .splitPDF: return "Split"
|
||||
case .rotatePDF: return "Rotate"
|
||||
case .compressPDF: return "Compress"
|
||||
case .encryptPDF: return "Encrypt"
|
||||
case .decryptPDF: return "Decrypt"
|
||||
case .imagesToPDF: return "Images to PDF"
|
||||
case .collageImages: return "Collage"
|
||||
case .extractPDFText: return "Extract Text"
|
||||
case .documentToPDF: return "To PDF"
|
||||
case .wordCount: return "Statistics"
|
||||
case .sheetsToCSV: return "To CSV"
|
||||
case .shiftSubtitle: return "Shift Time"
|
||||
case .extractArchive: return "Extract"
|
||||
case .listArchive: return "List Contents"
|
||||
case .mediaInfo: return "Info"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .compressImage, .compressVideo, .compressAudio, .compressPDF: return "arrow.down.right.and.arrow.up.left"
|
||||
case .resizeImage, .resizeVideo: return "arrow.up.left.and.arrow.down.right"
|
||||
case .cropImage: return "crop"
|
||||
case .rotateImage, .rotatePDF: return "rotate.right"
|
||||
case .flipImage: return "arrow.left.and.right.righttriangle.left.righttriangle.right"
|
||||
case .grayscaleImage: return "circle.lefthalf.filled"
|
||||
case .stripMetadata, .mediaInfo: return "info.circle"
|
||||
case .watermarkImage: return "signature"
|
||||
case .readQR: return "qrcode.viewfinder"
|
||||
case .trimVideo, .trimAudio: return "scissors"
|
||||
case .muteVideo, .bleepAudio: return "speaker.slash"
|
||||
case .speedVideo, .speedAudio: return "gauge.with.dots.needle.67percent"
|
||||
case .snapshotVideo: return "camera"
|
||||
case .videoToGIF: return "rectangle.stack"
|
||||
case .normalizeAudio: return "waveform.path.ecg"
|
||||
case .channelsAudio: return "speaker.wave.2"
|
||||
case .mergePDF: return "square.stack.3d.down.right"
|
||||
case .splitPDF: return "scissors"
|
||||
case .encryptPDF: return "lock"
|
||||
case .decryptPDF: return "lock.open"
|
||||
case .imagesToPDF: return "doc.badge.plus"
|
||||
case .collageImages: return "rectangle.grid.2x2"
|
||||
case .extractPDFText: return "text.viewfinder"
|
||||
case .documentToPDF: return "doc.richtext"
|
||||
case .wordCount: return "number"
|
||||
case .sheetsToCSV: return "tablecells.badge.ellipsis"
|
||||
case .shiftSubtitle: return "clock.arrow.circlepath"
|
||||
case .extractArchive: return "archivebox"
|
||||
case .listArchive: return "list.bullet.rectangle"
|
||||
}
|
||||
}
|
||||
|
||||
var category: FileCategory {
|
||||
switch self {
|
||||
case .compressImage, .resizeImage, .cropImage, .rotateImage, .flipImage,
|
||||
.grayscaleImage, .stripMetadata, .watermarkImage, .readQR:
|
||||
return .image
|
||||
case .compressVideo, .trimVideo, .muteVideo, .speedVideo, .snapshotVideo,
|
||||
.videoToGIF, .resizeVideo:
|
||||
return .video
|
||||
case .trimAudio, .normalizeAudio, .channelsAudio, .speedAudio, .bleepAudio, .compressAudio:
|
||||
return .audio
|
||||
case .mergePDF, .splitPDF, .rotatePDF, .compressPDF, .encryptPDF, .decryptPDF,
|
||||
.imagesToPDF, .collageImages, .extractPDFText:
|
||||
return .pdf
|
||||
case .documentToPDF, .wordCount:
|
||||
return .document
|
||||
case .sheetsToCSV:
|
||||
return .spreadsheet
|
||||
case .shiftSubtitle:
|
||||
return .subtitle
|
||||
case .extractArchive, .listArchive:
|
||||
return .archive
|
||||
case .mediaInfo:
|
||||
return .video
|
||||
}
|
||||
}
|
||||
|
||||
/// Tools that only make sense for a multi-file selection.
|
||||
var requiresMultipleFiles: Bool {
|
||||
self == .mergePDF || self == .imagesToPDF || self == .collageImages
|
||||
}
|
||||
|
||||
var minimumFileCount: Int { requiresMultipleFiles ? 2 : 1 }
|
||||
|
||||
/// Tools that produce a derived file instead of a transformed copy.
|
||||
var producesArtifacts: Bool {
|
||||
switch self {
|
||||
case .readQR, .extractPDFText, .listArchive, .mediaInfo, .wordCount:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Some tools can only work on narrower inputs (PDF tools on PDFs already,
|
||||
/// video-only tools on video).
|
||||
static func tools(for category: FileCategory) -> [ToolID] {
|
||||
allCases.filter { $0.category == category }
|
||||
}
|
||||
}
|
||||
|
||||
/// One editable field in a tool's options form.
|
||||
struct ToolField: Identifiable, Hashable {
|
||||
enum Kind: Hashable {
|
||||
case text
|
||||
case secret
|
||||
case integer(min: Int, max: Int)
|
||||
case decimal(min: Double, max: Double)
|
||||
case toggle
|
||||
case choice([String])
|
||||
case timecode
|
||||
}
|
||||
|
||||
let id: String
|
||||
let label: String
|
||||
let kind: Kind
|
||||
let defaultValue: String
|
||||
let unit: String?
|
||||
let help: String?
|
||||
|
||||
init(_ id: String, _ label: String, _ kind: Kind, default value: String, unit: String? = nil, help: String? = nil) {
|
||||
self.id = id
|
||||
self.label = label
|
||||
self.kind = kind
|
||||
self.defaultValue = value
|
||||
self.unit = unit
|
||||
self.help = help
|
||||
}
|
||||
}
|
||||
|
||||
extension ToolID {
|
||||
var fields: [ToolField] {
|
||||
switch self {
|
||||
case .compressImage:
|
||||
return [ToolField("quality", "Quality", .integer(min: 1, max: 100), default: "70", unit: "%")]
|
||||
case .resizeImage:
|
||||
return [
|
||||
ToolField("width", "Max width", .integer(min: 16, max: 20000), default: "1920", unit: "px"),
|
||||
ToolField("height", "Max height", .integer(min: 16, max: 20000), default: "1920", unit: "px"),
|
||||
ToolField("percent", "…or scale", .integer(min: 1, max: 400), default: "100", unit: "%")
|
||||
]
|
||||
case .cropImage:
|
||||
return [
|
||||
ToolField("inset", "Inset from each edge", .integer(min: 0, max: 4000), default: "0", unit: "px"),
|
||||
ToolField("aspect", "Aspect", .choice(["Keep", "1:1", "4:3", "3:2", "16:9", "9:16"]), default: "Keep")
|
||||
]
|
||||
case .rotateImage, .rotatePDF:
|
||||
return [ToolField("angle", "Rotation", .choice(["90° right", "180°", "90° left"]), default: "90° right")]
|
||||
case .flipImage:
|
||||
return [ToolField("axis", "Axis", .choice(["Horizontal", "Vertical"]), default: "Horizontal")]
|
||||
case .watermarkImage:
|
||||
return [
|
||||
ToolField("text", "Text", .text, default: "©"),
|
||||
ToolField("position", "Position", .choice(["Bottom Right", "Bottom Left", "Top Right", "Top Left", "Center"]), default: "Bottom Right"),
|
||||
ToolField("opacity", "Opacity", .integer(min: 5, max: 100), default: "45", unit: "%"),
|
||||
ToolField("size", "Size", .integer(min: 8, max: 40), default: "16", unit: "% of width")
|
||||
]
|
||||
case .compressVideo:
|
||||
return [ToolField("preset", "Quality", .choice(["Low", "Medium", "High"]), default: "Medium")]
|
||||
case .trimVideo, .trimAudio:
|
||||
return [
|
||||
ToolField("start", "Start", .timecode, default: "00:00:00"),
|
||||
ToolField("end", "End", .timecode, default: "", help: "Leave empty to keep the tail")
|
||||
]
|
||||
case .speedVideo, .speedAudio:
|
||||
return [ToolField("rate", "Speed", .decimal(min: 0.25, max: 4), default: "2", unit: "×")]
|
||||
case .snapshotVideo:
|
||||
return [ToolField("at", "Timestamp", .timecode, default: "00:00:01")]
|
||||
case .videoToGIF:
|
||||
return [
|
||||
ToolField("fps", "Frame rate", .integer(min: 5, max: 30), default: "12", unit: "fps"),
|
||||
ToolField("width", "Width", .integer(min: 120, max: 1600), default: "480", unit: "px")
|
||||
]
|
||||
case .resizeVideo:
|
||||
return [ToolField("width", "Width", .integer(min: 160, max: 7680), default: "1280", unit: "px")]
|
||||
case .bleepAudio:
|
||||
return [
|
||||
ToolField("start", "Mute from", .timecode, default: "00:00:00"),
|
||||
ToolField("end", "Mute until", .timecode, default: "00:00:01")
|
||||
]
|
||||
case .compressAudio:
|
||||
return [ToolField("bitrate", "Bitrate", .choice(["64", "96", "128", "192", "256", "320"]), default: "128", unit: "kbps")]
|
||||
case .channelsAudio:
|
||||
return [ToolField("mode", "Channels", .choice(["Mono", "Stereo"]), default: "Mono")]
|
||||
case .splitPDF:
|
||||
return [ToolField("pages", "Pages per file", .integer(min: 1, max: 500), default: "1")]
|
||||
case .compressPDF:
|
||||
return [ToolField("quality", "Image quality", .integer(min: 10, max: 100), default: "55", unit: "%")]
|
||||
case .encryptPDF:
|
||||
return [
|
||||
ToolField("password", "Password", .secret, default: ""),
|
||||
ToolField("owner", "Owner password", .secret, default: "", help: "Optional, defaults to the password")
|
||||
]
|
||||
case .decryptPDF:
|
||||
return [ToolField("password", "Password", .secret, default: "")]
|
||||
case .collageImages:
|
||||
return [
|
||||
ToolField("columns", "Columns", .integer(min: 1, max: 8), default: "3"),
|
||||
ToolField("page", "Page", .choice(["A4", "Letter", "Square"]), default: "A4"),
|
||||
ToolField("margin", "Margin", .integer(min: 0, max: 120), default: "24", unit: "pt"),
|
||||
ToolField("captions", "File names", .toggle, default: "false")
|
||||
]
|
||||
case .shiftSubtitle:
|
||||
return [ToolField("offset", "Shift by", .decimal(min: -600, max: 600), default: "1", unit: "s")]
|
||||
case .listArchive, .extractArchive, .wordCount, .mediaInfo, .readQR, .stripMetadata,
|
||||
.grayscaleImage, .normalizeAudio, .extractPDFText, .documentToPDF, .sheetsToCSV,
|
||||
.muteVideo, .mergePDF, .imagesToPDF:
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Values collected from a tool's options form. Round-trips through
|
||||
/// `UserDefaults` per tool so repeat use is one drop.
|
||||
struct ToolParams {
|
||||
private var storage: [String: String] = [:]
|
||||
|
||||
subscript(key: String) -> String? {
|
||||
get { storage[key] }
|
||||
set { storage[key] = newValue }
|
||||
}
|
||||
|
||||
func string(_ key: String, default fallback: String = "") -> String {
|
||||
let value = storage[key]?.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
return value.isEmpty ? fallback : value
|
||||
}
|
||||
|
||||
func int(_ key: String, default fallback: Int) -> Int {
|
||||
Int(string(key)) ?? fallback
|
||||
}
|
||||
|
||||
func double(_ key: String, default fallback: Double) -> Double {
|
||||
Double(string(key)) ?? fallback
|
||||
}
|
||||
|
||||
func bool(_ key: String, default fallback: Bool) -> Bool {
|
||||
guard let value = storage[key] else { return fallback }
|
||||
return value == "true" || value == "1"
|
||||
}
|
||||
|
||||
static func defaults(for tool: ToolID) -> ToolParams {
|
||||
var params = ToolParams()
|
||||
for field in tool.fields {
|
||||
params[field.id] = field.defaultValue
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
mutating func fillMissing(from tool: ToolID) {
|
||||
for field in tool.fields where storage[field.id] == nil {
|
||||
storage[field.id] = field.defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
var isEmpty: Bool { storage.isEmpty }
|
||||
|
||||
var dictionary: [String: String] { storage }
|
||||
}
|
||||
|
||||
/// Timecodes come in as `mm:ss`, `hh:mm:ss` or plain seconds.
|
||||
enum Timecode {
|
||||
static func seconds(from string: String) -> Double? {
|
||||
let trimmed = string.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty { return nil }
|
||||
let parts = trimmed.split(separator: ":").map(String.init)
|
||||
switch parts.count {
|
||||
case 1:
|
||||
return Double(parts[0])
|
||||
case 2:
|
||||
guard let minutes = Double(parts[0]), let seconds = Double(parts[1]) else { return nil }
|
||||
return minutes * 60 + seconds
|
||||
case 3:
|
||||
guard let h = Double(parts[0]), let m = Double(parts[1]), let s = Double(parts[2]) else { return nil }
|
||||
return h * 3600 + m * 60 + s
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// One wedge of the wheel.
|
||||
struct WheelItem: Identifiable {
|
||||
enum Payload {
|
||||
case format(Format)
|
||||
case tool(ToolID)
|
||||
case action(ActionID)
|
||||
}
|
||||
|
||||
let id: String
|
||||
let payload: Payload
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
let symbol: String
|
||||
let availability: Availability
|
||||
|
||||
var isEnabled: Bool { availability.isAvailable }
|
||||
}
|
||||
|
||||
enum WheelKind {
|
||||
case convert
|
||||
case tools
|
||||
case actions
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .convert: return "Convert"
|
||||
case .tools: return "Tools"
|
||||
case .actions: return "Actions"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the wheel contents for whatever is being dragged.
|
||||
enum WheelBuilder {
|
||||
static func kind(for modifiers: NSEvent.ModifierFlags, settings: Settings = .shared) -> WheelKind? {
|
||||
if modifiers.contains(settings.actionsKey.flag) { return .actions }
|
||||
if modifiers.contains(settings.toolsKey.flag) {
|
||||
if settings.requireShiftForTools, !modifiers.contains(.shift) { return nil }
|
||||
return .tools
|
||||
}
|
||||
if modifiers.contains(settings.convertKey.flag) { return .convert }
|
||||
return nil
|
||||
}
|
||||
|
||||
static func items(kind: WheelKind, urls: [URL], settings: Settings = .shared) -> [WheelItem] {
|
||||
switch kind {
|
||||
case .convert:
|
||||
return conversionItems(urls: urls)
|
||||
case .tools:
|
||||
return toolItems(urls: urls)
|
||||
case .actions:
|
||||
return actionItems(urls: urls, settings: settings)
|
||||
}
|
||||
}
|
||||
|
||||
private static func conversionItems(urls: [URL]) -> [WheelItem] {
|
||||
let categories = urls.map { FileKind.category(of: $0) }
|
||||
let formats = ConversionCatalog.outputs(for: categories)
|
||||
guard let category = categories.first, !formats.isEmpty else { return [] }
|
||||
|
||||
return formats.compactMap { format in
|
||||
guard !ConversionCatalog.isRedundant(format, urls: urls) else { return nil }
|
||||
let availability = Converter.availability(target: format, source: category)
|
||||
return WheelItem(
|
||||
id: "format-\(format.ext)",
|
||||
payload: .format(format),
|
||||
title: format.displayName,
|
||||
subtitle: availability.isAvailable ? nil : availability.reason,
|
||||
symbol: format.symbol,
|
||||
availability: availability
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func toolItems(urls: [URL]) -> [WheelItem] {
|
||||
let categories = Set(urls.map { FileKind.category(of: $0) })
|
||||
guard categories.count == 1, let category = categories.first else { return [] }
|
||||
|
||||
return ToolID.tools(for: category).compactMap { tool in
|
||||
guard urls.count >= tool.minimumFileCount else { return nil }
|
||||
var availability = Availability.ok
|
||||
if tool.producesArtifacts, urls.count > 1 {
|
||||
availability = .ok
|
||||
}
|
||||
if requiresFFmpeg(tool), !Toolchain.hasFFmpeg {
|
||||
availability = .unavailable("Needs ffmpeg — brew install ffmpeg")
|
||||
}
|
||||
return WheelItem(
|
||||
id: "tool-\(tool.rawValue)",
|
||||
payload: .tool(tool),
|
||||
title: tool.title,
|
||||
subtitle: availability.isAvailable ? nil : availability.reason,
|
||||
symbol: tool.symbol,
|
||||
availability: availability
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func requiresFFmpeg(_ tool: ToolID) -> Bool {
|
||||
switch tool {
|
||||
case .normalizeAudio: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func actionItems(urls: [URL], settings: Settings) -> [WheelItem] {
|
||||
let allFolders = urls.allSatisfy { FileKind.category(of: $0) == .folder }
|
||||
return ActionID.allCases.compactMap { action in
|
||||
if allFolders, action != .airdrop, action != .copy, action != .move,
|
||||
action != .copyPath, action != .terminal, action != .revealInFinder {
|
||||
return nil
|
||||
}
|
||||
_ = settings
|
||||
return WheelItem(
|
||||
id: "action-\(action.rawValue)",
|
||||
payload: .action(action),
|
||||
title: action.title,
|
||||
subtitle: nil,
|
||||
symbol: action.symbol,
|
||||
availability: .ok
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-line description of the selection for the centre of the wheel.
|
||||
static func summary(for urls: [URL]) -> String {
|
||||
guard let first = urls.first else { return "" }
|
||||
if urls.count > 1 { return "\(urls.count) files" }
|
||||
return first.lastPathComponent
|
||||
}
|
||||
|
||||
static func icon(for urls: [URL]) -> NSImage {
|
||||
guard let first = urls.first else {
|
||||
return NSImage(systemSymbolName: "doc", accessibilityDescription: nil) ?? NSImage()
|
||||
}
|
||||
return NSWorkspace.shared.icon(forFile: first.path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// Watches for drags anywhere on the system and reports them. Mouse events do
|
||||
/// not require any privacy permission; key events (Escape, arrows) do, so the
|
||||
/// wheel stays fully usable without them.
|
||||
final class DragMonitor {
|
||||
static let shared = DragMonitor()
|
||||
|
||||
/// Called on every poll while a drag is in flight.
|
||||
var onTick: (([URL], CGPoint, NSEvent.ModifierFlags) -> Void)?
|
||||
/// Called once when a drag starts, with the files being dragged.
|
||||
var onBegan: (([URL]) -> Void)?
|
||||
/// Called when the drag finishes (drop or cancel).
|
||||
var onEnded: ((CGPoint) -> Void)?
|
||||
/// Called for keys pressed during a drag.
|
||||
var onKey: ((UInt16, NSEvent.ModifierFlags) -> Bool)?
|
||||
|
||||
private var globalMouse: Any?
|
||||
private var localMouse: Any?
|
||||
private var keyMonitor: Any?
|
||||
private var timer: Timer?
|
||||
private var isDragging = false
|
||||
private var urls: [URL] = []
|
||||
|
||||
private init() {}
|
||||
|
||||
func start() {
|
||||
guard globalMouse == nil else { return }
|
||||
|
||||
let mask: NSEvent.EventTypeMask = [.leftMouseDragged, .rightMouseDragged, .otherMouseDragged]
|
||||
globalMouse = NSEvent.addGlobalMonitorForEvents(matching: mask) { [weak self] event in
|
||||
self?.handleDrag(event)
|
||||
}
|
||||
// Drags that end up over our own window are delivered to us, so the
|
||||
// global monitor never sees them.
|
||||
localMouse = NSEvent.addLocalMonitorForEvents(matching: mask) { [weak self] event in
|
||||
self?.handleDrag(event)
|
||||
return event
|
||||
}
|
||||
|
||||
let upMask: NSEvent.EventTypeMask = [.leftMouseUp, .rightMouseUp, .otherMouseUp]
|
||||
NSEvent.addGlobalMonitorForEvents(matching: upMask) { [weak self] _ in
|
||||
self?.handleEnd()
|
||||
}
|
||||
NSEvent.addLocalMonitorForEvents(matching: upMask) { [weak self] event in
|
||||
self?.handleEnd()
|
||||
return event
|
||||
}
|
||||
|
||||
keyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||||
guard let self, self.isDragging else { return }
|
||||
_ = self.onKey?(event.keyCode, event.modifierFlags)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
if let globalMouse { NSEvent.removeMonitor(globalMouse) }
|
||||
if let localMouse { NSEvent.removeMonitor(localMouse) }
|
||||
if let keyMonitor { NSEvent.removeMonitor(keyMonitor) }
|
||||
globalMouse = nil
|
||||
localMouse = nil
|
||||
keyMonitor = nil
|
||||
stopTimer()
|
||||
}
|
||||
|
||||
private func handleDrag(_ event: NSEvent) {
|
||||
if !isDragging {
|
||||
let dragged = DragMonitor.draggedFileURLs()
|
||||
guard !dragged.isEmpty else { return }
|
||||
isDragging = true
|
||||
urls = dragged
|
||||
onBegan?(dragged)
|
||||
startTimer()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleEnd() {
|
||||
guard isDragging else { return }
|
||||
onEnded?(NSEvent.mouseLocation)
|
||||
isDragging = false
|
||||
urls = []
|
||||
stopTimer()
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
stopTimer()
|
||||
let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in
|
||||
guard let self, self.isDragging else { return }
|
||||
self.onTick?(self.urls, NSEvent.mouseLocation, NSEvent.modifierFlags)
|
||||
}
|
||||
// `.common` keeps it firing even if a tracking loop is running.
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
self.timer = timer
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
/// The files currently on the global drag pasteboard. Finder, Mail and most
|
||||
/// apps publish real file URLs; apps that only promise a file are read too.
|
||||
static func draggedFileURLs() -> [URL] {
|
||||
let pasteboard = NSPasteboard(name: .drag)
|
||||
guard let types = pasteboard.types else { return [] }
|
||||
|
||||
let fileTypes: Set<NSPasteboard.PasteboardType> = [
|
||||
.fileURL,
|
||||
.filePromise,
|
||||
NSPasteboard.PasteboardType("NSFilenamesPboardType")
|
||||
]
|
||||
guard types.contains(where: { fileTypes.contains($0) }) else { return [] }
|
||||
|
||||
var urls: [URL] = []
|
||||
if let objects = pasteboard.readObjects(
|
||||
forClasses: [NSURL.self],
|
||||
options: [.urlReadingFileURLsOnly: true]
|
||||
) as? [URL] {
|
||||
urls = objects
|
||||
}
|
||||
if urls.isEmpty, let paths = pasteboard.propertyList(forType: NSPasteboard.PasteboardType("NSFilenamesPboardType")) as? [String] {
|
||||
urls = paths.map { URL(fileURLWithPath: $0) }
|
||||
}
|
||||
return urls.filter { FileManager.default.fileExists(atPath: $0.path) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// State behind the wheel: what is offered, what is highlighted, and what the
|
||||
/// centre of the wheel says.
|
||||
final class WheelModel: ObservableObject {
|
||||
@Published var kind: WheelKind = .convert
|
||||
@Published var items: [WheelItem] = []
|
||||
@Published var selection: Int?
|
||||
@Published var summary: String = ""
|
||||
@Published var subtitle: String = ""
|
||||
@Published var icon: NSImage = NSImage()
|
||||
@Published var radius: CGFloat = 168
|
||||
|
||||
var selectedItem: WheelItem? {
|
||||
guard let selection, items.indices.contains(selection) else { return nil }
|
||||
return items[selection]
|
||||
}
|
||||
}
|
||||
|
||||
/// Accepts the drop. Being a real drag destination is what stops Finder from
|
||||
/// moving the file, and it is how the wheel learns about the drop without
|
||||
/// needing an Accessibility event tap.
|
||||
final class DropCatcher: NSView {
|
||||
var onMoved: ((CGPoint) -> Void)?
|
||||
var onExit: (() -> Void)?
|
||||
var onDrop: (() -> Bool)?
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
registerForDraggedTypes([.fileURL, .filePromise, NSPasteboard.PasteboardType("NSFilenamesPboardType")])
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { nil }
|
||||
|
||||
private func screenPoint(_ sender: NSDraggingInfo) -> CGPoint {
|
||||
guard let window else { return NSEvent.mouseLocation }
|
||||
let inWindow = sender.draggingLocation
|
||||
return window.convertPoint(toScreen: inWindow)
|
||||
}
|
||||
|
||||
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
||||
onMoved?(screenPoint(sender))
|
||||
return .generic
|
||||
}
|
||||
|
||||
override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
|
||||
onMoved?(screenPoint(sender))
|
||||
return .generic
|
||||
}
|
||||
|
||||
override func draggingExited(_ sender: NSDraggingInfo?) {
|
||||
onExit?()
|
||||
}
|
||||
|
||||
override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { true }
|
||||
|
||||
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
|
||||
onMoved?(screenPoint(sender))
|
||||
return onDrop?() ?? false
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the overlay window and turns drags into jobs.
|
||||
final class WheelController {
|
||||
static let shared = WheelController()
|
||||
|
||||
private let model = WheelModel()
|
||||
private var window: NSWindow?
|
||||
private var catcher: DropCatcher?
|
||||
private var urls: [URL] = []
|
||||
private var isShowing = false
|
||||
private var didHandleDrop = false
|
||||
private var lastLocation: CGPoint = .zero
|
||||
private var pendingDismiss: DispatchWorkItem?
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Drag lifecycle
|
||||
|
||||
func begin(urls: [URL]) {
|
||||
self.urls = urls
|
||||
didHandleDrop = false
|
||||
}
|
||||
|
||||
func tick(location: CGPoint, modifiers: NSEvent.ModifierFlags) {
|
||||
guard !urls.isEmpty else { return }
|
||||
guard let kind = WheelBuilder.kind(for: modifiers) else {
|
||||
// No trigger key held: stay out of the way until one is pressed.
|
||||
if !isShowing { model.selection = nil }
|
||||
return
|
||||
}
|
||||
let items = WheelBuilder.items(kind: kind, urls: urls)
|
||||
guard !items.isEmpty else {
|
||||
dismiss()
|
||||
return
|
||||
}
|
||||
if !isShowing || model.kind != kind || model.items.map(\.id) != items.map(\.id) {
|
||||
present(kind: kind, items: items, at: location)
|
||||
} else {
|
||||
move(to: location)
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop committed by the drag destination.
|
||||
@discardableResult
|
||||
func commitDrop() -> Bool {
|
||||
guard let item = model.selectedItem else {
|
||||
dismiss()
|
||||
return false
|
||||
}
|
||||
guard item.isEnabled else {
|
||||
let reason = item.availability.reason ?? "Unavailable"
|
||||
DispatchQueue.main.async {
|
||||
Notifier.error(item.title, reason)
|
||||
}
|
||||
dismiss()
|
||||
return true
|
||||
}
|
||||
didHandleDrop = true
|
||||
let captured = urls
|
||||
let origin = lastLocation
|
||||
dismiss(animated: false)
|
||||
WheelController.dispatch(item: item, urls: captured, origin: origin)
|
||||
return true
|
||||
}
|
||||
|
||||
func end() {
|
||||
guard isShowing else { return }
|
||||
// Give the drag destination a beat to deliver the drop first.
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
if self.didHandleDrop { return }
|
||||
if let item = self.model.selectedItem, item.isEnabled {
|
||||
let captured = self.urls
|
||||
let origin = self.lastLocation
|
||||
self.dismiss(animated: false)
|
||||
WheelController.dispatch(item: item, urls: captured, origin: origin)
|
||||
} else {
|
||||
self.dismiss()
|
||||
}
|
||||
}
|
||||
pendingDismiss = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.22, execute: work)
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
pendingDismiss?.cancel()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
func handleKey(_ keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Bool {
|
||||
guard isShowing else { return false }
|
||||
switch keyCode {
|
||||
case 53: // Escape
|
||||
cancel()
|
||||
return true
|
||||
case 123, 126: // Left / Up
|
||||
step(-1)
|
||||
return true
|
||||
case 124, 125: // Right / Down
|
||||
step(1)
|
||||
return true
|
||||
case 36, 76: // Return / Enter
|
||||
commitDrop()
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func step(_ delta: Int) {
|
||||
guard !model.items.isEmpty else { return }
|
||||
let count = model.items.count
|
||||
let current = model.selection ?? 0
|
||||
model.selection = ((current + delta) % count + count) % count
|
||||
}
|
||||
|
||||
// MARK: - Window
|
||||
|
||||
private func present(kind: WheelKind, items: [WheelItem], at location: CGPoint) {
|
||||
pendingDismiss?.cancel()
|
||||
|
||||
let radius = Self.radius(forItemCount: items.count)
|
||||
let itemExtent: CGFloat = 86
|
||||
let size = (radius + itemExtent) * 2
|
||||
|
||||
model.kind = kind
|
||||
model.items = items
|
||||
model.radius = radius
|
||||
model.summary = WheelBuilder.summary(for: urls)
|
||||
model.subtitle = kind.title
|
||||
model.icon = WheelBuilder.icon(for: urls)
|
||||
|
||||
let frame = CGRect(
|
||||
x: location.x - size / 2,
|
||||
y: location.y - size / 2,
|
||||
width: size,
|
||||
height: size
|
||||
)
|
||||
|
||||
let window: NSWindow
|
||||
if let existing = self.window {
|
||||
window = existing
|
||||
} else {
|
||||
let created = OverlayWindow(
|
||||
contentRect: frame,
|
||||
styleMask: [.borderless],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
created.isOpaque = false
|
||||
created.backgroundColor = .clear
|
||||
created.hasShadow = false
|
||||
created.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.screenSaverWindow)) - 1)
|
||||
created.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle]
|
||||
created.ignoresMouseEvents = false
|
||||
created.isMovable = false
|
||||
created.animationBehavior = .none
|
||||
|
||||
let catcher = DropCatcher(frame: CGRect(origin: .zero, size: CGSize(width: size, height: size)))
|
||||
catcher.autoresizingMask = [.width, .height]
|
||||
catcher.wantsLayer = true
|
||||
let hosting = NSHostingView(rootView: WheelView(model: model))
|
||||
hosting.frame = catcher.bounds
|
||||
hosting.autoresizingMask = [.width, .height]
|
||||
catcher.addSubview(hosting)
|
||||
|
||||
catcher.onMoved = { [weak self] point in
|
||||
self?.move(to: point)
|
||||
}
|
||||
catcher.onExit = { [weak self] in
|
||||
self?.model.selection = nil
|
||||
}
|
||||
catcher.onDrop = { [weak self] in
|
||||
self?.commitDrop() ?? false
|
||||
}
|
||||
|
||||
created.contentView = catcher
|
||||
self.catcher = catcher
|
||||
self.window = created
|
||||
window = created
|
||||
}
|
||||
|
||||
if !isShowing {
|
||||
window.alphaValue = 0
|
||||
window.orderFrontRegardless()
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.12
|
||||
window.animator().alphaValue = 1
|
||||
}
|
||||
} else {
|
||||
window.setFrame(frame, display: true)
|
||||
}
|
||||
isShowing = true
|
||||
move(to: location)
|
||||
}
|
||||
|
||||
private func move(to location: CGPoint) {
|
||||
guard let window else { return }
|
||||
lastLocation = location
|
||||
var frame = window.frame
|
||||
frame.origin = CGPoint(x: location.x - frame.width / 2, y: location.y - frame.height / 2)
|
||||
window.setFrame(frame, display: true)
|
||||
model.selection = Self.selectionIndex(for: location, in: frame, items: model.items, radius: model.radius)
|
||||
}
|
||||
|
||||
private func dismiss(animated: Bool = true) {
|
||||
pendingDismiss?.cancel()
|
||||
guard let window, isShowing else {
|
||||
isShowing = false
|
||||
return
|
||||
}
|
||||
isShowing = false
|
||||
model.selection = nil
|
||||
if animated {
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.1
|
||||
window.animator().alphaValue = 0
|
||||
} completionHandler: {
|
||||
window.orderOut(nil)
|
||||
}
|
||||
} else {
|
||||
window.orderOut(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
urls = []
|
||||
dismiss(animated: false)
|
||||
}
|
||||
|
||||
// MARK: - Geometry
|
||||
|
||||
/// Items are spread on a ring; the radius grows with the item count so the
|
||||
/// pills never touch.
|
||||
static func radius(forItemCount count: Int) -> CGFloat {
|
||||
let base = Settings.shared.wheelRadius
|
||||
guard count > 2 else { return base }
|
||||
let arc = CGFloat(count) * 84 / (2 * .pi)
|
||||
return max(base, arc + 40)
|
||||
}
|
||||
|
||||
/// Nearest item to the pointer, or nil when the pointer is off the ring or
|
||||
/// in the dead zone at the centre.
|
||||
static func selectionIndex(for location: CGPoint, in frame: CGRect, items: [WheelItem], radius: CGFloat) -> Int? {
|
||||
guard !items.isEmpty else { return nil }
|
||||
let center = CGPoint(x: frame.midX, y: frame.midY)
|
||||
let dx = location.x - center.x
|
||||
let dy = location.y - center.y
|
||||
let distance = (dx * dx + dy * dy).squareRoot()
|
||||
|
||||
guard distance > radius * 0.34 else { return nil }
|
||||
if items.count == 1 { return 0 }
|
||||
guard distance < radius * 1.75 else { return nil }
|
||||
|
||||
var angle = atan2(dy, dx) * 180 / .pi
|
||||
if angle < 0 { angle += 360 }
|
||||
let step = 360 / Double(items.count)
|
||||
let firstItemAngle = 90.0
|
||||
var offset = (firstItemAngle - angle).truncatingRemainder(dividingBy: 360)
|
||||
if offset < 0 { offset += 360 }
|
||||
let index = Int((offset / step).rounded()) % items.count
|
||||
return index
|
||||
}
|
||||
|
||||
// MARK: - Dispatch
|
||||
|
||||
private static func dispatch(item: WheelItem, urls: [URL], origin: CGPoint?) {
|
||||
switch item.payload {
|
||||
case .format(let format):
|
||||
JobStore.shared.submit(urls: urls, target: .format(format), origin: origin)
|
||||
|
||||
case .tool(let tool):
|
||||
if tool.fields.isEmpty {
|
||||
JobStore.shared.submit(urls: urls, target: .tool(tool), origin: origin)
|
||||
} else {
|
||||
OptionsWindowController.shared.present(tool: tool, urls: urls, origin: origin)
|
||||
}
|
||||
|
||||
case .action(let action):
|
||||
JobStore.shared.submit(urls: urls, target: .action(action), origin: origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A window that never steals focus and never becomes the key window, so the
|
||||
/// user's drag and the frontmost app keep working.
|
||||
final class OverlayWindow: NSWindow {
|
||||
override var canBecomeKey: Bool { false }
|
||||
override var canBecomeMain: Bool { false }
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// The wheel itself: a ring of output formats with the dragged file in the
|
||||
/// middle. Rendered into a transparent, non-activating overlay window.
|
||||
struct WheelView: View {
|
||||
@ObservedObject var model: WheelModel
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
let size = proxy.size
|
||||
let center = CGPoint(x: size.width / 2, y: size.height / 2)
|
||||
|
||||
ZStack {
|
||||
if Settings.shared.dimBackground {
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.22))
|
||||
.frame(width: model.radius * 3.4, height: model.radius * 3.4)
|
||||
.position(center)
|
||||
.blur(radius: 24)
|
||||
}
|
||||
|
||||
disc(center: center)
|
||||
|
||||
centerBadge(center: center)
|
||||
|
||||
ForEach(Array(model.items.enumerated()), id: \.element.id) { index, item in
|
||||
itemView(item, isSelected: model.selection == index)
|
||||
.position(position(for: index, count: model.items.count, center: center))
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
// MARK: - Pieces
|
||||
|
||||
private func disc(center: CGPoint) -> some View {
|
||||
let diameter = (model.radius + 46) * 2
|
||||
return ZStack {
|
||||
Circle()
|
||||
.fill(.ultraThinMaterial)
|
||||
Circle()
|
||||
.strokeBorder(Color.white.opacity(0.14), lineWidth: 1)
|
||||
Circle()
|
||||
.strokeBorder(Color.white.opacity(0.06), lineWidth: 1)
|
||||
.frame(width: diameter - 26, height: diameter - 26)
|
||||
}
|
||||
.frame(width: diameter, height: diameter)
|
||||
.position(center)
|
||||
.shadow(color: .black.opacity(0.28), radius: 22, y: 6)
|
||||
}
|
||||
|
||||
private func centerBadge(center: CGPoint) -> some View {
|
||||
VStack(spacing: 5) {
|
||||
Image(nsImage: model.icon)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 40, height: 40)
|
||||
Text(model.summary)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.frame(maxWidth: max(90, model.radius * 0.95))
|
||||
if let item = model.selectedItem, !item.isEnabled, let reason = item.availability.reason {
|
||||
Text(reason)
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.orange)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: max(110, model.radius * 1.1))
|
||||
} else {
|
||||
Text(model.subtitle)
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(Color(nsColor: .windowBackgroundColor).opacity(0.55))
|
||||
)
|
||||
.position(center)
|
||||
}
|
||||
|
||||
private func itemView(_ item: WheelItem, isSelected: Bool) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isSelected ? Color.accentColor.opacity(0.92) : Color.black.opacity(0.34))
|
||||
Circle()
|
||||
.strokeBorder(Color.white.opacity(isSelected ? 0.85 : 0.16), lineWidth: isSelected ? 1.5 : 1)
|
||||
Image(systemName: item.symbol)
|
||||
.font(.system(size: 19, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
if !item.isEnabled {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(3)
|
||||
.background(Circle().fill(Color.orange))
|
||||
.offset(x: 16, y: -16)
|
||||
}
|
||||
}
|
||||
.frame(width: 46, height: 46)
|
||||
|
||||
if Settings.shared.showLabels {
|
||||
Text(item.title)
|
||||
.font(.system(size: 10.5, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.shadow(color: .black.opacity(0.65), radius: 2, y: 1)
|
||||
}
|
||||
}
|
||||
.frame(width: 84)
|
||||
.opacity(item.isEnabled ? 1 : 0.62)
|
||||
.scaleEffect(isSelected ? 1.14 : 1)
|
||||
.animation(.spring(response: 0.18, dampingFraction: 0.8), value: isSelected)
|
||||
}
|
||||
|
||||
private func position(for index: Int, count: Int, center: CGPoint) -> CGPoint {
|
||||
guard count > 0 else { return center }
|
||||
let step = 360.0 / Double(count)
|
||||
let degrees = 90.0 - Double(index) * step
|
||||
let radians = degrees * .pi / 180
|
||||
return CGPoint(
|
||||
x: center.x + CGFloat(cos(radians)) * model.radius,
|
||||
y: center.y - CGFloat(sin(radians)) * model.radius
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact list rendering, for users who prefer a straight menu.
|
||||
struct WheelListView: View {
|
||||
@ObservedObject var model: WheelModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(model.summary)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
ForEach(Array(model.items.enumerated()), id: \.element.id) { index, item in
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: item.symbol)
|
||||
.font(.system(size: 11))
|
||||
.frame(width: 16)
|
||||
Text(item.title)
|
||||
.font(.system(size: 12))
|
||||
Spacer(minLength: 8)
|
||||
if !item.isEnabled {
|
||||
Image(systemName: "lock.fill").font(.system(size: 9))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(model.selection == index ? Color.accentColor.opacity(0.85) : Color.clear)
|
||||
)
|
||||
.foregroundStyle(model.selection == index ? Color.white : Color.primary)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.frame(width: 220)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
/// `--self-test` exercises the real conversion paths end to end and exits
|
||||
/// non-zero on failure. Kept in the shipped binary so a build can be verified
|
||||
/// without opening the UI.
|
||||
enum SelfTest {
|
||||
private static var passes = 0
|
||||
private static var failures = 0
|
||||
private static var directory = FileManager.default.temporaryDirectory
|
||||
|
||||
static func run() -> Int32 {
|
||||
directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("konverter-selftest-\(UUID().uuidString)", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
print("Konverter self-test")
|
||||
print("Working in \(directory.path)")
|
||||
print("Engines: \(Toolchain.summary)")
|
||||
print("")
|
||||
|
||||
testImages()
|
||||
testPDF()
|
||||
testSubtitles()
|
||||
testSpreadsheets()
|
||||
testArchives()
|
||||
testDocuments()
|
||||
testAudio()
|
||||
testEbook()
|
||||
testGeometry()
|
||||
|
||||
print("")
|
||||
print("\(passes) passed, \(failures) failed")
|
||||
return failures == 0 ? 0 : 1
|
||||
}
|
||||
|
||||
// MARK: - Suites
|
||||
|
||||
private static func testImages() {
|
||||
section("Images")
|
||||
let source = directory.appendingPathComponent("sample.png")
|
||||
check("create sample png") {
|
||||
try makeImage(at: source, width: 320, height: 200)
|
||||
try requireFile(source)
|
||||
}
|
||||
|
||||
for format in [Format.jpg, .tiff, .gif, .pdf, .webp, .heic, .avif] {
|
||||
let availability = Converter.availability(target: format, source: .image)
|
||||
guard availability.isAvailable else {
|
||||
print("SKIP png → \(format.displayName) (\(availability.reason ?? "unavailable"))")
|
||||
continue
|
||||
}
|
||||
check("png → \(format.displayName)") {
|
||||
let output = directory.appendingPathComponent("out.\(format.ext)")
|
||||
try ImageConverter.convert(source: source, to: format, at: output)
|
||||
try requireFile(output)
|
||||
}
|
||||
}
|
||||
|
||||
check("image compress") {
|
||||
let output = directory.appendingPathComponent("compressed.jpg")
|
||||
try ImageTools.compress(source, at: output, format: .jpg, quality: 0.4)
|
||||
try requireFile(output)
|
||||
}
|
||||
|
||||
// PNG has no quality knob, so "compress" trades colour depth for size.
|
||||
check("png compress shrinks a lossless file") {
|
||||
let output = directory.appendingPathComponent("compressed.png")
|
||||
try ImageTools.compress(source, at: output, format: .png, quality: 0.3)
|
||||
try requireFile(output)
|
||||
let before = try fileSize(source)
|
||||
let after = try fileSize(output)
|
||||
guard after < before else {
|
||||
throw TestFailure("expected a smaller png, got \(before) → \(after) bytes")
|
||||
}
|
||||
}
|
||||
|
||||
check("image resize") {
|
||||
let output = directory.appendingPathComponent("resized.png")
|
||||
try ImageTools.resize(source, at: output, maxWidth: 100, maxHeight: 100, percent: 100)
|
||||
guard let image = Raster.firstImage(at: output), image.width <= 100 else {
|
||||
throw TestFailure("resize did not shrink the image")
|
||||
}
|
||||
}
|
||||
|
||||
check("image rotate") {
|
||||
let output = directory.appendingPathComponent("rotated.png")
|
||||
try ImageTools.rotate(source, at: output, degrees: 90)
|
||||
guard let image = Raster.firstImage(at: output), image.height > image.width else {
|
||||
throw TestFailure("rotation did not swap dimensions")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func testPDF() {
|
||||
section("PDF")
|
||||
let image = directory.appendingPathComponent("sample.png")
|
||||
let pdf = directory.appendingPathComponent("sample.pdf")
|
||||
|
||||
check("image → pdf") {
|
||||
try PDFConverter.imageToPDF(image, at: pdf)
|
||||
try requireFile(pdf)
|
||||
}
|
||||
|
||||
check("pdf text extraction") {
|
||||
let textPDF = directory.appendingPathComponent("text.pdf")
|
||||
try TextDocument.writePDF(fromText: "Hello Konverter", to: textPDF)
|
||||
let text = PDFConverter.extractText(textPDF)
|
||||
guard text.contains("Hello") else { throw TestFailure("no text found in \(text)") }
|
||||
}
|
||||
|
||||
check("pdf → png pages") {
|
||||
let folder = directory.appendingPathComponent("pages")
|
||||
let written = try PDFConverter.renderPages(pdf, to: .png, into: folder, dpi: 72)
|
||||
guard !written.isEmpty else { throw TestFailure("no pages rendered") }
|
||||
try requireFile(written[0])
|
||||
}
|
||||
|
||||
check("pdf split and merge") {
|
||||
let split = try PDFConverter.split(pdf, pagesPerFile: 1, into: directory.appendingPathComponent("split"))
|
||||
guard split.count == 1 else { throw TestFailure("expected one part, got \(split.count)") }
|
||||
let merged = directory.appendingPathComponent("merged.pdf")
|
||||
try PDFConverter.merge(split, at: merged)
|
||||
guard PDFConverter.pageCount(merged) == 1 else { throw TestFailure("merge lost pages") }
|
||||
}
|
||||
}
|
||||
|
||||
private static func testSubtitles() {
|
||||
section("Subtitles")
|
||||
let srt = directory.appendingPathComponent("captions.srt")
|
||||
try? """
|
||||
1
|
||||
00:00:01,000 --> 00:00:03,500
|
||||
First line
|
||||
|
||||
2
|
||||
00:00:04,000 --> 00:00:06,000
|
||||
Second line
|
||||
|
||||
""".write(to: srt, atomically: true, encoding: .utf8)
|
||||
|
||||
check("srt → vtt") {
|
||||
let vtt = directory.appendingPathComponent("captions.vtt")
|
||||
try SubtitleConverter.convert(source: srt, to: .vtt, at: vtt)
|
||||
let text = try String(contentsOf: vtt, encoding: .utf8)
|
||||
guard text.hasPrefix("WEBVTT"), text.contains("00:00:01.000") else {
|
||||
throw TestFailure("unexpected vtt output")
|
||||
}
|
||||
}
|
||||
|
||||
check("srt → txt") {
|
||||
let text = directory.appendingPathComponent("captions.txt")
|
||||
try SubtitleConverter.convert(source: srt, to: .txt, at: text)
|
||||
let contents = try String(contentsOf: text, encoding: .utf8)
|
||||
guard contents.contains("First line") else { throw TestFailure("missing cue text") }
|
||||
}
|
||||
|
||||
check("subtitle offset") {
|
||||
let cues = try SubtitleConverter.parse(srt)
|
||||
let shifted = SubtitleConverter.shift(cues, by: 2)
|
||||
guard let first = shifted.first, abs(first.start - 3) < 0.01 else {
|
||||
throw TestFailure("offset was not applied")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func testSpreadsheets() {
|
||||
section("Spreadsheets")
|
||||
let csv = directory.appendingPathComponent("table.csv")
|
||||
try? "Name,Score\nAda,42\nGrace,\"7,5\"\n".write(to: csv, atomically: true, encoding: .utf8)
|
||||
|
||||
check("csv parse") {
|
||||
let rows = try SpreadsheetConverter.read(csv)
|
||||
guard rows.count == 3, rows[1][0] == "Ada", rows[2][1] == "7,5" else {
|
||||
throw TestFailure("parsed \(rows)")
|
||||
}
|
||||
}
|
||||
|
||||
check("csv → xlsx → csv") {
|
||||
let xlsx = directory.appendingPathComponent("table.xlsx")
|
||||
let rows = try SpreadsheetConverter.read(csv)
|
||||
try XLSX.write(rows, to: xlsx)
|
||||
try requireFile(xlsx)
|
||||
let roundTrip = try XLSX.read(xlsx)
|
||||
guard roundTrip.count == rows.count, roundTrip[1][0] == "Ada" else {
|
||||
throw TestFailure("round trip produced \(roundTrip)")
|
||||
}
|
||||
}
|
||||
|
||||
check("csv → tsv") {
|
||||
let tsv = directory.appendingPathComponent("table.tsv")
|
||||
try SpreadsheetConverter.convert(source: csv, to: .tsv, at: tsv)
|
||||
let text = try String(contentsOf: tsv, encoding: .utf8)
|
||||
guard text.contains("\t") else { throw TestFailure("no tabs in output") }
|
||||
}
|
||||
}
|
||||
|
||||
private static func testArchives() {
|
||||
section("Archives")
|
||||
let folder = directory.appendingPathComponent("bundle", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
try? "archive me".write(to: folder.appendingPathComponent("note.txt"), atomically: true, encoding: .utf8)
|
||||
|
||||
check("folder → zip") {
|
||||
let zip = directory.appendingPathComponent("bundle.zip")
|
||||
try ArchiveConverter.convert(source: folder, to: .zip, at: zip)
|
||||
try requireFile(zip)
|
||||
}
|
||||
|
||||
check("zip → extract") {
|
||||
let zip = directory.appendingPathComponent("bundle.zip")
|
||||
let target = directory.appendingPathComponent("unpacked", isDirectory: true)
|
||||
try ArchiveConverter.extract(source: zip, into: target)
|
||||
let note = target.appendingPathComponent("bundle/note.txt")
|
||||
guard FileManager.default.fileExists(atPath: note.path) else {
|
||||
throw TestFailure("note.txt missing after extraction")
|
||||
}
|
||||
}
|
||||
|
||||
check("folder → tar") {
|
||||
let tar = directory.appendingPathComponent("bundle.tar")
|
||||
try ArchiveConverter.convert(source: folder, to: .tar, at: tar)
|
||||
try requireFile(tar)
|
||||
}
|
||||
}
|
||||
|
||||
private static func testDocuments() {
|
||||
section("Documents")
|
||||
let text = directory.appendingPathComponent("note.txt")
|
||||
try? "Hello from Konverter.\n\nSecond paragraph.\n".write(to: text, atomically: true, encoding: .utf8)
|
||||
|
||||
check("txt → md") {
|
||||
let markdown = directory.appendingPathComponent("note.md")
|
||||
try DocumentConverter.convert(source: text, to: .md, at: markdown)
|
||||
let contents = try String(contentsOf: markdown, encoding: .utf8)
|
||||
guard contents.contains("Hello from Konverter") else { throw TestFailure("text lost") }
|
||||
}
|
||||
|
||||
check("md → html") {
|
||||
let markdown = directory.appendingPathComponent("note.md")
|
||||
try? "# Title\n\nSome **bold** text.\n".write(to: markdown, atomically: true, encoding: .utf8)
|
||||
let html = directory.appendingPathComponent("note.html")
|
||||
try DocumentConverter.convert(source: markdown, to: .html, at: html)
|
||||
let contents = try String(contentsOf: html, encoding: .utf8)
|
||||
guard contents.contains("<strong>bold</strong>") else { throw TestFailure("markdown not rendered") }
|
||||
}
|
||||
|
||||
check("txt → pdf") {
|
||||
let pdf = directory.appendingPathComponent("note.pdf")
|
||||
try DocumentConverter.convert(source: text, to: .pdf, at: pdf)
|
||||
try requireFile(pdf)
|
||||
guard PDFConverter.pageCount(pdf) >= 1 else { throw TestFailure("empty pdf") }
|
||||
}
|
||||
|
||||
check("txt → docx") {
|
||||
let docx = directory.appendingPathComponent("note.docx")
|
||||
try DocumentConverter.convert(source: text, to: .docx, at: docx)
|
||||
try requireFile(docx)
|
||||
}
|
||||
|
||||
check("html → md") {
|
||||
let markdown = Markdown.markdown(fromHTML: "<h1>Title</h1><p>Hello <strong>world</strong></p>")
|
||||
guard markdown.contains("# Title"), markdown.contains("**world**") else {
|
||||
throw TestFailure("html not converted: \(markdown)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func testAudio() {
|
||||
section("Audio")
|
||||
let wav = directory.appendingPathComponent("tone.wav")
|
||||
|
||||
check("create wav") {
|
||||
try makeWAV(at: wav)
|
||||
try requireFile(wav)
|
||||
}
|
||||
|
||||
check("wav → m4a") {
|
||||
let m4a = directory.appendingPathComponent("tone.m4a")
|
||||
try AVConverter.convert(source: wav, to: .m4a, at: m4a)
|
||||
try requireFile(m4a)
|
||||
}
|
||||
|
||||
check("wav → aiff") {
|
||||
let aiff = directory.appendingPathComponent("tone.aiff")
|
||||
try CoreAudio.convert(source: wav, to: .aiff, at: aiff)
|
||||
try requireFile(aiff)
|
||||
}
|
||||
|
||||
check("media info") {
|
||||
let info = MediaInfo.describe(wav)
|
||||
guard info.contains("Duration") else { throw TestFailure("no duration in \(info)") }
|
||||
}
|
||||
}
|
||||
|
||||
private static func testEbook() {
|
||||
section("EPUB")
|
||||
let html = "<h1>Chapter</h1><p>An ebook built from scratch.</p>"
|
||||
let epub = directory.appendingPathComponent("book.epub")
|
||||
|
||||
check("build epub") {
|
||||
try EbookConverter.write(
|
||||
title: "Test Book",
|
||||
author: "Konverter",
|
||||
chapters: [("One", html), ("Two", "<p>More text.</p>")],
|
||||
at: epub
|
||||
)
|
||||
try requireFile(epub)
|
||||
}
|
||||
|
||||
check("epub → txt") {
|
||||
let text = try EbookConverter.plainText(epub)
|
||||
guard text.contains("ebook built from scratch") else {
|
||||
throw TestFailure("chapter text missing: \(text)")
|
||||
}
|
||||
}
|
||||
|
||||
check("epub → md") {
|
||||
let markdown = directory.appendingPathComponent("book.md")
|
||||
try EbookConverter.convert(source: epub, to: .md, at: markdown)
|
||||
let contents = try String(contentsOf: markdown, encoding: .utf8)
|
||||
guard contents.contains("ebook built") else { throw TestFailure("markdown lost the text") }
|
||||
}
|
||||
}
|
||||
|
||||
private static func testGeometry() {
|
||||
section("Wheel geometry")
|
||||
let frame = CGRect(x: 0, y: 0, width: 400, height: 400)
|
||||
let items = (0..<8).map { index in
|
||||
WheelItem(
|
||||
id: "item-\(index)",
|
||||
payload: .format(.png),
|
||||
title: "Item \(index)",
|
||||
subtitle: nil,
|
||||
symbol: "circle",
|
||||
availability: .ok
|
||||
)
|
||||
}
|
||||
let radius: CGFloat = 160
|
||||
|
||||
check("top hit selects the first item") {
|
||||
let index = WheelController.selectionIndex(
|
||||
for: CGPoint(x: 200, y: 340),
|
||||
in: frame,
|
||||
items: items,
|
||||
radius: radius
|
||||
)
|
||||
guard index == 0 else { throw TestFailure("got \(String(describing: index))") }
|
||||
}
|
||||
|
||||
check("dead zone selects nothing") {
|
||||
let index = WheelController.selectionIndex(
|
||||
for: CGPoint(x: 200, y: 200),
|
||||
in: frame,
|
||||
items: items,
|
||||
radius: radius
|
||||
)
|
||||
guard index == nil else { throw TestFailure("got \(String(describing: index))") }
|
||||
}
|
||||
|
||||
check("clockwise hit selects item 2") {
|
||||
let index = WheelController.selectionIndex(
|
||||
for: CGPoint(x: 360, y: 200),
|
||||
in: frame,
|
||||
items: items,
|
||||
radius: radius
|
||||
)
|
||||
guard index == 2 else { throw TestFailure("got \(String(describing: index))") }
|
||||
}
|
||||
|
||||
check("conflict naming") {
|
||||
let existing = directory.appendingPathComponent("taken.txt")
|
||||
try? "x".write(to: existing, atomically: true, encoding: .utf8)
|
||||
let candidate = OutputNaming.availableURL(for: existing)
|
||||
guard candidate.lastPathComponent == "taken 2.txt" else {
|
||||
throw TestFailure("got \(candidate.lastPathComponent)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Harness
|
||||
|
||||
private struct TestFailure: LocalizedError {
|
||||
let message: String
|
||||
init(_ message: String) { self.message = message }
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
private static func section(_ name: String) {
|
||||
print("— \(name)")
|
||||
}
|
||||
|
||||
private static func check(_ name: String, _ body: () throws -> Void) {
|
||||
do {
|
||||
try body()
|
||||
passes += 1
|
||||
print("PASS \(name)")
|
||||
} catch {
|
||||
failures += 1
|
||||
let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
||||
print("FAIL \(name): \(message)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func requireFile(_ url: URL) throws {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
throw TestFailure("\(url.lastPathComponent) was not created")
|
||||
}
|
||||
let size = try fileSize(url)
|
||||
guard size > 0 else {
|
||||
throw TestFailure("\(url.lastPathComponent) is empty")
|
||||
}
|
||||
}
|
||||
|
||||
private static func fileSize(_ url: URL) throws -> Int {
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||
return attributes[.size] as? Int ?? 0
|
||||
}
|
||||
|
||||
private static func makeImage(at url: URL, width: Int, height: Int) throws {
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else { throw TestFailure("could not create a bitmap") }
|
||||
|
||||
context.setFillColor(CGColor(srgbRed: 0.98, green: 0.52, blue: 0.18, alpha: 1))
|
||||
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
||||
context.setFillColor(CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 1))
|
||||
context.fillEllipse(in: CGRect(x: width / 4, y: height / 4, width: width / 2, height: height / 2))
|
||||
|
||||
guard let image = context.makeImage() else { throw TestFailure("could not render the bitmap") }
|
||||
try Raster.write(image, to: url, format: .png, quality: 1, metadata: nil)
|
||||
}
|
||||
|
||||
private static func makeWAV(at url: URL) throws {
|
||||
let sampleRate = 22050
|
||||
let seconds = 0.6
|
||||
let sampleCount = Int(Double(sampleRate) * seconds)
|
||||
var samples = Data()
|
||||
for index in 0..<sampleCount {
|
||||
let value = sin(2 * Double.pi * 440 * Double(index) / Double(sampleRate)) * 0.3
|
||||
var little = Int16(value * Double(Int16.max)).littleEndian
|
||||
samples.append(Data(bytes: &little, count: 2))
|
||||
}
|
||||
|
||||
var data = Data()
|
||||
func append(_ string: String) { data.append(Data(string.utf8)) }
|
||||
func append(_ value: UInt32) { var v = value.littleEndian; data.append(Data(bytes: &v, count: 4)) }
|
||||
func append(_ value: UInt16) { var v = value.littleEndian; data.append(Data(bytes: &v, count: 2)) }
|
||||
|
||||
append("RIFF")
|
||||
append(UInt32(36 + samples.count))
|
||||
append("WAVE")
|
||||
append("fmt ")
|
||||
append(UInt32(16))
|
||||
append(UInt16(1))
|
||||
append(UInt16(1))
|
||||
append(UInt32(sampleRate))
|
||||
append(UInt32(sampleRate * 2))
|
||||
append(UInt16(2))
|
||||
append(UInt16(16))
|
||||
append("data")
|
||||
append(UInt32(samples.count))
|
||||
data.append(samples)
|
||||
|
||||
try data.write(to: url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import PDFKit
|
||||
import QuickLookThumbnailing
|
||||
import SwiftUI
|
||||
|
||||
/// "Float": a borderless preview that hovers over other windows, the same
|
||||
/// gesture Tangerine offers from the Control wheel.
|
||||
final class FloatPanel: NSObject {
|
||||
static let shared = FloatPanel()
|
||||
|
||||
private var window: NSWindow?
|
||||
private var urls: [URL] = []
|
||||
private var index = 0
|
||||
|
||||
private override init() { super.init() }
|
||||
|
||||
@MainActor
|
||||
func show(urls: [URL]) {
|
||||
self.urls = urls
|
||||
index = 0
|
||||
let window = ensureWindow()
|
||||
if let screen = NSScreen.screens.first(where: { $0.frame.contains(NSEvent.mouseLocation) }) ?? NSScreen.main {
|
||||
let frame = screen.visibleFrame
|
||||
let size = CGSize(width: 420, height: 320)
|
||||
window.setContentSize(size)
|
||||
window.setFrameOrigin(CGPoint(
|
||||
x: min(max(frame.minX + 16, NSEvent.mouseLocation.x - size.width / 2), frame.maxX - size.width - 16),
|
||||
y: min(max(frame.minY + 16, NSEvent.mouseLocation.y - size.height - 24), frame.maxY - size.height - 16)
|
||||
))
|
||||
}
|
||||
window.alphaValue = 0
|
||||
window.orderFrontRegardless()
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.18
|
||||
window.animator().alphaValue = 1
|
||||
}
|
||||
render()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func ensureWindow() -> NSWindow {
|
||||
if let window { return window }
|
||||
let window = OverlayWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 420, height: 320),
|
||||
styleMask: [.borderless],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.isOpaque = false
|
||||
window.backgroundColor = .clear
|
||||
window.hasShadow = true
|
||||
window.level = .floating
|
||||
window.isMovableByWindowBackground = true
|
||||
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
window.contentView = NSHostingView(rootView: FloatView(controller: self))
|
||||
self.window = window
|
||||
return window
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func render() {
|
||||
guard let window, let content = window.contentView as? NSHostingView<FloatView> else { return }
|
||||
content.rootView = FloatView(controller: self)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func close() {
|
||||
guard let window else { return }
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.15
|
||||
window.animator().alphaValue = 0
|
||||
} completionHandler: {
|
||||
window.orderOut(nil)
|
||||
}
|
||||
urls = []
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func step(_ delta: Int) {
|
||||
guard !urls.isEmpty else { return }
|
||||
index = ((index + delta) % urls.count + urls.count) % urls.count
|
||||
render()
|
||||
}
|
||||
|
||||
var currentURL: URL? {
|
||||
urls.indices.contains(index) ? urls[index] : nil
|
||||
}
|
||||
|
||||
var counter: String? {
|
||||
urls.count > 1 ? "\(index + 1) of \(urls.count)" : nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func openInDefaultApp() {
|
||||
guard let url = currentURL else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func reveal() {
|
||||
guard let url = currentURL else { return }
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
}
|
||||
}
|
||||
|
||||
struct FloatView: View {
|
||||
let controller: FloatPanel
|
||||
|
||||
@State private var image: NSImage?
|
||||
@State private var failure: String?
|
||||
@State private var isLoading = true
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Group {
|
||||
if let image {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.padding(10)
|
||||
} else if let failure {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "eye.slash")
|
||||
.font(.system(size: 22))
|
||||
Text(failure)
|
||||
.font(.system(size: 11))
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
.padding()
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
Divider().opacity(0.35)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "doc")
|
||||
.font(.system(size: 11))
|
||||
Text(controller.currentURL?.lastPathComponent ?? "")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
if let counter = controller.counter {
|
||||
Text(counter)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
controller.step(-1)
|
||||
} label: { Image(systemName: "chevron.left") }
|
||||
.buttonStyle(.plain)
|
||||
Button {
|
||||
controller.step(1)
|
||||
} label: { Image(systemName: "chevron.right") }
|
||||
.buttonStyle(.plain)
|
||||
Button("Open") { controller.openInDefaultApp() }
|
||||
.buttonStyle(.plain)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
Button {
|
||||
controller.close()
|
||||
} label: { Image(systemName: "xmark") }
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(Color.primary.opacity(0.1))
|
||||
)
|
||||
.task(id: controller.currentURL) {
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
guard let url = controller.currentURL else {
|
||||
image = nil
|
||||
failure = "Nothing to preview"
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
failure = nil
|
||||
|
||||
// Images and PDFs get a real render; everything else falls back to a
|
||||
// Quick Look thumbnail.
|
||||
if FileKind.category(of: url) == .image, let loaded = NSImage(contentsOf: url) {
|
||||
image = loaded
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
if url.pathExtension.lowercased() == "pdf", let page = PDFKitPreview.image(for: url) {
|
||||
image = page
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
let request = QLThumbnailGenerator.Request(
|
||||
fileAt: url,
|
||||
size: CGSize(width: 900, height: 700),
|
||||
scale: NSScreen.main?.backingScaleFactor ?? 2,
|
||||
representationTypes: .all
|
||||
)
|
||||
let representation = try? await QLThumbnailGenerator.shared.generateBestRepresentation(for: request)
|
||||
if let cgImage = representation?.cgImage {
|
||||
image = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width / 2, height: cgImage.height / 2))
|
||||
} else {
|
||||
image = nil
|
||||
failure = "No preview available for .\(url.pathExtension)"
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
enum PDFKitPreview {
|
||||
static func image(for url: URL) -> NSImage? {
|
||||
guard let document = PDFDocument(url: url), let page = document.page(at: 0) else { return nil }
|
||||
let bounds = page.bounds(for: .mediaBox)
|
||||
let scale: CGFloat = 1.6
|
||||
let size = CGSize(width: bounds.width * scale, height: bounds.height * scale)
|
||||
let thumbnail = page.thumbnail(of: size, for: .mediaBox)
|
||||
return thumbnail
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// The small job window. Tangerine opens one per job; a single stack that
|
||||
/// shows every job is less noisy and can be dismissed in one click.
|
||||
final class JobPanelController: NSObject {
|
||||
static let shared = JobPanelController()
|
||||
|
||||
private var panel: NSPanel?
|
||||
|
||||
private override init() { super.init() }
|
||||
|
||||
func refresh(store: JobStore) {
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async { self.refresh(store: store) }
|
||||
return
|
||||
}
|
||||
guard Settings.shared.showProgressWindow else { return }
|
||||
guard !store.jobs.isEmpty else {
|
||||
panel?.orderOut(nil)
|
||||
panel = nil
|
||||
return
|
||||
}
|
||||
|
||||
let panel = ensurePanel()
|
||||
if !panel.isVisible {
|
||||
position(panel, near: store.jobs.last?.origin)
|
||||
panel.alphaValue = 0
|
||||
panel.orderFrontRegardless()
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.18
|
||||
panel.animator().alphaValue = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dismissIfAllFinished(store: JobStore) {
|
||||
guard store.activeCount == 0 else { return }
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
guard let self, let panel = self.panel, JobStore.shared.activeCount == 0 else { return }
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.2
|
||||
panel.animator().alphaValue = 0
|
||||
} completionHandler: {
|
||||
panel.orderOut(nil)
|
||||
JobStore.shared.dismissFinished()
|
||||
self.panel = nil
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.6, execute: work)
|
||||
}
|
||||
|
||||
func show() {
|
||||
refresh(store: .shared)
|
||||
}
|
||||
|
||||
private func ensurePanel() -> NSPanel {
|
||||
if let panel { return panel }
|
||||
let panel = NSPanel(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 320, height: 120),
|
||||
styleMask: [.borderless, .nonactivatingPanel],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
panel.isFloatingPanel = true
|
||||
panel.level = .floating
|
||||
panel.isOpaque = false
|
||||
panel.backgroundColor = .clear
|
||||
panel.hasShadow = true
|
||||
panel.hidesOnDeactivate = false
|
||||
panel.becomesKeyOnlyIfNeeded = true
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
panel.contentView = NSHostingView(rootView: JobsView(store: .shared))
|
||||
self.panel = panel
|
||||
return panel
|
||||
}
|
||||
|
||||
private func position(_ panel: NSPanel, near origin: CGPoint?) {
|
||||
let size = panel.contentView?.fittingSize ?? CGSize(width: 320, height: 140)
|
||||
panel.setContentSize(size)
|
||||
|
||||
let screen = NSScreen.screens.first { $0.frame.contains(origin ?? .zero) } ?? NSScreen.main
|
||||
guard let frame = screen?.visibleFrame else { return }
|
||||
|
||||
var point = CGPoint(x: frame.midX - size.width / 2, y: frame.minY + 80)
|
||||
if let origin {
|
||||
point = CGPoint(x: origin.x + 26, y: origin.y - size.height / 2)
|
||||
}
|
||||
point.x = min(max(frame.minX + 12, point.x), frame.maxX - size.width - 12)
|
||||
point.y = min(max(frame.minY + 12, point.y), frame.maxY - size.height - 12)
|
||||
panel.setFrameOrigin(point)
|
||||
}
|
||||
|
||||
/// Re-centres and resizes once the content changes.
|
||||
func layout() {
|
||||
guard let panel, panel.isVisible else { return }
|
||||
let size = panel.contentView?.fittingSize ?? panel.frame.size
|
||||
let anchor = CGPoint(x: panel.frame.minX, y: panel.frame.midY)
|
||||
panel.setContentSize(size)
|
||||
panel.setFrameOrigin(CGPoint(x: anchor.x, y: anchor.y - size.height / 2))
|
||||
}
|
||||
}
|
||||
|
||||
struct JobsView: View {
|
||||
@ObservedObject var store: JobStore
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
Text(store.activeCount > 0 ? "Working…" : "Finished")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
Spacer()
|
||||
Button {
|
||||
store.cancelAll()
|
||||
} label: {
|
||||
Image(systemName: "stop.circle")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Cancel everything")
|
||||
.opacity(store.activeCount > 0 ? 1 : 0)
|
||||
Button {
|
||||
store.dismissFinished()
|
||||
JobPanelController.shared.performClose()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Close")
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 6)
|
||||
|
||||
Divider().opacity(0.4)
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(store.jobs) { job in
|
||||
JobRow(job: job, store: store)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
.frame(maxHeight: 260)
|
||||
}
|
||||
.frame(width: 320)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(Color.primary.opacity(0.09))
|
||||
)
|
||||
.onReceive(store.$jobs) { _ in
|
||||
DispatchQueue.main.async {
|
||||
JobPanelController.shared.layout()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct JobRow: View {
|
||||
let job: Job
|
||||
@ObservedObject var store: JobStore
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
Text(job.title)
|
||||
.font(.system(size: 11.5, weight: .medium))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer(minLength: 4)
|
||||
if job.status == .running {
|
||||
Button {
|
||||
store.cancel(job.id)
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Cancel this job")
|
||||
} else {
|
||||
Button {
|
||||
store.remove(job.id)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: 9))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Remove")
|
||||
}
|
||||
}
|
||||
|
||||
if job.status == .running {
|
||||
ProgressView(value: max(0.02, job.progress))
|
||||
.progressViewStyle(.linear)
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Text(detail)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
Spacer(minLength: 4)
|
||||
if !job.outputs.isEmpty, job.status == .finished {
|
||||
Button("Reveal") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting(job.outputs)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var detail: String {
|
||||
if let error = job.errorMessage { return error }
|
||||
if job.sourceCount > 1, job.status == .finished, !job.outputs.isEmpty {
|
||||
return "\(job.outputs.count) files written"
|
||||
}
|
||||
return job.detail.isEmpty ? job.title : job.detail
|
||||
}
|
||||
|
||||
private var symbol: String {
|
||||
switch job.status {
|
||||
case .running: return "hourglass"
|
||||
case .finished: return "checkmark.circle.fill"
|
||||
case .failed: return "exclamationmark.triangle.fill"
|
||||
case .cancelled: return "slash.circle"
|
||||
}
|
||||
}
|
||||
|
||||
private var tint: Color {
|
||||
switch job.status {
|
||||
case .running: return .secondary
|
||||
case .finished: return .green
|
||||
case .failed: return .orange
|
||||
case .cancelled: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension JobPanelController {
|
||||
func performClose() {
|
||||
panel?.orderOut(nil)
|
||||
panel = nil
|
||||
JobStore.shared.dismissFinished()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// The menu bar item: the app's only permanent UI, and the place to turn the
|
||||
/// wheel off or fall back to picking files by hand.
|
||||
final class MenuBarController: NSObject, NSMenuDelegate {
|
||||
private var statusItem: NSStatusItem?
|
||||
|
||||
func install() {
|
||||
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
if let button = item.button {
|
||||
let image = NSImage(systemSymbolName: "arrow.triangle.2.circlepath", accessibilityDescription: "Konverter")
|
||||
image?.isTemplate = true
|
||||
button.image = image
|
||||
button.setAccessibilityLabel("Konverter")
|
||||
}
|
||||
let menu = NSMenu()
|
||||
menu.delegate = self
|
||||
item.menu = menu
|
||||
statusItem = item
|
||||
rebuild(menu)
|
||||
}
|
||||
|
||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||
rebuild(menu)
|
||||
}
|
||||
|
||||
private func rebuild(_ menu: NSMenu) {
|
||||
menu.removeAllItems()
|
||||
|
||||
let enabled = Settings.shared.enabled
|
||||
let toggle = NSMenuItem(
|
||||
title: enabled ? "Konverter Active" : "Konverter Paused",
|
||||
action: #selector(toggleEnabled),
|
||||
keyEquivalent: ""
|
||||
)
|
||||
toggle.target = self
|
||||
toggle.state = enabled ? .on : .off
|
||||
menu.addItem(toggle)
|
||||
|
||||
menu.addItem(.separator())
|
||||
|
||||
let convert = NSMenuItem(title: "Konvert Files…", action: #selector(convertFiles), keyEquivalent: "o")
|
||||
convert.target = self
|
||||
menu.addItem(convert)
|
||||
|
||||
let jobs = NSMenuItem(title: "Show Jobs", action: #selector(showJobs), keyEquivalent: "j")
|
||||
jobs.target = self
|
||||
menu.addItem(jobs)
|
||||
|
||||
menu.addItem(.separator())
|
||||
|
||||
let settings = NSMenuItem(title: "Settings…", action: #selector(showSettings), keyEquivalent: ",")
|
||||
settings.target = self
|
||||
menu.addItem(settings)
|
||||
|
||||
let about = NSMenuItem(title: "About Konverter", action: #selector(showAbout), keyEquivalent: "")
|
||||
about.target = self
|
||||
menu.addItem(about)
|
||||
|
||||
menu.addItem(.separator())
|
||||
|
||||
let quit = NSMenuItem(title: "Quit Konverter", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
|
||||
quit.target = NSApp
|
||||
menu.addItem(quit)
|
||||
}
|
||||
|
||||
@objc private func toggleEnabled() {
|
||||
Settings.shared.enabled.toggle()
|
||||
if !Settings.shared.enabled {
|
||||
WheelController.shared.cancel()
|
||||
}
|
||||
if let menu = statusItem?.menu {
|
||||
rebuild(menu)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func convertFiles() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = true
|
||||
panel.allowsMultipleSelection = true
|
||||
panel.prompt = "Choose"
|
||||
panel.message = "Pick files to convert, then choose an output format"
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
guard panel.runModal() == .OK, !panel.urls.isEmpty else { return }
|
||||
// Let the panel close before the menu opens over it.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
QuickMenu.show(urls: panel.urls, at: NSEvent.mouseLocation)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func showJobs() {
|
||||
JobStore.shared.panelVisible = true
|
||||
JobPanelController.shared.show()
|
||||
}
|
||||
|
||||
@objc private func showSettings() {
|
||||
PreferencesWindowController.shared.show()
|
||||
}
|
||||
|
||||
@objc private func showAbout() {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
NSApp.orderFrontStandardAboutPanel(options: [
|
||||
.applicationName: "Konverter",
|
||||
.applicationVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0",
|
||||
.credits: NSAttributedString(
|
||||
string: "Drag a file in Finder, then hold a key.\nShift converts · Command shows tools · Control shows file actions.\nEverything runs on this Mac.",
|
||||
attributes: [.font: NSFont.systemFont(ofSize: 11)]
|
||||
)
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Small, non-modal confirmations ("Path copied", "3 files converted").
|
||||
/// Nothing here blocks the user's flow, which is the whole point of the app.
|
||||
enum Notifier {
|
||||
private static var toastWindow: NSWindow?
|
||||
private static var dismissWorkItem: DispatchWorkItem?
|
||||
|
||||
@MainActor
|
||||
static func flash(title: String, body: String? = nil, symbol: String = "checkmark.circle.fill") {
|
||||
guard Settings.shared.showProgressWindow else { return }
|
||||
show(title: title, body: body, symbol: symbol, tone: .success, duration: 2.0)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func error(_ title: String, _ message: String) {
|
||||
show(title: title, body: message, symbol: "exclamationmark.triangle.fill", tone: .failure, duration: 6.0)
|
||||
}
|
||||
|
||||
private enum Tone { case success, failure }
|
||||
|
||||
@MainActor
|
||||
private static func show(title: String, body: String?, symbol: String, tone: Tone, duration: TimeInterval) {
|
||||
dismissWorkItem?.cancel()
|
||||
toastWindow?.close()
|
||||
|
||||
let accent: Color = tone == .success ? .green : .orange
|
||||
let view = HStack(spacing: 10) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(accent)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
if let body, !body.isEmpty {
|
||||
Text(body)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.frame(minWidth: 220, maxWidth: 420, alignment: .leading)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Color.primary.opacity(0.08))
|
||||
)
|
||||
|
||||
let controller = NSHostingController(rootView: view)
|
||||
let window = NSWindow(contentViewController: controller)
|
||||
window.styleMask = [.borderless]
|
||||
window.isOpaque = false
|
||||
window.backgroundColor = .clear
|
||||
window.hasShadow = true
|
||||
window.level = .statusBar
|
||||
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
||||
window.ignoresMouseEvents = true
|
||||
window.appearance = NSApp.effectiveAppearance
|
||||
|
||||
let size = controller.view.fittingSize
|
||||
let screen = NSScreen.main ?? NSScreen.screens.first
|
||||
if let frame = screen?.visibleFrame {
|
||||
let origin = CGPoint(x: frame.midX - size.width / 2, y: frame.minY + 96)
|
||||
window.setFrame(CGRect(origin: origin, size: size), display: true)
|
||||
}
|
||||
window.orderFrontRegardless()
|
||||
toastWindow = window
|
||||
|
||||
let work = DispatchWorkItem { [weak window] in
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.25
|
||||
window?.animator().alphaValue = 0
|
||||
} completionHandler: {
|
||||
window?.close()
|
||||
}
|
||||
if toastWindow === window { toastWindow = nil }
|
||||
}
|
||||
dismissWorkItem = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: work)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// The form a tool needs before it can run (quality, trim points, password …).
|
||||
/// Values are remembered per tool, so the second time is still one drop.
|
||||
final class OptionsWindowController: NSObject {
|
||||
static let shared = OptionsWindowController()
|
||||
|
||||
private var window: NSWindow?
|
||||
private var tool: ToolID?
|
||||
private var urls: [URL] = []
|
||||
private var origin: CGPoint?
|
||||
private var params = ToolParams()
|
||||
|
||||
private override init() { super.init() }
|
||||
|
||||
func present(tool: ToolID, urls: [URL], origin: CGPoint?) {
|
||||
self.tool = tool
|
||||
self.urls = urls
|
||||
self.origin = origin
|
||||
params = Settings.shared.params(for: tool)
|
||||
params.fillMissing(from: tool)
|
||||
|
||||
let view = OptionsView(
|
||||
tool: tool,
|
||||
params: params,
|
||||
fileCount: urls.count,
|
||||
onApply: { [weak self] values in self?.apply(values) },
|
||||
onCancel: { [weak self] in self?.close() }
|
||||
)
|
||||
|
||||
close()
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 340, height: 120),
|
||||
styleMask: [.titled, .closable, .fullSizeContentView],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = tool.title
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.isMovableByWindowBackground = true
|
||||
window.level = .floating
|
||||
window.contentView = NSHostingView(rootView: view)
|
||||
window.center()
|
||||
if let origin, let screen = NSScreen.screens.first(where: { $0.frame.contains(origin) }) ?? NSScreen.main {
|
||||
let frame = screen.visibleFrame
|
||||
window.setFrameOrigin(CGPoint(
|
||||
x: min(max(frame.minX + 20, origin.x - 170), frame.maxX - 360),
|
||||
y: min(max(frame.minY + 20, origin.y - 180), frame.maxY - 220)
|
||||
))
|
||||
}
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
self.window = window
|
||||
}
|
||||
|
||||
private func apply(_ values: ToolParams) {
|
||||
guard let tool else { return }
|
||||
Settings.shared.store(params: values, for: tool)
|
||||
let captured = urls
|
||||
let point = origin
|
||||
close()
|
||||
JobStore.shared.submit(urls: captured, target: .tool(tool), params: values, origin: point)
|
||||
}
|
||||
|
||||
private func close() {
|
||||
window?.orderOut(nil)
|
||||
window = nil
|
||||
}
|
||||
}
|
||||
|
||||
struct OptionsView: View {
|
||||
let tool: ToolID
|
||||
let fileCount: Int
|
||||
let onApply: (ToolParams) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@State private var values: [String: String] = [:]
|
||||
|
||||
init(
|
||||
tool: ToolID,
|
||||
params: ToolParams,
|
||||
fileCount: Int,
|
||||
onApply: @escaping (ToolParams) -> Void,
|
||||
onCancel: @escaping () -> Void
|
||||
) {
|
||||
self.tool = tool
|
||||
self.fileCount = fileCount
|
||||
self.onApply = onApply
|
||||
self.onCancel = onCancel
|
||||
var initial: [String: String] = [:]
|
||||
for field in tool.fields {
|
||||
initial[field.id] = params[field.id] ?? field.defaultValue
|
||||
}
|
||||
_values = State(initialValue: initial)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: tool.symbol)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(tool.title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
Text(fileCount == 1 ? "1 file" : "\(fileCount) files")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(tool.fields) { field in
|
||||
fieldView(field)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel", action: onCancel)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Button("Apply") {
|
||||
var params = ToolParams()
|
||||
for (key, value) in values { params[key] = value }
|
||||
onApply(params)
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(width: 340)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func fieldView(_ field: ToolField) -> some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack {
|
||||
Text(field.label)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
Spacer()
|
||||
if let unit = field.unit {
|
||||
Text(unit)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
control(for: field)
|
||||
if let help = field.help {
|
||||
Text(help)
|
||||
.font(.system(size: 9.5))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func control(for field: ToolField) -> some View {
|
||||
let binding = Binding<String>(
|
||||
get: { values[field.id] ?? field.defaultValue },
|
||||
set: { values[field.id] = $0 }
|
||||
)
|
||||
|
||||
switch field.kind {
|
||||
case .text:
|
||||
TextField("", text: binding)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
case .secret:
|
||||
SecureField("", text: binding)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
case .toggle:
|
||||
Toggle("", isOn: Binding(
|
||||
get: { (values[field.id] ?? field.defaultValue) == "true" },
|
||||
set: { values[field.id] = $0 ? "true" : "false" }
|
||||
))
|
||||
.labelsHidden()
|
||||
.toggleStyle(.switch)
|
||||
case .choice(let options):
|
||||
Picker("", selection: binding) {
|
||||
ForEach(options, id: \.self) { option in
|
||||
Text(option).tag(option)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
case .integer, .decimal, .timecode:
|
||||
TextField("", text: binding)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Settings window. Also the place where the active conversion engines are
|
||||
/// listed, so "why can't I export MKV?" has an answer in the app itself.
|
||||
final class PreferencesWindowController {
|
||||
static let shared = PreferencesWindowController()
|
||||
|
||||
private var window: NSWindow?
|
||||
|
||||
private init() {}
|
||||
|
||||
func show() {
|
||||
if let window {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
return
|
||||
}
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 520, height: 620),
|
||||
styleMask: [.titled, .closable, .fullSizeContentView],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "Konverter Settings"
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.contentView = NSHostingView(rootView: PreferencesView())
|
||||
window.center()
|
||||
window.isReleasedWhenClosed = false
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
self.window = window
|
||||
}
|
||||
}
|
||||
|
||||
struct PreferencesView: View {
|
||||
@ObservedObject private var settings = Settings.shared
|
||||
@State private var loginItemError: String?
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
general.tabItem { Label("General", systemImage: "gearshape") }
|
||||
wheel.tabItem { Label("Wheel", systemImage: "circle.hexagongrid") }
|
||||
engines.tabItem { Label("Engines", systemImage: "cpu") }
|
||||
}
|
||||
.padding(16)
|
||||
.frame(width: 520, height: 600)
|
||||
}
|
||||
|
||||
private var general: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle("Konverter is active", isOn: $settings.enabled)
|
||||
Toggle("Enable at login", isOn: Binding(
|
||||
get: { settings.openAtLogin },
|
||||
set: { value in
|
||||
if LoginItem.set(value) {
|
||||
settings.openAtLogin = value
|
||||
loginItemError = nil
|
||||
} else {
|
||||
settings.openAtLogin = LoginItem.isEnabled
|
||||
loginItemError = "Could not update the login item. Move Konverter to /Applications and try again."
|
||||
}
|
||||
}
|
||||
))
|
||||
if let loginItemError {
|
||||
Text(loginItemError)
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
} header: {
|
||||
Text("Startup")
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Save converted files", selection: $settings.outputLocation) {
|
||||
ForEach(OutputLocation.allCases) { location in
|
||||
Text(location.title).tag(location)
|
||||
}
|
||||
}
|
||||
if settings.outputLocation == .chosenFolder {
|
||||
HStack {
|
||||
Text(settings.outputFolderPath.isEmpty ? "No folder chosen" : settings.outputFolderPath)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
Button("Choose…") { chooseFolder() }
|
||||
}
|
||||
}
|
||||
Picker("When a file already exists", selection: $settings.conflictPolicy) {
|
||||
ForEach(ConflictPolicy.allCases) { policy in
|
||||
Text(policy.title).tag(policy)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Output")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Show the job window", isOn: $settings.showProgressWindow)
|
||||
Toggle("Reveal files in Finder when finished", isOn: $settings.revealWhenFinished)
|
||||
Toggle("Play a sound when finished", isOn: $settings.playSound)
|
||||
} header: {
|
||||
Text("Feedback")
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private var wheel: some View {
|
||||
Form {
|
||||
Section {
|
||||
Picker("Convert", selection: $settings.convertKey) {
|
||||
ForEach(TriggerKey.allCases) { key in Text(key.title).tag(key) }
|
||||
}
|
||||
Picker("Tools", selection: $settings.toolsKey) {
|
||||
ForEach(TriggerKey.allCases) { key in Text(key.title).tag(key) }
|
||||
}
|
||||
Picker("Actions", selection: $settings.actionsKey) {
|
||||
ForEach(TriggerKey.allCases) { key in Text(key.title).tag(key) }
|
||||
}
|
||||
Toggle("Tools also require Shift", isOn: $settings.requireShiftForTools)
|
||||
} header: {
|
||||
Text("Trigger keys")
|
||||
} footer: {
|
||||
Text("Start dragging a file in Finder, then hold a key. The wheel stays open until you drop or press Escape. Keys must differ, otherwise the first match wins (Actions, then Tools, then Convert).")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Style", selection: $settings.wheelStyle) {
|
||||
ForEach(WheelStyle.allCases) { style in Text(style.title).tag(style) }
|
||||
}
|
||||
HStack {
|
||||
Text("Radius")
|
||||
Slider(value: $settings.wheelRadius, in: 120...260, step: 4)
|
||||
Text("\(Int(settings.wheelRadius))")
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 28)
|
||||
}
|
||||
Toggle("Show labels", isOn: $settings.showLabels)
|
||||
Toggle("Dim the background", isOn: $settings.dimBackground)
|
||||
} header: {
|
||||
Text("Appearance")
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private var engines: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle("Use external tools", isOn: $settings.useExternalTools)
|
||||
Text("Konverter always works with Apple's frameworks. ffmpeg and LibreOffice only widen the format list.")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
} header: {
|
||||
Text("Conversion engines")
|
||||
}
|
||||
|
||||
Section {
|
||||
engineRow(name: "Core (ImageIO, PDFKit, AVFoundation, textutil)", available: true, detail: "Always available")
|
||||
engineRow(
|
||||
name: "ffmpeg",
|
||||
available: Toolchain.hasFFmpeg,
|
||||
detail: Toolchain.ffmpeg ?? "Install with: brew install ffmpeg"
|
||||
)
|
||||
engineRow(
|
||||
name: "LibreOffice",
|
||||
available: Toolchain.hasLibreOffice,
|
||||
detail: Toolchain.soffice ?? "Install with: brew install --cask libreoffice"
|
||||
)
|
||||
} header: {
|
||||
Text("Detected")
|
||||
}
|
||||
|
||||
Section {
|
||||
Text("Active: \(Toolchain.summary)")
|
||||
.font(.system(size: 11))
|
||||
if let advice = Toolchain.missingToolAdvice {
|
||||
Text(advice)
|
||||
.font(.system(size: 10.5, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
} header: {
|
||||
Text("Summary")
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private func engineRow(name: String, available: Bool, detail: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: available ? "checkmark.circle.fill" : "circle.dashed")
|
||||
.foregroundStyle(available ? Color.green : Color.secondary)
|
||||
.font(.system(size: 12))
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(name).font(.system(size: 12, weight: .medium))
|
||||
Text(detail)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func chooseFolder() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.canCreateDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.prompt = "Use Folder"
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
settings.outputFolderPath = url.path
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// Native menu fallback for the paths where there is no drag to follow: the
|
||||
/// menu bar's "Konvert Files…" item and the Finder Quick Action.
|
||||
enum QuickMenu {
|
||||
private final class Choice: NSObject {
|
||||
let handler: () -> Void
|
||||
|
||||
init(_ handler: @escaping () -> Void) {
|
||||
self.handler = handler
|
||||
}
|
||||
|
||||
@objc func fire() {
|
||||
handler()
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu items hold their target weakly, so the choices are retained
|
||||
/// here for the duration of the (synchronous) popup.
|
||||
private static var retained: [Choice] = []
|
||||
|
||||
static func show(urls: [URL], at point: CGPoint) {
|
||||
guard !urls.isEmpty else { return }
|
||||
retained.removeAll()
|
||||
|
||||
let menu = NSMenu()
|
||||
menu.autoenablesItems = false
|
||||
|
||||
let header = NSMenuItem(
|
||||
title: WheelBuilder.summary(for: urls),
|
||||
action: nil,
|
||||
keyEquivalent: ""
|
||||
)
|
||||
header.isEnabled = false
|
||||
menu.addItem(header)
|
||||
menu.addItem(.separator())
|
||||
|
||||
let category = FileKind.category(of: urls[0])
|
||||
|
||||
if ConversionCatalog.outputs(for: urls.map { FileKind.category(of: $0) }).isEmpty {
|
||||
let item = NSMenuItem(title: "No conversions for this selection", action: nil, keyEquivalent: "")
|
||||
item.isEnabled = false
|
||||
menu.addItem(item)
|
||||
} else {
|
||||
let convert = NSMenu()
|
||||
for item in WheelBuilder.items(kind: .convert, urls: urls) {
|
||||
let entry = NSMenuItem(title: item.title, action: #selector(Choice.fire), keyEquivalent: "")
|
||||
entry.target = makeChoice {
|
||||
JobStore.shared.submit(urls: urls, target: .format(format(from: item)))
|
||||
}
|
||||
entry.isEnabled = item.isEnabled
|
||||
if let reason = item.availability.reason { entry.toolTip = reason }
|
||||
convert.addItem(entry)
|
||||
}
|
||||
let submenu = NSMenuItem(title: "Convert", action: nil, keyEquivalent: "")
|
||||
submenu.submenu = convert
|
||||
menu.addItem(submenu)
|
||||
}
|
||||
|
||||
let tools = WheelBuilder.items(kind: .tools, urls: urls)
|
||||
if category != .unknown, !tools.isEmpty {
|
||||
let submenu = NSMenu()
|
||||
for item in tools {
|
||||
guard case .tool(let tool) = item.payload else { continue }
|
||||
let entry = NSMenuItem(title: tool.title, action: #selector(Choice.fire), keyEquivalent: "")
|
||||
entry.target = makeChoice {
|
||||
if tool.fields.isEmpty {
|
||||
JobStore.shared.submit(urls: urls, target: .tool(tool))
|
||||
} else {
|
||||
OptionsWindowController.shared.present(tool: tool, urls: urls, origin: point)
|
||||
}
|
||||
}
|
||||
entry.isEnabled = item.isEnabled
|
||||
if let reason = item.availability.reason { entry.toolTip = reason }
|
||||
submenu.addItem(entry)
|
||||
}
|
||||
let wrapper = NSMenuItem(title: "Tools", action: nil, keyEquivalent: "")
|
||||
wrapper.submenu = submenu
|
||||
menu.addItem(wrapper)
|
||||
}
|
||||
|
||||
let actions = NSMenu()
|
||||
for item in WheelBuilder.items(kind: .actions, urls: urls) {
|
||||
guard case .action(let action) = item.payload else { continue }
|
||||
let entry = NSMenuItem(title: action.title, action: #selector(Choice.fire), keyEquivalent: "")
|
||||
entry.target = makeChoice {
|
||||
JobStore.shared.submit(urls: urls, target: .action(action))
|
||||
}
|
||||
actions.addItem(entry)
|
||||
}
|
||||
let actionsWrapper = NSMenuItem(title: "Actions", action: nil, keyEquivalent: "")
|
||||
actionsWrapper.submenu = actions
|
||||
menu.addItem(actionsWrapper)
|
||||
|
||||
menu.popUp(positioning: nil, at: point, in: nil)
|
||||
retained.removeAll()
|
||||
}
|
||||
|
||||
private static func makeChoice(_ handler: @escaping () -> Void) -> Choice {
|
||||
let choice = Choice(handler)
|
||||
retained.append(choice)
|
||||
return choice
|
||||
}
|
||||
|
||||
private static func format(from item: WheelItem) -> Format {
|
||||
if case .format(let format) = item.payload { return format }
|
||||
return .png
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Longer text results (Info, Read QR, Statistics, archive listings).
|
||||
final class ResultWindowController {
|
||||
static let shared = ResultWindowController()
|
||||
|
||||
private var window: NSWindow?
|
||||
|
||||
private init() {}
|
||||
|
||||
func show(title: String, body: String) {
|
||||
let view = ResultView(title: title, text: body, onClose: { [weak self] in self?.close() })
|
||||
close()
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 460, height: 320),
|
||||
styleMask: [.titled, .closable, .resizable, .fullSizeContentView],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = title
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.level = .floating
|
||||
window.contentView = NSHostingView(rootView: view)
|
||||
window.center()
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
self.window = window
|
||||
}
|
||||
|
||||
func close() {
|
||||
window?.orderOut(nil)
|
||||
window = nil
|
||||
}
|
||||
}
|
||||
|
||||
struct ResultView: View {
|
||||
let title: String
|
||||
let text: String
|
||||
let onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
Text(text)
|
||||
.font(.system(size: 11.5, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(14)
|
||||
}
|
||||
Divider().opacity(0.4)
|
||||
HStack {
|
||||
Button("Copy") {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(text, forType: .string)
|
||||
}
|
||||
Spacer()
|
||||
Button("Done", action: onClose)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
.frame(minWidth: 420, minHeight: 260)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user