ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBPageBreakPolicy.swift
shenlei 5a41066b66 修复 EPUB 文本分页显示错位并补充分页调试能力
本次提交围绕 DTCoreText 文本页的分页一致性、交互索引和高亮命中进行了集中修复。

主要改动:

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

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

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

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

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

6. 放宽 inline attachment 的 avoid-break 处理,并补充相关分页问题调查文档与索引。
2026-07-08 14:27:36 +09:00

218 lines
9.0 KiB
Swift

import Foundation
import UIKit
struct RDEPUBPageBreakPolicy {
private let attributedString: NSAttributedString
init(attributedString: NSAttributedString) {
self.attributedString = attributedString
}
func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool {
guard let probeRange = clampedProbeRange(for: lineRange) else {
return false
}
var found = false
attributedString.enumerateAttributes(in: probeRange) { attributes, _, stop in
guard shouldTreatAvoidHintAsBlockProtection(attributes) else {
return
}
guard let rawValue = attributes[.rdPageSemanticHints] as? String else { return }
let hints = rawValue
.split(separator: ",")
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
if hints.contains(.avoidPageBreakInside) {
found = true
stop.pointee = true
}
}
return found
}
func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool {
guard let probeRange = clampedProbeRange(for: lineRange) else {
return false
}
var found = false
attributedString.enumerateAttribute(.rdPageSemanticHints, in: probeRange) { value, _, stop in
guard let rawValue = value as? String else { return }
let hints = rawValue
.split(separator: ",")
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
if hints.contains(.keepWithNext) {
found = true
stop.pointee = true
}
}
return found
}
func adjustedRange(
from proposedRange: NSRange,
totalLength: Int,
lineRanges: [NSRange],
factory: RDEPUBCoreTextPageFrameFactory
) -> (
range: NSRange,
breakReason: RDEPUBTextPageBreakReason,
blockRange: NSRange?,
attachmentRanges: [NSRange],
attachmentKinds: [RDEPUBTextAttachmentKind],
blockKinds: [RDEPUBTextBlockKind],
semanticHints: [RDEPUBTextSemanticHint],
attachmentPlacements: [RDEPUBTextAttachmentPlacement],
diagnostics: [String]
) {
let pageEnd = proposedRange.location + proposedRange.length
let proposedBlockKinds = factory.blockKinds(in: proposedRange)
let proposedSemanticHints = factory.semanticHints(in: proposedRange)
let proposedAttachmentPlacements = factory.attachmentPlacements(in: proposedRange)
guard pageEnd < totalLength else {
return (
range: proposedRange,
breakReason: .chapterEnd,
blockRange: factory.blockRange(at: max(proposedRange.location, pageEnd - 1)),
attachmentRanges: factory.attachmentRanges(in: proposedRange),
attachmentKinds: factory.attachmentKinds(in: proposedRange),
blockKinds: proposedBlockKinds,
semanticHints: proposedSemanticHints,
attachmentPlacements: proposedAttachmentPlacements,
diagnostics: factory.diagnostics(
reason: .chapterEnd,
range: proposedRange,
attachmentRanges: factory.attachmentRanges(in: proposedRange),
blockRange: factory.blockRange(at: max(proposedRange.location, pageEnd - 1)),
blockKinds: proposedBlockKinds,
semanticHints: proposedSemanticHints,
attachmentPlacements: proposedAttachmentPlacements
)
)
}
let currentBlockRange = factory.blockRange(at: max(proposedRange.location, pageEnd - 1))
let currentAttachmentRanges = factory.attachmentRanges(in: proposedRange)
let currentAttachmentKinds = factory.attachmentKinds(in: proposedRange)
let currentBlockKinds = proposedBlockKinds
let currentSemanticHints = proposedSemanticHints
let currentAttachmentPlacements = proposedAttachmentPlacements
if let pageRelateBoundary = preferredPageRelateBoundary(
after: proposedRange,
minimumEnd: proposedRange.location + 1,
lineRanges: lineRanges,
factory: factory
) {
let adjustedRange = NSRange(location: proposedRange.location, length: pageRelateBoundary - proposedRange.location)
return (
range: adjustedRange,
breakReason: .semanticBoundary,
blockRange: currentBlockRange,
attachmentRanges: currentAttachmentRanges,
attachmentKinds: currentAttachmentKinds,
blockKinds: currentBlockKinds,
semanticHints: currentSemanticHints,
attachmentPlacements: currentAttachmentPlacements,
diagnostics: factory.diagnostics(
reason: .semanticBoundary,
range: adjustedRange,
attachmentRanges: currentAttachmentRanges,
blockRange: currentBlockRange,
blockKinds: currentBlockKinds,
semanticHints: currentSemanticHints,
attachmentPlacements: currentAttachmentPlacements,
trigger: RDEPUBTextSemanticHint.pageRelate.rawValue
)
)
}
return (
range: proposedRange,
breakReason: .frameLimit,
blockRange: currentBlockRange,
attachmentRanges: currentAttachmentRanges,
attachmentKinds: currentAttachmentKinds,
blockKinds: currentBlockKinds,
semanticHints: currentSemanticHints,
attachmentPlacements: currentAttachmentPlacements,
diagnostics: factory.diagnostics(
reason: .frameLimit,
range: proposedRange,
attachmentRanges: currentAttachmentRanges,
blockRange: currentBlockRange,
blockKinds: currentBlockKinds,
semanticHints: currentSemanticHints,
attachmentPlacements: currentAttachmentPlacements
)
)
}
func preferredPageRelateBoundary(
after range: NSRange,
minimumEnd: Int,
lineRanges: [NSRange],
factory: RDEPUBCoreTextPageFrameFactory
) -> Int? {
let pageStartOfNext = range.location + range.length
guard pageStartOfNext > range.location,
pageStartOfNext < attributedString.length,
lineRanges.count >= 2,
factory.semanticHints(at: pageStartOfNext).contains(.pageRelate) else {
return nil
}
let boundaryBlockStart = factory.blockRange(at: pageStartOfNext)?.location
?? factory.paragraphRange(containing: pageStartOfNext).location
guard boundaryBlockStart == pageStartOfNext else { return nil }
let lastLineStart = lineRanges[lineRanges.count - 1].location
guard lastLineStart > range.location, lastLineStart >= minimumEnd else {
return nil
}
return lastLineStart
}
private func shouldTreatAvoidHintAsBlockProtection(_ attributes: [NSAttributedString.Key: Any]) -> Bool {
guard let rawValue = attributes[.rdPageSemanticHints] as? String else {
return false
}
let hints = rawValue
.split(separator: ",")
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
guard hints.contains(.avoidPageBreakInside) else {
return false
}
let placement = (attributes[.rdPageAttachmentPlacement] as? String)
.flatMap(RDEPUBTextAttachmentPlacement.init(rawValue:))
let blockKind = (attributes[.rdPageBlockKind] as? String)
.flatMap(RDEPUBTextBlockKind.init(rawValue:))
// 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
}
return true
}
private func clampedProbeRange(for lineRange: NSRange) -> NSRange? {
clampedRange(NSRange(location: lineRange.location, length: max(lineRange.length, 1)))
}
private func clampedRange(_ range: NSRange) -> NSRange? {
guard range.location >= 0, range.length >= 0 else { return nil }
guard attributedString.length > 0 else {
return range.location == 0 ? NSRange(location: 0, length: 0) : nil
}
guard range.location < attributedString.length else { return nil }
let maxLength = attributedString.length - range.location
return NSRange(location: range.location, length: min(range.length, maxLength))
}
}