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 和状态管理
This commit is contained in:
@@ -20,7 +20,9 @@ struct RDEPUBChapterTailNormalizer {
|
||||
previous.diagnostics.append(note)
|
||||
compacted.append(previous)
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("[EPUB][Pagination] href=\(href) dropped leading/intermediate whitespace frame \(NSStringFromRange(frame.contentRange))")
|
||||
#endif
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -36,7 +38,9 @@ struct RDEPUBChapterTailNormalizer {
|
||||
previousFrame.diagnostics.append(note)
|
||||
normalized.append(previousFrame)
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("[EPUB][Pagination] href=\(href) dropped trailing frame \(NSStringFromRange(lastFrame.contentRange))")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -137,7 +137,9 @@ public final class RDEPUBTextBookBuilder {
|
||||
// 保存分页缓存(只缓存页范围和分页原因,不缓存富文本)
|
||||
cacheCoordinator.save(chapters: chapters, key: cacheKey)
|
||||
|
||||
#if DEBUG
|
||||
print(sampler.summary())
|
||||
#endif
|
||||
lastBuildPerformanceSamples = sampler.samples
|
||||
|
||||
return book
|
||||
@@ -207,11 +209,15 @@ public final class RDEPUBTextBookBuilder {
|
||||
|
||||
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if item.href.lowercased().contains("cover") {
|
||||
#if DEBUG
|
||||
print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))")
|
||||
#endif
|
||||
}
|
||||
if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
|
||||
if item.href.lowercased().contains("cover") {
|
||||
#if DEBUG
|
||||
print("[EPUB][Cover] skipped href=\(item.href)")
|
||||
#endif
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -301,7 +307,9 @@ public final class RDEPUBTextBookBuilder {
|
||||
: normalizedFrames
|
||||
|
||||
if item.href.lowercased().contains("cover") {
|
||||
#if DEBUG
|
||||
print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")")
|
||||
#endif
|
||||
}
|
||||
|
||||
let chapterAttributedContent = content.copy() as! NSAttributedString
|
||||
|
||||
@@ -170,7 +170,9 @@ public final class RDEPUBTextBookCache {
|
||||
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 {
|
||||
@@ -179,17 +181,23 @@ public final class RDEPUBTextBookCache {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -204,9 +212,13 @@ public final class RDEPUBTextBookCache {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,7 +235,9 @@ public final class RDEPUBTextBookCache {
|
||||
for file in contents {
|
||||
try? fileManager.removeItem(at: file)
|
||||
}
|
||||
#if DEBUG
|
||||
print("[Cache] invalidateAll")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -51,7 +51,9 @@ public final class RDEPUBTextPerformanceSampler {
|
||||
/// 记录单个章节的性能采样,并输出日志
|
||||
public func record(_ sample: RDEPUBTextPerformanceSample) {
|
||||
samples.append(sample)
|
||||
#if DEBUG
|
||||
print("[PERF] \(sample.chapterHref): render=\(formatMS(sample.renderDuration)) paginate=\(formatMS(sample.paginateDuration)) pages=\(sample.pageCount) cache=\(sample.cacheHit ? "HIT" : "MISS")")
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 生成性能汇总报告,包含总渲染/分页耗时和缓存命中率
|
||||
|
||||
@@ -166,80 +166,6 @@ struct RDEPUBPageBreakPolicy {
|
||||
|
||||
// MARK: - 语义边界查找
|
||||
|
||||
/// 在指定范围内查找最优的语义分页点(pageBreakBefore / pageBreakAfter)。
|
||||
func preferredSemanticBoundary(
|
||||
in range: NSRange,
|
||||
minimumEnd: Int,
|
||||
factory: RDEPUBCoreTextPageFrameFactory
|
||||
) -> (location: Int, trigger: String)? {
|
||||
guard let safeRange = factory.clampedRange(range), safeRange.length > 0 else {
|
||||
return nil
|
||||
}
|
||||
var boundary: (location: Int, trigger: String)?
|
||||
attributedString.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { value, attributeRange, stop in
|
||||
guard let rawValue = value as? String else { return }
|
||||
let hints = rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
guard !hints.isEmpty else { return }
|
||||
|
||||
if hints.contains(.pageBreakBefore),
|
||||
attributeRange.location > safeRange.location,
|
||||
attributeRange.location >= minimumEnd {
|
||||
boundary = (attributeRange.location, RDEPUBTextSemanticHint.pageBreakBefore.rawValue)
|
||||
stop.pointee = true
|
||||
return
|
||||
}
|
||||
|
||||
let attributeEnd = attributeRange.location + attributeRange.length
|
||||
if hints.contains(.pageBreakAfter),
|
||||
attributeEnd > minimumEnd,
|
||||
attributeEnd < safeRange.location + safeRange.length {
|
||||
boundary = (attributeEnd, RDEPUBTextSemanticHint.pageBreakAfter.rawValue)
|
||||
stop.pointee = true
|
||||
return
|
||||
}
|
||||
}
|
||||
return boundary
|
||||
}
|
||||
|
||||
/// 查找附件边界:只有块级附件才触发分页。
|
||||
func preferredAttachmentBoundary(
|
||||
in range: NSRange,
|
||||
minimumEnd: Int,
|
||||
factory: RDEPUBCoreTextPageFrameFactory
|
||||
) -> Int? {
|
||||
guard let safeRange = factory.clampedRange(range), safeRange.length > 0 else {
|
||||
return nil
|
||||
}
|
||||
var boundary: Int?
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, attributeRange, stop in
|
||||
guard value != nil else { return }
|
||||
|
||||
let location = attributeRange.location
|
||||
let placement = factory.attachmentPlacement(at: location)
|
||||
let blockKind = factory.blockKind(at: location)
|
||||
|
||||
let isBlockLevelAttachment: Bool
|
||||
switch placement {
|
||||
case .centered:
|
||||
isBlockLevelAttachment = true
|
||||
case .inline, .baseline:
|
||||
isBlockLevelAttachment = false
|
||||
case nil:
|
||||
isBlockLevelAttachment = blockKind == .attachment
|
||||
}
|
||||
guard isBlockLevelAttachment else { return }
|
||||
|
||||
let boundaryRange = factory.blockRange(at: location) ?? factory.paragraphRange(containing: location)
|
||||
if boundaryRange.location > safeRange.location, boundaryRange.location >= minimumEnd {
|
||||
boundary = boundaryRange.location
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
return boundary
|
||||
}
|
||||
|
||||
/// 查找 pageRelate 跨页关联边界。
|
||||
func preferredPageRelateBoundary(
|
||||
after range: NSRange,
|
||||
|
||||
@@ -174,12 +174,12 @@ public final class RDEPUBChapterData {
|
||||
|
||||
/// 从搜索结果还原绝对字符范围
|
||||
public func absoluteRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
|
||||
if let location = searchMatch.rangeLocation {
|
||||
return NSRange(location: location, length: max(searchMatch.rangeLength, 1))
|
||||
}
|
||||
if let rangeAnchor = searchMatch.rangeAnchor {
|
||||
return indexTable.chapterRange(for: rangeAnchor)
|
||||
}
|
||||
if let location = searchMatch.rangeLocation {
|
||||
return NSRange(location: location, length: searchMatch.rangeLength)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -187,11 +187,7 @@ public struct RDEPUBTextIndexTable {
|
||||
|
||||
/// 将锚点转换为章节内字符偏移量。
|
||||
public func chapterOffset(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
chapterOffset(
|
||||
fileIndex: anchor.fileIndex,
|
||||
row: anchor.row,
|
||||
column: anchor.column
|
||||
) ?? anchor.chapterOffset
|
||||
anchor.chapterOffset
|
||||
}
|
||||
|
||||
/// 将锚点转换为全书绝对字符索引。
|
||||
|
||||
@@ -46,7 +46,9 @@ struct RDEPUBAttachmentNormalizer {
|
||||
|
||||
if !didLogFootnoteAttachment {
|
||||
didLogFootnoteAttachment = true
|
||||
#if DEBUG
|
||||
print("[EPUB][Attachment] footnote original=\(RDEPUBHTMLNormalizer.string(from: originalSize)) display=\(RDEPUBHTMLNormalizer.string(from: attachment.displaySize)) font=\(pointSize)")
|
||||
#endif
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -66,7 +68,9 @@ struct RDEPUBAttachmentNormalizer {
|
||||
|
||||
if !didLogCoverAttachment {
|
||||
didLogCoverAttachment = true
|
||||
#if DEBUG
|
||||
print("[EPUB][Attachment] cover original=\(RDEPUBHTMLNormalizer.string(from: originalSize)) display=\(RDEPUBHTMLNormalizer.string(from: attachment.displaySize))")
|
||||
#endif
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user