修复 EPUB 文本分页显示错位并补充分页调试能力

本次提交围绕 DTCoreText 文本页的分页一致性、交互索引和高亮命中进行了集中修复。

主要改动:

1. 文本页显示改为基于整章 attributed string 的上下文布局,只对当前页 range 进行渲染,避免页面子串重新换行导致的页末断行偏差。

2. 页面布局快照与交互控制器统一改为使用 chapter-absolute 索引,修正点击、选区、菜单锚点与高亮矩形在整章上下文下的定位。

3. 修复跨章节高亮串页问题,并调整文本页高亮命中逻辑:保留 CoreText 层绘制,点击时按高亮真实 rect 精确命中,避免重复绘制和整行误判。

4. 收紧 reader 级页面缓存策略,避免预加载同时持有多份整章显示副本带来的内存放大。

5. 新增分页边界校验器、垂直对齐器和 settings-flip 自动化调试入口,用于复现与诊断页范围/显示度量不一致问题。

6. 放宽 inline attachment 的 avoid-break 处理,并补充相关分页问题调查文档与索引。
This commit is contained in:
shenlei
2026-07-08 14:27:36 +09:00
parent d0efe3f2cc
commit 5a41066b66
12 changed files with 2167 additions and 1606 deletions
@@ -188,7 +188,12 @@ struct RDEPUBPageBreakPolicy {
let blockKind = (attributes[.rdPageBlockKind] as? String)
.flatMap(RDEPUBTextBlockKind.init(rawValue:))
if blockKind == .attachment, placement != .centered {
// Inline attachments (footnote icons, rare-character images) flow with
// the surrounding text, so their avoid hint must not lock the line to
// the next page. Placement is checked besides blockKind because an
// enclosing paragraph's semantics overwrite blockKind on the
// attachment's range, while placement survives.
if blockKind == .attachment || placement != nil, placement != .centered {
return false
}
@@ -281,6 +281,7 @@ public final class RDEPUBReaderController: UIViewController {
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
startInitialLoadIfNeeded()
RDEPUBSettingsFlipAutomation.startIfNeeded(controller: self)
}
public override func viewDidLayoutSubviews() {
@@ -0,0 +1,84 @@
import UIKit
/// Debug-only automation (`--demo-settings-flip`) that replays the user
/// gesture sequence suspected of producing stale page tables: open the
/// settings panel, change the line height, close the panel, flip pages
/// with several timing variants aimed at the preview-repagination and
/// in-flight chapter build races. Combine with
/// `--demo-pagination-validate` to detect any resulting metric mismatch.
enum RDEPUBSettingsFlipAutomation {
static let isEnabled = ProcessInfo.processInfo.arguments.contains("--demo-settings-flip")
private(set) static var hasStarted = false
static func startIfNeeded(controller: RDEPUBReaderController) {
guard isEnabled, !hasStarted else { return }
hasStarted = true
print("[SETTINGS-FLIP] scheduled")
// Pass 1 starts while progressive pagination of the freshly opened
// book is still running, so prefetch builds are in flight.
run(after: 2.0) { [weak controller] in
guard let controller else { return }
pass(controller: controller, index: 1, lineHeight: 1.8, changeToCloseDelay: 1.2) {
pass(controller: controller, index: 2, lineHeight: 1.6, changeToCloseDelay: 0.05) {
pass(controller: controller, index: 3, lineHeight: 1.8, changeToCloseDelay: 0.3) {
print("[SETTINGS-FLIP] finished all passes")
}
}
}
}
}
/// One panel round-trip: present change line height close after
/// `changeToCloseDelay` flip pages forward and back.
private static func pass(
controller: RDEPUBReaderController,
index: Int,
lineHeight: CGFloat,
changeToCloseDelay: TimeInterval,
completion: @escaping () -> Void
) {
print("[SETTINGS-FLIP] pass \(index) present panel")
controller.presentSettings()
run(after: 0.7) { [weak controller] in
guard let controller else { return }
print("[SETTINGS-FLIP] pass \(index) set lineHeightMultiple=\(lineHeight)")
controller.updateConfiguration { $0.lineHeightMultiple = lineHeight }
run(after: changeToCloseDelay) { [weak controller] in
guard let controller else { return }
print("[SETTINGS-FLIP] pass \(index) close panel")
controller.dismiss(animated: true) { [weak controller] in
controller?.runtime.settingsPanelDidDisappear()
run(after: 1.0) { [weak controller] in
guard let controller else { return }
flipPages(controller: controller, index: index, completion: completion)
}
}
}
}
}
private static func flipPages(
controller: RDEPUBReaderController,
index: Int,
completion: @escaping () -> Void
) {
let startPage = controller.readerView.currentPage + 1
let offsets = [1, 2, 3, 2, 1, 0]
print("[SETTINGS-FLIP] pass \(index) flip pages from \(startPage)")
for (step, offset) in offsets.enumerated() {
run(after: 0.6 * Double(step + 1)) { [weak controller] in
_ = controller?.go(toPageNumber: startPage + offset, animated: false)
}
}
run(after: 0.6 * Double(offsets.count + 1) + 0.5, block: completion)
}
private static func run(after delay: TimeInterval, block: @escaping () -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: block)
}
}
@@ -25,6 +25,7 @@ enum RDEPUBDarkImageAdjuster {
static func adjustIfNeeded(
_ content: NSMutableAttributedString,
in range: NSRange? = nil,
configuration: RDEPUBReaderConfiguration
) -> NSMutableAttributedString {
guard configuration.darkImageAdjustmentEnabled,
@@ -34,7 +35,8 @@ enum RDEPUBDarkImageAdjuster {
}
let fullRange = NSRange(location: 0, length: content.length)
content.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in
let targetRange = range.map { NSIntersectionRange($0, fullRange) } ?? fullRange
content.enumerateAttribute(.attachment, in: targetRange) { value, range, _ in
guard let attachment = value as? DTImageTextAttachment,
!isCoverAttachment(attachment),
let image = attachment.image,
@@ -44,10 +44,12 @@ final class RDEPUBPageInteractionController {
x: point.x - line.baselineOrigin.x,
y: point.y - line.baselineOrigin.y
)
// The layout frame is built in chapter context, so DTCoreText string
// indices are chapter-absolute already.
let idx = dtLine.stringIndex(forPosition: relativePoint)
guard idx != NSNotFound, idx >= 0 else { return nil }
return normalizedIndex(
idx + pageOffset(for: snapshot),
idx,
lineRange: line.stringRange,
pageRange: snapshot.pageContentRange
)
@@ -74,10 +76,9 @@ final class RDEPUBPageInteractionController {
#if canImport(DTCoreText)
guard let dtLine = dtLineContaining(range: line.stringRange) else { continue }
let pageOffset = pageOffset(for: snapshot)
let startX = dtLine.offset(forStringIndex: overlap.location - pageOffset)
let startX = dtLine.offset(forStringIndex: overlap.location)
let endIdx = overlap.location + overlap.length
let endX = dtLine.offset(forStringIndex: endIdx - pageOffset)
let endX = dtLine.offset(forStringIndex: endIdx)
#else
let startX: CGFloat = 0
let endX: CGFloat = line.frame.width
@@ -133,7 +134,7 @@ final class RDEPUBPageInteractionController {
#if canImport(DTCoreText)
guard let dtLine = dtLineContaining(range: line.stringRange) else { return nil }
let offsetX = dtLine.offset(forStringIndex: index - pageOffset(for: snapshot))
let offsetX = dtLine.offset(forStringIndex: index)
#else
let offsetX: CGFloat = 0
#endif
@@ -183,16 +184,11 @@ final class RDEPUBPageInteractionController {
#if canImport(DTCoreText)
private func dtLineContaining(range: NSRange) -> DTCoreTextLayoutLine? {
guard let dtLayoutFrame, let snapshot else { return nil }
let localLocation = max(range.location - pageOffset(for: snapshot), 0)
return dtLayoutFrame.lineContaining(UInt(localLocation))
guard let dtLayoutFrame else { return nil }
return dtLayoutFrame.lineContaining(UInt(max(range.location, 0)))
}
#endif
private func pageOffset(for snapshot: RDEPUBPageLayoutSnapshot) -> Int {
snapshot.page.pageStartOffset
}
private func mergeAdjacentRects(_ rects: [CGRect]) -> [CGRect] {
guard rects.count > 1 else { return rects }
@@ -130,13 +130,14 @@ struct RDEPUBPageLayoutSnapshot {
return nil
}
// The layout frame is produced in chapter context, so DTCoreText
// string ranges are already chapter-absolute.
var lines: [RDEPUBPageLine] = []
var runs: [RDEPUBPageRun] = []
var attachments: [RDEPUBPageAttachment] = []
let pageOffset = page.pageStartOffset
for dtLine in dtLines {
let lineRange = offset(dtLine.stringRange(), by: pageOffset)
let lineRange = dtLine.stringRange()
let line = RDEPUBPageLine(
stringRange: lineRange,
frame: dtLine.frame,
@@ -149,7 +150,7 @@ struct RDEPUBPageLayoutSnapshot {
if let glyphRuns = dtLine.glyphRuns as? [DTCoreTextGlyphRun] {
for run in glyphRuns {
let runRange = offset(run.stringRange(), by: pageOffset)
let runRange = run.stringRange()
let isAttachment = run.attachment != nil
runs.append(
RDEPUBPageRun(
@@ -177,7 +178,7 @@ struct RDEPUBPageLayoutSnapshot {
}
}
let visibleRange = offset(layoutFrame.visibleStringRange(), by: pageOffset)
let visibleRange = layoutFrame.visibleStringRange()
return RDEPUBPageLayoutSnapshot(
page: page,
@@ -219,11 +220,5 @@ struct RDEPUBPageLayoutSnapshot {
return RDEPUBAttachmentNormalizer.attachmentKind(for: attributes)
}
private static func offset(_ range: NSRange, by offset: Int) -> NSRange {
guard range.location != NSNotFound else {
return range
}
return NSRange(location: range.location + offset, length: range.length)
}
#endif
}
@@ -90,6 +90,10 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
private var coreTextDisplayContent: NSAttributedString?
private var coreTextDisplayRange: NSRange?
/// Framesetter over the full chapter copy, rebuilt when the display
/// content changes; layout frames are recomputed per bounds change.
private var coreTextLayouter: DTCoreTextLayouter?
#endif
private let interactionController = RDEPUBPageInteractionController()
@@ -313,7 +317,18 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
}
var shouldAvoidReaderPageCaching: Bool {
currentPage == nil || loadingSpinner.isAnimating
#if canImport(DTCoreText)
// In-context text layout keeps a chapter-length attributed copy and
// layouter alive for the visible page. Avoid reader-level page
// caching so preloading does not retain several full-chapter
// display copies at once.
return currentPage == nil
|| loadingSpinner.isAnimating
|| coreTextDisplayContent != nil
#else
currentPage == nil
|| loadingSpinner.isAnimating
#endif
}
private var hasInteractiveTextContent: Bool {
@@ -405,6 +420,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
coreTextContentView.layoutFrame = nil
coreTextDisplayContent = nil
coreTextDisplayRange = nil
coreTextLayouter = nil
#endif
updateStaticGestureAvailability()
updateSelectionPanAvailability()
@@ -418,15 +434,26 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
coverImageView.image = nil
#if canImport(DTCoreText)
let displayContent = RDEPUBDarkImageAdjuster.adjustIfNeeded(
normalizedPageContent(from: page),
// In-context display: keep the full chapter string and lay out only the
// page's range. CTTypesetter line breaks are context-sensitive, so
// re-wrapping a page substring can break ±1 character away from the
// paginator's line ends (lone characters spilling onto the last line);
// laying out the same chapter string the paginator used cannot.
// String indices in the layout frame are therefore chapter-absolute.
let displayContent = NSMutableAttributedString(attributedString: page.chapterContent)
let pageRange = NSIntersectionRange(
page.contentRange,
NSRange(location: 0, length: displayContent.length)
)
_ = RDEPUBDarkImageAdjuster.adjustIfNeeded(
displayContent,
in: pageRange,
configuration: configuration
)
let fullRange = NSRange(location: 0, length: displayContent.length)
displayContent.addAttribute(
.foregroundColor,
value: configuration.theme.contentTextColor,
range: fullRange
range: pageRange
)
applyHighlightsToContent(displayContent, highlights: highlights, page: page)
@@ -434,7 +461,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
coreTextContentView.backgroundColor = .clear
coreTextContentView.accessibilityIdentifier = "epub.reader.selection.text"
coreTextDisplayContent = displayContent
coreTextDisplayRange = NSRange(location: 0, length: displayContent.length)
coreTextDisplayRange = pageRange
coreTextLayouter = DTCoreTextLayouter(attributedString: displayContent)
coreTextLayouter?.shouldCacheLayoutFrames = false
coreTextContentView.attributedDisplayContent = displayContent
updateCoreTextLayoutFrameIfNeeded()
#else
@@ -493,6 +522,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
coreTextContentView.layoutFrame = nil
coreTextDisplayContent = nil
coreTextDisplayRange = nil
coreTextLayouter = nil
#endif
overlayView.clearSelection()
@@ -540,6 +570,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
coreTextContentView.layoutFrame = nil
coreTextDisplayContent = nil
coreTextDisplayRange = nil
coreTextLayouter = nil
#endif
return true
}
@@ -581,26 +612,27 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
return image(from: attachmentValue)
}
/// Marks highlight ranges on the chapter-length display copy. Ranges stay
/// chapter-absolute the in-context layout frame uses the same indices.
private func applyHighlightsToContent(
_ content: NSMutableAttributedString,
highlights: [RDEPUBHighlight],
page: RDEPUBTextPage
) {
for highlight in highlights {
guard highlight.location.href == page.href else { continue }
guard let rangeInfo = highlight.rangeInfo,
let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo),
let absoluteRange = info.nsRange else { continue }
let overlap = NSIntersectionRange(absoluteRange, page.contentRange)
guard overlap.length > 0 else { continue }
let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)
guard relativeRange.location >= 0,
relativeRange.location + relativeRange.length <= content.length else { continue }
guard overlap.length > 0,
overlap.location + overlap.length <= content.length else { continue }
switch highlight.style {
case .highlight:
content.addAttribute(kRDEPUBHighlightAttributeName, value: highlight.uiColor, range: relativeRange)
content.addAttribute(kRDEPUBHighlightAttributeName, value: highlight.uiColor, range: overlap)
case .underline:
content.addAttribute(kRDEPUBUnderlineAttributeName, value: highlight.uiColor, range: relativeRange)
content.addAttribute(kRDEPUBUnderlineAttributeName, value: highlight.uiColor, range: overlap)
}
}
}
@@ -698,13 +730,26 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
return
}
guard let layouter = DTCoreTextLayouter(attributedString: displayContent) else {
guard let layouter = coreTextLayouter else {
coreTextContentView.layoutFrame = nil
interactionController.configure(layoutFrame: nil, page: page)
return
}
layouter.shouldCacheLayoutFrames = false
let layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange)
if let layoutFrame {
RDEPUBTextPageBoundaryValidator.validate(
page: page,
displayLayoutFrame: layoutFrame,
displayContent: displayContent,
displayBounds: coreTextContentView.bounds
)
RDEPUBTextPageVerticalJustifier.justify(
layoutFrame,
contentHeight: coreTextContentView.bounds.height,
isChapterLastPage: page.pageIndexInChapter >= page.totalPagesInChapter - 1,
pixelScale: window?.screen.scale ?? UIScreen.main.scale
)
}
coreTextContentView.layoutFrame = layoutFrame
interactionController.configure(layoutFrame: layoutFrame, page: page)
overlayView.updateSnapshot(interactionController.snapshot)
@@ -713,6 +758,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
if let page = currentPage {
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
page: page,
// Text-page highlights/underlines are already painted by the
// CoreText render view via display-content attributes. Keep
// overlay decorations for search only to avoid double drawing.
highlights: [],
searchState: currentSearchState,
interactionController: interactionController
@@ -845,10 +893,15 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
!targetRect.isEmpty else {
return
}
let resolvedTargetRect = resolvedSelectionMenuTargetRect(from: targetRect)
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
let anchor = CGPoint(x: targetRect.midX, y: targetRect.midY)
becomeFirstResponder()
let anchor = CGPoint(x: resolvedTargetRect.midX, y: resolvedTargetRect.midY)
let config = UIEditMenuConfiguration(identifier: "SelectionMenu", sourcePoint: anchor)
interaction.presentEditMenu(with: config)
DispatchQueue.main.async { [weak self, weak interaction] in
guard let self, self.currentSelection != nil else { return }
interaction?.presentEditMenu(with: config)
}
} else {
becomeFirstResponder()
let menuController = UIMenuController.shared
@@ -857,7 +910,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
UIMenuItem(title: "高亮", action: #selector(rd_highlight(_:))),
UIMenuItem(title: "批注", action: #selector(rd_annotate(_:)))
]
menuController.setTargetRect(targetRect, in: coreTextRenderView ?? self)
menuController.setTargetRect(resolvedTargetRect, in: self)
menuController.setMenuVisible(true, animated: true)
}
}
@@ -891,6 +944,11 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
#endif
}
private func resolvedSelectionMenuTargetRect(from rect: CGRect) -> CGRect {
guard let renderView = coreTextRenderView else { return rect }
return convert(rect, from: renderView)
}
private func updateAccessibilityDecorationSummary() {
#if canImport(DTCoreText)
coreTextContentView.accessibilityValue = [
@@ -901,14 +959,17 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
}
private func highlight(at point: CGPoint) -> RDEPUBHighlight? {
guard currentPage != nil else { return nil }
let absoluteRange = backgroundOverlayView.absoluteRange(at: point) ?? overlayView.absoluteRange(at: point)
guard let absoluteRange else { return nil }
guard let page = currentPage else { return nil }
let matches = currentHighlights.filter { highlight in
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
guard highlight.location.href == page.href,
let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
return false
}
return NSIntersectionRange(range, absoluteRange).length > 0
let overlap = NSIntersectionRange(range, page.contentRange)
guard overlap.length > 0 else { return false }
return interactionController.selectionRects(for: overlap).contains {
$0.insetBy(dx: -4, dy: -4).contains(point)
}
}
return matches.sorted { lhs, rhs in
let lhsRange = RDEPUBTextOffsetRangeInfo.decode(from: lhs.rangeInfo)?.nsRange?.length ?? .max
@@ -1090,8 +1151,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
extension RDEPUBTextContentView: UIEditMenuInteractionDelegate {
func editMenuInteraction(
_ interaction: UIEditMenuInteraction,
menuFor configuration: UIEditMenuConfiguration
) -> UIMenu {
menuFor configuration: UIEditMenuConfiguration,
suggestedActions: [UIMenuElement]
) -> UIMenu? {
UIMenu(children: [
UICommand(title: "拷贝", action: #selector(rd_copy(_:))),
UICommand(title: "高亮", action: #selector(rd_highlight(_:))),
@@ -1105,7 +1167,7 @@ extension RDEPUBTextContentView: UIEditMenuInteractionDelegate {
) -> CGRect {
if let targetRect = selectionController.menuAnchorRect(interactionController: interactionController),
!targetRect.isEmpty {
return targetRect
return resolvedSelectionMenuTargetRect(from: targetRect)
}
return bounds
}
@@ -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
@@ -0,0 +1,57 @@
import UIKit
#if canImport(DTCoreText)
import DTCoreText
/// Redistributes the leftover space at the bottom of a page into the gaps
/// between lines so the last line sits flush with the content bottom edge
/// (vertical justification), keeping page character ranges untouched.
///
/// Mutating each line's `baselineOrigin` is sufficient: DTCoreText derives
/// line frames, glyph-run frames and attachment positions from it lazily,
/// so drawing, selection, highlights and hit-testing all stay consistent.
enum RDEPUBTextPageVerticalJustifier {
/// Leftover larger than this many typical line advances is kept as
/// whitespace instead of being stretched: it usually comes from a whole
/// block (image, table) pushed to the next page, and stretching would
/// make the line spacing visibly sparse.
static let maxStretchLineAdvanceRatio: CGFloat = 1.5
static func justify(
_ layoutFrame: DTCoreTextLayoutFrame,
contentHeight: CGFloat,
isChapterLastPage: Bool,
pixelScale: CGFloat
) {
guard !isChapterLastPage,
contentHeight > 0,
let lines = layoutFrame.lines as? [DTCoreTextLayoutLine],
lines.count >= 2,
let firstLine = lines.first,
let lastLine = lines.last else {
return
}
let leftover = contentHeight - lastLine.frame.maxY
guard leftover > 0.5 else { return }
let gapCount = CGFloat(lines.count - 1)
let typicalAdvance = (lastLine.baselineOrigin.y - firstLine.baselineOrigin.y) / gapCount
guard typicalAdvance > 0,
leftover <= typicalAdvance * maxStretchLineAdvanceRatio else {
return
}
let scale = max(pixelScale, 1)
for (index, line) in lines.enumerated() where index > 0 {
// Round each cumulative shift down to the pixel grid so glyphs
// stay sharp and the last line never overshoots the bottom edge.
let shift = floor(leftover * CGFloat(index) / gapCount * scale) / scale
var origin = line.baselineOrigin
origin.y += shift
line.baselineOrigin = origin
}
}
}
#endif