Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e59bad9abf | ||
|
|
278072aca0 | ||
|
|
3e60bf1869 | ||
|
|
e01cbc169d | ||
|
|
529663829f | ||
|
|
d830311894 | ||
|
|
735c14c824 | ||
|
|
85cf92161c | ||
|
|
9392027106 | ||
|
|
c61115cab3 | ||
|
|
95cead5863 | ||
|
|
68d9363f0a | ||
|
|
52f803e8db | ||
|
|
1422461224 | ||
|
|
a17164d1a9 | ||
|
|
1bde945d36 | ||
|
|
5a19cfde14 | ||
|
|
7c16647306 | ||
|
|
d56e99a83f | ||
|
|
fed34da246 | ||
|
|
72c1f8d89c | ||
|
|
822949f6cc | ||
|
|
6a0a1223b1 | ||
|
|
adc24d3f36 | ||
|
|
8ccb7157b2 | ||
|
|
063a493b18 | ||
|
|
d7fcda345d | ||
|
|
d5a7755702 | ||
|
|
83dfa40299 | ||
|
|
c4be426299 | ||
|
|
e4e629a06c | ||
|
|
5a41066b66 |
@@ -0,0 +1,90 @@
|
||||
---
|
||||
name: commit-git-changes
|
||||
description: 审查当前 Git 工作区,安全选择本次任务文件,生成包含问题背景、根因和修改方案的详细中文提交说明,并提交及按用户要求推送到 Git 服务器。默认不执行构建或测试;用户要求“提交代码”“提交并推送”“同步到 Git”“上传当前修改”或要求生成详细 commit message 时使用。
|
||||
---
|
||||
|
||||
# 提交 Git 改动
|
||||
|
||||
## 目标
|
||||
|
||||
在不混入无关修改、不泄露敏感信息、不改写远端历史的前提下,完成“审查—暂存—提交—推送—确认”闭环,并用提交说明解释为什么修改以及如何解决,而不只是罗列文件。
|
||||
|
||||
## 工作流
|
||||
|
||||
1. 读取仓库级 `AGENTS.md`、当前分支、远端和工作区状态。
|
||||
2. 检查暂存与未暂存差异:
|
||||
- 使用 `git status --short` 确认全部改动。
|
||||
- 使用 `git diff` 和 `git diff --cached` 理解真实行为变化。
|
||||
- 必要时查看相关源码和测试,不能仅依据文件名编写提交说明。
|
||||
3. 划定本次提交范围:
|
||||
- 只纳入当前用户任务直接相关的文件。
|
||||
- 保留用户已有或其他任务产生的无关修改。
|
||||
- 优先显式 `git add <path...>`;仅在确认所有改动均属本次提交时使用 `git add -A`。
|
||||
- 发现密钥、令牌、证书、个人配置、大型生成物或疑似敏感数据时停止提交并说明。
|
||||
4. 不默认执行构建、测试或项目级验证;仅在用户明确要求时执行。
|
||||
5. 暂存后再次检查:
|
||||
- 执行 `git diff --cached --check`。
|
||||
- 执行 `git diff --cached --stat` 和 `git diff --cached`。
|
||||
- 确认暂存区没有无关文件、调试残留和意外格式变化。
|
||||
6. 编写详细中文提交说明并创建提交。
|
||||
7. 用户要求提交到服务器或同步远端时推送:
|
||||
- 有 upstream 时推送当前分支。
|
||||
- 没有 upstream 且远端、目标分支明确时,使用 `git push -u <remote> <branch>`。
|
||||
- 远端或目标分支不明确时先询问,不猜测。
|
||||
- 禁止 force push;除非用户明确要求且风险已说明。
|
||||
8. 提交后确认提交哈希、提交标题、当前分支、推送结果和剩余未提交改动。
|
||||
|
||||
## 提交说明规范
|
||||
|
||||
提交说明使用“标题 + 空行 + 详细正文”的结构。
|
||||
|
||||
### 标题
|
||||
|
||||
- 用一句中文概括用户可感知的问题和修复结果。
|
||||
- 优先写清触发条件、异常表现和结果,例如:
|
||||
`修复无训练点普通课程完成后重进播放页回退为未完成`
|
||||
- 使用明确动词,如“修复”“新增”“调整”“重构”“移除”。
|
||||
- 不使用“修改代码”“优化问题”“更新若干内容”等空泛描述。
|
||||
- 保持单行,不以句号结尾,不添加无依据的工单号或模块前缀。
|
||||
|
||||
### 正文
|
||||
|
||||
用完整段落解释以下内容:
|
||||
|
||||
1. **问题背景与影响**:什么场景触发、用户看到什么、影响哪些路径。
|
||||
2. **根因**:原有数据流、状态条件或实现约束为什么导致问题。
|
||||
3. **修改方案**:关键行为如何改变,为什么选择该方案,必要时说明兼容和边界处理。
|
||||
|
||||
正文应描述行为和因果关系,不要把 `git diff --stat` 改写成文件清单。多个紧密相关的修改可以分段说明,但不要堆砌逐文件 bullet。
|
||||
|
||||
若用户明确要求并实际执行了构建、测试或其他验证,可以在正文末尾如实补充;未执行时不需要添加“验证:未执行”之类的说明。
|
||||
|
||||
### 示例
|
||||
|
||||
```text
|
||||
修复无训练点普通课程完成后重进播放页回退为未完成
|
||||
|
||||
课件完成态本地记录的保存不区分训练点,但读取端仅在存在
|
||||
trainPointModel 时才创建合并器,导致班级、训练和项目均为空的普通课程
|
||||
重进播放页后无法读取本地完成记录。
|
||||
|
||||
改为始终创建完成态合并器:无训练点时使用空维度合并;由于该场景没有
|
||||
服务端完成态可供对账,将本地记录作为唯一持久来源并跳过超期回收,保持
|
||||
重新进入播放页后的完成状态稳定。
|
||||
|
||||
```
|
||||
|
||||
根据真实改动改写示例内容,禁止照抄未发生的场景或验证结论。
|
||||
|
||||
## 提交与推送约束
|
||||
|
||||
- 不使用 `git reset --hard`、`git checkout --`、`git clean` 等破坏性命令处理工作区。
|
||||
- 不使用 `--amend`、rebase、历史改写或强制推送,除非用户明确授权。
|
||||
- 不跳过 hooks;hook 失败时先分析原因,不使用 `--no-verify` 绕过。
|
||||
- 提交失败后保留暂存区,修复可控问题并重试;不要重复创建等价提交。
|
||||
- 推送被拒绝时先获取并解释分支差异,不自动合并、变基或覆盖远端。
|
||||
- 若没有可提交差异,直接说明,不创建空提交。
|
||||
|
||||
## 交付
|
||||
|
||||
报告提交哈希和标题、推送的远端分支,以及仍留在工作区的未提交修改。若只完成本地提交而未推送,必须明确说明。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "提交 Git 代码"
|
||||
short_description: "安全审查改动并生成详细中文说明,完成 Git 提交与远端推送"
|
||||
default_prompt: "使用 $commit-git-changes 审查当前改动,生成详细中文提交说明并提交到 Git 服务器。"
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: discuss-sdk-feature-solution
|
||||
description: 讨论 ReadViewSDK 的新功能、修复或重构方案,核验 EPUB/PDF 阅读器源码,比较实现路径并收敛为可直接开发的计划。用户说“先讨论”“先给方案”“比较方案”“不要改代码”,或接口、缓存、渲染路径、持久化兼容存在会改变实现方式的灰区时使用;路径已明确且用户要求直接修改时不要使用。
|
||||
---
|
||||
|
||||
# SDK 功能方案讨论
|
||||
|
||||
## 目标
|
||||
|
||||
在编码前用仓库事实收敛决策,输出最小、可验证、能交给开发 Skill 直接执行的方案。本 Skill 默认不修改业务代码。
|
||||
|
||||
## 工作流
|
||||
|
||||
1. 检查工作区并定位目标模块、入口、相邻实现和对应文档。
|
||||
2. 区分已锁定决定、阻塞决定、实现假设、后续项与非范围。
|
||||
3. 按根目录 `AGENTS.md` 读取所需文档;结论必须由真实路径、类型、方法和调用链支撑。
|
||||
4. 方案未定时给出 2–4 个可行路径,比较改动面、公开 API、缓存/持久化、渲染路径、性能、回归和维护成本,并明确推荐一个。
|
||||
5. 用户已选定方案时只细化该方案,不继续横向发散。
|
||||
6. 方案至少覆盖:
|
||||
- EPUB:`.textReflowable`、`.webInteractive`、`.webFixedLayout` 中受影响的路径
|
||||
- PDF:宿主图片 Provider 与内置 PDFKit Provider 中受影响的路径
|
||||
- 公共 API、Codable/磁盘格式、缓存版本和 CocoaPods 集成影响
|
||||
- 异步乱序、取消、生命周期、内存压力和失败降级
|
||||
7. 收敛为文件级任务、数据/状态流、成功标准、验证方式、风险和回滚点。
|
||||
|
||||
## 输出
|
||||
|
||||
默认在对话中给出:
|
||||
|
||||
- 需求、范围与非范围
|
||||
- 已确认决定、阻塞项和必要假设
|
||||
- 方案对比或已选方案拆解
|
||||
- 涉及文件、调用链、复用点和兼容策略
|
||||
- 风险、回滚点与验证清单
|
||||
- 建议交给 `start-sdk-feature-dev-lite` 或 `start-sdk-feature-dev` 的执行摘要
|
||||
|
||||
用户要求落文档,或方案需要跨会话执行时,写入 `Doc/FeatureSolution/`,并在存在文档索引时同步 `Doc/index.md`。
|
||||
|
||||
## 边界
|
||||
|
||||
- 不为未确认的未来需求预埋抽象。
|
||||
- 必要改动、可选优化和后续治理必须分开。
|
||||
- 未核验的系统 API、第三方能力、文件格式或缓存行为不得写成事实。
|
||||
- 文档与源码冲突时以当前源码为事实,并明确指出文档待同步项。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "SDK 功能方案讨论"
|
||||
short_description: "核验当前仓库事实,比较实现路径并输出可直接开发的完整方案"
|
||||
default_prompt: "Use $discuss-sdk-feature-solution to compare implementation paths and produce an executable handoff."
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: generate-module-code-style-doc
|
||||
description: 基于真实源码生成或更新 ReadViewSDK 指定模块的代码组织、命名、分层、UI、状态、缓存和复用规范,适用于 EPUBCore、EPUBTextRendering、ReaderView、EPUBUI、RDPDFReaderView 或 Demo。全局规范使用 generate-sdk-code-style-doc。
|
||||
---
|
||||
|
||||
# 生成模块代码规范
|
||||
|
||||
## 工作流
|
||||
|
||||
1. 确定目标模块、关注范围和输出文件;优先增量更新 `Doc/` 中现有对应文档。
|
||||
2. 读取目标模块源码、对应 code reference、`Doc/CONVENTIONS.md` 和必要架构章节。
|
||||
3. 抽样入口、协议、模型、View/Cell、Controller、Coordinator/Service、缓存和 Extension;不存在的层级不要补造。
|
||||
4. 提炼真实且重复出现的模式,并区分当前约定、特殊例外及原因、尚未落地的建议。
|
||||
5. 记录目录职责、命名、可见性、依赖方向、状态管理、布局、回调、缓存、失败处理、公共 API 和跨模块边界。
|
||||
6. 对 EPUB 模块说明适用的渲染路径;对 PDF 模块说明自定义 Provider 与 PDFKit 路径差异。
|
||||
7. 增量更新文档,检查与全局规范、podspec、架构和源码是否冲突。
|
||||
8. 文档新增、移动或重命名时同步 `Doc/index.md`。
|
||||
|
||||
## 边界
|
||||
|
||||
- 只描述目标模块,不把局部模式提升为全局事实。
|
||||
- 不虚构理想分层、协议或不存在的调用链。
|
||||
- 默认不修改业务代码;发现问题时作为建议或 finding 单独列出。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "生成模块代码规范"
|
||||
short_description: "基于真实源码生成指定 EPUB、PDF 或 Demo 模块的局部规范文档"
|
||||
default_prompt: "Use $generate-module-code-style-doc to document the coding patterns of this ReadViewSDK module."
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: generate-module-implementation-logic
|
||||
description: 从 ReadViewSDK 真实源码生成或更新指定模块或场景的入口、调用链、数据流、状态流、渲染流、缓存、持久化、异步回调和异常分支文档。用户要求梳理实现逻辑、调用链、分页或缓存流程时使用;代码风格规范改用对应 code-style Skill。
|
||||
---
|
||||
|
||||
# 生成模块实现逻辑
|
||||
|
||||
## 工作流
|
||||
|
||||
1. 确定目标模块、用户场景和输出路径;优先复用 `Doc/` 中现有专题或 code reference。
|
||||
2. 读取目标入口、现有文档,以及 `AGENTS.md` 路由出的架构、API 或测试资料。
|
||||
3. 用引用搜索沿真实关系追踪:
|
||||
- 页面、控制器、协议或服务入口及触发条件
|
||||
- 数据模型、状态所有者和线程/队列
|
||||
- EPUB 解析/排版/分页/章节窗口,或 PDF Provider/渲染/OCR/标注
|
||||
- 内存与磁盘缓存、键、版本、驱逐和恢复
|
||||
- 通知、回调、delegate、持久化和跨模块依赖
|
||||
- 空值、失败、取消、重试、超时、迟到结果和兼容分支
|
||||
4. 对关键链路至少双向核验一次:从入口跟到结果,再从结果或回调查回调用方。
|
||||
5. 区分当前实现、历史兼容层、仅文档中的计划和待确认事实。
|
||||
6. 增量更新文档,保留正确内容,删除或标记失效链路。
|
||||
7. 结论引用真实文件、类型和方法;不确定信息明确标为待确认。
|
||||
8. 文档新增、移动或重命名时同步 `Doc/index.md`。
|
||||
|
||||
## 推荐结构
|
||||
|
||||
- 适用范围与模块职责
|
||||
- 入口和主要对象
|
||||
- 主流程与数据/状态/渲染变化
|
||||
- 缓存、持久化和并发模型
|
||||
- 异常、取消、重试和兼容处理
|
||||
- 维护风险与验证要点
|
||||
|
||||
纯文档任务不修改业务代码;源码暴露的缺陷应单独报告,不把建议写成已实现事实。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "生成模块实现逻辑"
|
||||
short_description: "梳理指定阅读器模块的入口、调用链、状态流、缓存及异常分支"
|
||||
default_prompt: "Use $generate-module-implementation-logic to document this module's verified implementation flow."
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: generate-sdk-code-style-doc
|
||||
description: 基于 ReadViewSDK 当前 EPUB、PDF 和 Demo 源码生成或更新全局代码规范、目录职责、命名、分层、UIKit/SnapKit、并发、缓存、公共 API 与测试约定。用户要求项目级编码规范或更新 Doc/CONVENTIONS.md 时使用;单模块规范改用 generate-module-code-style-doc。
|
||||
---
|
||||
|
||||
# 生成 SDK 全局代码规范
|
||||
|
||||
## 工作流
|
||||
|
||||
1. 明确输出路径;未指定时增量更新 `Doc/CONVENTIONS.md`。
|
||||
2. 读取现有目标文档、`Doc/ARCHITECTURE.md`、`Doc/index.md` 和两个 podspec。
|
||||
3. 从 EPUBCore、EPUBTextRendering、ReaderView、EPUBUI、RDPDFReaderView 与 Demo/UITests 抽样真实源码。
|
||||
4. 把结论分为:
|
||||
- 多模块源码验证的现行约定
|
||||
- 仅适用于 EPUB、PDF 或 Demo 的局部模式
|
||||
- 明确标记为建议、尚未成为项目事实的待统一项
|
||||
5. 覆盖目录职责、命名与可见性、公共 API、UIKit/SnapKit、异步与取消、缓存/持久化、错误处理、测试、注释和 CocoaPods 约定。
|
||||
6. 增量保留仍正确内容,删除失效描述;使用真实路径、类型和方法作为证据。
|
||||
7. 新增、移动或重命名文档时同步 `Doc/index.md`,最后检查链接和源码一致性。
|
||||
|
||||
## 边界
|
||||
|
||||
- 不把 EPUB 的局部模式提升为 PDF 的全局规范,反之亦然。
|
||||
- 不以旧 `.claude/skills` 或过期文档替代当前源码事实。
|
||||
- 不把个人偏好写成现行规范。
|
||||
- 文档任务默认不修改业务代码;发现缺陷时单独报告。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "生成 SDK 全局规范"
|
||||
short_description: "从当前 EPUB 与 PDF 源码证据生成或更新项目级代码规范文档"
|
||||
default_prompt: "Use $generate-sdk-code-style-doc to update the project-wide coding conventions from source evidence."
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: review-swift-ios-sdk
|
||||
description: 对 ReadViewSDK 的 Swift/iOS 代码进行专项审查,覆盖并发、主线程、生命周期、内存、缓存、渲染性能、可访问性、安全、公共 API 和持久化兼容。用户要求继续检查隐藏问题、代码审查、性能或内存审计时使用;普通功能开发和纯文档任务不要单独使用。
|
||||
---
|
||||
|
||||
# Swift/iOS SDK 专项审查
|
||||
|
||||
## 工作流
|
||||
|
||||
1. 明确审查范围和修改授权;只要求审查时不得修改代码。
|
||||
2. 检查工作区和最近改动,读取目标代码、相邻实现及 `AGENTS.md` 路由出的文档。
|
||||
3. 沿入口、异步回调、持久化和渲染结果双向追踪,不只检查单个函数。
|
||||
4. 仅报告能由当前源码证明、可触发且值得行动的问题。
|
||||
|
||||
## 检查维度
|
||||
|
||||
### 并发与生命周期
|
||||
|
||||
- UI 是否回到主线程,迟到结果是否会覆盖新状态。
|
||||
- token、重试、超时和取消是否保证恰好一次完成。
|
||||
- 闭包、Task、通知、定时器、观察者、WKWebView 和 Vision 请求是否释放。
|
||||
- PDFKit、缓存字典和持久化文件是否在正确队列或锁内访问。
|
||||
|
||||
### 性能与内存
|
||||
|
||||
- 滚动、缩放、绘画和分页热点是否包含同步 IO、重复解码或重复排版。
|
||||
- EPUB 章节窗口、页图缓存、PDF 页面位图和绘画缓存是否有明确边界。
|
||||
- 内存警告后可见内容能否恢复,驱逐后迟到回调是否重新撑大缓存。
|
||||
- 图片像素成本、NSCache 限额、autoreleasepool 和大对象生命周期是否合理。
|
||||
|
||||
### SDK 兼容性
|
||||
|
||||
- `public` API、协议要求、初始化方法和默认行为是否意外 breaking。
|
||||
- Codable、文件名、缓存键、book identifier 和 schema version 是否可迁移。
|
||||
- EPUB 三种渲染路径和 PDF 两种 Provider 路径是否行为一致。
|
||||
- podspec 是否包含新增源码、资源、系统 framework 和依赖。
|
||||
|
||||
### 交互、安全与可访问性
|
||||
|
||||
- 缩放锚点、Cell 复用、双页布局、手势竞争和绘画图层是否稳定。
|
||||
- 图标按钮、自定义控件、动态字体和 VoiceOver 语义是否完整。
|
||||
- 日志、文件路径和错误信息是否泄露敏感数据。
|
||||
|
||||
## 输出
|
||||
|
||||
按严重程度列出 findings;每条包含位置、触发条件、用户影响、根因和最小修复建议。没有发现问题时明确说明,并列出尚未覆盖的运行时验证空白。
|
||||
|
||||
若用户同时要求修改,沿用对应开发 Skill 的实现、验证和交付规则。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Swift/iOS SDK 专项审查"
|
||||
short_description: "审查并发、生命周期、性能、内存及公开 API 风险"
|
||||
default_prompt: "Use $review-swift-ios-sdk to audit this ReadViewSDK code for iOS-specific risks."
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: start-sdk-feature-dev-lite
|
||||
description: 实现 ReadViewSDK 的单模块功能、Bug 修复、UI 调整、局部重构、编译修复或小范围 CocoaPods 变更,并完成必要验证。作为默认开发入口;跨 EPUB/PDF 模块、改变公共协议或持久化格式、需要迁移回滚或严格执行完整方案时使用 start-sdk-feature-dev。
|
||||
---
|
||||
|
||||
# SDK 轻量开发
|
||||
|
||||
## 目标
|
||||
|
||||
用最小改动完成边界清晰的任务,形成“实现—验证—交付”闭环。
|
||||
|
||||
## 工作流
|
||||
|
||||
1. 检查工作区,识别并保留用户已有修改。
|
||||
2. 定位目标源码、现有相邻模式和 `AGENTS.md` 路由出的文档。
|
||||
3. 若用户提供方案,完整读取并执行已锁定决定;源码事实使方案不可行时先说明冲突。
|
||||
4. 明确 In Scope、Out of Scope、成功标准和短执行清单。
|
||||
5. 按模块边界实施最小修改:
|
||||
- EPUB 解析与资源:`Sources/RDEpubReaderView/EPUBCore`
|
||||
- EPUB 文本排版:`Sources/RDEpubReaderView/EPUBTextRendering`
|
||||
- EPUB 翻页容器:`Sources/RDEpubReaderView/ReaderView`
|
||||
- EPUB 成品 UI:`Sources/RDEpubReaderView/EPUBUI`
|
||||
- PDF 阅读器:`Sources/RDPDFReaderView`
|
||||
- 集成与回归入口:`ReadViewDemo`
|
||||
6. 自检空值、失败分支、异步乱序、取消语义、主线程、生命周期、Cell 复用、缓存边界、内存警告和可访问性。
|
||||
7. 检查公共 API、持久化模型、缓存键/版本、资源路径及 podspec 是否被意外改变。
|
||||
8. 按风险执行构建或定向 UI 测试;编译错误应修复并重试。
|
||||
9. 代码行为显著变化时更新最接近的 `Doc/` 文档。
|
||||
|
||||
## 升级条件
|
||||
|
||||
出现以下任一情况时切换到 `start-sdk-feature-dev` 并说明:
|
||||
|
||||
- 同时改变 EPUB 与 PDF 阅读器,或改变多个 EPUB 层级的职责。
|
||||
- 修改宿主可见协议、公共初始化方法或需要兼容迁移。
|
||||
- 改变持久化格式、缓存架构、分页主流程或跨模块状态流。
|
||||
- 用户要求逐项执行完整开发方案。
|
||||
|
||||
## 交付
|
||||
|
||||
最终简洁说明完成内容、关键取舍、主要文件、验证结果、未覆盖风险和同步文档;不强制套固定模板。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "SDK 轻量开发"
|
||||
short_description: "完成局部功能、Bug 修复、小范围重构及对应构建验证闭环"
|
||||
default_prompt: "Use $start-sdk-feature-dev-lite to implement this scoped ReadViewSDK change and verify it."
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: start-sdk-feature-dev
|
||||
description: 实现 ReadViewSDK 的跨层或跨阅读器改造、分页/缓存/持久化系统性重构、公共 API 演进、复杂联调,或严格执行用户提供的完整方案文档。单模块功能、UI 微调、Bug 修复和局部改动优先使用 start-sdk-feature-dev-lite。
|
||||
---
|
||||
|
||||
# SDK 完整开发
|
||||
|
||||
## 目标
|
||||
|
||||
对复杂任务建立可追溯计划,控制接口兼容、依赖、迁移和回滚风险,完成实现、联调、验证与文档同步。
|
||||
|
||||
## 工作流
|
||||
|
||||
1. 检查工作区,区分本任务改动和用户已有改动。
|
||||
2. 明确主改动模块、受影响模块、In Scope、Out of Scope、联调依赖和完成标准。
|
||||
3. 按 `AGENTS.md` 读取架构、公共 API、目标子系统和测试文档,并用源码复核。
|
||||
4. 用户提供方案时:
|
||||
- 完整读取并提取锁定决定、任务清单、文件清单、限制和验收标准。
|
||||
- 逐项执行,不自行替换技术路径、数据模型或文件布局。
|
||||
- 方案与源码冲突时暂停,说明影响和最小修订建议。
|
||||
5. 没有方案时制定最小可落地计划,明确执行顺序、数据/状态流、风险、回滚点和验证。
|
||||
6. 实施时区分计划内修改、不可避免的“必要补齐”和可选后续项;会改变方案的偏差先确认。
|
||||
7. 做跨模块自检:
|
||||
- EPUB 四层依赖方向和三种渲染路径
|
||||
- PDF 自定义 Provider 与 PDFKit 直读路径
|
||||
- 公共 API 的源码兼容与行为兼容
|
||||
- Codable/磁盘格式迁移、缓存键、版本和失效策略
|
||||
- 异步 token、取消、迟到回调、主线程和生命周期
|
||||
- 大图、排版产物、章节窗口和内存警告处理
|
||||
- CocoaPods source/resource/framework 配置和 Demo 集成
|
||||
8. 同步架构、API、子系统或测试文档;文档新增或移动时更新索引。
|
||||
9. 执行构建和与风险相称的定向回归,记录无法自动覆盖的联调项。
|
||||
|
||||
## 方案控制
|
||||
|
||||
- 用户确认的方案不能被“更简单的建议”静默覆盖。
|
||||
- 不以顺手治理为由扩大重构范围。
|
||||
- 涉及线上数据、缓存迁移或公共 API 时必须写明兼容、回滚或降级方式。
|
||||
- 新增依赖、资源或公开类型前必须确认 podspec 和宿主集成影响。
|
||||
|
||||
## 交付
|
||||
|
||||
最终说明范围和计划完成情况、关键实现与偏差、改动模块、构建/测试结果、兼容与回滚策略、未覆盖风险和同步文档。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "SDK 完整开发"
|
||||
short_description: "执行跨阅读器模块改造、复杂联调、兼容评估与完整验证交付"
|
||||
default_prompt: "Use $start-sdk-feature-dev to implement this cross-module ReadViewSDK plan with full verification."
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.agents/skills
|
||||
@@ -1,286 +0,0 @@
|
||||
---
|
||||
name: "Discuss SDK Feature Solution"
|
||||
description: "方案讨论优先 skill:用于 ReadViewSDK 新需求的实现方案分析、路径对比、取舍沟通、已定方案细化和实现交接。"
|
||||
argument-hint: "粘贴需求、目标模块、约束、已知备选方案;可附加:是否已确定方案 / 是否需要推荐方案 / 是否需要落方案文档。"
|
||||
---
|
||||
|
||||
# Discuss SDK Feature Solution
|
||||
|
||||
## Purpose
|
||||
|
||||
面向 ReadViewSDK 的"实现方案讨论入口" skill。
|
||||
用于在正式开发前,先把需求、约束、可选实现路径和关键取舍聊透;若方案已经确定,则只围绕已选方案继续细化,再交接给开发 skill 进入实现。
|
||||
|
||||
该 skill 借鉴 spec-driven 工作流思想:讨论阶段专门捕获灰区决策,计划阶段必须经过代码 / 文档事实核验,最终输出能直接喂给开发 skill 的结构化方案文档,避免"聊完还是不能开发"。
|
||||
|
||||
该 skill 的职责固定为:
|
||||
- 先理解需求与约束
|
||||
- 捕获灰区问题和实现假设
|
||||
- 用现有代码 / 文档核验方案可行性
|
||||
- 未定方案时:输出 2-4 个可行实现方案,并做对比
|
||||
- 已定方案时:只围绕确认方案展开实现讨论,不再继续扩展其他方案
|
||||
- 最后给出交接到开发 skill 的明确指令
|
||||
- 最终必须产出一份可直接供开发 skill 使用的开发详细文档,或一段可直接执行的开发详细描述
|
||||
|
||||
## Core Discussion Rules
|
||||
|
||||
- Rule 1 — Think Before Coding
|
||||
- 在提出方案前先核对代码和文档事实,不静默假设
|
||||
- 必须显式写出关键假设、主要取舍和已知风险
|
||||
- 遇到会改变实现路径的关键灰区,先提出并请求确认,不靠猜测补完整个方案
|
||||
- 若存在更简单且满足目标的实现路径,必须主动指出并优先推荐
|
||||
- Rule 2 — Simplicity First
|
||||
- 推荐方案优先选择最小可落地路径,而不是概念上更"完整"的设计
|
||||
- 不为了未来可能性预埋推测性扩展,不为单次使用引入抽象
|
||||
- 若某个方案明显过度设计,必须直接指出并解释为什么不推荐
|
||||
- Rule 3 — Surgical Changes
|
||||
- 方案只覆盖完成当前目标所必需的改动面
|
||||
- 不把无关重构、顺手统一风格或额外治理动作夹带进推荐方案
|
||||
- 若确有相邻改动依赖,必须明确标注"必要改动"与"可选优化"的边界
|
||||
- Rule 4 — Goal-Driven Execution
|
||||
- 讨论输出必须先定义成功标准和验证方式,再给实现交接建议
|
||||
- 不把讨论步骤本身当结果,重点是产出可执行、可验证的方案
|
||||
- 交接给开发 skill 时,必须让对方清楚"做到什么算完成"
|
||||
|
||||
## When To Use
|
||||
|
||||
当用户提出以下类型需求时使用:
|
||||
- "先讨论一下这个需求怎么实现"
|
||||
- "这个功能可能有多种实现方式,先分析方案"
|
||||
- "先别写代码,先帮我想实现路径"
|
||||
- "先比较几种方案,再决定怎么做"
|
||||
|
||||
适用场景:
|
||||
- 同一个需求可以通过多种技术路径实现
|
||||
- 需要权衡 SDK 分层(EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI)、复用、维护成本、回归影响
|
||||
- 需要在实现前先明确推荐方案和待确认细节
|
||||
- 已经明确采用某个方案,但还需要继续细化实现边界、拆解落地路径和交接开发
|
||||
|
||||
不适用场景:
|
||||
- 用户已经明确要直接实现,且实现路径基本单一时,优先使用开发类 skill
|
||||
- 用户只想生成文档,不需要方案讨论时,优先使用文档类 skill
|
||||
|
||||
## Inputs
|
||||
|
||||
推荐输入:
|
||||
- 需求描述
|
||||
- 目标模块或 Feature(属于哪一层:EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI / Legacy)
|
||||
- 已知约束(兼容性、性能、API 兼容、CocoaPods 发布等)
|
||||
- 已有备选方案(如有)
|
||||
- 已确认方案(如已决定)
|
||||
- 是否需要推荐方案
|
||||
- 是否需要最终落方案文档
|
||||
|
||||
若信息不足:
|
||||
- 先通过代码和文档补齐可发现事实
|
||||
- 再围绕真正影响方案选择的灰区进行澄清
|
||||
- 若用户不想继续问答,必须显式列出"实现假设",并把假设写入方案或交接摘要
|
||||
|
||||
若用户已经明确指定"采用方案 X / 就按这个方案做":
|
||||
- 视为进入"已定方案模式"
|
||||
- 后续输出禁止继续罗列其他候选方案、备选实现或横向对比
|
||||
- 仅允许在必要时补充"当前方案的风险、前提、边界和实现细化"
|
||||
|
||||
## Scope / Required Context
|
||||
|
||||
默认先读:
|
||||
1. `Doc/ARCHITECTURE.md` — 四层架构、数据流、分页模式、位置模型、已知限制
|
||||
2. `Doc/CODING_STYLE.md` — 命名规范(RD/RDEPUB 前缀)、分层规则、extension 拆分规则、SS→RD 迁移计划
|
||||
3. `Doc/EPUB_MAINTENANCE.md` — 文件职责表、DTCoreText 渲染管线、常见排查场景
|
||||
|
||||
按需再读:
|
||||
- 涉及 EPUB 解析时:`Sources/RDReaderView/EPUBCore/` 下相关文件
|
||||
- 涉及文本渲染时:`Sources/RDReaderView/EPUBTextRendering/` 下相关文件
|
||||
- 涉及阅读器容器时:`Sources/RDReaderView/RDReaderView.swift` 及同级文件
|
||||
- 涉及 UI 层时:`Sources/RDReaderView/EPUBUI/` 下相关文件
|
||||
- 涉及遗留代码时:`Sources/RDReaderView/LegacyRDReaderController/` 下相关文件
|
||||
- 涉及 JS 桥接时:`Sources/RDReaderView/Resources/epub-bridge.js`
|
||||
|
||||
若文档与代码不一致:
|
||||
- 以代码事实为准做方案讨论
|
||||
- 在推荐方案中指出不一致点和可能影响
|
||||
|
||||
方案文档落地目录约束:
|
||||
- 讨论型方案文档统一落到 `Doc/FeatureSolution/`(若目录不存在则创建)
|
||||
- 不要把方案讨论文档写入其他目录
|
||||
|
||||
## SDK-Specific Discussion Rules
|
||||
|
||||
讨论时必须考虑的 SDK 特有约束:
|
||||
|
||||
- **分层边界**:方案必须明确落在哪一层,不得跨层引入反向依赖(上层依赖下层允许,反之不允许)
|
||||
- **Public API 兼容性**:若改动涉及公共接口(`RDReaderView`、`RDReaderDataSource`、`RDURLReaderController`、`RDEPUBReaderController` 等),必须评估对宿主 App 的 breaking change 影响
|
||||
- **渲染路径差异**:方案必须考虑三种渲染路径(`webFixedLayout` / `webInteractive` / `textReflowable`)的适用性,不能只覆盖单一路径
|
||||
- **CocoaPods 发布影响**:若方案涉及新增依赖、资源文件或模块结构调整,必须评估 podspec 变更
|
||||
- **RD 前缀规范**:所有新增类型必须使用 `RD` 前缀,EPUB 相关使用 `RDEPUB` 前缀
|
||||
- **Legacy 层边界**:不在 Legacy 层新增功能;若方案涉及 Legacy 代码,必须明确是迁移还是在新层实现
|
||||
|
||||
## GSD-Inspired Discussion Rules
|
||||
|
||||
讨论阶段要像 `discuss -> plan -> verify` 闭环一样,先捕获决策,再验证计划,而不是直接跳到实现建议。
|
||||
|
||||
必须执行:
|
||||
- 灰区捕获:先识别布局、接口形态、数据结构、错误态、路由、持久化、兼容性、回归范围等不明确点
|
||||
- 事实核验:方案落地前必须用本仓库代码和 `Doc/` 文档确认入口、复用点、约束和风险
|
||||
- 假设显式化:不能确认的问题必须写成 `Assumption`,不得藏在方案正文里
|
||||
- 阻塞分级:会改变实现路径的问题标记为 `Blocking Decision`,不会改变路径的问题标记为 `Follow-up`
|
||||
- 审计留痕:若落方案文档,必须包含"讨论结论 / 关键决策 / 假设 / 非范围 / 验证清单 / 开发交接指令"
|
||||
- 计划可执行:最终方案必须足够小,能被开发 skill 直接逐项执行
|
||||
|
||||
禁止行为:
|
||||
- 不得只输出宽泛建议,必须落到文件、类、方法、数据流或协议层面的执行点
|
||||
- 不得在用户已确认方案后继续展开新方案,除非用户明确要求重新比较
|
||||
- 不得把未经核验的包、SDK、接口能力写成已确认事实
|
||||
- 不得把需要用户拍板的关键决策伪装成默认实现
|
||||
|
||||
## Required Workflow
|
||||
|
||||
### Phase 1 — 识别需求、范围、约束、现状
|
||||
|
||||
- 明确目标价值、影响模块(EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI / Legacy)、In Scope / Out of Scope
|
||||
- 识别当前项目里已有的实现模式、相邻能力和复用点
|
||||
- 判断这是单层需求还是跨层需求
|
||||
- 建立灰区清单,区分 `Blocking Decision` 与 `Follow-up`
|
||||
|
||||
### Phase 2 — 判断讨论模式
|
||||
|
||||
- 若方案未定:进入"多方案对比模式"
|
||||
- 若方案已定:进入"单方案细化模式"
|
||||
- 一旦用户已确认方案,后续同一轮讨论默认保持"单方案细化模式",除非用户明确要求重新打开方案比较
|
||||
|
||||
### Phase 3 — Research / Verify:核验代码与文档事实
|
||||
|
||||
- 对照当前仓库确认真实入口、调用链、数据模型、复用的 cell / view / handler / controller / protocol
|
||||
- 若方案涉及新增文件,必须确认所属目录层级和是否需要更新 podspec 的 source_files
|
||||
- 若方案涉及接口或数据字段,必须明确字段来源、空值策略和兼容策略
|
||||
- 若方案涉及 JS 桥接,必须确认 epub-bridge.js 的交互契约
|
||||
- 若发现文档和代码不一致,必须标注为风险或阻塞项
|
||||
- 若核验结果推翻原方案,必须暂停说明,不得继续包装成可执行方案
|
||||
|
||||
### Phase 4A — 多方案对比模式:列出多种实现路径
|
||||
|
||||
- 至少提出 2 个可行方案,推荐 2-4 个方案
|
||||
- 每个方案都要有清晰的实现方向,而不是抽象建议
|
||||
- 优先从现有项目模式和复用能力出发
|
||||
|
||||
### Phase 4B — 多方案对比模式:比较方案优缺点和影响范围
|
||||
|
||||
- 比较每个方案的:
|
||||
- 核心思路
|
||||
- 落在哪一层(EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI)
|
||||
- 适用前提
|
||||
- 优点
|
||||
- 风险 / 成本
|
||||
- 对 SDK 分层、Public API、podspec、回归范围的影响
|
||||
- 明确哪些差异会真正影响后续实现和维护
|
||||
|
||||
### Phase 5A — 多方案对比模式:提出推荐方案,并列出待确认细节
|
||||
|
||||
- 必须明确推荐一个默认方案,不能只平铺选项
|
||||
- 待确认项只保留真正影响实现的关键决策
|
||||
- 默认产物为对话输出;若用户明确要求,再可选落到 `Doc/FeatureSolution/` 下方案文档
|
||||
|
||||
### Phase 5B — 单方案细化模式:围绕确认方案展开
|
||||
|
||||
- 不再输出"方案 A / 方案 B / 方案 C"式内容
|
||||
- 不再补充"其他也可以这样做"的备选实现,除非用户明确要求回到方案比较
|
||||
- 只讨论以下内容:
|
||||
- 当前方案的实现拆解(文件 / 类 / 方法 / 协议)
|
||||
- SDK 分层内的模块边界与职责分配
|
||||
- 关键数据流 / 状态流 / 渲染流
|
||||
- 复用点、依赖点、回归点
|
||||
- 对三种渲染路径的覆盖情况
|
||||
- 风险、前提、灰度方式、验证要点
|
||||
- 交接给开发 skill 的实现摘要
|
||||
|
||||
### Phase 6 — 输出实现交接建议
|
||||
|
||||
- 在方案确认后,明确下一步应交给哪个开发 skill:
|
||||
- 小中型、单层、MVP 优先:轻量开发 skill
|
||||
- 跨层、复杂联调、完整交付:完整开发 skill
|
||||
- 无论是否落文档,最终都必须产出开发交接载体,且二选一不能缺失:
|
||||
- 完整开发 skill:产出可直接执行的开发详细文档,默认落到 `Doc/FeatureSolution/`
|
||||
- 轻量开发 skill:产出可直接粘贴执行的开发详细描述,覆盖实现目标、范围、代码落点、关键步骤、验收标准和自测要点
|
||||
- 给出可直接粘贴给开发 skill 的实现摘要;若已有更完整的详细文档/描述,则摘要必须引用它而不是只给一句话概括
|
||||
- 若落方案文档,必须让开发指令引用该文档的真实路径,并提醒开发 skill 严格按计划执行
|
||||
|
||||
## Output Contract
|
||||
|
||||
默认输出结构按模式区分:
|
||||
|
||||
若方案未定:
|
||||
- A. 需求理解
|
||||
- B. 灰区问题与实现假设
|
||||
- C. 当前实现与约束
|
||||
- D. 可选方案对比
|
||||
- E. 推荐方案
|
||||
- F. 待确认实现细节
|
||||
- G. 实现交接建议
|
||||
|
||||
若方案已定:
|
||||
- A. 需求理解
|
||||
- B. 灰区问题与实现假设
|
||||
- C. 当前实现与约束
|
||||
- D. 确认方案拆解
|
||||
- E. 实现风险与关键细节
|
||||
- F. 实现交接建议
|
||||
|
||||
其中要求:
|
||||
- 未定方案时:
|
||||
- `B. 灰区问题与实现假设` 必须区分 Blocking Decision / Follow-up / Assumption
|
||||
- `D. 可选方案对比` 至少给出 2 个方案,推荐 2-4 个
|
||||
- `E. 推荐方案` 必须明确推荐一个默认方案
|
||||
- `F. 待确认实现细节` 只保留会改变实现路径的关键问题
|
||||
- `G. 实现交接建议` 必须明确下一步交给哪个开发 skill
|
||||
- `G. 实现交接建议` 必须附带可供开发 skill 直接使用的交接内容:
|
||||
- 若下一步是完整开发 skill,必须提供开发详细文档路径或完整文档正文
|
||||
- 若下一步是轻量开发 skill,必须提供开发详细描述正文
|
||||
- 已定方案时:
|
||||
- `B. 灰区问题与实现假设` 必须列出阻塞项、假设和后续项
|
||||
- `D. 确认方案拆解` 只能围绕确认方案展开,禁止附带其他方案信息
|
||||
- `E. 实现风险与关键细节` 只讨论该方案落地所需信息
|
||||
- `F. 实现交接建议` 必须明确下一步交给哪个开发 skill,并附带可直接执行的详细文档或详细描述
|
||||
|
||||
若用户要求落方案文档,文档必须包含:
|
||||
- 需求目标
|
||||
- 讨论结论
|
||||
- 关键决策
|
||||
- 实现假设
|
||||
- 非范围
|
||||
- SDK 分层落点(文件 / 类 / 方法 / 协议)
|
||||
- 数据流 / 状态流 / 渲染流
|
||||
- 开发任务清单
|
||||
- 验收标准
|
||||
- 自测清单
|
||||
- 待确认项
|
||||
- 给开发 skill 的执行指令
|
||||
|
||||
若未落方案文档,但下一步要交给轻量开发 skill,则最终输出中的"开发详细描述"至少必须包含:
|
||||
- 实现目标
|
||||
- In Scope / Out of Scope
|
||||
- SDK 分层落点(文件 / 类 / 方法 / 协议)
|
||||
- 实现步骤
|
||||
- 关键约束与复用点
|
||||
- 对三种渲染路径的覆盖说明
|
||||
- 验收标准
|
||||
- 自测清单
|
||||
- 待确认项或实现假设
|
||||
|
||||
## Validation
|
||||
|
||||
- 本 skill 默认只做方案讨论,不直接进入代码实现
|
||||
- 默认不修改仓库文件;只有用户明确要求时,才可选落方案文档到 `Doc/FeatureSolution/`
|
||||
- 即使不落方案文档,最终回复也必须包含一份可直接交给开发 skill 使用的详细交接内容,不能只停留在高层方案结论
|
||||
- 若本次调用只做讨论或只新增方案文档,可不执行构建验证
|
||||
- 若新增或移动方案文档,建议同步更新目录索引
|
||||
- 若后续进入代码实现,则由对应开发 skill 负责执行项目默认构建验证(`xcodebuild` 或 CocoaPods 集成验证)
|
||||
- 若用户已确认方案,则默认进入单方案细化模式,除非用户明确要求重新比较备选方案
|
||||
|
||||
## Maintenance Rules
|
||||
|
||||
- 该 skill 的职责是"讨论后交接实现",不和开发 skill、文档类 skill 重叠
|
||||
- 优先复用 `Doc/` 作为知识源,不新增独立 references 体系
|
||||
- 方案文档目录固定为 `Doc/FeatureSolution/`
|
||||
- 持续保持 `discuss -> research/verify -> plan handoff` 的轻量闭环,避免退化成只聊天不交付的建议列表
|
||||
- 若该 skill 的默认输出结构、交接规则或适用边界调整,必须同步更新对应文档
|
||||
- SDK 四层架构(EPUBCore → EPUBTextRendering → RDReaderView → EPUBUI)是方案讨论的核心参照框架,任何方案都必须明确标注落点层级
|
||||
@@ -1,213 +0,0 @@
|
||||
---
|
||||
name: "Start SDK Feature Dev Lite"
|
||||
description: "轻量开发主控 skill:用于 ReadViewSDK 的小中型功能开发、单层改动、局部重构、文档同步与编译修复。"
|
||||
argument-hint: "粘贴需求、目标层级、验收标准;可附加:仅 MVP / 禁止新增依赖 / 指定渲染路径。"
|
||||
---
|
||||
|
||||
# Start SDK Feature Dev Lite
|
||||
|
||||
## Purpose
|
||||
|
||||
面向 ReadViewSDK 的轻量开发入口 skill。
|
||||
用于在现有项目约束下完成"小到中型"需求,遵循"先识别范围、再按需读文档、后实现、再验证、最后交付"的闭环。
|
||||
|
||||
该 skill 是默认开发入口,优先覆盖:
|
||||
- 单层内的新功能 MVP 实现
|
||||
- 小范围重构或代码整理
|
||||
- 文档同步更新
|
||||
- 编译错误定位与修复
|
||||
- 单个模块的 bug 修复或行为调整
|
||||
- podspec 小幅调整
|
||||
|
||||
默认吸收项目 `Doc/CODING_STYLE.md` 中的 Swift/iOS 通用规则;若任务重点落在并发、性能、可访问性或安全,再额外展开该专项的检查项。
|
||||
|
||||
## Core Execution Rules
|
||||
|
||||
- Rule 1 — Think Before Coding
|
||||
- 先核对代码和文档事实,不静默假设
|
||||
- 必须显式写出关键假设、主要取舍和已知风险
|
||||
- 遇到会改变实现路径的关键灰区,先暂停确认,不靠猜测继续推进
|
||||
- 若存在更简单且满足目标的实现路径,必须主动指出并优先采用
|
||||
- Rule 2 — Simplicity First
|
||||
- 只做满足需求和验收标准的最小实现
|
||||
- 不增加推测性功能,不为单次使用引入抽象
|
||||
- 若方案让资深工程师也会觉得过度设计,必须继续简化
|
||||
- Rule 3 — Surgical Changes
|
||||
- 只修改完成当前需求所必需的文件和代码
|
||||
- 不顺手重构无关代码,不扩散到相邻层做"顺便优化"
|
||||
- 非必要不改注释、格式或既有结构,新增代码保持现有风格
|
||||
- Rule 4 — Goal-Driven Execution
|
||||
- 先定义成功标准,再围绕成功标准实施和验证
|
||||
- 不给自己堆步骤,重点是闭环达到验收结果
|
||||
- 修改后必须验证,未验证通过前不能视为完成
|
||||
|
||||
## When To Use
|
||||
|
||||
当用户提出以下类型需求时使用:
|
||||
- "使用 start-sdk-feature-dev-lite 完成这个功能"
|
||||
- 在 ReadViewSDK 中开发单一功能或单层变更
|
||||
- 需要修复局部编译错误或行为问题
|
||||
- 需要同步更新文档
|
||||
- 需要小幅调整 podspec
|
||||
|
||||
不适用场景:
|
||||
- 需求跨多个 SDK 层级、涉及多阶段联调或需要完整项目级方案时,改用 `start-sdk-feature-dev`
|
||||
- 目标是只生成文档而不改业务代码时,优先使用文档类 skill
|
||||
- 目标是方案讨论而不进入实现时,优先使用 `discuss-sdk-feature-solution`
|
||||
|
||||
## Inputs
|
||||
|
||||
推荐输入:
|
||||
- 功能名称
|
||||
- 目标 / 用户价值
|
||||
- 范围(In Scope)
|
||||
- 非范围(Out of Scope)
|
||||
- 目标层级(EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI)
|
||||
- 详细需求
|
||||
- 验收标准
|
||||
- 约束(兼容性 / 性能 / 禁止新增依赖 / 指定渲染路径)
|
||||
|
||||
若信息不足:
|
||||
- 先基于代码和文档补齐可发现事实
|
||||
- 再列最多 5 条关键假设
|
||||
- 基于假设继续推进 MVP,并在交付中标注待确认项
|
||||
|
||||
## Scope / Required Context
|
||||
|
||||
仅针对 ReadViewSDK 现有架构执行。
|
||||
|
||||
SDK 四层架构参照:
|
||||
- Layer 1 — EPUBCore(`Sources/RDReaderView/EPUBCore/`):EPUB 解析引擎
|
||||
- Layer 2 — EPUBTextRendering(`Sources/RDReaderView/EPUBTextRendering/`):文本渲染路径
|
||||
- Layer 3 — RDReaderView(`Sources/RDReaderView/` 根目录):分页阅读器容器
|
||||
- Layer 4 — EPUBUI(`Sources/RDReaderView/EPUBUI/`):开箱即用 UI
|
||||
- Legacy(`Sources/RDReaderView/LegacyRDReaderController/`):遗留代码,不在其上新增功能
|
||||
|
||||
分层依赖规则(上→下允许,下→上禁止):
|
||||
- EPUBUI → RDReaderView → EPUBTextRendering → EPUBCore
|
||||
|
||||
默认先读:
|
||||
1. `Doc/ARCHITECTURE.md` — 四层架构、数据流、分页模式、位置模型
|
||||
2. `Doc/CODING_STYLE.md` — 命名规范、分层规则、extension 拆分规则
|
||||
3. `Doc/EPUB_MAINTENANCE.md` — 文件职责表、渲染管线、排查场景
|
||||
|
||||
按需再读:
|
||||
- `Doc/FeatureSolution/*.md`(若有方案文档)
|
||||
- 涉及哪一层就读该层目录下的相关源码
|
||||
- 涉及 JS 桥接时:`Sources/RDReaderView/Resources/epub-bridge.js`
|
||||
- 涉及 podspec 时:`RDReaderView.podspec`
|
||||
|
||||
若文档与代码不一致:
|
||||
- 以代码事实为准完成本次实现
|
||||
- 在交付中标注不一致点,并指出建议更新的文档
|
||||
|
||||
## Required Workflow
|
||||
|
||||
### Phase 1 — 识别需求和范围
|
||||
|
||||
- 明确目标价值、影响层级(EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI)、In Scope / Out of Scope
|
||||
- 优先做最小可落地实现,不做需求外重构
|
||||
- 若需求信息不足,先通过仓库事实补齐,再基于假设推进
|
||||
|
||||
### Phase 2 — 按需读取 Doc 与代码事实
|
||||
|
||||
- 先读默认 Doc(ARCHITECTURE / CODING_STYLE / EPUB_MAINTENANCE)
|
||||
- 根据需求类型补读对应层级的源码
|
||||
- 用源码确认真实入口、调用链和复用点,不只依赖文档
|
||||
|
||||
### Phase 3 — 形成最小实现方案
|
||||
|
||||
- 保持分层边界:
|
||||
- EPUB 解析逻辑在 EPUBCore
|
||||
- 文本渲染逻辑在 EPUBTextRendering
|
||||
- 分页容器逻辑在 RDReaderView
|
||||
- 开箱即用 UI 在 EPUBUI
|
||||
- 不在 Legacy 层新增功能
|
||||
- 命名、注释、日志风格遵循 `Doc/CODING_STYLE.md`
|
||||
- Swift / iOS 默认规则:
|
||||
- 新增 Swift 类型遵循 `RD` / `RDEPUB` 前缀和层级目录归属
|
||||
- 避免新增强制解包;确需使用时必须先收敛前置条件
|
||||
- 不为"现代化"而重写稳定代码;仅在本次需求明确受益时再迁移系统 API
|
||||
- 当前项目默认保持 UIKit + Auto Layout + 既有组件风格,SDK 层不使用 SnapKit
|
||||
- 注释和日志使用中文
|
||||
- Debug 输出放在 `#if DEBUG` 守卫下
|
||||
- 不在日志中输出敏感信息
|
||||
- 异步闭包默认 `[weak self]`,UI 更新回到主线程
|
||||
- 通知 / 定时器 / 回调在生命周期结束时必须清理
|
||||
- 数据模型用 `struct` + `Codable` + `Equatable`,服务对象用 `final class`
|
||||
- 可见性按最小原则:`private` > `fileprivate` > `internal` > `public`
|
||||
- 错误使用 `enum` + `LocalizedError`,禁止 force unwrap
|
||||
- 单文件若预计超过 600 行,需主动拆分扩展文件
|
||||
|
||||
### Phase 4 — 执行修改与文档同步
|
||||
|
||||
- 所有代码改动遵循最小改动原则
|
||||
- 修改代码时必须补充必要注释,重点说明关键逻辑、边界条件和不直观处理;不要省略应有注释,也不要添加无信息量的描述性注释
|
||||
- 默认补做轻量 SDK 自检:
|
||||
- 新增类型是否使用正确前缀和层级归属
|
||||
- 本次改动涉及的渲染路径是否正常工作
|
||||
- Public API 是否有意外 breaking change
|
||||
- podspec 是否需要更新(新增文件时)
|
||||
- 生命周期、通知/定时器/回调清理是否完整
|
||||
- 异步闭包是否考虑 `[weak self]`
|
||||
- 修改代码后必须同步更新受影响文档,不能只停留在代码实现
|
||||
- 若本次改动涉及 EPUB 维护相关,必须回写 `Doc/EPUB_MAINTENANCE.md`
|
||||
- 若本次是方案讨论文档交接落地,方案类文档统一放到 `Doc/FeatureSolution/`
|
||||
- 若新增、重命名或移动文档,必须同步更新目录索引
|
||||
|
||||
### Phase 5 — 构建验证与交付
|
||||
|
||||
- 完成修改后,按项目默认命令执行编译验证
|
||||
- 若出现编译错误,自动修复并重编译
|
||||
- 若遇构建锁问题,自动重试
|
||||
- 交付时固定输出:
|
||||
- A. 需求理解
|
||||
- B. 开发计划
|
||||
- C. 开发实施
|
||||
- D. 验证结果
|
||||
- E. 交付摘要
|
||||
|
||||
## Output Contract
|
||||
|
||||
最终回复必须完整输出以下 5 个板块,标题保持一致:
|
||||
- A. 需求理解
|
||||
- B. 开发计划
|
||||
- C. 开发实施
|
||||
- D. 验证结果
|
||||
- E. 交付摘要
|
||||
|
||||
各板块内容要求:
|
||||
- A:3-6 条,覆盖目标价值、范围、目标层级、限制
|
||||
- B:按"方案对齐 / MVP 实现 / 验证与交付"三阶段组织
|
||||
- C:描述实际改动、关键实现取舍,以及本次补充了哪些关键代码注释
|
||||
- D:给出构建结果、关键路径验证、未覆盖风险
|
||||
- E:列出改动文件、关键取舍、已同步文档与具体文档落点、后续建议
|
||||
|
||||
## Validation
|
||||
|
||||
若本次调用修改了任何 Swift / Objective-C / 工程配置 / podspec 文件,必须执行构建验证。
|
||||
|
||||
验证方式:
|
||||
- 若 Demo 工程可用:
|
||||
```bash
|
||||
xcodebuild build -workspace ReadViewSDKDemo/ReadViewSDKDemo.xcworkspace -scheme ReadViewSDKDemo -sdk iphonesimulator -derivedDataPath /private/tmp/readview-sdk-derived
|
||||
```
|
||||
- 若仅有 SDK 源码(无 workspace):
|
||||
```bash
|
||||
pod lib lint RDReaderView.podspec --allow-warnings
|
||||
```
|
||||
|
||||
验证规则:
|
||||
- 编译失败时必须自行修复并重试
|
||||
- 遇到 `database is locked` 等锁问题时自动重试,建议最多 5 次
|
||||
- 直到 `BUILD SUCCEEDED` 或 `pod lib lint passed` 才可交付
|
||||
- 如果本次仅修改文档或 skill 文件,可跳过构建,但要在交付中明确说明原因
|
||||
|
||||
## Maintenance Rules
|
||||
|
||||
- 优先复用 `Doc/`,不要把项目级规则复制进多个 skill 造成双份维护
|
||||
- 新增项目约束时,优先更新 `Doc/CODING_STYLE.md`、`Doc/ARCHITECTURE.md` 等主文档,再调整 skill
|
||||
- 轻量与完整要保持"轻重不同、规则不冲突",其中完整版应在轻量版闭环基础上扩展跨层与联调要求,而不是另起一套风格
|
||||
- 变更默认流程、输出格式或适用场景时,必须同步更新相关文档
|
||||
- 本 skill 持续作为默认开发入口,保持"轻量、清晰、可直接执行"
|
||||
- SDK 四层架构是执行的核心参照框架,单层改动也必须明确标注落点层级
|
||||
@@ -1,309 +0,0 @@
|
||||
---
|
||||
name: "Start SDK Feature Dev"
|
||||
description: "完整开发主控 skill:用于 ReadViewSDK 的跨层功能开发、多阶段联调、结构性重构与完整交付。"
|
||||
argument-hint: "粘贴完整需求,建议包含:背景、目标、范围、目标层级、交互说明、数据约束、验收标准、渲染路径覆盖要求。"
|
||||
---
|
||||
|
||||
# Start SDK Feature Dev
|
||||
|
||||
## Purpose
|
||||
|
||||
面向 ReadViewSDK 的完整开发主控 skill。
|
||||
用于复杂度高于单层修改的需求,遵循"先识别范围、再按需读文档、后对齐既定开发计划、再严格执行、再验证、最后交付"的闭环,并补充跨层联调、风险控制、回滚点和完整交付要求。
|
||||
|
||||
该 skill 优先覆盖:
|
||||
- 跨多个 SDK 层级(EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI)的功能开发
|
||||
- 多阶段联调与完整交付
|
||||
- 结构性重构或系统性收敛
|
||||
- 涉及 EPUB 解析、渲染管线、阅读器容器、UI 层协同改造的需求
|
||||
- 需要更完整风险说明、文档同步和验收闭环的开发任务
|
||||
|
||||
默认吸收项目 `Doc/CODING_STYLE.md` 中的 Swift/iOS 通用规则;若任务重点落在并发、性能、可访问性或安全,再额外展开该专项的检查项。
|
||||
|
||||
## Core Execution Rules
|
||||
|
||||
- Rule 1 — Think Before Coding
|
||||
- 先核对代码、文档和计划事实,不静默假设
|
||||
- 必须显式写出关键假设、主要取舍和已知风险
|
||||
- 遇到会改变计划或实现路径的关键灰区,先暂停确认,不靠猜测继续推进
|
||||
- 若存在更简单且满足目标的实现路径,只能作为建议提出;有既定计划时不得自行改用
|
||||
- Rule 2 — Simplicity First
|
||||
- 只做满足需求、计划和验收标准的最小实现
|
||||
- 不增加推测性功能,不为单次使用引入抽象
|
||||
- 若方案让资深工程师也会觉得过度设计,必须继续简化或回到计划边界内
|
||||
- Rule 3 — Surgical Changes
|
||||
- 只修改完成当前需求和计划项所必需的文件与代码
|
||||
- 不顺手重构无关代码,不扩散到相邻层做"顺便优化"
|
||||
- 非必要不改注释、格式或既有结构,新增代码保持现有风格
|
||||
- Rule 4 — Goal-Driven Execution
|
||||
- 先定义成功标准,再围绕成功标准实施和验证
|
||||
- 计划只是约束,不是目标本身;目标是按计划完成验收闭环
|
||||
- 修改后必须验证,未验证通过前不能视为完成
|
||||
|
||||
## Plan Execution Rule
|
||||
|
||||
若用户提供了开发计划文档、方案文档或明确引用 `Doc/FeatureSolution/*.md` 中的开发方案,本 skill 必须把该文档视为本次实现的主计划来源。
|
||||
|
||||
严格执行规则:
|
||||
- 必须先完整读取用户指定的开发计划文档,再开始代码修改
|
||||
- 必须从计划中抽取任务清单、文件清单、实现边界、非范围和验收标准
|
||||
- 必须按计划逐项实现,不得自行更换方案、合并步骤、替换文件布局、改变数据模型设计或引入计划外抽象
|
||||
- 必须遵守计划中的"不做 / 不新增 / 不使用 / 暂不实现"等限制项
|
||||
- 若计划与代码事实冲突,或计划中某项无法直接落地,必须暂停并向用户说明冲突点、影响和可选处理方式;不得自行选择替代方案继续实现
|
||||
- 若发现更优实现方式,只能作为"建议"在交付或暂停说明中提出,不得在本次实现中自由采用
|
||||
- 若计划未覆盖某个必要细节,只允许做最小补齐;补齐内容必须在交付中明确标注为"计划未写明,按最小必要实现补齐"
|
||||
- 若用户要求"严格按照开发计划执行 / 不要自由发挥",必须把偏离计划视为阻塞项处理
|
||||
|
||||
## When To Use
|
||||
|
||||
当用户提出以下类型需求时使用:
|
||||
- "使用 start-sdk-feature-dev 实现这个需求"
|
||||
- 一个需求影响多个 SDK 层级(跨 EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI)
|
||||
- 需要完整计划、实现、联调、回归和文档交付
|
||||
- 需要跨 EPUB 解析、渲染、阅读器容器、UI 的协同改造
|
||||
- 需要明确阶段性计划、回滚点和完整验收说明
|
||||
|
||||
Lite 与 Full 的边界:
|
||||
- 轻量开发 skill:单层、小中型、MVP 优先
|
||||
- 本 skill(start-sdk-feature-dev):跨层、复杂联调、需要更完整方案和验收
|
||||
|
||||
## Inputs
|
||||
|
||||
推荐输入模板:
|
||||
- 功能名称
|
||||
- 背景与目标
|
||||
- 用户故事
|
||||
- 详细需求
|
||||
- 非范围
|
||||
- 目标层级(EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI / Legacy)
|
||||
- 交互说明(含空态 / 错误态 / 加载态)
|
||||
- 渲染路径覆盖要求(webFixedLayout / webInteractive / textReflowable / 不限)
|
||||
- 数据与接口约束
|
||||
- 验收标准(Given-When-Then)
|
||||
- 兼容性要求(Public API 是否 breaking、podspec 变更等)
|
||||
- 性能与安全要求
|
||||
- 发布时间或优先级
|
||||
|
||||
若信息不足:
|
||||
- 先基于代码和文档补齐可发现事实
|
||||
- 再列最多 5 条关键假设
|
||||
- 若没有既定开发计划,可基于假设继续推进最小可落地实现,并在交付中标注待确认项
|
||||
- 若已有开发计划,信息不足时不得自行扩展方案;只能做计划内实现或暂停确认
|
||||
|
||||
## Scope / Required Context
|
||||
|
||||
仅针对 ReadViewSDK 现有架构执行。
|
||||
|
||||
SDK 四层架构参照:
|
||||
- Layer 1 — EPUBCore(`Sources/RDReaderView/EPUBCore/`):EPUB 解析引擎,ZIP 解压、OPF 解析、manifest/spine/TOC、资源 URL 解析、离屏 WKWebView 分页、阅读会话状态机、JS 桥接
|
||||
- Layer 2 — EPUBTextRendering(`Sources/RDReaderView/EPUBTextRendering/`):文本渲染路径,DTCoreText 转 NSAttributedString 后按字符范围分页
|
||||
- Layer 3 — RDReaderView(`Sources/RDReaderView/` 根目录 6 个文件):分页阅读器容器 UIView,支持 pageCurl / horizontalScroll / verticalScroll / horizontalCoverScroll 四种模式
|
||||
- Layer 4 — EPUBUI(`Sources/RDReaderView/EPUBUI/`):开箱即用 UI,工具栏、目录面板、高亮管理、设置面板、阅读位置持久化
|
||||
- Legacy(`Sources/RDReaderView/LegacyRDReaderController/`):遗留代码,不在其上新增功能
|
||||
|
||||
分层依赖规则:
|
||||
- 上层可依赖下层,下层不得依赖上层
|
||||
- EPUBUI 可依赖 RDReaderView、EPUBTextRendering、EPUBCore
|
||||
- RDReaderView 可依赖 EPUBTextRendering、EPUBCore
|
||||
- EPUBTextRendering 可依赖 EPUBCore
|
||||
- EPUBCore 不依赖任何上层
|
||||
|
||||
若确需跨层改动:
|
||||
- 必须在计划中写明影响范围
|
||||
- 必须在交付中写明回滚点或回退策略
|
||||
|
||||
默认先读:
|
||||
1. `Doc/ARCHITECTURE.md` — 四层架构、数据流、分页模式、位置模型、已知限制
|
||||
2. `Doc/CODING_STYLE.md` — 命名规范(RD/RDEPUB 前缀)、分层规则、extension 拆分、SS→RD 迁移计划
|
||||
3. `Doc/EPUB_MAINTENANCE.md` — 文件职责表、DTCoreText 渲染管线、常见排查场景
|
||||
|
||||
按需再读:
|
||||
- `Doc/FeatureSolution/*.md`(若有方案文档)
|
||||
- 涉及 EPUB 解析时:`Sources/RDReaderView/EPUBCore/` 下相关文件
|
||||
- 涉及文本渲染时:`Sources/RDReaderView/EPUBTextRendering/` 下相关文件
|
||||
- 涉及阅读器容器时:`Sources/RDReaderView/RDReaderView.swift` 及同级文件
|
||||
- 涉及 UI 层时:`Sources/RDReaderView/EPUBUI/` 下相关文件
|
||||
- 涉及遗留代码时:`Sources/RDReaderView/LegacyRDReaderController/` 下相关文件
|
||||
- 涉及 JS 桥接时:`Sources/RDReaderView/Resources/epub-bridge.js`
|
||||
- 涉及 podspec 时:`RDReaderView.podspec`
|
||||
|
||||
若文档与代码不一致:
|
||||
- 以代码事实为准落地
|
||||
- 在交付中说明不一致点和建议更新的文档项
|
||||
|
||||
若开发计划文档与代码不一致:
|
||||
- 不得直接以代码事实替换计划继续开发
|
||||
- 必须先判断不一致是否影响计划执行
|
||||
- 会影响计划执行时,暂停并向用户说明冲突点和建议调整项
|
||||
- 不影响计划执行时,按计划继续,并在交付中标注该不一致点
|
||||
|
||||
## SDK-Specific Execution Rules
|
||||
|
||||
### 命名与可见性
|
||||
- 所有新增类型必须使用 `RD` 前缀,EPUB 相关使用 `RDEPUB` 前缀
|
||||
- 新增类型归属正确层级目录,不得随意放置
|
||||
- 可见性按最小原则:`private` > `fileprivate` > `internal` > `public`
|
||||
- 数据模型用 `struct` + `Codable` + `Equatable`
|
||||
- 服务对象用 `final class`
|
||||
|
||||
### 代码组织
|
||||
- 单文件超过 600 行需主动拆分 `TypeName+Feature.swift` 扩展文件
|
||||
- 大型 Controller 超过 1000 行必须拆分
|
||||
- extension 文件承担独立子职责,不只做"行数搬运"
|
||||
|
||||
### 内存与并发
|
||||
- 异步闭包默认 `[weak self]`
|
||||
- UI 更新必须回到主线程
|
||||
- 通知 / 定时器 / 回调在 deinit 或生命周期结束时必须清理
|
||||
|
||||
### 渲染路径覆盖
|
||||
- 新增功能必须评估对三种渲染路径的影响:
|
||||
- `webFixedLayout`:固定版式 EPUB(漫画、绘本)— WKWebView 渲染
|
||||
- `webInteractive`:可重排 + 交互脚本 EPUB — WKWebView 渲染
|
||||
- `textReflowable`:纯文本可重排 EPUB — DTCoreText 渲染
|
||||
- 若功能仅适用于部分路径,必须在交付中明确标注适用范围和不适用路径的处理方式
|
||||
|
||||
### Public API 兼容性
|
||||
- 涉及公共接口(`RDReaderView`、`RDReaderDataSource`、`RDURLReaderController`、`RDEPUBReaderController` 等)的改动,必须评估 breaking change
|
||||
- 若存在 breaking change,必须在交付中标注并给出迁移指引
|
||||
- 新增 public API 需考虑 Objective-C 互操作(`@objc`、`@objcMembers`)
|
||||
|
||||
### CocoaPods 发布影响
|
||||
- 新增源文件:确认 podspec `source_files` glob 是否已覆盖
|
||||
- 新增资源文件:确认 podspec `resource` / `resource_bundles` 是否已覆盖
|
||||
- 新增依赖:确认 podspec `dependency` 是否已声明,评估对宿主 App 的依赖传递影响
|
||||
- 模块目录结构调整:必须同步更新 podspec
|
||||
|
||||
### JS 桥接
|
||||
- 涉及 `epub-bridge.js` 的改动,必须确认 JS ↔ Swift 消息契约的一致性
|
||||
- JS 侧新增消息类型时,Swift 侧必须有对应处理分支
|
||||
- 修改已有消息类型时,必须评估向后兼容
|
||||
|
||||
### 错误处理
|
||||
- 使用 `enum` + `LocalizedError` 定义错误类型
|
||||
- 禁止 force unwrap(`!`),确需使用时必须先收敛前置条件
|
||||
- 关键路径的错误必须向调用方传递,不得静默吞掉
|
||||
|
||||
## Required Workflow
|
||||
|
||||
### Phase 1 — 识别需求和范围
|
||||
|
||||
- 明确目标价值、影响层级(EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI / Legacy)、In Scope / Out of Scope
|
||||
- 判断是否涉及跨层、跨渲染路径、跨模块联调
|
||||
- 若用户提供开发计划文档,先确认本次执行的唯一计划来源,并提取计划任务清单
|
||||
- 优先做计划内最小可落地实现,不做需求外重构
|
||||
- 若需求信息不足,先通过仓库事实补齐,再基于假设推进
|
||||
|
||||
### Phase 2 — 按需读取 Doc 与代码事实
|
||||
|
||||
- 若用户指定开发计划文档,必须先完整读取该文档
|
||||
- 先读默认 Doc(ARCHITECTURE / CODING_STYLE / EPUB_MAINTENANCE)
|
||||
- 根据需求类型补读各层源码和相关文档
|
||||
- 用源码确认真实入口、调用链、数据流、状态流和复用点,不只依赖文档
|
||||
|
||||
### Phase 3 — 对齐并锁定开发计划
|
||||
|
||||
- 若已有开发计划,必须按计划锁定实现路径,不重新设计方案
|
||||
- 若没有开发计划,才允许形成最小可落地方案:先复用现有模式与能力,再考虑新增抽象
|
||||
- 保持分层边界:
|
||||
- EPUB 解析逻辑在 EPUBCore
|
||||
- 文本渲染逻辑在 EPUBTextRendering
|
||||
- 分页容器逻辑在 RDReaderView
|
||||
- 开箱即用 UI 在 EPUBUI
|
||||
- 不在 Legacy 层新增功能
|
||||
- 命名、注释、日志风格遵循 `Doc/CODING_STYLE.md`
|
||||
- Swift / iOS 默认规则:
|
||||
- 新增 Swift 类型遵循 `RD` / `RDEPUB` 前缀和层级目录归属
|
||||
- 避免新增强制解包;确需使用时必须先收敛前置条件
|
||||
- 不为"现代化"而重写稳定代码;仅在本次需求明确受益时再迁移系统 API
|
||||
- 当前项目默认保持 UIKit + Auto Layout + 既有组件风格,SDK 层不使用 SnapKit
|
||||
- 注释和日志使用中文
|
||||
- Debug 输出放在 `#if DEBUG` 守卫下
|
||||
- 不在日志中输出敏感信息
|
||||
- 单文件预计超过 600 行需主动拆分,超过 1000 行必须拆分
|
||||
- 若为跨层需求,计划中必须写清:
|
||||
- 主改动层级
|
||||
- 被影响层级
|
||||
- 渲染路径覆盖范围
|
||||
- 联调依赖点
|
||||
- 关键风险
|
||||
- 回滚点或降级方式
|
||||
- 开始编码前必须形成内部执行清单,清单项必须能追溯到开发计划;计划外项只能标记为"必要补齐"或"待确认",不能直接实施
|
||||
|
||||
### Phase 4 — 执行修改与文档同步
|
||||
|
||||
- 所有代码改动遵循计划内最小改动原则
|
||||
- 严格按开发计划清单逐项修改;不得临时改换实现方式或增加计划外功能
|
||||
- 若执行中需要偏离计划,必须暂停确认,不能先改后说明
|
||||
- 修改代码时必须补充必要注释,重点说明关键逻辑、边界条件和不直观处理
|
||||
- 默认补做 SDK 自检:
|
||||
- 检查新增类型是否使用正确前缀和层级归属
|
||||
- 检查三种渲染路径的覆盖情况
|
||||
- 检查 Public API 是否有意外 breaking change
|
||||
- 检查 podspec 是否需要更新
|
||||
- 检查 JS 桥接消息契约是否一致
|
||||
- 关键页面或组件检查生命周期、通知/定时器/回调清理是否完整
|
||||
- 新增 UI 检查长文本、图标按钮语义、重要状态是否只靠颜色表达
|
||||
- 异步闭包默认考虑 `[weak self]`,UI 更新回到主线程
|
||||
- 修改代码后必须同步更新受影响文档,不能只停留在代码实现
|
||||
- 若本次改动涉及 EPUB 维护相关,必须回写 `Doc/EPUB_MAINTENANCE.md`
|
||||
- 若本次是方案讨论文档交接落地,方案类文档统一放到 `Doc/FeatureSolution/`
|
||||
- 若新增、重命名或移动文档,必须同步更新目录索引
|
||||
|
||||
### Phase 5 — 构建验证与交付
|
||||
|
||||
- 完成修改后,按项目默认命令执行编译验证
|
||||
- 若出现编译错误,自动修复并重编译
|
||||
- 若遇构建锁问题,自动重试(建议最多 5 次)
|
||||
- 若是跨层需求,除构建外还应补充关键联调路径说明
|
||||
- 交付时固定输出:
|
||||
- A. 需求理解
|
||||
- B. 开发计划
|
||||
- C. 开发实施
|
||||
- D. 验证结果
|
||||
- E. 交付摘要
|
||||
|
||||
## Output Contract
|
||||
|
||||
最终回复必须完整输出以下 5 个固定板块:
|
||||
- A. 需求理解
|
||||
- B. 开发计划
|
||||
- C. 开发实施
|
||||
- D. 验证结果
|
||||
- E. 交付摘要
|
||||
|
||||
各板块内容要求:
|
||||
- A:3-8 条,覆盖目标价值、范围、目标层级、渲染路径覆盖、交互流、限制
|
||||
- B:按"计划来源 / 计划任务清单 / 执行顺序 / 验证与交付"组织;若有开发计划文档,必须逐条对应计划项
|
||||
- C:描述实现过程、复用点、关键取舍,以及本次补充了哪些关键代码注释;若有计划未写明但必须补齐的内容,必须单独标注
|
||||
- D:给出编译结果、关键路径验证、自动重试/自动修复情况、未覆盖风险
|
||||
- E:列出改动文件、计划符合情况、关键取舍、回滚点或降级方式、已同步文档与具体文档落点、后续建议
|
||||
|
||||
## Validation
|
||||
|
||||
若本次调用修改了任何 Swift / Objective-C / 工程配置 / podspec 文件,必须执行构建验证。
|
||||
|
||||
验证方式:
|
||||
- 若 Demo 工程可用:
|
||||
```bash
|
||||
xcodebuild build -workspace ReadViewSDKDemo/ReadViewSDKDemo.xcworkspace -scheme ReadViewSDKDemo -sdk iphonesimulator -derivedDataPath /private/tmp/readview-sdk-derived
|
||||
```
|
||||
- 若仅有 SDK 源码(无 workspace):
|
||||
```bash
|
||||
pod lib lint RDReaderView.podspec --allow-warnings
|
||||
```
|
||||
|
||||
验证规则:
|
||||
- 若出现编译错误,必须自动修复并重编译
|
||||
- 若遇到 `database is locked` 等构建锁问题,自动重试,建议最多 5 次
|
||||
- 直到 `BUILD SUCCEEDED` 或 `pod lib lint passed` 才可进入交付阶段
|
||||
- 若本次只改文档或 skill 文件,可跳过编译,但要在交付中明确说明
|
||||
|
||||
## Maintenance Rules
|
||||
|
||||
- 优先复用 `Doc/`,不要把项目级规则复制进多个 skill 造成双份维护
|
||||
- 新增项目约束时,优先更新 `Doc/CODING_STYLE.md`、`Doc/ARCHITECTURE.md` 等主文档,再调整 skill
|
||||
- 轻量与完整要保持"轻重不同、规则不冲突",其中完整版应在轻量版闭环基础上扩展跨层与联调要求,而不是另起一套风格
|
||||
- 变更默认流程、输出格式或适用边界时,必须同步更新相关文档
|
||||
- 本 skill 持续作为完整开发入口,保持"可执行、可联调、可回滚、可交付"
|
||||
- SDK 四层架构(EPUBCore → EPUBTextRendering → RDReaderView → EPUBUI)是执行的核心参照框架,所有改动必须明确标注落点层级和依赖方向
|
||||
+21
@@ -6,3 +6,24 @@ xcuserdata/
|
||||
.artifacts/
|
||||
.deriveddata/
|
||||
.swift-module-cache/
|
||||
ReadViewDemo/build/
|
||||
|
||||
# CocoaPods output for the Demo project is not vendored — everyone runs
|
||||
# `pod install` locally.
|
||||
ReadViewDemo/Pods/
|
||||
ReadViewDemo/Podfile.lock
|
||||
|
||||
# macOS system files
|
||||
.DS_Store
|
||||
|
||||
# Local backup directories
|
||||
*手动解包备份*
|
||||
|
||||
# AI tool output
|
||||
_ssoft-output/
|
||||
.claude/*
|
||||
!.claude/skills
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
@@ -1,3 +1,67 @@
|
||||
# AGENTS.md
|
||||
# ReadViewSDK AI 开发规则
|
||||
|
||||
- If using XcodeBuildMCP, use the installed XcodeBuildMCP skill before calling XcodeBuildMCP tools.
|
||||
## 适用范围与语言
|
||||
|
||||
- 默认处理当前仓库的 EPUB SDK、PDF SDK、Demo 和测试。
|
||||
- 计划、交付说明、`Doc/` 与项目 Markdown 使用中文。
|
||||
- 代码标识符、文件名、API 和配置键保持英文;代码注释、Docstring 和提交信息使用中文。
|
||||
- 只做当前目标所需的最小改动,保留工作区内与任务无关的未提交修改。
|
||||
- 任何写文件的 agent 必须先读取 `CONTEXT.md`。
|
||||
|
||||
## 自动选择项目 Skill
|
||||
|
||||
根据用户意图选择 `.agents/skills/` 中最匹配的 Skill:
|
||||
|
||||
- 先讨论、比较方案、不要改代码:`discuss-sdk-feature-solution`
|
||||
- 单模块功能、Bug 修复、UI 调整、局部重构、构建修复:`start-sdk-feature-dev-lite`
|
||||
- 跨层/跨阅读器改造、公共 API 或持久化迁移、复杂联调、完整方案执行:`start-sdk-feature-dev`
|
||||
- 隐藏问题、并发、生命周期、性能、内存、缓存或兼容性审查:`review-swift-ios-sdk`
|
||||
- 全局代码规范:`generate-sdk-code-style-doc`
|
||||
- 单模块代码规范:`generate-module-code-style-doc`
|
||||
- 模块调用链、状态流、分页或缓存实现逻辑:`generate-module-implementation-logic`
|
||||
- 审查改动、生成详细中文提交说明并提交或推送 Git:`commit-git-changes`
|
||||
|
||||
不要为简单任务叠加多个 Skill。用户明确指定 Skill 时优先使用;任务边界扩大时再切换或补充,并说明原因。
|
||||
|
||||
## 文档按需读取
|
||||
|
||||
先定位源码,再读取直接相关文档,不默认全文加载整个 `Doc/`:
|
||||
|
||||
| 任务 | 必读资料 |
|
||||
| --- | --- |
|
||||
| 任意代码修改 | `CONTEXT.md`、`Doc/CONVENTIONS.md` 相关章节 |
|
||||
| 分层或跨模块改造 | `Doc/ARCHITECTURE.md` |
|
||||
| 公共 API | `Doc/API_REFERENCE.md` 与真实 public 声明 |
|
||||
| EPUB 解析/排版/分页/UI | 对应 `Doc/*_CODE_REFERENCE.md` 或专题文档 |
|
||||
| PDF 阅读器 | `Sources/RDPDFReaderView` 源码;现有文档不足时以源码为准 |
|
||||
| 测试与回归 | `Doc/TESTING.md` |
|
||||
| 用户指定方案 | 指定文档,必须完整读取 |
|
||||
|
||||
`Doc/index.md` 用于路由。文档与源码冲突时以当前源码为事实,并指出待同步项。
|
||||
|
||||
## 模块边界
|
||||
|
||||
- EPUB:`EPUBCore` → `EPUBTextRendering` → `ReaderView` → `EPUBUI`,禁止下层反向依赖上层。
|
||||
- PDF:通用分页容器位于 `Sources/RDPDFReaderView/ReaderView`,成品能力位于 `Sources/RDPDFReaderView/Sources`。
|
||||
- Demo 和 UITests 只承担集成、样例与回归,不承载 SDK 核心业务。
|
||||
- 保持 UIKit、SnapKit 和当前 CocoaPods 结构;新增依赖、资源或系统 framework 时同步检查对应 podspec。
|
||||
- 新增类型遵循现有 `RD`、`RDEPUB`、`RDPDF` 前缀和最小可见性。
|
||||
|
||||
## SDK 兼容与质量
|
||||
|
||||
- 修改 public API 前评估宿主源码兼容、默认行为和迁移方式。
|
||||
- 修改 Codable、持久化文件、缓存键或 book identifier 时提供版本与失效/迁移策略。
|
||||
- UI 更新回主线程;异步任务处理取消、超时、迟到结果、恰好一次完成和生命周期。
|
||||
- EPUB 改动检查 `.textReflowable`、`.webInteractive`、`.webFixedLayout` 的影响。
|
||||
- PDF 改动检查宿主图片 Provider 与内置 PDFKit Provider 的影响。
|
||||
- 滚动、缩放、分页和绘画热点避免同步 IO、重复解码、无界缓存和大对象长期驻留。
|
||||
|
||||
## 验证
|
||||
|
||||
修改 Swift、工程配置或 podspec 后默认执行:
|
||||
|
||||
`xcodebuild -workspace ReadViewDemo/ReadViewDemo.xcworkspace -scheme ReadViewDemo -configuration Debug -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO build`
|
||||
|
||||
按风险补充 `Doc/TESTING.md` 中的定向 UI 测试。仅修改文档、规则或 Skill 时可跳过 Xcode 构建,但必须执行结构、引用和格式检查。
|
||||
|
||||
如果使用 XcodeBuildMCP,调用其工具前必须先读取已安装的 XcodeBuildMCP Skill,并先检查 session defaults。
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
# ReadViewSDK 代码审查报告
|
||||
|
||||
> 审查日期:2026-06-26
|
||||
> 审查范围:Sources/RDReaderView 全部源码
|
||||
> 审查范围:Sources/RDEpubReaderView 全部源码
|
||||
> 审查方法:逐文件阅读 + 交叉验证 + 线程模型分析
|
||||
|
||||
---
|
||||
@@ -148,4 +148,4 @@ func currentTextPageSize() -> CGSize {
|
||||
| 🟡 P2 | P2-2 | javaScriptStringLiteral 实现依赖隐式假设 | 可维护性 |
|
||||
| 🔵 P3 | P3-1 | 后台线程同步 hop 主线程获取布局信息 | 线程模型 |
|
||||
|
||||
**整体评价**: SDK 架构设计良好,模块分层清晰,引用管理(unowned/weak)使用正确,分页取消机制有 `paginationToken` + `cancellationController` 兜底。主要值得修复的是三个性能类 P1 问题(主线程阻塞搜索、资源加载阻塞、缓存无清理),两个 P2 鲁棒性/可维护性问题,以及一个 P3 线程模型优化建议。
|
||||
**整体评价**: SDK 架构设计良好,模块分层清晰,引用管理(unowned/weak)使用正确,分页取消机制有 `paginationToken` + `cancellationController` 兜底。主要值得修复的是三个性能类 P1 问题(主线程阻塞搜索、资源加载阻塞、缓存无清理),两个 P2 鲁棒性/可维护性问题,以及一个 P3 线程模型优化建议。
|
||||
|
||||
+34
-30
@@ -8,7 +8,7 @@
|
||||
|
||||
## 1. RDEPUBReaderController — 入口控制器
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift` 及扩展
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift` 及扩展
|
||||
|
||||
### 1.1 初始化
|
||||
|
||||
@@ -180,7 +180,7 @@ func nativeTextSemanticSummary() -> String?
|
||||
|
||||
## 2. RDEPUBReaderDelegate — 委托协议
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBUI/RDEPUBReaderDelegate.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderDelegate.swift`
|
||||
|
||||
所有方法均有默认空实现,可按需实现。
|
||||
|
||||
@@ -232,7 +232,7 @@ public protocol RDEPUBReaderDelegate: AnyObject {
|
||||
|
||||
## 3. RDEPUBReaderPersistence — 持久化协议
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
|
||||
|
||||
```swift
|
||||
public protocol RDEPUBReaderPersistence: AnyObject {
|
||||
@@ -251,20 +251,24 @@ public protocol RDEPUBReaderPersistence: AnyObject {
|
||||
}
|
||||
```
|
||||
|
||||
默认实现 `RDEPUBUserDefaultsPersistence` 使用 UserDefaults 存储:
|
||||
默认实现 `RDEPUBUserDefaultsPersistence` 将阅读位置和全局设置存入 UserDefaults;
|
||||
书签与高亮(可能含书籍正文摘录)存入带文件保护且排除备份的 Application Support 文件。
|
||||
升级后的首次读取会自动迁移历史 UserDefaults 数据,并仅在新副本成功落盘后删除旧副本。
|
||||
不同的 `UserDefaults` 容器会自动使用独立的文件命名空间,避免多账号或测试 suite
|
||||
之间混用同一本书的标注;标准容器保留对旧版未分区文件的只读兼容。
|
||||
|
||||
| 数据 | Key 格式 |
|
||||
|------|----------|
|
||||
| 阅读位置 | `ssreader.epub.location.{bookID}` |
|
||||
| 书签 | `ssreader.epub.bookmarks.{bookID}` |
|
||||
| 高亮 | `ssreader.epub.highlights.{bookID}` |
|
||||
| 书签 | 受保护文件(历史键:`ssreader.epub.bookmarks.{bookID}`) |
|
||||
| 高亮 | 受保护文件(历史键:`ssreader.epub.highlights.{bookID}`) |
|
||||
| 全局设置 | `ssreader.epub.settings` |
|
||||
|
||||
---
|
||||
|
||||
## 4. RDEPUBReaderConfiguration — 配置模型
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift`
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
@@ -273,7 +277,7 @@ public protocol RDEPUBReaderPersistence: AnyObject {
|
||||
| `fontChoice` | `RDEPUBReaderFontChoice` | `.system` | 字体选择(system/serif/rounded/monospaced) |
|
||||
| `numberOfColumns` | `Int` | `1` | 每页列数(1 或 2) |
|
||||
| `columnGap` | `CGFloat` | `20` | 列间距 |
|
||||
| `displayType` | `RDReaderView.DisplayType` | `.pageCurl` | 翻页模式 |
|
||||
| `displayType` | `RDEpubReaderView.DisplayType` | `.pageCurl` | 翻页模式 |
|
||||
| `landscapeDualPageEnabled` | `Bool` | `true` | 横屏双页 |
|
||||
| `showsTableOfContents` | `Bool` | `true` | 显示目录 |
|
||||
| `allowsHighlights` | `Bool` | `true` | 允许高亮 |
|
||||
@@ -301,50 +305,50 @@ public protocol RDEPUBReaderPersistence: AnyObject {
|
||||
|
||||
## 5. 翻页容器协议
|
||||
|
||||
**文件**:`Sources/RDReaderView/ReaderView/RDReaderViewProtocols.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/ReaderView/RDEpubReaderViewProtocols.swift`
|
||||
|
||||
### 5.1 RDReaderPageProvider(推荐)
|
||||
### 5.1 RDEpubReaderPageProvider(推荐)
|
||||
|
||||
```swift
|
||||
@objc public protocol RDReaderPageProvider: NSObjectProtocol {
|
||||
@objc public protocol RDEpubReaderPageProvider: NSObjectProtocol {
|
||||
|
||||
/// 总页数
|
||||
func numberOfPages(in readerView: RDReaderView) -> Int
|
||||
func numberOfPages(in readerView: RDEpubReaderView) -> Int
|
||||
|
||||
/// 返回指定页的视图
|
||||
/// - Parameters:
|
||||
/// - index: 页码索引(0-based)
|
||||
/// - reusableView: 可复用的旧视图(可能为 nil)
|
||||
func readerView(_ readerView: RDReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView
|
||||
func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView
|
||||
|
||||
/// 页面标识符(用于缓存去重)
|
||||
@objc optional func pageIdentifier(in readerView: RDReaderView, index: Int) -> String?
|
||||
@objc optional func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String?
|
||||
|
||||
/// 顶部工具栏视图
|
||||
@objc optional func readerViewTopChrome(_ readerView: RDReaderView) -> UIView?
|
||||
@objc optional func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView?
|
||||
|
||||
/// 底部工具栏视图
|
||||
@objc optional func readerViewBottomChrome(_ readerView: RDReaderView) -> UIView?
|
||||
@objc optional func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView?
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 RDReaderDelegate
|
||||
### 5.2 RDEpubReaderDelegate
|
||||
|
||||
```swift
|
||||
@objc public protocol RDReaderDelegate: NSObjectProtocol {
|
||||
@objc public protocol RDEpubReaderDelegate: NSObjectProtocol {
|
||||
|
||||
/// 页面变化回调
|
||||
func pageNum(readerView: RDReaderView, pageNum: Int)
|
||||
func pageNum(readerView: RDEpubReaderView, pageNum: Int)
|
||||
|
||||
/// 屏幕方向即将变化
|
||||
@objc optional func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool)
|
||||
@objc optional func readerViewOrientationWillChange(readerView: RDEpubReaderView, isLandscape: Bool)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 RDReaderPageNavigating
|
||||
### 5.3 RDEpubReaderPageNavigating
|
||||
|
||||
```swift
|
||||
public protocol RDReaderPageNavigating: AnyObject {
|
||||
public protocol RDEpubReaderPageNavigating: AnyObject {
|
||||
|
||||
/// 当前页码
|
||||
var currentPage: Int { get }
|
||||
@@ -357,19 +361,19 @@ public protocol RDReaderPageNavigating: AnyObject {
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 RDReaderDataSource(遗留)
|
||||
### 5.4 RDEpubReaderDataSource(遗留)
|
||||
|
||||
```swift
|
||||
@objc public protocol RDReaderDataSource: NSObjectProtocol {
|
||||
func pageCountOfReaderView(readerView: RDReaderView) -> Int
|
||||
func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView
|
||||
func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String?
|
||||
@objc optional func topToolView(readerView: RDReaderView) -> UIView?
|
||||
@objc optional func bottomToolView(readerView: RDReaderView) -> UIView?
|
||||
@objc public protocol RDEpubReaderDataSource: NSObjectProtocol {
|
||||
func pageCountOfReaderView(readerView: RDEpubReaderView) -> Int
|
||||
func pageContentView(readerView: RDEpubReaderView, pageNum: Int, containerView: UIView?) -> UIView
|
||||
func pageIdentifier(readerView: RDEpubReaderView, pageNum: Int) -> String?
|
||||
@objc optional func topToolView(readerView: RDEpubReaderView) -> UIView?
|
||||
@objc optional func bottomToolView(readerView: RDEpubReaderView) -> UIView?
|
||||
}
|
||||
```
|
||||
|
||||
通过 `RDReaderLegacyDataSourceAdapter` 自动适配到 `RDReaderPageProvider`。
|
||||
通过 `RDEpubReaderLegacyDataSourceAdapter` 自动适配到 `RDEpubReaderPageProvider`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+25
-25
@@ -22,7 +22,7 @@ ReadViewSDK 是一个 iOS EPUB 阅读器 SDK,支持文本重排(Reflowable
|
||||
│ ReadViewDemo (Demo App) │
|
||||
│ ViewController · LaunchAutomationPlan · UITests │
|
||||
└──────────────────────────────┬──────────────────────────────┘
|
||||
│ imports RDReaderView
|
||||
│ imports RDEpubReaderView
|
||||
┌──────────────────────────────▼──────────────────────────────┐
|
||||
│ EPUBUI 层 │
|
||||
│ RDEPUBReaderController · Coordinators · Settings · TextPage │
|
||||
@@ -30,8 +30,8 @@ ReadViewSDK 是一个 iOS EPUB 阅读器 SDK,支持文本重排(Reflowable
|
||||
└──────────────────────────────┬──────────────────────────────┘
|
||||
│
|
||||
┌──────────────────────────────▼──────────────────────────────┐
|
||||
│ RDReaderView 层 │
|
||||
│ RDReaderView · FlowLayout · PreloadController │
|
||||
│ RDEpubReaderView 层 │
|
||||
│ RDEpubReaderView · FlowLayout · PreloadController │
|
||||
│ SpreadResolver · TapRegionHandler · PagingController │
|
||||
└──────────────────────────────┬──────────────────────────────┘
|
||||
│
|
||||
@@ -54,7 +54,7 @@ ReadViewSDK 是一个 iOS EPUB 阅读器 SDK,支持文本重排(Reflowable
|
||||
|---|---|---|
|
||||
| **EPUBCore** | EPUB 文件解析、资源管理、WebView 渲染、JS 桥接 | `RDEPUBParser`, `RDEPUBPublication`, `RDEPUBWebView` |
|
||||
| **EPUBTextRendering** | HTML→NSAttributedString 转换、排版、分页、全书构建 | `RDEPUBTextBookBuilder`, `RDEPUBCoreTextPageFrameFactory` |
|
||||
| **RDReaderView** | 通用翻页容器、手势识别、页面预加载、双页布局 | `RDReaderView`, `RDReaderFlowLayout`, `RDReaderPreloadController` |
|
||||
| **RDEpubReaderView** | 通用翻页容器、手势识别、页面预加载、双页布局 | `RDEpubReaderView`, `RDEpubReaderFlowLayout`, `RDEpubReaderPreloadController` |
|
||||
| **EPUBUI** | 阅读器控制器、协调器模式、设置面板、按需加载、磁盘缓存 | `RDEPUBReaderController`, `RDEPUBReaderPaginationCoordinator` |
|
||||
|
||||
---
|
||||
@@ -310,10 +310,10 @@ RDEPUBWebView (UIView)
|
||||
|
||||
---
|
||||
|
||||
## 7. 翻页容器架构(RDReaderView)
|
||||
## 7. 翻页容器架构(RDEpubReaderView)
|
||||
|
||||
```
|
||||
RDReaderView (UIView)
|
||||
RDEpubReaderView (UIView)
|
||||
│
|
||||
├─ 三种翻页模式:
|
||||
│ ├─ .pageCurl → UIPageViewController (翻页动画)
|
||||
@@ -321,14 +321,14 @@ RDReaderView (UIView)
|
||||
│ └─ .verticalScroll → UICollectionView (垂直滚动)
|
||||
│
|
||||
├─ 组合对象:
|
||||
│ ├─ RDReaderPagingController // 翻页状态管理、请求队列
|
||||
│ ├─ RDReaderPreloadController // 页面预加载、缓存管理
|
||||
│ ├─ RDReaderSpreadResolver // 双页展开计算
|
||||
│ └─ RDReaderTapRegionHandler // 点击区域分类(左/中/右)
|
||||
│ ├─ RDEpubReaderPagingController // 翻页状态管理、请求队列
|
||||
│ ├─ RDEpubReaderPreloadController // 页面预加载、缓存管理
|
||||
│ ├─ RDEpubReaderSpreadResolver // 双页展开计算
|
||||
│ └─ RDEpubReaderTapRegionHandler // 点击区域分类(左/中/右)
|
||||
│
|
||||
├─ 数据源协议:
|
||||
│ ├─ RDReaderPageProvider (新) // 格式无关,优先级高
|
||||
│ └─ RDReaderDataSource (旧) // 遗留兼容,通过 Adapter 适配
|
||||
│ ├─ RDEpubReaderPageProvider (新) // 格式无关,优先级高
|
||||
│ └─ RDEpubReaderDataSource (旧) // 遗留兼容,通过 Adapter 适配
|
||||
│
|
||||
└─ 手势流:
|
||||
点击 → TapRegionHandler → 分类(左/中/右)
|
||||
@@ -351,7 +351,7 @@ struct RDEPUBReaderConfiguration {
|
||||
var numberOfColumns: Int // 1 或 2
|
||||
var columnGap: CGFloat // 默认 20
|
||||
var theme: ReaderTheme // 6 种主题
|
||||
var displayType: RDReaderView.DisplayType // pageCurl/horizontal/vertical
|
||||
var displayType: RDEpubReaderView.DisplayType // pageCurl/horizontal/vertical
|
||||
var onDemandChapterWindowSize: Int // 默认 3(奇数,最小 3,最大 15)
|
||||
var metadataParsingConcurrency: Int // 默认 CPU 核心数
|
||||
var chapterWindowRadius: Int // 内存缓存窗口半径
|
||||
@@ -374,7 +374,7 @@ struct RDEPUBReaderConfiguration {
|
||||
## 9. 目录结构
|
||||
|
||||
```
|
||||
Sources/RDReaderView/
|
||||
Sources/RDEpubReaderView/
|
||||
├── EPUBCore/ # EPUB 解析与 WebView 渲染
|
||||
│ ├── Models/ # 数据模型
|
||||
│ │ ├── RDEPUBAnnotationModels.swift
|
||||
@@ -428,19 +428,19 @@ Sources/RDReaderView/
|
||||
│ ├── RDEPUBDTCoreTextRenderer.swift # DTCoreText 渲染器实现
|
||||
│ ├── RDEPUBChapterData.swift # 章节查询门面
|
||||
│ ├── RDEPUBTextIndexTable.swift # 全书索引表
|
||||
│ └── RDPlainTextBookBuilder.swift # 纯文本 (.txt) 构建器
|
||||
│ └── RDEpubPlainTextBookBuilder.swift # 纯文本 (.txt) 构建器
|
||||
│
|
||||
├── ReaderView/ # 通用翻页容器
|
||||
│ ├── Paging/ # 翻页子系统
|
||||
│ │ ├── RDReaderPagingController.swift
|
||||
│ │ ├── RDReaderPreloadController.swift
|
||||
│ │ ├── RDReaderSpreadResolver.swift
|
||||
│ │ └── RDReaderTapRegionHandler.swift
|
||||
│ ├── RDReaderView.swift # 主容器视图
|
||||
│ ├── RDReaderView+*.swift # 扩展(CollectionView/PageCurl/ToolView/ContentAccess)
|
||||
│ ├── RDReaderFlowLayout.swift # 自定义 CollectionView 布局
|
||||
│ ├── RDReaderContentCell.swift # 滚动模式 Cell
|
||||
│ └── RDReaderPageChildViewController.swift // 翻页模式子 VC
|
||||
│ │ ├── RDEpubReaderPagingController.swift
|
||||
│ │ ├── RDEpubReaderPreloadController.swift
|
||||
│ │ ├── RDEpubReaderSpreadResolver.swift
|
||||
│ │ └── RDEpubReaderTapRegionHandler.swift
|
||||
│ ├── RDEpubReaderView.swift # 主容器视图
|
||||
│ ├── RDEpubReaderView+*.swift # 扩展(CollectionView/PageCurl/ToolView/ContentAccess)
|
||||
│ ├── RDEpubReaderFlowLayout.swift # 自定义 CollectionView 布局
|
||||
│ ├── RDEpubReaderContentCell.swift # 滚动模式 Cell
|
||||
│ └── RDEpubReaderPageChildViewController.swift // 翻页模式子 VC
|
||||
│
|
||||
└── EPUBUI/ # 阅读器 UI
|
||||
├── ReaderController/ # 控制器与协调器
|
||||
@@ -494,7 +494,7 @@ Sources/RDReaderView/
|
||||
| **Builder** | `RDEPUBBookPageMap.Builder` 增量构建页码映射 |
|
||||
| **Strategy** | `RDEPUBTextRenderer` 协议,可替换渲染器实现 |
|
||||
| **Pipeline** | `RDEPUBTextTypesetterPipeline` 排版管线(8 个逻辑阶段,封装为 5-6 个顶层调用) |
|
||||
| **Adapter** | `RDReaderLegacyDataSourceAdapter` 适配旧数据源协议 |
|
||||
| **Adapter** | `RDEpubReaderLegacyDataSourceAdapter` 适配旧数据源协议 |
|
||||
| **三级缓存** | 内存 → 磁盘摘要 → 全书分页,逐级降级 |
|
||||
| **Token 取消** | `paginationToken` 确保过期异步任务不干扰新任务 |
|
||||
| **Frozen Parameters** | 后台任务冻结 `renderSignature`,避免运行中参数漂移 |
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# ReadViewSDK 代码审查报告
|
||||
|
||||
> 审查范围:`Sources/RDReaderView/` 全部 184 个 Swift 文件(约 32,300 行)
|
||||
> 审查范围:`Sources/RDEpubReaderView/` 全部 184 个 Swift 文件(约 32,300 行)
|
||||
> 审查维度:并发安全、内存管理、API 正确性、Swift/iOS 平台特定、架构与依赖
|
||||
> 审查日期:2026-06-26
|
||||
|
||||
@@ -134,7 +134,7 @@ let stillChanged = abs(currentSize.width - self.lastTextPaginationPageSize!.widt
|
||||
|
||||
### H-10 · 依赖版本未锁定
|
||||
|
||||
`RDReaderView.podspec` 中 DTCoreText、SnapKit、SSAlertSwift 无版本约束:
|
||||
`RDEpubReaderView.podspec` 中 DTCoreText、SnapKit、SSAlertSwift 无版本约束:
|
||||
|
||||
```ruby
|
||||
s.dependency 'ZIPFoundation', '~> 0.9' # ✅ 已锁定
|
||||
@@ -143,7 +143,7 @@ s.dependency 'SnapKit' # ❌ 无约束
|
||||
s.dependency 'SSAlertSwift' # ❌ 无约束
|
||||
```
|
||||
|
||||
**位置:** `RDReaderView.podspec:17-20`
|
||||
**位置:** `RDEpubReaderView.podspec:17-20`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -298,14 +298,14 @@ return builder.build()
|
||||
|
||||
**触发条件:** `landscapeDualPageEnabled && isLandscape && !verticalScroll`
|
||||
|
||||
**页面配对逻辑(RDReaderSpreadResolver):**
|
||||
**页面配对逻辑(RDEpubReaderSpreadResolver):**
|
||||
|
||||
- 有封面页:封面独占一屏,后续页面两两配对
|
||||
- 配对规则:`(coverIndex, nil)`, `(1, 2)`, `(3, 4)`, ...
|
||||
- 无封面页:标准偶奇配对
|
||||
- 配对规则:`(0, 1)`, `(2, 3)`, `(4, 5)`, ...
|
||||
|
||||
**封面感知布局(RDReaderFlowLayout):**
|
||||
**封面感知布局(RDEpubReaderFlowLayout):**
|
||||
|
||||
```
|
||||
封面页: [====全屏宽度====]
|
||||
@@ -314,7 +314,7 @@ return builder.build()
|
||||
|
||||
### 5.3 页面预加载
|
||||
|
||||
`RDReaderPreloadController` 管理两个缓存:
|
||||
`RDEpubReaderPreloadController` 管理两个缓存:
|
||||
- `preloadedPageViews` - 预渲染的页面视图
|
||||
- `pageCurlCachedViews` - 当前显示的页面视图
|
||||
|
||||
@@ -580,7 +580,7 @@ protocol RDEPUBReaderPersistence {
|
||||
|
||||
## 9. 纯文本 (.txt) 支持
|
||||
|
||||
`RDPlainTextBookBuilder` 复用 EPUB 渲染管线处理 .txt 文件:
|
||||
`RDEpubPlainTextBookBuilder` 复用 EPUB 渲染管线处理 .txt 文件:
|
||||
|
||||
1. **解码:** 依次尝试 UTF-8 → GB18030 → GBK
|
||||
2. **分章:** 正则匹配 `^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$`
|
||||
|
||||
+13
-13
@@ -34,7 +34,7 @@ epubcfi(/6/4!ch01.xhtml/4/2/1:3)
|
||||
|
||||
顶层 CFI 模型,对应一个完整的 `epubcfi(...)` 字符串。
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFI.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFI.swift`
|
||||
|
||||
```swift
|
||||
public struct RDEPUBCFI: Codable, Equatable, Hashable {
|
||||
@@ -66,7 +66,7 @@ public enum RDEPUBCFISideBias: String, Codable {
|
||||
|
||||
路径模型,由一组 `RDEPUBCFIStep` 组成,描述从根节点到目标节点的遍历序列。
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift`
|
||||
|
||||
```swift
|
||||
public struct RDEPUBCFIPath: Codable, Equatable, Hashable {
|
||||
@@ -95,7 +95,7 @@ public struct RDEPUBCFIStep: Codable, Equatable, Hashable {
|
||||
|
||||
范围模型,表示文档中的一个连续区域,由父级 CFI 和起止 CFI 组成。
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift`
|
||||
|
||||
```swift
|
||||
public struct RDEPUBCFIRange: Codable, Equatable, Hashable {
|
||||
@@ -116,7 +116,7 @@ public struct RDEPUBCFIRange: Codable, Equatable, Hashable {
|
||||
|
||||
将 CFI 字符串解析为结构化模型。
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift`
|
||||
|
||||
```swift
|
||||
public enum RDEPUBCFIParser {
|
||||
@@ -154,7 +154,7 @@ print(cfi.textAssertion?.exact) // Optional("目标文本")
|
||||
|
||||
将 CFI 模型序列化为标准字符串。
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift`
|
||||
|
||||
```swift
|
||||
public enum RDEPUBCFISerializer {
|
||||
@@ -192,7 +192,7 @@ let serialized = RDEPUBCFISerializer.serialize(cfi)
|
||||
|
||||
将 CFI 解析为可直接用于资源定位的结果。
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift`
|
||||
|
||||
```swift
|
||||
public struct RDEPUBCFIResolverResult: Equatable {
|
||||
@@ -228,7 +228,7 @@ print(result.chapterOffset) // Optional(80)
|
||||
|
||||
从已知的章节信息生成 CFI。
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift`
|
||||
|
||||
```swift
|
||||
public enum RDEPUBCFIGenerator {
|
||||
@@ -274,7 +274,7 @@ public enum RDEPUBCFIGenerator {
|
||||
|
||||
从 HTML 源码中提取带有 `id` 属性的元素路径映射。
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift`
|
||||
|
||||
```swift
|
||||
public enum RDEPUBCFIDOMPathBuilder {
|
||||
@@ -296,7 +296,7 @@ public enum RDEPUBCFIDOMPathBuilder {
|
||||
|
||||
当 CFI 精确定位失败时(如 DOM 结构变更),恢复引擎通过多级降级策略尝试找到最佳匹配位置。
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift`
|
||||
|
||||
### 5.1 恢复置信度
|
||||
|
||||
@@ -361,7 +361,7 @@ let result = RDEPUBCFIRecoveryEngine.recover(
|
||||
|
||||
文本断言用于在 CFI 定位后验证所指位置的文本内容是否符合预期,增强定位鲁棒性。
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift`
|
||||
|
||||
```swift
|
||||
public struct RDEPUBCFITextAssertion: Codable, Equatable, Hashable {
|
||||
@@ -393,7 +393,7 @@ print(assertion?.suffix) // Optional("后续内容")
|
||||
|
||||
提供宽松解析接口,兼容非标准 CFI 格式。
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFICompatibility.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFICompatibility.swift`
|
||||
|
||||
```swift
|
||||
public enum RDEPUBCFICompatibility {
|
||||
@@ -418,7 +418,7 @@ public enum RDEPUBCFICompatibility {
|
||||
|
||||
CFI 映射是章节级别的索引结构,将 CFI 路径映射到章节文本偏移量,是容错恢复引擎的核心数据源。
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift`
|
||||
|
||||
### 8.1 RDEPUBCFIMap
|
||||
|
||||
@@ -506,7 +506,7 @@ public struct RDEPUBCFITokenAnchor: Codable, Equatable {
|
||||
|
||||
## 9. 错误类型(RDEPUBCFIError)
|
||||
|
||||
**文件**:`Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIError.swift`
|
||||
**文件**:`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIError.swift`
|
||||
|
||||
```swift
|
||||
public enum RDEPUBCFIError: Error, Equatable {
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
- 渐进补全:后台逐步补全所有章节的页码信息
|
||||
|
||||
**关键文件**:
|
||||
- `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/` 目录
|
||||
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift`
|
||||
- `Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/` 目录
|
||||
- `Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift`
|
||||
|
||||
---
|
||||
|
||||
|
||||
+21
-9
@@ -1,6 +1,18 @@
|
||||
# 代码库风险与关注点
|
||||
|
||||
**分析日期:** 2026-05-21
|
||||
**状态复核:** 2026-07-30(逐条对照源码,结论见下表;正文段落保留原始分析,勿当作现状)
|
||||
|
||||
| # | 项目 | 现状 |
|
||||
|---|------|------|
|
||||
| 1 | User Script Sandboxing 被关 | **部分处理**:已移除 Podfile 的全局强制覆盖;但显式打成 `YES` 会让 CocoaPods 的 `[CP] Embed Pods Frameworks` rsync 阶段被沙箱拒绝而构建失败(已实测)。要真正开启需先改掉 `use_frameworks!` 动态框架集成方式。 |
|
||||
| 2 | 外链未做 allowlist | **已修**:`RDEPUBReaderController+ExternalLinks.swift` 有 scheme allowlist + 二次确认弹窗 + delegate 否决。 |
|
||||
| 3 | Zip Slip 未加固 | **已修**:`RDEPUBParser+Archive.swift` 的 `validatedExtractionDestination` 做 standardized 前缀校验并拒绝 `..`/绝对路径。 |
|
||||
| 4 | 高亮/选中文本明文写 UserDefaults | **已修**:新增 `RDEPUBProtectedContentStore`,高亮与书签改写 Application Support 下带 file protection 的文件、排除备份,首次读取自动迁移并删除明文;另加 `clearReadingData(for:)` / `clearAllReadingContent()`。 |
|
||||
| 5 | Scheme handler 整文件读内存 | **原判断有误**:>512KB 早已走 `respondWithStreaming` 64KB 分块。本次补上 `maximumInMemoryResourceBytes`(默认 64MB)给走不了流式的加密 provider 分支兜底。 |
|
||||
| 6 | 分页 WKWebView 可能加载外部资源 | 未复核。 |
|
||||
| 7 | 解压缓存无限累积 | **已修**:`pruneExtractionCache(keeping:)` 先按 `extractionCacheMaximumAge`(30 天)再按 `extractionCacheMaximumTotalBytes`(512MB)做 LRU 淘汰;复用时 touch mtime;另有 `clearExtractionCache()`。 |
|
||||
| 8+ | 其余性能/可维护性项 | 未复核。 |
|
||||
|
||||
## 高风险:安全与隐私
|
||||
|
||||
@@ -105,15 +117,15 @@
|
||||
## Evidence(仓库路径)
|
||||
|
||||
- Build flags:`Podfile`
|
||||
- Pod metadata/toolchain:`RDReaderView.podspec`
|
||||
- Archive extraction:`Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift`
|
||||
- Resource path validation(post-extraction):`Sources/RDReaderView/EPUBCore/RDEPUBParser+Resources.swift`
|
||||
- Scheme handler reads full file bytes:`Sources/RDReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift`
|
||||
- WebView bridge + message handling:`Sources/RDReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift`
|
||||
- External link opening:`Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||
- UserDefaults persistence implementation:`Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
|
||||
- Hidden paginator web view:`Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift`
|
||||
- Debug logging:`Sources/RDReaderView/EPUBCore/RDEPUBWebViewDebug.swift`
|
||||
- Pod metadata/toolchain:`RDEpubReaderView.podspec`
|
||||
- Archive extraction:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Archive.swift`
|
||||
- Resource path validation(post-extraction):`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Resources.swift`
|
||||
- Scheme handler reads full file bytes:`Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift`
|
||||
- WebView bridge + message handling:`Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift`
|
||||
- External link opening:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||
- UserDefaults persistence implementation:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
|
||||
- Hidden paginator web view:`Sources/RDEpubReaderView/EPUBCore/RDEPUBPaginator.swift`
|
||||
- Debug logging:`Sources/RDEpubReaderView/EPUBCore/RDEPUBWebViewDebug.swift`
|
||||
- Vendored dependencies:`Pods/`、`ReadViewDemo/Pods/`
|
||||
|
||||
---
|
||||
|
||||
+23
-23
@@ -6,43 +6,43 @@
|
||||
|
||||
## 语言与工程约束
|
||||
|
||||
- **主要语言**:Swift(Podspec 声明 `s.swift_versions = ["5.10"]`,见 `RDReaderView.podspec`)
|
||||
- **最低系统版本**:Podspec `iOS 15.0`(`RDReaderView.podspec`),示例工程 Podfile/构建设置里常见为 `iOS 15.6`(`Podfile`)
|
||||
- **依赖管理**:CocoaPods(`Podfile`、`ReadViewDemo/Podfile`、`Podfile.lock`)
|
||||
- **主要语言**:Swift(Podspec 声明 `s.swift_versions = ["5.10"]`,见 `RDEpubReaderView.podspec`)
|
||||
- **最低系统版本**:Podspec `iOS 15.0`(`RDEpubReaderView.podspec`),示例工程 Podfile/构建设置里常见为 `iOS 15.6`(`ReadViewDemo/Podfile`)
|
||||
- **依赖管理**:CocoaPods(`ReadViewDemo/Podfile`、`ReadViewDemo/Podfile.lock`)
|
||||
|
||||
## 命名约定
|
||||
|
||||
**文件/类型命名(Swift):**
|
||||
- 以类型名为文件名的单文件组织较常见:`Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`
|
||||
- 以类型名为文件名的单文件组织较常见:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`
|
||||
- 大量使用前缀区分模块域:
|
||||
- `RD...`:阅读器 UI/控制器相关(如 `Sources/RDReaderView/RDReaderView.swift`、`Sources/RDReaderView/RDURLReaderController.swift`)
|
||||
- `RDEPUB...`:EPUB Core/UI/渲染相关(如 `Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||
- Extension 文件使用 `+` 命名:`Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift`
|
||||
- `RD...`:阅读器 UI/控制器相关(如 `Sources/RDEpubReaderView/RDEpubReaderView.swift`、`Sources/RDEpubReaderView/RDEpubURLReaderController.swift`)
|
||||
- `RDEPUB...`:EPUB Core/UI/渲染相关(如 `Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||
- Extension 文件使用 `+` 命名:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift`
|
||||
|
||||
**变量/函数命名:**
|
||||
- 基本遵循 Swift lowerCamelCase:`parse(epubURL:)`(`Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`)
|
||||
- 常量多用 `static let`:`kRDEPUBHighlightAttributeName`(`Sources/RDReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift`)
|
||||
- 基本遵循 Swift lowerCamelCase:`parse(epubURL:)`(`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`)
|
||||
- 常量多用 `static let`:`kRDEPUBHighlightAttributeName`(`Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift`)
|
||||
|
||||
## 代码风格与排版(从现有代码归纳)
|
||||
|
||||
**缩进与换行:**
|
||||
- 多数文件使用 4 空格缩进(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift`、`Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||
- 多数文件使用 4 空格缩进(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift`、`Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||
- 历史代码中更常见”强制换行/多行括号”风格
|
||||
|
||||
**空行与分组:**
|
||||
- UI 相关文件常用空行分隔属性/初始化/布局段落
|
||||
- `// MARK:` 用于分区组织(示例:`Sources/RDReaderView/RDReaderGestureController.swift`、`Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift`)
|
||||
- `// MARK:` 用于分区组织(示例:`Sources/RDEpubReaderView/RDEpubReaderGestureController.swift`、`Sources/RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift`)
|
||||
|
||||
**类型组织:**
|
||||
- 偏好用 `extension` 拆分职责/协议实现(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift` 的 `UITableViewDataSource/Delegate`)
|
||||
- API 暴露处使用 `public`、`public final class`、`public enum/struct`(示例:`Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||
- “对外只读、内部可写”常用 `public internal(set)`(示例:`Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`)
|
||||
- API 暴露处使用 `public`、`public final class`、`public enum/struct`(示例:`Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||
- “对外只读、内部可写”常用 `public internal(set)`(示例:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`)
|
||||
|
||||
## 导入与依赖使用
|
||||
|
||||
**import:**
|
||||
- UIKit/UI 文件:`import UIKit`(大量文件)
|
||||
- Core/模型文件:`import Foundation`(如 `Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||
- Core/模型文件:`import Foundation`(如 `Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`)
|
||||
- 三方依赖按需引入:
|
||||
- `SnapKit`:布局
|
||||
- `SSAlertSwift`:弹窗/提示
|
||||
@@ -52,15 +52,15 @@
|
||||
|
||||
## 错误处理与日志
|
||||
|
||||
- Core 解析层倾向用 `throws` + 自定义 `Error`(示例:`Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`、`Sources/RDReaderView/EPUBCore/RDEPUBModels.swift` 的 `RDEPUBParserError`)
|
||||
- Core 解析层倾向用 `throws` + 自定义 `Error`(示例:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`、`Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift` 的 `RDEPUBParserError`)
|
||||
- UI/控制器层常见 `guard` 早返回(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift`)
|
||||
- 未检测到统一日志框架(未发现专用 logging package/config);出现时以系统 API/局部输出为主(需按具体文件核对)。
|
||||
|
||||
## 注释与文档
|
||||
|
||||
- **项目规则(强约束)**:代码标识符保持英文,但**代码注释/文档/提交信息使用中文**(见 `CONTEXT.md`)。
|
||||
- 历史文件常带 Xcode 头部注释块(示例:`Sources/RDReaderView/ReaderView/RDReaderView.swift`)。
|
||||
- 公共 API 处存在少量三斜线文档注释(示例:`Sources/RDReaderView/RDReaderView.swift` 的中文说明)。
|
||||
- 历史文件常带 Xcode 头部注释块(示例:`Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift`)。
|
||||
- 公共 API 处存在少量三斜线文档注释(示例:`Sources/RDEpubReaderView/RDEpubReaderView.swift` 的中文说明)。
|
||||
|
||||
## Lint / Formatter / 静态检查
|
||||
|
||||
@@ -76,10 +76,10 @@
|
||||
## Evidence(关键证据文件)
|
||||
|
||||
- `CONTEXT.md`
|
||||
- `Podfile`
|
||||
- `RDReaderView.podspec`
|
||||
- `ReadViewDemo/Podfile`
|
||||
- `RDEpubReaderView.podspec`
|
||||
- `ReadViewDemo/ReadViewDemo/ViewController.swift`
|
||||
- `Sources/RDReaderView/ReaderView/RDReaderView.swift`
|
||||
- `Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`
|
||||
- `Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||
- `Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift`
|
||||
- `Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`
|
||||
- `Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`
|
||||
- `Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||
|
||||
@@ -471,7 +471,7 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||
|
||||
## 9. 纯文本构建器
|
||||
|
||||
**文件:** `RDPlainTextBookBuilder.swift`
|
||||
**文件:** `RDEpubPlainTextBookBuilder.swift`
|
||||
|
||||
从纯文本(.txt)文件构建 `RDEPUBTextBook`,用于支持 TXT 格式阅读。
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
let epubURL: URL // EPUB 文件路径
|
||||
let persistence: RDEPUBReaderPersistence? // 持久化代理
|
||||
let dependencies: RDEPUBReaderDependencies // 依赖注入
|
||||
let readerView = RDReaderView() // 翻页容器
|
||||
let readerView = RDEpubReaderView() // 翻页容器
|
||||
}
|
||||
```
|
||||
|
||||
@@ -57,7 +57,7 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `RDEPUBReaderController+PublicAPI.swift` | 公开 API(跳转、搜索、标注、书签) |
|
||||
| `RDEPUBReaderController+DataSource.swift` | `RDReaderPageProvider` 实现 |
|
||||
| `RDEPUBReaderController+DataSource.swift` | `RDEpubReaderPageProvider` 实现 |
|
||||
| `RDEPUBReaderController+ContentDelegates.swift` | WebView/TextContentView 代理路由 |
|
||||
| `RDEPUBReaderController+LocationResolution.swift` | 页码/位置解析、阅读状态同步 |
|
||||
| `RDEPUBReaderController+ExternalLinks.swift` | 外部链接处理(白名单、确认弹窗) |
|
||||
@@ -120,7 +120,7 @@ public protocol RDEPUBReaderPersistence: AnyObject {
|
||||
```swift
|
||||
final class RDEPUBReaderContext {
|
||||
weak var controller: RDEPUBReaderController?
|
||||
weak var readerView: RDReaderView?
|
||||
weak var readerView: RDEpubReaderView?
|
||||
var dependencies: RDEPUBReaderDependencies
|
||||
var runtime: RDEPUBReaderRuntime?
|
||||
|
||||
@@ -155,7 +155,7 @@ public struct RDEPUBReaderDependencies {
|
||||
public var makeParser: () -> RDEPUBParser
|
||||
public var makePaginator: () -> RDEPUBPaginator
|
||||
public var makeTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder
|
||||
public var makePlainTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder
|
||||
public var makePlainTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDEpubPlainTextBookBuilder
|
||||
public var makeTextRenderer: (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer
|
||||
|
||||
public static var live: RDEPUBReaderDependencies // 默认实现
|
||||
@@ -659,9 +659,9 @@ struct RDEPUBReaderUIState {
|
||||
|
||||
页图协调器(按需分页与全量分页的结果合并)。
|
||||
|
||||
### 10.6 RDURLReaderController
|
||||
### 10.6 RDEpubURLReaderController
|
||||
|
||||
**文件:** `RDURLReaderController.swift`
|
||||
**文件:** `RDEpubURLReaderController.swift`
|
||||
|
||||
URL 阅读器控制器(用于打开单个 URL)。
|
||||
|
||||
@@ -715,12 +715,12 @@ RDEPUBReaderRuntime.applyParsedPublication()
|
||||
│
|
||||
├── RDEPUBBookPageMap 生成
|
||||
│
|
||||
└── RDReaderView.transitionToPage() → 显示页面
|
||||
└── RDEpubReaderView.transitionToPage() → 显示页面
|
||||
|
||||
用户翻页
|
||||
│
|
||||
▼
|
||||
RDReaderView.currentPage 变化
|
||||
RDEpubReaderView.currentPage 变化
|
||||
│
|
||||
├── RDEPUBReaderLocationCoordinator.recordPageChangeIfNeeded()
|
||||
│ └── persistence.saveLocation()
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# 长章节内存优化 — 代码实施清单
|
||||
|
||||
> 背景:DTCoreText 文本页已采用"整章上下文布局",分页与显示边界一致(不可回退)。现状代价:每次翻页/预加载都整章重拷贝 + 重排版后丢弃,且章节缓存里每页常驻一份子串副本。
|
||||
>
|
||||
> 总原则:保留"整章参与排版",去掉"整章按页复制、按页可变、按页缓存"。不改分页器,不上窗口化。
|
||||
|
||||
---
|
||||
|
||||
## P0:立即可做
|
||||
|
||||
### P0-1 削减每页 `content` 子串常驻副本
|
||||
|
||||
**现状**:分页产物为每页保存整段 `attributedSubstring`,全章加总约等于又一份整章副本,随邻接窗口(radius 1,共 3 章)常驻。
|
||||
|
||||
生成点:
|
||||
|
||||
- `Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift:494`(`buildPagesFromRanges`)
|
||||
- `Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift:294`
|
||||
- `Sources/RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift:78`
|
||||
|
||||
现存全部消费点(已盘点确认,仅 3 处):
|
||||
|
||||
- `RDEPUBTextContentView.swift:562` — 封面检测 `coverImage(from: page.content)`,仅 `pageIndexInChapter == 0` 且 href 含 "cover"
|
||||
- `RDEPUBTextContentView.swift:641` — `normalizedPageContent`,仅非 DTCoreText 的 `#else` 回退路径调用
|
||||
- `RDEPUBTextBookBuilder.swift:93` — 构建期 debug 日志 preview
|
||||
|
||||
**任务**:
|
||||
|
||||
- [x] 将 `RDEPUBTextPage.content` 改为按需构造:已改为基于 `chapterContent` + `contentRange` 的计算属性(带范围钳制),公开读取 API 不变
|
||||
- [x] 三个构造点(`RDEPUBChapterLoader` / `RDEPUBTextBookBuilder` / `RDEpubPlainTextBookBuilder`)不再生成每页子串;消费点经计算属性透明按需构造
|
||||
- [x] `Equatable` 确认:`content` 为派生值,由 `contentRange` + `chapterContent` 判等覆盖,语义不变
|
||||
|
||||
**API 兼容性注意**:
|
||||
|
||||
- `RDEPUBTextPage` 当前是 `public struct`,`content` 也是公开存储属性;如果 SDK 已存在外部接入方直接读取 `page.content`,则不应直接移除该字段
|
||||
- 优先方案是保留 `content` 对外访问语义,但将其改为按需构造的计算属性或受控访问器,避免对外 API 断裂
|
||||
- 若确认当前版本尚无稳定外部依赖,才可以评估把 `content` 从存储属性调整为内部实现细节
|
||||
- 在实施前应补做一次全仓库与接入侧检索,确认是否有外部调用依赖 `page.content` 的“可变存储”特性
|
||||
|
||||
**验收**:打开 3 章窗口后常驻内存下降约"一整章文本规模 × 窗口章数";封面页显示正常;非 DTCoreText 回退路径回归通过;`--demo-pagination-validate` 命中数不回升。
|
||||
|
||||
### P0-2 增加内存打点
|
||||
|
||||
- [x] 已新增 `RDEPUBMemoryProbe`(`--demo-memory-probe` 启动参数开启,输出 phys_footprint);挂载点:`RDEPUBChapterRuntimeStore.insertChapter`、`pageNum` 回调每 20 次翻页、`invalidateAllForSettingsChange`、`viewWillTransition` 转屏完成
|
||||
- [x] 用 `--demo-memory-probe` 运行 Demo 即可采集对比基线
|
||||
|
||||
### P0-3 保持现有缓存限制,防止错误复用
|
||||
|
||||
- [x] `shouldAvoidReaderPageCaching`(`RDEPUBTextContentView.swift:319`)保持现状,已补注释说明放开的前置条件是 P1-1 落地
|
||||
- [x] 本阶段未改 `RDEpubReaderPreloadController` 行为
|
||||
|
||||
---
|
||||
|
||||
## P1:第二波(P1-1 与 P1-2 必须同批落地)
|
||||
|
||||
### P1-1 章节级共享 displayContent
|
||||
|
||||
**现状**:`RDEPUBTextContentView.configure` 每次创建整章可变副本(`RDEPUBTextContentView.swift:443`)并按页写入三类属性:页范围主题色(453)、页范围暗黑图附件替换(448)、按页裁剪的高亮属性(459 `applyHighlightsToContent`)。
|
||||
|
||||
**约束**:共享字符串一旦被任一页的 `DTCoreTextLayouter`/framesetter 持有,就不得再修改——所有属性必须在共享内容构建时一次性注入,或转为 overlay(见 P1-2)。
|
||||
|
||||
**任务**:
|
||||
|
||||
- [x] 已新建 `RDEPUBChapterDisplayContentCache`(LRU 容量 2,主线程限定,`RDEPUBReaderController` 持有):`content` + `layouter` + `signature`
|
||||
- [x] 构建共享内容时一次性注入:全章范围主题色;高亮/下划线因 P1-2 转 overlay,不进共享内容也不进签名
|
||||
- [x] 暗黑图策略选 a:构建时全章一次性替换,复用 `RDEPUBDarkImageAdjuster` 的 NSCache
|
||||
- [x] `RDEPUBTextContentView.configure` 已改为接收 `displayCache` 参数并引用共享 entry,整章拷贝已删除
|
||||
- [x] 签名 = 章节内容对象标识 + 长度 + 主题双色 + 暗黑图配置;设置/主题变化经签名自动失效重建(高亮变化不触发重建,仅重建 overlay 装饰)
|
||||
- [x] 控制器在 `didReceiveMemoryWarning` 清空 display cache;设置变更重建 chapterContent 实例后由签名兜底换代
|
||||
|
||||
**验收**:连续翻页不再出现整章拷贝(打点确认);翻页延迟下降;高亮显示、高亮点击菜单、underline、搜索 currentMatch、附件点击、选区拖拽全部回归通过。
|
||||
|
||||
> 2026-07-08 回归记录:高亮/批注/选区/翻页/设置 13 项 UI 用例全过;SearchTests 10 项失败,但已在会话前提交 5a41066 与 P0 提交 e4e629a 上复现同样失败(搜索 0 命中),确认为先于本优化存在的独立回归(最后一次已知通过是 2026-06-08 全量跑),需单独排查,与 P1 改动无关。
|
||||
|
||||
### P1-2 高亮/下划线剥离为 overlay(与 P1-1 绑定)
|
||||
|
||||
**现状**:搜索高亮已走 overlay(`RDEPUBTextContentView.swift:759` 处 `buildDecorations` 传 `highlights: []`,注释明确为避免双画);普通高亮/下划线仍靠写 `kRDEPUBHighlightAttributeName` / `kRDEPUBUnderlineAttributeName` 属性由 render view 绘制。
|
||||
|
||||
**任务**:
|
||||
|
||||
- [x] `buildDecorations` 已传入真实 highlights,高亮进背景层(文字下方)、下划线进前景层
|
||||
- [x] `applyHighlightsToContent` 与 render view 的 `drawHighlights`/`computeHighlightRects`/`attributedDisplayContent` 已整体移除,无双画
|
||||
- [x] 高亮命中与菜单锚点继续走 `interactionController.selectionRects`,坐标保持 chapter-absolute(未改动)
|
||||
|
||||
**验收**:高亮/下划线视觉与改前一致(含跨页高亮的页内裁剪);高亮点击弹菜单正常;与搜索高亮叠加时无双画。
|
||||
|
||||
### P1-3 章节级共享 display layouter
|
||||
|
||||
**现状**:每个页面视图各建一个 `DTCoreTextLayouter`(`RDEPUBTextContentView.swift:465`);章节运行时已有分页用 `RDEPUBTextLayouter`(`RDEPUBRuntimeChapter.layouter`),display layouter 向同一模式靠拢。
|
||||
|
||||
**任务**:
|
||||
|
||||
- [x] `DTCoreTextLayouter` 已随共享内容放入 display cache,与 signature 同生命周期
|
||||
- [x] 页面按 `bounds` + `page.contentRange` 取 `layoutFrame`;`shouldCacheLayoutFrames = false` 保持,layoutFrame 按页新建(VerticalJustifier 的修改不会污染共享对象)
|
||||
- [x] framesetter 只依赖字符串、与 bounds 无关,bounds 变化无需失效共享对象;layoutFrame 随页面视图释放
|
||||
- [x] `RDEPUBTextPageBoundaryValidator` 校验路径不变
|
||||
|
||||
**验收**:翻页时不再重建 framesetter(打点确认);横竖屏切换后布局正确;`--demo-pagination-validate` 不回升。
|
||||
|
||||
### P1-4 评估重新放开文本页预加载缓存
|
||||
|
||||
前置:P1-1~P1-3 全部落地后,页面视图不再持有整章副本。
|
||||
|
||||
- [ ] 调整 `shouldAvoidReaderPageCaching` 判定,允许轻量化后的文本页进入 `RDEpubReaderPreloadController` 缓存
|
||||
- [ ] 对比放开前后的翻页流畅度与峰值内存,数据不佳则回退此项
|
||||
|
||||
---
|
||||
|
||||
## P2:视 P0/P1 打点数据决定
|
||||
|
||||
### P2-1 章节级 LRU 分层淘汰
|
||||
|
||||
**现状**:`RDEPUBChapterRuntimeStore` 已有邻接窗口(`windowSpineIndices`,radius 1)、`evictableSpineIndices` / `evict` / `evictAllExceptCurrent` / `handleMemoryWarning` / `invalidateAllForSettingsChange`;磁盘侧有 `RDEPUBChapterSummaryDiskCache`(分页结果 + cfiMap + metadata)。当前邻接章节仍持有完整 `RDEPUBRuntimeChapter`(typesetString + pages + layouter)。
|
||||
|
||||
**任务**:
|
||||
|
||||
- [ ] 邻接章节降级:保留 page map / chapter summary / 分页结果,释放 `typesetAttributedString`、display content、layouter
|
||||
- [ ] 进入邻接章节时按 summary 重建,测量重建延迟;延迟不可接受则维持现状
|
||||
- [ ] 远距章节仅保留磁盘缓存 + 轻量 metadata(现有 evict 已覆盖,确认即可)
|
||||
|
||||
### P2-2 图片附件深化
|
||||
|
||||
**现状**:暗黑图已走 `NSCache`(50 MB / 100 张,`RDEPUBDarkImageAdjuster.swift:9`)且仅处理当前页范围;章节运行时另有 100 MB `imageCache`。
|
||||
|
||||
**任务**:
|
||||
|
||||
- [ ] 大图按显示尺寸下采样后再参与 attachment
|
||||
- [ ] 页面离屏后释放 attachment 强引用的已解码大图;避免共享 content 长期持有
|
||||
- [ ] 回归:图片点击查看、暗黑模式切换、附件位置与命中
|
||||
|
||||
---
|
||||
|
||||
## P3:仅在 P0–P2 之后数据仍不达标时
|
||||
|
||||
- [ ] 上下文窗口化布局(当前页所在段落块 + 前后足以影响断行的文本窗口)。风险最高:易重新引入分页/显示边界不一致(CTTypesetter 断行上下文敏感,为已知已解 bug 的根因),选区/搜索/高亮/附件索引需全部重验。默认不做。
|
||||
|
||||
---
|
||||
|
||||
## 明确不做
|
||||
|
||||
1. 回退"页内子串重新布局"(重新引入显示不全/页末孤字)
|
||||
2. 取消 chapter-absolute 索引(破坏点击/选区/高亮定位一致性)
|
||||
3. 先改分页器(正确性与性能问题混杂,无法回归)
|
||||
4. 未落地 P1 前放开文本页 reader 级缓存(会驻留多份整章副本)
|
||||
|
||||
---
|
||||
|
||||
## 各阶段统一验收指标
|
||||
|
||||
**正确性**:`--demo-pagination-validate` 命中数不回升;长章节页末无孤字/缺字;高亮显示、搜索高亮、附件点击、选区拖拽行为一致。
|
||||
|
||||
**内存**(对比 P0-2 基线):打开长章节后常驻;连续翻页 20 页峰值;设置变更前后峰值;横竖屏切换峰值。
|
||||
|
||||
**性能**:首次打开章节耗时;连续翻页帧稳定性;设置切换恢复时间。
|
||||
@@ -0,0 +1,123 @@
|
||||
# 分页问题调查记录(2026-07-06)
|
||||
|
||||
调查载体:《宝山辽墓材料与释读》(textReflowable / DTCoreText 路径),iPhone 15 Pro Max 尺寸(430×932,内容区 398×815pt)。当日共调查两个独立问题:
|
||||
|
||||
---
|
||||
|
||||
## 问题一:页底留空一行多,下一页首行未上移(已修复)
|
||||
|
||||
### 现象
|
||||
|
||||
第 6 页底部留有约 1.7 行高的空白,第 7 页首行"人目为帝羓,信有之也。[注]"本可容纳在第 6 页。截图实测:行距 75px@3x(25pt),第 6 页底部空隙 138px,足够再排一行。
|
||||
|
||||
### 根因链
|
||||
|
||||
1. `RDEPUBSemanticMarkerInjector.inferredHints` 给**所有** `<img>` 打上 `avoidPageBreakInside` 提示——包括行内脚注小图标(`<img class="qqreader-footnote">`,即"注"字图标)。
|
||||
2. `RDEPUBPageBreakPolicy.shouldTreatAvoidHintAsBlockProtection` 本有豁免:`blockKind == .attachment && placement != .centered → 不保护`。但 `applyPaginationSemantics` 按**结束标记的字符串顺序**应用属性:`<img>` 的结束标记先于外层 `<p>`,段落随后把图标位置的 `.rdPageBlockKind` 从 `attachment` 覆盖为 `paragraph`;而 `hints` 与 `placement` 因段落不携带这两个属性而幸存。豁免条件因此失效。
|
||||
3. `trimmedRangeForAvoidPageBreakInside` 把含注图标的行当作"不可分页块"的行,从页底裁掉(最多 3 行),推到下一页,留下空白。
|
||||
|
||||
### 验证手段
|
||||
|
||||
- Demo 启动参数 `--demo-pagination-debug` 输出 `[PAGINATION-DEBUG] avoidPageBreakInside removed N lines: …`;修复前被裁的行**全部**含 ``(脚注图标)。
|
||||
- 复现命令:`--demo-book-title 宝山 --demo-page 6 --demo-reset-state --demo-clear-cache --demo-pagination-debug`。
|
||||
|
||||
### 修复
|
||||
|
||||
`RDEPUBPageBreakPolicy.swift`:豁免条件放宽为 `(blockKind == .attachment || placement != nil) && placement != .centered`——不再依赖易被覆盖的 blockKind,只要附件 placement 是行内/基线(非居中)就不锁行。居中插图(bodyPic)的整块保护不受影响;同时覆盖 `s-pic`/`h-pic`/`g-pic` 等生僻字行内小图。
|
||||
|
||||
修复后验证:含 `` 的裁剪从多处降为 0;"人目为帝羓"行回到第 6 页且下一段首行补齐;全书页数 280 → 273(消除欠填页)。
|
||||
|
||||
> 备选方案(未采用):修 `applyPaginationSemantics` 的嵌套覆盖顺序(内层优先)。会改变列表/表格的 blockRange 语义,风险大。
|
||||
|
||||
---
|
||||
|
||||
## 问题二:页范围与显示内容度量错配,行中断页(2026-07-07 已定位根因,见文末新增章节)
|
||||
|
||||
### 现象
|
||||
|
||||
第 10 页最后一行只有孤字"相",第 11 页从"对简化,且无构造细致的翼墙。"开始——断点落在句子中间而非行边界。
|
||||
|
||||
### 铁证推理
|
||||
|
||||
当前设置下满行 26 字(字号 15、内容宽 398pt)。第 10 页范围要在"…而2号墓的门楼则相"后断开,要求该行装下 **27 字**——当前排版下不可能。**结论:页范围产生于另一套度量(更小字号或更宽版面),与显示时的排版不一致。** 同一套排版中分页断点必然落在行边界,孤字不可能出现。
|
||||
|
||||
### 已排除(受控实验)
|
||||
|
||||
在 `RDEPUBChapterLoader` 临时加 `--demo-chapter-dump` 转储(typesetString 逐属性段 + 页范围,写入 app Documents,实验后已还原):
|
||||
|
||||
- 冷启动(`MISS(fullRender)` 全量渲染分页)与热启动(`HIT(diskSummary)` 复用磁盘页范围)两条路径的字符串与页范围**完全一致**。字体压平、双重 `normalizeReadingAttributes`、语言码缺失(`makeChapterRenderRequest` 未传 `contentLanguageCode`)、`TailNormalizer` 尾页合并均验证为无影响或幂等。
|
||||
- 持久缓存键控正确:`RDEPUBChapterCacheKey.renderSignature` 含字体名/字号/行距倍数/lineSpacing/`layoutConfig.cacheSignature`(宽高、四边距、分栏、hyphenation、schemaVersion=17)+ 章节内容哈希。同设置跨启动复用安全。
|
||||
|
||||
### 主要嫌疑:会话中改设置的换表过渡态
|
||||
|
||||
- 用户两组截图之间调过行距(行距 25pt/页 32 行 → 28pt/页 28 行,字号未变);出现问题时显示总页数 196,既非 1.6 档全量分页(~273)也非 1.8 档(~290)——当时显示的是**未收敛的中间页表**。
|
||||
- 代码窗口:
|
||||
- `RDEPUBReaderRuntime.scheduleSettingsPreviewRepagination`:设置面板打开期间只重排**当前章**并 `applySettingsPreviewPageMap` 换部分页表,全量重排推迟到面板关闭(`needsFullRepaginationAfterSettingsClose`)。
|
||||
- `RDEPUBChapterRuntimeStore.chapterDataCache` 仅按 spineIndex 键控(无样式维度);`invalidateAllForSettingsChange` 清空后,**变更前已在飞行中的构建**完成时会把旧样式章节重新 `insertChapter` 插回。
|
||||
- 这些窗口里可能出现"旧页范围 + 新排版内容"的错配。
|
||||
|
||||
### 待办 / 复现所需
|
||||
|
||||
1. 确认触发操作:是否在设置面板调整行距/字号后(未关面板或刚关)翻页出现。
|
||||
2. 确认重启 app 后是否自愈(受控实验表明缓存路径本身一致,理应自愈;若不自愈则有持久化的脏状态)。
|
||||
3. 修复方向(待复现后定):设置变更代(generation)标记贯穿构建流程,飞行中的旧代构建完成后丢弃、不得插回 store;或 `chapterDataCache` 键加入 renderSignature。
|
||||
|
||||
### 经验教训
|
||||
|
||||
- 跨启动比较"第 N 页"截图无效:章节渐进加载过程中全局页码会漂移。
|
||||
- 判断断点是否合法的快捷方法:数满行字数。断点不在行边界 ⟺ 范围与显示度量不一致。
|
||||
|
||||
---
|
||||
|
||||
## 相关工具与命令备忘
|
||||
|
||||
| 用途 | 方法 |
|
||||
|---|---|
|
||||
| 分页裁剪日志 | 启动参数 `--demo-pagination-debug` |
|
||||
| 自动打开书籍/翻页 | `--demo-book-title 宝山 --demo-page N --demo-clear-cache --demo-reset-state` |
|
||||
| 注入阅读设置 | `simctl spawn <sim> defaults write cn.shen.ReadViewDemo ssreader.epub.settings -data <hex(JSON)>`,JSON 形如 `{"fontSize":15,"lineHeightMultiple":1.8}` |
|
||||
| 截屏 | `xcrun simctl io <sim> screenshot <绝对路径>`(相对路径会因只读文件系统失败) |
|
||||
| 模拟器 | 预装仅 16/17 系列;`simctl create` 可建 iPhone 15 Pro Max(430×932@3x 与问题截图同尺寸) |
|
||||
| 页边界/换行一致性校验 | 启动参数 `--demo-pagination-validate`(RDEPUBTextPageBoundaryValidator,逐页比对显示换行与全章上下文换行,输出 STALE-RANGE / DISPLAY-DIVERGE / DIAGNOSE / ATTR-DIFF) |
|
||||
| 设置面板自动翻转 | 启动参数 `--demo-settings-flip`(RDEPUBSettingsFlipAutomation,自动开面板→改行距→关面板→翻页,三种时序) |
|
||||
|
||||
---
|
||||
|
||||
## 问题二根因(2026-07-07 续查确认)
|
||||
|
||||
### 结论
|
||||
|
||||
**分页与显示使用同一引擎(DTCoreText/CoreText)但输入字符串不同:分页在“全章字符串”上下文中断行;显示把页内容取成子串后重新断行。`CTTypesetterSuggestLineBreak` 在同一文字、同一属性、同一宽度下,会因字符串上下文不同而在 CJK 标点压缩/悬挂处产生 ±1 字的断点漂移。** 页范围(全章上下文产物)与显示换行(子串产物)在页内任何一行发生漂移,累积到页尾就出现孤字(用户看到的「相」)或半空行。
|
||||
|
||||
与缓存、设置换表竞态无关:设置变更只是**搬动了页边界位置**,让某个边界恰好落在漂移点上,症状因此看似随设置变化出现/消失。这同时解释了此前所有观察:冷/热启动转储完全一致(分页自身是一致的,错配发生在分页 vs 显示);重启后"第 N 页"不复现(边界挪走了)。
|
||||
|
||||
### 证据(宝山书 spine 3,字号 15 / 行距 1.6 / 宽 398pt)
|
||||
|
||||
`--demo-pagination-validate` 基线运行(无任何设置操作)即命中,前 8 页中 5 页分叉。决定性样本 page 8/55 第 11 行(行首 offset 4224):
|
||||
|
||||
- 显示端断点 4252(行尾"…可以相"——孤字机制当场复现),全章上下文断点 4253("…可以相当")
|
||||
- 逐字符属性 diff:**完全一致**(无 ATTR-DIFF)
|
||||
- 对照排版:原始子串 = 4252,段落级上下文 = 4252,**只有全章上下文 = 4253**
|
||||
- page 6/55 的上下文探针行宽 408.39 > 框宽 398 —— 标点悬挂/压缩越界的直接证据
|
||||
|
||||
即断点差异既不是首行缩进归一化(`normalizedPageContent` 的 firstLineHeadIndent 处理本身是正确的),也不是属性差异,而是 CoreText 断行对字符串上下文(跨段落!)敏感。
|
||||
|
||||
### 修复(方案 A,2026-07-07 已实施)
|
||||
|
||||
显示端改为**全章上下文排版**:`RDEPUBTextContentView.configure` 取 `page.chapterContent` 的整章副本作为显示内容,`DTCoreTextLayouter(章节副本).layoutFrame(bounds, range: page.contentRange)` 只排当页范围。断行与分页器按构造一致(同串、同起点、同宽)。
|
||||
|
||||
配套改动:
|
||||
|
||||
- layoutFrame 的 stringRange 因此为**章节绝对坐标**。`RDEPUBPageLayoutSnapshot.build` 去掉 +pageStartOffset 归一(本就输出绝对坐标给消费方),`RDEPUBPageInteractionController` 去掉全部 -pageOffset 反换算;选区/高亮/点击测试/装饰层的消费接口本来就是绝对坐标,无需变动。
|
||||
- 主题前景色、暗色图片调整(`RDEPUBDarkImageAdjuster` 新增 `in range:` 参数)、高亮标记只施加在章节副本的当页范围上;`applyHighlightsToContent` 改用绝对 overlap 范围。
|
||||
- 删除 DTCoreText 路径对 `normalizedPageContent` 的调用——续段首行缩进/段前距归一化 hack 由上下文排版天然取代(非 DTCoreText 回退路径仍保留)。
|
||||
- layouter(framesetter)随显示内容缓存于 cell(`coreTextLayouter`),bounds 变化只重建 layoutFrame,避免每次 layout pass 对整章重建 framesetter。
|
||||
- `RDEPUBTextPageBoundaryValidator` 适配绝对坐标,保留为回归警报。
|
||||
|
||||
验证:`--demo-pagination-validate` 基线(原先 spine 3 前 8 页 5 处分叉)修复后 **0 命中**;第 10 页首行断点与分页一致(「…使用了更多」);Selection/Annotation UI 测试回归通过(见下)。
|
||||
|
||||
### 遗留(独立的潜在缺陷,本次未触发)
|
||||
|
||||
- `RDEPUBChapterRuntimeStore.chapterDataCache` 仅按 spineIndex 键控;设置变更时飞行中的旧构建完成后仍会插回(`RDEPUBChapterLoader.loadChapter` 的 `insertChapter` 无代际校验)。
|
||||
- `RDEPUBChapterLoader.pendingLoads` 按 spineIndex 合流,跨设置变更合流会把旧样式章节交给新请求。
|
||||
- 建议修复问题二后用 `--demo-settings-flip` + `--demo-pagination-validate` 回归验证这两个窗口。
|
||||
@@ -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 灰度指标稳定。
|
||||
|
||||
@@ -6,54 +6,54 @@
|
||||
|
||||
## 1. 模块概述
|
||||
|
||||
`ReaderView` 是 ReadViewSDK 的**通用翻页容器层**,位于 EPUBUI 层之下。它提供与 EPUB 内容无关的页面展示、翻页动画、手势识别、页面预加载和双页布局能力。上层通过 `RDReaderPageProvider` 协议提供页面内容视图,ReaderView 负责容器管理和翻页调度。
|
||||
`ReaderView` 是 ReadViewSDK 的**通用翻页容器层**,位于 EPUBUI 层之下。它提供与 EPUB 内容无关的页面展示、翻页动画、手势识别、页面预加载和双页布局能力。上层通过 `RDEpubReaderPageProvider` 协议提供页面内容视图,ReaderView 负责容器管理和翻页调度。
|
||||
|
||||
**文件清单(14 个 Swift 文件):**
|
||||
|
||||
| 文件 | 核心类型 | 职责 |
|
||||
|------|----------|------|
|
||||
| `RDReaderView.swift` | `RDReaderView` | 主容器视图,协调所有子组件 |
|
||||
| `RDReaderViewProtocols.swift` | 协议 + 枚举 | 数据源、代理、导航协议定义 |
|
||||
| `RDReaderFlowLayout.swift` | `RDReaderFlowLayout` | UICollectionView 自定义布局 |
|
||||
| `RDReaderGestureController.swift` | `RDReaderGestureController` | 手势控制器(预留) |
|
||||
| `RDReaderContentCell.swift` | `RDReaderContentCell` | CollectionView 内容 Cell |
|
||||
| `RDReaderPageChildViewController.swift` | `RDReaderPageChildViewController` | PageCurl 模式子 VC |
|
||||
| `RDReaderView+PageCurl.swift` | Extension | UIPageViewController 数据源/代理 |
|
||||
| `RDReaderView+CollectionView.swift` | Extension | UICollectionView 数据源/布局代理 |
|
||||
| `RDReaderView+ContentAccess.swift` | Extension | 内容视图访问与复用 |
|
||||
| `RDReaderView+ToolView.swift` | Extension | 工具栏安装与动画 |
|
||||
| `Paging/RDReaderPagingController.swift` | `RDReaderPagingController` | 翻页状态机 |
|
||||
| `Paging/RDReaderPreloadController.swift` | `RDReaderPreloadController` | 页面预加载与缓存 |
|
||||
| `Paging/RDReaderSpreadResolver.swift` | `RDReaderSpreadResolver` | 双页展开计算 |
|
||||
| `Paging/RDReaderTapRegionHandler.swift` | `RDReaderTapRegionHandler` | 点击区域判定 |
|
||||
| `RDEpubReaderView.swift` | `RDEpubReaderView` | 主容器视图,协调所有子组件 |
|
||||
| `RDEpubReaderViewProtocols.swift` | 协议 + 枚举 | 数据源、代理、导航协议定义 |
|
||||
| `RDEpubReaderFlowLayout.swift` | `RDEpubReaderFlowLayout` | UICollectionView 自定义布局 |
|
||||
| `RDEpubReaderGestureController.swift` | `RDEpubReaderGestureController` | 手势控制器(预留) |
|
||||
| `RDEpubReaderContentCell.swift` | `RDEpubReaderContentCell` | CollectionView 内容 Cell |
|
||||
| `RDEpubReaderPageChildViewController.swift` | `RDEpubReaderPageChildViewController` | PageCurl 模式子 VC |
|
||||
| `RDEpubReaderView+PageCurl.swift` | Extension | UIPageViewController 数据源/代理 |
|
||||
| `RDEpubReaderView+CollectionView.swift` | Extension | UICollectionView 数据源/布局代理 |
|
||||
| `RDEpubReaderView+ContentAccess.swift` | Extension | 内容视图访问与复用 |
|
||||
| `RDEpubReaderView+ToolView.swift` | Extension | 工具栏安装与动画 |
|
||||
| `Paging/RDEpubReaderPagingController.swift` | `RDEpubReaderPagingController` | 翻页状态机 |
|
||||
| `Paging/RDEpubReaderPreloadController.swift` | `RDEpubReaderPreloadController` | 页面预加载与缓存 |
|
||||
| `Paging/RDEpubReaderSpreadResolver.swift` | `RDEpubReaderSpreadResolver` | 双页展开计算 |
|
||||
| `Paging/RDEpubReaderTapRegionHandler.swift` | `RDEpubReaderTapRegionHandler` | 点击区域判定 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 协议定义(RDReaderViewProtocols.swift)
|
||||
## 2. 协议定义(RDEpubReaderViewProtocols.swift)
|
||||
|
||||
### 2.1 RDReaderDataSource(旧版数据源,已废弃)
|
||||
### 2.1 RDEpubReaderDataSource(旧版数据源,已废弃)
|
||||
|
||||
```swift
|
||||
@objc public protocol RDReaderDataSource: NSObjectProtocol {
|
||||
func pageCountOfReaderView(readerView: RDReaderView) -> Int
|
||||
func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView
|
||||
func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String?
|
||||
@objc optional func topToolView(readerView: RDReaderView) -> UIView?
|
||||
@objc optional func bottomToolView(readerView: RDReaderView) -> UIView?
|
||||
@objc public protocol RDEpubReaderDataSource: NSObjectProtocol {
|
||||
func pageCountOfReaderView(readerView: RDEpubReaderView) -> Int
|
||||
func pageContentView(readerView: RDEpubReaderView, pageNum: Int, containerView: UIView?) -> UIView
|
||||
func pageIdentifier(readerView: RDEpubReaderView, pageNum: Int) -> String?
|
||||
@objc optional func topToolView(readerView: RDEpubReaderView) -> UIView?
|
||||
@objc optional func bottomToolView(readerView: RDEpubReaderView) -> UIView?
|
||||
}
|
||||
```
|
||||
|
||||
> 向后兼容保留,新代码应使用 `RDReaderPageProvider`。
|
||||
> 向后兼容保留,新代码应使用 `RDEpubReaderPageProvider`。
|
||||
|
||||
### 2.2 RDReaderPageProvider(推荐数据源)
|
||||
### 2.2 RDEpubReaderPageProvider(推荐数据源)
|
||||
|
||||
```swift
|
||||
@objc public protocol RDReaderPageProvider: NSObjectProtocol {
|
||||
func numberOfPages(in readerView: RDReaderView) -> Int
|
||||
func readerView(_ readerView: RDReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView
|
||||
@objc optional func pageIdentifier(in readerView: RDReaderView, index: Int) -> String?
|
||||
@objc optional func readerViewTopChrome(_ readerView: RDReaderView) -> UIView?
|
||||
@objc optional func readerViewBottomChrome(_ readerView: RDReaderView) -> UIView?
|
||||
@objc public protocol RDEpubReaderPageProvider: NSObjectProtocol {
|
||||
func numberOfPages(in readerView: RDEpubReaderView) -> Int
|
||||
func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView
|
||||
@objc optional func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String?
|
||||
@objc optional func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView?
|
||||
@objc optional func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView?
|
||||
}
|
||||
```
|
||||
|
||||
@@ -65,31 +65,31 @@
|
||||
| `readerViewTopChrome(_:)` | 返回顶部工具栏视图 |
|
||||
| `readerViewBottomChrome(_:)` | 返回底部工具栏视图 |
|
||||
|
||||
### 2.3 RDReaderDelegate
|
||||
### 2.3 RDEpubReaderDelegate
|
||||
|
||||
```swift
|
||||
@objc public protocol RDReaderDelegate: NSObjectProtocol {
|
||||
func pageNum(readerView: RDReaderView, pageNum: Int)
|
||||
@objc optional func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool)
|
||||
@objc public protocol RDEpubReaderDelegate: NSObjectProtocol {
|
||||
func pageNum(readerView: RDEpubReaderView, pageNum: Int)
|
||||
@objc optional func readerViewOrientationWillChange(readerView: RDEpubReaderView, isLandscape: Bool)
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 RDReaderPageNavigating
|
||||
### 2.4 RDEpubReaderPageNavigating
|
||||
|
||||
```swift
|
||||
public protocol RDReaderPageNavigating: AnyObject {
|
||||
public protocol RDEpubReaderPageNavigating: AnyObject {
|
||||
var currentPage: Int { get }
|
||||
func reloadPages()
|
||||
func transition(to page: Int, animated: Bool)
|
||||
}
|
||||
```
|
||||
|
||||
`RDReaderView` 遵循此协议,提供统一的页面导航接口。
|
||||
`RDEpubReaderView` 遵循此协议,提供统一的页面导航接口。
|
||||
|
||||
### 2.5 枚举类型
|
||||
|
||||
```swift
|
||||
extension RDReaderView {
|
||||
extension RDEpubReaderView {
|
||||
public enum DisplayType {
|
||||
case pageCurl // 仿真翻页(UIPageViewController)
|
||||
case horizontalScroll // 水平滑动(UICollectionView)
|
||||
@@ -105,13 +105,13 @@ extension RDReaderView {
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心类:RDReaderView
|
||||
## 3. 核心类:RDEpubReaderView
|
||||
|
||||
**文件:** `RDReaderView.swift`
|
||||
**文件:** `RDEpubReaderView.swift`
|
||||
|
||||
`RDReaderView` 是一个 `UIView` 子类,作为翻页容器的主入口。内部管理两种翻页引擎:
|
||||
`RDEpubReaderView` 是一个 `UIView` 子类,作为翻页容器的主入口。内部管理两种翻页引擎:
|
||||
- **PageCurl 模式**:使用 `UIPageViewController` 实现仿真翻页
|
||||
- **Scroll 模式**:使用 `UICollectionView` + 自定义 `RDReaderFlowLayout` 实现滑动翻页
|
||||
- **Scroll 模式**:使用 `UICollectionView` + 自定义 `RDEpubReaderFlowLayout` 实现滑动翻页
|
||||
|
||||
### 3.1 关键属性
|
||||
|
||||
@@ -124,16 +124,16 @@ extension RDReaderView {
|
||||
| `coverPageIndex` | `Int?` | 封面页索引(独占一屏) |
|
||||
| `pagesPerScreen` | `Int` | 每屏页数(横屏双页时为 2) |
|
||||
| `preloadRadius` | `Int` | 预加载半径(默认 1) |
|
||||
| `dataSource` | `RDReaderDataSource?` | 旧版数据源 |
|
||||
| `pageProvider` | `RDReaderPageProvider?` | 推荐数据源 |
|
||||
| `delegate` | `RDReaderDelegate?` | 事件代理 |
|
||||
| `dataSource` | `RDEpubReaderDataSource?` | 旧版数据源 |
|
||||
| `pageProvider` | `RDEpubReaderPageProvider?` | 推荐数据源 |
|
||||
| `delegate` | `RDEpubReaderDelegate?` | 事件代理 |
|
||||
| `toolViewAnimationDuration` | `TimeInterval` | 工具栏动画时长(0.3s) |
|
||||
|
||||
### 3.2 关键方法
|
||||
|
||||
```swift
|
||||
/// 切换显示模式(pageCurl / horizontalScroll / verticalScroll)
|
||||
public func switchReaderDisplayType(_ displayType: RDReaderView.DisplayType)
|
||||
public func switchReaderDisplayType(_ displayType: RDEpubReaderView.DisplayType)
|
||||
|
||||
/// 跳转到指定页
|
||||
public func transitionToPage(pageNum: Int, animated: Bool = false)
|
||||
@@ -154,22 +154,22 @@ public func isFullScreenPage(_ pageNum: Int) -> Bool
|
||||
|------|------|------|
|
||||
| `pageViewController` | `UIPageViewController` | PageCurl 翻页引擎 |
|
||||
| `collectionView` | `UICollectionView` | Scroll 翻页引擎 |
|
||||
| `layout` | `RDReaderFlowLayout` | CollectionView 自定义布局 |
|
||||
| `spreadResolver` | `RDReaderSpreadResolver` | 双页配对计算 |
|
||||
| `tapRegionHandler` | `RDReaderTapRegionHandler` | 点击区域判定 |
|
||||
| `preloadController` | `RDReaderPreloadController` | 页面预加载与缓存 |
|
||||
| `pagingController` | `RDReaderPagingController` | 翻页状态管理 |
|
||||
| `layout` | `RDEpubReaderFlowLayout` | CollectionView 自定义布局 |
|
||||
| `spreadResolver` | `RDEpubReaderSpreadResolver` | 双页配对计算 |
|
||||
| `tapRegionHandler` | `RDEpubReaderTapRegionHandler` | 点击区域判定 |
|
||||
| `preloadController` | `RDEpubReaderPreloadController` | 页面预加载与缓存 |
|
||||
| `pagingController` | `RDEpubReaderPagingController` | 翻页状态管理 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 翻页状态机:RDReaderPagingController
|
||||
## 4. 翻页状态机:RDEpubReaderPagingController
|
||||
|
||||
**文件:** `Paging/RDReaderPagingController.swift`
|
||||
**文件:** `Paging/RDEpubReaderPagingController.swift`
|
||||
|
||||
管理 PageCurl 模式下的翻页请求队列,防止动画冲突。
|
||||
|
||||
```swift
|
||||
struct RDReaderPagingController {
|
||||
struct RDEpubReaderPagingController {
|
||||
struct PageTransitionRequest: Equatable {
|
||||
let pageNum: Int
|
||||
let animated: Bool
|
||||
@@ -180,7 +180,7 @@ struct RDReaderPagingController {
|
||||
var didBuildUI: Bool // UI 是否已构建
|
||||
|
||||
/// 判断是否应排队请求(PageCurl 模式下动画中返回 true)
|
||||
mutating func shouldQueuePageTransition(_ request: PageTransitionRequest, currentDisplayType: RDReaderView.DisplayType) -> Bool
|
||||
mutating func shouldQueuePageTransition(_ request: PageTransitionRequest, currentDisplayType: RDEpubReaderView.DisplayType) -> Bool
|
||||
|
||||
/// 完成翻页动画,返回待处理的请求
|
||||
mutating func finishPageCurlTransition() -> PageTransitionRequest?
|
||||
@@ -192,9 +192,9 @@ struct RDReaderPagingController {
|
||||
|
||||
---
|
||||
|
||||
## 5. 页面预加载:RDReaderPreloadController
|
||||
## 5. 页面预加载:RDEpubReaderPreloadController
|
||||
|
||||
**文件:** `Paging/RDReaderPreloadController.swift`
|
||||
**文件:** `Paging/RDEpubReaderPreloadController.swift`
|
||||
|
||||
负责在当前页周围预渲染页面视图,减少翻页时的白屏时间。
|
||||
|
||||
@@ -224,27 +224,27 @@ func invalidate(environment: Environment)
|
||||
|
||||
```swift
|
||||
struct Environment {
|
||||
let displayType: RDReaderView.DisplayType
|
||||
let displayType: RDEpubReaderView.DisplayType
|
||||
let isLandscape: Bool
|
||||
let pagesPerScreen: Int
|
||||
let boundsSize: CGSize
|
||||
let landscapeDualPageEnabled: Bool
|
||||
let coverPageIndex: Int?
|
||||
let totalPages: Int
|
||||
let spreadResolver: RDReaderSpreadResolver
|
||||
let spreadResolver: RDEpubReaderSpreadResolver
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 双页展开计算:RDReaderSpreadResolver
|
||||
## 6. 双页展开计算:RDEpubReaderSpreadResolver
|
||||
|
||||
**文件:** `Paging/RDReaderSpreadResolver.swift`
|
||||
**文件:** `Paging/RDEpubReaderSpreadResolver.swift`
|
||||
|
||||
纯函数式结构体,负责双页模式下的页面配对和导航计算。
|
||||
|
||||
```swift
|
||||
struct RDReaderSpreadResolver {
|
||||
struct RDEpubReaderSpreadResolver {
|
||||
/// 判断是否为全屏页(封面页独占一屏)
|
||||
func isFullScreenPage(_ pageNum: Int, landscapeDualPageEnabled: Bool, isLandscape: Bool, coverPageIndex: Int?) -> Bool
|
||||
|
||||
@@ -265,15 +265,15 @@ struct RDReaderSpreadResolver {
|
||||
|
||||
---
|
||||
|
||||
## 7. 点击区域判定:RDReaderTapRegionHandler
|
||||
## 7. 点击区域判定:RDEpubReaderTapRegionHandler
|
||||
|
||||
**文件:** `Paging/RDReaderTapRegionHandler.swift`
|
||||
**文件:** `Paging/RDEpubReaderTapRegionHandler.swift`
|
||||
|
||||
将屏幕三等分,判定点击属于左/中/右区域。
|
||||
|
||||
```swift
|
||||
struct RDReaderTapRegionHandler {
|
||||
func resolveTapEvent(point: CGPoint, viewFrame: CGRect, isToolViewVisible: Bool) -> RDReaderView.TapEvent
|
||||
struct RDEpubReaderTapRegionHandler {
|
||||
func resolveTapEvent(point: CGPoint, viewFrame: CGRect, isToolViewVisible: Bool) -> RDEpubReaderView.TapEvent
|
||||
}
|
||||
```
|
||||
|
||||
@@ -284,9 +284,9 @@ struct RDReaderTapRegionHandler {
|
||||
|
||||
---
|
||||
|
||||
## 8. 流式布局:RDReaderFlowLayout
|
||||
## 8. 流式布局:RDEpubReaderFlowLayout
|
||||
|
||||
**文件:** `RDReaderFlowLayout.swift`
|
||||
**文件:** `RDEpubReaderFlowLayout.swift`
|
||||
|
||||
`UICollectionViewFlowLayout` 子类,支持水平滚动和垂直滚动两种模式。
|
||||
|
||||
@@ -294,7 +294,7 @@ struct RDReaderTapRegionHandler {
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `displayType` | `RDReaderView.DisplayType` | 布局模式 |
|
||||
| `displayType` | `RDEpubReaderView.DisplayType` | 布局模式 |
|
||||
| `isLandscapeDualPage` | `Bool` | 是否横屏双页 |
|
||||
| `coverPageIndex` | `Int?` | 封面页索引 |
|
||||
| `pagesPerScreen` | `Int` | 每屏页数 |
|
||||
@@ -302,12 +302,12 @@ struct RDReaderTapRegionHandler {
|
||||
### 8.2 协议
|
||||
|
||||
```swift
|
||||
public protocol RDReaderFlowLayoutDataSoure: NSObjectProtocol {
|
||||
func heigtOfVerticalScrollPage(flowLayout: RDReaderFlowLayout, pageIndex: Int) -> CGFloat?
|
||||
public protocol RDEpubReaderFlowLayoutDataSoure: NSObjectProtocol {
|
||||
func heigtOfVerticalScrollPage(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int) -> CGFloat?
|
||||
}
|
||||
|
||||
@objc public protocol RDReaderFlowLayoutDelegate: NSObjectProtocol {
|
||||
func pageNum(flowLayout: RDReaderFlowLayout, pageIndex: Int)
|
||||
@objc public protocol RDEpubReaderFlowLayoutDelegate: NSObjectProtocol {
|
||||
func pageNum(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -324,28 +324,28 @@ func currentContentOffset(count: Int) -> CGPoint
|
||||
|
||||
---
|
||||
|
||||
## 9. 内容 Cell:RDReaderContentCell
|
||||
## 9. 内容 Cell:RDEpubReaderContentCell
|
||||
|
||||
**文件:** `RDReaderContentCell.swift`
|
||||
**文件:** `RDEpubReaderContentCell.swift`
|
||||
|
||||
`UICollectionViewCell` 子类,用于 Scroll 模式下承载页面内容视图。
|
||||
|
||||
```swift
|
||||
class RDReaderContentCell: UICollectionViewCell {
|
||||
class RDEpubReaderContentCell: UICollectionViewCell {
|
||||
var containerView: UIView? // 设置时自动添加到 contentView,移除旧视图
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. PageCurl 子控制器:RDReaderPageChildViewController
|
||||
## 10. PageCurl 子控制器:RDEpubReaderPageChildViewController
|
||||
|
||||
**文件:** `RDReaderPageChildViewController.swift`
|
||||
**文件:** `RDEpubReaderPageChildViewController.swift`
|
||||
|
||||
`UIViewController` 子类,作为 `UIPageViewController` 的页面 VC。
|
||||
|
||||
```swift
|
||||
class RDReaderPageChildViewController: UIViewController {
|
||||
class RDEpubReaderPageChildViewController: UIViewController {
|
||||
var contentView: UIView? // 内容视图,设置时自动安装到容器
|
||||
var pageNum: Int // 对应页码
|
||||
|
||||
@@ -357,7 +357,7 @@ class RDReaderPageChildViewController: UIViewController {
|
||||
|
||||
## 11. Extension 汇总
|
||||
|
||||
### 11.1 RDReaderView+PageCurl
|
||||
### 11.1 RDEpubReaderView+PageCurl
|
||||
|
||||
实现 `UIPageViewControllerDataSource` 和 `UIPageViewControllerDelegate`:
|
||||
|
||||
@@ -367,18 +367,18 @@ class RDReaderPageChildViewController: UIViewController {
|
||||
- `pageViewController(_:willTransitionTo:)` — 即将翻页时预加载
|
||||
|
||||
**特殊页码:**
|
||||
- `RDReaderView.blankPageNum`(`Int.max`)— 双页模式下的空白页
|
||||
- `RDReaderView.blankEndPageNum`(`Int.max - 1`)— 末尾空白页
|
||||
- `RDEpubReaderView.blankPageNum`(`Int.max`)— 双页模式下的空白页
|
||||
- `RDEpubReaderView.blankEndPageNum`(`Int.max - 1`)— 末尾空白页
|
||||
|
||||
### 11.2 RDReaderView+CollectionView
|
||||
### 11.2 RDEpubReaderView+CollectionView
|
||||
|
||||
实现 `UICollectionViewDataSource`、`RDReaderFlowLayoutDelegate`、`RDReaderFlowLayoutDataSoure`:
|
||||
实现 `UICollectionViewDataSource`、`RDEpubReaderFlowLayoutDelegate`、`RDEpubReaderFlowLayoutDataSoure`:
|
||||
|
||||
- `collectionView(_:cellForItemAt:)` — 复用预加载视图或创建新 Cell
|
||||
- `collectionView(_:numberOfItemsInSection:)` — 返回总页数
|
||||
- `pageNum(flowLayout:pageIndex:)` — 滚动时更新当前页码
|
||||
|
||||
### 11.3 RDReaderView+ContentAccess
|
||||
### 11.3 RDEpubReaderView+ContentAccess
|
||||
|
||||
提供内容视图的注册、复用和查询:
|
||||
|
||||
@@ -396,7 +396,7 @@ public func pageContentView(pageNum: Int) -> UIView?
|
||||
public func resolvedSinglePageSize(pageNum: Int? = nil) -> CGSize
|
||||
```
|
||||
|
||||
### 11.4 RDReaderView+ToolView
|
||||
### 11.4 RDEpubReaderView+ToolView
|
||||
|
||||
管理顶部/底部工具栏的安装、显示/隐藏动画:
|
||||
|
||||
@@ -420,11 +420,11 @@ func updateToolViewHeightConstraintsIfNeeded()
|
||||
| 模式 | 应用 |
|
||||
|------|------|
|
||||
| **策略模式** | `DisplayType` 切换 PageCurl / Scroll 两种翻页策略 |
|
||||
| **适配器模式** | `RDReaderLegacyDataSourceAdapter` 将旧 `RDReaderDataSource` 适配为 `RDReaderPageProvider` |
|
||||
| **命令队列** | `RDReaderPagingController` 管理翻页请求队列 |
|
||||
| **缓存签名** | `RDReaderPreloadController.CacheSignature` 检测环境变化自动失效 |
|
||||
| **适配器模式** | `RDEpubReaderLegacyDataSourceAdapter` 将旧 `RDEpubReaderDataSource` 适配为 `RDEpubReaderPageProvider` |
|
||||
| **命令队列** | `RDEpubReaderPagingController` 管理翻页请求队列 |
|
||||
| **缓存签名** | `RDEpubReaderPreloadController.CacheSignature` 检测环境变化自动失效 |
|
||||
| **关注点分离** | Extension 将不同功能拆分到独立文件 |
|
||||
| **纯函数** | `RDReaderSpreadResolver` 和 `RDReaderTapRegionHandler` 无状态计算 |
|
||||
| **纯函数** | `RDEpubReaderSpreadResolver` 和 `RDEpubReaderTapRegionHandler` 无状态计算 |
|
||||
|
||||
---
|
||||
|
||||
@@ -434,7 +434,7 @@ func updateToolViewHeightConstraintsIfNeeded()
|
||||
用户点击屏幕
|
||||
│
|
||||
▼
|
||||
RDReaderTapRegionHandler.resolveTapEvent()
|
||||
RDEpubReaderTapRegionHandler.resolveTapEvent()
|
||||
│
|
||||
├── .left → goPreviousPage() ──→ spreadResolver.nextPage(forward: false)
|
||||
├── .right → goNextPage() ──→ spreadResolver.nextPage(forward: true)
|
||||
|
||||
+11
-4
@@ -34,6 +34,7 @@
|
||||
| LocationPersistenceTests.swift | 阅读位置持久化 |
|
||||
| MetadataParseBenchmarkTests.swift | 元数据解析性能基准 |
|
||||
| PageNavigationTests.swift | 页面导航 |
|
||||
| PDFPageTurnBlankPageTests.swift | PDF 翻页后白页回归(截屏墨迹占比判定) |
|
||||
| ReaderAnnotationTests.swift | 标注基础功能 |
|
||||
| ReaderAnnotationExtendedTests.swift | 标注扩展功能 |
|
||||
| ReaderOpenCloseTests.swift | 阅读器打开/关闭 |
|
||||
@@ -64,13 +65,19 @@
|
||||
|
||||
### CocoaPods 依赖准备
|
||||
|
||||
> 仓库包含示例工程 `ReadViewDemo`,并已提交 `Pods/` 与 `Podfile.lock`。如本地环境未同步,可在仓库根或示例工程目录运行:
|
||||
> 仓库包含示例工程 `ReadViewDemo`,并已提交 `ReadViewDemo/Pods/` 与 `ReadViewDemo/Podfile.lock`。如本地环境未同步,请在示例工程目录运行:
|
||||
|
||||
```bash
|
||||
pod install
|
||||
cd ReadViewDemo && pod install
|
||||
```
|
||||
|
||||
(依赖入口:`Podfile`、`ReadViewDemo/Podfile`)
|
||||
(依赖入口:`ReadViewDemo/Podfile`)
|
||||
|
||||
### 模拟器要求
|
||||
|
||||
Demo 链接了 `FoundationModels.framework`(AI 助手示例入口)。该框架自 iOS 26 起提供,
|
||||
在 iOS 18 及更早的模拟器上 App 会在启动时因 `Library not loaded` 直接崩溃,测试表现为
|
||||
卡在 Launch 阶段。请选择 iOS 26 及以上的模拟器运行 UI 测试。
|
||||
|
||||
### 运行 UI 测试
|
||||
|
||||
@@ -116,7 +123,7 @@ xcodebuild test \
|
||||
建议同时记录以下观测项:
|
||||
|
||||
- `prepareOnDemandChapter` 主线程 wall clock
|
||||
- `RDReaderPreloadController` 预加载命中率
|
||||
- `RDEpubReaderPreloadController` 预加载命中率
|
||||
- `bookPageMap` partial extension / full replacement 次数
|
||||
- 页面静态底图缓存命中率
|
||||
- CFI 延迟构建完成次数与耗时
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
**输出**:`RDEPUBTypesettingOutput`(渲染请求、诊断信息、兼容性报告)
|
||||
|
||||
**关键文件**:`Sources/RDReaderView/EPUBTextRendering/Typesetter/`
|
||||
**关键文件**:`Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/`
|
||||
|
||||
---
|
||||
|
||||
|
||||
+28
-28
@@ -31,12 +31,12 @@ graph TB
|
||||
IndexTable[RDEPUBTextIndexTable]
|
||||
end
|
||||
|
||||
subgraph RDReaderView
|
||||
ReaderView[RDReaderView]
|
||||
FlowLayout[RDReaderFlowLayout]
|
||||
Preload[RDReaderPreloadController]
|
||||
Spread[RDReaderSpreadResolver]
|
||||
TapRegion[RDReaderTapRegionHandler]
|
||||
subgraph RDEpubReaderView
|
||||
ReaderView[RDEpubReaderView]
|
||||
FlowLayout[RDEpubReaderFlowLayout]
|
||||
Preload[RDEpubReaderPreloadController]
|
||||
Spread[RDEpubReaderSpreadResolver]
|
||||
TapRegion[RDEpubReaderTapRegionHandler]
|
||||
end
|
||||
|
||||
subgraph EPUBUI
|
||||
@@ -297,11 +297,11 @@ classDiagram
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class RDReaderView {
|
||||
class RDEpubReaderView {
|
||||
+currentPage: Int
|
||||
+currentDisplayType: DisplayType
|
||||
+pageProvider: RDReaderPageProvider?
|
||||
+delegate: RDReaderDelegate?
|
||||
+pageProvider: RDEpubReaderPageProvider?
|
||||
+delegate: RDEpubReaderDelegate?
|
||||
+landscapeDualPageEnabled: Bool
|
||||
+pageDirection: PageDirection
|
||||
+coverPageIndex: Int?
|
||||
@@ -318,14 +318,14 @@ classDiagram
|
||||
verticalScroll
|
||||
}
|
||||
|
||||
class RDReaderPageProvider {
|
||||
class RDEpubReaderPageProvider {
|
||||
<<protocol>>
|
||||
+numberOfPages(in:) Int
|
||||
+readerView(_:viewForPageAt:reusableView:) UIView
|
||||
+pageIdentifier(in:index:) String?
|
||||
}
|
||||
|
||||
class RDReaderFlowLayout {
|
||||
class RDEpubReaderFlowLayout {
|
||||
+displayType: DisplayType
|
||||
+isLandscapeDualPage: Bool
|
||||
+coverPageIndex: Int?
|
||||
@@ -333,7 +333,7 @@ classDiagram
|
||||
+currentPage: Int
|
||||
}
|
||||
|
||||
class RDReaderPreloadController {
|
||||
class RDEpubReaderPreloadController {
|
||||
+radius: Int
|
||||
+pageViewForDisplay(pageNum:environment:contentViewProvider:) UIView?
|
||||
+takePreloadedView(for:) UIView?
|
||||
@@ -341,13 +341,13 @@ classDiagram
|
||||
+invalidate(environment:)
|
||||
}
|
||||
|
||||
class RDReaderSpreadResolver {
|
||||
class RDEpubReaderSpreadResolver {
|
||||
+isFullScreenPage(...) Bool
|
||||
+dualPagePair(for:totalPages:coverPageIndex:) (Int, Int?)
|
||||
+nextPage(from:totalPages:pagesPerScreen:coverPageIndex:forward:) Int?
|
||||
}
|
||||
|
||||
class RDReaderTapRegionHandler {
|
||||
class RDEpubReaderTapRegionHandler {
|
||||
+resolveTapEvent(point:viewFrame:isToolViewVisible:) TapEvent
|
||||
}
|
||||
|
||||
@@ -359,7 +359,7 @@ classDiagram
|
||||
right
|
||||
}
|
||||
|
||||
class RDReaderPagingController {
|
||||
class RDEpubReaderPagingController {
|
||||
+isTransitioning: Bool
|
||||
+didBuildUI: Bool
|
||||
+pendingTransitionRequest: PageTransitionRequest?
|
||||
@@ -367,25 +367,25 @@ classDiagram
|
||||
+finishPageCurlTransition() PageTransitionRequest?
|
||||
}
|
||||
|
||||
class RDReaderContentCell {
|
||||
class RDEpubReaderContentCell {
|
||||
+containerView: UIView?
|
||||
}
|
||||
|
||||
class RDReaderPageChildViewController {
|
||||
class RDEpubReaderPageChildViewController {
|
||||
+contentView: UIView?
|
||||
+pageNum: Int
|
||||
}
|
||||
|
||||
RDReaderView --> DisplayType : uses
|
||||
RDReaderView --> RDReaderPageProvider : delegates
|
||||
RDReaderView --> RDReaderFlowLayout : owns
|
||||
RDReaderView --> RDReaderPreloadController : owns
|
||||
RDReaderView --> RDReaderSpreadResolver : owns
|
||||
RDReaderView --> RDReaderTapRegionHandler : owns
|
||||
RDReaderView --> RDReaderPagingController : owns
|
||||
RDReaderView --> RDReaderContentCell : creates
|
||||
RDReaderView --> RDReaderPageChildViewController : creates
|
||||
RDReaderTapRegionHandler --> TapEvent : produces
|
||||
RDEpubReaderView --> DisplayType : uses
|
||||
RDEpubReaderView --> RDEpubReaderPageProvider : delegates
|
||||
RDEpubReaderView --> RDEpubReaderFlowLayout : owns
|
||||
RDEpubReaderView --> RDEpubReaderPreloadController : owns
|
||||
RDEpubReaderView --> RDEpubReaderSpreadResolver : owns
|
||||
RDEpubReaderView --> RDEpubReaderTapRegionHandler : owns
|
||||
RDEpubReaderView --> RDEpubReaderPagingController : owns
|
||||
RDEpubReaderView --> RDEpubReaderContentCell : creates
|
||||
RDEpubReaderView --> RDEpubReaderPageChildViewController : creates
|
||||
RDEpubReaderTapRegionHandler --> TapEvent : produces
|
||||
```
|
||||
|
||||
---
|
||||
@@ -403,7 +403,7 @@ classDiagram
|
||||
+highlights: [RDEPUBHighlight]
|
||||
+bookmarks: [RDEPUBBookmark]
|
||||
+tableOfContents: [EPUBTableOfContentsItem]
|
||||
+readerView: RDReaderView
|
||||
+readerView: RDEpubReaderView
|
||||
+readerContext: RDEPUBReaderContext
|
||||
+runtime: RDEPUBReaderRuntime?
|
||||
}
|
||||
|
||||
+11
-3
@@ -1,6 +1,6 @@
|
||||
# ReadViewSDK 文档索引
|
||||
|
||||
> 最后更新:2026-06-18
|
||||
> 最后更新:2026-07-25
|
||||
|
||||
---
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [CONCERNS.md](CONCERNS.md) | 代码库风险与关注点:12 项安全/性能/可维护性风险 |
|
||||
| [CONCERNS.md](CONCERNS.md) | 代码库风险与关注点:12 项安全/性能/可维护性风险(顶部有 2026-07-30 状态复核表) |
|
||||
| [TESTING.md](TESTING.md) | 测试基础设施:UI 测试文件清单、运行方式、覆盖率 |
|
||||
| [CONVENTIONS.md](CONVENTIONS.md) | 编码规范:命名约定、代码风格、导入规范、错误处理 |
|
||||
|
||||
@@ -33,10 +33,18 @@
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [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 标记、样式合成为、字体规范化、片段标记 |
|
||||
| [CHAPTER_RUNTIME.md](CHAPTER_RUNTIME.md) | 章节运行时详解:按需加载、章节窗口协调、页图管理、磁盘缓存、后台补全 |
|
||||
| [CFI_SUBSYSTEM.md](CFI_SUBSYSTEM.md) | CFI 子系统详解:EPUB CFI 解析、生成、序列化、范围、恢复引擎 |
|
||||
| [CFI_ISSUES_REVIEW.md](CFI_ISSUES_REVIEW.md) | CFI 实现问题分析报告:11 个问题(3 高 / 5 中 / 3 低),含修复优先级建议 |
|
||||
| [PAGINATION_ISSUES_2026-07-06.md](PAGINATION_ISSUES_2026-07-06.md) | 分页问题调查记录:脚注图标 avoid-trim 裁行(已修复)+页范围/显示度量错配行中断页(待复现),含调试命令备忘 |
|
||||
| [LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md](LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md) | 长章节内存优化低风险实施方案:在保留整章上下文布局正确性的前提下,分阶段收敛整章副本、共享 layouter、治理缓存与图片内存 |
|
||||
| [API_REFERENCE.md](API_REFERENCE.md) | 公开 API 参考:RDEPUBReaderController 公开接口、委托协议、配置模型 |
|
||||
| [当前阅读器问题修复开发清单.md](当前阅读器问题修复开发清单.md) | 基于当前代码实现整理的逐任务开发计划:10 个任务、修改位置、实施步骤、风险点与验收标准 |
|
||||
| [大书远距目录跳转与后台补全优化方案.md](大书远距目录跳转与后台补全优化方案.md) | 面向大书按需分页模式的完整优化方案:远距目录跳转、后台补全优先级、页图接管协议与分阶段实施路径 |
|
||||
@@ -45,7 +53,7 @@
|
||||
|
||||
## 项目信息
|
||||
|
||||
- **模块总数:** 4 个(EPUBCore、EPUBTextRendering、RDReaderView、EPUBUI)
|
||||
- **模块总数:** 4 个(EPUBCore、EPUBTextRendering、RDEpubReaderView、EPUBUI)
|
||||
- **Swift 文件数:** 142 个(SDK Sources)
|
||||
- **测试用例数:** 23 个测试类,约 99 个测试方法(UI 测试)
|
||||
- **最低 iOS 版本:** 15.6
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
platform :ios, '15.6'
|
||||
use_frameworks!
|
||||
|
||||
workspace 'ReadViewSDK.xcworkspace'
|
||||
project 'ReadViewSDK.xcodeproj'
|
||||
|
||||
target 'ReadViewSDK' do
|
||||
pod 'RDReaderView', :path => '..'
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
apply_settings = lambda do |config|
|
||||
config.build_settings['ENABLE_USER_SCRIPT_SANDBOXING'] = 'NO'
|
||||
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.6'
|
||||
end
|
||||
|
||||
# Pods project (Pods.xcodeproj)
|
||||
installer.pods_project.build_configurations.each { |c| apply_settings.call(c) }
|
||||
installer.pods_project.targets.each { |t| t.build_configurations.each { |c| apply_settings.call(c) } }
|
||||
|
||||
installer.aggregate_targets.each do |at|
|
||||
p = at.user_project
|
||||
next unless p
|
||||
|
||||
# User project(s) that integrate Pods (e.g. ReadViewDemo / ReadViewSDK)
|
||||
p.build_configurations.each { |c| apply_settings.call(c) }
|
||||
p.targets.each { |t| t.build_configurations.each { |c| apply_settings.call(c) } }
|
||||
p.save
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = "RDEpubReaderView"
|
||||
s.module_name = "RDEpubReaderView"
|
||||
s.version = "0.0.1"
|
||||
s.summary = "A reader view for EPUB and plain-text books"
|
||||
s.platform = :ios, "15.0"
|
||||
s.swift_versions = ["5.10"]
|
||||
s.homepage = "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git"
|
||||
s.author = { "shenlei" => "shenlei@touchread.com" }
|
||||
s.source = { :git => "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git", :tag => "#{s.version}" }
|
||||
s.license = "MIT"
|
||||
s.source_files = "Sources/RDEpubReaderView/**/*.{swift}"
|
||||
s.resource_bundles = {
|
||||
"RDEpubReaderViewAssets" => ["Sources/RDEpubReaderView/EPUBCore/Resources/**/*"]
|
||||
}
|
||||
s.dependency "ZIPFoundation", "~> 0.9"
|
||||
s.dependency "DTCoreText", "~> 1.6"
|
||||
s.requires_arc = true
|
||||
end
|
||||
@@ -1,21 +0,0 @@
|
||||
|
||||
|
||||
Pod::Spec.new do |s|
|
||||
s.name = "RDReaderView"
|
||||
s.version = "0.0.1"
|
||||
s.summary = "A reader view for novel"
|
||||
s.platform = :ios, "15.6"
|
||||
s.swift_versions = ["5.10"]
|
||||
s.homepage = "https://github.com/namesubai/RDReaderView.git"
|
||||
s.author = { "subai" => "804663401@qq.com" }
|
||||
s.source = { :git => "https://github.com/namesubai/RDReaderView.git", :tag => "#{s.version}"}
|
||||
s.license = "MIT"
|
||||
s.source_files = 'Sources/RDReaderView/**/*.{swift}'
|
||||
s.resource_bundles = {
|
||||
'RDReaderViewAssets' => ['Sources/RDReaderView/EPUBCore/Resources/**/*']
|
||||
}
|
||||
s.dependency 'ZIPFoundation', '~> 0.9'
|
||||
s.dependency 'DTCoreText', '~> 1.6'
|
||||
s.requires_arc = true
|
||||
|
||||
end
|
||||
+19
-3
@@ -3,14 +3,30 @@ platform :ios, '15.6'
|
||||
target 'ReadViewDemo' do
|
||||
# Comment the next line if you don't want to use dynamic frameworks
|
||||
use_frameworks!
|
||||
pod 'RDReaderView', :path => '..'
|
||||
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/Speech', :path => '../Sources/RDEpubReaderView'
|
||||
pod 'RDEpubReaderView/AI', :path => '../Sources/RDEpubReaderView'
|
||||
pod 'RDPDFReaderView', :path => '../Sources/RDPDFReaderView'
|
||||
pod 'RDPDFReaderView/Speech', :path => '../Sources/RDPDFReaderView'
|
||||
pod 'RDPDFReaderView/AI', :path => '../Sources/RDPDFReaderView'
|
||||
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
# 不再由本 Podfile 全局关闭 ENABLE_USER_SCRIPT_SANDBOXING——之前的写法会把
|
||||
# 宿主工程一起强制改成 NO,属于我们主动降级安全基线。现在交回工程默认值。
|
||||
#
|
||||
# 注意:本工程用 use_frameworks!,CocoaPods 生成的 [CP] Embed Pods Frameworks
|
||||
# 脚本阶段用 rsync 往 .app/Frameworks 拷贝产物;一旦把该设置显式打成 YES,
|
||||
# 这一步会被沙箱拒绝(file-write-unlink / utimensat: Operation not permitted)
|
||||
# 而整个构建失败——已实测确认。真要打开必须先改掉动态框架集成方式
|
||||
# (静态库 / SPM),别只翻这个开关。
|
||||
apply_settings = lambda do |config|
|
||||
config.build_settings['ENABLE_USER_SCRIPT_SANDBOXING'] = 'NO'
|
||||
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.6'
|
||||
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
|
||||
end
|
||||
|
||||
# Pods project (Pods.xcodeproj)
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
PODS:
|
||||
- DTCoreText (1.6.28):
|
||||
- DTCoreText/Core (= 1.6.28)
|
||||
- DTFoundation/Core (~> 1.7.19)
|
||||
- DTFoundation/DTAnimatedGIF (~> 1.7.19)
|
||||
- DTFoundation/DTHTMLParser (~> 1.7.19)
|
||||
- DTFoundation/UIKit (~> 1.7.19)
|
||||
- DTCoreText/Core (1.6.28):
|
||||
- DTFoundation/Core (~> 1.7.19)
|
||||
- DTFoundation/DTAnimatedGIF (~> 1.7.19)
|
||||
- DTFoundation/DTHTMLParser (~> 1.7.19)
|
||||
- DTFoundation/UIKit (~> 1.7.19)
|
||||
- DTFoundation/Core (1.7.19)
|
||||
- DTFoundation/DTAnimatedGIF (1.7.19)
|
||||
- DTFoundation/DTHTMLParser (1.7.19):
|
||||
- DTFoundation/Core
|
||||
- DTFoundation/UIKit (1.7.19):
|
||||
- DTFoundation/Core
|
||||
- RDReaderView (0.0.1):
|
||||
- DTCoreText (~> 1.6)
|
||||
- ZIPFoundation (~> 0.9)
|
||||
- ZIPFoundation (0.9.20)
|
||||
|
||||
DEPENDENCIES:
|
||||
- RDReaderView (from `..`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- DTCoreText
|
||||
- DTFoundation
|
||||
- ZIPFoundation
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
RDReaderView:
|
||||
:path: ".."
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
||||
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
||||
RDReaderView: 2e0eeeff4bcfe8bbc09642344d326440af280ed5
|
||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||
|
||||
PODFILE CHECKSUM: 775f5c8c488024e24d494aad6331e9fef8f9e2e5
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
@@ -1,12 +0,0 @@
|
||||
//
|
||||
// CTLineUtils.h
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oleksandr Deundiak on 7/15/15.
|
||||
// Copyright 2015. All rights reserved.
|
||||
//
|
||||
|
||||
#import <CoreText/CoreText.h>
|
||||
|
||||
BOOL areLinesEqual(CTLineRef line1, CTLineRef line2);
|
||||
CFIndex getTruncationIndex(CTLineRef line, CTLineRef trunc);
|
||||
-102
@@ -1,102 +0,0 @@
|
||||
//
|
||||
// CTLineUtils.m
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oleksandr Deundiak on 7/15/15.
|
||||
// Copyright 2015. All rights reserved.
|
||||
//
|
||||
|
||||
#import "CTLineUtils.h"
|
||||
|
||||
BOOL areLinesEqual(CTLineRef line1, CTLineRef line2)
|
||||
{
|
||||
if(line1 == nil || line2 == nil) {
|
||||
return NO;
|
||||
}
|
||||
|
||||
CFArrayRef glyphRuns1 = CTLineGetGlyphRuns(line1);
|
||||
CFArrayRef glyphRuns2 = CTLineGetGlyphRuns(line2);
|
||||
CFIndex runCount1 = CFArrayGetCount(glyphRuns1), runCount2 = CFArrayGetCount(glyphRuns2);
|
||||
|
||||
if (runCount1 != runCount2)
|
||||
return NO;
|
||||
|
||||
for (CFIndex i = 0; i < runCount1; i++)
|
||||
{
|
||||
CTRunRef run1 = CFArrayGetValueAtIndex(glyphRuns1, i);
|
||||
CTRunRef run2 = CFArrayGetValueAtIndex(glyphRuns2, i);
|
||||
|
||||
CFIndex countInRun1 = CTRunGetGlyphCount(run1), countInRun2 = CTRunGetGlyphCount(run2);
|
||||
if (countInRun1 != countInRun2)
|
||||
return NO;
|
||||
|
||||
const CGGlyph* constGlyphs1 = CTRunGetGlyphsPtr(run1);
|
||||
CGGlyph* glyphs1 = NULL;
|
||||
if (constGlyphs1 == NULL)
|
||||
{
|
||||
glyphs1 = (CGGlyph*)malloc(countInRun1*sizeof(CGGlyph));
|
||||
CTRunGetGlyphs(run1, CFRangeMake(0, countInRun1), glyphs1);
|
||||
constGlyphs1 = glyphs1;
|
||||
}
|
||||
|
||||
const CGGlyph* constGlyphs2 = CTRunGetGlyphsPtr(run2);
|
||||
CGGlyph* glyphs2 = NULL;
|
||||
if (constGlyphs2 == NULL)
|
||||
{
|
||||
glyphs2 = (CGGlyph*)malloc(countInRun2*sizeof(CGGlyph));
|
||||
CTRunGetGlyphs(run2, CFRangeMake(0, countInRun2), glyphs2);
|
||||
constGlyphs2 = glyphs2;
|
||||
}
|
||||
|
||||
BOOL result = YES;
|
||||
for (CFIndex j = 0; j < countInRun1; j++)
|
||||
{
|
||||
if (constGlyphs1[j] != constGlyphs2[j])
|
||||
{
|
||||
result = NO;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (glyphs1 != NULL)
|
||||
free(glyphs1);
|
||||
|
||||
if (glyphs2 != NULL)
|
||||
free(glyphs2);
|
||||
|
||||
if (!result)
|
||||
return NO;
|
||||
}
|
||||
|
||||
return YES;
|
||||
}
|
||||
|
||||
CFIndex getTruncationIndex(CTLineRef line, CTLineRef trunc)
|
||||
{
|
||||
if (line == nil || trunc == nil) return 0;
|
||||
|
||||
CFIndex truncCount = CFArrayGetCount(CTLineGetGlyphRuns(trunc));
|
||||
|
||||
CFArrayRef lineRuns = CTLineGetGlyphRuns(line);
|
||||
CFIndex lineRunsCount = CFArrayGetCount(lineRuns);
|
||||
|
||||
CFIndex index = lineRunsCount - truncCount - 1;
|
||||
|
||||
// If the index is negative, CFArrayGetValueAtIndex will crash on iOS 10 beta.
|
||||
// We will just return 0 because on iOS 9, CFArrayGetValueAtIndex would have
|
||||
// returned nil anyways and the return truncation index would be 0.
|
||||
// Apple might have enabled an assert that only appears in the iOS 10 beta
|
||||
// release, but we will just avoid passing invalid arguments just to be safe.
|
||||
if (index < 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
CTRunRef lineLastRun = CFArrayGetValueAtIndex(lineRuns, index);
|
||||
|
||||
CFRange lastRunRange = CTRunGetStringRange(lineLastRun);
|
||||
|
||||
return lastRunRange.location = lastRunRange.length;
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
//
|
||||
// DTAccessibilityElement.h
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Austen Green on 3/13/13.
|
||||
// Copyright (c) 2013 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCompatibility.h"
|
||||
|
||||
#if TARGET_OS_IPHONE && !TARGET_OS_WATCH
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
/**
|
||||
A UIAccessibilityElement subclass that automatically converts its local accessibilityFrame to screen coordinates.
|
||||
*/
|
||||
@interface DTAccessibilityElement : UIAccessibilityElement
|
||||
/**
|
||||
The frame for the accessibility element in terms of the receiver's superview.
|
||||
*/
|
||||
@property (nonatomic, assign) CGRect localCoordinateAccessibilityFrame;
|
||||
|
||||
/**
|
||||
The point for activating accessibility events in terms of the receiver's superview.
|
||||
*/
|
||||
@property (nonatomic, assign) CGPoint localCoordinateAccessibilityActivationPoint;
|
||||
|
||||
/**
|
||||
The designated initializer. This class should be initialized with a UIView as its accessibility container.
|
||||
@param parentView The logical superview for the onscreen element the receiver represents.
|
||||
@returns Returns an initialized DTAccessibilityElement */
|
||||
|
||||
- (id)initWithParentView:(UIView *)parentView;
|
||||
|
||||
@end
|
||||
|
||||
#endif
|
||||
@@ -1,56 +0,0 @@
|
||||
//
|
||||
// DTAccessibilityElement.m
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Austen Green on 3/13/13.
|
||||
// Copyright (c) 2013 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTAccessibilityElement.h"
|
||||
|
||||
#if TARGET_OS_IPHONE && !TARGET_OS_WATCH
|
||||
|
||||
#import <DTFoundation/DTWeakSupport.h>
|
||||
|
||||
static const CGPoint DTAccessibilityElementNullActivationPoint = {CGFLOAT_MAX, CGFLOAT_MAX};
|
||||
|
||||
@interface DTAccessibilityElement()
|
||||
@property (nonatomic, DT_WEAK_PROPERTY) UIView *parentView;
|
||||
@end
|
||||
|
||||
@implementation DTAccessibilityElement
|
||||
|
||||
- (id)initWithParentView:(UIView *)parentView
|
||||
{
|
||||
self = [super initWithAccessibilityContainer:parentView];
|
||||
if (self)
|
||||
{
|
||||
_parentView = parentView;
|
||||
_localCoordinateAccessibilityActivationPoint = DTAccessibilityElementNullActivationPoint;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (CGRect)accessibilityFrame
|
||||
{
|
||||
CGRect frame = self.localCoordinateAccessibilityFrame;
|
||||
frame = [self.parentView.window convertRect:frame fromView:self.parentView];
|
||||
return frame;
|
||||
}
|
||||
|
||||
- (CGPoint)accessibilityActivationPoint
|
||||
{
|
||||
CGPoint point = self.localCoordinateAccessibilityActivationPoint;
|
||||
if (CGPointEqualToPoint(point, DTAccessibilityElementNullActivationPoint))
|
||||
{
|
||||
point = [super accessibilityActivationPoint];
|
||||
}
|
||||
|
||||
point = [self.parentView.window convertPoint:point fromView:self.parentView];
|
||||
|
||||
return point;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#endif
|
||||
@@ -1,61 +0,0 @@
|
||||
//
|
||||
// DTAccessibilityViewProxy.h
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Austen Green on 5/6/13.
|
||||
// Copyright (c) 2013 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCompatibility.h"
|
||||
#import "DTAccessibilityElement.h"
|
||||
|
||||
#if TARGET_OS_IPHONE && !TARGET_OS_WATCH
|
||||
|
||||
#import "DTTextAttachment.h"
|
||||
#import <DTFoundation/DTWeakSupport.h>
|
||||
|
||||
@protocol DTAccessibilityViewProxyDelegate;
|
||||
|
||||
/**
|
||||
UIView proxy for DTAttributedTextContentView custom subviews for text attachments.
|
||||
*/
|
||||
|
||||
@interface DTAccessibilityViewProxy : NSObject
|
||||
/**
|
||||
The delegate for the proxy
|
||||
*/
|
||||
@property (nonatomic, DT_WEAK_PROPERTY, readonly) id<DTAccessibilityViewProxyDelegate> delegate;
|
||||
|
||||
/**
|
||||
The text attachment represented by the proxy
|
||||
*/
|
||||
@property (nonatomic, strong, readonly) DTTextAttachment *textAttachment;
|
||||
|
||||
/**
|
||||
Creates a text attachment proxy for use with the VoiceOver system.
|
||||
@param textAttachment The <DTTextAttachment> that will be represented by a view.
|
||||
@param delegate An object conforming to <DTAccessibilityViewProxyDelegate> that will provide a view when needed by the proxy.
|
||||
@returns A new proxy object
|
||||
*/
|
||||
|
||||
- (id)initWithTextAttachment:(DTTextAttachment *)textAttachment delegate:(id<DTAccessibilityViewProxyDelegate>)delegate;
|
||||
|
||||
@end
|
||||
|
||||
/**
|
||||
Protocol to provide custom views for accessibility elements representing a DTTextAttachment.
|
||||
*/
|
||||
@protocol DTAccessibilityViewProxyDelegate
|
||||
@required
|
||||
/**
|
||||
Provides a view for an attachment, e.g. an imageView for images
|
||||
|
||||
@param attachment The <DTTextAttachment> that the requested view should represent
|
||||
@param proxy The frame that the view should use to fit on top of the space reserved for the attachment.
|
||||
@returns The sender requesting the view.
|
||||
*/
|
||||
|
||||
- (UIView *)viewForTextAttachment:(DTTextAttachment *)attachment proxy:(DTAccessibilityViewProxy *)proxy;
|
||||
@end
|
||||
|
||||
#endif
|
||||
@@ -1,62 +0,0 @@
|
||||
//
|
||||
// DTAccessibilityViewProxy.m
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Austen Green on 5/6/13.
|
||||
// Copyright (c) 2013 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTAccessibilityViewProxy.h"
|
||||
|
||||
#if TARGET_OS_IPHONE && !TARGET_OS_WATCH
|
||||
|
||||
@implementation DTAccessibilityViewProxy
|
||||
|
||||
- (id)initWithTextAttachment:(DTTextAttachment *)textAttachment delegate:(id<DTAccessibilityViewProxyDelegate>)delegate
|
||||
{
|
||||
_textAttachment = textAttachment;
|
||||
_delegate = delegate;
|
||||
return self;
|
||||
}
|
||||
|
||||
- (UIView *)proxiedView
|
||||
{
|
||||
return [self.delegate viewForTextAttachment:self.textAttachment proxy:self];
|
||||
}
|
||||
|
||||
- (Class)class
|
||||
{
|
||||
Class aClass = [[self proxiedView] class];
|
||||
|
||||
if (!aClass)
|
||||
aClass = [DTAccessibilityViewProxy class];
|
||||
|
||||
return aClass;
|
||||
}
|
||||
|
||||
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel
|
||||
{
|
||||
NSMethodSignature *signature = [UIView instanceMethodSignatureForSelector:sel];
|
||||
|
||||
return signature;
|
||||
}
|
||||
|
||||
- (void)forwardInvocation:(NSInvocation *)invocation
|
||||
{
|
||||
UIView *view = [self proxiedView];
|
||||
[invocation invokeWithTarget:view];
|
||||
}
|
||||
|
||||
- (BOOL)isEqual:(id)object
|
||||
{
|
||||
return [[self proxiedView] isEqual:object];
|
||||
}
|
||||
|
||||
- (NSUInteger)hash
|
||||
{
|
||||
return [[self proxiedView] hash];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#endif
|
||||
@@ -1,21 +0,0 @@
|
||||
//
|
||||
// DTHTMLElementA.h
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 21.03.13.
|
||||
// Copyright (c) 2013 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTHTMLElement.h"
|
||||
|
||||
/**
|
||||
Specialized subclass of <DTHTMLElement> that represents a hyperlink.
|
||||
*/
|
||||
@interface DTAnchorHTMLElement : DTHTMLElement
|
||||
|
||||
/**
|
||||
Foreground text color of the receiver when highlighted
|
||||
*/
|
||||
@property (nonatomic, strong) DTColor *highlightedTextColor;
|
||||
|
||||
@end
|
||||
@@ -1,54 +0,0 @@
|
||||
//
|
||||
// DTHTMLElementA.m
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 21.03.13.
|
||||
// Copyright (c) 2013 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCompatibility.h"
|
||||
#import "DTAnchorHTMLElement.h"
|
||||
#import "DTColorFunctions.h"
|
||||
|
||||
@implementation DTAnchorHTMLElement
|
||||
{
|
||||
DTColor *_highlightedTextColor;
|
||||
}
|
||||
|
||||
- (void)applyStyleDictionary:(NSDictionary *)styles
|
||||
{
|
||||
[super applyStyleDictionary:styles];
|
||||
|
||||
// get highlight color from a:active pseudo-selector
|
||||
NSString *activeColor = [styles objectForKey:@"active:color"];
|
||||
|
||||
if (activeColor)
|
||||
{
|
||||
self.highlightedTextColor = DTColorCreateWithHTMLName(activeColor);
|
||||
}
|
||||
}
|
||||
|
||||
- (NSAttributedString *)attributedString
|
||||
{
|
||||
// super returns a mutable attributed string
|
||||
NSMutableAttributedString *mutableAttributedString = (NSMutableAttributedString *)[super attributedString];
|
||||
|
||||
if (_highlightedTextColor)
|
||||
{
|
||||
NSRange range = NSMakeRange(0, [mutableAttributedString length]);
|
||||
|
||||
// this additional attribute keeps the highlight color
|
||||
[mutableAttributedString addAttribute:DTLinkHighlightColorAttribute value:(id)_highlightedTextColor range:range];
|
||||
|
||||
// we need to set the text color via the graphics context
|
||||
[mutableAttributedString addAttribute:(id)kCTForegroundColorFromContextAttributeName value:[NSNumber numberWithBool:YES] range:range];
|
||||
}
|
||||
|
||||
return mutableAttributedString;
|
||||
}
|
||||
|
||||
#pragma mark - Properties
|
||||
|
||||
@synthesize highlightedTextColor = _highlightedTextColor;
|
||||
|
||||
@end
|
||||
@@ -1,44 +0,0 @@
|
||||
//
|
||||
// DTAttributedLabel.h
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Brian Kenny on 1/17/13.
|
||||
// Copyright (c) 2013 Cocoanetics.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCompatibility.h"
|
||||
|
||||
#if TARGET_OS_IPHONE && !TARGET_OS_WATCH
|
||||
|
||||
#import "DTAttributedTextContentView.h"
|
||||
|
||||
/**
|
||||
A Rich Text replacement for `UILabel`. It inherits from <DTAttributedTextContentView> and as such you can also set the delegate to provide custom subviews i.e. for images or hyperlinks.
|
||||
|
||||
Contrary to DTAttributedTextContentView the intrinsicContentSize is only as wide as the text content. To shrink the DTAttributedLabel to that call -sizeToFit.
|
||||
*/
|
||||
|
||||
@interface DTAttributedLabel : DTAttributedTextContentView
|
||||
|
||||
/**
|
||||
@name Setting Attributes
|
||||
*/
|
||||
|
||||
/**
|
||||
The number of lines to display in the receiver
|
||||
*/
|
||||
@property(nonatomic, assign) NSInteger numberOfLines;
|
||||
|
||||
/**
|
||||
The line break mode of the receiver
|
||||
*/
|
||||
@property(nonatomic, assign) NSLineBreakMode lineBreakMode;
|
||||
|
||||
/**
|
||||
The string to append to the visible string in case a truncation occurs
|
||||
*/
|
||||
@property(nonatomic, strong) NSAttributedString *truncationString;
|
||||
|
||||
@end
|
||||
|
||||
#endif
|
||||
@@ -1,125 +0,0 @@
|
||||
//
|
||||
// DTAttributedLabel.m
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Brian Kenny on 1/17/13.
|
||||
// Copyright (c) 2013 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTAttributedLabel.h"
|
||||
|
||||
#if TARGET_OS_IPHONE && !TARGET_OS_WATCH
|
||||
|
||||
#import "DTCoreTextLayoutFrame.h"
|
||||
#import <QuartzCore/QuartzCore.h>
|
||||
|
||||
@implementation DTAttributedLabel
|
||||
|
||||
+ (Class)layerClass
|
||||
{
|
||||
// most likely the label will be less than a screen size and so we don't want any tiling behavior
|
||||
return [CALayer class];
|
||||
}
|
||||
|
||||
- (void) setupAttributedLabel
|
||||
{
|
||||
// we want to relayout the text if height or width change
|
||||
self.relayoutMask = DTAttributedTextContentViewRelayoutOnHeightChanged | DTAttributedTextContentViewRelayoutOnWidthChanged;
|
||||
|
||||
self.layoutFrameHeightIsConstrainedByBounds = YES; // height is not flexible
|
||||
}
|
||||
|
||||
- (id)initWithFrame:(CGRect)frame
|
||||
{
|
||||
self = [super initWithFrame:frame];
|
||||
|
||||
if (self)
|
||||
{
|
||||
[self setupAttributedLabel];
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
- (id) initWithCoder:(NSCoder *)aDecoder {
|
||||
self = [super initWithCoder:aDecoder];
|
||||
|
||||
if (self != nil)
|
||||
{
|
||||
[self setupAttributedLabel];
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
|
||||
|
||||
- (void) awakeFromNib
|
||||
{
|
||||
[super awakeFromNib];
|
||||
[self setupAttributedLabel];
|
||||
}
|
||||
|
||||
#pragma mark - Sizing
|
||||
|
||||
- (CGSize)intrinsicContentSize
|
||||
{
|
||||
if (!self.layoutFrame) // creates new layout frame if possible
|
||||
{
|
||||
return CGSizeMake(-1, -1); // UIViewNoIntrinsicMetric as of iOS 6
|
||||
}
|
||||
|
||||
// we have a layout frame and from this we get the needed size
|
||||
CGSize intrisicContentSize = [_layoutFrame intrinsicContentFrame].size;
|
||||
return CGSizeMake(intrisicContentSize.width + _edgeInsets.left + _edgeInsets.right,
|
||||
intrisicContentSize.height + _edgeInsets.top + _edgeInsets.bottom);
|
||||
}
|
||||
|
||||
#pragma mark - Properties
|
||||
|
||||
- (NSInteger)numberOfLines
|
||||
{
|
||||
return _numberOfLines;
|
||||
}
|
||||
|
||||
- (void)setNumberOfLines:(NSInteger)numberOfLines
|
||||
{
|
||||
if (numberOfLines != _numberOfLines)
|
||||
{
|
||||
_numberOfLines = numberOfLines;
|
||||
[self relayoutText];
|
||||
}
|
||||
}
|
||||
|
||||
- (NSLineBreakMode)lineBreakMode
|
||||
{
|
||||
return _lineBreakMode;
|
||||
}
|
||||
|
||||
- (void)setLineBreakMode:(NSLineBreakMode)lineBreakMode
|
||||
{
|
||||
if (lineBreakMode != _lineBreakMode)
|
||||
{
|
||||
_lineBreakMode = lineBreakMode;
|
||||
[self relayoutText];
|
||||
}
|
||||
}
|
||||
|
||||
- (NSAttributedString*)truncationString
|
||||
{
|
||||
return _truncationString;
|
||||
}
|
||||
|
||||
- (void)setTruncationString:(NSAttributedString *)truncationString
|
||||
{
|
||||
if (![truncationString isEqualToAttributedString:_truncationString])
|
||||
{
|
||||
_truncationString = truncationString;
|
||||
[self relayoutText];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@end
|
||||
|
||||
#endif
|
||||
@@ -1,93 +0,0 @@
|
||||
//
|
||||
// DTAttributedTextCell.h
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 8/4/11.
|
||||
// Copyright 2011 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCompatibility.h"
|
||||
|
||||
#if TARGET_OS_IPHONE && !TARGET_OS_WATCH
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
#import "DTAttributedTextContentView.h"
|
||||
#import <DTFoundation/DTWeakSupport.h>
|
||||
|
||||
/**
|
||||
This class represents a tableview cell that contains an attributed text as its content.
|
||||
*/
|
||||
@interface DTAttributedTextCell : UITableViewCell
|
||||
|
||||
/**
|
||||
@name Creating Cells
|
||||
*/
|
||||
|
||||
/**
|
||||
Creates a tableview cell with a given reuse identifier.
|
||||
@param reuseIdentifier The reuse identifier to use for the cell
|
||||
@returns A prepared cell
|
||||
*/
|
||||
- (id)initWithReuseIdentifier:(NSString *)reuseIdentifier;
|
||||
|
||||
/**
|
||||
@name Setting Attributed Content
|
||||
*/
|
||||
|
||||
/**
|
||||
The attributed string content of the receiver
|
||||
*/
|
||||
@property (nonatomic, strong) NSAttributedString *attributedString;
|
||||
|
||||
/**
|
||||
A delegate implementing DTAttributedTextContentViewDelegate to provide custom subviews for images and links.
|
||||
*/
|
||||
@property (nonatomic, DT_WEAK_PROPERTY) IBOutlet id <DTAttributedTextContentViewDelegate> textDelegate;
|
||||
|
||||
/**
|
||||
This method allows to set HTML text directly as content of the receiver.
|
||||
|
||||
This will be converted to an attributed string.
|
||||
@param html The HTML string to set as the receiver's text content
|
||||
*/
|
||||
- (void)setHTMLString:(NSString *)html;
|
||||
|
||||
/**
|
||||
This method allows to set HTML text directly as content of the receiver.
|
||||
|
||||
This will be converted to an attributed string.
|
||||
@param html The HTML string to set as the receiver's text content
|
||||
@param options The options used for rendering the HTML
|
||||
*/
|
||||
- (void) setHTMLString:(NSString *)html options:(NSDictionary*) options;
|
||||
|
||||
|
||||
/**
|
||||
@name Getting Information
|
||||
*/
|
||||
|
||||
/**
|
||||
Determines the row height that is needed in a specific table view to show the entire text content.
|
||||
|
||||
The table view is necessary because from this the method can know the style. Also the accessory type needs to be set before calling this method because this reduces the available space.
|
||||
@note This value is only useful for table views with variable row height.
|
||||
@param tableView The table view to determine the height for.
|
||||
*/
|
||||
- (CGFloat)requiredRowHeightInTableView:(UITableView *)tableView;
|
||||
|
||||
/**
|
||||
Determines whether the cells built-in contentView is allowed to dictate the size available for text. If active then attributedTextContextView's height always matches the cell height.
|
||||
|
||||
Set this to `YES` for use in fixed row height table views, leave it `NO` for flexible row height table views.
|
||||
*/
|
||||
@property (nonatomic, assign) BOOL hasFixedRowHeight;
|
||||
|
||||
/**
|
||||
The attributed text content view that the receiver uses to display the attributed text content.
|
||||
*/
|
||||
@property (nonatomic, readonly) DTAttributedTextContentView *attributedTextContextView;
|
||||
|
||||
@end
|
||||
|
||||
#endif
|
||||
@@ -1,276 +0,0 @@
|
||||
//
|
||||
// DTAttributedTextCell.m
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 8/4/11.
|
||||
// Copyright 2011 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCompatibility.h"
|
||||
|
||||
#import "DTAttributedTextCell.h"
|
||||
|
||||
#if TARGET_OS_IPHONE && !TARGET_OS_WATCH
|
||||
|
||||
#import "DTCoreText.h"
|
||||
#import "DTCSSStylesheet.h"
|
||||
|
||||
#import <DTFoundation/DTLog.h>
|
||||
|
||||
@implementation DTAttributedTextCell
|
||||
{
|
||||
DTAttributedTextContentView *_attributedTextContextView;
|
||||
|
||||
DT_WEAK_VARIABLE id <DTAttributedTextContentViewDelegate> _textDelegate;
|
||||
|
||||
NSUInteger _htmlHash; // preserved hash to avoid relayouting for same HTML
|
||||
|
||||
BOOL _hasFixedRowHeight;
|
||||
DT_WEAK_VARIABLE UITableView *_containingTableView;
|
||||
}
|
||||
|
||||
- (id)initWithReuseIdentifier:(NSString *)reuseIdentifier
|
||||
{
|
||||
self = [super initWithStyle:UITableViewCellStyleDefault reuseIdentifier:reuseIdentifier];
|
||||
|
||||
if (self)
|
||||
{
|
||||
// content view created lazily
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
_textDelegate = nil;
|
||||
_containingTableView = nil;
|
||||
}
|
||||
|
||||
- (void)layoutSubviews
|
||||
{
|
||||
[super layoutSubviews];
|
||||
|
||||
if (!self.superview)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_hasFixedRowHeight)
|
||||
{
|
||||
self.attributedTextContextView.frame = self.contentView.bounds;
|
||||
}
|
||||
else
|
||||
{
|
||||
CGFloat neededContentHeight = [self requiredRowHeightInTableView:_containingTableView];
|
||||
|
||||
// after the first call here the content view size is correct
|
||||
CGRect frame = CGRectMake(0, 0, self.contentView.bounds.size.width, neededContentHeight);
|
||||
self.attributedTextContextView.frame = frame;
|
||||
}
|
||||
}
|
||||
|
||||
- (UITableView *)_findContainingTableView
|
||||
{
|
||||
UIView *tableView = self.superview;
|
||||
|
||||
while (tableView)
|
||||
{
|
||||
if ([tableView isKindOfClass:[UITableView class]])
|
||||
{
|
||||
return (UITableView *)tableView;
|
||||
}
|
||||
|
||||
tableView = tableView.superview;
|
||||
}
|
||||
|
||||
return nil;
|
||||
}
|
||||
|
||||
- (void)didMoveToSuperview
|
||||
{
|
||||
[super didMoveToSuperview];
|
||||
|
||||
_containingTableView = [self _findContainingTableView];
|
||||
|
||||
// on < iOS 7 we need to make the background translucent to avoid artifacts at rounded edges
|
||||
if (_containingTableView.style == UITableViewStyleGrouped)
|
||||
{
|
||||
if (NSFoundationVersionNumber < DTNSFoundationVersionNumber_iOS_7_0)
|
||||
{
|
||||
_attributedTextContextView.backgroundColor = [UIColor clearColor];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// http://stackoverflow.com/questions/4708085/how-to-determine-margin-of-a-grouped-uitableview-or-better-how-to-set-it/4872199#4872199
|
||||
- (CGFloat)_groupedCellMarginWithTableWidth:(CGFloat)tableViewWidth
|
||||
{
|
||||
CGFloat marginWidth;
|
||||
if(tableViewWidth > 20)
|
||||
{
|
||||
if(tableViewWidth < 400 || [UIDevice currentDevice].userInterfaceIdiom==UIUserInterfaceIdiomPhone)
|
||||
{
|
||||
marginWidth = 10;
|
||||
}
|
||||
else
|
||||
{
|
||||
marginWidth = MAX(31.f, MIN(45.f, tableViewWidth*0.06f));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
marginWidth = tableViewWidth - 10;
|
||||
}
|
||||
return marginWidth;
|
||||
}
|
||||
|
||||
- (CGFloat)requiredRowHeightInTableView:(UITableView *)tableView
|
||||
{
|
||||
if (_hasFixedRowHeight)
|
||||
{
|
||||
DTLogWarning(@"You are calling %s even though the cell is configured with fixed row height", (const char *)__PRETTY_FUNCTION__);
|
||||
}
|
||||
|
||||
BOOL ios6Style = (NSFoundationVersionNumber < DTNSFoundationVersionNumber_iOS_7_0);
|
||||
CGFloat contentWidth = tableView.frame.size.width;
|
||||
|
||||
// reduce width for grouped table views
|
||||
if (ios6Style && tableView.style == UITableViewStyleGrouped)
|
||||
{
|
||||
contentWidth -= [self _groupedCellMarginWithTableWidth:contentWidth] * 2;
|
||||
}
|
||||
|
||||
// reduce width for accessories
|
||||
|
||||
switch (self.accessoryType)
|
||||
{
|
||||
case UITableViewCellAccessoryDisclosureIndicator:
|
||||
{
|
||||
contentWidth -= ios6Style ? 20.0f : 10.0f + 8.0f + 15.0f;
|
||||
break;
|
||||
}
|
||||
|
||||
case UITableViewCellAccessoryCheckmark:
|
||||
{
|
||||
contentWidth -= ios6Style ? 20.0f : 10.0f + 14.0f + 15.0f;
|
||||
break;
|
||||
}
|
||||
#if TARGET_OS_IOS
|
||||
case UITableViewCellAccessoryDetailDisclosureButton:
|
||||
{
|
||||
contentWidth -= ios6Style ? 33.0f : 10.0f + 42.0f + 15.0f;
|
||||
break;
|
||||
}
|
||||
|
||||
#if __IPHONE_OS_VERSION_MAX_ALLOWED > __IPHONE_6_1
|
||||
case UITableViewCellAccessoryDetailButton:
|
||||
{
|
||||
contentWidth -= 10.0f + 22.0f + 15.0f;
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
case UITableViewCellAccessoryNone:
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
{
|
||||
DTLogWarning(@"AccessoryType %d not implemented on %@", self.accessoryType, NSStringFromClass([self class]));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
CGSize neededSize = [self.attributedTextContextView suggestedFrameSizeToFitEntireStringConstraintedToWidth:contentWidth];
|
||||
|
||||
// note: non-integer row heights caused trouble < iOS 5.0
|
||||
return neededSize.height;
|
||||
}
|
||||
|
||||
#pragma mark Properties
|
||||
|
||||
- (void)setSelected:(BOOL)selected animated:(BOOL)animated
|
||||
{
|
||||
[super setSelected:selected animated:animated];
|
||||
|
||||
// Configure the view for the selected state
|
||||
}
|
||||
|
||||
- (void)setHTMLString:(NSString *)html
|
||||
{
|
||||
[self setHTMLString:html options:nil];
|
||||
}
|
||||
|
||||
- (void) setHTMLString:(NSString *)html options:(NSDictionary*) options {
|
||||
|
||||
NSUInteger newHash = [html hash];
|
||||
|
||||
if (newHash == _htmlHash)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_htmlHash = newHash;
|
||||
|
||||
NSData *data = [html dataUsingEncoding:NSUTF8StringEncoding];
|
||||
NSAttributedString *string = [[NSAttributedString alloc] initWithHTMLData:data options:options documentAttributes:NULL];
|
||||
self.attributedString = string;
|
||||
|
||||
[self setNeedsLayout];
|
||||
|
||||
}
|
||||
|
||||
- (void)setAttributedString:(NSAttributedString *)attributedString
|
||||
{
|
||||
// passthrough
|
||||
self.attributedTextContextView.attributedString = attributedString;
|
||||
}
|
||||
|
||||
- (NSAttributedString *)attributedString
|
||||
{
|
||||
// passthrough
|
||||
return _attributedTextContextView.attributedString;
|
||||
}
|
||||
|
||||
- (DTAttributedTextContentView *)attributedTextContextView
|
||||
{
|
||||
if (!_attributedTextContextView)
|
||||
{
|
||||
// don't know size because there's no string in it
|
||||
_attributedTextContextView = [[DTAttributedTextContentView alloc] initWithFrame:self.contentView.bounds];
|
||||
|
||||
_attributedTextContextView.edgeInsets = UIEdgeInsetsMake(5, 5, 5, 5);
|
||||
_attributedTextContextView.layoutFrameHeightIsConstrainedByBounds = _hasFixedRowHeight;
|
||||
_attributedTextContextView.delegate = _textDelegate;
|
||||
|
||||
[self.contentView addSubview:_attributedTextContextView];
|
||||
}
|
||||
|
||||
return _attributedTextContextView;
|
||||
}
|
||||
|
||||
- (void)setHasFixedRowHeight:(BOOL)hasFixedRowHeight
|
||||
{
|
||||
if (_hasFixedRowHeight != hasFixedRowHeight)
|
||||
{
|
||||
_hasFixedRowHeight = hasFixedRowHeight;
|
||||
|
||||
[self setNeedsLayout];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)setTextDelegate:(id)textDelegate
|
||||
{
|
||||
_textDelegate = textDelegate;
|
||||
_attributedTextContextView.delegate = _textDelegate;
|
||||
}
|
||||
|
||||
@synthesize attributedTextContextView = _attributedTextContextView;
|
||||
@synthesize hasFixedRowHeight = _hasFixedRowHeight;
|
||||
@synthesize textDelegate = _textDelegate;
|
||||
|
||||
@end
|
||||
|
||||
#endif
|
||||
@@ -1,370 +0,0 @@
|
||||
//
|
||||
// DTAttributedTextContentView.h
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 1/9/11.
|
||||
// Copyright 2011 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCompatibility.h"
|
||||
|
||||
#if TARGET_OS_IPHONE && !TARGET_OS_WATCH
|
||||
|
||||
#import "DTCoreTextLayoutFrame.h"
|
||||
#import <DTFoundation/DTWeakSupport.h>
|
||||
|
||||
@class DTAttributedTextContentView;
|
||||
@class DTCoreTextLayoutFrame;
|
||||
@class DTTextBlock;
|
||||
@class DTCoreTextLayouter;
|
||||
@class DTTextAttachment;
|
||||
|
||||
/**
|
||||
notification that gets sent as soon as the receiver has done a layout pass
|
||||
*/
|
||||
extern NSString * const DTAttributedTextContentViewDidFinishLayoutNotification;
|
||||
|
||||
/**
|
||||
Protocol to provide custom views for elements in an DTAttributedTextContentView. Also the delegate gets notified once the text view has been drawn.
|
||||
*/
|
||||
@protocol DTAttributedTextContentViewDelegate <NSObject>
|
||||
|
||||
@optional
|
||||
|
||||
/**
|
||||
@name Notifications
|
||||
*/
|
||||
|
||||
/**
|
||||
Called before a layout frame or a part of it is drawn. The text delegate can draw contents that goes under the text in this method.
|
||||
|
||||
@param attributedTextContentView The content view that will be drawing a layout frame
|
||||
@param layoutFrame The layout frame that will be drawn for
|
||||
@param context The graphics context that will drawn into
|
||||
*/
|
||||
- (void)attributedTextContentView:(DTAttributedTextContentView *)attributedTextContentView willDrawLayoutFrame:(DTCoreTextLayoutFrame *)layoutFrame inContext:(CGContextRef)context;
|
||||
|
||||
|
||||
/**
|
||||
Called after a layout frame or a part of it is drawn. The text delegate can draw contents that goes over the text in this method.
|
||||
|
||||
@param attributedTextContentView The content view that drew a layout frame
|
||||
@param layoutFrame The layout frame that was drawn for
|
||||
@param context The graphics context that was drawn into
|
||||
*/
|
||||
- (void)attributedTextContentView:(DTAttributedTextContentView *)attributedTextContentView didDrawLayoutFrame:(DTCoreTextLayoutFrame *)layoutFrame inContext:(CGContextRef)context;
|
||||
|
||||
|
||||
/**
|
||||
Called before the text belonging to a text block is drawn.
|
||||
|
||||
This gives the developer an opportunity to draw a custom background below a text block.
|
||||
|
||||
@param attributedTextContentView The content view that drew a layout frame
|
||||
@param textBlock The text block
|
||||
@param frame The frame within the content view's coordinate system that will be drawn into
|
||||
@param context The graphics context that will be drawn into
|
||||
@param layoutFrame The layout frame that will be drawn for
|
||||
@returns `YES` is the standard fill of the text block should be drawn, `NO` if it should not
|
||||
*/
|
||||
- (BOOL)attributedTextContentView:(DTAttributedTextContentView *)attributedTextContentView shouldDrawBackgroundForTextBlock:(DTTextBlock *)textBlock frame:(CGRect)frame context:(CGContextRef)context forLayoutFrame:(DTCoreTextLayoutFrame *)layoutFrame;
|
||||
|
||||
/**
|
||||
@name Providing Custom Views for Content
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
Provide custom view for an attachment, e.g. an imageView for images
|
||||
|
||||
@param attributedTextContentView The content view asking for a custom view
|
||||
@param attachment The <DTTextAttachment> that this view should represent
|
||||
@param frame The frame that the view should use to fit on top of the space reserved for the attachment
|
||||
@returns The view that should represent the given attachment
|
||||
*/
|
||||
- (UIView *)attributedTextContentView:(DTAttributedTextContentView *)attributedTextContentView viewForAttachment:(DTTextAttachment *)attachment frame:(CGRect)frame;
|
||||
|
||||
|
||||
/**
|
||||
Provide button to be placed over links, the identifier is used to link multiple parts of the same A tag
|
||||
|
||||
@param attributedTextContentView The content view asking for a custom view
|
||||
@param url The `NSURL` of the hyperlink
|
||||
@param identifier An identifier that uniquely identifies the hyperlink within the document
|
||||
@param frame The frame that the view should use to fit on top of the space reserved for the attachment
|
||||
@returns The view that should represent the given hyperlink
|
||||
*/
|
||||
- (UIView *)attributedTextContentView:(DTAttributedTextContentView *)attributedTextContentView viewForLink:(NSURL *)url identifier:(NSString *)identifier frame:(CGRect)frame;
|
||||
|
||||
|
||||
/**
|
||||
Provide generic views for all attachments.
|
||||
|
||||
This is only called if the more specific delegate methods are not implemented.
|
||||
|
||||
@param attributedTextContentView The content view asking for a custom view
|
||||
@param string The attributed sub-string containing this element
|
||||
@param frame The frame that the view should use to fit on top of the space reserved for the attachment
|
||||
@returns The view that should represent the given hyperlink or text attachment
|
||||
@see attributedTextContentView:viewForAttachment:frame: and attributedTextContentView:viewForAttachment:frame:
|
||||
*/
|
||||
- (UIView *)attributedTextContentView:(DTAttributedTextContentView *)attributedTextContentView viewForAttributedString:(NSAttributedString *)string frame:(CGRect)frame;
|
||||
|
||||
@end
|
||||
|
||||
|
||||
enum {
|
||||
DTAttributedTextContentViewRelayoutNever = 0,
|
||||
DTAttributedTextContentViewRelayoutOnWidthChanged = 1 << 0,
|
||||
DTAttributedTextContentViewRelayoutOnHeightChanged = 1 << 1,
|
||||
};
|
||||
typedef NSUInteger DTAttributedTextContentViewRelayoutMask;
|
||||
|
||||
|
||||
/**
|
||||
Attributed Text Content Views display attributed strings generated by DTHTMLAttributedStringBuilder. They can display images and hyperlinks inline or optionally place custom subviews (which get provided via the <delegate> in the appropriate places. By itself content views do not scroll, for that there is the `UIScrollView` subclass <DTAttributedTextView>.
|
||||
|
||||
Generally you have two options to providing content:
|
||||
|
||||
- set the attributed string
|
||||
- set a layout frame
|
||||
|
||||
The first you would normally use, the second you would use if you are layouting a larger text and then simply want to display individual parts (e.g. pages from an e-book) in a content view.
|
||||
|
||||
DTAttributedTextContentView is designed to be used as the content view inside a DTAttributedTextView and thus sizes its intrinsicContentSize always to be the same as the width of the set frame. Use DTAttributedLabel if you don't require scrolling behavior.
|
||||
*/
|
||||
|
||||
@interface DTAttributedTextContentView : UIView
|
||||
{
|
||||
NSAttributedString *_attributedString;
|
||||
DTCoreTextLayoutFrame *_layoutFrame;
|
||||
|
||||
UIEdgeInsets _edgeInsets;
|
||||
|
||||
NSMutableDictionary *customViewsForAttachmentsIndex;
|
||||
|
||||
BOOL _flexibleHeight;
|
||||
|
||||
// for layoutFrame
|
||||
NSInteger _numberOfLines;
|
||||
NSLineBreakMode _lineBreakMode;
|
||||
NSAttributedString *_truncationString;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
@name Sizing
|
||||
*/
|
||||
|
||||
/**
|
||||
Calculates the suggested frame size that would fit the entire <attributedString> with a maximum width.
|
||||
|
||||
This does a full layout pass that is cached in <DTCoreTextLayouter>. If you specify a frame that fits the result from this method then the resulting layoutFrame is reused.
|
||||
|
||||
Since this obeys the <edgeInsets> you have to add these to the final frame size.
|
||||
|
||||
@param width The maximum width to layout for
|
||||
@returns The suggested frame size
|
||||
*/
|
||||
- (CGSize)suggestedFrameSizeToFitEntireStringConstraintedToWidth:(CGFloat)width; // obeys the edge insets
|
||||
|
||||
/**
|
||||
The size of contents of the receiver. This is possibly used by auto-layout, but also for example if you want to get the size of the receiver necessary for a scroll view
|
||||
|
||||
This method is defined as of iOS 6, but to support earlier OS versions
|
||||
*/
|
||||
- (CGSize)intrinsicContentSize;
|
||||
|
||||
/**
|
||||
Whether the receiver calculates layout limited to the view bounds.
|
||||
|
||||
If set to `YES` then the layout process calculates the layoutFrame with open ended height. If set to ´NO` then the current bounds of the receiver determine the height.
|
||||
*/
|
||||
@property (nonatomic, assign) BOOL layoutFrameHeightIsConstrainedByBounds;
|
||||
|
||||
|
||||
/**
|
||||
@name Layouting
|
||||
*/
|
||||
|
||||
/**
|
||||
Discards the current <layoutFrame> and creates a new one based on the <attributedString>.
|
||||
*/
|
||||
- (void)relayoutText;
|
||||
|
||||
|
||||
/**
|
||||
The layouter to use for the receiver. Created by default.
|
||||
|
||||
By default this is generated automatically for the current <attributedString>. You can also supply your own if you require special layouting behavior.
|
||||
*/
|
||||
@property (atomic, strong) DTCoreTextLayouter *layouter;
|
||||
|
||||
|
||||
/**
|
||||
The layout frame to use for the receiver. Created by default.
|
||||
|
||||
A layout frame is basically one rectangle, inset by the <edgeInsets>. By default this is automatically generated for the current <attributedString>. You can also create a <DTCoreTextLayoutFrame> seperately and set this property to display the layout frame. This is usedful for example if you layout entire e-book and then set the <layoutFrame> for displaying individual pages.
|
||||
*/
|
||||
@property (atomic, strong) DTCoreTextLayoutFrame *layoutFrame;
|
||||
|
||||
|
||||
/**
|
||||
@name Working with Custom Subviews
|
||||
*/
|
||||
|
||||
/**
|
||||
Removes all custom subviews (excluding views representing links) from the receiver.
|
||||
*/
|
||||
- (void)removeAllCustomViews;
|
||||
|
||||
|
||||
/**
|
||||
Removes all custom subviews representing links from the receiver
|
||||
*/
|
||||
- (void)removeAllCustomViewsForLinks;
|
||||
|
||||
|
||||
/**
|
||||
Removes invisible custom subviews and lays out subviews visible in the given rectangle
|
||||
@param rect The bounds of the visible area to layout custom subviews in.
|
||||
*/
|
||||
- (void)layoutSubviewsInRect:(CGRect)rect;
|
||||
|
||||
|
||||
/**
|
||||
@name Providing Content
|
||||
*/
|
||||
|
||||
/**
|
||||
The attributed string to display in the receiver
|
||||
*/
|
||||
@property (nonatomic, copy) NSAttributedString *attributedString;
|
||||
|
||||
|
||||
/**
|
||||
The delegate that is in charge of supplying custom behavior for the receiver. It must conform to <DTAttributedTextContentViewDelegate> and provide custom subviews, link buttons, etc.
|
||||
*/
|
||||
|
||||
@property (nonatomic, DT_WEAK_PROPERTY) IBOutlet id <DTAttributedTextContentViewDelegate> delegate;
|
||||
|
||||
/**
|
||||
@name Customizing Content Display
|
||||
*/
|
||||
|
||||
/**
|
||||
The insets to apply around the text content
|
||||
*/
|
||||
@property (nonatomic) UIEdgeInsets edgeInsets;
|
||||
|
||||
/**
|
||||
Specifies if the receiver should draw image text attachments.
|
||||
|
||||
Set to `NO` if you use the delegate methods to provide custom subviews to display images.
|
||||
*/
|
||||
@property (nonatomic) BOOL shouldDrawImages;
|
||||
|
||||
|
||||
/**
|
||||
Specified if the receiver should draw hyperlinks.
|
||||
|
||||
If set to `NO` then your custom subview/button for hyperlinks is responsible for displaying hyperlinks. You can use <DTLinkButton> to have links show a differently for normal and highlighted style
|
||||
*/
|
||||
@property (nonatomic) BOOL shouldDrawLinks;
|
||||
|
||||
|
||||
/**
|
||||
Specifies if the receiver should layout custom subviews in layoutSubviews.
|
||||
|
||||
If set to `YES` then all custom subviews will always be layouted. Set to `NO` to only layout visible subviews, e.g. in a scroll view. Defaults to `YES` if used stand-alone, `NO` inside a <DTAttributedTextView>.
|
||||
*/
|
||||
@property (nonatomic) BOOL shouldLayoutCustomSubviews;
|
||||
|
||||
|
||||
/**
|
||||
The amount by which all contents of the receiver will offset of display and subview layouting
|
||||
*/
|
||||
@property (nonatomic) CGPoint layoutOffset;
|
||||
|
||||
|
||||
/**
|
||||
The offset to apply for drawing the background.
|
||||
|
||||
If you set a pattern color as background color you can have the pattern phase be offset by this value.
|
||||
*/
|
||||
@property (nonatomic) CGSize backgroundOffset;
|
||||
|
||||
|
||||
/**
|
||||
An integer bit mask that determines how the receiver relayouts its contents when its bounds change.
|
||||
|
||||
When the view’s bounds change, that view automatically re-layouts its text according to the relayout mask. You specify the value of this mask by combining the constants described in DTAttributedTextContentViewRelayoutMask using the C bitwise OR operator. Combining these constants lets you specify which dimensions will cause a re-layout if modified. The default value of this property is DTAttributedTextContentViewRelayoutOnWidthChanged, which indicates that the text will be re-layouted if the width changes, but not if the height changes.
|
||||
*/
|
||||
@property (nonatomic) DTAttributedTextContentViewRelayoutMask relayoutMask;
|
||||
|
||||
@end
|
||||
|
||||
|
||||
/**
|
||||
You can globally customize the layer class to be used for new instances of <DTAttributedTextContentView>. By itself it makes most sense to go with the default `CALayer`. For larger bodies of text, i.e. if there is scrolling then you should use a `CATiledLayer` subclass instead.
|
||||
*/
|
||||
@interface DTAttributedTextContentView (Tiling)
|
||||
|
||||
/**
|
||||
Sets the layer class globally to use in new instances of content views. Defaults to `CALayer`.
|
||||
|
||||
While being fine for most use cases you should use a `CATiledLayer` subclass for anything larger than a screen full, e.g. in scroll views.
|
||||
@param layerClass The class to use, should be a `CALayer` subclass
|
||||
*/
|
||||
+ (void)setLayerClass:(Class)layerClass;
|
||||
|
||||
/**
|
||||
The current layer class that is used for new instances
|
||||
@returns The `CALayer` subclass that new instances are using
|
||||
*/
|
||||
+ (Class)layerClass;
|
||||
|
||||
@end
|
||||
|
||||
|
||||
/**
|
||||
Methods for drawing the content view
|
||||
*/
|
||||
@interface DTAttributedTextContentView (Drawing)
|
||||
|
||||
/**
|
||||
Creates an image from a part of the receiver's content view
|
||||
@param bounds The bounds of the content to draw
|
||||
@param options The drawing options to apply when drawing
|
||||
@see [DTCoreTextLayoutFrame drawInContext:options:] for a list of available drawing options
|
||||
@returns A `UIImage` with the specified content
|
||||
*/
|
||||
- (UIImage *)contentImageWithBounds:(CGRect)bounds options:(DTCoreTextLayoutFrameDrawingOptions)options;
|
||||
|
||||
@end
|
||||
|
||||
|
||||
/**
|
||||
Methods for getting cursor position and frame. Those are convenience methods that call through to the layoutFrame property which has the same coordinate system as the receiver.
|
||||
*/
|
||||
@interface DTAttributedTextContentView (Cursor)
|
||||
|
||||
/**
|
||||
Determines the closest string index to a point in the receiver's frame.
|
||||
|
||||
This can be used to find the cursor position to position an input caret at.
|
||||
@param point The point
|
||||
@returns The resulting string index
|
||||
*/
|
||||
- (NSInteger)closestCursorIndexToPoint:(CGPoint)point;
|
||||
|
||||
/**
|
||||
The rectangle to draw a caret for a given index
|
||||
@param index The string index for which to determine a cursor frame
|
||||
@returns The cursor rectangle
|
||||
*/
|
||||
- (CGRect)cursorRectAtIndex:(NSInteger)index;
|
||||
|
||||
@end
|
||||
|
||||
#endif
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,136 +0,0 @@
|
||||
//
|
||||
// DTAttributedTextView.h
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 1/12/11.
|
||||
// Copyright 2011 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCompatibility.h"
|
||||
|
||||
#if TARGET_OS_IPHONE && !TARGET_OS_WATCH
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
#import "DTAttributedTextContentView.h"
|
||||
|
||||
@class DTAttributedTextView;
|
||||
|
||||
/**
|
||||
This view is designed to be a replacement for `UITextView`. It is a `UIScrollView` subclass and creates a <DTAttributedTextContentView> as content view for displaying the text.
|
||||
|
||||
The content view of type <DTAttributedTextContentView> is created lazily. You should not set values on it directly if you use it in conjunction with this class for scrolling.
|
||||
*/
|
||||
|
||||
@interface DTAttributedTextView : UIScrollView
|
||||
{
|
||||
// ivars needed by subclasses
|
||||
DTAttributedTextContentView *_attributedTextContentView;
|
||||
}
|
||||
|
||||
/**
|
||||
@name Providing Content
|
||||
*/
|
||||
|
||||
/**
|
||||
The attributed text to be displayed in the text content view of the receiver.
|
||||
*/
|
||||
@property (nonatomic, strong) NSAttributedString *attributedString;
|
||||
|
||||
|
||||
/**
|
||||
A delegate implementing DTAttributedTextContentViewDelegate to provide custom subviews for images and links.
|
||||
*/
|
||||
@property (nonatomic, DT_WEAK_PROPERTY) IBOutlet id <DTAttributedTextContentViewDelegate> textDelegate;
|
||||
|
||||
|
||||
/**
|
||||
Performs a new layout pass on the receiver. This destroys the frame setter, calls relayoutText on the content view and marks the receiver as needing layout so that custom subviews get appropriately sized.
|
||||
*/
|
||||
- (void)relayoutText;
|
||||
|
||||
/**
|
||||
@name Accessing Subviews
|
||||
*/
|
||||
|
||||
/**
|
||||
References to the DTAttributedTextContentView that display the text. This is not named contentView because this class inherits from `UIScrollView` which has an internal property of this name
|
||||
*/
|
||||
@property (nonatomic, strong, readonly) DTAttributedTextContentView *attributedTextContentView;
|
||||
|
||||
/**
|
||||
A view to be displayed behind the text content view
|
||||
*/
|
||||
@property (nonatomic, strong) IBOutlet UIView *backgroundView;
|
||||
|
||||
|
||||
/**
|
||||
@name Customizing Display
|
||||
*/
|
||||
|
||||
/**
|
||||
If the content view of the receiver should draw links. Set to `NO` if displaying links as custom views via textDelegate;
|
||||
|
||||
Defaults to `YES` if you supply your own link drawing then set this property to NO and supply your custom view (e.g. <DTLinkButton>) via the <textDelegate>.
|
||||
*/
|
||||
@property (nonatomic, assign) BOOL shouldDrawLinks;
|
||||
|
||||
/**
|
||||
If the content view of the receiver should draw images. Set to `NO` if displaying images as custom views via textDelegate;
|
||||
|
||||
Defaults to `YES` if you supply your own image drawing then set this property to NO and supply your custom image view (e.g. <DTLazyImageView>) via the <textDelegate>.
|
||||
*/
|
||||
@property (nonatomic, assign) BOOL shouldDrawImages;
|
||||
|
||||
|
||||
/**
|
||||
@name Customizing Content View
|
||||
*/
|
||||
|
||||
/**
|
||||
You can override this method to provide a different class to use for the content view. If you replace the content view class then it should inherit from <DTAttributedTextContentView> which is also the default.
|
||||
@returns The class to use for the content view.
|
||||
*/
|
||||
- (Class)classForContentView;
|
||||
|
||||
/**
|
||||
@name User Interaction
|
||||
*/
|
||||
|
||||
/**
|
||||
Scrolls the receiver to the anchor with the given name to the top.
|
||||
@param anchorName The name of the href anchor.
|
||||
@param animated `YES` if the movement should be animated.
|
||||
*/
|
||||
- (void)scrollToAnchorNamed:(NSString *)anchorName animated:(BOOL)animated;
|
||||
|
||||
/**
|
||||
Scrolls the receiver until the text in the specified range is visible.
|
||||
@param range The range of text to scroll into view.
|
||||
@param animated `YES` if the movement should be animated.
|
||||
*/
|
||||
- (void)scrollRangeToVisible:(NSRange)range animated:(BOOL)animated;
|
||||
|
||||
/**
|
||||
@name Working with a Cursor
|
||||
*/
|
||||
|
||||
/**
|
||||
Determines the closest string index to a point in the receiver's frame.
|
||||
|
||||
This can be used to find the cursor position to position an input caret at.
|
||||
@param point The point
|
||||
@returns The resulting string index
|
||||
*/
|
||||
- (NSInteger)closestCursorIndexToPoint:(CGPoint)point;
|
||||
|
||||
/**
|
||||
The rectangle to draw a caret for a given index
|
||||
@param index The string index for which to determine a cursor frame
|
||||
@returns The cursor rectangle
|
||||
*/
|
||||
- (CGRect)cursorRectAtIndex:(NSInteger)index;
|
||||
|
||||
@end
|
||||
|
||||
#endif
|
||||
@@ -1,429 +0,0 @@
|
||||
//
|
||||
// DTAttributedTextView.m
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 1/12/11.
|
||||
// Copyright 2011 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTAttributedTextView.h"
|
||||
|
||||
#if TARGET_OS_IPHONE && !TARGET_OS_WATCH
|
||||
|
||||
#import <QuartzCore/QuartzCore.h>
|
||||
|
||||
#import "DTCoreText.h"
|
||||
|
||||
#import <DTFoundation/DTTiledLayerWithoutFade.h>
|
||||
#import <DTFoundation/DTBlockFunctions.h>
|
||||
|
||||
|
||||
@interface DTAttributedTextView ()
|
||||
|
||||
- (void)_setup;
|
||||
|
||||
@end
|
||||
|
||||
|
||||
|
||||
@implementation DTAttributedTextView
|
||||
{
|
||||
UIView *_backgroundView;
|
||||
|
||||
// these are pass-through, i.e. store until the content view is created
|
||||
DT_WEAK_VARIABLE id textDelegate;
|
||||
NSAttributedString *_attributedString;
|
||||
|
||||
BOOL _shouldDrawLinks;
|
||||
BOOL _shouldDrawImages;
|
||||
}
|
||||
|
||||
- (id)initWithFrame:(CGRect)frame
|
||||
{
|
||||
self = [super initWithFrame:frame];
|
||||
|
||||
if (self)
|
||||
{
|
||||
[self _setup];
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
}
|
||||
|
||||
- (void)layoutSubviews
|
||||
{
|
||||
[super layoutSubviews];
|
||||
|
||||
self.attributedTextContentView.edgeInsets = self.contentInset;
|
||||
|
||||
// layout custom subviews for visible area
|
||||
[_attributedTextContentView layoutSubviewsInRect:self.bounds];
|
||||
}
|
||||
|
||||
- (void)awakeFromNib
|
||||
{
|
||||
[super awakeFromNib];
|
||||
[self _setup];
|
||||
}
|
||||
|
||||
- (void)safeAreaInsetsDidChange
|
||||
{
|
||||
[super safeAreaInsetsDidChange];
|
||||
}
|
||||
|
||||
// default
|
||||
- (void)_setup
|
||||
{
|
||||
if (self.backgroundColor)
|
||||
{
|
||||
CGFloat alpha = [self.backgroundColor alphaComponent];
|
||||
|
||||
if (alpha < 1.0)
|
||||
{
|
||||
self.opaque = NO;
|
||||
}
|
||||
else
|
||||
{
|
||||
self.opaque = YES;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
self.backgroundColor = [DTColor whiteColor];
|
||||
self.opaque = YES;
|
||||
}
|
||||
|
||||
self.autoresizesSubviews = NO;
|
||||
self.clipsToBounds = YES;
|
||||
|
||||
// defaults
|
||||
_shouldDrawLinks = YES;
|
||||
_shouldDrawImages = YES;
|
||||
}
|
||||
|
||||
// override class e.g. for mutable content view
|
||||
- (Class)classForContentView
|
||||
{
|
||||
return [DTAttributedTextContentView class];
|
||||
}
|
||||
|
||||
#pragma mark External Methods
|
||||
- (void)scrollToAnchorNamed:(NSString *)anchorName animated:(BOOL)animated
|
||||
{
|
||||
NSRange range = [self.attributedTextContentView.attributedString rangeOfAnchorNamed:anchorName];
|
||||
|
||||
if (range.location != NSNotFound)
|
||||
{
|
||||
[self scrollRangeToVisible:range animated:animated];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)scrollRangeToVisible:(NSRange)range animated:(BOOL)animated
|
||||
{
|
||||
// get the line of the first index of the anchor range
|
||||
DTCoreTextLayoutLine *line = [self.attributedTextContentView.layoutFrame lineContainingIndex:range.location];
|
||||
|
||||
// make sure we don't scroll too far
|
||||
CGFloat maxScrollPos = self.contentSize.height - self.bounds.size.height + self.contentInset.bottom + self.contentInset.top;
|
||||
CGFloat scrollPos = MIN(line.frame.origin.y, maxScrollPos);
|
||||
|
||||
// scroll
|
||||
[self setContentOffset:CGPointMake(0, scrollPos) animated:animated];
|
||||
}
|
||||
|
||||
- (void)relayoutText
|
||||
{
|
||||
DT_WEAK_VARIABLE typeof(self) weakSelf = self;
|
||||
DTBlockPerformSyncIfOnMainThreadElseAsync(^{
|
||||
DTAttributedTextView *strongSelf = weakSelf;
|
||||
|
||||
// need to reset the layouter because otherwise we get the old framesetter or cached layout frames
|
||||
strongSelf->_attributedTextContentView.layouter = nil;
|
||||
|
||||
// here we're layouting the entire string, might be more efficient to only relayout the paragraphs that contain these attachments
|
||||
[strongSelf->_attributedTextContentView relayoutText];
|
||||
|
||||
// layout custom subviews for visible area
|
||||
[strongSelf setNeedsLayout];
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - Working with a Cursor
|
||||
|
||||
- (NSInteger)closestCursorIndexToPoint:(CGPoint)point
|
||||
{
|
||||
// the point is in the coordinate system of the receiver, need to convert into those of the content view first
|
||||
CGPoint pointInContentView = [self.attributedTextContentView convertPoint:point fromView:self];
|
||||
|
||||
return [self.attributedTextContentView closestCursorIndexToPoint:pointInContentView];
|
||||
}
|
||||
|
||||
- (CGRect)cursorRectAtIndex:(NSInteger)index
|
||||
{
|
||||
CGRect rectInContentView = [self.attributedTextContentView cursorRectAtIndex:index];
|
||||
|
||||
// the point is in the coordinate system of the content view, need to convert into those of the receiver first
|
||||
CGRect rect = [self.attributedTextContentView convertRect:rectInContentView toView:self];
|
||||
|
||||
return rect;
|
||||
}
|
||||
|
||||
#pragma mark Notifications
|
||||
- (void)contentViewDidLayout:(NSNotification *)notification
|
||||
{
|
||||
DT_WEAK_VARIABLE typeof(self) weakSelf = self;
|
||||
DTBlockPerformSyncIfOnMainThreadElseAsync(^{
|
||||
DTAttributedTextView *strongSelf = weakSelf;
|
||||
|
||||
NSDictionary *userInfo = [notification userInfo];
|
||||
CGRect optimalFrame = [[userInfo objectForKey:@"OptimalFrame"] CGRectValue];
|
||||
|
||||
CGRect frame = UIEdgeInsetsInsetRect(self.bounds, self.contentInset);
|
||||
|
||||
// ignore possibly delayed layout notification for a different width
|
||||
if (optimalFrame.size.width == frame.size.width)
|
||||
{
|
||||
strongSelf->_attributedTextContentView.frame = optimalFrame;
|
||||
strongSelf.contentSize = [strongSelf->_attributedTextContentView intrinsicContentSize];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark Properties
|
||||
- (DTAttributedTextContentView *)attributedTextContentView
|
||||
{
|
||||
if (!_attributedTextContentView)
|
||||
{
|
||||
// subclasses can specify a DTAttributedTextContentView subclass instead
|
||||
Class classToUse = [self classForContentView];
|
||||
|
||||
CGRect frame = UIEdgeInsetsInsetRect(self.bounds, self.contentInset);
|
||||
|
||||
if (frame.size.width<=0 || frame.size.height<=0)
|
||||
{
|
||||
frame = CGRectZero;
|
||||
}
|
||||
|
||||
// make sure we always have a tiled layer
|
||||
Class previousLayerClass = nil;
|
||||
|
||||
// for DTAttributedTextContentView subclasses we force a tiled layer
|
||||
if ([classToUse isSubclassOfClass:[DTAttributedTextContentView class]])
|
||||
{
|
||||
Class layerClass = [DTAttributedTextContentView layerClass];
|
||||
|
||||
if (![layerClass isSubclassOfClass:[CATiledLayer class]])
|
||||
{
|
||||
[DTAttributedTextContentView setLayerClass:[DTTiledLayerWithoutFade class]];
|
||||
previousLayerClass = layerClass;
|
||||
}
|
||||
}
|
||||
|
||||
_attributedTextContentView = [[classToUse alloc] initWithFrame:frame];
|
||||
|
||||
// restore previous layer class if we changed the layer class for the content view
|
||||
if (previousLayerClass)
|
||||
{
|
||||
[DTAttributedTextContentView setLayerClass:previousLayerClass];
|
||||
}
|
||||
|
||||
_attributedTextContentView.userInteractionEnabled = YES;
|
||||
_attributedTextContentView.backgroundColor = self.backgroundColor;
|
||||
_attributedTextContentView.shouldLayoutCustomSubviews = NO; // we call layout when scrolling
|
||||
|
||||
// adjust opaqueness based on background color alpha
|
||||
CGFloat alpha = [self.backgroundColor alphaComponent];
|
||||
|
||||
if (alpha < 1.0)
|
||||
{
|
||||
_attributedTextContentView.opaque = NO;
|
||||
}
|
||||
else
|
||||
{
|
||||
_attributedTextContentView.opaque = YES;
|
||||
}
|
||||
|
||||
// set text delegate if it was set before instantiation of content view
|
||||
_attributedTextContentView.delegate = self->_textDelegate;
|
||||
|
||||
// pass on setting
|
||||
_attributedTextContentView.shouldDrawLinks = _shouldDrawLinks;
|
||||
|
||||
// notification that tells us about the actual size of the content view
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(contentViewDidLayout:) name:DTAttributedTextContentViewDidFinishLayoutNotification object:_attributedTextContentView];
|
||||
|
||||
// temporary frame to specify the width
|
||||
_attributedTextContentView.frame = frame;
|
||||
|
||||
// set text we previously got, this also triggers a relayout
|
||||
_attributedTextContentView.attributedString = _attributedString;
|
||||
|
||||
// this causes a relayout and the resulting notification will allow us to set the final frame
|
||||
|
||||
[self addSubview:_attributedTextContentView];
|
||||
}
|
||||
|
||||
return _attributedTextContentView;
|
||||
}
|
||||
|
||||
- (void)setBackgroundColor:(DTColor *)newColor
|
||||
{
|
||||
if ([newColor alphaComponent] < 1.0)
|
||||
{
|
||||
super.backgroundColor = newColor;
|
||||
_attributedTextContentView.backgroundColor = [DTColor clearColor];
|
||||
self.opaque = NO;
|
||||
}
|
||||
else
|
||||
{
|
||||
super.backgroundColor = newColor;
|
||||
|
||||
if (_attributedTextContentView.opaque)
|
||||
{
|
||||
_attributedTextContentView.backgroundColor = newColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)setContentInset:(UIEdgeInsets)contentInset
|
||||
{
|
||||
[super setContentInset:contentInset];
|
||||
|
||||
// height does not matter, that will be determined anyhow
|
||||
CGRect contentFrame = CGRectMake(0, 0, self.frame.size.width - self.contentInset.left - self.contentInset.right, _attributedTextContentView.frame.size.height);
|
||||
|
||||
if (CGRectEqualToRect(contentFrame, self.attributedTextContentView.frame))
|
||||
{
|
||||
self.attributedTextContentView.frame = contentFrame;
|
||||
}
|
||||
}
|
||||
|
||||
- (UIView *)backgroundView
|
||||
{
|
||||
if (!_backgroundView)
|
||||
{
|
||||
_backgroundView = [[UIView alloc] initWithFrame:self.bounds];
|
||||
_backgroundView.backgroundColor = [DTColor whiteColor];
|
||||
|
||||
// default is no interaction because background should have no interaction
|
||||
_backgroundView.userInteractionEnabled = NO;
|
||||
|
||||
[self insertSubview:_backgroundView belowSubview:self.attributedTextContentView];
|
||||
|
||||
// make content transparent so that we see the background
|
||||
_attributedTextContentView.backgroundColor = [DTColor clearColor];
|
||||
_attributedTextContentView.opaque = NO;
|
||||
}
|
||||
|
||||
return _backgroundView;
|
||||
}
|
||||
|
||||
- (void)setBackgroundView:(UIView *)backgroundView
|
||||
{
|
||||
if (_backgroundView != backgroundView)
|
||||
{
|
||||
[_backgroundView removeFromSuperview];
|
||||
_backgroundView = backgroundView;
|
||||
|
||||
if (_attributedTextContentView)
|
||||
{
|
||||
[self insertSubview:_backgroundView belowSubview:_attributedTextContentView];
|
||||
}
|
||||
else
|
||||
{
|
||||
[self addSubview:_backgroundView];
|
||||
}
|
||||
|
||||
if (_backgroundView)
|
||||
{
|
||||
// make content transparent so that we see the background
|
||||
_attributedTextContentView.backgroundColor = [DTColor clearColor];
|
||||
_attributedTextContentView.opaque = NO;
|
||||
}
|
||||
else
|
||||
{
|
||||
_attributedTextContentView.backgroundColor = [DTColor whiteColor];
|
||||
_attributedTextContentView.opaque = YES;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)setAttributedString:(NSAttributedString *)string
|
||||
{
|
||||
_attributedString = string;
|
||||
|
||||
// might need layout for visible custom views
|
||||
[self setNeedsLayout];
|
||||
|
||||
if (_attributedTextContentView)
|
||||
{
|
||||
// pass it along if contentView already exists
|
||||
_attributedTextContentView.attributedString = string;
|
||||
|
||||
// this causes a relayout and the resulting notification will allow us to set the frame and contentSize
|
||||
}
|
||||
}
|
||||
|
||||
- (NSAttributedString *)attributedString
|
||||
{
|
||||
return _attributedString;
|
||||
}
|
||||
|
||||
- (void)setFrame:(CGRect)frame
|
||||
{
|
||||
CGRect oldFrame = self.frame;
|
||||
|
||||
if (!CGRectEqualToRect(oldFrame, frame))
|
||||
{
|
||||
[super setFrame:frame]; // need to set own frame first because layout completion needs this updated frame
|
||||
|
||||
if (oldFrame.size.width != frame.size.width)
|
||||
{
|
||||
// height does not matter, that will be determined anyhow
|
||||
CGRect contentFrame = CGRectMake(0, 0, frame.size.width - self.contentInset.left - self.contentInset.right, _attributedTextContentView.frame.size.height);
|
||||
|
||||
_attributedTextContentView.frame = contentFrame;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)setTextDelegate:(id<DTAttributedTextContentViewDelegate>)aTextDelegate
|
||||
{
|
||||
// store unsafe pointer to delegate because we might not have a contentView yet
|
||||
self->_textDelegate = aTextDelegate;
|
||||
|
||||
// set it if possible, otherwise it will be set in contentView lazy property
|
||||
_attributedTextContentView.delegate = aTextDelegate;
|
||||
}
|
||||
|
||||
- (id<DTAttributedTextContentViewDelegate>)textDelegate
|
||||
{
|
||||
return _attributedTextContentView.delegate ?: self->_textDelegate;
|
||||
}
|
||||
|
||||
- (void)setShouldDrawLinks:(BOOL)shouldDrawLinks
|
||||
{
|
||||
_shouldDrawLinks = shouldDrawLinks;
|
||||
_attributedTextContentView.shouldDrawLinks = _shouldDrawLinks;
|
||||
}
|
||||
|
||||
- (void)setShouldDrawImages:(BOOL)shouldDrawImages
|
||||
{
|
||||
_shouldDrawImages = shouldDrawImages;
|
||||
_attributedTextContentView.shouldDrawImages = _shouldDrawImages;
|
||||
}
|
||||
|
||||
@synthesize attributedTextContentView = _attributedTextContentView;
|
||||
@synthesize attributedString = _attributedString;
|
||||
@synthesize textDelegate = _textDelegate;
|
||||
|
||||
@synthesize shouldDrawLinks = _shouldDrawLinks;
|
||||
|
||||
@end
|
||||
|
||||
#endif
|
||||
@@ -1,17 +0,0 @@
|
||||
//
|
||||
// DTHTMLElementBR.h
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 26.12.12.
|
||||
// Copyright (c) 2012 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTHTMLElement.h"
|
||||
|
||||
/**
|
||||
Specialized subclass of <DTHTMLElement> that represents a line break.
|
||||
*/
|
||||
|
||||
@interface DTBreakHTMLElement : DTHTMLElement
|
||||
|
||||
@end
|
||||
@@ -1,22 +0,0 @@
|
||||
//
|
||||
// DTHTMLElementBR.m
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 26.12.12.
|
||||
// Copyright (c) 2012 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTBreakHTMLElement.h"
|
||||
|
||||
@implementation DTBreakHTMLElement
|
||||
|
||||
- (NSAttributedString *)attributedString
|
||||
{
|
||||
@synchronized(self)
|
||||
{
|
||||
NSDictionary *attributes = [self attributesForAttributedStringRepresentation];
|
||||
return [[NSAttributedString alloc] initWithString:UNICODE_LINE_FEED attributes:attributes];
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -1,264 +0,0 @@
|
||||
//
|
||||
// DTCSSListStyle.h
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 8/11/11.
|
||||
// Copyright 2011 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCompatibility.h"
|
||||
|
||||
/**
|
||||
List Styles
|
||||
*/
|
||||
typedef NS_ENUM(NSUInteger, DTCSSListStyleType)
|
||||
{
|
||||
/**
|
||||
The list style should be inherited from the parent
|
||||
*/
|
||||
DTCSSListStyleTypeInherit = 0,
|
||||
|
||||
/**
|
||||
No list style
|
||||
*/
|
||||
DTCSSListStyleTypeNone,
|
||||
|
||||
/**
|
||||
Circle bullet list style
|
||||
*/
|
||||
DTCSSListStyleTypeCircle,
|
||||
|
||||
/**
|
||||
Decimal number list style
|
||||
*/
|
||||
DTCSSListStyleTypeDecimal,
|
||||
|
||||
/**
|
||||
Decimal number list style with a leading zero
|
||||
*/
|
||||
DTCSSListStyleTypeDecimalLeadingZero,
|
||||
|
||||
/**
|
||||
Disc bullet list style
|
||||
*/
|
||||
DTCSSListStyleTypeDisc,
|
||||
|
||||
/**
|
||||
Square bullet list style
|
||||
*/
|
||||
DTCSSListStyleTypeSquare,
|
||||
|
||||
/**
|
||||
Numbered list style with uppercase letters
|
||||
*/
|
||||
DTCSSListStyleTypeUpperAlpha,
|
||||
|
||||
/**
|
||||
Numbered list style with uppercase letters
|
||||
*/
|
||||
DTCSSListStyleTypeUpperLatin,
|
||||
|
||||
/**
|
||||
Numbered list style with uppercase roman numbers
|
||||
*/
|
||||
DTCSSListStyleTypeUpperRoman,
|
||||
|
||||
/**
|
||||
Numbered list style with lowercase letters
|
||||
*/
|
||||
DTCSSListStyleTypeLowerAlpha,
|
||||
|
||||
/**
|
||||
Numbered list style with lowercase letters
|
||||
*/
|
||||
DTCSSListStyleTypeLowerLatin,
|
||||
|
||||
/**
|
||||
Numbered list style with lowercase roman numbers
|
||||
*/
|
||||
DTCSSListStyleTypeLowerRoman,
|
||||
|
||||
/**
|
||||
Plus bullet list style
|
||||
*/
|
||||
DTCSSListStyleTypePlus,
|
||||
|
||||
/**
|
||||
Underscore bullet list style
|
||||
*/
|
||||
DTCSSListStyleTypeUnderscore,
|
||||
|
||||
/**
|
||||
Image bullet list style
|
||||
*/
|
||||
DTCSSListStyleTypeImage,
|
||||
|
||||
/**
|
||||
Value used to represent an invalid list style
|
||||
*/
|
||||
DTCSSListStyleTypeInvalid = NSIntegerMax
|
||||
};
|
||||
|
||||
/**
|
||||
List Marker Positions
|
||||
*/
|
||||
typedef NS_ENUM(NSUInteger, DTCSSListStylePosition)
|
||||
{
|
||||
/**
|
||||
List position should be inherited
|
||||
*/
|
||||
DTCSSListStylePositionInherit = 0,
|
||||
|
||||
/**
|
||||
List prefix position inside
|
||||
*/
|
||||
DTCSSListStylePositionInside,
|
||||
|
||||
/**
|
||||
List prefix position outside
|
||||
*/
|
||||
DTCSSListStylePositionOutside,
|
||||
|
||||
/**
|
||||
Value used to represent an invalid list style position
|
||||
*/
|
||||
DTCSSListStylePositionInvalid = NSIntegerMax
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
This class is the equivalent of `NSTextList` on Mac with the added handling of the marker position.
|
||||
*/
|
||||
@interface DTCSSListStyle : NSObject <NSCoding>
|
||||
|
||||
|
||||
/**
|
||||
@name Getting Types from Strings
|
||||
*/
|
||||
|
||||
/**
|
||||
Convert a string into a list style type.
|
||||
|
||||
@param string The string to convert
|
||||
*/
|
||||
+ (DTCSSListStyleType)listStyleTypeFromString:(NSString *)string;
|
||||
|
||||
|
||||
/**
|
||||
Convert a string into a marker position.
|
||||
|
||||
@param string The string to convert
|
||||
*/
|
||||
+ (DTCSSListStylePosition)listStylePositionFromString:(NSString *)string;
|
||||
|
||||
/**
|
||||
@name Creating List Styles
|
||||
*/
|
||||
|
||||
/**
|
||||
Creates a list style from the passed CSS style dictionary
|
||||
|
||||
@param styles A CSS style dictionary from which the construct a suitable list style
|
||||
*/
|
||||
- (id)initWithStyles:(NSDictionary *)styles;
|
||||
|
||||
/**
|
||||
@name Working with CSS Styles
|
||||
*/
|
||||
|
||||
/**
|
||||
Update the receiver from the CSS styles dictionary passed
|
||||
|
||||
@param styles A dictionary of CSS styles.
|
||||
*/
|
||||
- (void)updateFromStyleDictionary:(NSDictionary *)styles;
|
||||
|
||||
|
||||
/**
|
||||
@name Working with Prefixes
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
Returns the prefix for lists of the receiver's settings.
|
||||
|
||||
@param counter The counter value to use for ordered lists.
|
||||
@returns The prefix string to prepend to list items.
|
||||
*/
|
||||
- (NSString *)prefixWithCounter:(NSInteger)counter;
|
||||
|
||||
|
||||
/**
|
||||
@name Managing Item Numbering
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
Sets the starting item number for the text list.
|
||||
|
||||
The default value is `1`. This value will be used only for ordered lists, and ignored in other cases.
|
||||
|
||||
@param itemNum The item number.
|
||||
*/
|
||||
- (void)setStartingItemNumber:(NSInteger)itemNum;
|
||||
|
||||
|
||||
/**
|
||||
Returns the starting item number for the text list.
|
||||
|
||||
The default value is `1`. This value will be used only for ordered lists, and ignored in other cases.
|
||||
@returns The item number.
|
||||
*/
|
||||
- (NSInteger)startingItemNumber;
|
||||
|
||||
|
||||
/**
|
||||
@name Comparing Lists
|
||||
*/
|
||||
|
||||
/**
|
||||
Determine if another list style has equivalent settings. Note that this does not mean that they are identical, only that they look the same.
|
||||
@param otherListStyle The other list style to compare the receiver with
|
||||
@returns `YES` if the other list style has the same values
|
||||
*/
|
||||
- (BOOL)isEqualToListStyle:(DTCSSListStyle *)otherListStyle;
|
||||
|
||||
|
||||
/**
|
||||
@name Getting Information about Lists
|
||||
*/
|
||||
|
||||
/**
|
||||
Returns if the receiver is an ordered or unordered list
|
||||
|
||||
@returns `YES` if the receiver is ordered, `NO` if it is unordered
|
||||
*/
|
||||
- (BOOL)isOrdered;
|
||||
|
||||
/**
|
||||
If the list style is inherited.
|
||||
|
||||
@warn This is not implemented.
|
||||
*/
|
||||
@property (nonatomic, assign) BOOL inherit;
|
||||
|
||||
|
||||
/**
|
||||
The type of the text list. See DTCSSListStyleType for available types
|
||||
*/
|
||||
@property (nonatomic, assign) DTCSSListStyleType type;
|
||||
|
||||
|
||||
/**
|
||||
The position of the marker in the prefix. See DTCSSListStylePosition for available positions.
|
||||
*/
|
||||
@property (nonatomic, assign) DTCSSListStylePosition position;
|
||||
|
||||
|
||||
/**
|
||||
The image name to use for the marker
|
||||
*/
|
||||
@property (nonatomic, copy) NSString *imageName;
|
||||
|
||||
|
||||
@end
|
||||
@@ -1,483 +0,0 @@
|
||||
//
|
||||
// DTCSSListStyle.m
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 8/11/11.
|
||||
// Copyright 2011 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCSSListStyle.h"
|
||||
|
||||
#import "DTCoreTextConstants.h"
|
||||
|
||||
#import "NSScanner+HTML.h"
|
||||
#import "NSNumber+RomanNumerals.h"
|
||||
|
||||
|
||||
@interface DTCSSListStyle ()
|
||||
|
||||
- (void)updateFromStyleDictionary:(NSDictionary *)styles;
|
||||
|
||||
@property (nonatomic, assign) NSInteger startingItemNumber;
|
||||
|
||||
@end
|
||||
|
||||
|
||||
|
||||
@implementation DTCSSListStyle
|
||||
{
|
||||
BOOL _inherit;
|
||||
|
||||
DTCSSListStyleType _type;
|
||||
DTCSSListStylePosition _position;
|
||||
|
||||
NSString *_imageName;
|
||||
NSInteger _startingItemNumber;
|
||||
}
|
||||
|
||||
- (id)initWithStyles:(NSDictionary *)styles
|
||||
{
|
||||
self = [super init];
|
||||
|
||||
if (self)
|
||||
{
|
||||
// default
|
||||
_position = DTCSSListStylePositionOutside;
|
||||
_startingItemNumber = 1;
|
||||
|
||||
[self updateFromStyleDictionary:styles];
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
- (instancetype)initWithCoder:(NSCoder *)aDecoder {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_inherit = [aDecoder decodeBoolForKey:@"inherit"];
|
||||
_type = [aDecoder decodeIntegerForKey:@"type"];
|
||||
_position = [aDecoder decodeIntegerForKey:@"position"];
|
||||
_imageName = [aDecoder decodeObjectForKey:@"imageName"];
|
||||
_startingItemNumber = [aDecoder decodeIntegerForKey:@"startingItemNumber"];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)encodeWithCoder:(NSCoder *)aCoder {
|
||||
[aCoder encodeBool:_inherit forKey:@"inherit"];
|
||||
[aCoder encodeInteger:_type forKey:@"type"];
|
||||
[aCoder encodeInteger:_position forKey:@"position"];
|
||||
[aCoder encodeObject:_imageName forKey:@"imageName"];
|
||||
[aCoder encodeInteger:_startingItemNumber forKey:@"startingItemNumber"];
|
||||
}
|
||||
|
||||
// convert string to listStyleType
|
||||
+ (DTCSSListStyleType)listStyleTypeFromString:(NSString *)string
|
||||
{
|
||||
if (!string)
|
||||
{
|
||||
return DTCSSListStyleTypeInvalid;
|
||||
}
|
||||
|
||||
// always compare lower case
|
||||
string = [string lowercaseString];
|
||||
|
||||
if ([string isEqualToString:@"inherit"])
|
||||
{
|
||||
return DTCSSListStyleTypeInherit;
|
||||
}
|
||||
else if ([string isEqualToString:@"none"])
|
||||
{
|
||||
return DTCSSListStyleTypeNone;
|
||||
}
|
||||
else if ([string isEqualToString:@"circle"])
|
||||
{
|
||||
return DTCSSListStyleTypeCircle;
|
||||
}
|
||||
else if ([string isEqualToString:@"square"])
|
||||
{
|
||||
return DTCSSListStyleTypeSquare;
|
||||
}
|
||||
else if ([string isEqualToString:@"decimal"])
|
||||
{
|
||||
return DTCSSListStyleTypeDecimal;
|
||||
}
|
||||
else if ([string isEqualToString:@"decimal-leading-zero"])
|
||||
{
|
||||
return DTCSSListStyleTypeDecimalLeadingZero;
|
||||
}
|
||||
else if ([string isEqualToString:@"disc"])
|
||||
{
|
||||
return DTCSSListStyleTypeDisc;
|
||||
}
|
||||
else if ([string isEqualToString:@"upper-alpha"]||[string isEqualToString:@"upper-latin"])
|
||||
{
|
||||
return DTCSSListStyleTypeUpperAlpha;
|
||||
}
|
||||
else if ([string isEqualToString:@"lower-alpha"]||[string isEqualToString:@"lower-latin"])
|
||||
{
|
||||
return DTCSSListStyleTypeLowerAlpha;
|
||||
}
|
||||
else if ([string isEqualToString:@"lower-roman"])
|
||||
{
|
||||
return DTCSSListStyleTypeLowerRoman;
|
||||
}
|
||||
else if ([string isEqualToString:@"upper-roman"])
|
||||
{
|
||||
return DTCSSListStyleTypeUpperRoman;
|
||||
}
|
||||
else if ([string isEqualToString:@"plus"])
|
||||
{
|
||||
return DTCSSListStyleTypePlus;
|
||||
}
|
||||
else if ([string isEqualToString:@"underscore"])
|
||||
{
|
||||
return DTCSSListStyleTypeUnderscore;
|
||||
}
|
||||
else
|
||||
{
|
||||
return DTCSSListStyleTypeNone;
|
||||
}
|
||||
}
|
||||
|
||||
+ (DTCSSListStylePosition)listStylePositionFromString:(NSString *)string
|
||||
{
|
||||
if (!string)
|
||||
{
|
||||
return DTCSSListStylePositionInvalid;
|
||||
}
|
||||
|
||||
// always compare lower case
|
||||
string = [string lowercaseString];
|
||||
|
||||
if ([string isEqualToString:@"inherit"])
|
||||
{
|
||||
return DTCSSListStylePositionInherit;
|
||||
}
|
||||
else if ([string isEqualToString:@"inside"])
|
||||
{
|
||||
return DTCSSListStylePositionInside;
|
||||
}
|
||||
else if ([string isEqualToString:@"outside"])
|
||||
{
|
||||
return DTCSSListStylePositionOutside;
|
||||
}
|
||||
else
|
||||
{
|
||||
return DTCSSListStylePositionInherit;
|
||||
}
|
||||
}
|
||||
|
||||
// returns NO if not a valid type
|
||||
- (BOOL)setTypeWithString:(NSString *)string
|
||||
{
|
||||
DTCSSListStyleType type = [DTCSSListStyle listStyleTypeFromString:string];
|
||||
if (type == DTCSSListStyleTypeInvalid)
|
||||
{
|
||||
return NO;
|
||||
}
|
||||
|
||||
_type = type;
|
||||
|
||||
return YES;
|
||||
}
|
||||
|
||||
// returns NO if not a valid type
|
||||
- (BOOL)setPositionWithString:(NSString *)string
|
||||
{
|
||||
DTCSSListStylePosition position = [DTCSSListStyle listStylePositionFromString:string];
|
||||
|
||||
if (position == DTCSSListStylePositionInvalid)
|
||||
{
|
||||
return NO;
|
||||
}
|
||||
|
||||
_position = position;
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (void)updateFromStyleDictionary:(NSDictionary *)styles
|
||||
{
|
||||
NSString *shortHand = [[styles objectForKey:@"list-style"] lowercaseString];
|
||||
|
||||
if (shortHand)
|
||||
{
|
||||
if ([shortHand isEqualToString:@"inherit"])
|
||||
{
|
||||
_inherit = YES;
|
||||
return;
|
||||
}
|
||||
|
||||
NSArray *components = [shortHand componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
|
||||
|
||||
BOOL typeWasSet = NO;
|
||||
BOOL positionWasSet = NO;
|
||||
|
||||
|
||||
for (NSString *oneComponent in components)
|
||||
{
|
||||
if ([oneComponent hasPrefix:@"url"])
|
||||
{
|
||||
// list-style-image
|
||||
NSString *urlString;
|
||||
NSScanner *scanner = [NSScanner scannerWithString:oneComponent];
|
||||
|
||||
if ([scanner scanCSSURL:&urlString])
|
||||
{
|
||||
self.imageName = urlString;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!typeWasSet && [self setTypeWithString:oneComponent])
|
||||
{
|
||||
typeWasSet = YES;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!positionWasSet && [self setPositionWithString:oneComponent])
|
||||
{
|
||||
positionWasSet = YES;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// not a short hand, set from individual types
|
||||
|
||||
[self setTypeWithString:[styles objectForKey:@"list-style-type"]];
|
||||
[self setPositionWithString:[styles objectForKey:@"list-style-position"]];
|
||||
|
||||
NSObject *tmpValue = [styles objectForKey:@"list-style-image"];
|
||||
|
||||
if ([tmpValue isKindOfClass:NSString.class])
|
||||
{
|
||||
// extract just the name
|
||||
|
||||
NSString *urlString;
|
||||
NSScanner *scanner = [NSScanner scannerWithString:(NSString *)tmpValue];
|
||||
|
||||
if ([scanner scanCSSURL:&urlString])
|
||||
{
|
||||
self.imageName = urlString;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#ifndef COVERAGE
|
||||
// exclude methods from coverage testing
|
||||
|
||||
- (NSString *)description
|
||||
{
|
||||
return [NSString stringWithFormat:@"<%@ %p type=%d position=%d>", NSStringFromClass([self class]), self, (int)_type, (int)_position];
|
||||
}
|
||||
|
||||
- (NSUInteger)hash
|
||||
{
|
||||
NSUInteger calcHash = 7;
|
||||
|
||||
calcHash = calcHash*31 + [_imageName hash];
|
||||
calcHash = calcHash*31 + (NSUInteger)_type;
|
||||
calcHash = calcHash*31 + (NSUInteger)_position;
|
||||
calcHash = calcHash*31 + (NSUInteger)_startingItemNumber;
|
||||
calcHash = calcHash*31 + (NSUInteger)_inherit;
|
||||
|
||||
return calcHash;
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
/*
|
||||
Note: this is not isEqual: because on iOS 7 -[NSMutableAttributedString initWithString:attributes:] calls this via -[NSArray isEqualToArray:]. There isEqual: needs to be returning NO, because otherwise there is some weird internal caching side effect where it reuses previous list arrays
|
||||
*/
|
||||
- (BOOL)isEqualToListStyle:(DTCSSListStyle *)otherListStyle
|
||||
{
|
||||
if (!otherListStyle)
|
||||
{
|
||||
return NO;
|
||||
}
|
||||
|
||||
if (otherListStyle == self)
|
||||
{
|
||||
return YES;
|
||||
}
|
||||
|
||||
if (![otherListStyle isKindOfClass:[DTCSSListStyle class]])
|
||||
{
|
||||
return NO;
|
||||
}
|
||||
|
||||
if (_inherit != otherListStyle->_inherit)
|
||||
{
|
||||
return NO;
|
||||
}
|
||||
|
||||
if (_type != otherListStyle->_type)
|
||||
{
|
||||
return NO;
|
||||
}
|
||||
|
||||
if (_position != otherListStyle->_position)
|
||||
{
|
||||
return NO;
|
||||
}
|
||||
|
||||
if (_startingItemNumber != otherListStyle->_startingItemNumber)
|
||||
{
|
||||
return NO;
|
||||
}
|
||||
|
||||
if (_imageName == otherListStyle->_imageName)
|
||||
{
|
||||
return YES;
|
||||
}
|
||||
|
||||
return ([_imageName isEqualToString:otherListStyle->_imageName]);
|
||||
}
|
||||
|
||||
#pragma mark Copying
|
||||
|
||||
- (id)copyWithZone:(NSZone *)zone
|
||||
{
|
||||
DTCSSListStyle *newStyle = [[DTCSSListStyle allocWithZone:zone] init];
|
||||
newStyle.type = self.type;
|
||||
newStyle.position = self.position;
|
||||
newStyle.imageName = self.imageName;
|
||||
newStyle.startingItemNumber = self.startingItemNumber;
|
||||
|
||||
return newStyle;
|
||||
}
|
||||
|
||||
#pragma mark Utilities
|
||||
|
||||
- (NSString *)prefixWithCounter:(NSInteger)counter
|
||||
{
|
||||
NSString *token = nil;
|
||||
|
||||
DTCSSListStyleType listStyleType = _type;
|
||||
|
||||
if (self.imageName)
|
||||
{
|
||||
listStyleType = DTCSSListStyleTypeImage;
|
||||
}
|
||||
|
||||
|
||||
switch (listStyleType)
|
||||
{
|
||||
case DTCSSListStyleTypeNone:
|
||||
case DTCSSListStyleTypeInherit: // should never be called with inherit
|
||||
case DTCSSListStyleTypeInvalid:
|
||||
{
|
||||
return nil;
|
||||
}
|
||||
case DTCSSListStyleTypeImage:
|
||||
{
|
||||
token = UNICODE_OBJECT_PLACEHOLDER;
|
||||
break;
|
||||
}
|
||||
case DTCSSListStyleTypeCircle:
|
||||
{
|
||||
token = @"\u25e6";
|
||||
break;
|
||||
}
|
||||
case DTCSSListStyleTypeSquare:
|
||||
{
|
||||
token = @"\u25aa";
|
||||
break;
|
||||
}
|
||||
case DTCSSListStyleTypeDecimal:
|
||||
{
|
||||
token = [NSString stringWithFormat:@"%d.", (int)counter];
|
||||
break;
|
||||
}
|
||||
case DTCSSListStyleTypeDecimalLeadingZero:
|
||||
{
|
||||
token = [NSString stringWithFormat:@"%02d.", (int)counter];
|
||||
break;
|
||||
}
|
||||
case DTCSSListStyleTypeDisc:
|
||||
{
|
||||
token = @"\u2022";
|
||||
break;
|
||||
}
|
||||
case DTCSSListStyleTypeUpperAlpha:
|
||||
case DTCSSListStyleTypeUpperLatin:
|
||||
{
|
||||
char letter = 'A' + (char)(counter - 1);
|
||||
token = [NSString stringWithFormat:@"%c.", letter];
|
||||
break;
|
||||
}
|
||||
case DTCSSListStyleTypeLowerAlpha:
|
||||
case DTCSSListStyleTypeLowerLatin:
|
||||
{
|
||||
char letter = 'a' + (char)(counter - 1);
|
||||
token = [NSString stringWithFormat:@"%c.", letter];
|
||||
break;
|
||||
}
|
||||
case DTCSSListStyleTypePlus:
|
||||
{
|
||||
token = @"+";
|
||||
break;
|
||||
}
|
||||
case DTCSSListStyleTypeUnderscore:
|
||||
{
|
||||
token = @"_";
|
||||
break;
|
||||
}
|
||||
case DTCSSListStyleTypeUpperRoman:
|
||||
{
|
||||
token = [NSString stringWithFormat:@"%@.",[@(counter) romanNumeral]];
|
||||
break;
|
||||
}
|
||||
case DTCSSListStyleTypeLowerRoman:
|
||||
{
|
||||
token = [NSString stringWithFormat:@"%@.",[[@(counter) romanNumeral] lowercaseString]];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (_position == DTCSSListStylePositionInside)
|
||||
{
|
||||
// iOS needs second tab, Mac ignores position outside
|
||||
#if TARGET_OS_IPHONE
|
||||
return [NSString stringWithFormat:@"\x09\x09%@", token];
|
||||
#else
|
||||
return [NSString stringWithFormat:@"\x09%@\x09", token];
|
||||
#endif
|
||||
}
|
||||
else
|
||||
{
|
||||
return [NSString stringWithFormat:@"\x09%@\x09", token];
|
||||
}
|
||||
}
|
||||
|
||||
- (BOOL)isOrdered
|
||||
{
|
||||
switch (_type)
|
||||
{
|
||||
case DTCSSListStyleTypeDecimal:
|
||||
case DTCSSListStyleTypeDecimalLeadingZero:
|
||||
case DTCSSListStyleTypeUpperAlpha:
|
||||
case DTCSSListStyleTypeUpperLatin:
|
||||
case DTCSSListStyleTypeLowerAlpha:
|
||||
case DTCSSListStyleTypeLowerLatin:
|
||||
return YES;
|
||||
|
||||
default:
|
||||
return NO;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark Properties
|
||||
|
||||
@synthesize inherit = _inherit;
|
||||
@synthesize type = _type;
|
||||
@synthesize position = _position;
|
||||
@synthesize imageName = _imageName;
|
||||
@synthesize startingItemNumber = _startingItemNumber;
|
||||
|
||||
@end
|
||||
|
||||
// TO DO: Implement image
|
||||
@@ -1,86 +0,0 @@
|
||||
//
|
||||
// DTCSSStylesheet.h
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 9/5/11.
|
||||
// Copyright (c) 2011 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
@class DTHTMLElement;
|
||||
|
||||
/**
|
||||
This class represents a CSS style sheet used for specifying formatting for certain CSS selectors.
|
||||
|
||||
It supports matching styles by class, by id or by tag name. Hierarchy matching is not supported yet.
|
||||
*/
|
||||
@interface DTCSSStylesheet : NSObject <NSCopying>
|
||||
|
||||
|
||||
/**
|
||||
@name Creating Stylesheets
|
||||
*/
|
||||
|
||||
/**
|
||||
Creates the default stylesheet.
|
||||
|
||||
This stylesheet is based on the standard styles that Webkit provides for these tags. This stylesheet is loaded from default.css.
|
||||
*/
|
||||
+ (DTCSSStylesheet *)defaultStyleSheet;
|
||||
|
||||
|
||||
/**
|
||||
Creates a stylesheet with a given style block
|
||||
|
||||
@param css The CSS string for the style block
|
||||
*/
|
||||
- (id)initWithStyleBlock:(NSString *)css;
|
||||
|
||||
|
||||
/**
|
||||
@name Working with CSS Style Blocks
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
Parses a style block string and adds the found style rules to the receiver.
|
||||
|
||||
@param css The CSS string for the style block
|
||||
*/
|
||||
- (void)parseStyleBlock:(NSString *)css;
|
||||
|
||||
|
||||
/**
|
||||
Merges styles from given stylesheet into the receiver
|
||||
|
||||
@param stylesheet the stylesheet to merge
|
||||
*/
|
||||
- (void)mergeStylesheet:(DTCSSStylesheet *)stylesheet;
|
||||
|
||||
|
||||
/**
|
||||
@name Accessing Style Information
|
||||
*/
|
||||
|
||||
/**
|
||||
Returns a dictionary that contains the merged style for a given element and the applicable style rules from the receiver.
|
||||
|
||||
@param element The HTML element.
|
||||
@param matchedSelectors The CSS selectors that caused a match
|
||||
@param ignoreInlineStyle If `YES` then the inline styles of the element will be ignored and only the receiver's styles used
|
||||
@returns The merged style dictionary containing only styles which selector matches the element
|
||||
*/
|
||||
- (NSDictionary *)mergedStyleDictionaryForElement:(DTHTMLElement *)element matchedSelectors:(NSSet * __autoreleasing*)matchedSelectors ignoreInlineStyle:(BOOL)ignoreInlineStyle;
|
||||
|
||||
/**
|
||||
Returns a dictionary of the styles of the receiver
|
||||
*/
|
||||
- (NSDictionary *)styles;
|
||||
|
||||
/**
|
||||
Returns an ordered (by declaration) set of the selectors for all of the styles.
|
||||
*/
|
||||
- (NSArray *)orderedSelectors;
|
||||
|
||||
@end
|
||||
@@ -1,976 +0,0 @@
|
||||
//
|
||||
// DTCSSStylesheet.m
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 9/5/11.
|
||||
// Copyright (c) 2011 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCSSStylesheet.h"
|
||||
#import "DTCSSListStyle.h"
|
||||
|
||||
#import "DTHTMLElement.h"
|
||||
#import "NSScanner+HTML.h"
|
||||
#import "NSString+CSS.h"
|
||||
#import "NSString+HTML.h"
|
||||
|
||||
|
||||
|
||||
|
||||
@implementation DTCSSStylesheet
|
||||
{
|
||||
NSMutableDictionary *_styles;
|
||||
NSMutableDictionary *_orderedSelectorWeights;
|
||||
NSMutableArray *_orderedSelectors;
|
||||
}
|
||||
|
||||
#pragma mark Creating Stylesheets
|
||||
|
||||
+ (DTCSSStylesheet *)defaultStyleSheet
|
||||
{
|
||||
static DTCSSStylesheet *defaultDTCSSStylesheet = nil;
|
||||
if (defaultDTCSSStylesheet)
|
||||
{
|
||||
return defaultDTCSSStylesheet;
|
||||
}
|
||||
|
||||
@synchronized(self)
|
||||
{
|
||||
if (!defaultDTCSSStylesheet)
|
||||
{
|
||||
#if SWIFT_PACKAGE
|
||||
// get resource bundle via macro
|
||||
NSString *path = [SWIFTPM_MODULE_BUNDLE pathForResource:@"default" ofType:@"css"];
|
||||
#else
|
||||
NSBundle *bundle = [NSBundle bundleForClass:self];
|
||||
NSString *path = [[NSBundle bundleForClass:self] pathForResource:@"default" ofType:@"css"];
|
||||
|
||||
// Cocoapods uses a separate Resources bundle to include default.css
|
||||
if (!path)
|
||||
{
|
||||
NSString *resourcesBundlePath = [bundle pathForResource:@"Resources" ofType:@"bundle"];
|
||||
NSBundle *resourcesBundle = [NSBundle bundleWithPath:resourcesBundlePath];
|
||||
path = [resourcesBundle pathForResource:@"default" ofType:@"css"];
|
||||
}
|
||||
#endif
|
||||
|
||||
NSAssert(path != nil, @"Missing default.css");
|
||||
|
||||
NSString *cssString = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil];
|
||||
|
||||
defaultDTCSSStylesheet = [[DTCSSStylesheet alloc] initWithStyleBlock:cssString];
|
||||
}
|
||||
}
|
||||
return defaultDTCSSStylesheet;
|
||||
}
|
||||
|
||||
- (id)initWithStyleBlock:(NSString *)css
|
||||
{
|
||||
self = [super init];
|
||||
|
||||
if (self)
|
||||
{
|
||||
_styles = [[NSMutableDictionary alloc] init];
|
||||
_orderedSelectorWeights = [[NSMutableDictionary alloc] init];
|
||||
_orderedSelectors = [[NSMutableArray alloc] init];
|
||||
|
||||
[self parseStyleBlock:css];
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
- (id)initWithStylesheet:(DTCSSStylesheet *)stylesheet
|
||||
{
|
||||
self = [super init];
|
||||
|
||||
if (self)
|
||||
{
|
||||
_styles = [[NSMutableDictionary alloc] init];
|
||||
_orderedSelectorWeights = [[NSMutableDictionary alloc] init];
|
||||
_orderedSelectors = [[NSMutableArray alloc] init];
|
||||
|
||||
[self mergeStylesheet:stylesheet];
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
#ifndef COVERAGE
|
||||
|
||||
- (NSString *)description
|
||||
{
|
||||
return [_styles description];
|
||||
|
||||
}
|
||||
#endif
|
||||
|
||||
#pragma mark Working with Style Blocks
|
||||
|
||||
- (void)_uncompressShorthands:(NSMutableDictionary *)styles
|
||||
{
|
||||
// list-style shorthand
|
||||
NSString *shortHand = [[styles objectForKey:@"list-style"] lowercaseString];
|
||||
|
||||
if (shortHand && [shortHand isKindOfClass:[NSString class]])
|
||||
{
|
||||
[styles removeObjectForKey:@"list-style"];
|
||||
|
||||
if ([shortHand isEqualToString:@"inherit"])
|
||||
{
|
||||
[styles setObject:@"inherit" forKey:@"list-style-type"];
|
||||
[styles setObject:@"inherit" forKey:@"list-style-position"];
|
||||
return;
|
||||
}
|
||||
|
||||
NSArray *components = [shortHand componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
|
||||
|
||||
BOOL typeWasSet = NO;
|
||||
BOOL positionWasSet = NO;
|
||||
|
||||
DTCSSListStyleType listStyleType = DTCSSListStyleTypeNone;
|
||||
DTCSSListStylePosition listStylePosition = DTCSSListStylePositionInherit;
|
||||
|
||||
for (NSString *oneComponent in components)
|
||||
{
|
||||
if ([oneComponent hasPrefix:@"url"])
|
||||
{
|
||||
// list-style-image
|
||||
NSScanner *scanner = [NSScanner scannerWithString:oneComponent];
|
||||
|
||||
if ([scanner scanCSSURL:NULL])
|
||||
{
|
||||
[styles setObject:oneComponent forKey:@"list-style-image"];
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!typeWasSet)
|
||||
{
|
||||
// check if valid type
|
||||
listStyleType = [DTCSSListStyle listStyleTypeFromString:oneComponent];
|
||||
|
||||
if (listStyleType != DTCSSListStyleTypeInvalid)
|
||||
{
|
||||
[styles setObject:oneComponent forKey:@"list-style-type"];
|
||||
|
||||
typeWasSet = YES;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!positionWasSet)
|
||||
{
|
||||
// check if valid position
|
||||
listStylePosition = [DTCSSListStyle listStylePositionFromString:oneComponent];
|
||||
|
||||
if (listStylePosition != DTCSSListStylePositionInvalid)
|
||||
{
|
||||
[styles setObject:oneComponent forKey:@"list-style-position"];
|
||||
|
||||
positionWasSet = YES;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// font shorthand, see http://www.w3.org/TR/CSS21/fonts.html#font-shorthand
|
||||
shortHand = [styles objectForKey:@"font"];
|
||||
|
||||
if (shortHand && [shortHand isKindOfClass:[NSString class]])
|
||||
{
|
||||
NSString *fontStyle = @"normal";
|
||||
NSArray *validFontStyles = [NSArray arrayWithObjects:@"italic", @"oblique", nil];
|
||||
|
||||
NSString *fontVariant = @"normal";
|
||||
NSArray *validFontVariants = [NSArray arrayWithObjects:@"small-caps", nil];
|
||||
BOOL fontVariantSet = NO;
|
||||
|
||||
NSString *fontWeight = @"normal";
|
||||
NSArray *validFontWeights = [NSArray arrayWithObjects:@"bold", @"bolder", @"lighter", @"100", @"200", @"300", @"400", @"500", @"600", @"700", @"800", @"900", nil];
|
||||
BOOL fontWeightSet = NO;
|
||||
|
||||
NSString *fontSize = @"normal";
|
||||
NSArray *validFontSizes = [NSArray arrayWithObjects:@"xx-small", @"x-small", @"small", @"medium", @"large", @"x-large", @"xx-large", @"larger", @"smaller", nil];
|
||||
BOOL fontSizeSet = NO;
|
||||
|
||||
NSArray *suffixesToIgnore = [NSArray arrayWithObjects:@"caption", @"icon", @"menu", @"message-box", @"small-caption", @"status-bar", @"inherit", nil];
|
||||
|
||||
NSString *lineHeight = @"normal";
|
||||
|
||||
NSMutableString *fontFamily = [NSMutableString string];
|
||||
|
||||
NSArray *components = [shortHand componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
|
||||
|
||||
for (NSString *oneComponent in components)
|
||||
{
|
||||
// try font size keywords
|
||||
if ([validFontSizes containsObject:oneComponent])
|
||||
{
|
||||
fontSize = oneComponent;
|
||||
fontSizeSet = YES;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
NSInteger slashIndex = [oneComponent rangeOfString:@"/"].location;
|
||||
|
||||
if (slashIndex != NSNotFound)
|
||||
{
|
||||
// font-size / line-height
|
||||
|
||||
fontSize = [oneComponent substringToIndex:slashIndex];
|
||||
fontSizeSet = YES;
|
||||
|
||||
lineHeight = [oneComponent substringFromIndex:slashIndex+1];
|
||||
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
// length
|
||||
if ([oneComponent hasSuffix:@"%"] || [oneComponent hasSuffix:@"em"] || [oneComponent hasSuffix:@"px"] || [oneComponent hasSuffix:@"pt"])
|
||||
{
|
||||
fontSize = oneComponent;
|
||||
fontSizeSet = YES;
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (fontSizeSet)
|
||||
{
|
||||
if ([suffixesToIgnore containsObject:oneComponent])
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// assume that this is part of font family
|
||||
if ([fontFamily length])
|
||||
{
|
||||
[fontFamily appendString:@" "];
|
||||
}
|
||||
|
||||
[fontFamily appendString:oneComponent];
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!fontWeightSet && [validFontStyles containsObject:oneComponent])
|
||||
{
|
||||
fontStyle = oneComponent;
|
||||
}
|
||||
else if (!fontVariantSet && [validFontVariants containsObject:oneComponent])
|
||||
{
|
||||
fontVariant = oneComponent;
|
||||
fontVariantSet = YES;
|
||||
}
|
||||
else if (!fontWeightSet && [validFontWeights containsObject:oneComponent])
|
||||
{
|
||||
fontWeight = oneComponent;
|
||||
fontWeightSet = YES;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[styles removeObjectForKey:@"font"];
|
||||
|
||||
// size and family are mandatory, without them this is invalid
|
||||
if ([fontSize length] && [fontFamily length])
|
||||
{
|
||||
[styles setObject:fontStyle forKey:@"font-style"];
|
||||
[styles setObject:fontWeight forKey:@"font-weight"];
|
||||
[styles setObject:fontVariant forKey:@"font-variant"];
|
||||
[styles setObject:fontSize forKey:@"font-size"];
|
||||
[styles setObject:lineHeight forKey:@"line-height"];
|
||||
[styles setObject:fontFamily forKey:@"font-family"];
|
||||
}
|
||||
}
|
||||
|
||||
shortHand = [styles objectForKey:@"margin"];
|
||||
|
||||
if (shortHand && [shortHand isKindOfClass:[NSString class]])
|
||||
{
|
||||
NSArray *parts = [shortHand componentsSeparatedByString:@" "];
|
||||
|
||||
NSString *topMargin;
|
||||
NSString *rightMargin;
|
||||
NSString *bottomMargin;
|
||||
NSString *leftMargin;
|
||||
|
||||
if ([parts count] == 4)
|
||||
{
|
||||
topMargin = [parts objectAtIndex:0];
|
||||
rightMargin = [parts objectAtIndex:1];
|
||||
bottomMargin = [parts objectAtIndex:2];
|
||||
leftMargin = [parts objectAtIndex:3];
|
||||
}
|
||||
else if ([parts count] == 3)
|
||||
{
|
||||
topMargin = [parts objectAtIndex:0];
|
||||
rightMargin = [parts objectAtIndex:1];
|
||||
bottomMargin = [parts objectAtIndex:2];
|
||||
leftMargin = [parts objectAtIndex:1];
|
||||
}
|
||||
else if ([parts count] == 2)
|
||||
{
|
||||
topMargin = [parts objectAtIndex:0];
|
||||
rightMargin = [parts objectAtIndex:1];
|
||||
bottomMargin = [parts objectAtIndex:0];
|
||||
leftMargin = [parts objectAtIndex:1];
|
||||
}
|
||||
else
|
||||
{
|
||||
NSString *onlyValue = [parts objectAtIndex:0];
|
||||
|
||||
topMargin = onlyValue;
|
||||
rightMargin = onlyValue;
|
||||
bottomMargin = onlyValue;
|
||||
leftMargin = onlyValue;
|
||||
}
|
||||
|
||||
// only apply the ones where there is no previous direct setting
|
||||
|
||||
if (![styles objectForKey:@"margin-top"])
|
||||
{
|
||||
[styles setObject:topMargin forKey:@"margin-top"];
|
||||
}
|
||||
|
||||
if (![styles objectForKey:@"margin-right"])
|
||||
{
|
||||
[styles setObject:rightMargin forKey:@"margin-right"];
|
||||
}
|
||||
|
||||
if (![styles objectForKey:@"margin-bottom"])
|
||||
{
|
||||
[styles setObject:bottomMargin forKey:@"margin-bottom"];
|
||||
}
|
||||
|
||||
if (![styles objectForKey:@"margin-left"])
|
||||
{
|
||||
[styles setObject:leftMargin forKey:@"margin-left"];
|
||||
}
|
||||
|
||||
// remove the shorthand
|
||||
[styles removeObjectForKey:@"margin"];
|
||||
}
|
||||
|
||||
shortHand = [styles objectForKey:@"padding"];
|
||||
|
||||
if (shortHand && [shortHand isKindOfClass:[NSString class]])
|
||||
{
|
||||
NSArray *parts = [shortHand componentsSeparatedByString:@" "];
|
||||
|
||||
NSString *topPadding;
|
||||
NSString *rightPadding;
|
||||
NSString *bottomPadding;
|
||||
NSString *leftPadding;
|
||||
|
||||
if ([parts count] == 4)
|
||||
{
|
||||
topPadding = [parts objectAtIndex:0];
|
||||
rightPadding = [parts objectAtIndex:1];
|
||||
bottomPadding = [parts objectAtIndex:2];
|
||||
leftPadding = [parts objectAtIndex:3];
|
||||
}
|
||||
else if ([parts count] == 3)
|
||||
{
|
||||
topPadding = [parts objectAtIndex:0];
|
||||
rightPadding = [parts objectAtIndex:1];
|
||||
bottomPadding = [parts objectAtIndex:2];
|
||||
leftPadding = [parts objectAtIndex:1];
|
||||
}
|
||||
else if ([parts count] == 2)
|
||||
{
|
||||
topPadding = [parts objectAtIndex:0];
|
||||
rightPadding = [parts objectAtIndex:1];
|
||||
bottomPadding = [parts objectAtIndex:0];
|
||||
leftPadding = [parts objectAtIndex:1];
|
||||
}
|
||||
else
|
||||
{
|
||||
NSString *onlyValue = [parts objectAtIndex:0];
|
||||
|
||||
topPadding = onlyValue;
|
||||
rightPadding = onlyValue;
|
||||
bottomPadding = onlyValue;
|
||||
leftPadding = onlyValue;
|
||||
}
|
||||
|
||||
// only apply the ones where there is no previous direct setting
|
||||
|
||||
if (![styles objectForKey:@"padding-top"])
|
||||
{
|
||||
[styles setObject:topPadding forKey:@"padding-top"];
|
||||
}
|
||||
|
||||
if (![styles objectForKey:@"padding-right"])
|
||||
{
|
||||
[styles setObject:rightPadding forKey:@"padding-right"];
|
||||
}
|
||||
|
||||
if (![styles objectForKey:@"padding-bottom"])
|
||||
{
|
||||
[styles setObject:bottomPadding forKey:@"padding-bottom"];
|
||||
}
|
||||
|
||||
if (![styles objectForKey:@"padding-left"])
|
||||
{
|
||||
[styles setObject:leftPadding forKey:@"padding-left"];
|
||||
}
|
||||
|
||||
// remove the shorthand
|
||||
[styles removeObjectForKey:@"padding"];
|
||||
}
|
||||
|
||||
shortHand = [styles objectForKey:@"background"];
|
||||
|
||||
if (shortHand && [shortHand isKindOfClass:[NSString class]])
|
||||
{
|
||||
// ignore most tokens except background-color
|
||||
|
||||
[styles removeObjectForKey:@"background"];
|
||||
|
||||
NSCharacterSet *tokenDelimiters = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
||||
NSString *trimmedString = [shortHand stringByTrimmingCharactersInSet:tokenDelimiters];
|
||||
NSScanner *scanner = [NSScanner scannerWithString:trimmedString];
|
||||
|
||||
while (![scanner isAtEnd])
|
||||
{
|
||||
NSString *colorName;
|
||||
if ([scanner scanHTMLColor:NULL HTMLName:&colorName])
|
||||
{
|
||||
[styles setObject:colorName forKey:@"background-color"];
|
||||
break;
|
||||
}
|
||||
[scanner scanUpToCharactersFromSet:tokenDelimiters intoString:NULL];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)_addStyleRule:(NSString *)rule withSelector:(NSString*)selectors
|
||||
{
|
||||
NSArray *split = [selectors componentsSeparatedByString:@","];
|
||||
|
||||
for (NSString *selector in split)
|
||||
{
|
||||
NSString *cleanSelector = [selector stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
|
||||
NSMutableDictionary *ruleDictionary = [[rule dictionaryOfCSSStyles] mutableCopy];
|
||||
|
||||
// remove !important, we're ignoring these
|
||||
for (NSString *oneKey in [ruleDictionary allKeys])
|
||||
{
|
||||
id value = [ruleDictionary objectForKey:oneKey];
|
||||
if ([value isKindOfClass:[NSString class]])
|
||||
{
|
||||
NSRange rangeOfImportant = [value rangeOfString:@"!important" options:NSCaseInsensitiveSearch];
|
||||
|
||||
if (rangeOfImportant.location != NSNotFound)
|
||||
{
|
||||
value = [value stringByReplacingCharactersInRange:rangeOfImportant withString:@""];
|
||||
value = [value stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
|
||||
[ruleDictionary setObject:value forKey:oneKey];
|
||||
}
|
||||
|
||||
} else if ([value isKindOfClass:[NSArray class]])
|
||||
{
|
||||
NSMutableArray *newVal;
|
||||
|
||||
for (NSUInteger i = 0; i < [(NSArray*)value count]; ++i)
|
||||
{
|
||||
NSString *s = [value objectAtIndex:i];
|
||||
|
||||
NSRange rangeOfImportant = [s rangeOfString:@"!important" options:NSCaseInsensitiveSearch];
|
||||
|
||||
if (rangeOfImportant.location != NSNotFound)
|
||||
{
|
||||
s = [s stringByReplacingCharactersInRange:rangeOfImportant withString:@""];
|
||||
s = [s stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
|
||||
if (!newVal)
|
||||
{
|
||||
if ([value isKindOfClass:[NSMutableArray class]])
|
||||
{
|
||||
newVal = value;
|
||||
} else
|
||||
{
|
||||
newVal = [value mutableCopy];
|
||||
}
|
||||
}
|
||||
|
||||
// replace the value that had !important with a version without it
|
||||
[newVal replaceObjectAtIndex:i withObject:s];
|
||||
}
|
||||
}
|
||||
|
||||
if (newVal)
|
||||
{
|
||||
[ruleDictionary setObject:newVal forKey:oneKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// need to uncompress because otherwise we might get shorthands and non-shorthands together
|
||||
[self _uncompressShorthands:ruleDictionary];
|
||||
|
||||
// check if there is a pseudo selector
|
||||
NSRange colonRange = [cleanSelector rangeOfString:@":"];
|
||||
NSString *pseudoSelector = nil;
|
||||
|
||||
if (colonRange.length==1)
|
||||
{
|
||||
pseudoSelector = [cleanSelector substringFromIndex:colonRange.location+1];
|
||||
cleanSelector = [cleanSelector substringToIndex:colonRange.location];
|
||||
|
||||
// prefix all rules with the pseudo-selector
|
||||
for (NSString *oneRuleKey in [ruleDictionary allKeys])
|
||||
{
|
||||
id value = [ruleDictionary objectForKey:oneRuleKey];
|
||||
|
||||
// prefix key with the pseudo selector
|
||||
NSString *prefixedKey = [NSString stringWithFormat:@"%@:%@", pseudoSelector, oneRuleKey];
|
||||
[ruleDictionary setObject:value forKey:prefixedKey];
|
||||
[ruleDictionary removeObjectForKey:oneRuleKey];
|
||||
}
|
||||
}
|
||||
|
||||
NSDictionary *existingRulesForSelector = [_styles objectForKey:cleanSelector];
|
||||
|
||||
if (existingRulesForSelector)
|
||||
{
|
||||
// substitute new rules over old ones
|
||||
NSMutableDictionary *tmpDict = [existingRulesForSelector mutableCopy];
|
||||
|
||||
// append new rules
|
||||
[tmpDict addEntriesFromDictionary:ruleDictionary];
|
||||
|
||||
// save it
|
||||
[self _addStyles:tmpDict withSelector:cleanSelector];
|
||||
}
|
||||
else
|
||||
{
|
||||
[self _addStyles:ruleDictionary withSelector:cleanSelector];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
- (void)parseStyleBlock:(NSString*)css
|
||||
{
|
||||
NSUInteger braceMarker = 0;
|
||||
|
||||
NSInteger braceLevel = 0;
|
||||
|
||||
NSString* selector;
|
||||
|
||||
NSUInteger length = [css length];
|
||||
|
||||
for (NSUInteger i = 0; i < length; i++)
|
||||
{
|
||||
unichar c = [css characterAtIndex:i];
|
||||
|
||||
if (c == '/')
|
||||
{
|
||||
i++;
|
||||
|
||||
if (i < length)
|
||||
{
|
||||
c = [css characterAtIndex:i];
|
||||
|
||||
if (c == '*')
|
||||
{
|
||||
// skip comment until closing /
|
||||
|
||||
for (; i < length; i++)
|
||||
{
|
||||
if ([css characterAtIndex:i] == '/')
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (i < length)
|
||||
{
|
||||
braceMarker = i+1;
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
// end of string
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// not a comment
|
||||
i--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An opening brace! It could be the start of a new rule, or it could be a nested brace.
|
||||
if (c == '{')
|
||||
{
|
||||
// If we start a new rule...
|
||||
|
||||
if (braceLevel == 0)
|
||||
{
|
||||
// Grab the selector and clean up extraneous spaces (we'll process it in a moment)
|
||||
selector = [css substringWithRange:NSMakeRange(braceMarker, i-braceMarker)];
|
||||
NSArray *selectorParts = [selector componentsSeparatedByString:@" "];
|
||||
NSMutableArray *cleanSelectorParts = [NSMutableArray array];
|
||||
for (NSString *partialSelector in selectorParts)
|
||||
{
|
||||
if (partialSelector.length)
|
||||
{
|
||||
[cleanSelectorParts addObject:partialSelector];
|
||||
}
|
||||
}
|
||||
selector = [cleanSelectorParts componentsJoinedByString:@" "];
|
||||
|
||||
// And mark our position so we can grab the rule's CSS when it is closed
|
||||
braceMarker = i + 1;
|
||||
}
|
||||
|
||||
// Increase the brace level.
|
||||
braceLevel += 1;
|
||||
}
|
||||
|
||||
// A closing brace!
|
||||
else if (c == '}')
|
||||
{
|
||||
// If we finished a rule...
|
||||
if (braceLevel == 1)
|
||||
{
|
||||
NSString *rule = [css substringWithRange:NSMakeRange(braceMarker, i-braceMarker)];
|
||||
|
||||
[self _addStyleRule:rule withSelector: selector];
|
||||
|
||||
braceMarker = i + 1;
|
||||
}
|
||||
// Skip unpaired closing brace
|
||||
else if (braceLevel < 1) {
|
||||
braceMarker += 1;
|
||||
}
|
||||
|
||||
braceLevel = MAX(braceLevel-1, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
- (void)mergeStylesheet:(DTCSSStylesheet *)stylesheet
|
||||
{
|
||||
NSArray *otherStylesheetStyleKeys = stylesheet.orderedSelectors;
|
||||
|
||||
for (NSString *oneKey in otherStylesheetStyleKeys)
|
||||
{
|
||||
NSDictionary *existingStyles = [_styles objectForKey:oneKey];
|
||||
NSDictionary *stylesToMerge = [[stylesheet styles] objectForKey:oneKey];
|
||||
if (existingStyles)
|
||||
{
|
||||
NSMutableDictionary *mutableStyles = [existingStyles mutableCopy];
|
||||
|
||||
for (NSString *oneStyleKey in stylesToMerge)
|
||||
{
|
||||
NSString *mergingStyleString = [stylesToMerge objectForKey:oneStyleKey];
|
||||
|
||||
[mutableStyles setObject:mergingStyleString forKey:oneStyleKey];
|
||||
}
|
||||
|
||||
[self _addStyles:mutableStyles withSelector:oneKey];
|
||||
}
|
||||
else
|
||||
{
|
||||
// nothing to worry
|
||||
[self _addStyles:stylesToMerge withSelector:oneKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)_addStyles:(NSDictionary *)styles withSelector:(NSString *)selector {
|
||||
[_styles setObject:styles forKey:selector];
|
||||
|
||||
if (![_orderedSelectors containsObject:selector])
|
||||
{
|
||||
[_orderedSelectors addObject:selector];
|
||||
[_orderedSelectorWeights setObject:@([self _weightForSelector:selector]) forKey:selector];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark Accessing Style Information
|
||||
|
||||
- (NSDictionary *)mergedStyleDictionaryForElement:(DTHTMLElement *)element matchedSelectors:(NSSet * __autoreleasing*)matchedSelectors ignoreInlineStyle:(BOOL)ignoreInlineStyle
|
||||
{
|
||||
// We are going to combine all the relevant styles for this tag.
|
||||
// (Note that when styles are applied, the later styles take precedence,
|
||||
// so the order in which we grab them matters!)
|
||||
|
||||
NSMutableDictionary *tmpDict = [NSMutableDictionary dictionary];
|
||||
|
||||
// Get based on element
|
||||
NSDictionary *byTagName = [self.styles objectForKey:element.name];
|
||||
|
||||
if (byTagName)
|
||||
{
|
||||
[tmpDict addEntriesFromDictionary:byTagName];
|
||||
}
|
||||
|
||||
// Get based on class(es)
|
||||
NSString *classString = [element.attributes objectForKey:@"class"];
|
||||
NSArray *classes = [classString componentsSeparatedByString:@" "];
|
||||
|
||||
// Cascaded selectors with more than one part are sorted by specificity
|
||||
NSMutableArray *matchingCascadingSelectors = [self matchingComplexCascadingSelectorsForElement:element];
|
||||
[matchingCascadingSelectors sortUsingComparator:^NSComparisonResult(NSString *selector1, NSString *selector2)
|
||||
{
|
||||
NSInteger weightForSelector1 = [[self->_orderedSelectorWeights objectForKey:selector1] integerValue];
|
||||
NSInteger weightForSelector2 = [[self->_orderedSelectorWeights objectForKey:selector2] integerValue];
|
||||
|
||||
if (weightForSelector1 == weightForSelector2)
|
||||
{
|
||||
weightForSelector1 += [self->_orderedSelectors indexOfObject:selector1];
|
||||
weightForSelector2 += [self->_orderedSelectors indexOfObject:selector2];
|
||||
}
|
||||
|
||||
if (weightForSelector1 > weightForSelector2)
|
||||
{
|
||||
return (NSComparisonResult)NSOrderedDescending;
|
||||
}
|
||||
|
||||
if (weightForSelector1 < weightForSelector2)
|
||||
{
|
||||
return (NSComparisonResult)NSOrderedAscending;
|
||||
}
|
||||
|
||||
return (NSComparisonResult)NSOrderedSame;
|
||||
}];
|
||||
|
||||
NSMutableSet *tmpMatchedSelectors;
|
||||
|
||||
if (matchedSelectors)
|
||||
{
|
||||
tmpMatchedSelectors = [NSMutableSet set];
|
||||
}
|
||||
|
||||
// Apply complex cascading selectors first, then apply most specific selectors
|
||||
for (NSString *cascadingSelector in matchingCascadingSelectors)
|
||||
{
|
||||
NSDictionary *byCascadingSelector = [_styles objectForKey:cascadingSelector];
|
||||
[tmpDict addEntriesFromDictionary:byCascadingSelector];
|
||||
[tmpMatchedSelectors addObject:cascadingSelector];
|
||||
}
|
||||
|
||||
// Applied the parameter element's classes last
|
||||
for (NSString *class in classes)
|
||||
{
|
||||
NSString *classRule = [NSString stringWithFormat:@".%@", class];
|
||||
NSDictionary *byClass = [_styles objectForKey: classRule];
|
||||
|
||||
if (byClass)
|
||||
{
|
||||
[tmpDict addEntriesFromDictionary:byClass];
|
||||
[tmpMatchedSelectors addObject:class];
|
||||
}
|
||||
|
||||
NSString *classAndTagRule = [NSString stringWithFormat:@"%@.%@", element.name, class];
|
||||
NSDictionary *byClassAndName = [_styles objectForKey:classAndTagRule];
|
||||
|
||||
if (byClassAndName)
|
||||
{
|
||||
[tmpDict addEntriesFromDictionary:byClassAndName];
|
||||
[tmpMatchedSelectors addObject:classAndTagRule];
|
||||
}
|
||||
}
|
||||
|
||||
// Get based on id
|
||||
NSString *idRule = [NSString stringWithFormat:@"#%@", [element.attributes objectForKey:@"id"]];
|
||||
NSDictionary *byID = [_styles objectForKey:idRule];
|
||||
|
||||
if (byID)
|
||||
{
|
||||
[tmpDict addEntriesFromDictionary:byID];
|
||||
[tmpMatchedSelectors addObject:idRule];
|
||||
}
|
||||
|
||||
if (!ignoreInlineStyle)
|
||||
{
|
||||
// Get tag's local style attribute
|
||||
NSString *styleString = [element.attributes objectForKey:@"style"];
|
||||
|
||||
if ([styleString length])
|
||||
{
|
||||
NSMutableDictionary *localStyles = [[styleString dictionaryOfCSSStyles] mutableCopy];
|
||||
|
||||
// need to uncompress because otherwise we might get shorthands and non-shorthands together
|
||||
[self _uncompressShorthands:localStyles];
|
||||
|
||||
[tmpDict addEntriesFromDictionary:localStyles];
|
||||
}
|
||||
}
|
||||
|
||||
if ([tmpDict count])
|
||||
{
|
||||
if (matchedSelectors && [tmpMatchedSelectors count])
|
||||
{
|
||||
*matchedSelectors = [tmpMatchedSelectors copy];
|
||||
}
|
||||
|
||||
return tmpDict;
|
||||
}
|
||||
else
|
||||
{
|
||||
return nil;
|
||||
}
|
||||
}
|
||||
|
||||
- (NSDictionary *)styles
|
||||
{
|
||||
return _styles;
|
||||
}
|
||||
|
||||
- (NSArray *)orderedSelectors
|
||||
{
|
||||
return _orderedSelectors;
|
||||
}
|
||||
|
||||
// This looks for cascaded selectors with more than one part to them
|
||||
- (NSMutableArray *)matchingComplexCascadingSelectorsForElement:(DTHTMLElement *)element
|
||||
{
|
||||
__block NSMutableArray *matchedSelectors = [NSMutableArray array];
|
||||
|
||||
for (NSString *selector in _orderedSelectors)
|
||||
{
|
||||
// We only process the selector if our selector has more than 1 part to it (e.g. ".foo" would be skipped and ".foo .bar" would not)
|
||||
if (![selector rangeOfString:@" "].length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
NSArray *selectorParts = [selector componentsSeparatedByString:@" "];
|
||||
|
||||
if (selectorParts.count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
DTHTMLElement *nextElement = element;
|
||||
|
||||
// Walking up the hierarchy so start at the right side of the selector and work to the left
|
||||
// Aside: Manual for loop here is faster than for in with reverseObjectEnumerator
|
||||
for (NSUInteger j = selectorParts.count; j-- > 0;)
|
||||
{
|
||||
NSString *selectorPart = [selectorParts objectAtIndex:j];
|
||||
BOOL matched = NO;
|
||||
|
||||
if (selectorPart.length)
|
||||
{
|
||||
while (nextElement != nil)
|
||||
{
|
||||
DTHTMLElement *currentElement = nextElement;
|
||||
|
||||
//This must be set to advance here, above all of the breaks, so the loop properly advances.
|
||||
nextElement = currentElement.parentElement;
|
||||
|
||||
if ([selectorPart characterAtIndex:0] == '#')
|
||||
{
|
||||
// If we're at an id and it doesn't match the current element then the style doesn't apply
|
||||
NSString *currentElementId = [currentElement.attributes objectForKey:@"id"];
|
||||
if (currentElementId && [[selectorPart substringFromIndex:1] isEqualToString:currentElementId])
|
||||
{
|
||||
matched = YES;
|
||||
break;
|
||||
}
|
||||
} else if ([selectorPart characterAtIndex:0] == '.')
|
||||
{
|
||||
NSString *currentElementClassesString = [currentElement.attributes objectForKey:@"class"];
|
||||
NSArray *currentElementClasses = [currentElementClassesString componentsSeparatedByString:@" "];
|
||||
for (NSString *currentElementClass in currentElementClasses)
|
||||
{
|
||||
if ([currentElementClass isEqualToString:[selectorPart substringFromIndex:1]])
|
||||
{
|
||||
matched = YES;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matched)
|
||||
{
|
||||
break;
|
||||
}
|
||||
} else if ([selectorPart isEqualToString:currentElement.name] && (selectorParts.count > 1))
|
||||
{
|
||||
// This condition depends on the "if (selectorParts.count < 2)" conditional above. If that's removed, we must make sure selectorParts
|
||||
// contains > 1 item for this to be matched (we want the element name alone to be matched last).
|
||||
matched = YES;
|
||||
break;
|
||||
}
|
||||
|
||||
// break if the right most portion of the selector doesn't match the target element
|
||||
if (!matched && ([currentElement isEqual:element])) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!matched)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
//Only match if we really are on the last part of the selector and all other parts have matched so far
|
||||
if (j == 0)
|
||||
{
|
||||
if (matched && ![matchedSelectors containsObject:selector])
|
||||
{
|
||||
[matchedSelectors addObject:selector];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matchedSelectors;
|
||||
}
|
||||
|
||||
// This computes the specificity for a given selector
|
||||
- (NSUInteger)_weightForSelector:(NSString *)selector {
|
||||
if ((selector == nil) || (selector.length == 0))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
NSUInteger weight = 0;
|
||||
|
||||
NSArray *selectorParts = [selector componentsSeparatedByString:@" "];
|
||||
for (NSString *selectorPart in selectorParts)
|
||||
{
|
||||
if (selectorPart.length == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ([selectorPart characterAtIndex:0] == '#')
|
||||
{
|
||||
weight += 100;
|
||||
} else if ([selectorPart characterAtIndex:0] == '.')
|
||||
{
|
||||
weight += 10;
|
||||
} else {
|
||||
weight += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return weight;
|
||||
}
|
||||
|
||||
#pragma mark NSCopying
|
||||
|
||||
- (id)copyWithZone:(NSZone *)zone
|
||||
{
|
||||
DTCSSStylesheet *newStylesheet = [[DTCSSStylesheet allocWithZone:zone] initWithStylesheet:self];
|
||||
|
||||
return newStylesheet;
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -1,63 +0,0 @@
|
||||
//
|
||||
// DTColor+Compatibility.h
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 1/9/11.
|
||||
// Copyright 2011 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCompatibility.h"
|
||||
|
||||
#if TARGET_OS_IPHONE
|
||||
|
||||
#import <UIKit/UIColor.h>
|
||||
|
||||
/**
|
||||
Implementations of methods on NSColor/UIColor which are missing on the other platform.
|
||||
*/
|
||||
@interface UIColor (HTML)
|
||||
|
||||
|
||||
/**
|
||||
A quick method to return the alpha component of this UIColor by using the CGColorGetAlpha method.
|
||||
@returns The floating point alpha value of this UIColor.
|
||||
*/
|
||||
- (CGFloat)alphaComponent;
|
||||
|
||||
@end
|
||||
|
||||
#endif
|
||||
|
||||
#if TARGET_OS_OSX
|
||||
#import <AppKit/NSColor.h>
|
||||
|
||||
/**
|
||||
Methods used to work with HTML representations of colors.
|
||||
*/
|
||||
@interface NSColor (HTML)
|
||||
|
||||
|
||||
/**
|
||||
Return a string hexadecimal representation of this NSColor. Splits the color into components with CGColor methods, re-maps them from percentages in the range 0-255, and returns the RGB color (alpha is stripped) in a six character string.
|
||||
@returns A CSS hexadecimal NSString specifying this NSColor.
|
||||
*/
|
||||
//- (NSString *)htmlHexString;
|
||||
|
||||
#if MAC_OS_X_VERSION_MAX_ALLOWED <= MAC_OS_X_VERSION_10_7
|
||||
/**
|
||||
Converts a CGColorRef into an NSColor by placing each component into an NSColor and pending on the component count to return a grayscale or rgb color. If there are not 2 (grayscale) or 4 (rgba) components the color is from an unsupported color space and nil is returned.
|
||||
@param cgColor The CGColorRef to convert
|
||||
@returns An NSColor of this CGColorRef
|
||||
*/
|
||||
+ (NSColor *)colorWithCGColor:(CGColorRef)cgColor;
|
||||
|
||||
/**
|
||||
Converts an NSColor into a CGColorRef.
|
||||
@returns A CGColorRef of this NSColor
|
||||
*/
|
||||
- (CGColorRef)CGColor DT_RETURNS_INNER_POINTER;
|
||||
#endif
|
||||
|
||||
@end
|
||||
|
||||
#endif
|
||||
@@ -1,122 +0,0 @@
|
||||
//
|
||||
// DTColor+Compatibility.m
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 1/9/11.
|
||||
// Copyright 2011 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTColor+Compatibility.h"
|
||||
|
||||
#if TARGET_OS_IPHONE
|
||||
|
||||
#import "DTColorFunctions.h"
|
||||
|
||||
@implementation UIColor (HTML)
|
||||
|
||||
- (CGFloat)alphaComponent
|
||||
{
|
||||
return CGColorGetAlpha(self.CGColor);
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#else
|
||||
|
||||
#if MAC_OS_X_VERSION_MAX_ALLOWED <= MAC_OS_X_VERSION_10_7 || MAC_OS_X_VERSION_MIN_REQUIRED <= MAC_OS_X_VERSION_10_7
|
||||
#import "DTCoreTextMacros.h"
|
||||
#import <objc/runtime.h>
|
||||
|
||||
static void* DTCoreTextCGColorKey = &DTCoreTextCGColorKey;
|
||||
#endif // MAC_OS_X_VERSION_MAX_ALLOWED <= MAC_OS_X_VERSION_10_7 || MAC_OS_X_VERSION_MIN_REQUIRED <= MAC_OS_X_VERSION_10_7
|
||||
|
||||
|
||||
#if MAC_OS_X_VERSION_MIN_REQUIRED <= MAC_OS_X_VERSION_10_7
|
||||
@interface NSColor (DTCoreText)
|
||||
+ (NSColor *)DTCoreText_colorWithCGColor:(CGColorRef)cgColor;
|
||||
- (CGColorRef)DTCoreText_CGColor DT_RETURNS_INNER_POINTER;
|
||||
@end
|
||||
|
||||
static void DTCoreTextAddMissingSelector(Class aClass, SEL aSelector, SEL implementationSelector)
|
||||
{
|
||||
Method method = class_getInstanceMethod(aClass, aSelector);
|
||||
if (method == NULL) {
|
||||
method = class_getInstanceMethod(aClass, implementationSelector);
|
||||
NSCAssert(method != NULL, @"missing implementation method");
|
||||
|
||||
IMP methodImplementation = method_getImplementation(method);
|
||||
const char *methodTypeEncoding = method_getTypeEncoding(method);
|
||||
|
||||
#if !defined(NS_BLOCK_ASSERTIONS)
|
||||
BOOL rc = class_addMethod(aClass, aSelector, methodImplementation, methodTypeEncoding);
|
||||
NSCAssert(rc, @"failed to add missing method");
|
||||
#else
|
||||
(void) class_addMethod(aClass, aSelector, methodImplementation, methodTypeEncoding);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
__attribute__((constructor))
|
||||
static void DTCoreTextNSColorInitialization(void)
|
||||
{
|
||||
Class NSColorClass = objc_getClass("NSColor");
|
||||
Class NSColorMetaClass = object_getClass(NSColorClass);
|
||||
DTCoreTextAddMissingSelector(NSColorMetaClass, @selector(colorWithCGColor:), @selector(DTCoreText_colorWithCGColor:));
|
||||
DTCoreTextAddMissingSelector(NSColorClass, @selector(CGColor), @selector(DTCoreText_CGColor));
|
||||
}
|
||||
|
||||
#define colorWithCGColor DTCoreText_colorWithCGColor
|
||||
#define CGColor DTCoreText_CGColor
|
||||
#define HTML DTCoreText
|
||||
#endif // MAC_OS_X_VERSION_MIN_REQUIRED <= MAC_OS_X_VERSION_10_7
|
||||
|
||||
@implementation NSColor (HTML)
|
||||
|
||||
#if MAC_OS_X_VERSION_MAX_ALLOWED <= MAC_OS_X_VERSION_10_7 || MAC_OS_X_VERSION_MIN_REQUIRED <= MAC_OS_X_VERSION_10_7
|
||||
+ (NSColor *)colorWithCGColor:(CGColorRef)cgColor
|
||||
{
|
||||
size_t count = CGColorGetNumberOfComponents(cgColor);
|
||||
const CGFloat *components = CGColorGetComponents(cgColor);
|
||||
|
||||
// Grayscale
|
||||
if (count == 2)
|
||||
{
|
||||
return [NSColor colorWithDeviceWhite:components[0] alpha:components[1]];
|
||||
}
|
||||
|
||||
// RGB
|
||||
else if (count == 4)
|
||||
{
|
||||
return [NSColor colorWithDeviceRed:components[0] green:components[1] blue:components[2] alpha:components[3]];
|
||||
}
|
||||
|
||||
// neither grayscale nor rgba
|
||||
return nil;
|
||||
}
|
||||
|
||||
// From https://gist.github.com/1593255
|
||||
- (CGColorRef)CGColor
|
||||
{
|
||||
CGColorRef color = (__bridge CGColorRef)objc_getAssociatedObject(self, DTCoreTextCGColorKey);
|
||||
if (color == NULL)
|
||||
{
|
||||
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
|
||||
|
||||
NSColor *selfCopy = [self colorUsingColorSpaceName:NSDeviceRGBColorSpace];
|
||||
|
||||
CGFloat colorValues[4];
|
||||
[selfCopy getRed:&colorValues[0] green:&colorValues[1] blue:&colorValues[2] alpha:&colorValues[3]];
|
||||
|
||||
color = CGColorCreate(colorSpace, colorValues);
|
||||
CGColorSpaceRelease(colorSpace);
|
||||
|
||||
objc_setAssociatedObject(self, DTCoreTextCGColorKey, CFBridgingRelease(color), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
}
|
||||
|
||||
return color;
|
||||
}
|
||||
#endif // MAC_OS_X_VERSION_MAX_ALLOWED <= MAC_OS_X_VERSION_10_7 || MAC_OS_X_VERSION_MIN_REQUIRED <= MAC_OS_X_VERSION_10_7
|
||||
|
||||
@end
|
||||
|
||||
#endif
|
||||
@@ -1,34 +0,0 @@
|
||||
//
|
||||
// DTColorFunctions.h
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 9/9/13.
|
||||
// Copyright (c) 2013 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCompatibility.h"
|
||||
|
||||
/**
|
||||
Takes a CSS color string ('333', 'F9FFF9'), determines the RGB values used, and returns a UIColor object of that color.
|
||||
For each part of the RGB color those numbers for that color are converted to a number using a category on NSString. Then that number is divided by the maximum value, 15 for 3 character strings and 255 for 6 character strings, making the color a percentage and within the range 0.0 and 1.0 that UIColor uses.
|
||||
@param hexString A CSS hexadecimal color string of length 6 or 3.
|
||||
@returns A UIColor object generated from the hexadecimal color string with alpha 1.0.
|
||||
*/
|
||||
DTColor *DTColorCreateWithHexString(NSString *hexString);
|
||||
|
||||
|
||||
/**
|
||||
Takes an English string representing a color and maps it to a numeric RGB value as declared by the HTML and CSS specifications (see http://www.w3schools.com/html/html_colornames.asp). Also accepts CSS `#` hexadecimal colors, `rgba`, and `rgb` and does the right thing returning a corresponding UIColor.
|
||||
If a color begins with a `#` we know that it is a hexadecimal color and send it to colorWithHexString:. If the string is an `rgba()` color declaration the comma delimited r, g, b, and a values are made into percentages and then made into a UIColor which is returned. If the string is an `rgb()` color declaration the same process happens except with an alpha of 1.0.
|
||||
The last case is that the color string is not a numeric declaration `#`, nor a `rgba` or `rgb` declaration so the CSS color value matching the English string is found in a lookup dictionary and then passed to colorWithHexString: which will make a UIColor out of the hexadecimal string.
|
||||
@param name The CSS color string that we want to map from a name into an RGB color.
|
||||
@returns A UIColor object representing the name parameter as numeric values declared by the HTML and CSS specifications, a `rgba()` color, or a `rgb()` color.
|
||||
*/
|
||||
DTColor *DTColorCreateWithHTMLName(NSString *name);
|
||||
|
||||
|
||||
/**
|
||||
Return a string hexadecimal representation of this UIColor. Splits the color into components with CGColor methods, re-maps them from percentages to the range 0-255, and depending on the number of components returns a grayscale (repeating string of two characters) or color RGB (alpha is stripped) six character string. In the event of a non-2 or non-4 component color nil is returned as it is from an unsupported color space.
|
||||
@returns A CSS hexadecimal NSString specifying this UIColor.
|
||||
*/
|
||||
NSString *DTHexStringFromDTColor(DTColor *color);
|
||||
@@ -1,305 +0,0 @@
|
||||
//
|
||||
// DTColorFunctions.m
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 9/9/13.
|
||||
// Copyright (c) 2013 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCompatibility.h"
|
||||
#import "DTColorFunctions.h"
|
||||
|
||||
|
||||
static NSDictionary *colorLookup = nil;
|
||||
|
||||
#pragma mark - Private Functions
|
||||
|
||||
NSUInteger _integerValueFromHexString(NSString *hexString);
|
||||
|
||||
#pragma mark - Implementations
|
||||
|
||||
NSUInteger _integerValueFromHexString(NSString *hexString)
|
||||
{
|
||||
int result = 0;
|
||||
sscanf([hexString UTF8String], "%x", &result);
|
||||
return result;
|
||||
}
|
||||
|
||||
//- (BOOL)isNumeric
|
||||
//{
|
||||
// const char *s = [self UTF8String];
|
||||
//
|
||||
// for (size_t i=0;i<strlen(s);i++)
|
||||
// {
|
||||
// if ((s[i]<'0' || s[i]>'9') && (s[i] != '.'))
|
||||
// {
|
||||
// return NO;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return YES;
|
||||
//}
|
||||
|
||||
|
||||
DTColor *DTColorCreateWithHexString(NSString *hexString)
|
||||
{
|
||||
if ([hexString length]!=6 && [hexString length]!=3)
|
||||
{
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSUInteger digits = [hexString length]/3;
|
||||
CGFloat maxValue = (digits==1)?15.0:255.0;
|
||||
|
||||
NSUInteger redValue = _integerValueFromHexString([hexString substringWithRange:NSMakeRange(0, digits)]);
|
||||
NSUInteger greenValue = _integerValueFromHexString([hexString substringWithRange:NSMakeRange(digits, digits)]);
|
||||
NSUInteger blueValue = _integerValueFromHexString([hexString substringWithRange:NSMakeRange(2*digits, digits)]);
|
||||
|
||||
CGFloat red = redValue/maxValue;
|
||||
CGFloat green = greenValue/maxValue;
|
||||
CGFloat blue = blueValue/maxValue;
|
||||
|
||||
#if TARGET_OS_IPHONE
|
||||
return [DTColor colorWithRed:red green:green blue:blue alpha:1.0];
|
||||
#else
|
||||
return (DTColor *)[NSColor colorWithDeviceRed:red green:green blue:blue alpha:1.0];
|
||||
#endif
|
||||
}
|
||||
|
||||
DTColor *DTColorCreateWithHTMLName(NSString *name)
|
||||
{
|
||||
if ([name hasPrefix:@"#"])
|
||||
{
|
||||
return DTColorCreateWithHexString([name substringFromIndex:1]);
|
||||
}
|
||||
|
||||
if ([name hasPrefix:@"rgba"])
|
||||
{
|
||||
NSString *rgbaName = [name stringByTrimmingCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@"rgba() "]];
|
||||
NSArray *rgba = [rgbaName componentsSeparatedByString:@","];
|
||||
|
||||
if ([rgba count] != 4)
|
||||
{
|
||||
// Incorrect syntax
|
||||
return nil;
|
||||
}
|
||||
|
||||
CGFloat red = (CGFloat)[[rgba objectAtIndex:0] floatValue] / 255;
|
||||
CGFloat green = [[rgba objectAtIndex:1] floatValue] / 255;
|
||||
CGFloat blue = [[rgba objectAtIndex:2] floatValue] / 255;
|
||||
CGFloat alpha = [[rgba objectAtIndex:3] floatValue];
|
||||
|
||||
#if TARGET_OS_IPHONE
|
||||
return [DTColor colorWithRed:red green:green blue:blue alpha:alpha];
|
||||
#else
|
||||
return (DTColor *)[NSColor colorWithDeviceRed:red green:green blue:blue alpha:alpha];
|
||||
#endif
|
||||
}
|
||||
|
||||
if([name hasPrefix:@"rgb"])
|
||||
{
|
||||
NSString * rgbName = [name stringByTrimmingCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@"rbg() "]];
|
||||
NSArray* rgb = [rgbName componentsSeparatedByCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@","]];
|
||||
|
||||
if ([rgb count] != 3)
|
||||
{
|
||||
// Incorrect syntax
|
||||
return nil;
|
||||
}
|
||||
|
||||
CGFloat red = [[rgb objectAtIndex:0] floatValue] / 255;
|
||||
CGFloat green = [[rgb objectAtIndex:1] floatValue] / 255;
|
||||
CGFloat blue = [[rgb objectAtIndex:2] floatValue] / 255;
|
||||
CGFloat alpha = 1.0;
|
||||
|
||||
#if TARGET_OS_IPHONE
|
||||
return [DTColor colorWithRed:red green:green blue:blue alpha:alpha];
|
||||
#else
|
||||
return (DTColor *)[NSColor colorWithDeviceRed:red green:green blue:blue alpha:alpha];
|
||||
#endif
|
||||
}
|
||||
|
||||
static dispatch_once_t predicate;
|
||||
dispatch_once(&predicate, ^{
|
||||
colorLookup = [[NSDictionary alloc] initWithObjectsAndKeys:
|
||||
@"F0F8FF", @"aliceblue",
|
||||
@"FAEBD7", @"antiquewhite",
|
||||
@"00FFFF", @"aqua",
|
||||
@"7FFFD4", @"aquamarine",
|
||||
@"F0FFFF", @"azure",
|
||||
@"F5F5DC", @"beige",
|
||||
@"FFE4C4", @"bisque",
|
||||
@"000000", @"black",
|
||||
@"FFEBCD", @"blanchedalmond",
|
||||
@"0000FF", @"blue",
|
||||
@"8A2BE2", @"blueviolet",
|
||||
@"A52A2A", @"brown",
|
||||
@"DEB887", @"burlywood",
|
||||
@"5F9EA0", @"cadetblue",
|
||||
@"7FFF00", @"chartreuse",
|
||||
@"D2691E", @"chocolate",
|
||||
@"FF7F50", @"coral",
|
||||
@"6495ED", @"cornflowerblue",
|
||||
@"FFF8DC", @"cornsilk",
|
||||
@"DC143C", @"crimson",
|
||||
@"00FFFF", @"cyan",
|
||||
@"00008B", @"darkblue",
|
||||
@"008B8B", @"darkcyan",
|
||||
@"B8860B", @"darkgoldenrod",
|
||||
@"A9A9A9", @"darkgray",
|
||||
@"A9A9A9", @"darkgrey",
|
||||
@"006400", @"darkgreen",
|
||||
@"BDB76B", @"darkkhaki",
|
||||
@"8B008B", @"darkmagenta",
|
||||
@"556B2F", @"darkolivegreen",
|
||||
@"FF8C00", @"darkorange",
|
||||
@"9932CC", @"darkorchid",
|
||||
@"8B0000", @"darkred",
|
||||
@"E9967A", @"darksalmon",
|
||||
@"8FBC8F", @"darkseagreen",
|
||||
@"483D8B", @"darkslateblue",
|
||||
@"2F4F4F", @"darkslategray",
|
||||
@"2F4F4F", @"darkslategrey",
|
||||
@"00CED1", @"darkturquoise",
|
||||
@"9400D3", @"darkviolet",
|
||||
@"FF1493", @"deeppink",
|
||||
@"00BFFF", @"deepskyblue",
|
||||
@"696969", @"dimgray",
|
||||
@"696969", @"dimgrey",
|
||||
@"1E90FF", @"dodgerblue",
|
||||
@"B22222", @"firebrick",
|
||||
@"FFFAF0", @"floralwhite",
|
||||
@"228B22", @"forestgreen",
|
||||
@"FF00FF", @"fuchsia",
|
||||
@"DCDCDC", @"gainsboro",
|
||||
@"F8F8FF", @"ghostwhite",
|
||||
@"FFD700", @"gold",
|
||||
@"DAA520", @"goldenrod",
|
||||
@"808080", @"gray",
|
||||
@"808080", @"grey",
|
||||
@"008000", @"green",
|
||||
@"ADFF2F", @"greenyellow",
|
||||
@"F0FFF0", @"honeydew",
|
||||
@"FF69B4", @"hotpink",
|
||||
@"CD5C5C", @"indianred",
|
||||
@"4B0082", @"indigo",
|
||||
@"FFFFF0", @"ivory",
|
||||
@"F0E68C", @"khaki",
|
||||
@"E6E6FA", @"lavender",
|
||||
@"FFF0F5", @"lavenderblush",
|
||||
@"7CFC00", @"lawngreen",
|
||||
@"FFFACD", @"lemonchiffon",
|
||||
@"ADD8E6", @"lightblue",
|
||||
@"F08080", @"lightcoral",
|
||||
@"E0FFFF", @"lightcyan",
|
||||
@"FAFAD2", @"lightgoldenrodyellow",
|
||||
@"D3D3D3", @"lightgray",
|
||||
@"D3D3D3", @"lightgrey",
|
||||
@"90EE90", @"lightgreen",
|
||||
@"FFB6C1", @"lightpink",
|
||||
@"FFA07A", @"lightsalmon",
|
||||
@"20B2AA", @"lightseagreen",
|
||||
@"87CEFA", @"lightskyblue",
|
||||
@"778899", @"lightslategray",
|
||||
@"778899", @"lightslategrey",
|
||||
@"B0C4DE", @"lightsteelblue",
|
||||
@"FFFFE0", @"lightyellow",
|
||||
@"00FF00", @"lime",
|
||||
@"32CD32", @"limegreen",
|
||||
@"FAF0E6", @"linen",
|
||||
@"FF00FF", @"magenta",
|
||||
@"800000", @"maroon",
|
||||
@"66CDAA", @"mediumaquamarine",
|
||||
@"0000CD", @"mediumblue",
|
||||
@"BA55D3", @"mediumorchid",
|
||||
@"9370D8", @"mediumpurple",
|
||||
@"3CB371", @"mediumseagreen",
|
||||
@"7B68EE", @"mediumslateblue",
|
||||
@"00FA9A", @"mediumspringgreen",
|
||||
@"48D1CC", @"mediumturquoise",
|
||||
@"C71585", @"mediumvioletred",
|
||||
@"191970", @"midnightblue",
|
||||
@"F5FFFA", @"mintcream",
|
||||
@"FFE4E1", @"mistyrose",
|
||||
@"FFE4B5", @"moccasin",
|
||||
@"FFDEAD", @"navajowhite",
|
||||
@"000080", @"navy",
|
||||
@"FDF5E6", @"oldlace",
|
||||
@"808000", @"olive",
|
||||
@"6B8E23", @"olivedrab",
|
||||
@"FFA500", @"orange",
|
||||
@"FF4500", @"orangered",
|
||||
@"DA70D6", @"orchid",
|
||||
@"EEE8AA", @"palegoldenrod",
|
||||
@"98FB98", @"palegreen",
|
||||
@"AFEEEE", @"paleturquoise",
|
||||
@"D87093", @"palevioletred",
|
||||
@"FFEFD5", @"papayawhip",
|
||||
@"FFDAB9", @"peachpuff",
|
||||
@"CD853F", @"peru",
|
||||
@"FFC0CB", @"pink",
|
||||
@"DDA0DD", @"plum",
|
||||
@"B0E0E6", @"powderblue",
|
||||
@"800080", @"purple",
|
||||
@"FF0000", @"red",
|
||||
@"BC8F8F", @"rosybrown",
|
||||
@"4169E1", @"royalblue",
|
||||
@"8B4513", @"saddlebrown",
|
||||
@"FA8072", @"salmon",
|
||||
@"F4A460", @"sandybrown",
|
||||
@"2E8B57", @"seagreen",
|
||||
@"FFF5EE", @"seashell",
|
||||
@"A0522D", @"sienna",
|
||||
@"C0C0C0", @"silver",
|
||||
@"87CEEB", @"skyblue",
|
||||
@"6A5ACD", @"slateblue",
|
||||
@"708090", @"slategray",
|
||||
@"708090", @"slategrey",
|
||||
@"FFFAFA", @"snow",
|
||||
@"00FF7F", @"springgreen",
|
||||
@"4682B4", @"steelblue",
|
||||
@"D2B48C", @"tan",
|
||||
@"008080", @"teal",
|
||||
@"D8BFD8", @"thistle",
|
||||
@"FF6347", @"tomato",
|
||||
@"40E0D0", @"turquoise",
|
||||
@"EE82EE", @"violet",
|
||||
@"F5DEB3", @"wheat",
|
||||
@"FFFFFF", @"white",
|
||||
@"F5F5F5", @"whitesmoke",
|
||||
@"FFFF00", @"yellow",
|
||||
@"9ACD32", @"yellowgreen",
|
||||
nil];
|
||||
});
|
||||
|
||||
NSString *hexString = [colorLookup objectForKey:[name lowercaseString]];
|
||||
|
||||
return DTColorCreateWithHexString(hexString);
|
||||
}
|
||||
|
||||
NSString *DTHexStringFromDTColor(DTColor *color)
|
||||
{
|
||||
CGColorRef cgColor = color.CGColor;
|
||||
size_t count = CGColorGetNumberOfComponents(cgColor);
|
||||
const CGFloat *components = CGColorGetComponents(cgColor);
|
||||
|
||||
static NSString *stringFormat = @"%02x%02x%02x";
|
||||
|
||||
// Grayscale
|
||||
if (count == 2)
|
||||
{
|
||||
NSUInteger white = (NSUInteger)(components[0] * (CGFloat)255);
|
||||
return [NSString stringWithFormat:stringFormat, white, white, white];
|
||||
}
|
||||
|
||||
// RGB
|
||||
else if (count == 4)
|
||||
{
|
||||
return [NSString stringWithFormat:stringFormat, (NSUInteger)(components[0] * (CGFloat)255),
|
||||
(NSUInteger)(components[1] * (CGFloat)255), (NSUInteger)(components[2] * (CGFloat)255)];
|
||||
}
|
||||
|
||||
// Unsupported color space
|
||||
return nil;
|
||||
}
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
//
|
||||
// DTCompatibility.h
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Letterer on 09.04.12.
|
||||
// Copyright (c) 2012 Drobnik.com. All rights reserved.
|
||||
//
|
||||
#import <Availability.h>
|
||||
#import <TargetConditionals.h>
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
#pragma mark - iOS
|
||||
|
||||
#if TARGET_OS_IPHONE
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
// Compatibility Aliases
|
||||
#define DTColor UIColor
|
||||
#define DTImage UIImage
|
||||
#define DTFont UIFont
|
||||
|
||||
// Edge Insets
|
||||
#define DTEdgeInsets UIEdgeInsets
|
||||
#define DTEdgeInsetsMake(top, left, bottom, right) UIEdgeInsetsMake(top, left, bottom, right)
|
||||
|
||||
// NS-style text attributes are possible with iOS SDK 6.0 or higher
|
||||
#if __IPHONE_OS_VERSION_MAX_ALLOWED > __IPHONE_5_1
|
||||
#define DTCORETEXT_SUPPORT_NS_ATTRIBUTES 1
|
||||
#endif
|
||||
|
||||
// NSParagraphStyle supports tabs as of iOS SDK 7.0 or higher
|
||||
#if __IPHONE_OS_VERSION_MAX_ALLOWED > __IPHONE_6_1
|
||||
#define DTCORETEXT_SUPPORT_NSPARAGRAPHSTYLE_TABS 1
|
||||
#endif
|
||||
|
||||
// iOS before 5.0 has leak in CoreText replacing attributes
|
||||
#if __IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_5_0
|
||||
#define DTCORETEXT_NEEDS_ATTRIBUTE_REPLACEMENT_LEAK_FIX 1
|
||||
#endif
|
||||
|
||||
// iOS 7 bug (rdar://14684188) workaround, can be removed once this bug is fixed
|
||||
#if __IPHONE_OS_VERSION_MAX_ALLOWED > __IPHONE_6_1
|
||||
#define DTCORETEXT_FIX_14684188 1
|
||||
#endif
|
||||
|
||||
// use NSURLSession if NSURLConnection is deprecated
|
||||
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_9_0
|
||||
#define DTCORETEXT_USES_NSURLSESSION 1
|
||||
#endif
|
||||
|
||||
// constant for checking for iOS 6
|
||||
#define DTNSFoundationVersionNumber_iOS_6_0 992.00
|
||||
|
||||
// constant for checking for iOS 7
|
||||
#define DTNSFoundationVersionNumber_iOS_7_0 1047.00
|
||||
|
||||
|
||||
// runtime-check if NS-style attributes are allowed
|
||||
static inline BOOL DTCoreTextModernAttributesPossible(void);
|
||||
static inline BOOL DTCoreTextModernAttributesPossible(void)
|
||||
{
|
||||
#if DTCORETEXT_SUPPORT_NS_ATTRIBUTES
|
||||
if (floor(NSFoundationVersionNumber) >= DTNSFoundationVersionNumber_iOS_6_0)
|
||||
{
|
||||
return YES;
|
||||
}
|
||||
#endif
|
||||
return NO;
|
||||
}
|
||||
|
||||
// runtime-check if CoreText draws underlines
|
||||
static inline BOOL DTCoreTextDrawsUnderlinesWithGlyphs(void);
|
||||
static inline BOOL DTCoreTextDrawsUnderlinesWithGlyphs(void)
|
||||
{
|
||||
if (floor(NSFoundationVersionNumber) >= DTNSFoundationVersionNumber_iOS_7_0)
|
||||
{
|
||||
return YES;
|
||||
}
|
||||
|
||||
return NO;
|
||||
}
|
||||
|
||||
#if TARGET_CPU_ARM64 || TARGET_CPU_X86_64
|
||||
#define DTNSNumberFromCGFloat(x) [NSNumber numberWithDouble:x]
|
||||
#else
|
||||
#define DTNSNumberFromCGFloat(x) [NSNumber numberWithFloat:x]
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
||||
|
||||
#pragma mark - Mac
|
||||
|
||||
|
||||
#if !TARGET_OS_IPHONE
|
||||
|
||||
|
||||
#import <AppKit/AppKit.h>
|
||||
|
||||
// Compatibility Aliases
|
||||
#define DTColor NSColor
|
||||
#define DTImage NSImage
|
||||
#define DTFont NSFont
|
||||
|
||||
// Edge Insets
|
||||
#define DTEdgeInsets NSEdgeInsets
|
||||
#define DTEdgeInsetsMake(top, left, bottom, right) NSEdgeInsetsMake(top, left, bottom, right)
|
||||
|
||||
// Mac supports NS-Style Text Attributes since 10.0
|
||||
#define DTCORETEXT_SUPPORT_NS_ATTRIBUTES 1
|
||||
#define DTCORETEXT_SUPPORT_NSPARAGRAPHSTYLE_TABS 1
|
||||
|
||||
// theoretically MacOS before 10.8 might have a leak in CoreText replacing attributes
|
||||
#if __MAC_OS_X_VERSION_MIN_REQUIRED < __MAC_10_7
|
||||
#define DTCORETEXT_NEEDS_ATTRIBUTE_REPLACEMENT_LEAK_FIX 1
|
||||
#endif
|
||||
|
||||
// use NSURLSession if NSURLConnection is deprecated
|
||||
#if __MAC_OS_X_VERSION_MAX_ALLOWED >= __MAC_10_11
|
||||
#define DTCORETEXT_USES_NSURLSESSION 1
|
||||
#endif
|
||||
|
||||
// NSValue has sizeValue on Mac, CGSizeValue on iOS
|
||||
#define CGSizeValue sizeValue
|
||||
|
||||
// String functions named differently on Mac
|
||||
static inline NSString *NSStringFromCGRect(const CGRect rect);
|
||||
static inline NSString *NSStringFromCGRect(const CGRect rect)
|
||||
{
|
||||
return NSStringFromRect(NSRectFromCGRect(rect));
|
||||
}
|
||||
|
||||
static inline NSString *NSStringFromCGSize(const CGSize size);
|
||||
static inline NSString *NSStringFromCGSize(const CGSize size)
|
||||
{
|
||||
return NSStringFromSize(NSSizeFromCGSize(size));
|
||||
}
|
||||
|
||||
static inline NSString *NSStringFromCGPoint(const CGPoint point);
|
||||
static inline NSString *NSStringFromCGPoint(const CGPoint point)
|
||||
{
|
||||
return NSStringFromPoint(NSPointFromCGPoint(point));
|
||||
}
|
||||
|
||||
// runtime-check if NS-style attributes are allowed
|
||||
static inline BOOL DTCoreTextModernAttributesPossible();
|
||||
static inline BOOL DTCoreTextModernAttributesPossible()
|
||||
{
|
||||
return YES;
|
||||
}
|
||||
|
||||
// runtime-check if CoreText draws underlines
|
||||
static inline BOOL DTCoreTextDrawsUnderlinesWithGlyphs();
|
||||
static inline BOOL DTCoreTextDrawsUnderlinesWithGlyphs()
|
||||
{
|
||||
return NO;
|
||||
}
|
||||
|
||||
#define DTNSNumberFromCGFloat(x) [NSNumber numberWithDouble:x]
|
||||
#endif
|
||||
|
||||
// this enables generic ceil, floor, abs, round functions that work for 64 and 32 bit
|
||||
#include <tgmath.h>
|
||||
@@ -1,96 +0,0 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <CoreGraphics/CoreGraphics.h>
|
||||
|
||||
#if TARGET_OS_IPHONE
|
||||
#import <CoreText/CoreText.h>
|
||||
#elif TARGET_OS_MAC
|
||||
#import <ApplicationServices/ApplicationServices.h>
|
||||
#endif
|
||||
|
||||
// global constants
|
||||
#import "DTCoreTextMacros.h"
|
||||
#import "DTCoreTextConstants.h"
|
||||
#import "DTCompatibility.h"
|
||||
|
||||
#import "DTColor+Compatibility.h"
|
||||
#import "DTImage+HTML.h"
|
||||
|
||||
// common utilities
|
||||
#if TARGET_OS_IPHONE
|
||||
#import "DTCoreTextFunctions.h"
|
||||
#endif
|
||||
|
||||
#import "DTColorFunctions.h"
|
||||
|
||||
// common classes
|
||||
#import "DTCSSListStyle.h"
|
||||
#import "DTTextBlock.h"
|
||||
#import "DTCSSStylesheet.h"
|
||||
#import "DTCoreTextFontDescriptor.h"
|
||||
#import "DTCoreTextParagraphStyle.h"
|
||||
#import "DTHTMLAttributedStringBuilder.h"
|
||||
#import "DTHTMLElement.h"
|
||||
#import "DTAnchorHTMLElement.h"
|
||||
#import "DTBreakHTMLElement.h"
|
||||
#import "DTListItemHTMLElement.h"
|
||||
#import "DTHorizontalRuleHTMLElement.h"
|
||||
#import "DTStylesheetHTMLElement.h"
|
||||
#import "DTTextAttachmentHTMLElement.h"
|
||||
#import "DTTextHTMLElement.h"
|
||||
#import "DTHTMLWriter.h"
|
||||
#import "NSCharacterSet+HTML.h"
|
||||
#import "NSCoder+DTCompatibility.h"
|
||||
#import "NSDictionary+DTCoreText.h"
|
||||
#import "NSAttributedString+HTML.h"
|
||||
#import "NSAttributedString+SmallCaps.h"
|
||||
#import "NSAttributedString+DTCoreText.h"
|
||||
#import "NSMutableAttributedString+HTML.h"
|
||||
#import "NSMutableString+HTML.h"
|
||||
#import "NSScanner+HTML.h"
|
||||
#import "NSString+CSS.h"
|
||||
#import "NSString+HTML.h"
|
||||
#import "NSString+Paragraphs.h"
|
||||
#import "NSNumber+RomanNumerals.h"
|
||||
|
||||
// parsing classes
|
||||
#import "DTHTMLParserNode.h"
|
||||
#import "DTHTMLParserTextNode.h"
|
||||
|
||||
// text attachment cluster
|
||||
#import "DTTextAttachment.h"
|
||||
#import "DTDictationPlaceholderTextAttachment.h"
|
||||
#import "DTIframeTextAttachment.h"
|
||||
#import "DTImageTextAttachment.h"
|
||||
#import "DTObjectTextAttachment.h"
|
||||
#import "DTVideoTextAttachment.h"
|
||||
|
||||
#import "NSAttributedStringRunDelegates.h"
|
||||
|
||||
#import "DTCoreTextGlyphRun.h"
|
||||
#import "DTCoreTextLayoutFrame.h"
|
||||
#import "DTCoreTextLayoutFrame+Cursor.h"
|
||||
#import "DTCoreTextLayoutLine.h"
|
||||
#import "DTCoreTextLayouter.h"
|
||||
|
||||
// TARGET_OS_IPHONE is both tvOS and iOS
|
||||
#if TARGET_OS_IPHONE
|
||||
|
||||
#import "DTLazyImageView.h"
|
||||
#import "DTLinkButton.h"
|
||||
|
||||
#import "DTAttributedLabel.h"
|
||||
#import "DTAttributedTextCell.h"
|
||||
#import "DTAttributedTextContentView.h"
|
||||
#import "DTAttributedTextView.h"
|
||||
#import "DTCoreTextFontCollection.h"
|
||||
|
||||
#import "DTDictationPlaceholderView.h"
|
||||
|
||||
#import "UIFont+DTCoreText.h"
|
||||
|
||||
#import "DTAccessibilityElement.h"
|
||||
#import "DTAccessibilityViewProxy.h"
|
||||
#import "DTCoreTextLayoutFrameAccessibilityElementGenerator.h"
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
#import "DTCompatibility.h"
|
||||
|
||||
// unicode characters
|
||||
|
||||
#define UNICODE_OBJECT_PLACEHOLDER @"\ufffc"
|
||||
#define UNICODE_LINE_FEED @"\u2028"
|
||||
|
||||
// unicode spaces used in CharacterSet.ignorableWhitespaceCharacterSet
|
||||
|
||||
#define UNICODE_NON_BREAKING_SPACE @"\u00a0"
|
||||
#define UNICODE_OGHAM_SPACE_MARK @"\u1680"
|
||||
#define UNICODE_MONGOLIAN_VOWEL_SEPARATOR @"\u180e"
|
||||
#define UNICODE_EN_QUAD @"\u2000"
|
||||
#define UNICODE_EM_QUAD @"\u2001"
|
||||
#define UNICODE_EN_SPACE @"\u2002"
|
||||
#define UNICODE_EM_SPACE @"\u2003"
|
||||
#define UNICODE_THREE_PER_EM_SPACE @"\u2004"
|
||||
#define UNICODE_FOUR_PER_EM_SPACE @"\u2005"
|
||||
#define UNICODE_SIX_PER_EM_SPACE @"\u2006"
|
||||
#define UNICODE_FIGURE_SPACE @"\u2007"
|
||||
#define UNICODE_PUNCTUATION_SPACE @"\u2008"
|
||||
#define UNICODE_THIN_SPACE @"\u2009"
|
||||
#define UNICODE_HAIR_SPACE @"\u200a"
|
||||
#define UNICODE_ZERO_WIDTH_SPACE @"\u200b"
|
||||
#define UNICODE_NARROW_NO_BREAK_SPACE @"\u202f"
|
||||
#define UNICODE_MEDIUM_MATHEMATICAL_SPACE @"\u205f"
|
||||
#define UNICODE_IDEOGRAPHIC_SPACE @"\u3000"
|
||||
#define UNICODE_ZERO_WIDTH_NO_BREAK_SPACE @"\ufeff"
|
||||
|
||||
// standard options
|
||||
|
||||
#if TARGET_OS_IPHONE
|
||||
extern NSString * const NSBaseURLDocumentOption;
|
||||
extern NSString * const NSTextEncodingNameDocumentOption;
|
||||
extern NSString * const NSTextSizeMultiplierDocumentOption;
|
||||
|
||||
#if __IPHONE_OS_VERSION_MAX_ALLOWED < __IPHONE_7_0
|
||||
extern NSString * const NSAttachmentAttributeName;
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
||||
// custom options
|
||||
|
||||
extern NSString * const DTMaxImageSize;
|
||||
extern NSString * const DTDefaultFontFamily;
|
||||
extern NSString * const DTDefaultFontName;
|
||||
extern NSString * const DTDefaultFontSize;
|
||||
extern NSString * const DTDefaultFontDescriptor;
|
||||
extern NSString * const DTDefaultTextColor;
|
||||
extern NSString * const DTDefaultLinkColor;
|
||||
extern NSString * const DTDefaultLinkDecoration;
|
||||
extern NSString * const DTDefaultLinkHighlightColor;
|
||||
extern NSString * const DTDefaultTextAlignment;
|
||||
extern NSString * const DTDefaultLineHeightMultiplier;
|
||||
extern NSString * const DTDefaultLineHeightMultiplier;
|
||||
extern NSString * const DTDefaultFirstLineHeadIndent;
|
||||
extern NSString * const DTDefaultHeadIndent;
|
||||
extern NSString * const DTDefaultStyleSheet;
|
||||
extern NSString * const DTUseiOS6Attributes;
|
||||
extern NSString * const DTWillFlushBlockCallBack;
|
||||
extern NSString * const DTProcessCustomHTMLAttributes;
|
||||
extern NSString * const DTIgnoreInlineStylesOption;
|
||||
extern NSString * const DTDocumentPreserveTrailingSpaces;
|
||||
|
||||
|
||||
// attributed string attribute constants
|
||||
|
||||
extern NSString * const DTTextListsAttribute;
|
||||
extern NSString * const DTAttachmentParagraphSpacingAttribute;
|
||||
extern NSString * const DTLinkAttribute;
|
||||
extern NSString * const DTLinkHighlightColorAttribute;
|
||||
extern NSString * const DTAnchorAttribute;
|
||||
extern NSString * const DTGUIDAttribute;
|
||||
extern NSString * const DTHeaderLevelAttribute;
|
||||
extern NSString * const DTStrikeOutAttribute;
|
||||
extern NSString * const DTBackgroundColorAttribute;
|
||||
extern NSString * const DTShadowsAttribute;
|
||||
extern NSString * const DTHorizontalRuleStyleAttribute;
|
||||
extern NSString * const DTTextBlocksAttribute;
|
||||
extern NSString * const DTFieldAttribute;
|
||||
extern NSString * const DTCustomAttributesAttribute;
|
||||
extern NSString * const DTAscentMultiplierAttribute;
|
||||
extern NSString * const DTBackgroundStrokeColorAttribute;
|
||||
extern NSString * const DTBackgroundStrokeWidthAttribute;
|
||||
extern NSString * const DTBackgroundCornerRadiusAttribute;
|
||||
extern NSString * const DTArchivingAttribute;
|
||||
|
||||
// field constants
|
||||
|
||||
extern NSString * const DTListPrefixField;
|
||||
|
||||
// iOS 6 compatibility
|
||||
extern BOOL ___useiOS6Attributes;
|
||||
|
||||
// exceptions
|
||||
extern NSString * const DTCoreTextFontDescriptorException;
|
||||
|
||||
// macros
|
||||
|
||||
#define IS_WHITESPACE(_c) (_c == ' ' || _c == '\t' || _c == 0xA || _c == 0xB || _c == 0xC || _c == 0xD || _c == 0x85)
|
||||
|
||||
// types
|
||||
|
||||
/**
|
||||
DTHTMLElement display style
|
||||
*/
|
||||
typedef NS_ENUM(NSUInteger, DTHTMLElementDisplayStyle)
|
||||
{
|
||||
/**
|
||||
The element is inline text
|
||||
*/
|
||||
DTHTMLElementDisplayStyleInline = 0, // default
|
||||
|
||||
/**
|
||||
The element is not displayed
|
||||
*/
|
||||
DTHTMLElementDisplayStyleNone,
|
||||
|
||||
/**
|
||||
The element is a block
|
||||
*/
|
||||
DTHTMLElementDisplayStyleBlock,
|
||||
|
||||
/**
|
||||
The element is an item in a list
|
||||
*/
|
||||
DTHTMLElementDisplayStyleListItem,
|
||||
|
||||
/**
|
||||
The element is a table
|
||||
*/
|
||||
DTHTMLElementDisplayStyleTable,
|
||||
};
|
||||
|
||||
/**
|
||||
DTHTMLElement floating style
|
||||
*/
|
||||
typedef NS_ENUM(NSUInteger, DTHTMLElementFloatStyle)
|
||||
{
|
||||
/**
|
||||
The element does not float
|
||||
*/
|
||||
DTHTMLElementFloatStyleNone = 0,
|
||||
|
||||
|
||||
/**
|
||||
The element should float left-aligned
|
||||
*/
|
||||
DTHTMLElementFloatStyleLeft,
|
||||
|
||||
|
||||
/**
|
||||
The element should float right-aligned
|
||||
*/
|
||||
DTHTMLElementFloatStyleRight
|
||||
};
|
||||
|
||||
/**
|
||||
DTHTMLElement font variants
|
||||
*/
|
||||
typedef NS_ENUM(NSUInteger, DTHTMLElementFontVariant)
|
||||
{
|
||||
/**
|
||||
The element inherits the font variant
|
||||
*/
|
||||
DTHTMLElementFontVariantInherit = 0,
|
||||
|
||||
/**
|
||||
The element uses the normal font variant
|
||||
*/
|
||||
DTHTMLElementFontVariantNormal,
|
||||
|
||||
/**
|
||||
The element should display in small caps
|
||||
*/
|
||||
DTHTMLElementFontVariantSmallCaps
|
||||
};
|
||||
|
||||
/**
|
||||
The algorithm that DTCoreTextLayoutFrame uses for positioning lines
|
||||
*/
|
||||
typedef NS_ENUM(NSUInteger, DTCoreTextLayoutFrameLinePositioningOptions)
|
||||
{
|
||||
/**
|
||||
The line positioning algorithm is similar to how Safari positions lines
|
||||
*/
|
||||
DTCoreTextLayoutFrameLinePositioningOptionAlgorithmWebKit = 1,
|
||||
|
||||
/**
|
||||
The line positioning algorithm is how it was before the implementation of DTCoreTextLayoutFrameLinePositioningOptionAlgorithmWebKit
|
||||
*/
|
||||
DTCoreTextLayoutFrameLinePositioningOptionAlgorithmLegacy = 2
|
||||
};
|
||||
|
||||
// layouting
|
||||
|
||||
// the value to use if the width is unknown
|
||||
#define CGFLOAT_WIDTH_UNKNOWN 16777215.0f
|
||||
|
||||
// the value to use if the height is unknown
|
||||
#define CGFLOAT_HEIGHT_UNKNOWN 16777215.0f
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
#import "DTCoreTextConstants.h"
|
||||
|
||||
// standard options
|
||||
|
||||
#if TARGET_OS_IPHONE
|
||||
NSString * const NSBaseURLDocumentOption = @"NSBaseURLDocumentOption";
|
||||
NSString * const NSTextEncodingNameDocumentOption = @"NSTextEncodingNameDocumentOption";
|
||||
NSString * const NSTextSizeMultiplierDocumentOption = @"NSTextSizeMultiplierDocumentOption";
|
||||
|
||||
#if __IPHONE_OS_VERSION_MAX_ALLOWED < __IPHONE_7_0
|
||||
NSString * const NSAttachmentAttributeName = @"NSAttachmentAttributeName";
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
||||
// custom options
|
||||
|
||||
NSString * const DTMaxImageSize = @"DTMaxImageSize";
|
||||
NSString * const DTDefaultFontFamily = @"DTDefaultFontFamily";
|
||||
NSString * const DTDefaultFontName = @"DTDefaultFontName";
|
||||
NSString * const DTDefaultFontSize = @"DTDefaultFontSize";
|
||||
NSString * const DTDefaultFontDescriptor = @"DTDefaultFontDescriptor";
|
||||
NSString * const DTDefaultTextColor = @"DTDefaultTextColor";
|
||||
NSString * const DTDefaultLinkColor = @"DTDefaultLinkColor";
|
||||
NSString * const DTDefaultLinkHighlightColor = @"DTDefaultLinkHighlightColor";
|
||||
NSString * const DTDefaultLinkDecoration = @"DTDefaultLinkDecoration";
|
||||
NSString * const DTDefaultTextAlignment = @"DTDefaultTextAlignment";
|
||||
NSString * const DTDefaultLineHeightMultiplier = @"DTDefaultLineHeightMultiplier";
|
||||
NSString * const DTDefaultFirstLineHeadIndent = @"DTDefaultFirstLineHeadIndent";
|
||||
NSString * const DTDefaultHeadIndent = @"DTDefaultHeadIndent";
|
||||
NSString * const DTDefaultStyleSheet = @"DTDefaultStyleSheet";
|
||||
NSString * const DTUseiOS6Attributes = @"DTUseiOS6Attributes";
|
||||
NSString * const DTWillFlushBlockCallBack = @"DTWillFlushBlockCallBack";
|
||||
NSString * const DTProcessCustomHTMLAttributes = @"DTProcessCustomHTMLAttributes";
|
||||
NSString * const DTIgnoreInlineStylesOption = @"DTIgnoreInlineStyles";
|
||||
NSString * const DTDocumentPreserveTrailingSpaces = @"DTDocumentPreserveTrailingSpaces";
|
||||
|
||||
// attributed string attribute constants
|
||||
|
||||
NSString * const DTTextListsAttribute = @"DTTextLists";
|
||||
NSString * const DTAttachmentParagraphSpacingAttribute = @"DTAttachmentParagraphSpacing";
|
||||
NSString * const DTLinkAttribute = @"NSLink";
|
||||
NSString * const DTLinkHighlightColorAttribute = @"DTLinkHighlightColor";
|
||||
NSString * const DTAnchorAttribute = @"DTAnchor";
|
||||
NSString * const DTGUIDAttribute = @"DTGUID";
|
||||
NSString * const DTHeaderLevelAttribute = @"DTHeaderLevel";
|
||||
NSString * const DTStrikeOutAttribute = @"DTStrikethrough";
|
||||
NSString * const DTBackgroundColorAttribute = @"DTBackgroundColor";
|
||||
NSString * const DTShadowsAttribute = @"DTShadows";
|
||||
NSString * const DTHorizontalRuleStyleAttribute = @"DTHorizontalRuleStyle";
|
||||
NSString * const DTTextBlocksAttribute = @"DTTextBlocks";
|
||||
NSString * const DTFieldAttribute = @"DTField";
|
||||
NSString * const DTCustomAttributesAttribute = @"DTCustomAttributes";
|
||||
NSString * const DTAscentMultiplierAttribute = @"DTAscentMultiplierAttribute";
|
||||
NSString * const DTBackgroundStrokeColorAttribute = @"DTBackgroundStrokeColor";
|
||||
NSString * const DTBackgroundStrokeWidthAttribute = @"DTBackgroundStrokeWidth";
|
||||
NSString * const DTBackgroundCornerRadiusAttribute = @"DTBackgroundCornerRadius";
|
||||
NSString * const DTArchivingAttribute = @"DTArchivingAttribute";
|
||||
|
||||
// field constants
|
||||
NSString * const DTListPrefixField = @"{listprefix}";
|
||||
|
||||
// iOS 6 compatibility
|
||||
|
||||
BOOL ___useiOS6Attributes = NO; // this gets set globally by DTHTMLAttributedStringBuilder
|
||||
|
||||
|
||||
// exceptions
|
||||
|
||||
NSString * const DTCoreTextFontDescriptorException = @"DTCoreTextFontDescriptorException";
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
//
|
||||
// DTCoreTextFontCollection.h
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 5/23/11.
|
||||
// Copyright 2011 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
|
||||
@class DTCoreTextFontDescriptor;
|
||||
|
||||
/**
|
||||
Class representing a collection of fonts
|
||||
*/
|
||||
|
||||
@interface DTCoreTextFontCollection : NSObject
|
||||
|
||||
/**
|
||||
@name Creating Font Collections
|
||||
*/
|
||||
|
||||
/**
|
||||
Creates a font collection with all available fonts on the system
|
||||
*/
|
||||
+ (DTCoreTextFontCollection *)availableFontsCollection;
|
||||
|
||||
/**
|
||||
@name Getting Information about Font Collections
|
||||
*/
|
||||
|
||||
/**
|
||||
The font family names that occur in the receiver's list of fonts
|
||||
*/
|
||||
- (NSArray *)fontFamilyNames;
|
||||
|
||||
/**
|
||||
The font descriptors describing all fonts in the receiver's font collection
|
||||
*/
|
||||
- (NSArray *)fontDescriptors;
|
||||
|
||||
/**
|
||||
@name Searching for Fonts
|
||||
*/
|
||||
|
||||
/**
|
||||
The font descriptor describing a font in the receiver's collection that matches a given descriptor
|
||||
@param descriptor The font descriptor to search for
|
||||
@returns The first found font descriptor in the font collection
|
||||
*/
|
||||
- (DTCoreTextFontDescriptor *)matchingFontDescriptorForFontDescriptor:(DTCoreTextFontDescriptor *)descriptor;
|
||||
|
||||
@end
|
||||
@@ -1,162 +0,0 @@
|
||||
//
|
||||
// DTCoreTextFontCollection.m
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 5/23/11.
|
||||
// Copyright 2011 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCompatibility.h"
|
||||
#import "DTCoreTextFontCollection.h"
|
||||
#import "DTCoreTextFontDescriptor.h"
|
||||
|
||||
#if TARGET_OS_IPHONE
|
||||
#import <CoreText/CoreText.h>
|
||||
#elif TARGET_OS_MAC
|
||||
#import <ApplicationServices/ApplicationServices.h>
|
||||
#endif
|
||||
|
||||
@interface DTCoreTextFontCollection ()
|
||||
|
||||
@property (nonatomic, strong) NSArray *fontDescriptors;
|
||||
@property (nonatomic, strong) NSCache *fontMatchCache;
|
||||
|
||||
- (id)initWithAvailableFonts;
|
||||
|
||||
@end
|
||||
|
||||
static DTCoreTextFontCollection *_availableFontsCollection = nil;
|
||||
|
||||
|
||||
@implementation DTCoreTextFontCollection
|
||||
{
|
||||
NSArray *_fontDescriptors;
|
||||
NSCache *_fontMatchCache;
|
||||
}
|
||||
|
||||
+ (DTCoreTextFontCollection *)availableFontsCollection
|
||||
{
|
||||
static dispatch_once_t predicate;
|
||||
|
||||
dispatch_once(&predicate, ^{
|
||||
_availableFontsCollection = [[DTCoreTextFontCollection alloc] initWithAvailableFonts];
|
||||
});
|
||||
|
||||
return _availableFontsCollection;
|
||||
}
|
||||
|
||||
- (id)initWithAvailableFonts
|
||||
{
|
||||
self = [super init];
|
||||
|
||||
if (self)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
|
||||
- (DTCoreTextFontDescriptor *)matchingFontDescriptorForFontDescriptor:(DTCoreTextFontDescriptor *)descriptor
|
||||
{
|
||||
DTCoreTextFontDescriptor *firstMatch = nil;
|
||||
NSString *cacheKey = [NSString stringWithFormat:@"fontFamily BEGINSWITH[cd] %@ and boldTrait == %d and italicTrait == %d", descriptor.fontFamily, descriptor.boldTrait, descriptor.italicTrait];
|
||||
|
||||
// try cache
|
||||
firstMatch = [self.fontMatchCache objectForKey:cacheKey];
|
||||
|
||||
if (firstMatch)
|
||||
{
|
||||
DTCoreTextFontDescriptor *retMatch = [firstMatch copy];
|
||||
retMatch.pointSize = descriptor.pointSize;
|
||||
return retMatch;
|
||||
}
|
||||
|
||||
// need to search
|
||||
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"fontFamily BEGINSWITH[cd] %@ and boldTrait == %d and italicTrait == %d", descriptor.fontFamily, descriptor.boldTrait, descriptor.italicTrait];
|
||||
|
||||
NSArray *matchingDescriptors = [self.fontDescriptors filteredArrayUsingPredicate:predicate];
|
||||
|
||||
//NSLog(@"%@", matchingDescriptors);
|
||||
|
||||
if ([matchingDescriptors count])
|
||||
{
|
||||
firstMatch = [matchingDescriptors objectAtIndex:0];
|
||||
[self.fontMatchCache setObject:firstMatch forKey:cacheKey];
|
||||
|
||||
DTCoreTextFontDescriptor *retMatch = [firstMatch copy];
|
||||
|
||||
retMatch.pointSize = descriptor.pointSize;
|
||||
return retMatch;
|
||||
}
|
||||
|
||||
return nil;
|
||||
}
|
||||
|
||||
#pragma mark Properties
|
||||
|
||||
- (NSArray *)fontDescriptors
|
||||
{
|
||||
if (!_fontDescriptors)
|
||||
{
|
||||
CTFontCollectionRef fonts = CTFontCollectionCreateFromAvailableFonts(NULL);
|
||||
|
||||
CFArrayRef matchingFonts = CTFontCollectionCreateMatchingFontDescriptors(fonts);
|
||||
|
||||
if (matchingFonts)
|
||||
{
|
||||
// convert all to our objects
|
||||
NSMutableArray *tmpArray = [[NSMutableArray alloc] init];
|
||||
|
||||
for (NSInteger i=0; i<CFArrayGetCount(matchingFonts); i++)
|
||||
{
|
||||
CTFontDescriptorRef fontDesc = CFArrayGetValueAtIndex(matchingFonts, i);
|
||||
|
||||
|
||||
DTCoreTextFontDescriptor *desc = [[DTCoreTextFontDescriptor alloc] initWithCTFontDescriptor:fontDesc];
|
||||
[tmpArray addObject:desc];
|
||||
}
|
||||
|
||||
CFRelease(matchingFonts);
|
||||
|
||||
self.fontDescriptors = tmpArray;
|
||||
}
|
||||
|
||||
CFRelease(fonts);
|
||||
}
|
||||
|
||||
return _fontDescriptors;
|
||||
}
|
||||
|
||||
- (NSCache *)fontMatchCache
|
||||
{
|
||||
if (!_fontMatchCache)
|
||||
{
|
||||
_fontMatchCache = [[NSCache alloc] init];
|
||||
}
|
||||
|
||||
return _fontMatchCache;
|
||||
}
|
||||
|
||||
- (NSArray *)fontFamilyNames
|
||||
{
|
||||
NSMutableArray *tmpArray = [NSMutableArray array];
|
||||
|
||||
for (DTCoreTextFontDescriptor *oneDescriptor in [self fontDescriptors])
|
||||
{
|
||||
NSString *familyName = oneDescriptor.fontFamily;
|
||||
|
||||
if (![tmpArray containsObject:familyName])
|
||||
{
|
||||
[tmpArray addObject:familyName];
|
||||
}
|
||||
}
|
||||
|
||||
return [tmpArray sortedArrayUsingSelector:@selector(compare:)];
|
||||
}
|
||||
|
||||
@synthesize fontDescriptors = _fontDescriptors;
|
||||
@synthesize fontMatchCache = _fontMatchCache;
|
||||
|
||||
@end
|
||||
@@ -1,228 +0,0 @@
|
||||
//
|
||||
// DTCoreTextFontDescriptor.h
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 1/26/11.
|
||||
// Copyright 2011 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <CoreText/CoreText.h>
|
||||
|
||||
|
||||
/**
|
||||
This class describes the attributes of a font. It is used to represent fonts throughout the parsing and when needed is able to generated matching `CTFont` instances.
|
||||
*/
|
||||
@interface DTCoreTextFontDescriptor : NSObject <NSCopying, NSCoding>
|
||||
|
||||
/**
|
||||
@name Creating Font Descriptors
|
||||
*/
|
||||
|
||||
/**
|
||||
Convenience method to create a font descriptor from a font attributes dictionary
|
||||
@param attributes The dictionary of font attributes
|
||||
@returns An initialized font descriptor
|
||||
*/
|
||||
+ (DTCoreTextFontDescriptor *)fontDescriptorWithFontAttributes:(NSDictionary *)attributes;
|
||||
|
||||
/**
|
||||
Convenience method for creates a font descriptor from a Core Text font
|
||||
@param ctFont The Core Text font
|
||||
@returns An initialized font descriptor
|
||||
*/
|
||||
+ (DTCoreTextFontDescriptor *)fontDescriptorForCTFont:(CTFontRef)ctFont;
|
||||
|
||||
/**
|
||||
Creates a font descriptor from a font attributes dictionary
|
||||
@param attributes The dictionary of font attributes
|
||||
@returns An initialized font descriptor
|
||||
*/
|
||||
- (id)initWithFontAttributes:(NSDictionary *)attributes;
|
||||
|
||||
/**
|
||||
Creates a font descriptor from a Core Text font descriptor
|
||||
@param ctFontDescriptor The Core Text font descriptor
|
||||
@returns An initialized font descriptor
|
||||
*/
|
||||
- (id)initWithCTFontDescriptor:(CTFontDescriptorRef)ctFontDescriptor;
|
||||
|
||||
/**
|
||||
Creates a font descriptor from a Core Text font
|
||||
@param ctFont The Core Text font
|
||||
@returns An initialized font descriptor
|
||||
*/
|
||||
- (id)initWithCTFont:(CTFontRef)ctFont;
|
||||
|
||||
|
||||
/**
|
||||
@name Creating Fonts from Font Descriptors
|
||||
*/
|
||||
|
||||
/**
|
||||
Creates a `CTFont` matching the receiver's attribute
|
||||
@returns a +1 owning reference of a Core Text font
|
||||
*/
|
||||
- (CTFontRef)newMatchingFont;
|
||||
|
||||
/**
|
||||
@name Specifying Font Attributes
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
Sets the font attributes from a dictionary
|
||||
@param newAttributes The font attributes dictionary
|
||||
*/
|
||||
- (void)setFontAttributes:(NSDictionary *)newAttributes;
|
||||
|
||||
/**
|
||||
Retrieves a dictionary of font attributes
|
||||
*/
|
||||
- (NSDictionary *)fontAttributes;
|
||||
|
||||
|
||||
/**
|
||||
The font family name of the described font
|
||||
*/
|
||||
@property (nonatomic, copy) NSString *fontFamily;
|
||||
|
||||
/**
|
||||
The font name of the described font
|
||||
*/
|
||||
@property (nonatomic, copy) NSString *fontName;
|
||||
|
||||
/**
|
||||
The point size of the described font
|
||||
*/
|
||||
@property (nonatomic) CGFloat pointSize;
|
||||
|
||||
/**
|
||||
Whether the described font has the bold trait
|
||||
*/
|
||||
@property (nonatomic) BOOL boldTrait;
|
||||
|
||||
/**
|
||||
Whether the described font has the italic trait
|
||||
*/
|
||||
@property (nonatomic) BOOL italicTrait;
|
||||
|
||||
/**
|
||||
Whether the described font has the expanded trait
|
||||
*/
|
||||
@property (nonatomic) BOOL expandedTrait;
|
||||
|
||||
/**
|
||||
Whether the described font has the condensed trait
|
||||
*/
|
||||
@property (nonatomic) BOOL condensedTrait;
|
||||
|
||||
/**
|
||||
Whether the described font has the monospace trait
|
||||
*/
|
||||
@property (nonatomic) BOOL monospaceTrait;
|
||||
|
||||
/**
|
||||
Whether the described font has the vertical trait
|
||||
*/
|
||||
@property (nonatomic) BOOL verticalTrait;
|
||||
|
||||
/**
|
||||
Whether the described font is optimized for use in User Interfaces
|
||||
*/
|
||||
@property (nonatomic) BOOL UIoptimizedTrait;
|
||||
|
||||
/**
|
||||
The symbolic traits of the receiver
|
||||
*/
|
||||
@property (nonatomic) CTFontSymbolicTraits symbolicTraits;
|
||||
|
||||
/**
|
||||
The stylistic class of the receiver
|
||||
*/
|
||||
@property (nonatomic) CTFontStylisticClass stylisticClass;
|
||||
|
||||
/**
|
||||
`YES` if the small caps style is enabled, `NO` if not
|
||||
*/
|
||||
@property (nonatomic) BOOL smallCapsFeature;
|
||||
|
||||
/**
|
||||
Determining if the font described by the receiver has native small caps support
|
||||
@returns `YES` if this font supports native small caps
|
||||
*/
|
||||
- (BOOL)supportsNativeSmallCaps;
|
||||
|
||||
/**
|
||||
Working with CSS
|
||||
*/
|
||||
|
||||
/**
|
||||
The CSS style sheet representation of the receiver
|
||||
@returns A CSS style string
|
||||
*/
|
||||
- (NSString *)cssStyleRepresentation;
|
||||
|
||||
|
||||
/**
|
||||
@name Global Font Overriding
|
||||
*/
|
||||
|
||||
/**
|
||||
A call to the method is ideally placed into your app delegate. This loads all available system fonts into a look up table to allow DTCoreText to quickly find a specific combination of font-family and italic and bold attributes. Please refer to the [Programming Guide](../docs/Programming%20Guide.html) for information when you should be using this.
|
||||
|
||||
Calling this does not replace entries already existing in the lookup table, for example loaded from the `DTCoreTextFontOverrides.plist` included in the app bundle.
|
||||
*/
|
||||
+ (void)asyncPreloadFontLookupTable;
|
||||
|
||||
/**
|
||||
Sets the font family to use if the font family in a font descriptor is invalid.
|
||||
|
||||
The fallback font family cannot be `nil` and must be a valid font family. The default is **Times New Roman**.
|
||||
@param fontFamily The font family
|
||||
*/
|
||||
+ (void)setFallbackFontFamily:(NSString *)fontFamily;
|
||||
|
||||
/**
|
||||
Returns the font family to use if the font family in a font descriptor is invalid. The default is **Times New Roman**.
|
||||
@returns The font family
|
||||
*/
|
||||
+ (NSString *)fallbackFontFamily;
|
||||
|
||||
/**
|
||||
Sets the global font name override to use when encountering a font family with given bold and italic attributes.
|
||||
@param fontName The font name to use
|
||||
@param fontFamily The font family to use this for
|
||||
@param bold The bold trait
|
||||
@param italic The italic trait
|
||||
*/
|
||||
+ (void)setOverrideFontName:(NSString *)fontName forFontFamily:(NSString *)fontFamily bold:(BOOL)bold italic:(BOOL)italic;
|
||||
|
||||
/**
|
||||
Retrieves the global font name override for a given font family with bold and italic traits.
|
||||
@param fontFamily The font family to retrieve the override for
|
||||
@param bold The bold trait
|
||||
@param italic The italic trait
|
||||
@returns The font name to use for this combination of parameters
|
||||
*/
|
||||
+ (NSString *)overrideFontNameforFontFamily:(NSString *)fontFamily bold:(BOOL)bold italic:(BOOL)italic;
|
||||
|
||||
/**
|
||||
Sets the global font name override to use when encountering small caps text in a font family with given bold and italic attributes.
|
||||
@param fontName The font name to use
|
||||
@param fontFamily The font family to use this for
|
||||
@param bold The bold trait
|
||||
@param italic The italic trait
|
||||
*/
|
||||
+ (void)setSmallCapsFontName:(NSString *)fontName forFontFamily:(NSString *)fontFamily bold:(BOOL)bold italic:(BOOL)italic;
|
||||
|
||||
/**
|
||||
Retrieves the global font name override to use for small caps text for a given font family with bold and italic traits.
|
||||
@param fontFamily The font family to retrieve the override for
|
||||
@param bold The bold trait
|
||||
@param italic The italic trait
|
||||
@returns The font name to use for this combination of parameters
|
||||
*/
|
||||
+ (NSString *)smallCapsFontNameforFontFamily:(NSString *)fontFamily bold:(BOOL)bold italic:(BOOL)italic;
|
||||
|
||||
@end
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,69 +0,0 @@
|
||||
//
|
||||
// DTCoreTextFunctions.h
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 21.12.12.
|
||||
// Copyright (c) 2012 Drobnik.com. All rights reserved.
|
||||
//
|
||||
#import <CoreText/CoreText.h>
|
||||
|
||||
#import "DTCompatibility.h"
|
||||
|
||||
#if TARGET_OS_OSX
|
||||
#import <AppKit/AppKit.h>
|
||||
#endif
|
||||
|
||||
#import "DTCompatibility.h"
|
||||
|
||||
#if TARGET_OS_IPHONE
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
/**
|
||||
Creates a CTFont from a UIFont
|
||||
@param font The `UIFont`
|
||||
@returns The matching CTFont
|
||||
*/
|
||||
CTFontRef DTCTFontCreateWithUIFont(UIFont *font);
|
||||
#endif
|
||||
|
||||
/**
|
||||
Converts an NSLineBreakMode into CoreText line truncation type
|
||||
*/
|
||||
CTLineTruncationType DTCTLineTruncationTypeFromNSLineBreakMode(NSLineBreakMode lineBreakMode);
|
||||
|
||||
/**
|
||||
Rounds the passed value according to the specified content scale.
|
||||
|
||||
With contentScale 1 the results are identical to roundf, with Retina content scale 2 the results are multiples of 0.5.
|
||||
*/
|
||||
CGFloat DTRoundWithContentScale(CGFloat value, CGFloat contentScale);
|
||||
|
||||
/**
|
||||
Rounds up the passed value according to the specified content scale.
|
||||
|
||||
With contentScale 1 the results are identical to roundf, with Retina content scale 2 the results are multiples of 0.5.
|
||||
*/
|
||||
CGFloat DTCeilWithContentScale(CGFloat value, CGFloat contentScale);
|
||||
|
||||
/**
|
||||
Rounds down the passed value according to the sspecifiedcontent scale.
|
||||
|
||||
With contentScale 1 the results are identical to roundf, with Retina content scale 2 the results are multiples of 0.5.
|
||||
*/
|
||||
CGFloat DTFloorWithContentScale(CGFloat value, CGFloat contentScale);
|
||||
|
||||
#pragma mark - Alignment Conversion
|
||||
|
||||
#if DTCORETEXT_SUPPORT_NS_ATTRIBUTES
|
||||
/**
|
||||
Converts from NSTextAlignment to CTTextAligment
|
||||
*/
|
||||
CTTextAlignment DTNSTextAlignmentToCTTextAlignment(NSTextAlignment nsTextAlignment);
|
||||
|
||||
/**
|
||||
Converts from CTTextAlignment to NSTextAligment
|
||||
*/
|
||||
NSTextAlignment DTNSTextAlignmentFromCTTextAlignment(CTTextAlignment ctTextAlignment);
|
||||
#endif
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
//
|
||||
// DTCoreTextFunctions.m
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 21.12.12.
|
||||
// Copyright (c) 2012 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCoreTextFunctions.h"
|
||||
|
||||
#import <DTFoundation/DTLog.h>
|
||||
|
||||
#if TARGET_OS_IPHONE
|
||||
CTFontRef DTCTFontCreateWithUIFont(UIFont *font)
|
||||
{
|
||||
return CTFontCreateWithName((__bridge CFStringRef)font.fontName, font.pointSize, NULL);
|
||||
}
|
||||
#endif
|
||||
|
||||
CTLineTruncationType DTCTLineTruncationTypeFromNSLineBreakMode(NSLineBreakMode lineBreakMode)
|
||||
{
|
||||
#if TARGET_OS_IPHONE && __IPHONE_OS_VERSION_MIN_REQUIRED < 60000
|
||||
switch (lineBreakMode)
|
||||
{
|
||||
case UILineBreakModeHeadTruncation:
|
||||
return kCTLineTruncationStart;
|
||||
|
||||
case UILineBreakModeMiddleTruncation:
|
||||
return kCTLineTruncationMiddle;
|
||||
|
||||
default:
|
||||
return kCTLineTruncationEnd;
|
||||
}
|
||||
#else
|
||||
switch (lineBreakMode)
|
||||
{
|
||||
case NSLineBreakByTruncatingHead:
|
||||
return kCTLineTruncationStart;
|
||||
|
||||
case NSLineBreakByTruncatingMiddle:
|
||||
return kCTLineTruncationMiddle;
|
||||
|
||||
default:
|
||||
return kCTLineTruncationEnd;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
CGFloat DTRoundWithContentScale(CGFloat value, CGFloat contentScale)
|
||||
{
|
||||
return round(value*contentScale)/contentScale;
|
||||
}
|
||||
|
||||
CGFloat DTCeilWithContentScale(CGFloat value, CGFloat contentScale)
|
||||
{
|
||||
return ceil(value*contentScale)/contentScale;
|
||||
}
|
||||
|
||||
CGFloat DTFloorWithContentScale(CGFloat value, CGFloat contentScale)
|
||||
{
|
||||
return floor(value*contentScale)/contentScale;
|
||||
}
|
||||
|
||||
#pragma mark - Alignment Functions
|
||||
|
||||
#if DTCORETEXT_SUPPORT_NS_ATTRIBUTES
|
||||
|
||||
CTTextAlignment DTNSTextAlignmentToCTTextAlignment(NSTextAlignment nsTextAlignment)
|
||||
{
|
||||
switch (nsTextAlignment)
|
||||
{
|
||||
#if TARGET_OS_IPHONE
|
||||
case NSTextAlignmentLeft:
|
||||
{
|
||||
return kCTTextAlignmentLeft;
|
||||
}
|
||||
|
||||
case NSTextAlignmentRight:
|
||||
{
|
||||
return kCTTextAlignmentRight;
|
||||
}
|
||||
|
||||
case NSTextAlignmentCenter:
|
||||
{
|
||||
return kCTTextAlignmentCenter;
|
||||
}
|
||||
|
||||
case NSTextAlignmentJustified:
|
||||
{
|
||||
return kCTTextAlignmentJustified;
|
||||
}
|
||||
|
||||
case NSTextAlignmentNatural:
|
||||
{
|
||||
return kCTTextAlignmentNatural;
|
||||
}
|
||||
#else
|
||||
case NSLeftTextAlignment:
|
||||
{
|
||||
return kCTTextAlignmentLeft;
|
||||
}
|
||||
|
||||
case NSRightTextAlignment:
|
||||
{
|
||||
return kCTTextAlignmentRight;
|
||||
}
|
||||
|
||||
case NSCenterTextAlignment:
|
||||
{
|
||||
return kCTTextAlignmentCenter;
|
||||
}
|
||||
|
||||
case NSJustifiedTextAlignment:
|
||||
{
|
||||
return kCTTextAlignmentJustified;
|
||||
}
|
||||
|
||||
case NSNaturalTextAlignment:
|
||||
{
|
||||
return kCTTextAlignmentNatural;
|
||||
}
|
||||
#endif
|
||||
|
||||
default:
|
||||
{
|
||||
DTLogError(@"Unknown alignment %d", (int)nsTextAlignment);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NSTextAlignment DTNSTextAlignmentFromCTTextAlignment(CTTextAlignment ctTextAlignment)
|
||||
{
|
||||
#if TARGET_OS_IPHONE
|
||||
switch (ctTextAlignment)
|
||||
{
|
||||
case kCTTextAlignmentLeft:
|
||||
{
|
||||
return NSTextAlignmentLeft;
|
||||
}
|
||||
|
||||
case kCTTextAlignmentRight:
|
||||
{
|
||||
return NSTextAlignmentRight;
|
||||
}
|
||||
|
||||
case kCTTextAlignmentCenter:
|
||||
{
|
||||
return NSTextAlignmentCenter;
|
||||
}
|
||||
|
||||
case kCTTextAlignmentJustified:
|
||||
{
|
||||
return NSTextAlignmentJustified;
|
||||
}
|
||||
|
||||
case kCTTextAlignmentNatural:
|
||||
{
|
||||
return NSTextAlignmentNatural;
|
||||
}
|
||||
}
|
||||
#else
|
||||
switch (ctTextAlignment)
|
||||
{
|
||||
case kCTTextAlignmentLeft:
|
||||
{
|
||||
return NSLeftTextAlignment;
|
||||
}
|
||||
|
||||
case kCTTextAlignmentRight:
|
||||
{
|
||||
return NSRightTextAlignment;
|
||||
}
|
||||
|
||||
case kCTTextAlignmentCenter:
|
||||
{
|
||||
return NSCenterTextAlignment;
|
||||
}
|
||||
|
||||
case kCTTextAlignmentJustified:
|
||||
{
|
||||
return NSJustifiedTextAlignment;
|
||||
}
|
||||
|
||||
case kCTTextAlignmentNatural:
|
||||
{
|
||||
return NSNaturalTextAlignment;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
//
|
||||
// DTCoreTextGlyphRun.h
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 1/25/11.
|
||||
// Copyright 2011 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCompatibility.h"
|
||||
|
||||
#if TARGET_OS_IPHONE
|
||||
#import <CoreText/CoreText.h>
|
||||
#elif TARGET_OS_MAC
|
||||
#import <ApplicationServices/ApplicationServices.h>
|
||||
#endif
|
||||
|
||||
@class DTCoreTextLayoutLine;
|
||||
@class DTTextAttachment;
|
||||
|
||||
|
||||
/**
|
||||
This class is an Objective-C wrapper around `CTRun` and represents a glyph run. That is, a number of characters from the original `NSAttributedString` that share the same characteristics and attributes.
|
||||
*/
|
||||
|
||||
@interface DTCoreTextGlyphRun : NSObject
|
||||
{
|
||||
NSRange _stringRange;
|
||||
}
|
||||
|
||||
/**
|
||||
@name Creating Glyph Runs
|
||||
*/
|
||||
|
||||
/**
|
||||
Creates a new glyph run from a `CTRun`, belonging to a given layout line and with a given offset from the left line origin.
|
||||
@param run The Core Text glyph run to wrap
|
||||
@param layoutLine The layout line that this glyph run belongs to
|
||||
@param offset The offset from the left line origin to place the glyph run at
|
||||
@returns An initialized DTCoreTextGlyphRun
|
||||
*/
|
||||
- (id)initWithRun:(CTRunRef)run layoutLine:(DTCoreTextLayoutLine *)layoutLine offset:(CGFloat)offset;
|
||||
|
||||
/**
|
||||
@name Drawing
|
||||
*/
|
||||
|
||||
/**
|
||||
Draws the receiver into the given context with the position that it derives from the layout line it belongs to.
|
||||
@see drawDecorationInContext: for drawing the receiver's decoration
|
||||
@param context The graphics context to draw into
|
||||
*/
|
||||
- (void)drawInContext:(CGContextRef)context;
|
||||
|
||||
/**
|
||||
Draws the receiver's decoration into the given context with the position that it derives from the layout line it belongs to. Decoration is background highlighting, underline and strike-through.
|
||||
@param context The graphics context to draw into
|
||||
*/
|
||||
- (void)drawDecorationInContext:(CGContextRef)context;
|
||||
|
||||
/**
|
||||
Creates a `CGPath` containing the shapes of all glyphs in the receiver
|
||||
*/
|
||||
- (CGPathRef)newPathWithGlyphs;
|
||||
|
||||
/**
|
||||
@name Getting Information
|
||||
*/
|
||||
|
||||
/**
|
||||
Determines the frame of a specific glyph
|
||||
@param index The index of the glyph
|
||||
@return The frame of the glyph
|
||||
*/
|
||||
- (CGRect)frameOfGlyphAtIndex:(NSInteger)index;
|
||||
|
||||
/**
|
||||
The bounds of an image encompassing the entire run.
|
||||
@param context The graphics context used for the measurement
|
||||
@returns The rectangle containing the result
|
||||
*/
|
||||
- (CGRect)imageBoundsInContext:(CGContextRef)context;
|
||||
|
||||
/**
|
||||
The string range (of the attributed string) that is represented by the receiver
|
||||
@returns The range
|
||||
*/
|
||||
- (NSRange)stringRange;
|
||||
|
||||
/**
|
||||
The string indices of the receiver
|
||||
@returns An array of string indices
|
||||
*/
|
||||
- (NSArray *)stringIndices;
|
||||
|
||||
/**
|
||||
The frame rectangle of the glyph run, relative to the layout frame coordinate system
|
||||
*/
|
||||
@property (nonatomic, readonly) CGRect frame;
|
||||
|
||||
/**
|
||||
The number of glyphs that the receiver is made up of
|
||||
*/
|
||||
@property (nonatomic, readonly) NSInteger numberOfGlyphs;
|
||||
|
||||
/**
|
||||
The Core Text attributes that are shared by all glyphs of the receiver
|
||||
*/
|
||||
@property (nonatomic, readonly) NSDictionary *attributes;
|
||||
|
||||
/**
|
||||
Returns `YES` if the receiver is part of a hyperlink, `NO` otherwise
|
||||
*/
|
||||
@property (nonatomic, assign, readonly, getter=isHyperlink) BOOL hyperlink;
|
||||
|
||||
/**
|
||||
Returns `YES` if the receiver represents trailing whitespace in a line.
|
||||
|
||||
This can be used to avoid drawing of background color, strikeout or underline for empty trailing white space glyph runs.
|
||||
*/
|
||||
- (BOOL)isTrailingWhitespace;
|
||||
|
||||
/**
|
||||
The ascent (height above the baseline) of the receiver
|
||||
*/
|
||||
@property (nonatomic, readonly) CGFloat ascent;
|
||||
|
||||
/**
|
||||
The descent (height below the baseline) of the receiver
|
||||
*/
|
||||
@property (nonatomic, readonly) CGFloat descent;
|
||||
|
||||
/**
|
||||
The leading (additional space above the ascent) of the receiver
|
||||
*/
|
||||
@property (nonatomic, readonly) CGFloat leading;
|
||||
|
||||
/**
|
||||
The width of the receiver
|
||||
*/
|
||||
@property (nonatomic, readonly) CGFloat width;
|
||||
|
||||
/**
|
||||
`YES` if the writing direction is Right-to-Left, otherwise `NO`
|
||||
*/
|
||||
@property (nonatomic, readonly) BOOL writingDirectionIsRightToLeft;
|
||||
|
||||
/**
|
||||
The text attachment of the receiver, or `nil` if there is none
|
||||
*/
|
||||
@property (nonatomic, readonly) DTTextAttachment *attachment;
|
||||
|
||||
|
||||
@end
|
||||
@@ -1,541 +0,0 @@
|
||||
//
|
||||
// DTCoreTextGlyphRun.m
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 1/25/11.
|
||||
// Copyright 2011 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCompatibility.h"
|
||||
#import "DTCoreTextGlyphRun.h"
|
||||
#import "DTCoreTextLayoutLine.h"
|
||||
#import "DTTextAttachment.h"
|
||||
#import "DTCoreTextConstants.h"
|
||||
#import "DTCoreTextParagraphStyle.h"
|
||||
#import "DTCoreTextFunctions.h"
|
||||
#import "NSDictionary+DTCoreText.h"
|
||||
#import <DTFoundation/DTWeakSupport.h>
|
||||
|
||||
#import <DTFoundation/DTLog.h>
|
||||
|
||||
@implementation DTCoreTextGlyphRun
|
||||
{
|
||||
CTRunRef _run;
|
||||
CGRect _frame;
|
||||
|
||||
CGFloat _offset; // x distance from line origin
|
||||
CGFloat _ascent;
|
||||
CGFloat _descent;
|
||||
CGFloat _leading;
|
||||
CGFloat _width;
|
||||
|
||||
BOOL _writingDirectionIsRightToLeft;
|
||||
BOOL _isTrailingWhitespace;
|
||||
|
||||
NSInteger _numberOfGlyphs;
|
||||
|
||||
const CGPoint *_glyphPositionPoints;
|
||||
|
||||
DT_WEAK_VARIABLE DTCoreTextLayoutLine *_line; // retain cycle, since these objects are retained by the _line
|
||||
DT_WEAK_VARIABLE NSDictionary *_attributes; // weak because it is owned by _run IVAR
|
||||
NSArray *_stringIndices;
|
||||
|
||||
DTTextAttachment *_attachment;
|
||||
BOOL _hyperlink;
|
||||
|
||||
BOOL _didCheckForAttachmentInAttributes;
|
||||
BOOL _didCheckForHyperlinkInAttributes;
|
||||
BOOL _didCalculateMetrics;
|
||||
BOOL _didDetermineTrailingWhitespace;
|
||||
}
|
||||
|
||||
- (id)initWithRun:(CTRunRef)run layoutLine:(DTCoreTextLayoutLine *)layoutLine offset:(CGFloat)offset
|
||||
{
|
||||
self = [super init];
|
||||
|
||||
if (self)
|
||||
{
|
||||
_run = run;
|
||||
CFRetain(_run);
|
||||
|
||||
_offset = offset;
|
||||
_line = layoutLine;
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
if (_run)
|
||||
{
|
||||
CFRelease(_run);
|
||||
}
|
||||
}
|
||||
|
||||
#ifndef COVERAGE
|
||||
// exclude method from coverage testing
|
||||
|
||||
- (NSString *)description
|
||||
{
|
||||
return [NSString stringWithFormat:@"<%@ glyphs=%ld %@>", [self class], (long)[self numberOfGlyphs], NSStringFromCGRect(_frame)];
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
#pragma mark - Drawing
|
||||
|
||||
- (void)drawInContext:(CGContextRef)context
|
||||
{
|
||||
if (!_run || !context)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CGAffineTransform textMatrix = CTRunGetTextMatrix(_run);
|
||||
|
||||
if (CGAffineTransformIsIdentity(textMatrix))
|
||||
{
|
||||
CTRunDraw(_run, context, CFRangeMake(0, 0));
|
||||
}
|
||||
else
|
||||
{
|
||||
CGPoint pos = CGContextGetTextPosition(context);
|
||||
|
||||
// set tx and ty to current text pos according to docs
|
||||
textMatrix.tx = pos.x;
|
||||
textMatrix.ty = pos.y;
|
||||
|
||||
CGContextSetTextMatrix(context, textMatrix);
|
||||
|
||||
CTRunDraw(_run, context, CFRangeMake(0, 0));
|
||||
|
||||
// restore identity
|
||||
CGContextSetTextMatrix(context, CGAffineTransformIdentity);
|
||||
}
|
||||
}
|
||||
|
||||
- (void)drawDecorationInContext:(CGContextRef)context
|
||||
{
|
||||
// get the scaling factor of the current translation matrix
|
||||
CGAffineTransform ctm = CGContextGetCTM(context);
|
||||
CGFloat contentScale = MAX(ctm.a, -ctm.d); // needed for rounding operations
|
||||
|
||||
if (contentScale<1 || contentScale>2)
|
||||
{
|
||||
contentScale = 2;
|
||||
}
|
||||
|
||||
CGFloat smallestPixelWidth = 1.0f/contentScale;
|
||||
|
||||
DTColor *backgroundColor = [self.attributes backgroundColor];
|
||||
|
||||
// -------------- Line-Out, Underline, Background-Color
|
||||
BOOL drawStrikeOut = [[_attributes objectForKey:DTStrikeOutAttribute] boolValue];
|
||||
BOOL drawUnderline = [[_attributes objectForKey:(id)kCTUnderlineStyleAttributeName] boolValue];
|
||||
|
||||
if (drawStrikeOut||drawUnderline||backgroundColor)
|
||||
{
|
||||
// calculate area covered by non-whitespace
|
||||
CGRect lineFrame = _line.frame;
|
||||
|
||||
// LTR line frames include trailing whitespace in width
|
||||
// we need to subtract it so that we don't highlight/underline it
|
||||
if (!_line.writingDirectionIsRightToLeft)
|
||||
{
|
||||
lineFrame.size.width -= _line.trailingWhitespaceWidth;
|
||||
}
|
||||
|
||||
// exclude trailing whitespace so that we don't underline too much
|
||||
CGRect runStrokeBounds = CGRectIntersection(lineFrame, self.frame);
|
||||
|
||||
NSInteger superscriptStyle = [[_attributes objectForKey:(id)kCTSuperscriptAttributeName] integerValue];
|
||||
|
||||
switch (superscriptStyle)
|
||||
{
|
||||
case 1:
|
||||
{
|
||||
runStrokeBounds.origin.y -= _ascent * 0.47f;
|
||||
break;
|
||||
}
|
||||
case -1:
|
||||
{
|
||||
runStrokeBounds.origin.y += _ascent * 0.25f;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (backgroundColor)
|
||||
{
|
||||
CGRect backgroundColorRect = CGRectIntegral(CGRectMake(runStrokeBounds.origin.x, lineFrame.origin.y, runStrokeBounds.size.width, lineFrame.size.height));
|
||||
|
||||
CGContextSetFillColorWithColor(context, backgroundColor.CGColor);
|
||||
CGContextFillRect(context, backgroundColorRect);
|
||||
}
|
||||
|
||||
if (drawStrikeOut || drawUnderline)
|
||||
{
|
||||
BOOL didDrawSomething = NO;
|
||||
|
||||
CGContextSaveGState(context);
|
||||
|
||||
CTFontRef usedFont = (__bridge CTFontRef)([_attributes objectForKey:(id)kCTFontAttributeName]);
|
||||
|
||||
CGFloat fontUnderlineThickness;
|
||||
|
||||
if (usedFont)
|
||||
{
|
||||
fontUnderlineThickness = CTFontGetUnderlineThickness(usedFont) * smallestPixelWidth;
|
||||
}
|
||||
else
|
||||
{
|
||||
fontUnderlineThickness = smallestPixelWidth;
|
||||
}
|
||||
|
||||
CGFloat usedUnderlineThickness = DTCeilWithContentScale(fontUnderlineThickness, contentScale);
|
||||
|
||||
CGContextSetLineWidth(context, usedUnderlineThickness);
|
||||
|
||||
if (drawStrikeOut)
|
||||
{
|
||||
CGFloat y;
|
||||
|
||||
if (usedFont)
|
||||
{
|
||||
CGFloat strokePosition = CTFontGetXHeight(usedFont)/(CGFloat)2.0;
|
||||
y = DTRoundWithContentScale(runStrokeBounds.origin.y + _ascent - strokePosition, contentScale);
|
||||
}
|
||||
else
|
||||
{
|
||||
y = DTRoundWithContentScale((runStrokeBounds.origin.y + self.frame.size.height/2.0f + 1), contentScale);
|
||||
}
|
||||
|
||||
if ((int)(usedUnderlineThickness/smallestPixelWidth)%2) // odd line width
|
||||
{
|
||||
y += smallestPixelWidth/2.0f; // shift down half a pixel to avoid aliasing
|
||||
}
|
||||
|
||||
CGContextMoveToPoint(context, runStrokeBounds.origin.x, y);
|
||||
CGContextAddLineToPoint(context, runStrokeBounds.origin.x + runStrokeBounds.size.width, y);
|
||||
|
||||
didDrawSomething = YES;
|
||||
}
|
||||
|
||||
// only draw underlines if Core Text didn't draw them yet
|
||||
if (drawUnderline && !DTCoreTextDrawsUnderlinesWithGlyphs())
|
||||
{
|
||||
CGFloat y;
|
||||
|
||||
// use lowest underline position of all glyph runs in same line
|
||||
CGFloat underlinePosition = [_line underlineOffset];
|
||||
|
||||
y = DTRoundWithContentScale(_line.baselineOrigin.y + underlinePosition - fontUnderlineThickness/2.0f, contentScale);
|
||||
|
||||
if ((int)(usedUnderlineThickness/smallestPixelWidth)%2) // odd line width
|
||||
{
|
||||
y += smallestPixelWidth/2.0f; // shift down half a pixel to avoid aliasing
|
||||
}
|
||||
|
||||
CGContextMoveToPoint(context, runStrokeBounds.origin.x, y);
|
||||
CGContextAddLineToPoint(context, runStrokeBounds.origin.x + runStrokeBounds.size.width, y);
|
||||
|
||||
didDrawSomething = YES;
|
||||
}
|
||||
|
||||
if (didDrawSomething)
|
||||
{
|
||||
CGContextStrokePath(context);
|
||||
}
|
||||
|
||||
CGContextRestoreGState(context); // restore antialiasing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (CGPathRef)newPathWithGlyphs
|
||||
{
|
||||
CTFontRef font = (__bridge CTFontRef)[self.attributes objectForKey:(id)kCTFontAttributeName];
|
||||
|
||||
if (!font)
|
||||
{
|
||||
DTLogError(@"CTFont missing on %@", self);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
const CGGlyph *glyphs = CTRunGetGlyphsPtr(_run);
|
||||
const CGPoint *positions = CTRunGetPositionsPtr(_run);
|
||||
|
||||
CGMutablePathRef mutablePath = CGPathCreateMutable();
|
||||
|
||||
for (NSUInteger i = 0; i < CTRunGetGlyphCount(_run); i++)
|
||||
{
|
||||
CGGlyph glyph = glyphs[i];
|
||||
CGPoint position = positions[i];
|
||||
|
||||
CGAffineTransform glyphTransform = CTRunGetTextMatrix(_run);
|
||||
|
||||
glyphTransform = CGAffineTransformScale(glyphTransform, 1, -1);
|
||||
|
||||
|
||||
CGPathRef glyphPath = CTFontCreatePathForGlyph(font, glyph, &glyphTransform);
|
||||
|
||||
CGAffineTransform posTransform = CGAffineTransformMakeTranslation(position.x, position.y);
|
||||
CGPathAddPath(mutablePath, &posTransform, glyphPath);
|
||||
|
||||
CGPathRelease(glyphPath);
|
||||
}
|
||||
|
||||
return mutablePath;
|
||||
}
|
||||
|
||||
#pragma mark - Calculations
|
||||
- (void)calculateMetrics
|
||||
{
|
||||
// calculate metrics
|
||||
@synchronized(self)
|
||||
{
|
||||
if (!_didCalculateMetrics)
|
||||
{
|
||||
_width = (CGFloat)CTRunGetTypographicBounds((CTRunRef)_run, CFRangeMake(0, 0), &_ascent, &_descent, &_leading);
|
||||
_didCalculateMetrics = YES;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (CGRect)frameOfGlyphAtIndex:(NSInteger)index
|
||||
{
|
||||
if (!_didCalculateMetrics)
|
||||
{
|
||||
[self calculateMetrics];
|
||||
}
|
||||
|
||||
if (!_glyphPositionPoints)
|
||||
{
|
||||
// this is a pointer to the points inside the run, thus no retain necessary
|
||||
_glyphPositionPoints = CTRunGetPositionsPtr(_run);
|
||||
}
|
||||
|
||||
if (!_glyphPositionPoints || index >= self.numberOfGlyphs)
|
||||
{
|
||||
return CGRectNull;
|
||||
}
|
||||
|
||||
CGPoint glyphPosition = _glyphPositionPoints[index];
|
||||
|
||||
CGRect rect = CGRectMake(_line.baselineOrigin.x + glyphPosition.x, _line.baselineOrigin.y - _ascent, _offset + _width - glyphPosition.x, _ascent + _descent);
|
||||
if (index < self.numberOfGlyphs-1)
|
||||
{
|
||||
rect.size.width = _glyphPositionPoints[index+1].x - glyphPosition.x;
|
||||
}
|
||||
|
||||
return rect;
|
||||
}
|
||||
|
||||
// TODO: fix indices if the stringRange is modified
|
||||
- (NSArray *)stringIndices
|
||||
{
|
||||
if (!_stringIndices)
|
||||
{
|
||||
const CFIndex *indices = CTRunGetStringIndicesPtr(_run);
|
||||
NSInteger count = self.numberOfGlyphs;
|
||||
NSMutableArray *array = [NSMutableArray arrayWithCapacity:count];
|
||||
NSInteger i;
|
||||
for (i = 0; i < count; i++)
|
||||
{
|
||||
[array addObject:[NSNumber numberWithInteger:indices[i]]];
|
||||
}
|
||||
_stringIndices = array;
|
||||
}
|
||||
return _stringIndices;
|
||||
}
|
||||
|
||||
// bounds of an image encompassing the entire run
|
||||
- (CGRect)imageBoundsInContext:(CGContextRef)context
|
||||
{
|
||||
return CTRunGetImageBounds(_run, context, CFRangeMake(0, 0));
|
||||
}
|
||||
|
||||
// range of the characters from the original string
|
||||
- (NSRange)stringRange
|
||||
{
|
||||
if (!_stringRange.length)
|
||||
{
|
||||
CFRange range = CTRunGetStringRange(_run);
|
||||
|
||||
_stringRange = NSMakeRange(range.location + _line.stringLocationOffset, range.length);
|
||||
}
|
||||
|
||||
return _stringRange;
|
||||
}
|
||||
|
||||
- (void)fixMetricsFromAttachment
|
||||
{
|
||||
if (self.attachment)
|
||||
{
|
||||
if (!_didCalculateMetrics)
|
||||
{
|
||||
[self calculateMetrics];
|
||||
}
|
||||
|
||||
_descent = 0;
|
||||
_ascent = self.attachment.displaySize.height;
|
||||
}
|
||||
}
|
||||
|
||||
- (BOOL)isTrailingWhitespace
|
||||
{
|
||||
if (_didDetermineTrailingWhitespace)
|
||||
{
|
||||
return _isTrailingWhitespace;
|
||||
}
|
||||
|
||||
BOOL isTrailing;
|
||||
|
||||
if (_line.writingDirectionIsRightToLeft)
|
||||
{
|
||||
isTrailing = (self == [[_line glyphRuns] objectAtIndex:0]);
|
||||
}
|
||||
else
|
||||
{
|
||||
isTrailing = (self == [[_line glyphRuns] lastObject]);
|
||||
}
|
||||
|
||||
if (isTrailing)
|
||||
{
|
||||
if (!_didCalculateMetrics)
|
||||
{
|
||||
[self calculateMetrics];
|
||||
}
|
||||
|
||||
// this is trailing whitespace if it matches the lines's trailing whitespace
|
||||
if (_line.trailingWhitespaceWidth >= _width)
|
||||
{
|
||||
_isTrailingWhitespace = YES;
|
||||
}
|
||||
}
|
||||
|
||||
_didDetermineTrailingWhitespace = YES;
|
||||
return _isTrailingWhitespace;
|
||||
}
|
||||
|
||||
#pragma mark Properites
|
||||
- (NSInteger)numberOfGlyphs
|
||||
{
|
||||
if (!_numberOfGlyphs)
|
||||
{
|
||||
_numberOfGlyphs = CTRunGetGlyphCount(_run);
|
||||
}
|
||||
|
||||
return _numberOfGlyphs;
|
||||
}
|
||||
|
||||
- (NSDictionary *)attributes
|
||||
{
|
||||
if (!_attributes)
|
||||
{
|
||||
_attributes = (__bridge NSDictionary *)CTRunGetAttributes(_run);
|
||||
}
|
||||
|
||||
return _attributes;
|
||||
}
|
||||
|
||||
- (DTTextAttachment *)attachment
|
||||
{
|
||||
if (!_attachment)
|
||||
{
|
||||
if (!_didCheckForAttachmentInAttributes)
|
||||
{
|
||||
_attachment = [self.attributes objectForKey:NSAttachmentAttributeName];
|
||||
|
||||
_didCheckForAttachmentInAttributes = YES;
|
||||
}
|
||||
}
|
||||
|
||||
return _attachment;
|
||||
}
|
||||
|
||||
- (BOOL)isHyperlink
|
||||
{
|
||||
if (!_hyperlink)
|
||||
{
|
||||
if (!_didCheckForHyperlinkInAttributes)
|
||||
{
|
||||
_hyperlink = [self.attributes objectForKey:DTLinkAttribute]!=nil;
|
||||
|
||||
_didCheckForHyperlinkInAttributes = YES;
|
||||
}
|
||||
}
|
||||
|
||||
return _hyperlink;
|
||||
}
|
||||
|
||||
- (CGRect)frame
|
||||
{
|
||||
if (!_didCalculateMetrics)
|
||||
{
|
||||
[self calculateMetrics];
|
||||
}
|
||||
|
||||
return CGRectMake(_line.baselineOrigin.x + _offset, _line.baselineOrigin.y - _ascent, _width, _ascent + _descent);
|
||||
}
|
||||
|
||||
- (CGFloat)width
|
||||
{
|
||||
if (!_didCalculateMetrics)
|
||||
{
|
||||
[self calculateMetrics];
|
||||
}
|
||||
|
||||
return _width;
|
||||
}
|
||||
|
||||
- (CGFloat)ascent
|
||||
{
|
||||
if (!_didCalculateMetrics)
|
||||
{
|
||||
[self calculateMetrics];
|
||||
}
|
||||
|
||||
return _ascent;
|
||||
}
|
||||
|
||||
- (CGFloat)descent
|
||||
{
|
||||
if (!_didCalculateMetrics)
|
||||
{
|
||||
[self calculateMetrics];
|
||||
}
|
||||
|
||||
return _descent;
|
||||
}
|
||||
|
||||
- (CGFloat)leading
|
||||
{
|
||||
if (!_didCalculateMetrics)
|
||||
{
|
||||
[self calculateMetrics];
|
||||
}
|
||||
|
||||
return _leading;
|
||||
}
|
||||
|
||||
- (BOOL)writingDirectionIsRightToLeft
|
||||
{
|
||||
CTRunStatus status = CTRunGetStatus(_run);
|
||||
|
||||
return (status & kCTRunStatusRightToLeft)!=0;
|
||||
}
|
||||
|
||||
@synthesize frame = _frame;
|
||||
@synthesize numberOfGlyphs = _numberOfGlyphs;
|
||||
@synthesize attributes = _attributes;
|
||||
|
||||
@synthesize ascent = _ascent;
|
||||
@synthesize descent = _descent;
|
||||
@synthesize leading = _leading;
|
||||
@synthesize attachment = _attachment;
|
||||
@synthesize writingDirectionIsRightToLeft = _writingDirectionIsRightToLeft;
|
||||
|
||||
@end
|
||||
@@ -1,33 +0,0 @@
|
||||
//
|
||||
// DTCoreTextLayoutFrame+Cursor.h
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 10.07.13.
|
||||
// Copyright (c) 2013 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCoreTextLayoutFrame.h"
|
||||
|
||||
/**
|
||||
The **Cursor** category extends DTCoreTextLayoutFrame for working with a caret and determine the string index of touch coordinates.
|
||||
*/
|
||||
|
||||
@interface DTCoreTextLayoutFrame (Cursor)
|
||||
|
||||
/**
|
||||
Determines the closest string index to a point in the receiver's frame.
|
||||
|
||||
This can be used to find the cursor position to position an input caret at.
|
||||
@param point The point
|
||||
@returns The resulting string index
|
||||
*/
|
||||
- (NSInteger)closestCursorIndexToPoint:(CGPoint)point;
|
||||
|
||||
/**
|
||||
The rectangle to draw a caret for a given index
|
||||
@param index The string index for which to determine a cursor frame
|
||||
@returns The cursor rectangle
|
||||
*/
|
||||
- (CGRect)cursorRectAtIndex:(NSInteger)index;
|
||||
|
||||
@end
|
||||
@@ -1,114 +0,0 @@
|
||||
//
|
||||
// DTCoreTextLayoutFrame+Cursor.m
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 10.07.13.
|
||||
// Copyright (c) 2013 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCoreTextLayoutFrame+Cursor.h"
|
||||
#import "DTCoreTextLayoutLine.h"
|
||||
|
||||
@implementation DTCoreTextLayoutFrame (Cursor)
|
||||
|
||||
- (NSInteger)closestCursorIndexToPoint:(CGPoint)point
|
||||
{
|
||||
NSArray *lines = self.lines;
|
||||
|
||||
if (![lines count])
|
||||
{
|
||||
return NSNotFound;
|
||||
}
|
||||
|
||||
DTCoreTextLayoutLine *firstLine = [lines objectAtIndex:0];
|
||||
if (point.y < CGRectGetMinY(firstLine.frame))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
DTCoreTextLayoutLine *lastLine = [lines lastObject];
|
||||
if (point.y > CGRectGetMaxY(lastLine.frame))
|
||||
{
|
||||
NSRange stringRange = [self visibleStringRange];
|
||||
|
||||
if (stringRange.length)
|
||||
{
|
||||
return NSMaxRange([self visibleStringRange])-1;
|
||||
}
|
||||
}
|
||||
|
||||
// find closest line
|
||||
DTCoreTextLayoutLine *closestLine = nil;
|
||||
CGFloat closestDistance = CGFLOAT_MAX;
|
||||
|
||||
for (DTCoreTextLayoutLine *oneLine in lines)
|
||||
{
|
||||
// line contains point
|
||||
if (CGRectGetMinY(oneLine.frame) <= point.y && CGRectGetMaxY(oneLine.frame) >= point.y)
|
||||
{
|
||||
closestLine = oneLine;
|
||||
break;
|
||||
}
|
||||
|
||||
CGFloat top = CGRectGetMinY(oneLine.frame);
|
||||
CGFloat bottom = CGRectGetMaxY(oneLine.frame);
|
||||
|
||||
CGFloat distance = CGFLOAT_MAX;
|
||||
|
||||
if (top > point.y)
|
||||
{
|
||||
distance = top - point.y;
|
||||
}
|
||||
else if (bottom < point.y)
|
||||
{
|
||||
distance = point.y - bottom;
|
||||
}
|
||||
|
||||
if (distance < closestDistance)
|
||||
{
|
||||
closestLine = oneLine;
|
||||
closestDistance = distance;
|
||||
}
|
||||
}
|
||||
|
||||
if (!closestLine)
|
||||
{
|
||||
return NSNotFound;
|
||||
}
|
||||
|
||||
NSInteger closestIndex = [closestLine stringIndexForPosition:point];
|
||||
|
||||
NSInteger maxIndex = NSMaxRange([closestLine stringRange])-1;
|
||||
|
||||
if (closestIndex > maxIndex)
|
||||
{
|
||||
closestIndex = maxIndex;
|
||||
}
|
||||
|
||||
if (closestIndex>=0)
|
||||
{
|
||||
return closestIndex;
|
||||
}
|
||||
|
||||
return NSNotFound;
|
||||
}
|
||||
|
||||
- (CGRect)cursorRectAtIndex:(NSInteger)index
|
||||
{
|
||||
DTCoreTextLayoutLine *line = [self lineContainingIndex:index];
|
||||
|
||||
if (!line)
|
||||
{
|
||||
return CGRectZero;
|
||||
}
|
||||
|
||||
CGFloat offset = [line offsetForStringIndex:index];
|
||||
|
||||
CGRect rect = line.frame;
|
||||
rect.size.width = 3.0;
|
||||
rect.origin.x += offset;
|
||||
|
||||
return rect;
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -1,385 +0,0 @@
|
||||
//
|
||||
// DTCoreTextLayoutFrame.h
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 1/24/11.
|
||||
// Copyright 2011 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCompatibility.h"
|
||||
#import "DTCoreTextConstants.h"
|
||||
|
||||
|
||||
#import <CoreText/CoreText.h>
|
||||
|
||||
#if TARGET_OS_IPHONE && !TARGET_OS_WATCH
|
||||
#import <UIKit/UIKit.h>
|
||||
#endif
|
||||
|
||||
#if TARGET_OS_OSX
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <ApplicationServices/ApplicationServices.h>
|
||||
#endif
|
||||
|
||||
@class DTCoreTextLayoutLine;
|
||||
@class DTTextBlock;
|
||||
|
||||
/**
|
||||
A handler block that is called whenever a text block attributed is encountered during text drawing
|
||||
*/
|
||||
typedef void (^DTCoreTextLayoutFrameTextBlockHandler)(DTTextBlock *textBlock, CGRect frame, CGContextRef context, BOOL *shouldDrawDefaultBackground);
|
||||
|
||||
/**
|
||||
The drawing options for DTCoreTextLayoutFrame
|
||||
*/
|
||||
typedef NS_ENUM(NSUInteger, DTCoreTextLayoutFrameDrawingOptions)
|
||||
{
|
||||
/**
|
||||
The default method for drawing draws links and attachments. Links are drawn non-highlighted
|
||||
*/
|
||||
DTCoreTextLayoutFrameDrawingDefault = 1<<0,
|
||||
|
||||
/**
|
||||
Links are not drawn, e.g. if they are displayed via custom buttons
|
||||
*/
|
||||
DTCoreTextLayoutFrameDrawingOmitLinks = 1<<1,
|
||||
|
||||
/**
|
||||
Text attachments are omitted from drawing, e.g. if they are displayed via custom views
|
||||
*/
|
||||
DTCoreTextLayoutFrameDrawingOmitAttachments = 1<<2,
|
||||
|
||||
/**
|
||||
If links are drawn they are displayed with the highlighted variant
|
||||
*/
|
||||
DTCoreTextLayoutFrameDrawingDrawLinksHighlighted = 1<<3
|
||||
} ;
|
||||
|
||||
|
||||
@class DTCoreTextLayouter;
|
||||
|
||||
/**
|
||||
This class represents a single frame of text and basically wraps CTFrame. It provides an array of text lines that fit in the given rectangle.
|
||||
|
||||
Both styles of layouting are supported: open ended (suitable for scroll views) and limited to a given rectangle. To use the open-ended style specify `CGFLOAT_HEIGHT_UNKNOWN` for the <frame> height when creating a layout frame.
|
||||
|
||||
The array of lines is built lazily the first time it is accessed or - for open-ended frames - when the frame property is being queried.
|
||||
*/
|
||||
@interface DTCoreTextLayoutFrame : NSObject
|
||||
{
|
||||
CGRect _frame;
|
||||
|
||||
NSArray *_lines;
|
||||
NSArray *_paragraphRanges;
|
||||
|
||||
NSArray *_textAttachments;
|
||||
NSAttributedString *_attributedStringFragment;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
@name Creating Layout Frames
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
Creates a Layout Frame with the given frame using the attributed string loaded into the layouter.
|
||||
|
||||
@param frame The rectangle specifying origin and size of available for text. Specify `CGFLOAT_WIDTH_UNKNOWN` to not limit the width. Specify `CGFLOAT_HEIGHT_UNKNOWN` to not limit the height.
|
||||
@param layouter A reference to the layouter for this text box.
|
||||
*/
|
||||
- (id)initWithFrame:(CGRect)frame layouter:(DTCoreTextLayouter *)layouter;
|
||||
|
||||
|
||||
/**
|
||||
Creates a Layout Frame with the given frame using the attributed string loaded into the layouter.
|
||||
|
||||
@param frame The rectangle specifying origin and size of available for text. Specify `CGFLOAT_WIDTH_UNKNOWN` to not limit the width. Specify `CGFLOAT_HEIGHT_UNKNOWN` to not limit the height.
|
||||
@param layouter A reference to the layouter for the receiver. Note: The layouter owns the attributed string.
|
||||
@param range The range within the attributed string to layout into the receiver.
|
||||
*/
|
||||
- (id)initWithFrame:(CGRect)frame layouter:(DTCoreTextLayouter *)layouter range:(NSRange)range;
|
||||
|
||||
|
||||
/**
|
||||
@name Getting Information
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
The string range that is visible i.e. fits into the given rectangle. For open-ended frames this is typically the entire string. For frame-contrained layout frames it is the substring that fits.
|
||||
*/
|
||||
- (NSRange)visibleStringRange;
|
||||
|
||||
|
||||
/**
|
||||
This is a copy of the attributed string owned by the layouter of the receiver.
|
||||
*/
|
||||
- (NSAttributedString *)attributedStringFragment;
|
||||
|
||||
|
||||
/**
|
||||
An array that maps glyphs with string indices.
|
||||
*/
|
||||
- (NSArray *)stringIndices;
|
||||
|
||||
|
||||
/**
|
||||
The frame rectangle for the layout frame.
|
||||
*/
|
||||
@property (nonatomic, assign, readonly) CGRect frame;
|
||||
|
||||
|
||||
/**
|
||||
Calculates the frame that is covered by the text content.
|
||||
|
||||
The result is calculated by enumerating over all lines and creating a union over all their frames. This is different than the frame property since this gets calculated.
|
||||
@returns The area that is covered by the text content.
|
||||
@note The width depends on how many glyphs Core Text was able to fit into a line. A line that gets broken might not have glyphs all the way to the margin. The y origin is always adjusted to be the same as frame since the first line might have some leading. The height is the minimum height that fits all layout lines.
|
||||
*/
|
||||
- (CGRect)intrinsicContentFrame;
|
||||
|
||||
|
||||
/**
|
||||
@name Drawing
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
Draws the receiver into the given graphics context.
|
||||
|
||||
@warning This method is deprecated, use -[DTCoreTextLayoutFrame drawInContext:options:] instead
|
||||
@param context A graphics context to draw into
|
||||
@param drawImages Whether images should be drawn together with the text. If you specify `NO` then space is left blank where images would go and you have to add your own views to display these images.
|
||||
@param drawLinks Whether hyperlinks should be drawn together with the text. If you specify `NO` then space is left blank where links would go and you have to add your own views to display these images.
|
||||
*/
|
||||
- (void)drawInContext:(CGContextRef)context drawImages:(BOOL)drawImages drawLinks:(BOOL)drawLinks __attribute__((deprecated("use -[DTCoreTextLayoutFrame drawInContext:options:] instead")));
|
||||
|
||||
|
||||
/**
|
||||
Draws the receiver into the given graphics context.
|
||||
|
||||
@param context A graphics context to draw into
|
||||
@param options The drawing options. See DTCoreTextLayoutFrameDrawingOptions for available options.
|
||||
*/
|
||||
- (void)drawInContext:(CGContextRef)context options:(DTCoreTextLayoutFrameDrawingOptions)options;
|
||||
|
||||
|
||||
/**
|
||||
Set a custom handler to be executed before text belonging to a text block is drawn. Of type <DTCoreTextLayoutFrameTextBlockHandler>.
|
||||
*/
|
||||
@property (nonatomic, copy) DTCoreTextLayoutFrameTextBlockHandler textBlockHandler;
|
||||
|
||||
|
||||
/**
|
||||
@name Working with Glyphs
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
Retrieves the index of the text line that contains the given glyph index.
|
||||
|
||||
@param index The index of the glyph
|
||||
@returns The index of the line containing this glyph
|
||||
*/
|
||||
- (NSInteger)lineIndexForGlyphIndex:(NSInteger)index;
|
||||
|
||||
|
||||
/**
|
||||
Retrieves the frame of the glyph at the given glyph index.
|
||||
|
||||
@param index The index of the glyph
|
||||
@returns The frame of this glyph
|
||||
*/
|
||||
- (CGRect)frameOfGlyphAtIndex:(NSInteger)index;
|
||||
|
||||
|
||||
/**
|
||||
@name Working with Text Lines
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
The text lines that belong to the receiver.
|
||||
*/
|
||||
@property (nonatomic, strong, readonly) NSArray *lines;
|
||||
|
||||
|
||||
/**
|
||||
The text lines that are visible inside the given rectangle. Also incomplete lines are included.
|
||||
|
||||
@param rect The rectangle
|
||||
@returns An array, sorted from top to bottom, of lines at least partially visible
|
||||
*/
|
||||
- (NSArray *)linesVisibleInRect:(CGRect)rect;
|
||||
|
||||
|
||||
/**
|
||||
The text lines that are visible inside the given rectangle. Only fully visible lines are included.
|
||||
|
||||
@param rect The rectangle
|
||||
@returns An array, sorted from top to bottom, of lines fully visible
|
||||
*/
|
||||
- (NSArray *)linesContainedInRect:(CGRect)rect;
|
||||
|
||||
|
||||
/**
|
||||
The layout line that contains the given string index.
|
||||
|
||||
@param index The string index
|
||||
@returns The layout line that this index belongs to
|
||||
*/
|
||||
- (DTCoreTextLayoutLine *)lineContainingIndex:(NSUInteger)index;
|
||||
|
||||
|
||||
/**
|
||||
Determins if the given line is the first in a paragraph.
|
||||
|
||||
This is needed for example to determine whether paragraphSpaceBefore needs to be applied before it.
|
||||
@param line The Line
|
||||
@returns `YES` if the given line is the first in a paragraph
|
||||
*/
|
||||
- (BOOL)isLineFirstInParagraph:(DTCoreTextLayoutLine *)line;
|
||||
|
||||
|
||||
/**
|
||||
Determins if the given line is the last in a paragraph.
|
||||
|
||||
This is needed for example to determine whether paragraph spacing needs to be applied after it.
|
||||
@param line The Line
|
||||
@returns `YES` if the given line is the last in a paragraph
|
||||
*/
|
||||
- (BOOL)isLineLastInParagraph:(DTCoreTextLayoutLine *)line;
|
||||
|
||||
|
||||
/**
|
||||
Finds the appropriate baseline origin for a line to position it at the correct distance from a previous line.
|
||||
|
||||
Support Layout options are:
|
||||
|
||||
- DTCoreTextLayoutFrameLinePositioningAlgorithmWebKit,
|
||||
- DTCoreTextLayoutFrameLinePositioningAlgorithmLegacy
|
||||
|
||||
@param line The line
|
||||
@param previousLine The line after which to position the line.
|
||||
@param options The layout options to employ for positioning lines
|
||||
@returns The correct baseline origin for the line.
|
||||
*/
|
||||
- (CGPoint)baselineOriginToPositionLine:(DTCoreTextLayoutLine *)line afterLine:(DTCoreTextLayoutLine *)previousLine options:(DTCoreTextLayoutFrameLinePositioningOptions)options;
|
||||
|
||||
/**
|
||||
Finds the appropriate baseline origin for a line to position it at the correct distance from a previous line using the DTCoreTextLayoutFrameLinePositioningOptionAlgorithmLegacy algorithm.
|
||||
|
||||
@warning This method is deprecated, use -[baselineOriginToPositionLine:afterLine:algorithm:] instead
|
||||
@param line The line
|
||||
@param previousLine The line after which to position the line.
|
||||
@returns The correct baseline origin for the line.
|
||||
*/
|
||||
- (CGPoint)baselineOriginToPositionLine:(DTCoreTextLayoutLine *)line afterLine:(DTCoreTextLayoutLine *)previousLine __attribute__((deprecated("use use -[baselineOriginToPositionLine:afterLine:algorithm:] instead")));;
|
||||
|
||||
/**
|
||||
The ratio to decide when to create a justified line
|
||||
*/
|
||||
@property (nonatomic, readwrite) CGFloat justifyRatio;
|
||||
|
||||
/**
|
||||
@name Text Attachments
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
The array of all <DTTextAttachment> instances that belong to the receiver.
|
||||
@returns All text attachments of the receiver.
|
||||
*/
|
||||
- (NSArray *)textAttachments;
|
||||
|
||||
|
||||
/**
|
||||
The array of all DTTextAttachment instances that belong to the receiver which also match the specified predicate.
|
||||
|
||||
@param predicate A predicate that uses properties of <DTTextAttachment> to reduce the returned array
|
||||
@returns A filtered array of text attachments.
|
||||
*/
|
||||
- (NSArray *)textAttachmentsWithPredicate:(NSPredicate *)predicate;
|
||||
|
||||
|
||||
/**
|
||||
@name Getting Paragraph Info
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
Finding which paragraph a given string index belongs to.
|
||||
|
||||
@param stringIndex The index in the string to look for
|
||||
@returns The index of the paragraph, numbered from 0
|
||||
*/
|
||||
- (NSUInteger)paragraphIndexContainingStringIndex:(NSUInteger)stringIndex;
|
||||
|
||||
|
||||
/**
|
||||
Determines the paragraph range (of paragraph indexes) that encompass the entire given string Range.
|
||||
|
||||
@param stringRange The string range for which the paragraph range is sought for
|
||||
@returns The range of paragraphs that fully enclose the string range
|
||||
*/
|
||||
- (NSRange)paragraphRangeContainingStringRange:(NSRange)stringRange;
|
||||
|
||||
|
||||
/**
|
||||
The text lines that belong to the specified paragraph.
|
||||
|
||||
@param index The index of the paragraph
|
||||
@returns An array, sorted from top to bottom, of lines in this paragraph
|
||||
*/
|
||||
- (NSArray *)linesInParagraphAtIndex:(NSUInteger)index;
|
||||
|
||||
|
||||
/**
|
||||
An array of `NSRange` values encapsulated in `NSValue` instances. Each range is the string range contained in the corresponding paragraph.
|
||||
*/
|
||||
@property (nonatomic, strong, readonly) NSArray *paragraphRanges;
|
||||
|
||||
|
||||
/**
|
||||
@name Debugging
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
Switches on the debug drawing mode where individual glyph runs, baselines, et cetera get individually marked.
|
||||
|
||||
@param debugFrames if the debug drawing should occur
|
||||
*/
|
||||
+ (void)setShouldDrawDebugFrames:(BOOL)debugFrames;
|
||||
|
||||
|
||||
/**
|
||||
@returns the current value of the debug frame drawing
|
||||
*/
|
||||
+ (BOOL)shouldDrawDebugFrames;
|
||||
|
||||
/**
|
||||
@name Truncation
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
Maximum number of lines to display before truncation. Default is 0 which indicates no limit.
|
||||
*/
|
||||
@property(nonatomic, assign) NSInteger numberOfLines;
|
||||
|
||||
|
||||
/**
|
||||
Line break mode used to indicate how truncation should occur
|
||||
*/
|
||||
@property(nonatomic, assign) NSLineBreakMode lineBreakMode;
|
||||
|
||||
|
||||
/**
|
||||
Optional attributed string to use as truncation indicator. If nil, will use "…" w/ attributes taken from text being truncated
|
||||
*/
|
||||
@property(nonatomic, strong)NSAttributedString *truncationString;
|
||||
|
||||
|
||||
@end
|
||||
File diff suppressed because it is too large
Load Diff
Generated
-39
@@ -1,39 +0,0 @@
|
||||
//
|
||||
// DTCoreTextLayoutFrameAccessibilityElementGenerator.h
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Austen Green on 3/13/13.
|
||||
// Copyright (c) 2013 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCompatibility.h"
|
||||
|
||||
#if TARGET_OS_IPHONE && !TARGET_OS_WATCH
|
||||
|
||||
#import "DTAccessibilityElement.h"
|
||||
|
||||
@class DTCoreTextLayoutFrame, DTTextAttachment;
|
||||
|
||||
/**
|
||||
A block that provides accessibility information for the passed text attachments
|
||||
*/
|
||||
typedef id(^DTAttachmentViewProvider)(DTTextAttachment *textAttachment);
|
||||
|
||||
/**
|
||||
Generates an array of objects conforming to the UIAccessibility informal protocol based on a <DTCoreTextLayoutFrame>.
|
||||
*/
|
||||
@interface DTCoreTextLayoutFrameAccessibilityElementGenerator : NSObject
|
||||
|
||||
/**
|
||||
The designated initializer. The DTAttachmentViewProvider block may be used to provide custom subviews in place of a static accessibility element.
|
||||
@param frame The <DTCoreTextLayoutFrame> to generate accessibility elements for.
|
||||
@param view The logical superview of the elements - the view that owns the local coordinate system for drawing the frame.
|
||||
@param block A callback block which takes a <DTTextAttachment> object and returns an object that conforms to the UIAccessibility informal protocol.
|
||||
@returns Returns an array of objects conforming to the UIAccessibility informal protocol, suitable for presentation for the VoiceOver system.
|
||||
*/
|
||||
|
||||
- (NSArray *)accessibilityElementsForLayoutFrame:(DTCoreTextLayoutFrame *)frame view:(UIView *)view attachmentViewProvider:(DTAttachmentViewProvider)block;
|
||||
|
||||
@end
|
||||
|
||||
#endif
|
||||
Generated
-124
@@ -1,124 +0,0 @@
|
||||
//
|
||||
// DTCoreTextLayoutFrameAccessibilityElementGenerator.m
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Austen Green on 3/13/13.
|
||||
// Copyright (c) 2013 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCoreTextLayoutFrameAccessibilityElementGenerator.h"
|
||||
|
||||
#if TARGET_OS_IPHONE && !TARGET_OS_WATCH
|
||||
|
||||
#import "DTCoreTextLayoutFrame.h"
|
||||
#import "DTCoreTextLayoutLine.h"
|
||||
#import "DTCoreTextGlyphRun.h"
|
||||
#import "DTAccessibilityElement.h"
|
||||
#import "DTCoreTextConstants.h"
|
||||
#import "DTTextAttachment.h"
|
||||
|
||||
@implementation DTCoreTextLayoutFrameAccessibilityElementGenerator
|
||||
|
||||
- (NSArray *)accessibilityElementsForLayoutFrame:(DTCoreTextLayoutFrame *)frame view:(UIView *)view attachmentViewProvider:(DTAttachmentViewProvider)block
|
||||
{
|
||||
NSMutableArray *elements = [NSMutableArray array];
|
||||
|
||||
for (NSUInteger idx = 0; idx < frame.paragraphRanges.count; idx++)
|
||||
{
|
||||
NSArray *paragraphElements = [self accessibilityElementsInParagraphAtIndex:idx layoutFrame:frame view:view attachmentViewProvider:block];
|
||||
[elements addObjectsFromArray:paragraphElements];
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
- (NSArray *)accessibilityElementsInParagraphAtIndex:(NSUInteger)index layoutFrame:(DTCoreTextLayoutFrame *)frame view:(UIView *)view attachmentViewProvider:(DTAttachmentViewProvider)block
|
||||
{
|
||||
NSMutableArray *elements = [NSMutableArray array];
|
||||
|
||||
[self enumerateAccessibleGroupsInFrame:frame forParagraphAtIndex:index usingBlock:^(NSDictionary *attrs, NSRange substringRange, BOOL *stop, NSArray *runs) {
|
||||
id element = [self accessibilityElementForTextInAttributedString:frame.attributedStringFragment atRange:substringRange attributes:attrs run:runs view:view attachmentViewProvider:block];
|
||||
if (element)
|
||||
[elements addObject:element];
|
||||
}];
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
- (void)enumerateAccessibleGroupsInFrame:(DTCoreTextLayoutFrame *)frame forParagraphAtIndex:(NSUInteger)index usingBlock:(void(^)(NSDictionary *attrs, NSRange substringRange, BOOL *stop, NSArray *runs))block
|
||||
{
|
||||
NSValue *value = [frame.paragraphRanges objectAtIndex:index];
|
||||
NSRange paragraphRange = value.rangeValue;
|
||||
NSArray *lines = [frame linesInParagraphAtIndex:index];
|
||||
|
||||
[frame.attributedStringFragment enumerateAttributesInRange:paragraphRange options:0 usingBlock:^(NSDictionary *attrs, NSRange range, BOOL *stop) {
|
||||
NSMutableArray *runs = [NSMutableArray array];
|
||||
for (DTCoreTextLayoutLine *line in lines)
|
||||
{
|
||||
[runs addObjectsFromArray:[line glyphRunsWithRange:range]];
|
||||
}
|
||||
|
||||
block(attrs, range, stop, runs);
|
||||
}];
|
||||
}
|
||||
|
||||
- (id)accessibilityElementForTextInAttributedString:(NSAttributedString *)attributedString atRange:(NSRange)range attributes:(NSDictionary *)attributes run:(NSArray *)runs view:(UIView *)view attachmentViewProvider:(DTAttachmentViewProvider)block
|
||||
{
|
||||
DTTextAttachment *attachment = [attributes objectForKey:NSAttachmentAttributeName];
|
||||
|
||||
if (attachment != nil)
|
||||
return [self viewForAttachment:attachment attachmentViewProvider:block];
|
||||
else
|
||||
return [self accessibilityElementForTextInAttributedString:attributedString atRange:range attributes:attributes run:runs view:view];
|
||||
}
|
||||
|
||||
- (DTAccessibilityElement *)accessibilityElementForTextInAttributedString:(NSAttributedString *)attributedString atRange:(NSRange)range attributes:(NSDictionary *)attributes run:(NSArray *)runs view:(UIView *)view
|
||||
{
|
||||
NSString *text = [attributedString.string substringWithRange:range];
|
||||
|
||||
DTAccessibilityElement *element = [[DTAccessibilityElement alloc] initWithParentView:view];
|
||||
element.accessibilityLabel = text;
|
||||
element.localCoordinateAccessibilityFrame = [self frameForRuns:runs];
|
||||
|
||||
// We're trying to keep the accessibility frame behavior consistent with web view, which seems to do a union of the rects for all the runs composing a single accessibility group,
|
||||
// even if that spans across multiple lines. Set the local coordinate activation point to support multi-line links. A link that is at the end of one line and
|
||||
// wraps to the beginning of the next would have a rect that's the size of both lines combined. The center of that rect would be outside the hit areas for either of the
|
||||
// runs individually, so we set the accessibility activation point to be the origin of the first run.
|
||||
if (runs.count > 1)
|
||||
{
|
||||
DTCoreTextGlyphRun *run = [runs objectAtIndex:0];
|
||||
element.localCoordinateAccessibilityActivationPoint = run.frame.origin;
|
||||
}
|
||||
|
||||
element.accessibilityTraits = UIAccessibilityTraitStaticText;
|
||||
|
||||
if ([attributes objectForKey:DTLinkAttribute])
|
||||
element.accessibilityTraits |= UIAccessibilityTraitLink;
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
- (UIView *)viewForAttachment:(DTTextAttachment *)attachment attachmentViewProvider:(DTAttachmentViewProvider)block
|
||||
{
|
||||
UIView *view = nil;
|
||||
|
||||
if (block)
|
||||
{
|
||||
view = block(attachment);
|
||||
}
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
- (CGRect)frameForRuns:(NSArray *)runs
|
||||
{
|
||||
CGRect frame = CGRectNull;
|
||||
for (DTCoreTextGlyphRun *run in runs)
|
||||
frame = CGRectUnion(frame, run.frame);
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#endif
|
||||
@@ -1,220 +0,0 @@
|
||||
//
|
||||
// DTCoreTextLayoutLine.h
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 1/24/11.
|
||||
// Copyright 2011 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCompatibility.h"
|
||||
|
||||
#if TARGET_OS_IPHONE
|
||||
#import <CoreText/CoreText.h>
|
||||
#elif TARGET_OS_MAC
|
||||
#import <ApplicationServices/ApplicationServices.h>
|
||||
#endif
|
||||
|
||||
@class DTCoreTextLayoutFrame;
|
||||
@class DTCoreTextParagraphStyle;
|
||||
@class DTTextBlock;
|
||||
|
||||
/**
|
||||
This class represents one layouted line and contains a number of glyph runs.
|
||||
*/
|
||||
@interface DTCoreTextLayoutLine : NSObject
|
||||
{
|
||||
// IVAR required by DTRichTextEditor, used in category
|
||||
NSInteger _stringLocationOffset; // offset to modify internal string location to get actual location
|
||||
}
|
||||
|
||||
/**
|
||||
@name Creating Layout Lines
|
||||
*/
|
||||
|
||||
/**
|
||||
Creates a layout line from a given `CTLine`
|
||||
@param line The Core Text line to wrap
|
||||
@returns A prepared layout line
|
||||
*/
|
||||
- (id)initWithLine:(CTLineRef)line;
|
||||
|
||||
/**
|
||||
Creates a layout line from a given `CTLine`
|
||||
@param line The Core Text line to wrap
|
||||
@param stringLocationOffset Offset to modify internal string location to get actual location
|
||||
@returns A prepared layout line
|
||||
*/
|
||||
|
||||
- (id)initWithLine:(CTLineRef)line stringLocationOffset:(NSInteger)stringLocationOffset;
|
||||
|
||||
/**
|
||||
@name Drawing Layout Lines
|
||||
*/
|
||||
|
||||
/**
|
||||
Draws the receiver in a given graphics context
|
||||
@param context The graphics context to draw into
|
||||
*/
|
||||
- (void)drawInContext:(CGContextRef)context;
|
||||
|
||||
/**
|
||||
Creates a `CGPath` containing the shapes of all glyphs in the line
|
||||
*/
|
||||
- (CGPathRef)newPathWithGlyphs;
|
||||
|
||||
/**
|
||||
@name Getting Information about Layout Lines
|
||||
*/
|
||||
|
||||
/**
|
||||
The range in the original string that is represented by the receiver
|
||||
@returns The string strange
|
||||
*/
|
||||
- (NSRange)stringRange;
|
||||
|
||||
/**
|
||||
The number of glyphs the receiver consists of
|
||||
@returns the number of glyphs
|
||||
*/
|
||||
- (NSInteger)numberOfGlyphs;
|
||||
|
||||
/**
|
||||
Determines the frame of a specific glyph
|
||||
@param index The index of the glyph
|
||||
@return The frame of the glyph
|
||||
*/
|
||||
- (CGRect)frameOfGlyphAtIndex:(NSInteger)index;
|
||||
|
||||
/**
|
||||
Retrieves the glyphRuns with a given range
|
||||
@param range The range
|
||||
@returns An array of glyph runs
|
||||
*/
|
||||
- (NSArray *)glyphRunsWithRange:(NSRange)range;
|
||||
|
||||
/**
|
||||
The frame of a number of glyphs with a given range
|
||||
@param range The range
|
||||
@returns The rectangle containing the result
|
||||
*/
|
||||
- (CGRect)frameOfGlyphsWithRange:(NSRange)range;
|
||||
|
||||
/**
|
||||
The bounds of an image encompassing the entire run.
|
||||
@param context The graphics context used for the measurement
|
||||
@returns The rectangle containing the result
|
||||
*/
|
||||
- (CGRect)imageBoundsInContext:(CGContextRef)context;
|
||||
|
||||
/**
|
||||
The string indices of the receiver
|
||||
@returns An array of string indices
|
||||
*/
|
||||
- (NSArray *)stringIndices;
|
||||
|
||||
/**
|
||||
Determines the graphical offset for a given string index
|
||||
@param index The string index
|
||||
@returns The offset
|
||||
*/
|
||||
- (CGFloat)offsetForStringIndex:(NSInteger)index;
|
||||
|
||||
/**
|
||||
Determines the string index that is closest to a given point
|
||||
@param position The position to determine the string index for
|
||||
@returns The string index
|
||||
*/
|
||||
- (NSInteger)stringIndexForPosition:(CGPoint)position;
|
||||
|
||||
/**
|
||||
The frame of the receiver relative to the layout frame
|
||||
*/
|
||||
@property (nonatomic, assign) CGRect frame;
|
||||
|
||||
/**
|
||||
The glyph runs that the line contains.
|
||||
*/
|
||||
@property (nonatomic, readonly) NSArray *glyphRuns;
|
||||
|
||||
/**
|
||||
The ascent (height above the baseline) of the receiver
|
||||
*/
|
||||
@property (nonatomic, assign) CGFloat ascent; // needs to be modifiable
|
||||
|
||||
/**
|
||||
The descent (height below the baseline) of the receiver
|
||||
*/
|
||||
@property (nonatomic, readonly) CGFloat descent;
|
||||
|
||||
/**
|
||||
The leading (additional space above the ascent) of the receiver
|
||||
*/
|
||||
@property (nonatomic, readonly) CGFloat leading;
|
||||
|
||||
/**
|
||||
The width of the trailing whitespace of the receiver
|
||||
*/
|
||||
@property (nonatomic, readonly) CGFloat trailingWhitespaceWidth;
|
||||
|
||||
/**
|
||||
The offset for the underline in positive points measured from the baseline. This is the maximum underline value of the fonts of all glyph runs of the receiver.
|
||||
*/
|
||||
@property (nonatomic, readonly) CGFloat underlineOffset;
|
||||
|
||||
/**
|
||||
The line height of the line. This is determined by getting the maximum font size of all glyph runs of the receiver.
|
||||
*/
|
||||
@property (nonatomic, readonly) CGFloat lineHeight;
|
||||
|
||||
/**
|
||||
The paragraph style of the paragraph this line belongs to. All lines in a paragraph are supposed to have the same paragraph style, so this takes the paragraph style of the first glyph run
|
||||
*/
|
||||
@property (nonatomic, readonly) DTCoreTextParagraphStyle *paragraphStyle;
|
||||
|
||||
/**
|
||||
The text blocks that the receiver belongs to.
|
||||
*/
|
||||
@property (nonatomic, readonly) NSArray *textBlocks;
|
||||
|
||||
/**
|
||||
The text attachments occurring in glyph runs of the receiver.
|
||||
*/
|
||||
@property (nonatomic, readonly) NSArray *attachments;
|
||||
|
||||
/**
|
||||
The baseline origin of the receiver
|
||||
*/
|
||||
@property (nonatomic, assign) CGPoint baselineOrigin;
|
||||
|
||||
/**
|
||||
`YES` if the writing direction is Right-to-Left, otherwise `NO`
|
||||
*/
|
||||
@property (nonatomic, assign) BOOL writingDirectionIsRightToLeft;
|
||||
|
||||
/**
|
||||
The offset to modify internal string location to get actual location
|
||||
*/
|
||||
|
||||
@property (nonatomic, readonly) NSInteger stringLocationOffset;
|
||||
|
||||
/**
|
||||
Method to efficiently determine if the receiver is a horizontal rule.
|
||||
|
||||
Note: This is used to shortcut drawing of text lines and to allow a horizontal rule line have an "endlessly wide" width so that it gets picked up by [DTCoreTextLayoutFrame linesVisibleInRect:].
|
||||
*/
|
||||
- (BOOL)isHorizontalRule;
|
||||
|
||||
|
||||
/**
|
||||
@name Creating Variants
|
||||
*/
|
||||
|
||||
/**
|
||||
Creates a version of the receiver that is justified to the given width.
|
||||
|
||||
@param justificationFactor Full or partial justification. When set to `1.0` or greater, full justification is performed. If this parameter is set to less than `1.0`, varying degrees of partial justification are performed. If it is set to `0` or less, no justification is performed.
|
||||
@param justificationWidth The width to which the resultant line is justified. If justificationWidth is less than the actual width of the line, then negative justification is performed (that is, glyphs are squeezed together).
|
||||
*/
|
||||
- (DTCoreTextLayoutLine *)justifiedLineWithFactor:(CGFloat)justificationFactor justificationWidth:(CGFloat)justificationWidth;
|
||||
|
||||
@end
|
||||
@@ -1,571 +0,0 @@
|
||||
//
|
||||
// DTCoreTextLayoutLine.m
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 1/24/11.
|
||||
// Copyright 2011 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCompatibility.h"
|
||||
#import "DTCoreTextLayoutLine.h"
|
||||
#import "DTCoreTextGlyphRun.h"
|
||||
#import "DTCoreTextLayoutFrame.h"
|
||||
#import "DTCoreTextLayouter.h"
|
||||
#import "DTTextAttachment.h"
|
||||
#import "NSDictionary+DTCoreText.h"
|
||||
#import "DTTextBlock.h"
|
||||
#import "DTCoreTextConstants.h"
|
||||
#import "DTCoreTextFunctions.h"
|
||||
|
||||
@interface DTCoreTextLayoutLine ()
|
||||
|
||||
@property (nonatomic, strong) NSArray *glyphRuns;
|
||||
|
||||
@end
|
||||
|
||||
@implementation DTCoreTextLayoutLine
|
||||
{
|
||||
CGRect _frame;
|
||||
CTLineRef _line;
|
||||
|
||||
CGPoint _baselineOrigin;
|
||||
|
||||
CGFloat _ascent;
|
||||
CGFloat _descent;
|
||||
CGFloat _leading;
|
||||
CGFloat _width;
|
||||
CGFloat _trailingWhitespaceWidth;
|
||||
|
||||
CGFloat _underlineOffset;
|
||||
CGFloat _lineHeight;
|
||||
|
||||
NSArray *_glyphRuns;
|
||||
|
||||
BOOL _didCalculateMetrics;
|
||||
|
||||
BOOL _writingDirectionIsRightToLeft;
|
||||
BOOL _needsToDetectWritingDirection;
|
||||
|
||||
BOOL _hasScannedGlyphRunsForValues;
|
||||
}
|
||||
|
||||
- (id)initWithLine:(CTLineRef)line
|
||||
{
|
||||
return [self initWithLine:line stringLocationOffset:0];
|
||||
}
|
||||
|
||||
- (id)initWithLine:(CTLineRef)line stringLocationOffset:(NSInteger)stringLocationOffset
|
||||
{
|
||||
if (!line)
|
||||
{
|
||||
return nil;
|
||||
}
|
||||
|
||||
if ((self = [super init]))
|
||||
{
|
||||
_line = line;
|
||||
CFRetain(_line);
|
||||
|
||||
// writing direction
|
||||
_needsToDetectWritingDirection = YES;
|
||||
|
||||
_stringLocationOffset = stringLocationOffset;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
CFRelease(_line);
|
||||
}
|
||||
|
||||
#ifndef COVERAGE
|
||||
// exclude method from coverage testing
|
||||
|
||||
- (NSString *)description
|
||||
{
|
||||
return [NSString stringWithFormat:@"<%@ origin=%@ frame=%@ range=%@", [self class], NSStringFromCGPoint(_baselineOrigin), NSStringFromCGRect(self.frame), NSStringFromRange([self stringRange])];
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
- (NSRange)stringRange
|
||||
{
|
||||
CFRange range = CTLineGetStringRange(_line);
|
||||
|
||||
// add offset if there is one, i.e. from merged lines
|
||||
range.location += _stringLocationOffset;
|
||||
|
||||
return NSMakeRange(range.location, range.length);
|
||||
}
|
||||
|
||||
- (NSInteger)numberOfGlyphs
|
||||
{
|
||||
NSInteger ret = 0;
|
||||
for (DTCoreTextGlyphRun *oneRun in self.glyphRuns)
|
||||
{
|
||||
ret += [oneRun numberOfGlyphs];
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
#pragma mark - Drawing
|
||||
|
||||
- (void)drawInContext:(CGContextRef)context
|
||||
{
|
||||
CTLineDraw(_line, context);
|
||||
}
|
||||
|
||||
- (CGPathRef)newPathWithGlyphs
|
||||
{
|
||||
// mutable path for the line
|
||||
CGMutablePathRef mutablePath = CGPathCreateMutable();
|
||||
|
||||
for (DTCoreTextGlyphRun *oneRun in self.glyphRuns)
|
||||
{
|
||||
CGPathRef glyphPath = [oneRun newPathWithGlyphs];
|
||||
|
||||
CGAffineTransform posTransform = CGAffineTransformMakeTranslation(_baselineOrigin.x, _baselineOrigin.y);
|
||||
CGPathAddPath(mutablePath, &posTransform, glyphPath);
|
||||
|
||||
CGPathRelease(glyphPath);
|
||||
}
|
||||
|
||||
return mutablePath;
|
||||
}
|
||||
|
||||
#pragma mark - Creating Variants
|
||||
|
||||
- (DTCoreTextLayoutLine *)justifiedLineWithFactor:(CGFloat)justificationFactor justificationWidth:(CGFloat)justificationWidth
|
||||
{
|
||||
// make this line justified
|
||||
CTLineRef justifiedLine = CTLineCreateJustifiedLine(_line, justificationFactor, justificationWidth);
|
||||
|
||||
DTCoreTextLayoutLine *newLine = [[DTCoreTextLayoutLine alloc] initWithLine:justifiedLine];
|
||||
|
||||
CFRelease(justifiedLine);
|
||||
|
||||
return newLine;
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Calculations
|
||||
- (NSArray *)stringIndices
|
||||
{
|
||||
NSMutableArray *array = [NSMutableArray array];
|
||||
for (DTCoreTextGlyphRun *oneRun in self.glyphRuns)
|
||||
{
|
||||
[array addObjectsFromArray:[oneRun stringIndices]];
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
- (CGRect)frameOfGlyphAtIndex:(NSInteger)index
|
||||
{
|
||||
for (DTCoreTextGlyphRun *oneRun in self.glyphRuns)
|
||||
{
|
||||
NSInteger count = [oneRun numberOfGlyphs];
|
||||
if (index >= count)
|
||||
{
|
||||
index -= count;
|
||||
}
|
||||
else
|
||||
{
|
||||
return [oneRun frameOfGlyphAtIndex:index];
|
||||
}
|
||||
}
|
||||
|
||||
return CGRectZero;
|
||||
}
|
||||
|
||||
- (NSArray *)glyphRunsWithRange:(NSRange)range
|
||||
{
|
||||
NSMutableArray *tmpArray = [NSMutableArray arrayWithCapacity:[self numberOfGlyphs]];
|
||||
|
||||
for (DTCoreTextGlyphRun *oneRun in self.glyphRuns)
|
||||
{
|
||||
NSRange runRange = [oneRun stringRange];
|
||||
|
||||
// intersect these ranges
|
||||
NSRange intersectionRange = NSIntersectionRange(range, runRange);
|
||||
|
||||
// if intersection is longer than zero length they intersect
|
||||
if (intersectionRange.length)
|
||||
{
|
||||
[tmpArray addObject:oneRun];
|
||||
}
|
||||
}
|
||||
|
||||
return tmpArray;
|
||||
}
|
||||
|
||||
- (CGRect)frameOfGlyphsWithRange:(NSRange)range
|
||||
{
|
||||
NSArray *glyphRuns = [self glyphRunsWithRange:range];
|
||||
|
||||
CGRect tmpRect = CGRectMake(CGFLOAT_MAX, CGFLOAT_MAX, 0, 0);
|
||||
|
||||
for (DTCoreTextGlyphRun *oneRun in glyphRuns)
|
||||
{
|
||||
CGRect glyphFrame = oneRun.frame;
|
||||
|
||||
if (glyphFrame.origin.x < tmpRect.origin.x)
|
||||
{
|
||||
tmpRect.origin.x = glyphFrame.origin.x;
|
||||
}
|
||||
|
||||
if (glyphFrame.origin.y < tmpRect.origin.y)
|
||||
{
|
||||
tmpRect.origin.y = glyphFrame.origin.y;
|
||||
}
|
||||
|
||||
if (glyphFrame.size.height > tmpRect.size.height)
|
||||
{
|
||||
tmpRect.size.height = glyphFrame.size.height;
|
||||
}
|
||||
|
||||
tmpRect.size.width = glyphFrame.origin.x + glyphFrame.size.width - tmpRect.origin.x;
|
||||
}
|
||||
|
||||
CGFloat maxX = CGRectGetMaxX(self.frame) - _trailingWhitespaceWidth;
|
||||
if (CGRectGetMaxX(tmpRect) > maxX)
|
||||
{
|
||||
tmpRect.size.width = maxX - tmpRect.origin.x;
|
||||
}
|
||||
|
||||
return tmpRect;
|
||||
}
|
||||
|
||||
// bounds of an image encompassing the entire run
|
||||
- (CGRect)imageBoundsInContext:(CGContextRef)context
|
||||
{
|
||||
return CTLineGetImageBounds(_line, context);
|
||||
}
|
||||
|
||||
- (CGFloat)offsetForStringIndex:(NSInteger)index
|
||||
{
|
||||
// subtract offset if there is one, i.e. from merged lines
|
||||
index -= _stringLocationOffset;
|
||||
|
||||
return CTLineGetOffsetForStringIndex(_line, index, NULL);
|
||||
}
|
||||
|
||||
- (NSInteger)stringIndexForPosition:(CGPoint)position
|
||||
{
|
||||
// position is in same coordinate system as frame
|
||||
CGPoint adjustedPosition = position;
|
||||
CGRect frame = self.frame;
|
||||
adjustedPosition.x -= frame.origin.x;
|
||||
adjustedPosition.y -= frame.origin.y;
|
||||
|
||||
NSInteger index = CTLineGetStringIndexForPosition(_line, adjustedPosition);
|
||||
|
||||
// add offset if there is one, i.e. from merged lines
|
||||
index += _stringLocationOffset;
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
- (void)_calculateMetrics
|
||||
{
|
||||
@synchronized(self)
|
||||
{
|
||||
if (!_didCalculateMetrics)
|
||||
{
|
||||
_width = (CGFloat)CTLineGetTypographicBounds(_line, &_ascent, &_descent, &_leading);
|
||||
_trailingWhitespaceWidth = (CGFloat)CTLineGetTrailingWhitespaceWidth(_line);
|
||||
|
||||
_didCalculateMetrics = YES;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (BOOL)isHorizontalRule
|
||||
{
|
||||
// HR is only a single \n
|
||||
|
||||
if (self.stringRange.length>1)
|
||||
{
|
||||
return NO;
|
||||
}
|
||||
|
||||
NSArray *runs = self.glyphRuns;
|
||||
|
||||
// thus only a single glyphRun
|
||||
|
||||
if ([runs count]>1)
|
||||
{
|
||||
return NO;
|
||||
}
|
||||
|
||||
DTCoreTextGlyphRun *singleRun = [runs lastObject];
|
||||
|
||||
if ([singleRun.attributes objectForKey:DTHorizontalRuleStyleAttribute])
|
||||
{
|
||||
return YES;
|
||||
}
|
||||
|
||||
return NO;
|
||||
}
|
||||
|
||||
#pragma mark Determining Values from the glyph runs
|
||||
|
||||
- (void)_scanGlyphRunsForValues
|
||||
{
|
||||
@synchronized(self)
|
||||
{
|
||||
CGFloat maxOffset = 0;
|
||||
CGFloat maxFontSize = 0;
|
||||
|
||||
for (DTCoreTextGlyphRun *oneRun in self.glyphRuns)
|
||||
{
|
||||
CTFontRef usedFont = (__bridge CTFontRef)([oneRun.attributes objectForKey:(id)kCTFontAttributeName]);
|
||||
|
||||
if (usedFont)
|
||||
{
|
||||
maxOffset = MAX(maxOffset, fabs(CTFontGetUnderlinePosition(usedFont)));
|
||||
|
||||
maxFontSize = MAX(maxFontSize, CTFontGetSize(usedFont));
|
||||
}
|
||||
}
|
||||
|
||||
_underlineOffset = maxOffset;
|
||||
_lineHeight = maxFontSize;
|
||||
|
||||
_hasScannedGlyphRunsForValues= YES;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Properties
|
||||
- (NSArray *)glyphRuns
|
||||
{
|
||||
@synchronized(self)
|
||||
{
|
||||
if (!_glyphRuns)
|
||||
{
|
||||
// run array is owned by line
|
||||
CFArrayRef runs = CTLineGetGlyphRuns(_line);
|
||||
CFIndex runCount = CFArrayGetCount(runs);
|
||||
|
||||
if (runCount)
|
||||
{
|
||||
NSMutableArray *tmpArray = [[NSMutableArray alloc] initWithCapacity:runCount];
|
||||
|
||||
for (CFIndex i=0; i<runCount; i++)
|
||||
{
|
||||
CTRunRef oneRun = CFArrayGetValueAtIndex(runs, i);
|
||||
|
||||
CGPoint *positions = (CGPoint*)CTRunGetPositionsPtr(oneRun);
|
||||
|
||||
BOOL shouldFreePositions = NO;
|
||||
|
||||
if (positions == NULL) // Ptr gave NULL, we'll need to copy positions array and later free it
|
||||
{
|
||||
CFIndex glyphCount = CTRunGetGlyphCount(oneRun);
|
||||
|
||||
shouldFreePositions = YES;
|
||||
|
||||
size_t positionsBufferSize = sizeof(CGPoint) * glyphCount;
|
||||
CGPoint *positionsBuffer = malloc(positionsBufferSize);
|
||||
CTRunGetPositions(oneRun, CFRangeMake(0, 0), positionsBuffer);
|
||||
positions = positionsBuffer;
|
||||
}
|
||||
|
||||
// assumption: position of first glyph is also the correct offset of the entire run
|
||||
CGPoint position = positions[0];
|
||||
|
||||
DTCoreTextGlyphRun *glyphRun = [[DTCoreTextGlyphRun alloc] initWithRun:oneRun layoutLine:self offset:position.x];
|
||||
[tmpArray addObject:glyphRun];
|
||||
|
||||
if ( shouldFreePositions )
|
||||
{
|
||||
free(positions);
|
||||
}
|
||||
}
|
||||
|
||||
_glyphRuns = tmpArray;
|
||||
}
|
||||
}
|
||||
|
||||
return _glyphRuns;
|
||||
}
|
||||
}
|
||||
|
||||
- (CGRect)frame
|
||||
{
|
||||
if (!_didCalculateMetrics)
|
||||
{
|
||||
[self _calculateMetrics];
|
||||
}
|
||||
|
||||
CGRect frame = CGRectMake(_baselineOrigin.x, _baselineOrigin.y - _ascent, _width, _ascent + _descent);
|
||||
|
||||
// make sure that HR are extremely wide to be be picked up
|
||||
if ([self isHorizontalRule])
|
||||
{
|
||||
frame.size.width = CGFLOAT_MAX;
|
||||
}
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
- (CGFloat)width
|
||||
{
|
||||
if (!_didCalculateMetrics)
|
||||
{
|
||||
[self _calculateMetrics];
|
||||
}
|
||||
|
||||
return _width;
|
||||
}
|
||||
|
||||
- (NSArray *)attachments
|
||||
{
|
||||
NSMutableArray *tmpArray = [NSMutableArray array];
|
||||
|
||||
for (DTCoreTextGlyphRun *oneRun in self.glyphRuns)
|
||||
{
|
||||
DTTextAttachment *attachment = oneRun.attachment;
|
||||
|
||||
if (attachment)
|
||||
{
|
||||
[tmpArray addObject:attachment];
|
||||
}
|
||||
}
|
||||
|
||||
if ([tmpArray count])
|
||||
{
|
||||
return tmpArray;
|
||||
}
|
||||
|
||||
return nil;
|
||||
}
|
||||
|
||||
|
||||
- (CGFloat)ascent
|
||||
{
|
||||
if (!_didCalculateMetrics)
|
||||
{
|
||||
[self _calculateMetrics];
|
||||
}
|
||||
|
||||
return _ascent;
|
||||
}
|
||||
|
||||
- (void)setAscent:(CGFloat)ascent
|
||||
{
|
||||
// need to get metrics because otherwise ascent gets overwritten
|
||||
if (!_didCalculateMetrics)
|
||||
{
|
||||
[self _calculateMetrics];
|
||||
}
|
||||
|
||||
_ascent = ascent;
|
||||
}
|
||||
|
||||
|
||||
- (CGFloat)descent
|
||||
{
|
||||
if (!_didCalculateMetrics)
|
||||
{
|
||||
[self _calculateMetrics];
|
||||
}
|
||||
|
||||
return _descent;
|
||||
}
|
||||
|
||||
- (CGFloat)leading
|
||||
{
|
||||
if (!_didCalculateMetrics)
|
||||
{
|
||||
[self _calculateMetrics];
|
||||
}
|
||||
|
||||
return _leading;
|
||||
}
|
||||
|
||||
- (CGFloat)underlineOffset
|
||||
{
|
||||
if (!_hasScannedGlyphRunsForValues)
|
||||
{
|
||||
[self _scanGlyphRunsForValues];
|
||||
}
|
||||
|
||||
return _underlineOffset;
|
||||
}
|
||||
|
||||
- (CGFloat)lineHeight
|
||||
{
|
||||
if (!_hasScannedGlyphRunsForValues)
|
||||
{
|
||||
[self _scanGlyphRunsForValues];
|
||||
}
|
||||
|
||||
return _lineHeight;
|
||||
}
|
||||
|
||||
- (DTCoreTextParagraphStyle *)paragraphStyle
|
||||
{
|
||||
// get paragraph style from any glyph
|
||||
DTCoreTextGlyphRun *lastRun = [self.glyphRuns lastObject];
|
||||
NSDictionary *attributes = lastRun.attributes;
|
||||
|
||||
return [attributes paragraphStyle];
|
||||
}
|
||||
|
||||
- (NSArray *)textBlocks
|
||||
{
|
||||
// get text blocks from any glyph
|
||||
DTCoreTextGlyphRun *lastRun = [self.glyphRuns lastObject];
|
||||
NSDictionary *attributes = lastRun.attributes;
|
||||
|
||||
return [attributes objectForKey:DTTextBlocksAttribute];
|
||||
}
|
||||
|
||||
- (CGFloat)trailingWhitespaceWidth
|
||||
{
|
||||
if (!_didCalculateMetrics)
|
||||
{
|
||||
[self _calculateMetrics];
|
||||
}
|
||||
|
||||
return _trailingWhitespaceWidth;
|
||||
}
|
||||
|
||||
- (BOOL)writingDirectionIsRightToLeft
|
||||
{
|
||||
if (_needsToDetectWritingDirection)
|
||||
{
|
||||
if ([self.glyphRuns count])
|
||||
{
|
||||
DTCoreTextGlyphRun *firstRun = [self.glyphRuns objectAtIndex:0];
|
||||
|
||||
_writingDirectionIsRightToLeft = [firstRun writingDirectionIsRightToLeft];
|
||||
}
|
||||
}
|
||||
|
||||
return _writingDirectionIsRightToLeft;
|
||||
}
|
||||
|
||||
- (void)setWritingDirectionIsRightToLeft:(BOOL)writingDirectionIsRightToLeft
|
||||
{
|
||||
_writingDirectionIsRightToLeft = writingDirectionIsRightToLeft;
|
||||
_needsToDetectWritingDirection = NO;
|
||||
}
|
||||
|
||||
@synthesize frame =_frame;
|
||||
@synthesize glyphRuns = _glyphRuns;
|
||||
|
||||
@synthesize ascent = _ascent;
|
||||
@synthesize descent = _descent;
|
||||
@synthesize leading = _leading;
|
||||
@synthesize trailingWhitespaceWidth = _trailingWhitespaceWidth;
|
||||
|
||||
@synthesize baselineOrigin = _baselineOrigin;
|
||||
@synthesize writingDirectionIsRightToLeft = _writingDirectionIsRightToLeft;
|
||||
|
||||
@synthesize stringLocationOffset = _stringLocationOffset;
|
||||
|
||||
@end
|
||||
@@ -1,69 +0,0 @@
|
||||
//
|
||||
// DTCoreTextLayouter.h
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 1/24/11.
|
||||
// Copyright 2011 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCompatibility.h"
|
||||
|
||||
#if TARGET_OS_IPHONE
|
||||
#import <CoreText/CoreText.h>
|
||||
#elif TARGET_OS_MAC
|
||||
#import <ApplicationServices/ApplicationServices.h>
|
||||
#endif
|
||||
|
||||
#import "DTCoreTextLayoutFrame.h"
|
||||
#import "DTCoreTextLayoutLine.h"
|
||||
#import "DTCoreTextGlyphRun.h"
|
||||
|
||||
/**
|
||||
This class owns an attributed string and is able to create layoutFrames for certain ranges in this string. Optionally it caches these layout frames.
|
||||
*/
|
||||
@interface DTCoreTextLayouter : NSObject
|
||||
|
||||
/**
|
||||
@name Creating a Layouter
|
||||
*/
|
||||
|
||||
/**
|
||||
Designated Initializer. Creates a new Layouter with an attributed string
|
||||
@param attributedString The `NSAttributedString` to layout for
|
||||
@returns An initialized layouter
|
||||
*/
|
||||
- (id)initWithAttributedString:(NSAttributedString *)attributedString;
|
||||
|
||||
|
||||
/**
|
||||
@name Creating Layout Frames
|
||||
*/
|
||||
|
||||
/**
|
||||
Creates a layout frame with a given rectangle and string range. The layouter fills the layout frame with as many lines as fit. You can query [DTCoreTextLayoutFrame visibleStringRange] for the range the fits and create another layout frame that continues the text from there to create multiple pages, for example for an e-book.
|
||||
@param frame The rectangle to fill with text
|
||||
@param range The string range to fill, pass {0,0} for the entire string (as much as fits)
|
||||
*/
|
||||
- (DTCoreTextLayoutFrame *)layoutFrameWithRect:(CGRect)frame range:(NSRange)range;
|
||||
|
||||
/**
|
||||
If set to `YES` then the receiver will cache layout frames generated with layoutFrameWithRect:range: for a given rect
|
||||
*/
|
||||
@property (nonatomic, assign) BOOL shouldCacheLayoutFrames;
|
||||
|
||||
|
||||
/**
|
||||
@name Getting Information
|
||||
*/
|
||||
|
||||
/**
|
||||
The attributed string that the layouter currently owns
|
||||
*/
|
||||
@property (nonatomic, strong) NSAttributedString *attributedString;
|
||||
|
||||
/**
|
||||
The internal framesetter of the receiver
|
||||
*/
|
||||
@property (nonatomic, readonly) CTFramesetterRef framesetter;
|
||||
|
||||
@end
|
||||
@@ -1,153 +0,0 @@
|
||||
//
|
||||
// DTCoreTextLayouter.m
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Oliver Drobnik on 1/24/11.
|
||||
// Copyright 2011 Drobnik.com. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCompatibility.h"
|
||||
#import "DTCoreTextLayouter.h"
|
||||
|
||||
@interface DTCoreTextLayouter ()
|
||||
|
||||
@property (nonatomic, strong) NSMutableArray *frames;
|
||||
|
||||
- (void)_discardFramesetter;
|
||||
|
||||
@end
|
||||
|
||||
|
||||
@implementation DTCoreTextLayouter
|
||||
{
|
||||
CTFramesetterRef _framesetter;
|
||||
NSAttributedString *_attributedString;
|
||||
BOOL _shouldCacheLayoutFrames;
|
||||
NSCache *_layoutFrameCache;
|
||||
}
|
||||
|
||||
- (id)initWithAttributedString:(NSAttributedString *)attributedString
|
||||
{
|
||||
if ((self = [super init]))
|
||||
{
|
||||
if (!attributedString)
|
||||
{
|
||||
return nil;
|
||||
}
|
||||
|
||||
self.attributedString = attributedString;
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
[self _discardFramesetter];
|
||||
}
|
||||
|
||||
- (DTCoreTextLayoutFrame *)layoutFrameWithRect:(CGRect)frame range:(NSRange)range
|
||||
{
|
||||
DTCoreTextLayoutFrame *newFrame = nil;
|
||||
NSString *cacheKey = nil;
|
||||
|
||||
// need to have a non zero
|
||||
if (!(frame.size.width > 0 && frame.size.height > 0))
|
||||
{
|
||||
return nil;
|
||||
}
|
||||
|
||||
if (_shouldCacheLayoutFrames)
|
||||
{
|
||||
cacheKey = [NSString stringWithFormat:@"%lud-%@-%@", (unsigned long)[_attributedString hash], NSStringFromCGRect(frame), NSStringFromRange(range)];
|
||||
|
||||
DTCoreTextLayoutFrame *cachedLayoutFrame = [_layoutFrameCache objectForKey:cacheKey];
|
||||
|
||||
if (cachedLayoutFrame)
|
||||
{
|
||||
return cachedLayoutFrame;
|
||||
}
|
||||
}
|
||||
|
||||
@autoreleasepool
|
||||
{
|
||||
newFrame = [[DTCoreTextLayoutFrame alloc] initWithFrame:frame layouter:self range:range];
|
||||
};
|
||||
|
||||
if (newFrame && _shouldCacheLayoutFrames)
|
||||
{
|
||||
[_layoutFrameCache setObject:newFrame forKey:cacheKey];
|
||||
}
|
||||
|
||||
return newFrame;
|
||||
}
|
||||
|
||||
- (void)_discardFramesetter
|
||||
{
|
||||
// framesetter needs to go
|
||||
if (_framesetter)
|
||||
{
|
||||
CFRelease(_framesetter);
|
||||
_framesetter = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark Properties
|
||||
|
||||
- (CTFramesetterRef)framesetter
|
||||
{
|
||||
@synchronized(self)
|
||||
{
|
||||
if (!_framesetter)
|
||||
{
|
||||
_framesetter = CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef)self.attributedString);
|
||||
}
|
||||
|
||||
|
||||
return _framesetter;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)setAttributedString:(NSAttributedString *)attributedString
|
||||
{
|
||||
@synchronized(self)
|
||||
{
|
||||
if (_attributedString != attributedString)
|
||||
{
|
||||
_attributedString = attributedString;
|
||||
|
||||
[self _discardFramesetter];
|
||||
|
||||
// clear the cache
|
||||
[_layoutFrameCache removeAllObjects];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (NSAttributedString *)attributedString
|
||||
{
|
||||
return _attributedString;
|
||||
}
|
||||
|
||||
- (void)setShouldCacheLayoutFrames:(BOOL)shouldCacheLayoutFrames
|
||||
{
|
||||
if (_shouldCacheLayoutFrames != shouldCacheLayoutFrames)
|
||||
{
|
||||
_shouldCacheLayoutFrames = shouldCacheLayoutFrames;
|
||||
|
||||
if (shouldCacheLayoutFrames)
|
||||
{
|
||||
_layoutFrameCache = [[NSCache alloc] init];
|
||||
}
|
||||
else
|
||||
{
|
||||
_layoutFrameCache = nil;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@synthesize attributedString = _attributedString;
|
||||
@synthesize framesetter = _framesetter;
|
||||
@synthesize shouldCacheLayoutFrames = _shouldCacheLayoutFrames;
|
||||
|
||||
@end
|
||||
@@ -1,17 +0,0 @@
|
||||
//
|
||||
// DTCoreTextMacros.h
|
||||
// DTCoreText
|
||||
//
|
||||
// Created by Jean-Charles BERTIN on 5/28/14.
|
||||
// Copyright (c) 2014 Axinoe. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DTCompatibility.h"
|
||||
|
||||
#ifndef DT_RETURNS_INNER_POINTER
|
||||
#if __has_attribute(objc_returns_inner_pointer)
|
||||
#define DT_RETURNS_INNER_POINTER __attribute__((objc_returns_inner_pointer))
|
||||
#else
|
||||
#define DT_RETURNS_INNER_POINTER
|
||||
#endif
|
||||
#endif
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user