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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 21:16:57 +08:00

269 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import CoreText
import UIKit
#if canImport(DTCoreText)
import DTCoreText
#endif
///
///
///
/// 1. avoidPageBreakInside WXRead 退
/// 2. keepWithNext
/// 3. pageBreakBefore/After
/// 4. pageRelate
/// 5.
/// 6. CoreText
struct RDEPUBChapterPageCounter {
private let factory: RDEPUBCoreTextPageFrameFactory
private let attributedString: NSAttributedString
private let pageSize: CGSize
private let config: RDEPUBTextLayoutConfig
private let pageBreakPolicy: RDEPUBPageBreakPolicy
private let framesetter: CTFramesetter
/// DTCoreText
private let dtLayoutRect: CGRect
init(factory: RDEPUBCoreTextPageFrameFactory) {
self.factory = factory
self.attributedString = factory.attributedString
self.pageSize = factory.pageSize
self.config = factory.config
self.pageBreakPolicy = RDEPUBPageBreakPolicy(attributedString: factory.attributedString)
self.framesetter = CTFramesetterCreateWithAttributedString(factory.attributedString)
self.dtLayoutRect = factory.config.contentRect(fallback: factory.pageSize)
}
///
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
return []
}
#if canImport(DTCoreText)
return layoutFramesUsingDTCoreText(fragmentOffsets: fragmentOffsets)
#else
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
#endif
}
// MARK: - CoreText 退
private func layoutFramesUsingCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
return []
}
var frames: [RDEPUBTextLayoutFrame] = []
var location = 0
let resolvedSize = config.resolvedFrameSize(fallback: pageSize)
let contentRect = config.contentRect(fallback: resolvedSize)
guard contentRect.width > 0, contentRect.height > 0 else {
return []
}
while location < attributedString.length {
let framePath = RDEPUBCoreTextPageFrameFactory.makeLayoutPath(
pageSize: resolvedSize,
config: config
)
let frame = CTFramesetterCreateFrame(
framesetter,
CFRangeMake(location, 0),
framePath,
nil
)
let proposedRange = proposedVisibleRange(
from: frame,
start: location,
totalLength: attributedString.length
)
guard proposedRange.length > 0 else {
break
}
let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
let keepWithNextAdjusted = factory.trimmedRangeForKeepWithNext(from: frame, proposed: avoidAdjusted)
let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: frame)
let widowOrphanAdjusted = factory.trimmedRangeForWidowAndOrphanControl(
proposed: keepWithNextAdjusted,
lineRanges: lineRanges
)
let effectiveLineRanges = lineRangesWithinRange(lineRanges, range: widowOrphanAdjusted)
let adjusted = pageBreakPolicy.adjustedRange(
from: widowOrphanAdjusted,
totalLength: attributedString.length,
lineRanges: effectiveLineRanges,
factory: factory
)
let trailingFragmentID = factory.nearestTrailingFragmentID(
endingAt: adjusted.range.location + adjusted.range.length,
fragmentOffsets: fragmentOffsets
)
frames.append(
RDEPUBTextLayoutFrame(
contentRange: adjusted.range,
breakReason: adjusted.breakReason,
blockRange: adjusted.blockRange,
attachmentRanges: adjusted.attachmentRanges,
attachmentKinds: adjusted.attachmentKinds,
blockKinds: adjusted.blockKinds,
semanticHints: adjusted.semanticHints,
attachmentPlacements: adjusted.attachmentPlacements,
trailingFragmentID: trailingFragmentID,
diagnostics: adjusted.diagnostics
)
)
let nextLocation = adjusted.range.location + adjusted.range.length
guard nextLocation > location else {
location += max(proposedRange.length, 1)
continue
}
location = nextLocation
}
return frames
}
// MARK: - DTCoreText
#if canImport(DTCoreText)
private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
guard config.numberOfColumns == 1 else {
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
}
guard let layouter = DTCoreTextLayouter(attributedString: attributedString) else {
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
}
layouter.shouldCacheLayoutFrames = false
var frames: [RDEPUBTextLayoutFrame] = []
var location = 0
let pageRect = dtLayoutRect
while location < attributedString.length {
guard let layoutFrame = layouter.layoutFrame(with: pageRect, range: NSRange(location: location, length: 0)) else {
break
}
let visibleRange = layoutFrame.visibleStringRange()
guard visibleRange.length > 0 else {
break
}
let proposedRange = NSRange(location: location, length: visibleRange.length)
let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: layoutFrame, proposed: proposedRange)
let lineAdjusted = factory.trimmedRangeForKeepWithNext(from: layoutFrame, proposed: avoidAdjusted)
let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: layoutFrame)
let widowOrphanAdjusted = factory.trimmedRangeForWidowAndOrphanControl(
proposed: lineAdjusted,
lineRanges: lineRanges
)
let effectiveLineRanges = lineRangesWithinRange(lineRanges, range: widowOrphanAdjusted)
let adjusted = pageBreakPolicy.adjustedRange(
from: widowOrphanAdjusted,
totalLength: attributedString.length,
lineRanges: effectiveLineRanges,
factory: factory
)
let verifiedRange: NSRange
if adjusted.breakReason == .attachmentBoundary {
verifiedRange = verifiedDisplayRange(for: adjusted.range)
} else {
verifiedRange = adjusted.range
}
let trailingFragmentID = factory.nearestTrailingFragmentID(
endingAt: verifiedRange.location + verifiedRange.length,
fragmentOffsets: fragmentOffsets
)
let diagnostics = verifiedRange == adjusted.range
? adjusted.diagnostics
: adjusted.diagnostics + ["verified-display-range \(NSStringFromRange(adjusted.range)) -> \(NSStringFromRange(verifiedRange))"]
frames.append(
RDEPUBTextLayoutFrame(
contentRange: verifiedRange,
breakReason: adjusted.breakReason,
blockRange: factory.blockRange(at: max(verifiedRange.location, verifiedRange.location + verifiedRange.length - 1)),
attachmentRanges: factory.attachmentRanges(in: verifiedRange),
attachmentKinds: factory.attachmentKinds(in: verifiedRange),
blockKinds: factory.blockKinds(in: verifiedRange),
semanticHints: factory.semanticHints(in: verifiedRange),
attachmentPlacements: factory.attachmentPlacements(in: verifiedRange),
trailingFragmentID: trailingFragmentID,
diagnostics: diagnostics
)
)
let nextLocation = verifiedRange.location + verifiedRange.length
guard nextLocation > location else {
location += max(visibleRange.length, 1)
continue
}
location = nextLocation
}
return frames
}
private func verifiedDisplayRange(for range: NSRange) -> NSRange {
guard let clampedRange = factory.clampedRange(range),
clampedRange.length > 0,
!factory.attachmentRanges(in: clampedRange).isEmpty else {
return range
}
let pageContent = attributedString.attributedSubstring(from: clampedRange)
guard let layouter = DTCoreTextLayouter(attributedString: pageContent) else {
return clampedRange
}
layouter.shouldCacheLayoutFrames = false
guard let layoutFrame = layouter.layoutFrame(with: dtLayoutRect, range: NSRange(location: 0, length: 0)) else {
return clampedRange
}
let visibleRange = layoutFrame.visibleStringRange()
guard visibleRange.length > 0, visibleRange.length < clampedRange.length else {
return clampedRange
}
return NSRange(location: clampedRange.location, length: visibleRange.length)
}
#endif
// MARK: - WXRead
/// CoreText frame path
private func proposedVisibleRange(
from frame: CTFrame,
start location: Int,
totalLength: Int
) -> NSRange {
let visibleRange = CTFrameGetVisibleStringRange(frame)
guard visibleRange.length > 0 else {
return NSRange(location: location, length: 0)
}
let resolvedLocation = max(location, visibleRange.location)
let visibleEnd = visibleRange.location + visibleRange.length
let length = min(max(visibleEnd - resolvedLocation, 0), totalLength - resolvedLocation)
guard length > 0 else {
return NSRange(location: resolvedLocation, length: 0)
}
return NSRange(location: resolvedLocation, length: length)
}
private func lineRangesWithinRange(_ lineRanges: [NSRange], range: NSRange) -> [NSRange] {
lineRanges.filter { lineRange in
lineRange.location >= range.location && NSMaxRange(lineRange) <= NSMaxRange(range)
}
}
}