import CoreText import UIKit #if canImport(DTCoreText) import DTCoreText #endif struct RDEPUBChapterPageCounter { private let factory: RDEPUBCoreTextPageFrameFactory private let attributedString: NSAttributedString private let pageSize: CGSize private let config: RDEPUBTextLayoutConfig private let pageBreakPolicy: RDEPUBPageBreakPolicy private let framesetter: CTFramesetter 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 } 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 contentRect = config.contentRect(fallback: resolvedSize) guard contentRect.width > 0, contentRect.height > 0 else { return [] } while location < attributedString.length { let framePath = RDEPUBCoreTextPageFrameFactory.makeLayoutPath( pageSize: resolvedSize, config: config ) let frame = CTFramesetterCreateFrame( framesetter, CFRangeMake(location, 0), framePath, nil ) let proposedRange = proposedVisibleRange( from: frame, start: location, totalLength: attributedString.length ) guard proposedRange.length > 0 else { break } let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange) let keepWithNextAdjusted = factory.trimmedRangeForKeepWithNext(from: frame, proposed: avoidAdjusted) let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: frame) let widowOrphanAdjusted = factory.trimmedRangeForWidowAndOrphanControl( proposed: keepWithNextAdjusted, lineRanges: lineRanges ) let effectiveLineRanges = lineRangesWithinRange(lineRanges, range: widowOrphanAdjusted) let adjusted = pageBreakPolicy.adjustedRange( from: widowOrphanAdjusted, totalLength: attributedString.length, lineRanges: effectiveLineRanges, 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 } #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 let isDebug = ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") if isDebug { print("[PAGINATION-DEBUG] pageRect=\(pageRect) totalLength=\(attributedString.length)") } 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 widowOrphanAdjusted = factory.trimmedRangeForWidowAndOrphanControl( proposed: lineAdjusted, lineRanges: lineRanges ) if isDebug { let pageCount = frames.count + 1 let previewText = (attributedString.string as NSString).substring(with: NSRange(location: location, length: min(20, attributedString.length - location))) let avoidRemoved = proposedRange.length - avoidAdjusted.length let kwNextRemoved = avoidAdjusted.length - lineAdjusted.length let widowRemoved = lineAdjusted.length - widowOrphanAdjusted.length let totalRemoved = proposedRange.length - widowOrphanAdjusted.length print("[PAGINATION-DEBUG] page#\(pageCount) loc=\(location) proposed=\(proposedRange.length) avoid(-\(avoidRemoved)) kwNext(-\(kwNextRemoved)) widowOrphan(-\(widowRemoved)) total(-\(totalRemoved)) lastLine=\"\(previewText)\"") } let effectiveLineRanges = lineRangesWithinRange(lineRanges, range: widowOrphanAdjusted) let adjusted = pageBreakPolicy.adjustedRange( from: widowOrphanAdjusted, totalLength: attributedString.length, lineRanges: effectiveLineRanges, 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 private func proposedVisibleRange( from frame: CTFrame, start location: Int, totalLength: Int ) -> NSRange { let visibleRange = CTFrameGetVisibleStringRange(frame) guard visibleRange.length > 0 else { return NSRange(location: location, length: 0) } let resolvedLocation = max(location, visibleRange.location) let visibleEnd = visibleRange.location + visibleRange.length let length = min(max(visibleEnd - resolvedLocation, 0), totalLength - resolvedLocation) guard length > 0 else { return NSRange(location: resolvedLocation, length: 0) } return NSRange(location: resolvedLocation, length: length) } private func lineRangesWithinRange(_ lineRanges: [NSRange], range: NSRange) -> [NSRange] { lineRanges.filter { lineRange in lineRange.location >= range.location && NSMaxRange(lineRange) <= NSMaxRange(range) } } }