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..]*>|"#, 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) } }