Files
konverter/Sources/Konverter/Convert/EbookConverter.swift
T
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

231 lines
11 KiB
Swift

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