// RDEPUBParser+TOC.swift // EPUB 目录解析 // 支持两种目录格式:NCX(EPUB 2)和 Navigation Document(EPUB 3), // 优先使用 NCX,其次 Nav Document,最终回退到 spine 列表。 // 内部使用 TOCTreeBuilder 构建树状目录结构。 import Foundation extension RDEPUBParser { /// 从 OPF 解析结果中解析目录 /// 优先尝试 NCX,再尝试 Nav Document,两者都失败则回退到 spine 列表 func parseTOC(from packageDocument: OPFPackageDocument, opfURL: URL) -> [EPUBTableOfContentsItem] { let opfDirectoryURL = opfURL.deletingLastPathComponent() if let ncxItem = packageDocument.ncxItem ?? packageDocument.manifest.first(where: { $0.isNCX }) { let ncxURL = opfDirectoryURL.appendingPathComponent(ncxItem.href) let items = parseNCXDocumentItems(at: ncxURL) if !items.isEmpty { return items } } if let navItem = packageDocument.navigationItem { let navURL = opfDirectoryURL.appendingPathComponent(navItem.href) let items = parseNavDocumentItems(at: navURL, baseURL: navURL.deletingLastPathComponent()) if !items.isEmpty { return items } } return spine.map { EPUBTableOfContentsItem(title: $0.title, href: $0.href) } } /// 解析 NCX(EPUB 2)目录文件 func parseNCXDocumentItems(at ncxURL: URL) -> [EPUBTableOfContentsItem] { guard FileManager.default.fileExists(atPath: ncxURL.path), let parser = XMLParser(contentsOf: ncxURL) else { return [] } let delegate = NCXParserDelegate(baseURL: ncxURL.deletingLastPathComponent()) { [weak self] href, baseURL in self?.normalizeTOCHref(href, relativeTo: baseURL) } parser.shouldProcessNamespaces = false parser.delegate = delegate guard parser.parse() else { return [] } return delegate.items } /// 解析 Navigation Document(EPUB 3)目录文件 func parseNavDocumentItems(at navURL: URL, baseURL: URL) -> [EPUBTableOfContentsItem] { guard FileManager.default.fileExists(atPath: navURL.path), let parser = XMLParser(contentsOf: navURL) else { return [] } let delegate = NavDocumentParserDelegate(baseURL: baseURL) { [weak self] href, currentBaseURL in self?.normalizeTOCHref(href, relativeTo: currentBaseURL) } parser.shouldProcessNamespaces = false parser.delegate = delegate guard parser.parse() else { return [] } return delegate.items } /// 标准化目录 href(拆分路径和 fragment,对路径部分做相对路径解析) func normalizeTOCHref(_ href: String, relativeTo baseURL: URL) -> String? { let components = href.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false) let pathPart = components.first.map(String.init) ?? href let fragment = components.count > 1 ? String(components[1]) : nil let normalizedPath: String if pathPart.isEmpty { normalizedPath = "" } else { normalizedPath = normalize(href: pathPart, relativeTo: baseURL) } if let fragment, !fragment.isEmpty { if normalizedPath.isEmpty { return "#\(fragment)" } return "\(normalizedPath)#\(fragment)" } return normalizedPath.isEmpty ? nil : normalizedPath } } /// 目录树节点,存储标题、href 和子节点,最终转换为 EPUBTableOfContentsItem private final class TOCNode { var title = "" var href: String? var children: [TOCNode] = [] /// 将节点及其子节点递归转换为目录项 func asItem() -> EPUBTableOfContentsItem? { let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) guard let href, !href.isEmpty else { return nil } return EPUBTableOfContentsItem( title: trimmedTitle.isEmpty ? href : trimmedTitle, href: href, children: children.compactMap { $0.asItem() } ) } } /// 目录树构建器:通过 beginNode/endNode 操作维护节点栈,构建嵌套目录结构 private final class TOCTreeBuilder { /// 当前嵌套路径上的节点栈 private var nodeStack: [TOCNode] = [] /// 顶层根节点列表 private var rootNodes: [TOCNode] = [] /// 获取构建完成的目录项列表 var items: [EPUBTableOfContentsItem] { rootNodes.compactMap { $0.asItem() } } func beginNode() { nodeStack.append(TOCNode()) } func updateCurrentHref(_ href: String?) { nodeStack.last?.href = href } func appendCurrentTitle(_ text: String) { nodeStack.last?.title += text } func endNode() { guard let node = nodeStack.popLast() else { return } if let parent = nodeStack.last { parent.children.append(node) } else { rootNodes.append(node) } } func reset() { nodeStack.removeAll() } } /// NCX 文档的 SAX 解析代理,解析 元素 private final class NCXParserDelegate: NSObject, XMLParserDelegate { private let baseURL: URL private let hrefNormalizer: (String, URL) -> String? private let treeBuilder = TOCTreeBuilder() private var readingLabelText = false init(baseURL: URL, hrefNormalizer: @escaping (String, URL) -> String?) { self.baseURL = baseURL self.hrefNormalizer = hrefNormalizer } var items: [EPUBTableOfContentsItem] { treeBuilder.items } func parser( _ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:] ) { let name = XMLName.localName(from: qName ?? elementName) switch name { case "navPoint": treeBuilder.beginNode() case "content": guard let src = attributeDict["src"] else { return } treeBuilder.updateCurrentHref(hrefNormalizer(src, baseURL)) case "text": readingLabelText = true default: break } } func parser(_ parser: XMLParser, foundCharacters string: String) { if readingLabelText { treeBuilder.appendCurrentTitle(string) } } func parser( _ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String? ) { let name = XMLName.localName(from: qName ?? elementName) switch name { case "text": readingLabelText = false case "navPoint": treeBuilder.endNode() default: break } } } /// Navigation Document 的 SAX 解析代理,解析含 epub:type="toc" 的