import UIKit #if canImport(DTCoreText) import DTCoreText /// Debug-only detector for pagination/display metric mismatches, enabled by /// the `--demo-pagination-validate` launch argument. /// /// For every displayed text page it re-wraps the chapter text from the page /// start at the display width (same CoreText engine the paginator used, in /// chapter context) and compares the resulting line breaks against both the /// page range and the lines actually drawn. Two failure classes: /// /// - `STALE-RANGE`: the page range does not end on a line boundary of the /// current metrics — the range was produced under different metrics than /// the ones on screen (stale page table). /// - `DISPLAY-DIVERGE`: the range is fine, but the drawn lines break at /// different offsets than the in-context wrap — the display-side content /// transform (e.g. continuation-paragraph normalization) changed wrapping. enum RDEPUBTextPageBoundaryValidator { static let isEnabled = ProcessInfo.processInfo.arguments.contains("--demo-pagination-validate") /// Extra characters wrapped past the page end so the probe can see the /// line that a mid-line page boundary cuts through. private static let probeTailLength = 400 static func validate( page: RDEPUBTextPage, displayLayoutFrame: DTCoreTextLayoutFrame, displayContent: NSAttributedString?, displayBounds: CGRect ) { guard isEnabled else { return } let chapter = page.chapterContent let pageStart = page.contentRange.location let pageEnd = page.contentRange.location + page.contentRange.length guard page.contentRange.length > 0, pageStart >= 0, pageEnd <= chapter.length, displayBounds.width > 0 else { return } guard let layouter = DTCoreTextLayouter(attributedString: chapter) else { return } layouter.shouldCacheLayoutFrames = false let probeLength = min(chapter.length - pageStart, page.contentRange.length + probeTailLength) let probeRect = CGRect(x: 0, y: 0, width: displayBounds.width, height: 4_000_000) guard let probeFrame = layouter.layoutFrame( with: probeRect, range: NSRange(location: pageStart, length: probeLength) ), let probeLines = probeFrame.lines as? [DTCoreTextLayoutLine] else { return } let probeRanges = probeLines.map { $0.stringRange() } // Class A: the page must end on a line boundary of the current wrap // (unless it is the chapter's last page, which ends at chapter end). let isChapterLastPage = pageEnd >= chapter.length if !isChapterLastPage, !probeRanges.contains(where: { NSMaxRange($0) == pageEnd }), let cutLine = probeRanges.first(where: { NSLocationInRange(pageEnd - 1, $0) }) { let text = chapter.string as NSString let lineText = safeSubstring(text, cutLine) print("[PAGINATION-VALIDATE] STALE-RANGE spine=\(page.spineIndex) page=\(page.pageIndexInChapter + 1)/\(page.totalPagesInChapter) pageEnd=\(pageEnd) cutLine=\(NSStringFromRange(cutLine)) width=\(displayBounds.width) line=\"\(lineText)\"") } // Class B: the drawn lines must break at the same offsets as the // in-context wrap. The display layout frame is built in chapter // context, so its string ranges are chapter-absolute. guard let displayLines = displayLayoutFrame.lines as? [DTCoreTextLayoutLine] else { return } for (index, displayLine) in displayLines.enumerated() { let displayRange = displayLine.stringRange() let displayEndInChapter = NSMaxRange(displayRange) guard displayEndInChapter < pageEnd else { break } guard index < probeRanges.count else { break } let probeEnd = NSMaxRange(probeRanges[index]) if probeEnd != displayEndInChapter { let text = chapter.string as NSString let lineStartInChapter = displayRange.location let displayLineRangeInChapter = displayRange let isParagraphStart = lineStartInChapter == 0 || text.character(at: lineStartInChapter - 1) == 0x0A let chapterStyle = chapter.attribute( .paragraphStyle, at: lineStartInChapter, effectiveRange: nil ) as? NSParagraphStyle let displayStyle = displayContent?.attribute( .paragraphStyle, at: displayRange.location, effectiveRange: nil ) as? NSParagraphStyle let displayLineWidth = displayLine.frame.width let probeLineWidth = index < probeLines.count ? probeLines[index].frame.width : -1 print("[PAGINATION-VALIDATE] DISPLAY-DIVERGE spine=\(page.spineIndex) page=\(page.pageIndexInChapter + 1)/\(page.totalPagesInChapter) lineIndex=\(index) displayLineEnd=\(displayEndInChapter) probeLineEnd=\(probeEnd) width=\(displayBounds.width) paraStart=\(isParagraphStart) chapterIndents=(\(chapterStyle?.firstLineHeadIndent ?? -1),\(chapterStyle?.headIndent ?? -1),tail:\(chapterStyle?.tailIndent ?? -1)) displayIndents=(\(displayStyle?.firstLineHeadIndent ?? -1),\(displayStyle?.headIndent ?? -1),tail:\(displayStyle?.tailIndent ?? -1)) displayLineWidth=\(displayLineWidth) probeLineWidth=\(probeLineWidth) displayLine=\"\(safeSubstring(text, displayLineRangeInChapter))\" displayBreak=\"…\(safeSubstring(text, NSRange(location: max(displayEndInChapter - 2, 0), length: min(4, text.length - max(displayEndInChapter - 2, 0)))))\" probeBreak=\"…\(safeSubstring(text, NSRange(location: max(probeEnd - 2, 0), length: min(4, text.length - max(probeEnd - 2, 0)))))\"") diagnoseDivergence( page: page, displayContent: displayContent, lineIndex: index, lineStartInChapter: lineStartInChapter, displayEndInChapter: displayEndInChapter, probeEnd: probeEnd, width: displayBounds.width ) break } } } /// Narrows a display/probe line-break divergence down to its cause by /// re-wrapping controlled variants and diffing attributes over the line. private static func diagnoseDivergence( page: RDEPUBTextPage, displayContent: NSAttributedString?, lineIndex: Int, lineStartInChapter: Int, displayEndInChapter: Int, probeEnd: Int, width: CGFloat ) { let chapter = page.chapterContent // Variant 1: the raw page substring with no display normalization. let rawSubstring = chapter.attributedSubstring(from: page.contentRange) let rawEnd = lineEnd( wrapping: rawSubstring, lineIndex: lineIndex, width: width ).map { $0 + page.pageStartOffset } // Variant 2: wrap the chapter from the start of the paragraph that // contains the diverging line (context = current paragraph only). let text = chapter.string as NSString let paragraphRange = text.paragraphRange( for: NSRange(location: lineStartInChapter, length: 0) ) let paraString = chapter.attributedSubstring( from: NSRange( location: paragraphRange.location, length: min(chapter.length - paragraphRange.location, paragraphRange.length + probeTailLength) ) ) var paraEnd: Int? if let layouter = DTCoreTextLayouter(attributedString: paraString) { layouter.shouldCacheLayoutFrames = false let frame = layouter.layoutFrame( with: CGRect(x: 0, y: 0, width: width, height: 4_000_000), range: NSRange(location: 0, length: paraString.length) ) if let lines = frame?.lines as? [DTCoreTextLayoutLine] { let target = lineStartInChapter - paragraphRange.location if let matched = lines.first(where: { $0.stringRange().location == target }) { paraEnd = NSMaxRange(matched.stringRange()) + paragraphRange.location } } } print("[PAGINATION-VALIDATE] DIAGNOSE lineStart=\(lineStartInChapter) display=\(displayEndInChapter) probeFullContext=\(probeEnd) rawSubstringWrap=\(rawEnd ?? -1) paragraphContextWrap=\(paraEnd ?? -1)") // Attribute diff between chapter text and display content over the // diverging line (through the longer of the two ends). The display // content is a chapter-length copy, so indices are shared. guard let displayContent else { return } let diffEnd = max(displayEndInChapter, probeEnd) var position = lineStartInChapter while position < diffEnd { guard position >= 0, position < displayContent.length, position < chapter.length else { break } var chapterRunRange = NSRange() let chapterAttrs = chapter.attributes(at: position, effectiveRange: &chapterRunRange) var displayRunRange = NSRange() let displayAttrs = displayContent.attributes(at: position, effectiveRange: &displayRunRange) let keys = Set(chapterAttrs.keys).union(displayAttrs.keys) for key in keys { let lhs = chapterAttrs[key] as AnyObject? let rhs = displayAttrs[key] as AnyObject? if let lhs, let rhs, lhs.isEqual(rhs) { continue } if lhs == nil, rhs == nil { continue } print("[PAGINATION-VALIDATE] ATTR-DIFF pos=\(position) key=\(key.rawValue) chapter=\(describeAttr(lhs)) display=\(describeAttr(rhs))") } let nextPosition = min( NSMaxRange(chapterRunRange), NSMaxRange(displayRunRange) ) guard nextPosition > position else { break } position = nextPosition } } private static func describeAttr(_ value: AnyObject?) -> String { guard let value else { return "nil" } if let font = value as? UIFont { return "font(\(font.fontName),\(font.pointSize))" } if let style = value as? NSParagraphStyle { return "para(fli:\(style.firstLineHeadIndent),hi:\(style.headIndent),ti:\(style.tailIndent),lbm:\(style.lineBreakMode.rawValue),align:\(style.alignment.rawValue),lhm:\(style.lineHeightMultiple),ls:\(style.lineSpacing),min:\(style.minimumLineHeight),max:\(style.maximumLineHeight))" } if let number = value as? NSNumber { return "num(\(number))" } return String(describing: type(of: value)) } /// Wraps `content` page-locally and returns the chapter-relative end of /// line `lineIndex`, or nil if it cannot be produced. private static func lineEnd( wrapping content: NSAttributedString, lineIndex: Int, width: CGFloat ) -> Int? { guard content.length > 0, let layouter = DTCoreTextLayouter(attributedString: content) else { return nil } layouter.shouldCacheLayoutFrames = false let frame = layouter.layoutFrame( with: CGRect(x: 0, y: 0, width: width, height: 4_000_000), range: NSRange(location: 0, length: content.length) ) guard let lines = frame?.lines as? [DTCoreTextLayoutLine], lineIndex < lines.count else { return nil } return NSMaxRange(lines[lineIndex].stringRange()) } private static func safeSubstring(_ text: NSString, _ range: NSRange) -> String { guard range.location >= 0, NSMaxRange(range) <= text.length else { return "" } return text.substring(with: range) .replacingOccurrences(of: "\n", with: "⏎") } } #endif