ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBPageMapReconciliationCoordinator.swift
shenlei 47fe2dc450 页表全量接管改为按子集覆盖判定
fullReplace 从"候选章节数 >= 当前章节数"改为"候选页表必须覆盖当前窗口
的全部章节",避免跳章后 partial map 数量达标但缺少远端已映射章节时,
全量替换丢弃这些条目导致总页数抖动。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:14:48 +09:00

230 lines
8.9 KiB
Swift

import Foundation
enum RDEPUBPageMapTakeoverDecision {
case keepCurrentWindow
case expandWindow(RDEPUBBackgroundCoverageSegment)
case segmentReplace(RDEPUBBackgroundCoverageSegment)
case fullReplace(RDEPUBBookPageMap)
}
final class RDEPUBPageMapReconciliationCoordinator {
private unowned let context: RDEPUBReaderContext
init(context: RDEPUBReaderContext) {
self.context = context
}
func evaluateTakeover(
candidatePageMap: RDEPUBBookPageMap?,
candidateSegment: RDEPUBBackgroundCoverageSegment?,
currentWindow: RDEPUBBookPageMap?,
jumpSession: RDEPUBJumpSession?
) -> RDEPUBPageMapTakeoverDecision {
guard let currentWindow else {
if let candidatePageMap {
return .fullReplace(candidatePageMap)
}
return .keepCurrentWindow
}
let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
let lastBuildableSpineIndex = context.publication?.spine.indices
.reversed()
.first(where: { index in
guard let item = context.publication?.spine[index] else { return false }
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}) ?? 0
if let jumpSession {
let protectedIndices = jumpSession.protectedSpineIndices
if let currentSpineIndex, protectedIndices.contains(currentSpineIndex) {
if let candidateSegment {
let candidateIndices = candidateSegment.resolvedSpineIndices
let coverageRatio = Double(protectedIndices.intersection(candidateIndices).count) /
Double(protectedIndices.count)
if coverageRatio < 0.8 {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"evaluateTakeover: keepCurrentWindow — jumpSession coverageRatio=\(String(format: "%.2f", coverageRatio)) protected=\(protectedIndices.count) candidateChapters=\(candidateIndices.count)"
)
return .keepCurrentWindow
}
}
}
}
if let currentSpineIndex {
let requiresAdjacentCoverage = currentSpineIndex > 0 && currentSpineIndex < lastBuildableSpineIndex
if requiresAdjacentCoverage {
if let candidateSegment {
let hasPrev = candidateSegment.contains(spineIndex: currentSpineIndex - 1)
let hasNext = candidateSegment.contains(spineIndex: currentSpineIndex + 1)
if !hasPrev || !hasNext {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"evaluateTakeover: keepCurrentWindow — adjacentCoverage missing hasPrev=\(hasPrev) hasNext=\(hasNext) currentSpine=\(currentSpineIndex)"
)
return .keepCurrentWindow
}
}
}
}
if let candidateSegment {
let currentRenderSignature = context.currentRenderSignature()
if candidateSegment.renderSignature != currentRenderSignature {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"evaluateTakeover: keepCurrentWindow — renderSignature mismatch segment=\(candidateSegment.renderSignature) current=\(currentRenderSignature)"
)
return .keepCurrentWindow
}
}
if let candidateSegment {
return evaluateSegmentTakeover(
candidateSegment: candidateSegment,
currentWindow: currentWindow,
currentSpineIndex: currentSpineIndex,
lastBuildableSpineIndex: lastBuildableSpineIndex
)
}
if let candidatePageMap {
return evaluateFullPageMapTakeover(
candidatePageMap: candidatePageMap,
currentWindow: currentWindow,
currentSpineIndex: currentSpineIndex,
lastBuildableSpineIndex: lastBuildableSpineIndex
)
}
return .keepCurrentWindow
}
private func evaluateSegmentTakeover(
candidateSegment: RDEPUBBackgroundCoverageSegment,
currentWindow: RDEPUBBookPageMap,
currentSpineIndex: Int?,
lastBuildableSpineIndex: Int
) -> RDEPUBPageMapTakeoverDecision {
let currentIndices = Set(currentWindow.entries.map { $0.spineIndex })
let candidateIndices = candidateSegment.resolvedSpineIndices
if let currentSpineIndex {
if !candidateIndices.contains(currentSpineIndex) {
return .keepCurrentWindow
}
}
if let currentSpineIndex {
let hasPrev = candidateIndices.contains(currentSpineIndex - 1) || currentSpineIndex == 0
let hasNext = candidateIndices.contains(currentSpineIndex + 1) ||
currentSpineIndex == lastBuildableSpineIndex
if !hasPrev || !hasNext {
return .keepCurrentWindow
}
}
let isContinuous = currentIndices.contains(candidateSegment.lowerSpineIndex - 1) ||
currentIndices.contains(candidateSegment.upperSpineIndex + 1) ||
candidateIndices.contains(currentWindow.entries.first?.spineIndex ?? Int.max) ||
candidateIndices.contains(currentWindow.entries.last?.spineIndex ?? Int.min)
if isContinuous {
return .expandWindow(candidateSegment)
} else {
let overlap = currentIndices.intersection(candidateIndices)
let overlapRatio = Double(overlap.count) / Double(currentIndices.count)
if overlapRatio > 0.5 {
return .segmentReplace(candidateSegment)
}
}
return .keepCurrentWindow
}
private func evaluateFullPageMapTakeover(
candidatePageMap: RDEPUBBookPageMap,
currentWindow: RDEPUBBookPageMap,
currentSpineIndex: Int?,
lastBuildableSpineIndex: Int
) -> RDEPUBPageMapTakeoverDecision {
let candidateIndices = Set(candidatePageMap.entries.map { $0.spineIndex })
let currentIndices = Set(currentWindow.entries.map { $0.spineIndex })
let currentEntries = currentWindow.entries.count
if let currentSpineIndex {
if !candidateIndices.contains(currentSpineIndex) {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"evaluateFullPageMapTakeover: keepCurrentWindow — currentSpine=\(currentSpineIndex) not in candidate chapters=\(candidateIndices.count)"
)
return .keepCurrentWindow
}
}
if let currentSpineIndex {
let hasPrev = candidateIndices.contains(currentSpineIndex - 1) || currentSpineIndex == 0
let hasNext = candidateIndices.contains(currentSpineIndex + 1) ||
currentSpineIndex == lastBuildableSpineIndex
if !hasPrev || !hasNext {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"evaluateFullPageMapTakeover: keepCurrentWindow — adjacentCoverage missing hasPrev=\(hasPrev) hasNext=\(hasNext) currentSpine=\(currentSpineIndex)"
)
return .keepCurrentWindow
}
}
let coversCurrentWindow = currentIndices.isSubset(of: candidateIndices)
if coversCurrentWindow {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"evaluateFullPageMapTakeover: fullReplace — candidateChapters=\(candidateIndices.count) currentChapters=\(currentEntries) candidatePages=\(candidatePageMap.totalPages) currentPages=\(currentWindow.totalPages)"
)
return .fullReplace(candidatePageMap)
}
RDEPUBBackgroundTrace.log(
"Reconciliation",
"evaluateFullPageMapTakeover: keepCurrentWindow — candidate does not cover currentWindow candidateChapters=\(candidateIndices.count) currentChapters=\(currentEntries)"
)
return .keepCurrentWindow
}
func protectedSpineIndices(
currentSpineIndex: Int?,
jumpSession: RDEPUBJumpSession?
) -> Set<Int> {
var indices: Set<Int> = []
if let currentSpineIndex {
indices.insert(currentSpineIndex)
if currentSpineIndex > 0 {
indices.insert(currentSpineIndex - 1)
}
indices.insert(currentSpineIndex + 1)
}
if let jumpSession {
indices.formUnion(jumpSession.protectedSpineIndices)
}
return indices
}
}