feat(reader): 实现大书快速进入与增量分页路径

- PaginationCoordinator: 新增 textReflowable 快速进入路径,支持增量章节构建
  与 staged 合并策略,避免整书一次性构建的主线程阻塞
- TextBookBuilder: 重构为支持单章构建与增量合并模式
- ChapterPageCounter: 优化分页计数逻辑,支持章节级分页缓存
- PageFrameFactory: 新增 CoreText 页面帧工厂,提升排版性能
- ReaderContext/ReaderRuntime: 适配增量分页状态管理

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-06-02 21:16:57 +08:00
co-authored by Claude Opus 4.7
parent 488350d956
commit c76beed03c
15 changed files with 984 additions and 268 deletions
@@ -183,6 +183,38 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
return NSRange(location: proposed.location, length: adjustedLength)
}
/// avoidWidows / avoidOrphans
func trimmedRangeForWidowAndOrphanControl(
proposed: NSRange,
lineRanges: [NSRange]
) -> NSRange {
guard !lineRanges.isEmpty else { return proposed }
guard config.avoidWidows || config.avoidOrphans else { return proposed }
var adjusted = proposed
var visibleLines = lineRanges
if config.avoidWidows,
let widowAdjusted = trimmedRangeAvoidingWidow(
proposed: adjusted,
lineRanges: visibleLines
),
widowAdjusted != adjusted {
adjusted = widowAdjusted
visibleLines = Array(visibleLines.dropLast())
}
if config.avoidOrphans,
let orphanAdjusted = trimmedRangeAvoidingOrphan(
proposed: adjusted,
lineRanges: visibleLines
) {
adjusted = orphanAdjusted
}
return adjusted
}
// MARK: -
/// CTFrame
@@ -263,6 +295,101 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
}
// MARK: - Widow / Orphan
private func trimmedRangeAvoidingWidow(
proposed: NSRange,
lineRanges: [NSRange]
) -> NSRange? {
guard lineRanges.count >= 2,
let lastLine = lineRanges.last else {
return nil
}
let paragraphRange = paragraphRange(containing: lastLine.location)
let trailingLines = trailingLineCount(in: lineRanges, paragraphRange: paragraphRange)
let paragraphContinuesOnNextPage = NSMaxRange(proposed) < NSMaxRange(paragraphRange)
guard trailingLines == 1, paragraphContinuesOnNextPage else {
return nil
}
let keptLine = lineRanges[lineRanges.count - 2]
let adjustedLength = NSMaxRange(keptLine) - proposed.location
guard adjustedLength > 0 else { return nil }
return NSRange(location: proposed.location, length: adjustedLength)
}
private func trimmedRangeAvoidingOrphan(
proposed: NSRange,
lineRanges: [NSRange]
) -> NSRange? {
guard lineRanges.count >= 2,
let lastLine = lineRanges.last else {
return nil
}
let paragraphRange = paragraphRange(containing: lastLine.location)
let pageEnd = NSMaxRange(proposed)
let paragraphEnd = NSMaxRange(paragraphRange)
guard pageEnd < paragraphEnd else { return nil }
let remainingLineCount = estimatedRemainingLineCount(
in: paragraphRange,
startingAt: pageEnd
)
let trailingLines = trailingLineCount(in: lineRanges, paragraphRange: paragraphRange)
guard remainingLineCount == 1,
trailingLines >= 2 else {
return nil
}
let keptLine = lineRanges[lineRanges.count - 2]
let adjustedLength = NSMaxRange(keptLine) - proposed.location
guard adjustedLength > 0 else { return nil }
return NSRange(location: proposed.location, length: adjustedLength)
}
private func trailingLineCount(
in lineRanges: [NSRange],
paragraphRange: NSRange
) -> Int {
var count = 0
for lineRange in lineRanges.reversed() {
if NSIntersectionRange(lineRange, paragraphRange).length > 0 {
count += 1
} else {
break
}
}
return count
}
private func estimatedRemainingLineCount(
in paragraphRange: NSRange,
startingAt location: Int
) -> Int {
let paragraphEnd = NSMaxRange(paragraphRange)
var currentLocation = max(location, paragraphRange.location)
guard currentLocation < paragraphEnd else { return 0 }
let width = max(config.columnRects(fallback: pageSize).first?.width ?? config.contentRect(fallback: pageSize).width, 1)
let typesetter = CTTypesetterCreateWithAttributedString(attributedString)
var lineCount = 0
while currentLocation < paragraphEnd {
let suggestedCount = CTTypesetterSuggestLineBreak(typesetter, currentLocation, Double(width))
let lineLength = max(suggestedCount, 1)
currentLocation += lineLength
lineCount += 1
if lineCount > 2 {
break
}
}
return lineCount
}
///
func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {