ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookCache.swift
shen 6f75b083f7 feat: EPUB 阅读器搜索、选中注释、书签 chrome 状态及大量重构优化
- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态
- 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试
- 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证)
- 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互
- 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache)
- 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy
- 更新 epub-bridge.js 与 JS bridge 通信协议
- 全面更新现有 UI 测试以适配新的 helper 和状态管理
2026-06-13 22:48:56 +08:00

244 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
import CryptoKit
// MARK: - WXRead
///
///
/// WXRead WRChapterPageCount NSRange +
/// NSAttributedString HTML CoreText
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 使
/// NSKeyedArchiver
///
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
}
}
/// NSRange location/length 便 NSSecureCoding
final class ChapterPaginationArchive: NSObject, NSSecureCoding {
static var supportsSecureCoding: Bool { true }
let href: String
/// rangeLengths
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: -
/// WXRead WRChapterPageCount
///
///
/// - = SHA256(ID + + + + + schema)
/// - = NSRange + NSKeyedArchiver
/// - HTML CoreText
/// - 线 serial DispatchQueue
public final class RDEPUBTextBookCache {
///
//
public var schemaVersion: Int = 6
/// 线
private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility)
///
private let cacheDirectory: URL
///
/// - Parameter subdirectory: Caches
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: -
// WRChapterPageCount.currentCacheKeyWithBookId
// bookID + fontSize + lineHeightMultiple + contentInsets + pageSize
/// SHA256 + ".cache"
///
///
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"
}
// MARK: - /
/// href
/// nil
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
}
}
}
// MARK: -
///
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
}
}
}