- 给全部 78 个 Swift 源文件添加详细的中文注释(文件级、类级、方法级) - 删除 LegacyRDReaderController/ 死代码目录(16 文件 4592 行) - 根目录翻页容器文件移入 ReaderView/ 目录 - Resources/ 移入 EPUBCore/Resources/(与使用者归属一致) - RDEPUBTextIndexTable.swift 移入 EPUBTextRendering/(消除反向依赖) - RDURLReaderController.swift 移入 EPUBUI/(入口控制器归入 UI 层) - 更新 podspec 资源路径
319 lines
9.9 KiB
Swift
319 lines
9.9 KiB
Swift
// 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 解析代理,解析 <navPoint>、<content>、<text> 元素
|
||
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" 的 <nav> 元素
|
||
private final class NavDocumentParserDelegate: NSObject, XMLParserDelegate {
|
||
private let baseURL: URL
|
||
private let hrefNormalizer: (String, URL) -> String?
|
||
private var insideTOCNav = false
|
||
private var listDepth = 0
|
||
private let treeBuilder = TOCTreeBuilder()
|
||
private var collectingAnchorText = 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 "nav":
|
||
insideTOCNav = isTOCNav(attributes: attributeDict)
|
||
case "ol":
|
||
if insideTOCNav {
|
||
listDepth += 1
|
||
}
|
||
case "li":
|
||
if insideTOCNav, listDepth > 0 {
|
||
treeBuilder.beginNode()
|
||
}
|
||
case "a":
|
||
guard insideTOCNav else {
|
||
return
|
||
}
|
||
collectingAnchorText = true
|
||
if let href = attributeDict["href"] {
|
||
treeBuilder.updateCurrentHref(hrefNormalizer(href, baseURL))
|
||
}
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
|
||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||
if collectingAnchorText {
|
||
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 "a":
|
||
collectingAnchorText = false
|
||
case "li":
|
||
guard insideTOCNav else {
|
||
break
|
||
}
|
||
treeBuilder.endNode()
|
||
case "ol":
|
||
if insideTOCNav {
|
||
listDepth = max(0, listDepth - 1)
|
||
}
|
||
case "nav":
|
||
insideTOCNav = false
|
||
listDepth = 0
|
||
treeBuilder.reset()
|
||
collectingAnchorText = false
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
|
||
/// 判断 <nav> 元素是否为 TOC 导航(检查 epub:type、type、role 属性)
|
||
private func isTOCNav(attributes: [String: String]) -> Bool {
|
||
if let epubType = attributes["epub:type"]?.lowercased(), epubType.contains("toc") {
|
||
return true
|
||
}
|
||
if let type = attributes["type"]?.lowercased(), type.contains("toc") {
|
||
return true
|
||
}
|
||
if let role = attributes["role"]?.lowercased(), role.contains("doc-toc") {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
} |