Files
ReadViewSDK/Sources/RDPDFReaderView/Sources/RDPDFReaderImageTextRecognizer.swift
T
shenlei 95cead5863 修复 PDF 阅读交互稳定性并统一项目技能管理
PDF 阅读器在慢速双指缩放、竖向滚动页高异步更新、页面请求超时和绘画橡皮擦等场景中,存在视口跳动、迟到结果被错误丢弃、缓存边界不明确及笔迹显示与持久化不同步的问题;同一本书的标注读取和并发写入也会产生不必要的磁盘访问或覆盖风险。\n\n调整缩放 inset 计算和竖向阅读锚点恢复,避免缩放变换被重复计入、真实页高到达时改变当前阅读位置。页面请求改为带 token 的超时重试机制,可在缓存窗口内接收有效迟到结果并提供失败重试入口;页面大图、OCR、笔迹和描述缓存统一按当前页前后两页收敛,PDF 标识改为完整内容 SHA-256,避免同名或相近文件复用错误阅读状态。\n\n绘画会话内按路径实时重绘,退出会话后使用按图层派生的位图缓存,确保橡皮擦即时作用于已提交笔迹;同时为笔迹持久化增加版本控制、为标注读写增加同步保护和内存快照,降低复用与并发场景下的状态错乱。\n\n将项目技能统一迁入 .agents/skills,并以 .claude/skills 相对软链接供 Claude Code 读取;新增详细 Git 提交技能,自动审查改动、生成中文提交说明并约束安全推送。

验证:所有项目技能通过 quick_validate;执行 xcodebuild -workspace ReadViewDemo/ReadViewDemo.xcworkspace -scheme ReadViewDemo -configuration Debug -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO build,构建成功。
2026-07-24 14:40:02 +09:00

349 lines
14 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 CoreImage
import UIKit
import Vision
/// 为只有页面图片的宿主提供按需 OCR。识别结果是行级 `RDPDFReaderTextRun`
/// 可直接用于复制、文字高亮和文字选区。
///
/// 回调始终回到主线程;识别工作本身在内部串行队列执行,避免连续翻页时同时发起过多
/// Vision 请求占用 CPU。
public final class RDPDFReaderImageTextRecognizer {
public typealias Completion = ([RDPDFReaderTextRun]) -> Void
/// 默认使用准确模式,适合中文等阅读场景;追求响应速度时可改为 `.fast`。
public var recognitionLevel: VNRequestTextRecognitionLevel
/// 留空时由 Vision 自动选择可用语言。宿主也可显式传入,例如 `["zh-Hans", "en-US"]`。
public var recognitionLanguages: [String]
public var usesLanguageCorrection: Bool
private let processingQueue = DispatchQueue(
label: "com.readoor.pdf-reader.image-text-recognizer",
qos: .userInitiated
)
private let requestLock = NSLock()
private struct PendingRequest {
let workItem: DispatchWorkItem
let completion: Completion
}
private var pendingRequests: [UUID: PendingRequest] = [:]
/// 已进入 `perform` 的请求。`DispatchWorkItem.cancel()` 中断不了它们,
/// 必须对 VNRequest 本身调用 `cancel()`。
private var activeVisionRequests: [UUID: VNRecognizeTextRequest] = [:]
public init(
recognitionLevel: VNRequestTextRecognitionLevel = .accurate,
recognitionLanguages: [String] = [],
usesLanguageCorrection: Bool = true
) {
self.recognitionLevel = recognitionLevel
self.recognitionLanguages = recognitionLanguages
self.usesLanguageCorrection = usesLanguageCorrection
}
/// 异步识别页面图片中的文字。结果的矩形以图片左上角为原点,范围为 0...1。
@discardableResult
public func recognizeTextRuns(in image: UIImage, completion: @escaping Completion) -> UUID {
let requestID = UUID()
guard let cgImage = Self.makeCGImage(from: image) else {
deliver([], to: completion)
return requestID
}
let configuration = Configuration(
recognitionLevel: recognitionLevel,
recognitionLanguages: recognitionLanguages,
usesLanguageCorrection: usesLanguageCorrection
)
let orientation = CGImagePropertyOrientation(orientation: image.imageOrientation)
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
let request = VNRecognizeTextRequest { request, _ in
let observations = request.results as? [VNRecognizedTextObservation] ?? []
let runs = Self.makeTextRuns(from: observations)
guard let completion = self.takeCompletion(for: requestID) else { return }
self.deliver(runs, to: completion)
}
request.recognitionLevel = configuration.recognitionLevel
request.recognitionLanguages = configuration.recognitionLanguages
request.usesLanguageCorrection = configuration.usesLanguageCorrection
guard self.registerActiveVisionRequest(request, for: requestID) else { return }
var performFailed = false
do {
try VNImageRequestHandler(cgImage: cgImage, orientation: orientation).perform([request])
} catch {
performFailed = true
}
self.unregisterActiveVisionRequest(requestID)
if performFailed {
guard let completion = self.takeCompletion(for: requestID) else { return }
self.deliver([], to: completion)
}
}
requestLock.lock()
pendingRequests[requestID] = PendingRequest(workItem: workItem, completion: completion)
requestLock.unlock()
processingQueue.async(execute: workItem)
return requestID
}
/// 取消尚未完成的识别请求。每个被取消的请求仍会以空结果完成一次,保证回调和
/// Swift Concurrency 调用方不会因快速翻页永久挂起。
public func cancelAllRequests() {
requestLock.lock()
let cancelled = Array(pendingRequests.values)
let inFlight = Array(activeVisionRequests.values)
pendingRequests.removeAll()
activeVisionRequests.removeAll()
requestLock.unlock()
cancelled.forEach {
$0.workItem.cancel()
deliver([], to: $0.completion)
}
// 让正在执行的识别尽快返回,当前停留页不必等整页识别跑完才能入队。
inFlight.forEach { $0.cancel() }
}
/// Swift Concurrency 版本,与回调版本使用相同的识别和主线程回调语义。
@available(iOS 15.0, *)
public func recognizeTextRuns(in image: UIImage) async -> [RDPDFReaderTextRun] {
await withCheckedContinuation { continuation in
recognizeTextRuns(in: image) { runs in
continuation.resume(returning: runs)
}
}
}
private struct Configuration {
let recognitionLevel: VNRequestTextRecognitionLevel
let recognitionLanguages: [String]
let usesLanguageCorrection: Bool
}
private struct RecognizedLine {
let text: String
let normalizedRect: CGRect
let characterRects: [CGRect]?
}
private static func makeTextRuns(from observations: [VNRecognizedTextObservation]) -> [RDPDFReaderTextRun] {
let lines = observations.compactMap { observation -> RecognizedLine? in
guard let candidate = observation.topCandidates(1).first else { return nil }
let text = candidate.string.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, let rect = normalizedUIKitRect(fromVisionRect: observation.boundingBox) else {
return nil
}
return RecognizedLine(
text: text,
normalizedRect: rect,
characterRects: makeCharacterRects(from: candidate, trimmedText: text)
)
}
return lines
.sorted(by: isBeforeInReadingOrder)
.enumerated()
.map { index, line in
RDPDFReaderTextRun(
text: line.text,
normalizedRects: [line.normalizedRect],
readingOrder: index,
characterRects: line.characterRects
)
}
}
/// 逐组合字符询问 Vision 的字符外接框,供字符级选区使用。
/// 任一字符取不到坐标时整行返回 `nil`,由文本层退回均分估算。
private static func makeCharacterRects(from candidate: VNRecognizedText, trimmedText: String) -> [CGRect]? {
let original = candidate.string
guard let trimmedRange = original.range(of: trimmedText) else { return nil }
var rects: [CGRect] = []
var index = trimmedRange.lowerBound
while index < trimmedRange.upperBound {
let next = original.index(after: index)
guard let observation = try? candidate.boundingBox(for: index..<next),
let rect = normalizedUIKitRect(fromVisionRect: observation.boundingBox) else {
return nil
}
rects.append(rect)
index = next
}
return rects.isEmpty ? nil : rects
}
/// Vision 使用左下角为原点;阅读器其余 UI 使用 UIKit 左上角为原点。
private static func normalizedUIKitRect(fromVisionRect rect: CGRect) -> CGRect? {
let minX = clamp(rect.minX)
let maxX = clamp(rect.maxX)
let minY = clamp(1 - rect.maxY)
let maxY = clamp(1 - rect.minY)
guard maxX > minX, maxY > minY else { return nil }
return CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY)
}
private static func isBeforeInReadingOrder(_ lhs: RecognizedLine, _ rhs: RecognizedLine) -> Bool {
// 同一视觉行内按从左到右排序;不同行则按从上到下排序。
let verticalTolerance = max(min(lhs.normalizedRect.height, rhs.normalizedRect.height) * 0.5, 0.01)
if abs(lhs.normalizedRect.midY - rhs.normalizedRect.midY) <= verticalTolerance {
if lhs.normalizedRect.minX != rhs.normalizedRect.minX {
return lhs.normalizedRect.minX < rhs.normalizedRect.minX
}
return lhs.normalizedRect.minY < rhs.normalizedRect.minY
}
return lhs.normalizedRect.minY < rhs.normalizedRect.minY
}
private static func clamp(_ value: CGFloat) -> CGFloat {
min(max(value, 0), 1)
}
private static func makeCGImage(from image: UIImage) -> CGImage? {
if let cgImage = image.cgImage {
return cgImage
}
guard let ciImage = image.ciImage else { return nil }
return CIContext(options: nil).createCGImage(ciImage, from: ciImage.extent)
}
private func deliver(_ runs: [RDPDFReaderTextRun], to completion: @escaping Completion) {
DispatchQueue.main.async {
completion(runs)
}
}
/// 只有仍处于活跃状态的请求才能进入执行阶段并登记可取消的 VNRequest。
private func registerActiveVisionRequest(_ request: VNRecognizeTextRequest, for requestID: UUID) -> Bool {
requestLock.lock()
defer { requestLock.unlock() }
guard pendingRequests[requestID]?.workItem.isCancelled == false else { return false }
activeVisionRequests[requestID] = request
return true
}
private func unregisterActiveVisionRequest(_ requestID: UUID) {
requestLock.lock()
activeVisionRequests.removeValue(forKey: requestID)
requestLock.unlock()
}
/// 仅活跃请求能取得一次完成权;取消与 Vision 回调竞争时不会重复完成。
private func takeCompletion(for requestID: UUID) -> Completion? {
requestLock.lock()
defer { requestLock.unlock() }
return pendingRequests.removeValue(forKey: requestID)?.completion
}
}
private extension CGImagePropertyOrientation {
init(orientation: UIImage.Orientation) {
switch orientation {
case .up:
self = .up
case .upMirrored:
self = .upMirrored
case .down:
self = .down
case .downMirrored:
self = .downMirrored
case .left:
self = .left
case .leftMirrored:
self = .leftMirrored
case .right:
self = .right
case .rightMirrored:
self = .rightMirrored
@unknown default:
self = .up
}
}
}
/// 页面文本结果的可清除磁盘缓存。OCR 与 PDFKit 原生文本使用不同命名空间,避免混用。
final class RDPDFReaderTextRunDiskCache {
private struct Document: Codable {
let version: Int
let runs: [RDPDFReaderTextRun]
}
private static let documentVersion = 1
private let rootURL: URL
private let queue = DispatchQueue(label: "com.readoor.pdf-reader.ocr-disk-cache", qos: .utility)
init(bookIdentifier: String, cacheVersion: Int, namespace: String, profile: String = "") {
// 目录分两级:书籍散列在外、命名空间+版本+配置在内。这样 version/profile
// 变更后能定位并删除同一本书同一命名空间下不再使用的旧目录。
let variantName = "\(namespace)-v\(cacheVersion)-\(Self.stableIdentifier(for: profile))"
let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
let bookRootURL = cachesURL
.appendingPathComponent("RDPDFReaderView", isDirectory: true)
.appendingPathComponent("TextRuns", isDirectory: true)
.appendingPathComponent(Self.stableIdentifier(for: bookIdentifier), isDirectory: true)
rootURL = bookRootURL.appendingPathComponent(variantName, isDirectory: true)
queue.async {
let entries = (try? FileManager.default.contentsOfDirectory(
at: bookRootURL,
includingPropertiesForKeys: nil
)) ?? []
for entry in entries
where entry.lastPathComponent.hasPrefix("\(namespace)-") && entry.lastPathComponent != variantName {
try? FileManager.default.removeItem(at: entry)
}
}
}
func load(pageIndex: Int, completion: @escaping ([RDPDFReaderTextRun]?) -> Void) {
queue.async {
let runs = self.loadLocked(pageIndex: pageIndex)
DispatchQueue.main.async { completion(runs) }
}
}
/// PDFKit 的渲染队列在后台调用,避免为了读磁盘缓存再切换到主线程。
func loadSynchronously(pageIndex: Int) -> [RDPDFReaderTextRun]? {
queue.sync { loadLocked(pageIndex: pageIndex) }
}
func save(_ runs: [RDPDFReaderTextRun], pageIndex: Int) {
let url = fileURL(for: pageIndex)
queue.async {
guard let data = try? JSONEncoder().encode(Document(version: Self.documentVersion, runs: runs)) else { return }
do {
try FileManager.default.createDirectory(at: self.rootURL, withIntermediateDirectories: true)
try data.write(to: url, options: .atomic)
} catch {
// 文本缓存不可用时仍可重新提取/识别,不能影响阅读流程。
}
}
}
private func fileURL(for pageIndex: Int) -> URL {
rootURL.appendingPathComponent("\(max(0, pageIndex)).json")
}
static func stableIdentifier(for value: String) -> String {
stableIdentifier(for: Array(value.utf8))
}
static func stableIdentifier<Bytes: Sequence>(for bytes: Bytes) -> String where Bytes.Element == UInt8 {
var hash: UInt64 = 14_695_981_039_346_656_037
for byte in bytes {
hash ^= UInt64(byte)
hash &*= 1_099_511_628_211
}
return String(hash, radix: 16)
}
private func loadLocked(pageIndex: Int) -> [RDPDFReaderTextRun]? {
let url = fileURL(for: pageIndex)
guard let data = try? Data(contentsOf: url),
let document = try? JSONDecoder().decode(Document.self, from: data),
document.version == Self.documentVersion else { return nil }
return document.runs
}
}