Files
ReadViewSDK/Sources/RDPDFReaderView/Sources/RDPDFReaderImageTextLayerView.swift
T
2026-07-27 21:43:13 +08:00

1075 lines
43 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import UIKit
/// 图片 PDF 页面上的当前选择。所有矩形均以页面图片左上角为原点,使用 0...1 的比例坐标。
///
/// `text` 在区域选择时为 `nil`,调用方可据此隐藏“复制”操作,仅保留区域高亮和注释。
public struct RDPDFReaderImageTextSelection: Equatable {
public let text: String?
public let rects: [CGRect]
public let source: RDPDFReaderAnnotationSource
public init(text: String?, rects: [CGRect], source: RDPDFReaderAnnotationSource) {
self.text = text
self.rects = rects
self.source = source
}
/// 与 `RDPDFReaderAnnotation.normalizedRects` 对齐的别名,便于直接创建标注模型。
public var normalizedRects: [CGRect] { rects }
/// 当前选择覆盖的最小外接范围,适合用于定位动作菜单。
public var normalizedRect: CGRect {
rects.reduce(into: CGRect.null) { result, rect in
result = result.union(rect)
}
}
/// 文字选择才允许复制;区域选择应仅提供高亮和注释。
public var canCopyText: Bool {
source != .region && !(text?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
}
}
/// 选区交互所处的阶段,语义与 EPUB 文本选择保持一致:
/// 菜单只应在 `selectionActive` 时出现,拖动手柄或长按扩选期间应隐藏。
public enum RDPDFReaderSelectionInteractionState: String, Equatable {
case idle
case selecting
case selectionActive
case adjustingHandle
}
/// 选区两端的边界手柄。
public enum RDPDFReaderSelectionHandle {
case start
case end
}
/// 覆盖在已解析图片页面内容上的文字选择与标注层。
///
/// 此视图必须与图片放在同一个内容坐标系中(例如 `RDPDFZoomablePageView.contentView`),
/// 这样缩放或平移页面时,文字选区和已保存划线都会与图片保持一致。
///
/// 文字选择与 EPUB 阅读器同一套交互:长按选中整词,继续拖动按词扩选,
/// 松手后可拖动首尾手柄按词微调边界;长按空白后拖动仍是区域框选。
public final class RDPDFReaderImageTextLayerView: UIView, UIGestureRecognizerDelegate {
/// 当前图片页的索引。`annotations` 可以包含多页数据,绘制时只显示本页的标注。
public var pageIndex: Int = 0 {
didSet {
guard pageIndex != oldValue else { return }
clearSelection()
updateAccessibilityValue()
setNeedsDisplay()
}
}
/// 当前页面可命中的文字行。宿主文本和 OCR 文本都使用同一坐标体系。
public var textRuns: [RDPDFReaderTextRun] = [] {
didSet {
rebuildGlyphTable()
if selectedSelection?.source != .region {
clearSelection()
}
updateAccessibilityValue()
setNeedsDisplay()
}
}
/// 已保存的高亮/注释。可直接传入整本书的标注,视图会按 `pageIndex` 过滤。
public var annotations: [RDPDFReaderAnnotation] = [] {
didSet {
updateAccessibilityValue()
setNeedsDisplay()
}
}
/// 当前朗读句的临时高亮。它不参与点击、复制或持久化,避免与用户标注混淆。
public var speechHighlightRects: [CGRect] = [] {
didSet { setNeedsDisplay() }
}
/// `textRuns` 的来源。主程序解析的文本使用 `.text`SDK OCR 结果使用 `.ocr`。
/// 区域框选始终使用 `.region`,不会受此属性影响。
public var textSource: RDPDFReaderAnnotationSource = .text {
didSet {
guard textSource != oldValue else { return }
if selectedSelection?.source != .region {
clearSelection()
}
updateAccessibilityValue()
}
}
/// 可由阅读器在画笔、翻页动画或编辑面板显示期间临时关闭选择行为。
public var isSelectionEnabled = true {
didSet {
longPressGestureRecognizer.isEnabled = isSelectionEnabled
selectionHandlePanGestureRecognizer.isEnabled = isSelectionEnabled
if !isSelectionEnabled { clearSelection() }
}
}
/// 是否允许在未命中文字时拖出自由区域选区。
///
/// 默认关闭以与 EPUB 的文字选区保持一致:长按空白不应生成跨行的大矩形。
/// 需要图片/扫描件区域标注时,由宿主在进入明确的“区域标注”工具模式后再开启。
public var allowsRegionSelection = false {
didSet {
guard !allowsRegionSelection, selectedSelection?.source == .region else { return }
clearSelection()
}
}
/// 当前选区。文字选择包含可复制文本;区域选择的 `text` 为 `nil`。
public private(set) var selectedSelection: RDPDFReaderImageTextSelection? {
didSet {
guard oldValue != selectedSelection else { return }
updateAccessibilityValue()
setNeedsDisplay()
onSelectionChanged?(selectedSelection)
}
}
/// 选区交互阶段。上层依此决定何时显示锚定在选区旁的操作菜单。
public private(set) var interactionState: RDPDFReaderSelectionInteractionState = .idle {
didSet {
guard oldValue != interactionState else { return }
updateAccessibilityValue()
onInteractionStateChanged?(interactionState)
}
}
/// 选择变化回调;上层可据此显示复制、高亮、注释动作,并暂停翻页手势。
public var onSelectionChanged: ((RDPDFReaderImageTextSelection?) -> Void)?
/// 交互阶段变化回调。`selectionActive` 时显示菜单,其余阶段隐藏。
public var onInteractionStateChanged: ((RDPDFReaderSelectionInteractionState) -> Void)?
/// 拖选或拖动手柄期间的触点(本视图坐标),供上层展示放大镜;`nil` 表示拖动结束。
public var onSelectionFocusChanged: ((CGPoint?) -> Void)?
/// 暴露给阅读器容器,用于与翻页、缩放、画笔等手势建立失败关系。
public let longPressGestureRecognizer = UILongPressGestureRecognizer()
/// 选区激活后拖动首尾手柄的手势。仅在触点落在手柄附近时才会开始。
public let selectionHandlePanGestureRecognizer = UIPanGestureRecognizer()
/// 当前页面中带有笔记文本的标注。
///
/// EPUB 不为注释单独绘制圆点;保留这个查询接口仅用于兼容已有调用方,
/// 页面点击仍通过划线本身进入统一的标注菜单。
public var currentPageNotes: [RDPDFReaderAnnotation] {
currentPageAnnotations.filter { !($0.note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) }
}
/// 页内单个组合字符及其在页面文本中的位置。选区以字符为最小单位。
private struct Glyph {
let runIndex: Int
let textRange: NSRange
let normalizedRect: CGRect
}
private enum InteractionMode {
case idle
case text
case region(anchor: CGPoint)
}
private var interactionMode: InteractionMode = .idle
private var orderedTextRunIndices: [Int] = []
private var glyphs: [Glyph] = []
private var pageText: NSString = ""
private var cachedWordRanges: [NSRange]?
private var selectionAnchorGlyph = NSNotFound
private var selectionStartGlyph = NSNotFound
private var selectionEndGlyph = NSNotFound
private var activeHandle: RDPDFReaderSelectionHandle?
private let selectionColor = UIColor(red: 0.14, green: 0.42, blue: 0.95, alpha: 1)
private let handleColor = UIColor(red: 20 / 255, green: 122 / 255, blue: 1, alpha: 1)
private let handleStemWidth: CGFloat = 2.5
private let handleKnobRadius: CGFloat = 7
private let handleHitSlop: CGFloat = 20
public override init(frame: CGRect) {
super.init(frame: frame)
configureView()
}
public required init?(coder: NSCoder) {
super.init(coder: coder)
configureView()
}
public override func draw(_ rect: CGRect) {
super.draw(rect)
guard let context = UIGraphicsGetCurrentContext(), bounds.width > 0, bounds.height > 0 else { return }
context.clear(rect)
currentPageAnnotations.forEach { draw(annotation: $0, in: context) }
drawSpeechHighlight(in: context)
if let selectedSelection {
draw(selection: selectedSelection, in: context)
}
}
/// 清除当前选择并让上层隐藏菜单。
public func clearSelection() {
interactionMode = .idle
activeHandle = nil
selectionAnchorGlyph = NSNotFound
selectionStartGlyph = NSNotFound
selectionEndGlyph = NSNotFound
interactionState = .idle
onSelectionFocusChanged?(nil)
selectedSelection = nil
}
private func drawSpeechHighlight(in context: CGContext) {
guard !speechHighlightRects.isEmpty else { return }
context.setFillColor(UIColor(red: 0.18, green: 0.50, blue: 0.95, alpha: 0.24).cgColor)
for normalizedRect in speechHighlightRects.compactMap(clampedNormalizedRect) {
context.fill(contentRect(from: normalizedRect))
}
}
/// 当前选区的外接矩形(本视图坐标),用于锚定选区操作菜单。
public func menuAnchorRect() -> CGRect? {
guard let selection = selectedSelection else { return nil }
let rects = selection.rects.compactMap(clampedNormalizedRect).map(contentRect(from:))
guard !rects.isEmpty else { return nil }
return rects.dropFirst().reduce(rects[0]) { $0.union($1) }
}
/// 命中测试选区手柄。输入点使用本视图坐标。
public func selectionHandle(at point: CGPoint) -> RDPDFReaderSelectionHandle? {
guard let geometry = selectionHandleGeometry else { return nil }
let startDistance = hypot(point.x - geometry.startKnobCenter.x, point.y - geometry.startKnobCenter.y)
let endDistance = hypot(point.x - geometry.endKnobCenter.x, point.y - geometry.endKnobCenter.y)
let maxDistance = handleKnobRadius + handleHitSlop
switch (startDistance <= maxDistance, endDistance <= maxDistance) {
case (true, true):
return startDistance <= endDistance ? .start : .end
case (true, false):
return .start
case (false, true):
return .end
default:
return nil
}
}
/// 供外部 UI 在点击笔记标记后判断命中的标注。输入点与本视图使用同一内容坐标系。
public func note(at point: CGPoint, hitSlop: CGFloat = 18) -> RDPDFReaderAnnotation? {
guard !currentPageNotes.isEmpty, bounds.width > 0, bounds.height > 0 else { return nil }
let hitSlopSquared = hitSlop * hitSlop
return currentPageNotes.min { lhs, rhs in
squaredDistance(from: point, to: noteMarkerCenter(for: lhs)) < squaredDistance(from: point, to: noteMarkerCenter(for: rhs))
}.flatMap { annotation in
squaredDistance(from: point, to: noteMarkerCenter(for: annotation)) <= hitSlopSquared ? annotation : nil
}
}
// MARK: - Gesture handling
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
guard isSelectionEnabled else { return }
let location = gesture.location(in: self)
let point = normalizedPoint(from: location)
switch gesture.state {
case .began:
// 比严格字框多留一点手指容差,避免落在字符边缘时误判为空白。
if let glyphIndex = nearestGlyphIndex(at: point, maxDistanceInPoints: 12) {
interactionMode = .text
interactionState = .selecting
beginWordSelection(at: glyphIndex)
onSelectionFocusChanged?(location)
} else if allowsRegionSelection {
// 未命中文本时进入区域框选;只有拖出最小距离后才会产生有效选区。
interactionMode = .region(anchor: point)
interactionState = .selecting
selectedSelection = nil
} else {
// EPUB 同样只对实际文本响应长按;空白区域保持阅读器的普通交互。
interactionMode = .idle
interactionState = .idle
onSelectionFocusChanged?(nil)
}
case .changed:
switch interactionMode {
case .text:
if let glyphIndex = nearestGlyphIndex(at: point) {
extendSelection(to: glyphIndex)
}
onSelectionFocusChanged?(location)
case .region(let anchor):
selectedSelection = makeRegionSelection(from: anchor, to: point)
case .idle:
break
}
case .ended:
onSelectionFocusChanged?(nil)
if selectedSelection == nil {
// 仅长按但未形成有效选区时不应显示无意义的菜单。
clearSelection()
} else {
interactionMode = .idle
interactionState = .selectionActive
}
case .cancelled, .failed:
clearSelection()
default:
break
}
}
@objc private func handleHandlePan(_ gesture: UIPanGestureRecognizer) {
let location = gesture.location(in: self)
switch gesture.state {
case .began:
guard let handle = selectionHandle(at: location) else {
gesture.state = .cancelled
return
}
activeHandle = handle
interactionState = .adjustingHandle
onSelectionFocusChanged?(location)
case .changed:
guard let handle = activeHandle else { return }
adjustSelection(byMoving: handle, to: normalizedPoint(from: location))
onSelectionFocusChanged?(location)
case .ended, .cancelled, .failed:
activeHandle = nil
onSelectionFocusChanged?(nil)
interactionState = selectedSelection == nil ? .idle : .selectionActive
default:
break
}
}
public override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
// UIKit 会就“命中本视图的所有手势”(含外层翻页滚动、阅读器捏合)调用此方法;
// 这里只允许约束本层自己的选择手势,其余一律交还默认判定,否则画笔会话
// 关闭选区(isSelectionEnabled = false)时会连带否决外层的拖动与缩放。
let ownsGesture = gestureRecognizer === longPressGestureRecognizer
|| gestureRecognizer === selectionHandlePanGestureRecognizer
guard ownsGesture else { return super.gestureRecognizerShouldBegin(gestureRecognizer) }
guard isSelectionEnabled, bounds.width > 0, bounds.height > 0 else { return false }
if gestureRecognizer === selectionHandlePanGestureRecognizer {
guard let selection = selectedSelection, selection.source != .region else { return false }
return selectionHandle(at: gestureRecognizer.location(in: self)) != nil
}
return true
}
public func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
// 选择态必须独占单指输入,外层阅读器可通过暴露的手势再精确设置失败关系。
false
}
// MARK: - Glyph table
private func rebuildGlyphTable() {
orderedTextRunIndices = textRuns.indices.sorted { lhs, rhs in
let left = textRuns[lhs]
let right = textRuns[rhs]
if left.readingOrder != right.readingOrder {
return left.readingOrder < right.readingOrder
}
return lhs < rhs
}
var text = ""
var utf16Length = 0
var newGlyphs: [Glyph] = []
for runIndex in orderedTextRunIndices {
let run = textRuns[runIndex]
let characters = Array(run.text)
guard !characters.isEmpty else { continue }
let rects = resolvedCharacterRects(for: run, characterCount: characters.count)
guard rects.count == characters.count else { continue }
if !newGlyphs.isEmpty {
text += "\n"
utf16Length += 1
}
for (character, rect) in zip(characters, rects) {
let unitCount = String(character).utf16.count
newGlyphs.append(Glyph(
runIndex: runIndex,
textRange: NSRange(location: utf16Length, length: unitCount),
normalizedRect: rect
))
text.append(character)
utf16Length += unitCount
}
}
pageText = text as NSString
glyphs = newGlyphs
cachedWordRanges = nil
}
private func resolvedCharacterRects(for run: RDPDFReaderTextRun, characterCount: Int) -> [CGRect] {
if let characterRects = run.characterRects, characterRects.count == characterCount {
let clamped = characterRects.compactMap(clampedNormalizedRect)
if clamped.count == characterCount {
return clamped
}
}
return approximatedCharacterRects(for: run, characterCount: characterCount)
}
/// 宿主/OCR 未提供字符坐标时,把行矩形按宽度均分为字符格子。
/// 变宽字体下边界会有偏差,但仍可支持字符级选择与词吸附。
private func approximatedCharacterRects(for run: RDPDFReaderTextRun, characterCount: Int) -> [CGRect] {
let lineRects = run.normalizedRects.compactMap(clampedNormalizedRect)
guard !lineRects.isEmpty, characterCount > 0 else { return [] }
let totalWidth = lineRects.reduce(0) { $0 + $1.width }
guard totalWidth > 0 else { return [] }
var result: [CGRect] = []
var assigned = 0
var cumulativeWidth: CGFloat = 0
for (index, lineRect) in lineRects.enumerated() {
cumulativeWidth += lineRect.width
let target = index == lineRects.count - 1
? characterCount
: Int((CGFloat(characterCount) * cumulativeWidth / totalWidth).rounded())
let count = max(0, min(target, characterCount) - assigned)
guard count > 0 else { continue }
let characterWidth = lineRect.width / CGFloat(count)
for slot in 0..<count {
result.append(CGRect(
x: lineRect.minX + CGFloat(slot) * characterWidth,
y: lineRect.minY,
width: characterWidth,
height: lineRect.height
))
}
assigned += count
}
return result
}
// MARK: - Selection
private func beginWordSelection(at glyphIndex: Int) {
selectionAnchorGlyph = glyphIndex
let wordRange = snappedWordRange(forGlyphAt: glyphIndex)
selectionStartGlyph = firstGlyphIndex(intersecting: wordRange) ?? glyphIndex
selectionEndGlyph = lastGlyphIndex(intersecting: wordRange) ?? glyphIndex
applyGlyphSelection()
}
private func extendSelection(to glyphIndex: Int) {
guard selectionAnchorGlyph != NSNotFound else { return }
// 与 EPUB 相同:拖动方向的边界吸附到词边界,锚点一侧保持原位。
let handle: RDPDFReaderSelectionHandle = glyphIndex >= selectionAnchorGlyph ? .end : .start
selectionStartGlyph = selectionAnchorGlyph
selectionEndGlyph = snappedBoundaryGlyph(for: glyphIndex, handle: handle)
applyGlyphSelection()
}
private func adjustSelection(byMoving handle: RDPDFReaderSelectionHandle, to point: CGPoint) {
guard let glyphIndex = nearestGlyphIndex(at: point, maxDistanceInPoints: 44),
selectionStartGlyph != NSNotFound,
selectionEndGlyph != NSNotFound else {
return
}
let lower = min(selectionStartGlyph, selectionEndGlyph)
let upper = max(selectionStartGlyph, selectionEndGlyph)
switch handle {
case .start:
selectionStartGlyph = min(snappedBoundaryGlyph(for: glyphIndex, handle: .start), upper)
selectionEndGlyph = upper
case .end:
selectionStartGlyph = lower
selectionEndGlyph = max(snappedBoundaryGlyph(for: glyphIndex, handle: .end), lower)
}
applyGlyphSelection()
}
private func applyGlyphSelection() {
guard selectionStartGlyph != NSNotFound,
selectionEndGlyph != NSNotFound,
!glyphs.isEmpty else {
selectedSelection = nil
return
}
let lower = max(0, min(selectionStartGlyph, selectionEndGlyph))
let upper = min(glyphs.count - 1, max(selectionStartGlyph, selectionEndGlyph))
guard lower <= upper else {
selectedSelection = nil
return
}
let rects = mergedLineRects(forGlyphsIn: lower...upper)
guard !rects.isEmpty else {
selectedSelection = nil
return
}
let textLocation = glyphs[lower].textRange.location
let textRange = NSRange(location: textLocation, length: NSMaxRange(glyphs[upper].textRange) - textLocation)
let text = pageText.substring(with: textRange)
selectedSelection = RDPDFReaderImageTextSelection(
text: text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : text,
rects: rects,
source: textSource
)
}
/// 把连续选中的字符矩形合并为按行的整段矩形,供绘制与保存标注使用。
private func mergedLineRects(forGlyphsIn range: ClosedRange<Int>) -> [CGRect] {
var segments: [CGRect] = []
var current: CGRect?
var currentRunIndex = -1
for index in range {
let glyph = glyphs[index]
guard let rect = clampedNormalizedRect(glyph.normalizedRect) else { continue }
if let existing = current,
glyph.runIndex == currentRunIndex,
abs(existing.midY - rect.midY) <= max(existing.height, rect.height) * 0.6 {
current = existing.union(rect)
} else {
if let existing = current { segments.append(existing) }
current = rect
currentRunIndex = glyph.runIndex
}
}
if let current { segments.append(current) }
return segments
}
// MARK: - Word boundaries
private var wordRanges: [NSRange] {
if let cachedWordRanges { return cachedWordRanges }
var ranges: [NSRange] = []
pageText.enumerateSubstrings(
in: NSRange(location: 0, length: pageText.length),
options: [.byWords, .substringNotRequired]
) { _, substringRange, _, _ in
guard substringRange.length > 0 else { return }
ranges.append(substringRange)
}
cachedWordRanges = ranges
return ranges
}
/// 与 EPUB 相同的词吸附规则:优先返回包含该字符的词;字符是空白时吸附到最近的词;
/// 标点等不属于任何词的字符退回字符本身。
private func snappedWordRange(forGlyphAt glyphIndex: Int) -> NSRange {
let characterRange = glyphs[glyphIndex].textRange
if let containing = wordRanges.first(where: {
NSIntersectionRange($0, characterRange).length > 0 || NSLocationInRange(characterRange.location, $0)
}) {
return containing
}
if characterRange.location < pageText.length,
let scalar = UnicodeScalar(pageText.character(at: characterRange.location)),
CharacterSet.whitespacesAndNewlines.contains(scalar),
let nearest = nearestWordRange(to: characterRange.location) {
return nearest
}
return characterRange
}
private func nearestWordRange(to location: Int) -> NSRange? {
var nearestRange: NSRange?
var nearestDistance = Int.max
for range in wordRanges {
let distance: Int
if location < range.location {
distance = range.location - location
} else if location >= NSMaxRange(range) {
distance = location - (NSMaxRange(range) - 1)
} else {
distance = 0
}
if distance < nearestDistance {
nearestDistance = distance
nearestRange = range
}
}
return nearestRange
}
private func snappedBoundaryGlyph(for glyphIndex: Int, handle: RDPDFReaderSelectionHandle) -> Int {
let wordRange = snappedWordRange(forGlyphAt: glyphIndex)
switch handle {
case .start:
return firstGlyphIndex(intersecting: wordRange) ?? glyphIndex
case .end:
return lastGlyphIndex(intersecting: wordRange) ?? glyphIndex
}
}
private func firstGlyphIndex(intersecting range: NSRange) -> Int? {
var low = 0
var high = glyphs.count
while low < high {
let mid = (low + high) / 2
if NSMaxRange(glyphs[mid].textRange) <= range.location {
low = mid + 1
} else {
high = mid
}
}
guard low < glyphs.count, glyphs[low].textRange.location < NSMaxRange(range) else { return nil }
return low
}
private func lastGlyphIndex(intersecting range: NSRange) -> Int? {
var low = 0
var high = glyphs.count
while low < high {
let mid = (low + high) / 2
if glyphs[mid].textRange.location < NSMaxRange(range) {
low = mid + 1
} else {
high = mid
}
}
let index = low - 1
guard index >= 0, NSMaxRange(glyphs[index].textRange) > range.location else { return nil }
return index
}
// MARK: - Glyph hit testing
private func exactGlyphIndex(at point: CGPoint) -> Int? {
let tolerance = normalizedDistance(forPoints: 8)
return glyphs.firstIndex { glyph in
normalizedRectContains(glyph.normalizedRect, point: point, tolerance: tolerance)
}
}
private func nearestGlyphIndex(at point: CGPoint, maxDistanceInPoints: CGFloat = 28) -> Int? {
if let exact = exactGlyphIndex(at: point) { return exact }
guard !glyphs.isEmpty else { return nil }
let maximumDistance = normalizedDistance(forPoints: maxDistanceInPoints)
var nearest: (index: Int, distance: CGFloat)?
for (index, glyph) in glyphs.enumerated() {
let glyphDistance = distance(from: point, to: glyph.normalizedRect)
guard glyphDistance <= maximumDistance else { continue }
if nearest == nil || glyphDistance < nearest!.distance {
nearest = (index, glyphDistance)
}
}
return nearest?.index
}
private func makeRegionSelection(from anchor: CGPoint, to point: CGPoint) -> RDPDFReaderImageTextSelection? {
let distanceInPoints = hypot((point.x - anchor.x) * bounds.width, (point.y - anchor.y) * bounds.height)
guard distanceInPoints >= 6 else { return nil }
let minimumWidth = min(0.2, normalizedDistance(forHorizontalPoints: 12))
let minimumHeight = min(0.2, normalizedDistance(forVerticalPoints: 12))
var rect = CGRect(
x: min(anchor.x, point.x),
y: min(anchor.y, point.y),
width: abs(point.x - anchor.x),
height: abs(point.y - anchor.y)
)
if rect.width < minimumWidth {
rect = CGRect(x: rect.midX - minimumWidth / 2, y: rect.minY, width: minimumWidth, height: rect.height)
}
if rect.height < minimumHeight {
rect = CGRect(x: rect.minX, y: rect.midY - minimumHeight / 2, width: rect.width, height: minimumHeight)
}
guard let normalizedRect = clampedNormalizedRect(rect) else { return nil }
return RDPDFReaderImageTextSelection(text: nil, rects: [normalizedRect], source: .region)
}
// MARK: - Drawing
private var currentPageAnnotations: [RDPDFReaderAnnotation] {
annotations.filter { $0.pageIndex == pageIndex }
}
private func draw(annotation: RDPDFReaderAnnotation, in context: CGContext) {
let color = UIColor(rdReaderHex: annotation.color, fallback: UIColor(red: 0.97, green: 0.78, blue: 0.16, alpha: 1))
let contentRects = annotation.normalizedRects
.compactMap(clampedNormalizedRect)
.map(contentRect(from:))
guard !contentRects.isEmpty else { return }
// 与 EPUB 的 `RDEPUBSelectionOverlayView` 一致:划线和带注释的
// 划线均只绘制同色下划线,不叠加色块或笔记圆点。
context.saveGState()
context.setStrokeColor(color.cgColor)
context.setLineWidth(2)
for lineRect in contentRects {
let y = lineRect.maxY - 1
context.move(to: CGPoint(x: lineRect.minX, y: y))
context.addLine(to: CGPoint(x: lineRect.maxX, y: y))
context.strokePath()
}
context.restoreGState()
}
private func draw(selection: RDPDFReaderImageTextSelection, in context: CGContext) {
if selection.source == .region {
draw(
normalizedRects: selection.rects,
fillColor: selectionColor.withAlphaComponent(0.13),
strokeColor: selectionColor.withAlphaComponent(0.95),
dashed: true,
in: context
)
} else {
// 文字选区与 EPUB 一致:纯色淡蓝填充加首尾手柄,无描边。
draw(
normalizedRects: selection.rects,
fillColor: selectionColor.withAlphaComponent(0.24),
strokeColor: nil,
dashed: false,
in: context
)
drawSelectionHandles(in: context)
}
}
private func draw(
normalizedRects: [CGRect],
fillColor: UIColor,
strokeColor: UIColor?,
dashed: Bool,
in context: CGContext
) {
let contentRects = normalizedRects.compactMap { clampedNormalizedRect($0) }.map(contentRect(from:))
guard !contentRects.isEmpty else { return }
context.saveGState()
context.setFillColor(fillColor.cgColor)
if let strokeColor {
context.setStrokeColor(strokeColor.cgColor)
context.setLineWidth(1.5)
if dashed { context.setLineDash(phase: 0, lengths: [5, 3]) }
}
for rect in contentRects {
let path = UIBezierPath(roundedRect: rect, cornerRadius: min(3, min(rect.width, rect.height) / 4))
context.addPath(path.cgPath)
context.drawPath(using: strokeColor == nil ? .fill : .fillStroke)
}
context.restoreGState()
}
private var selectionHandleGeometry: (startKnobCenter: CGPoint, endKnobCenter: CGPoint)? {
guard let selection = selectedSelection, selection.source != .region else { return nil }
let rects = selection.rects.compactMap(clampedNormalizedRect).map(contentRect(from:))
guard let firstRect = rects.first, let lastRect = rects.last else { return nil }
return (
startKnobCenter: CGPoint(x: firstRect.minX, y: firstRect.minY - handleKnobRadius),
endKnobCenter: CGPoint(x: lastRect.maxX, y: lastRect.maxY + handleKnobRadius)
)
}
private func drawSelectionHandles(in context: CGContext) {
guard let selection = selectedSelection else { return }
let rects = selection.rects.compactMap(clampedNormalizedRect).map(contentRect(from:))
guard let firstRect = rects.first, let lastRect = rects.last else { return }
context.saveGState()
context.setFillColor(handleColor.cgColor)
let stemHalfWidth = handleStemWidth / 2
let startStem = CGRect(
x: firstRect.minX - stemHalfWidth,
y: firstRect.minY - handleKnobRadius * 2,
width: handleStemWidth,
height: firstRect.height + handleKnobRadius * 2
)
context.fill(startStem)
let startKnob = CGRect(
x: firstRect.minX - handleKnobRadius,
y: firstRect.minY - handleKnobRadius * 2,
width: handleKnobRadius * 2,
height: handleKnobRadius * 2
)
context.fillEllipse(in: startKnob)
let endStem = CGRect(
x: lastRect.maxX - stemHalfWidth,
y: lastRect.minY,
width: handleStemWidth,
height: lastRect.height + handleKnobRadius * 2
)
context.fill(endStem)
let endKnob = CGRect(
x: lastRect.maxX - handleKnobRadius,
y: lastRect.maxY,
width: handleKnobRadius * 2,
height: handleKnobRadius * 2
)
context.fillEllipse(in: endKnob)
context.restoreGState()
}
private func noteMarkerCenter(for annotation: RDPDFReaderAnnotation) -> CGPoint {
let rects = annotation.normalizedRects.compactMap { clampedNormalizedRect($0) }.map(contentRect(from:))
let union = rects.reduce(into: CGRect.null) { result, rect in result = result.union(rect) }
guard !union.isNull else { return .zero }
let radius: CGFloat = 8
return CGPoint(
x: min(bounds.maxX - radius, max(bounds.minX + radius, union.maxX)),
y: min(bounds.maxY - radius, max(bounds.minY + radius, union.minY))
)
}
// MARK: - Coordinates and accessibility
private func configureView() {
isOpaque = false
backgroundColor = .clear
clipsToBounds = true
isUserInteractionEnabled = true
contentScaleFactor = UIScreen.main.scale
longPressGestureRecognizer.addTarget(self, action: #selector(handleLongPress(_:)))
longPressGestureRecognizer.minimumPressDuration = 0.35
longPressGestureRecognizer.allowableMovement = .greatestFiniteMagnitude
longPressGestureRecognizer.cancelsTouchesInView = true
longPressGestureRecognizer.delegate = self
addGestureRecognizer(longPressGestureRecognizer)
selectionHandlePanGestureRecognizer.addTarget(self, action: #selector(handleHandlePan(_:)))
selectionHandlePanGestureRecognizer.maximumNumberOfTouches = 1
selectionHandlePanGestureRecognizer.cancelsTouchesInView = true
selectionHandlePanGestureRecognizer.delegate = self
addGestureRecognizer(selectionHandlePanGestureRecognizer)
accessibilityIdentifier = "pdf.reader.text.layer"
accessibilityLabel = "PDF 文本选择层"
accessibilityHint = "长按文字选中词语后拖动可扩大选区,松手后可拖动手柄微调;长按空白后拖动可框选区域"
accessibilityTraits = .allowsDirectInteraction
updateAccessibilityValue()
}
private func updateAccessibilityValue() {
let annotationCount = currentPageAnnotations.count
let noteCount = currentPageNotes.count
guard let selection = selectedSelection else {
accessibilityValue = "page=\(pageIndex);selection=none;annotations=\(annotationCount);notes=\(noteCount);textRuns=\(textRuns.count);state=\(interactionState.rawValue)"
return
}
let text = selection.text?
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: ";", with: ",")
.prefix(120) ?? ""
accessibilityValue = "page=\(pageIndex);selection=active;source=\(selection.source.rawValue);rects=\(selection.rects.count);text=\(text);annotations=\(annotationCount);notes=\(noteCount);textRuns=\(textRuns.count);state=\(interactionState.rawValue)"
}
private func normalizedPoint(from point: CGPoint) -> CGPoint {
guard bounds.width > 0, bounds.height > 0 else { return .zero }
return CGPoint(
x: min(1, max(0, (point.x - bounds.minX) / bounds.width)),
y: min(1, max(0, (point.y - bounds.minY) / bounds.height))
)
}
private func contentRect(from normalizedRect: CGRect) -> CGRect {
CGRect(
x: bounds.minX + normalizedRect.minX * bounds.width,
y: bounds.minY + normalizedRect.minY * bounds.height,
width: normalizedRect.width * bounds.width,
height: normalizedRect.height * bounds.height
)
}
private func clampedNormalizedRect(_ rect: CGRect) -> CGRect? {
guard !rect.isNull, !rect.isInfinite else { return nil }
let standardized = rect.standardized
guard standardized.minX.isFinite, standardized.minY.isFinite, standardized.maxX.isFinite, standardized.maxY.isFinite else { return nil }
let minX = min(1, max(0, standardized.minX))
let minY = min(1, max(0, standardized.minY))
let maxX = min(1, max(0, standardized.maxX))
let maxY = min(1, max(0, standardized.maxY))
guard maxX > minX, maxY > minY else { return nil }
return CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY)
}
private func normalizedRectContains(_ rect: CGRect, point: CGPoint, tolerance: CGFloat) -> Bool {
guard let normalizedRect = clampedNormalizedRect(rect) else { return false }
return normalizedRect.insetBy(dx: -tolerance, dy: -tolerance).contains(point)
}
private func distance(from point: CGPoint, to rect: CGRect) -> CGFloat {
guard let normalizedRect = clampedNormalizedRect(rect) else { return .greatestFiniteMagnitude }
let dx = max(0, max(normalizedRect.minX - point.x, point.x - normalizedRect.maxX))
let dy = max(0, max(normalizedRect.minY - point.y, point.y - normalizedRect.maxY))
return hypot(dx, dy)
}
private func normalizedDistance(forPoints points: CGFloat) -> CGFloat {
guard bounds.width > 0, bounds.height > 0 else { return 0 }
return points / min(bounds.width, bounds.height)
}
private func normalizedDistance(forHorizontalPoints points: CGFloat) -> CGFloat {
guard bounds.width > 0 else { return 0 }
return points / bounds.width
}
private func normalizedDistance(forVerticalPoints points: CGFloat) -> CGFloat {
guard bounds.height > 0 else { return 0 }
return points / bounds.height
}
private func squaredDistance(from lhs: CGPoint, to rhs: CGPoint) -> CGFloat {
let dx = lhs.x - rhs.x
let dy = lhs.y - rhs.y
return dx * dx + dy * dy
}
}
/// 拖选文字时跟随触点的圆形放大镜,交互样式与 EPUB 阅读器一致。
///
/// 由宿主页面视图持有:`sourceView` 传入与文字层同坐标系的内容视图
/// (例如 `RDPDFZoomablePageView.contentView`),放大镜会截取触点附近内容放大展示。
public final class RDPDFReaderSelectionLoupeView: UIView {
private let imageView = UIImageView()
private let magnification: CGFloat = 1.45
private let captureSize = CGSize(width: 84, height: 84)
public override init(frame: CGRect) {
super.init(frame: CGRect(origin: .zero, size: CGSize(width: 96, height: 96)))
isUserInteractionEnabled = false
backgroundColor = .clear
isHidden = true
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.18
layer.shadowRadius = 10
layer.shadowOffset = CGSize(width: 0, height: 5)
imageView.frame = bounds
imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
imageView.layer.cornerRadius = bounds.width / 2
imageView.layer.cornerCurve = .continuous
imageView.layer.borderWidth = 1.5
imageView.layer.borderColor = UIColor(white: 0.82, alpha: 0.95).cgColor
imageView.clipsToBounds = true
addSubview(imageView)
}
public required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 展示或移动放大镜。`focusPoint` 为 `sourceView` 坐标;`targetPoint` 为宿主视图坐标。
public func present(sourceView: UIView, focusPoint: CGPoint, hostBounds: CGRect, targetPoint: CGPoint) {
imageView.image = snapshot(from: sourceView, focusPoint: focusPoint)
let targetCenter = CGPoint(
x: min(max(targetPoint.x, hostBounds.minX + bounds.width / 2), hostBounds.maxX - bounds.width / 2),
y: min(
max(hostBounds.minY + bounds.height / 2, targetPoint.y - 74),
hostBounds.maxY - bounds.height / 2
)
)
center = targetCenter
if isHidden {
alpha = 0
transform = CGAffineTransform(scaleX: 0.92, y: 0.92)
isHidden = false
UIView.animate(withDuration: 0.12) {
self.alpha = 1
self.transform = .identity
}
}
}
public func dismiss() {
guard !isHidden else { return }
isHidden = true
alpha = 0
imageView.image = nil
}
private func snapshot(from sourceView: UIView, focusPoint: CGPoint) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: captureSize)
return renderer.image { context in
let cgContext = context.cgContext
cgContext.setFillColor(UIColor.systemBackground.cgColor)
cgContext.fill(CGRect(origin: .zero, size: captureSize))
cgContext.translateBy(
x: captureSize.width / 2 - focusPoint.x * magnification,
y: captureSize.height / 2 - focusPoint.y * magnification
)
cgContext.scaleBy(x: magnification, y: magnification)
sourceView.layer.render(in: cgContext)
}
}
}
private extension UIColor {
convenience init(rdReaderHex string: String, fallback: UIColor) {
let hex = string
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "#", with: "")
guard let value = UInt64(hex, radix: 16) else {
self.init(cgColor: fallback.cgColor)
return
}
switch hex.count {
case 3:
let red = CGFloat((value >> 8) & 0xF) / 15
let green = CGFloat((value >> 4) & 0xF) / 15
let blue = CGFloat(value & 0xF) / 15
self.init(red: red, green: green, blue: blue, alpha: 1)
case 6:
self.init(
red: CGFloat((value >> 16) & 0xFF) / 255,
green: CGFloat((value >> 8) & 0xFF) / 255,
blue: CGFloat(value & 0xFF) / 255,
alpha: 1
)
case 8:
self.init(
red: CGFloat((value >> 24) & 0xFF) / 255,
green: CGFloat((value >> 16) & 0xFF) / 255,
blue: CGFloat((value >> 8) & 0xFF) / 255,
alpha: CGFloat(value & 0xFF) / 255
)
default:
self.init(cgColor: fallback.cgColor)
}
}
}