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
"" + (chapter.title.isEmpty ? "" : "\(Markdown.escape(chapter.title))
") + chapter.body + ""
}.joined(separator: "\n")
return """
\(Markdown.escape(title))
\(body)
"""
}
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)]*>", options: .regularExpression),
let endRange = inner.range(of: "(?is)", options: .regularExpression) {
inner = String(inner[bodyRange.upperBound..]*>|(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)^.*?]*>(.*?).*$"#, 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 """
""".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("\(Markdown.escape(chapterTitle))\(chapter.1)"
let xhtml = body.contains("xmlns=\"http://www.w3.org/1999/xhtml\"")
? body
: body.replacingOccurrences(of: "\n"
spine += "\n"
navItems += "\(Markdown.escape(chapterTitle))\n"
}
try """
\(identifier)
\(Markdown.escape(title))
\(Markdown.escape(author))
en
\(ISO8601DateFormatter().string(from: Date()))
\(manifest)
\(spine)
""".write(to: staging.appendingPathComponent("OEBPS/content.opf"), atomically: true, encoding: .utf8)
try """
\(Markdown.escape(title))
""".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)
}
}