Files
ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift
T
2026-05-26 21:08:27 +08:00

859 lines
36 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
/// CoreText 分页引擎:将富文本按页面尺寸拆分为多帧(每帧对应一页)。
///
/// 分页策略优先级(从高到低):
/// 1. avoidPageBreakInside — 不在保护块内分页(对标 WXRead 的行级回退扫描)
/// 2. keepWithNext — 标题等元素需与下一段同页
/// 3. 语义边界 — pageBreakBefore/After 等显式分页标记
/// 4. pageRelate — 微信读书式的跨页关联元素
/// 5. 附件边界 — 块级附件应整体移到下一页
/// 6. 帧限制 — 默认按 CoreText 可视范围分页
///
/// 支持两条渲染路径:
/// - DTCoreText 路径(首选):DTCoreTextLayouter → DTCoreTextLayoutFrame
/// - CoreText 回退路径:CTFramesetterCreateFrame
struct RDEPUBTextLayouter {
/// 待分页的富文本
private let attributedString: NSAttributedString
/// 页面尺寸(决定每帧能容纳多少内容)
private let pageSize: CGSize
/// CoreText 帧设置器
private let framesetter: CTFramesetter
/// 页面矩形路径(用于 CTFrame 排版)
private let path: CGPath
/// DTCoreText 路径可直接消费的单矩形布局区域
private let dtLayoutRect: CGRect
/// 布局配置(avoidPageBreakInside、孤行控制等)
private let config: RDEPUBTextLayoutConfig
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
self.attributedString = attributedString
self.pageSize = pageSize
self.config = config
self.framesetter = CTFramesetterCreateWithAttributedString(attributedString)
self.dtLayoutRect = config.contentRect(fallback: pageSize)
self.path = Self.makeLayoutPath(pageSize: pageSize, config: config)
}
/// 执行分页,返回布局帧列表(每帧对应一页)。
/// 根据编译环境选择 DTCoreText 或 CoreText 路径。
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 分页路径(回退方案)
/// 使用原生 CoreText API 进行分页。
///
/// 流程:CTFramesetterCreateFrame → 获取可视范围 → 语义边界调整 → 记录帧
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
while location < attributedString.length {
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(location, 0), path, nil)
let visibleRange = CTFrameGetVisibleStringRange(frame)
guard visibleRange.length > 0 else {
break
}
let proposedRange = NSRange(location: location, length: visibleRange.length)
// 行级 avoidPageBreakInside 处理(WXRead 方案:从最后一行向前扫描)
let avoidAdjusted = trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
let lineAdjusted = trimmedRangeForKeepWithNext(from: frame, proposed: avoidAdjusted)
let lineRanges = lineRanges(from: frame)
let adjusted = adjustedRange(
from: lineAdjusted,
totalLength: attributedString.length,
lineRanges: lineRanges
)
let trailingFragmentID = 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(visibleRange.length, 1)
continue
}
location = nextLocation
}
return frames
}
// MARK: - DTCoreText 分页路径(首选方案)
#if canImport(DTCoreText)
/// 使用 DTCoreTextLayouter 进行分页,提供更精确的行级语义处理。
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 = trimmedRangeForAvoidPageBreakInside(from: layoutFrame, proposed: proposedRange)
let lineAdjusted = trimmedRangeForKeepWithNext(from: layoutFrame, proposed: avoidAdjusted)
let lineRanges = lineRanges(from: layoutFrame)
let adjusted = adjustedRange(
from: lineAdjusted,
totalLength: attributedString.length,
lineRanges: lineRanges
)
let verifiedRange = verifiedDisplayRange(for: adjusted.range)
let trailingFragmentID = 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: blockRange(at: max(verifiedRange.location, verifiedRange.location + verifiedRange.length - 1)),
attachmentRanges: attachmentRanges(in: verifiedRange),
attachmentKinds: attachmentKinds(in: verifiedRange),
blockKinds: blockKinds(in: verifiedRange),
semanticHints: semanticHints(in: verifiedRange),
attachmentPlacements: 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 range.length > 0,
!attachmentRanges(in: range).isEmpty else {
return range
}
let pageContent = attributedString.attributedSubstring(from: range)
guard let layouter = DTCoreTextLayouter(attributedString: pageContent) else {
return range
}
layouter.shouldCacheLayoutFrames = false
guard let layoutFrame = layouter.layoutFrame(with: dtLayoutRect, range: NSRange(location: 0, length: 0)) else {
return range
}
let visibleRange = layoutFrame.visibleStringRange()
guard visibleRange.length > 0, visibleRange.length < range.length else {
return range
}
return NSRange(location: range.location, length: visibleRange.length)
}
#endif
private 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: - 语义边界调整
/// 对 CoreText 提出的分页范围进行语义边界调整。
///
/// 调整优先级:
/// 1. 若已达章节末尾,直接返回 chapterEnd
/// 2. 优先在语义边界(pageBreakBefore/After)分页
/// 3. 其次在 pageRelate 跨页关联点分页
/// 4. 再次在附件边界分页(块级附件需整体移动)
/// 5. 以上都不满足时,使用原始帧限制分页
///
/// 最小分页长度约束:不低于原始范围的 55%,避免单页内容过少。
private func adjustedRange(
from proposedRange: NSRange,
totalLength: Int,
lineRanges: [NSRange]
) -> (
range: NSRange,
breakReason: RDEPUBTextPageBreakReason,
blockRange: NSRange?,
attachmentRanges: [NSRange],
attachmentKinds: [RDEPUBTextAttachmentKind],
blockKinds: [RDEPUBTextBlockKind],
semanticHints: [RDEPUBTextSemanticHint],
attachmentPlacements: [RDEPUBTextAttachmentPlacement],
diagnostics: [String]
) {
let pageEnd = proposedRange.location + proposedRange.length
let proposedBlockKinds = blockKinds(in: proposedRange)
let proposedSemanticHints = semanticHints(in: proposedRange)
let proposedAttachmentPlacements = attachmentPlacements(in: proposedRange)
guard pageEnd < totalLength else {
return (
range: proposedRange,
breakReason: .chapterEnd,
blockRange: blockRange(at: max(proposedRange.location, pageEnd - 1)),
attachmentRanges: attachmentRanges(in: proposedRange),
attachmentKinds: attachmentKinds(in: proposedRange),
blockKinds: proposedBlockKinds,
semanticHints: proposedSemanticHints,
attachmentPlacements: proposedAttachmentPlacements,
diagnostics: diagnostics(
reason: .chapterEnd,
range: proposedRange,
attachmentRanges: attachmentRanges(in: proposedRange),
blockRange: blockRange(at: max(proposedRange.location, pageEnd - 1)),
blockKinds: proposedBlockKinds,
semanticHints: proposedSemanticHints,
attachmentPlacements: proposedAttachmentPlacements
)
)
}
// 最小分页长度:原始范围的 55%
let minLength = max(Int(Double(proposedRange.length) * 0.55), 1)
let minimumEnd = proposedRange.location + minLength
let currentBlockRange = blockRange(at: max(proposedRange.location, pageEnd - 1))
let currentAttachmentRanges = attachmentRanges(in: proposedRange)
let currentAttachmentKinds = attachmentKinds(in: proposedRange)
let currentBlockKinds = proposedBlockKinds
let currentSemanticHints = proposedSemanticHints
let currentAttachmentPlacements = proposedAttachmentPlacements
// 1. 语义边界(pageBreakBefore/After
if let semanticBoundary = preferredSemanticBoundary(
in: proposedRange,
minimumEnd: minimumEnd
) {
let adjustedRange = NSRange(location: proposedRange.location, length: semanticBoundary.location - proposedRange.location)
return (
range: adjustedRange,
breakReason: .semanticBoundary,
blockRange: currentBlockRange,
attachmentRanges: currentAttachmentRanges,
attachmentKinds: currentAttachmentKinds,
blockKinds: currentBlockKinds,
semanticHints: currentSemanticHints,
attachmentPlacements: currentAttachmentPlacements,
diagnostics: diagnostics(
reason: .semanticBoundary,
range: adjustedRange,
attachmentRanges: currentAttachmentRanges,
blockRange: currentBlockRange,
blockKinds: currentBlockKinds,
semanticHints: currentSemanticHints,
attachmentPlacements: currentAttachmentPlacements,
trigger: semanticBoundary.trigger
)
)
}
// 2. pageRelate 跨页关联边界
if let pageRelateBoundary = preferredPageRelateBoundary(
after: proposedRange,
minimumEnd: minimumEnd,
lineRanges: lineRanges
) {
let adjustedRange = NSRange(location: proposedRange.location, length: pageRelateBoundary - proposedRange.location)
return (
range: adjustedRange,
breakReason: .semanticBoundary,
blockRange: currentBlockRange,
attachmentRanges: currentAttachmentRanges,
attachmentKinds: currentAttachmentKinds,
blockKinds: currentBlockKinds,
semanticHints: currentSemanticHints,
attachmentPlacements: currentAttachmentPlacements,
diagnostics: diagnostics(
reason: .semanticBoundary,
range: adjustedRange,
attachmentRanges: currentAttachmentRanges,
blockRange: currentBlockRange,
blockKinds: currentBlockKinds,
semanticHints: currentSemanticHints,
attachmentPlacements: currentAttachmentPlacements,
trigger: RDEPUBTextSemanticHint.pageRelate.rawValue
)
)
}
// 3. 附件边界(块级附件整体移动)
if let attachmentBoundary = preferredAttachmentBoundary(
in: proposedRange,
minimumEnd: minimumEnd
) {
let adjustedRange = NSRange(location: proposedRange.location, length: attachmentBoundary - proposedRange.location)
return (
range: adjustedRange,
breakReason: .attachmentBoundary,
blockRange: currentBlockRange,
attachmentRanges: currentAttachmentRanges,
attachmentKinds: currentAttachmentKinds,
blockKinds: currentBlockKinds,
semanticHints: currentSemanticHints,
attachmentPlacements: currentAttachmentPlacements,
diagnostics: diagnostics(
reason: .attachmentBoundary,
range: adjustedRange,
attachmentRanges: currentAttachmentRanges,
blockRange: currentBlockRange,
blockKinds: currentBlockKinds,
semanticHints: currentSemanticHints,
attachmentPlacements: currentAttachmentPlacements
)
)
}
// 4. 帧限制(默认分页)
return (
range: proposedRange,
breakReason: .frameLimit,
blockRange: currentBlockRange,
attachmentRanges: currentAttachmentRanges,
attachmentKinds: currentAttachmentKinds,
blockKinds: currentBlockKinds,
semanticHints: currentSemanticHints,
attachmentPlacements: currentAttachmentPlacements,
diagnostics: diagnostics(
reason: .frameLimit,
range: proposedRange,
attachmentRanges: currentAttachmentRanges,
blockRange: currentBlockRange,
blockKinds: currentBlockKinds,
semanticHints: currentSemanticHints,
attachmentPlacements: currentAttachmentPlacements
)
)
}
// MARK: - 语义边界查找
/// 在指定范围内查找最优的语义分页点(pageBreakBefore / pageBreakAfter)。
///
/// 只有当边界位置超过 minimumEnd(最小分页长度约束)时才有效。
private func preferredSemanticBoundary(
in range: NSRange,
minimumEnd: Int
) -> (location: Int, trigger: String)? {
var boundary: (location: Int, trigger: String)?
attributedString.enumerateAttribute(.rdPageSemanticHints, in: range) { value, attributeRange, stop in
guard let rawValue = value as? String else { return }
let hints = rawValue
.split(separator: ",")
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
guard !hints.isEmpty else { return }
if hints.contains(.pageBreakBefore),
attributeRange.location > range.location,
attributeRange.location >= minimumEnd {
boundary = (attributeRange.location, RDEPUBTextSemanticHint.pageBreakBefore.rawValue)
stop.pointee = true
return
}
let attributeEnd = attributeRange.location + attributeRange.length
if hints.contains(.pageBreakAfter),
attributeEnd > minimumEnd,
attributeEnd < range.location + range.length {
boundary = (attributeEnd, RDEPUBTextSemanticHint.pageBreakAfter.rawValue)
stop.pointee = true
return
}
}
return boundary
}
/// 查找附件边界:只有块级附件(.attachment 或 .centered)才触发分页,
/// 行内脚注图标等不应导致整段移动。
private func preferredAttachmentBoundary(in range: NSRange, minimumEnd: Int) -> Int? {
var boundary: Int?
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, stop in
guard value != nil else { return }
let location = attributeRange.location
let placement = attachmentPlacement(at: location)
let blockKind = blockKind(at: location)
// 对标 WXRead:只有块级附件才应将整个块推到下一页。
// 行内脚注图标等行内附件不应导致整段移动。
let isBlockLevelAttachment = blockKind == .attachment || placement == .centered
guard isBlockLevelAttachment else { return }
let boundaryRange = blockRange(at: location) ?? paragraphRange(containing: location)
if boundaryRange.location > range.location, boundaryRange.location >= minimumEnd {
boundary = boundaryRange.location
stop.pointee = true
}
}
return boundary
}
/// 查找 pageRelate 跨页关联边界:当下一页起始是一个 pageRelate 块且
/// 该块恰好是当前页最后一行时,将最后一行移到下一页。
private func preferredPageRelateBoundary(
after range: NSRange,
minimumEnd: Int,
lineRanges: [NSRange]
) -> Int? {
let pageStartOfNext = range.location + range.length
guard pageStartOfNext > range.location,
pageStartOfNext < attributedString.length,
lineRanges.count >= 2,
semanticHints(at: pageStartOfNext).contains(.pageRelate) else {
return nil
}
let boundaryBlockStart = blockRange(at: pageStartOfNext)?.location ?? paragraphRange(containing: pageStartOfNext).location
guard boundaryBlockStart == pageStartOfNext else { return nil }
let lastLineStart = lineRanges[lineRanges.count - 1].location
guard lastLineStart > range.location, lastLineStart >= minimumEnd else {
return nil
}
return lastLineStart
}
// MARK: - 属性查询工具
/// 获取指定位置的块级元素范围
private 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
}
/// 获取指定位置的块级元素类型
private 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)
}
/// 获取指定位置的附件布局方式
private 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)
}
/// 获取包含指定位置的段落范围
private 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))
}
/// 获取指定范围内的所有附件字符范围
private func attachmentRanges(in range: NSRange) -> [NSRange] {
var results: [NSRange] = []
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, _ in
guard value != nil else { return }
results.append(attributeRange)
}
return results
}
/// 获取指定位置的语义提示列表
private 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)) }
}
/// 获取指定范围内的附件类型列表(去重)
private func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
var kinds: [RDEPUBTextAttachmentKind] = []
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, _, _ in
guard let rawValue = value as? String,
let kind = RDEPUBTextAttachmentKind(rawValue: rawValue),
!kinds.contains(kind) else {
return
}
kinds.append(kind)
}
return kinds
}
/// 获取指定范围内的块级元素类型列表(去重)
private func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] {
var kinds: [RDEPUBTextBlockKind] = []
attributedString.enumerateAttribute(.rdPageBlockKind, in: range) { value, _, _ in
guard let rawValue = value as? String,
let kind = RDEPUBTextBlockKind(rawValue: rawValue),
!kinds.contains(kind) else {
return
}
kinds.append(kind)
}
return kinds
}
/// 获取指定范围内的语义提示列表(去重)
private func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] {
var hints: [RDEPUBTextSemanticHint] = []
attributedString.enumerateAttribute(.rdPageSemanticHints, in: range) { 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
}
/// 获取指定范围内的附件布局方式列表(去重)
private func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] {
var placements: [RDEPUBTextAttachmentPlacement] = []
attributedString.enumerateAttribute(.rdPageAttachmentPlacement, in: range) { value, _, _ in
guard let rawValue = value as? String,
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue),
!placements.contains(placement) else {
return
}
placements.append(placement)
}
return placements
}
/// 查找指定位置之前最近的 fragment ID(用于阅读位置恢复)
private func nearestTrailingFragmentID(
endingAt location: Int,
fragmentOffsets: [String: Int]
) -> String? {
fragmentOffsets
.filter { $0.value <= location }
.max { lhs, rhs in lhs.value < rhs.value }?
.key
}
// MARK: - 行级 avoidPageBreakInsideWXRead 方案)
/// 从 CTFrame 最后一行向前扫描,移除落在 avoidPageBreakInside 保护块内的尾部行。
/// 对标 WXRead 的 WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded。
/// 最多移除 3 行(kMaxLinesToRemove),避免因保护块过大导致整页内容被清空。
private 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 }
var origins = [CGPoint](repeating: .zero, count: lines.count)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
// 从最后一行向前扫描,统计落在保护块内的连续尾部行数
// kMaxLinesToRemove = 3(与 WXRead 一致)
let kMaxLinesToRemove = 3
var linesToRemove = 0
for i in stride(from: lines.count - 1, through: 0, by: -1) {
let lineRange = CTLineGetStringRange(lines[i])
let lineNSRange = NSRange(location: lineRange.location, length: lineRange.length)
if lineIsInAvoidPageBreakInsideBlock(lineNSRange) {
linesToRemove += 1
if linesToRemove >= kMaxLinesToRemove {
// 不超过 kMaxLinesToRemove 行,停在这里
linesToRemove = kMaxLinesToRemove
break
}
} else {
break
}
}
guard linesToRemove > 0 else { return proposed }
let validLineCount = lines.count - linesToRemove
guard validLineCount > 0 else {
// 所有行都在保护块内,回退到原始范围
return proposed
}
let lastValidLine = lines[validLineCount - 1]
let lastLineRange = CTLineGetStringRange(lastValidLine)
let endLocation = lastLineRange.location + lastLineRange.length
let adjustedLength = endLocation - proposed.location
guard adjustedLength > 0 else { return proposed }
return NSRange(location: proposed.location, length: adjustedLength)
}
/// CoreText 路径的 keepWithNext 处理:从最后行向前扫描
private 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)
}
// MARK: - DTCoreText 行级处理
#if canImport(DTCoreText)
/// DTCoreText 路径的 avoidPageBreakInside 处理
private 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 kMaxLinesToRemove = 3
var linesToRemove = 0
for line in lines.reversed() {
let lineRange = line.stringRange()
if lineIsInAvoidPageBreakInsideBlock(lineRange) {
linesToRemove += 1
if linesToRemove >= kMaxLinesToRemove {
linesToRemove = kMaxLinesToRemove
break
}
} else {
break
}
}
guard linesToRemove > 0 else { return proposed }
let validLineCount = lines.count - linesToRemove
guard validLineCount > 0 else { return proposed }
let lastValidLine = lines[validLineCount - 1]
let lastLineRange = lastValidLine.stringRange()
let endLocation = lastLineRange.location + lastLineRange.length
let adjustedLength = endLocation - proposed.location
guard adjustedLength > 0 else { return proposed }
return NSRange(location: proposed.location, length: adjustedLength)
}
/// DTCoreText 路径的 keepWithNext 处理
private 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
/// 从最后行向前扫描,移除落在 keepWithNext 保护块内的尾部行。
/// 最多移除 3 行。
private 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 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)
}
/// 检查某行是否落在 avoidPageBreakInside 保护块内
private func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool {
var found = false
let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1))
attributedString.enumerateAttribute(.rdPageSemanticHints, in: probeRange) { value, _, stop in
guard let rawValue = value as? String else { return }
let hints = rawValue
.split(separator: ",")
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
if hints.contains(.avoidPageBreakInside) {
found = true
stop.pointee = true
}
}
return found
}
/// 检查某行是否落在 keepWithNext 保护块内
private func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool {
var found = false
let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1))
attributedString.enumerateAttribute(.rdPageSemanticHints, in: probeRange) { value, _, stop in
guard let rawValue = value as? String else { return }
let hints = rawValue
.split(separator: ",")
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
if hints.contains(.keepWithNext) {
found = true
stop.pointee = true
}
}
return found
}
/// 获取 CTFrame 中所有行的字符范围
private 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 中所有行的字符范围
private func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else {
return []
}
return lines.map { $0.stringRange() }
}
#endif
// MARK: - 诊断日志
/// 生成分页诊断日志,记录分页原因、范围和语义信息
private 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
}
}