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 let resolvedSize = config.resolvedFrameSize(fallback: pageSize) let usableWidth = resolvedSize.width - config.edgeInsets.left - config.edgeInsets.right let usableHeight = resolvedSize.height - config.edgeInsets.top - config.edgeInsets.bottom guard usableWidth > 0, usableHeight > 0 else { return [] } while location < attributedString.length { let framePath = CGMutablePath() // 对齐 WXRead WRChapterPageCount: // CoreText 分页路径使用 bottom inset 作为 y 原点,而不是 UIKit 语义的 top inset。 let pageRect = CGRect( x: config.edgeInsets.left, y: config.edgeInsets.bottom, width: usableWidth, height: usableHeight ) framePath.addRect(pageRect) let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(location, 0), framePath, nil) let proposedRange = proposedRangeUsingWXReadPageCount( from: frame, start: location, usableHeight: usableHeight, totalLength: attributedString.length ) guard proposedRange.length > 0 else { break } // 行级 avoidPageBreakInside 处理(WXRead 方案:从最后一行向前扫描) let avoidAdjusted = trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange) let lineRanges = lineRanges(from: frame) let adjusted = adjustedRange( from: avoidAdjusted, 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(proposedRange.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: NSRange if adjusted.breakReason == .attachmentBoundary { verifiedRange = verifiedDisplayRange(for: adjusted.range) } else { verifiedRange = 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 let clampedRange = clampedRange(range), clampedRange.length > 0, !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 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 ) ) } 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 // 对齐 WXRead:默认按 CTFrame 已经容纳的行数分页,仅保留 pageRelate 这种 // 微信读书特有的跨页关联规则;其他 keepWithNext/attachmentBoundary/通用语义边界 // 先不参与截断,避免提前裁短页尾内容。 if let pageRelateBoundary = preferredPageRelateBoundary( after: proposedRange, minimumEnd: proposedRange.location + 1, 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 ) ) } // 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)? { guard let safeRange = clampedRange(range), safeRange.length > 0 else { return nil } var boundary: (location: Int, trigger: String)? attributedString.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { 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 > safeRange.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 < safeRange.location + safeRange.length { boundary = (attributeEnd, RDEPUBTextSemanticHint.pageBreakAfter.rawValue) stop.pointee = true return } } return boundary } /// 查找附件边界:只有块级附件(.attachment 或 .centered)才触发分页, /// 行内脚注图标等不应导致整段移动。 private func preferredAttachmentBoundary(in range: NSRange, minimumEnd: Int) -> Int? { guard let safeRange = clampedRange(range), safeRange.length > 0 else { return nil } var boundary: Int? attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, attributeRange, stop in guard value != nil else { return } let location = attributeRange.location let placement = attachmentPlacement(at: location) let blockKind = blockKind(at: location) // 对标 WXRead:只有块级附件才应将整个块推到下一页。 // 行内脚注图标等行内附件不应导致整段移动。 // 只有真正的块级附件才需要整块挪页。 // 行内/基线对齐的附件(例如脚注 note.png)不能触发附件边界分页。 let isBlockLevelAttachment: Bool switch placement { case .centered: isBlockLevelAttachment = true case .inline, .baseline: isBlockLevelAttachment = false case nil: isBlockLevelAttachment = blockKind == .attachment } guard isBlockLevelAttachment else { return } let boundaryRange = blockRange(at: location) ?? paragraphRange(containing: location) if boundaryRange.location > safeRange.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] { 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 } /// 获取指定位置的语义提示列表 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] { 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 } /// 获取指定范围内的块级元素类型列表(去重) private 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 } /// 获取指定范围内的语义提示列表(去重) private 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 } /// 获取指定范围内的附件布局方式列表(去重) private 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 的合法边界内,避免属性枚举与子串提取越界。 private 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)) } private func clampedProbeRange(for lineRange: NSRange) -> NSRange? { clampedRange(NSRange(location: lineRange.location, length: max(lineRange.length, 1))) } /// 查找指定位置之前最近的 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 { guard let probeRange = clampedProbeRange(for: lineRange) else { return false } var found = false attributedString.enumerateAttributes(in: probeRange) { attributes, _, stop in guard shouldTreatAvoidHintAsBlockProtection(attributes) else { return } guard let rawValue = attributes[.rdPageSemanticHints] 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 } /// 只有真正的块级内容才应触发 avoidPageBreakInside。 /// 像脚注 note.png 这类行内图片会被标记为 `img`,但不该把整行推到下一页。 private func shouldTreatAvoidHintAsBlockProtection(_ attributes: [NSAttributedString.Key: Any]) -> Bool { guard let rawValue = attributes[.rdPageSemanticHints] as? String else { return false } let hints = rawValue .split(separator: ",") .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) } guard hints.contains(.avoidPageBreakInside) else { return false } let placement = (attributes[.rdPageAttachmentPlacement] as? String) .flatMap(RDEPUBTextAttachmentPlacement.init(rawValue:)) let blockKind = (attributes[.rdPageBlockKind] as? String) .flatMap(RDEPUBTextBlockKind.init(rawValue:)) if blockKind == .attachment, placement != .centered { return false } return true } /// 检查某行是否落在 keepWithNext 保护块内 private func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool { guard let probeRange = clampedProbeRange(for: lineRange) else { return false } var found = false 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) } } /// 逐句对齐 WXRead `WRChapterPageCount.recalculatePageRangesForAttributedString` 的分页循环: /// 1. 取出 CTFrame 当前页的所有行 /// 2. 读取每行 origin /// 3. 用 `lineY - ascent > usableHeight` 判断是否越过可用高度 /// 4. 累计能容纳行的字符数得到本页范围 private func proposedRangeUsingWXReadPageCount( from frame: CTFrame, start location: Int, usableHeight: CGFloat, totalLength: Int ) -> NSRange { let lines = CTFrameGetLines(frame) as! [CTLine] guard !lines.isEmpty else { return NSRange(location: location, length: 0) } var origins = [CGPoint](repeating: .zero, count: lines.count) CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins) var pageCharCount = 0 for (index, line) in lines.enumerated() { let lineRange = CTLineGetStringRange(line) let lineY = origins[index].y var ascent: CGFloat = 0 var descent: CGFloat = 0 CTLineGetTypographicBounds(line, &ascent, &descent, nil) if lineY - ascent > usableHeight { break } pageCharCount += lineRange.length } if pageCharCount == 0 { pageCharCount = 1 } return NSRange(location: location, length: min(pageCharCount, totalLength - location)) } #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 } }