1075 lines
43 KiB
Swift
1075 lines
43 KiB
Swift
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)
|
||
}
|
||
}
|
||
}
|