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
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
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
|
||||
Reference in New Issue
Block a user