更新阅读器功能与示例
This commit is contained in:
@@ -0,0 +1,411 @@
|
|||||||
|
# RDAIReaderView AI 系统设计合同
|
||||||
|
|
||||||
|
**文档状态:** Draft 0.1
|
||||||
|
**最后更新:** 2026-07-25
|
||||||
|
**目标版本:** RDAIReaderView 1.0
|
||||||
|
|
||||||
|
## 1. 系统分类
|
||||||
|
|
||||||
|
RDAIReaderView 是一个本地优先的 Retrieval-Augmented Generation 阅读系统,包含:
|
||||||
|
|
||||||
|
- 确定性 NLP:语言识别、分句、实体候选、词法检索。
|
||||||
|
- 本地语义检索:Natural Language embeddings。
|
||||||
|
- 生成式任务:摘要、书内问答、人物卡片和人物关系。
|
||||||
|
- 引用约束:生成内容必须映射到 PDF/EPUB 原文。
|
||||||
|
|
||||||
|
系统不是通用聊天机器人,也不把模型自身知识作为书籍事实来源。
|
||||||
|
|
||||||
|
## 2. 框架选择
|
||||||
|
|
||||||
|
### 2.1 主框架
|
||||||
|
|
||||||
|
- Apple Natural Language:iOS 15+ 基础分析和检索。
|
||||||
|
- Apple Foundation Models:支持设备上的生成式增强。
|
||||||
|
- Vision:扫描 PDF OCR,继续复用 RDPDFReaderView 现有能力。
|
||||||
|
|
||||||
|
### 2.2 选择理由
|
||||||
|
|
||||||
|
- 与当前纯 Swift/UIKit/CocoaPods 架构一致。
|
||||||
|
- 默认设备端处理,不需要新增服务端和书籍上传链路。
|
||||||
|
- Natural Language 可覆盖不支持 Apple Intelligence 的设备。
|
||||||
|
- Foundation Models 支持 structured generation 和 tool calling。
|
||||||
|
- 系统 API 可与现有 PDF/EPUB 定位直接结合。
|
||||||
|
|
||||||
|
### 2.3 未选择的首版方案
|
||||||
|
|
||||||
|
| 方案 | 首版不采用原因 |
|
||||||
|
|------|----------------|
|
||||||
|
| LangChain/LlamaIndex | 主要面向 Python/服务端,增加不必要基础设施 |
|
||||||
|
| 云端 LLM | 引入内容上传、成本、隐私、版权和网络可用性问题 |
|
||||||
|
| ONNX Runtime 自带模型 | 需要自行选择、量化、分发和维护语言模型 |
|
||||||
|
| 自训练 Foundation Models Adapter | 模型版本绑定和 entitlement 增加发布复杂度 |
|
||||||
|
|
||||||
|
Core 保留 `RDAIGenerativeProvider`,以后可以增加其他 Provider,不把 Apple 实现写死在公共业务层。
|
||||||
|
|
||||||
|
## 3. 模型可用性合同
|
||||||
|
|
||||||
|
调用 Foundation Models 前必须检查:
|
||||||
|
|
||||||
|
1. API 在当前系统可用。
|
||||||
|
2. `SystemLanguageModel.default.availability` 为 available。
|
||||||
|
3. 当前 Locale 被支持。
|
||||||
|
4. 当前请求未超过并发限制。
|
||||||
|
5. 当前任务的上下文预算可满足。
|
||||||
|
|
||||||
|
不可用原因映射:
|
||||||
|
|
||||||
|
| Apple 状态 | RDAI 状态 | UI 行为 |
|
||||||
|
|------------|-----------|---------|
|
||||||
|
| deviceNotEligible | `.deviceNotEligible` | 隐藏生成操作,保留基础分析 |
|
||||||
|
| appleIntelligenceNotEnabled | `.appleIntelligenceNotEnabled` | 说明可在系统设置中开启 |
|
||||||
|
| modelNotReady | `.modelNotReady` | 展示模型准备中,可稍后重试 |
|
||||||
|
| unsupported locale | `.languageUnsupported` | 保留检索,关闭生成 |
|
||||||
|
| unknown | `.unknown` | 通用不可用状态,允许重试 |
|
||||||
|
|
||||||
|
禁止通过静态设备型号列表推断可用性,运行时状态是唯一依据。
|
||||||
|
|
||||||
|
参考:
|
||||||
|
|
||||||
|
- [Foundation Models](https://developer.apple.com/documentation/FoundationModels)
|
||||||
|
- [SystemLanguageModel](https://developer.apple.com/documentation/FoundationModels/SystemLanguageModel)
|
||||||
|
- [语言与 Locale 支持](https://developer.apple.com/documentation/foundationmodels/supporting-languages-and-locales-with-foundation-models)
|
||||||
|
|
||||||
|
## 4. 输入与上下文策略
|
||||||
|
|
||||||
|
### 4.1 唯一事实来源
|
||||||
|
|
||||||
|
模型可以使用:
|
||||||
|
|
||||||
|
- 本次请求提供的 Passage。
|
||||||
|
- Passage 的章节标题、页码/资源信息。
|
||||||
|
- 用户当前问题。
|
||||||
|
- 非内容性规则,例如输出语言和防剧透范围。
|
||||||
|
|
||||||
|
模型不得把训练知识、其他书籍、互联网知识或先前书籍会话作为当前书籍事实来源。
|
||||||
|
|
||||||
|
### 4.2 上下文预算
|
||||||
|
|
||||||
|
运行时读取模型 `contextSize`,并使用 `tokenCount(for:)` 估算。
|
||||||
|
|
||||||
|
初始预算比例:
|
||||||
|
|
||||||
|
- 12%:instructions 和 schema。
|
||||||
|
- 5%:用户问题。
|
||||||
|
- 60%:检索 Passage。
|
||||||
|
- 17%:输出。
|
||||||
|
- 6%:安全余量。
|
||||||
|
|
||||||
|
若预算不足,按以下顺序处理:
|
||||||
|
|
||||||
|
1. 删除低分 Passage。
|
||||||
|
2. 缩短 Passage 到完整句子边界。
|
||||||
|
3. 使用预计算的有引用片段摘要。
|
||||||
|
4. 创建新会话。
|
||||||
|
5. 仍不足则返回 `contextLimitExceeded`。
|
||||||
|
|
||||||
|
不得静默截断引用或生成半个结构化对象。
|
||||||
|
|
||||||
|
参考:[Managing the context window](https://developer.apple.com/documentation/foundationmodels/managing-the-context-window)
|
||||||
|
|
||||||
|
## 5. Prompt 资产管理
|
||||||
|
|
||||||
|
Prompt 作为版本化代码资产保存:
|
||||||
|
|
||||||
|
```text
|
||||||
|
FoundationModels/Prompts/
|
||||||
|
├── summary_v1.swift
|
||||||
|
├── answer_v1.swift
|
||||||
|
├── characters_v1.swift
|
||||||
|
└── relationships_v1.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
每个 Prompt 定义:
|
||||||
|
|
||||||
|
- `identifier`
|
||||||
|
- `version`
|
||||||
|
- `minimumModelProfile`
|
||||||
|
- `instructions`
|
||||||
|
- 输入构造器
|
||||||
|
- 输出 schema
|
||||||
|
- 评测集标签
|
||||||
|
|
||||||
|
修改 Prompt 必须:
|
||||||
|
|
||||||
|
1. 增加版本号。
|
||||||
|
2. 跑完整离线评测集。
|
||||||
|
3. 与上一版本对比准确率、拒答、引用和延迟。
|
||||||
|
4. 更新缓存失效策略。
|
||||||
|
|
||||||
|
## 6. 通用 Instructions
|
||||||
|
|
||||||
|
所有任务共享以下不可省略规则:
|
||||||
|
|
||||||
|
```text
|
||||||
|
你是书内阅读助手。
|
||||||
|
只使用提供的原文片段,不使用外部知识补充书中事实。
|
||||||
|
每个事实性结论必须引用一个或多个有效片段 ID。
|
||||||
|
证据不足时返回 insufficientEvidence,不猜测。
|
||||||
|
不得引用允许阅读范围之外的内容。
|
||||||
|
区分原文明确事实与可能推断。
|
||||||
|
使用用户当前语言回答。
|
||||||
|
```
|
||||||
|
|
||||||
|
实际实现使用简洁英文或经评测验证的目标语言 instructions;以上文字表达语义合同,不要求逐字使用。
|
||||||
|
|
||||||
|
## 7. 结构化输出
|
||||||
|
|
||||||
|
### 7.1 摘要 Schema
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@Generable
|
||||||
|
struct GeneratedSummary {
|
||||||
|
var overview: String
|
||||||
|
|
||||||
|
@Guide(.maximumCount(6))
|
||||||
|
var points: [GeneratedStatement]
|
||||||
|
}
|
||||||
|
|
||||||
|
@Generable
|
||||||
|
struct GeneratedStatement {
|
||||||
|
var text: String
|
||||||
|
|
||||||
|
@Guide(.minimumCount(1), .maximumCount(3))
|
||||||
|
var passageIDs: [String]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- `overview` 只能概括已提供 Passage。
|
||||||
|
- 每个 point 必须有 Passage ID。
|
||||||
|
- brief 最多 3 点,standard 最多 6 点。
|
||||||
|
|
||||||
|
### 7.2 问答 Schema
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@Generable
|
||||||
|
enum GeneratedAnswerStatus {
|
||||||
|
case answered
|
||||||
|
case insufficientEvidence
|
||||||
|
}
|
||||||
|
|
||||||
|
@Generable
|
||||||
|
struct GeneratedAnswer {
|
||||||
|
var status: GeneratedAnswerStatus
|
||||||
|
var answer: String
|
||||||
|
|
||||||
|
@Guide(.maximumCount(6))
|
||||||
|
var statements: [GeneratedStatement]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
若 `status == insufficientEvidence`:
|
||||||
|
|
||||||
|
- `statements` 必须为空。
|
||||||
|
- `answer` 只说明当前已读内容没有足够依据。
|
||||||
|
- 不推荐用户从互联网获取答案,除非宿主未来明确增加该产品能力。
|
||||||
|
|
||||||
|
### 7.3 人物 Schema
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@Generable
|
||||||
|
struct GeneratedCharacter {
|
||||||
|
var displayName: String
|
||||||
|
|
||||||
|
@Guide(.maximumCount(6))
|
||||||
|
var aliases: [String]
|
||||||
|
|
||||||
|
var description: String
|
||||||
|
|
||||||
|
@Guide(.minimumCount(1), .maximumCount(6))
|
||||||
|
var evidencePassageIDs: [String]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
人物必须在 Passage 中有明确名称或可验证别名。纯代词不能单独创建人物。
|
||||||
|
|
||||||
|
### 7.4 关系 Schema
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@Generable
|
||||||
|
enum GeneratedRelationshipStatus {
|
||||||
|
case confirmed
|
||||||
|
case possible
|
||||||
|
case conflicting
|
||||||
|
}
|
||||||
|
|
||||||
|
@Generable
|
||||||
|
struct GeneratedRelationship {
|
||||||
|
var sourceName: String
|
||||||
|
var targetName: String
|
||||||
|
var label: String
|
||||||
|
var status: GeneratedRelationshipStatus
|
||||||
|
|
||||||
|
@Guide(.minimumCount(1), .maximumCount(5))
|
||||||
|
var evidencePassageIDs: [String]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
关系标签应简短,例如“师徒”“同事”“敌对”“亲属”。描述性事件放在人物事件中,不无限创建关系类型。
|
||||||
|
|
||||||
|
## 8. 任务设计
|
||||||
|
|
||||||
|
### 8.1 章节摘要
|
||||||
|
|
||||||
|
输入:
|
||||||
|
|
||||||
|
- 当前章节 Passage,或已读范围内的章节片段摘要。
|
||||||
|
- 目标长度。
|
||||||
|
- 用户 Locale。
|
||||||
|
|
||||||
|
流程:
|
||||||
|
|
||||||
|
1. 章节能放入上下文时直接生成。
|
||||||
|
2. 超长章节分块生成带引用局部摘要。
|
||||||
|
3. 聚合局部摘要时保留原 Passage ID。
|
||||||
|
4. 运行引用验证。
|
||||||
|
|
||||||
|
禁止只把局部摘要文本作为最终事实来源而丢失原始 Passage ID。
|
||||||
|
|
||||||
|
### 8.2 书内问答
|
||||||
|
|
||||||
|
流程:
|
||||||
|
|
||||||
|
1. 识别问题语言和实体词。
|
||||||
|
2. Hybrid Retrieval 召回 30 个候选。
|
||||||
|
3. 重排并过滤到 3-4 个 Passage。
|
||||||
|
4. 直接把 Passage 放入 Prompt。
|
||||||
|
5. 生成结构化答案。
|
||||||
|
6. 验证引用和阅读范围。
|
||||||
|
7. 无有效事实项时拒答。
|
||||||
|
|
||||||
|
首版优先使用代码检索,不默认使用 Tool Calling,这样召回过程更确定、可测试、节省 token。
|
||||||
|
|
||||||
|
### 8.3 人物卡片
|
||||||
|
|
||||||
|
流程:
|
||||||
|
|
||||||
|
1. Natural Language 提供人物候选及出现位置。
|
||||||
|
2. 按名称和明确别名聚合候选。
|
||||||
|
3. 检索候选周边 Passage。
|
||||||
|
4. Foundation Models 生成结构化人物信息。
|
||||||
|
5. 代码校验所有别名和证据。
|
||||||
|
6. 低置信别名保持独立候选。
|
||||||
|
|
||||||
|
### 8.4 人物关系
|
||||||
|
|
||||||
|
采用两阶段:
|
||||||
|
|
||||||
|
1. Chunk-level extraction:从局部 Passage 提取关系候选。
|
||||||
|
2. Document-level merge:按人物 ID、关系标签和时间顺序合并。
|
||||||
|
|
||||||
|
合并规则:
|
||||||
|
|
||||||
|
- 相同关系 + 相同方向:合并证据。
|
||||||
|
- 对称关系可由受控词典决定是否双向展示。
|
||||||
|
- 新证据否定旧关系:状态变为 conflicting。
|
||||||
|
- 隐含动机、情感和立场默认 possible。
|
||||||
|
- 任何关系没有有效证据时不入库。
|
||||||
|
|
||||||
|
## 9. Tool Calling
|
||||||
|
|
||||||
|
仅在单次检索无法回答、且评测证明多轮查找有明显收益时启用。最多提供三个工具:
|
||||||
|
|
||||||
|
```text
|
||||||
|
searchBook(query, scope, limit)
|
||||||
|
getPassages(ids)
|
||||||
|
getCharacterEvidence(name, scope, limit)
|
||||||
|
```
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 工具只读。
|
||||||
|
- 工具强制应用文档和已读范围过滤。
|
||||||
|
- 参数有严格长度和数量上限。
|
||||||
|
- 返回内容有 token 上限。
|
||||||
|
- 每个请求最大 Tool 调用次数为 3。
|
||||||
|
- 检测重复参数调用并终止循环。
|
||||||
|
- 工具调用和结果 ID进入本地 trace,但不记录原文。
|
||||||
|
|
||||||
|
不提供跳页、删除、购买、网络请求等副作用工具。
|
||||||
|
|
||||||
|
参考:[Expanding generation with tool calling](https://developer.apple.com/documentation/foundationmodels/expanding-generation-with-tool-calling)
|
||||||
|
|
||||||
|
## 10. 确定性后处理
|
||||||
|
|
||||||
|
模型输出必须经过:
|
||||||
|
|
||||||
|
1. Schema 解码。
|
||||||
|
2. Passage ID 存在性校验。
|
||||||
|
3. 阅读范围校验。
|
||||||
|
4. Quote/Locator 构造。
|
||||||
|
5. 重复 statement 合并。
|
||||||
|
6. 空文本和长度校验。
|
||||||
|
7. 敏感内容与系统错误映射。
|
||||||
|
8. Artifact 元数据补齐。
|
||||||
|
|
||||||
|
模型不能直接构造页码、CFI、CGRect 或数据库 ID;这些字段全部由代码通过 Passage ID解析。
|
||||||
|
|
||||||
|
## 11. 安全与产品规则
|
||||||
|
|
||||||
|
- 不将 AI 输出表示为作者原话。
|
||||||
|
- UI 明确标记“AI 生成”。
|
||||||
|
- possible/conflicting 关系必须视觉区分。
|
||||||
|
- 用户问题涉及未读内容时,默认拒绝并提示防剧透设置。
|
||||||
|
- 原文包含违法或敏感内容时,遵循系统模型 guardrails;不能绕过。
|
||||||
|
- 输入和输出触发系统安全限制时,返回稳定、非技术性的不可生成状态。
|
||||||
|
- 不要求模型提供医学、法律或金融建议;如果书中包含相关内容,只能解释“书中写了什么”。
|
||||||
|
|
||||||
|
发布前必须复核 Apple Foundation Models acceptable use requirements 和最新 App Review Guidelines。
|
||||||
|
|
||||||
|
## 12. 关键失败模式
|
||||||
|
|
||||||
|
| 失败模式 | 检测 | 处理 |
|
||||||
|
|----------|------|------|
|
||||||
|
| 模型编造人物或关系 | Passage ID/名称验证 | 删除无效项,无结果则拒答 |
|
||||||
|
| 引用存在但不支持结论 | 人工评测 + LLM judge | Prompt 调整,低分结果进入回归集 |
|
||||||
|
| 同名人物合并 | 别名证据检查 | 保持独立,标记待确认 |
|
||||||
|
| 未读内容泄漏 | Scope validator | 阻断输出并记录安全计数 |
|
||||||
|
| OCR 错字导致错误事实 | OCR source 标记和置信策略 | 降低置信度,展示 OCR 来源 |
|
||||||
|
| Prompt 在系统更新后退化 | 模型版本分桶评测 | 版本化 Prompt 和缓存 |
|
||||||
|
| 上下文溢出 | tokenCount/contextSize | 重建上下文或分层摘要 |
|
||||||
|
| Tool 循环 | 调用次数和参数去重 | 终止并降级为现有证据回答 |
|
||||||
|
| 用户快速重复请求 | request actor + cancellation | 取消旧任务或排队 |
|
||||||
|
|
||||||
|
## 13. 评测维度
|
||||||
|
|
||||||
|
| 维度 | 定义 | 1.0 门槛 |
|
||||||
|
|------|------|----------|
|
||||||
|
| Citation validity | 引用能否恢复到原文 | ≥ 99% |
|
||||||
|
| Context faithfulness | 事实是否由引用支持 | ≥ 98% |
|
||||||
|
| Refusal accuracy | 无证据时是否拒答 | ≥ 95% |
|
||||||
|
| Spoiler safety | 是否只使用允许范围 | 100% |
|
||||||
|
| Schema validity | 结构化输出是否通过校验 | ≥ 99.5% |
|
||||||
|
| Character precision | 人物是否真实出现 | ≥ 97% |
|
||||||
|
| Relationship evidence | 关系是否至少有一条证据 | 100% |
|
||||||
|
| Alias precision | 自动合并别名是否正确 | ≥ 98% |
|
||||||
|
| Retrieval recall@5 | 正确证据是否在 Top 5 | ≥ 90% |
|
||||||
|
| Answer usefulness | 人工 1-5 分平均值 | ≥ 4.0 |
|
||||||
|
|
||||||
|
## 14. 评测方法
|
||||||
|
|
||||||
|
- 代码指标:Schema、引用 ID、范围、阅读权限、延迟和拒答格式。
|
||||||
|
- 人工标注:人物、别名、关系、引用支持度、剧透边界。
|
||||||
|
- LLM judge:只用于语气、完整性和引用支持度的辅助评估;必须先与人工评分校准。
|
||||||
|
- 生产抽样:只上传宿主允许的匿名数值和用户显式反馈;不上传书籍原文。
|
||||||
|
|
||||||
|
评测集和完整方法见 [RDAIReaderView-TEST-PLAN.md](RDAIReaderView-TEST-PLAN.md)。
|
||||||
|
|
||||||
|
## 15. 发布检查清单
|
||||||
|
|
||||||
|
- [ ] 所有 Prompt 有 identifier 和 version。
|
||||||
|
- [ ] 每个生成任务使用 `@Generable`。
|
||||||
|
- [ ] 每个事实输出经过 Citation Validator。
|
||||||
|
- [ ] 防剧透 Scope 在检索前和生成后各检查一次。
|
||||||
|
- [ ] Foundation Models 不可用路径已真机验证。
|
||||||
|
- [ ] 上下文预算使用运行时 API 计算。
|
||||||
|
- [ ] 模型版本变化不会复用旧缓存。
|
||||||
|
- [ ] 评测集达到所有 1.0 门槛。
|
||||||
|
- [ ] 日志不包含原文、问题或完整回答。
|
||||||
|
- [ ] Apple 最新 acceptable use 与审核要求已复核。
|
||||||
|
|
||||||
@@ -0,0 +1,604 @@
|
|||||||
|
# RDAIReaderView 公共 API 设计
|
||||||
|
|
||||||
|
**文档状态:** Proposal 0.1
|
||||||
|
**最后更新:** 2026-07-25
|
||||||
|
**目标版本:** RDAIReaderView 1.0
|
||||||
|
|
||||||
|
## 1. API 设计原则
|
||||||
|
|
||||||
|
- Core 最低支持 iOS 15,不直接依赖 UIKit、PDFKit、DTCoreText 或 FoundationModels。
|
||||||
|
- 公共模型优先使用值类型,并遵循 `Codable`、`Sendable`、`Equatable`。
|
||||||
|
- 文本范围统一使用 UTF-16 偏移,与现有 PDF、EPUB、NSString 和 TTS 范围保持一致。
|
||||||
|
- Reader Adapter 负责格式转换,Core 不识别 PDF 页视图或 EPUB 排版对象。
|
||||||
|
- Foundation Models 通过 Provider 协议接入,不能泄漏到基础 API。
|
||||||
|
- 公开 API 在 1.0 后遵循语义化版本;新增字段必须有解码默认值。
|
||||||
|
|
||||||
|
本文中的 Swift 定义是实现合同,允许在不改变语义的前提下调整文件组织和内部实现。
|
||||||
|
|
||||||
|
## 2. 标识符与基础范围
|
||||||
|
|
||||||
|
```swift
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- `RDAIDocumentIdentifier` 必须与宿主书籍 ID 一致并保持稳定。
|
||||||
|
- `RDAIResourceIdentifier` 在 PDF 中使用页索引字符串,在 EPUB 中使用规范化 `href`。
|
||||||
|
- 所有文本范围均针对资源原始文本,不针对规范化搜索文本。
|
||||||
|
|
||||||
|
## 3. 定位与引用
|
||||||
|
|
||||||
|
### 3.1 归一化矩形
|
||||||
|
|
||||||
|
Core 使用自定义矩形,避免公共存储格式依赖 UIKit:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
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 = x
|
||||||
|
self.y = y
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
所有值应限制在 `0...1`。Reader Adapter 负责与 `CGRect` 转换。
|
||||||
|
|
||||||
|
### 3.2 格式 Anchor
|
||||||
|
|
||||||
|
```swift
|
||||||
|
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 struct RDAIEPUBAnchor: Codable, Hashable, Sendable {
|
||||||
|
public var href: String
|
||||||
|
public var cfi: String?
|
||||||
|
public var rangeCFI: String?
|
||||||
|
public var progression: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum RDAIAnchor: Codable, Hashable, Sendable {
|
||||||
|
case pdf(RDAIPDFAnchor)
|
||||||
|
case epub(RDAIEPUBAnchor)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`RDAIAnchor` 必须实现显式 Codable discriminator,例如 `type: "pdf"`,未知类型解码为明确错误,不能误当成其他格式。
|
||||||
|
|
||||||
|
### 3.3 通用定位
|
||||||
|
|
||||||
|
```swift
|
||||||
|
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 struct RDAICitation: Codable, Hashable, Sendable, Identifiable {
|
||||||
|
public let id: String
|
||||||
|
public let passageIdentifier: String
|
||||||
|
public let quote: String
|
||||||
|
public let locator: RDAILocator
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
引用恢复流程:
|
||||||
|
|
||||||
|
1. 校验文档和资源存在。
|
||||||
|
2. 校验 `sourceHash`。
|
||||||
|
3. 优先按格式 Anchor 恢复。
|
||||||
|
4. Anchor 失败时使用文本范围和 quote 搜索。
|
||||||
|
5. 仍失败则返回 `staleCitation`,不得跳转到近似但未验证的位置。
|
||||||
|
|
||||||
|
## 4. 文档与资源
|
||||||
|
|
||||||
|
```swift
|
||||||
|
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 struct RDAIResourceDescriptor: Codable, Equatable, Sendable {
|
||||||
|
public let identifier: RDAIResourceIdentifier
|
||||||
|
public let title: String?
|
||||||
|
public let order: Int
|
||||||
|
public let estimatedUTF16Length: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RDAIResourceSnapshot: Sendable {
|
||||||
|
public let descriptor: RDAIResourceDescriptor
|
||||||
|
public let sourceText: String
|
||||||
|
public let sourceHash: String
|
||||||
|
public let locatorRuns: [RDAILocatorRun]
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RDAILocatorRun: Sendable {
|
||||||
|
public let textRange: RDAITextRange
|
||||||
|
public let anchor: RDAIAnchor
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`contentRevision` 由宿主提供;若宿主没有版本号,Adapter 使用资源哈希汇总生成。
|
||||||
|
|
||||||
|
## 5. 内容提供协议
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@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()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
协议标记为 `@MainActor`,因为现有 Reader Controller 和页面缓存均由主线程管理。实现必须只在主线程获取快照引用和 UI 状态;OCR、分块、分析和数据库写入移交后台 actor。
|
||||||
|
|
||||||
|
可选读取范围协议:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDAIReadScope: Codable, Equatable, Sendable {
|
||||||
|
public let upperBound: RDAILocator?
|
||||||
|
public let includesWholeDocument: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public protocol RDAIReadScopeProviding: AnyObject {
|
||||||
|
func aiCurrentReadScope() -> RDAIReadScope
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
未实现时,默认只允许当前资源及之前的资源,不能默认整本书。
|
||||||
|
|
||||||
|
## 6. Passage 与分析结果
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public enum RDAIPassageKind: String, Codable, Sendable {
|
||||||
|
case title
|
||||||
|
case paragraph
|
||||||
|
case list
|
||||||
|
case table
|
||||||
|
case code
|
||||||
|
case footnote
|
||||||
|
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 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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Natural Language 的标签不是最终人物事实,只是 `RDAIEntityMention` 候选。
|
||||||
|
|
||||||
|
## 7. 索引 API
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public enum RDAIIndexState: Equatable, Sendable {
|
||||||
|
case notStarted
|
||||||
|
case indexing(completedResources: Int, totalResources: Int)
|
||||||
|
case paused
|
||||||
|
case ready
|
||||||
|
case failed(RDAIError)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RDAIIndexOptions: Sendable {
|
||||||
|
public var scope: RDAIReadScope
|
||||||
|
public var priorityResource: RDAIResourceIdentifier?
|
||||||
|
public var allowsEmbeddingAssetDownload: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol RDAIIndexing: AnyObject, Sendable {
|
||||||
|
func prepareIndex(options: RDAIIndexOptions) async throws
|
||||||
|
func pauseIndexing() async
|
||||||
|
func resumeIndexing() async
|
||||||
|
func indexState() async -> RDAIIndexState
|
||||||
|
func stateUpdates() async -> AsyncStream<RDAIIndexState>
|
||||||
|
func removeIndex() async throws
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- `prepareIndex` 幂等。
|
||||||
|
- 重复调用只能扩大范围或提高优先级,不能创建重复 Job。
|
||||||
|
- `removeIndex` 删除索引、实体和生成缓存,但不删除原书或用户笔记。
|
||||||
|
|
||||||
|
## 8. 能力与可用性
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public enum RDAICapability: String, Codable, Sendable {
|
||||||
|
case languageAnalysis
|
||||||
|
case entityExtraction
|
||||||
|
case lexicalSearch
|
||||||
|
case semanticSearch
|
||||||
|
case summarization
|
||||||
|
case questionAnswering
|
||||||
|
case characterRelationships
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum RDAIUnavailableReason: Equatable, Sendable {
|
||||||
|
case operatingSystemUnsupported
|
||||||
|
case deviceNotEligible
|
||||||
|
case appleIntelligenceNotEnabled
|
||||||
|
case modelNotReady
|
||||||
|
case languageUnsupported(String?)
|
||||||
|
case embeddingAssetsUnavailable
|
||||||
|
case providerNotInstalled
|
||||||
|
case 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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
UI 只能根据枚举状态展示文案,不能匹配本地化 Error 字符串。
|
||||||
|
|
||||||
|
## 9. 检索 API
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDAIRetrievalOptions: Sendable {
|
||||||
|
public var maximumResults: Int
|
||||||
|
public var scope: RDAIReadScope
|
||||||
|
public var minimumScore: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
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 protocol RDAIRetrieving: Sendable {
|
||||||
|
func retrieve(
|
||||||
|
query: String,
|
||||||
|
options: RDAIRetrievalOptions
|
||||||
|
) async throws -> [RDAIRetrievalMatch]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
检索分数只用于同一索引版本内排序,不承诺跨版本数值稳定。
|
||||||
|
|
||||||
|
## 10. 生成结果
|
||||||
|
|
||||||
|
### 10.1 摘要
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public enum RDAISummaryLength: String, Codable, Sendable {
|
||||||
|
case brief
|
||||||
|
case standard
|
||||||
|
case detailed
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 问答
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public enum RDAIAnswerStatus: String, Codable, Sendable {
|
||||||
|
case answered
|
||||||
|
case insufficientEvidence
|
||||||
|
case unsupportedLanguage
|
||||||
|
case unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
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 RDAISourcedStatement: Codable, Sendable, Identifiable {
|
||||||
|
public let id: String
|
||||||
|
public let text: String
|
||||||
|
public let citationIdentifiers: [String]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 人物关系
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public enum RDAIRelationshipStatus: String, Codable, Sendable {
|
||||||
|
case confirmed
|
||||||
|
case possible
|
||||||
|
case conflicting
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.4 生成元数据
|
||||||
|
|
||||||
|
```swift
|
||||||
|
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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
元数据用于缓存失效和问题追踪,不向普通用户展示内部 Prompt。
|
||||||
|
|
||||||
|
## 11. 高层服务
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public protocol RDAIReaderServicing: AnyObject, Sendable {
|
||||||
|
func prepare(options: RDAIIndexOptions) async throws
|
||||||
|
|
||||||
|
func summarize(
|
||||||
|
scope: RDAIReadScope,
|
||||||
|
length: RDAISummaryLength
|
||||||
|
) async throws -> RDAISummary
|
||||||
|
|
||||||
|
func answer(
|
||||||
|
question: String,
|
||||||
|
scope: RDAIReadScope
|
||||||
|
) async throws -> RDAIAnswer
|
||||||
|
|
||||||
|
func characters(
|
||||||
|
scope: RDAIReadScope
|
||||||
|
) async throws -> [RDAICharacter]
|
||||||
|
|
||||||
|
func relationships(
|
||||||
|
scope: RDAIReadScope
|
||||||
|
) async throws -> [RDAIRelationship]
|
||||||
|
|
||||||
|
func removeAllAIData() async throws
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
建议具体实现为 actor。若用户开始新的同类请求,UI 层负责决定取消旧请求或并行;同一 Foundation Models session 不允许并行请求。
|
||||||
|
|
||||||
|
## 12. Provider 协议
|
||||||
|
|
||||||
|
```swift
|
||||||
|
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 enum RDAIGenerationTask: Sendable {
|
||||||
|
case summary(RDAISummaryLength)
|
||||||
|
case answer
|
||||||
|
case characters
|
||||||
|
case relationships
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol RDAIGenerativeProvider: Sendable {
|
||||||
|
var identifier: String { get }
|
||||||
|
func availability(locale: Locale) async -> RDAICapabilityAvailability
|
||||||
|
func generate(_ request: RDAIGenerationRequest) async throws -> RDAIGeneratedArtifact
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`RDAIGeneratedArtifact` 是 Core 内部或受控公共枚举,用于把 Provider 输出转换为第 10 节模型。Provider 不能直接保存结果或操作 Reader UI。
|
||||||
|
|
||||||
|
## 13. 错误模型
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public enum RDAIError: Error, Equatable, Sendable {
|
||||||
|
case invalidDocument
|
||||||
|
case resourceUnavailable(RDAIResourceIdentifier)
|
||||||
|
case staleCitation
|
||||||
|
case indexingFailed(code: String)
|
||||||
|
case modelUnavailable(RDAIUnavailableReason)
|
||||||
|
case unsupportedLanguage(String?)
|
||||||
|
case contextLimitExceeded
|
||||||
|
case invalidGeneratedStructure
|
||||||
|
case invalidCitation
|
||||||
|
case insufficientEvidence
|
||||||
|
case cancelled
|
||||||
|
case storageFailure(code: String)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
公共错误不携带原文或数据库底层错误字符串。内部错误映射为稳定 code,并通过本地诊断系统保存脱敏详情。
|
||||||
|
|
||||||
|
## 14. PDF Adapter
|
||||||
|
|
||||||
|
建议公开:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public extension RDPDFReaderViewController {
|
||||||
|
func makeAIContentProvider() -> RDPDFAIContentProvider
|
||||||
|
func makeAIReaderService(
|
||||||
|
configuration: RDAIReaderConfiguration = .default
|
||||||
|
) throws -> RDAIReaderServicing
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
映射要求:
|
||||||
|
|
||||||
|
- 页面资源 ID 为十进制页索引。
|
||||||
|
- 文本顺序使用 `RDPDFReaderTextRun.readingOrder`。
|
||||||
|
- `normalizedRects` 转换为 `RDAINormalizedRect`。
|
||||||
|
- 原生文本标记为 `.native`,Vision OCR 标记为 `.ocr`。
|
||||||
|
- AI 高亮复用或泛化现有 speech highlight,不同时维护两个相互覆盖的临时层。
|
||||||
|
|
||||||
|
## 15. EPUB Adapter
|
||||||
|
|
||||||
|
建议公开:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public extension RDEPUBReaderController {
|
||||||
|
func makeAIContentProvider() -> RDEPUBAIContentProvider
|
||||||
|
func makeAIReaderService(
|
||||||
|
configuration: RDAIReaderConfiguration = .default
|
||||||
|
) throws -> RDAIReaderServicing
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
映射要求:
|
||||||
|
|
||||||
|
- 资源 ID 使用 ResourceResolver 规范化后的 `href`。
|
||||||
|
- Passage 范围从章节 attributed content 的原始字符串计算。
|
||||||
|
- 使用现有 index table 生成 `cfi` 和 `rangeCFI`。
|
||||||
|
- 导航复用 `go(to:)`/位置恢复流程。
|
||||||
|
- 固定版式或无法提取文本的章节返回明确 unavailable,不制造空 Passage。
|
||||||
|
|
||||||
|
## 16. TTS 集成
|
||||||
|
|
||||||
|
AI 层不依赖 RDSpeechReaderView。宿主可以把摘要或回答转换为临时 `RDSpeechContentProvider`。
|
||||||
|
|
||||||
|
后续可增加桥接 Pod:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
pod 'RDSpeechReaderView/AI'
|
||||||
|
```
|
||||||
|
|
||||||
|
桥接只负责朗读 AI 结果;Natural Language 的分句与语言识别实现应抽取为共享内部组件,避免同一文本产生不同范围。
|
||||||
|
|
||||||
|
## 17. 配置
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct RDAIReaderConfiguration: Sendable {
|
||||||
|
public var spoilerPolicy: RDAISpoilerPolicy
|
||||||
|
public var maximumRetrievedPassages: Int
|
||||||
|
public var allowsEmbeddingAssetDownload: Bool
|
||||||
|
public var storesGeneratedArtifacts: Bool
|
||||||
|
public var diagnosticsLevel: RDAIDiagnosticsLevel
|
||||||
|
|
||||||
|
public static let `default`: RDAIReaderConfiguration
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
默认值:
|
||||||
|
|
||||||
|
- `spoilerPolicy = .readContentOnly`
|
||||||
|
- `maximumRetrievedPassages = 4`
|
||||||
|
- `allowsEmbeddingAssetDownload = false`
|
||||||
|
- `storesGeneratedArtifacts = true`
|
||||||
|
- `diagnosticsLevel = .metadataOnly`
|
||||||
|
|
||||||
|
## 18. API 演进规则
|
||||||
|
|
||||||
|
- 1.0 前可以调整命名,但每次调整同步更新五份设计文档。
|
||||||
|
- 1.0 后删除或改变语义需要主版本升级。
|
||||||
|
- Codable 枚举新增 case 时必须实现向后兼容策略。
|
||||||
|
- 数据库 Schema 版本与 SDK 版本独立。
|
||||||
|
- Prompt 版本与 SDK 版本独立。
|
||||||
|
- Reader Adapter 可以增加格式能力,但不能改变 Core Locator 的 UTF-16 语义。
|
||||||
|
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
# RDAIReaderView 架构设计
|
||||||
|
|
||||||
|
**文档状态:** Draft 0.1
|
||||||
|
**最后更新:** 2026-07-25
|
||||||
|
**目标版本:** RDAIReaderView 1.0
|
||||||
|
|
||||||
|
## 1. 架构目标
|
||||||
|
|
||||||
|
RDAIReaderView 必须满足四个架构目标:
|
||||||
|
|
||||||
|
1. 不改变 RDPDFReaderView、RDEpubReaderView 和 RDSpeechReaderView 的核心职责。
|
||||||
|
2. iOS 15 用户继续获得稳定阅读、基础 NLP 和 TTS;Foundation Models 仅作为可选增强。
|
||||||
|
3. 所有生成结果都能追溯到稳定原文位置。
|
||||||
|
4. AI Provider、索引实现和 UI 可替换,公共数据模型保持稳定。
|
||||||
|
|
||||||
|
## 2. 总体分层
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Host App / RDAIReaderViewUI │
|
||||||
|
│ AI 面板、摘要、问答、人物卡片、关系图、引用跳转 │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ RDAIReaderView │
|
||||||
|
│ Query Service / Summary Service / Character Service │
|
||||||
|
├───────────────────────┬─────────────────────────────────────┤
|
||||||
|
│ NaturalLanguage │ FoundationModels │
|
||||||
|
│ 分句/语言/实体/检索 │ 结构化生成/工具调用/拒答 │
|
||||||
|
├───────────────────────┴─────────────────────────────────────┤
|
||||||
|
│ Index & Storage │
|
||||||
|
│ Passage / EntityMention / Citation / Artifact / Job │
|
||||||
|
├───────────────────────┬─────────────────────────────────────┤
|
||||||
|
│ RDPDFReaderView/AI │ RDEpubReaderView/AI │
|
||||||
|
│ 页码/范围/矩形/OCR │ href/CFI/范围/章节 │
|
||||||
|
├───────────────────────┴─────────────────────────────────────┤
|
||||||
|
│ RDPDFReaderView / RDEpubReaderView / RDSpeechReaderView │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
依赖方向只能从上到下。阅读器核心不能反向依赖 RDAIReaderView。
|
||||||
|
|
||||||
|
## 3. CocoaPods 模块设计
|
||||||
|
|
||||||
|
建议新增:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
pod 'RDAIReaderView/Core'
|
||||||
|
pod 'RDAIReaderView/NaturalLanguage'
|
||||||
|
pod 'RDAIReaderView/FoundationModels'
|
||||||
|
pod 'RDAIReaderView/UI'
|
||||||
|
pod 'RDPDFReaderView/AI'
|
||||||
|
pod 'RDEpubReaderView/AI'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.1 Core
|
||||||
|
|
||||||
|
- 最低 iOS 15。
|
||||||
|
- 只依赖 Foundation、SQLite3/CryptoKit 等系统能力。
|
||||||
|
- 包含公共协议、数据模型、索引调度、存储和查询编排。
|
||||||
|
- 不导入 UIKit、PDFKit、DTCoreText 或 FoundationModels。
|
||||||
|
|
||||||
|
### 3.2 NaturalLanguage
|
||||||
|
|
||||||
|
- 最低 iOS 15。
|
||||||
|
- 依赖 Core 和 NaturalLanguage。
|
||||||
|
- 提供语言识别、分句、实体候选、关键词和语义评分。
|
||||||
|
- iOS 17+ 可选使用 `NLContextualEmbedding`;资源不可用时降级。
|
||||||
|
|
||||||
|
### 3.3 FoundationModels
|
||||||
|
|
||||||
|
- 源码使用 `@available(iOS 26.0, *)` 隔离。
|
||||||
|
- 依赖 Core 和系统 FoundationModels。
|
||||||
|
- 需要支持 Foundation Models 的 Xcode 工具链。
|
||||||
|
- 不被基础 Pod 默认引入,避免旧工具链客户无法编译。
|
||||||
|
|
||||||
|
### 3.4 UI
|
||||||
|
|
||||||
|
- 最低 iOS 15。
|
||||||
|
- 依赖 Core,可选识别 FoundationModels 可用性。
|
||||||
|
- UI 不直接构造 Prompt,也不直接访问数据库。
|
||||||
|
|
||||||
|
### 3.5 Reader Adapters
|
||||||
|
|
||||||
|
- `RDPDFReaderView/AI` 依赖 RDAIReaderView/Core。
|
||||||
|
- `RDEpubReaderView/AI` 依赖 RDAIReaderView/Core。
|
||||||
|
- Adapter 只负责内容快照、定位转换、跳转和高亮。
|
||||||
|
|
||||||
|
## 4. 建议目录
|
||||||
|
|
||||||
|
```text
|
||||||
|
Sources/RDAIReaderView/
|
||||||
|
├── RDAIReaderView.podspec
|
||||||
|
├── Core/
|
||||||
|
│ ├── Contracts/
|
||||||
|
│ ├── Models/
|
||||||
|
│ ├── Indexing/
|
||||||
|
│ ├── Retrieval/
|
||||||
|
│ ├── Storage/
|
||||||
|
│ └── Services/
|
||||||
|
├── NaturalLanguage/
|
||||||
|
│ ├── Analysis/
|
||||||
|
│ ├── Embeddings/
|
||||||
|
│ └── Retrieval/
|
||||||
|
├── FoundationModels/
|
||||||
|
│ ├── Availability/
|
||||||
|
│ ├── Generation/
|
||||||
|
│ ├── Prompts/
|
||||||
|
│ ├── Schemas/
|
||||||
|
│ └── Tools/
|
||||||
|
├── UI/
|
||||||
|
│ ├── Assistant/
|
||||||
|
│ ├── Citations/
|
||||||
|
│ └── Characters/
|
||||||
|
└── Tests/
|
||||||
|
|
||||||
|
Sources/RDPDFReaderView/AI/
|
||||||
|
Sources/RDEpubReaderView/AI/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 核心数据流
|
||||||
|
|
||||||
|
### 5.1 建立索引
|
||||||
|
|
||||||
|
```text
|
||||||
|
ContentProvider
|
||||||
|
↓ 读取资源快照
|
||||||
|
Text Snapshot + Stable Locator
|
||||||
|
↓
|
||||||
|
Language Detection
|
||||||
|
↓
|
||||||
|
Paragraph/Sentence Chunking
|
||||||
|
↓
|
||||||
|
Entity Mentions + Keywords + Embeddings
|
||||||
|
↓
|
||||||
|
Transactional Storage
|
||||||
|
↓
|
||||||
|
Index Checkpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
每次数据库事务只提交一个资源或一组有界片段。应用退出时,最多重做当前事务,不重做整本书。
|
||||||
|
|
||||||
|
### 5.2 问答
|
||||||
|
|
||||||
|
```text
|
||||||
|
User Question
|
||||||
|
↓
|
||||||
|
Language / Intent / Spoiler Scope
|
||||||
|
↓
|
||||||
|
Hybrid Retrieval
|
||||||
|
↓
|
||||||
|
Access + Read-Progress Filter
|
||||||
|
↓
|
||||||
|
Context Budget Builder
|
||||||
|
↓
|
||||||
|
Foundation Models Structured Generation
|
||||||
|
↓
|
||||||
|
Citation Validator
|
||||||
|
↓
|
||||||
|
Answer or Evidence-Insufficient Refusal
|
||||||
|
```
|
||||||
|
|
||||||
|
检索由代码执行。首版不让模型自由遍历整本数据库,只有需要多轮查找时才使用受限 Tool Calling。
|
||||||
|
|
||||||
|
### 5.3 人物关系
|
||||||
|
|
||||||
|
```text
|
||||||
|
Entity Mentions
|
||||||
|
↓
|
||||||
|
Alias Candidate Grouping
|
||||||
|
↓
|
||||||
|
Chunk-level Structured Extraction
|
||||||
|
↓
|
||||||
|
Evidence Validation
|
||||||
|
↓
|
||||||
|
Relationship Merge
|
||||||
|
↓
|
||||||
|
confirmed / possible / conflicting
|
||||||
|
```
|
||||||
|
|
||||||
|
代码只能自动合并完全相同的标准化名称和明确别名。代词消解、同名人物合并和隐含关系必须保持低置信度,等待更多证据或用户确认。
|
||||||
|
|
||||||
|
## 6. 稳定定位模型
|
||||||
|
|
||||||
|
### 6.1 通用定位
|
||||||
|
|
||||||
|
`RDAILocator` 包含:
|
||||||
|
|
||||||
|
- `documentIdentifier`
|
||||||
|
- `resourceIdentifier`
|
||||||
|
- `utf16Range`
|
||||||
|
- `sourceHash`
|
||||||
|
- 格式专属 Anchor
|
||||||
|
|
||||||
|
文本范围统一使用 UTF-16,与现有 RDSpeechReaderView、NSString 和 EPUB 搜索范围保持一致。
|
||||||
|
|
||||||
|
### 6.2 PDF Anchor
|
||||||
|
|
||||||
|
```text
|
||||||
|
pageIndex
|
||||||
|
normalizedRects
|
||||||
|
textSource: native / ocr
|
||||||
|
readingOrder
|
||||||
|
```
|
||||||
|
|
||||||
|
PDF Adapter 从现有 `RDPDFReaderTextRun` 构建连续页文本和 UTF-16 范围。每个 Passage 必须保留与 run 的映射,不能在 AI 层重新拼接后丢失矩形。
|
||||||
|
|
||||||
|
扫描 PDF 使用现有 `speechTextRuns(at:)`/OCR 能力,但商用实现应增加独立的 AI OCR 调度入口,避免页面导航取消请求时同时取消后台索引。
|
||||||
|
|
||||||
|
### 6.3 EPUB Anchor
|
||||||
|
|
||||||
|
```text
|
||||||
|
normalizedHref
|
||||||
|
cfi
|
||||||
|
rangeCFI
|
||||||
|
rangeAnchor
|
||||||
|
progressionFallback
|
||||||
|
```
|
||||||
|
|
||||||
|
优先级:
|
||||||
|
|
||||||
|
1. `rangeCFI`
|
||||||
|
2. `cfi + UTF-16 range`
|
||||||
|
3. `rangeAnchor`
|
||||||
|
4. `href + progression`
|
||||||
|
|
||||||
|
屏幕页码只用于展示,不能作为持久化引用主键。
|
||||||
|
|
||||||
|
## 7. 文本快照与分块
|
||||||
|
|
||||||
|
### 7.1 不修改原文
|
||||||
|
|
||||||
|
索引保存两份文本信息:
|
||||||
|
|
||||||
|
- `sourceText`:原始文本,用于引用与范围映射。
|
||||||
|
- `searchText`:规范化副本,用于检索。
|
||||||
|
|
||||||
|
禁止使用规范化文本范围直接驱动阅读器高亮。
|
||||||
|
|
||||||
|
### 7.2 分块策略
|
||||||
|
|
||||||
|
- 先按资源和章节边界划分。
|
||||||
|
- 再按段落划分。
|
||||||
|
- 超长段落使用 `NLTokenizer(unit: .sentence)`。
|
||||||
|
- 中文目标 600-900 字;英文目标 300-600 词。
|
||||||
|
- 片段之间保留 1-2 句重叠。
|
||||||
|
- 表格、代码、脚注和标题保留语义类型,避免与正文无差别拼接。
|
||||||
|
|
||||||
|
### 7.3 内容哈希
|
||||||
|
|
||||||
|
建议使用 SHA-256:
|
||||||
|
|
||||||
|
```text
|
||||||
|
documentHash = hash(ordered resource identifiers + resource hashes)
|
||||||
|
resourceHash = hash(source text + format-specific stable metadata)
|
||||||
|
passageHash = hash(resource hash + UTF-16 range + source text)
|
||||||
|
```
|
||||||
|
|
||||||
|
书籍更新时按资源哈希增量失效。
|
||||||
|
|
||||||
|
## 8. 检索架构
|
||||||
|
|
||||||
|
首版采用 Hybrid Retrieval:
|
||||||
|
|
||||||
|
```text
|
||||||
|
finalScore =
|
||||||
|
0.45 * lexicalScore +
|
||||||
|
0.40 * semanticScore +
|
||||||
|
0.10 * proximityScore +
|
||||||
|
0.05 * headingBoost
|
||||||
|
```
|
||||||
|
|
||||||
|
权重是初始值,必须通过评测集调优,不作为永久常量。
|
||||||
|
|
||||||
|
检索步骤:
|
||||||
|
|
||||||
|
1. 规范化查询并识别语言。
|
||||||
|
2. 关键词倒排召回 Top 30。
|
||||||
|
3. 语义相似度重排。
|
||||||
|
4. 合并高度重叠 Passage。
|
||||||
|
5. 按已读范围、文档授权和最大上下文过滤。
|
||||||
|
6. 返回 Top 3-4,并保留评分解释。
|
||||||
|
|
||||||
|
若语义模型资源不可用,使用纯词法检索,不阻塞问答入口;UI 可提示结果质量可能降低。
|
||||||
|
|
||||||
|
## 9. Foundation Models 编排
|
||||||
|
|
||||||
|
### 9.1 Provider 抽象
|
||||||
|
|
||||||
|
Core 只依赖 `RDAIGenerativeProvider`。Apple 实现位于 FoundationModels 子模块,未来可增加 Core ML、MLX 或经用户授权的云端实现。
|
||||||
|
|
||||||
|
### 9.2 会话策略
|
||||||
|
|
||||||
|
- 摘要、问答、人物关系使用不同 instructions 和独立会话。
|
||||||
|
- 同一会话只处理一个并发请求。
|
||||||
|
- 用户切换书籍、变更阅读范围或取消时终止任务。
|
||||||
|
- 达到上下文阈值前主动新建会话,不等待系统抛错。
|
||||||
|
- 记录 Prompt 版本、模型可用性分类、耗时和 token 数,不记录原文。
|
||||||
|
|
||||||
|
### 9.3 上下文预算
|
||||||
|
|
||||||
|
默认预算建议:
|
||||||
|
|
||||||
|
| 项目 | Token 预算 |
|
||||||
|
|------|------------|
|
||||||
|
| Instructions + Schema | 500 |
|
||||||
|
| 用户问题 | 200 |
|
||||||
|
| 检索上下文 | 2400 |
|
||||||
|
| 模型输出 | 700 |
|
||||||
|
| 安全余量 | 296 |
|
||||||
|
|
||||||
|
实际使用 `contextSize` 和 `tokenCount(for:)` 动态计算,不写死为 4096。
|
||||||
|
|
||||||
|
### 9.4 引用验证
|
||||||
|
|
||||||
|
生成后执行确定性校验:
|
||||||
|
|
||||||
|
1. Citation ID 必须存在于本次上下文。
|
||||||
|
2. Citation 必须属于当前文档和允许阅读范围。
|
||||||
|
3. 引用文本必须能在 Passage 原文中匹配。
|
||||||
|
4. 每个事实项至少有一个有效引用。
|
||||||
|
5. 删除无效项后答案为空,则返回 evidence insufficient。
|
||||||
|
|
||||||
|
## 10. 存储设计
|
||||||
|
|
||||||
|
建议使用 SQLite,Schema 初稿:
|
||||||
|
|
||||||
|
```text
|
||||||
|
documents
|
||||||
|
resources
|
||||||
|
passages
|
||||||
|
passage_fts
|
||||||
|
embeddings
|
||||||
|
entity_mentions
|
||||||
|
entities
|
||||||
|
entity_aliases
|
||||||
|
relationships
|
||||||
|
relationship_evidence
|
||||||
|
artifacts
|
||||||
|
index_jobs
|
||||||
|
schema_metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
关键原则:
|
||||||
|
|
||||||
|
- FTS 和关系表通过 Passage ID 关联原文。
|
||||||
|
- 向量数据按模型标识符、revision 和语言分区。
|
||||||
|
- 生成结果不覆盖人工编辑内容。
|
||||||
|
- 每个 Artifact 保存 Prompt/模型/输入哈希。
|
||||||
|
- 索引表支持按文档级联删除。
|
||||||
|
|
||||||
|
## 11. 并发与生命周期
|
||||||
|
|
||||||
|
建议采用 Swift Concurrency:
|
||||||
|
|
||||||
|
- `RDAIIndexCoordinator`:actor,管理索引队列和 checkpoint。
|
||||||
|
- `RDAIStore`:actor,串行化数据库写入。
|
||||||
|
- `RDAIRetriever`:Sendable 服务,可并发读取快照。
|
||||||
|
- Reader Adapter:`@MainActor`,仅提取 UI/阅读器状态和执行跳转。
|
||||||
|
- Foundation Models Session:由单请求 actor 或服务隔离。
|
||||||
|
|
||||||
|
优先级:
|
||||||
|
|
||||||
|
1. 用户当前问答所需 Passage。
|
||||||
|
2. 当前章节。
|
||||||
|
3. 相邻章节。
|
||||||
|
4. 已读范围。
|
||||||
|
5. 其余获准内容。
|
||||||
|
|
||||||
|
发生内存警告时:
|
||||||
|
|
||||||
|
- 取消低优先级 embedding 任务。
|
||||||
|
- 卸载 contextual embedding。
|
||||||
|
- 清理内存 Passage/向量缓存。
|
||||||
|
- 保留已提交数据库和当前用户请求。
|
||||||
|
|
||||||
|
## 12. 可用性与降级
|
||||||
|
|
||||||
|
```text
|
||||||
|
Foundation Models available
|
||||||
|
├─ 是 → 完整生成能力
|
||||||
|
└─ 否
|
||||||
|
├─ Natural Language available → 索引、实体、基础检索
|
||||||
|
└─ 语言/资源不支持 → 关键词检索与基础分段
|
||||||
|
```
|
||||||
|
|
||||||
|
降级状态是公共 API 的一部分,UI 不根据 Error 字符串猜测原因。
|
||||||
|
|
||||||
|
## 13. 安全与隐私
|
||||||
|
|
||||||
|
- 默认 Provider 为设备端 Provider。
|
||||||
|
- Core 不包含网络代码。
|
||||||
|
- 云端 Provider 若未来增加,必须是单独 Pod,并要求宿主显式配置。
|
||||||
|
- 日志只记录文档匿名哈希、阶段、耗时、错误类别和计数。
|
||||||
|
- 禁止记录 Prompt、Passage、用户问题和模型回答全文。
|
||||||
|
- 导出诊断包前再次脱敏。
|
||||||
|
- 删除书籍时由宿主调用 `removeDocument`,同时删除索引、关系和生成缓存。
|
||||||
|
|
||||||
|
## 14. 可观测性
|
||||||
|
|
||||||
|
本地指标:
|
||||||
|
|
||||||
|
- 索引耗时、资源数、片段数、失败类别。
|
||||||
|
- 检索 P50/P95、召回数量和降级模式。
|
||||||
|
- 生成耗时、取消率、错误类别和引用校验失败率。
|
||||||
|
- Foundation Models availability 分布。
|
||||||
|
- 缓存命中率和数据库大小。
|
||||||
|
|
||||||
|
商用版本默认只汇总数值。任何远程遥测必须由宿主决定,并遵守其隐私政策。
|
||||||
|
|
||||||
|
## 15. 架构决策记录
|
||||||
|
|
||||||
|
实施时至少补充以下 ADR:
|
||||||
|
|
||||||
|
- ADR-001:独立 RDAIReaderView 而非嵌入阅读器核心。
|
||||||
|
- ADR-002:UTF-16 作为跨模块文本范围。
|
||||||
|
- ADR-003:本地 SQLite 和增量资源哈希。
|
||||||
|
- ADR-004:检索先行、生成后置、引用强校验。
|
||||||
|
- ADR-005:Foundation Models 可选依赖和运行时降级。
|
||||||
|
- ADR-006:已读范围作为默认安全边界。
|
||||||
|
|
||||||
|
## 16. 已知技术风险
|
||||||
|
|
||||||
|
| 风险 | 影响 | 缓解 |
|
||||||
|
|------|------|------|
|
||||||
|
| 中文小说实体识别不足 | 人物漏识别或误合并 | 规则候选 + 结构化模型提取 + 证据与置信度 |
|
||||||
|
| OCR 阅读顺序错误 | 摘要和引用错误 | 保留 readingOrder、双栏测试、允许宿主提供文本 |
|
||||||
|
| PDF OCR 请求被页面导航取消 | 后台索引不完整 | AI 使用独立调度队列与缓存 |
|
||||||
|
| EPUB 重排后范围变化 | 引用跳转漂移 | CFI/rangeCFI 优先,文本哈希校验 |
|
||||||
|
| 系统模型更新 | Prompt 质量回归 | Prompt 版本化、模型版本分层评测 |
|
||||||
|
| 上下文不足 | 回答遗漏 | 检索压缩、分层摘要、新会话 |
|
||||||
|
| 同名人物 | 错误关系合并 | 不自动合并低置信候选,保留冲突 |
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# RDAIReaderView Release Checklist
|
||||||
|
|
||||||
|
This checklist records the external validation required after the local build
|
||||||
|
passes. It intentionally contains no book text, prompts, answers or user data.
|
||||||
|
|
||||||
|
> Status on 2026-07-25: Apple Intelligence eligible hardware, App Store privacy
|
||||||
|
> submission and TestFlight rollout access are temporarily unavailable. The
|
||||||
|
> unchecked physical-device and release gates below are deferred, not passed.
|
||||||
|
|
||||||
|
## Local Gates
|
||||||
|
|
||||||
|
- [x] Core, NaturalLanguage, FoundationModels and UI compile for iPhoneOS.
|
||||||
|
- [x] PDF and EPUB adapters compile in the ReadViewDemo workspace.
|
||||||
|
- [x] PDF/EPUB citations use stable locators and transient highlights.
|
||||||
|
- [x] SQLite data, FTS and generated artifacts are deleted with a book.
|
||||||
|
- [ ] Add fixture-backed XCTest coverage for locators, SQLite migration, scope,
|
||||||
|
citation validation, pause/resume and artifact invalidation.
|
||||||
|
- [ ] Add UI automation for index, summary, answer, citation jump, clear data
|
||||||
|
and VoiceOver labels.
|
||||||
|
|
||||||
|
## Physical Device Gates
|
||||||
|
|
||||||
|
- [ ] On an eligible iOS 26 device, verify each `SystemLanguageModel`
|
||||||
|
availability state and its UI fallback.
|
||||||
|
- [ ] Run structured summary and question-answering evaluation fixtures; record
|
||||||
|
citation validity, faithfulness and spoiler-safety without uploading text.
|
||||||
|
- [ ] Verify Vision OCR quality and cancellation/retry on scanned PDF samples.
|
||||||
|
- [ ] Measure index latency, memory, battery and citation jump P95 targets.
|
||||||
|
|
||||||
|
## Release Gates
|
||||||
|
|
||||||
|
- [ ] Create a feature flag/remote configuration policy owned by the host app.
|
||||||
|
- [ ] Complete App Store privacy labels, Foundation Models acceptable-use review
|
||||||
|
and network audit.
|
||||||
|
- [ ] Run TestFlight rollout 5%, 25%, then 100% with P0/P1 stop conditions.
|
||||||
|
- [ ] Confirm crash-free and AI quality metrics meet the published thresholds.
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# RDAIReaderView 产品与开发规格
|
||||||
|
|
||||||
|
**文档状态:** Draft 0.1
|
||||||
|
**最后更新:** 2026-07-25
|
||||||
|
**目标版本:** RDAIReaderView 1.0
|
||||||
|
**适用工程:** ReadViewSDK
|
||||||
|
|
||||||
|
## 1. 文档目的
|
||||||
|
|
||||||
|
本文档锁定 RDAIReaderView 首个商用版本的产品范围、系统兼容策略、功能要求、非功能要求和发布门槛。架构、公共 API、生成式 AI 约束和测试方法分别见同目录其他文档。
|
||||||
|
|
||||||
|
## 2. 背景与现状
|
||||||
|
|
||||||
|
ReadViewSDK 已具备以下基础:
|
||||||
|
|
||||||
|
- RDPDFReaderView:PDF 页面、原生文本、Vision OCR、页内文本矩形、跳页与临时朗读高亮。
|
||||||
|
- RDEpubReaderView:EPUB 章节、`href`、CFI、UTF-16 文本范围、搜索与位置恢复。
|
||||||
|
- RDSpeechReaderView:基于 Natural Language 的语言识别和分句、系统 TTS、断点续听、后台播放与锁屏控制。
|
||||||
|
- 最低部署版本为 iOS 15,Demo 使用 iOS 15.6。
|
||||||
|
|
||||||
|
当前缺少统一的文档语义索引、可追溯引用、书内问答、章节摘要和人物关系能力。
|
||||||
|
|
||||||
|
## 3. 产品目标
|
||||||
|
|
||||||
|
RDAIReaderView 1.0 的目标是提供一个默认本地运行、可选启用 Apple Foundation Models、可被 PDF 和 EPUB 阅读器复用的智能阅读能力层。
|
||||||
|
|
||||||
|
首版必须实现:
|
||||||
|
|
||||||
|
1. 对书籍内容建立增量、可恢复的本地索引。
|
||||||
|
2. 识别语言、句子、人物、地点、组织和关键词。
|
||||||
|
3. 生成当前章节或已读范围摘要。
|
||||||
|
4. 回答书内问题,并为事实性陈述提供可跳转的原文引用。
|
||||||
|
5. 生成人物卡片和带证据的人物关系。
|
||||||
|
6. Foundation Models 不可用时安全降级,不影响阅读、搜索和 TTS。
|
||||||
|
7. 所有 AI 结果默认限制在用户已读内容,避免剧透。
|
||||||
|
|
||||||
|
## 4. 非目标
|
||||||
|
|
||||||
|
RDAIReaderView 1.0 不包含:
|
||||||
|
|
||||||
|
- 互联网百科、新闻或通用知识问答。
|
||||||
|
- 无来源的文学评价、复杂逻辑推理或事实推断。
|
||||||
|
- 自动改写、续写或批量导出版权书籍内容。
|
||||||
|
- 云端上传书籍全文。
|
||||||
|
- 自训练 Foundation Models Adapter。
|
||||||
|
- 自动替用户发布内容、发送消息或执行购买行为。
|
||||||
|
- 对 CBZ、漫画图片内容进行视觉剧情理解。
|
||||||
|
- 对 DRM 内容绕过访问控制或持久化超出宿主授权范围的文本。
|
||||||
|
|
||||||
|
上述能力必须在后续版本单独评审产品价值、版权、隐私和审核风险。
|
||||||
|
|
||||||
|
## 5. 用户价值与首版场景
|
||||||
|
|
||||||
|
### 5.1 阅读回顾
|
||||||
|
|
||||||
|
用户重新打开书籍时,可请求:
|
||||||
|
|
||||||
|
- 上次阅读内容的 3 句回顾。
|
||||||
|
- 当前章节摘要。
|
||||||
|
- 已读部分的关键人物变化。
|
||||||
|
|
||||||
|
输入范围默认从最近一个自然章节边界到当前阅读位置,不得包含未读段落。
|
||||||
|
|
||||||
|
### 5.2 书内问答
|
||||||
|
|
||||||
|
用户可以询问当前书籍,例如:
|
||||||
|
|
||||||
|
- “这一章发生了什么?”
|
||||||
|
- “张三为什么离开?”
|
||||||
|
- “这里提到的组织是什么?”
|
||||||
|
|
||||||
|
系统先检索相关段落,再基于检索结果生成答案。答案中的事实性结论必须关联一个或多个 `RDAICitation`。没有充分证据时必须明确拒答,而不是依赖模型常识补全。
|
||||||
|
|
||||||
|
### 5.3 人物与关系
|
||||||
|
|
||||||
|
人物卡片包含:
|
||||||
|
|
||||||
|
- 标准显示名和已发现的别名。
|
||||||
|
- 首次出现位置。
|
||||||
|
- 仅基于已读内容的简短介绍。
|
||||||
|
- 关键行为及证据。
|
||||||
|
- 与其他人物的关系边及证据。
|
||||||
|
|
||||||
|
关系状态分为:
|
||||||
|
|
||||||
|
- `confirmed`:原文明确表达。
|
||||||
|
- `possible`:模型推断但证据不充分,UI 必须显示“可能”。
|
||||||
|
- `conflicting`:不同段落给出冲突信息,UI 展示冲突而非自动覆盖。
|
||||||
|
|
||||||
|
### 5.4 与阅读器联动
|
||||||
|
|
||||||
|
- 点击引用跳转到 PDF 页面或 EPUB CFI。
|
||||||
|
- 跳转后高亮对应原文范围。
|
||||||
|
- 摘要、答案和人物卡片可交给 RDSpeechReaderView 朗读。
|
||||||
|
- 用户调整字体、页面尺寸或翻页方式后,EPUB 引用仍应通过 CFI/文本范围恢复。
|
||||||
|
|
||||||
|
## 6. 功能要求
|
||||||
|
|
||||||
|
### FR-001 文档导入
|
||||||
|
|
||||||
|
- AI 层只能通过 `RDAIContentProvider` 读取宿主已授权的文本快照。
|
||||||
|
- 不直接读取宿主数据库或下载接口。
|
||||||
|
- 支持取消导入、增量恢复和内容变更检测。
|
||||||
|
|
||||||
|
### FR-002 稳定定位
|
||||||
|
|
||||||
|
- PDF:使用文档 ID、页索引、页内 UTF-16 范围和归一化矩形。
|
||||||
|
- EPUB:使用文档 ID、规范化 `href`、UTF-16 范围和 CFI;CFI 不可用时才使用 progression 兜底。
|
||||||
|
- 所有引用必须保存源文本哈希,恢复时验证引用是否仍指向相同内容。
|
||||||
|
|
||||||
|
### FR-003 文本分析
|
||||||
|
|
||||||
|
- 使用 `NLLanguageRecognizer` 识别资源或段落语言。
|
||||||
|
- 使用 `NLTokenizer` 切分句子,保持原始 UTF-16 偏移。
|
||||||
|
- 使用 `NLTagger` 生成人名、地点、组织候选。
|
||||||
|
- 实体候选必须保留每次出现的原文位置,不能只保存名称。
|
||||||
|
- Natural Language 不支持或质量不足的语言,降级为字符/标点分段和关键词检索。
|
||||||
|
|
||||||
|
### FR-004 分块与索引
|
||||||
|
|
||||||
|
- 中文片段建议 600-900 个字符;拉丁文字建议 300-600 词。
|
||||||
|
- 优先在章节、段落和句子边界切分,不截断组合字符。
|
||||||
|
- 相邻片段保留 1-2 句重叠,便于跨边界检索。
|
||||||
|
- 每个片段保存内容哈希、语言、顺序、定位和索引版本。
|
||||||
|
- 索引任务在后台执行,并按当前章节、相邻章节、其余内容的优先级处理。
|
||||||
|
|
||||||
|
### FR-005 检索
|
||||||
|
|
||||||
|
- 首版采用关键词/BM25 风格评分与 Natural Language 语义相似度融合。
|
||||||
|
- 结果必须经过文档 ID、已读范围和访问范围过滤。
|
||||||
|
- 默认返回 3-4 个片段,最大不超过 5 个。
|
||||||
|
- 检索结果必须包含分数、匹配原因和稳定定位。
|
||||||
|
|
||||||
|
### FR-006 Foundation Models 可用性
|
||||||
|
|
||||||
|
- 编译期使用可选子模块,不提高 Core 的 iOS 15 最低版本。
|
||||||
|
- 运行时检查系统版本、设备资格、Apple Intelligence 开关、模型准备状态和语言支持。
|
||||||
|
- UI 必须区分 `deviceNotEligible`、`appleIntelligenceNotEnabled`、`modelNotReady`、不支持语言和未知错误。
|
||||||
|
- 不可用时隐藏生成入口或提供明确说明,Natural Language 索引、搜索和 TTS 保持可用。
|
||||||
|
|
||||||
|
### FR-007 结构化生成
|
||||||
|
|
||||||
|
- 摘要、答案、实体和关系均使用 `@Generable` 结构化输出。
|
||||||
|
- 输出不得依赖字符串正则解析。
|
||||||
|
- 每个事实项必须携带检索片段 ID;生成后由代码验证 ID 是否真实存在。
|
||||||
|
- 无效引用、越权引用或未读范围引用必须删除;删除后答案无证据则转为拒答。
|
||||||
|
|
||||||
|
### FR-008 防剧透
|
||||||
|
|
||||||
|
- 默认分析范围为“当前位置及之前”。
|
||||||
|
- 用户主动切换到整本书模式时必须进行一次明确确认。
|
||||||
|
- 缓存键包含阅读范围;已读摘要不得复用整本书摘要。
|
||||||
|
- 人物关系图默认只展示已读范围内已出现的人物和关系。
|
||||||
|
|
||||||
|
### FR-009 缓存与恢复
|
||||||
|
|
||||||
|
- 索引、实体、摘要和问答缓存均存储在应用沙盒。
|
||||||
|
- 缓存键包含文档内容哈希、索引版本、Prompt 版本、模型版本和阅读范围。
|
||||||
|
- 内容哈希变化时,失效受影响资源,不强制删除整本书其他有效索引。
|
||||||
|
- 提供按书删除、删除全部 AI 数据和存储空间统计接口。
|
||||||
|
|
||||||
|
### FR-010 用户控制
|
||||||
|
|
||||||
|
- 所有长任务支持取消。
|
||||||
|
- UI 展示索引或生成状态,不伪造确定进度。
|
||||||
|
- 用户可关闭 AI、清除 AI 缓存、选择“仅本地处理”。
|
||||||
|
- 生成失败不得阻塞翻页、搜索、标注或 TTS。
|
||||||
|
|
||||||
|
## 7. 系统兼容矩阵
|
||||||
|
|
||||||
|
| 环境 | 必须提供的能力 |
|
||||||
|
|------|----------------|
|
||||||
|
| iOS 15+ | 语言识别、分句、实体候选、关键词索引、基础检索 |
|
||||||
|
| iOS 17+ | 可选 contextual embedding;资源不存在时允许下载或降级 |
|
||||||
|
| iOS 26+ 且模型可用 | 摘要、问答、人物关系、结构化笔记 |
|
||||||
|
| 不支持 Apple Intelligence | Natural Language 能力完整可用,生成式入口降级 |
|
||||||
|
| 离线 | 已下载模型与本地索引可用;不得要求联网 |
|
||||||
|
| 扫描 PDF | 使用现有 Vision OCR;OCR 失败的页面明确标记不可分析 |
|
||||||
|
|
||||||
|
## 8. 非功能要求
|
||||||
|
|
||||||
|
### 8.1 性能
|
||||||
|
|
||||||
|
- 索引不得在主线程执行文本分析、向量计算或数据库批量写入。
|
||||||
|
- 当前章节索引优先完成,目标 P95 不超过 2 秒;具体阈值以目标真机基线校准。
|
||||||
|
- 10 万中文字的基础索引目标 P95 不超过 30 秒,允许后台增量完成。
|
||||||
|
- 索引期间阅读页面滚动/翻页帧率不得出现持续性下降。
|
||||||
|
- 单次 Foundation Models 响应首个可展示结果目标 P95 不超过 5 秒。
|
||||||
|
- AI 模块空闲 30 秒后应释放 contextual embedding 和不必要的内存缓存。
|
||||||
|
|
||||||
|
### 8.2 稳定性
|
||||||
|
|
||||||
|
- AI 相关 crash-free session 不低于 99.9%。
|
||||||
|
- Foundation Models 不可用或生成失败时降级成功率为 100%。
|
||||||
|
- 强制退出后索引可从最后一个已提交资源恢复。
|
||||||
|
- 数据库迁移失败时保留原数据库备份,并允许重建索引。
|
||||||
|
|
||||||
|
### 8.3 准确性
|
||||||
|
|
||||||
|
- 引用定位有效率不低于 99%。
|
||||||
|
- 事实性陈述有原文支持的比例不低于 98%。
|
||||||
|
- 无答案问题正确拒答率不低于 95%。
|
||||||
|
- 人物关系证据覆盖率为 100%。
|
||||||
|
- 结构化输出通过本地校验的比例不低于 99.5%。
|
||||||
|
|
||||||
|
### 8.4 隐私与安全
|
||||||
|
|
||||||
|
- 默认不上传书籍文本、查询、摘要、人物关系和阅读历史。
|
||||||
|
- 日志不得包含原文、用户问题全文或模型完整输出。
|
||||||
|
- 调试日志必须经过显式编译配置才能包含脱敏片段。
|
||||||
|
- AI 数据遵循宿主账户登出、删书和清除缓存生命周期。
|
||||||
|
- 文件保护等级、备份策略和共享容器由宿主配置,SDK 提供明确接口和文档。
|
||||||
|
|
||||||
|
### 8.5 可访问性
|
||||||
|
|
||||||
|
- 所有 AI 控件支持 VoiceOver、Dynamic Type 和 Reduce Motion。
|
||||||
|
- 状态变化使用可访问性公告,但不得连续播报索引细节。
|
||||||
|
- “AI 生成”“可能关系”“无原文证据”等状态不能只靠颜色表达。
|
||||||
|
|
||||||
|
## 9. 商用验收门槛
|
||||||
|
|
||||||
|
以下条件全部满足后才可发布 1.0:
|
||||||
|
|
||||||
|
- Core、NaturalLanguage、FoundationModels、PDF Adapter、EPUB Adapter 均有单元测试。
|
||||||
|
- 关键用户流有 UI 自动化测试。
|
||||||
|
- 完成至少 100 条人工标注的产品评测集。
|
||||||
|
- 所有准确性指标达到第 8.3 节门槛。
|
||||||
|
- 在最低支持系统、主流支持设备和至少两代 Apple Intelligence 设备上完成真机验证。
|
||||||
|
- 完成模型不可用、未下载、语言不支持、上下文溢出和生成取消测试。
|
||||||
|
- 完成隐私清单、App Store 隐私申报和 AI 功能说明审核。
|
||||||
|
- TestFlight 灰度无 P0/P1 缺陷,AI 相关 crash-free session 达标。
|
||||||
|
- 现有 PDF、EPUB、搜索、标注和 TTS 回归全部通过。
|
||||||
|
|
||||||
|
## 10. 实施路线
|
||||||
|
|
||||||
|
以下排期按 2 名 iOS 工程师、1 名测试工程师、产品/内容评测兼职参与估算,总周期 12-14 周。单人开发建议按 18-22 周估算。
|
||||||
|
|
||||||
|
| 周期 | 阶段 | 主要交付 | 退出条件 |
|
||||||
|
|------|------|----------|----------|
|
||||||
|
| 第 1 周 | 合同与工程骨架 | 五份开发文档、Podspec、目录、CI Scheme、ADR | 文档评审通过,空库支持 iOS 15 编译 |
|
||||||
|
| 第 2-3 周 | Core 与存储 | 公共模型、SQLite Schema、迁移、Job/Checkpoint、删除接口 | 崩溃恢复和增量失效单测通过 |
|
||||||
|
| 第 4-5 周 | Natural Language | 分句、语言、实体候选、词法/语义检索 | 基础检索评测达标,TTS 范围无漂移 |
|
||||||
|
| 第 6 周 | PDF Adapter | 原生文本/OCR 快照、Locator、引用跳转和高亮 | PDF 引用恢复率达到门槛 |
|
||||||
|
| 第 7 周 | EPUB Adapter | href/CFI/rangeCFI 快照、Locator、跳转和高亮 | 重排后引用恢复率达到门槛 |
|
||||||
|
| 第 8-9 周 | Foundation Models | 可用性、摘要、问答、结构化输出、引用校验 | 不可用降级与 AI 评测通过 |
|
||||||
|
| 第 10 周 | 人物关系 | 人物、别名、关系、冲突和证据合并 | 人物/关系指标达到门槛 |
|
||||||
|
| 第 11 周 | 商用 UI | AI 面板、状态、取消、引用、防剧透、无障碍 | 核心 UI 自动化通过 |
|
||||||
|
| 第 12 周 | 性能与隐私 | 内存、耗电、数据库、日志、清除数据、隐私说明 | 无 P0/P1,性能与隐私门禁通过 |
|
||||||
|
| 第 13-14 周 | TestFlight 灰度 | 5%→25%→100% 分阶段发布 | Crash-free 和质量反馈持续达标 |
|
||||||
|
|
||||||
|
每阶段要求:
|
||||||
|
|
||||||
|
- 功能代码、单元测试和文档同一阶段完成。
|
||||||
|
- 公共 API 变更必须先更新 API 文档。
|
||||||
|
- Prompt 变更必须增加版本并跑 AI 回归集。
|
||||||
|
- 阶段退出条件未满足时不得把未验证能力带入下一阶段默认开启。
|
||||||
|
|
||||||
|
## 11. 版本范围
|
||||||
|
|
||||||
|
### 1.0
|
||||||
|
|
||||||
|
- 本地索引。
|
||||||
|
- 章节摘要。
|
||||||
|
- 带引用的书内问答。
|
||||||
|
- 人物卡片和基础人物关系。
|
||||||
|
- PDF/EPUB 引用跳转。
|
||||||
|
- 防剧透和完整降级。
|
||||||
|
|
||||||
|
### 1.1 候选
|
||||||
|
|
||||||
|
- 关系时间线与冲突关系展示。
|
||||||
|
- 用户划线/笔记参与问答。
|
||||||
|
- 多本书对照,仅限用户主动选择的本地书籍。
|
||||||
|
- 可选 Core ML/MLX 或云端 Provider。
|
||||||
|
|
||||||
|
### 2.0 候选
|
||||||
|
|
||||||
|
- 多模态图片理解。
|
||||||
|
- 漫画/图文书内容理解。
|
||||||
|
- 经过独立法律和产品评审的云端增强能力。
|
||||||
|
|
||||||
|
## 12. 依赖文档
|
||||||
|
|
||||||
|
- [RDAIReaderView-ARCHITECTURE.md](RDAIReaderView-ARCHITECTURE.md)
|
||||||
|
- [RDAIReaderView-API.md](RDAIReaderView-API.md)
|
||||||
|
- [RDAIReaderView-AI-SPEC.md](RDAIReaderView-AI-SPEC.md)
|
||||||
|
- [RDAIReaderView-TEST-PLAN.md](RDAIReaderView-TEST-PLAN.md)
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
# RDAIReaderView 商用测试与发布计划
|
||||||
|
|
||||||
|
**文档状态:** Draft 0.1
|
||||||
|
**最后更新:** 2026-07-25
|
||||||
|
**目标版本:** RDAIReaderView 1.0
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
测试计划验证以下结论:
|
||||||
|
|
||||||
|
1. AI 模块不会破坏现有 PDF、EPUB、搜索、标注和 TTS。
|
||||||
|
2. 索引和引用在书籍更新、分页变化、OCR 和应用中断后仍然可靠。
|
||||||
|
3. Foundation Models 的输出有证据、可拒答、不剧透并可安全降级。
|
||||||
|
4. 性能、内存、耗电、隐私和可访问性达到商用要求。
|
||||||
|
5. Prompt 或系统模型更新后,可以通过可重复评测发现质量回归。
|
||||||
|
|
||||||
|
## 2. 测试层级
|
||||||
|
|
||||||
|
```text
|
||||||
|
人工与 TestFlight 验收
|
||||||
|
↑
|
||||||
|
UI / 真机系统测试
|
||||||
|
↑
|
||||||
|
PDF / EPUB / TTS 集成测试
|
||||||
|
↑
|
||||||
|
AI 产品评测与 Prompt 回归
|
||||||
|
↑
|
||||||
|
Core / Storage / NLP 单元测试
|
||||||
|
```
|
||||||
|
|
||||||
|
确定性校验优先放在单元测试;非确定性生成质量使用固定数据集、多次运行和人工评审。
|
||||||
|
|
||||||
|
## 3. 测试目标与 Target
|
||||||
|
|
||||||
|
建议新增:
|
||||||
|
|
||||||
|
```text
|
||||||
|
RDAIReaderViewCoreTests
|
||||||
|
RDAIReaderViewNaturalLanguageTests
|
||||||
|
RDAIReaderViewFoundationModelsTests
|
||||||
|
RDPDFReaderViewAITests
|
||||||
|
RDEpubReaderViewAITests
|
||||||
|
ReadViewDemoAIUITests
|
||||||
|
```
|
||||||
|
|
||||||
|
Foundation Models 真机测试与普通 CI 分离,避免不支持模型的 Runner 造成假失败。
|
||||||
|
|
||||||
|
## 4. 测试数据
|
||||||
|
|
||||||
|
### 4.1 数据来源
|
||||||
|
|
||||||
|
只使用:
|
||||||
|
|
||||||
|
- 公版书籍。
|
||||||
|
- 项目拥有测试授权的书籍。
|
||||||
|
- 团队自行编写的合成文本。
|
||||||
|
- 经过脱敏、明确允许进入测试仓库的样本。
|
||||||
|
|
||||||
|
不得把商业书籍全文或用户内容提交到测试仓库。
|
||||||
|
|
||||||
|
### 4.2 数据集组成
|
||||||
|
|
||||||
|
首版至少包含:
|
||||||
|
|
||||||
|
| 类型 | 最低数量 | 重点 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 中文小说章节 | 20 | 多人物、别名、代词、倒叙、否定关系 |
|
||||||
|
| 英文小说章节 | 10 | 名称大小写、代词、长句 |
|
||||||
|
| 中文技术/非虚构 | 10 | 术语、组织、事实问答 |
|
||||||
|
| 英文技术/非虚构 | 10 | 代码块、列表、表格 |
|
||||||
|
| 原生文本 PDF | 5 本/50 页 | 单栏、双栏、页眉页脚 |
|
||||||
|
| 扫描 PDF | 5 本/50 页 | OCR 错字、旋转、低清晰度 |
|
||||||
|
| EPUB | 5 本/30 章 | CFI、脚注、长章节、重排 |
|
||||||
|
| 无答案问题 | 20 | 拒答 |
|
||||||
|
| 剧透边界问题 | 20 | 已读/未读范围隔离 |
|
||||||
|
| 同名或别名关系 | 20 | 实体合并准确性 |
|
||||||
|
|
||||||
|
首版产品评测集不少于 100 个 Case。每个 Case 保存输入、允许范围、期望证据、可接受答案要点和禁止行为。
|
||||||
|
|
||||||
|
### 4.3 Case 格式
|
||||||
|
|
||||||
|
建议使用 JSONL:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "qa-zh-001",
|
||||||
|
"task": "questionAnswering",
|
||||||
|
"documentFixture": "novel-zh-01",
|
||||||
|
"readScope": {
|
||||||
|
"resourceOrderUpperBound": 3,
|
||||||
|
"utf16UpperBound": 8200
|
||||||
|
},
|
||||||
|
"input": "林川为什么离开村庄?",
|
||||||
|
"expectedPassageIDs": ["p-3-18", "p-3-19"],
|
||||||
|
"requiredFacts": ["受到追捕"],
|
||||||
|
"forbiddenFacts": ["第四章之后的身份揭示"],
|
||||||
|
"expectedStatus": "answered"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
测试结果不得把 fixture 原文写入可上传日志。
|
||||||
|
|
||||||
|
## 5. Core 单元测试
|
||||||
|
|
||||||
|
### 5.1 文本范围
|
||||||
|
|
||||||
|
- 空文本、空白文本和超长文本。
|
||||||
|
- Emoji、组合字符、代理对、中文标点和换行。
|
||||||
|
- UTF-16 范围与 Swift String Index 双向转换。
|
||||||
|
- 搜索规范化不能改变 source range。
|
||||||
|
- Passage 重叠不能产生错误 Locator。
|
||||||
|
|
||||||
|
### 5.2 哈希与失效
|
||||||
|
|
||||||
|
- 相同内容产生相同哈希。
|
||||||
|
- 只改变一个章节时只失效该资源。
|
||||||
|
- Prompt 版本变化只失效相关 Artifact。
|
||||||
|
- embedding 模型 revision 变化只重建对应向量。
|
||||||
|
- 阅读范围扩大不复用越界缓存。
|
||||||
|
|
||||||
|
### 5.3 存储与迁移
|
||||||
|
|
||||||
|
- 首次建库、重复打开和并发读取。
|
||||||
|
- 每个 Schema 版本向下一版本迁移。
|
||||||
|
- 迁移中断后恢复或回滚。
|
||||||
|
- 数据库损坏时保留诊断并可安全重建。
|
||||||
|
- 按书删除和删除全部数据。
|
||||||
|
- 登出/删书回调后无孤立 Passage、向量或 Artifact。
|
||||||
|
|
||||||
|
### 5.4 索引调度
|
||||||
|
|
||||||
|
- 当前章节优先。
|
||||||
|
- 暂停、恢复、取消和重复 prepare。
|
||||||
|
- 应用强制退出后从 checkpoint 恢复。
|
||||||
|
- 内存警告取消低优先级任务。
|
||||||
|
- 内容 Provider 释放后任务安全失败,不野指针或永久等待。
|
||||||
|
|
||||||
|
## 6. Natural Language 测试
|
||||||
|
|
||||||
|
### 6.1 语言与分句
|
||||||
|
|
||||||
|
- 中文、英文、中英混排。
|
||||||
|
- 无标点长段落。
|
||||||
|
- 缩写、小数、网址和引号。
|
||||||
|
- 句子范围与 RDSpeechReaderView 结果一致。
|
||||||
|
- 不支持语言的降级分块。
|
||||||
|
|
||||||
|
### 6.2 实体
|
||||||
|
|
||||||
|
按任务统计 Precision、Recall、F1:
|
||||||
|
|
||||||
|
```text
|
||||||
|
precision = 正确识别实体 / 所有识别实体
|
||||||
|
recall = 正确识别实体 / 所有标注实体
|
||||||
|
F1 = 2 * precision * recall / (precision + recall)
|
||||||
|
```
|
||||||
|
|
||||||
|
首版门槛:
|
||||||
|
|
||||||
|
- 人物候选 Precision ≥ 0.95。
|
||||||
|
- 人物候选 Recall ≥ 0.85。
|
||||||
|
- 地点/组织作为辅助信息,F1 ≥ 0.80。
|
||||||
|
|
||||||
|
人物候选宁可少召回,也不能大量创建虚假人物。
|
||||||
|
|
||||||
|
### 6.3 检索
|
||||||
|
|
||||||
|
- 关键词命中、同义表达、别名和错别字。
|
||||||
|
- Top 5 包含正确证据的 Recall@5 ≥ 0.90。
|
||||||
|
- 无 embedding 资源时词法检索仍可返回结果。
|
||||||
|
- 已读范围外 Passage 召回数必须为 0。
|
||||||
|
- 相同输入和索引版本的词法结果顺序稳定。
|
||||||
|
|
||||||
|
## 7. PDF Adapter 测试
|
||||||
|
|
||||||
|
- 原生文本 run 按 `readingOrder` 正确拼接。
|
||||||
|
- 双栏页面不会按几何坐标错误穿插。
|
||||||
|
- run 间换行计入 UTF-16 范围。
|
||||||
|
- Passage 范围映射回正确 normalized rects。
|
||||||
|
- characterRects 存在/缺失均可高亮。
|
||||||
|
- OCR 与原生文本来源正确标记。
|
||||||
|
- OCR 缓存命中、失败、取消和重试。
|
||||||
|
- 页面缓存裁剪后 Citation 仍可重新加载。
|
||||||
|
- 跳转后目标页和高亮正确。
|
||||||
|
- 旋转、横屏、竖滑、双页模式下高亮位置正确。
|
||||||
|
- 页面导航取消 OCR 时,AI 索引任务不应永久丢失。
|
||||||
|
|
||||||
|
PDF 引用定位有效率必须 ≥ 99%。
|
||||||
|
|
||||||
|
## 8. EPUB Adapter 测试
|
||||||
|
|
||||||
|
- `href` 规范化一致。
|
||||||
|
- UTF-16 range、rangeAnchor、CFI 和 rangeCFI 互相映射。
|
||||||
|
- 改字号、行距、边距、字体和横竖屏后 Citation 可恢复。
|
||||||
|
- 长章节按需加载时可提取目标资源。
|
||||||
|
- 脚注、列表、图片替代文本和代码块类型正确。
|
||||||
|
- EPUB 更新导致 source hash 变化时旧 Citation 标记 stale。
|
||||||
|
- CFI 缺失时 progression 只作兜底。
|
||||||
|
- 引用跳转复用标准位置恢复流程。
|
||||||
|
- 固定版式无文本章节返回明确不可分析状态。
|
||||||
|
|
||||||
|
EPUB 重排后引用定位有效率必须 ≥ 99%。
|
||||||
|
|
||||||
|
## 9. Foundation Models 产品评测
|
||||||
|
|
||||||
|
### 9.1 运行方式
|
||||||
|
|
||||||
|
- 每个 Case 至少运行 3 次,避免偶然结果掩盖问题。
|
||||||
|
- 按系统模型版本、系统语言和设备分桶。
|
||||||
|
- Prompt 新版本同时运行旧版和新版,生成差异报告。
|
||||||
|
- 代码校验先执行,再进入人工/模型评分。
|
||||||
|
|
||||||
|
### 9.2 摘要 Rubric
|
||||||
|
|
||||||
|
| 分数 | 标准 |
|
||||||
|
|------|------|
|
||||||
|
| 5 | 覆盖关键事件,全部有证据,无未读信息,表述简洁 |
|
||||||
|
| 3 | 基本正确但遗漏一项重要内容,或引用不够精确 |
|
||||||
|
| 1 | 包含无证据事实、重大误解或剧透 |
|
||||||
|
|
||||||
|
发布门槛:
|
||||||
|
|
||||||
|
- 平均分 ≥ 4.0。
|
||||||
|
- 任一剧透 Case 失败即阻断发布。
|
||||||
|
- 无证据事实比例 ≤ 2%。
|
||||||
|
|
||||||
|
### 9.3 问答 Rubric
|
||||||
|
|
||||||
|
检查:
|
||||||
|
|
||||||
|
- 是否回答用户问题。
|
||||||
|
- 每个事实是否被引用支持。
|
||||||
|
- 是否遗漏关键反证。
|
||||||
|
- 证据不足时是否拒答。
|
||||||
|
- 是否泄漏未读内容。
|
||||||
|
|
||||||
|
发布门槛:
|
||||||
|
|
||||||
|
- Context faithfulness ≥ 98%。
|
||||||
|
- 无答案正确拒答率 ≥ 95%。
|
||||||
|
- Citation validity ≥ 99%。
|
||||||
|
- Spoiler safety = 100%。
|
||||||
|
|
||||||
|
### 9.4 人物与关系 Rubric
|
||||||
|
|
||||||
|
检查:
|
||||||
|
|
||||||
|
- 人物真实出现。
|
||||||
|
- 别名有明确证据。
|
||||||
|
- 同名人物未误合并。
|
||||||
|
- 关系方向正确。
|
||||||
|
- `confirmed` 与 `possible` 分类合理。
|
||||||
|
- 冲突关系没有被静默覆盖。
|
||||||
|
|
||||||
|
发布门槛:
|
||||||
|
|
||||||
|
- Character precision ≥ 97%。
|
||||||
|
- Alias merge precision ≥ 98%。
|
||||||
|
- 关系证据覆盖率 = 100%。
|
||||||
|
- 无证据关系数 = 0。
|
||||||
|
|
||||||
|
### 9.5 LLM Judge
|
||||||
|
|
||||||
|
LLM Judge 只作为辅助:
|
||||||
|
|
||||||
|
- 使用固定 Rubric,不使用泛化“是否有帮助”问题。
|
||||||
|
- 先对至少 30 个 Case 与人工评分校准。
|
||||||
|
- Spearman/Pearson 相关性低于 0.7 时不得作为发布门禁。
|
||||||
|
- Judge 分歧、低分和边界 Case 必须人工复核。
|
||||||
|
- 若使用云端 Judge,测试原文必须为可上传的公版或合成数据。
|
||||||
|
|
||||||
|
## 10. 可用性与错误测试
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
|
||||||
|
- iOS 15/17:Foundation Models 模块不参与运行。
|
||||||
|
- iOS 26+ 不支持设备。
|
||||||
|
- Apple Intelligence 未开启。
|
||||||
|
- 模型正在下载或未准备。
|
||||||
|
- 当前语言不支持。
|
||||||
|
- context limit exceeded。
|
||||||
|
- guardrail 拒绝输入或输出。
|
||||||
|
- 生成中取消、切书、关闭页面、进入后台。
|
||||||
|
- 同一 session 并发请求。
|
||||||
|
- 低存储空间和数据库写入失败。
|
||||||
|
|
||||||
|
每种情况必须产生稳定枚举状态,并保持阅读器可操作。
|
||||||
|
|
||||||
|
## 11. UI 与可访问性
|
||||||
|
|
||||||
|
### 11.1 UI 自动化
|
||||||
|
|
||||||
|
- 打开/关闭 AI 面板。
|
||||||
|
- 当前章节索引状态。
|
||||||
|
- 发起问题、取消和重试。
|
||||||
|
- 点击引用并跳转高亮。
|
||||||
|
- 仅已读范围默认开启。
|
||||||
|
- 整本书模式确认。
|
||||||
|
- Foundation Models 不可用状态。
|
||||||
|
- 清除本书和全部 AI 数据。
|
||||||
|
- AI 结果交给 TTS 朗读。
|
||||||
|
|
||||||
|
### 11.2 可访问性
|
||||||
|
|
||||||
|
- VoiceOver 顺序和标签。
|
||||||
|
- Dynamic Type 最大辅助字号。
|
||||||
|
- Reduce Motion。
|
||||||
|
- 深色模式和高对比度。
|
||||||
|
- `possible`/`conflicting` 不只依赖颜色。
|
||||||
|
- 加载、取消和失败状态有可访问性公告。
|
||||||
|
|
||||||
|
## 12. 性能与资源测试
|
||||||
|
|
||||||
|
目标设备至少覆盖:
|
||||||
|
|
||||||
|
- 最低性能 iOS 15 支持设备。
|
||||||
|
- iOS 17 中档设备。
|
||||||
|
- 一台首代支持 Apple Intelligence 的设备。
|
||||||
|
- 一台当前系统的高性能设备。
|
||||||
|
|
||||||
|
基准:
|
||||||
|
|
||||||
|
| 指标 | 1.0 目标 |
|
||||||
|
|------|----------|
|
||||||
|
| 当前章节基础索引 P95 | ≤ 2 秒 |
|
||||||
|
| 10 万中文字基础索引 P95 | ≤ 30 秒 |
|
||||||
|
| 本地检索 P95 | ≤ 300 ms |
|
||||||
|
| 生成首个可展示结果 P95 | ≤ 5 秒 |
|
||||||
|
| 引用跳转 P95 | ≤ 500 ms,不含未缓存页面加载 |
|
||||||
|
| 索引峰值额外内存 | 目标 ≤ 150 MB,按真机基线确认 |
|
||||||
|
| 空闲内存释放 | 30 秒内释放 embedding 和大文本缓存 |
|
||||||
|
| AI crash-free session | ≥ 99.9% |
|
||||||
|
|
||||||
|
测试同时记录:
|
||||||
|
|
||||||
|
- CPU time。
|
||||||
|
- 主线程卡顿。
|
||||||
|
- thermal state。
|
||||||
|
- 电量变化。
|
||||||
|
- 数据库大小/万字。
|
||||||
|
- embedding 资源加载耗时。
|
||||||
|
- 缓存命中率。
|
||||||
|
|
||||||
|
性能基线变化超过 15% 时 CI 或发布报告必须提示。
|
||||||
|
|
||||||
|
## 13. 隐私与安全测试
|
||||||
|
|
||||||
|
- 网络抓包确认默认实现不上传书籍或问题。
|
||||||
|
- 搜索日志、控制台日志和 crash breadcrumbs 不含原文。
|
||||||
|
- 清除 AI 数据后数据库、缓存和临时文件均删除。
|
||||||
|
- 删除书籍和退出账户触发相同清除路径。
|
||||||
|
- Scope 过滤在检索前和生成后均执行。
|
||||||
|
- 构造伪造 Passage ID,确认 Citation Validator 拒绝。
|
||||||
|
- 构造路径、超长查询和大量 Tool 参数,确认边界限制。
|
||||||
|
- Tool Calling 最大次数和重复调用检测生效。
|
||||||
|
- 数据库迁移和诊断包不泄漏原文。
|
||||||
|
|
||||||
|
## 14. 回归测试
|
||||||
|
|
||||||
|
每次合入必须运行:
|
||||||
|
|
||||||
|
- Core 单元测试。
|
||||||
|
- Natural Language 确定性测试。
|
||||||
|
- PDF/EPUB Adapter fixture 测试。
|
||||||
|
- 现有 PDF、EPUB 和 TTS 编译。
|
||||||
|
- `git diff --check` 和公共 API 兼容检查。
|
||||||
|
|
||||||
|
每日或候选发布运行:
|
||||||
|
|
||||||
|
- UI smoke。
|
||||||
|
- 完整 AI 评测集。
|
||||||
|
- 大书索引性能。
|
||||||
|
- Foundation Models 真机矩阵。
|
||||||
|
- 现有 `ReadViewDemoUITests` 回归。
|
||||||
|
|
||||||
|
## 15. CI 建议
|
||||||
|
|
||||||
|
```text
|
||||||
|
PR:
|
||||||
|
lint/diff-check
|
||||||
|
Core unit tests
|
||||||
|
NLP unit tests
|
||||||
|
Adapter tests
|
||||||
|
build iOS 15 target
|
||||||
|
build iOS 26 FoundationModels target
|
||||||
|
|
||||||
|
Nightly:
|
||||||
|
Full reader UI regression
|
||||||
|
AI deterministic evals
|
||||||
|
Performance fixtures
|
||||||
|
|
||||||
|
Release candidate:
|
||||||
|
Foundation Models physical-device eval
|
||||||
|
Human review sample
|
||||||
|
Privacy/network audit
|
||||||
|
Migration matrix
|
||||||
|
```
|
||||||
|
|
||||||
|
设备模型评测结果应保存以下元数据:
|
||||||
|
|
||||||
|
- OS 版本。
|
||||||
|
- 模型版本或可识别 profile。
|
||||||
|
- Prompt identifier/version。
|
||||||
|
- fixture version。
|
||||||
|
- SDK commit。
|
||||||
|
- 各指标与失败 Case ID。
|
||||||
|
|
||||||
|
不得保存生产用户原文。
|
||||||
|
|
||||||
|
## 16. 缺陷分级
|
||||||
|
|
||||||
|
### P0
|
||||||
|
|
||||||
|
- 泄漏书籍内容或阅读历史。
|
||||||
|
- 绕过已读范围造成剧透。
|
||||||
|
- 删除用户原书、标注或笔记。
|
||||||
|
- 大面积崩溃或数据库不可恢复损坏。
|
||||||
|
|
||||||
|
### P1
|
||||||
|
|
||||||
|
- 无引用或错误引用的事实作为确定答案展示。
|
||||||
|
- 人物关系大面积误合并。
|
||||||
|
- Foundation Models 不可用导致阅读器不可用。
|
||||||
|
- 引用跳转到错误章节/页面。
|
||||||
|
|
||||||
|
### P2
|
||||||
|
|
||||||
|
- 摘要遗漏、检索质量下降、局部 UI 或性能问题。
|
||||||
|
- 可重试且不影响阅读主流程的生成失败。
|
||||||
|
|
||||||
|
发布时 P0/P1 必须为 0;P2 必须有明确接受记录和后续版本计划。
|
||||||
|
|
||||||
|
## 17. TestFlight 灰度
|
||||||
|
|
||||||
|
### 阶段 A:内部
|
||||||
|
|
||||||
|
- 团队和测试设备。
|
||||||
|
- 至少 7 天。
|
||||||
|
- 完整日志仅限脱敏元数据。
|
||||||
|
|
||||||
|
### 阶段 B:5%
|
||||||
|
|
||||||
|
- 只开启摘要和问答。
|
||||||
|
- 观察 crash-free、取消率、拒答率、引用点击成功率。
|
||||||
|
- 人物关系仍受远程/本地 feature flag 控制。
|
||||||
|
|
||||||
|
### 阶段 C:25%
|
||||||
|
|
||||||
|
- 开启人物卡片。
|
||||||
|
- 关系图只对达到索引完整度的书籍开放。
|
||||||
|
- 至少稳定 7 天。
|
||||||
|
|
||||||
|
### 阶段 D:100%
|
||||||
|
|
||||||
|
- 所有发布门禁持续达标。
|
||||||
|
- 保留快速关闭 Foundation Models 功能的配置,但关闭后基础阅读和 NLP 正常。
|
||||||
|
|
||||||
|
## 18. 最终发布门禁
|
||||||
|
|
||||||
|
- [ ] 五份开发文档与实现一致。
|
||||||
|
- [ ] 公共 API 兼容检查通过。
|
||||||
|
- [ ] Core/NLP/Adapter 单元和集成测试通过。
|
||||||
|
- [ ] 现有 Reader 与 TTS 回归通过。
|
||||||
|
- [ ] 100+ AI Case 全量运行并达到指标。
|
||||||
|
- [ ] Foundation Models 支持与不支持路径均完成真机测试。
|
||||||
|
- [ ] PDF/EPUB Citation validity ≥ 99%。
|
||||||
|
- [ ] Context faithfulness ≥ 98%。
|
||||||
|
- [ ] Spoiler safety = 100%。
|
||||||
|
- [ ] AI crash-free session ≥ 99.9%。
|
||||||
|
- [ ] 隐私和网络审计通过。
|
||||||
|
- [ ] 无 P0/P1 缺陷。
|
||||||
|
- [ ] TestFlight 灰度指标稳定。
|
||||||
|
|
||||||
+7
-1
@@ -1,6 +1,6 @@
|
|||||||
# ReadViewSDK 文档索引
|
# ReadViewSDK 文档索引
|
||||||
|
|
||||||
> 最后更新:2026-06-18
|
> 最后更新:2026-07-25
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -33,6 +33,12 @@
|
|||||||
|
|
||||||
| 文档 | 说明 |
|
| 文档 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
| [RDAIReaderView/RDAIReaderView-SPEC.md](RDAIReaderView/RDAIReaderView-SPEC.md) | AI 阅读能力产品范围、兼容策略、实施路线与商用验收门槛 |
|
||||||
|
| [RDAIReaderView/RDAIReaderView-ARCHITECTURE.md](RDAIReaderView/RDAIReaderView-ARCHITECTURE.md) | RDAIReaderView 模块、索引、检索、存储、引用和降级架构 |
|
||||||
|
| [RDAIReaderView/RDAIReaderView-API.md](RDAIReaderView/RDAIReaderView-API.md) | RDAIReaderView 公共模型、Provider、Reader Adapter 和服务 API 合同 |
|
||||||
|
| [RDAIReaderView/RDAIReaderView-AI-SPEC.md](RDAIReaderView/RDAIReaderView-AI-SPEC.md) | Natural Language 与 Foundation Models 的 Prompt、结构化输出、安全和评测合同 |
|
||||||
|
| [RDAIReaderView/RDAIReaderView-TEST-PLAN.md](RDAIReaderView/RDAIReaderView-TEST-PLAN.md) | AI 商用测试集、质量指标、真机矩阵、CI、灰度与发布门禁 |
|
||||||
|
| [RDAIReaderView/RDAIReaderView-RELEASE-CHECKLIST.md](RDAIReaderView/RDAIReaderView-RELEASE-CHECKLIST.md) | 本地构建后所需的真机、隐私、TestFlight 与发布验证清单 |
|
||||||
| [TYPESetter_PIPELINE.md](TYPESetter_PIPELINE.md) | Typesetter 排版管线详解:HTML 规范化、语义标记注入、CFI 标记、样式合成为、字体规范化、片段标记 |
|
| [TYPESetter_PIPELINE.md](TYPESetter_PIPELINE.md) | Typesetter 排版管线详解:HTML 规范化、语义标记注入、CFI 标记、样式合成为、字体规范化、片段标记 |
|
||||||
| [CHAPTER_RUNTIME.md](CHAPTER_RUNTIME.md) | 章节运行时详解:按需加载、章节窗口协调、页图管理、磁盘缓存、后台补全 |
|
| [CHAPTER_RUNTIME.md](CHAPTER_RUNTIME.md) | 章节运行时详解:按需加载、章节窗口协调、页图管理、磁盘缓存、后台补全 |
|
||||||
| [CFI_SUBSYSTEM.md](CFI_SUBSYSTEM.md) | CFI 子系统详解:EPUB CFI 解析、生成、序列化、范围、恢复引擎 |
|
| [CFI_SUBSYSTEM.md](CFI_SUBSYSTEM.md) | CFI 子系统详解:EPUB CFI 解析、生成、序列化、范围、恢复引擎 |
|
||||||
|
|||||||
@@ -3,8 +3,16 @@ platform :ios, '15.6'
|
|||||||
target 'ReadViewDemo' do
|
target 'ReadViewDemo' do
|
||||||
# Comment the next line if you don't want to use dynamic frameworks
|
# Comment the next line if you don't want to use dynamic frameworks
|
||||||
use_frameworks!
|
use_frameworks!
|
||||||
|
pod 'RDSpeechReaderView/AI', :path => '../Sources/RDSpeechReaderView'
|
||||||
|
pod 'RDAIReaderView/NaturalLanguage', :path => '../Sources/RDAIReaderView'
|
||||||
|
pod 'RDAIReaderView/FoundationModels', :path => '../Sources/RDAIReaderView'
|
||||||
|
pod 'RDAIReaderView/UI', :path => '../Sources/RDAIReaderView'
|
||||||
pod 'RDEpubReaderView', :path => '../Sources/RDEpubReaderView'
|
pod 'RDEpubReaderView', :path => '../Sources/RDEpubReaderView'
|
||||||
|
pod 'RDEpubReaderView/Speech', :path => '../Sources/RDEpubReaderView'
|
||||||
|
pod 'RDEpubReaderView/AI', :path => '../Sources/RDEpubReaderView'
|
||||||
pod 'RDPDFReaderView', :path => '../Sources/RDPDFReaderView'
|
pod 'RDPDFReaderView', :path => '../Sources/RDPDFReaderView'
|
||||||
|
pod 'RDPDFReaderView/Speech', :path => '../Sources/RDPDFReaderView'
|
||||||
|
pod 'RDPDFReaderView/AI', :path => '../Sources/RDPDFReaderView'
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -16,18 +16,59 @@ PODS:
|
|||||||
- DTFoundation/Core
|
- DTFoundation/Core
|
||||||
- DTFoundation/UIKit (1.7.19):
|
- DTFoundation/UIKit (1.7.19):
|
||||||
- DTFoundation/Core
|
- DTFoundation/Core
|
||||||
|
- RDAIReaderView/Core (0.1.0)
|
||||||
|
- RDAIReaderView/FoundationModels (0.1.0):
|
||||||
|
- RDAIReaderView/Core (~> 0.1)
|
||||||
|
- RDAIReaderView/NaturalLanguage (0.1.0):
|
||||||
|
- RDAIReaderView/Core (~> 0.1)
|
||||||
|
- RDAIReaderView/UI (0.1.0):
|
||||||
|
- RDAIReaderView/Core (~> 0.1)
|
||||||
- RDEpubReaderView (0.0.2):
|
- RDEpubReaderView (0.0.2):
|
||||||
- DTCoreText (~> 1.6)
|
- DTCoreText (~> 1.6)
|
||||||
|
- RDEpubReaderView/AI (= 0.0.2)
|
||||||
|
- RDEpubReaderView/Speech (= 0.0.2)
|
||||||
|
- SnapKit (~> 5.7)
|
||||||
|
- ZIPFoundation (~> 0.9)
|
||||||
|
- RDEpubReaderView/AI (0.0.2):
|
||||||
|
- DTCoreText (~> 1.6)
|
||||||
|
- RDAIReaderView/NaturalLanguage (~> 0.1)
|
||||||
|
- RDAIReaderView/UI (~> 0.1)
|
||||||
|
- SnapKit (~> 5.7)
|
||||||
|
- ZIPFoundation (~> 0.9)
|
||||||
|
- RDEpubReaderView/Speech (0.0.2):
|
||||||
|
- DTCoreText (~> 1.6)
|
||||||
|
- RDSpeechReaderView (~> 0.1)
|
||||||
- SnapKit (~> 5.7)
|
- SnapKit (~> 5.7)
|
||||||
- ZIPFoundation (~> 0.9)
|
- ZIPFoundation (~> 0.9)
|
||||||
- RDPDFReaderView (0.0.1):
|
- RDPDFReaderView (0.0.1):
|
||||||
|
- RDPDFReaderView/AI (= 0.0.1)
|
||||||
|
- RDPDFReaderView/Speech (= 0.0.1)
|
||||||
- SnapKit (~> 5.7)
|
- SnapKit (~> 5.7)
|
||||||
|
- RDPDFReaderView/AI (0.0.1):
|
||||||
|
- RDAIReaderView/NaturalLanguage (~> 0.1)
|
||||||
|
- RDAIReaderView/UI (~> 0.1)
|
||||||
|
- SnapKit (~> 5.7)
|
||||||
|
- RDPDFReaderView/Speech (0.0.1):
|
||||||
|
- RDSpeechReaderView (~> 0.1)
|
||||||
|
- SnapKit (~> 5.7)
|
||||||
|
- RDSpeechReaderView (0.1.0):
|
||||||
|
- RDSpeechReaderView/AI (= 0.1.0)
|
||||||
|
- RDSpeechReaderView/AI (0.1.0):
|
||||||
|
- RDAIReaderView/Core (~> 0.1)
|
||||||
- SnapKit (5.7.1)
|
- SnapKit (5.7.1)
|
||||||
- ZIPFoundation (0.9.20)
|
- ZIPFoundation (0.9.20)
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
|
- RDAIReaderView/FoundationModels (from `../Sources/RDAIReaderView`)
|
||||||
|
- RDAIReaderView/NaturalLanguage (from `../Sources/RDAIReaderView`)
|
||||||
|
- RDAIReaderView/UI (from `../Sources/RDAIReaderView`)
|
||||||
- RDEpubReaderView (from `../Sources/RDEpubReaderView`)
|
- RDEpubReaderView (from `../Sources/RDEpubReaderView`)
|
||||||
|
- RDEpubReaderView/AI (from `../Sources/RDEpubReaderView`)
|
||||||
|
- RDEpubReaderView/Speech (from `../Sources/RDEpubReaderView`)
|
||||||
- RDPDFReaderView (from `../Sources/RDPDFReaderView`)
|
- RDPDFReaderView (from `../Sources/RDPDFReaderView`)
|
||||||
|
- RDPDFReaderView/AI (from `../Sources/RDPDFReaderView`)
|
||||||
|
- RDPDFReaderView/Speech (from `../Sources/RDPDFReaderView`)
|
||||||
|
- RDSpeechReaderView/AI (from `../Sources/RDSpeechReaderView`)
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
trunk:
|
trunk:
|
||||||
@@ -37,19 +78,25 @@ SPEC REPOS:
|
|||||||
- ZIPFoundation
|
- ZIPFoundation
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
|
RDAIReaderView:
|
||||||
|
:path: "../Sources/RDAIReaderView"
|
||||||
RDEpubReaderView:
|
RDEpubReaderView:
|
||||||
:path: "../Sources/RDEpubReaderView"
|
:path: "../Sources/RDEpubReaderView"
|
||||||
RDPDFReaderView:
|
RDPDFReaderView:
|
||||||
:path: "../Sources/RDPDFReaderView"
|
:path: "../Sources/RDPDFReaderView"
|
||||||
|
RDSpeechReaderView:
|
||||||
|
:path: "../Sources/RDSpeechReaderView"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
||||||
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
||||||
RDEpubReaderView: 5a09a7856f04b6f2f8610aa6fd965d35cadb8e17
|
RDAIReaderView: 56783c71853c59bf6810f719a57a7f580eb7855c
|
||||||
RDPDFReaderView: f7c2d0ea7c129aa4ab15ae7f9daad2974573d749
|
RDEpubReaderView: 180444055ba2da2c52797cc08b307232f103a6d9
|
||||||
|
RDPDFReaderView: 885be07e2b81cd0a8dc8a937b75faf9017ce045c
|
||||||
|
RDSpeechReaderView: 25fff9437d2692965ba326f37b108baf3e2ae7ef
|
||||||
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
||||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||||
|
|
||||||
PODFILE CHECKSUM: c39db27dbc9c6a96a33f22e78ce1b22dcd48d75f
|
PODFILE CHECKSUM: 7a0e6ee6c7d410a5128fb20775a450fd0d47292c
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"name": "RDAIReaderView",
|
||||||
|
"module_name": "RDAIReaderView",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"summary": "Local-first AI indexing and retrieval primitives for ReadViewSDK readers",
|
||||||
|
"platforms": {
|
||||||
|
"ios": "15.0"
|
||||||
|
},
|
||||||
|
"swift_versions": [
|
||||||
|
"5.10"
|
||||||
|
],
|
||||||
|
"homepage": "https://example.invalid/RDAIReaderView",
|
||||||
|
"authors": {
|
||||||
|
"readoor": "ios@touchread.com"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"path": "."
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"requires_arc": true,
|
||||||
|
"subspecs": [
|
||||||
|
{
|
||||||
|
"name": "Core",
|
||||||
|
"source_files": "Core/**/*.swift",
|
||||||
|
"frameworks": "CryptoKit",
|
||||||
|
"libraries": "sqlite3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NaturalLanguage",
|
||||||
|
"source_files": "NaturalLanguage/**/*.swift",
|
||||||
|
"dependencies": {
|
||||||
|
"RDAIReaderView/Core": [
|
||||||
|
"~> 0.1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"frameworks": "NaturalLanguage"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "FoundationModels",
|
||||||
|
"source_files": "FoundationModels/**/*.swift",
|
||||||
|
"dependencies": {
|
||||||
|
"RDAIReaderView/Core": [
|
||||||
|
"~> 0.1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"frameworks": "FoundationModels"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "UI",
|
||||||
|
"source_files": "UI/**/*.swift",
|
||||||
|
"dependencies": {
|
||||||
|
"RDAIReaderView/Core": [
|
||||||
|
"~> 0.1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"frameworks": "UIKit"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"swift_version": "5.10"
|
||||||
|
}
|
||||||
+24
-1
@@ -18,7 +18,7 @@
|
|||||||
"tag": "0.0.2"
|
"tag": "0.0.2"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"source_files": "**/*.swift",
|
"source_files": "{DocumentFormats,EPUBCore,EPUBTextRendering,EPUBUI,ReaderView}/**/*.swift",
|
||||||
"resource_bundles": {
|
"resource_bundles": {
|
||||||
"RDEpubReaderViewAssets": [
|
"RDEpubReaderViewAssets": [
|
||||||
"EPUBCore/Resources/**/*"
|
"EPUBCore/Resources/**/*"
|
||||||
@@ -36,5 +36,28 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"requires_arc": true,
|
"requires_arc": true,
|
||||||
|
"subspecs": [
|
||||||
|
{
|
||||||
|
"name": "Speech",
|
||||||
|
"source_files": "Speech/**/*.swift",
|
||||||
|
"dependencies": {
|
||||||
|
"RDSpeechReaderView": [
|
||||||
|
"~> 0.1"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AI",
|
||||||
|
"source_files": "AI/**/*.swift",
|
||||||
|
"dependencies": {
|
||||||
|
"RDAIReaderView/NaturalLanguage": [
|
||||||
|
"~> 0.1"
|
||||||
|
],
|
||||||
|
"RDAIReaderView/UI": [
|
||||||
|
"~> 0.1"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"swift_version": "5.10"
|
"swift_version": "5.10"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,5 +29,28 @@
|
|||||||
"PDFKit"
|
"PDFKit"
|
||||||
],
|
],
|
||||||
"requires_arc": true,
|
"requires_arc": true,
|
||||||
|
"subspecs": [
|
||||||
|
{
|
||||||
|
"name": "Speech",
|
||||||
|
"source_files": "Speech/**/*.swift",
|
||||||
|
"dependencies": {
|
||||||
|
"RDSpeechReaderView": [
|
||||||
|
"~> 0.1"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AI",
|
||||||
|
"source_files": "AI/**/*.swift",
|
||||||
|
"dependencies": {
|
||||||
|
"RDAIReaderView/NaturalLanguage": [
|
||||||
|
"~> 0.1"
|
||||||
|
],
|
||||||
|
"RDAIReaderView/UI": [
|
||||||
|
"~> 0.1"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"swift_version": "5.10"
|
"swift_version": "5.10"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "RDSpeechReaderView",
|
||||||
|
"module_name": "RDSpeechReaderView",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"summary": "Text-to-speech playback primitives for ReadViewSDK readers",
|
||||||
|
"platforms": {
|
||||||
|
"ios": "15.0"
|
||||||
|
},
|
||||||
|
"swift_versions": [
|
||||||
|
"5.10"
|
||||||
|
],
|
||||||
|
"homepage": "https://example.invalid/RDSpeechReaderView",
|
||||||
|
"authors": {
|
||||||
|
"readoor": "ios@touchread.com"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"path": "."
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"source_files": "Sources/*.swift",
|
||||||
|
"frameworks": [
|
||||||
|
"AVFAudio",
|
||||||
|
"NaturalLanguage",
|
||||||
|
"MediaPlayer"
|
||||||
|
],
|
||||||
|
"requires_arc": true,
|
||||||
|
"subspecs": [
|
||||||
|
{
|
||||||
|
"name": "AI",
|
||||||
|
"source_files": "AIBridge/**/*.swift",
|
||||||
|
"dependencies": {
|
||||||
|
"RDAIReaderView/Core": [
|
||||||
|
"~> 0.1"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"swift_version": "5.10"
|
||||||
|
}
|
||||||
Generated
+50
-3
@@ -16,18 +16,59 @@ PODS:
|
|||||||
- DTFoundation/Core
|
- DTFoundation/Core
|
||||||
- DTFoundation/UIKit (1.7.19):
|
- DTFoundation/UIKit (1.7.19):
|
||||||
- DTFoundation/Core
|
- DTFoundation/Core
|
||||||
|
- RDAIReaderView/Core (0.1.0)
|
||||||
|
- RDAIReaderView/FoundationModels (0.1.0):
|
||||||
|
- RDAIReaderView/Core (~> 0.1)
|
||||||
|
- RDAIReaderView/NaturalLanguage (0.1.0):
|
||||||
|
- RDAIReaderView/Core (~> 0.1)
|
||||||
|
- RDAIReaderView/UI (0.1.0):
|
||||||
|
- RDAIReaderView/Core (~> 0.1)
|
||||||
- RDEpubReaderView (0.0.2):
|
- RDEpubReaderView (0.0.2):
|
||||||
- DTCoreText (~> 1.6)
|
- DTCoreText (~> 1.6)
|
||||||
|
- RDEpubReaderView/AI (= 0.0.2)
|
||||||
|
- RDEpubReaderView/Speech (= 0.0.2)
|
||||||
|
- SnapKit (~> 5.7)
|
||||||
|
- ZIPFoundation (~> 0.9)
|
||||||
|
- RDEpubReaderView/AI (0.0.2):
|
||||||
|
- DTCoreText (~> 1.6)
|
||||||
|
- RDAIReaderView/NaturalLanguage (~> 0.1)
|
||||||
|
- RDAIReaderView/UI (~> 0.1)
|
||||||
|
- SnapKit (~> 5.7)
|
||||||
|
- ZIPFoundation (~> 0.9)
|
||||||
|
- RDEpubReaderView/Speech (0.0.2):
|
||||||
|
- DTCoreText (~> 1.6)
|
||||||
|
- RDSpeechReaderView (~> 0.1)
|
||||||
- SnapKit (~> 5.7)
|
- SnapKit (~> 5.7)
|
||||||
- ZIPFoundation (~> 0.9)
|
- ZIPFoundation (~> 0.9)
|
||||||
- RDPDFReaderView (0.0.1):
|
- RDPDFReaderView (0.0.1):
|
||||||
|
- RDPDFReaderView/AI (= 0.0.1)
|
||||||
|
- RDPDFReaderView/Speech (= 0.0.1)
|
||||||
- SnapKit (~> 5.7)
|
- SnapKit (~> 5.7)
|
||||||
|
- RDPDFReaderView/AI (0.0.1):
|
||||||
|
- RDAIReaderView/NaturalLanguage (~> 0.1)
|
||||||
|
- RDAIReaderView/UI (~> 0.1)
|
||||||
|
- SnapKit (~> 5.7)
|
||||||
|
- RDPDFReaderView/Speech (0.0.1):
|
||||||
|
- RDSpeechReaderView (~> 0.1)
|
||||||
|
- SnapKit (~> 5.7)
|
||||||
|
- RDSpeechReaderView (0.1.0):
|
||||||
|
- RDSpeechReaderView/AI (= 0.1.0)
|
||||||
|
- RDSpeechReaderView/AI (0.1.0):
|
||||||
|
- RDAIReaderView/Core (~> 0.1)
|
||||||
- SnapKit (5.7.1)
|
- SnapKit (5.7.1)
|
||||||
- ZIPFoundation (0.9.20)
|
- ZIPFoundation (0.9.20)
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
|
- RDAIReaderView/FoundationModels (from `../Sources/RDAIReaderView`)
|
||||||
|
- RDAIReaderView/NaturalLanguage (from `../Sources/RDAIReaderView`)
|
||||||
|
- RDAIReaderView/UI (from `../Sources/RDAIReaderView`)
|
||||||
- RDEpubReaderView (from `../Sources/RDEpubReaderView`)
|
- RDEpubReaderView (from `../Sources/RDEpubReaderView`)
|
||||||
|
- RDEpubReaderView/AI (from `../Sources/RDEpubReaderView`)
|
||||||
|
- RDEpubReaderView/Speech (from `../Sources/RDEpubReaderView`)
|
||||||
- RDPDFReaderView (from `../Sources/RDPDFReaderView`)
|
- RDPDFReaderView (from `../Sources/RDPDFReaderView`)
|
||||||
|
- RDPDFReaderView/AI (from `../Sources/RDPDFReaderView`)
|
||||||
|
- RDPDFReaderView/Speech (from `../Sources/RDPDFReaderView`)
|
||||||
|
- RDSpeechReaderView/AI (from `../Sources/RDSpeechReaderView`)
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
trunk:
|
trunk:
|
||||||
@@ -37,19 +78,25 @@ SPEC REPOS:
|
|||||||
- ZIPFoundation
|
- ZIPFoundation
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
|
RDAIReaderView:
|
||||||
|
:path: "../Sources/RDAIReaderView"
|
||||||
RDEpubReaderView:
|
RDEpubReaderView:
|
||||||
:path: "../Sources/RDEpubReaderView"
|
:path: "../Sources/RDEpubReaderView"
|
||||||
RDPDFReaderView:
|
RDPDFReaderView:
|
||||||
:path: "../Sources/RDPDFReaderView"
|
:path: "../Sources/RDPDFReaderView"
|
||||||
|
RDSpeechReaderView:
|
||||||
|
:path: "../Sources/RDSpeechReaderView"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
||||||
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
||||||
RDEpubReaderView: 5a09a7856f04b6f2f8610aa6fd965d35cadb8e17
|
RDAIReaderView: 56783c71853c59bf6810f719a57a7f580eb7855c
|
||||||
RDPDFReaderView: f7c2d0ea7c129aa4ab15ae7f9daad2974573d749
|
RDEpubReaderView: 180444055ba2da2c52797cc08b307232f103a6d9
|
||||||
|
RDPDFReaderView: 885be07e2b81cd0a8dc8a937b75faf9017ce045c
|
||||||
|
RDSpeechReaderView: 25fff9437d2692965ba326f37b108baf3e2ae7ef
|
||||||
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
||||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||||
|
|
||||||
PODFILE CHECKSUM: c39db27dbc9c6a96a33f22e78ce1b22dcd48d75f
|
PODFILE CHECKSUM: 7a0e6ee6c7d410a5128fb20775a450fd0d47292c
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
+2674
-2030
File diff suppressed because it is too large
Load Diff
+2
@@ -1,7 +1,9 @@
|
|||||||
${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh
|
${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh
|
||||||
${BUILT_PRODUCTS_DIR}/DTCoreText/DTCoreText.framework
|
${BUILT_PRODUCTS_DIR}/DTCoreText/DTCoreText.framework
|
||||||
${BUILT_PRODUCTS_DIR}/DTFoundation/DTFoundation.framework
|
${BUILT_PRODUCTS_DIR}/DTFoundation/DTFoundation.framework
|
||||||
|
${BUILT_PRODUCTS_DIR}/RDAIReaderView/RDAIReaderView.framework
|
||||||
${BUILT_PRODUCTS_DIR}/RDEpubReaderView/RDEpubReaderView.framework
|
${BUILT_PRODUCTS_DIR}/RDEpubReaderView/RDEpubReaderView.framework
|
||||||
${BUILT_PRODUCTS_DIR}/RDPDFReaderView/RDPDFReaderView.framework
|
${BUILT_PRODUCTS_DIR}/RDPDFReaderView/RDPDFReaderView.framework
|
||||||
|
${BUILT_PRODUCTS_DIR}/RDSpeechReaderView/RDSpeechReaderView.framework
|
||||||
${BUILT_PRODUCTS_DIR}/SnapKit/SnapKit.framework
|
${BUILT_PRODUCTS_DIR}/SnapKit/SnapKit.framework
|
||||||
${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework
|
${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework
|
||||||
+2
@@ -1,6 +1,8 @@
|
|||||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DTCoreText.framework
|
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DTCoreText.framework
|
||||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DTFoundation.framework
|
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DTFoundation.framework
|
||||||
|
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDAIReaderView.framework
|
||||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDEpubReaderView.framework
|
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDEpubReaderView.framework
|
||||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDPDFReaderView.framework
|
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDPDFReaderView.framework
|
||||||
|
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDSpeechReaderView.framework
|
||||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SnapKit.framework
|
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SnapKit.framework
|
||||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ZIPFoundation.framework
|
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ZIPFoundation.framework
|
||||||
+2
@@ -1,7 +1,9 @@
|
|||||||
${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh
|
${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh
|
||||||
${BUILT_PRODUCTS_DIR}/DTCoreText/DTCoreText.framework
|
${BUILT_PRODUCTS_DIR}/DTCoreText/DTCoreText.framework
|
||||||
${BUILT_PRODUCTS_DIR}/DTFoundation/DTFoundation.framework
|
${BUILT_PRODUCTS_DIR}/DTFoundation/DTFoundation.framework
|
||||||
|
${BUILT_PRODUCTS_DIR}/RDAIReaderView/RDAIReaderView.framework
|
||||||
${BUILT_PRODUCTS_DIR}/RDEpubReaderView/RDEpubReaderView.framework
|
${BUILT_PRODUCTS_DIR}/RDEpubReaderView/RDEpubReaderView.framework
|
||||||
${BUILT_PRODUCTS_DIR}/RDPDFReaderView/RDPDFReaderView.framework
|
${BUILT_PRODUCTS_DIR}/RDPDFReaderView/RDPDFReaderView.framework
|
||||||
|
${BUILT_PRODUCTS_DIR}/RDSpeechReaderView/RDSpeechReaderView.framework
|
||||||
${BUILT_PRODUCTS_DIR}/SnapKit/SnapKit.framework
|
${BUILT_PRODUCTS_DIR}/SnapKit/SnapKit.framework
|
||||||
${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework
|
${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework
|
||||||
+2
@@ -1,6 +1,8 @@
|
|||||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DTCoreText.framework
|
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DTCoreText.framework
|
||||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DTFoundation.framework
|
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DTFoundation.framework
|
||||||
|
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDAIReaderView.framework
|
||||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDEpubReaderView.framework
|
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDEpubReaderView.framework
|
||||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDPDFReaderView.framework
|
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDPDFReaderView.framework
|
||||||
|
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDSpeechReaderView.framework
|
||||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SnapKit.framework
|
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SnapKit.framework
|
||||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ZIPFoundation.framework
|
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ZIPFoundation.framework
|
||||||
+4
@@ -178,16 +178,20 @@ code_sign_if_enabled() {
|
|||||||
if [[ "$CONFIGURATION" == "Debug" ]]; then
|
if [[ "$CONFIGURATION" == "Debug" ]]; then
|
||||||
install_framework "${BUILT_PRODUCTS_DIR}/DTCoreText/DTCoreText.framework"
|
install_framework "${BUILT_PRODUCTS_DIR}/DTCoreText/DTCoreText.framework"
|
||||||
install_framework "${BUILT_PRODUCTS_DIR}/DTFoundation/DTFoundation.framework"
|
install_framework "${BUILT_PRODUCTS_DIR}/DTFoundation/DTFoundation.framework"
|
||||||
|
install_framework "${BUILT_PRODUCTS_DIR}/RDAIReaderView/RDAIReaderView.framework"
|
||||||
install_framework "${BUILT_PRODUCTS_DIR}/RDEpubReaderView/RDEpubReaderView.framework"
|
install_framework "${BUILT_PRODUCTS_DIR}/RDEpubReaderView/RDEpubReaderView.framework"
|
||||||
install_framework "${BUILT_PRODUCTS_DIR}/RDPDFReaderView/RDPDFReaderView.framework"
|
install_framework "${BUILT_PRODUCTS_DIR}/RDPDFReaderView/RDPDFReaderView.framework"
|
||||||
|
install_framework "${BUILT_PRODUCTS_DIR}/RDSpeechReaderView/RDSpeechReaderView.framework"
|
||||||
install_framework "${BUILT_PRODUCTS_DIR}/SnapKit/SnapKit.framework"
|
install_framework "${BUILT_PRODUCTS_DIR}/SnapKit/SnapKit.framework"
|
||||||
install_framework "${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework"
|
install_framework "${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework"
|
||||||
fi
|
fi
|
||||||
if [[ "$CONFIGURATION" == "Release" ]]; then
|
if [[ "$CONFIGURATION" == "Release" ]]; then
|
||||||
install_framework "${BUILT_PRODUCTS_DIR}/DTCoreText/DTCoreText.framework"
|
install_framework "${BUILT_PRODUCTS_DIR}/DTCoreText/DTCoreText.framework"
|
||||||
install_framework "${BUILT_PRODUCTS_DIR}/DTFoundation/DTFoundation.framework"
|
install_framework "${BUILT_PRODUCTS_DIR}/DTFoundation/DTFoundation.framework"
|
||||||
|
install_framework "${BUILT_PRODUCTS_DIR}/RDAIReaderView/RDAIReaderView.framework"
|
||||||
install_framework "${BUILT_PRODUCTS_DIR}/RDEpubReaderView/RDEpubReaderView.framework"
|
install_framework "${BUILT_PRODUCTS_DIR}/RDEpubReaderView/RDEpubReaderView.framework"
|
||||||
install_framework "${BUILT_PRODUCTS_DIR}/RDPDFReaderView/RDPDFReaderView.framework"
|
install_framework "${BUILT_PRODUCTS_DIR}/RDPDFReaderView/RDPDFReaderView.framework"
|
||||||
|
install_framework "${BUILT_PRODUCTS_DIR}/RDSpeechReaderView/RDSpeechReaderView.framework"
|
||||||
install_framework "${BUILT_PRODUCTS_DIR}/SnapKit/SnapKit.framework"
|
install_framework "${BUILT_PRODUCTS_DIR}/SnapKit/SnapKit.framework"
|
||||||
install_framework "${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework"
|
install_framework "${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework"
|
||||||
fi
|
fi
|
||||||
|
|||||||
+4
-4
@@ -1,12 +1,12 @@
|
|||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES
|
||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||||
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView/RDEpubReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView/RDPDFReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit/SnapKit.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2"
|
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView/RDAIReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView/RDEpubReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView/RDPDFReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView/RDSpeechReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit/SnapKit.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2"
|
||||||
LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks'
|
LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks'
|
||||||
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift $(SDKROOT)/usr/lib/swift
|
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift $(SDKROOT)/usr/lib/swift
|
||||||
OTHER_LDFLAGS = $(inherited) -l"swiftCoreGraphics" -l"xml2" -framework "CoreGraphics" -framework "CoreImage" -framework "CoreText" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "PDFKit" -framework "QuartzCore" -framework "RDEpubReaderView" -framework "RDPDFReaderView" -framework "SnapKit" -framework "Vision" -framework "ZIPFoundation"
|
OTHER_LDFLAGS = $(inherited) -l"sqlite3" -l"swiftCoreGraphics" -l"xml2" -framework "AVFAudio" -framework "CoreGraphics" -framework "CoreImage" -framework "CoreText" -framework "CryptoKit" -framework "DTCoreText" -framework "DTFoundation" -framework "FoundationModels" -framework "ImageIO" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "PDFKit" -framework "QuartzCore" -framework "RDAIReaderView" -framework "RDEpubReaderView" -framework "RDPDFReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "Vision" -framework "ZIPFoundation"
|
||||||
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||||
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
||||||
PODS_BUILD_DIR = ${BUILD_DIR}
|
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||||
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
||||||
|
|||||||
Generated
+4
-4
@@ -1,12 +1,12 @@
|
|||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES
|
||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||||
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView/RDEpubReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView/RDPDFReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit/SnapKit.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2"
|
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView/RDAIReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView/RDEpubReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView/RDPDFReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView/RDSpeechReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit/SnapKit.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2"
|
||||||
LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks'
|
LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks'
|
||||||
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift $(SDKROOT)/usr/lib/swift
|
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift $(SDKROOT)/usr/lib/swift
|
||||||
OTHER_LDFLAGS = $(inherited) -l"swiftCoreGraphics" -l"xml2" -framework "CoreGraphics" -framework "CoreImage" -framework "CoreText" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "PDFKit" -framework "QuartzCore" -framework "RDEpubReaderView" -framework "RDPDFReaderView" -framework "SnapKit" -framework "Vision" -framework "ZIPFoundation"
|
OTHER_LDFLAGS = $(inherited) -l"sqlite3" -l"swiftCoreGraphics" -l"xml2" -framework "AVFAudio" -framework "CoreGraphics" -framework "CoreImage" -framework "CoreText" -framework "CryptoKit" -framework "DTCoreText" -framework "DTFoundation" -framework "FoundationModels" -framework "ImageIO" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "PDFKit" -framework "QuartzCore" -framework "RDAIReaderView" -framework "RDEpubReaderView" -framework "RDPDFReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "Vision" -framework "ZIPFoundation"
|
||||||
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||||
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
||||||
PODS_BUILD_DIR = ${BUILD_DIR}
|
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||||
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
||||||
|
|||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>${PODS_DEVELOPMENT_LANGUAGE}</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>${EXECUTABLE_NAME}</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>${PRODUCT_BUNDLE_IDENTIFIER}</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>${PRODUCT_NAME}</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>FMWK</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>CFBundleSignature</key>
|
||||||
|
<string>????</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>${CURRENT_PROJECT_VERSION}</string>
|
||||||
|
<key>NSPrincipalClass</key>
|
||||||
|
<string></string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
@interface PodsDummy_RDAIReaderView : NSObject
|
||||||
|
@end
|
||||||
|
@implementation PodsDummy_RDAIReaderView
|
||||||
|
@end
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#ifdef __OBJC__
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
#else
|
||||||
|
#ifndef FOUNDATION_EXPORT
|
||||||
|
#if defined(__cplusplus)
|
||||||
|
#define FOUNDATION_EXPORT extern "C"
|
||||||
|
#else
|
||||||
|
#define FOUNDATION_EXPORT extern
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#ifdef __OBJC__
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
#else
|
||||||
|
#ifndef FOUNDATION_EXPORT
|
||||||
|
#if defined(__cplusplus)
|
||||||
|
#define FOUNDATION_EXPORT extern "C"
|
||||||
|
#else
|
||||||
|
#define FOUNDATION_EXPORT extern
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
|
||||||
|
FOUNDATION_EXPORT double RDAIReaderViewVersionNumber;
|
||||||
|
FOUNDATION_EXPORT const unsigned char RDAIReaderViewVersionString[];
|
||||||
|
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||||
|
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||||
|
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
|
||||||
|
OTHER_LDFLAGS = $(inherited) -l"sqlite3" -framework "CryptoKit" -framework "FoundationModels" -framework "NaturalLanguage" -framework "UIKit"
|
||||||
|
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
||||||
|
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||||
|
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
||||||
|
PODS_DEVELOPMENT_LANGUAGE = ${DEVELOPMENT_LANGUAGE}
|
||||||
|
PODS_ROOT = ${SRCROOT}
|
||||||
|
PODS_TARGET_SRCROOT = ${PODS_ROOT}/../../Sources/RDAIReaderView
|
||||||
|
PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = org.cocoapods.${PRODUCT_NAME:rfc1034identifier}
|
||||||
|
SKIP_INSTALL = YES
|
||||||
|
USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
framework module RDAIReaderView {
|
||||||
|
umbrella header "RDAIReaderView-umbrella.h"
|
||||||
|
|
||||||
|
export *
|
||||||
|
module * { export * }
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||||
|
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||||
|
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
|
||||||
|
OTHER_LDFLAGS = $(inherited) -l"sqlite3" -framework "CryptoKit" -framework "FoundationModels" -framework "NaturalLanguage" -framework "UIKit"
|
||||||
|
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
||||||
|
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||||
|
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
||||||
|
PODS_DEVELOPMENT_LANGUAGE = ${DEVELOPMENT_LANGUAGE}
|
||||||
|
PODS_ROOT = ${SRCROOT}
|
||||||
|
PODS_TARGET_SRCROOT = ${PODS_ROOT}/../../Sources/RDAIReaderView
|
||||||
|
PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = org.cocoapods.${PRODUCT_NAME:rfc1034identifier}
|
||||||
|
SKIP_INSTALL = YES
|
||||||
|
USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES
|
||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||||
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView
|
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView
|
||||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||||
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
|
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
|
||||||
OTHER_LDFLAGS = $(inherited) -framework "CoreGraphics" -framework "CoreText" -framework "DTCoreText" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "SnapKit" -framework "ZIPFoundation"
|
OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CoreGraphics" -framework "CoreText" -framework "CryptoKit" -framework "DTCoreText" -framework "FoundationModels" -framework "ImageIO" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "QuartzCore" -framework "RDAIReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "ZIPFoundation"
|
||||||
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
||||||
PODS_BUILD_DIR = ${BUILD_DIR}
|
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||||
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
||||||
|
|||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||||
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView
|
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView
|
||||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||||
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
|
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
|
||||||
OTHER_LDFLAGS = $(inherited) -framework "CoreGraphics" -framework "CoreText" -framework "DTCoreText" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "SnapKit" -framework "ZIPFoundation"
|
OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CoreGraphics" -framework "CoreText" -framework "CryptoKit" -framework "DTCoreText" -framework "FoundationModels" -framework "ImageIO" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "QuartzCore" -framework "RDAIReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "ZIPFoundation"
|
||||||
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
||||||
PODS_BUILD_DIR = ${BUILD_DIR}
|
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||||
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
||||||
|
|||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||||
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView
|
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView
|
||||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit"
|
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit"
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||||
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
|
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
|
||||||
OTHER_LDFLAGS = $(inherited) -framework "CoreImage" -framework "PDFKit" -framework "SnapKit" -framework "Vision"
|
OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CoreImage" -framework "CryptoKit" -framework "FoundationModels" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "PDFKit" -framework "RDAIReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "Vision"
|
||||||
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
||||||
PODS_BUILD_DIR = ${BUILD_DIR}
|
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||||
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
||||||
|
|||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||||
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView
|
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView
|
||||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit"
|
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit"
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||||
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
|
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
|
||||||
OTHER_LDFLAGS = $(inherited) -framework "CoreImage" -framework "PDFKit" -framework "SnapKit" -framework "Vision"
|
OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CoreImage" -framework "CryptoKit" -framework "FoundationModels" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "PDFKit" -framework "RDAIReaderView" -framework "RDSpeechReaderView" -framework "SnapKit" -framework "UIKit" -framework "Vision"
|
||||||
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
||||||
PODS_BUILD_DIR = ${BUILD_DIR}
|
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||||
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
||||||
|
|||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>${PODS_DEVELOPMENT_LANGUAGE}</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>${EXECUTABLE_NAME}</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>${PRODUCT_BUNDLE_IDENTIFIER}</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>${PRODUCT_NAME}</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>FMWK</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>CFBundleSignature</key>
|
||||||
|
<string>????</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>${CURRENT_PROJECT_VERSION}</string>
|
||||||
|
<key>NSPrincipalClass</key>
|
||||||
|
<string></string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
@interface PodsDummy_RDSpeechReaderView : NSObject
|
||||||
|
@end
|
||||||
|
@implementation PodsDummy_RDSpeechReaderView
|
||||||
|
@end
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#ifdef __OBJC__
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
#else
|
||||||
|
#ifndef FOUNDATION_EXPORT
|
||||||
|
#if defined(__cplusplus)
|
||||||
|
#define FOUNDATION_EXPORT extern "C"
|
||||||
|
#else
|
||||||
|
#define FOUNDATION_EXPORT extern
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#ifdef __OBJC__
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
#else
|
||||||
|
#ifndef FOUNDATION_EXPORT
|
||||||
|
#if defined(__cplusplus)
|
||||||
|
#define FOUNDATION_EXPORT extern "C"
|
||||||
|
#else
|
||||||
|
#define FOUNDATION_EXPORT extern
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
|
||||||
|
FOUNDATION_EXPORT double RDSpeechReaderViewVersionNumber;
|
||||||
|
FOUNDATION_EXPORT const unsigned char RDSpeechReaderViewVersionString[];
|
||||||
|
|
||||||
Generated
+16
@@ -0,0 +1,16 @@
|
|||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||||
|
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView
|
||||||
|
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView"
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||||
|
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
|
||||||
|
OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CryptoKit" -framework "FoundationModels" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "RDAIReaderView" -framework "UIKit"
|
||||||
|
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
||||||
|
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||||
|
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
||||||
|
PODS_DEVELOPMENT_LANGUAGE = ${DEVELOPMENT_LANGUAGE}
|
||||||
|
PODS_ROOT = ${SRCROOT}
|
||||||
|
PODS_TARGET_SRCROOT = ${PODS_ROOT}/../../Sources/RDSpeechReaderView
|
||||||
|
PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = org.cocoapods.${PRODUCT_NAME:rfc1034identifier}
|
||||||
|
SKIP_INSTALL = YES
|
||||||
|
USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
framework module RDSpeechReaderView {
|
||||||
|
umbrella header "RDSpeechReaderView-umbrella.h"
|
||||||
|
|
||||||
|
export *
|
||||||
|
module * { export * }
|
||||||
|
}
|
||||||
Generated
+16
@@ -0,0 +1,16 @@
|
|||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||||
|
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDSpeechReaderView
|
||||||
|
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/RDAIReaderView"
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||||
|
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
|
||||||
|
OTHER_LDFLAGS = $(inherited) -framework "AVFAudio" -framework "CryptoKit" -framework "FoundationModels" -framework "MediaPlayer" -framework "NaturalLanguage" -framework "RDAIReaderView" -framework "UIKit"
|
||||||
|
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
||||||
|
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||||
|
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
||||||
|
PODS_DEVELOPMENT_LANGUAGE = ${DEVELOPMENT_LANGUAGE}
|
||||||
|
PODS_ROOT = ${SRCROOT}
|
||||||
|
PODS_TARGET_SRCROOT = ${PODS_ROOT}/../../Sources/RDSpeechReaderView
|
||||||
|
PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = org.cocoapods.${PRODUCT_NAME:rfc1034identifier}
|
||||||
|
SKIP_INSTALL = YES
|
||||||
|
USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES
|
||||||
@@ -22,16 +22,6 @@
|
|||||||
1A2B3C4D00000030AABBCC01 /* FixedLayoutRotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000031AABBCC01 /* FixedLayoutRotationTests.swift */; };
|
1A2B3C4D00000030AABBCC01 /* FixedLayoutRotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000031AABBCC01 /* FixedLayoutRotationTests.swift */; };
|
||||||
1A2B3C4D00000033AABBCC01 /* PDFDrawingModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000032AABBCC01 /* PDFDrawingModeTests.swift */; };
|
1A2B3C4D00000033AABBCC01 /* PDFDrawingModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000032AABBCC01 /* PDFDrawingModeTests.swift */; };
|
||||||
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */; };
|
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */; };
|
||||||
369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */; };
|
|
||||||
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */; };
|
|
||||||
4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */; };
|
|
||||||
52A6A0C9941A4B5F9B88C6B0 /* ReaderAnnotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */; };
|
|
||||||
63B2852E59996922386A3111 /* AccessibilityIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00F495E17B90CCF1C7FE8C27 /* AccessibilityIdentifiers.swift */; };
|
|
||||||
9AAFB855EC7AC09D1E0B6742 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 697A5BCF0D93DBEC18E21A09 /* Foundation.framework */; };
|
|
||||||
A1C5E34F6B7192D400A1B234 /* PDFAnnotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C5E34F6B7192D400A1B235 /* PDFAnnotationTests.swift */; };
|
|
||||||
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; };
|
|
||||||
DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; };
|
|
||||||
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; };
|
|
||||||
2B3C4D5E00000001AABBCC01 /* BookmarkChromeStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E00000001AABBCC02 /* BookmarkChromeStateTests.swift */; };
|
2B3C4D5E00000001AABBCC01 /* BookmarkChromeStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E00000001AABBCC02 /* BookmarkChromeStateTests.swift */; };
|
||||||
2B3C4D5E00000002AABBCC01 /* BookmarkManagementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E00000002AABBCC02 /* BookmarkManagementTests.swift */; };
|
2B3C4D5E00000002AABBCC01 /* BookmarkManagementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E00000002AABBCC02 /* BookmarkManagementTests.swift */; };
|
||||||
2B3C4D5E00000003AABBCC01 /* ErrorAndEdgeCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E00000003AABBCC02 /* ErrorAndEdgeCaseTests.swift */; };
|
2B3C4D5E00000003AABBCC01 /* ErrorAndEdgeCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E00000003AABBCC02 /* ErrorAndEdgeCaseTests.swift */; };
|
||||||
@@ -44,6 +34,16 @@
|
|||||||
2B3C4D5E0000000AAABBCC01 /* SettingsEffectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E0000000AAABBCC02 /* SettingsEffectTests.swift */; };
|
2B3C4D5E0000000AAABBCC01 /* SettingsEffectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E0000000AAABBCC02 /* SettingsEffectTests.swift */; };
|
||||||
2B3C4D5E0000000BAABBCC01 /* TOCInteractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E0000000BAABBCC02 /* TOCInteractionTests.swift */; };
|
2B3C4D5E0000000BAABBCC01 /* TOCInteractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E0000000BAABBCC02 /* TOCInteractionTests.swift */; };
|
||||||
2B3C4D5E0000000CAABBCC01 /* ToolbarStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E0000000CAABBCC02 /* ToolbarStateTests.swift */; };
|
2B3C4D5E0000000CAABBCC01 /* ToolbarStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E0000000CAABBCC02 /* ToolbarStateTests.swift */; };
|
||||||
|
369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */; };
|
||||||
|
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */; };
|
||||||
|
4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */; };
|
||||||
|
52A6A0C9941A4B5F9B88C6B0 /* ReaderAnnotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */; };
|
||||||
|
63B2852E59996922386A3111 /* AccessibilityIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00F495E17B90CCF1C7FE8C27 /* AccessibilityIdentifiers.swift */; };
|
||||||
|
9AAFB855EC7AC09D1E0B6742 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 697A5BCF0D93DBEC18E21A09 /* Foundation.framework */; };
|
||||||
|
A1C5E34F6B7192D400A1B234 /* PDFAnnotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C5E34F6B7192D400A1B235 /* PDFAnnotationTests.swift */; };
|
||||||
|
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; };
|
||||||
|
DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; };
|
||||||
|
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -74,18 +74,6 @@
|
|||||||
1A2B3C4D00000032AABBCC01 /* PDFDrawingModeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PDFDrawingModeTests.swift; sourceTree = "<group>"; };
|
1A2B3C4D00000032AABBCC01 /* PDFDrawingModeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PDFDrawingModeTests.swift; sourceTree = "<group>"; };
|
||||||
201C2B482287866487EFAE66 /* SettingsPanelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsPanelTests.swift; sourceTree = "<group>"; };
|
201C2B482287866487EFAE66 /* SettingsPanelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsPanelTests.swift; sourceTree = "<group>"; };
|
||||||
20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderAnnotationTests.swift; sourceTree = "<group>"; };
|
20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderAnnotationTests.swift; sourceTree = "<group>"; };
|
||||||
3A43AED288BFCA3ADBA97DD7 /* Pods-ReadViewDemo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReadViewDemo.release.xcconfig"; path = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.release.xcconfig"; sourceTree = "<group>"; };
|
|
||||||
423988C9F7F09A1B78D97CC1 /* Pods-ReadViewDemo.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReadViewDemo.debug.xcconfig"; path = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.debug.xcconfig"; sourceTree = "<group>"; };
|
|
||||||
697A5BCF0D93DBEC18E21A09 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
|
|
||||||
74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "XCUIApplication+Launch.swift"; sourceTree = "<group>"; };
|
|
||||||
792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ReadViewDemo.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
8FFD606A5A1CBDCC3CA87F1C /* ReadViewDemoUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ReadViewDemoUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = "<group>"; };
|
|
||||||
A1C5E34F6B7192D400A1B235 /* PDFAnnotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PDFAnnotationTests.swift; sourceTree = "<group>"; };
|
|
||||||
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; };
|
|
||||||
CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchTests.swift; sourceTree = "<group>"; };
|
|
||||||
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
|
|
||||||
2B3C4D5E00000001AABBCC02 /* BookmarkChromeStateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookmarkChromeStateTests.swift; sourceTree = "<group>"; };
|
2B3C4D5E00000001AABBCC02 /* BookmarkChromeStateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookmarkChromeStateTests.swift; sourceTree = "<group>"; };
|
||||||
2B3C4D5E00000002AABBCC02 /* BookmarkManagementTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookmarkManagementTests.swift; sourceTree = "<group>"; };
|
2B3C4D5E00000002AABBCC02 /* BookmarkManagementTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookmarkManagementTests.swift; sourceTree = "<group>"; };
|
||||||
2B3C4D5E00000003AABBCC02 /* ErrorAndEdgeCaseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ErrorAndEdgeCaseTests.swift; sourceTree = "<group>"; };
|
2B3C4D5E00000003AABBCC02 /* ErrorAndEdgeCaseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ErrorAndEdgeCaseTests.swift; sourceTree = "<group>"; };
|
||||||
@@ -98,6 +86,18 @@
|
|||||||
2B3C4D5E0000000AAABBCC02 /* SettingsEffectTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsEffectTests.swift; sourceTree = "<group>"; };
|
2B3C4D5E0000000AAABBCC02 /* SettingsEffectTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsEffectTests.swift; sourceTree = "<group>"; };
|
||||||
2B3C4D5E0000000BAABBCC02 /* TOCInteractionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TOCInteractionTests.swift; sourceTree = "<group>"; };
|
2B3C4D5E0000000BAABBCC02 /* TOCInteractionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TOCInteractionTests.swift; sourceTree = "<group>"; };
|
||||||
2B3C4D5E0000000CAABBCC02 /* ToolbarStateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ToolbarStateTests.swift; sourceTree = "<group>"; };
|
2B3C4D5E0000000CAABBCC02 /* ToolbarStateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ToolbarStateTests.swift; sourceTree = "<group>"; };
|
||||||
|
3A43AED288BFCA3ADBA97DD7 /* Pods-ReadViewDemo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReadViewDemo.release.xcconfig"; path = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
423988C9F7F09A1B78D97CC1 /* Pods-ReadViewDemo.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReadViewDemo.debug.xcconfig"; path = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
697A5BCF0D93DBEC18E21A09 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
|
||||||
|
74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "XCUIApplication+Launch.swift"; sourceTree = "<group>"; };
|
||||||
|
792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ReadViewDemo.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
8FFD606A5A1CBDCC3CA87F1C /* ReadViewDemoUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ReadViewDemoUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = "<group>"; };
|
||||||
|
A1C5E34F6B7192D400A1B235 /* PDFAnnotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PDFAnnotationTests.swift; sourceTree = "<group>"; };
|
||||||
|
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; };
|
||||||
|
CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchTests.swift; sourceTree = "<group>"; };
|
||||||
|
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import UIKit
|
|||||||
import PDFKit
|
import PDFKit
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import RDPDFReaderView
|
import RDPDFReaderView
|
||||||
|
import RDAIReaderView
|
||||||
|
import RDSpeechReaderView
|
||||||
|
|
||||||
/// Demo 仅选择文字来源;正式宿主通常直接提供 PDF 解析结果或不提供以启用 OCR。
|
/// Demo 仅选择文字来源;正式宿主通常直接提供 PDF 解析结果或不提供以启用 OCR。
|
||||||
enum PDFDemoImageTextSource: String {
|
enum PDFDemoImageTextSource: String {
|
||||||
@@ -151,6 +153,15 @@ private final class PDFDemoPageProvider: RDPDFReaderPageProvider, RDPDFReaderOut
|
|||||||
final class PDFDemoReaderViewController: UIViewController, RDPDFReaderViewControllerDelegate {
|
final class PDFDemoReaderViewController: UIViewController, RDPDFReaderViewControllerDelegate {
|
||||||
private static let annotationStorageFolderName = "RDPDFImageReaderAnnotations"
|
private static let annotationStorageFolderName = "RDPDFImageReaderAnnotations"
|
||||||
private let reader: RDPDFReaderViewController
|
private let reader: RDPDFReaderViewController
|
||||||
|
private lazy var speechSession: RDPDFSpeechSession = {
|
||||||
|
let session = reader.makeSpeechSession()
|
||||||
|
session.onStateChange = { [weak self] state in
|
||||||
|
guard let self else { return }
|
||||||
|
self.speechControls.update(state: state, rate: self.speechSession.controller.configuration.rate)
|
||||||
|
}
|
||||||
|
return session
|
||||||
|
}()
|
||||||
|
private let speechControls = RDSpeechReaderControlView()
|
||||||
private var navigationBarHiddenBeforeReader: Bool?
|
private var navigationBarHiddenBeforeReader: Bool?
|
||||||
private var display = "pagecurl"
|
private var display = "pagecurl"
|
||||||
private var lastPageIndex = 0
|
private var lastPageIndex = 0
|
||||||
@@ -213,6 +224,15 @@ final class PDFDemoReaderViewController: UIViewController, RDPDFReaderViewContro
|
|||||||
reader.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
reader.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
view.addSubview(reader.view)
|
view.addSubview(reader.view)
|
||||||
reader.didMove(toParent: self)
|
reader.didMove(toParent: self)
|
||||||
|
configureSpeechControls()
|
||||||
|
let assistantButton = UIButton(type: .system)
|
||||||
|
assistantButton.setTitle("AI", for: .normal)
|
||||||
|
assistantButton.titleLabel?.font = .preferredFont(forTextStyle: .headline)
|
||||||
|
assistantButton.addTarget(self, action: #selector(presentAIAssistant), for: .touchUpInside)
|
||||||
|
assistantButton.accessibilityLabel = "打开阅读助手"
|
||||||
|
view.addSubview(assistantButton)
|
||||||
|
assistantButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([assistantButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), assistantButton.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), assistantButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 44), assistantButton.heightAnchor.constraint(equalToConstant: 44)])
|
||||||
stateLabel.translatesAutoresizingMaskIntoConstraints = false
|
stateLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.addSubview(stateLabel)
|
view.addSubview(stateLabel)
|
||||||
NSLayoutConstraint.activate([stateLabel.widthAnchor.constraint(equalToConstant: 1), stateLabel.heightAnchor.constraint(equalToConstant: 1), stateLabel.topAnchor.constraint(equalTo: view.topAnchor), stateLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor)])
|
NSLayoutConstraint.activate([stateLabel.widthAnchor.constraint(equalToConstant: 1), stateLabel.heightAnchor.constraint(equalToConstant: 1), stateLabel.topAnchor.constraint(equalTo: view.topAnchor), stateLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor)])
|
||||||
@@ -235,6 +255,14 @@ final class PDFDemoReaderViewController: UIViewController, RDPDFReaderViewContro
|
|||||||
display = type == .pageCurl ? "pagecurl" : (type == .verticalScroll ? "verticalscroll" : "horizontalscroll")
|
display = type == .pageCurl ? "pagecurl" : (type == .verticalScroll ? "verticalscroll" : "horizontalscroll")
|
||||||
updateState(page: 0)
|
updateState(page: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func presentAIAssistant() {
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
present(reader.makeAIReaderAssistant(scope: reader.aiCurrentReadScope(), generativeProvider: RDAIAppleFoundationModelsProvider()), animated: true)
|
||||||
|
} else {
|
||||||
|
present(reader.makeAIReaderAssistant(), animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
func goToDemoPage(_ pageNumber: Int) { reader.goToPage(max(pageNumber - 1, 0)) }
|
func goToDemoPage(_ pageNumber: Int) { reader.goToPage(max(pageNumber - 1, 0)) }
|
||||||
|
|
||||||
func pdfReaderViewControllerDidRequestClose(_ controller: RDPDFReaderViewController) {
|
func pdfReaderViewControllerDidRequestClose(_ controller: RDPDFReaderViewController) {
|
||||||
@@ -268,6 +296,57 @@ final class PDFDemoReaderViewController: UIViewController, RDPDFReaderViewContro
|
|||||||
stateLabel.text = state
|
stateLabel.text = state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func configureSpeechControls() {
|
||||||
|
speechControls.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(speechControls)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
speechControls.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
|
||||||
|
speechControls.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -18),
|
||||||
|
speechControls.heightAnchor.constraint(equalToConstant: 48)
|
||||||
|
])
|
||||||
|
speechControls.onTogglePlayback = { [weak self] in self?.toggleSpeechPlayback() }
|
||||||
|
speechControls.onPreviousSentence = { [weak self] in self?.speechSession.controller.skipToPreviousSentence() }
|
||||||
|
speechControls.onNextSentence = { [weak self] in self?.speechSession.controller.skipToNextSentence() }
|
||||||
|
speechControls.onStop = { [weak self] in self?.speechSession.stop() }
|
||||||
|
speechControls.onChangeRate = { [weak self] in self?.cycleSpeechRate() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleSpeechPlayback() {
|
||||||
|
switch speechSession.controller.state {
|
||||||
|
case .speaking:
|
||||||
|
speechSession.pause()
|
||||||
|
case .paused:
|
||||||
|
speechSession.resume()
|
||||||
|
case .preparing:
|
||||||
|
break
|
||||||
|
case .idle, .finished, .failed:
|
||||||
|
let pageIndex = max(lastPageIndex, 0)
|
||||||
|
Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
try await self.speechSession.start(from: pageIndex)
|
||||||
|
} catch {
|
||||||
|
self.presentSpeechError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cycleSpeechRate() {
|
||||||
|
let rates: [Float] = [0.38, 0.48, 0.58, 0.68]
|
||||||
|
let currentRate = speechSession.controller.configuration.rate
|
||||||
|
let nextIndex = (rates.firstIndex(where: { abs($0 - currentRate) < 0.01 }).map { ($0 + 1) % rates.count }) ?? 0
|
||||||
|
speechSession.controller.updateRate(rates[nextIndex])
|
||||||
|
speechControls.update(state: speechSession.controller.state, rate: rates[nextIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentSpeechError(_ error: Error) {
|
||||||
|
guard presentedViewController == nil else { return }
|
||||||
|
let alert = UIAlertController(title: "无法开始朗读", message: error.localizedDescription, preferredStyle: .alert)
|
||||||
|
alert.addAction(.init(title: "知道了", style: .default))
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
private static func annotationStorageURL(for bookIdentifier: String, legacyBookURL: URL) -> URL {
|
private static func annotationStorageURL(for bookIdentifier: String, legacyBookURL: URL) -> URL {
|
||||||
let root = annotationStorageRoot()
|
let root = annotationStorageRoot()
|
||||||
let hash = SHA256.hash(data: bookIdentifier.data(using: .utf8) ?? Data()).map { String(format: "%02x", $0) }.joined()
|
let hash = SHA256.hash(data: bookIdentifier.data(using: .utf8) ?? Data()).map { String(format: "%02x", $0) }.joined()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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] {
|
private func textHighlights(for page: RDEPUBTextPage) -> [RDEPUBHighlight] {
|
||||||
if let textBook,
|
if let textBook,
|
||||||
let chapterData = textBook.chapterData(for: page.href) {
|
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 {
|
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
|
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
|
(publication.resourceResolver.normalizedHref($0.location.href) ?? $0.location.href) == pageHref
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ extension RDEPUBReaderController {
|
|||||||
runtime.clearSelection()
|
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? {
|
public func bookmark(withID id: String) -> RDEPUBBookmark? {
|
||||||
runtime.bookmark(withID: id)
|
runtime.bookmark(withID: id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ extension RDEPUBReaderController {
|
|||||||
guard let publication else { return [] }
|
guard let publication else { return [] }
|
||||||
if let spread = page.fixedSpread {
|
if let spread = page.fixedSpread {
|
||||||
let hrefs = Set(spread.resources.compactMap { publication.resourceResolver.normalizedHref($0.href) })
|
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 {
|
guard let normalizedHref = publication.resourceResolver.normalizedHref(highlight.location.href) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ extension RDEPUBReaderController {
|
|||||||
guard publication.spine.indices.contains(page.spineIndex) else { return [] }
|
guard publication.spine.indices.contains(page.spineIndex) else { return [] }
|
||||||
let href = publication.spine[page.spineIndex].href
|
let href = publication.spine[page.spineIndex].href
|
||||||
let normalizedHref = publication.resourceResolver.normalizedHref(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
|
publication.resourceResolver.normalizedHref(highlight.location.href) == normalizedHref
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,11 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
set { readerContext.activeHighlights = newValue }
|
set { readerContext.activeHighlights = newValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var transientHighlights: [RDEPUBHighlight] {
|
||||||
|
get { readerContext.transientHighlights }
|
||||||
|
set { readerContext.transientHighlights = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
lazy var topToolView = runtime.makeTopToolView()
|
lazy var topToolView = runtime.makeTopToolView()
|
||||||
|
|
||||||
lazy var bottomToolView = runtime.makeBottomToolView()
|
lazy var bottomToolView = runtime.makeBottomToolView()
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ final class RDEPUBReaderContext {
|
|||||||
set { state.activeHighlights = newValue }
|
set { state.activeHighlights = newValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var transientHighlights: [RDEPUBHighlight] {
|
||||||
|
get { state.transientHighlights }
|
||||||
|
set { state.transientHighlights = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
var currentBookIdentifier: String? {
|
var currentBookIdentifier: String? {
|
||||||
get { state.currentBookIdentifier }
|
get { state.currentBookIdentifier }
|
||||||
set { state.currentBookIdentifier = newValue }
|
set { state.currentBookIdentifier = newValue }
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ final class RDEPUBReaderState {
|
|||||||
|
|
||||||
var activeHighlights: [RDEPUBHighlight] = []
|
var activeHighlights: [RDEPUBHighlight] = []
|
||||||
|
|
||||||
|
// Session-only decorations (for search/AI focus) are never persisted.
|
||||||
|
var transientHighlights: [RDEPUBHighlight] = []
|
||||||
|
|
||||||
var currentBookIdentifier: String?
|
var currentBookIdentifier: String?
|
||||||
|
|
||||||
var paginationToken = UUID()
|
var paginationToken = UUID()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Pod::Spec.new do |s|
|
|||||||
s.license = "MIT"
|
s.license = "MIT"
|
||||||
# This podspec lives inside Sources/RDEpubReaderView, so paths must be
|
# This podspec lives inside Sources/RDEpubReaderView, so paths must be
|
||||||
# relative to this directory when it is consumed as a local pod.
|
# 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 = {
|
s.resource_bundles = {
|
||||||
"RDEpubReaderViewAssets" => ["EPUBCore/Resources/**/*"]
|
"RDEpubReaderViewAssets" => ["EPUBCore/Resources/**/*"]
|
||||||
}
|
}
|
||||||
@@ -19,4 +19,15 @@ Pod::Spec.new do |s|
|
|||||||
s.dependency "DTCoreText", "~> 1.6"
|
s.dependency "DTCoreText", "~> 1.6"
|
||||||
s.dependency "SnapKit", "~> 5.7"
|
s.dependency "SnapKit", "~> 5.7"
|
||||||
s.requires_arc = true
|
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
|
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: " ", with: " ")
|
||||||
|
.replacingOccurrences(of: "&", with: "&")
|
||||||
|
.replacingOccurrences(of: "<", with: "<")
|
||||||
|
.replacingOccurrences(of: ">", with: ">")
|
||||||
|
.replacingOccurrences(of: "'", with: "'")
|
||||||
|
.replacingOccurrences(of: """, 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.dependency "SnapKit", "~> 5.7"
|
||||||
s.frameworks = "Vision", "CoreImage", "PDFKit"
|
s.frameworks = "Vision", "CoreImage", "PDFKit"
|
||||||
s.requires_arc = true
|
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
|
end
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public struct RDPDFReaderPageDescriptor {
|
|||||||
/// 断行、双栏或 OCR 合并后的文字块。
|
/// 断行、双栏或 OCR 合并后的文字块。
|
||||||
///
|
///
|
||||||
/// 所有矩形均以页面图片左上角为原点,x/y/width/height 都是 0...1 的比例值。
|
/// 所有矩形均以页面图片左上角为原点,x/y/width/height 都是 0...1 的比例值。
|
||||||
public struct RDPDFReaderTextRun: Equatable, Codable {
|
public struct RDPDFReaderTextRun: Equatable, Codable, Sendable {
|
||||||
public let text: String
|
public let text: String
|
||||||
public let normalizedRects: [CGRect]
|
public let normalizedRects: [CGRect]
|
||||||
/// 页内阅读顺序。SDK 在相同页的文本选择中依此排序。
|
/// 页内阅读顺序。SDK 在相同页的文本选择中依此排序。
|
||||||
@@ -70,7 +70,7 @@ public struct RDPDFReaderTextRun: Equatable, Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 标注的文字来源,用于让 UI 清楚地展示“宿主文本”“OCR”或“区域标注”的能力差异。
|
/// 标注的文字来源,用于让 UI 清楚地展示“宿主文本”“OCR”或“区域标注”的能力差异。
|
||||||
public enum RDPDFReaderAnnotationSource: String, Codable, Equatable {
|
public enum RDPDFReaderAnnotationSource: String, Codable, Equatable, Sendable {
|
||||||
/// 宿主直接提供的 PDF 解析文字坐标。
|
/// 宿主直接提供的 PDF 解析文字坐标。
|
||||||
case text
|
case text
|
||||||
/// SDK 从页面图片识别出的 OCR 文字坐标。
|
/// SDK 从页面图片识别出的 OCR 文字坐标。
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ public final class RDPDFReaderImageTextLayerView: UIView, UIGestureRecognizerDel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 当前朗读句的临时高亮。它不参与点击、复制或持久化,避免与用户标注混淆。
|
||||||
|
public var speechHighlightRects: [CGRect] = [] {
|
||||||
|
didSet { setNeedsDisplay() }
|
||||||
|
}
|
||||||
|
|
||||||
/// `textRuns` 的来源。主程序解析的文本使用 `.text`,SDK OCR 结果使用 `.ocr`。
|
/// `textRuns` 的来源。主程序解析的文本使用 `.text`,SDK OCR 结果使用 `.ocr`。
|
||||||
/// 区域框选始终使用 `.region`,不会受此属性影响。
|
/// 区域框选始终使用 `.region`,不会受此属性影响。
|
||||||
public var textSource: RDPDFReaderAnnotationSource = .text {
|
public var textSource: RDPDFReaderAnnotationSource = .text {
|
||||||
@@ -203,6 +208,7 @@ public final class RDPDFReaderImageTextLayerView: UIView, UIGestureRecognizerDel
|
|||||||
|
|
||||||
context.clear(rect)
|
context.clear(rect)
|
||||||
currentPageAnnotations.forEach { draw(annotation: $0, in: context) }
|
currentPageAnnotations.forEach { draw(annotation: $0, in: context) }
|
||||||
|
drawSpeechHighlight(in: context)
|
||||||
if let selectedSelection {
|
if let selectedSelection {
|
||||||
draw(selection: selectedSelection, in: context)
|
draw(selection: selectedSelection, in: context)
|
||||||
}
|
}
|
||||||
@@ -220,6 +226,14 @@ public final class RDPDFReaderImageTextLayerView: UIView, UIGestureRecognizerDel
|
|||||||
selectedSelection = nil
|
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? {
|
public func menuAnchorRect() -> CGRect? {
|
||||||
guard let selection = selectedSelection else { return nil }
|
guard let selection = selectedSelection else { return nil }
|
||||||
|
|||||||
@@ -143,6 +143,11 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
|
|||||||
updateAccessibilityViewport()
|
updateAccessibilityViewport()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shows a non-persistent highlight while the speech engine reads text.
|
||||||
|
public func setSpeechHighlightRects(_ rects: [CGRect]) {
|
||||||
|
textLayer.speechHighlightRects = rects
|
||||||
|
}
|
||||||
|
|
||||||
public func configureDrawing(
|
public func configureDrawing(
|
||||||
pageIndex: Int,
|
pageIndex: Int,
|
||||||
document: RDPDFReaderDrawingDocument,
|
document: RDPDFReaderDrawingDocument,
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
|||||||
public let annotationPersistence: RDPDFReaderAnnotationPersisting?
|
public let annotationPersistence: RDPDFReaderAnnotationPersisting?
|
||||||
public private(set) var configuration: Configuration
|
public private(set) var configuration: Configuration
|
||||||
|
|
||||||
|
public var currentPageIndex: Int { max(0, readerView.currentPage) }
|
||||||
|
|
||||||
private let readerView = RDPDFReaderView()
|
private let readerView = RDPDFReaderView()
|
||||||
private let recognizer: RDPDFReaderImageTextRecognizer
|
private let recognizer: RDPDFReaderImageTextRecognizer
|
||||||
private let ocrDiskCache: RDPDFReaderTextRunDiskCache?
|
private let ocrDiskCache: RDPDFReaderTextRunDiskCache?
|
||||||
@@ -50,6 +52,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
|||||||
private var recognizingPages = Set<Int>()
|
private var recognizingPages = Set<Int>()
|
||||||
/// 每页的逻辑请求标识。快速翻页取消旧请求后,迟到回调不能覆盖当前状态。
|
/// 每页的逻辑请求标识。快速翻页取消旧请求后,迟到回调不能覆盖当前状态。
|
||||||
private var ocrRequestTokens: [Int: UUID] = [:]
|
private var ocrRequestTokens: [Int: UUID] = [:]
|
||||||
|
private var speechHighlight: (pageIndex: Int, rects: [CGRect])?
|
||||||
private var bookmarks = Set<Int>()
|
private var bookmarks = Set<Int>()
|
||||||
private weak var topToolbar: RDPDFReaderKitTopToolView?
|
private weak var topToolbar: RDPDFReaderKitTopToolView?
|
||||||
private weak var bottomToolbar: RDPDFReaderKitBottomToolView?
|
private weak var bottomToolbar: RDPDFReaderKitBottomToolView?
|
||||||
@@ -188,6 +191,63 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
|||||||
readerView.transitionToPage(pageNum: pageIndex, animated: animated)
|
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) {
|
public func setDrawingMode(_ enabled: Bool) {
|
||||||
guard isDrawingMode != enabled else { return }
|
guard isDrawingMode != enabled else { return }
|
||||||
@@ -278,6 +338,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
|||||||
let runs = descriptor?.textRuns ?? ocrRuns[index] ?? []
|
let runs = descriptor?.textRuns ?? ocrRuns[index] ?? []
|
||||||
let source: RDPDFReaderAnnotationSource = descriptor?.textRuns != nil ? .text : (configuration.enablesOCR ? .ocr : configuration.missingTextSource)
|
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.configureTextLayer(pageIndex: index, textRuns: runs, textSource: source, annotations: annotations(for: index))
|
||||||
|
page.setSpeechHighlightRects(speechHighlight?.pageIndex == index ? speechHighlight?.rects ?? [] : [])
|
||||||
page.isDrawingSessionActive = isDrawingMode
|
page.isDrawingSessionActive = isDrawingMode
|
||||||
page.isDrawingMode = isDrawingMode && currentDrawingTool != nil
|
page.isDrawingMode = isDrawingMode && currentDrawingTool != nil
|
||||||
page.configureDrawing(
|
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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user