本次提交围绕 DTCoreText 文本页的分页一致性、交互索引和高亮命中进行了集中修复。 主要改动: 1. 文本页显示改为基于整章 attributed string 的上下文布局,只对当前页 range 进行渲染,避免页面子串重新换行导致的页末断行偏差。 2. 页面布局快照与交互控制器统一改为使用 chapter-absolute 索引,修正点击、选区、菜单锚点与高亮矩形在整章上下文下的定位。 3. 修复跨章节高亮串页问题,并调整文本页高亮命中逻辑:保留 CoreText 层绘制,点击时按高亮真实 rect 精确命中,避免重复绘制和整行误判。 4. 收紧 reader 级页面缓存策略,避免预加载同时持有多份整章显示副本带来的内存放大。 5. 新增分页边界校验器、垂直对齐器和 settings-flip 自动化调试入口,用于复现与诊断页范围/显示度量不一致问题。 6. 放宽 inline attachment 的 avoid-break 处理,并补充相关分页问题调查文档与索引。
224 lines
12 KiB
Swift
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
|