feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化

- 实现EPUB阅读器搜索功能及选中注释功能
- 优化CFI模块,修复代码审查发现的11个问题
- 实现大书远距目录跳转与后台补全优化方案
- 优化设置面板与章节运行时联动
- 重构及大量改进优化
This commit is contained in:
shenlei
2026-06-22 20:26:34 +08:00
parent f50495ad91
commit c65c190b71
178 changed files with 11380 additions and 6728 deletions
@@ -75,7 +75,7 @@ public enum RDEPUBCFIDOMPathBuilder {
match.numberOfRanges > 2 else {
return nil
}
return nsAttributes.substring(with: match.range(at: 2)).nilIfEmpty
return nsAttributes.substring(with: match.range(at: 2)).rd_cfiNilIfEmpty
}
static func isIgnorableTag(_ tagName: String) -> Bool {
@@ -162,7 +162,7 @@ enum RDEPUBCFITextNodeMapBuilder {
}
// Use comment/CDATA-stripped HTML for both path building and tag matching
let cleanedHTML = stripCommentsAndCDATA(rawHTML)
let cleanedHTML = RDEPUBCFIDOMPathBuilder.stripCommentsAndCDATA(rawHTML)
let fragments = RDEPUBCFIDOMPathBuilder.fragmentPaths(in: rawHTML)
var markers: [RDEPUBCFIMarker] = fragmentMarkers(fragmentOffsets: fragmentOffsets, paths: fragments)
@@ -516,6 +516,14 @@ private struct RDEPUBNormalizedTextIndex {
}
}
extension String {
var rd_cfiNilIfEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
private extension String {
var fnv1a64: UInt64 {
let offsetBasis: UInt64 = 14695981039346656037
@@ -114,7 +114,7 @@ public enum RDEPUBCFIParser {
if let open = rawStep.firstIndex(of: "["),
let close = rawStep.lastIndex(of: "]"),
open < close {
idAssertion = String(rawStep[rawStep.index(after: open)..<close]).nilIfEmpty
idAssertion = String(rawStep[rawStep.index(after: open)..<close]).rd_cfiNilIfEmpty
} else {
idAssertion = nil
}
@@ -34,6 +34,6 @@ public struct RDEPUBCFIStep: Codable, Equatable, Hashable {
public init(index: Int, idAssertion: String? = nil) {
self.index = index
self.idAssertion = idAssertion?.nilIfEmpty
self.idAssertion = idAssertion?.rd_cfiNilIfEmpty
}
}
@@ -48,7 +48,13 @@ enum RDEPUBCFIRecoveryEngine {
)
}
if let token = tokenRecoveredResult(cfi: cfi, cfiMap: cfiMap, chapterText: chapterText, lastOffset: lastOffset) {
if let token = tokenRecoveredResult(
cfi: cfi,
cfiMap: cfiMap,
chapterText: chapterText,
fallbackOffset: resolved.chapterOffset,
lastOffset: lastOffset
) {
return token
}
@@ -123,6 +129,7 @@ enum RDEPUBCFIRecoveryEngine {
cfi: RDEPUBCFI,
cfiMap: RDEPUBCFIMap?,
chapterText: String?,
fallbackOffset: Int?,
lastOffset: Int
) -> RDEPUBCFIRecoveryResult? {
guard let cfiMap,
@@ -149,6 +156,7 @@ enum RDEPUBCFIRecoveryEngine {
let normalizedText = RDEPUBCFITextNodeMapBuilder.normalizedText(from: chapterText)
guard !normalizedText.isEmpty else { return nil }
let preferredOffset = fallbackOffset.map { clamp($0, lastOffset: lastOffset) }
var bestOffset: Int?
var bestScore = Int.max
@@ -161,7 +169,14 @@ enum RDEPUBCFIRecoveryEngine {
lastOffset: lastOffset,
windowRadius: 768
)
let score = abs(calibrated - approximateOffset)
let score = tokenRecoveryScore(
for: anchor,
targetPath: cfi.contentPath,
approximateOffset: approximateOffset,
calibratedOffset: calibrated,
preferredOffset: preferredOffset,
allAnchors: cfiMap.recoveryMetadata.tokenIndex
)
if score < bestScore {
bestScore = score
bestOffset = calibrated
@@ -317,4 +332,49 @@ enum RDEPUBCFIRecoveryEngine {
private static func clamp(_ offset: Int, lastOffset: Int) -> Int {
min(max(offset, 0), max(lastOffset, 0))
}
private static func tokenRecoveryScore(
for anchor: RDEPUBCFITokenAnchor,
targetPath: RDEPUBCFIPath,
approximateOffset: Int,
calibratedOffset: Int,
preferredOffset: Int?,
allAnchors: [RDEPUBCFITokenAnchor]
) -> Int {
var score = abs(calibratedOffset - approximateOffset)
score += pathDistance(anchor.cfiPath, targetPath) * 1_024
if let preferredOffset {
score += abs(approximateOffset - preferredOffset)
let expectedOccurrence = nearestOccurrence(
forToken: anchor.token,
preferredOffset: preferredOffset,
anchors: allAnchors
)
score += abs(anchor.occurrence - expectedOccurrence) * 256
}
return score
}
private static func nearestOccurrence(
forToken token: String,
preferredOffset: Int,
anchors: [RDEPUBCFITokenAnchor]
) -> Int {
anchors
.filter { $0.token == token }
.min(by: { abs($0.chapterOffset - preferredOffset) < abs($1.chapterOffset - preferredOffset) })?
.occurrence ?? 0
}
private static func pathDistance(_ lhs: RDEPUBCFIPath, _ rhs: RDEPUBCFIPath) -> Int {
let prefix = lhs.commonPrefix(with: rhs).steps.count
let remainingLHS = lhs.steps.dropFirst(prefix)
let remainingRHS = rhs.steps.dropFirst(prefix)
let stepDistance = zip(remainingLHS, remainingRHS).reduce(0) { partial, pair in
partial + abs(pair.0.index - pair.1.index)
}
return stepDistance + abs(lhs.steps.count - rhs.steps.count) * 2
}
}
@@ -28,7 +28,7 @@ public enum RDEPUBCFIResolver {
if cfi.packagePath.steps.count >= 3 {
manifestStep = cfi.packagePath.steps[2]
} else {
manifestStep = cfi.packagePath.steps.last(where: { $0.idAssertion?.nilIfEmpty != nil })
manifestStep = cfi.packagePath.steps.last(where: { $0.idAssertion?.rd_cfiNilIfEmpty != nil })
}
let href = manifestStep?.idAssertion
@@ -40,7 +40,7 @@ public enum RDEPUBCFIResolver {
fileIndex = nil
}
let fragmentID = cfi.contentPath.steps.last(where: { $0.idAssertion?.nilIfEmpty != nil })?.idAssertion
let fragmentID = cfi.contentPath.steps.last(where: { $0.idAssertion?.rd_cfiNilIfEmpty != nil })?.idAssertion
return RDEPUBCFIResolverResult(
href: href,
fileIndex: fileIndex,
@@ -48,4 +48,4 @@ public enum RDEPUBCFIResolver {
fragmentID: fragmentID
)
}
}
}
@@ -9,8 +9,14 @@ public struct RDEPUBCFITextAssertion: Codable, Equatable, Hashable {
public var suffix: String?
public init(prefix: String? = nil, exact: String? = nil, suffix: String? = nil) {
self.prefix = prefix?.nilIfEmpty
self.exact = exact?.nilIfEmpty
self.suffix = suffix?.nilIfEmpty
self.prefix = Self.trimmedOrNil(prefix)
self.exact = Self.trimmedOrNil(exact)
self.suffix = Self.trimmedOrNil(suffix)
}
private static func trimmedOrNil(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}