更新阅读器功能与示例
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user