更新阅读器功能与示例

This commit is contained in:
shen
2026-07-27 21:43:13 +08:00
parent 68d9363f0a
commit 9392027106
78 changed files with 8780 additions and 2084 deletions
@@ -0,0 +1,169 @@
import CoreGraphics
import Foundation
import RDAIReaderView
import UIKit
/// Bridges PDF text runs into RDAIReaderView without exposing PDF reader UI
/// types to the AI core. The reader remains the source of truth for native
/// text, OCR and page-level highlighting.
@MainActor
public final class RDPDFAIContentProvider: RDAIContentProvider {
private weak var reader: RDPDFReaderViewController?
private let book: RDPDFReaderBookDescriptor
private let extractionCoordinator = RDPDFAIExtractionCoordinator()
init(reader: RDPDFReaderViewController) {
self.reader = reader
book = reader.pageProvider.readerBookDescriptor()
}
public func aiDocumentDescriptor() -> RDAIDocumentDescriptor {
let identifier = RDAIDocumentIdentifier(rawValue: book.identifier)
return RDAIDocumentDescriptor(
identifier: identifier,
title: book.title,
format: .pdf,
contentRevision: RDAIContentHasher.hash("\(book.identifier)|\(book.totalPages)")
)
}
public func aiResources() async throws -> [RDAIResourceDescriptor] {
(0..<book.totalPages).map {
RDAIResourceDescriptor(identifier: RDAIResourceIdentifier(rawValue: String($0)), order: $0)
}
}
public func aiResourceSnapshot(for identifier: RDAIResourceIdentifier) async throws -> RDAIResourceSnapshot {
guard let reader,
let pageIndex = Int(identifier.rawValue),
pageIndex >= 0,
pageIndex < book.totalPages else {
throw RDAIError.resourceUnavailable(identifier)
}
let extraction = await extractionCoordinator.text(for: pageIndex, reader: reader)
let runs = extraction.runs.sorted { $0.readingOrder < $1.readingOrder }
let textSource = extraction.source
var sourceText = ""
var locatorRuns: [RDAILocatorRun] = []
for run in runs where !run.text.isEmpty {
if !sourceText.isEmpty { sourceText.append("\n") }
let range = RDAITextRange(location: sourceText.utf16.count, length: run.text.utf16.count)
sourceText.append(run.text)
let source: RDAIPDFAnchor.TextSource = textSource == .ocr ? .ocr : .native
let anchor = RDAIPDFAnchor(
pageIndex: pageIndex,
rects: run.normalizedRects.map(RDAINormalizedRect.init),
textSource: source,
readingOrder: run.readingOrder
)
locatorRuns.append(RDAILocatorRun(textRange: range, anchor: .pdf(anchor)))
}
let descriptor = RDAIResourceDescriptor(
identifier: identifier,
order: pageIndex,
estimatedUTF16Length: sourceText.utf16.count
)
return RDAIResourceSnapshot(
descriptor: descriptor,
sourceText: sourceText,
sourceHash: RDAIContentHasher.hash(sourceText),
locatorRuns: locatorRuns
)
}
public func aiNavigate(to locator: RDAILocator, animated: Bool) async throws {
guard let reader,
case .pdf(let anchor) = locator.anchor else {
throw RDAIError.staleCitation
}
reader.showSpeechHighlight(
pageIndex: anchor.pageIndex,
normalizedRects: anchor.rects.map(\.cgRect),
animated: animated
)
}
public func aiShowCitationHighlight(_ citation: RDAICitation) async throws {
try await aiNavigate(to: citation.locator, animated: true)
}
public func aiClearCitationHighlight() {
reader?.clearSpeechHighlight()
}
}
private actor RDPDFAIExtractionCoordinator {
struct Result: Sendable {
let runs: [RDPDFReaderTextRun]
let source: RDPDFReaderAnnotationSource
}
private var tasks: [Int: Task<Result, Never>] = [:]
func text(for pageIndex: Int, reader: RDPDFReaderViewController) async -> Result {
if let task = tasks[pageIndex] { return await task.value }
let task = Task { @MainActor [weak reader] in
guard let reader else { return Result(runs: [], source: .region) }
let runs = await reader.speechTextRuns(at: pageIndex)
let source = await reader.speechTextSource(at: pageIndex)
return Result(runs: runs, source: source)
}
tasks[pageIndex] = task
let result = await task.value
tasks.removeValue(forKey: pageIndex)
return result
}
}
public extension RDPDFReaderViewController {
func aiCurrentReadScope() -> RDAIReadScope {
let page = currentPageIndex
let descriptor = pageProvider.readerBookDescriptor()
let locator = RDAILocator(
documentIdentifier: RDAIDocumentIdentifier(rawValue: descriptor.identifier),
resourceIdentifier: RDAIResourceIdentifier(rawValue: String(page)),
textRange: RDAITextRange(location: 0, length: Int.max),
anchor: .pdf(.init(pageIndex: page, rects: [], textSource: .native)),
sourceHash: ""
)
return RDAIReadScope(upperBound: locator)
}
func makeAIContentProvider() -> RDPDFAIContentProvider {
RDPDFAIContentProvider(reader: self)
}
func makeAIReaderService(
generativeProvider: (any RDAIGenerativeProvider)? = nil,
configuration: RDAIReaderConfiguration = .default
) -> RDAIReaderService {
RDAIReaderService(
contentProvider: makeAIContentProvider(),
analyzer: RDAINaturalLanguageAnalyzer(),
semanticScorer: RDAINaturalLanguageSemanticScorer(),
persistentStore: try? RDAISQLiteIndexStore(),
generativeProvider: generativeProvider,
configuration: configuration
)
}
func makeAIReaderAssistant(scope: RDAIReadScope) -> UIViewController {
UINavigationController(rootViewController: RDAIReaderAssistantViewController(service: makeAIReaderService(), scope: scope))
}
func makeAIReaderAssistant(scope: RDAIReadScope, generativeProvider: (any RDAIGenerativeProvider)?) -> UIViewController {
UINavigationController(rootViewController: RDAIReaderAssistantViewController(service: makeAIReaderService(generativeProvider: generativeProvider), scope: scope))
}
func makeAIReaderAssistant() -> UIViewController { makeAIReaderAssistant(scope: aiCurrentReadScope()) }
}
private extension RDAINormalizedRect {
init(_ rect: CGRect) {
self.init(x: rect.origin.x, y: rect.origin.y, width: rect.width, height: rect.height)
}
var cgRect: CGRect {
CGRect(x: x, y: y, width: width, height: height)
}
}
@@ -13,4 +13,15 @@ Pod::Spec.new do |s|
s.dependency "SnapKit", "~> 5.7"
s.frameworks = "Vision", "CoreImage", "PDFKit"
s.requires_arc = true
s.subspec "Speech" do |speech|
speech.source_files = "Speech/**/*.swift"
speech.dependency "RDSpeechReaderView", "~> 0.1"
end
s.subspec "AI" do |ai|
ai.source_files = "AI/**/*.swift"
ai.dependency "RDAIReaderView/NaturalLanguage", "~> 0.1"
ai.dependency "RDAIReaderView/UI", "~> 0.1"
end
end
@@ -39,7 +39,7 @@ public struct RDPDFReaderPageDescriptor {
/// OCR
///
/// x/y/width/height 0...1
public struct RDPDFReaderTextRun: Equatable, Codable {
public struct RDPDFReaderTextRun: Equatable, Codable, Sendable {
public let text: String
public let normalizedRects: [CGRect]
/// SDK
@@ -70,7 +70,7 @@ public struct RDPDFReaderTextRun: Equatable, Codable {
}
/// UI 宿OCR
public enum RDPDFReaderAnnotationSource: String, Codable, Equatable {
public enum RDPDFReaderAnnotationSource: String, Codable, Equatable, Sendable {
/// 宿 PDF
case text
/// SDK OCR
@@ -83,6 +83,11 @@ public final class RDPDFReaderImageTextLayerView: UIView, UIGestureRecognizerDel
}
}
///
public var speechHighlightRects: [CGRect] = [] {
didSet { setNeedsDisplay() }
}
/// `textRuns` 使 `.text`SDK OCR 使 `.ocr`
/// 使 `.region`
public var textSource: RDPDFReaderAnnotationSource = .text {
@@ -203,6 +208,7 @@ public final class RDPDFReaderImageTextLayerView: UIView, UIGestureRecognizerDel
context.clear(rect)
currentPageAnnotations.forEach { draw(annotation: $0, in: context) }
drawSpeechHighlight(in: context)
if let selectedSelection {
draw(selection: selectedSelection, in: context)
}
@@ -220,6 +226,14 @@ public final class RDPDFReaderImageTextLayerView: UIView, UIGestureRecognizerDel
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 }
@@ -143,6 +143,11 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
updateAccessibilityViewport()
}
/// Shows a non-persistent highlight while the speech engine reads text.
public func setSpeechHighlightRects(_ rects: [CGRect]) {
textLayer.speechHighlightRects = rects
}
public func configureDrawing(
pageIndex: Int,
document: RDPDFReaderDrawingDocument,
@@ -40,6 +40,8 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
public let annotationPersistence: RDPDFReaderAnnotationPersisting?
public private(set) var configuration: Configuration
public var currentPageIndex: Int { max(0, readerView.currentPage) }
private let readerView = RDPDFReaderView()
private let recognizer: RDPDFReaderImageTextRecognizer
private let ocrDiskCache: RDPDFReaderTextRunDiskCache?
@@ -50,6 +52,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
private var recognizingPages = Set<Int>()
///
private var ocrRequestTokens: [Int: UUID] = [:]
private var speechHighlight: (pageIndex: Int, rects: [CGRect])?
private var bookmarks = Set<Int>()
private weak var topToolbar: RDPDFReaderKitTopToolView?
private weak var bottomToolbar: RDPDFReaderKitBottomToolView?
@@ -188,6 +191,63 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
readerView.transitionToPage(pageNum: pageIndex, animated: animated)
}
/// Returns native PDF text when available and otherwise performs the same
/// on-device OCR fallback used by the reader page. This keeps speech and
/// visual selection on one source of truth for scanned documents.
public func speechTextRuns(at pageIndex: Int) async -> [RDPDFReaderTextRun] {
guard pageIndex >= 0, pageIndex < book.totalPages else { return [] }
if let nativeRuns = pageDescriptors[pageIndex]?.textRuns, !nativeRuns.isEmpty { return nativeRuns }
if let cachedRuns = ocrRuns[pageIndex], !cachedRuns.isEmpty { return cachedRuns }
let descriptor: RDPDFReaderPageDescriptor
if let cached = pageDescriptors[pageIndex] {
descriptor = cached
} else {
descriptor = await withCheckedContinuation { continuation in
pageProvider.readerPage(at: pageIndex) { continuation.resume(returning: $0) }
}
pageDescriptors[pageIndex] = descriptor
}
if let nativeRuns = descriptor.textRuns, !nativeRuns.isEmpty { return nativeRuns }
guard configuration.enablesOCR, let image = descriptor.image else { return [] }
let runs = await withCheckedContinuation { continuation in
recognizer.recognizeTextRuns(in: image) { continuation.resume(returning: $0) }
}
guard !runs.isEmpty else { return [] }
ocrRuns[pageIndex] = runs
ocrDiskCache?.save(runs, pageIndex: pageIndex)
refreshVisiblePage(pageIndex)
return runs
}
/// Reports whether readable text for a page came from the host/PDF source
/// or the reader's OCR fallback. AI citations retain this distinction so
/// callers can present OCR-derived facts with appropriate confidence.
public func speechTextSource(at pageIndex: Int) async -> RDPDFReaderAnnotationSource {
guard pageIndex >= 0, pageIndex < book.totalPages else { return configuration.missingTextSource }
if pageDescriptors[pageIndex]?.textRuns != nil { return .text }
_ = await speechTextRuns(at: pageIndex)
return pageDescriptors[pageIndex]?.textRuns != nil
? .text
: (configuration.enablesOCR ? .ocr : configuration.missingTextSource)
}
/// Updates the transient speech focus without creating a user annotation.
/// The caller supplies the same normalized coordinate system as text runs.
public func showSpeechHighlight(pageIndex: Int, normalizedRects: [CGRect], animated: Bool = true) {
guard pageIndex >= 0, pageIndex < book.totalPages else { return }
speechHighlight = normalizedRects.isEmpty ? nil : (pageIndex, normalizedRects)
goToPage(pageIndex, animated: animated)
refreshVisiblePage(pageIndex)
}
public func clearSpeechHighlight() {
let highlightedPage = speechHighlight?.pageIndex
speechHighlight = nil
if let highlightedPage { refreshVisiblePage(highlightedPage) }
}
///
public func setDrawingMode(_ enabled: Bool) {
guard isDrawingMode != enabled else { return }
@@ -278,6 +338,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
let runs = descriptor?.textRuns ?? ocrRuns[index] ?? []
let source: RDPDFReaderAnnotationSource = descriptor?.textRuns != nil ? .text : (configuration.enablesOCR ? .ocr : configuration.missingTextSource)
page.configureTextLayer(pageIndex: index, textRuns: runs, textSource: source, annotations: annotations(for: index))
page.setSpeechHighlightRects(speechHighlight?.pageIndex == index ? speechHighlight?.rects ?? [] : [])
page.isDrawingSessionActive = isDrawingMode
page.isDrawingMode = isDrawingMode && currentDrawingTool != nil
page.configureDrawing(
@@ -0,0 +1,106 @@
import Foundation
import RDSpeechReaderView
/// Adapts host-supplied PDF text runs to the generic speech reader. It reads
/// native text when available; image-only PDF OCR remains owned by the PDF
/// reader's OCR pipeline and can be added without changing the core API.
@MainActor
public final class RDPDFSpeechContentProvider: RDSpeechContentProvider {
private let pageProvider: RDPDFReaderPageProvider
private let book: RDPDFReaderBookDescriptor
private weak var reader: RDPDFReaderViewController?
private var textRunsByPage: [Int: [RDPDFReaderTextRun]] = [:]
public init(pageProvider: RDPDFReaderPageProvider) {
self.pageProvider = pageProvider
book = pageProvider.readerBookDescriptor()
}
init(reader: RDPDFReaderViewController) {
self.reader = reader
pageProvider = reader.pageProvider
book = reader.pageProvider.readerBookDescriptor()
}
public func speechBookDescriptor() -> RDSpeechBookDescriptor {
RDSpeechBookDescriptor(identifier: book.identifier, title: book.title)
}
public func speechContentBatch(
startingAt location: RDSpeechLocation?,
limit: Int
) async throws -> RDSpeechContentBatch {
let startPage = max(0, Int(location?.resourceIdentifier ?? "") ?? 0)
let startOffset = location?.textOffset ?? 0
var units: [RDSpeechTextUnit] = []
for pageIndex in startPage..<book.totalPages {
let sourceRuns: [RDPDFReaderTextRun]
if let reader {
sourceRuns = await reader.speechTextRuns(at: pageIndex)
} else {
sourceRuns = (await loadPage(at: pageIndex)).textRuns ?? []
}
let runs = sourceRuns.sorted { $0.readingOrder < $1.readingOrder }
textRunsByPage[pageIndex] = runs
let pageText = runs
.map(\.text)
.joined(separator: "\n")
let pageLocation = RDSpeechLocation(
bookIdentifier: book.identifier,
resourceIdentifier: String(pageIndex)
)
let pageUnits = RDSpeechTextPreprocessor.makeUnits(text: pageText, location: pageLocation)
.filter { pageIndex != startPage || NSMaxRange($0.textRange) > startOffset }
for unit in pageUnits {
guard units.count < limit else {
return RDSpeechContentBatch(units: units, nextLocation: unit.location)
}
units.append(unit)
}
}
return RDSpeechContentBatch(units: units, nextLocation: nil)
}
/// Converts the current utterance range into page rectangles. A text run
/// may span several lines, so the first release highlights whole matching
/// runs; character-level rectangles can refine this later without changing
/// the speech controller API.
public func normalizedRects(for spokenRange: RDSpeechSpokenRange) -> [CGRect] {
guard let pageIndex = Int(spokenRange.unit.location.resourceIdentifier),
let runs = textRunsByPage[pageIndex] else {
return []
}
let target = NSRange(
location: spokenRange.unit.textRange.location + spokenRange.range.location,
length: spokenRange.range.length
)
var runStart = 0
var rects: [CGRect] = []
for run in runs {
let runLength = run.text.utf16.count
let runRange = NSRange(location: runStart, length: runLength)
if NSIntersectionRange(runRange, target).length > 0 {
rects.append(contentsOf: run.normalizedRects)
}
// The text provider joins runs with a newline before tokenizing.
runStart += runLength + 1
}
return rects
}
private func loadPage(at index: Int) async -> RDPDFReaderPageDescriptor {
await withCheckedContinuation { continuation in
pageProvider.readerPage(at: index) { page in
continuation.resume(returning: page)
}
}
}
}
public extension RDPDFReaderViewController {
func makeSpeechContentProvider() -> RDPDFSpeechContentProvider {
RDPDFSpeechContentProvider(reader: self)
}
}
@@ -0,0 +1,59 @@
import Foundation
import RDSpeechReaderView
/// A ready-to-use PDF speech session. It owns the controller delegate so page
/// navigation and temporary sentence highlighting stay synchronized.
@MainActor
public final class RDPDFSpeechSession: NSObject, RDSpeechReaderControllerDelegate {
public let controller: RDSpeechReaderController
public let contentProvider: RDPDFSpeechContentProvider
public var onStateChange: ((RDSpeechReaderState) -> Void)?
private weak var reader: RDPDFReaderViewController?
init(reader: RDPDFReaderViewController, configuration: RDSpeechReaderConfiguration) {
self.reader = reader
contentProvider = reader.makeSpeechContentProvider()
controller = RDSpeechReaderController(contentProvider: contentProvider, configuration: configuration)
super.init()
controller.delegate = self
}
public func start(from pageIndex: Int = 0) async throws {
let location = RDSpeechLocation(
bookIdentifier: contentProvider.speechBookDescriptor().identifier,
resourceIdentifier: String(max(0, pageIndex))
)
try await controller.start(from: location)
}
public func pause() { controller.pause() }
public func resume() { controller.resume() }
public func stop() {
controller.stop()
reader?.clearSpeechHighlight()
}
public func speechReaderController(_ controller: RDSpeechReaderController, didChange state: RDSpeechReaderState) {
if case .finished = state { reader?.clearSpeechHighlight() }
if case .idle = state { reader?.clearSpeechHighlight() }
onStateChange?(state)
}
public func speechReaderController(_ controller: RDSpeechReaderController, willSpeak range: RDSpeechSpokenRange) {
guard let pageIndex = Int(range.unit.location.resourceIdentifier) else { return }
reader?.showSpeechHighlight(
pageIndex: pageIndex,
normalizedRects: contentProvider.normalizedRects(for: range)
)
}
}
public extension RDPDFReaderViewController {
func makeSpeechSession(
configuration: RDSpeechReaderConfiguration = .default
) -> RDPDFSpeechSession {
RDPDFSpeechSession(reader: self, configuration: configuration)
}
}