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