- PaginationCoordinator: 新增 textReflowable 快速进入路径,支持增量章节构建 与 staged 合并策略,避免整书一次性构建的主线程阻塞 - TextBookBuilder: 重构为支持单章构建与增量合并模式 - ChapterPageCounter: 优化分页计数逻辑,支持章节级分页缓存 - PageFrameFactory: 新增 CoreText 页面帧工厂,提升排版性能 - ReaderContext/ReaderRuntime: 适配增量分页状态管理 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
269 lines
11 KiB
Swift
269 lines
11 KiB
Swift
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)
|
||
}
|
||
}
|
||
}
|