右对齐文本显示修复(如"巫鸿"只显示"鸿"的问题): - RDEPUBChapterTailNormalizer: 短尾页合并时检查 avoidPageBreakInside 语义, 避免将带有此标记的短文本(如 right-info 署名)错误合并回上一页 - RDEPUBTextContentView: 续段归一化时跳过右对齐/居中对齐的段落, 防止错误修改 firstLineHeadIndent 导致首字不可见 - RDEPUBCSSCompatibilityLayer: 为含 text-align:right/center 的 CSS 块 自动注入 text-indent:0 !important,确保右对齐/居中文本无首行缩进 - RDEPUBTextRendererSupport: 排版属性归一化时将右对齐/居中段落的 firstLineHeadIndent 重置为 0 图片查看器及脚注点击功能: - 新增 RDEPUBImageViewController 和 RDEPUBImageViewerCoordinator, 支持从 WebView 和 TextPage 两种模式查看图片 - epub-bridge.js: 检测图片和脚注图片的点击事件,脚注图片显示弹窗 - RDEPUBJavaScriptBridge: 新增 imageDidTap/footnoteDidTap 桥接消息 - RDEPUBWebView: 新增图片和脚注点击的 delegate 方法 - RDEPUBAttachmentNormalizer: 改进脚注检测,优先使用 alt 文本判断 - RDEPUBPaginationModels: 新增 .footnote 附件类型 - RDEPUBPageLayoutSnapshot: 运行时动态解析附件类型,脚注优先级高于图片 位置解析改进: - RDEPUBReaderController+LocationResolution: 利用 rangeInfo 提升页码定位精度 其他: - RDEPUBTextBookCache: schema 版本升级至 13 - RDEPUBReaderTheme: 主题更新 - Pod 项目文件更新 Co-Authored-By: Claude <noreply@anthropic.com>
229 lines
7.9 KiB
Swift
229 lines
7.9 KiB
Swift
import Foundation
|
|
|
|
final class RDEPUBChapterSummaryDiskCache {
|
|
|
|
private let cacheDirectory: URL
|
|
|
|
private let fileManager = FileManager.default
|
|
|
|
private let queue = DispatchQueue(label: "com.rdreader.summarydiskcache", qos: .utility)
|
|
|
|
init(cacheDirectory: URL) {
|
|
self.cacheDirectory = cacheDirectory
|
|
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
|
}
|
|
|
|
func write(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
|
queue.async {
|
|
self.writeImmediately(summary: summary, for: key)
|
|
}
|
|
}
|
|
|
|
func writeSynchronously(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
|
queue.sync {
|
|
self.writeImmediately(summary: summary, for: key)
|
|
}
|
|
}
|
|
|
|
func flushPendingWrites() {
|
|
queue.sync { }
|
|
}
|
|
|
|
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {
|
|
let fileURL = self.fileURL(for: key)
|
|
let data: Data
|
|
do {
|
|
data = try Data(contentsOf: fileURL)
|
|
} catch {
|
|
let nsError = error as NSError
|
|
if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError {
|
|
|
|
} else {
|
|
#if DEBUG
|
|
print("[RDEPUBChapterSummaryDiskCache] ⚠️ read IO error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
|
|
#endif
|
|
}
|
|
return nil
|
|
}
|
|
do {
|
|
return try JSONDecoder().decode(RDEPUBChapterSummary.self, from: data)
|
|
} catch {
|
|
#if DEBUG
|
|
print("[RDEPUBChapterSummaryDiskCache] ⚠️ decode error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
|
|
#endif
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func readAll(keys: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]) -> (
|
|
summaries: [Int: RDEPUBChapterSummary],
|
|
mapBuilder: RDEPUBBookPageMap.Builder
|
|
) {
|
|
var summaries: [Int: RDEPUBChapterSummary] = [:]
|
|
var mapBuilder = RDEPUBBookPageMap.Builder()
|
|
|
|
for item in keys {
|
|
if let summary = read(for: item.key) {
|
|
summaries[item.spineIndex] = summary
|
|
mapBuilder.add(
|
|
spineIndex: item.spineIndex,
|
|
href: item.href,
|
|
title: item.title,
|
|
pageCount: summary.pageCount,
|
|
fragmentOffsets: summary.fragmentOffsets
|
|
)
|
|
}
|
|
}
|
|
return (summaries, mapBuilder)
|
|
}
|
|
|
|
func isCacheComplete(keys: [RDEPUBChapterCacheKey]) -> Bool {
|
|
for key in keys {
|
|
if read(for: key) == nil {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func removeAll() {
|
|
removeFiles(matching: { _ in true })
|
|
}
|
|
|
|
func removeAll(forBookID bookID: String) {
|
|
let bookPrefix = Self.cacheNamespacePrefix(for: bookID)
|
|
removeFiles { $0.hasPrefix(bookPrefix + "__") }
|
|
}
|
|
|
|
func removeAll(forRenderSignature renderSignature: String) {
|
|
let renderPrefix = "__" + Self.cacheNamespacePrefix(for: renderSignature) + "__"
|
|
removeFiles { $0.contains(renderPrefix) }
|
|
}
|
|
|
|
var cacheStatistics: (fileCount: Int, totalBytes: Int64) {
|
|
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: [.fileSizeKey]) else {
|
|
return (0, 0)
|
|
}
|
|
var count = 0
|
|
var totalBytes: Int64 = 0
|
|
for fileURL in files where fileURL.pathExtension == "json" {
|
|
count += 1
|
|
if let size = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
|
|
totalBytes += Int64(size)
|
|
}
|
|
}
|
|
return (count, totalBytes)
|
|
}
|
|
|
|
private func fileURL(for key: RDEPUBChapterCacheKey) -> URL {
|
|
let bookPrefix = Self.cacheNamespacePrefix(for: key.bookID)
|
|
let renderPrefix = Self.cacheNamespacePrefix(for: key.renderSignature)
|
|
let rawKey = "\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)"
|
|
let digest = rawKey.rd_sha256Hex
|
|
return cacheDirectory.appendingPathComponent("\(bookPrefix)__\(renderPrefix)__\(digest).json")
|
|
}
|
|
|
|
private func writeImmediately(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
|
let fileURL = self.fileURL(for: key)
|
|
let tmpURL = fileURL.appendingPathExtension("tmp")
|
|
do {
|
|
let data = try JSONEncoder().encode(summary)
|
|
try data.write(to: tmpURL)
|
|
if fileManager.fileExists(atPath: fileURL.path) {
|
|
_ = try fileManager.replaceItemAt(fileURL, withItemAt: tmpURL)
|
|
} else {
|
|
try fileManager.moveItem(at: tmpURL, to: fileURL)
|
|
}
|
|
} catch {
|
|
#if DEBUG
|
|
print("[RDEPUBChapterSummaryDiskCache] ⚠️ write error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
|
|
#endif
|
|
try? fileManager.removeItem(at: tmpURL)
|
|
}
|
|
}
|
|
|
|
private func removeFiles(matching predicate: (String) -> Bool) {
|
|
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return }
|
|
for fileURL in files where fileURL.pathExtension == "json" && predicate(fileURL.lastPathComponent) {
|
|
try? fileManager.removeItem(at: fileURL)
|
|
}
|
|
}
|
|
|
|
private static func cacheNamespacePrefix(for rawValue: String) -> String {
|
|
rawValue.rd_sha256Hex.prefix(12).lowercased()
|
|
}
|
|
}
|
|
|
|
struct RDEPUBChapterSummary: Codable {
|
|
|
|
let pageRanges: [RangeData]
|
|
|
|
let pageCount: Int
|
|
|
|
let fragmentOffsets: [String: Int]
|
|
|
|
let cfiMap: RDEPUBCFIMap?
|
|
|
|
let renderSignature: String
|
|
|
|
let schemaVersion: Int
|
|
|
|
let chapterContentHash: String
|
|
|
|
let pageMetadataList: [PageMetadataSummary]
|
|
|
|
static let currentSchemaVersion = 16
|
|
|
|
struct RangeData: Codable {
|
|
|
|
let location: Int
|
|
|
|
let length: Int
|
|
|
|
var nsRange: NSRange { NSRange(location: location, length: length) }
|
|
}
|
|
|
|
struct PageMetadataSummary: Codable {
|
|
|
|
let breakReason: String
|
|
|
|
let attachmentRanges: [RangeData]
|
|
|
|
let attachmentKinds: [String]
|
|
|
|
let blockKinds: [String]
|
|
|
|
let semanticHints: [String]
|
|
|
|
let attachmentPlacements: [String]
|
|
|
|
let trailingFragmentID: String?
|
|
|
|
func toPageMetadata() -> RDEPUBTextPageMetadata {
|
|
RDEPUBTextPageMetadata(
|
|
breakReason: RDEPUBTextPageBreakReason(rawValue: breakReason) ?? .frameLimit,
|
|
blockRange: nil,
|
|
attachmentRanges: attachmentRanges.map { $0.nsRange },
|
|
attachmentKinds: attachmentKinds.compactMap { RDEPUBTextAttachmentKind(rawValue: $0) },
|
|
blockKinds: blockKinds.compactMap { RDEPUBTextBlockKind(rawValue: $0) },
|
|
semanticHints: semanticHints.compactMap { RDEPUBTextSemanticHint(rawValue: $0) },
|
|
attachmentPlacements: attachmentPlacements.compactMap { RDEPUBTextAttachmentPlacement(rawValue: $0) },
|
|
trailingFragmentID: trailingFragmentID,
|
|
diagnostics: []
|
|
)
|
|
}
|
|
|
|
static func from(_ metadata: RDEPUBTextPageMetadata) -> PageMetadataSummary {
|
|
PageMetadataSummary(
|
|
breakReason: metadata.breakReason.rawValue,
|
|
attachmentRanges: metadata.attachmentRanges.map { .init(location: $0.location, length: $0.length) },
|
|
attachmentKinds: metadata.attachmentKinds.map { $0.rawValue },
|
|
blockKinds: metadata.blockKinds.map { $0.rawValue },
|
|
semanticHints: metadata.semanticHints.map { $0.rawValue },
|
|
attachmentPlacements: metadata.attachmentPlacements.map { $0.rawValue },
|
|
trailingFragmentID: metadata.trailingFragmentID
|
|
)
|
|
}
|
|
}
|
|
}
|