- Refactor chapter runtime: replace window coordinator/snapshot with warmup orchestrator - Update EPUB core: parser, reading session, JS bridge, navigator layout - Update reader controller: data source, location resolution, persistence - Update chapter runtime: data cache, loader, runtime store, disk cache, warmup orchestrator - Remove deprecated navigation state machine and pagination state - Update text rendering: book cache, HTML normalizer - Update UI: text content view, dark image adjuster, text selection controller - Update settings and reader configuration - Add CODE_REVIEW.md and AUDIT_FINAL.md documentation - Update pod dependencies (remove SSAlertSwift, SnapKit) - Update podspec and pod configuration files Co-Authored-By: Claude <noreply@anthropic.com>
206 lines
7.5 KiB
Swift
206 lines
7.5 KiB
Swift
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 = 14
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|