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.
231 lines
11 KiB
Swift
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)
|
|
}
|
|
}
|