- PaginationCoordinator: 新增 textReflowable 快速进入路径,支持增量章节构建 与 staged 合并策略,避免整书一次性构建的主线程阻塞 - TextBookBuilder: 重构为支持单章构建与增量合并模式 - ChapterPageCounter: 优化分页计数逻辑,支持章节级分页缓存 - PageFrameFactory: 新增 CoreText 页面帧工厂,提升排版性能 - ReaderContext/ReaderRuntime: 适配增量分页状态管理 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
517 lines
19 KiB
Swift
517 lines
19 KiB
Swift
import CoreText
|
||
import UIKit
|
||
|
||
#if canImport(DTCoreText)
|
||
import DTCoreText
|
||
#endif
|
||
|
||
/// CoreText 帧工厂:负责帧创建、行级裁剪、属性查询和诊断构建。
|
||
///
|
||
/// 叶节点组件,被 RDEPUBChapterPageCounter 和 RDEPUBPageBreakPolicy 调用。
|
||
struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||
let attributedString: NSAttributedString
|
||
let pageSize: CGSize
|
||
let config: RDEPUBTextLayoutConfig
|
||
private let pageBreakPolicy: RDEPUBPageBreakPolicy
|
||
|
||
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
|
||
self.attributedString = attributedString
|
||
self.pageSize = pageSize
|
||
self.config = config
|
||
self.pageBreakPolicy = RDEPUBPageBreakPolicy(attributedString: attributedString)
|
||
}
|
||
|
||
/// 协议要求的便捷初始化(使用默认配置)
|
||
init() {
|
||
self.init(attributedString: NSAttributedString(), pageSize: .zero, config: .default)
|
||
}
|
||
|
||
// MARK: - RDEPUBPageFrameBuilding 协议
|
||
|
||
func makeFrames(
|
||
attributedString: NSAttributedString,
|
||
pageSize: CGSize,
|
||
config: RDEPUBTextLayoutConfig,
|
||
fragmentOffsets: [String: Int]
|
||
) -> [RDEPUBTextLayoutFrame] {
|
||
let factory = RDEPUBCoreTextPageFrameFactory(attributedString: attributedString, pageSize: pageSize, config: config)
|
||
let counter = RDEPUBChapterPageCounter(factory: factory)
|
||
return counter.layoutFrames(fragmentOffsets: fragmentOffsets)
|
||
}
|
||
|
||
// MARK: - 帧构建
|
||
|
||
/// 从列矩形构建 CGPath(用于 CTFramesetterCreateFrame)。
|
||
static func makeLayoutPath(pageSize: CGSize, config: RDEPUBTextLayoutConfig) -> CGPath {
|
||
let columnRects = config.columnRects(fallback: pageSize)
|
||
guard columnRects.count > 1 else {
|
||
return CGPath(rect: columnRects.first ?? CGRect(origin: .zero, size: pageSize), transform: nil)
|
||
}
|
||
|
||
let path = CGMutablePath()
|
||
for rect in columnRects {
|
||
path.addRect(rect)
|
||
}
|
||
return path
|
||
}
|
||
|
||
// MARK: - 行级裁剪
|
||
|
||
/// 从 CTFrame 最后一行向前扫描,移除落在 avoidPageBreakInside 保护块内的尾部行。
|
||
func trimmedRangeForAvoidPageBreakInside(
|
||
from frame: CTFrame,
|
||
proposed: NSRange
|
||
) -> NSRange {
|
||
guard config.avoidPageBreakInsideEnabled else { return proposed }
|
||
|
||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||
guard !lines.isEmpty else { return proposed }
|
||
|
||
let lineRanges = lines.map {
|
||
let range = CTLineGetStringRange($0)
|
||
return NSRange(location: range.location, length: range.length)
|
||
}
|
||
return trimmedRangeForAvoidPageBreakInside(proposed: proposed, lineRanges: lineRanges)
|
||
}
|
||
|
||
/// CoreText 路径的 keepWithNext 处理:从最后行向前扫描
|
||
func trimmedRangeForKeepWithNext(
|
||
from frame: CTFrame,
|
||
proposed: NSRange
|
||
) -> NSRange {
|
||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||
let lineRanges = lines.map {
|
||
let range = CTLineGetStringRange($0)
|
||
return NSRange(location: range.location, length: range.length)
|
||
}
|
||
return trimmedRangeForKeepWithNext(proposed: proposed, lineRanges: lineRanges)
|
||
}
|
||
|
||
#if canImport(DTCoreText)
|
||
/// DTCoreText 路径的 avoidPageBreakInside 处理
|
||
func trimmedRangeForAvoidPageBreakInside(
|
||
from layoutFrame: DTCoreTextLayoutFrame,
|
||
proposed: NSRange
|
||
) -> NSRange {
|
||
guard config.avoidPageBreakInsideEnabled else { return proposed }
|
||
|
||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine], !lines.isEmpty else {
|
||
return proposed
|
||
}
|
||
let lineRanges = lines.map { $0.stringRange() }
|
||
return trimmedRangeForAvoidPageBreakInside(proposed: proposed, lineRanges: lineRanges)
|
||
}
|
||
|
||
/// DTCoreText 路径的 keepWithNext 处理
|
||
func trimmedRangeForKeepWithNext(
|
||
from layoutFrame: DTCoreTextLayoutFrame,
|
||
proposed: NSRange
|
||
) -> NSRange {
|
||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine], !lines.isEmpty else {
|
||
return proposed
|
||
}
|
||
let lineRanges = lines.map { $0.stringRange() }
|
||
return trimmedRangeForKeepWithNext(proposed: proposed, lineRanges: lineRanges)
|
||
}
|
||
#endif
|
||
|
||
/// 从最后行向前扫描,移除落在 avoidPageBreakInside 保护块内的尾部行(通用路径)。
|
||
func trimmedRangeForAvoidPageBreakInside(
|
||
proposed: NSRange,
|
||
lineRanges: [NSRange]
|
||
) -> NSRange {
|
||
guard config.avoidPageBreakInsideEnabled, !lineRanges.isEmpty else { return proposed }
|
||
|
||
let kMaxLinesToRemove = 3
|
||
var linesToRemove = 0
|
||
|
||
for lineRange in lineRanges.reversed() {
|
||
if pageBreakPolicy.lineIsInAvoidPageBreakInsideBlock(lineRange) {
|
||
linesToRemove += 1
|
||
if linesToRemove >= kMaxLinesToRemove {
|
||
linesToRemove = kMaxLinesToRemove
|
||
break
|
||
}
|
||
} else {
|
||
break
|
||
}
|
||
}
|
||
|
||
guard linesToRemove > 0 else { return proposed }
|
||
|
||
let validLineCount = lineRanges.count - linesToRemove
|
||
guard validLineCount > 0 else { return proposed }
|
||
|
||
let lastValidLine = lineRanges[validLineCount - 1]
|
||
let endLocation = lastValidLine.location + lastValidLine.length
|
||
let adjustedLength = endLocation - proposed.location
|
||
guard adjustedLength > 0 else { return proposed }
|
||
return NSRange(location: proposed.location, length: adjustedLength)
|
||
}
|
||
|
||
/// 从最后行向前扫描,移除落在 keepWithNext 保护块内的尾部行。
|
||
func trimmedRangeForKeepWithNext(
|
||
proposed: NSRange,
|
||
lineRanges: [NSRange]
|
||
) -> NSRange {
|
||
guard !lineRanges.isEmpty else { return proposed }
|
||
|
||
let kMaxLinesToRemove = 3
|
||
var linesToRemove = 0
|
||
|
||
for lineRange in lineRanges.reversed() {
|
||
if pageBreakPolicy.lineIsInKeepWithNextBlock(lineRange) {
|
||
linesToRemove += 1
|
||
if linesToRemove >= kMaxLinesToRemove {
|
||
linesToRemove = kMaxLinesToRemove
|
||
break
|
||
}
|
||
} else {
|
||
break
|
||
}
|
||
}
|
||
|
||
guard linesToRemove > 0 else { return proposed }
|
||
|
||
let validLineCount = lineRanges.count - linesToRemove
|
||
guard validLineCount > 0 else { return proposed }
|
||
|
||
let lastValidLine = lineRanges[validLineCount - 1]
|
||
let endLocation = lastValidLine.location + lastValidLine.length
|
||
let adjustedLength = endLocation - proposed.location
|
||
guard adjustedLength > 0 else { return proposed }
|
||
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 中所有行的字符范围
|
||
static func lineRanges(from frame: CTFrame) -> [NSRange] {
|
||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||
return lines.map {
|
||
let lineRange = CTLineGetStringRange($0)
|
||
return NSRange(location: lineRange.location, length: lineRange.length)
|
||
}
|
||
}
|
||
|
||
#if canImport(DTCoreText)
|
||
/// 获取 DTCoreTextLayoutFrame 中所有行的字符范围
|
||
static func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {
|
||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else {
|
||
return []
|
||
}
|
||
return lines.map { $0.stringRange() }
|
||
}
|
||
#endif
|
||
|
||
// MARK: - 属性查询
|
||
|
||
/// 获取指定位置的块级元素范围
|
||
func blockRange(at location: Int) -> NSRange? {
|
||
guard location >= 0, location < attributedString.length else { return nil }
|
||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||
if let encodedRange = attributes[.rdPageBlockRange] as? String {
|
||
return NSRangeFromString(encodedRange)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
/// 获取指定位置的块级元素类型
|
||
func blockKind(at location: Int) -> RDEPUBTextBlockKind? {
|
||
guard location >= 0, location < attributedString.length else { return nil }
|
||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||
guard let rawValue = attributes[.rdPageBlockKind] as? String else { return nil }
|
||
return RDEPUBTextBlockKind(rawValue: rawValue)
|
||
}
|
||
|
||
/// 获取指定位置的附件布局方式
|
||
func attachmentPlacement(at location: Int) -> RDEPUBTextAttachmentPlacement? {
|
||
guard location >= 0, location < attributedString.length else { return nil }
|
||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||
guard let rawValue = attributes[.rdPageAttachmentPlacement] as? String else { return nil }
|
||
return RDEPUBTextAttachmentPlacement(rawValue: rawValue)
|
||
}
|
||
|
||
/// 获取包含指定位置的段落范围
|
||
func paragraphRange(containing location: Int) -> NSRange {
|
||
let source = attributedString.string as NSString
|
||
guard source.length > 0 else { return NSRange(location: 0, length: 0) }
|
||
let safeLocation = min(max(location, 0), max(source.length - 1, 0))
|
||
return source.paragraphRange(for: NSRange(location: safeLocation, length: 0))
|
||
}
|
||
|
||
/// 获取指定范围内的所有附件字符范围
|
||
func attachmentRanges(in range: NSRange) -> [NSRange] {
|
||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||
return []
|
||
}
|
||
var results: [NSRange] = []
|
||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, attributeRange, _ in
|
||
guard value != nil else { return }
|
||
results.append(attributeRange)
|
||
}
|
||
return results
|
||
}
|
||
|
||
/// 获取指定位置的语义提示列表
|
||
func semanticHints(at location: Int) -> [RDEPUBTextSemanticHint] {
|
||
guard location >= 0, location < attributedString.length else { return [] }
|
||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||
guard let rawValue = attributes[.rdPageSemanticHints] as? String else { return [] }
|
||
return rawValue
|
||
.split(separator: ",")
|
||
.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 {
|
||
return []
|
||
}
|
||
var kinds: [RDEPUBTextAttachmentKind] = []
|
||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, _, _ in
|
||
guard let rawValue = value as? String,
|
||
let kind = RDEPUBTextAttachmentKind(rawValue: rawValue),
|
||
!kinds.contains(kind) else {
|
||
return
|
||
}
|
||
kinds.append(kind)
|
||
}
|
||
return kinds
|
||
}
|
||
|
||
/// 获取指定范围内的块级元素类型列表(去重)
|
||
func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] {
|
||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||
return []
|
||
}
|
||
var kinds: [RDEPUBTextBlockKind] = []
|
||
attributedString.enumerateAttribute(.rdPageBlockKind, in: safeRange) { value, _, _ in
|
||
guard let rawValue = value as? String,
|
||
let kind = RDEPUBTextBlockKind(rawValue: rawValue),
|
||
!kinds.contains(kind) else {
|
||
return
|
||
}
|
||
kinds.append(kind)
|
||
}
|
||
return kinds
|
||
}
|
||
|
||
/// 获取指定范围内的语义提示列表(去重)
|
||
func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] {
|
||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||
return []
|
||
}
|
||
var hints: [RDEPUBTextSemanticHint] = []
|
||
attributedString.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { value, _, _ in
|
||
guard let rawValue = value as? String else { return }
|
||
for hint in rawValue.split(separator: ",").compactMap({ RDEPUBTextSemanticHint(rawValue: String($0)) }) where !hints.contains(hint) {
|
||
hints.append(hint)
|
||
}
|
||
}
|
||
return hints
|
||
}
|
||
|
||
/// 获取指定范围内的附件布局方式列表(去重)
|
||
func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] {
|
||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||
return []
|
||
}
|
||
var placements: [RDEPUBTextAttachmentPlacement] = []
|
||
attributedString.enumerateAttribute(.rdPageAttachmentPlacement, in: safeRange) { value, _, _ in
|
||
guard let rawValue = value as? String,
|
||
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue),
|
||
!placements.contains(placement) else {
|
||
return
|
||
}
|
||
placements.append(placement)
|
||
}
|
||
return placements
|
||
}
|
||
|
||
/// 将范围裁剪到 attributedString 的合法边界内。
|
||
func clampedRange(_ range: NSRange) -> NSRange? {
|
||
guard range.location >= 0, range.length >= 0 else { return nil }
|
||
guard attributedString.length > 0 else {
|
||
return range.location == 0 ? NSRange(location: 0, length: 0) : nil
|
||
}
|
||
guard range.location < attributedString.length else { return nil }
|
||
|
||
let maxLength = attributedString.length - range.location
|
||
return NSRange(location: range.location, length: min(range.length, maxLength))
|
||
}
|
||
|
||
/// 查找指定位置之前最近的 fragment ID(用于阅读位置恢复)
|
||
func nearestTrailingFragmentID(
|
||
endingAt location: Int,
|
||
fragmentOffsets: [String: Int]
|
||
) -> String? {
|
||
fragmentOffsets
|
||
.filter { $0.value <= location }
|
||
.max { lhs, rhs in lhs.value < rhs.value }?
|
||
.key
|
||
}
|
||
|
||
// MARK: - 诊断日志
|
||
|
||
/// 生成分页诊断日志
|
||
func diagnostics(
|
||
reason: RDEPUBTextPageBreakReason,
|
||
range: NSRange,
|
||
attachmentRanges: [NSRange],
|
||
blockRange: NSRange?,
|
||
blockKinds: [RDEPUBTextBlockKind],
|
||
semanticHints: [RDEPUBTextSemanticHint],
|
||
attachmentPlacements: [RDEPUBTextAttachmentPlacement],
|
||
trigger: String? = nil
|
||
) -> [String] {
|
||
var items = ["page break: \(reason.rawValue)", "page range: \(NSStringFromRange(range))"]
|
||
if let blockRange {
|
||
items.append("block range: \(NSStringFromRange(blockRange))")
|
||
}
|
||
if !attachmentRanges.isEmpty {
|
||
items.append("attachment ranges: \(attachmentRanges.map(NSStringFromRange).joined(separator: ","))")
|
||
}
|
||
if !blockKinds.isEmpty {
|
||
items.append("block kinds: \(blockKinds.map(\.rawValue).joined(separator: ","))")
|
||
}
|
||
if !semanticHints.isEmpty {
|
||
items.append("semantic hints: \(semanticHints.map(\.rawValue).joined(separator: ","))")
|
||
}
|
||
if !attachmentPlacements.isEmpty {
|
||
items.append("attachment placements: \(attachmentPlacements.map(\.rawValue).joined(separator: ","))")
|
||
}
|
||
if let trigger, !trigger.isEmpty {
|
||
items.append("semantic trigger: \(trigger)")
|
||
}
|
||
return items
|
||
}
|
||
}
|