Files
TZGyn 2d031e58a4 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.
2026-10-20 00:36:40 +08:00

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)
}
}