右对齐文本显示修复(如"巫鸿"只显示"鸿"的问题): - 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>
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 = 13
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|