Files
ReadViewSDK/Sources/RDReaderView/EPUBUI/RDEPUBPageLayoutSnapshot.swift
T

224 lines
7.6 KiB
Swift

import UIKit
#if canImport(DTCoreText)
import DTCoreText
#endif
// MARK: - 页面排版快照数据结构
/// 文本行信息
/// 描述 DTCoreText 排版后的一行文本的位置、范围和基线信息
struct RDEPUBPageLine {
/// 行内文本在 chapterContent 中的绝对字符范围
let stringRange: NSRange
/// 行的框架(相对于排版区域)
let frame: CGRect
/// 行的基线原点
let baselineOrigin: CGPoint
/// 基线以上的高度(升部)
let ascent: CGFloat
/// 基线以下的高度(降部)
let descent: CGFloat
/// 行间距
let leading: CGFloat
}
/// 文本 Run 信息
/// 描述一行内某个连续绘制单元(如普通文字或附件)
struct RDEPUBPageRun {
/// Run 内文本的绝对字符范围
let stringRange: NSRange
/// Run 的绘制框架
let frame: CGRect
/// 是否为附件(图片等)
let isAttachment: Bool
}
/// 附件信息
/// 描述嵌入在文本中的图片或其他附件
struct RDEPUBPageAttachment {
/// 附件在文本中的绝对字符范围
let stringRange: NSRange
/// 附件的显示框架
let frame: CGRect
/// 附件的建议显示尺寸
let displaySize: CGSize
/// 附件的布局方式(行内/浮动等)
let placement: RDEPUBTextAttachmentPlacement?
/// 附件类型(封面图/普通图片等)
let kind: RDEPUBTextAttachmentKind?
}
// MARK: - 页面排版快照
/// 页面排版快照
/// 封装 DTCoreText 的排版结果,提供高效的文本位置查询能力
/// 用于支持文本选择、高亮渲染和搜索结果定位
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,
contentOffsetBase: Int
) -> RDEPUBPageLayoutSnapshot? {
guard let dtLines = layoutFrame.lines as? [DTCoreTextLayoutLine], !dtLines.isEmpty else {
return nil
}
var lines: [RDEPUBPageLine] = []
var runs: [RDEPUBPageRun] = []
var attachments: [RDEPUBPageAttachment] = []
let pageOffset = contentOffsetBase
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 kind = page.metadata.attachmentKinds.indices.contains(attachmentIndex)
? page.metadata.attachmentKinds[attachmentIndex]
: nil
return (placement, kind)
}
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
}