import Foundation struct RDEPUBChapterTailNormalizer { func normalize( _ frames: [RDEPUBTextLayoutFrame], content: NSAttributedString, href: String ) -> [RDEPUBTextLayoutFrame] { guard frames.count > 1 else { return frames } var normalized = frames var compacted: [RDEPUBTextLayoutFrame] = [] compacted.reserveCapacity(normalized.count) for frame in normalized { if shouldDropWhitespaceOnlyFrame(frame, in: content) { let note = "normalized: dropped whitespace-only intermediate page \(NSStringFromRange(frame.contentRange))" if var previous = compacted.popLast() { previous.diagnostics.append(note) compacted.append(previous) } else { #if DEBUG print("[EPUB][Pagination] href=\(href) dropped leading/intermediate whitespace frame \(NSStringFromRange(frame.contentRange))") #endif } continue } compacted.append(frame) } normalized = compacted while let lastFrame = normalized.last, shouldDropWhitespaceOnlyFrame(lastFrame, in: content) { normalized.removeLast() let note = "normalized: dropped whitespace-only trailing page \(NSStringFromRange(lastFrame.contentRange))" if var previousFrame = normalized.popLast() { previousFrame.diagnostics.append(note) normalized.append(previousFrame) } else { #if DEBUG print("[EPUB][Pagination] href=\(href) dropped trailing frame \(NSStringFromRange(lastFrame.contentRange))") #endif } } guard normalized.count > 1, let lastFrame = normalized.last, let previousFrame = normalized.dropLast().last, shouldMergeShortTrailingFrame(lastFrame, previousFrame: previousFrame, in: content) else { return normalized } let mergedFrame = mergeTrailingFrame(previousFrame, with: lastFrame) normalized.removeLast(2) normalized.append(mergedFrame) return normalized } private func shouldDropWhitespaceOnlyFrame( _ frame: RDEPUBTextLayoutFrame, in content: NSAttributedString ) -> Bool { guard frame.contentRange.length > 0, attachmentCount(in: content, range: frame.contentRange) == 0 else { return false } return visibleCharacterCount(in: content, range: frame.contentRange) == 0 } private func shouldMergeShortTrailingFrame( _ trailingFrame: RDEPUBTextLayoutFrame, previousFrame: RDEPUBTextLayoutFrame, in content: NSAttributedString ) -> Bool { guard trailingFrame.contentRange.length > 0, NSMaxRange(previousFrame.contentRange) == trailingFrame.contentRange.location else { return false } let visibleCount = visibleCharacterCount(in: content, range: trailingFrame.contentRange) let trailingAttachmentCount = attachmentCount(in: content, range: trailingFrame.contentRange) guard visibleCount <= 2, trailingFrame.contentRange.length <= 2, visibleCount > 0 || trailingAttachmentCount > 0 else { return false } let previousVisibleCount = visibleCharacterCount(in: content, range: previousFrame.contentRange) return previousVisibleCount >= max(visibleCount * 8, 12) } private func mergeTrailingFrame( _ previousFrame: RDEPUBTextLayoutFrame, with trailingFrame: RDEPUBTextLayoutFrame ) -> RDEPUBTextLayoutFrame { let mergedRange = NSRange( location: previousFrame.contentRange.location, length: NSMaxRange(trailingFrame.contentRange) - previousFrame.contentRange.location ) return RDEPUBTextLayoutFrame( contentRange: mergedRange, breakReason: trailingFrame.breakReason, blockRange: trailingFrame.blockRange ?? previousFrame.blockRange, attachmentRanges: uniqueRanges(from: previousFrame.attachmentRanges + trailingFrame.attachmentRanges), attachmentKinds: uniqueValues(from: previousFrame.attachmentKinds + trailingFrame.attachmentKinds), blockKinds: uniqueValues(from: previousFrame.blockKinds + trailingFrame.blockKinds), semanticHints: uniqueValues(from: previousFrame.semanticHints + trailingFrame.semanticHints), attachmentPlacements: uniqueValues(from: previousFrame.attachmentPlacements + trailingFrame.attachmentPlacements), trailingFragmentID: trailingFrame.trailingFragmentID ?? previousFrame.trailingFragmentID, diagnostics: previousFrame.diagnostics + trailingFrame.diagnostics + ["normalized: merged short trailing page \(NSStringFromRange(trailingFrame.contentRange)) into previous page"] ) } private func visibleCharacterCount( in content: NSAttributedString, range: NSRange ) -> Int { guard range.length > 0 else { return 0 } let string = content.attributedSubstring(from: range).string let filteredScalars = string.unicodeScalars.filter { scalar in !CharacterSet.whitespacesAndNewlines.contains(scalar) && !CharacterSet.controlCharacters.contains(scalar) } return filteredScalars.count } private func attachmentCount(in content: NSAttributedString, range: NSRange) -> Int { guard content.length > 0, range.length > 0 else { return 0 } var count = 0 content.enumerateAttribute(.attachment, in: range) { value, _, _ in if value != nil { count += 1 } } return count } private func uniqueValues(from values: [T]) -> [T] { values.reduce(into: [T]()) { result, value in if !result.contains(value) { result.append(value) } } } private func uniqueRanges(from ranges: [NSRange]) -> [NSRange] { ranges.reduce(into: [NSRange]()) { result, value in if !result.contains(value) { result.append(value) } } } }