import CoreText import UIKit #if canImport(DTCoreText) import DTCoreText #endif struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { let attributedString: NSAttributedString let pageSize: CGSize let config: RDEPUBTextLayoutConfig private let pageBreakPolicy: RDEPUBPageBreakPolicy init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) { self.attributedString = attributedString self.pageSize = pageSize self.config = config self.pageBreakPolicy = RDEPUBPageBreakPolicy(attributedString: attributedString) } init() { self.init(attributedString: NSAttributedString(), pageSize: .zero, config: .default) } func makeFrames( attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig, fragmentOffsets: [String: Int] ) -> [RDEPUBTextLayoutFrame] { let factory = RDEPUBCoreTextPageFrameFactory(attributedString: attributedString, pageSize: pageSize, config: config) let counter = RDEPUBChapterPageCounter(factory: factory) return counter.layoutFrames(fragmentOffsets: fragmentOffsets) } 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 } 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 } let lineRanges = lines.map { let range = CTLineGetStringRange($0) return NSRange(location: range.location, length: range.length) } return trimmedRangeForAvoidPageBreakInside(proposed: proposed, lineRanges: lineRanges) } 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) } #if canImport(DTCoreText) 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 lineRanges = lines.map { $0.stringRange() } return trimmedRangeForAvoidPageBreakInside(proposed: proposed, lineRanges: lineRanges) } 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 func trimmedRangeForAvoidPageBreakInside( proposed: NSRange, lineRanges: [NSRange] ) -> NSRange { guard config.avoidPageBreakInsideEnabled, !lineRanges.isEmpty else { return proposed } let kMaxLinesToRemove = 3 var linesToRemove = 0 for lineRange in lineRanges.reversed() { if pageBreakPolicy.lineIsInAvoidPageBreakInsideBlock(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 } if ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") { let removedText = (attributedString.string as NSString).substring(with: NSRange(location: endLocation, length: min(proposed.length - adjustedLength, 40))) print("[PAGINATION-DEBUG] avoidPageBreakInside removed \(linesToRemove) lines: \"\(removedText)\"") } return NSRange(location: proposed.location, length: adjustedLength) } 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 pageBreakPolicy.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 } if ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") { let removedText = (attributedString.string as NSString).substring(with: NSRange(location: endLocation, length: min(proposed.length - adjustedLength, 40))) print("[PAGINATION-DEBUG] keepWithNext removed \(linesToRemove) lines: \"\(removedText)\"") } return NSRange(location: proposed.location, length: adjustedLength) } func trimmedRangeForWidowAndOrphanControl( proposed: NSRange, lineRanges: [NSRange] ) -> NSRange { guard !lineRanges.isEmpty else { return proposed } guard config.avoidWidows || config.avoidOrphans else { return proposed } var adjusted = proposed var visibleLines = lineRanges if config.avoidWidows, let widowAdjusted = trimmedRangeAvoidingWidow( proposed: adjusted, lineRanges: visibleLines ), widowAdjusted != adjusted { adjusted = widowAdjusted visibleLines = Array(visibleLines.dropLast()) } if config.avoidOrphans, let orphanAdjusted = trimmedRangeAvoidingOrphan( proposed: adjusted, lineRanges: visibleLines ) { adjusted = orphanAdjusted } return adjusted } static 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) static func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] { guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else { return [] } return lines.map { $0.stringRange() } } #endif 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 } 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) } 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) } 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)) } 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 } 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 trimmedRangeAvoidingWidow( proposed: NSRange, lineRanges: [NSRange] ) -> NSRange? { guard lineRanges.count >= 2, let lastLine = lineRanges.last else { return nil } let paragraphRange = paragraphRange(containing: lastLine.location) let trailingLines = trailingLineCount(in: lineRanges, paragraphRange: paragraphRange) let paragraphContinuesOnNextPage = NSMaxRange(proposed) < NSMaxRange(paragraphRange) guard trailingLines == 1, paragraphContinuesOnNextPage else { return nil } let keptLine = lineRanges[lineRanges.count - 2] let adjustedLength = NSMaxRange(keptLine) - proposed.location guard adjustedLength > 0 else { return nil } if ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") { let removedText = (attributedString.string as NSString).substring(with: NSRange(location: NSMaxRange(keptLine), length: min(proposed.length - adjustedLength, 40))) print("[PAGINATION-DEBUG] widow control removed 1 line: \"\(removedText)\" paragraphRange=\(NSStringFromRange(paragraphRange))") } return NSRange(location: proposed.location, length: adjustedLength) } private func trimmedRangeAvoidingOrphan( proposed: NSRange, lineRanges: [NSRange] ) -> NSRange? { guard lineRanges.count >= 2, let lastLine = lineRanges.last else { return nil } let paragraphRange = paragraphRange(containing: lastLine.location) let pageEnd = NSMaxRange(proposed) let paragraphEnd = NSMaxRange(paragraphRange) guard pageEnd < paragraphEnd else { return nil } let remainingLineCount = estimatedRemainingLineCount( in: paragraphRange, startingAt: pageEnd ) let trailingLines = trailingLineCount(in: lineRanges, paragraphRange: paragraphRange) guard remainingLineCount == 1, trailingLines >= 2 else { return nil } let keptLine = lineRanges[lineRanges.count - 2] let adjustedLength = NSMaxRange(keptLine) - proposed.location guard adjustedLength > 0 else { return nil } return NSRange(location: proposed.location, length: adjustedLength) } private func trailingLineCount( in lineRanges: [NSRange], paragraphRange: NSRange ) -> Int { var count = 0 for lineRange in lineRanges.reversed() { if NSIntersectionRange(lineRange, paragraphRange).length > 0 { count += 1 } else { break } } return count } private func estimatedRemainingLineCount( in paragraphRange: NSRange, startingAt location: Int ) -> Int { let paragraphEnd = NSMaxRange(paragraphRange) var currentLocation = max(location, paragraphRange.location) guard currentLocation < paragraphEnd else { return 0 } let width = max(config.columnRects(fallback: pageSize).first?.width ?? config.contentRect(fallback: pageSize).width, 1) let typesetter = CTTypesetterCreateWithAttributedString(attributedString) var lineCount = 0 while currentLocation < paragraphEnd { let suggestedCount = CTTypesetterSuggestLineBreak(typesetter, currentLocation, Double(width)) let lineLength = max(suggestedCount, 1) currentLocation += lineLength lineCount += 1 if lineCount > 2 { break } } return lineCount } 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 } 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 } 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 } 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 } 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)) } func nearestTrailingFragmentID( endingAt location: Int, fragmentOffsets: [String: Int] ) -> String? { fragmentOffsets .filter { $0.value <= location } .max { lhs, rhs in lhs.value < rhs.value }? .key } 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 } }