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 // 与 packageDocument 使用同一份已去重索引,避免重复 manifest id 再次触发断言。 self.manifest = packageDocument.manifestByID 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 } }