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,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.")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user