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