import Foundation import CryptoKit // MARK: - Pagination Cache (WXRead-style: page ranges only, no attributedString) /// Per-chapter pagination metadata cached to disk. /// Mirrors WRChapterPageCount's caching: stores page NSRange + break reasons, /// NOT the full attributed string. Builder re-renders HTML on cache hit but /// skips the rd_paginatedFrames pagination step. public struct RDEPUBTextChapterPaginationCache: Equatable { public var href: String public var pageRanges: [NSRange] public var breakReasons: [RDEPUBTextPageBreakReason] public var semanticHints: [RDEPUBTextSemanticHint] public init( href: String, pageRanges: [NSRange], breakReasons: [RDEPUBTextPageBreakReason], semanticHints: [RDEPUBTextSemanticHint] ) { self.href = href self.pageRanges = pageRanges self.breakReasons = breakReasons self.semanticHints = semanticHints } } // MARK: - NSCoding Archive (simple: strings + ints only) final class PaginationCacheArchive: NSObject, NSSecureCoding { static var supportsSecureCoding: Bool { true } let chapters: [ChapterPaginationArchive] init(chapters: [ChapterPaginationArchive]) { self.chapters = chapters } func encode(with coder: NSCoder) { coder.encode(chapters, forKey: "chapters") } required init?(coder: NSCoder) { guard let chapters = coder.decodeObject(of: [NSArray.self, ChapterPaginationArchive.self], forKey: "chapters") as? [ChapterPaginationArchive] else { return nil } self.chapters = chapters } } final class ChapterPaginationArchive: NSObject, NSSecureCoding { static var supportsSecureCoding: Bool { true } let href: String let rangeLocations: [NSNumber] let rangeLengths: [NSNumber] let breakReasons: [String] let semanticHints: [String] init(from cache: RDEPUBTextChapterPaginationCache) { self.href = cache.href self.rangeLocations = cache.pageRanges.map { NSNumber(value: $0.location) } self.rangeLengths = cache.pageRanges.map { NSNumber(value: $0.length) } self.breakReasons = cache.breakReasons.map(\.rawValue) self.semanticHints = cache.semanticHints.map(\.rawValue) } func encode(with coder: NSCoder) { coder.encode(href, forKey: "href") coder.encode(rangeLocations, forKey: "rangeLocations") coder.encode(rangeLengths, forKey: "rangeLengths") coder.encode(breakReasons, forKey: "breakReasons") coder.encode(semanticHints, forKey: "semanticHints") } required init?(coder: NSCoder) { guard let href = coder.decodeObject(of: NSString.self, forKey: "href") as String?, let rangeLocations = coder.decodeObject(of: [NSArray.self, NSNumber.self], forKey: "rangeLocations") as? [NSNumber], let rangeLengths = coder.decodeObject(of: [NSArray.self, NSNumber.self], forKey: "rangeLengths") as? [NSNumber], let breakReasons = coder.decodeObject(of: [NSArray.self, NSString.self], forKey: "breakReasons") as? [String], let semanticHints = coder.decodeObject(of: [NSArray.self, NSString.self], forKey: "semanticHints") as? [String] else { return nil } self.href = href self.rangeLocations = rangeLocations self.rangeLengths = rangeLengths self.breakReasons = breakReasons self.semanticHints = semanticHints } func toCache() -> RDEPUBTextChapterPaginationCache { let pageRanges = zip(rangeLocations, rangeLengths).map { loc, len in NSRange(location: loc.intValue, length: len.intValue) } return RDEPUBTextChapterPaginationCache( href: href, pageRanges: pageRanges, breakReasons: breakReasons.compactMap(RDEPUBTextPageBreakReason.init(rawValue:)), semanticHints: semanticHints.compactMap(RDEPUBTextSemanticHint.init(rawValue:)) ) } } // MARK: - RDEPUBTextBookCache /// Disk-persistent cache for per-chapter pagination metadata. /// Follows WXRead's WRChapterPageCount caching pattern: /// cache key = bookID + layout settings, cache value = page NSRange per chapter. /// Attributed strings are NOT cached — builder re-renders HTML on cache hit. public final class RDEPUBTextBookCache { public var schemaVersion: Int = 1 private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility) private let cacheDirectory: URL public init(subdirectory: String = "RDEPUBTextBookCache") { let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first? .appendingPathComponent(subdirectory, isDirectory: true) ?? FileManager.default.temporaryDirectory.appendingPathComponent(subdirectory, isDirectory: true) self.cacheDirectory = baseURL try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) } // MARK: - Cache Key // Mirrors WRChapterPageCount.currentCacheKeyWithBookId: // Encodes bookID + fontSize + lineHeightMultiple + contentInsets + pageSize public func cacheKey( bookID: String, fontSize: CGFloat, lineHeightMultiple: CGFloat, contentInsets: UIEdgeInsets, pageSize: CGSize ) -> String { let raw = "\(bookID)_\(fontSize)_\(lineHeightMultiple)_\(contentInsets.top)_\(contentInsets.left)_\(contentInsets.bottom)_\(contentInsets.right)_\(pageSize.width)_\(pageSize.height)_v\(schemaVersion)" let digest = SHA256.hash(data: Data(raw.utf8)) let hex = digest.map { String(format: "%02x", $0) }.joined() return hex + ".cache" } // MARK: - Load / Save (pagination metadata only) /// Load cached pagination metadata for all chapters. /// Returns dictionary keyed by chapter href. public func load(key: String) -> [String: RDEPUBTextChapterPaginationCache]? { queue.sync { let fileURL = cacheDirectory.appendingPathComponent(key) guard FileManager.default.fileExists(atPath: fileURL.path) else { print("[Cache] load MISS key=\(key)") return nil } do { let data = try Data(contentsOf: fileURL) guard let archive = try NSKeyedUnarchiver.unarchivedObject( ofClass: PaginationCacheArchive.self, from: data ) else { print("[Cache] load MISS key=\(key) (unarchive returned nil)") return nil } var result: [String: RDEPUBTextChapterPaginationCache] = [:] for chapter in archive.chapters { result[chapter.href] = chapter.toCache() } print("[Cache] load HIT key=\(key) chapters=\(result.count)") return result } catch { print("[Cache] load MISS key=\(key) error=\(error)") return nil } } } /// Save pagination metadata for all chapters. public func save(_ chapters: [RDEPUBTextChapterPaginationCache], key: String) { queue.sync { let fileURL = cacheDirectory.appendingPathComponent(key) do { let archives = chapters.map { ChapterPaginationArchive(from: $0) } let bookArchive = PaginationCacheArchive(chapters: archives) let data = try NSKeyedArchiver.archivedData(withRootObject: bookArchive, requiringSecureCoding: true) try data.write(to: fileURL, options: .atomic) print("[Cache] save key=\(key) chapters=\(chapters.count)") } catch { print("[Cache] save FAILED key=\(key) error=\(error)") } } } // MARK: - Invalidate public func invalidateAll() { queue.sync { let fileManager = FileManager.default guard let contents = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return } for file in contents { try? fileManager.removeItem(at: file) } print("[Cache] invalidateAll") } } }