ReadViewSDK/Sources/RDReaderView/EPUBCore/RDEPUBParser+Package.swift
shenlei d5a7755702 feat: 支持加密 EPUB、试读墙、工具栏定制与朝向锁定
- 新增 RDEPUBResourceDataProvider 解密钩子协议 + 访问登记表,收敛章节 HTML/
  图片/内联 CSS/脚注/封面/图片查看器/正文取图等读取点,scheme handler 对加密书
  禁用流式分支;新增 RDEPUBDecryptingImageAttachment 解密 DTCoreText 图片附件;
  RDEPUBReaderDependencies.live(resourceDataProvider:) 便捷注入。明文书零影响。
- 试读墙:configuration.trialPolicy + delegate epubReaderTrialWallView/
  DidReachTrialWall,UI 由宿主提供(RDEPUBReaderController+Trial)。
- 工具栏定制:RDEPUBReaderTop/BottomToolViewProtocol 协议 + dependencies 工厂注入,
  内置栏已 conform,configureTopToolView 改协议类型。
- 朝向锁定:RDEPUBMetadata.orientation(OPF rendition:orientation 主,
  iBooks display-options 兜底)+ RDEPUBReaderController+Orientation 重写支持朝向、
  打开后主动转向。

注:RDEPUBReaderController+LocationResolution / PaginationCoordinator 为本次改动前
即存在的工作区修改,一并纳入。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 12:25:17 +09:00

401 lines
13 KiB
Swift

import Foundation
extension RDEPUBParser {
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
}
}
struct OPFPackageDocument {
var metadata: RDEPUBMetadata
var manifest: [RDEPUBManifestItem]
var manifestByID: [String: RDEPUBManifestItem]
var spineReferences: [OPFSpineReference]
var ncxItem: RDEPUBManifestItem?
var navigationItem: RDEPUBManifestItem?
}
struct OPFSpineReference {
var idref: String
var linear: Bool
var properties: [String]
var pageSpread: RDEPUBPageSpread?
}
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?
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 == "rendition:orientation",
let value = attributeDict["content"] {
metadata.orientation = orientation(from: value)
}
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()
}
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
}
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 "rendition:orientation":
metadata.orientation = orientation(from: normalizedValue)
case "dcterms:identifier", "identifier":
if metadata.identifier == nil {
metadata.identifier = normalizedValue
}
case "title":
if let refinesID = currentMetaRefinesID {
manifestTitleByID[refinesID] = normalizedValue
}
default:
break
}
}
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
}
private func layout(from rawValue: String) -> RDEPUBLayout {
rawValue.lowercased().contains("pre-paginated") ? .fixed : .reflowable
}
private func orientation(from rawValue: String) -> RDEPUBOrientation? {
switch rawValue.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) {
case "portrait":
return .portrait
case "landscape":
return .landscape
case "auto":
return .auto
default:
return nil
}
}
}
enum XMLSection {
case metadata
case manifest
case spine
case other
}
enum XMLName {
static func localName(from rawName: String) -> String {
rawName.split(separator: ":").last.map(String.init) ?? rawName
}
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)
}
static func refinedID(from rawValue: String) -> String? {
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("#") else {
return nil
}
return String(trimmed.dropFirst())
}
}
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)
}
}