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] = [] 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 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 }