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.. 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) } }