本次提交围绕 DTCoreText 文本页的分页一致性、交互索引和高亮命中进行了集中修复。 主要改动: 1. 文本页显示改为基于整章 attributed string 的上下文布局,只对当前页 range 进行渲染,避免页面子串重新换行导致的页末断行偏差。 2. 页面布局快照与交互控制器统一改为使用 chapter-absolute 索引,修正点击、选区、菜单锚点与高亮矩形在整章上下文下的定位。 3. 修复跨章节高亮串页问题,并调整文本页高亮命中逻辑:保留 CoreText 层绘制,点击时按高亮真实 rect 精确命中,避免重复绘制和整行误判。 4. 收紧 reader 级页面缓存策略,避免预加载同时持有多份整章显示副本带来的内存放大。 5. 新增分页边界校验器、垂直对齐器和 settings-flip 自动化调试入口,用于复现与诊断页范围/显示度量不一致问题。 6. 放宽 inline attachment 的 avoid-break 处理,并补充相关分页问题调查文档与索引。
225 lines
6.8 KiB
Swift
225 lines
6.8 KiB
Swift
import UIKit
|
|
|
|
#if canImport(DTCoreText)
|
|
import DTCoreText
|
|
#endif
|
|
|
|
struct RDEPUBPageLine {
|
|
|
|
let stringRange: NSRange
|
|
|
|
let frame: CGRect
|
|
|
|
let baselineOrigin: CGPoint
|
|
|
|
let ascent: CGFloat
|
|
|
|
let descent: CGFloat
|
|
|
|
let leading: CGFloat
|
|
}
|
|
|
|
struct RDEPUBPageRun {
|
|
|
|
let stringRange: NSRange
|
|
|
|
let frame: CGRect
|
|
|
|
let isAttachment: Bool
|
|
}
|
|
|
|
struct RDEPUBPageAttachment {
|
|
|
|
let stringRange: NSRange
|
|
|
|
let frame: CGRect
|
|
|
|
let displaySize: CGSize
|
|
|
|
let placement: RDEPUBTextAttachmentPlacement?
|
|
|
|
let kind: RDEPUBTextAttachmentKind?
|
|
}
|
|
|
|
struct RDEPUBPageLayoutSnapshot {
|
|
|
|
let page: RDEPUBTextPage
|
|
|
|
let lines: [RDEPUBPageLine]
|
|
|
|
let runs: [RDEPUBPageRun]
|
|
|
|
let attachments: [RDEPUBPageAttachment]
|
|
|
|
let pageContentRange: NSRange
|
|
#if canImport(DTCoreText)
|
|
|
|
let layoutFrame: DTCoreTextLayoutFrame
|
|
#endif
|
|
|
|
var contentBounds: CGRect {
|
|
let rects = lines.map(\.frame) + attachments.map(\.frame)
|
|
guard var bounds = rects.first else { return .zero }
|
|
for rect in rects.dropFirst() {
|
|
bounds = bounds.union(rect)
|
|
}
|
|
return bounds
|
|
}
|
|
|
|
func line(containing absoluteIndex: Int) -> RDEPUBPageLine? {
|
|
lines.first { NSLocationInRange(absoluteIndex, $0.stringRange) }
|
|
}
|
|
|
|
func run(containing absoluteIndex: Int) -> RDEPUBPageRun? {
|
|
runs.first { NSLocationInRange(absoluteIndex, $0.stringRange) }
|
|
}
|
|
|
|
func runs(intersecting range: NSRange) -> [RDEPUBPageRun] {
|
|
runs.filter { NSIntersectionRange($0.stringRange, range).length > 0 }
|
|
}
|
|
|
|
func attachment(at point: CGPoint, hitSlop: CGFloat = 6) -> RDEPUBPageAttachment? {
|
|
attachments.first { $0.frame.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point) }
|
|
}
|
|
|
|
func line(at point: CGPoint, hitSlop: CGFloat = 4) -> RDEPUBPageLine? {
|
|
lines.first { line in
|
|
let lineRect = CGRect(
|
|
x: line.frame.minX,
|
|
y: line.baselineOrigin.y - line.ascent,
|
|
width: max(line.frame.width, 1),
|
|
height: line.ascent + line.descent + line.leading
|
|
)
|
|
return lineRect.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point)
|
|
}
|
|
}
|
|
|
|
func run(at point: CGPoint, hitSlop: CGFloat = 4) -> RDEPUBPageRun? {
|
|
runs.first { $0.frame.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point) }
|
|
}
|
|
|
|
func rects(containing point: CGPoint, in decorations: [RDEPUBTextOverlayDecoration]) -> [RDEPUBTextOverlayDecoration] {
|
|
decorations.filter { decoration in
|
|
decoration.rects.contains { $0.insetBy(dx: -4, dy: -4).contains(point) }
|
|
}
|
|
}
|
|
|
|
func absoluteRange(at point: CGPoint, in decorations: [RDEPUBTextOverlayDecoration]) -> NSRange? {
|
|
if let attachment = attachment(at: point) {
|
|
return attachment.stringRange
|
|
}
|
|
if let run = run(at: point) {
|
|
return run.stringRange
|
|
}
|
|
let hitDecorations = rects(containing: point, in: decorations)
|
|
if let mostSpecific = hitDecorations.min(by: { lhs, rhs in
|
|
lhs.absoluteRange.length < rhs.absoluteRange.length
|
|
}) {
|
|
return mostSpecific.absoluteRange
|
|
}
|
|
return line(at: point)?.stringRange
|
|
}
|
|
|
|
#if canImport(DTCoreText)
|
|
|
|
static func build(
|
|
from layoutFrame: DTCoreTextLayoutFrame,
|
|
page: RDEPUBTextPage
|
|
) -> RDEPUBPageLayoutSnapshot? {
|
|
guard let dtLines = layoutFrame.lines as? [DTCoreTextLayoutLine], !dtLines.isEmpty else {
|
|
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] = []
|
|
|
|
for dtLine in dtLines {
|
|
let lineRange = dtLine.stringRange()
|
|
let line = RDEPUBPageLine(
|
|
stringRange: lineRange,
|
|
frame: dtLine.frame,
|
|
baselineOrigin: dtLine.baselineOrigin,
|
|
ascent: dtLine.ascent,
|
|
descent: dtLine.descent,
|
|
leading: dtLine.leading
|
|
)
|
|
lines.append(line)
|
|
|
|
if let glyphRuns = dtLine.glyphRuns as? [DTCoreTextGlyphRun] {
|
|
for run in glyphRuns {
|
|
let runRange = run.stringRange()
|
|
let isAttachment = run.attachment != nil
|
|
runs.append(
|
|
RDEPUBPageRun(
|
|
stringRange: runRange,
|
|
frame: run.frame,
|
|
isAttachment: isAttachment
|
|
)
|
|
)
|
|
|
|
guard isAttachment else { continue }
|
|
let metadata = attachmentMetadata(
|
|
for: runRange,
|
|
on: page
|
|
)
|
|
attachments.append(
|
|
RDEPUBPageAttachment(
|
|
stringRange: runRange,
|
|
frame: run.frame,
|
|
displaySize: run.frame.size,
|
|
placement: metadata.placement,
|
|
kind: metadata.kind
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
let visibleRange = layoutFrame.visibleStringRange()
|
|
|
|
return RDEPUBPageLayoutSnapshot(
|
|
page: page,
|
|
lines: lines,
|
|
runs: runs,
|
|
attachments: attachments,
|
|
pageContentRange: visibleRange,
|
|
layoutFrame: layoutFrame
|
|
)
|
|
}
|
|
|
|
private static func attachmentMetadata(
|
|
for range: NSRange,
|
|
on page: RDEPUBTextPage
|
|
) -> (placement: RDEPUBTextAttachmentPlacement?, kind: RDEPUBTextAttachmentKind?) {
|
|
guard let attachmentIndex = page.metadata.attachmentRanges.firstIndex(where: { NSIntersectionRange($0, range).length > 0 }) else {
|
|
return (nil, nil)
|
|
}
|
|
|
|
let placement = page.metadata.attachmentPlacements.indices.contains(attachmentIndex)
|
|
? page.metadata.attachmentPlacements[attachmentIndex]
|
|
: nil
|
|
let metadataKind = page.metadata.attachmentKinds.indices.contains(attachmentIndex)
|
|
? page.metadata.attachmentKinds[attachmentIndex]
|
|
: nil
|
|
let resolvedKind = attachmentKind(at: range, on: page) ?? metadataKind
|
|
return (placement, resolvedKind)
|
|
}
|
|
|
|
private static func attachmentKind(
|
|
at range: NSRange,
|
|
on page: RDEPUBTextPage
|
|
) -> RDEPUBTextAttachmentKind? {
|
|
guard range.location >= 0,
|
|
range.location < page.chapterContent.length else {
|
|
return nil
|
|
}
|
|
let attributes = page.chapterContent.attributes(at: range.location, effectiveRange: nil)
|
|
return RDEPUBAttachmentNormalizer.attachmentKind(for: attributes)
|
|
}
|
|
|
|
#endif
|
|
}
|