ReadViewSDK/Sources/RDReaderView/EPUBCore/RDEPUBParser+TOC.swift
shenlei c65c190b71 feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化
- 实现EPUB阅读器搜索功能及选中注释功能
- 优化CFI模块,修复代码审查发现的11个问题
- 实现大书远距目录跳转与后台补全优化方案
- 优化设置面板与章节运行时联动
- 重构及大量改进优化
2026-06-22 20:26:34 +08:00

303 lines
8.7 KiB
Swift

import Foundation
extension RDEPUBParser {
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) }
}
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
}
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
}
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
}
}
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() }
)
}
}
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()
}
}
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
}
}
}
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
}
}
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
}
}