更新阅读器功能与示例

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,23 @@
import Foundation
public enum RDAIDiagnosticStage: String, Codable, Sendable { case indexing, retrieval, generation, storage }
public struct RDAIDiagnosticEvent: Codable, Sendable {
public let documentHash: String
public let stage: RDAIDiagnosticStage
public let durationMilliseconds: Int
public let errorCode: String?
public let count: Int
public let createdAt: Date
public init(documentHash: String, stage: RDAIDiagnosticStage, durationMilliseconds: Int, errorCode: String? = nil, count: Int = 0, createdAt: Date = .init()) { self.documentHash = documentHash; self.stage = stage; self.durationMilliseconds = durationMilliseconds; self.errorCode = errorCode; self.count = count; self.createdAt = createdAt }
}
public protocol RDAIDiagnosticsRecording: Sendable { func record(_ event: RDAIDiagnosticEvent) async }
public actor RDAIInMemoryDiagnosticsRecorder: RDAIDiagnosticsRecording {
private var values: [RDAIDiagnosticEvent] = []
public init() {}
public func record(_ event: RDAIDiagnosticEvent) { values.append(event) }
public func events() -> [RDAIDiagnosticEvent] { values }
public func removeAll() { values.removeAll() }
}
@@ -0,0 +1,528 @@
import CryptoKit
import Foundation
public struct RDAIIndexOptions: Sendable {
public var scope: RDAIReadScope
public var priorityResource: RDAIResourceIdentifier?
public init(scope: RDAIReadScope, priorityResource: RDAIResourceIdentifier? = nil) {
self.scope = scope
self.priorityResource = priorityResource
}
}
public enum RDAIIndexState: Equatable, Sendable {
case notStarted
case indexing(completedResources: Int, totalResources: Int)
case paused(completedResources: Int, totalResources: Int)
case ready
case failed(RDAIError)
}
public struct RDAIRetrievalOptions: Sendable {
public var maximumResults: Int
public var scope: RDAIReadScope
public var minimumScore: Double
public init(maximumResults: Int = 4, scope: RDAIReadScope, minimumScore: Double = 0.01) {
self.maximumResults = max(1, maximumResults)
self.scope = scope
self.minimumScore = min(max(minimumScore, 0), 1)
}
}
public struct RDAIRetrievalMatch: Sendable, Identifiable {
public let id: String
public let passage: RDAIPassage
public let score: Double
public let lexicalScore: Double
public let semanticScore: Double?
public init(passage: RDAIPassage, score: Double, lexicalScore: Double, semanticScore: Double? = nil) {
id = passage.id
self.passage = passage
self.score = score
self.lexicalScore = lexicalScore
self.semanticScore = semanticScore
}
}
public enum RDAIContentHasher {
public static func hash(_ text: String) -> String {
SHA256.hash(data: Data(text.utf8)).map { String(format: "%02x", $0) }.joined()
}
}
public actor RDAIInMemoryIndexStore {
private var resources: [RDAIDocumentIdentifier: [RDAIResourceDescriptor]] = [:]
private var passages: [RDAIDocumentIdentifier: [RDAIPassage]] = [:]
private var entities: [RDAIDocumentIdentifier: [RDAIEntityMention]] = [:]
public init() {}
public func replace(
document: RDAIDocumentDescriptor,
resource: RDAIResourceDescriptor,
analysis: RDAIAnalysisResult
) {
var documentResources = resources[document.identifier] ?? []
documentResources.removeAll { $0.identifier == resource.identifier }
documentResources.append(resource)
resources[document.identifier] = documentResources.sorted { $0.order < $1.order }
var documentPassages = passages[document.identifier] ?? []
documentPassages.removeAll { $0.resourceIdentifier == resource.identifier }
documentPassages.append(contentsOf: analysis.passages)
passages[document.identifier] = documentPassages
var documentEntities = entities[document.identifier] ?? []
documentEntities.removeAll { $0.locator.resourceIdentifier == resource.identifier }
documentEntities.append(contentsOf: analysis.entityMentions)
entities[document.identifier] = documentEntities
}
public func passages(for documentIdentifier: RDAIDocumentIdentifier) -> [RDAIPassage] {
passages[documentIdentifier] ?? []
}
public func entities(for documentIdentifier: RDAIDocumentIdentifier) -> [RDAIEntityMention] {
entities[documentIdentifier] ?? []
}
public func resourceOrder(
for identifier: RDAIResourceIdentifier,
documentIdentifier: RDAIDocumentIdentifier
) -> Int? {
resources[documentIdentifier]?.first { $0.identifier == identifier }?.order
}
public func remove(documentIdentifier: RDAIDocumentIdentifier) {
resources.removeValue(forKey: documentIdentifier)
passages.removeValue(forKey: documentIdentifier)
entities.removeValue(forKey: documentIdentifier)
}
}
/// First implementation of the local indexing pipeline. Storage is deliberately
/// injectable so a SQLite-backed store can replace it without changing adapters.
@MainActor
public final class RDAIReaderService {
public private(set) var state: RDAIIndexState = .notStarted {
didSet { stateContinuations.values.forEach { $0.yield(state) } }
}
private let contentProvider: RDAIContentProvider
private let analyzer: any RDAIAnalyzing
private let store: RDAIInMemoryIndexStore
private let persistentStore: RDAISQLiteIndexStore?
private let generativeProvider: (any RDAIGenerativeProvider)?
private let semanticScorer: (any RDAISemanticScoring)?
private let configuration: RDAIReaderConfiguration
private let diagnostics: (any RDAIDiagnosticsRecording)?
private var stateContinuations: [UUID: AsyncStream<RDAIIndexState>.Continuation] = [:]
private var pauseRequested = false
private var resumeContinuation: CheckedContinuation<Void, Never>?
public init(
contentProvider: RDAIContentProvider,
analyzer: some RDAIAnalyzing,
semanticScorer: (any RDAISemanticScoring)? = nil,
store: RDAIInMemoryIndexStore = .init(),
persistentStore: RDAISQLiteIndexStore? = nil,
generativeProvider: (any RDAIGenerativeProvider)? = nil,
configuration: RDAIReaderConfiguration = .default,
diagnostics: (any RDAIDiagnosticsRecording)? = nil
) {
self.contentProvider = contentProvider
self.analyzer = analyzer
self.semanticScorer = semanticScorer
self.store = store
self.persistentStore = persistentStore
self.generativeProvider = generativeProvider
self.configuration = configuration
self.diagnostics = diagnostics
}
public func stateUpdates() -> AsyncStream<RDAIIndexState> {
let identifier = UUID()
return AsyncStream { [weak self] continuation in
guard let self else {
continuation.finish()
return
}
continuation.yield(self.state)
self.stateContinuations[identifier] = continuation
continuation.onTermination = { [weak self] _ in
Task { @MainActor in self?.stateContinuations.removeValue(forKey: identifier) }
}
}
}
public func prepareIndex(options: RDAIIndexOptions) async throws {
guard configuration.isAIEnabled else { throw RDAIError.disabled }
let startedAt = Date()
let document = contentProvider.aiDocumentDescriptor()
var resources = try await contentProvider.aiResources().sorted { $0.order < $1.order }
resources = prioritized(resources, preferred: options.priorityResource)
let scopedResources = try await resourcesAllowed(by: options.scope, document: document, resources: resources)
state = .indexing(completedResources: 0, totalResources: scopedResources.count)
do {
for (index, resource) in scopedResources.enumerated() {
try Task.checkCancellation()
await waitIfPaused(completed: index, total: scopedResources.count)
try Task.checkCancellation()
let sourceSnapshot = try await contentProvider.aiResourceSnapshot(for: resource.identifier)
let snapshot = limited(sourceSnapshot, by: options.scope)
guard !snapshot.sourceText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
state = .indexing(completedResources: index + 1, totalResources: scopedResources.count)
continue
}
if let persistentStore,
try await persistentStore.resourceHash(documentID: document.identifier, resourceID: resource.identifier) == snapshot.sourceHash {
try await persistentStore.saveCheckpoint(documentID: document.identifier, resourceOrder: resource.order)
state = .indexing(completedResources: index + 1, totalResources: scopedResources.count)
continue
}
let analysis = await analyzer.analyze(document: document, snapshot: snapshot)
if let persistentStore {
try await persistentStore.replace(document: document, resource: resource, analysis: analysis, sourceHash: snapshot.sourceHash)
try await persistentStore.saveCheckpoint(documentID: document.identifier, resourceOrder: resource.order)
} else {
await store.replace(document: document, resource: resource, analysis: analysis)
}
state = .indexing(completedResources: index + 1, totalResources: scopedResources.count)
}
state = .ready
await record(stage: .indexing, startedAt: startedAt, count: scopedResources.count)
} catch is CancellationError {
state = .notStarted
throw RDAIError.cancelled
} catch let error as RDAIError {
state = .failed(error)
await record(stage: .indexing, startedAt: startedAt, errorCode: "rdai-error")
throw error
} catch {
// Public errors must not expose paths, OCR text or SQLite details.
let wrapped = RDAIError.indexingFailed("unexpected")
state = .failed(wrapped)
await record(stage: .indexing, startedAt: startedAt, errorCode: "indexing-failed")
throw wrapped
}
}
public func retrieve(query: String, options: RDAIRetrievalOptions) async -> [RDAIRetrievalMatch] {
let document = contentProvider.aiDocumentDescriptor()
let allPassages = await storedPassages(for: document.identifier)
let filtered = await filter(allPassages, scope: options.scope, documentIdentifier: document.identifier)
var matches: [RDAIRetrievalMatch] = []
for passage in filtered {
let score = lexicalScore(query: query, text: passage.text)
let semantic = await semanticScorer?.score(query: query, text: passage.text, languageCode: passage.languageCode)
let finalScore = semantic.map { 0.6 * score + 0.4 * $0 } ?? score
guard finalScore >= options.minimumScore else { continue }
matches.append(RDAIRetrievalMatch(passage: passage, score: finalScore, lexicalScore: score, semanticScore: semantic))
}
return matches.sorted {
if $0.score == $1.score { return $0.passage.order < $1.passage.order }
return $0.score > $1.score
}
.prefix(options.maximumResults)
.map { $0 }
}
public func removeIndex() async {
if let persistentStore { try? await persistentStore.remove(documentID: contentProvider.aiDocumentDescriptor().identifier) }
else { await store.remove(documentIdentifier: contentProvider.aiDocumentDescriptor().identifier) }
state = .notStarted
}
public func pauseIndexing() {
pauseRequested = true
}
public func resumeIndexing() {
pauseRequested = false
resumeContinuation?.resume()
resumeContinuation = nil
}
public func removeAllAIData() async {
if let persistentStore { try? await persistentStore.removeAll() }
else { await store.remove(documentIdentifier: contentProvider.aiDocumentDescriptor().identifier) }
state = .notStarted
}
public func storageBytes() async -> Int64 {
await persistentStore?.storageBytes() ?? 0
}
public func showCitation(_ citation: RDAICitation) async throws {
try await contentProvider.aiShowCitationHighlight(citation)
}
public func clearCitationHighlight() {
contentProvider.aiClearCitationHighlight()
}
public func availability(for capability: RDAICapability, locale: Locale = .current) async -> RDAICapabilityAvailability {
guard configuration.isAIEnabled else { return .unavailable(reason: .providerNotInstalled) }
switch capability {
case .languageAnalysis, .entityExtraction, .lexicalSearch: return .available
case .semanticSearch:
return semanticScorer == nil ? .degraded(reason: .embeddingAssetsUnavailable) : .available
case .summarization, .questionAnswering, .characterRelationships:
guard let generativeProvider else { return .degraded(reason: .providerNotInstalled) }
return await generativeProvider.availability(for: capability, locale: locale)
}
}
public func summarize(scope: RDAIReadScope, length: RDAISummaryLength) async throws -> RDAISummary {
guard configuration.isAIEnabled else { throw RDAIError.disabled }
let document = contentProvider.aiDocumentDescriptor()
let cacheKey = artifactKey(document: document, scope: scope, suffix: "summary|\(length.rawValue)")
if configuration.storesGeneratedArtifacts,
let persistentStore,
let data = try? await persistentStore.artifact(documentID: document.identifier, key: cacheKey),
let cached = try? JSONDecoder().decode(RDAISummary.self, from: data) { return cached }
let passages = try await passages(for: scope, limit: length == .brief ? 2 : (length == .standard ? 4 : 6))
let metadata = generationMetadata(provider: generativeProvider?.identifier ?? "extractive")
if let generativeProvider,
case .available = await generativeProvider.availability(for: .summarization, locale: .current),
case let .summary(overview, statements) = try await generativeProvider.generate(.init(task: .summary(length), passages: passages, scope: scope)) {
let validated = validatedStatements(statements, passages: passages)
guard !validated.isEmpty else { throw RDAIError.insufficientEvidence }
let result = RDAISummary(title: document.title, overview: overview, keyPoints: validated, citations: citations(for: passages), metadata: metadata)
try? await saveArtifact(result, document: document, key: cacheKey)
return result
}
let statements = passages.map { RDAISourcedStatement(text: $0.text, citationIdentifiers: [$0.id]) }
let result = RDAISummary(title: document.title, overview: passages.first?.text ?? "暂无可总结的已读内容。", keyPoints: statements, citations: citations(for: passages), metadata: metadata)
try? await saveArtifact(result, document: document, key: cacheKey)
return result
}
public func answer(question: String, scope: RDAIReadScope) async throws -> RDAIAnswer {
guard configuration.isAIEnabled else { throw RDAIError.disabled }
let document = contentProvider.aiDocumentDescriptor()
let cacheKey = artifactKey(document: document, scope: scope, suffix: "answer|\(RDAIContentHasher.hash(question))")
if configuration.storesGeneratedArtifacts,
let persistentStore,
let data = try? await persistentStore.artifact(documentID: document.identifier, key: cacheKey),
let cached = try? JSONDecoder().decode(RDAIAnswer.self, from: data) { return cached }
let matches = await retrieve(query: question, options: .init(maximumResults: configuration.maximumRetrievedPassages, scope: scope))
let passages = matches.map(\.passage)
let metadata = generationMetadata(provider: generativeProvider?.identifier ?? "extractive")
guard !passages.isEmpty else { return RDAIAnswer(status: .insufficientEvidence, text: "在当前阅读范围内未找到足够证据。", statements: [], citations: [], metadata: metadata) }
if let generativeProvider,
case .available = await generativeProvider.availability(for: .questionAnswering, locale: .current),
case let .answer(text, statements) = try await generativeProvider.generate(.init(task: .answer, userText: question, passages: passages, scope: scope)) {
let validated = validatedStatements(statements, passages: passages)
guard !validated.isEmpty else { return RDAIAnswer(status: .insufficientEvidence, text: "模型结果缺少可验证的原文证据。", statements: [], citations: [], metadata: metadata) }
let result = RDAIAnswer(status: .answered, text: text, statements: validated, citations: citations(for: passages), metadata: metadata)
try? await saveArtifact(result, document: document, key: cacheKey)
return result
}
let statements = passages.map { RDAISourcedStatement(text: $0.text, citationIdentifiers: [$0.id]) }
let result = RDAIAnswer(status: .answered, text: "以下内容与问题最相关:", statements: statements, citations: citations(for: passages), metadata: metadata)
try? await saveArtifact(result, document: document, key: cacheKey)
return result
}
public func characters(scope: RDAIReadScope) async -> [RDAICharacter] {
let document = contentProvider.aiDocumentDescriptor()
let mentions = (await storedEntities(for: document.identifier)).filter { $0.kind == .person }
let grouped = Dictionary(grouping: mentions, by: { canonicalCharacterName($0.normalizedName) })
return grouped.compactMap { (name, values) -> RDAICharacter? in
guard let first = values.first else { return nil }
let evidence = values.prefix(3).map { mention in
RDAICitation(passageIdentifier: mention.id, quote: mention.surfaceText, locator: mention.locator)
}
return RDAICharacter(id: RDAIContentHasher.hash(name), displayName: first.surfaceText, aliases: Array(Set(values.map(\.surfaceText))).sorted(), description: "在已读内容中出现 \(values.count) 次。", firstAppearance: evidence.first, evidence: evidence)
}.sorted { $0.displayName < $1.displayName }
}
/// Conservative local relationship extraction: entities must co-occur in
/// one allowed passage. It never promotes co-occurrence to a fact.
public func relationships(scope: RDAIReadScope) async -> [RDAIRelationship] {
let document = contentProvider.aiDocumentDescriptor()
let passages = await filter(await storedPassages(for: document.identifier), scope: scope, documentIdentifier: document.identifier)
let mentions = (await storedEntities(for: document.identifier)).filter { $0.kind == .person }
var evidenceByPair: [String: [RDAICitation]] = [:]
for passage in passages {
let names = Array(Set(mentions.filter {
$0.locator.resourceIdentifier == passage.resourceIdentifier && $0.locator.textRange.intersects(passage.locator.textRange)
}.map { canonicalCharacterName($0.normalizedName) })).sorted()
guard names.count > 1 else { continue }
for left in names.indices {
for right in names.indices where right > left {
let key = "\(names[left])|\(names[right])"
evidenceByPair[key, default: []].append(citation(for: passage))
}
}
}
return evidenceByPair.compactMap { key, evidence in
let names = key.split(separator: "|", maxSplits: 1).map(String.init)
guard names.count == 2, !evidence.isEmpty else { return nil }
return RDAIRelationship(id: RDAIContentHasher.hash(key), sourceCharacterIdentifier: RDAIContentHasher.hash(names[0]), targetCharacterIdentifier: RDAIContentHasher.hash(names[1]), label: "共同出现", status: .possible, evidence: Array(evidence.prefix(3)))
}.sorted { $0.id < $1.id }
}
private func passages(for scope: RDAIReadScope, limit: Int) async throws -> [RDAIPassage] {
let document = contentProvider.aiDocumentDescriptor()
let stored = await storedPassages(for: document.identifier)
let all = await filter(stored, scope: scope, documentIdentifier: document.identifier)
guard !all.isEmpty else { throw RDAIError.insufficientEvidence }
return Array(all.sorted { $0.order < $1.order }.prefix(limit))
}
private func citations(for passages: [RDAIPassage]) -> [RDAICitation] { passages.map(citation(for:)) }
private func citation(for passage: RDAIPassage) -> RDAICitation { RDAICitation(passageIdentifier: passage.id, quote: passage.text, locator: passage.locator) }
private func validatedStatements(_ statements: [RDAISourcedStatement], passages: [RDAIPassage]) -> [RDAISourcedStatement] {
let identifiers = Set(passages.map(\.id))
return statements.compactMap { statement in
let citations = statement.citationIdentifiers.filter { identifiers.contains($0) }
guard !citations.isEmpty, !statement.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
return RDAISourcedStatement(id: statement.id, text: statement.text, citationIdentifiers: citations)
}
}
private func canonicalCharacterName(_ value: String) -> String {
let compact = value.unicodeScalars.filter { CharacterSet.alphanumerics.contains($0) || (0x4E00...0x9FFF).contains($0.value) }
var name = String(String.UnicodeScalarView(compact)).lowercased()
// Only remove explicit honorific suffixes; ambiguous aliases remain
// distinct so the UI does not silently merge two people.
for suffix in ["先生", "女士", "小姐", "老师", "教授", "博士", "将军"] where name.hasSuffix(suffix) && name.count > suffix.count {
name.removeLast(suffix.count)
break
}
return name
}
private func generationMetadata(provider: String) -> RDAIGenerationMetadata { .init(providerIdentifier: provider, promptIdentifier: "rdai.evidence.v1", promptVersion: 1, scopeHash: RDAIContentHasher.hash(contentProvider.aiDocumentDescriptor().identifier.rawValue)) }
private func artifactKey(document: RDAIDocumentDescriptor, scope: RDAIReadScope, suffix: String) -> String {
let scopeData = (try? JSONEncoder().encode(scope)).map { String(data: $0, encoding: .utf8) } ?? ""
return RDAIContentHasher.hash("\(document.contentRevision)|\(scopeData)|rdai.evidence.v1|\(generativeProvider?.identifier ?? "extractive")|\(suffix)")
}
private func saveArtifact<T: Encodable>(_ value: T, document: RDAIDocumentDescriptor, key: String) async throws {
guard configuration.storesGeneratedArtifacts, let persistentStore else { return }
try await persistentStore.saveArtifact(documentID: document.identifier, key: key, payload: JSONEncoder().encode(value))
}
private func record(stage: RDAIDiagnosticStage, startedAt: Date, errorCode: String? = nil, count: Int = 0) async {
guard configuration.diagnosticsLevel != .disabled, let diagnostics else { return }
await diagnostics.record(.init(documentHash: RDAIContentHasher.hash(contentProvider.aiDocumentDescriptor().identifier.rawValue), stage: stage, durationMilliseconds: Int(Date().timeIntervalSince(startedAt) * 1_000), errorCode: errorCode, count: count))
}
private func prioritized(
_ resources: [RDAIResourceDescriptor],
preferred: RDAIResourceIdentifier?
) -> [RDAIResourceDescriptor] {
guard let preferred else { return resources }
return resources.sorted { lhs, rhs in
if lhs.identifier == preferred { return true }
if rhs.identifier == preferred { return false }
return lhs.order < rhs.order
}
}
private func waitIfPaused(completed: Int, total: Int) async {
guard pauseRequested else { return }
state = .paused(completedResources: completed, totalResources: total)
await withTaskCancellationHandler(operation: {
await withCheckedContinuation { continuation in
resumeContinuation = continuation
}
}, onCancel: { [weak self] in
Task { @MainActor in self?.resumeIndexing() }
})
state = .indexing(completedResources: completed, totalResources: total)
}
private func resourcesAllowed(
by scope: RDAIReadScope,
document: RDAIDocumentDescriptor,
resources: [RDAIResourceDescriptor]
) async throws -> [RDAIResourceDescriptor] {
guard !scope.includesWholeDocument else { return resources }
guard let upperBound = scope.upperBound,
upperBound.documentIdentifier == document.identifier,
let boundary = resources.first(where: { $0.identifier == upperBound.resourceIdentifier }) else {
return []
}
return resources.filter { $0.order <= boundary.order }
}
private func limited(_ snapshot: RDAIResourceSnapshot, by scope: RDAIReadScope) -> RDAIResourceSnapshot {
guard !scope.includesWholeDocument,
let upperBound = scope.upperBound,
upperBound.resourceIdentifier == snapshot.descriptor.identifier else {
return snapshot
}
let length = min(max(upperBound.textRange.upperBound, 0), snapshot.sourceText.utf16.count)
let source = (snapshot.sourceText as NSString).substring(to: length)
let availableRange = RDAITextRange(location: 0, length: length)
let runs = snapshot.locatorRuns.filter { $0.textRange.intersects(availableRange) }
return RDAIResourceSnapshot(
descriptor: snapshot.descriptor,
sourceText: source,
sourceHash: RDAIContentHasher.hash(source),
locatorRuns: runs
)
}
private func filter(
_ passages: [RDAIPassage],
scope: RDAIReadScope,
documentIdentifier: RDAIDocumentIdentifier
) async -> [RDAIPassage] {
guard !scope.includesWholeDocument else { return passages }
guard let upperBound = scope.upperBound,
upperBound.documentIdentifier == documentIdentifier,
let boundaryOrder = await resourceOrder(for: upperBound.resourceIdentifier, documentIdentifier: documentIdentifier) else {
return []
}
return await withTaskGroup(of: (RDAIPassage, Int?).self) { group in
for passage in passages {
group.addTask { [store] in
let order = await self.resourceOrder(for: passage.resourceIdentifier, documentIdentifier: documentIdentifier)
return (passage, order)
}
}
var allowed: [RDAIPassage] = []
for await (passage, order) in group {
guard let order, order <= boundaryOrder else { continue }
if passage.resourceIdentifier == upperBound.resourceIdentifier,
passage.locator.textRange.location >= upperBound.textRange.upperBound {
continue
}
allowed.append(passage)
}
return allowed
}
}
private func resourceOrder(for identifier: RDAIResourceIdentifier, documentIdentifier: RDAIDocumentIdentifier) async -> Int? {
if let persistentStore { return try? await persistentStore.resourceOrder(documentID: documentIdentifier, resourceID: identifier) }
return await store.resourceOrder(for: identifier, documentIdentifier: documentIdentifier)
}
private func storedPassages(for identifier: RDAIDocumentIdentifier) async -> [RDAIPassage] {
if let persistentStore { return (try? await persistentStore.passages(documentID: identifier)) ?? [] }
return await store.passages(for: identifier)
}
private func storedEntities(for identifier: RDAIDocumentIdentifier) async -> [RDAIEntityMention] {
if let persistentStore { return (try? await persistentStore.entities(documentID: identifier)) ?? [] }
return await store.entities(for: identifier)
}
private func lexicalScore(query: String, text: String) -> Double {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return 0 }
let normalizedText = text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
let normalizedQuery = trimmed.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
if normalizedText.localizedCaseInsensitiveContains(normalizedQuery) { return 1 }
let terms = normalizedQuery.split(whereSeparator: { $0.isWhitespace || $0.isNewline }).map(String.init)
guard !terms.isEmpty else { return 0 }
let matches = terms.filter { normalizedText.localizedCaseInsensitiveContains($0) }.count
return Double(matches) / Double(terms.count)
}
}
@@ -0,0 +1,44 @@
import Foundation
enum RDAILocatorBuilder {
static func makeLocator(
document: RDAIDocumentDescriptor,
snapshot: RDAIResourceSnapshot,
range: RDAITextRange
) -> RDAILocator? {
let matchingRuns = snapshot.locatorRuns.filter { $0.textRange.intersects(range) }
guard let first = matchingRuns.first else { return nil }
let anchor = mergedAnchor(matchingRuns) ?? first.anchor
return RDAILocator(
documentIdentifier: document.identifier,
resourceIdentifier: snapshot.descriptor.identifier,
textRange: range,
anchor: anchor,
sourceHash: snapshot.sourceHash
)
}
private static func mergedAnchor(_ runs: [RDAILocatorRun]) -> RDAIAnchor? {
guard !runs.isEmpty else { return nil }
let anchors = runs.compactMap { run -> RDAIPDFAnchor? in
if case .pdf(let anchor) = run.anchor { return anchor }
return nil
}
guard anchors.count == runs.count,
let first = anchors.first,
anchors.allSatisfy({ $0.pageIndex == first.pageIndex && $0.textSource == first.textSource }) else {
return nil
}
var rects: [RDAINormalizedRect] = []
for rect in anchors.flatMap(\.rects) where !rects.contains(rect) {
rects.append(rect)
}
let readingOrder = anchors.compactMap(\.readingOrder).min()
return .pdf(RDAIPDFAnchor(
pageIndex: first.pageIndex,
rects: rects,
textSource: first.textSource,
readingOrder: readingOrder
))
}
}
@@ -0,0 +1,448 @@
import Foundation
public struct RDAIDocumentIdentifier: RawRepresentable, Codable, Hashable, Sendable {
public let rawValue: String
public init(rawValue: String) {
self.rawValue = rawValue
}
}
public struct RDAIResourceIdentifier: RawRepresentable, Codable, Hashable, Sendable {
public let rawValue: String
public init(rawValue: String) {
self.rawValue = rawValue
}
}
/// UTF-16 offsets are shared by PDF text runs, EPUB search and speech progress.
public struct RDAITextRange: Codable, Hashable, Sendable {
public var location: Int
public var length: Int
public init(location: Int, length: Int) {
self.location = max(0, location)
self.length = max(0, length)
}
public var upperBound: Int { location + length }
public func intersects(_ other: RDAITextRange) -> Bool {
location < other.upperBound && other.location < upperBound
}
}
public struct RDAINormalizedRect: Codable, Hashable, Sendable {
public var x: Double
public var y: Double
public var width: Double
public var height: Double
public init(x: Double, y: Double, width: Double, height: Double) {
self.x = min(max(x, 0), 1)
self.y = min(max(y, 0), 1)
self.width = min(max(width, 0), 1)
self.height = min(max(height, 0), 1)
}
}
public struct RDAIPDFAnchor: Codable, Hashable, Sendable {
public enum TextSource: String, Codable, Sendable {
case native
case ocr
}
public var pageIndex: Int
public var rects: [RDAINormalizedRect]
public var textSource: TextSource
public var readingOrder: Int?
public init(pageIndex: Int, rects: [RDAINormalizedRect], textSource: TextSource, readingOrder: Int? = nil) {
self.pageIndex = max(0, pageIndex)
self.rects = rects
self.textSource = textSource
self.readingOrder = readingOrder
}
}
public struct RDAIEPUBAnchor: Codable, Hashable, Sendable {
public var href: String
public var cfi: String?
public var rangeCFI: String?
public var progression: Double?
public init(href: String, cfi: String? = nil, rangeCFI: String? = nil, progression: Double? = nil) {
self.href = href
self.cfi = cfi
self.rangeCFI = rangeCFI
self.progression = progression.map { min(max($0, 0), 1) }
}
}
public enum RDAIAnchor: Hashable, Sendable {
case pdf(RDAIPDFAnchor)
case epub(RDAIEPUBAnchor)
}
extension RDAIAnchor: Codable {
private enum CodingKeys: String, CodingKey { case type, pdf, epub }
private enum Kind: String, Codable { case pdf, epub }
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
switch try container.decode(Kind.self, forKey: .type) {
case .pdf:
self = .pdf(try container.decode(RDAIPDFAnchor.self, forKey: .pdf))
case .epub:
self = .epub(try container.decode(RDAIEPUBAnchor.self, forKey: .epub))
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .pdf(let anchor):
try container.encode(Kind.pdf, forKey: .type)
try container.encode(anchor, forKey: .pdf)
case .epub(let anchor):
try container.encode(Kind.epub, forKey: .type)
try container.encode(anchor, forKey: .epub)
}
}
}
public struct RDAILocator: Codable, Hashable, Sendable {
public var documentIdentifier: RDAIDocumentIdentifier
public var resourceIdentifier: RDAIResourceIdentifier
public var textRange: RDAITextRange
public var anchor: RDAIAnchor
public var sourceHash: String
public init(
documentIdentifier: RDAIDocumentIdentifier,
resourceIdentifier: RDAIResourceIdentifier,
textRange: RDAITextRange,
anchor: RDAIAnchor,
sourceHash: String
) {
self.documentIdentifier = documentIdentifier
self.resourceIdentifier = resourceIdentifier
self.textRange = textRange
self.anchor = anchor
self.sourceHash = sourceHash
}
}
public struct RDAICitation: Codable, Hashable, Sendable, Identifiable {
public let id: String
public let passageIdentifier: String
public let quote: String
public let locator: RDAILocator
public init(id: String = UUID().uuidString, passageIdentifier: String, quote: String, locator: RDAILocator) {
self.id = id
self.passageIdentifier = passageIdentifier
self.quote = quote
self.locator = locator
}
}
public enum RDAIDocumentFormat: String, Codable, Sendable {
case pdf
case epub
}
public struct RDAIDocumentDescriptor: Codable, Equatable, Sendable {
public let identifier: RDAIDocumentIdentifier
public let title: String
public let format: RDAIDocumentFormat
public let contentRevision: String
public init(identifier: RDAIDocumentIdentifier, title: String, format: RDAIDocumentFormat, contentRevision: String) {
self.identifier = identifier
self.title = title
self.format = format
self.contentRevision = contentRevision
}
}
public struct RDAIResourceDescriptor: Codable, Equatable, Sendable {
public let identifier: RDAIResourceIdentifier
public let title: String?
public let order: Int
public let estimatedUTF16Length: Int?
public init(identifier: RDAIResourceIdentifier, title: String? = nil, order: Int, estimatedUTF16Length: Int? = nil) {
self.identifier = identifier
self.title = title
self.order = max(0, order)
self.estimatedUTF16Length = estimatedUTF16Length
}
}
public struct RDAILocatorRun: Sendable {
public let textRange: RDAITextRange
public let anchor: RDAIAnchor
public init(textRange: RDAITextRange, anchor: RDAIAnchor) {
self.textRange = textRange
self.anchor = anchor
}
}
public struct RDAIResourceSnapshot: Sendable {
public let descriptor: RDAIResourceDescriptor
public let sourceText: String
public let sourceHash: String
public let locatorRuns: [RDAILocatorRun]
public init(descriptor: RDAIResourceDescriptor, sourceText: String, sourceHash: String, locatorRuns: [RDAILocatorRun]) {
self.descriptor = descriptor
self.sourceText = sourceText
self.sourceHash = sourceHash
self.locatorRuns = locatorRuns
}
}
@MainActor
public protocol RDAIContentProvider: AnyObject {
func aiDocumentDescriptor() -> RDAIDocumentDescriptor
func aiResources() async throws -> [RDAIResourceDescriptor]
func aiResourceSnapshot(for identifier: RDAIResourceIdentifier) async throws -> RDAIResourceSnapshot
func aiNavigate(to locator: RDAILocator, animated: Bool) async throws
func aiShowCitationHighlight(_ citation: RDAICitation) async throws
func aiClearCitationHighlight()
}
public struct RDAIReadScope: Codable, Equatable, Sendable {
public let upperBound: RDAILocator?
public let includesWholeDocument: Bool
public init(upperBound: RDAILocator? = nil, includesWholeDocument: Bool = false) {
self.upperBound = upperBound
self.includesWholeDocument = includesWholeDocument
}
public static let wholeDocument = RDAIReadScope(includesWholeDocument: true)
}
@MainActor
public protocol RDAIReadScopeProviding: AnyObject {
func aiCurrentReadScope() -> RDAIReadScope
}
public enum RDAIPassageKind: String, Codable, Sendable {
case paragraph
case unknown
}
public struct RDAIPassage: Codable, Equatable, Sendable, Identifiable {
public let id: String
public let documentIdentifier: RDAIDocumentIdentifier
public let resourceIdentifier: RDAIResourceIdentifier
public let text: String
public let languageCode: String?
public let kind: RDAIPassageKind
public let locator: RDAILocator
public let contentHash: String
public let order: Int
public init(
id: String,
documentIdentifier: RDAIDocumentIdentifier,
resourceIdentifier: RDAIResourceIdentifier,
text: String,
languageCode: String?,
kind: RDAIPassageKind = .paragraph,
locator: RDAILocator,
contentHash: String,
order: Int
) {
self.id = id
self.documentIdentifier = documentIdentifier
self.resourceIdentifier = resourceIdentifier
self.text = text
self.languageCode = languageCode
self.kind = kind
self.locator = locator
self.contentHash = contentHash
self.order = order
}
}
public enum RDAIEntityKind: String, Codable, Sendable {
case person
case place
case organization
case other
}
public struct RDAIEntityMention: Codable, Equatable, Sendable, Identifiable {
public let id: String
public let normalizedName: String
public let surfaceText: String
public let kind: RDAIEntityKind
public let confidence: Double
public let locator: RDAILocator
public init(id: String, normalizedName: String, surfaceText: String, kind: RDAIEntityKind, confidence: Double, locator: RDAILocator) {
self.id = id
self.normalizedName = normalizedName
self.surfaceText = surfaceText
self.kind = kind
self.confidence = min(max(confidence, 0), 1)
self.locator = locator
}
}
public struct RDAIAnalysisResult: Sendable {
public let passages: [RDAIPassage]
public let entityMentions: [RDAIEntityMention]
public init(passages: [RDAIPassage], entityMentions: [RDAIEntityMention]) {
self.passages = passages
self.entityMentions = entityMentions
}
}
public protocol RDAIAnalyzing: Sendable {
func analyze(document: RDAIDocumentDescriptor, snapshot: RDAIResourceSnapshot) async -> RDAIAnalysisResult
}
public protocol RDAISemanticScoring: Sendable {
func score(query: String, text: String, languageCode: String?) async -> Double?
}
public enum RDAIError: Error, Equatable, Sendable {
case disabled
case invalidDocument
case resourceUnavailable(RDAIResourceIdentifier)
case staleCitation
case indexingFailed(String)
case modelUnavailable(RDAIUnavailableReason)
case insufficientEvidence
case storageFailure(String)
case cancelled
}
public enum RDAICapability: String, Codable, Sendable {
case languageAnalysis, entityExtraction, lexicalSearch, semanticSearch
case summarization, questionAnswering, characterRelationships
}
public enum RDAIUnavailableReason: Equatable, Sendable {
case operatingSystemUnsupported, deviceNotEligible, appleIntelligenceNotEnabled
case modelNotReady, languageUnsupported(String?), embeddingAssetsUnavailable
case providerNotInstalled, unknown(String)
}
public enum RDAICapabilityAvailability: Equatable, Sendable {
case available
case degraded(reason: RDAIUnavailableReason)
case unavailable(reason: RDAIUnavailableReason)
}
public protocol RDAICapabilityProviding: Sendable {
func availability(for capability: RDAICapability, locale: Locale?) async -> RDAICapabilityAvailability
}
public enum RDAISummaryLength: String, Codable, Sendable { case brief, standard, detailed }
public enum RDAIAnswerStatus: String, Codable, Sendable { case answered, insufficientEvidence, unsupportedLanguage, unavailable }
public enum RDAIRelationshipStatus: String, Codable, Sendable { case confirmed, possible, conflicting }
public struct RDAIGenerationMetadata: Codable, Sendable {
public let providerIdentifier: String
public let modelVersion: String?
public let promptIdentifier: String
public let promptVersion: Int
public let generatedAt: Date
public let scopeHash: String
public init(providerIdentifier: String, modelVersion: String? = nil, promptIdentifier: String, promptVersion: Int, generatedAt: Date = .init(), scopeHash: String) {
self.providerIdentifier = providerIdentifier
self.modelVersion = modelVersion
self.promptIdentifier = promptIdentifier
self.promptVersion = promptVersion
self.generatedAt = generatedAt
self.scopeHash = scopeHash
}
}
public struct RDAISourcedStatement: Codable, Sendable, Identifiable {
public let id: String
public let text: String
public let citationIdentifiers: [String]
public init(id: String = UUID().uuidString, text: String, citationIdentifiers: [String]) { self.id = id; self.text = text; self.citationIdentifiers = citationIdentifiers }
}
public struct RDAISummary: Codable, Sendable {
public let title: String
public let overview: String
public let keyPoints: [RDAISourcedStatement]
public let citations: [RDAICitation]
public let metadata: RDAIGenerationMetadata
}
public struct RDAIAnswer: Codable, Sendable {
public let status: RDAIAnswerStatus
public let text: String
public let statements: [RDAISourcedStatement]
public let citations: [RDAICitation]
public let metadata: RDAIGenerationMetadata
}
public struct RDAICharacter: Codable, Sendable, Identifiable {
public let id: String
public let displayName: String
public let aliases: [String]
public let description: String
public let firstAppearance: RDAICitation?
public let evidence: [RDAICitation]
}
public struct RDAIRelationship: Codable, Sendable, Identifiable {
public let id: String
public let sourceCharacterIdentifier: String
public let targetCharacterIdentifier: String
public let label: String
public let status: RDAIRelationshipStatus
public let evidence: [RDAICitation]
}
public enum RDAIGenerationTask: Sendable { case summary(RDAISummaryLength), answer, characters, relationships }
public struct RDAIGenerationRequest: Sendable {
public let task: RDAIGenerationTask
public let userText: String?
public let passages: [RDAIPassage]
public let locale: Locale
public let scope: RDAIReadScope
public init(task: RDAIGenerationTask, userText: String? = nil, passages: [RDAIPassage], locale: Locale = .current, scope: RDAIReadScope) { self.task = task; self.userText = userText; self.passages = passages; self.locale = locale; self.scope = scope }
}
public enum RDAIGeneratedArtifact: Sendable {
case summary(overview: String, statements: [RDAISourcedStatement])
case answer(text: String, statements: [RDAISourcedStatement])
case characters([RDAICharacter])
case relationships([RDAIRelationship])
}
public protocol RDAIGenerativeProvider: RDAICapabilityProviding {
var identifier: String { get }
func generate(_ request: RDAIGenerationRequest) async throws -> RDAIGeneratedArtifact
}
public enum RDAISpoilerPolicy: String, Codable, Sendable { case readContentOnly, wholeDocument }
public enum RDAIDiagnosticsLevel: String, Codable, Sendable { case disabled, metadataOnly }
public struct RDAIReaderConfiguration: Sendable {
public var isAIEnabled: Bool
public var isLocalOnly: Bool
public var spoilerPolicy: RDAISpoilerPolicy
public var maximumRetrievedPassages: Int
public var storesGeneratedArtifacts: Bool
public var diagnosticsLevel: RDAIDiagnosticsLevel
public init(isAIEnabled: Bool = true, isLocalOnly: Bool = true, spoilerPolicy: RDAISpoilerPolicy = .readContentOnly, maximumRetrievedPassages: Int = 4, storesGeneratedArtifacts: Bool = true, diagnosticsLevel: RDAIDiagnosticsLevel = .metadataOnly) { self.isAIEnabled = isAIEnabled; self.isLocalOnly = isLocalOnly; self.spoilerPolicy = spoilerPolicy; self.maximumRetrievedPassages = max(1, maximumRetrievedPassages); self.storesGeneratedArtifacts = storesGeneratedArtifacts; self.diagnosticsLevel = diagnosticsLevel }
public static let `default` = RDAIReaderConfiguration()
}
@@ -0,0 +1,125 @@
import Foundation
import SQLite3
private let rdaiSQLiteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
/// Durable local store. Each resource replacement is atomic, so interrupted
/// indexing can safely resume from the first resource without a checkpoint.
public actor RDAISQLiteIndexStore {
private var database: OpaquePointer?
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
public init(directory: URL? = nil) throws {
let base = directory ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
let url = base.appendingPathComponent("RDAIReaderView.sqlite")
guard sqlite3_open_v2(url.path, &database, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
throw RDAIError.storageFailure("open")
}
try Self.configure(database: database)
}
deinit { sqlite3_close(database) }
public func replace(document: RDAIDocumentDescriptor, resource: RDAIResourceDescriptor, analysis: RDAIAnalysisResult, sourceHash: String) throws {
try transaction {
try execute("INSERT INTO documents(id,title,format,revision) VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET title=excluded.title,format=excluded.format,revision=excluded.revision", [document.identifier.rawValue, document.title, document.format.rawValue, document.contentRevision])
try execute("DELETE FROM resources WHERE document_id=? AND resource_id=?", [document.identifier.rawValue, resource.identifier.rawValue])
try execute("INSERT INTO resources(document_id,resource_id,source_hash,payload) VALUES(?,?,?,?)", [document.identifier.rawValue, resource.identifier.rawValue, sourceHash, try json(resource)])
try execute("DELETE FROM passages WHERE document_id=? AND resource_id=?", [document.identifier.rawValue, resource.identifier.rawValue])
try execute("DELETE FROM passage_fts WHERE document_id=? AND resource_id=?", [document.identifier.rawValue, resource.identifier.rawValue])
try execute("DELETE FROM entity_mentions WHERE document_id=? AND resource_id=?", [document.identifier.rawValue, resource.identifier.rawValue])
for passage in analysis.passages {
try execute("INSERT INTO passages(id,document_id,resource_id,content_hash,payload) VALUES(?,?,?,?,?)", [passage.id, document.identifier.rawValue, resource.identifier.rawValue, passage.contentHash, try json(passage)])
try execute("INSERT INTO passage_fts(id,document_id,resource_id,text) VALUES(?,?,?,?)", [passage.id, document.identifier.rawValue, resource.identifier.rawValue, passage.text])
}
for mention in analysis.entityMentions {
try execute("INSERT INTO entity_mentions(id,document_id,resource_id,normalized_name,payload) VALUES(?,?,?,?,?)", [mention.id, document.identifier.rawValue, resource.identifier.rawValue, mention.normalizedName, try json(mention)])
}
}
}
public func resourceHash(documentID: RDAIDocumentIdentifier, resourceID: RDAIResourceIdentifier) throws -> String? {
try query("SELECT source_hash FROM resources WHERE document_id=? AND resource_id=?", [documentID.rawValue, resourceID.rawValue]).first
}
public func passages(documentID: RDAIDocumentIdentifier) throws -> [RDAIPassage] {
try query("SELECT payload FROM passages WHERE document_id=? ORDER BY rowid", [documentID.rawValue]).compactMap { try? decode(RDAIPassage.self, $0) }
}
public func entities(documentID: RDAIDocumentIdentifier) throws -> [RDAIEntityMention] {
try query("SELECT payload FROM entity_mentions WHERE document_id=? ORDER BY rowid", [documentID.rawValue]).compactMap { try? decode(RDAIEntityMention.self, $0) }
}
public func resourceOrder(documentID: RDAIDocumentIdentifier, resourceID: RDAIResourceIdentifier) throws -> Int? {
guard let payload = try query("SELECT payload FROM resources WHERE document_id=? AND resource_id=?", [documentID.rawValue, resourceID.rawValue]).first,
let resource = try? decode(RDAIResourceDescriptor.self, payload) else { return nil }
return resource.order
}
public func remove(documentID: RDAIDocumentIdentifier) throws {
try transaction {
try execute("DELETE FROM passage_fts WHERE document_id=?", [documentID.rawValue])
try execute("DELETE FROM documents WHERE id=?", [documentID.rawValue])
}
}
public func removeAll() throws {
try transaction {
try execute("DELETE FROM documents")
try execute("DELETE FROM passage_fts")
}
}
public func saveCheckpoint(documentID: RDAIDocumentIdentifier, resourceOrder: Int) throws {
try execute("INSERT INTO index_jobs(document_id,completed_order,updated_at) VALUES(?,?,?) ON CONFLICT(document_id) DO UPDATE SET completed_order=excluded.completed_order,updated_at=excluded.updated_at", [documentID.rawValue, String(resourceOrder), String(Date().timeIntervalSince1970)])
}
public func checkpoint(documentID: RDAIDocumentIdentifier) throws -> Int? {
try query("SELECT completed_order FROM index_jobs WHERE document_id=?", [documentID.rawValue]).first.flatMap(Int.init)
}
public func saveArtifact(documentID: RDAIDocumentIdentifier, key: String, payload: Data) throws {
try execute("INSERT INTO artifacts(document_id,cache_key,payload,created_at) VALUES(?,?,?,?) ON CONFLICT(document_id,cache_key) DO UPDATE SET payload=excluded.payload,created_at=excluded.created_at", [documentID.rawValue, key, payload, String(Date().timeIntervalSince1970)])
}
public func artifact(documentID: RDAIDocumentIdentifier, key: String) throws -> Data? {
try queryData("SELECT payload FROM artifacts WHERE document_id=? AND cache_key=?", [documentID.rawValue, key]).first
}
public func storageBytes() -> Int64 {
guard let database,
let path = sqlite3_db_filename(database, "main"),
let attributes = try? FileManager.default.attributesOfItem(atPath: String(cString: path)),
let size = attributes[.size] as? NSNumber else { return 0 }
return size.int64Value
}
private static func configure(database: OpaquePointer?) throws {
try executeScript(database: database, sql: """
CREATE TABLE IF NOT EXISTS schema_metadata(version INTEGER NOT NULL);
INSERT INTO schema_metadata(version) SELECT 1 WHERE NOT EXISTS(SELECT 1 FROM schema_metadata);
CREATE TABLE IF NOT EXISTS documents(id TEXT PRIMARY KEY,title TEXT NOT NULL,format TEXT NOT NULL,revision TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS resources(document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,resource_id TEXT NOT NULL,source_hash TEXT NOT NULL,payload BLOB NOT NULL,PRIMARY KEY(document_id,resource_id));
CREATE TABLE IF NOT EXISTS passages(id TEXT PRIMARY KEY,document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,resource_id TEXT NOT NULL,content_hash TEXT NOT NULL,payload BLOB NOT NULL);
CREATE VIRTUAL TABLE IF NOT EXISTS passage_fts USING fts5(id UNINDEXED,document_id UNINDEXED,resource_id UNINDEXED,text);
CREATE TABLE IF NOT EXISTS entity_mentions(id TEXT PRIMARY KEY,document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,resource_id TEXT NOT NULL,normalized_name TEXT NOT NULL,payload BLOB NOT NULL);
CREATE TABLE IF NOT EXISTS index_jobs(document_id TEXT PRIMARY KEY REFERENCES documents(id) ON DELETE CASCADE,completed_order INTEGER NOT NULL,updated_at REAL NOT NULL);
CREATE TABLE IF NOT EXISTS artifacts(document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,cache_key TEXT NOT NULL,payload BLOB NOT NULL,created_at REAL NOT NULL,PRIMARY KEY(document_id,cache_key));
""")
}
private func transaction(_ body: () throws -> Void) throws { try execute("BEGIN IMMEDIATE"); do { try body(); try execute("COMMIT") } catch { try? execute("ROLLBACK"); throw error } }
private static func executeScript(database: OpaquePointer?, sql: String) throws {
var errorMessage: UnsafeMutablePointer<Int8>?
guard sqlite3_exec(database, sql, nil, nil, &errorMessage) == SQLITE_OK else {
defer { sqlite3_free(errorMessage) }
throw RDAIError.storageFailure("migration")
}
}
private func json<T: Encodable>(_ value: T) throws -> Data { try encoder.encode(value) }
private func decode<T: Decodable>(_ type: T.Type, _ value: String) throws -> T { try decoder.decode(T.self, from: Data(value.utf8)) }
private func queryData(_ sql: String, _ bindings: [String]) throws -> [Data] { var statement: OpaquePointer?; guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else { throw RDAIError.storageFailure("prepare") }; defer { sqlite3_finalize(statement) }; for (index, value) in bindings.enumerated() { sqlite3_bind_text(statement, Int32(index + 1), value, -1, rdaiSQLiteTransient) }; var rows: [Data] = []; while sqlite3_step(statement) == SQLITE_ROW { let count = sqlite3_column_bytes(statement, 0); if let value = sqlite3_column_blob(statement, 0), count > 0 { rows.append(Data(bytes: value, count: Int(count))) } }; return rows }
private func query(_ sql: String, _ bindings: [String]) throws -> [String] { var statement: OpaquePointer?; guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else { throw RDAIError.storageFailure("prepare") }; defer { sqlite3_finalize(statement) }; for (index, value) in bindings.enumerated() { sqlite3_bind_text(statement, Int32(index + 1), value, -1, rdaiSQLiteTransient) }; var rows: [String] = []; while sqlite3_step(statement) == SQLITE_ROW { if let value = sqlite3_column_text(statement, 0) { rows.append(String(cString: value)) } }; return rows }
private func execute(_ sql: String, _ bindings: [Any] = []) throws { var statement: OpaquePointer?; guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else { throw RDAIError.storageFailure("prepare") }; defer { sqlite3_finalize(statement) }; for (index, value) in bindings.enumerated() { if let text = value as? String { sqlite3_bind_text(statement, Int32(index + 1), text, -1, rdaiSQLiteTransient) } else if let data = value as? Data { _ = data.withUnsafeBytes { sqlite3_bind_blob(statement, Int32(index + 1), $0.baseAddress, Int32(data.count), rdaiSQLiteTransient) } } }; guard sqlite3_step(statement) == SQLITE_DONE else { throw RDAIError.storageFailure("execute") } }
}
@@ -0,0 +1,77 @@
import Foundation
import FoundationModels
@available(iOS 26.0, *)
@Generable(description: "A factual statement grounded in one or more supplied passage identifiers.")
private struct RDAIModelStatement {
var text: String
var passageIDs: [String]
}
@available(iOS 26.0, *)
@Generable(description: "A citation-grounded book summary.")
private struct RDAIModelSummary {
var overview: String
var statements: [RDAIModelStatement]
}
@available(iOS 26.0, *)
@Generable(description: "A citation-grounded answer to a question about supplied passages.")
private struct RDAIModelAnswer {
var answer: String
var statements: [RDAIModelStatement]
}
/// Optional on-device provider. The Core service remains fully functional on
/// older systems by falling back to evidence-only retrieval.
@available(iOS 26.0, *)
public actor RDAIAppleFoundationModelsProvider: RDAIGenerativeProvider {
public let identifier = "apple.foundation-models"
public init() {}
public func availability(for capability: RDAICapability, locale: Locale?) async -> RDAICapabilityAvailability {
switch SystemLanguageModel.default.availability {
case .available: return .available
case .unavailable(.deviceNotEligible): return .unavailable(reason: .deviceNotEligible)
case .unavailable(.appleIntelligenceNotEnabled): return .unavailable(reason: .appleIntelligenceNotEnabled)
case .unavailable(.modelNotReady): return .unavailable(reason: .modelNotReady)
@unknown default: return .unavailable(reason: .unknown("system-model-unavailable"))
}
}
public func generate(_ request: RDAIGenerationRequest) async throws -> RDAIGeneratedArtifact {
guard case .available = await availability(for: capability(for: request.task), locale: request.locale) else {
throw RDAIError.modelUnavailable(.modelNotReady)
}
let context = request.passages.enumerated().map { "[\($0.element.id)] \($0.element.text)" }.joined(separator: "\n\n")
let task: String
switch request.task {
case .summary: task = "Summarize the passages in the user's language. Do not add facts."
case .answer: task = "Answer the question using only the passages. If evidence is insufficient, say so. Question: \(request.userText ?? "")"
case .characters, .relationships: throw RDAIError.insufficientEvidence
}
let session = LanguageModelSession(instructions: "You are a book reading assistant. Use only supplied passages and never reveal unread content.")
switch request.task {
case .summary:
let response = try await session.respond(to: "\(task) Each statement must include its supplied passage IDs.\n\nPassages:\n\(context)", generating: RDAIModelSummary.self)
return .summary(overview: response.content.overview, statements: response.content.statements.map(statement))
case .answer:
let response = try await session.respond(to: "\(task) Each statement must include its supplied passage IDs.\n\nPassages:\n\(context)", generating: RDAIModelAnswer.self)
return .answer(text: response.content.answer, statements: response.content.statements.map(statement))
case .characters, .relationships: throw RDAIError.insufficientEvidence
}
}
private func capability(for task: RDAIGenerationTask) -> RDAICapability {
switch task {
case .summary: return .summarization
case .answer: return .questionAnswering
case .characters, .relationships: return .characterRelationships
}
}
private func statement(_ value: RDAIModelStatement) -> RDAISourcedStatement {
RDAISourcedStatement(text: value.text, citationIdentifiers: value.passageIDs)
}
}
@@ -0,0 +1,171 @@
import Foundation
import NaturalLanguage
public struct RDAINaturalLanguageAnalyzer: RDAIAnalyzing {
public struct Configuration: Sendable {
public var maximumPassageUTF16Length: Int
public var overlapSentenceCount: Int
public init(maximumPassageUTF16Length: Int = 800, overlapSentenceCount: Int = 1) {
self.maximumPassageUTF16Length = max(160, maximumPassageUTF16Length)
self.overlapSentenceCount = max(0, overlapSentenceCount)
}
}
public let configuration: Configuration
public init(configuration: Configuration = .init()) {
self.configuration = configuration
}
public func analyze(document: RDAIDocumentDescriptor, snapshot: RDAIResourceSnapshot) async -> RDAIAnalysisResult {
await Task.detached(priority: .utility) {
let language = Self.detectLanguage(in: snapshot.sourceText)
let passages = Self.makePassages(document: document, snapshot: snapshot, language: language, configuration: configuration)
let mentions = Self.makeEntityMentions(document: document, snapshot: snapshot)
return RDAIAnalysisResult(passages: passages, entityMentions: mentions)
}.value
}
private static func detectLanguage(in text: String) -> String? {
let recognizer = NLLanguageRecognizer()
recognizer.processString(text)
return recognizer.dominantLanguage?.rawValue
}
private static func makePassages(
document: RDAIDocumentDescriptor,
snapshot: RDAIResourceSnapshot,
language: String?,
configuration: Configuration
) -> [RDAIPassage] {
let sentenceRanges = sentenceRanges(
in: snapshot.sourceText,
maximumUTF16Length: configuration.maximumPassageUTF16Length
)
guard !sentenceRanges.isEmpty else { return [] }
var result: [RDAIPassage] = []
var startIndex = 0
var order = 0
while startIndex < sentenceRanges.count {
var endIndex = startIndex
var length = 0
while endIndex < sentenceRanges.count {
let candidate = sentenceRanges[endIndex]
let nextLength = max(candidate.upperBound - sentenceRanges[startIndex].location, candidate.length)
if endIndex > startIndex && nextLength > configuration.maximumPassageUTF16Length { break }
length = nextLength
endIndex += 1
}
guard length > 0 else { break }
let rawRange = RDAITextRange(location: sentenceRanges[startIndex].location, length: length)
guard let range = trimmedRange(rawRange, in: snapshot.sourceText),
let locator = RDAILocatorBuilder.makeLocator(document: document, snapshot: snapshot, range: range) else {
startIndex = max(startIndex + 1, endIndex)
continue
}
let text = (snapshot.sourceText as NSString).substring(with: NSRange(location: range.location, length: range.length))
let contentHash = RDAIContentHasher.hash(text)
result.append(RDAIPassage(
id: RDAIContentHasher.hash("\(document.identifier.rawValue)|\(snapshot.descriptor.identifier.rawValue)|\(range.location)|\(range.length)|\(contentHash)"),
documentIdentifier: document.identifier,
resourceIdentifier: snapshot.descriptor.identifier,
text: text,
languageCode: language,
locator: locator,
contentHash: contentHash,
order: order
))
order += 1
let nextStart = max(endIndex - configuration.overlapSentenceCount, startIndex + 1)
startIndex = nextStart
}
return result
}
private static func sentenceRanges(in text: String, maximumUTF16Length: Int) -> [RDAITextRange] {
let tokenizer = NLTokenizer(unit: .sentence)
tokenizer.string = text
var ranges: [RDAITextRange] = []
tokenizer.enumerateTokens(in: text.startIndex..<text.endIndex) { range, _ in
let nsRange = NSRange(range, in: text)
if nsRange.length > 0 { ranges.append(RDAITextRange(location: nsRange.location, length: nsRange.length)) }
return true
}
if ranges.isEmpty, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
ranges = [RDAITextRange(location: 0, length: text.utf16.count)]
}
return ranges.flatMap { split($0, in: text, maximumUTF16Length: maximumUTF16Length) }
}
private static func split(
_ range: RDAITextRange,
in text: String,
maximumUTF16Length: Int
) -> [RDAITextRange] {
guard range.length > maximumUTF16Length else { return [range] }
let source = text as NSString
var result: [RDAITextRange] = []
var cursor = range.location
while cursor < range.upperBound {
let candidate = min(cursor + maximumUTF16Length, range.upperBound)
var boundary = candidate
if candidate < range.upperBound {
let composed = source.rangeOfComposedCharacterSequence(at: candidate)
boundary = composed.location > cursor ? composed.location : min(composed.upperBound, range.upperBound)
}
guard boundary > cursor else { break }
result.append(RDAITextRange(location: cursor, length: boundary - cursor))
cursor = boundary
}
return result
}
private static func trimmedRange(_ range: RDAITextRange, in text: String) -> RDAITextRange? {
let source = (text as NSString).substring(with: NSRange(location: range.location, length: range.length))
let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let leading = source.utf16.count - source.drop(while: { $0.isWhitespace || $0.isNewline }).utf16.count
return RDAITextRange(location: range.location + leading, length: trimmed.utf16.count)
}
private static func makeEntityMentions(
document: RDAIDocumentDescriptor,
snapshot: RDAIResourceSnapshot
) -> [RDAIEntityMention] {
let tagger = NLTagger(tagSchemes: [.nameType])
tagger.string = snapshot.sourceText
var mentions: [RDAIEntityMention] = []
let fullRange = snapshot.sourceText.startIndex..<snapshot.sourceText.endIndex
tagger.enumerateTags(in: fullRange, unit: .word, scheme: .nameType, options: [.omitWhitespace, .omitPunctuation, .joinNames]) { tag, range in
guard let tag,
let kind = entityKind(for: tag) else { return true }
let nsRange = NSRange(range, in: snapshot.sourceText)
let textRange = RDAITextRange(location: nsRange.location, length: nsRange.length)
guard let locator = RDAILocatorBuilder.makeLocator(document: document, snapshot: snapshot, range: textRange) else { return true }
let surfaceText = String(snapshot.sourceText[range])
let normalized = surfaceText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else { return true }
mentions.append(RDAIEntityMention(
id: RDAIContentHasher.hash("\(snapshot.descriptor.identifier.rawValue)|\(textRange.location)|\(normalized)|\(kind.rawValue)"),
normalizedName: normalized.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current),
surfaceText: normalized,
kind: kind,
confidence: 0.8,
locator: locator
))
return true
}
return mentions
}
private static func entityKind(for tag: NLTag) -> RDAIEntityKind? {
switch tag {
case .personalName: return .person
case .placeName: return .place
case .organizationName: return .organization
default: return nil
}
}
}
@@ -0,0 +1,19 @@
import Foundation
import NaturalLanguage
/// Uses Apple's bundled sentence embeddings when the language asset exists.
/// Distances are converted to a stable 0...1 ranking signal only within one
/// query; callers must not compare values across embedding revisions.
public struct RDAINaturalLanguageSemanticScorer: RDAISemanticScoring {
public init() {}
public func score(query: String, text: String, languageCode: String?) async -> Double? {
await Task.detached(priority: .utility) {
let language = languageCode.flatMap(NLLanguage.init(rawValue:)) ?? .english
guard let embedding = NLEmbedding.sentenceEmbedding(for: language) else { return nil }
let distance = embedding.distance(between: query, and: text)
guard distance.isFinite else { return nil }
return 1 / (1 + Double(distance))
}.value
}
}
@@ -0,0 +1,37 @@
Pod::Spec.new do |s|
s.name = "RDAIReaderView"
s.module_name = "RDAIReaderView"
s.version = "0.1.0"
s.summary = "Local-first AI indexing and retrieval primitives for ReadViewSDK readers"
s.platform = :ios, "15.0"
s.swift_versions = ["5.10"]
s.homepage = "https://example.invalid/RDAIReaderView"
s.author = { "readoor" => "ios@touchread.com" }
s.source = { :path => "." }
s.license = "MIT"
s.requires_arc = true
s.subspec "Core" do |core|
core.source_files = "Core/**/*.swift"
core.frameworks = "CryptoKit"
core.libraries = "sqlite3"
end
s.subspec "NaturalLanguage" do |natural_language|
natural_language.source_files = "NaturalLanguage/**/*.swift"
natural_language.dependency "RDAIReaderView/Core", "~> 0.1"
natural_language.frameworks = "NaturalLanguage"
end
s.subspec "FoundationModels" do |foundation_models|
foundation_models.source_files = "FoundationModels/**/*.swift"
foundation_models.dependency "RDAIReaderView/Core", "~> 0.1"
foundation_models.frameworks = "FoundationModels"
end
s.subspec "UI" do |ui|
ui.source_files = "UI/**/*.swift"
ui.dependency "RDAIReaderView/Core", "~> 0.1"
ui.frameworks = "UIKit"
end
end
+38
View File
@@ -0,0 +1,38 @@
# RDAIReaderView
`RDAIReaderView` is the local indexing and retrieval foundation for ReadViewSDK AI reader features. Version `0.1` provides Natural Language sentence chunking, language detection, named-entity candidates, source-backed citations and local lexical retrieval. It does not upload book content.
## Install
```ruby
pod 'RDAIReaderView/NaturalLanguage'
pod 'RDPDFReaderView/AI'
pod 'RDEpubReaderView/AI'
```
## Use with PDF
```swift
let service = reader.makeAIReaderService()
try await service.prepareIndex(
options: RDAIIndexOptions(scope: .wholeDocument)
)
let matches = await service.retrieve(
query: "主角为什么离开",
options: RDAIRetrievalOptions(scope: .wholeDocument)
)
if let match = matches.first {
let citation = RDAICitation(
passageIdentifier: match.passage.id,
quote: match.passage.text,
locator: match.passage.locator
)
try await reader.makeAIContentProvider().aiShowCitationHighlight(citation)
}
```
PDF citations retain native-text versus OCR provenance. EPUB citations use normalized `href` and derive CFI ranges when navigating.
Foundation Models generation, persistent SQLite storage and the AI UI are planned follow-up modules; the Core API is intentionally independent of those capabilities.
@@ -0,0 +1,136 @@
import UIKit
/// A reusable, local-first assistant sheet. Hosts supply the read scope so the
/// UI cannot silently expand answers beyond the reader's spoiler boundary.
@MainActor
public final class RDAIReaderAssistantViewController: UIViewController {
private let service: RDAIReaderService
private let scope: RDAIReadScope
private let output = UITextView()
private let citationsStack = UIStackView()
private let questionField = UITextField()
private let activity = UIActivityIndicatorView(style: .medium)
private var citations: [RDAICitation] = []
private var indexingTask: Task<Void, Never>?
private struct Presentation {
let text: String
let citations: [RDAICitation]
}
public init(service: RDAIReaderService, scope: RDAIReadScope) {
self.service = service
self.scope = scope
super.init(nibName: nil, bundle: nil)
title = "阅读助手"
}
required init?(coder: NSCoder) { nil }
public override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
let closeButton = UIBarButtonItem(barButtonSystemItem: .close, target: self, action: #selector(close))
let cancelButton = UIBarButtonItem(title: "取消索引", style: .plain, target: self, action: #selector(cancelIndexing))
navigationItem.rightBarButtonItems = [closeButton, cancelButton]
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "清除数据", style: .plain, target: self, action: #selector(clearAIData))
let summary = button("摘要", action: #selector(showSummary))
let characters = button("人物", action: #selector(showCharacters))
let relationships = button("关系", action: #selector(showRelationships))
questionField.placeholder = "向已读内容提问"
questionField.borderStyle = .roundedRect
questionField.returnKeyType = .send
questionField.delegate = self
output.isEditable = false
output.font = .preferredFont(forTextStyle: .body)
output.adjustsFontForContentSizeCategory = true
output.accessibilityIdentifier = "rdai.assistant.output"
let actions = UIStackView(arrangedSubviews: [summary, characters, relationships])
actions.axis = .horizontal; actions.distribution = .fillEqually; actions.spacing = 8
citationsStack.axis = .vertical; citationsStack.spacing = 6
let stack = UIStackView(arrangedSubviews: [actions, questionField, output, citationsStack])
stack.axis = .vertical; stack.spacing = 12
view.addSubview(stack); view.addSubview(activity)
stack.translatesAutoresizingMaskIntoConstraints = false; activity.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), stack.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), stack.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), stack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16), output.heightAnchor.constraint(greaterThanOrEqualToConstant: 180), activity.centerXAnchor.constraint(equalTo: view.centerXAnchor), activity.centerYAnchor.constraint(equalTo: view.centerYAnchor)])
startIndexing()
}
deinit { indexingTask?.cancel() }
@objc private func close() { indexingTask?.cancel(); service.resumeIndexing(); service.clearCitationHighlight(); dismiss(animated: true) }
@objc private func cancelIndexing() { indexingTask?.cancel(); service.resumeIndexing(); output.text = "已取消本地索引。"; activity.stopAnimating() }
@objc private func clearAIData() {
let alert = UIAlertController(title: "清除 AI 数据", message: "将删除本书的本地索引、缓存和生成结果,不会删除原书或用户标注。", preferredStyle: .actionSheet)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "清除", style: .destructive) { [weak self] _ in
guard let self else { return }
Task { await self.service.removeIndex(); self.service.clearCitationHighlight(); self.output.text = "已清除本地 AI 数据。"; self.renderCitations([]) }
})
present(alert, animated: true)
}
private func startIndexing() {
indexingTask?.cancel()
indexingTask = Task { [weak self] in
guard let self else { return }
await self.prepareIndex()
}
}
private func prepareIndex() async {
activity.startAnimating()
output.text = "正在建立本地索引…"
let updatesTask = Task { [weak self] in
guard let self else { return }
for await state in service.stateUpdates() {
guard !Task.isCancelled else { return }
self.display(indexState: state)
}
}
defer { updatesTask.cancel(); activity.stopAnimating(); indexingTask = nil }
do {
try await service.prepareIndex(options: .init(scope: scope))
} catch is CancellationError {
output.text = "已取消本地索引。"
} catch {
output.text = "本地索引暂不可用。"
}
}
private func display(indexState: RDAIIndexState) {
switch indexState {
case .indexing(let completed, let total): output.text = "正在建立本地索引:\(completed)/\(total)"
case .paused(let completed, let total): output.text = "索引已暂停:\(completed)/\(total)"
case .ready: output.text = "本地索引已就绪。"
case .failed: output.text = "本地索引暂不可用。"
case .notStarted: break
}
}
@objc private func showSummary() { Task { await perform("正在生成摘要…") { let summary = try await self.service.summarize(scope: self.scope, length: .standard); return .init(text: summary.keyPoints.map(\.text).joined(separator: "\n\n"), citations: summary.citations) } } }
@objc private func showCharacters() { Task { await perform("正在整理人物…") { let values = await self.service.characters(scope: self.scope); return .init(text: values.map { "\($0.displayName)\($0.description)" }.joined(separator: "\n"), citations: values.flatMap(\.evidence)) } } }
@objc private func showRelationships() { Task { await perform("正在整理关系…") {
let characters = await self.service.characters(scope: self.scope)
let names = Dictionary(uniqueKeysWithValues: characters.map { ($0.id, $0.displayName) })
let values = await self.service.relationships(scope: self.scope)
guard !values.isEmpty else { return .init(text: "当前已读范围内没有足够证据建立人物关系。", citations: []) }
return .init(text: values.map { relationship in
let source = names[relationship.sourceCharacterIdentifier] ?? "未知人物"
let target = names[relationship.targetCharacterIdentifier] ?? "未知人物"
return "\(source) - \(relationship.label) - \(target)\(relationship.status.localizedDescription)"
}.joined(separator: "\n"), citations: values.flatMap(\.evidence))
} } }
private func ask() { guard let question = questionField.text?.trimmingCharacters(in: .whitespacesAndNewlines), !question.isEmpty else { return }; Task { await perform("正在检索原文…") { let answer = try await self.service.answer(question: question, scope: self.scope); return .init(text: ([answer.text] + answer.statements.map(\.text)).joined(separator: "\n\n"), citations: answer.citations) } } }
private func perform(_ placeholder: String, operation: @escaping @MainActor () async throws -> Presentation) async { activity.startAnimating(); output.text = placeholder; renderCitations([]); defer { activity.stopAnimating() }; do { let presentation = try await operation(); output.text = presentation.text; renderCitations(presentation.citations) } catch { output.text = "暂时无法完成此请求。" } }
private func renderCitations(_ values: [RDAICitation]) { citations = values; citationsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }; for (index, citation) in values.enumerated() { var configuration = UIButton.Configuration.gray(); configuration.title = "原文:\(citation.quote.prefix(42))"; configuration.titleLineBreakMode = .byTruncatingTail; let button = UIButton(configuration: configuration); button.contentHorizontalAlignment = .leading; button.tag = index; button.addTarget(self, action: #selector(openCitation(_:)), for: .touchUpInside); citationsStack.addArrangedSubview(button) } }
@objc private func openCitation(_ sender: UIButton) { guard citations.indices.contains(sender.tag) else { return }; Task { try? await service.showCitation(citations[sender.tag]) } }
private func button(_ title: String, action: Selector) -> UIButton { var configuration = UIButton.Configuration.tinted(); configuration.title = title; let button = UIButton(configuration: configuration); button.addTarget(self, action: action, for: .touchUpInside); return button }
}
extension RDAIReaderAssistantViewController: UITextFieldDelegate { public func textFieldShouldReturn(_ textField: UITextField) -> Bool { ask(); return true } }
private extension RDAIRelationshipStatus {
var localizedDescription: String {
switch self { case .confirmed: return "已确认"; case .possible: return "可能"; case .conflicting: return "存在冲突" }
}
}
@@ -0,0 +1,198 @@
import Foundation
import RDAIReaderView
import UIKit
/// EPUB adapter using normalized hrefs and the existing CFI index table for
/// stable citation navigation across font and pagination changes.
@MainActor
public final class RDEPUBAIContentProvider: RDAIContentProvider {
private weak var controller: RDEPUBReaderController?
init(controller: RDEPUBReaderController) {
self.controller = controller
}
public func aiDocumentDescriptor() -> RDAIDocumentDescriptor {
let identifier = controller?.currentBookIdentifier ?? "rd-epub-reader"
let chapters = readableChapters()
let revision = chapters.map { "\($0.href)|\($0.text.utf16.count)" }.joined(separator: "|")
return RDAIDocumentDescriptor(
identifier: RDAIDocumentIdentifier(rawValue: identifier),
title: controller?.title ?? "",
format: .epub,
contentRevision: RDAIContentHasher.hash(revision)
)
}
public func aiResources() async throws -> [RDAIResourceDescriptor] {
readableChapters().enumerated().map { index, chapter in
RDAIResourceDescriptor(
identifier: RDAIResourceIdentifier(rawValue: chapter.href),
title: chapter.title,
order: index,
estimatedUTF16Length: chapter.text.utf16.count
)
}
}
public func aiResourceSnapshot(for identifier: RDAIResourceIdentifier) async throws -> RDAIResourceSnapshot {
guard let chapter = readableChapters().first(where: { $0.href == identifier.rawValue }) else {
throw RDAIError.resourceUnavailable(identifier)
}
let resource = RDAIResourceDescriptor(
identifier: identifier,
title: chapter.title,
order: chapter.order,
estimatedUTF16Length: chapter.text.utf16.count
)
let anchor = RDAIEPUBAnchor(href: chapter.href, progression: 0)
return RDAIResourceSnapshot(
descriptor: resource,
sourceText: chapter.text,
sourceHash: RDAIContentHasher.hash(chapter.text),
locatorRuns: [RDAILocatorRun(
textRange: RDAITextRange(location: 0, length: chapter.text.utf16.count),
anchor: .epub(anchor)
)]
)
}
public func aiNavigate(to locator: RDAILocator, animated: Bool) async throws {
guard let controller,
case .epub(let anchor) = locator.anchor else {
throw RDAIError.staleCitation
}
let resolved = resolveLocation(locator: locator, anchor: anchor, controller: controller)
controller.go(to: resolved)
}
public func aiShowCitationHighlight(_ citation: RDAICitation) async throws {
guard let controller,
case .epub(let anchor) = citation.locator.anchor else {
throw RDAIError.staleCitation
}
let location = resolveLocation(locator: citation.locator, anchor: anchor, controller: controller)
let range = citation.locator.textRange
let decoration = RDEPUBHighlight(
id: "rdai-citation-\(citation.id)",
bookIdentifier: controller.currentBookIdentifier,
location: location,
text: citation.quote,
rangeInfo: RDEPUBTextOffsetRangeInfo(href: anchor.href, start: range.location, end: range.upperBound).jsonString(),
style: .highlight,
color: "#86D7FF"
)
controller.setTransientHighlights([decoration])
controller.go(to: location)
}
public func aiClearCitationHighlight() { controller?.clearTransientHighlights() }
private func resolveLocation(
locator: RDAILocator,
anchor: RDAIEPUBAnchor,
controller: RDEPUBReaderController
) -> RDEPUBLocation {
let sourceLength = readableChapters().first { $0.href == anchor.href }?.text.utf16.count ?? 1
let progression = Double(locator.textRange.location) / Double(max(sourceLength - 1, 1))
guard let chapterData = controller.textChapterData(forNormalizedHref: anchor.href) else {
return RDEPUBLocation(
bookIdentifier: controller.currentBookIdentifier,
href: anchor.href,
progression: anchor.progression ?? progression,
cfi: anchor.cfi,
rangeCFI: anchor.rangeCFI
)
}
let range = NSRange(location: locator.textRange.location, length: max(locator.textRange.length, 1))
let rangeAnchor = chapterData.rangeAnchor(for: range)
let cfiRange = chapterData.indexTable.cfiRange(for: rangeAnchor)
return RDEPUBLocation(
bookIdentifier: controller.currentBookIdentifier,
href: anchor.href,
progression: progression,
rangeAnchor: rangeAnchor,
cfi: cfiRange?.start.rawValue,
rangeCFI: cfiRange?.rawValue
)
}
private func readableChapters() -> [(href: String, title: String?, text: String, order: Int)] {
guard let controller else { return [] }
if let textBook = controller.textBook {
return textBook.chapters.enumerated().map {
(href: $0.element.href, title: $0.element.title, text: $0.element.attributedContent.string, order: $0.offset)
}
}
guard let publication = controller.publication,
let parser = controller.parser else {
return []
}
return publication.spine.enumerated().compactMap { index, item in
guard item.linear,
item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
let html = parser.htmlString(forRelativePath: item.href) else {
return nil
}
let href = publication.resourceResolver.normalizedHref(item.href) ?? item.href
return (href: href, title: item.href, text: plainText(fromHTML: html), order: index)
}
}
private func plainText(fromHTML html: String) -> String {
guard let data = html.data(using: .utf8),
let attributed = try? NSAttributedString(
data: data,
options: [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
],
documentAttributes: nil
) else {
return html.replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression)
}
return attributed.string
}
}
public extension RDEPUBReaderController {
func aiCurrentReadScope() -> RDAIReadScope {
guard let location = currentLocation else { return .init() }
let locator = RDAILocator(
documentIdentifier: RDAIDocumentIdentifier(rawValue: currentBookIdentifier ?? "rd-epub-reader"),
resourceIdentifier: RDAIResourceIdentifier(rawValue: location.href),
textRange: RDAITextRange(location: 0, length: Int.max),
anchor: .epub(.init(href: location.href, cfi: location.cfi, rangeCFI: location.rangeCFI, progression: location.progression)),
sourceHash: ""
)
return RDAIReadScope(upperBound: locator)
}
func makeAIContentProvider() -> RDEPUBAIContentProvider {
RDEPUBAIContentProvider(controller: 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()) }
}
@@ -164,14 +164,14 @@ extension RDEPUBReaderController: RDEpubReaderDataSource, RDEpubReaderPageProvid
private func textHighlights(for page: RDEPUBTextPage) -> [RDEPUBHighlight] {
if let textBook,
let chapterData = textBook.chapterData(for: page.href) {
return chapterData.highlights(on: page, from: activeHighlights)
return chapterData.highlights(on: page, from: activeHighlights + transientHighlights)
}
guard let publication else {
return activeHighlights.filter { $0.location.href == page.href }
return (activeHighlights + transientHighlights).filter { $0.location.href == page.href }
}
let pageHref = publication.resourceResolver.normalizedHref(page.href) ?? page.href
return activeHighlights.filter {
return (activeHighlights + transientHighlights).filter {
(publication.resourceResolver.normalizedHref($0.location.href) ?? $0.location.href) == pageHref
}
}
@@ -21,6 +21,18 @@ extension RDEPUBReaderController {
runtime.clearSelection()
}
/// Displays temporary decorations without mutating annotation persistence.
public func setTransientHighlights(_ highlights: [RDEPUBHighlight]) {
transientHighlights = highlights
refreshVisibleContentPreservingLocation()
}
public func clearTransientHighlights() {
guard !transientHighlights.isEmpty else { return }
transientHighlights.removeAll()
refreshVisibleContentPreservingLocation()
}
public func bookmark(withID id: String) -> RDEPUBBookmark? {
runtime.bookmark(withID: id)
}
@@ -69,7 +69,7 @@ extension RDEPUBReaderController {
guard let publication else { return [] }
if let spread = page.fixedSpread {
let hrefs = Set(spread.resources.compactMap { publication.resourceResolver.normalizedHref($0.href) })
return activeHighlights.filter { highlight in
return (activeHighlights + transientHighlights).filter { highlight in
guard let normalizedHref = publication.resourceResolver.normalizedHref(highlight.location.href) else {
return false
}
@@ -80,7 +80,7 @@ extension RDEPUBReaderController {
guard publication.spine.indices.contains(page.spineIndex) else { return [] }
let href = publication.spine[page.spineIndex].href
let normalizedHref = publication.resourceResolver.normalizedHref(href)
return activeHighlights.filter { highlight in
return (activeHighlights + transientHighlights).filter { highlight in
publication.resourceResolver.normalizedHref(highlight.location.href) == normalizedHref
}
}
@@ -140,6 +140,11 @@ public final class RDEPUBReaderController: UIViewController {
set { readerContext.activeHighlights = newValue }
}
var transientHighlights: [RDEPUBHighlight] {
get { readerContext.transientHighlights }
set { readerContext.transientHighlights = newValue }
}
lazy var topToolView = runtime.makeTopToolView()
lazy var bottomToolView = runtime.makeBottomToolView()
@@ -72,6 +72,11 @@ final class RDEPUBReaderContext {
set { state.activeHighlights = newValue }
}
var transientHighlights: [RDEPUBHighlight] {
get { state.transientHighlights }
set { state.transientHighlights = newValue }
}
var currentBookIdentifier: String? {
get { state.currentBookIdentifier }
set { state.currentBookIdentifier = newValue }
@@ -20,6 +20,9 @@ final class RDEPUBReaderState {
var activeHighlights: [RDEPUBHighlight] = []
// Session-only decorations (for search/AI focus) are never persisted.
var transientHighlights: [RDEPUBHighlight] = []
var currentBookIdentifier: String?
var paginationToken = UUID()
@@ -11,7 +11,7 @@ Pod::Spec.new do |s|
s.license = "MIT"
# This podspec lives inside Sources/RDEpubReaderView, so paths must be
# relative to this directory when it is consumed as a local pod.
s.source_files = "**/*.swift"
s.source_files = "{DocumentFormats,EPUBCore,EPUBTextRendering,EPUBUI,ReaderView}/**/*.swift"
s.resource_bundles = {
"RDEpubReaderViewAssets" => ["EPUBCore/Resources/**/*"]
}
@@ -19,4 +19,15 @@ Pod::Spec.new do |s|
s.dependency "DTCoreText", "~> 1.6"
s.dependency "SnapKit", "~> 5.7"
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
@@ -0,0 +1,108 @@
import Foundation
import RDSpeechReaderView
/// EPUB/text-book adapter. Locations use an EPUB resource href plus a UTF-16
/// offset, keeping saved speech progress independent of screen pagination.
@MainActor
public final class RDEPUBSpeechContentProvider: RDSpeechContentProvider {
private weak var controller: RDEPUBReaderController?
public init(controller: RDEPUBReaderController) {
self.controller = controller
}
public func speechBookDescriptor() -> RDSpeechBookDescriptor {
let identifier = controller?.currentLocation?.bookIdentifier
?? controller?.currentBookIdentifier
?? "rd-epub-reader"
return RDSpeechBookDescriptor(identifier: identifier, title: controller?.title ?? "")
}
public func speechContentBatch(
startingAt location: RDSpeechLocation?,
limit: Int
) async throws -> RDSpeechContentBatch {
guard let controller else { throw RDSpeechReaderError.invalidContentLocation }
let descriptor = speechBookDescriptor()
let chapters = readableChapters(from: controller)
guard !chapters.isEmpty else { throw RDSpeechReaderError.noReadableContent }
let startHref = location?.resourceIdentifier
let startOffset = location?.textOffset ?? 0
let startIndex = startHref.flatMap { href in
chapters.firstIndex { $0.href == href }
} ?? 0
var units: [RDSpeechTextUnit] = []
for chapter in chapters.dropFirst(startIndex) {
let chapterLocation = RDSpeechLocation(
bookIdentifier: descriptor.identifier,
resourceIdentifier: chapter.href
)
let chapterUnits = RDSpeechTextPreprocessor.makeUnits(
text: chapter.text,
location: chapterLocation
).filter { chapter.href != startHref || NSMaxRange($0.textRange) > startOffset }
for unit in chapterUnits {
guard units.count < limit else {
return RDSpeechContentBatch(units: units, nextLocation: unit.location)
}
units.append(unit)
}
}
return RDSpeechContentBatch(units: units, nextLocation: nil)
}
private func readableChapters(from controller: RDEPUBReaderController) -> [(href: String, text: String)] {
if let textBook = controller.textBook {
return textBook.chapters.map { (href: $0.href, text: $0.attributedContent.string) }
}
guard let publication = controller.publication,
let parser = controller.parser else {
return []
}
return publication.spine.compactMap { item in
guard item.linear,
item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
let html = parser.htmlString(forRelativePath: item.href) else {
return nil
}
let href = publication.resourceResolver.normalizedHref(item.href) ?? item.href
return (href: href, text: plainText(fromHTML: html, baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent()))
}
}
private func plainText(fromHTML html: String, baseURL: URL?) -> String {
guard let data = html.data(using: .utf8) else {
return fallbackPlainText(fromHTML: html)
}
var options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
]
if let baseURL {
options[NSAttributedString.DocumentReadingOptionKey(rawValue: "NSBaseURLDocumentOption")] = baseURL
}
if let attributed = try? NSAttributedString(data: data, options: options, documentAttributes: nil) {
return attributed.string
}
return fallbackPlainText(fromHTML: html)
}
private func fallbackPlainText(fromHTML html: String) -> String {
html
.replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression)
.replacingOccurrences(of: "&nbsp;", with: " ")
.replacingOccurrences(of: "&amp;", with: "&")
.replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">")
.replacingOccurrences(of: "&#39;", with: "'")
.replacingOccurrences(of: "&quot;", with: "\"")
}
}
public extension RDEPUBReaderController {
func makeSpeechContentProvider() -> RDEPUBSpeechContentProvider {
RDEPUBSpeechContentProvider(controller: self)
}
}
@@ -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)
}
}
@@ -0,0 +1,34 @@
import Foundation
import RDAIReaderView
/// Temporary speech content for an AI answer or summary. It deliberately uses
/// an in-memory identifier and disables progress persistence by default.
@MainActor
public final class RDAISpeechContentProvider: RDSpeechContentProvider {
private let descriptor: RDSpeechBookDescriptor
private let units: [RDSpeechTextUnit]
public init(title: String, text: String, language: String? = nil) {
let identifier = "rdai-speech-\(UUID().uuidString)"
descriptor = RDSpeechBookDescriptor(identifier: identifier, title: title)
let location = RDSpeechLocation(bookIdentifier: identifier, resourceIdentifier: "ai-result")
units = RDSpeechTextPreprocessor.makeUnits(text: text, location: location, language: language)
}
public func speechBookDescriptor() -> RDSpeechBookDescriptor { descriptor }
public func speechContentBatch(startingAt location: RDSpeechLocation?, limit: Int) async throws -> RDSpeechContentBatch {
let start = location.flatMap { requested in units.firstIndex { $0.location.textOffset >= requested.textOffset } } ?? 0
let batch = Array(units.dropFirst(start).prefix(max(1, limit)))
let nextIndex = start + batch.count
return RDSpeechContentBatch(units: batch, nextLocation: nextIndex < units.count ? units[nextIndex].location : nil)
}
}
@MainActor
public extension RDSpeechReaderController {
convenience init(aiTitle: String, text: String, language: String? = nil, configuration: RDSpeechReaderConfiguration = .default) {
let provider = RDAISpeechContentProvider(title: aiTitle, text: text, language: language)
self.init(contentProvider: provider, configuration: configuration, progressStore: nil)
}
}
@@ -0,0 +1,20 @@
Pod::Spec.new do |s|
s.name = "RDSpeechReaderView"
s.module_name = "RDSpeechReaderView"
s.version = "0.1.0"
s.summary = "Text-to-speech playback primitives for ReadViewSDK readers"
s.platform = :ios, "15.0"
s.swift_versions = ["5.10"]
s.homepage = "https://example.invalid/RDSpeechReaderView"
s.author = { "readoor" => "ios@touchread.com" }
s.source = { :path => "." }
s.license = "MIT"
s.source_files = "Sources/*.swift"
s.frameworks = "AVFAudio", "NaturalLanguage", "MediaPlayer"
s.requires_arc = true
s.subspec "AI" do |ai|
ai.source_files = "AIBridge/**/*.swift"
ai.dependency "RDAIReaderView/Core", "~> 0.1"
end
end
+46
View File
@@ -0,0 +1,46 @@
# RDSpeechReaderView
`RDSpeechReaderView` provides bounded, on-device text-to-speech playback for
ReadViewSDK readers. It uses `AVSpeechSynthesizer` and does not upload book
content or create exportable audio files.
## Install
```ruby
pod 'RDSpeechReaderView'
pod 'RDPDFReaderView/Speech'
pod 'RDEpubReaderView/Speech'
```
The host app must enable the **Audio, AirPlay, and Picture in Picture**
background mode to continue reading after it enters the background.
## Use with PDF
```swift
let provider = reader.makeSpeechContentProvider()
let speech = RDSpeechReaderController(contentProvider: provider)
try await speech.start()
```
The PDF adapter reads `RDPDFReaderTextRun` values supplied by the host or by
PDFKit. Image-only PDFs require an OCR-backed provider before they can speak.
For automatic page navigation and temporary read-aloud highlighting, create a
PDF session instead:
```swift
let session = reader.makeSpeechSession()
try await session.start(from: 0)
```
## Use with EPUB
```swift
let provider = reader.makeSpeechContentProvider()
let speech = RDSpeechReaderController(contentProvider: provider)
try await speech.start(from: nil)
```
Speech locations use EPUB `href` plus a UTF-16 text offset. They remain stable
when the user changes fonts or page size, unlike screen page numbers.
@@ -0,0 +1,199 @@
import Foundation
import AVFAudio
/// A stable position in a book. The reader-specific adapter owns the mapping
/// between this value and its own pagination or document location model.
public struct RDSpeechLocation: Codable, Equatable, Hashable, Sendable {
public var bookIdentifier: String
public var resourceIdentifier: String
public var textOffset: Int
public var anchor: String?
public init(
bookIdentifier: String,
resourceIdentifier: String,
textOffset: Int = 0,
anchor: String? = nil
) {
self.bookIdentifier = bookIdentifier
self.resourceIdentifier = resourceIdentifier
self.textOffset = max(0, textOffset)
self.anchor = anchor
}
}
public struct RDSpeechBookDescriptor: Equatable, Sendable {
public let identifier: String
public let title: String
public init(identifier: String, title: String) {
self.identifier = identifier
self.title = title
}
}
/// A text unit is normally one sentence. `textRange` is expressed in UTF-16
/// offsets within the resource identified by `location`.
public struct RDSpeechTextUnit: Equatable, Sendable, Identifiable {
public let id: String
public let text: String
public let location: RDSpeechLocation
public let textRange: NSRange
public let language: String?
public init(
id: String = UUID().uuidString,
text: String,
location: RDSpeechLocation,
textRange: NSRange,
language: String? = nil
) {
self.id = id
self.text = text
self.location = location
self.textRange = textRange
self.language = language
}
}
/// A bounded result keeps long books from being loaded into the speech queue
/// all at once. `nextLocation` is nil after the final readable unit.
public struct RDSpeechContentBatch: Sendable {
public let units: [RDSpeechTextUnit]
public let nextLocation: RDSpeechLocation?
public init(units: [RDSpeechTextUnit], nextLocation: RDSpeechLocation?) {
self.units = units
self.nextLocation = nextLocation
}
}
@MainActor
public protocol RDSpeechContentProvider: AnyObject {
func speechBookDescriptor() -> RDSpeechBookDescriptor
func speechContentBatch(
startingAt location: RDSpeechLocation?,
limit: Int
) async throws -> RDSpeechContentBatch
}
public enum RDSpeechReaderState: Equatable, Sendable {
case idle
case preparing
case speaking(RDSpeechLocation)
case paused(RDSpeechLocation)
case finished
case failed(String)
}
public struct RDSpeechSpokenRange: Equatable, Sendable {
public let unit: RDSpeechTextUnit
/// UTF-16 range relative to `unit.text`.
public let range: NSRange
public init(unit: RDSpeechTextUnit, range: NSRange) {
self.unit = unit
self.range = range
}
}
@MainActor
public protocol RDSpeechReaderControllerDelegate: AnyObject {
func speechReaderController(_ controller: RDSpeechReaderController, didChange state: RDSpeechReaderState)
func speechReaderController(_ controller: RDSpeechReaderController, willSpeak range: RDSpeechSpokenRange)
}
public extension RDSpeechReaderControllerDelegate {
func speechReaderController(_ controller: RDSpeechReaderController, didChange state: RDSpeechReaderState) {}
func speechReaderController(_ controller: RDSpeechReaderController, willSpeak range: RDSpeechSpokenRange) {}
}
public struct RDSpeechReaderConfiguration: Sendable {
public var rate: Float
public var voiceIdentifier: String?
public var defaultLanguage: String
public var batchSize: Int
public var configuresAudioSession: Bool
public var ducksOtherAudio: Bool
public var enablesRemoteControls: Bool
public init(
rate: Float = 0.48,
voiceIdentifier: String? = nil,
defaultLanguage: String = "zh-CN",
batchSize: Int = 24,
configuresAudioSession: Bool = true,
ducksOtherAudio: Bool = false,
enablesRemoteControls: Bool = true
) {
self.rate = min(max(rate, 0.0), 1.0)
self.voiceIdentifier = voiceIdentifier
self.defaultLanguage = defaultLanguage
self.batchSize = max(1, batchSize)
self.configuresAudioSession = configuresAudioSession
self.ducksOtherAudio = ducksOtherAudio
self.enablesRemoteControls = enablesRemoteControls
}
public static let `default` = RDSpeechReaderConfiguration()
}
/// Stores only the book identifier and reading location. Book text is never
/// persisted by the speech framework.
public protocol RDSpeechProgressPersisting: AnyObject {
func restoreSpeechLocation(for bookIdentifier: String) -> RDSpeechLocation?
func saveSpeechLocation(_ location: RDSpeechLocation, for bookIdentifier: String)
func clearSpeechLocation(for bookIdentifier: String)
}
public final class RDSpeechUserDefaultsProgressStore: RDSpeechProgressPersisting {
private let defaults: UserDefaults
private let keyPrefix: String
public init(defaults: UserDefaults = .standard, keyPrefix: String = "com.readoor.rdspeech.progress.") {
self.defaults = defaults
self.keyPrefix = keyPrefix
}
public func restoreSpeechLocation(for bookIdentifier: String) -> RDSpeechLocation? {
guard let data = defaults.data(forKey: key(for: bookIdentifier)) else { return nil }
return try? JSONDecoder().decode(RDSpeechLocation.self, from: data)
}
public func saveSpeechLocation(_ location: RDSpeechLocation, for bookIdentifier: String) {
guard let data = try? JSONEncoder().encode(location) else { return }
defaults.set(data, forKey: key(for: bookIdentifier))
}
public func clearSpeechLocation(for bookIdentifier: String) {
defaults.removeObject(forKey: key(for: bookIdentifier))
}
private func key(for bookIdentifier: String) -> String { keyPrefix + bookIdentifier }
}
public struct RDSpeechVoice: Equatable, Sendable, Identifiable {
public let id: String
public let name: String
public let language: String
public init(voice: AVSpeechSynthesisVoice) {
id = voice.identifier
name = voice.name
language = voice.language
}
}
public enum RDSpeechReaderError: LocalizedError, Equatable {
case noReadableContent
case invalidContentLocation
public var errorDescription: String? {
switch self {
case .noReadableContent:
return "The selected content has no readable text."
case .invalidContentLocation:
return "The selected speech location is no longer available."
}
}
}
@@ -0,0 +1,106 @@
import UIKit
/// A small host-owned control strip. It deliberately exposes actions as
/// closures so reader-specific sessions can keep navigation and highlights in
/// sync without coupling this view to a document format.
public final class RDSpeechReaderControlView: UIView {
public var onTogglePlayback: (() -> Void)?
public var onStop: (() -> Void)?
public var onChangeRate: (() -> Void)?
public var onPreviousSentence: (() -> Void)?
public var onNextSentence: (() -> Void)?
private let previousButton = UIButton(type: .system)
private let playPauseButton = UIButton(type: .system)
private let nextButton = UIButton(type: .system)
private let stopButton = UIButton(type: .system)
private let rateButton = UIButton(type: .system)
public override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor.secondarySystemBackground.withAlphaComponent(0.96)
layer.cornerRadius = 18
layer.cornerCurve = .continuous
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.12
layer.shadowRadius = 10
layer.shadowOffset = CGSize(width: 0, height: 4)
let stack = UIStackView(arrangedSubviews: [previousButton, playPauseButton, nextButton, stopButton, rateButton])
stack.axis = .horizontal
stack.alignment = .center
stack.spacing = 4
addSubview(stack)
stack.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
stack.topAnchor.constraint(equalTo: topAnchor, constant: 6),
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -6)
])
configure(previousButton, identifier: "rd.speech.previous")
configure(playPauseButton, identifier: "rd.speech.playPause")
configure(nextButton, identifier: "rd.speech.next")
configure(stopButton, identifier: "rd.speech.stop")
configure(rateButton, identifier: "rd.speech.rate")
previousButton.setImage(UIImage(systemName: "backward.fill"), for: .normal)
nextButton.setImage(UIImage(systemName: "forward.fill"), for: .normal)
stopButton.setImage(UIImage(systemName: "stop.fill"), for: .normal)
rateButton.titleLabel?.font = .monospacedDigitSystemFont(ofSize: 13, weight: .semibold)
previousButton.addTarget(self, action: #selector(previousSentence), for: .touchUpInside)
playPauseButton.addTarget(self, action: #selector(togglePlayback), for: .touchUpInside)
nextButton.addTarget(self, action: #selector(nextSentence), for: .touchUpInside)
stopButton.addTarget(self, action: #selector(stop), for: .touchUpInside)
rateButton.addTarget(self, action: #selector(changeRate), for: .touchUpInside)
update(state: .idle, rate: 0.48)
}
public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
public func update(state: RDSpeechReaderState, rate: Float) {
let isPaused: Bool
switch state {
case .speaking:
isPaused = false
playPauseButton.setImage(UIImage(systemName: "pause.fill"), for: .normal)
playPauseButton.accessibilityLabel = "暂停朗读"
case .paused:
isPaused = true
playPauseButton.setImage(UIImage(systemName: "play.fill"), for: .normal)
playPauseButton.accessibilityLabel = "继续朗读"
default:
isPaused = false
playPauseButton.setImage(UIImage(systemName: "play.fill"), for: .normal)
playPauseButton.accessibilityLabel = "开始朗读"
}
stopButton.isEnabled = isPaused || state.isActive
previousButton.isEnabled = state.isActive
nextButton.isEnabled = state.isActive
rateButton.setTitle(String(format: "%.2gx", rate), for: .normal)
}
private func configure(_ button: UIButton, identifier: String) {
button.tintColor = .label
button.accessibilityIdentifier = identifier
button.widthAnchor.constraint(equalToConstant: 42).isActive = true
button.heightAnchor.constraint(equalToConstant: 36).isActive = true
}
@objc private func togglePlayback() { onTogglePlayback?() }
@objc private func previousSentence() { onPreviousSentence?() }
@objc private func nextSentence() { onNextSentence?() }
@objc private func stop() { onStop?() }
@objc private func changeRate() { onChangeRate?() }
}
private extension RDSpeechReaderState {
var isActive: Bool {
switch self {
case .preparing, .speaking, .paused:
return true
case .idle, .finished, .failed:
return false
}
}
}
@@ -0,0 +1,381 @@
import AVFAudio
import Foundation
import MediaPlayer
@MainActor
public final class RDSpeechReaderController: NSObject {
public weak var delegate: RDSpeechReaderControllerDelegate?
public private(set) var state: RDSpeechReaderState = .idle {
didSet {
updateNowPlayingInfo()
delegate?.speechReaderController(self, didChange: state)
}
}
public private(set) var configuration: RDSpeechReaderConfiguration
private let contentProvider: RDSpeechContentProvider
private let progressStore: RDSpeechProgressPersisting?
private let synthesizer = AVSpeechSynthesizer()
private var currentUnit: RDSpeechTextUnit?
private var queuedUnits: [RDSpeechTextUnit] = []
private var completedUnits: [RDSpeechTextUnit] = []
private var nextLocation: RDSpeechLocation?
private var isLoadingBatch = false
private var isStopping = false
private var shouldResumeAfterInterruption = false
private var sleepTimerTask: Task<Void, Never>?
private var notificationTokens: [NSObjectProtocol] = []
public init(
contentProvider: RDSpeechContentProvider,
configuration: RDSpeechReaderConfiguration = .default,
progressStore: RDSpeechProgressPersisting? = RDSpeechUserDefaultsProgressStore()
) {
self.contentProvider = contentProvider
self.configuration = configuration
self.progressStore = progressStore
super.init()
synthesizer.delegate = self
installAudioObservers()
if configuration.enablesRemoteControls { installRemoteControls() }
}
deinit {
notificationTokens.forEach(NotificationCenter.default.removeObserver)
MPRemoteCommandCenter.shared().playCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().pauseCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().togglePlayPauseCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().nextTrackCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().previousTrackCommand.removeTarget(nil)
}
public func start(from location: RDSpeechLocation? = nil) async throws {
stop(clearProgress: false)
isStopping = false
completedUnits.removeAll()
state = .preparing
do {
if configuration.configuresAudioSession { try configureAudioSession() }
let initialLocation = location ?? progressStore?.restoreSpeechLocation(for: contentProvider.speechBookDescriptor().identifier)
try await loadBatch(startingAt: initialLocation, requiresContent: true)
try await speakNextUnit()
} catch {
state = .failed(error.localizedDescription)
throw error
}
}
public func pause() {
guard synthesizer.isSpeaking else { return }
synthesizer.pauseSpeaking(at: .word)
}
public func resume() {
guard synthesizer.isPaused else { return }
synthesizer.continueSpeaking()
}
public func stop() { stop(clearProgress: false) }
public func stop(clearProgress: Bool) {
isStopping = true
synthesizer.stopSpeaking(at: .immediate)
currentUnit = nil
queuedUnits.removeAll()
nextLocation = nil
isLoadingBatch = false
sleepTimerTask?.cancel()
sleepTimerTask = nil
if clearProgress { progressStore?.clearSpeechLocation(for: contentProvider.speechBookDescriptor().identifier) }
state = .idle
}
public func skipToNextSentence() {
guard state.isActive else { return }
stopCurrentUtteranceForNavigation()
Task { [weak self] in
guard let self else { return }
do { try await self.speakNextUnit() }
catch { self.state = .failed(error.localizedDescription) }
}
}
public func skipToPreviousSentence() {
guard state.isActive else { return }
let current = currentUnit
let previous = completedUnits.popLast() ?? current
guard let previous else { return }
if let current, previous.id != current.id { queuedUnits.insert(current, at: 0) }
stopCurrentUtteranceForNavigation()
currentUnit = previous
speak(previous)
}
/// Restarts the current sentence so a changed rate takes effect immediately.
public func updateRate(_ rate: Float) {
configuration.rate = min(max(rate, 0.0), 1.0)
restartCurrentUnitIfNeeded()
}
/// Restarts the current sentence so a changed voice takes effect immediately.
public func updateVoice(identifier: String?) {
configuration.voiceIdentifier = identifier
restartCurrentUnitIfNeeded()
}
public func availableVoices(languagePrefix: String? = nil) -> [RDSpeechVoice] {
AVSpeechSynthesisVoice.speechVoices()
.filter { voice in languagePrefix.map { voice.language.hasPrefix($0) } ?? true }
.map(RDSpeechVoice.init)
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
public func setSleepTimer(after interval: TimeInterval?) {
sleepTimerTask?.cancel()
guard let interval, interval > 0 else {
sleepTimerTask = nil
return
}
sleepTimerTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
guard !Task.isCancelled else { return }
self?.stop()
}
}
private func loadBatch(startingAt location: RDSpeechLocation?, requiresContent: Bool) async throws {
guard !isLoadingBatch, !isStopping else { return }
isLoadingBatch = true
defer { isLoadingBatch = false }
var requestedLocation = location
var mustContainContent = requiresContent
while !isStopping {
let batch = try await contentProvider.speechContentBatch(
startingAt: requestedLocation,
limit: configuration.batchSize
)
guard !isStopping else { return }
if !batch.units.isEmpty {
queuedUnits.append(contentsOf: batch.units)
nextLocation = batch.nextLocation
return
}
guard let next = batch.nextLocation else {
if mustContainContent { throw RDSpeechReaderError.noReadableContent }
state = .finished
progressStore?.clearSpeechLocation(for: contentProvider.speechBookDescriptor().identifier)
return
}
requestedLocation = next
mustContainContent = false
}
}
private func speakNextUnit() async throws {
guard !isStopping else { return }
if queuedUnits.isEmpty, let nextLocation {
try await loadBatch(startingAt: nextLocation, requiresContent: false)
}
guard !isStopping else { return }
guard !queuedUnits.isEmpty else {
if state != .finished {
state = .finished
progressStore?.clearSpeechLocation(for: contentProvider.speechBookDescriptor().identifier)
}
return
}
let unit = queuedUnits.removeFirst()
currentUnit = unit
speak(unit)
}
private func speak(_ unit: RDSpeechTextUnit) {
let utterance = AVSpeechUtterance(string: unit.text)
utterance.rate = configuration.rate
utterance.voice = resolvedVoice(for: unit)
synthesizer.speak(utterance)
}
private func restartCurrentUnitIfNeeded() {
guard let currentUnit, state.isActive else { return }
let wasPaused = synthesizer.isPaused
stopCurrentUtteranceForNavigation()
self.currentUnit = currentUnit
speak(currentUnit)
if wasPaused { synthesizer.pauseSpeaking(at: .immediate) }
}
private func stopCurrentUtteranceForNavigation() {
isStopping = true
synthesizer.stopSpeaking(at: .immediate)
isStopping = false
}
private func resolvedVoice(for unit: RDSpeechTextUnit) -> AVSpeechSynthesisVoice? {
if let identifier = configuration.voiceIdentifier,
let voice = AVSpeechSynthesisVoice(identifier: identifier) {
return voice
}
return AVSpeechSynthesisVoice(language: unit.language ?? configuration.defaultLanguage)
?? AVSpeechSynthesisVoice(language: configuration.defaultLanguage)
}
private func configureAudioSession() throws {
let session = AVAudioSession.sharedInstance()
var options: AVAudioSession.CategoryOptions = []
if configuration.ducksOtherAudio { options.insert(.duckOthers) }
try session.setCategory(.playback, mode: .spokenAudio, options: options)
try session.setActive(true, options: [])
}
private func installAudioObservers() {
let center = NotificationCenter.default
notificationTokens.append(center.addObserver(
forName: AVAudioSession.interruptionNotification,
object: AVAudioSession.sharedInstance(),
queue: .main
) { [weak self] notification in
let typeValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt ?? 0
guard let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
Task { @MainActor [weak self] in
guard let self else { return }
if type == .began {
self.shouldResumeAfterInterruption = self.state.isSpeaking
self.pause()
} else if self.shouldResumeAfterInterruption {
self.shouldResumeAfterInterruption = false
self.resume()
}
}
})
notificationTokens.append(center.addObserver(
forName: AVAudioSession.routeChangeNotification,
object: AVAudioSession.sharedInstance(),
queue: .main
) { [weak self] notification in
guard let rawValue = notification.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
AVAudioSession.RouteChangeReason(rawValue: rawValue) == .oldDeviceUnavailable else { return }
Task { @MainActor [weak self] in self?.pause() }
})
}
private func installRemoteControls() {
let commands = MPRemoteCommandCenter.shared()
commands.playCommand.isEnabled = true
commands.pauseCommand.isEnabled = true
commands.togglePlayPauseCommand.isEnabled = true
commands.nextTrackCommand.isEnabled = true
commands.previousTrackCommand.isEnabled = true
commands.playCommand.addTarget { [weak self] _ in
Task { @MainActor [weak self] in self?.resume() }
return .success
}
commands.pauseCommand.addTarget { [weak self] _ in
Task { @MainActor [weak self] in self?.pause() }
return .success
}
commands.togglePlayPauseCommand.addTarget { [weak self] _ in
Task { @MainActor [weak self] in
guard let self else { return }
self.synthesizer.isPaused ? self.resume() : self.pause()
}
return .success
}
commands.nextTrackCommand.addTarget { [weak self] _ in
Task { @MainActor [weak self] in self?.skipToNextSentence() }
return .success
}
commands.previousTrackCommand.addTarget { [weak self] _ in
Task { @MainActor [weak self] in self?.skipToPreviousSentence() }
return .success
}
}
private func updateNowPlayingInfo() {
guard configuration.enablesRemoteControls else { return }
let book = contentProvider.speechBookDescriptor()
var info: [String: Any] = [
MPMediaItemPropertyTitle: book.title,
MPMediaItemPropertyArtist: "ReadViewSDK"
]
info[MPNowPlayingInfoPropertyPlaybackRate] = state.isSpeaking ? 1.0 : 0.0
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}
}
extension RDSpeechReaderController: AVSpeechSynthesizerDelegate {
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) {
Task { @MainActor [weak self] in self?.handleDidStart() }
}
public nonisolated func speechSynthesizer(
_ synthesizer: AVSpeechSynthesizer,
willSpeakRangeOfSpeechString characterRange: NSRange,
utterance: AVSpeechUtterance
) {
Task { @MainActor [weak self, characterRange] in self?.handleWillSpeak(characterRange) }
}
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didPause utterance: AVSpeechUtterance) {
Task { @MainActor [weak self] in self?.handleDidPause() }
}
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didContinue utterance: AVSpeechUtterance) {
Task { @MainActor [weak self] in self?.handleDidContinue() }
}
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
Task { @MainActor [weak self] in self?.handleDidFinish() }
}
}
private extension RDSpeechReaderController {
func handleDidStart() {
guard let currentUnit else { return }
progressStore?.saveSpeechLocation(currentUnit.location, for: contentProvider.speechBookDescriptor().identifier)
state = .speaking(currentUnit.location)
}
func handleWillSpeak(_ characterRange: NSRange) {
guard let currentUnit else { return }
var location = currentUnit.location
location.textOffset = currentUnit.textRange.location + characterRange.location
progressStore?.saveSpeechLocation(location, for: contentProvider.speechBookDescriptor().identifier)
delegate?.speechReaderController(self, willSpeak: RDSpeechSpokenRange(unit: currentUnit, range: characterRange))
}
func handleDidPause() {
guard let currentUnit else { return }
state = .paused(currentUnit.location)
}
func handleDidContinue() {
guard let currentUnit else { return }
state = .speaking(currentUnit.location)
}
func handleDidFinish() {
guard !isStopping, let finished = currentUnit else { return }
completedUnits.append(finished)
currentUnit = nil
Task { [weak self] in
guard let self else { return }
do { try await self.speakNextUnit() }
catch { self.state = .failed(error.localizedDescription) }
}
}
}
private extension RDSpeechReaderState {
var isActive: Bool {
switch self {
case .preparing, .speaking, .paused: return true
case .idle, .finished, .failed: return false
}
}
var isSpeaking: Bool {
if case .speaking = self { return true }
return false
}
}
@@ -0,0 +1,71 @@
import Foundation
import NaturalLanguage
public enum RDSpeechTextPreprocessor {
/// Splits one resource into sentence-sized units while preserving UTF-16
/// offsets, so adapters can map speech progress back to their reader UI.
public static func makeUnits(
text: String,
location: RDSpeechLocation,
language: String? = nil
) -> [RDSpeechTextUnit] {
// Keep the source text unchanged here. Reader adapters use UTF-16
// offsets to drive their highlights, so whitespace normalization would
// make the returned ranges drift away from the rendered document.
let source = text
guard !source.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return [] }
let resolvedLanguage = language ?? detectLanguage(in: source)
let tokenizer = NLTokenizer(unit: .sentence)
tokenizer.string = source
let fullRange = source.startIndex..<source.endIndex
var units: [RDSpeechTextUnit] = []
tokenizer.enumerateTokens(in: fullRange) { range, _ in
let sentence = String(source[range])
let trimmed = sentence.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return true }
let leadingUTF16Count = sentence.utf16.count - sentence.drop(while: { $0.isWhitespace || $0.isNewline }).utf16.count
let sentenceRange = NSRange(range, in: source)
let textRange = NSRange(
location: location.textOffset + sentenceRange.location + leadingUTF16Count,
length: trimmed.utf16.count
)
var unitLocation = location
unitLocation.textOffset = textRange.location
units.append(
RDSpeechTextUnit(
text: trimmed,
location: unitLocation,
textRange: textRange,
language: resolvedLanguage
)
)
return true
}
if units.isEmpty {
let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines)
let range = NSRange(location: location.textOffset, length: trimmed.utf16.count)
return [RDSpeechTextUnit(text: trimmed, location: location, textRange: range, language: resolvedLanguage)]
}
return units
}
public static func normalize(_ text: String) -> String {
text
.replacingOccurrences(of: "\\u{00A0}", with: " ")
.replacingOccurrences(of: "\\r\\n", with: "\\n")
.replacingOccurrences(of: "\\r", with: "\\n")
.replacingOccurrences(of: "[\\t ]+", with: " ", options: .regularExpression)
.replacingOccurrences(of: " *\\n *", with: "\\n", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func detectLanguage(in text: String) -> String? {
let recognizer = NLLanguageRecognizer()
recognizer.processString(text)
return recognizer.dominantLanguage?.rawValue
}
}