ReadViewSDK/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBPageLayoutSnapshot.swift
shenlei d7f28c3f10 fix: 修复右对齐短文本显示不完整及多项功能改进
右对齐文本显示修复(如"巫鸿"只显示"鸿"的问题):
- RDEPUBChapterTailNormalizer: 短尾页合并时检查 avoidPageBreakInside 语义,
  避免将带有此标记的短文本(如 right-info 署名)错误合并回上一页
- RDEPUBTextContentView: 续段归一化时跳过右对齐/居中对齐的段落,
  防止错误修改 firstLineHeadIndent 导致首字不可见
- RDEPUBCSSCompatibilityLayer: 为含 text-align:right/center 的 CSS 块
  自动注入 text-indent:0 !important,确保右对齐/居中文本无首行缩进
- RDEPUBTextRendererSupport: 排版属性归一化时将右对齐/居中段落的
  firstLineHeadIndent 重置为 0

图片查看器及脚注点击功能:
- 新增 RDEPUBImageViewController 和 RDEPUBImageViewerCoordinator,
  支持从 WebView 和 TextPage 两种模式查看图片
- epub-bridge.js: 检测图片和脚注图片的点击事件,脚注图片显示弹窗
- RDEPUBJavaScriptBridge: 新增 imageDidTap/footnoteDidTap 桥接消息
- RDEPUBWebView: 新增图片和脚注点击的 delegate 方法
- RDEPUBAttachmentNormalizer: 改进脚注检测,优先使用 alt 文本判断
- RDEPUBPaginationModels: 新增 .footnote 附件类型
- RDEPUBPageLayoutSnapshot: 运行时动态解析附件类型,脚注优先级高于图片

位置解析改进:
- RDEPUBReaderController+LocationResolution: 利用 rangeInfo 提升页码定位精度

其他:
- RDEPUBTextBookCache: schema 版本升级至 13
- RDEPUBReaderTheme: 主题更新
- Pod 项目文件更新

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-25 15:39:58 +08:00

230 lines
7.1 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
}
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 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 = offset(run.stringRange(), by: pageOffset)
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 = offset(layoutFrame.visibleStringRange(), by: pageOffset)
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)
}
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
}