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.
218 lines
8.6 KiB
Swift
218 lines
8.6 KiB
Swift
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)
|
|
}
|
|
}
|