import CoreText import UIKit #if canImport(DTCoreText) import DTCoreText #endif struct RDEPUBTextLayouter { private let attributedString: NSAttributedString private let pageSize: CGSize private let framesetter: CTFramesetter private let path: CGPath 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.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 [] } #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 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) // Line-level avoidPageBreakInside (WXRead approach: scan CTFrame lines backward) let avoidAdjusted = trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange) let lineAdjusted = trimmedRangeForKeepWithNext(from: frame, proposed: avoidAdjusted) let lineRanges = lineRanges(from: frame) let adjusted = adjustedRange( from: lineAdjusted, 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(visibleRange.length, 1) continue } location = nextLocation } return frames } #if canImport(DTCoreText) private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] { guard let layouter = DTCoreTextLayouter(attributedString: attributedString) else { return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets) } layouter.shouldCacheLayoutFrames = false var frames: [RDEPUBTextLayoutFrame] = [] var location = 0 let pageRect = CGRect(origin: .zero, size: pageSize) 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 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 } #endif 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 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 pageRelateBoundary = preferredPageRelateBoundary( after: proposedRange, minimumEnd: minimumEnd, 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 ) ) } 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 ) ) } 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 } } 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 location = attributeRange.location let placement = attachmentPlacement(at: location) let blockKind = blockKind(at: location) // Mirror WXRead more closely: only block-level attachments should // push the entire block to the next page. Inline footnote icons and // other inline attachments must not cause a whole paragraph to move. let isBlockLevelAttachment = blockKind == .attachment || placement == .centered guard isBlockLevelAttachment else { return } let boundaryRange = blockRange(at: location) ?? paragraphRange(containing: location) if boundaryRange.location > range.location, boundaryRange.location >= minimumEnd { boundary = boundaryRange.location stop.pointee = true } } return boundary } 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 } 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] { var results: [NSRange] = [] attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { 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] { 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 } // MARK: - Line-level avoidPageBreakInside (WXRead approach) /// Scans CTFrame lines backward from the last line, removing trailing lines /// that fall inside an avoidPageBreakInside block. Mirrors WXRead's /// WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded. 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) // Scan backward: count consecutive trailing lines inside protected blocks // kMaxLinesToRemove = 3 (same as 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 { // Don't remove more than kMaxLinesToRemove; stop here // (line at index i stays, so linesToRemove stays at kMaxLinesToRemove) linesToRemove = kMaxLinesToRemove break } } else { break } } guard linesToRemove > 0 else { return proposed } let validLineCount = lines.count - linesToRemove guard validLineCount > 0 else { // All lines are in protected blocks; fall back to proposed 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) } 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) } #if canImport(DTCoreText) 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) } 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 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) } /// Checks if a line's string range intersects with an avoidPageBreakInside block. private func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool { var found = false let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1)) 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(.avoidPageBreakInside) { found = true stop.pointee = true } } return found } private func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool { var found = false let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1)) 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 } 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) } } #if canImport(DTCoreText) private func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] { guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else { return [] } return lines.map { $0.stringRange() } } #endif 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 } }