Add Konverter: a local-only menu bar file converter

Drag files in Finder, hold a modifier, and drop on a wheel of outputs.
Conversions run entirely on-device through ImageIO, PDFKit, AVFoundation,
the bundled CLI tools, and optional ffmpeg/LibreOffice.

Includes a --self-test suite (41 checks) and a --convert CLI mode so the
conversion matrix can be verified without the UI.
This commit is contained in:
2026-10-20 00:36:40 +08:00
commit 2d031e58a4
47 changed files with 9149 additions and 0 deletions
+368
View File
@@ -0,0 +1,368 @@
import AVFoundation
import CoreGraphics
import Foundation
import ImageIO
/// Audio and video, mostly through AVFoundation. Formats Apple cannot write
/// (MKV, WebM, AVI, WMV, MP3, FLAC, OGG, Opus, WMA) are delegated to ffmpeg
/// when it is installed — see `FFmpeg`.
enum AVConverter {
// MARK: - Probing
static func duration(_ url: URL) -> Double? {
let asset = AVURLAsset(url: url, options: [AVURLAssetPreferPreciseDurationAndTimingKey: true])
let seconds = CMTimeGetSeconds(asset.duration)
return seconds.isFinite && seconds > 0 ? seconds : nil
}
/// Uses the synchronously loaded track list; called only while building the
/// drag wheel, so it must not await.
static func trackSummary(_ url: URL) -> (video: Bool, audio: Bool) {
let asset = AVURLAsset(url: url)
var video = false
var audio = false
for track in asset.tracks {
switch track.mediaType {
case .video where track.naturalSize.height > 0: video = true
case .audio: audio = true
default: break
}
}
return (video, audio)
}
// MARK: - Conversion
static func convert(
source: URL,
to format: Format,
at destination: URL,
quality: String = "Medium",
bitrate: String = "128",
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws {
try cancellation?.check()
switch format {
case .gif:
try makeGIF(source: source, at: destination, fps: 12, maxWidth: 480, cancellation: cancellation, progress: progress)
return
case .mp4, .mov, .m4v:
try exportWithSession(
source: source,
destination: destination,
fileType: format == .mov ? .mov : .mp4,
preset: videoPreset(for: quality),
cancellation: cancellation,
progress: progress
)
return
case .m4a, .mp3:
if format == .m4a {
try exportWithSession(
source: source,
destination: destination,
fileType: .m4a,
preset: AVAssetExportPresetAppleM4A,
cancellation: cancellation,
progress: progress
)
return
}
try FFmpeg.convert(source: source, to: format, at: destination, bitrate: bitrate, cancellation: cancellation, progress: progress)
return
default:
// Everything else: try ffmpeg, then CoreAudio, then AVFoundation.
if Toolchain.hasFFmpeg, Settings.shared.useExternalTools {
try FFmpeg.convert(source: source, to: format, at: destination, bitrate: bitrate, cancellation: cancellation, progress: progress)
return
}
if format == .wav || format == .aiff {
try CoreAudio.convert(source: source, to: format, at: destination, cancellation: cancellation)
return
}
throw ConversionError.missingTool(
"\(format.displayName) needs ffmpeg. Install it with: brew install ffmpeg"
)
}
}
static func videoPreset(for quality: String) -> String {
switch quality {
case "Low": return AVAssetExportPreset960x540
case "High": return AVAssetExportPreset1920x1080
default: return AVAssetExportPreset1280x720
}
}
// MARK: - Tools
static func trim(
source: URL,
at destination: URL,
start: Double,
end: Double?,
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws {
let asset = AVURLAsset(url: source)
guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
throw ConversionError.failed("Could not prepare a trim for \(source.lastPathComponent).")
}
let duration = CMTimeGetSeconds(asset.duration)
let startTime = max(0, min(start, duration))
let endTime = min(end ?? duration, duration)
guard endTime > startTime else {
throw ConversionError.failed("The trim range is empty.")
}
session.timeRange = CMTimeRange(
start: CMTime(seconds: startTime, preferredTimescale: 600),
end: CMTime(seconds: endTime, preferredTimescale: 600)
)
try run(session: session, destination: destination, fileType: destination.pathExtension == "mov" ? .mov : .mp4, cancellation: cancellation, progress: progress)
}
/// Rebuilds the file with the audio track dropped.
static func mute(
source: URL,
at destination: URL,
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws {
let asset = AVURLAsset(url: source)
let composition = AVMutableComposition()
var inserted = false
for track in asset.tracks where track.mediaType == .video {
guard let target = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else { continue }
try target.insertTimeRange(CMTimeRange(start: .zero, duration: asset.duration), of: track, at: .zero)
target.preferredTransform = track.preferredTransform
inserted = true
}
guard inserted else { throw ConversionError.failed("No video track to keep.") }
try exportComposition(composition, at: destination, cancellation: cancellation, progress: progress)
}
static func changeSpeed(
source: URL,
at destination: URL,
rate: Double,
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws {
guard rate > 0 else { throw ConversionError.failed("Speed must be greater than zero.") }
let asset = AVURLAsset(url: source)
let composition = AVMutableComposition()
let duration = asset.duration
for track in asset.tracks {
guard track.mediaType == .video || track.mediaType == .audio else { continue }
guard let target = composition.addMutableTrack(withMediaType: track.mediaType, preferredTrackID: kCMPersistentTrackID_Invalid) else { continue }
try target.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: track, at: .zero)
if track.mediaType == .video {
target.preferredTransform = track.preferredTransform
} else {
target.scaleTimeRange(CMTimeRange(start: .zero, duration: duration), toDuration: CMTimeMultiplyByFloat64(duration, multiplier: 1 / rate))
}
}
for track in composition.tracks where track.mediaType == .video {
track.scaleTimeRange(CMTimeRange(start: .zero, duration: duration), toDuration: CMTimeMultiplyByFloat64(duration, multiplier: 1 / rate))
}
try exportComposition(composition, at: destination, cancellation: cancellation, progress: progress, audioPitch: .varispeed)
}
static func resize(
source: URL,
at destination: URL,
width: Int,
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws {
let asset = AVURLAsset(url: source)
guard let track = asset.tracks(withMediaType: .video).first else {
throw ConversionError.failed("No video track to resize.")
}
let natural = track.naturalSize.applying(track.preferredTransform)
let sourceSize = CGSize(width: abs(natural.width), height: abs(natural.height))
guard sourceSize.width > 0 else { throw ConversionError.failed("Unknown video size.") }
let scale = CGFloat(width) / sourceSize.width
let renderSize = CGSize(width: CGFloat(width).rounded(), height: (sourceSize.height * scale).rounded())
let composition = AVMutableComposition()
guard let videoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else {
throw ConversionError.failed("Could not build the resize composition.")
}
let range = CMTimeRange(start: .zero, duration: asset.duration)
try videoTrack.insertTimeRange(range, of: track, at: .zero)
if let audio = asset.tracks(withMediaType: .audio).first,
let audioTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) {
try audioTrack.insertTimeRange(range, of: audio, at: .zero)
}
let instruction = AVMutableVideoCompositionInstruction()
instruction.timeRange = range
let layer = AVMutableVideoCompositionLayerInstruction(assetTrack: videoTrack)
let transform = track.preferredTransform.concatenating(CGAffineTransform(scaleX: scale, y: scale))
layer.setTransform(transform, at: .zero)
instruction.layerInstructions = [layer]
let videoComposition = AVMutableVideoComposition()
videoComposition.renderSize = renderSize
videoComposition.frameDuration = CMTime(value: 1, timescale: 30)
videoComposition.instructions = [instruction]
try exportComposition(composition, at: destination, cancellation: cancellation, progress: progress, videoComposition: videoComposition)
}
static func snapshot(
source: URL,
at destination: URL,
time: Double,
cancellation: Cancellation? = nil
) throws {
let asset = AVURLAsset(url: source)
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
generator.requestedTimeToleranceBefore = .zero
generator.requestedTimeToleranceAfter = .zero
try cancellation?.check()
let image = try generator.copyCGImage(at: CMTime(seconds: time, preferredTimescale: 600), actualTime: nil)
try Raster.write(image, to: destination, format: .png, quality: 1, metadata: nil)
}
/// Frame-by-frame GIF. Long clips are capped so the result stays small.
static func makeGIF(
source: URL,
at destination: URL,
fps: Int,
maxWidth: Int,
maxDuration: Double = 20,
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws {
let asset = AVURLAsset(url: source)
let duration = min(CMTimeGetSeconds(asset.duration), maxDuration)
guard duration > 0 else { throw ConversionError.failed("Nothing to animate.") }
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
generator.maximumSize = CGSize(width: maxWidth, height: maxWidth)
generator.requestedTimeToleranceBefore = CMTime(seconds: 0.05, preferredTimescale: 600)
generator.requestedTimeToleranceAfter = CMTime(seconds: 0.05, preferredTimescale: 600)
guard let output = CGImageDestinationCreateWithURL(destination as CFURL, "com.compuserve.gif" as CFString, 0, nil) else {
throw ConversionError.failed("Could not create \(destination.lastPathComponent).")
}
CGImageDestinationSetProperties(output, [
kCGImagePropertyGIFDictionary: [kCGImagePropertyGIFLoopCount: 0]
] as CFDictionary)
let frameCount = max(1, Int(duration * Double(fps)))
let frameDuration = 1.0 / Double(fps)
for index in 0..<frameCount {
try cancellation?.check()
let time = CMTime(seconds: Double(index) * frameDuration, preferredTimescale: 600)
if let image = try? generator.copyCGImage(at: time, actualTime: nil) {
CGImageDestinationAddImage(output, image, [
kCGImagePropertyGIFDictionary: [kCGImagePropertyGIFDelayTime: frameDuration]
] as CFDictionary)
}
progress?(Double(index + 1) / Double(frameCount))
}
guard CGImageDestinationFinalize(output) else {
throw ConversionError.failed("Writing the GIF failed.")
}
}
// MARK: - Internals
private static func exportComposition(
_ composition: AVComposition,
at destination: URL,
cancellation: Cancellation?,
progress: ((Double) -> Void)?,
audioPitch: AVAudioTimePitchAlgorithm? = nil,
videoComposition: AVVideoComposition? = nil
) throws {
let fileType: AVFileType = destination.pathExtension == "mov"
? .mov
: (destination.pathExtension == "m4a" ? .m4a : .mp4)
guard let session = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetHighestQuality) else {
throw ConversionError.failed("Could not prepare the export.")
}
if let audioPitch { session.audioTimePitchAlgorithm = audioPitch }
if let videoComposition { session.videoComposition = videoComposition }
try run(session: session, destination: destination, fileType: fileType, cancellation: cancellation, progress: progress)
}
private static func exportWithSession(
source: URL,
destination: URL,
fileType: AVFileType,
preset: String,
cancellation: Cancellation?,
progress: ((Double) -> Void)?
) throws {
let asset = AVURLAsset(url: source)
guard let session = AVAssetExportSession(asset: asset, presetName: preset) else {
throw ConversionError.failed("This file cannot be exported with the \(preset) preset.")
}
try run(session: session, destination: destination, fileType: fileType, cancellation: cancellation, progress: progress)
}
private final class ErrorBox: @unchecked Sendable {
private let lock = NSLock()
private var value: Error?
func set(_ error: Error) { lock.lock(); value = error; lock.unlock() }
func get() -> Error? { lock.lock(); defer { lock.unlock() }; return value }
}
private static func run(
session: AVAssetExportSession,
destination: URL,
fileType: AVFileType,
cancellation: Cancellation?,
progress: ((Double) -> Void)?
) throws {
if FileManager.default.fileExists(atPath: destination.path) {
try? FileManager.default.removeItem(at: destination)
}
let box = ErrorBox()
let ready = DispatchSemaphore(value: 0)
if #available(macOS 15.0, *) {
let session = session
Task.detached(priority: .userInitiated) {
do {
try await session.export(to: destination, as: fileType)
} catch {
box.set(error)
}
ready.signal()
}
} else {
session.outputURL = destination
session.outputFileType = fileType
session.shouldOptimizeForNetworkUse = false
session.exportAsynchronously { ready.signal() }
}
// Poll for progress and cancellation while the export runs.
while ready.wait(timeout: .now() + 0.2) == .timedOut {
if cancellation?.isCancelled == true {
session.cancelExport()
throw CancellationError()
}
progress?(Double(session.progress))
}
try cancellation?.check()
if let error = box.get() { throw ConversionError.failed(error.localizedDescription) }
if session.status == .failed {
throw ConversionError.failed(session.error?.localizedDescription ?? "Export failed.")
}
if session.status == .cancelled { throw CancellationError() }
progress?(1)
}
}
@@ -0,0 +1,142 @@
import Foundation
/// ZIP/TAR/GZIP via the system's own tools (`ditto`, `tar`, `gzip`), which is
/// both faster and more correct than rolling our own containers.
enum ArchiveConverter {
static func convert(
source: URL,
to format: Format,
at destination: URL,
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws {
try cancellation?.check()
progress?(0.1)
switch format {
case .zip:
try makeZip(source: source, at: destination, cancellation: cancellation)
case .tar:
try Shell.run(
Toolchain.tar,
["-cf", destination.path, "-C", source.deletingLastPathComponent().path, source.lastPathComponent],
cancellation: cancellation
)
case .gzip:
try makeGzip(source: source, at: destination, cancellation: cancellation)
default:
throw ConversionError.unsupported("\(format.displayName) is not an archive format Konverter writes.")
}
progress?(1)
}
private static func makeZip(source: URL, at destination: URL, cancellation: Cancellation?) throws {
var arguments = ["-c", "-k", "--sequesterRsrc"]
if source.hasDirectoryPath || isDirectory(source) {
arguments.append("--keepParent")
}
arguments += [source.path, destination.path]
try Shell.run(Toolchain.ditto, arguments, cancellation: cancellation)
}
private static func makeGzip(source: URL, at destination: URL, cancellation: Cancellation?) throws {
// gzip only compresses a single stream, so a folder is tarred first and
// the pair is squeezed into one .gz — the classic .tar.gz shape.
if isDirectory(source) {
let staging = FileManager.default.temporaryDirectory
.appendingPathComponent("konverter-\(UUID().uuidString).tar")
defer { try? FileManager.default.removeItem(at: staging) }
try Shell.run(
Toolchain.tar,
["-cf", staging.path, "-C", source.deletingLastPathComponent().path, source.lastPathComponent],
cancellation: cancellation
)
let data = try Shell.runData(Toolchain.gzip, ["-n", "-9", "-c", staging.path], cancellation: cancellation)
try data.write(to: destination)
} else {
let data = try Shell.runData(Toolchain.gzip, ["-n", "-9", "-c", source.path], cancellation: cancellation)
try data.write(to: destination)
}
}
/// Expands an archive into `directory`, returning the folder that callers
/// should reveal.
@discardableResult
static func extract(
source: URL,
into directory: URL,
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws -> URL {
try cancellation?.check()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
progress?(0.2)
let ext = source.pathExtension.lowercased()
switch ext {
case "zip":
try Shell.run(Toolchain.ditto, ["-x", "-k", source.path, directory.path], cancellation: cancellation)
case "tar", "tgz":
try Shell.run(Toolchain.tar, ["-xf", source.path, "-C", directory.path], cancellation: cancellation)
case "gz":
let inner = source.deletingPathExtension().lastPathComponent
if inner.lowercased().hasSuffix(".tar") {
try Shell.run(Toolchain.tar, ["-xzf", source.path, "-C", directory.path], cancellation: cancellation)
} else {
let data = try Shell.runData(Toolchain.gunzip, ["-c", source.path], cancellation: cancellation)
try data.write(to: directory.appendingPathComponent(inner))
}
default:
try extractWithLibarchive(source: source, into: directory, cancellation: cancellation)
}
progress?(1)
return directory
}
/// RAR/7z/BZip2/XZ: libarchive (bsdtar) reads most of them; `unar` is used
/// when present because it handles RAR5 and encrypted archives better.
private static func extractWithLibarchive(source: URL, into directory: URL, cancellation: Cancellation?) throws {
if let unar = Shell.find("unar", extraPaths: ["/opt/homebrew/bin", "/usr/local/bin"]) {
try Shell.run(unar, ["-quiet", "-force-overwrite", "-output-directory", directory.path, source.path], cancellation: cancellation)
return
}
do {
try Shell.run(Toolchain.tar, ["-xf", source.path, "-C", directory.path], cancellation: cancellation)
return
} catch {
throw ConversionError.missingTool(
"Konverter cannot open .\(source.pathExtension) archives. Install unar (brew install unar) and try again."
)
}
}
static func list(_ source: URL) throws -> String {
switch source.pathExtension.lowercased() {
case "zip":
let result = try Shell.run(Toolchain.unzip, ["-l", source.path])
return result.stdout
case "tar", "tgz":
return try Shell.run(Toolchain.tar, ["-tf", source.path]).stdout
case "gz":
let size = (try? FileManager.default.attributesOfItem(atPath: source.path)[.size] as? Int) ?? 0
return """
GZIP stream
Compressed size: \(ByteCountFormatter.string(fromByteCount: Int64(size ?? 0), countStyle: .file))
Inner name: \(source.deletingPathExtension().lastPathComponent)
"""
default:
let result = try? Shell.run(Toolchain.tar, ["-tf", source.path])
guard let stdout = result?.stdout else {
throw ConversionError.missingTool("Cannot list .\(source.pathExtension) archives without unar.")
}
return stdout
}
}
static func isArchive(_ url: URL) -> Bool {
FileKind.category(of: url) == .archive
}
private static func isDirectory(_ url: URL) -> Bool {
(try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
}
}
+176
View File
@@ -0,0 +1,176 @@
import Foundation
import PDFKit
/// Whether an output format can actually be produced on this Mac, and why not
/// when it cannot. The wheel uses this to mark options as unavailable instead
/// of failing after the user has already committed to a drop.
struct Availability {
let isAvailable: Bool
let reason: String?
static let ok = Availability(isAvailable: true, reason: nil)
static func unavailable(_ reason: String) -> Availability {
Availability(isAvailable: false, reason: reason)
}
}
/// Front door for format conversions: picks the engine, handles formats that
/// produce more than one file, and reports a useful reason when nothing can.
enum Converter {
@discardableResult
static func convert(
source: URL,
to format: Format,
at destination: URL,
quality: Double = 0.9,
mediaQuality: String = "Medium",
audioBitrate: String = "192",
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws -> [URL] {
try cancellation?.check()
let category = FileKind.category(of: source)
switch category {
case .image:
try ImageConverter.convert(
source: source,
to: format,
at: destination,
quality: quality,
cancellation: cancellation
)
progress?(1)
return [destination]
case .video, .audio:
try AVConverter.convert(
source: source,
to: format,
at: destination,
quality: mediaQuality,
bitrate: audioBitrate,
cancellation: cancellation,
progress: progress
)
return [destination]
case .pdf:
switch format {
case .jpg, .png:
let directory = OutputNaming.artifactDirectory(for: source, suffix: " \(format.displayName)")
return try PDFConverter.renderPages(
source,
to: format,
into: directory,
dpi: 300,
cancellation: cancellation,
progress: progress
)
case .pptx, .xlsx:
try Office.convert(source, to: format, at: destination, cancellation: cancellation)
progress?(1)
return [destination]
case .pdf:
guard let document = PDFDocument(url: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
guard document.write(to: destination) else {
throw ConversionError.failed("Could not rewrite the PDF.")
}
progress?(1)
return [destination]
default:
try DocumentConverter.convert(
source: source,
to: format,
at: destination,
cancellation: cancellation,
progress: progress
)
return [destination]
}
case .document, .spreadsheet:
try DocumentConverter.convert(
source: source,
to: format,
at: destination,
cancellation: cancellation,
progress: progress
)
return [destination]
case .slides:
try SlidesConverter.convert(
source: source,
to: format,
at: destination,
cancellation: cancellation,
progress: progress
)
if format == .png || format == .jpg {
let directory = OutputNaming.artifactDirectory(for: source, suffix: " \(format.displayName)")
let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? []
return files.sorted { $0.lastPathComponent < $1.lastPathComponent }
}
return [destination]
case .ebook:
try EbookConverter.convert(
source: source,
to: format,
at: destination,
cancellation: cancellation,
progress: progress
)
return [destination]
case .subtitle:
try SubtitleConverter.convert(source: source, to: format, at: destination, cancellation: cancellation)
progress?(1)
return [destination]
case .archive:
try ArchiveConverter.convert(
source: source,
to: format,
at: destination,
cancellation: cancellation,
progress: progress
)
return [destination]
case .folder, .unknown:
throw ConversionError.unsupported("\(source.lastPathComponent) has no conversion options.")
}
}
// MARK: - Availability
static func availability(target: Format, source: FileCategory) -> Availability {
if let offered = ConversionCatalog.targets[source], !offered.contains(target) {
return .unavailable("\(source.title) cannot become \(target.displayName).")
}
if target.requiresFFmpeg, !Toolchain.hasFFmpeg {
return .unavailable("Needs ffmpeg — brew install ffmpeg")
}
if target.requiresLibreOffice, !Toolchain.hasLibreOffice {
return .unavailable("Needs LibreOffice — brew install --cask libreoffice")
}
if !Settings.shared.useExternalTools, target.requiresFFmpeg || target.requiresLibreOffice {
return .unavailable("External tools are turned off in Settings.")
}
if target.category == .image, !Raster.canEncode(target) {
if target == .webp, WebPEncoder.isAvailable {
return .ok
}
return .unavailable("This Mac cannot write \(target.displayName) files.")
}
if source == .spreadsheet, !["xlsx", "ods", "csv", "tsv"].contains(target.ext), target != .pdf {
return .unavailable("Spreadsheets cannot become \(target.displayName).")
}
return .ok
}
}
@@ -0,0 +1,113 @@
import AppKit
import Foundation
import PDFKit
/// Document conversions. `textutil` handles the Office formats; Markdown and
/// EPUB are ours; PDF output lays text out with CoreText unless LibreOffice is
/// available for pixel-perfect fidelity.
enum DocumentConverter {
static func convert(
source: URL,
to format: Format,
at destination: URL,
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws {
try cancellation?.check()
let sourceExtension = FileKind.canonicalExtension(source.pathExtension)
switch format {
case .txt:
guard let text = TextDocument.plainText(from: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
try TextDocument.write(text, to: destination)
case .md:
if sourceExtension == "md" || sourceExtension == "txt" {
let text = TextDocument.plainText(from: source) ?? ""
try TextDocument.write(text, to: destination)
} else {
let html = try TextDocument.html(from: source)
try TextDocument.write(Markdown.markdown(fromHTML: html), to: destination)
}
case .html:
try TextDocument.write(try TextDocument.html(from: source), to: destination)
case .pdf:
try makePDF(from: source, at: destination, cancellation: cancellation, progress: progress)
case .epub:
let title = source.deletingPathExtension().lastPathComponent
let html = try TextDocument.html(from: source)
let chapterTitle = title
try EbookConverter.write(
title: title,
author: "Konverter",
chapters: [(chapterTitle, html)],
at: destination
)
case .docx, .doc, .rtf, .odt:
if sourceExtension == "pdf" || sourceExtension == "epub" {
// No importer for these: start from extracted text/HTML.
let html = try TextDocument.html(from: source)
try TextDocument.textutilConvert(sourceHTML: html, to: format, at: destination, cancellation: cancellation)
} else if sourceExtension == "md" {
let html = try TextDocument.html(from: source)
try TextDocument.textutilConvert(sourceHTML: html, to: format, at: destination, cancellation: cancellation)
} else {
try TextDocument.textutilConvert(source, to: format, at: destination, cancellation: cancellation)
}
case .pptx, .xlsx, .ods, .pages:
try Office.convert(source, to: format, at: destination, cancellation: cancellation)
default:
throw ConversionError.unsupported("Documents cannot become \(format.displayName).")
}
}
private static func makePDF(
from source: URL,
at destination: URL,
cancellation: Cancellation?,
progress: ((Double) -> Void)?
) throws {
let sourceExtension = FileKind.canonicalExtension(source.pathExtension)
if sourceExtension == "pdf" {
// "PDF → PDF" means a normalised, linearised copy.
if let document = PDFDocument(url: source) {
guard document.write(to: destination) else {
throw ConversionError.failed("Could not rewrite the PDF.")
}
return
}
}
if Toolchain.hasLibreOffice, Settings.shared.useExternalTools {
do {
try Office.convert(source, to: .pdf, at: destination, cancellation: cancellation)
return
} catch is CancellationError {
throw CancellationError()
} catch {
// Fall through to the CoreText layout rather than failing the
// whole job because LibreOffice refused one file.
}
}
if let attributed = TextDocument.attributedString(from: source) {
try TextDocument.writePDF(attributed, to: destination)
progress?(1)
return
}
if let text = TextDocument.plainText(from: source), !text.isEmpty {
try TextDocument.writePDF(fromText: text, to: destination)
progress?(1)
return
}
throw ConversionError.failed("Could not lay out \(source.lastPathComponent) as a PDF.")
}
}
@@ -0,0 +1,230 @@
import Foundation
/// EPUB in and out. Reading follows `META-INF/container.xml` → OPF → spine so
/// chapters come out in reading order; writing produces an EPUB 3 package
/// whose `mimetype` entry is stored uncompressed, as the spec requires.
enum EbookConverter {
static func convert(
source: URL,
to format: Format,
at destination: URL,
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws {
try cancellation?.check()
switch format {
case .txt:
try TextDocument.write(try plainText(source), to: destination)
case .html:
try TextDocument.write(try html(source), to: destination)
case .md:
try TextDocument.write(Markdown.markdown(fromHTML: try html(source)), to: destination)
case .pdf:
try TextDocument.writePDF(fromText: try plainText(source), to: destination)
case .epub:
let title = source.deletingPathExtension().lastPathComponent
try write(
title: title,
author: "Konverter",
chapters: [(title, try html(source))],
at: destination
)
default:
throw ConversionError.unsupported("EPUB cannot become \(format.displayName).")
}
progress?(1)
}
// MARK: - Reading
static func plainText(_ url: URL) throws -> String {
let chapters = try chapters(url)
return chapters
.map { Markdown.decodeEntities(Markdown.stripTags($0.html)).trimmed }
.filter { !$0.isEmpty }
.joined(separator: "\n\n")
}
static func html(_ url: URL) throws -> String {
let chapters = try chapters(url)
let title = url.deletingPathExtension().lastPathComponent
let body = chapters.map { chapter in
"<section>" + (chapter.title.isEmpty ? "" : "<h2>\(Markdown.escape(chapter.title))</h2>") + chapter.body + "</section>"
}.joined(separator: "\n")
return """
<!DOCTYPE html><html><head><meta charset="utf-8"><title>\(Markdown.escape(title))</title>
<style>body{font:16px/1.6 -apple-system,Georgia,serif;max-width:720px;margin:48px auto;padding:0 24px;color:#1d1d1f}</style>
</head><body>\(body)</body></html>
"""
}
private struct Chapter {
var title: String
var html: String
var body: String {
// Keep the inner markup, drop the document scaffolding.
var inner = html
if let bodyRange = inner.range(of: "(?is)<body[^>]*>", options: .regularExpression),
let endRange = inner.range(of: "(?is)</body>", options: .regularExpression) {
inner = String(inner[bodyRange.upperBound..<endRange.lowerBound])
}
return Markdown.replace(#"(?is)<(html|head|body|title|meta|link|script|style)[^>]*>|</(html|head|body|title|script|style)>"#, in: inner) { _ in "" }
}
}
private static func chapters(_ url: URL) throws -> [Chapter] {
let staging = try Unzip.expand(url)
defer { try? FileManager.default.removeItem(at: staging) }
let opf = try packageDocument(in: staging)
let ordered = opf.map { spineOrder(opfPath: $0, staging: staging) } ?? []
let files = ordered.isEmpty ? looseContentFiles(in: staging) : ordered
var chapters: [Chapter] = []
for file in files {
guard let content = try? String(contentsOf: file, encoding: .utf8) else { continue }
if content.trimmed.isEmpty { continue }
let title = Markdown.replace(#"(?is)^.*?<title[^>]*>(.*?)</title>.*$"#, in: content) { $0[1] }.trimmed
chapters.append(Chapter(title: title.count < 120 ? title : "", html: content))
}
guard !chapters.isEmpty else { throw ConversionError.failed("No chapters found in the EPUB.") }
return chapters
}
private static func packageDocument(in staging: URL) throws -> URL? {
let container = staging.appendingPathComponent("META-INF/container.xml")
guard let data = try? Data(contentsOf: container),
let text = String(data: data, encoding: .utf8),
let match = text.range(of: #"full-path="([^"]+)""#, options: .regularExpression) else {
return nil
}
let path = String(text[match]).replacingOccurrences(of: "full-path=\"", with: "").replacingOccurrences(of: "\"", with: "")
return staging.appendingPathComponent(path)
}
private static func spineOrder(opfPath: URL, staging: URL) -> [URL] {
guard let delegate = OPFDelegate(path: opfPath) else { return [] }
let parser = XMLParser(data: (try? Data(contentsOf: opfPath)) ?? Data())
parser.delegate = delegate
parser.parse()
let base = opfPath.deletingLastPathComponent()
return delegate.spine
.compactMap { delegate.manifest[$0] }
.map { base.appendingPathComponent($0) }
.filter { FileManager.default.fileExists(atPath: $0.path) }
}
private static func looseContentFiles(in staging: URL) -> [URL] {
let enumerator = FileManager.default.enumerator(at: staging, includingPropertiesForKeys: nil)
var files: [URL] = []
while let file = enumerator?.nextObject() as? URL {
let ext = file.pathExtension.lowercased()
if ext == "xhtml" || ext == "html" || ext == "htm" { files.append(file) }
}
return files.sorted { $0.path < $1.path }
}
private final class OPFDelegate: NSObject, XMLParserDelegate {
var manifest: [String: String] = [:]
var spine: [String] = []
private let basePath: String
init?(path: URL) {
basePath = path.path
super.init()
}
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?, attributes: [String: String]) {
switch elementName {
case "item":
if let id = attributes["id"], let href = attributes["href"] {
let type = attributes["media-type"] ?? ""
if type.contains("xhtml") || href.hasSuffix(".html") || href.hasSuffix(".xhtml") || href.hasSuffix(".htm") {
manifest[id] = href.removingPercentEncoding ?? href
}
}
case "itemref":
if let idref = attributes["idref"] { spine.append(idref) }
default:
break
}
}
}
// MARK: - Writing
static func write(
title: String,
author: String,
chapters: [(String, String)],
at destination: URL
) throws {
guard !chapters.isEmpty else { throw ConversionError.failed("An EPUB needs at least one chapter.") }
let staging = FileManager.default.temporaryDirectory
.appendingPathComponent("konverter-epub-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: staging) }
try FileManager.default.createDirectory(at: staging.appendingPathComponent("META-INF"), withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: staging.appendingPathComponent("OEBPS"), withIntermediateDirectories: true)
let identifier = "urn:uuid:" + UUID().uuidString.lowercased()
try "application/epub+zip".write(to: staging.appendingPathComponent("mimetype"), atomically: true, encoding: .utf8)
try """
<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>
""".write(to: staging.appendingPathComponent("META-INF/container.xml"), atomically: true, encoding: .utf8)
var manifest = ""
var spine = ""
var navItems = ""
for (index, chapter) in chapters.enumerated() {
let fileName = "chapter\(index + 1).xhtml"
let chapterTitle = chapter.0.isEmpty ? "Chapter \(index + 1)" : chapter.0
let body = chapter.1.contains("<body") || chapter.1.contains("<html")
? chapter.1
: "<html xmlns=\"http://www.w3.org/1999/xhtml\"><head><title>\(Markdown.escape(chapterTitle))</title></head><body>\(chapter.1)</body></html>"
let xhtml = body.contains("xmlns=\"http://www.w3.org/1999/xhtml\"")
? body
: body.replacingOccurrences(of: "<html", with: "<html xmlns=\"http://www.w3.org/1999/xhtml\"", options: [], range: body.range(of: "<html"))
try xhtml.write(to: staging.appendingPathComponent("OEBPS/" + fileName), atomically: true, encoding: .utf8)
manifest += "<item id=\"chapter\(index + 1)\" href=\"\(fileName)\" media-type=\"application/xhtml+xml\"/>\n"
spine += "<itemref idref=\"chapter\(index + 1)\"/>\n"
navItems += "<li><a href=\"\(fileName)\">\(Markdown.escape(chapterTitle))</a></li>\n"
}
try """
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="bookid">\(identifier)</dc:identifier>
<dc:title>\(Markdown.escape(title))</dc:title>
<dc:creator>\(Markdown.escape(author))</dc:creator>
<dc:language>en</dc:language>
<meta property="dcterms:modified">\(ISO8601DateFormatter().string(from: Date()))</meta>
</metadata>
<manifest>
\(manifest)<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
</manifest>
<spine>
\(spine)</spine>
</package>
""".write(to: staging.appendingPathComponent("OEBPS/content.opf"), atomically: true, encoding: .utf8)
try """
<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head><title>\(Markdown.escape(title))</title></head>
<body><nav epub:type="toc" id="toc"><h1>\(Markdown.escape(title))</h1><ol>
\(navItems)</ol></nav></body></html>
""".write(to: staging.appendingPathComponent("OEBPS/nav.xhtml"), atomically: true, encoding: .utf8)
try? FileManager.default.removeItem(at: destination)
// mimetype must be the first entry and must not be compressed.
try Shell.run(Toolchain.zip, ["-X0", destination.path, "mimetype"], currentDirectory: staging)
try Shell.run(Toolchain.zip, ["-Xr9", destination.path, "META-INF", "OEBPS"], currentDirectory: staging)
}
}
+156
View File
@@ -0,0 +1,156 @@
import Foundation
/// ffmpeg back end. Used for containers and codecs Apple's frameworks cannot
/// write. Progress is parsed from `-progress pipe:1`.
enum FFmpeg {
static func convert(
source: URL,
to format: Format,
at destination: URL,
bitrate: String = "128",
quality: String = "Medium",
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws {
guard let ffmpeg = Toolchain.ffmpeg, Settings.shared.useExternalTools else {
throw ConversionError.missingTool("\(format.displayName) requires ffmpeg — install it with: brew install ffmpeg")
}
var arguments = ["-y", "-hide_banner", "-loglevel", "error", "-i", source.path]
arguments += encodingArguments(for: format, bitrate: bitrate, quality: quality)
arguments += ["-progress", "pipe:1", "-nostats"]
arguments.append(destination.path)
let duration = AVConverter.duration(source) ?? 0
try Shell.runReportingProgress(ffmpeg, arguments, cancellation: cancellation) { _, line in
guard let progress, duration > 0 else { return }
if let range = line.range(of: "out_time_us="),
let microseconds = Double(line[range.upperBound...].trimmingCharacters(in: .whitespaces)) {
progress(min(1, microseconds / 1_000_000 / duration))
}
}
progress?(1)
}
/// Down-mixes any media to audio.
static func extractAudio(
source: URL,
to format: Format,
at destination: URL,
bitrate: String = "192",
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws {
try convert(source: source, to: format, at: destination, bitrate: bitrate, cancellation: cancellation, progress: progress)
}
/// Progress-reporting pass-through for tools (trim, speed, gain, …).
static func filter(
source: URL,
at destination: URL,
arguments: [String],
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws {
guard let ffmpeg = Toolchain.ffmpeg, Settings.shared.useExternalTools else {
throw ConversionError.missingTool("This tool requires ffmpeg — install it with: brew install ffmpeg")
}
var full = ["-y", "-hide_banner", "-loglevel", "error", "-i", source.path]
full += arguments
full += ["-progress", "pipe:1", "-nostats", destination.path]
let duration = AVConverter.duration(source) ?? 0
try Shell.runReportingProgress(ffmpeg, full, cancellation: cancellation) { _, line in
guard let progress, duration > 0 else { return }
if let range = line.range(of: "out_time_us="),
let microseconds = Double(line[range.upperBound...].trimmingCharacters(in: .whitespaces)) {
progress(min(1, microseconds / 1_000_000 / duration))
}
}
progress?(1)
}
private static func encodingArguments(for format: Format, bitrate: String, quality: String) -> [String] {
let audioRate = "\(bitrate)k"
switch format {
case .mp3: return ["-vn", "-c:a", "libmp3lame", "-b:a", audioRate]
case .m4a: return ["-vn", "-c:a", "aac", "-b:a", audioRate]
case .wav: return ["-vn", "-c:a", "pcm_s16le"]
case .aiff: return ["-vn", "-c:a", "pcm_s16be"]
case .flac: return ["-vn", "-c:a", "flac"]
case .ogg: return ["-vn", "-c:a", "libvorbis", "-q:a", "5"]
case .opus: return ["-vn", "-c:a", "libopus", "-b:a", audioRate]
case .wma: return ["-vn", "-c:a", "wmav2", "-b:a", audioRate]
case .mkv: return videoArguments(codec: "libx264", quality: quality) + ["-c:a", "aac", "-b:a", audioRate]
case .webm: return ["-c:v", "libvpx-vp9", "-crf", "32", "-b:v", "0", "-c:a", "libopus", "-b:a", audioRate]
case .avi: return ["-c:v", "mpeg4", "-q:v", "5", "-c:a", "libmp3lame", "-b:a", audioRate]
case .wmv: return ["-c:v", "wmv2", "-b:v", "2M", "-c:a", "wmav2", "-b:a", audioRate]
case .mp4, .mov, .m4v, .gif: return videoArguments(codec: "libx264", quality: quality) + ["-c:a", "aac", "-b:a", audioRate]
default: return []
}
}
private static func videoArguments(codec: String, quality: String) -> [String] {
let crf: String
switch quality {
case "Low": crf = "30"
case "High": crf = "20"
default: crf = "25"
}
return ["-c:v", codec, "-preset", "medium", "-crf", crf, "-pix_fmt", "yuv420p"]
}
}
/// CoreAudio's `afconvert`: always present, handles PCM containers and
/// channel remapping without any third-party install.
enum CoreAudio {
static func convert(
source: URL,
to format: Format,
at destination: URL,
cancellation: Cancellation? = nil
) throws {
guard let fileFormat = coreAudioFileFormat(for: format) else {
throw ConversionError.unsupported("CoreAudio cannot write \(format.displayName).")
}
do {
try Shell.run(
Toolchain.afconvert,
["-f", fileFormat, "-d", dataFormat(for: fileFormat), source.path, destination.path],
cancellation: cancellation
)
} catch {
throw ConversionError.failed("CoreAudio could not convert \(source.lastPathComponent): \((error as? LocalizedError)?.errorDescription ?? error.localizedDescription)")
}
}
static func remix(
source: URL,
at destination: URL,
mono: Bool,
cancellation: Cancellation? = nil
) throws {
try Shell.run(
Toolchain.afconvert,
["-f", "caff", "-d", "LEI16", "-c", mono ? "1" : "2", source.path, destination.path],
cancellation: cancellation
)
}
private static func coreAudioFileFormat(for format: Format) -> String? {
switch format {
case .wav: return "WAVE"
case .aiff: return "AIFF"
case .m4a: return "m4af"
default: return nil
}
}
private static func dataFormat(for fileFormat: String) -> String {
switch fileFormat {
case "WAVE": return "LEI16"
case "AIFF": return "BEI16"
default: return "aac"
}
}
}
@@ -0,0 +1,328 @@
import CoreGraphics
import Foundation
import ImageIO
import AppKit
import UniformTypeIdentifiers
enum ConversionError: LocalizedError {
case unreadable(String)
case unsupported(String)
case failed(String)
case missingTool(String)
var errorDescription: String? {
switch self {
case .unreadable(let name): return "Could not read \(name)."
case .unsupported(let reason): return reason
case .failed(let reason): return reason
case .missingTool(let reason): return reason
}
}
}
/// Bitmap plumbing shared by the image converter and the image tools.
enum Raster {
static let encodableTypes: Set<String> = Set(
(CGImageDestinationCopyTypeIdentifiers() as? [String]) ?? []
)
static func canEncode(_ format: Format) -> Bool {
guard let uti = format.uti else { return false }
return encodableTypes.contains(uti)
}
static func imageSource(at url: URL) -> CGImageSource? {
CGImageSourceCreateWithURL(url as CFURL, [kCGImageSourceShouldCacheImmediately: true] as CFDictionary)
}
/// Decodes the first frame. Falls back to AppKit for formats ImageIO
/// cannot decode but the system can (SVG, some RAW, exotic TIFFs), and to
/// Quick Look as a last resort.
static func firstImage(at url: URL) -> CGImage? {
if let source = imageSource(at: url), let image = CGImageSourceCreateImageAtIndex(source, 0, nil) {
return image
}
if let image = NSImage(contentsOf: url),
let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) {
return cgImage
}
return quickLookRaster(url)
}
static func frameCount(at url: URL) -> Int {
guard let source = imageSource(at: url) else { return 1 }
return max(CGImageSourceGetCount(source), 1)
}
private static func quickLookRaster(_ url: URL) -> CGImage? {
guard let qlmanage = Toolchain.qlmanage else { return nil }
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("konverter-ql-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: directory) }
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
_ = try? Shell.run(qlmanage, ["-t", "-s", "2048", "-o", directory.path, url.path])
guard let produced = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)
.first(where: { $0.pathExtension == "png" }) else { return nil }
return NSImage(contentsOf: produced)?.cgImage(forProposedRect: nil, context: nil, hints: nil)
}
/// Paints the image onto an opaque background, dropping the alpha channel.
/// JPEG cannot store alpha and would otherwise render black.
static func flattened(_ image: CGImage, background: CGColor = CGColor(gray: 1, alpha: 1)) -> CGImage {
let width = image.width
let height = image.height
guard let context = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue
) else { return image }
context.setFillColor(background)
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
return context.makeImage() ?? image
}
/// Reduces the number of distinct colours in an image. Lossless formats
/// have no quality knob, so a "compress" of a PNG has to trade colour
/// depth for size — which is exactly what this does. `quality` is the
/// 0…1 slider: 1.0 leaves the pixels untouched.
static func posterized(_ image: CGImage, quality: Double) -> CGImage {
let bits = max(1.0, min(8.0, quality * 8.0))
let levels = min(256, max(2, Int(pow(2.0, bits))))
guard levels < 256 else { return image }
let width = image.width
let height = image.height
let bytesPerRow = width * 4
var buffer = [UInt8](repeating: 0, count: bytesPerRow * height)
let space = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
let drawn: Bool = buffer.withUnsafeMutableBytes { raw -> Bool in
guard let context = CGContext(
data: raw.baseAddress,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: bytesPerRow,
space: space,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return false }
context.interpolationQuality = .high
context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
return true
}
guard drawn else { return image }
let step = 256 / levels
let half = step / 2
for index in stride(from: 0, to: buffer.count, by: 4) {
for channel in 0..<3 {
let value = Int(buffer[index + channel])
buffer[index + channel] = UInt8(min(255, (value / step) * step + half))
}
}
let result: CGImage? = buffer.withUnsafeMutableBytes { raw -> CGImage? in
guard let context = CGContext(
data: raw.baseAddress,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: bytesPerRow,
space: space,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return nil }
return context.makeImage()
}
return result ?? image
}
/// Bakes an EXIF orientation into the pixels so the result needs no
/// metadata to look right.
static func oriented(_ image: CGImage, orientation: CGImagePropertyOrientation) -> CGImage {
guard orientation != .up else { return image }
let swapsDimensions = orientation == .left || orientation == .right
|| orientation == .leftMirrored || orientation == .rightMirrored
let width = swapsDimensions ? image.height : image.width
let height = swapsDimensions ? image.width : image.height
guard let context = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: image.colorSpace ?? CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return image }
switch orientation {
case .up: break
case .upMirrored: context.translateBy(x: CGFloat(width), y: 0); context.scaleBy(x: -1, y: 1)
case .down: context.translateBy(x: CGFloat(width), y: CGFloat(height)); context.rotate(by: .pi)
case .downMirrored: context.translateBy(x: 0, y: CGFloat(height)); context.scaleBy(x: 1, y: -1)
case .left: context.translateBy(x: 0, y: CGFloat(height)); context.rotate(by: -.pi / 2)
case .leftMirrored: context.translateBy(x: CGFloat(width), y: CGFloat(height)); context.rotate(by: -.pi / 2); context.scaleBy(x: 1, y: -1)
case .right: context.translateBy(x: CGFloat(width), y: 0); context.rotate(by: .pi / 2)
case .rightMirrored: context.rotate(by: .pi / 2); context.scaleBy(x: 1, y: -1)
@unknown default: break
}
context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
return context.makeImage() ?? image
}
static func orientation(of source: CGImageSource) -> CGImagePropertyOrientation {
guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
let raw = properties[kCGImagePropertyOrientation] as? UInt32,
let orientation = CGImagePropertyOrientation(rawValue: raw) else { return .up }
return orientation
}
/// Encodes a single image. `metadata == nil` writes a clean file with no
/// EXIF/GPS/XMP at all.
static func write(
_ image: CGImage,
to url: URL,
format: Format,
quality: Double,
metadata: [CFString: Any]?
) throws {
guard let uti = format.uti else {
throw ConversionError.unsupported("\(format.displayName) is not an image format.")
}
guard canEncode(format) else {
throw ConversionError.unsupported("This Mac cannot write \(format.displayName) images.")
}
var properties: [CFString: Any] = metadata ?? [kCGImagePropertyOrientation: CGImagePropertyOrientation.up.rawValue]
if format.isLossy {
properties[kCGImageDestinationLossyCompressionQuality] = max(0, min(1, quality))
}
guard let destination = CGImageDestinationCreateWithURL(url as CFURL, uti as CFString, 1, nil) else {
throw ConversionError.failed("Could not create \(format.displayName) at \(url.lastPathComponent).")
}
CGImageDestinationAddImage(destination, image, properties as CFDictionary)
guard CGImageDestinationFinalize(destination) else {
throw ConversionError.failed("Writing \(url.lastPathComponent) failed.")
}
}
}
/// WebP has no encoder in ImageIO on macOS. `cwebp` (from libwebp) is the
/// best path; ffmpeg works when it was built with libwebp. Both are optional.
enum WebPEncoder {
static let cwebp: String? = Shell.find("cwebp", extraPaths: ["/opt/homebrew/bin", "/usr/local/bin"])
static let ffmpegHasLibwebp: Bool = {
guard let ffmpeg = Toolchain.ffmpeg else { return false }
let encoders = Shell.output(ffmpeg, ["-hide_banner", "-encoders"]) ?? ""
return encoders.contains("libwebp")
}()
static var isAvailable: Bool {
Settings.shared.useExternalTools && (cwebp != nil || ffmpegHasLibwebp)
}
static func encode(_ image: CGImage, to url: URL, quality: Double) throws {
let staging = FileManager.default.temporaryDirectory
.appendingPathComponent("konverter-webp-\(UUID().uuidString).png")
defer { try? FileManager.default.removeItem(at: staging) }
try Raster.write(image, to: staging, format: .png, quality: 1, metadata: nil)
if let cwebp {
try Shell.run(cwebp, ["-quiet", "-q", String(Int(quality * 100)), staging.path, "-o", url.path])
return
}
if let ffmpeg = Toolchain.ffmpeg, ffmpegHasLibwebp {
try Shell.run(
ffmpeg,
["-y", "-hide_banner", "-loglevel", "error", "-i", staging.path,
"-frames:v", "1", "-c:v", "libwebp", "-quality", String(Int(quality * 100)), url.path]
)
return
}
throw ConversionError.unsupported("WebP needs cwebp — brew install webp")
}
}
/// Format → format for still and animated bitmaps.
enum ImageConverter {
static func convert(
source: URL,
to format: Format,
at destination: URL,
quality: Double = 0.9,
keepMetadata: Bool = true,
cancellation: Cancellation? = nil
) throws {
try cancellation?.check()
if format == .pdf {
try PDFConverter.imageToPDF(source, at: destination, cancellation: cancellation)
return
}
// WebP has no ImageIO encoder on macOS: cwebp or ffmpeg fills in.
if !Raster.canEncode(format) {
if format == .webp, let image = Raster.firstImage(at: source), WebPEncoder.isAvailable {
try WebPEncoder.encode(image, to: destination, quality: quality)
return
}
throw ConversionError.unsupported("This Mac cannot write \(format.displayName) images.")
}
guard let imageSource = Raster.imageSource(at: source) else {
// Formats ImageIO cannot even open (SVG, PSD …): rasterise once.
guard let image = Raster.firstImage(at: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
let prepared = format == .jpg ? Raster.flattened(image) : image
try Raster.write(prepared, to: destination, format: format, quality: quality, metadata: nil)
return
}
let frameCount = max(CGImageSourceGetCount(imageSource), 1)
guard let uti = format.uti, Raster.canEncode(format) else {
throw ConversionError.unsupported("This Mac cannot write \(format.displayName) images.")
}
var properties: [CFString: Any] = [:]
if keepMetadata, let sourceProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any] {
properties = sourceProperties
}
// JPEG has no alpha channel: re-encode through a white background.
let needsFlatten = format == .jpg
guard let destinationRef = CGImageDestinationCreateWithURL(
destination as CFURL,
uti as CFString,
frameCount,
nil
) else {
throw ConversionError.failed("Could not create \(format.displayName) at \(destination.lastPathComponent).")
}
if format.isLossy {
properties[kCGImageDestinationLossyCompressionQuality] = quality
}
for index in 0..<frameCount {
try cancellation?.check()
if needsFlatten || !keepMetadata {
guard let frame = CGImageSourceCreateImageAtIndex(imageSource, index, nil) else { continue }
let prepared = needsFlatten ? Raster.flattened(frame) : frame
CGImageDestinationAddImage(destinationRef, prepared, properties as CFDictionary)
} else {
CGImageDestinationAddImageFromSource(destinationRef, imageSource, index, properties as CFDictionary)
}
}
guard CGImageDestinationFinalize(destinationRef) else {
throw ConversionError.failed("Writing \(destination.lastPathComponent) failed.")
}
}
}
+268
View File
@@ -0,0 +1,268 @@
import AppKit
import CoreGraphics
import CoreText
import Foundation
import Vision
/// In-place image transforms.
enum ImageTools {
static func compress(_ source: URL, at destination: URL, format: Format, quality: Double) throws {
guard let image = Raster.firstImage(at: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
let outputFormat = format
var prepared = format == .jpg ? Raster.flattened(image) : image
// PNG/BMP/TIFF are lossless: ImageIO ignores the quality knob, so the
// slider drives colour depth instead. JPEG-style quality is meaningless
// there, and shipping a "compressed" file that got bigger is worse than
// a visibly banded one that actually got smaller.
switch outputFormat {
case .png, .bmp, .tiff:
prepared = Raster.posterized(prepared, quality: quality)
default:
break
}
try Raster.write(prepared, to: destination, format: outputFormat, quality: quality, metadata: nil)
}
static func resize(
_ source: URL,
at destination: URL,
maxWidth: Int,
maxHeight: Int,
percent: Int
) throws {
guard let image = Raster.firstImage(at: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
var targetWidth = image.width
var targetHeight = image.height
if percent != 100 {
targetWidth = Int(Double(image.width) * Double(percent) / 100)
targetHeight = Int(Double(image.height) * Double(percent) / 100)
} else {
let scale = min(Double(maxWidth) / Double(image.width), Double(maxHeight) / Double(image.height), 1)
targetWidth = Int(Double(image.width) * scale)
targetHeight = Int(Double(image.height) * scale)
}
targetWidth = max(1, targetWidth)
targetHeight = max(1, targetHeight)
guard let resized = draw(image, size: CGSize(width: targetWidth, height: targetHeight)) else {
throw ConversionError.failed("Resizing failed.")
}
try write(resized, to: destination, matching: source)
}
static func crop(_ source: URL, at destination: URL, inset: Int, aspect: String) throws {
guard let image = Raster.firstImage(at: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
var rect = CGRect(x: 0, y: 0, width: image.width, height: image.height)
if aspect != "Keep", let ratio = aspectRatio(aspect) {
let current = CGFloat(image.width) / CGFloat(image.height)
if current > ratio {
let width = CGFloat(image.height) * ratio
rect = CGRect(x: (CGFloat(image.width) - width) / 2, y: 0, width: width, height: CGFloat(image.height))
} else {
let height = CGFloat(image.width) / ratio
rect = CGRect(x: 0, y: (CGFloat(image.height) - height) / 2, width: CGFloat(image.width), height: height)
}
}
if inset > 0 {
rect = rect.insetBy(dx: CGFloat(inset), dy: CGFloat(inset))
}
rect = rect.integral
guard rect.width > 1, rect.height > 1 else {
throw ConversionError.failed("The crop values remove the whole image.")
}
guard let cropped = image.cropping(to: rect) else {
throw ConversionError.failed("Cropping failed.")
}
try write(cropped, to: destination, matching: source)
}
private static func aspectRatio(_ aspect: String) -> CGFloat? {
let parts = aspect.split(separator: ":").compactMap { Double($0) }
guard parts.count == 2, parts[1] != 0 else { return nil }
return CGFloat(parts[0] / parts[1])
}
static func rotate(_ source: URL, at destination: URL, degrees: Int) throws {
guard let image = Raster.firstImage(at: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
let radians = CGFloat(degrees) * .pi / 180
let swaps = degrees % 180 != 0
let width = swaps ? image.height : image.width
let height = swaps ? image.width : image.height
guard let context = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: image.colorSpace ?? CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { throw ConversionError.failed("Rotating failed.") }
context.translateBy(x: CGFloat(width) / 2, y: CGFloat(height) / 2)
context.rotate(by: radians)
context.draw(
image,
in: CGRect(
x: -CGFloat(image.width) / 2,
y: -CGFloat(image.height) / 2,
width: CGFloat(image.width),
height: CGFloat(image.height)
)
)
guard let rotated = context.makeImage() else { throw ConversionError.failed("Rotating failed.") }
try write(rotated, to: destination, matching: source)
}
static func flip(_ source: URL, at destination: URL, horizontal: Bool) throws {
guard let image = Raster.firstImage(at: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
guard let context = CGContext(
data: nil,
width: image.width,
height: image.height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: image.colorSpace ?? CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { throw ConversionError.failed("Flipping failed.") }
if horizontal {
context.translateBy(x: CGFloat(image.width), y: 0)
context.scaleBy(x: -1, y: 1)
} else {
context.translateBy(x: 0, y: CGFloat(image.height))
context.scaleBy(x: 1, y: -1)
}
context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
guard let flipped = context.makeImage() else { throw ConversionError.failed("Flipping failed.") }
try write(flipped, to: destination, matching: source)
}
static func grayscale(_ source: URL, at destination: URL) throws {
guard let image = Raster.firstImage(at: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
guard let space = CGColorSpace(name: CGColorSpace.linearGray),
let context = CGContext(
data: nil,
width: image.width,
height: image.height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: space,
bitmapInfo: CGImageAlphaInfo.none.rawValue
) else { throw ConversionError.failed("Grayscale conversion failed.") }
context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
guard let gray = context.makeImage() else { throw ConversionError.failed("Grayscale conversion failed.") }
try write(gray, to: destination, matching: source)
}
static func stripMetadata(_ source: URL, at destination: URL, format: Format) throws {
guard let image = Raster.firstImage(at: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
let prepared = format == .jpg ? Raster.flattened(image) : image
try Raster.write(prepared, to: destination, format: format, quality: 0.95, metadata: nil)
}
static func watermark(
_ source: URL,
at destination: URL,
text: String,
position: String,
opacity: Double,
sizePercent: Double
) throws {
guard let image = Raster.firstImage(at: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
let width = image.width
let height = image.height
guard let context = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: image.colorSpace ?? CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { throw ConversionError.failed("Watermarking failed.") }
context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
let fontSize = max(8, CGFloat(sizePercent) / 100 * CGFloat(width))
let font = NSFont.systemFont(ofSize: fontSize, weight: .semibold)
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: NSColor.white.withAlphaComponent(opacity / 100)
]
let attributed = NSAttributedString(string: text, attributes: attributes)
let line = CTLineCreateWithAttributedString(attributed)
let bounds = CTLineGetBoundsWithOptions(line, .useOpticalBounds)
let margin = fontSize * 0.6
let origin: CGPoint
switch position {
case "Bottom Left": origin = CGPoint(x: margin, y: margin)
case "Top Right": origin = CGPoint(x: CGFloat(width) - bounds.width - margin, y: CGFloat(height) - bounds.height - margin)
case "Top Left": origin = CGPoint(x: margin, y: CGFloat(height) - bounds.height - margin)
case "Center": origin = CGPoint(x: (CGFloat(width) - bounds.width) / 2, y: (CGFloat(height) - bounds.height) / 2)
default: origin = CGPoint(x: CGFloat(width) - bounds.width - margin, y: margin)
}
// A soft shadow keeps light text readable over light photos.
context.setShadow(offset: CGSize(width: 0, height: -fontSize * 0.05), blur: fontSize * 0.2, color: CGColor(gray: 0, alpha: opacity / 140))
context.textPosition = origin
CTLineDraw(line, context)
guard let stamped = context.makeImage() else { throw ConversionError.failed("Watermarking failed.") }
try write(stamped, to: destination, matching: source)
}
/// QR payloads, via Vision so it also reads the newer formats CIDetector
/// misses.
static func readQR(_ source: URL) -> String? {
guard let image = Raster.firstImage(at: source) else { return nil }
let request = VNDetectBarcodesRequest()
request.symbologies = [.qr, .aztec, .pdf417, .code128, .code39, .ean13, .dataMatrix]
let handler = VNImageRequestHandler(cgImage: image, options: [:])
do {
try handler.perform([request])
} catch {
return nil
}
let payloads = (request.results ?? []).compactMap(\.payloadStringValue)
return payloads.isEmpty ? nil : payloads.joined(separator: "\n")
}
// MARK: - Helpers
private static func draw(_ image: CGImage, size: CGSize) -> CGImage? {
guard let context = CGContext(
data: nil,
width: Int(size.width),
height: Int(size.height),
bitsPerComponent: 8,
bytesPerRow: 0,
space: image.colorSpace ?? CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return nil }
context.interpolationQuality = .high
context.draw(image, in: CGRect(origin: .zero, size: size))
return context.makeImage()
}
private static func write(_ image: CGImage, to destination: URL, matching source: URL) throws {
let format = FileKind.format(of: destination) ?? FileKind.format(of: source) ?? .png
let prepared = format == .jpg ? Raster.flattened(image) : image
try Raster.write(prepared, to: destination, format: format, quality: 0.92, metadata: nil)
}
}
+282
View File
@@ -0,0 +1,282 @@
import AppKit
import Foundation
/// A deliberately small Markdown renderer/parser. Enough for the formats the
/// app offers (headings, emphasis, code, lists, quotes, links, rules, images)
/// without pulling in a dependency.
enum Markdown {
// MARK: - Markdown → HTML
static func html(from markdown: String, title: String = "Document") -> String {
let lines = markdown.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n")
var body: [String] = []
var paragraph: [String] = []
var listType: String?
var inCode = false
var codeLines: [String] = []
func flushParagraph() {
guard !paragraph.isEmpty else { return }
body.append("<p>" + inline(paragraph.joined(separator: " ")) + "</p>")
paragraph = []
}
func closeList() {
if let type = listType {
body.append("</\(type)>")
listType = nil
}
}
for rawLine in lines {
let line = rawLine
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("```") {
if inCode {
body.append("<pre><code>" + escape(codeLines.joined(separator: "\n")) + "</code></pre>")
codeLines = []
inCode = false
} else {
flushParagraph()
closeList()
inCode = true
}
continue
}
if inCode {
codeLines.append(line)
continue
}
if trimmed.isEmpty {
flushParagraph()
closeList()
continue
}
if let heading = headingLevel(trimmed) {
flushParagraph()
closeList()
let text = trimmed.dropFirst(heading).trimmingCharacters(in: .whitespaces)
body.append("<h\(heading)>" + inline(text) + "</h\(heading)>")
continue
}
if trimmed == "---" || trimmed == "***" || trimmed == "___" {
flushParagraph()
closeList()
body.append("<hr/>")
continue
}
if trimmed.hasPrefix(">") {
flushParagraph()
closeList()
let text = trimmed.dropFirst().trimmingCharacters(in: .whitespaces)
body.append("<blockquote><p>" + inline(text) + "</p></blockquote>")
continue
}
if let item = listItem(trimmed) {
flushParagraph()
if listType != item.type {
closeList()
listType = item.type
body.append("<\(item.type)>")
}
body.append("<li>" + inline(item.text) + "</li>")
continue
}
if trimmed.hasPrefix("|") && trimmed.hasSuffix("|") {
flushParagraph()
closeList()
let cells = trimmed.dropFirst().dropLast().split(separator: "|").map {
inline($0.trimmingCharacters(in: .whitespaces))
}
if !cells.allSatisfy({ $0.allSatisfy { $0 == "-" || $0 == ":" } }) {
body.append("<tr>" + cells.map { "<td>\($0)</td>" }.joined() + "</tr>")
}
continue
}
paragraph.append(trimmed)
}
if inCode {
body.append("<pre><code>" + escape(codeLines.joined(separator: "\n")) + "</code></pre>")
}
flushParagraph()
closeList()
return """
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>\(escape(title))</title>
<style>
body { font: 16px/1.6 -apple-system, "Helvetica Neue", sans-serif; max-width: 720px; margin: 48px auto; padding: 0 24px; color: #1d1d1f; }
h1,h2,h3,h4 { line-height: 1.25; margin-top: 1.6em; }
code { background: #f2f2f4; padding: 2px 5px; border-radius: 4px; font-size: 0.9em; }
pre { background: #f2f2f4; padding: 14px; border-radius: 8px; overflow-x: auto; }
blockquote { border-left: 3px solid #d0d0d5; margin-left: 0; padding-left: 16px; color: #555; }
table { border-collapse: collapse; } td { border: 1px solid #ddd; padding: 6px 10px; }
hr { border: none; border-top: 1px solid #ddd; margin: 2em 0; }
</style></head><body>
\(body.joined(separator: "\n"))
</body></html>
"""
}
private static func headingLevel(_ line: String) -> Int? {
let hashes = line.prefix { $0 == "#" }
guard (1...6).contains(hashes.count) else { return nil }
let rest = line.dropFirst(hashes.count)
guard rest.isEmpty || rest.first == " " else { return nil }
return hashes.count
}
private static func listItem(_ line: String) -> (type: String, text: String)? {
for marker in ["- ", "* ", "+ "] where line.hasPrefix(marker) {
return ("ul", String(line.dropFirst(2)))
}
let digits = line.prefix { $0.isNumber }
if !digits.isEmpty, line.dropFirst(digits.count).hasPrefix(". ") {
return ("ol", String(line.dropFirst(digits.count + 1)))
}
return nil
}
/// Inline spans: bold, italic, strikethrough, code, links, images.
static func inline(_ raw: String) -> String {
var text = escape(raw)
text = replace(#"!\[([^\]]*)\]\(([^)]+)\)"#, in: text) { match in
"<img src=\"\(match[2])\" alt=\"\(match[1])\"/>"
}
text = replace(#"\[([^\]]+)\]\(([^)]+)\)"#, in: text) { match in
"<a href=\"\(match[2])\">\(match[1])</a>"
}
text = replace(#"`([^`]+)`"#, in: text) { "<code>\($0[1])</code>" }
text = replace(#"\*\*([^*]+)\*\*"#, in: text) { "<strong>\($0[1])</strong>" }
text = replace(#"__([^_]+)__"#, in: text) { "<strong>\($0[1])</strong>" }
text = replace(#"(?<!\*)\*([^*]+)\*(?!\*)"#, in: text) { "<em>\($0[1])</em>" }
text = replace(#"(?<!_)_([^_]+)_(?!_)"#, in: text) { "<em>\($0[1])</em>" }
text = replace(#"~~([^~]+)~~"#, in: text) { "<del>\($0[1])</del>" }
return text
}
// MARK: - HTML → Markdown
static func markdown(fromHTML html: String) -> String {
var text = html
// Drop anything that cannot be represented as text.
text = replace(#"(?is)<(head|script|style|noscript)[^>]*>.*?</\1>"#, in: text) { _ in "" }
text = replace(#"(?is)<!--.*?-->"#, in: text) { _ in "" }
// Block level first.
for level in (1...6).reversed() {
text = replace(#"(?is)<h\#(level)[^>]*>(.*?)</h\#(level)>"#, in: text) { match in
"\n\n" + String(repeating: "#", count: level) + " " + stripTags(match[1]).trimmed + "\n\n"
}
}
text = replace(#"(?is)<pre[^>]*>(.*?)</pre>"#, in: text) { match in
"\n\n```\n" + stripTags(match[1]).trimmingCharacters(in: .newlines) + "\n```\n\n"
}
text = replace(#"(?is)<blockquote[^>]*>(.*?)</blockquote>"#, in: text) { match in
"\n\n" + stripTags(match[1]).trimmed
.split(separator: "\n").map { "> " + $0 }.joined(separator: "\n") + "\n\n"
}
text = replace(#"(?is)<li[^>]*>(.*?)</li>"#, in: text) { match in
"\n- " + stripTags(match[1]).trimmed
}
text = replace(#"(?is)<(ul|ol)[^>]*>"#, in: text) { _ in "\n" }
text = replace(#"(?is)</(ul|ol)>"#, in: text) { _ in "\n" }
text = replace(#"(?is)<hr[^>]*/?>"#, in: text) { _ in "\n\n---\n\n" }
text = replace(#"(?is)<t[dh][^>]*>(.*?)</t[dh]>"#, in: text) { match in
" " + stripTags(match[1]).trimmed + " |"
}
text = replace(#"(?is)<tr[^>]*>"#, in: text) { _ in "\n|" }
text = replace(#"(?is)<br[^>]*/?>"#, in: text) { _ in " \n" }
text = replace(#"(?is)</p>"#, in: text) { _ in "\n\n" }
text = replace(#"(?is)<p[^>]*>"#, in: text) { _ in "\n\n" }
text = replace(#"(?is)</div>"#, in: text) { _ in "\n" }
// Inline.
text = replace(#"(?is)<(strong|b)[^>]*>(.*?)</\1>"#, in: text) { "**\(stripTags($0[2]).trimmed)**" }
text = replace(#"(?is)<(em|i)[^>]*>(.*?)</\1>"#, in: text) { "*\(stripTags($0[2]).trimmed)*" }
text = replace(#"(?is)<del[^>]*>(.*?)</del>"#, in: text) { "~~\(stripTags($0[1]).trimmed)~~" }
text = replace(#"(?is)<code[^>]*>(.*?)</code>"#, in: text) { "`\(stripTags($0[1]))`" }
text = replace(#"(?is)<img[^>]*alt=["']([^"']*)["'][^>]*src=["']([^"']+)["'][^>]*/?>"#, in: text) {
"![\($0[1])](\($0[2]))"
}
text = replace(#"(?is)<img[^>]*src=["']([^"']+)["'][^>]*/?>"#, in: text) { match in "![](\(match[1]))" }
text = replace(#"(?is)<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>"#, in: text) {
"[\(stripTags($0[2]).trimmed)](\($0[1]))"
}
text = stripTags(text)
text = decodeEntities(text)
// Collapse the whitespace the block replacements left behind.
text = replace(#"[ \t]+\n"#, in: text) { _ in "\n" }
text = replace(#"\n{3,}"#, in: text) { _ in "\n\n" }
return text.trimmingCharacters(in: .whitespacesAndNewlines) + "\n"
}
// MARK: - Helpers
static func escape(_ string: String) -> String {
string
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
}
static func stripTags(_ html: String) -> String {
replace(#"(?is)<[^>]+>"#, in: html) { _ in "" }
}
static func decodeEntities(_ text: String) -> String {
var result = text
let map = [
"&nbsp;": " ", "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": "\"",
"&#39;": "'", "&apos;": "'", "&mdash;": "—", "&ndash;": "–", "&hellip;": "…",
"&rsquo;": "’", "&lsquo;": "‘", "&ldquo;": "“", "&rdquo;": "”"
]
for (entity, value) in map {
result = result.replacingOccurrences(of: entity, with: value)
}
// Numeric entities.
result = replace(#"&#(\d+);"#, in: result) { match in
guard let code = UInt32(match[1]), let scalar = Unicode.Scalar(code) else { return match[0] }
return String(Character(scalar))
}
return result
}
/// Regex replacement where the closure receives the full match plus groups.
static func replace(_ pattern: String, in string: String, _ transform: ([String]) -> String) -> String {
guard let regex = try? NSRegularExpression(pattern: pattern) else { return string }
let range = NSRange(string.startIndex..., in: string)
let matches = regex.matches(in: string, range: range)
guard !matches.isEmpty else { return string }
var output = string
for match in matches.reversed() {
guard let matchRange = Range(match.range, in: output) else { continue }
var groups: [String] = []
for index in 0..<match.numberOfRanges {
if let groupRange = Range(match.range(at: index), in: output) {
groups.append(String(output[groupRange]))
} else {
groups.append("")
}
}
output.replaceSubrange(matchRange, with: transform(groups))
}
return output
}
}
extension String {
var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) }
}
+225
View File
@@ -0,0 +1,225 @@
import AVFoundation
import CoreMedia
import Foundation
/// Video and audio tools. Where AVFoundation cannot express an edit (loudness
/// normalisation, silence ranges, bitrate targets) ffmpeg is used instead.
enum MediaTools {
static func compressVideo(_ source: URL, at destination: URL, quality: String) throws {
if Toolchain.hasFFmpeg, Settings.shared.useExternalTools {
let crf = quality == "Low" ? "30" : (quality == "High" ? "21" : "26")
try FFmpeg.filter(
source: source,
at: destination,
arguments: ["-c:v", "libx264", "-preset", "slow", "-crf", crf, "-c:a", "aac", "-b:a", "128k"]
)
return
}
try AVConverter.convert(source: source, to: destination.pathExtension == "mov" ? .mov : .mp4, at: destination, quality: quality)
}
static func normalizeAudio(_ source: URL, at destination: URL) throws {
try FFmpeg.filter(
source: source,
at: destination,
arguments: ["-af", "loudnorm=I=-16:TP=-1.5:LRA=11", "-c:a", codecForContainer(destination)]
)
}
static func channels(_ source: URL, at destination: URL, mono: Bool) throws {
if Toolchain.hasFFmpeg, Settings.shared.useExternalTools {
try FFmpeg.filter(
source: source,
at: destination,
arguments: ["-ac", mono ? "1" : "2", "-c:a", codecForContainer(destination)]
)
return
}
try CoreAudio.remix(source: source, at: destination, mono: mono)
}
static func bleep(_ source: URL, at destination: URL, start: Double, end: Double) throws {
let filter = "volume=enable='between(t,\(start),\(end))':volume=0"
try FFmpeg.filter(
source: source,
at: destination,
arguments: ["-af", filter, "-c:a", codecForContainer(destination)]
)
}
static func compressAudio(_ source: URL, at destination: URL, bitrate: String) throws {
guard let format = FileKind.format(of: destination) else {
throw ConversionError.failed("Unknown audio container.")
}
try FFmpeg.convert(source: source, to: format, at: destination, bitrate: bitrate)
}
/// Audio edits keep the source container where possible so the output name
/// and the file itself stay recognisable.
static func audioSibling(for source: URL, suffix: String, forcing format: Format? = nil) -> URL {
let resolved = format ?? FileKind.format(of: source) ?? .m4a
return OutputNaming.sibling(for: source, suffix: suffix, extension: resolved.ext)
}
private static func codecForContainer(_ url: URL) -> String {
switch FileKind.canonicalExtension(url.pathExtension) {
case "mp3": return "libmp3lame"
case "ogg": return "libvorbis"
case "opus": return "libopus"
case "flac": return "flac"
case "wav": return "pcm_s16le"
case "aiff": return "pcm_s16be"
case "wma": return "wmav2"
case "caf": return "pcm_s16le"
default: return "aac"
}
}
}
/// Serialisable, human readable descriptions for the info tools.
enum MediaInfo {
static func describe(_ url: URL) -> String {
switch FileKind.category(of: url) {
case .image: return describeImage(url)
case .video, .audio: return describeMedia(url)
case .pdf: return describePDF(url)
case .archive: return describeArchive(url)
case .spreadsheet, .document, .ebook, .subtitle: return describeText(url)
default: return describeFile(url)
}
}
static func describeFile(_ url: URL) -> String {
describeFileBasics(url).joined(separator: "\n")
}
private static func describeFileBasics(_ url: URL) -> [String] {
var lines: [String] = ["Name: \(url.lastPathComponent)"]
let values = try? url.resourceValues(forKeys: [.fileSizeKey, .creationDateKey, .contentModificationDateKey])
if let size = values?.fileSize {
lines.append("Size: \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file))")
}
if let created = values?.creationDate {
lines.append("Created: \(DateFormatter.localizedString(from: created, dateStyle: .medium, timeStyle: .short))")
}
if let modified = values?.contentModificationDate {
lines.append("Modified: \(DateFormatter.localizedString(from: modified, dateStyle: .medium, timeStyle: .short))")
}
return lines
}
private static func describeImage(_ url: URL) -> String {
var lines = describeFileBasics(url)
guard let source = Raster.imageSource(at: url) else { return lines.joined(separator: "\n") }
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] ?? [:]
if let width = properties[kCGImagePropertyPixelWidth] as? Int,
let height = properties[kCGImagePropertyPixelHeight] as? Int {
lines.append("Dimensions: \(width) × \(height)")
}
if let dpiWidth = properties[kCGImagePropertyDPIWidth] as? Double {
lines.append("Resolution: \(Int(dpiWidth)) dpi")
}
if let colorModel = properties[kCGImagePropertyColorModel] as? String {
lines.append("Color: \(colorModel)")
}
if properties[kCGImagePropertyGIFDictionary] != nil {
lines.append("Format: animated GIF")
}
if let exif = properties[kCGImagePropertyExifDictionary] as? [CFString: Any] {
if let date = exif[kCGImagePropertyExifDateTimeOriginal] as? String {
lines.append("Captured: \(date)")
}
}
if let tiff = properties[kCGImagePropertyTIFFDictionary] as? [CFString: Any] {
if let make = tiff[kCGImagePropertyTIFFMake] as? String,
let model = tiff[kCGImagePropertyTIFFModel] as? String {
lines.append("Camera: \(make) \(model)")
}
}
if let gps = properties[kCGImagePropertyGPSDictionary] as? [CFString: Any],
let latitude = gps[kCGImagePropertyGPSLatitude] as? Double,
let longitude = gps[kCGImagePropertyGPSLongitude] as? Double {
lines.append("Location: \(String(format: "%.4f", latitude)), \(String(format: "%.4f", longitude))")
}
lines.append("Frames: \(Raster.frameCount(at: url))")
return lines.joined(separator: "\n")
}
private static func describeMedia(_ url: URL) -> String {
var lines = describeFileBasics(url)
let asset = AVURLAsset(url: url)
let duration = CMTimeGetSeconds(asset.duration)
if duration.isFinite, duration > 0 {
let minutes = Int(duration) / 60
let remaining = duration - Double(minutes * 60)
lines.append("Duration: \(minutes):\(String(format: "%05.2f", remaining))")
}
for track in asset.tracks {
switch track.mediaType {
case .video:
let size = track.naturalSize.applying(track.preferredTransform)
let frameRate = track.nominalFrameRate
lines.append("Video: \(Int(abs(size.width)))×\(Int(abs(size.height))) @ \(String(format: "%.2f", frameRate)) fps")
lines.append("Codec: \(codecName(track) ?? "unknown")")
case .audio:
let rate = track.nominalFrameRate
lines.append("Audio: \(codecName(track) ?? "unknown") @ \(Int(rate)) Hz")
default:
break
}
}
if let ffprobe = Toolchain.ffprobe,
let output = Shell.output(ffprobe, ["-v", "error", "-show_entries", "format=bit_rate:stream=bit_rate", "-of", "default=nw=1", url.path]) {
let rates = output.split(separator: "\n").compactMap { $0.split(separator: "=").last }.compactMap { Int($0) }.filter { $0 > 0 }
if let bitrate = rates.first {
lines.append("Bitrate: \(bitrate / 1000) kbps")
}
}
return lines.joined(separator: "\n")
}
private static func codecName(_ track: AVAssetTrack) -> String? {
guard let raw = track.formatDescriptions.first else { return nil }
let description = raw as! CMFormatDescription
return describeFormat(description)
}
private static func describeFormat(_ description: CMFormatDescription) -> String? {
let codec = CMFormatDescriptionGetMediaSubType(description)
let bytes = [
UInt8((codec >> 24) & 0xFF), UInt8((codec >> 16) & 0xFF),
UInt8((codec >> 8) & 0xFF), UInt8(codec & 0xFF)
]
let name = String(bytes: bytes, encoding: .ascii)?.trimmingCharacters(in: .whitespaces)
return name?.isEmpty == false ? name : nil
}
private static func describePDF(_ url: URL) -> String {
var lines = describeFileBasics(url)
lines.append("Pages: \(PDFConverter.pageCount(url))")
if let size = PDFConverter.pageSize(url) {
lines.append("Page size: \(Int(size.width)) × \(Int(size.height)) pt")
}
let text = PDFConverter.extractText(url)
lines.append("Words: \(text.split { $0 == " " || $0.isNewline }.count)")
return lines.joined(separator: "\n")
}
private static func describeArchive(_ url: URL) -> String {
var lines = describeFileBasics(url)
let listing = (try? ArchiveConverter.list(url)) ?? ""
let entries = listing.split(separator: "\n").count
lines.append("Entries: \(entries)")
return lines.joined(separator: "\n")
}
private static func describeText(_ url: URL) -> String {
var lines = describeFileBasics(url)
let text = TextDocument.plainText(from: url) ?? ""
let words = text.split { $0 == " " || $0.isNewline }.count
lines.append("Words: \(words)")
lines.append("Characters: \(text.count)")
lines.append("Lines: \(text.components(separatedBy: "\n").count)")
return lines.joined(separator: "\n")
}
}
+110
View File
@@ -0,0 +1,110 @@
import Foundation
/// LibreOffice bridge for formats Apple's frameworks cannot render (PPT/PPTX,
/// XLS/XLSX/ODS when they need layout, PAGES, Keynote).
enum Office {
/// LibreOffice cannot run two headless conversions at once in the same
/// profile, so every call is serialised.
private static let lock = NSLock()
static func convert(
_ source: URL,
to format: Format,
at destination: URL,
cancellation: Cancellation? = nil
) throws {
guard let soffice = Toolchain.soffice, Settings.shared.useExternalTools else {
throw ConversionError.missingTool(
"\(format.displayName) needs LibreOffice. Install it with: brew install --cask libreoffice"
)
}
let filter = filterName(for: format)
let staging = FileManager.default.temporaryDirectory
.appendingPathComponent("konverter-office-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: staging) }
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
lock.lock()
defer { lock.unlock() }
try cancellation?.check()
try Shell.run(
soffice,
[
"--headless", "--norestore", "--nolockcheck", "--nodefault",
"-env:UserInstallation=file://" + staging.path + "/profile",
"--convert-to", filter,
"--outdir", staging.path,
source.path
],
cancellation: cancellation
)
let produced = try FileManager.default
.contentsOfDirectory(at: staging, includingPropertiesForKeys: nil)
.first { $0.pathExtension.lowercased() == destination.pathExtension.lowercased() }
guard let produced else {
throw ConversionError.failed("LibreOffice did not produce a \(format.displayName) file.")
}
try? FileManager.default.removeItem(at: destination)
try FileManager.default.moveItem(at: produced, to: destination)
}
static func filterName(for format: Format) -> String {
switch format {
case .pdf: return "pdf"
case .docx: return "docx"
case .odt: return "odt"
case .rtf: return "rtf"
case .txt: return "txt"
case .html: return "html"
case .csv: return "csv"
case .xlsx: return "xlsx"
case .ods: return "ods"
case .pptx: return "pptx"
case .epub: return "epub"
case .png: return "png"
case .jpg: return "jpg"
default: return format.ext
}
}
}
/// PPT/PPTX/KEY → PDF or images. Everything funnels through a PDF so multi
/// slide decks come out with one image per slide.
enum SlidesConverter {
static func convert(
source: URL,
to format: Format,
at destination: URL,
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws {
switch format {
case .pdf:
try Office.convert(source, to: .pdf, at: destination, cancellation: cancellation)
case .png, .jpg:
let staging = FileManager.default.temporaryDirectory
.appendingPathComponent("konverter-slides-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: staging) }
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
let pdf = staging.appendingPathComponent("deck.pdf")
try Office.convert(source, to: .pdf, at: pdf, cancellation: cancellation)
let directory = OutputNaming.artifactDirectory(
for: destination,
suffix: ""
)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
_ = try PDFConverter.renderPages(
pdf,
to: format,
into: directory,
dpi: 200,
cancellation: cancellation,
progress: progress
)
default:
throw ConversionError.unsupported("Slides become PDF or images.")
}
}
}
@@ -0,0 +1,381 @@
import AppKit
import CoreGraphics
import CoreText
import Foundation
import PDFKit
/// Everything PDF: rasterising pages, building PDFs from images, text
/// extraction, splitting, merging, rotating and password handling.
enum PDFConverter {
static let defaultDPI: CGFloat = 300
// MARK: - Images → PDF
static func imageToPDF(_ source: URL, at destination: URL, cancellation: Cancellation? = nil) throws {
guard let image = Raster.firstImage(at: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
try imagesToPDF([(image, nil)], at: destination, cancellation: cancellation)
}
/// Each image becomes one page, sized so the image keeps its aspect ratio
/// at 72 dpi (a 1440×900 screenshot becomes a 1440×900 pt page).
static func imagesToPDF(
_ pages: [(image: CGImage, name: String?)],
at destination: URL,
pageSize: CGSize? = nil,
cancellation: Cancellation? = nil
) throws {
guard !pages.isEmpty else { throw ConversionError.failed("Nothing to put in the PDF.") }
var mediaBox = CGRect(origin: .zero, size: pages[0].name == nil
? CGSize(width: pages[0].image.width, height: pages[0].image.height)
: CGSize(width: 595, height: 842))
if let pageSize { mediaBox = CGRect(origin: .zero, size: pageSize) }
guard let context = CGContext(destination as CFURL, mediaBox: &mediaBox, nil) else {
throw ConversionError.failed("Could not create \(destination.lastPathComponent).")
}
for page in pages {
try cancellation?.check()
let image = page.image
var box = mediaBox
if page.name == nil || pageSize == nil {
box = CGRect(x: 0, y: 0, width: image.width, height: image.height)
}
context.beginPDFPage([kCGPDFContextMediaBox: CGRectToData(box)] as CFDictionary)
let scale = min(box.width / CGFloat(image.width), box.height / CGFloat(image.height))
let size = CGSize(width: CGFloat(image.width) * scale, height: CGFloat(image.height) * scale)
let rect = CGRect(
x: (box.width - size.width) / 2,
y: (box.height - size.height) / 2,
width: size.width,
height: size.height
)
context.draw(image, in: rect)
context.endPDFPage()
}
context.closePDF()
}
private static func CGRectToData(_ rect: CGRect) -> CFData {
var rect = rect
return CFDataCreate(nil, withUnsafeBytes(of: &rect) { $0.bindMemory(to: UInt8.self).baseAddress }, MemoryLayout<CGRect>.size)
}
// MARK: - PDF → images
/// Renders each page to `format` at `dpi`. Returns the files written.
@discardableResult
static func renderPages(
_ source: URL,
to format: Format,
into directory: URL,
dpi: CGFloat = 300,
pageNumbers: [Int]? = nil,
quality: Double = 0.85,
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws -> [URL] {
guard let document = PDFDocument(url: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
let base = source.deletingPathExtension().lastPathComponent
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let indices = pageNumbers ?? Array(0..<document.pageCount)
var written: [URL] = []
let scale = dpi / 72
let padding = String(indices.count).count
for (offset, pageIndex) in indices.enumerated() {
try cancellation?.check()
guard let page = document.page(at: pageIndex) else { continue }
let bounds = page.bounds(for: .mediaBox)
let pixelWidth = Int((bounds.width * scale).rounded())
let pixelHeight = Int((bounds.height * scale).rounded())
guard pixelWidth > 0, pixelHeight > 0 else { continue }
guard let context = CGContext(
data: nil,
width: pixelWidth,
height: pixelHeight,
bitsPerComponent: 8,
bytesPerRow: 0,
space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue
) else { continue }
context.setFillColor(CGColor(gray: 1, alpha: 1))
context.fill(CGRect(x: 0, y: 0, width: pixelWidth, height: pixelHeight))
context.scaleBy(x: scale, y: scale)
context.translateBy(x: -bounds.origin.x, y: -bounds.origin.y)
page.draw(with: .mediaBox, to: context)
guard let image = context.makeImage() else { continue }
let number = String(pageIndex + 1).leftPadded(to: padding)
let target = OutputNaming.availableURL(
for: directory.appendingPathComponent("\(base)-\(number)").appendingPathExtension(format.ext)
)
try Raster.write(image, to: target, format: format, quality: quality, metadata: nil)
written.append(target)
progress?(Double(offset + 1) / Double(indices.count))
}
return written
}
// MARK: - Text
static func extractText(_ source: URL, pageBreak: String = "\n\n") -> String {
guard let document = PDFDocument(url: source) else { return "" }
return (0..<document.pageCount)
.compactMap { document.page(at: $0)?.string }
.joined(separator: pageBreak)
}
// MARK: - Document surgery
static func merge(_ sources: [URL], at destination: URL, cancellation: Cancellation? = nil) throws {
let merged = PDFDocument()
var index = 0
for source in sources {
try cancellation?.check()
guard let document = PDFDocument(url: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
for pageIndex in 0..<document.pageCount {
try cancellation?.check()
guard let page = document.page(at: pageIndex)?.copy() as? PDFPage else { continue }
merged.insert(page, at: index)
index += 1
}
}
guard merged.pageCount > 0 else { throw ConversionError.failed("No pages to merge.") }
guard merged.write(to: destination) else {
throw ConversionError.failed("Could not write \(destination.lastPathComponent).")
}
}
/// Splits into chunks of `pagesPerFile`, writing into a sibling folder.
static func split(
_ source: URL,
pagesPerFile: Int,
into directory: URL,
cancellation: Cancellation? = nil
) throws -> [URL] {
guard let document = PDFDocument(url: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
let base = source.deletingPathExtension().lastPathComponent
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
var written: [URL] = []
var start = 0
var part = 1
while start < document.pageCount {
try cancellation?.check()
let chunk = PDFDocument()
var cursor = 0
for pageIndex in start..<min(start + pagesPerFile, document.pageCount) {
guard let page = document.page(at: pageIndex)?.copy() as? PDFPage else { continue }
chunk.insert(page, at: cursor)
cursor += 1
}
let target = OutputNaming.availableURL(
for: directory.appendingPathComponent("\(base)-\(String(format: "%02d", part)).pdf")
)
guard chunk.write(to: target) else {
throw ConversionError.failed("Could not write \(target.lastPathComponent).")
}
written.append(target)
start += pagesPerFile
part += 1
}
return written
}
static func rotate(_ source: URL, degrees: Int, at destination: URL) throws {
guard let document = PDFDocument(url: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
for index in 0..<document.pageCount {
guard let page = document.page(at: index) else { continue }
page.rotation = (page.rotation + degrees) % 360
}
guard document.write(to: destination) else {
throw ConversionError.failed("Could not write \(destination.lastPathComponent).")
}
}
static func encrypt(_ source: URL, at destination: URL, password: String, ownerPassword: String?) throws {
guard let document = PDFDocument(url: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
let options: [PDFDocumentWriteOption: Any] = [
.userPasswordOption: password,
.ownerPasswordOption: ownerPassword?.isEmpty == false ? ownerPassword! : password
]
guard document.write(to: destination, withOptions: options) else {
throw ConversionError.failed("Could not write the encrypted PDF.")
}
}
/// Rewrites through an image pipeline, which is what "compress a PDF"
/// means in practice: re-encode each page as a JPEG at lower quality.
static func compress(
_ source: URL,
at destination: URL,
quality: Double,
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws {
guard let document = PDFDocument(url: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
guard let firstPage = document.page(at: 0) else { return }
var mediaBox = firstPage.bounds(for: .mediaBox)
guard let context = CGContext(destination as CFURL, mediaBox: &mediaBox, nil) else {
throw ConversionError.failed("Could not create the compressed PDF.")
}
for index in 0..<document.pageCount {
try cancellation?.check()
guard let page = document.page(at: index) else { continue }
let bounds = page.bounds(for: .mediaBox)
let scale: CGFloat = 150 / 72
let width = Int(bounds.width * scale)
let height = Int(bounds.height * scale)
var box = bounds
context.beginPDFPage([kCGPDFContextMediaBox: CGRectToData(box)] as CFDictionary)
if width > 0, height > 0,
let bitmap = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue
) {
bitmap.setFillColor(CGColor(gray: 1, alpha: 1))
bitmap.fill(CGRect(x: 0, y: 0, width: width, height: height))
bitmap.scaleBy(x: scale, y: scale)
bitmap.translateBy(x: -bounds.origin.x, y: -bounds.origin.y)
page.draw(with: .mediaBox, to: bitmap)
if let image = bitmap.makeImage(),
let jpeg = JPEGEncoder.encode(image, quality: quality) {
box = bounds
context.draw(jpeg, in: bounds)
}
}
context.endPDFPage()
progress?(Double(index + 1) / Double(document.pageCount))
}
context.closePDF()
}
/// Lays images out in a grid on a single page (Tangerine's "collage").
static func collage(
_ entries: [(image: CGImage, name: String)],
at destination: URL,
columns: Int,
page: String,
margin: CGFloat,
captions: Bool,
cancellation: Cancellation? = nil
) throws {
guard !entries.isEmpty else { throw ConversionError.failed("Nothing to lay out.") }
let pageSize: CGSize
switch page {
case "Letter": pageSize = CGSize(width: 612, height: 792)
case "Square": pageSize = CGSize(width: 800, height: 800)
default: pageSize = CGSize(width: 595, height: 842)
}
var mediaBox = CGRect(origin: .zero, size: pageSize)
guard let context = CGContext(destination as CFURL, mediaBox: &mediaBox, nil) else {
throw ConversionError.failed("Could not create \(destination.lastPathComponent).")
}
context.beginPDFPage([kCGPDFContextMediaBox: CGRectToData(mediaBox)] as CFDictionary)
let columnCount = max(1, columns)
let rowCount = Int(ceil(Double(entries.count) / Double(columnCount)))
let usableWidth = pageSize.width - margin * 2
let usableHeight = pageSize.height - margin * 2
let cellWidth = usableWidth / CGFloat(columnCount)
let cellHeight = usableHeight / CGFloat(max(1, rowCount))
let captionHeight: CGFloat = captions ? 14 : 0
for (index, entry) in entries.enumerated() {
try cancellation?.check()
let column = index % columnCount
let row = index / columnCount
let cell = CGRect(
x: margin + CGFloat(column) * cellWidth,
y: pageSize.height - margin - CGFloat(row + 1) * cellHeight,
width: cellWidth,
height: cellHeight
).insetBy(dx: 6, dy: 6)
let imageArea = cell.insetBy(dx: 0, dy: captionHeight)
let scale = min(imageArea.width / CGFloat(entry.image.width), imageArea.height / CGFloat(entry.image.height))
let size = CGSize(width: CGFloat(entry.image.width) * scale, height: CGFloat(entry.image.height) * scale)
context.draw(entry.image, in: CGRect(
x: imageArea.midX - size.width / 2,
y: imageArea.midY - size.height / 2,
width: size.width,
height: size.height
))
if captions, let escaped = entry.name as CFString? {
let font = CTFontCreateWithName("Helvetica" as CFString, 9, nil)
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: CGColor(gray: 0.35, alpha: 1)
]
let line = CTLineCreateWithAttributedString(
NSAttributedString(string: escaped as String, attributes: attributes)
)
context.textPosition = CGPoint(x: cell.minX, y: cell.minY)
CTLineDraw(line, context)
}
}
context.endPDFPage()
context.closePDF()
}
/// Rasterises a PDF page range to a single contact sheet, used by the
/// "Info" summary.
static func pageSize(_ source: URL) -> CGSize? {
PDFDocument(url: source)?.page(at: 0)?.bounds(for: .mediaBox).size
}
static func pageCount(_ source: URL) -> Int {
PDFDocument(url: source)?.pageCount ?? 0
}
}
/// Small helper so the PDF compressor can produce JPEG bytes in memory.
enum JPEGEncoder {
static func encode(_ image: CGImage, quality: Double) -> CGImage? {
let flattened = Raster.flattened(image)
guard let data = CFDataCreateMutable(nil, 0),
let destination = CGImageDestinationCreateWithData(data, "public.jpeg" as CFString, 1, nil) else {
return nil
}
CGImageDestinationAddImage(
destination,
flattened,
[kCGImageDestinationLossyCompressionQuality: quality] as CFDictionary
)
guard CGImageDestinationFinalize(destination) else { return nil }
guard let source = CGImageSourceCreateWithData(data, nil) else { return nil }
return CGImageSourceCreateImageAtIndex(source, 0, nil)
}
}
extension String {
func leftPadded(to width: Int, with character: Character = "0") -> String {
count >= width ? self : String(repeating: String(character), count: width - count) + self
}
}
@@ -0,0 +1,495 @@
import AppKit
import Foundation
/// CSV/TSV/XLSX/ODS. The delimited formats are parsed in-process; XLSX is read
/// and written as OOXML by hand so no dependency is needed; everything else
/// goes through LibreOffice.
enum SpreadsheetConverter {
typealias Rows = [[String]]
static func convert(
source: URL,
to format: Format,
at destination: URL,
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws {
try cancellation?.check()
switch format {
case .csv, .tsv:
let rows = try read(source, cancellation: cancellation)
try write(rows, to: destination, delimiter: format == .tsv ? "\t" : ",")
case .xlsx:
let rows = try read(source, cancellation: cancellation)
try XLSX.write(rows, to: destination)
case .pdf:
let rows = try read(source, cancellation: cancellation)
try writePDF(rows, at: destination)
case .ods, .numbers, .xls:
try Office.convert(source, to: format, at: destination, cancellation: cancellation)
default:
throw ConversionError.unsupported("Spreadsheets cannot become \(format.displayName).")
}
progress?(1)
}
// MARK: - Reading
static func read(_ url: URL, cancellation: Cancellation? = nil) throws -> Rows {
switch FileKind.canonicalExtension(url.pathExtension) {
case "csv":
let text = try string(from: url)
return parseDelimited(text, delimiter: ",")
case "tsv":
let text = try string(from: url)
return parseDelimited(text, delimiter: "\t")
case "xlsx":
return try XLSX.read(url, cancellation: cancellation)
case "ods":
return try ODS.read(url, cancellation: cancellation)
case "pdf":
// Tables in a PDF: best effort, one row per line.
let text = PDFConverter.extractText(url)
return text
.components(separatedBy: "\n")
.filter { !$0.trimmed.isEmpty }
.map { [$0] }
default:
throw ConversionError.unsupported("Konverter cannot read .\(url.pathExtension) spreadsheets without LibreOffice.")
}
}
private static func string(from url: URL) throws -> String {
if let utf8 = try? String(contentsOf: url, encoding: .utf8) { return utf8 }
if let latin = try? String(contentsOf: url, encoding: .isoLatin1) { return latin }
throw ConversionError.unreadable(url.lastPathComponent)
}
/// RFC 4180-ish: quotes, doubled quotes, embedded newlines.
static func parseDelimited(_ text: String, delimiter: Character) -> Rows {
var rows: Rows = []
var row: [String] = []
var field = ""
var inQuotes = false
var iterator = text.makeIterator()
var pending: Character?
while let character = pending ?? iterator.next() {
pending = nil
if inQuotes {
if character == "\"" {
if let next = iterator.next() {
if next == "\"" {
field.append("\"")
} else {
inQuotes = false
pending = next
}
} else {
inQuotes = false
}
} else {
field.append(character)
}
continue
}
switch character {
case "\"":
inQuotes = true
case delimiter:
row.append(field)
field = ""
case "\r":
break
case "\n":
row.append(field)
field = ""
rows.append(row)
row = []
default:
field.append(character)
}
}
if !field.isEmpty || !row.isEmpty {
row.append(field)
rows.append(row)
}
// Drop a trailing empty row caused by a final newline.
if let last = rows.last, last.allSatisfy({ $0.isEmpty }) { rows.removeLast() }
return rows
}
// MARK: - Writing
static func write(_ rows: Rows, to url: URL, delimiter: String) throws {
let text = rows.map { row in
row.map { escape($0, delimiter: delimiter) }.joined(separator: delimiter)
}.joined(separator: "\n")
try text.write(to: url, atomically: true, encoding: .utf8)
}
private static func escape(_ field: String, delimiter: String) -> String {
let needsQuotes = field.contains(delimiter) || field.contains("\"") || field.contains("\n") || field.contains("\r")
guard needsQuotes else { return field }
return "\"" + field.replacingOccurrences(of: "\"", with: "\"\"") + "\""
}
static func writePDF(_ rows: Rows, at url: URL) throws {
let columnCount = rows.map(\.count).max() ?? 0
guard columnCount > 0 else { throw ConversionError.failed("Nothing to write.") }
var html = """
<!DOCTYPE html><html><head><meta charset="utf-8"><style>
body{font:11px -apple-system,sans-serif;margin:24px}
table{border-collapse:collapse;width:100%}
td,th{border:1px solid #d8d8dd;padding:4px 8px;vertical-align:top}
th{background:#f4f4f7;text-align:left}
</style></head><body><table>
"""
for (index, row) in rows.enumerated() {
let tag = index == 0 ? "th" : "td"
html += "<tr>" + (0..<columnCount).map { column in
let value = column < row.count ? row[column] : ""
return "<\(tag)>\(Markdown.escape(value))</\(tag)>"
}.joined() + "</tr>"
}
html += "</table></body></html>"
let attributed = try? NSAttributedString(
data: Data(html.utf8),
options: [.documentType: NSAttributedString.DocumentType.html],
documentAttributes: nil
)
if let attributed {
try TextDocument.writePDF(attributed, to: url, pageSize: CGSize(width: 842, height: 595))
} else {
try TextDocument.writePDF(fromText: rows.map { $0.joined(separator: "\t") }.joined(separator: "\n"), to: url)
}
}
}
/// Minimal OOXML spreadsheet reader/writer.
enum XLSX {
static func read(_ url: URL, cancellation: Cancellation? = nil) throws -> SpreadsheetConverter.Rows {
let staging = try Unzip.expand(url, cancellation: cancellation)
defer { try? FileManager.default.removeItem(at: staging) }
let sharedStrings = parseSharedStrings(
at: staging.appendingPathComponent("xl/sharedStrings.xml")
)
let sheetURL = firstSheet(at: staging)
guard let sheetURL else { throw ConversionError.failed("This workbook has no sheets.") }
return try parseSheet(at: sheetURL, sharedStrings: sharedStrings)
}
static func write(_ rows: SpreadsheetConverter.Rows, to url: URL) throws {
let staging = FileManager.default.temporaryDirectory
.appendingPathComponent("konverter-xlsx-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: staging) }
try FileManager.default.createDirectory(
at: staging.appendingPathComponent("xl/_rels"),
withIntermediateDirectories: true
)
try FileManager.default.createDirectory(
at: staging.appendingPathComponent("_rels"),
withIntermediateDirectories: true
)
try FileManager.default.createDirectory(
at: staging.appendingPathComponent("xl/worksheets"),
withIntermediateDirectories: true
)
try contentTypes.write(to: staging.appendingPathComponent("[Content_Types].xml"), atomically: true, encoding: .utf8)
try packageRels.write(to: staging.appendingPathComponent("_rels/.rels"), atomically: true, encoding: .utf8)
try workbookRels.write(to: staging.appendingPathComponent("xl/_rels/workbook.xml.rels"), atomically: true, encoding: .utf8)
try styles.write(to: staging.appendingPathComponent("xl/styles.xml"), atomically: true, encoding: .utf8)
try workbook.write(to: staging.appendingPathComponent("xl/workbook.xml"), atomically: true, encoding: .utf8)
try sheetXML(rows).write(to: staging.appendingPathComponent("xl/worksheets/sheet1.xml"), atomically: true, encoding: .utf8)
let zip = Toolchain.zip
try? FileManager.default.removeItem(at: url)
try Shell.run(zip, ["-r", "-X", "-q", url.path, "[Content_Types].xml", "_rels", "xl"], currentDirectory: staging)
}
private static func sheetXML(_ rows: SpreadsheetConverter.Rows) -> String {
var xml = """
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>
"""
for (rowIndex, row) in rows.enumerated() {
xml += "<row r=\"\(rowIndex + 1)\">"
for (columnIndex, value) in row.enumerated() {
let reference = "\(columnName(columnIndex))\(rowIndex + 1)"
if let number = numberValue(value) {
xml += "<c r=\"\(reference)\"><v>\(number)</v></c>"
} else {
xml += "<c r=\"\(reference)\" t=\"inlineStr\"><is><t xml:space=\"preserve\">\(Markdown.escape(value))</t></is></c>"
}
}
xml += "</row>"
}
xml += "</sheetData></worksheet>"
return xml
}
/// Writes a number only when the value really is one, so phone numbers and
/// zero-padded ids stay text.
private static func numberValue(_ value: String) -> String? {
let trimmed = value.trimmed
guard !trimmed.isEmpty, trimmed == value else { return nil }
guard trimmed.rangeOfCharacter(from: CharacterSet(charactersIn: "0123456789")) != nil else { return nil }
guard let number = Double(trimmed), number.isFinite else { return nil }
if trimmed.count > 1, trimmed.hasPrefix("0"), !trimmed.hasPrefix("0.") { return nil }
return trimmed
}
static func columnName(_ index: Int) -> String {
var index = index
var name = ""
repeat {
name = String(UnicodeScalar(UInt8(65 + index % 26))) + name
index = index / 26 - 1
} while index >= 0
return name
}
static func columnIndex(_ reference: String) -> Int {
var index = 0
for character in reference.uppercased() {
guard let ascii = character.asciiValue, ascii >= 65, ascii <= 90 else { break }
index = index * 26 + Int(ascii - 64)
}
return max(0, index - 1)
}
// MARK: - Parsing helpers
private static func firstSheet(at staging: URL) -> URL? {
let worksheets = staging.appendingPathComponent("xl/worksheets")
let files = (try? FileManager.default.contentsOfDirectory(at: worksheets, includingPropertiesForKeys: nil)) ?? []
return files.filter { $0.pathExtension == "xml" }.sorted { $0.lastPathComponent < $1.lastPathComponent }.first
}
private static func parseSharedStrings(at url: URL) -> [String] {
guard let data = try? Data(contentsOf: url) else { return [] }
let parser = XMLParser(data: data)
let delegate = SharedStringsDelegate()
parser.delegate = delegate
parser.parse()
return delegate.strings
}
private static func parseSheet(at url: URL, sharedStrings: [String]) throws -> SpreadsheetConverter.Rows {
guard let data = try? Data(contentsOf: url) else {
throw ConversionError.failed("Could not read the worksheet.")
}
let parser = XMLParser(data: data)
let delegate = SheetDelegate(sharedStrings: sharedStrings)
parser.delegate = delegate
parser.parse()
return delegate.rows
}
private final class SharedStringsDelegate: NSObject, XMLParserDelegate {
var strings: [String] = []
private var current: String?
private var buffer = ""
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?, attributes: [String: String]) {
if elementName == "si" { current = ""; buffer = "" }
if elementName == "t" { buffer = "" }
}
func parser(_ parser: XMLParser, foundCharacters string: String) {
buffer += string
}
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName: String?) {
if elementName == "t" { current = (current ?? "") + buffer }
if elementName == "si" { strings.append(current ?? ""); current = nil }
}
}
private final class SheetDelegate: NSObject, XMLParserDelegate {
let sharedStrings: [String]
var rows: SpreadsheetConverter.Rows = []
private var row: [String] = []
private var pendingColumn = 0
private var valueBuffer = ""
private var cellType = ""
private var isInline = false
private var inCell = false
init(sharedStrings: [String]) {
self.sharedStrings = sharedStrings
}
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?, attributes: [String: String]) {
switch elementName {
case "row":
row = []
case "c":
inCell = true
cellType = attributes["t"] ?? ""
isInline = false
valueBuffer = ""
if let reference = attributes["r"] {
pendingColumn = XLSX.columnIndex(reference)
}
case "is":
isInline = true
case "v", "t":
valueBuffer = ""
default:
break
}
}
func parser(_ parser: XMLParser, foundCharacters string: String) {
if inCell { valueBuffer += string }
}
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName: String?) {
switch elementName {
case "c":
let value: String
if cellType == "s", let index = Int(valueBuffer.trimmed), index < sharedStrings.count {
value = sharedStrings[index]
} else {
value = isInline ? valueBuffer : valueBuffer.trimmed
}
while row.count < pendingColumn { row.append("") }
row.append(value)
pendingColumn = row.count
inCell = false
valueBuffer = ""
case "row":
rows.append(row)
row = []
default:
break
}
}
}
// MARK: - Static OOXML scaffolding
private static let contentTypes = """
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
</Types>
"""
private static let packageRels = """
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>
"""
private static let workbookRels = """
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>
"""
private static let workbook = """
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>
"""
private static let styles = """
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
<fills count="1"><fill><patternFill patternType="none"/></fill></fills>
<borders count="1"><border/></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>
</styleSheet>
"""
}
/// ODS is a zip of `content.xml`; a light parser is enough for flat tables.
enum ODS {
static func read(_ url: URL, cancellation: Cancellation? = nil) throws -> SpreadsheetConverter.Rows {
let staging = try Unzip.expand(url, cancellation: cancellation)
defer { try? FileManager.default.removeItem(at: staging) }
guard let data = try? Data(contentsOf: staging.appendingPathComponent("content.xml")) else {
throw ConversionError.failed("This ODS file has no content.xml.")
}
let parser = XMLParser(data: data)
let delegate = TableDelegate()
parser.delegate = delegate
parser.parse()
return delegate.rows
}
private final class TableDelegate: NSObject, XMLParserDelegate {
private(set) var rows: SpreadsheetConverter.Rows = []
private var row: [String] = []
private var buffer = ""
private var inCell = false
private var inRow = false
private var repeatColumns = 1
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?, attributes: [String: String]) {
switch elementName {
case "table-row":
inRow = true
row = []
case "table-cell":
inCell = true
buffer = ""
repeatColumns = Int(attributes["table:number-columns-repeated"] ?? "1") ?? 1
case "p":
if inCell, !buffer.isEmpty { buffer += " " }
default:
break
}
}
func parser(_ parser: XMLParser, foundCharacters string: String) {
if inCell { buffer += string }
}
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName: String?) {
switch elementName {
case "table-cell":
let value = buffer.trimmed
for _ in 0..<min(repeatColumns, 256) { row.append(value) }
inCell = false
case "table-row":
if !row.allSatisfy({ $0.isEmpty }) { rows.append(row) }
inRow = false
case "table":
break
default:
break
}
}
}
}
enum Unzip {
/// Expands an archive and returns the staging directory the caller must
/// delete.
static func expand(_ url: URL, cancellation: Cancellation? = nil) throws -> URL {
let staging = FileManager.default.temporaryDirectory
.appendingPathComponent("konverter-unzip-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
do {
try Shell.run(Toolchain.unzip, ["-qq", "-o", url.path, "-d", staging.path], cancellation: cancellation)
} catch {
try? FileManager.default.removeItem(at: staging)
throw error
}
return staging
}
}
@@ -0,0 +1,146 @@
import Foundation
struct SubtitleCue {
var start: Double
var end: Double
var text: String
}
/// SRT ↔ VTT ↔ TXT, plus the offset tool.
enum SubtitleConverter {
static func convert(
source: URL,
to format: Format,
at destination: URL,
cancellation: Cancellation? = nil
) throws {
var cues = try parse(source)
try cancellation?.check()
cues = cues.map { cue in
var copy = cue
copy.text = SubtitleConverter.normalized(copy.text)
return copy
}
switch format {
case .srt, .vtt:
try write(cues, to: destination, format: format)
case .txt:
try TextDocument.write(cues.map(\.text).joined(separator: "\n"), to: destination)
default:
throw ConversionError.unsupported("Subtitles cannot become \(format.displayName).")
}
}
static func parse(_ url: URL) throws -> [SubtitleCue] {
guard let raw = try? String(contentsOf: url, encoding: .utf8)
?? (try? String(contentsOf: url, encoding: .isoLatin1)) else {
throw ConversionError.unreadable(url.lastPathComponent)
}
let ext = FileKind.canonicalExtension(url.pathExtension)
switch ext {
case "vtt":
return parseBlocks(raw, timeSeparator: ".")
case "ass", "ssa":
return parseASS(raw)
default:
return parseBlocks(raw, timeSeparator: nil)
}
}
private static func parseBlocks(_ raw: String, timeSeparator: Character?) -> [SubtitleCue] {
let normalized = raw
.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
.replacingOccurrences(of: "\u{FEFF}", with: "")
let blocks = normalized.components(separatedBy: "\n\n")
var cues: [SubtitleCue] = []
for block in blocks {
let lines = block.components(separatedBy: "\n").map { $0.trimmed }.filter { !$0.isEmpty }
guard let timeLineIndex = lines.firstIndex(where: { $0.contains("-->") }) else { continue }
let timeLine = lines[timeLineIndex]
let parts = timeLine.components(separatedBy: "-->")
guard parts.count == 2,
let start = seconds(from: parts[0], separator: timeSeparator),
let end = seconds(from: parts[1], separator: timeSeparator) else { continue }
let text = lines.dropFirst(timeLineIndex + 1).joined(separator: "\n")
cues.append(SubtitleCue(start: start, end: max(end, start), text: text.isEmpty ? " " : text))
}
return cues
}
private static func parseASS(_ raw: String) -> [SubtitleCue] {
var cues: [SubtitleCue] = []
for line in raw.components(separatedBy: "\n") where line.hasPrefix("Dialogue:") {
let fields = line.dropFirst("Dialogue:".count).components(separatedBy: ",")
guard fields.count >= 10,
let start = seconds(from: fields[1], separator: nil),
let end = seconds(from: fields[2], separator: nil) else { continue }
let text = fields[9...].joined(separator: ",")
.replacingOccurrences(of: "\\N", with: "\n")
.replacingOccurrences(of: "\\n", with: "\n")
cues.append(SubtitleCue(start: start, end: end, text: text))
}
return cues
}
static func write(_ cues: [SubtitleCue], to url: URL, format: Format) throws {
var output = format == .vtt ? "WEBVTT\n\n" : ""
for (index, cue) in cues.enumerated() {
if format == .srt {
output += "\(index + 1)\n"
output += "\(timecode(cue.start, separator: ",")) --> \(timecode(cue.end, separator: ","))\n"
} else {
output += "\(timecode(cue.start, separator: ".")) --> \(timecode(cue.end, separator: "."))\n"
}
output += cue.text + "\n\n"
}
try TextDocument.write(output, to: url)
}
static func shift(_ cues: [SubtitleCue], by offset: Double) -> [SubtitleCue] {
cues.map { cue in
var copy = cue
let shift = max(0, cue.start + offset) - cue.start
copy.start = max(0, cue.start + offset)
copy.end = max(copy.start, cue.end + shift)
return copy
}
}
private static func normalized(_ text: String) -> String {
var value = text
for tag in ["<i>", "</i>", "<b>", "</b>", "<u>", "</u>", "<font>", "</font>"] {
value = value.replacingOccurrences(of: tag, with: "")
}
return value
}
// MARK: - Timecodes
static func timecode(_ seconds: Double, separator: Character = ",") -> String {
let clamped = max(0, seconds)
let hours = Int(clamped) / 3600
let minutes = (Int(clamped) % 3600) / 60
let wholeSeconds = Int(clamped) % 60
let millis = Int(((clamped - Double(Int(clamped))) * 1000).rounded())
return String(format: "%02d:%02d:%02d%@%03d", hours, minutes, wholeSeconds, String(separator), millis)
}
private static func seconds(from text: String, separator: Character?) -> Double? {
let cleaned = text.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: ",", with: ".")
let components = cleaned.components(separatedBy: ":")
var value: Double = 0
for (index, component) in components.enumerated() {
guard let number = Double(component) else { return nil }
switch components.count - index {
case 1: value += number
case 2: value += number * 60
case 3: value += number * 3600
default: return nil
}
}
_ = separator
return value
}
}
@@ -0,0 +1,217 @@
import AppKit
import CoreText
import Foundation
/// Text extraction and rendering shared by the document, ebook, PDF and
/// spreadsheet paths. `textutil` does the heavy lifting for Office formats;
/// CoreText lays text out when we have to make a PDF ourselves.
enum TextDocument {
static func plainText(from url: URL) -> String? {
switch FileKind.canonicalExtension(url.pathExtension) {
case "txt", "md", "csv", "tsv", "srt", "vtt":
return (try? String(contentsOf: url, encoding: .utf8))
?? (try? String(contentsOf: url, encoding: .isoLatin1))
case "html":
if let html = try? String(contentsOf: url, encoding: .utf8) {
return textFromHTML(html)
}
return nil
case "epub":
return try? EbookConverter.plainText(url)
case "pdf":
return PDFConverter.extractText(url)
default:
return textutilText(from: url)
}
}
static func textFromHTML(_ html: String) -> String {
guard let data = html.data(using: .utf8),
let attributed = try? NSAttributedString(
data: data,
options: [.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue],
documentAttributes: nil
) else {
return Markdown.decodeEntities(Markdown.stripTags(html))
}
return attributed.string
}
static func textutilText(from url: URL) -> String? {
guard let result = try? Shell.run(Toolchain.textutil, ["-convert", "txt", "-stdout", url.path]) else {
return nil
}
return result.stdout
}
static func html(from url: URL) throws -> String {
switch FileKind.canonicalExtension(url.pathExtension) {
case "md":
let source = try String(contentsOf: url, encoding: .utf8)
return Markdown.html(from: source, title: url.deletingPathExtension().lastPathComponent)
case "html":
return try String(contentsOf: url, encoding: .utf8)
case "txt":
let source = try String(contentsOf: url, encoding: .utf8)
let escaped = Markdown.escape(source)
return "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body><pre>\(escaped)</pre></body></html>"
case "pdf":
let text = PDFConverter.extractText(url)
return htmlDocument(fromPlainText: text, title: url.deletingPathExtension().lastPathComponent)
case "epub":
return try EbookConverter.html(url)
default:
let result = try Shell.run(Toolchain.textutil, ["-convert", "html", "-stdout", url.path])
return result.stdout
}
}
static func attributedString(from url: URL) -> NSAttributedString? {
if let data = try? Data(contentsOf: url),
let attributed = try? NSAttributedString(
data: data,
options: [.documentType: documentType(for: url), .characterEncoding: String.Encoding.utf8.rawValue],
documentAttributes: nil
) {
return attributed
}
// Fall back to HTML produced by textutil, which every Office format can
// be converted to.
if let html = try? html(from: url), let data = html.data(using: .utf8) {
return try? NSAttributedString(
data: data,
options: [.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue],
documentAttributes: nil
)
}
return nil
}
private static func documentType(for url: URL) -> NSAttributedString.DocumentType {
switch FileKind.canonicalExtension(url.pathExtension) {
case "rtf": return .rtf
case "rtfd": return .rtfd
case "html": return .html
case "doc": return .docFormat
case "docx": return .officeOpenXML
case "odt": return .openDocument
default: return .plain
}
}
static func htmlDocument(fromPlainText text: String, title: String) -> String {
let paragraphs = text
.components(separatedBy: "\n\n")
.map { $0.trimmed }
.filter { !$0.isEmpty }
.map { "<p>" + Markdown.escape($0).replacingOccurrences(of: "\n", with: "<br/>") + "</p>" }
.joined(separator: "\n")
return """
<!DOCTYPE html><html><head><meta charset="utf-8"><title>\(Markdown.escape(title))</title>
<style>body{font:16px/1.6 -apple-system,sans-serif;max-width:720px;margin:48px auto;padding:0 24px}</style>
</head><body>\(paragraphs)</body></html>
"""
}
static func write(_ text: String, to url: URL) throws {
try text.write(to: url, atomically: true, encoding: .utf8)
}
/// Converts text/HTML to an Office format with `textutil`.
static func textutilConvert(_ source: URL, to format: Format, at destination: URL, cancellation: Cancellation? = nil) throws {
guard let identifier = textutilIdentifier(for: format) else {
throw ConversionError.unsupported("textutil cannot write \(format.displayName).")
}
try Shell.run(
Toolchain.textutil,
["-convert", identifier, "-output", destination.path, source.path],
cancellation: cancellation
)
}
static func textutilConvert(sourceHTML: String, to format: Format, at destination: URL, cancellation: Cancellation? = nil) throws {
let temporary = FileManager.default.temporaryDirectory
.appendingPathComponent("konverter-\(UUID().uuidString).html")
defer { try? FileManager.default.removeItem(at: temporary) }
try sourceHTML.write(to: temporary, atomically: true, encoding: .utf8)
try textutilConvert(temporary, to: format, at: destination, cancellation: cancellation)
}
private static func textutilIdentifier(for format: Format) -> String? {
switch format {
case .txt: return "txt"
case .html: return "html"
case .rtf: return "rtf"
case .docx: return "docx"
case .doc: return "doc"
case .odt: return "odt"
default: return nil
}
}
// MARK: - PDF layout
/// Paginates an attributed string into a PDF at A4 with 56 pt margins.
static func writePDF(_ attributed: NSAttributedString, to url: URL, pageSize: CGSize = CGSize(width: 595, height: 842)) throws {
let margin: CGFloat = 56
var mediaBox = CGRect(origin: .zero, size: pageSize)
guard let context = CGContext(url as CFURL, mediaBox: &mediaBox, nil) else {
throw ConversionError.failed("Could not create \(url.lastPathComponent).")
}
let text = NSMutableAttributedString(attributedString: attributed)
if text.length == 0 {
text.append(NSAttributedString(string: " "))
}
// A readable default for documents that carry no fonts.
text.enumerateAttribute(.font, in: NSRange(location: 0, length: text.length)) { value, range, _ in
if value == nil {
text.addAttribute(.font, value: NSFont.systemFont(ofSize: 12), range: range)
}
}
let framesetter = CTFramesetterCreateWithAttributedString(text as CFAttributedString)
let textRect = CGRect(
x: margin,
y: margin,
width: pageSize.width - margin * 2,
height: pageSize.height - margin * 2
)
let path = CGPath(rect: textRect, transform: nil)
var cursor = 0
let total = text.length
repeat {
context.beginPDFPage(nil)
let frame = CTFramesetterCreateFrame(
framesetter,
CFRange(location: cursor, length: 0),
path,
nil
)
CTFrameDraw(frame, context)
context.endPDFPage()
let visible = CTFrameGetVisibleStringRange(frame)
if visible.length <= 0 { break }
cursor = visible.location + visible.length
} while cursor < total
context.closePDF()
}
static func writePDF(fromText text: String, to url: URL, pageSize: CGSize = CGSize(width: 595, height: 842)) throws {
let style = NSMutableParagraphStyle()
style.lineSpacing = 3
let attributed = NSAttributedString(
string: text,
attributes: [
.font: NSFont.systemFont(ofSize: 12),
.paragraphStyle: style
]
)
try writePDF(attributed, to: url, pageSize: pageSize)
}
}
+446
View File
@@ -0,0 +1,446 @@
import AppKit
import Foundation
import PDFKit
/// What a tool produced. `message` is for the read-only tools (Info, Read QR,
/// List Contents, Statistics) which show a result instead of writing a file.
struct ToolOutcome {
var outputs: [URL] = []
var message: String?
var messageTitle: String?
var revealInFinder: Bool = true
}
/// Runs a `ToolID` over one or more files. Every tool is written so that a
/// failure on one file does not silently swallow the rest: the first error is
/// reported after the successful outputs are collected.
enum ToolRunner {
static func run(
tool: ToolID,
inputs: [URL],
params: ToolParams,
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws -> ToolOutcome {
guard inputs.count >= tool.minimumFileCount else {
throw ConversionError.failed("\(tool.title) needs at least \(tool.minimumFileCount) files.")
}
try cancellation?.check()
switch tool {
case .compressImage:
let quality = Double(params.int("quality", default: 70)) / 100
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let format = FileKind.format(of: source) ?? .jpg
let destination = OutputNaming.sibling(for: source, suffix: "-compressed")
try ImageTools.compress(source, at: destination, format: format, quality: quality)
return destination
}
case .resizeImage:
let width = params.int("width", default: 1920)
let height = params.int("height", default: 1920)
let percent = params.int("percent", default: 100)
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "-resized")
try ImageTools.resize(source, at: destination, maxWidth: width, maxHeight: height, percent: percent)
return destination
}
case .cropImage:
let inset = params.int("inset", default: 0)
let aspect = params.string("aspect", default: "Keep")
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "-cropped")
try ImageTools.crop(source, at: destination, inset: inset, aspect: aspect)
return destination
}
case .rotateImage:
let degrees = params.string("angle", default: "90° right").hasPrefix("90° r") ? 90 : (params.string("angle", default: "").hasPrefix("180") ? 180 : -90)
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "-rotated")
try ImageTools.rotate(source, at: destination, degrees: degrees)
return destination
}
case .flipImage:
let horizontal = params.string("axis", default: "Horizontal") == "Horizontal"
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "-flipped")
try ImageTools.flip(source, at: destination, horizontal: horizontal)
return destination
}
case .grayscaleImage:
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "-gray", extension: "png")
try ImageTools.grayscale(source, at: destination)
return destination
}
case .stripMetadata:
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let format = FileKind.format(of: source) ?? .jpg
let destination = OutputNaming.sibling(for: source, suffix: "-clean")
try ImageTools.stripMetadata(source, at: destination, format: format)
return destination
}
case .watermarkImage:
let text = params.string("text", default: "©")
let position = params.string("position", default: "Bottom Right")
let opacity = Double(params.int("opacity", default: 45))
let size = Double(params.int("size", default: 16))
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "-watermark")
try ImageTools.watermark(source, at: destination, text: text, position: position, opacity: opacity, sizePercent: size)
return destination
}
case .readQR:
var results: [String] = []
for source in inputs {
try cancellation?.check()
let payload = ImageTools.readQR(source)
results.append("\(source.lastPathComponent):\n\(payload ?? "No barcode found")")
}
return ToolOutcome(message: results.joined(separator: "\n\n"), messageTitle: "Barcode")
case .compressVideo:
let quality = params.string("preset", default: "Medium")
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let ext = container(for: source, fallback: "mp4")
let destination = OutputNaming.sibling(for: source, suffix: "-compressed", extension: ext)
try MediaTools.compressVideo(source, at: destination, quality: quality)
return destination
}
case .trimVideo, .trimAudio:
let start = Timecode.seconds(from: params.string("start", default: "0")) ?? 0
let end = Timecode.seconds(from: params.string("end", default: ""))
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let ext = container(for: source, fallback: tool == .trimAudio ? "m4a" : "mp4")
let destination = OutputNaming.sibling(for: source, suffix: "-trimmed", extension: ext)
try trim(source, at: destination, start: start, end: end, cancellation: cancellation)
return destination
}
case .muteVideo:
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let ext = container(for: source, fallback: "mp4")
let destination = OutputNaming.sibling(for: source, suffix: "-muted", extension: ext)
try AVConverter.mute(source: source, at: destination, cancellation: cancellation)
return destination
}
case .speedVideo, .speedAudio:
let rate = params.double("rate", default: 2)
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let ext = container(for: source, fallback: tool == .speedAudio ? "m4a" : "mp4")
let destination = OutputNaming.sibling(for: source, suffix: "-\(rateString(rate))x", extension: ext)
try AVConverter.changeSpeed(source: source, at: destination, rate: rate, cancellation: cancellation)
return destination
}
case .snapshotVideo:
let at = Timecode.seconds(from: params.string("at", default: "1")) ?? 1
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "-frame", extension: "png")
try AVConverter.snapshot(source: source, at: destination, time: at, cancellation: cancellation)
return destination
}
case .videoToGIF:
let fps = params.int("fps", default: 12)
let width = params.int("width", default: 480)
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "", extension: "gif")
try AVConverter.makeGIF(source: source, at: destination, fps: fps, maxWidth: width, cancellation: cancellation)
return destination
}
case .resizeVideo:
let width = params.int("width", default: 1280)
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let ext = container(for: source, fallback: "mp4")
let destination = OutputNaming.sibling(for: source, suffix: "-resized", extension: ext)
try AVConverter.resize(source: source, at: destination, width: width, cancellation: cancellation)
return destination
}
case .normalizeAudio:
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = MediaTools.audioSibling(for: source, suffix: "-normalized")
try MediaTools.normalizeAudio(source, at: destination)
return destination
}
case .channelsAudio:
let mono = params.string("mode", default: "Mono") == "Mono"
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = MediaTools.audioSibling(for: source, suffix: mono ? "-mono" : "-stereo")
try MediaTools.channels(source, at: destination, mono: mono)
return destination
}
case .bleepAudio:
let start = Timecode.seconds(from: params.string("start", default: "0")) ?? 0
let end = Timecode.seconds(from: params.string("end", default: "1")) ?? 1
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = MediaTools.audioSibling(for: source, suffix: "-bleeped")
try MediaTools.bleep(source, at: destination, start: start, end: max(end, start + 0.05))
return destination
}
case .compressAudio:
let bitrate = params.string("bitrate", default: "128")
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = MediaTools.audioSibling(for: source, suffix: "-compressed")
try MediaTools.compressAudio(source, at: destination, bitrate: bitrate)
return destination
}
case .mergePDF:
let first = inputs[0]
let destination = OutputNaming.availableURL(
for: first.deletingLastPathComponent()
.appendingPathComponent(first.deletingPathExtension().lastPathComponent + " merged.pdf")
)
try PDFConverter.merge(inputs, at: destination, cancellation: cancellation)
progress?(1)
return ToolOutcome(outputs: [destination])
case .splitPDF:
let perFile = max(1, params.int("pages", default: 1))
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let directory = OutputNaming.artifactDirectory(for: source, suffix: " split")
let written = try PDFConverter.split(source, pagesPerFile: perFile, into: directory, cancellation: cancellation)
guard let first = written.first else { throw ConversionError.failed("Nothing to split.") }
return first
}
case .rotatePDF:
let angle = params.string("angle", default: "90° right")
let degrees = angle.hasPrefix("180") ? 180 : (angle.hasPrefix("90° l") ? -90 : 90)
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "-rotated", extension: "pdf")
try PDFConverter.rotate(source, degrees: degrees, at: destination)
return destination
}
case .compressPDF:
let quality = Double(params.int("quality", default: 55)) / 100
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "-compressed", extension: "pdf")
try PDFConverter.compress(source, at: destination, quality: quality, cancellation: cancellation)
return destination
}
case .encryptPDF:
let password = params.string("password", default: "")
guard !password.isEmpty else { throw ConversionError.failed("Enter a password first.") }
let owner = params.string("owner", default: "")
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "-encrypted", extension: "pdf")
try PDFConverter.encrypt(source, at: destination, password: password, ownerPassword: owner)
return destination
}
case .decryptPDF:
let password = params.string("password", default: "")
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "-decrypted", extension: "pdf")
guard let document = PDFDocument(url: source),
document.unlock(withPassword: password) || !document.isLocked else {
throw ConversionError.failed("Wrong password or the PDF is not encrypted.")
}
guard document.write(to: destination) else {
throw ConversionError.failed("Could not write the decrypted PDF.")
}
return destination
}
case .imagesToPDF:
let images: [(CGImage, String?)] = try inputs.map { source in
guard let image = Raster.firstImage(at: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
return (image, source.lastPathComponent)
}
let first = inputs[0]
let destination = OutputNaming.availableURL(
for: first.deletingLastPathComponent()
.appendingPathComponent(first.deletingPathExtension().lastPathComponent + " images.pdf")
)
try PDFConverter.imagesToPDF(images, at: destination, cancellation: cancellation)
progress?(1)
return ToolOutcome(outputs: [destination])
case .collageImages:
let columns = max(1, params.int("columns", default: 3))
let page = params.string("page", default: "A4")
let margin = Double(params.int("margin", default: 24))
let captions = params.bool("captions", default: false)
let entries: [(CGImage, String)] = try inputs.map { source in
guard let image = Raster.firstImage(at: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
return (image, source.lastPathComponent)
}
let first = inputs[0]
let destination = OutputNaming.availableURL(
for: first.deletingLastPathComponent()
.appendingPathComponent(first.deletingPathExtension().lastPathComponent + " collage.pdf")
)
try PDFConverter.collage(
entries,
at: destination,
columns: columns,
page: page,
margin: margin,
captions: captions,
cancellation: cancellation
)
progress?(1)
return ToolOutcome(outputs: [destination])
case .extractPDFText:
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let text = PDFConverter.extractText(source)
guard !text.trimmed.isEmpty else {
throw ConversionError.failed("No text layer in \(source.lastPathComponent).")
}
let destination = OutputNaming.sibling(for: source, suffix: "", extension: "txt")
try TextDocument.write(text, to: destination)
return destination
}
case .documentToPDF:
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "", extension: "pdf")
try DocumentConverter.convert(source: source, to: .pdf, at: destination, cancellation: cancellation)
return destination
}
case .wordCount:
var results: [String] = []
for source in inputs {
try cancellation?.check()
let text = TextDocument.plainText(from: source) ?? ""
let words = text.split { $0 == " " || $0.isNewline }.count
let lines = text.components(separatedBy: "\n").count
results.append("""
\(source.lastPathComponent)
\(words) words · \(text.count) characters · \(lines) lines
""")
}
return ToolOutcome(message: results.joined(separator: "\n\n"), messageTitle: "Statistics")
case .sheetsToCSV:
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let rows = try SpreadsheetConverter.read(source, cancellation: cancellation)
let destination = OutputNaming.sibling(for: source, suffix: "", extension: "csv")
try SpreadsheetConverter.write(rows, to: destination, delimiter: ",")
return destination
}
case .shiftSubtitle:
let offset = params.double("offset", default: 0)
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let format = FileKind.format(of: source) ?? .srt
let cues = SubtitleConverter.shift(try SubtitleConverter.parse(source), by: offset)
let destination = OutputNaming.sibling(for: source, suffix: "-shifted")
try SubtitleConverter.write(cues, to: destination, format: format == .vtt ? .vtt : .srt)
return destination
}
case .extractArchive:
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let directory = OutputNaming.artifactDirectory(for: source, suffix: " extracted")
let result = try ArchiveConverter.extract(source: source, into: directory, cancellation: cancellation, progress: nil)
return result
}
case .listArchive:
var results: [String] = []
for source in inputs {
try cancellation?.check()
results.append("\(source.lastPathComponent)\n\((try? ArchiveConverter.list(source)) ?? "Unreadable")")
}
return ToolOutcome(message: results.joined(separator: "\n\n"), messageTitle: "Archive")
case .mediaInfo:
var results: [String] = []
for source in inputs {
try cancellation?.check()
results.append(MediaInfo.describe(source))
}
return ToolOutcome(message: results.joined(separator: "\n\n"), messageTitle: "Info")
}
}
// MARK: - Helpers
private static func mapFiles(
_ inputs: [URL],
cancellation: Cancellation?,
progress: ((Double) -> Void)?,
_ transform: (URL) throws -> URL
) throws -> ToolOutcome {
var outputs: [URL] = []
var firstError: Error?
for (index, source) in inputs.enumerated() {
try cancellation?.check()
do {
let output = try transform(source)
outputs.append(output)
} catch is CancellationError {
throw CancellationError()
} catch {
if firstError == nil { firstError = error }
}
progress?(Double(index + 1) / Double(inputs.count))
}
if outputs.isEmpty, let firstError { throw firstError }
return ToolOutcome(outputs: outputs)
}
/// Keeps the source container when it is one we can write, otherwise uses
/// the fallback (`mp4` for video, `m4a` for audio).
private static func container(for source: URL, fallback: String) -> String {
let ext = FileKind.canonicalExtension(source.pathExtension)
let videoContainers = ["mp4", "mov", "m4v"]
let audioContainers = ["m4a", "mp3", "wav", "aiff", "flac", "ogg", "opus", "wma"]
if videoContainers.contains(ext) || audioContainers.contains(ext) { return ext }
return fallback
}
private static func rateString(_ rate: Double) -> String {
rate == rate.rounded() ? String(Int(rate)) : String(format: "%.2f", rate)
}
private static func trim(_ source: URL, at destination: URL, start: Double, end: Double?, cancellation: Cancellation?) throws {
let ext = FileKind.canonicalExtension(source.pathExtension)
if ["mp4", "mov", "m4v", "m4a"].contains(ext) {
try AVConverter.trim(source: source, at: destination, start: start, end: end, cancellation: cancellation)
return
}
if Toolchain.hasFFmpeg, Settings.shared.useExternalTools {
var arguments = ["-ss", String(format: "%.3f", start)]
if let end, end > start { arguments += ["-t", String(format: "%.3f", end - start)] }
arguments += ["-c:a", "copy"]
try FFmpeg.filter(source: source, at: destination, arguments: arguments)
return
}
if ["wav", "aiff"].contains(ext) {
let staged = FileManager.default.temporaryDirectory
.appendingPathComponent("konverter-trim-\(UUID().uuidString).m4a")
defer { try? FileManager.default.removeItem(at: staged) }
try AVConverter.trim(source: source, at: staged, start: start, end: end, cancellation: cancellation)
try CoreAudio.convert(source: staged, to: FileKind.format(of: destination) ?? .wav, at: destination, cancellation: cancellation)
return
}
throw ConversionError.missingTool("Trimming .\(ext) needs ffmpeg — brew install ffmpeg")
}
}