import CoreText import UIKit #if canImport(DTCoreText) import DTCoreText #endif /// 章节分页计数器:顶层编排循环,将富文本按页面尺寸拆分为多帧。 /// /// 分页策略优先级(从高到低): /// 1. avoidPageBreakInside — 不在保护块内分页(WXRead 行级回退扫描) /// 2. keepWithNext — 标题等元素需与下一段同页 /// 3. 语义边界 — pageBreakBefore/After 等显式分页标记 /// 4. pageRelate — 微信读书式的跨页关联元素 /// 5. 附件边界 — 块级附件应整体移到下一页 /// 6. 帧限制 — 默认按 CoreText 可视范围分页 struct RDEPUBChapterPageCounter { private let factory: RDEPUBCoreTextPageFrameFactory private let attributedString: NSAttributedString private let pageSize: CGSize private let config: RDEPUBTextLayoutConfig private let pageBreakPolicy: RDEPUBPageBreakPolicy /// CoreText 帧设置器 private let framesetter: CTFramesetter /// DTCoreText 路径可直接消费的单矩形布局区域 private let dtLayoutRect: CGRect init(factory: RDEPUBCoreTextPageFrameFactory) { self.factory = factory self.attributedString = factory.attributedString self.pageSize = factory.pageSize self.config = factory.config self.pageBreakPolicy = RDEPUBPageBreakPolicy(attributedString: factory.attributedString) self.framesetter = CTFramesetterCreateWithAttributedString(factory.attributedString) self.dtLayoutRect = factory.config.contentRect(fallback: factory.pageSize) } /// 执行分页,返回布局帧列表(每帧对应一页)。 func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] { guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else { return [] } #if canImport(DTCoreText) return layoutFramesUsingDTCoreText(fragmentOffsets: fragmentOffsets) #else return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets) #endif } // MARK: - CoreText 分页路径(回退方案) private func layoutFramesUsingCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] { guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else { return [] } var frames: [RDEPUBTextLayoutFrame] = [] var location = 0 let resolvedSize = config.resolvedFrameSize(fallback: pageSize) let 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() 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 } let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange) let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: frame) let adjusted = pageBreakPolicy.adjustedRange( from: avoidAdjusted, totalLength: attributedString.length, lineRanges: lineRanges, factory: factory ) let trailingFragmentID = factory.nearestTrailingFragmentID( endingAt: adjusted.range.location + adjusted.range.length, fragmentOffsets: fragmentOffsets ) frames.append( RDEPUBTextLayoutFrame( contentRange: adjusted.range, breakReason: adjusted.breakReason, blockRange: adjusted.blockRange, attachmentRanges: adjusted.attachmentRanges, attachmentKinds: adjusted.attachmentKinds, blockKinds: adjusted.blockKinds, semanticHints: adjusted.semanticHints, attachmentPlacements: adjusted.attachmentPlacements, trailingFragmentID: trailingFragmentID, diagnostics: adjusted.diagnostics ) ) let nextLocation = adjusted.range.location + adjusted.range.length guard nextLocation > location else { location += max(proposedRange.length, 1) continue } location = nextLocation } return frames } // MARK: - DTCoreText 分页路径(首选方案) #if canImport(DTCoreText) private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] { guard config.numberOfColumns == 1 else { return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets) } guard let layouter = DTCoreTextLayouter(attributedString: attributedString) else { return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets) } layouter.shouldCacheLayoutFrames = false var frames: [RDEPUBTextLayoutFrame] = [] var location = 0 let pageRect = dtLayoutRect while location < attributedString.length { guard let layoutFrame = layouter.layoutFrame(with: pageRect, range: NSRange(location: location, length: 0)) else { break } let visibleRange = layoutFrame.visibleStringRange() guard visibleRange.length > 0 else { break } let proposedRange = NSRange(location: location, length: visibleRange.length) let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: layoutFrame, proposed: proposedRange) let lineAdjusted = factory.trimmedRangeForKeepWithNext(from: layoutFrame, proposed: avoidAdjusted) let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: layoutFrame) let adjusted = pageBreakPolicy.adjustedRange( from: lineAdjusted, totalLength: attributedString.length, lineRanges: lineRanges, factory: factory ) let verifiedRange: NSRange if adjusted.breakReason == .attachmentBoundary { verifiedRange = verifiedDisplayRange(for: adjusted.range) } else { verifiedRange = adjusted.range } let trailingFragmentID = factory.nearestTrailingFragmentID( endingAt: verifiedRange.location + verifiedRange.length, fragmentOffsets: fragmentOffsets ) let diagnostics = verifiedRange == adjusted.range ? adjusted.diagnostics : adjusted.diagnostics + ["verified-display-range \(NSStringFromRange(adjusted.range)) -> \(NSStringFromRange(verifiedRange))"] frames.append( RDEPUBTextLayoutFrame( contentRange: verifiedRange, breakReason: adjusted.breakReason, blockRange: factory.blockRange(at: max(verifiedRange.location, verifiedRange.location + verifiedRange.length - 1)), attachmentRanges: factory.attachmentRanges(in: verifiedRange), attachmentKinds: factory.attachmentKinds(in: verifiedRange), blockKinds: factory.blockKinds(in: verifiedRange), semanticHints: factory.semanticHints(in: verifiedRange), attachmentPlacements: factory.attachmentPlacements(in: verifiedRange), trailingFragmentID: trailingFragmentID, diagnostics: diagnostics ) ) let nextLocation = verifiedRange.location + verifiedRange.length guard nextLocation > location else { location += max(visibleRange.length, 1) continue } location = nextLocation } return frames } private func verifiedDisplayRange(for range: NSRange) -> NSRange { guard let clampedRange = factory.clampedRange(range), clampedRange.length > 0, !factory.attachmentRanges(in: clampedRange).isEmpty else { return range } let pageContent = attributedString.attributedSubstring(from: clampedRange) guard let layouter = DTCoreTextLayouter(attributedString: pageContent) else { return clampedRange } layouter.shouldCacheLayoutFrames = false guard let layoutFrame = layouter.layoutFrame(with: dtLayoutRect, range: NSRange(location: 0, length: 0)) else { return clampedRange } let visibleRange = layoutFrame.visibleStringRange() guard visibleRange.length > 0, visibleRange.length < clampedRange.length else { return clampedRange } return NSRange(location: clampedRange.location, length: visibleRange.length) } #endif // MARK: - WXRead 分页对齐 /// 逐句对齐 WXRead `WRChapterPageCount.recalculatePageRangesForAttributedString` 的分页循环。 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)) } }