- 给全部 78 个 Swift 源文件添加详细的中文注释(文件级、类级、方法级) - 删除 LegacyRDReaderController/ 死代码目录(16 文件 4592 行) - 根目录翻页容器文件移入 ReaderView/ 目录 - Resources/ 移入 EPUBCore/Resources/(与使用者归属一致) - RDEPUBTextIndexTable.swift 移入 EPUBTextRendering/(消除反向依赖) - RDURLReaderController.swift 移入 EPUBUI/(入口控制器归入 UI 层) - 更新 podspec 资源路径
396 lines
15 KiB
Swift
396 lines
15 KiB
Swift
// RDEPUBParser+Package.swift
|
||
// OPF(Package Document)解析与 Spine 构建
|
||
// 包含 OPFPackageParserDelegate(SAX 解析 metadata/manifest/spine),
|
||
// 从解析结果构建 RDEPUBSpineItem 数组,处理 rendition:layout、page-spread 等属性。
|
||
// 同时定义 XMLName、XMLSection、XMLParserContext 等 XML 解析辅助工具。
|
||
|
||
import Foundation
|
||
|
||
extension RDEPUBParser {
|
||
/// 从 OPF 解析结果构建 spine 阅读顺序数组
|
||
/// 逐项匹配 manifest,解析 href、布局模式(fixed/reflowable)、page-spread 等属性
|
||
/// - Parameters:
|
||
/// - packageDocument: OPF 解析结果(含 metadata、manifest、spineReferences)
|
||
/// - opfURL: OPF 文件路径
|
||
/// - Returns: spine 项数组
|
||
func buildSpine(from packageDocument: OPFPackageDocument, opfURL: URL) throws -> [RDEPUBSpineItem] {
|
||
let opfDirectoryURL = opfURL.deletingLastPathComponent()
|
||
let publicationLayout = packageDocument.metadata.layout
|
||
|
||
let spineItems = try packageDocument.spineReferences.map { reference in
|
||
guard let manifestItem = packageDocument.manifestByID[reference.idref] else {
|
||
throw RDEPUBParserError.missingManifestItem(idref: reference.idref)
|
||
}
|
||
|
||
let normalizedHref = normalize(href: manifestItem.href, relativeTo: opfDirectoryURL)
|
||
let title = manifestItem.title?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let fallbackTitle = normalizedHref
|
||
.split(separator: "/")
|
||
.last
|
||
.map(String.init)
|
||
.map { $0.replacingOccurrences(of: ".xhtml", with: "") }
|
||
.map { $0.replacingOccurrences(of: ".html", with: "") }
|
||
.flatMap { $0.isEmpty ? nil : $0 }
|
||
?? reference.idref
|
||
|
||
let effectiveLayout: RDEPUBLayout?
|
||
if reference.properties.contains("rendition:layout-pre-paginated") || manifestItem.properties.contains("rendition:layout-pre-paginated") {
|
||
effectiveLayout = .fixed
|
||
} else if reference.properties.contains("rendition:layout-reflowable") || manifestItem.properties.contains("rendition:layout-reflowable") {
|
||
effectiveLayout = .reflowable
|
||
} else {
|
||
effectiveLayout = publicationLayout == .fixed ? .fixed : nil
|
||
}
|
||
|
||
return RDEPUBSpineItem(
|
||
idref: reference.idref,
|
||
href: normalizedHref,
|
||
mediaType: manifestItem.mediaType,
|
||
title: title?.isEmpty == false ? title! : fallbackTitle,
|
||
linear: reference.linear,
|
||
properties: reference.properties,
|
||
pageSpread: reference.pageSpread,
|
||
layout: effectiveLayout
|
||
)
|
||
}
|
||
|
||
guard !spineItems.isEmpty else {
|
||
throw RDEPUBParserError.emptySpine
|
||
}
|
||
|
||
return spineItems
|
||
}
|
||
}
|
||
|
||
/// OPF 包文档的解析结果,包含 metadata、manifest、spine 引用及 NCX/nav 项
|
||
struct OPFPackageDocument {
|
||
var metadata: RDEPUBMetadata
|
||
var manifest: [RDEPUBManifestItem]
|
||
var manifestByID: [String: RDEPUBManifestItem]
|
||
var spineReferences: [OPFSpineReference]
|
||
/// NCX 目录文件的 manifest 项(EPUB 2)
|
||
var ncxItem: RDEPUBManifestItem?
|
||
/// Navigation Document 的 manifest 项(EPUB 3)
|
||
var navigationItem: RDEPUBManifestItem?
|
||
}
|
||
|
||
/// OPF 中 spine/itemref 元素的解析结果
|
||
struct OPFSpineReference {
|
||
/// 对应 manifest 中的 item id
|
||
var idref: String
|
||
/// 是否为线性阅读项("no" 表示辅助内容)
|
||
var linear: Bool
|
||
/// 属性列表(如 rendition:layout-pre-paginated、page-spread-left 等)
|
||
var properties: [String]
|
||
/// 页面展开方向(左/右/居中)
|
||
var pageSpread: RDEPUBPageSpread?
|
||
}
|
||
|
||
/// OPF 文档的 SAX 解析代理
|
||
/// 解析 metadata(identifier/title/creator/language/rendition:layout 等)、
|
||
/// manifest(item 元素)、spine(itemref 元素)三个主要区域
|
||
final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
||
private var metadata = RDEPUBMetadata()
|
||
private var manifestItems: [RDEPUBManifestItem] = []
|
||
private var spineReferences: [OPFSpineReference] = []
|
||
private let xmlContext = XMLParserContext()
|
||
private var uniqueIdentifierID: String?
|
||
private var currentIdentifierElementID: String?
|
||
private var currentMetaProperty: String?
|
||
private var identifierByID: [String: String] = [:]
|
||
private var manifestTitleByID: [String: String] = [:]
|
||
private var currentMetaRefinesID: String?
|
||
private var ncxID: String?
|
||
|
||
/// 将解析中间结果组装为 OPFPackageDocument 结构体
|
||
func packageDocument() -> OPFPackageDocument {
|
||
if metadata.identifier == nil, let fallbackIdentifier = identifierByID.values.first {
|
||
metadata.identifier = fallbackIdentifier
|
||
}
|
||
metadata.title = metadata.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
metadata.author = metadata.author?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
metadata.language = metadata.language?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
|
||
let manifest = manifestItems.map { item in
|
||
var updatedItem = item
|
||
updatedItem.title = manifestTitleByID[item.id]
|
||
return updatedItem
|
||
}
|
||
|
||
return OPFPackageDocument(
|
||
metadata: metadata,
|
||
manifest: manifest,
|
||
manifestByID: Dictionary(uniqueKeysWithValues: manifest.map { ($0.id, $0) }),
|
||
spineReferences: spineReferences,
|
||
ncxItem: manifest.first(where: { $0.id == ncxID || $0.isNCX }),
|
||
navigationItem: manifest.first(where: { $0.isNavigationDocument })
|
||
)
|
||
}
|
||
|
||
func parser(
|
||
_ parser: XMLParser,
|
||
didStartElement elementName: String,
|
||
namespaceURI: String?,
|
||
qualifiedName qName: String?,
|
||
attributes attributeDict: [String: String] = [:]
|
||
) {
|
||
let name = xmlContext.startElement(named: qName ?? elementName)
|
||
|
||
switch name {
|
||
case "package":
|
||
metadata.version = attributeDict["version"]
|
||
uniqueIdentifierID = attributeDict["unique-identifier"]
|
||
case "identifier":
|
||
if currentSection == .metadata {
|
||
currentIdentifierElementID = attributeDict["id"]
|
||
}
|
||
case "item":
|
||
guard currentSection == .manifest,
|
||
let id = attributeDict["id"],
|
||
let href = attributeDict["href"],
|
||
let mediaType = attributeDict["media-type"] else {
|
||
return
|
||
}
|
||
manifestItems.append(
|
||
RDEPUBManifestItem(
|
||
id: id,
|
||
href: href,
|
||
mediaType: mediaType,
|
||
properties: XMLName.tokenize(attributeDict["properties"]),
|
||
fallback: attributeDict["fallback"],
|
||
mediaOverlay: attributeDict["media-overlay"]
|
||
)
|
||
)
|
||
case "itemref":
|
||
guard currentSection == .spine, let idref = attributeDict["idref"] else {
|
||
return
|
||
}
|
||
spineReferences.append(
|
||
OPFSpineReference(
|
||
idref: idref,
|
||
linear: (attributeDict["linear"]?.lowercased() ?? "yes") != "no",
|
||
properties: XMLName.tokenize(attributeDict["properties"]),
|
||
pageSpread: pageSpread(from: attributeDict)
|
||
)
|
||
)
|
||
case "meta":
|
||
currentMetaProperty = attributeDict["property"] ?? attributeDict["name"]
|
||
currentMetaRefinesID = attributeDict["refines"].flatMap(XMLName.refinedID(from:))
|
||
if let property = currentMetaProperty?.lowercased(),
|
||
property == "rendition:layout",
|
||
let value = attributeDict["content"] {
|
||
metadata.layout = layout(from: value)
|
||
}
|
||
if let property = currentMetaProperty?.lowercased(),
|
||
property == "rendition:spread" {
|
||
metadata.spread = attributeDict["content"]
|
||
}
|
||
if let property = currentMetaProperty?.lowercased(),
|
||
property == "title",
|
||
let refinesID = currentMetaRefinesID,
|
||
let value = attributeDict["content"],
|
||
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
manifestTitleByID[refinesID] = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
}
|
||
default:
|
||
break
|
||
}
|
||
|
||
if name == "spine" {
|
||
if let direction = attributeDict["page-progression-direction"]?.lowercased() {
|
||
metadata.readingProgression = direction == "ltr" ? .ltr : (direction == "rtl" ? .rtl : .auto)
|
||
}
|
||
ncxID = attributeDict["toc"]
|
||
}
|
||
}
|
||
|
||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||
xmlContext.appendCharacters(string)
|
||
}
|
||
|
||
func parser(
|
||
_ parser: XMLParser,
|
||
didEndElement elementName: String,
|
||
namespaceURI: String?,
|
||
qualifiedName qName: String?
|
||
) {
|
||
let name = XMLName.localName(from: qName ?? elementName)
|
||
let text = xmlContext.trimmedCharacters
|
||
|
||
switch (currentSection, name) {
|
||
case (.metadata, "identifier"):
|
||
if let identifierID = currentIdentifierElementID, !text.isEmpty {
|
||
identifierByID[identifierID] = text
|
||
if identifierID == uniqueIdentifierID {
|
||
metadata.identifier = text
|
||
}
|
||
} else if metadata.identifier == nil, !text.isEmpty {
|
||
metadata.identifier = text
|
||
}
|
||
currentIdentifierElementID = nil
|
||
case (.metadata, "title"):
|
||
if metadata.title.isEmpty, !text.isEmpty {
|
||
metadata.title = text
|
||
}
|
||
case (.metadata, "creator"):
|
||
if metadata.author == nil, !text.isEmpty {
|
||
metadata.author = text
|
||
}
|
||
case (.metadata, "language"):
|
||
if metadata.language == nil, !text.isEmpty {
|
||
metadata.language = text
|
||
}
|
||
case (.metadata, "meta"):
|
||
applyMetaValue(text)
|
||
currentMetaProperty = nil
|
||
currentMetaRefinesID = nil
|
||
default:
|
||
break
|
||
}
|
||
|
||
xmlContext.endElement()
|
||
}
|
||
|
||
/// 当前解析所在的 OPF 区域(metadata/manifest/spine/other)
|
||
private var currentSection: XMLSection {
|
||
if xmlContext.containsElement(named: "manifest") {
|
||
return .manifest
|
||
}
|
||
if xmlContext.containsElement(named: "spine") {
|
||
return .spine
|
||
}
|
||
if xmlContext.containsElement(named: "metadata") {
|
||
return .metadata
|
||
}
|
||
return .other
|
||
}
|
||
|
||
/// 处理 <meta> 元素的文本内容,提取 rendition:layout、rendition:spread、identifier 等
|
||
private func applyMetaValue(_ value: String) {
|
||
let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !normalizedValue.isEmpty, let property = currentMetaProperty?.lowercased() else {
|
||
return
|
||
}
|
||
|
||
switch property {
|
||
case "rendition:layout":
|
||
metadata.layout = layout(from: normalizedValue)
|
||
case "rendition:spread":
|
||
metadata.spread = normalizedValue
|
||
case "dcterms:identifier", "identifier":
|
||
if metadata.identifier == nil {
|
||
metadata.identifier = normalizedValue
|
||
}
|
||
case "title":
|
||
if let refinesID = currentMetaRefinesID {
|
||
manifestTitleByID[refinesID] = normalizedValue
|
||
}
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
|
||
/// 从属性字典中提取 page-spread 信息(支持 properties 和 page-spread 两种写法)
|
||
private func pageSpread(from attributes: [String: String]) -> RDEPUBPageSpread? {
|
||
if let properties = attributes["properties"]?.lowercased() {
|
||
if properties.contains("page-spread-left") {
|
||
return .left
|
||
}
|
||
if properties.contains("page-spread-right") {
|
||
return .right
|
||
}
|
||
if properties.contains("page-spread-center") {
|
||
return .center
|
||
}
|
||
}
|
||
|
||
if let legacySpread = attributes["page-spread"]?.lowercased() {
|
||
switch legacySpread {
|
||
case "left":
|
||
return .left
|
||
case "right":
|
||
return .right
|
||
case "center":
|
||
return .center
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
/// 将 rendition:layout 原始值转换为布局枚举
|
||
private func layout(from rawValue: String) -> RDEPUBLayout {
|
||
rawValue.lowercased().contains("pre-paginated") ? .fixed : .reflowable
|
||
}
|
||
}
|
||
|
||
/// OPF 文档的区域枚举,用于区分当前解析位置
|
||
enum XMLSection {
|
||
case metadata
|
||
case manifest
|
||
case spine
|
||
case other
|
||
}
|
||
|
||
/// XML 名称解析工具:提取本地名、分词、提取 refines ID
|
||
enum XMLName {
|
||
/// 从带命名空间前缀的名称中提取本地名(如 "dc:title" -> "title")
|
||
static func localName(from rawName: String) -> String {
|
||
rawName.split(separator: ":").last.map(String.init) ?? rawName
|
||
}
|
||
|
||
/// 将空格分隔的属性值拆分为 token 数组
|
||
static func tokenize(_ rawValue: String?) -> [String] {
|
||
guard let rawValue else { return [] }
|
||
return rawValue
|
||
.split(whereSeparator: { $0 == " " || $0 == "\n" || $0 == "\t" || $0 == "\r" })
|
||
.map(String.init)
|
||
}
|
||
|
||
/// 从 refines 属性值中提取 ID(去掉 "#" 前缀)
|
||
static func refinedID(from rawValue: String) -> String? {
|
||
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard trimmed.hasPrefix("#") else {
|
||
return nil
|
||
}
|
||
return String(trimmed.dropFirst())
|
||
}
|
||
}
|
||
|
||
/// XML 解析上下文,维护元素栈和当前文本内容
|
||
final class XMLParserContext {
|
||
/// 元素名称栈,用于追踪嵌套层级
|
||
private var elementStack: [String] = []
|
||
/// 当前元素累积的文本内容
|
||
private var currentCharacters = ""
|
||
|
||
/// 去除空白后的当前文本
|
||
var trimmedCharacters: String {
|
||
currentCharacters.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
}
|
||
|
||
func startElement(named rawName: String) -> String {
|
||
let name = XMLName.localName(from: rawName)
|
||
elementStack.append(name)
|
||
currentCharacters = ""
|
||
return name
|
||
}
|
||
|
||
func appendCharacters(_ string: String) {
|
||
currentCharacters += string
|
||
}
|
||
|
||
func endElement() {
|
||
if !elementStack.isEmpty {
|
||
_ = elementStack.removeLast()
|
||
}
|
||
currentCharacters = ""
|
||
}
|
||
|
||
func containsElement(named name: String) -> Bool {
|
||
elementStack.contains(name)
|
||
}
|
||
}
|