- 实现EPUB阅读器搜索功能及选中注释功能 - 优化CFI模块,修复代码审查发现的11个问题 - 实现大书远距目录跳转与后台补全优化方案 - 优化设置面板与章节运行时联动 - 重构及大量改进优化
303 lines
8.7 KiB
Swift
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
|
|
}
|
|
} |