215 lines
7.2 KiB
Swift
215 lines
7.2 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
|
||
) -> RDEPUBPageLayoutSnapshot? {
|
||
guard let dtLines = layoutFrame.lines as? [DTCoreTextLayoutLine], !dtLines.isEmpty else {
|
||
return nil
|
||
}
|
||
|
||
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 kind = page.metadata.attachmentKinds.indices.contains(attachmentIndex)
|
||
? page.metadata.attachmentKinds[attachmentIndex]
|
||
: nil
|
||
return (placement, kind)
|
||
}
|
||
#endif
|
||
}
|