import CoreText import UIKit struct RDEPUBTextLayouter { private let attributedString: NSAttributedString private let pageSize: CGSize private let framesetter: CTFramesetter private let path: CGPath init(attributedString: NSAttributedString, pageSize: CGSize) { self.attributedString = attributedString self.pageSize = pageSize self.framesetter = CTFramesetterCreateWithAttributedString(attributedString) self.path = CGPath(rect: CGRect(origin: .zero, size: pageSize), transform: nil) } func layoutFrames(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) let adjusted = adjustedRange(from: proposedRange, totalLength: attributedString.length) 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 } private func adjustedRange( from proposedRange: NSRange, totalLength: Int ) -> ( 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 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 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 ) ) } 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 ) ) } if let blockBoundary = preferredBlockBoundary( near: pageEnd, lowerBound: minimumEnd ) { let adjustedRange = NSRange(location: proposedRange.location, length: blockBoundary - proposedRange.location) return ( range: adjustedRange, breakReason: .blockBoundary, blockRange: currentBlockRange, attachmentRanges: currentAttachmentRanges, attachmentKinds: currentAttachmentKinds, blockKinds: currentBlockKinds, semanticHints: currentSemanticHints, attachmentPlacements: currentAttachmentPlacements, diagnostics: diagnostics( reason: .blockBoundary, range: adjustedRange, attachmentRanges: currentAttachmentRanges, blockRange: currentBlockRange, blockKinds: currentBlockKinds, semanticHints: currentSemanticHints, attachmentPlacements: currentAttachmentPlacements ) ) } 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 ) ) } 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 } if hints.contains(.avoidPageBreakInside), attributeRange.location > range.location, attributeRange.location < range.location + range.length, attributeRange.location >= minimumEnd, attributeEnd > range.location + range.length { boundary = (attributeRange.location, RDEPUBTextSemanticHint.avoidPageBreakInside.rawValue) stop.pointee = true } } return boundary } 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 paragraphRange = paragraphRange(containing: attributeRange.location) if paragraphRange.location > range.location, paragraphRange.location >= minimumEnd { boundary = paragraphRange.location stop.pointee = true } } return boundary } private func preferredBlockBoundary(near location: Int, lowerBound: Int) -> Int? { var probe = max(lowerBound, 0) let searchEnd = min(location, attributedString.length) guard probe < searchEnd else { return nil } var lastBoundary: Int? while probe < searchEnd { let block = blockRange(at: probe) ?? paragraphRange(containing: probe) let candidate = block.location if candidate > lowerBound, candidate < location { lastBoundary = candidate } probe = max(block.location + max(block.length, 1), probe + 1) } return lastBoundary } 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 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 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 } private func nearestTrailingFragmentID( endingAt location: Int, fragmentOffsets: [String: Int] ) -> String? { fragmentOffsets .filter { $0.value <= location } .max { lhs, rhs in lhs.value < rhs.value }? .key } 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 } }