ReadViewSDK/Sources/RDReaderView/EPUBCore/RDEPUBParser.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

209 lines
7.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
public final class RDEPUBParser {
public internal(set) var metadata = RDEPUBMetadata()
public internal(set) var manifest: [String: RDEPUBManifestItem] = [:]
public internal(set) var spine: [RDEPUBSpineItem] = []
public internal(set) var tableOfContents: [EPUBTableOfContentsItem] = []
public internal(set) var extractionRootURL: URL?
public internal(set) var opfURL: URL?
public var opfDirectoryURL: URL? {
opfURL?.deletingLastPathComponent()
}
/// nil
/// `parse(epubURL:)` `RDEPUBReaderDependencies.live(resourceDataProvider:)`
public var resourceDataProvider: RDEPUBResourceDataProvider? {
didSet {
guard resourceDataProvider != nil else { return }
RDEPUBResourceAccessRegistry.register(self)
}
}
/// provider provider provider nil
public func providedResourceData(at fileURL: URL) -> Data? {
resourceDataProvider?.resourceData(at: fileURL)
}
/// provider
public func resourceData(at fileURL: URL) -> Data? {
if let provided = providedResourceData(at: fileURL) {
return provided
}
return try? Data(contentsOf: fileURL)
}
public init() {}
// MARK: - Cache Management
/// The base directory used for EPUB extraction caches.
public static var extractionCacheBaseDirectory: URL {
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
?? FileManager.default.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true)
}
/// Removes all EPUB extraction caches from disk.
/// Call this when the host app wants to free disk space, e.g. on didReceiveMemoryWarning
/// or during cleanup when the reader is no longer needed.
public static func cleanExtractionCache() throws {
let fileManager = FileManager.default
let baseURL = extractionCacheBaseDirectory
guard fileManager.fileExists(atPath: baseURL.path) else { return }
try fileManager.removeItem(at: baseURL)
}
/// Evicts extraction caches until total disk usage is below the given threshold,
/// removing least-recently-accessed directories first.
/// - Parameter maxBytes: Maximum total bytes for all extraction caches. Defaults to 500 MB.
public static func evictExtractionCache(maxBytes: UInt64 = 500 * 1024 * 1024) throws {
let fileManager = FileManager.default
let baseURL = extractionCacheBaseDirectory
guard fileManager.fileExists(atPath: baseURL.path) else { return }
let contents = try fileManager.contentsOfDirectory(
at: baseURL,
includingPropertiesForKeys: [.contentAccessDateKey, .isDirectoryKey],
options: .skipsHiddenFiles
)
var entries: [(url: URL, accessDate: Date, size: UInt64)] = []
var totalSize: UInt64 = 0
for directoryURL in contents {
guard let resourceValues = try? directoryURL.resourceValues(forKeys: [.isDirectoryKey, .contentAccessDateKey]),
resourceValues.isDirectory == true else {
continue
}
let directorySize = directoryURL.directorySize
let accessDate = resourceValues.contentAccessDate ?? Date.distantPast
entries.append((url: directoryURL, accessDate: accessDate, size: directorySize))
totalSize += directorySize
}
guard totalSize > maxBytes else { return }
entries.sort { $0.accessDate < $1.accessDate }
for entry in entries {
guard totalSize > maxBytes else { break }
try? fileManager.removeItem(at: entry.url)
totalSize -= entry.size
}
}
public func makePublication() -> RDEPUBPublication {
RDEPUBPublication(parser: self)
}
public func parse(epubURL: URL) throws {
reset()
let extractionURL = try extractArchiveIfNeeded(epubURL: epubURL)
extractionRootURL = extractionURL
let containerURL = extractionURL.appendingPathComponent("META-INF/container.xml")
guard FileManager.default.fileExists(atPath: containerURL.path) else {
throw RDEPUBParserError.missingContainerXML
}
let rootFilePath = try parseContainerRootFile(at: containerURL)
let packageURL = extractionURL.appendingPathComponent(rootFilePath)
guard FileManager.default.fileExists(atPath: packageURL.path) else {
throw RDEPUBParserError.invalidRootFilePath(rootFilePath)
}
try parseOPF(at: packageURL)
// OPF 退 iBooks display-options
if metadata.orientation == nil {
let displayOptionsURL = extractionURL
.appendingPathComponent("META-INF/com.apple.ibooks.display-options.xml")
if let orientation = parseIBooksOrientation(at: displayOptionsURL) {
metadata.orientation = orientation
}
}
}
/// `com.apple.ibooks.display-options.xml` orientation-lock
/// platform[@name] / option[@name="orientation-lock"]
private func parseIBooksOrientation(at url: URL) -> RDEPUBOrientation? {
guard FileManager.default.fileExists(atPath: url.path),
let parser = XMLParser(contentsOf: url) else {
return nil
}
let delegate = IBooksDisplayOptionsParserDelegate()
parser.delegate = delegate
guard parser.parse() else { return nil }
switch delegate.orientationLock?.lowercased() {
case "portrait-orientation", "portrait":
return .portrait
case "landscape-orientation", "landscape":
return .landscape
case "none":
return .auto
default:
return nil
}
}
public func parseOPF(at opfURL: URL) throws {
resetPublicationState()
guard let parser = XMLParser(contentsOf: opfURL) else {
throw RDEPUBParserError.invalidXML(opfURL)
}
let delegate = OPFPackageParserDelegate()
parser.shouldProcessNamespaces = false
parser.delegate = delegate
guard parser.parse() else {
throw parser.parserError ?? RDEPUBParserError.invalidXML(opfURL)
}
let packageDocument = delegate.packageDocument()
self.opfURL = opfURL
self.metadata = packageDocument.metadata
self.manifest = Dictionary(uniqueKeysWithValues: packageDocument.manifest.map { ($0.id, $0) })
self.spine = try buildSpine(from: packageDocument, opfURL: opfURL)
self.tableOfContents = parseTOC(from: packageDocument, opfURL: opfURL)
}
public func parseTOC() -> [EPUBTableOfContentsItem] {
tableOfContents
}
public func parseNavDocument(_ navURL: URL, baseURL: URL? = nil) -> [EPUBTableOfContentsItem] {
parseNavDocumentItems(at: navURL, baseURL: baseURL ?? navURL.deletingLastPathComponent())
}
}
// MARK: - URL Directory Size Extension
private extension URL {
var directorySize: UInt64 {
let fileManager = FileManager.default
guard let enumerator = fileManager.enumerator(at: self, includingPropertiesForKeys: [.fileSizeKey], options: [.skipsHiddenFiles], errorHandler: nil) else {
return 0
}
var totalSize: UInt64 = 0
for case let fileURL as URL in enumerator {
guard let resourceValues = try? fileURL.resourceValues(forKeys: [.fileSizeKey]),
let fileSize = resourceValues.fileSize else {
continue
}
totalSize += UInt64(fileSize)
}
return totalSize
}
}