import Foundation import CryptoKit 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 } } 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:)) ) } } public final class RDEPUBTextBookCache { public var schemaVersion: Int = 6 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) } public func cacheKey( bookID: String, fontSize: CGFloat, lineHeightMultiple: CGFloat, contentInsets: UIEdgeInsets, pageSize: CGSize, layoutConfigSignature: String = RDEPUBTextLayoutConfig.default.cacheSignature ) -> String { let raw = "\(bookID)_\(fontSize)_\(lineHeightMultiple)_\(contentInsets.top)_\(contentInsets.left)_\(contentInsets.bottom)_\(contentInsets.right)_\(pageSize.width)_\(pageSize.height)_\(layoutConfigSignature)_v\(schemaVersion)" let digest = SHA256.hash(data: Data(raw.utf8)) let hex = digest.map { String(format: "%02x", $0) }.joined() return hex + ".cache" } public func load(key: String) -> [String: RDEPUBTextChapterPaginationCache]? { queue.sync { let fileURL = cacheDirectory.appendingPathComponent(key) guard FileManager.default.fileExists(atPath: fileURL.path) else { #if DEBUG print("[Cache] load MISS key=\(key)") #endif return nil } do { let data = try Data(contentsOf: fileURL) guard let archive = try NSKeyedUnarchiver.unarchivedObject( ofClass: PaginationCacheArchive.self, from: data ) else { #if DEBUG print("[Cache] load MISS key=\(key) (unarchive returned nil)") #endif return nil } var result: [String: RDEPUBTextChapterPaginationCache] = [:] for chapter in archive.chapters { result[chapter.href] = chapter.toCache() } #if DEBUG print("[Cache] load HIT key=\(key) chapters=\(result.count)") #endif return result } catch { #if DEBUG print("[Cache] load MISS key=\(key) error=\(error)") #endif return nil } } } 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) #if DEBUG print("[Cache] save key=\(key) chapters=\(chapters.count)") #endif } catch { #if DEBUG print("[Cache] save FAILED key=\(key) error=\(error)") #endif } } } 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) } #if DEBUG print("[Cache] invalidateAll") #endif } } }