Files
ReadViewSDK/Sources/RDPDFReaderView/Speech/RDPDFSpeechContentProvider.swift
T
2026-07-27 21:43:13 +08:00

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