ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift

204 lines
8.2 KiB
Swift

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")
}
}
}