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