859 lines
36 KiB
Swift
859 lines
36 KiB
Swift
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: - 行级 avoidPageBreakInside(WXRead 方案)
|
||
|
||
/// 从 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
|
||
}
|
||
}
|