refactor: split reader architecture and chrome handling
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user