ReadViewSDK/Sources/RDEpubReaderView/EPUBUI/TextPage/RDEPUBTextPageBoundaryValidator.swift
shenlei d7fcda345d refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs
- Rename source module from RDReaderView to RDEpubReaderView
- Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/
- Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec
- Update Podfile, demo project, and CocoaPods config for new pod name
- Delete old RDReaderView pod support files from ReadViewDemo/Pods
- Add new RDEpubReaderView pod support files
- Update documentation (API ref, architecture, UML, conventions, etc.)
- Add FixedLayoutRotationTests
- Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
2026-07-10 19:44:53 +09:00

224 lines
12 KiB
Swift

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