107 lines
4.2 KiB
Swift
107 lines
4.2 KiB
Swift
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)
|
|
}
|
|
}
|