Epub阅读器0.0.1
This commit is contained in:
commit
daa36d8fe7
286
.claude/skills/discuss-feature-solution.md
Normal file
286
.claude/skills/discuss-feature-solution.md
Normal file
@ -0,0 +1,286 @@
|
||||
---
|
||||
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)是方案讨论的核心参照框架,任何方案都必须明确标注落点层级
|
||||
213
.claude/skills/start-sdk-feature-dev-lite.md
Normal file
213
.claude/skills/start-sdk-feature-dev-lite.md
Normal file
@ -0,0 +1,213 @@
|
||||
---
|
||||
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 四层架构是执行的核心参照框架,单层改动也必须明确标注落点层级
|
||||
309
.claude/skills/start-sdk-feature-dev.md
Normal file
309
.claude/skills/start-sdk-feature-dev.md
Normal file
@ -0,0 +1,309 @@
|
||||
---
|
||||
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)是执行的核心参照框架,所有改动必须明确标注落点层级和依赖方向
|
||||
415
Doc/ARCHITECTURE.md
Normal file
415
Doc/ARCHITECTURE.md
Normal file
@ -0,0 +1,415 @@
|
||||
# RDReaderView 架构文档
|
||||
|
||||
## 1. 项目概览
|
||||
|
||||
RDReaderView 是一个 iOS 阅读器组件库(CocoaPods),提供开箱即用的 EPUB 阅读能力,并保留对纯文本翻页的支持。
|
||||
|
||||
- **最低 iOS 版本**:15.0(podspec 仍标注 9.0,实际 demo Podfile 要求 15.0)
|
||||
- **Swift 版本**:5.0+
|
||||
- **依赖**:ZIPFoundation(EPUB 解压)、DTCoreText(文本 EPUB 渲染)
|
||||
- **Demo 额外依赖**:SnapKit、SSAlertSwift
|
||||
|
||||
---
|
||||
|
||||
## 2. 总体分层
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Demo / 宿主 App │
|
||||
│ ViewController → RDReaderController(demo 级路由控制器) │
|
||||
└───────────────────────────┬─────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────────▼─────────────────────────────┐
|
||||
│ EPUBUI 层(library 级读者 UI) │
|
||||
│ RDEPUBReaderController(开箱即用入口) │
|
||||
│ RDEPUBReaderConfiguration / Theme / Persistence │
|
||||
│ TopToolView / BottomToolView / ChapterList / Settings │
|
||||
│ RDEPUBTextContentView / RDEPUBWebContentView │
|
||||
└───────────────────────────┬─────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────────▼─────────────────────────────┐
|
||||
│ 翻页容器层(RDReaderView) │
|
||||
│ RDReaderView(UIView,统一翻页外壳) │
|
||||
│ 4 种翻页模式:pageCurl / horizontalScroll / │
|
||||
│ verticalScroll / horizontalCoverScroll │
|
||||
│ RDReaderFlowLayout / RDReaderContentCell │
|
||||
│ RDReaderPageChildViewController(pageCurl 页包装) │
|
||||
└───────────────────────────┬─────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────────▼─────────────────────────────┐
|
||||
│ EPUBCore 层(EPUB 引擎) │
|
||||
│ │
|
||||
│ Publication 层 │
|
||||
│ RDEPUBParser(+Archive / +Package / +TOC / …) │
|
||||
│ RDEPUBPublication(出版物聚合对象) │
|
||||
│ RDEPUBModels(metadata / manifest / spine 模型) │
|
||||
│ RDEPUBReadingModels(location / viewport / highlight) │
|
||||
│ │
|
||||
│ Services 层 │
|
||||
│ RDEPUBResourceResolver(资源 URL 统一入口) │
|
||||
│ RDEPUBPreferences(展示参数聚合) │
|
||||
│ RDEPUBPaginator(离屏分页服务) │
|
||||
│ │
|
||||
│ Navigator 层 │
|
||||
│ RDEPUBReadingSession(状态机 + 会话协调) │
|
||||
│ RDEPUBNavigatorState(状态枚举) │
|
||||
│ RDEPUBNavigatorLayoutContext │
|
||||
│ │
|
||||
│ Resource View 层 │
|
||||
│ RDEPUBWebView(+Configuration / +Reflowable / │
|
||||
│ +FixedLayout / +JavaScriptBridge) │
|
||||
│ RDEPUBResourceURLSchemeHandler(ss-reader:// 协议) │
|
||||
│ RDEPUBStyleSheetBuilder / RDEPUBJavaScriptBridge │
|
||||
│ RDEPUBFixedLayoutTemplate / RDEPUBAssetRepository │
|
||||
│ RDEPUBRenderRequest │
|
||||
└───────────────────────────┬─────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────────▼─────────────────────────────┐
|
||||
│ EPUBTextRendering 层(文本 EPUB 渲染) │
|
||||
│ RDEPUBTextRenderer(协议) │
|
||||
│ RDEPUBDTCoreTextRenderer(DTCoreText 实现) │
|
||||
│ RDEPUBTextRendererSupport / RDEPUBTextPaginationSupport │
|
||||
│ RDEPUBTextBookBuilder │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 翻页容器层(RDReaderView)
|
||||
|
||||
### 3.1 四种翻页模式
|
||||
|
||||
| 模式 | 实现方式 | 特点 |
|
||||
|------|----------|------|
|
||||
| `pageCurl` | UIPageViewController | 原生翻书效果,手势由系统提供 |
|
||||
| `horizontalScroll` | UICollectionView + RDReaderFlowLayout | 每屏显示 2 项,水平分页滚动 |
|
||||
| `verticalScroll` | UICollectionView + RDReaderFlowLayout | 全宽项目,垂直连续滚动 |
|
||||
| `horizontalCoverScroll` | UICollectionView + RDReaderFlowLayout | 覆盖滚动效果,Z 轴动画 |
|
||||
|
||||
### 3.2 数据源协议
|
||||
|
||||
```swift
|
||||
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?
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 手势分区(scroll 模式)
|
||||
|
||||
屏幕水平三等分:
|
||||
- **左 1/3**:上一页
|
||||
- **中 1/3**:显示 / 隐藏工具栏
|
||||
- **右 1/3**:下一页
|
||||
|
||||
### 3.4 翻页模式切换
|
||||
|
||||
`switchReaderDisplayType(_ type:)` 会完全销毁并重建底层 view(pageViewController 或 collectionView),然后重新加载数据。
|
||||
|
||||
---
|
||||
|
||||
## 4. EPUB 引擎层(EPUBCore)
|
||||
|
||||
### 4.1 Publication 层:解析与聚合
|
||||
|
||||
**主链路:**
|
||||
|
||||
```
|
||||
epubURL
|
||||
→ RDEPUBParser.parse(epubURL:)
|
||||
→ extractArchive # ZIP 解压到沙盒临时目录
|
||||
→ parseContainerXML # 定位 OPF 路径
|
||||
→ parseOPF # 解析 metadata / manifest / spine
|
||||
→ parseTOC # 解析 NCX 或 Nav 目录
|
||||
→ RDEPUBPublication(parser:) # 聚合解析结果,挂载 resourceResolver
|
||||
```
|
||||
|
||||
**RDEPUBPublication 暴露的能力:**
|
||||
|
||||
| 属性 / 方法 | 说明 |
|
||||
|------------|------|
|
||||
| `metadata` | 书名、作者、语言、layout 等 |
|
||||
| `manifest` | id → RDEPUBManifestItem 映射 |
|
||||
| `spine` | 有序 spine 列表(包含 href) |
|
||||
| `tableOfContents` | 树形目录 |
|
||||
| `layout` | `.reflowable` 或 `.fixed` |
|
||||
| `readingProfile` | `.webInteractive` / `.webFixedLayout` / `.textReflowable` |
|
||||
| `resourceResolver` | 统一资源 URL 解析入口 |
|
||||
| `fixedLayoutSpreadEnabled(for:viewportSize:)` | 判断是否启用双页 spread |
|
||||
| `makeFixedSpreads(preferences:viewportSize:)` | 生成 fixed spread 页模型 |
|
||||
|
||||
**readingProfile 判定逻辑:**
|
||||
|
||||
```
|
||||
layout == .fixed → webFixedLayout
|
||||
layout == .reflowable + 含交互脚本 → webInteractive
|
||||
layout == .reflowable + 无交互脚本 → textReflowable
|
||||
```
|
||||
|
||||
### 4.2 Services 层
|
||||
|
||||
#### RDEPUBResourceResolver
|
||||
|
||||
统一处理所有资源路径转换,是 WebView 和 Paginator 访问资源的唯一入口:
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `fileURL(forHref:)` | href → 本地文件 URL |
|
||||
| `schemeURL(forHref:)` | href → `ss-reader://` 协议 URL |
|
||||
| `href(forSpineIndex:)` | spineIndex → href |
|
||||
| `normalizedHref(_:)` | 相对路径标准化(统一相对 OPF) |
|
||||
|
||||
#### RDEPUBPaginator
|
||||
|
||||
使用隐藏的 `WKWebView` 离屏加载每个 spine 资源,通过 JS 注入分页 CSS,回调每个资源的页数:
|
||||
|
||||
```swift
|
||||
paginator.calculate(publication: publication, preferences: preferences, viewportSize: size) { pageCounts in
|
||||
// pageCounts[i] = spine[i] 的页数
|
||||
}
|
||||
```
|
||||
|
||||
#### RDEPUBPreferences
|
||||
|
||||
聚合 WebView 和 Paginator 共用的展示参数:字号、行距、主题色、边距、布局适配模式、spread 模式。
|
||||
|
||||
### 4.3 Navigator 层
|
||||
|
||||
#### RDEPUBNavigatorState 状态机
|
||||
|
||||
```
|
||||
initializing → loading → idle ←→ jumping
|
||||
←→ moving
|
||||
←→ repaginating
|
||||
```
|
||||
|
||||
| 状态 | 含义 | 允许动作 |
|
||||
|------|------|----------|
|
||||
| `initializing` | 刚打开书籍 | 接收初始恢复位置 |
|
||||
| `loading` | 生成首轮 page model | 缓存 pending navigation |
|
||||
| `idle` | 显示稳定 | 应用 staged snapshot、处理跳转、保存位置 |
|
||||
| `jumping` | 目录 / 内部链接跳转中 | 重放 pending location |
|
||||
| `moving` | 用户翻页中 | 更新当前 pageNum |
|
||||
| `repaginating` | 重新分页中(字号/横竖屏变化) | 缓存当前位置,等待完成 |
|
||||
|
||||
#### RDEPUBReadingSession
|
||||
|
||||
会话级协调者,持有:
|
||||
|
||||
- `publication`:出版物只读视图
|
||||
- `activePages / activeChapters`:当前展示的页模型和章节信息
|
||||
- `stagedPages / stagedChapters`:后台分页完成后暂存,等待 idle 状态时应用
|
||||
- `pendingNavigationLocation / pendingNavigationPageNum`:等待当前加载完成后再执行的跳转请求
|
||||
- `currentViewport / currentReadingContext`:当前可见区域的位置信息
|
||||
|
||||
关键操作:
|
||||
|
||||
```swift
|
||||
session.stageSnapshot(snapshot, restoreLocation:) // 后台分页完成,暂存结果
|
||||
session.consumeStagedSnapshotIfAllowed() // idle 时消费暂存(线程安全切换)
|
||||
session.transition(to: .idle) // 状态跃迁
|
||||
session.clearPendingNavigation() // 取消待执行跳转
|
||||
```
|
||||
|
||||
### 4.4 Resource View 层
|
||||
|
||||
#### RDEPUBWebView
|
||||
|
||||
承载单个 spine 资源的 WKWebView,按职责拆分为 4 个扩展文件:
|
||||
|
||||
| 扩展 | 职责 |
|
||||
|------|------|
|
||||
| `+Configuration` | WKWebViewConfiguration、schemeHandler 注册、user scripts |
|
||||
| `+Reflowable` | 注入分页 CSS、滚动到指定 progression、接收 JS 事件 |
|
||||
| `+FixedLayout` | fixed-layout HTML wrapper 生成和加载 |
|
||||
| `+JavaScriptBridge` | JS ↔ Swift 消息路由、选区、高亮、进度上报 |
|
||||
|
||||
#### ss-reader:// 协议
|
||||
|
||||
所有 spine 资源(XHTML、CSS、图片、字体)统一通过 `ss-reader://book/<relative-path>` 访问,由 `RDEPUBResourceURLSchemeHandler` 从本地解压目录读取并返回。
|
||||
|
||||
优点:
|
||||
- 相对资源路径在 WebView 中自然解析
|
||||
- fixed-layout iframe 不再白屏
|
||||
- 无需 `allowingReadAccessTo` 路径权限
|
||||
|
||||
---
|
||||
|
||||
## 5. 文本 EPUB 渲染层(EPUBTextRendering)
|
||||
|
||||
适用于 `readingProfile == .textReflowable` 的书籍(纯文本小说类 EPUB2)。
|
||||
|
||||
### 5.1 渲染链路
|
||||
|
||||
```
|
||||
章节 HTML 文件
|
||||
→ RDEPUBTextRendererSupport.injectFragmentMarkers(into:) # 注入 fragment 标记
|
||||
→ RDEPUBDTCoreTextRenderer.renderChapter(html:baseURL:style:)
|
||||
→ DTHTMLAttributedStringBuilder # HTML → NSAttributedString
|
||||
→ extractFragmentOffsets # fragment → 字符偏移量映射
|
||||
→ normalizeReadingAttributes # 统一字体/行距
|
||||
→ RDEPUBRenderedChapterContent
|
||||
.attributedString # 渲染后的富文本
|
||||
.fragmentOffsets # fragment id → 字符偏移
|
||||
```
|
||||
|
||||
### 5.2 分页
|
||||
|
||||
```
|
||||
RDEPUBTextBookBuilder.buildBook(publication:style:pageSize:renderer:)
|
||||
→ 逐章节调用 renderer.renderChapter(...)
|
||||
→ RDEPUBTextPaginationSupport.paginate(attributedString:pageSize:)
|
||||
→ RDEPUBTextBook(章节 + 页模型 + fragment 索引)
|
||||
```
|
||||
|
||||
### 5.3 内容显示
|
||||
|
||||
`RDEPUBTextContentView` 基于 `NSAttributedString` + `UITextView`(或 DTCoreText 自定义绘制),实现:
|
||||
- 每页显示对应字符范围的内容
|
||||
- 高亮重叠渲染
|
||||
- 支持按 fragment 偏移量跳转
|
||||
|
||||
---
|
||||
|
||||
## 6. EPUBUI 层(开箱即用读者 UI)
|
||||
|
||||
`RDEPUBReaderController` 是 library 层提供的完整读者入口,调用方只需传入 EPUB 文件 URL:
|
||||
|
||||
```swift
|
||||
let controller = RDEPUBReaderController(
|
||||
epubURL: url,
|
||||
configuration: .default,
|
||||
persistence: RDEPUBReaderPersistence(storageKey: "my-book")
|
||||
)
|
||||
controller.delegate = self
|
||||
present(controller, animated: true)
|
||||
```
|
||||
|
||||
### 6.1 内置能力
|
||||
|
||||
| 能力 | 对应文件 |
|
||||
|------|----------|
|
||||
| 顶部工具栏(书名、返回、目录、高亮入口) | RDEPUBReaderTopToolView |
|
||||
| 底部工具栏(进度条、页码) | RDEPUBReaderBottomToolView |
|
||||
| 目录面板 | RDEPUBReaderChapterListController |
|
||||
| 高亮管理 | RDEPUBReaderHighlightsViewController |
|
||||
| 设置面板(字号、行距、主题、翻页模式) | RDEPUBReaderSettingsViewController |
|
||||
| 阅读位置持久化 | RDEPUBReaderPersistence |
|
||||
|
||||
### 6.2 配置项(RDEPUBReaderConfiguration)
|
||||
|
||||
| 属性 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `fontSize` | 15 | 字号(pt) |
|
||||
| `lineHeightMultiple` | 1.6 | 行距倍数 |
|
||||
| `displayType` | `.pageCurl` | 翻页模式 |
|
||||
| `landscapeDualPageEnabled` | `true` | 横屏双页 |
|
||||
| `showsTableOfContents` | `true` | 是否显示目录入口 |
|
||||
| `allowsHighlights` | `true` | 是否显示高亮入口 |
|
||||
| `showsSettingsPanel` | `true` | 是否显示设置入口 |
|
||||
| `theme` | `.light` | 主题 |
|
||||
| `fixedLayoutSpreadMode` | `.automatic` | fixed-layout spread 模式 |
|
||||
| `textRenderingEngine` | `.dtCoreText` | 文本 EPUB 渲染引擎 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 关键数据流
|
||||
|
||||
### 7.1 打开 EPUB 书籍
|
||||
|
||||
```
|
||||
RDEPUBReaderController.init(epubURL:)
|
||||
→ viewDidLoad
|
||||
→ RDEPUBParser.parse(epubURL:)
|
||||
→ RDEPUBPublication(parser:)
|
||||
→ RDEPUBReadingSession(publication:)
|
||||
→ readingProfile 判断渲染路径
|
||||
textReflowable:
|
||||
RDEPUBTextBookBuilder.buildBook(...) # 后台分页
|
||||
→ session.stageSnapshot(...)
|
||||
→ session.consumeStagedSnapshotIfAllowed() # idle 时应用
|
||||
→ readerView.reloadData()
|
||||
webInteractive / webFixedLayout:
|
||||
RDEPUBPaginator.calculate(...) # 离屏 WebView 分页
|
||||
→ session.stageSnapshot(...)
|
||||
→ readerView.reloadData()
|
||||
→ persistence.restoreLocation() # 恢复上次阅读位置
|
||||
```
|
||||
|
||||
### 7.2 翻页
|
||||
|
||||
```
|
||||
用户手势(tap / swipe)
|
||||
→ RDReaderView 检测手势分区 / 翻页方向
|
||||
→ delegate.pageNum(readerView:pageNum:)
|
||||
→ RDEPUBReaderController 根据 pageNum 找 EPUBPage
|
||||
→ RDEPUBReadingSession.transition(to: .moving)
|
||||
→ readerView.pageContentView(pageNum:) 回调
|
||||
→ 创建 RDEPUBWebContentView 或 RDEPUBTextContentView
|
||||
→ loadPage(spineIndex: pageIndexInChapter: preferences:)
|
||||
→ JS bridge 上报 progression → session.currentViewport 更新
|
||||
```
|
||||
|
||||
### 7.3 定位模型(RDEPUBLocation)
|
||||
|
||||
EPUB 是可重排内容,字号 / 横竖屏变化会使页号失效。定位模型使用 `href + progression`:
|
||||
|
||||
```swift
|
||||
struct RDEPUBLocation: Codable, Equatable {
|
||||
var bookIdentifier: String? // 隔离不同书的进度
|
||||
var href: String // OPF 相对路径(对应 spine 资源)
|
||||
var progression: Double // 视口起始位置 [0, 1]
|
||||
var lastProgression: Double? // 视口末尾位置 [0, 1]
|
||||
var fragment: String? // 锚点
|
||||
}
|
||||
```
|
||||
|
||||
**恢复流程:**
|
||||
|
||||
1. 标准化 `href`(相对 OPF)
|
||||
2. 找到对应 spineIndex
|
||||
3. 由 `navigationProgression` 估算扁平页号
|
||||
4. 若有 `fragment`,加载后在 WebView 中锚点滚动
|
||||
|
||||
---
|
||||
|
||||
## 8. 已知限制与后续待办
|
||||
|
||||
| 问题 | 说明 |
|
||||
|------|------|
|
||||
| Demo 层 RDReaderController 过大 | 约 1900+ 行,仍混有 EPUB 加载、UI 管理、数据源逻辑,待进一步拆分 |
|
||||
| RDEPUBParser.swift 仍偏大 | 约 900 行,虽已拆出多个扩展文件,OPF metadata 细节仍集中 |
|
||||
| 横竖屏切换已接入一阶段支持 | `RDEPUBReaderController.viewWillTransition()` 统一接管正文重分页,`RDReaderView` 保留容器级双页布局刷新;仍需补齐固定回归矩阵 |
|
||||
| 固定手势分区比例 | 三等分固定写死,不支持自定义 |
|
||||
| 自动化测试为首批接入状态 | 已有 `ReadViewSDKDemoTests` / `ReadViewSDKDemoUITests` 与 parser / resolver / persistence / smoke 用例,分页、恢复链路和更多 UI 闭环仍需继续补齐 |
|
||||
| 基线验证待跑 | 四本样书(凡人修仙传、爱忘事熊爷爷、宝山辽墓、张学良传)的分页耗时、目录命中率、末页事件等数据尚未收集 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 构建与依赖
|
||||
|
||||
### 9.1 安装
|
||||
|
||||
```bash
|
||||
cd RDReaderDemo
|
||||
pod install
|
||||
# 打开 RDReaderDemo.xcworkspace,选 RDReaderDemo scheme,构建
|
||||
```
|
||||
|
||||
### 9.2 主要依赖
|
||||
|
||||
| 依赖 | 用途 | 使用层 |
|
||||
|------|------|--------|
|
||||
| ZIPFoundation | EPUB ZIP 解压 | RDEPUBParser+Archive |
|
||||
| DTCoreText | HTML → NSAttributedString | RDEPUBDTCoreTextRenderer |
|
||||
| SnapKit | Demo 布局 | Demo 层 |
|
||||
| SSAlertSwift | Demo 弹窗 | Demo 层 |
|
||||
|
||||
### 9.3 podspec 关键配置
|
||||
|
||||
- `s.source_files = 'Sources/RDReaderView/**/*.{swift}'`(递归包含所有子目录)
|
||||
- `s.resource_bundles`:包含 JS、CSS 等资源文件
|
||||
- Library 本身无需 demo 层依赖
|
||||
293
Doc/CODING_STYLE.md
Normal file
293
Doc/CODING_STYLE.md
Normal file
@ -0,0 +1,293 @@
|
||||
# ReadSDK 代码规范
|
||||
|
||||
## 适用范围
|
||||
|
||||
本文档适用于 `ReadSDK` 新增代码与重构代码。
|
||||
|
||||
- 规范覆盖 `Sources/` 与 `RDReaderDemo/` 中的 Swift 代码。
|
||||
- 命名、分层与职责边界以 SDK 可维护性和可扩展性为优先。
|
||||
- 本文档分为两类内容:
|
||||
- `已观察到的约定`:当前工程常见写法。
|
||||
- `建议统一的规范`:后续统一执行的规则。
|
||||
|
||||
## 新架构目录与职责
|
||||
|
||||
### 已观察到的约定
|
||||
|
||||
- `Sources/RDReaderView`:阅读核心容器、翻页能力与基础视图。
|
||||
- `EPUBCore`:EPUB 解析、资源定位、导航状态、分页与会话协调。
|
||||
- `EPUBTextRendering`:文本渲染引擎与分页支持。
|
||||
- `EPUBUI`:可开箱即用的 Reader UI 层。
|
||||
- `RDReaderDemo`:示例应用与调试入口。
|
||||
|
||||
### 建议统一的规范
|
||||
|
||||
- 新增业务能力优先归入 `Sources/RDReaderView` 下的对应模块目录。
|
||||
- `Core` 层只承载解析、会话、状态机与通用能力,不写页面级交互。
|
||||
- `UI` 层只承载展示、事件分发和轻量状态同步,不直接处理底层解析逻辑。
|
||||
- 若模块持续膨胀,优先在当前模块下继续拆分子文件,不跨目录散落实现。
|
||||
|
||||
## 命名规范
|
||||
|
||||
### 已观察到的约定
|
||||
|
||||
- 当前工程历史命名以 `SS`、`RDEPUB` 开头。
|
||||
- 控制器常用 `...Controller`,视图常用 `...View`,会话对象常用 `...Session`。
|
||||
- 扩展文件采用 `类型名+功能域.swift`,如 `Parser+Archive.swift`。
|
||||
- 事件方法常使用 `Action` 结尾,例如 `pageTapAction()`、`themeChangeAction()`。
|
||||
- 绑定数据的方法常使用 `bind`、`update`、`refresh`、`configure` 等动词。
|
||||
|
||||
### 建议统一的规范
|
||||
|
||||
- **所有新增类型必须以 `RD` 开头。**
|
||||
- EPUB 相关类型统一以 `RDEPUB` 开头。
|
||||
- 方法名、变量名沿用 Swift 小驼峰,不增加额外前缀。
|
||||
- 类型名应反映职责,不使用过宽泛的后缀;只有真正承担协调逻辑时才使用 `Manager`、`Handler`。
|
||||
- 事件处理方法统一使用"对象/意图 + Action"命名,例如 `pageTapAction`、`themeChangeAction`。
|
||||
- 数据绑定方法优先使用以下语义:
|
||||
- `bind...`:将模型绑定到视图或模块。
|
||||
- `update...`:增量刷新已有界面或状态。
|
||||
- `configure...`:一次性配置样式或依赖。
|
||||
- `refresh...`:重新拉取或重建数据状态。
|
||||
- 避免新增拼写不一致的方法名;若发现历史命名拼写错误,新增代码必须使用正确拼写,旧接口修复时应配合调用点一起调整。
|
||||
|
||||
命名示例:
|
||||
|
||||
- `RDReaderView`
|
||||
- `RDEPUBParser`
|
||||
- `RDEPUBPublication`
|
||||
- `RDEPUBReadingSession`
|
||||
- `RDEPUBReaderController`
|
||||
- `RDEPUBReaderTheme`
|
||||
|
||||
扩展文件命名示例:
|
||||
|
||||
```text
|
||||
RDEPUBParser.swift
|
||||
RDEPUBParser+Archive.swift
|
||||
RDEPUBParser+Package.swift
|
||||
RDEPUBParser+TOC.swift
|
||||
RDEPUBParser+Resources.swift
|
||||
RDEPUBWebView.swift
|
||||
RDEPUBWebView+Configuration.swift
|
||||
RDEPUBWebView+Reflowable.swift
|
||||
```
|
||||
|
||||
## 分层与职责边界
|
||||
|
||||
### 已观察到的约定
|
||||
|
||||
- 阅读入口负责容器装配、翻页模式切换和事件分发。
|
||||
- EPUB 核心层负责解析、导航、分页、资源读取与定位。
|
||||
- 渲染层负责 HTML/富文本渲染与分页支持。
|
||||
- UI 层负责主题、工具栏、目录、设置等交互能力。
|
||||
|
||||
### 建议统一的规范
|
||||
|
||||
- `RD...Controller`:负责页面级编排与流程调度,不承载复杂渲染细节。
|
||||
- `RD...View`:负责展示与局部交互,不承载完整业务流程。
|
||||
- `RDEPUB...Core`:负责解析、会话状态与数据模型,不依赖具体页面。
|
||||
- 配置、主题、定位、进度模型统一下沉为 `struct`。
|
||||
- 跨层通信优先通过会话层或协议,不做跨层直接写状态。
|
||||
|
||||
## UI 与布局规范
|
||||
|
||||
### 已观察到的约定
|
||||
|
||||
- 视图多采用 `lazy var` 初始化,并在闭包内完成默认配置。
|
||||
- 自定义 View 通常在 `init(frame:)` 或业务绑定后调用 `initView()` 完成视图树搭建。
|
||||
- 复杂页面使用分区 extension 组织代理与事件实现。
|
||||
- 页面或组件内部按职责拆分样式方法,例如 `topBarStyle()`、`contentStyle()`。
|
||||
- 页面经常通过回调闭包把交互抛给外层,例如 `pageChangeCallback`、`selectionCallback`。
|
||||
- 模块内存在多处布局方式混用情况。
|
||||
|
||||
### 建议统一的规范
|
||||
|
||||
- SDK 层新增 UI 使用 Auto Layout 原生约束,Demo 层可使用 SnapKit;单文件内不混用多套布局体系。
|
||||
- 视图层初始化顺序保持一致:
|
||||
- 定义属性与子视图
|
||||
- 在 `initView()` 中组装视图树
|
||||
- 在独立方法中拆分样式和状态刷新逻辑
|
||||
- 当约束会被多次切换时:
|
||||
- 首次创建使用 `makeConstraints`(SnapKit)或 `NSLayoutConstraint`
|
||||
- 重建结构使用 `remakeConstraints`(SnapKit)或先移除再添加
|
||||
- 仅修改常量时使用 `updateConstraints`(SnapKit)或修改 `constant` 属性
|
||||
- 布局分支明显时,优先拆成语义化私有方法,不要把所有状态分支堆在一个超长方法里。
|
||||
- 对外暴露的 UI 刷新入口建议以 `bind` 或 `update` 开头,避免把布局细节暴露给调用方。
|
||||
- 交互事件通过闭包或 delegate 抛出,避免子视图持有上层业务依赖。
|
||||
|
||||
## 交互与状态处理规范
|
||||
|
||||
### 已观察到的约定
|
||||
|
||||
- 事件处理使用 `@objc` + selector。
|
||||
- 异步回调中广泛使用 `[weak self]`。
|
||||
- 状态判断常通过 `guard` 提前返回。
|
||||
|
||||
### 建议统一的规范
|
||||
|
||||
- 按钮、通知、系统回调放入独立 extension 分组。
|
||||
- 异步闭包默认先使用 `[weak self]`,仅在必要时改强引用。
|
||||
- 多前置条件入口统一先 `guard` 校验,减少嵌套。
|
||||
- 导航与进度恢复统一通过 `RDEPUBReadingSession` 协调。
|
||||
|
||||
## 代码风格细则
|
||||
|
||||
### 已观察到的约定
|
||||
|
||||
- extension 分区较常见。
|
||||
- 解析与渲染模型多数采用 `struct`。
|
||||
- 存在少量历史命名不统一与可选值处理不一致情况。
|
||||
|
||||
### 建议统一的规范
|
||||
|
||||
- 默认遵循”最小可见性”:`private` > `fileprivate` > `internal` > `public`。
|
||||
- 纯数据模型使用 `struct` + `Codable` + `Equatable`。
|
||||
- 服务对象使用 `final class`,避免无意义继承。
|
||||
- 错误类型统一使用 `enum + LocalizedError`。
|
||||
- 新增代码避免强制解包;若当前上下文无法避免,至少先在上层收敛边界。
|
||||
- 统一优先使用 `guard` 做前置失败处理,减少深层嵌套。
|
||||
- extension 的拆分原则以”单一职责”优先:
|
||||
- 事件处理一组
|
||||
- 代理 / DataSource 实现一组
|
||||
- 工具方法一组
|
||||
- 通知适配一组
|
||||
- 调试代码继续使用 `#if DEBUG` 包裹,不把调试边框、日志、测试分支直接带入正式逻辑。
|
||||
|
||||
## 文件组织规范
|
||||
|
||||
### 已观察到的约定
|
||||
|
||||
- 目录按阅读容器、EPUB Core、渲染、UI 分层组织。
|
||||
- 大类通过 `+Extension` 文件拆分职责。
|
||||
|
||||
### 建议统一的规范
|
||||
|
||||
- 目录保持以下分层,不跨层放置实现:
|
||||
- `Sources/RDReaderView/EPUBCore`
|
||||
- `Sources/RDReaderView/EPUBTextRendering`
|
||||
- `Sources/RDReaderView/EPUBUI`
|
||||
- 单文件建议不超过 `600` 行;超出后按职责拆分 extension 文件。
|
||||
- extension 文件命名统一 `RD类型名+功能域.swift`。
|
||||
|
||||
## 注释规范
|
||||
|
||||
- 注释、错误提示、日志统一使用中文。
|
||||
- 关键流程方法保留”为什么这样做”的注释,不写重复代码字面行为的注释。
|
||||
- 调试输出统一放在 `#if DEBUG` 下。
|
||||
- 新代码保留有信息量的注释,避免重复描述显而易见的代码行为。
|
||||
|
||||
## 已观察到的项目模式
|
||||
|
||||
### 模式 1:统一入口 + 扩展拆分
|
||||
|
||||
- `RDEPUBParser` 负责 EPUB 解析主入口,不同能力拆到 `+Archive`、`+Package`、`+TOC`、`+Resources` 等扩展文件。
|
||||
- `RDEPUBReadingSession` 负责阅读会话主入口,状态管理、分页、定位等能力拆到扩展文件。
|
||||
- 该模式适合继续用于解析器、会话管理、控制器工具方法等横向能力。
|
||||
|
||||
### 模式 2:页面编排在 Controller,局部交互下沉到 View
|
||||
|
||||
- `RDEPUBReaderController` 负责阅读页整体编排:翻页容器装配、工具栏切换、阅读位置恢复。
|
||||
- `RDReaderView` 负责分页容器布局与翻页交互,并通过 DataSource / Delegate 把数据需求交回外层。
|
||||
- 该模式保持 Controller 管流程、View 管展示的职责分离。
|
||||
|
||||
### 模式 3:列表与容器逻辑通过扩展拆开
|
||||
|
||||
- 复杂容器将 `UICollectionViewDataSource`、`UICollectionViewDelegate`、`UICollectionViewDelegateFlowLayout` 分别拆分到 extension。
|
||||
- 该模式降低单文件中主逻辑与代理逻辑的耦合,适合继续用于任何包含列表或容器的组件。
|
||||
|
||||
### 模式 4:渲染路径抽象
|
||||
|
||||
- `RDEPUBReadingProfile` 根据 EPUB 特征自动选择渲染路径(`webFixedLayout` / `webInteractive` / `textReflowable`)。
|
||||
- 新增渲染相关功能时,必须评估对三种路径的覆盖情况。
|
||||
|
||||
## 待统一项
|
||||
|
||||
- 当前访问控制级别存在混用:同一类里 `public`、默认 `internal`、`private` 并存,建议后续新增代码默认从最小可见范围开始声明。
|
||||
- 当前存在少量强制解包,建议新增代码优先通过前置校验收敛风险。
|
||||
- 当前存在拼写不一致问题,建议后续新增代码统一使用标准英文单词,旧接口如需修复应配合调用点一起调整。
|
||||
- 当前部分注释偏”过程说明”或遗留调试注释,建议新代码保留有信息量的注释。
|
||||
- 当前个别 View 在数据绑定阶段再次调用 `initView()` 重建界面,这种方式在复杂组件中容易引入重复添加子视图或状态不一致。建议新增组件优先区分”初始化视图结构”和”刷新数据状态”两个阶段。
|
||||
|
||||
## 禁忌事项
|
||||
|
||||
| 禁忌 | 替代做法 |
|
||||
|------|----------|
|
||||
| 新增类型不加 `RD` 前缀 | 所有新增类型统一 `RD` / `RDEPUB` 前缀 |
|
||||
| UI 层直接拼装解析状态 | 通过 `RDEPUBReadingSession` 获取状态 |
|
||||
| 控制器直接操作底层解析细节 | 通过 `RDEPUBPublication`、`RDEPUBParser` 暴露接口 |
|
||||
| 强制解包可选值 | `guard let` / `if let` |
|
||||
| 用页号单独恢复阅读进度 | 统一使用 `href + progression` |
|
||||
|
||||
## 使用建议
|
||||
|
||||
- 新增功能前先确定目录归属和职责边界。
|
||||
- 命名先定前缀再落代码:类型一律 `RD` 开头。
|
||||
- 若需迁移历史 `SS` 前缀,按模块渐进替换,优先替换新增与重构触达文件。
|
||||
|
||||
## 旧 SS 命名迁移到 RD 的分阶段执行清单
|
||||
|
||||
### 阶段 0:冻结新增 SS 命名(立即执行)
|
||||
|
||||
- 目标:从当前时点开始,不再引入新的 `SS`/`RDEPUB` 类型名。
|
||||
- 动作:
|
||||
- 新增类型统一使用 `RD`/`RDEPUB` 前缀。
|
||||
- Code Review 增加命名检查项:发现新增 `SS` 命名必须驳回。
|
||||
- 在 PR 模板中加入“本次是否新增旧前缀命名”勾选项。
|
||||
- 验收:
|
||||
- 新提交代码中,新增类型 `SS` 前缀数量为 `0`。
|
||||
|
||||
### 阶段 1:建立迁移映射表(第 1 周)
|
||||
|
||||
- 目标:明确“旧名 -> 新名”一一映射,避免多人并行改名冲突。
|
||||
- 动作:
|
||||
- 统计核心公开类型、内部核心类型、测试类型三类清单。
|
||||
- 建立命名映射表,例如:
|
||||
- `RDReaderView` -> `RDReaderView`
|
||||
- `RDEPUBParser` -> `RDEPUBParser`
|
||||
- `RDEPUBReadingSession` -> `RDEPUBReadingSession`
|
||||
- 对外 API 单独标记“需兼容过渡”的类型。
|
||||
- 验收:
|
||||
- 映射表覆盖全部高频核心类型,且团队评审通过。
|
||||
|
||||
### 阶段 2:先迁移内部类型(第 2-3 周)
|
||||
|
||||
- 目标:优先改内部实现,降低外部兼容压力。
|
||||
- 动作:
|
||||
- 按模块分批迁移:`EPUBCore` -> `EPUBTextRendering` -> `EPUBUI`。
|
||||
- 每批次只改一个子模块,避免超大 PR。
|
||||
- 同步修复调用点、扩展文件名与注释中的旧命名。
|
||||
- 验收:
|
||||
- 目标模块内类型命名全部满足 `RD` 规则。
|
||||
- 编译通过,Demo 阅读主流程可用。
|
||||
|
||||
### 阶段 3:迁移公开 API 并保留兼容层(第 3-4 周)
|
||||
|
||||
- 目标:完成对外接口改名,同时给接入方提供平滑升级窗口。
|
||||
- 动作:
|
||||
- 对外公开类型切换为 `RD` 命名。
|
||||
- 旧公开类型保留兼容别名,并标注废弃说明(`deprecated`)。
|
||||
- 在 Release Note 提供“旧名/新名对照表”和迁移示例。
|
||||
- 验收:
|
||||
- 新接入示例仅使用 `RD` 命名。
|
||||
- 旧接入代码在兼容期内无需立即改动即可编译。
|
||||
|
||||
### 阶段 4:清理兼容层与收口(下一主版本)
|
||||
|
||||
- 目标:在约定主版本移除旧前缀,完成命名收口。
|
||||
- 动作:
|
||||
- 删除 `SS` 兼容别名与过渡代码。
|
||||
- 清理文档、注释、示例工程中的旧前缀残留。
|
||||
- 对外发布最终迁移公告与升级说明。
|
||||
- 验收:
|
||||
- 工程内无 `SS`/`RDEPUB` 类型定义残留。
|
||||
- 文档与示例代码全部为 `RD`/`RDEPUB` 命名。
|
||||
|
||||
### 迁移过程约束
|
||||
|
||||
- 每次迁移 PR 必须包含:
|
||||
- 命名改动清单
|
||||
- 影响范围说明
|
||||
- 回归验证结果(编译、Demo 主流程、关键阅读路径)
|
||||
- 禁止在同一 PR 中同时做“大规模命名迁移 + 业务逻辑重构”。
|
||||
- 若改名会影响外部接入,必须先补迁移文档再合并代码。
|
||||
357
Doc/EPUBCore_功能实现逻辑.md
Normal file
357
Doc/EPUBCore_功能实现逻辑.md
Normal file
@ -0,0 +1,357 @@
|
||||
# EPUBCore 功能实现逻辑
|
||||
|
||||
## 1. 范围与目标
|
||||
|
||||
- 代码范围:`Sources/RDReaderView/EPUBCore/`(30 个 Swift 文件 + 2 个资源文件)
|
||||
- 目标:说明 EPUBCore 如何完成 EPUB 解析、资源定位、阅读会话管理、离屏分页、JS 桥接渲染和全文搜索。
|
||||
- 主链路关键词:`epubURL -> RDEPUBParser.parse -> container.xml -> OPF -> spine/TOC -> RDEPUBPublication -> RDEPUBReadingSession -> RDEPUBWebView/Paginator -> 分页/渲染`。
|
||||
|
||||
## 2. 关键对象职责
|
||||
|
||||
### 2.1 解析器 `RDEPUBParser`
|
||||
|
||||
- 文件:`EPUBCore/RDEPUBParser.swift` + 5 个扩展文件
|
||||
- 入口方法:`parse(epubURL:)`
|
||||
- 职责:
|
||||
- ZIP 解压到缓存目录(`RDEPUBParser+Archive.swift`)
|
||||
- 解析 `META-INF/container.xml` 定位 OPF 根文件
|
||||
- 解析 OPF 文档提取 metadata / manifest / spine(`RDEPUBParser+Package.swift`)
|
||||
- 解析目录支持 NCX(EPUB 2)和 Nav Document(EPUB 3)(`RDEPUBParser+TOC.swift`)
|
||||
- 判断阅读配置文件类型(`RDEPUBParser+ReadingProfile.swift`)
|
||||
- 资源路径转换:相对路径 ↔ 文件 URL ↔ `ss-reader://` 自定义 scheme URL(`RDEPUBParser+Resources.swift`)
|
||||
|
||||
### 2.2 Publication 门面 `RDEPUBPublication`
|
||||
|
||||
- 文件:`EPUBCore/RDEPUBPublication.swift`
|
||||
- 职责:
|
||||
- 包装 `RDEPUBParser` + `RDEPUBResourceResolver`,对外暴露只读接口
|
||||
- 提供 metadata、manifest、spine、TOC、layout、readingProfile、readingProgression
|
||||
- 计算 fixed layout 的 spread 组合:`makeFixedSpreads(preferences:viewportSize:)`
|
||||
|
||||
### 2.3 阅读会话 `RDEPUBReadingSession`
|
||||
|
||||
- 文件:`EPUBCore/RDEPUBReadingSession.swift` + `RDEPUBNavigatorState.swift`
|
||||
- 职责:
|
||||
- 管理导航状态机:`initializing -> loading -> idle <-> jumping/moving/repaginating`
|
||||
- 维护活跃/暂存分页快照(`activePages` / `activeChapters` / `stagedPages` / `stagedChapters`)
|
||||
- 管理待定导航目标(`pendingNavigationLocation` / `pendingNavigationPageNum`)
|
||||
- 更新阅读上下文(`currentReadingContext`:location + viewport + pageNumber + chapterIndex)
|
||||
- 构建分页快照:`makePaginationSnapshot(pageCounts:preferences:layoutContext:)`
|
||||
|
||||
### 2.4 WebView 渲染层 `RDEPUBWebView`
|
||||
|
||||
- 文件:`EPUBCore/RDEPUBWebView.swift` + 5 个扩展文件
|
||||
- 职责:
|
||||
- 内部持有 WKWebView,配置自定义 scheme handler 和 JS 桥接
|
||||
- 可重排内容加载:`loadPage(parser:spineIndex:pageIndex:...)`(`+Reflowable.swift`)
|
||||
- 固定版式内容加载:`loadFixedSpread(parser:spread:...)`(`+FixedLayout.swift`)
|
||||
- 处理 JS 桥接消息和链接导航拦截(`+JavaScriptBridge.swift`)
|
||||
- 搜索高亮装饰(`+Search.swift`)
|
||||
- 定义 `RDEPUBWebViewDelegate` 协议(6 个回调方法)
|
||||
|
||||
### 2.5 离屏分页器 `RDEPUBPaginator`
|
||||
|
||||
- 文件:`EPUBCore/RDEPUBPaginator.swift`
|
||||
- 职责:
|
||||
- 创建隐藏的 WKWebView 加载每个 spine 项的 HTML
|
||||
- 注入分页 CSS,运行 JS 测量 `scrollWidth`
|
||||
- 计算 `ceil(scrollWidth / viewportWidth)` 作为页数
|
||||
- 多次测量取最大值(延迟 0ms / 80ms / 180ms)确保布局稳定
|
||||
|
||||
### 2.6 资源解析 `RDEPUBResourceResolver` + `RDEPUBResourceURLSchemeHandler`
|
||||
|
||||
- 文件:`EPUBCore/RDEPUBResourceResolver.swift`、`EPUBCore/RDEPUBResourceURLSchemeHandler.swift`
|
||||
- 职责:
|
||||
- `RDEPUBResourceResolver`:对外门面,提供 href 标准化、spine 索引查找、Location 构建
|
||||
- `RDEPUBResourceURLSchemeHandler`:实现 `WKURLSchemeHandler`,将 `ss-reader://book/<path>` 映射到解压后的文件系统路径,缺失的可选资源(字体/图片/CSS/JS)返回空 200
|
||||
|
||||
### 2.7 JS 桥接
|
||||
|
||||
- 文件:`EPUBCore/RDEPUBJavaScriptBridge.swift`、`Resources/epub-bridge.js`
|
||||
- 6 种消息类型(JS → Swift):
|
||||
- `ssReaderProgressionChanged`:阅读进度变化
|
||||
- `ssReaderSelectionChanged`:文本选择变化
|
||||
- `ssReaderInternalLink`:内部链接点击
|
||||
- `ssReaderExternalLink`:外部链接点击
|
||||
- `ssReaderJSError`:JS 错误
|
||||
- `ssReaderFixedLayoutReady`:固定版式渲染完成
|
||||
- JS 端 `window.RDReaderBridge` 暴露:`applyPagination`、`setPageMetrics`、`scrollToPage`、`scrollToLocation`、`setHighlights`、`clearHighlights`、`reportProgression`、`selectionPayload`
|
||||
|
||||
### 2.8 搜索引擎 `RDEPUBHTMLSearchEngine`
|
||||
|
||||
- 文件:`EPUBCore/RDEPUBSearchEngine.swift`、`EPUBCore/RDEPUBSearchModels.swift`
|
||||
- 职责:
|
||||
- 将 HTML 转为纯文本(NSAttributedString 或正则兜底)
|
||||
- 执行大小写不敏感的全文搜索
|
||||
- 返回 `RDEPUBSearchMatch` 数组(含 progression 偏移和预览文本)
|
||||
|
||||
### 2.9 配置与样式
|
||||
|
||||
- `RDEPUBPreferences`(`EPUBCore/RDEPUBPreferences.swift`):用户阅读偏好(字号、行高、内容边距、主题色、固定版式适配模式),构建 `RDEPUBPresentationStyle` 和 `RDEPUBRenderRequest`
|
||||
- `RDEPUBNavigatorLayoutContext`(`EPUBCore/RDEPUBNavigatorLayoutContext.swift`):容器布局上下文(容器尺寸、每屏页数、安全区域、设备类型)
|
||||
- `RDEPUBStyleSheetBuilder`(`EPUBCore/RDEPUBStyleSheetBuilder.swift`):生成分页 CSS、渲染 CSS 和测量 JS 脚本
|
||||
- `RDEPUBFixedLayoutTemplate`(`EPUBCore/RDEPUBFixedLayoutTemplate.swift`):生成固定版式 HTML 模板
|
||||
|
||||
## 3. 主流程(代码级)
|
||||
|
||||
### 3.1 EPUB 解析全流程
|
||||
|
||||
1. 入口:`RDEPUBParser.parse(epubURL:)`。
|
||||
2. 调用 `extractArchiveIfNeeded(epubURL:)`:
|
||||
- 缓存路径:`~/Library/Caches/ssreaderview-epub/<slug>-<fileSize>-<timestamp>/`
|
||||
- 若目录已存在则跳过解压
|
||||
- 否则用 ZIPFoundation 解压 ZIP 到缓存目录
|
||||
3. 解析 `META-INF/container.xml`:
|
||||
- `ContainerXMLParserDelegate`(SAX)找到第一个 `<rootfile>` 的 `full-path` 属性
|
||||
4. 解析 OPF 文档:
|
||||
- `OPFPackageParserDelegate`(SAX)分段解析 metadata / manifest / spine
|
||||
- 提取 `<package>` 版本和 unique-identifier
|
||||
- 提取 `<dc:identifier>` / `<dc:title>` / `<dc:creator>` / `<dc:language>`
|
||||
- 提取 `<meta property="rendition:layout">` 判断 fixed/reflowable
|
||||
- 提取 manifest 每个 `<item>` 为 `RDEPUBManifestItem`
|
||||
- 提取 spine 每个 `<itemref>` 为 `OPFSpineReference`
|
||||
5. 构建 spine:`buildSpine(from:opfURL:)`
|
||||
- 将 spine reference 匹配到 manifest item
|
||||
- 标准化 href(相对于 OPF 目录)
|
||||
- 确定每项的有效 layout(显式属性覆盖出版级设置)
|
||||
- 返回 `[RDEPUBSpineItem]`,空 spine 抛 `emptySpine` 错误
|
||||
6. 解析目录:`parseTOC(from:opfURL:)`
|
||||
- 优先 NCX(`NCXParserDelegate`),其次 Nav Document(`NavDocumentParserDelegate`)
|
||||
- 兜底使用 spine 标题
|
||||
- href 标准化保留 fragment 标识符
|
||||
7. 创建 Publication:`makePublication()` → `RDEPUBPublication(parser: self)`
|
||||
|
||||
### 3.2 阅读配置文件判断
|
||||
|
||||
- 入口:`RDEPUBParser+ReadingProfile.swift`
|
||||
- 判断逻辑:
|
||||
- 扫描 manifest 检查是否有 `scripted` 属性的项
|
||||
- 扫描 HTML body 检查是否有 `script`、`iframe`、`video` 等交互元素
|
||||
- 结果:
|
||||
- `.webFixedLayout`:固定版式 EPUB(漫画、绘本)— WKWebView 渲染
|
||||
- `.webInteractive`:可重排 + 交互脚本 EPUB — WKWebView 渲染
|
||||
- `.textReflowable`:纯文本可重排 EPUB — DTCoreText 渲染
|
||||
|
||||
### 3.3 离屏分页测量
|
||||
|
||||
1. 入口:`RDEPUBPaginator.calculate(parser:hostingView:presentation:completion:)`
|
||||
2. 初始化所有 spine 项页数为 `[1, 1, ..., 1]`
|
||||
3. 固定版式直接返回(每 spread = 1 页)
|
||||
4. 顺序遍历可渲染的 HTML/XHTML spine 项
|
||||
5. 对每项:
|
||||
- `webView.loadFileURL(...)` 加载文件
|
||||
- `didFinish` 后启动多次测量(`scheduleMeasurementPass`)
|
||||
- 3 次测量延迟 [0ms, 80ms, 180ms]
|
||||
- 每次执行 `measurementScript`:注入分页 CSS → 测量 `scrollWidth` → 返回 `Math.max(1, Math.ceil(totalWidth / viewportWidth))`
|
||||
- 取多次测量的最大值
|
||||
6. 全部完成后回调 `completion([Int])` 页数数组
|
||||
7. `activeSessionID`(UUID)防止过期回调污染结果
|
||||
|
||||
### 3.4 WebView 可重排内容加载
|
||||
|
||||
1. 入口:`RDEPUBWebView.loadPage(parser:spineIndex:pageIndex:...)`
|
||||
2. 计算 `loadSignature`(包含 publication key、spine index、href、viewport、字号、行高、主题色、目标位置、高亮等)
|
||||
3. 若签名与 `currentLoadSignature` 相同且已渲染/正在加载,跳过重复请求
|
||||
4. 若签名匹配但文档 URL 已加载,仅调用 `applyPresentation()` 重新应用样式
|
||||
5. 否则 `handleReflowableLoad(...)` 执行完整加载:
|
||||
- 读取 HTML 内容
|
||||
- 调用 `applyPresentationScript(for:)` 生成 JS
|
||||
- JS 执行:`RDReaderBridge.applyPagination(css)` → `setPageMetrics` → 清除并重新应用高亮 → 滚动到目标位置 → 报告进度 → 应用搜索装饰
|
||||
|
||||
### 3.5 固定版式内容加载
|
||||
|
||||
1. 入口:`RDEPUBWebView.loadFixedSpread(parser:spread:...)`
|
||||
2. `RDEPUBFixedLayoutTemplate.html(for:publication:)` 从模板生成 HTML,包含 spread 中每个资源的 `<iframe>`
|
||||
3. `webView.loadHTMLString(html, baseURL: "ss-reader://book/")` 加载
|
||||
4. 模板 JS 加载每个 iframe,检测页面尺寸,计算缩放(auto/page/width 适配模式),定位 iframe
|
||||
5. 所有 iframe 加载完成后 JS 发送 `ssReaderFixedLayoutReady`
|
||||
6. Swift 收到消息后应用搜索装饰并调用 `rendered()`
|
||||
7. 1 秒兜底定时器确保即使 JS 消息丢失也能调用 `rendered()`
|
||||
|
||||
### 3.6 JS 桥接消息处理
|
||||
|
||||
- 入口:`RDEPUBWebView+JavaScriptBridge.swift` 的 `userContentController(_:didReceive:)`
|
||||
- 分发逻辑:
|
||||
- `progressionChanged` → 提取 progression/lastProgression/fragment → `delegate?.epubWebView(_:didUpdateLocation:spineIndex:)`
|
||||
- `selectionChanged` → 提取 text/rangeInfo → 创建 `RDEPUBSelection` → `delegate?.epubWebView(_:didChangeSelection:spineIndex:)`
|
||||
- `internalLink` → 创建 `RDEPUBLocation` → `delegate?.epubWebView(_:didActivateInternalLink:fromSpineIndex:)`
|
||||
- `externalLink` → 提取 URL → `delegate?.epubWebView(_:didActivateExternalLink:)`
|
||||
- `javaScriptError` → `delegate?.epubWebView(_:didLogJavaScriptError:)`
|
||||
- `fixedLayoutReady` → 应用搜索装饰 → `rendered()`
|
||||
|
||||
### 3.7 链接导航拦截
|
||||
|
||||
- 入口:`decidePolicyFor navigationAction`
|
||||
- 逻辑:
|
||||
- `ss-reader://` URL + `.linkActivated` → 内部链接代理回调,取消导航
|
||||
- `http/https/mailto/tel` → 外部链接代理回调,取消导航
|
||||
- 其他 → 允许导航
|
||||
|
||||
## 4. 异常与边界处理
|
||||
|
||||
- `RDEPUBParserError`(7 种,全部 `LocalizedError`,中文描述):
|
||||
- `archiveOpenFailed(URL)`:ZIPFoundation 无法打开 EPUB 文件
|
||||
- `missingContainerXML`:`META-INF/container.xml` 不存在
|
||||
- `missingRootFile`:container.xml 中无 `<rootfile>`
|
||||
- `invalidRootFilePath(String)`:rootfile 路径对应的 OPF 文件不存在
|
||||
- `invalidXML(URL)`:XMLParser 无法创建或解析失败
|
||||
- `missingManifestItem(idref:)`:spine 引用了不存在的 manifest 项
|
||||
- `emptySpine`:构建后 spine 为空
|
||||
- 目录解析:NCX 和 Nav Document 解析器失败时返回空数组,不抛异常,兜底使用 spine 标题
|
||||
- 资源缺失:`RDEPUBResourceURLSchemeHandler` 对缺失的可选资源(字体/图片/CSS/JS)返回空 200,避免 WKWebView 控制台噪音
|
||||
- 分页失败:`RDEPUBPaginator` 导航失败(`didFail` / `didFailProvisional`)时将页数设为 1 并继续下一项
|
||||
- HTML 转纯文本兜底:`RDEPUBHTMLSearchEngine` 在 NSAttributedString 失败时回退到正则去标签
|
||||
- 固定版式 1 秒兜底:`scheduleFixedLayoutReadyFallback()` 确保即使 JS ready 消息丢失也能调用 `rendered()`
|
||||
- 重复请求跳过:`handleReflowableLoad` 在 loadSignature 匹配且已渲染/正在加载时跳过
|
||||
|
||||
## 5. 数据结构与字段映射
|
||||
|
||||
### 5.1 EPUB 元数据
|
||||
|
||||
```
|
||||
RDEPUBParser
|
||||
├── metadata: RDEPUBMetadata
|
||||
│ ├── identifier, title, author, language, version
|
||||
│ ├── layout: RDEPUBLayout (.reflowable | .fixed)
|
||||
│ ├── readingProgression: RDEPUBReadingProgression (.ltr | .rtl | .auto)
|
||||
│ └── spread: String?
|
||||
├── manifest: [String: RDEPUBManifestItem]
|
||||
│ ├── id, href, mediaType
|
||||
│ ├── properties: [String] (nav, scripted, cover-image, rendition:layout-*)
|
||||
│ └── title: String?
|
||||
├── spine: [RDEPUBSpineItem]
|
||||
│ ├── idref -> manifest[id]
|
||||
│ ├── href (normalized), mediaType, title, linear
|
||||
│ ├── pageSpread: RDEPUBPageSpread? (.left | .right | .center)
|
||||
│ └── layout: RDEPUBLayout? (per-item override)
|
||||
└── tableOfContents: [EPUBTableOfContentsItem]
|
||||
├── title, href
|
||||
└── children: [EPUBTableOfContentsItem] (recursive)
|
||||
```
|
||||
|
||||
### 5.2 阅读位置模型
|
||||
|
||||
```
|
||||
RDEPUBLocation
|
||||
├── bookIdentifier, href
|
||||
├── progression: Double? (0..1,章节内起始位置)
|
||||
├── lastProgression: Double? (0..1,章节内结束位置)
|
||||
├── fragment: String? (#anchor)
|
||||
└── navigationProgression (computed: midpoint of progression and lastProgression)
|
||||
```
|
||||
|
||||
### 5.3 视口模型
|
||||
|
||||
```
|
||||
RDEPUBViewport
|
||||
├── resources: [RDEPUBViewportResource]
|
||||
│ ├── href, spineIndex
|
||||
│ ├── progression, lastProgression, fragment
|
||||
├── visiblePageNumber, chapterIndex
|
||||
└── isFixedLayout: Bool
|
||||
```
|
||||
|
||||
### 5.4 渲染请求
|
||||
|
||||
```
|
||||
RDEPUBRenderRequest (enum)
|
||||
├── .reflowable(RDEPUBReflowableRenderRequest)
|
||||
│ ├── spineIndex, href, pageIndex, totalPagesInChapter
|
||||
│ ├── presentation: RDEPUBPresentationStyle
|
||||
│ │ ├── viewportSize, contentInsets
|
||||
│ │ ├── fontSize, lineHeightMultiple
|
||||
│ │ └── themeBackgroundColor, themeTextColor
|
||||
│ ├── targetLocation: RDEPUBLocation?
|
||||
│ ├── highlights: [RDEPUBHighlight]
|
||||
│ └── searchPresentation: RDEPUBSearchPresentation?
|
||||
└── .fixed(RDEPUBFixedRenderRequest)
|
||||
├── spread: EPUBFixedSpread
|
||||
│ └── resources: [EPUBFixedSpreadResource]
|
||||
├── viewportSize, contentInset
|
||||
├── backgroundColorCSS
|
||||
├── fit: RDEPUBFixedLayoutFit (.auto | .page | .width)
|
||||
└── searchPresentation: RDEPUBSearchPresentation?
|
||||
```
|
||||
|
||||
### 5.5 高亮与选择
|
||||
|
||||
```
|
||||
RDEPUBHighlight
|
||||
├── id, bookIdentifier
|
||||
├── location: RDEPUBLocation
|
||||
├── text, rangeInfo (JSON-serialized DOM range)
|
||||
├── color: String, note: String
|
||||
└── createdAt: Date
|
||||
|
||||
RDEPUBSelection
|
||||
├── bookIdentifier
|
||||
├── location: RDEPUBLocation
|
||||
├── text, rangeInfo
|
||||
└── createdAt: Date
|
||||
```
|
||||
|
||||
### 5.6 搜索模型
|
||||
|
||||
```
|
||||
RDEPUBSearchState
|
||||
├── keyword: String
|
||||
├── matches: [RDEPUBSearchMatch]
|
||||
│ ├── href, progression
|
||||
│ ├── previewText, localMatchIndex
|
||||
│ ├── rangeLocation, rangeLength
|
||||
└── currentMatchIndex: Int
|
||||
```
|
||||
|
||||
## 6. 分页与渲染机制
|
||||
|
||||
### 6.1 CSS 多栏分页原理
|
||||
|
||||
- HTML 内容被包裹为 `body > div#ss-reader-viewport > div#ss-reader-content`
|
||||
- `#ss-reader-content` 应用 `-webkit-column-width: <viewportWidth>px`
|
||||
- `html` / `body` 设置 `overflow: hidden`
|
||||
- 页间导航通过 `transform: translate3d(-N*stride, 0, 0)` 实现
|
||||
|
||||
### 6.2 重新布局触发条件
|
||||
|
||||
- `scheduleRelayout()`(60ms 防抖)在以下事件触发:
|
||||
- `window.load`、`window.resize`
|
||||
- `document.fonts.ready`
|
||||
- 每个 `<img>` / `<iframe>` / `<video>` 的 load/error 事件
|
||||
|
||||
### 6.3 选择变化处理
|
||||
|
||||
- `selectionchange` 监听器(120ms 防抖)发送 `ssReaderSelectionChanged` 消息,携带 text、rangeInfo、progression
|
||||
|
||||
## 7. 缓存策略
|
||||
|
||||
- **EPUB 解压缓存**:解压目录按 `文件大小 + 修改时间` 生成缓存 key,已存在则跳过解压
|
||||
- **Load Signature 去重**:每次渲染请求计算签名(含 publication key、layout、spine index、viewport、字号、行高、主题色、目标位置、高亮),相同签名跳过重复加载
|
||||
- **WKWebView 复用**:同一 publication 配置下复用已有 WKWebView 实例,仅切换 publication 时才 teardown
|
||||
- **非持久化数据存储**:`WKWebViewConfiguration().websiteDataStore = .nonPersistent()`,不缓存 Web 内容到磁盘
|
||||
|
||||
## 8. 联调与排查建议
|
||||
|
||||
- 排查 1:EPUB 解析失败
|
||||
- 检查 `parse(epubURL:)` 抛出的 `RDEPUBParserError` 具体类型
|
||||
- 检查 EPUB 文件是否损坏(ZIP 能否正常打开)
|
||||
- 检查 `META-INF/container.xml` 是否存在且格式正确
|
||||
- 排查 2:目录为空
|
||||
- 检查 OPF 中是否有 NCX 或 Nav Document 引用
|
||||
- 检查 NCX 文件路径是否正确(相对于 OPF 目录)
|
||||
- 目录解析失败时会兜底使用 spine 标题
|
||||
- 排查 3:页面内容空白
|
||||
- 检查 `ss-reader://` scheme handler 是否正确映射资源路径
|
||||
- 检查 `RDEPUBResourceURLSchemeHandler` 日志(`RDEPUBWebViewDebug`)
|
||||
- 确认 HTML 文件在解压目录中实际存在
|
||||
- 排查 4:分页页数不正确
|
||||
- 检查 `RDEPUBPaginator` 多次测量结果是否一致
|
||||
- 检查 viewport 尺寸计算是否正确(容器宽度 / pagesPerScreen)
|
||||
- 检查 CSS 分页样式是否正确注入
|
||||
- 排查 5:JS 桥接消息未收到
|
||||
- 确认 `epub-bridge.js` 已正确注入(检查 `WKUserContentController`)
|
||||
- 检查 JS 控制台是否有错误
|
||||
- 固定版式检查是否收到 `ssReaderFixedLayoutReady`,否则看 1 秒兜底定时器
|
||||
- 排查 6:内部链接不跳转
|
||||
- 检查链接 href 是否以 `ss-reader://` 开头
|
||||
- 检查 `decidePolicyFor navigationAction` 中的链接类型判断
|
||||
- 确认 `RDEPUBLocation` 构建是否正确(href + fragment)
|
||||
264
Doc/EPUBTextRendering_功能实现逻辑.md
Normal file
264
Doc/EPUBTextRendering_功能实现逻辑.md
Normal file
@ -0,0 +1,264 @@
|
||||
# EPUBTextRendering 功能实现逻辑
|
||||
|
||||
## 1. 范围与目标
|
||||
|
||||
- 代码范围:`Sources/RDReaderView/EPUBTextRendering/`(6 个 Swift 文件)
|
||||
- 目标:说明文本渲染路径如何将 EPUB HTML 转换为 NSAttributedString、按字符范围分页、构建书籍模型,并支持全文搜索与 textReflowable 标注定位。
|
||||
- 主链路关键词:`RDEPUBParser HTML -> DTCoreText 渲染 -> 片段标记注入/提取 -> CoreText 分页 -> RDEPUBTextBook -> 页面查找/位置转换`。
|
||||
- 适用范围:仅用于 `textReflowable` 阅读配置文件(纯文本可重排 EPUB,如小说)。固定版式和交互式 EPUB 使用 WebView 渲染路径。
|
||||
|
||||
## 2. 关键对象职责
|
||||
|
||||
### 2.1 渲染协议 `RDEPUBTextRenderer`
|
||||
|
||||
- 文件:`EPUBTextRendering/RDEPUBTextRenderer.swift`
|
||||
- 职责:
|
||||
- 定义单方法协议:`renderChapter(html:baseURL:style:) throws -> RDEPUBRenderedChapterContent`
|
||||
- 作为渲染后端的抽象点,当前仅 `RDEPUBDTCoreTextRenderer` 一个实现
|
||||
|
||||
### 2.2 DTCoreText 渲染器 `RDEPUBDTCoreTextRenderer`
|
||||
|
||||
- 文件:`EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift`
|
||||
- 职责:
|
||||
- 实现 `RDEPUBTextRenderer` 协议
|
||||
- 使用 `DTHTMLAttributedStringBuilder` 将 HTML 转为 NSAttributedString
|
||||
- 渲染后处理:提取片段标记、统一字体/行间距/颜色
|
||||
- DTCoreText 不可用时回退到纯文本渲染
|
||||
|
||||
### 2.3 渲染支持工具 `RDEPUBTextRendererSupport`
|
||||
|
||||
- 文件:`EPUBTextRendering/RDEPUBTextRendererSupport.swift`
|
||||
- 职责:
|
||||
- 片段标记注入:扫描 HTML 中带 `id` 属性的元素,在其前注入 `${id=<fragmentID>}` 不可见标记
|
||||
- 片段偏移提取:从 NSAttributedString 中提取标记位置并删除标记
|
||||
- 字体标准化:保留 HTML 中的粗体/斜体 trait,统一使用配置的基础字体族和字号
|
||||
- 段落样式创建:强制应用配置的行间距和段间距
|
||||
- 兜底渲染:DTCoreText 不可用时从原始 HTML 字符串生成纯文本 NSAttributedString
|
||||
|
||||
### 2.4 分页支持 `NSAttributedString.ss_pageRanges(size:)`
|
||||
|
||||
- 文件:`EPUBTextRendering/RDEPUBTextPaginationSupport.swift`
|
||||
- 职责:
|
||||
- 基于 CoreText framesetter 的分页
|
||||
- 从位置 0 开始,反复创建 CTFrame 测量可见字符范围
|
||||
- 返回 `[NSRange]`,每个 range 对应一页
|
||||
- 安全保护:`visibleRange.length == 0` 时 break 防止死循环
|
||||
|
||||
### 2.5 书籍构建器 `RDEPUBTextBookBuilder`
|
||||
|
||||
- 文件:`EPUBTextRendering/RDEPUBTextBookBuilder.swift`
|
||||
- 职责:
|
||||
- 遍历 publication 的 linear spine 项(仅 HTML/XHTML 类型)
|
||||
- 对每项:HTML 标准化 → 渲染 → 跳过空白封面/标题页 → 分页 → 构建 `RDEPUBTextPage` 数组
|
||||
- 汇总所有章节为 `RDEPUBTextBook`
|
||||
- 后台队列执行,主线程回调结果
|
||||
|
||||
### 2.6 全文搜索引擎 `RDEPUBTextSearchEngine`
|
||||
|
||||
- 文件:`EPUBTextRendering/RDEPUBTextSearchEngine.swift`
|
||||
- 职责:
|
||||
- 实现 `RDEPUBSearchEngine` 协议
|
||||
- 在已渲染的 NSAttributedString 纯文本上执行线性搜索
|
||||
- 返回 `RDEPUBSearchMatch` 数组(含 href、progression、预览文本、匹配位置)
|
||||
|
||||
## 3. 主流程(代码级)
|
||||
|
||||
### 3.1 完整渲染-分页流程
|
||||
|
||||
1. 入口:`RDEPUBTextBookBuilder.build(parser:publication:pageSize:style:)`
|
||||
2. 遍历 `publication.spine` 中 `item.linear == true` 的项
|
||||
3. 过滤仅处理 mediaType 包含 "html" 或 "xhtml" 的项
|
||||
4. 读取 `parser.htmlString(forRelativePath: item.href)` 获取原始 HTML
|
||||
5. HTML 标准化:
|
||||
- 移除中文分页标记:`<hr lang="zh-CN">分页符</hr>`
|
||||
- `\r` 替换为 `\n`
|
||||
- 连续 `\n` 合并为单个
|
||||
6. 检查是否跳过(空白且 href 包含 "cover" 或 "title")
|
||||
7. 调用 `renderer.renderChapter(html:baseURL:style:)` 渲染
|
||||
|
||||
### 3.2 DTCoreText 渲染管线
|
||||
|
||||
1. 片段标记注入:`RDEPUBTextRendererSupport.injectFragmentMarkers(into: html)`
|
||||
- 正则 `(<[^>]+\sid="([^"]+)"[^>]*>)` 匹配带 id 的元素
|
||||
- 在匹配标签前插入 `${id=<fragmentID>}` 文本标记
|
||||
2. UTF-8 编码为 Data
|
||||
3. `DTHTMLAttributedStringBuilder(html:options:documentAttributes:)` 执行渲染
|
||||
4. DTCoreText 选项:
|
||||
- `NSTextSizeMultiplierDocumentOption: 1.0`
|
||||
- `DTDefaultFontFamily` / `DTDefaultFontName` / `DTDefaultFontSize`:来自 `style.font`
|
||||
- `DTDefaultLineHeightMultiplier`:`(font.lineHeight + lineSpacing) / max(font.lineHeight, 1)`
|
||||
- `DTUseiOS6Attributes: true`:生成 UIKit 兼容属性
|
||||
- `NSBaseURLDocumentOption`:资源相对路径解析
|
||||
- `DTDefaultTextColor`:可选文本颜色
|
||||
5. 后处理:
|
||||
- 提取片段偏移并删除标记
|
||||
- `normalizeReadingAttributes`:遍历每个属性 run,统一字体(保留粗/斜体 trait)、强制行间距/段间距、覆盖前景色
|
||||
|
||||
### 3.3 CoreText 分页算法
|
||||
|
||||
1. 从 `NSAttributedString` 创建 `CTFramesetter`
|
||||
2. 创建页面大小的 `CGPath`
|
||||
3. 从 location = 0 开始循环:
|
||||
- 创建 CTFrame(range 从当前 location 到字符串末尾)
|
||||
- `CTFrameGetVisibleStringRange(frame)` 获取可见字符数
|
||||
- 追加 `NSRange(location: currentLocation, length: visibleRange.length)`
|
||||
- location += visibleRange.length
|
||||
4. 循环终止条件:`location + visibleRange.length >= attributedString.length`
|
||||
5. 安全保护:`visibleRange.length == 0` 时 break
|
||||
|
||||
### 3.4 页面与位置互转
|
||||
|
||||
**Location → Page Number**(`RDEPUBTextBook.pageNumber(for:resolver:bookIdentifier:)`):
|
||||
1. 有 fragment 时:用 `fragmentOffsets` 查找字符偏移,再定位到对应页面
|
||||
2. 无 fragment 时:用 `navigationProgression`(progression 和 lastProgression 的中点)计算字符偏移
|
||||
3. 遍历 pages 找到 `pageStartOffset <= offset < pageEndOffset` 的页面
|
||||
|
||||
**Page Number → Location**(`RDEPUBTextBook.location(forPageNumber:bookIdentifier:)`):
|
||||
1. 通过 page number 查找 `RDEPUBTextPage`
|
||||
2. 计算 `progression = pageStartOffset / totalCharacters`
|
||||
3. 计算 `lastProgression = pageEndOffset / totalCharacters`
|
||||
4. 构建 `RDEPUBLocation`(href + progression + lastProgression)
|
||||
|
||||
### 3.5 标注定位与恢复
|
||||
|
||||
textReflowable 路径不复用 WebView 的 DOM range,而使用章节字符偏移作为精确锚点:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "text-offset",
|
||||
"href": "chapter.xhtml",
|
||||
"start": 1234,
|
||||
"end": 1260
|
||||
}
|
||||
```
|
||||
|
||||
1. `RDEPUBTextContentView` 保留当前 `RDEPUBTextPage`,从 `page.pageStartOffset` 将页内 `UITextView.selectedRange` 映射为章节全局 `start/end`
|
||||
2. 生成 `RDEPUBSelection` 后回传 `RDEPUBReaderController`,共享层继续负责创建、去重、持久化、列表和跳转
|
||||
3. 页面重绘时,`RDEPUBTextContentView` 仅消费 `kind == "text-offset"` 的 rangeInfo,计算当前页 `[pageStartOffset, pageEndOffset]` 与标注 `[start, end)` 的重叠
|
||||
4. `highlight` 样式叠加 `.backgroundColor`,`underline` 样式叠加 `.underlineStyle` 和 `.underlineColor`
|
||||
5. 字号、行高、主题变化导致重新分页后,标注仍以章节全局字符偏移恢复到新的页面切片
|
||||
|
||||
## 4. 异常与边界处理
|
||||
|
||||
- `RDEPUBTextRenderingError.htmlEncodingFailed`:HTML 字符串无法编码为 UTF-8 Data(极罕见)
|
||||
- DTCoreText builder 返回 nil:回退到 `fallbackAttributedString`,生成纯文本(含 HTML 标签原文),降级体验
|
||||
- 空白章节跳过:href 包含 "cover" 或 "title" 且渲染后文本为空的章节被跳过
|
||||
- 分页零结果兜底:`pageRanges` 为空但 content 非空时,整个章节作为一页
|
||||
- CoreText 零高度安全保护:`visibleRange.length == 0` 时 break 防止无限循环
|
||||
- Token 取消:`paginationToken`(UUID)防止异步完成后的过期结果污染 UI
|
||||
|
||||
## 5. 数据结构与字段映射
|
||||
|
||||
### 5.1 渲染配置
|
||||
|
||||
```swift
|
||||
RDEPUBTextRenderStyle
|
||||
├── font: UIFont // 基础字体
|
||||
├── lineSpacing: CGFloat // 额外行间距
|
||||
├── textColor: UIColor? // 文本颜色(nil 时保持 HTML 原始颜色)
|
||||
└── backgroundColor: UIColor? // 背景色
|
||||
```
|
||||
|
||||
### 5.2 渲染输出
|
||||
|
||||
```swift
|
||||
RDEPUBRenderedChapterContent
|
||||
├── attributedString: NSAttributedString // 渲染后的富文本
|
||||
└── fragmentOffsets: [String: Int] // fragment ID -> 字符偏移映射
|
||||
```
|
||||
|
||||
### 5.3 页面模型
|
||||
|
||||
```swift
|
||||
RDEPUBTextPage
|
||||
├── absolutePageIndex: Int // 全局页码
|
||||
├── chapterIndex: Int // 章节索引
|
||||
├── spineIndex: Int // spine 索引
|
||||
├── href: String // 章节 href
|
||||
├── chapterTitle: String? // 章节标题
|
||||
├── pageIndexInChapter: Int // 章节内页码
|
||||
├── totalPagesInChapter: Int // 章节总页数
|
||||
├── content: NSAttributedString // 本页的富文本切片
|
||||
├── contentRange: NSRange // 在整章富文本中的 range
|
||||
├── pageStartOffset: Int // 起始字符偏移
|
||||
└── pageEndOffset: Int // 结束字符偏移
|
||||
```
|
||||
|
||||
### 5.4 章节模型
|
||||
|
||||
```swift
|
||||
RDEPUBTextChapter
|
||||
├── chapterIndex: Int
|
||||
├── spineIndex: Int
|
||||
├── href: String
|
||||
├── title: String?
|
||||
├── attributedContent: NSAttributedString // 整章渲染后的富文本
|
||||
├── fragmentOffsets: [String: Int]
|
||||
└── pages: [RDEPUBTextPage]
|
||||
```
|
||||
|
||||
### 5.5 书籍模型
|
||||
|
||||
```swift
|
||||
RDEPUBTextBook
|
||||
├── chapters: [RDEPUBTextChapter]
|
||||
├── pages: [RDEPUBTextPage] // 扁平化页面列表
|
||||
├── page(at:) -> RDEPUBTextPage? // 1-based 页码查找
|
||||
├── pageNumber(for:resolver:bookIdentifier:) -> Int? // Location -> 页码
|
||||
└── location(forPageNumber:bookIdentifier:) -> RDEPUBLocation? // 页码 -> Location
|
||||
```
|
||||
|
||||
## 6. 与 EPUBCore 层的集成
|
||||
|
||||
### 消费的数据
|
||||
|
||||
| 来源 | 数据 | 用途 |
|
||||
|------|------|------|
|
||||
| `RDEPUBParser` | `htmlString(forRelativePath:)` | 读取章节 HTML 内容 |
|
||||
| `RDEPUBParser` | `fileURL(forRelativePath:)` | 计算 DTCoreText 的 baseURL |
|
||||
| `RDEPUBPublication` | `spine` | 遍历 linear HTML/XHTML 项 |
|
||||
| `RDEPUBPublication` | `tableOfContents` | 匹配章节标题 |
|
||||
| `RDEPUBSpineItem` | `href`, `mediaType`, `linear`, `title` | 过滤和标识章节 |
|
||||
| `RDEPUBResourceResolver` | `normalizedLocation` / `normalizedHref` | 位置查找时标准化 href |
|
||||
| `RDEPUBLocation` | `href`, `fragment`, `navigationProgression` | 页面定位 |
|
||||
| `RDEPUBSearchEngine` | 协议接口 | `RDEPUBTextSearchEngine` 实现此协议 |
|
||||
| `RDEPUBHighlight` | `style`, `rangeInfo`, `color`, `note` | text-offset 标注恢复与页内绘制 |
|
||||
|
||||
## 7. 配置参数
|
||||
|
||||
- **字号**:`RDEPUBReaderConfiguration.fontSize`,默认 15pt
|
||||
- **行高倍数**:`lineHeightMultiple`,默认 1.6(行间距 = font.lineHeight * 0.6,最低 4pt)
|
||||
- **内容边距**:`reflowableContentInsets`,默认 (40, 16, 40, 16),影响有效页面尺寸
|
||||
- **主题色**:通过 `RDEPUBReaderTheme` 的 `contentTextColor` / `contentBackgroundColor` 传入
|
||||
- **渲染引擎**:`RDEPUBTextRenderingEngine`,当前仅 `.dtCoreText`
|
||||
|
||||
## 8. 性能特征
|
||||
|
||||
- **全文渲染**:每章一次性渲染为完整的 NSMutableAttributedString,无分块/懒加载
|
||||
- **内存**:`RDEPUBTextBook` 同时持有 chapters(含完整 attributedContent)和 pages(含子串切片),存在一定程度的内存重复
|
||||
- **后台执行**:`build` 方法在 `DispatchQueue.global(qos: .userInitiated)` 执行,不阻塞主线程
|
||||
- **搜索性能**:线性扫描每章的 `attributedContent.string`,无索引,搜索时间与总文本量线性相关
|
||||
- **无分页缓存**:字号/行高变化时全书重新渲染和分页,无增量更新
|
||||
|
||||
## 9. 联调与排查建议
|
||||
|
||||
- 排查 1:渲染后文本为空
|
||||
- 检查 `parser.htmlString(forRelativePath:)` 是否返回非 nil
|
||||
- 检查 spine 项的 mediaType 是否包含 "html" 或 "xhtml"
|
||||
- 空白封面/标题页会被 `shouldSkipChapter` 跳过
|
||||
- 排查 2:分页页数不正确
|
||||
- 检查 `pageSize` 计算是否正确(viewport - contentInsets)
|
||||
- 检查 DTCoreText 行高倍数是否正确应用
|
||||
- 检查 CoreText 分页是否有 `visibleRange.length == 0` 的异常情况
|
||||
- 排查 3:字体/颜色不正确
|
||||
- 检查 `RDEPUBTextRenderStyle` 配置
|
||||
- 检查 `normalizeReadingAttributes` 是否正确保留粗/斜体 trait
|
||||
- 检查 `foregroundColor` 覆盖逻辑
|
||||
- 排查 4:Fragment 定位失败
|
||||
- 检查 `injectFragmentMarkers` 是否正确注入标记
|
||||
- 检查 `extractFragmentOffsets` 是否正确提取偏移
|
||||
- 确认标记在 DTCoreText 渲染后仍存在于 attributed string 中
|
||||
- 排查 5:搜索结果不准确
|
||||
- 搜索基于已渲染的纯文本(去 HTML 标签),非原始 HTML
|
||||
- 确认 `attributedContent.string` 包含预期的文本内容
|
||||
- 搜索为大小写不敏感的线性扫描
|
||||
307
Doc/EPUBUI_功能实现逻辑.md
Normal file
307
Doc/EPUBUI_功能实现逻辑.md
Normal file
@ -0,0 +1,307 @@
|
||||
# EPUBUI 功能实现逻辑
|
||||
|
||||
## 1. 范围与目标
|
||||
|
||||
- 代码范围:`Sources/RDReaderView/EPUBUI/`(16 个 Swift 文件)
|
||||
- 目标:说明 EPUBUI 如何作为开箱即用的阅读器 UI 层,协调 EPUBCore 解析、EPUBTextRendering 文本渲染、RDReaderView 分页容器,提供完整的阅读体验(工具栏、目录、高亮批注、设置面板、阅读位置持久化、搜索)。
|
||||
- 主链路关键词:`RDEPUBReaderController.init -> 解析 EPUB -> 分页 -> 渲染 -> 用户交互(翻页/工具栏/设置/高亮/搜索)-> 持久化`。
|
||||
|
||||
## 2. 关键对象职责
|
||||
|
||||
### 2.1 主控制器 `RDEPUBReaderController`
|
||||
|
||||
- 文件:`EPUBUI/RDEPUBReaderController.swift`(~1255 行)
|
||||
- 入口方法:`init(epubURL:configuration:persistence:)`
|
||||
- 职责:
|
||||
- 加载 EPUB:后台解析 → 应用分页 → 恢复阅读位置
|
||||
- 实现 `RDReaderDataSource` / `RDReaderDelegate` 为容器提供数据
|
||||
- 实现 `RDEPUBWebContentViewDelegate` 接收 WebView 事件
|
||||
- 管理工具栏显隐、目录面板、高亮管理、设置面板、搜索
|
||||
- 持久化阅读位置、高亮、用户设置
|
||||
- 对外暴露 `RDEPUBReaderDelegate` 回调宿主 App
|
||||
|
||||
### 2.2 配置 `RDEPUBReaderConfiguration`
|
||||
|
||||
- 文件:`EPUBUI/RDEPUBReaderConfiguration.swift`
|
||||
- 13 个配置项:
|
||||
- `fontSize`(默认 15)、`lineHeightMultiple`(默认 1.6)
|
||||
- `displayType`(默认 .pageCurl)、`landscapeDualPageEnabled`(默认 true)
|
||||
- `showsTableOfContents`(默认 true)、`allowsHighlights`(默认 true)、`showsSettingsPanel`(默认 true)
|
||||
- `reflowableContentInsets`(默认 40/16/40/16)、`fixedContentInset`(默认 .zero)
|
||||
- `theme`(默认 .light)
|
||||
- `fixedLayoutFit`(默认 .page)、`fixedLayoutSpreadMode`(默认 .automatic)
|
||||
- `textRenderingEngine`(默认 .dtCoreText)
|
||||
- 变更检测:
|
||||
- `requiresRepagination`:fontSize / lineHeightMultiple / contentInsets / fixedLayoutFit / fixedLayoutSpreadMode / textRenderingEngine 变化 → 完整重新分页
|
||||
- `requiresVisibleRefresh`:theme 变化 → 刷新可见内容(不重新分页)
|
||||
|
||||
### 2.3 委托协议 `RDEPUBReaderDelegate`
|
||||
|
||||
- 文件:`EPUBUI/RDEPUBReaderDelegate.swift`
|
||||
- 10 个可选方法(全部有默认空实现):
|
||||
- `didOpen`:成功打开出版物
|
||||
- `didUpdateLocation`:翻页或滚动时位置更新
|
||||
- `didReachEnd`:到达最后一页
|
||||
- `didChangeSelection`:文本选择变化或清除
|
||||
- `didUpdateHighlights`:高亮增删改
|
||||
- `didUpdateSearchResult`:搜索状态变化
|
||||
- `didChangeCurrentSearchMatch`:当前搜索匹配项变化
|
||||
- `didUpdateCurrentTableOfContentsItem`:翻页时匹配的目录项
|
||||
- `didActivateExternalLink`:外部链接点击
|
||||
- `didFailWithError`:解析或分页错误
|
||||
|
||||
### 2.4 持久化 `RDEPUBReaderPersistence`
|
||||
|
||||
- 文件:`EPUBUI/RDEPUBReaderPersistence.swift`
|
||||
- 协议定义 6 个方法:loadLocation / saveLocation / loadHighlights / saveHighlights / loadReaderSettings / saveReaderSettings
|
||||
- 默认实现 `RDEPUBUserDefaultsPersistence`:
|
||||
- 位置:`ssreader.epub.location.<bookIdentifier>`(JSON 编码)
|
||||
- 高亮:`ssreader.epub.highlights.<bookIdentifier>`(JSON 编码)
|
||||
- 设置:`ssreader.epub.settings`(全局,非按书)
|
||||
|
||||
### 2.5 主题 `RDEPUBReaderTheme`
|
||||
|
||||
- 文件:`EPUBUI/RDEPUBReaderTheme.swift`
|
||||
- 6 个颜色属性:contentBackgroundColor、contentTextColor、toolBackgroundColor、toolControlTextColor、toolControlBorderUnselectColor、toolLineColor
|
||||
- 6 个内置预设:`.light`、`.dark`、`.yellow`、`.green`、`.pink`、`.blue`
|
||||
- 计算属性 `themeBackgroundColorCSS` / `themeTextColorCSS` 用于 Web 渲染路径
|
||||
|
||||
### 2.6 设置面板 `RDEPUBReaderSettingsViewController`
|
||||
|
||||
- 文件:`EPUBUI/RDEPUBReaderSettingsViewController.swift`
|
||||
- 5 个控制项:
|
||||
- 亮度滑块(0-1)
|
||||
- 字号 A-/A+(范围 12-36,步长 1)
|
||||
- 行高分段(紧凑 1.3 / 标准 1.6 / 宽松 1.9)
|
||||
- 显示模式分段(仿真 / 横滑 / 竖滑 / 覆盖)
|
||||
- 主题选择(6 个圆形色块按钮)
|
||||
- 所有变更通过闭包实时回调
|
||||
|
||||
### 2.7 内容视图
|
||||
|
||||
- `RDEPUBTextContentView`(`EPUBUI/RDEPUBTextContentView.swift`):textReflowable 路径,UITextView 展示富文本,支持系统选区、标注菜单、用户标注叠加和搜索高亮叠加
|
||||
- `RDEPUBWebContentView`(`EPUBUI/RDEPUBWebContentView.swift`):web 路径,包装 `RDEPUBWebView`,转发位置、选区、标注菜单、链接和 JS 错误事件
|
||||
|
||||
### 2.8 其他 UI 组件
|
||||
|
||||
- `RDEPUBReaderTopToolView`:顶部导航栏(返回按钮 + 标题)
|
||||
- `RDEPUBReaderBottomToolView`:底部工具栏(目录 / 批注 / 标注 / 设置 4 个按钮)
|
||||
- `RDEPUBReaderChapterListController`:目录列表(modal UITableViewController,当前项高亮 systemBlue)
|
||||
- `RDEPUBReaderHighlightsViewController`:标注管理(全部 / 批注 / 划线过滤,列表 + 编辑备注 + 删除 + 跳转,空态按过滤条件提示)
|
||||
|
||||
## 3. 主流程(代码级)
|
||||
|
||||
### 3.1 初始化与加载
|
||||
|
||||
1. `init(epubURL:configuration:persistence:)`:
|
||||
- 加载持久化的阅读设置,叠加到 configuration
|
||||
- 恢复屏幕亮度
|
||||
2. `viewDidLoad`:
|
||||
- 设置背景色
|
||||
- `setupReaderView()`:添加 RDReaderView 全屏约束,注册 `RDEPUBTextContentView` 和 `RDEPUBWebContentView`
|
||||
- `setupLoadingIndicator()`、`setupErrorLabel()`
|
||||
3. `viewDidAppear`:调用 `startInitialLoadIfNeeded()`
|
||||
4. 加载序列:
|
||||
- `loadPublication()` → 读取持久化位置
|
||||
- 后台 `RDEPUBParser.parse(epubURL:)` 解析
|
||||
- 主线程 `applyParsedPublication()` → `paginatePublication()`
|
||||
|
||||
### 3.2 分页流程
|
||||
|
||||
`paginatePublication(restoreLocation:)` 根据 readingProfile 分三条路径:
|
||||
|
||||
**textReflowable 路径**:
|
||||
1. 后台队列:`RDEPUBTextBookBuilder.build(parser:publication:pageSize:style:)`
|
||||
2. 主线程 `applyTextBook(textBook:)`:保存 textBook,reloadData
|
||||
|
||||
**fixed layout 路径**:
|
||||
1. 创建快照:`pageCounts: Array(repeating: 1, count: publication.spine.count)`
|
||||
2. `applyPaginationSnapshot(snapshot:restoreLocation:)`
|
||||
|
||||
**web interactive 路径**:
|
||||
1. `RDEPUBPaginator.calculate(parser:hostingView:presentation:completion:)`
|
||||
2. 回调收到页数数组后 `applyPaginationSnapshot(snapshot:restoreLocation:)`
|
||||
|
||||
三条路径汇合 `finishPagination(restoreLocation:)`:
|
||||
- `readerView.reloadData()`
|
||||
- `restoreReadingLocation(restoreLocation)`
|
||||
|
||||
### 3.3 页面内容工厂
|
||||
|
||||
`pageContentView(readerView:pageNum:containerView:)`:
|
||||
- 有 textBook → 出队/创建 `RDEPUBTextContentView`,调用 `configure(page:pageNumber:totalPages:configuration:searchState:)`
|
||||
- 无 textBook → 出队/创建 `RDEPUBWebContentView`,调用 `configure(publication:request:pageNumber:totalPages:theme:)`
|
||||
|
||||
`pageIdentifier(readerView:pageNum:)`:返回对应类名字符串用于 cell 出队
|
||||
|
||||
### 3.4 工具栏交互
|
||||
|
||||
- RDReaderView 内部处理点击手势,中心 1/3 区域切换工具栏
|
||||
- 顶部工具栏:返回按钮 pop/dismiss,标题设为 metadata.title
|
||||
- 底部工具栏:4 个按钮按配置 flags 控制可见性
|
||||
- 目录按钮 → present `RDEPUBReaderChapterListController`(.pageSheet)
|
||||
- 批注按钮 → present `RDEPUBReaderHighlightsViewController`(.pageSheet)
|
||||
- 标注按钮 → `presentAnnotationActionSheet(for:)`(需有 currentSelection,否则 disabled),可创建高亮、划线或带 note 的批注
|
||||
- 设置按钮 → present `RDEPUBReaderSettingsViewController`(.pageSheet)
|
||||
|
||||
### 3.5 设置变更处理
|
||||
|
||||
- 字号变化:`configuration.fontSize = newValue` → `requiresRepagination` → `repaginatePreservingCurrentLocation()`
|
||||
- 行高变化:同上
|
||||
- 显示模式变化:`readerView.switchReaderDisplayType()` 重建容器
|
||||
- 主题变化:`configuration.theme = newValue` → `requiresVisibleRefresh` → `refreshVisibleContentPreservingLocation()`
|
||||
- 亮度变化:`UIScreen.main.brightness = value` + 持久化
|
||||
|
||||
### 3.6 标注管理
|
||||
|
||||
- 创建:`addAnnotation(from:style:color:note:)` → 从 currentSelection 构建 → 去重 → 追加到 activeHighlights → 持久化 → 刷新可见内容
|
||||
- 兼容入口:`addHighlight(from:color:note:)` 仍保留,内部按 `.highlight` 创建
|
||||
- 样式:`RDEPUBHighlightStyle.highlight` 使用背景色,`RDEPUBHighlightStyle.underline` 使用下划线;旧数据缺少 style 时默认按普通高亮解码
|
||||
- 批注:note 非空时同一条 `RDEPUBHighlight` 同时承担批注语义
|
||||
- 删除:`removeHighlight(id:)` → 移除 → 持久化 → 刷新
|
||||
- 编辑备注:`updateHighlightNote(id:note:)` → 更新 → 持久化 → 刷新
|
||||
- 跳转:`go(toHighlightID:animated:)` → 从高亮 location 解析页码 → `transitionToPage`
|
||||
- WebView 选区:`epub-bridge.js` 生成 `dom-range` rangeInfo,Web 内容通过 payload 的 `style` 字段恢复高亮或划线
|
||||
- DT 选区:`RDEPUBTextContentView` 把页内 selectedRange 映射为章节全局 text-offset rangeInfo,并在当前页与标注范围重叠时叠加背景或下划线
|
||||
- 选区菜单:WebView 与 DT 路径都通过自定义菜单动作收口到 `拷贝 / 高亮 / 批注`,动作最终复用同一套创建与批注输入流程
|
||||
|
||||
### 3.7 书签管理
|
||||
|
||||
- 创建/切换:`toggleBookmark(note:)` 基于 `currentVisibleLocation()` 生成当前位置书签;若同一语义位置已存在书签则执行取消
|
||||
- 位置范围:书签使用 `RDEPUBLocation`,持久化字段包含 `href / fragment / progression`,不依赖正文选区
|
||||
- 当前位置命中:优先比较标准化后的 `href`,若双方都有 `fragment` 则直接比较;否则按 `navigationProgression` 容差判断(fixed-layout 0.01,reflowable 0.05)
|
||||
- 标题补全:优先复用当前 TOC 项标题,否则根据扁平目录用 href 反查章节标题
|
||||
- 列表管理:底部工具栏 `书签` 入口弹出 `RDEPUBReaderBookmarksViewController`,支持按创建时间倒序展示、跳转与删除
|
||||
- 顶部状态:TopToolView 右上角书签按钮显示当前页是否已加书签,点击可直接添加/取消
|
||||
- 持久化:每次新增/删除都会通过 `RDEPUBReaderPersistence.saveBookmarks(_:for:)` 按书保存,并回调 `didUpdateBookmarks`
|
||||
|
||||
### 3.8 搜索流程
|
||||
|
||||
1. `search(keyword:)`:标准化输入
|
||||
2. textBook 路径:`RDEPUBTextSearchEngine.search(keyword:)`
|
||||
3. web 路径:`RDEPUBHTMLSearchEngine.search(keyword:)`
|
||||
4. 存储结果到 `searchState`,导航到第一个匹配
|
||||
5. `searchNext()` / `searchPrevious()`:循环推进 currentMatchIndex 并导航
|
||||
6. `clearSearch()`:清除状态,刷新可见内容
|
||||
|
||||
### 3.9 阅读位置持久化
|
||||
|
||||
**保存时机**:
|
||||
- 每次翻页:`pageNum(readerView:pageNum:)` → `persist(location:)`
|
||||
- WebView 滚动:`epubWebContentView(_:didUpdateLocation:)` → `persist(location:)`
|
||||
|
||||
**恢复时机**:
|
||||
- 初始加载:`loadPublication()` 读取 → 传递到 `finishPagination()` → `restoreReadingLocation()`
|
||||
- 显式导航:`go(to:)` / `go(toTableOfContentsHref:)` / `go(toHighlightID:)` / 搜索导航
|
||||
|
||||
**恢复逻辑**:
|
||||
- textBook 路径:`textBook.pageNumber(for:resolver:bookIdentifier:)` → `readerView.transitionToPage`
|
||||
- readingSession 路径:`readingSession.queueNavigation(to:)` → `transitionToPage`
|
||||
|
||||
### 3.9 横竖屏适配
|
||||
|
||||
- `viewWillTransition(to:with:)`:等待旋转过渡完成后,统一触发 viewport 变化处理
|
||||
- `viewDidLayoutSubviews`:用于补齐分屏、多窗口、safe area 变化等非旋转型视口变化
|
||||
- 控制器内部使用 viewport signature 做去重,避免一次旋转触发多次正文重分页
|
||||
- RDReaderView 的 `readerViewOrientationWillChange` 仅保留容器级双页布局和 pageCurl 重建职责,不再直接驱动 EPUBUI 重新分页
|
||||
|
||||
## 4. 异常与边界处理
|
||||
|
||||
- EPUB 解析失败:显示 errorLabel,delegate 收到 `didFailWithError`
|
||||
- 分页失败:主线程 `handle(error:)` 处理
|
||||
- `paginationToken` 防竞态:每次加载/分页生成新 UUID,后台完成时校验 token 一致才应用结果
|
||||
- 标注去重:`addAnnotation` 检查相同 location、text、rangeInfo 和 style 是否已存在标注
|
||||
- 自定义主题不可持久化:仅 6 个内置 preset 可序列化,自定义主题仅当前会话有效
|
||||
- 目录页码缺失:`flattenedTableOfContents` 中 pageNumber 可能为 nil(无法定位到对应页面的 TOC 项)
|
||||
|
||||
## 5. 数据结构与字段映射
|
||||
|
||||
### 5.1 持久化键值
|
||||
|
||||
| 数据 | UserDefaults Key | 编码 |
|
||||
|------|-----------------|------|
|
||||
| 阅读位置 | `ssreader.epub.location.<bookIdentifier>` | JSON → `RDEPUBLocation` |
|
||||
| 高亮列表 | `ssreader.epub.highlights.<bookIdentifier>` | JSON → `[RDEPUBHighlight]` |
|
||||
| 书签列表 | `ssreader.epub.bookmarks.<bookIdentifier>` | JSON → `[RDEPUBBookmark]` |
|
||||
| 阅读设置 | `ssreader.epub.settings` | JSON → `RDEPUBReaderSettings` |
|
||||
|
||||
### 5.2 Book Identifier
|
||||
|
||||
- 优先使用 `parser.metadata.identifier`
|
||||
- 回退使用 `epubURL.lastPathComponent`
|
||||
|
||||
### 5.3 设置快照
|
||||
|
||||
```swift
|
||||
RDEPUBReaderSettings (Codable)
|
||||
├── brightness: CGFloat
|
||||
├── fontSize: CGFloat
|
||||
├── lineHeightMultiple: CGFloat
|
||||
├── displayMode: RDEPUBReaderDisplayMode
|
||||
└── themePreset: RDEPUBReaderThemePreset?
|
||||
```
|
||||
|
||||
### 5.4 目录扁平化项
|
||||
|
||||
```swift
|
||||
RDEPUBReaderTableOfContentsItem
|
||||
├── title: String
|
||||
├── href: String
|
||||
├── depth: Int // 缩进层级
|
||||
└── pageNumber: Int? // 可能为 nil
|
||||
```
|
||||
|
||||
## 6. 视图绑定规则
|
||||
|
||||
- 规则 1:内容视图通过 class name 注册到 RDReaderView,出队时根据 textBook 是否存在选择类型
|
||||
- 规则 2:RDEPUBTextContentView 展示 attributedText + 搜索高亮叠加 + 页码标签("N / M")
|
||||
- 规则 3:RDEPUBWebContentView 包装 RDEPUBWebView + 页码标签
|
||||
- 规则 4:顶部工具栏固定提供返回与书签切换入口;底部工具栏提供目录、书签、标注、设置等管理入口
|
||||
- 规则 5:设置面板所有变更实时生效(闭包回调),无需确认按钮
|
||||
|
||||
## 7. 通知协作与回调机制
|
||||
|
||||
### 外部通信(Delegate)
|
||||
|
||||
- `RDEPUBReaderDelegate`:11 个可选方法,覆盖打开、位置更新、到达末尾、选择变化、高亮更新、书签更新、搜索更新、目录项更新、外部链接、错误
|
||||
|
||||
### 内部通信(闭包)
|
||||
|
||||
- `RDEPUBReaderTopToolView.onBack / onToggleBookmark`
|
||||
- `RDEPUBReaderBottomToolView.onShowTableOfContents / onShowBookmarks / onShowHighlights / onAddHighlight / onShowSettings`
|
||||
- `RDEPUBReaderChapterListController.onSelectItem`
|
||||
- `RDEPUBReaderHighlightsViewController.onSelectHighlight / onUpdateHighlight / onDeleteHighlight`
|
||||
- `RDEPUBReaderSettingsViewController.onBrightnessChange / onFontSizeChange / onLineHeightChange / onDisplayTypeChange / onThemeChange`
|
||||
|
||||
### 容器通信(协议)
|
||||
|
||||
- `RDReaderDataSource` / `RDReaderDelegate`:RDEPUBReaderController 实现,为 RDReaderView 提供页面数据和接收页面变化事件
|
||||
- `RDEPUBWebContentViewDelegate`:RDEPUBReaderController 实现,接收 WebView 的位置更新、选择变化、链接激活、JS 错误
|
||||
|
||||
## 8. 联调与排查建议
|
||||
|
||||
- 排查 1:阅读器打开后空白
|
||||
- 检查 `RDEPUBParser.parse(epubURL:)` 是否成功
|
||||
- 检查 `startInitialLoadIfNeeded()` 是否被调用(需要 viewDidAppear)
|
||||
- 检查 errorLabel 是否显示
|
||||
- 排查 2:翻页后位置不恢复
|
||||
- 检查 `persistence.loadLocation(for:)` 是否返回非 nil
|
||||
- 检查 `restoreReadingLocation` 是否在 `finishPagination` 中被调用
|
||||
- textBook 路径检查 `textBook.pageNumber(for:)` 返回值
|
||||
- 排查 3:设置变更后无效果
|
||||
- 字号/行高变化需触发 `repaginatePreservingCurrentLocation()`
|
||||
- 主题变化只需 `refreshVisibleContentPreservingLocation()`
|
||||
- 检查 `requiresRepagination` / `requiresVisibleRefresh` 的判断逻辑
|
||||
- 排查 4:高亮不显示
|
||||
- 确认 `currentSelection` 非 nil
|
||||
- 确认 `addHighlight` 成功返回
|
||||
- 确认 `refreshVisibleContentPreservingLocation()` 被调用
|
||||
- textBook 路径检查 `RDEPUBTextContentView.configure` 中的搜索高亮逻辑
|
||||
- 排查 5:搜索无结果
|
||||
- textBook 路径确认 `RDEPUBTextSearchEngine.search` 被调用
|
||||
- web 路径确认 `RDEPUBHTMLSearchEngine.search` 被调用
|
||||
- 检查搜索关键词是否为空
|
||||
- 排查 6:横屏布局异常
|
||||
- 确认 `landscapeDualPageEnabled` 是否为 true
|
||||
- 检查 `viewDidLayoutSubviews` 是否触发了重新分页
|
||||
- 检查 RDReaderView 的 orientation change 处理
|
||||
347
Doc/EPUB_MAINTENANCE.md
Normal file
347
Doc/EPUB_MAINTENANCE.md
Normal file
@ -0,0 +1,347 @@
|
||||
# EPUB 阅读模块维护与排查指南
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
本文档说明 EPUB 阅读模块的关键文件职责、主要数据流、常见问题排查和后续扩展建议,供维护时快速建立上下文。
|
||||
|
||||
完整架构设计请参考 [ARCHITECTURE.md](ARCHITECTURE.md)。
|
||||
编码规范请参考 [CODING_STYLE.md](CODING_STYLE.md)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 关键文件速查
|
||||
|
||||
### 2.1 EPUBCore 层
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `RDEPUBParser.swift` + 扩展 | EPUB 解压、container.xml、OPF、manifest、spine、TOC/NCX 解析 |
|
||||
| `RDEPUBPublication.swift` | 解析结果的唯一聚合入口,外部不直接访问 parser 字段 |
|
||||
| `RDEPUBModels.swift` | metadata / manifest / spine / TOC 基础模型 |
|
||||
| `RDEPUBReadingModels.swift` | RDEPUBLocation、RDEPUBViewport、RDEPUBHighlight、选区模型 |
|
||||
| `RDEPUBResourceResolver.swift` | href → fileURL / schemeURL 的统一入口,`normalizedHref` 标准化路径 |
|
||||
| `RDEPUBPaginator.swift` | 离屏 WKWebView 分页,计算每个 spine 资源被视口切成多少页 |
|
||||
| `RDEPUBReadingSession.swift` | 状态机 + 会话协调,管理 staged/active/pending 页模型 |
|
||||
| `RDEPUBNavigatorState.swift` | 状态枚举(initializing/loading/idle/jumping/moving/repaginating) |
|
||||
| `RDEPUBWebView.swift` + 扩展 | 承载 spine 资源的 WKWebView,分页 CSS 注入、JS bridge、fixed wrapper |
|
||||
| `RDEPUBResourceURLSchemeHandler.swift` | `ss-reader://` 协议,从本地解压目录提供所有 EPUB 资源 |
|
||||
| `RDEPUBStyleSheetBuilder.swift` | 生成注入 WebView 的 CSS |
|
||||
| `RDEPUBJavaScriptBridge.swift` | JS ↔ Swift 消息定义和编解码 |
|
||||
|
||||
### 2.2 EPUBTextRendering 层
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `RDEPUBTextRenderer.swift` | 渲染引擎协议 + 错误类型 + 样式 / 内容模型 |
|
||||
| `RDEPUBDTCoreTextRenderer.swift` | DTCoreText 实现,HTML → NSAttributedString |
|
||||
| `RDEPUBTextRendererSupport.swift` | fragment 标记注入、fragment offset 提取、属性标准化 |
|
||||
| `RDEPUBTextPaginationSupport.swift` | NSAttributedString 切页算法 |
|
||||
| `RDEPUBTextBookBuilder.swift` | 驱动逐章节渲染和分页,生成 RDEPUBTextBook |
|
||||
|
||||
### 2.3 EPUBUI 层
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `RDEPUBReaderController.swift` | 开箱即用读者控制器,整合 session / readerView / UI |
|
||||
| `RDEPUBReaderConfiguration.swift` | 外部配置项(字号、主题、翻页模式等) |
|
||||
| `RDEPUBReaderPersistence.swift` | 阅读位置和高亮的本地持久化 |
|
||||
| `RDEPUBReaderSettings.swift` | 运行时设置状态 |
|
||||
| `RDEPUBWebContentView.swift` | WKWebView 内容视图,供 RDReaderView 渲染 WebView 类 spine |
|
||||
| `RDEPUBTextContentView.swift` | 文本内容视图,供 RDReaderView 渲染文本类 spine |
|
||||
|
||||
---
|
||||
|
||||
## 3. 资源服务协议说明
|
||||
|
||||
加载 spine 资源统一使用 `ss-reader://` 协议,不直接使用 `file://`:
|
||||
|
||||
```swift
|
||||
// 正确方式
|
||||
let schemeURL = publication.resourceResolver.schemeURL(forHref: spineItem.href)
|
||||
webView.load(URLRequest(url: schemeURL))
|
||||
|
||||
// 禁止方式(relative 资源、图片、CSS 会失效)
|
||||
webView.loadHTMLString(htmlString, baseURL: nil)
|
||||
```
|
||||
|
||||
这样 HTML 内部的相对 CSS、图片、字体和 iframe 资源都会通过同一个 schemeHandler 解析,避免 `loadHTMLString` 或 `allowingReadAccessTo` 导致的资源失效问题。
|
||||
|
||||
### 3.1 DTCoreText 实现逻辑(textReflowable 路径)
|
||||
|
||||
当 `readingProfile == .textReflowable` 时,渲染链路不再走 WebView 列分页,而是走 `EPUBTextRendering/` 的 DTCoreText 文本渲染链。
|
||||
|
||||
**端到端流程:**
|
||||
|
||||
1. `RDEPUBTextBookBuilder` 按 spine 顺序读取章节 HTML。
|
||||
2. `RDEPUBTextRendererSupport.injectFragmentMarkers(into:)` 注入 fragment 标记,保证目录锚点可映射到文本偏移。
|
||||
3. `RDEPUBDTCoreTextRenderer.renderChapter(html:baseURL:style:)` 执行章节渲染。
|
||||
4. 渲染成功时,内部通过 `DTHTMLAttributedStringBuilder` 将 HTML 转为 `NSAttributedString`。
|
||||
5. `RDEPUBTextRendererSupport.extractFragmentOffsets(from:)` 提取 `fragment -> 字符偏移` 映射。
|
||||
6. `RDEPUBTextRendererSupport.normalizeReadingAttributes(in:style:)` 统一字体、行距、颜色等阅读属性。
|
||||
7. `RDEPUBTextPaginationSupport` 将富文本按可视区域切页,构建页模型。
|
||||
8. `RDEPUBTextBookBuilder` 汇总章节与页映射,交给 `RDEPUBTextContentView` 呈现。
|
||||
|
||||
**关键实现点:**
|
||||
|
||||
1. 条件编译:`#if canImport(DTCoreText)`
|
||||
- 可用时走 DTCoreText 路径。
|
||||
- 不可用时走 fallback(纯 `NSAttributedString` 降级),确保工程可编译可运行。
|
||||
2. `dtOptions(baseURL:style:)` 会注入:
|
||||
- 默认字体族 / 字体名 / 字号
|
||||
- 行高倍数(由字体行高与行距折算)
|
||||
- `NSBaseURLDocumentOption`(保证相对资源解析)
|
||||
- 默认文本颜色
|
||||
3. fragment 偏移在“属性标准化前后”都要保持稳定,避免目录跳转漂移。
|
||||
4. 文本分页以字符范围为准,不以 WebView 像素滚动位置为准。
|
||||
|
||||
**失败与降级策略:**
|
||||
|
||||
1. HTML 转 UTF-8 失败:抛出 `htmlEncodingFailed`。
|
||||
2. DTCoreText builder 失败:进入 fallback 渲染路径。
|
||||
3. fallback 仍会执行 fragment 提取与阅读属性标准化,保证目录跳转和阅读样式能力不丢失。
|
||||
|
||||
**与定位恢复的关系:**
|
||||
|
||||
1. 仍使用 `RDEPUBLocation`(`href + progression`)作为跨会话恢复模型。
|
||||
2. textReflowable 模式下,`fragmentOffsets` 用于将目录锚点映射到分页后的字符区间,再换算到页索引。
|
||||
3. 字号/行距变化触发重分页后,优先按 `href + progression` 回落恢复,再用 fragment 做细化定位。
|
||||
|
||||
**推荐日志点(DTCoreText 专用):**
|
||||
|
||||
1. `RDEPUBDTCoreTextRenderer.renderChapter(...)`:记录章节 href、输入 HTML 长度、渲染耗时。
|
||||
2. `makeAttributedString(from:baseURL:style:)`:记录 DTCoreText 是否成功返回 attributed string。
|
||||
3. `extractFragmentOffsets(from:)`:记录 fragment 数量与关键锚点是否命中。
|
||||
4. `RDEPUBTextPaginationSupport`:记录总页数、每页字符范围边界。
|
||||
|
||||
---
|
||||
|
||||
## 4. 常见问题排查
|
||||
|
||||
### 4.1 EPUB 打不开或解析失败
|
||||
|
||||
**优先检查:**
|
||||
|
||||
1. `RDEPUBParser.parse(epubURL:)` 是否抛出异常,打印具体错误
|
||||
2. `RDEPUBParser+Archive.swift` — 解压是否成功
|
||||
3. `META-INF/container.xml` 是否存在
|
||||
4. OPF 中 manifest / spine 是否完整
|
||||
|
||||
**常见原因:**
|
||||
|
||||
- 文件不是标准 EPUB ZIP 结构
|
||||
- `container.xml` 找不到 OPF 路径
|
||||
- OPF manifest 或 spine 格式异常
|
||||
- 文件路径包含特殊字符导致解压目录路径错误
|
||||
|
||||
**排查顺序:**
|
||||
|
||||
1. 确认 EPUB 已成功复制到沙盒
|
||||
2. 确认解压目录存在(`extractionRootURL`)
|
||||
3. 确认 `opfDirectoryURL` 不为 nil
|
||||
4. 确认 `spine.count > 0`
|
||||
|
||||
---
|
||||
|
||||
### 4.2 EPUB 显示空白页
|
||||
|
||||
**优先检查:**
|
||||
|
||||
1. `RDEPUBWebContentView.loadPage(...)` 传入的 spineIndex 是否在范围内
|
||||
2. `RDEPUBWebView+Reflowable` — 分页 CSS 注入是否成功
|
||||
3. `WKNavigationDelegate.didFinish` 是否正常触发
|
||||
4. `RDEPUBResourceURLSchemeHandler` 是否正确返回资源数据
|
||||
|
||||
**常见原因:**
|
||||
|
||||
1. scheme URL 拼装错误,资源请求 404
|
||||
2. 注入的 CSS 破坏了原始布局(尤其是 `column-width` 样式冲突)
|
||||
3. fixed-layout 书籍走了 reflowable 渲染路径(readingProfile 判定错误)
|
||||
|
||||
**排查顺序:**
|
||||
|
||||
1. 确认 `schemeURL(forHref:)` 返回的 URL 对应文件真实存在
|
||||
2. 用 `RDEPUBWebViewDebug` 打开 WebView inspector 查看网络请求
|
||||
3. 确认 readingProfile 判定正确(webInteractive / webFixedLayout / textReflowable)
|
||||
|
||||
---
|
||||
|
||||
### 4.3 修改字号后位置恢复不准
|
||||
|
||||
**原因:** 字号变化触发重新分页(repaginating),页号失效。
|
||||
|
||||
**正确做法:**
|
||||
|
||||
当前实现依赖 `href + progression` 恢复位置,不依赖纯页号。排查重点:
|
||||
|
||||
1. `RDEPUBLocation.href` 是否已通过 `normalizedHref` 标准化
|
||||
2. `progression` 是否在字号变化后重新从 WebView 读取
|
||||
3. `RDEPUBReadingSession.navigatorState` 是否在重分页后回到 `.idle` 并消费了 staged snapshot
|
||||
|
||||
---
|
||||
|
||||
### 4.4 目录点击后跳错位置
|
||||
|
||||
**优先检查:**
|
||||
|
||||
1. `RDEPUBParser+TOC.swift` — TOC href 是否正确标准化为相对 OPF 路径
|
||||
2. `RDEPUBResourceResolver.normalizedHref(_:)` — 跳转时是否使用了标准化 href
|
||||
3. `fragment` 是否正确传入 WebView 锚点滚动
|
||||
|
||||
**常见原因:**
|
||||
|
||||
1. TOC 链接是相对 Nav 或 NCX 文件目录,未转为相对 OPF
|
||||
2. `href#fragment` 只命中了资源,未命中锚点
|
||||
3. 同一资源中多个 TOC 条目被折叠成同一页
|
||||
|
||||
**排查步骤:**
|
||||
|
||||
1. 打印原始 TOC href 和标准化后 href 进行比较
|
||||
2. 确认 `fragment` 字段被保留在 RDEPUBLocation 中
|
||||
3. 确认 WebView 收到 `fragment` 后执行了 `scrollToFragment(fragment:)` JS 调用
|
||||
|
||||
---
|
||||
|
||||
### 4.5 正文内部链接不工作
|
||||
|
||||
**优先检查:**
|
||||
|
||||
1. `RDEPUBWebView+JavaScriptBridge` 中内部链接拦截逻辑
|
||||
2. `RDEPUBReaderController.navigateToLocation(_:)` — 跨资源跳转路径
|
||||
|
||||
**常见原因:**
|
||||
|
||||
1. 链接是空 `#fragment`,但当前 DOM 中找不到对应元素
|
||||
2. 跨资源链接 href 未被 normalizedHref 处理
|
||||
3. 对应 spine 资源没有分页结果(pageCounts[spineIndex] == 0)
|
||||
|
||||
---
|
||||
|
||||
### 4.6 图片多时位置跳动
|
||||
|
||||
**原因:** 图片异步加载导致 DOM 高度变化,进度报告时机早于实际布局稳定。
|
||||
|
||||
**排查:**
|
||||
|
||||
1. 检查 JS `ResizeObserver` 是否在图片加载后触发了进度重同步
|
||||
2. 确认 `didUpdateLocation` 不在 pending navigation 未消费前覆盖目标位置
|
||||
3. 确认 `scrollToLocation()` JS 调用时序在 `didFinish` 之后
|
||||
|
||||
---
|
||||
|
||||
### 4.7 高亮未显示或恢复失败
|
||||
|
||||
**优先检查:**
|
||||
|
||||
1. `RDEPUBHighlight` 是否成功写入 persistence
|
||||
2. `RDEPUBReaderController.activeHighlights` 是否正确过滤出当前 spine 的高亮
|
||||
3. `RDEPUBWebView+JavaScriptBridge` 中 `setHighlights()` JS 调用是否触发
|
||||
4. JS 中 `rangeFromInfo()` 是否能找到对应 DOM 节点(嵌套节点恢复容易失败)
|
||||
|
||||
**排查顺序:**
|
||||
|
||||
1. 确认有效选区(`currentSelection` 不为 nil)
|
||||
2. 确认 `addHighlight()` 调用后 persistence 中数据已写入
|
||||
3. 确认当前页传入的 highlights 数组不为空
|
||||
4. 确认 JS 收到了 highlights 数据并尝试了 rangeFromInfo 恢复
|
||||
|
||||
---
|
||||
|
||||
### 4.8 fixed-layout 书籍显示异常
|
||||
|
||||
**优先检查:**
|
||||
|
||||
1. `RDEPUBPublication.readingProfile` 是否判定为 `.webFixedLayout`
|
||||
2. `RDEPUBFixedLayoutTemplate` — wrapper HTML 是否正确生成
|
||||
3. viewport meta 尺寸是否与书籍 OPF 中声明一致
|
||||
4. spread 模式是否正确(单页 vs 双页)
|
||||
|
||||
---
|
||||
|
||||
## 5. 推荐日志点
|
||||
|
||||
遇到复杂问题时,建议先在以下位置加日志:
|
||||
|
||||
1. `RDEPUBParser.parse(epubURL:)` — 打印解压路径、OPF 路径、spine 数量
|
||||
2. `RDEPUBReadingSession.transition(to:)` — 打印状态跃迁
|
||||
3. `RDEPUBPaginator` — 打印每个 spine 资源的分页结果
|
||||
4. `RDEPUBReaderController.restoreLocation(_:)` — 打印恢复位置的 href / progression
|
||||
5. `RDEPUBWebView+JavaScriptBridge` — 打印所有 JS 消息收发
|
||||
6. `RDEPUBResourceURLSchemeHandler` — 打印资源请求 URL 和响应状态
|
||||
|
||||
---
|
||||
|
||||
## 6. 标准排查顺序
|
||||
|
||||
当 EPUB 功能出问题时,建议按以下顺序排查:
|
||||
|
||||
1. **解析是否成功** — parser.spine.count > 0?metadata 正确?
|
||||
2. **readingProfile 是否正确** — 用了对的渲染路径?
|
||||
3. **TOC href 是否标准化** — 与 spine href 格式一致?
|
||||
4. **分页结果是否合理** — 每个 spine 资源的 pageCount > 0?
|
||||
5. **资源是否能正常加载** — ss-reader:// 请求是否 200?
|
||||
6. **JS bridge 是否正常** — progression 上报是否到达 Swift 侧?
|
||||
7. **状态机是否流转正确** — session.navigatorState 是否符合预期?
|
||||
8. **控制器是否正确映射页号** — EPUBPage 列表与 RDReaderView 页号对应是否正确?
|
||||
|
||||
---
|
||||
|
||||
## 7. 后续扩展建议
|
||||
|
||||
1. **拆分 demo 层 RDReaderController**:约 1900+ 行,EPUB 加载、UI、数据源职责仍混在一起,建议继续向 library 层迁移
|
||||
2. **基线验证**:用四本样书跑一轮分页耗时、目录命中率、末页事件、设置恢复稳定性数据
|
||||
3. **横竖屏支持回归矩阵**:控制器级 `viewWillTransition()` 与 viewport 去重已接入,下一步重点是补齐 pageCurl / scroll、三条正文路径和 iPad 视口变化场景的固定回归
|
||||
4. **Pod 分层**:评估是否将 EPUBCore / EPUBTextRendering / EPUBUI 拆为独立 subspec
|
||||
5. **RDEPUBParser 继续收敛**:OPF metadata 细节处理仍偏重,可考虑按 OPFMetadataParser 单独拆出
|
||||
|
||||
---
|
||||
|
||||
## 8. 阅读器能力现状与下一阶段规划
|
||||
|
||||
### 8.1 当前已具备的核心能力
|
||||
|
||||
当前 SDK 已具备一套可用的 EPUB 阅读器主链路,包含:
|
||||
|
||||
1. **EPUB 打开与解析**:支持 ZIP 解压、OPF / spine / TOC 解析,以及三类 readingProfile 分流
|
||||
2. **正文渲染与分页**:支持 WebView 路径和 DTCoreText 文本路径,覆盖固定版式、交互式可重排和纯文本可重排 EPUB
|
||||
3. **完整阅读交互**:支持目录跳转、翻页、阅读位置恢复、主题切换、字号和行高调整、亮度调整
|
||||
4. **标注能力**:支持高亮、划线、批注、标注列表、编辑、删除、跳转与持久化恢复
|
||||
5. **书签能力**:支持当前位置书签添加/取消、书签列表、删除、跳转与持久化恢复
|
||||
6. **自动化测试基础能力**:已接入 `ReadViewSDKDemoTests` / `ReadViewSDKDemoUITests`、样书夹具定位工具,以及 parser / resolver / persistence 首批 XCTest 和阅读器启动 smoke 用例
|
||||
7. **搜索能力**:支持全文搜索、匹配项跳转、当前匹配高亮
|
||||
|
||||
### 8.2 高优先级待补能力
|
||||
|
||||
从“可用 MVP”走向“稳定可交付阅读器”,当前最值得优先完成的是:
|
||||
|
||||
1. **样书基线验证**
|
||||
需要基于四本样书补齐分页耗时、目录命中率、末页事件、设置恢复稳定性等基线数据
|
||||
|
||||
### 8.3 面向完整商业阅读器的下一阶段能力
|
||||
|
||||
若目标是继续向完整阅读产品演进,建议后续补齐以下能力:
|
||||
|
||||
1. **阅读进度展示**
|
||||
包括全书进度、章节进度、页码信息、剩余页数或剩余章节等
|
||||
2. **更完整的标注管理**
|
||||
包括按章节分组、排序、筛选、批量删除、标注导出、复制批注内容等
|
||||
3. **更成熟的搜索体验**
|
||||
包括搜索结果列表页、章节聚合、上下文预览、搜索历史等
|
||||
4. **更多阅读增强能力**
|
||||
包括字体切换、自定义主题、翻页手势自定义、TTS 朗读、无障碍支持等
|
||||
|
||||
### 8.4 工程侧持续建设建议
|
||||
|
||||
除了阅读器功能本身,还建议并行推进以下工程项:
|
||||
|
||||
1. **控制器继续拆分**
|
||||
`RDEPUBReaderController` 与 demo 层控制器都偏大,继续叠加功能会增加维护成本
|
||||
2. **Parser 与模块职责继续收敛**
|
||||
`RDEPUBParser` 仍承担较多 OPF metadata 解析细节,后续可继续按职责拆分
|
||||
3. **回归验收矩阵固化**
|
||||
建议把样书、渲染路径、主题、字号、翻页模式、标注恢复等整理为固定回归清单
|
||||
|
||||
### 8.5 推荐下一阶段实施顺序
|
||||
|
||||
如果以“最小投入换最大稳定性提升”为目标,建议按下面顺序推进:
|
||||
|
||||
1. **样书基线验证**
|
||||
398
Doc/FeatureSolution/书签能力方案讨论.md
Normal file
398
Doc/FeatureSolution/书签能力方案讨论.md
Normal file
@ -0,0 +1,398 @@
|
||||
# 书签能力方案讨论
|
||||
|
||||
## 需求背景
|
||||
|
||||
`Doc/EPUB_MAINTENANCE.md` 已将“书签能力”列为 EPUB 阅读器下一阶段的高优先级能力之一。
|
||||
|
||||
当前阅读器已经具备:
|
||||
|
||||
1. EPUB 打开与解析
|
||||
2. WebView / DTCoreText / fixed-layout 三条正文渲染路径
|
||||
3. 目录跳转、阅读位置恢复、主题与字号调整
|
||||
4. 高亮、划线、批注、搜索等阅读交互能力
|
||||
|
||||
但现阶段“位置记录”仍主要依赖:
|
||||
|
||||
1. 自动保存的上次阅读位置
|
||||
2. 需要选中文本的高亮 / 批注能力
|
||||
|
||||
这会遗漏一种非常常见、也非常轻量的阅读诉求:
|
||||
|
||||
1. 只想标记当前位置
|
||||
2. 稍后回来继续读
|
||||
3. 不需要摘录正文
|
||||
4. 不需要输入批注
|
||||
|
||||
因此,“书签能力”这项需求的目标,不是扩展现有标注功能,而是补上一条面向“位置收藏”的轻量链路,用来覆盖“只记录位置、不做摘录”的常见阅读需求。
|
||||
|
||||
## 代码事实
|
||||
|
||||
### 已有基础
|
||||
|
||||
当前仓库其实已经具备不少可直接复用的基础设施:
|
||||
|
||||
1. `RDEPUBLocation` 已能稳定表达 `href + progression + fragment`
|
||||
2. `RDEPUBReaderController` 已具备“当前位置读取”和“跳转到位置”的主链路
|
||||
3. `RDEPUBReaderPersistence` 已形成按 `bookIdentifier` 分书持久化的模式
|
||||
4. 高亮管理页已经具备“列表展示 -> 点击跳转 -> 删除”的成熟交互范式
|
||||
|
||||
结论:
|
||||
|
||||
1. 书签不需要重新设计位置模型
|
||||
2. 书签可以复用现有定位和跳转能力
|
||||
3. 更需要新增的是独立模型、持久化存储位和 UI 入口
|
||||
|
||||
### 当前实现存在的问题
|
||||
|
||||
#### 问题 1:当前只有单一阅读位置,没有“多书签”能力
|
||||
|
||||
`RDEPUBReaderPersistence` 当前只提供:
|
||||
|
||||
1. `loadLocation / saveLocation`
|
||||
2. `loadHighlights / saveHighlights`
|
||||
3. `loadReaderSettings / saveReaderSettings`
|
||||
|
||||
这意味着:
|
||||
|
||||
1. 系统只能保存“上次读到哪”
|
||||
2. 不能保存“用户主动收藏的多个位置”
|
||||
|
||||
而这两者的语义并不相同:
|
||||
|
||||
1. 阅读位置是自动覆盖的
|
||||
2. 书签是主动创建、可长期保留多个的
|
||||
|
||||
#### 问题 2:当前标注模型不适合直接承载书签
|
||||
|
||||
虽然看起来可以用 `RDEPUBHighlight` 勉强模拟书签,但这会带来明显问题:
|
||||
|
||||
1. 书签不一定有选中文本
|
||||
2. 书签不应依赖 `rangeInfo`
|
||||
3. 书签列表应以“位置”为中心,而不是以“摘录内容”为中心
|
||||
4. 书签与高亮、划线、批注的展示和管理语义不同
|
||||
|
||||
因此,不建议把书签硬塞进现有高亮模型。
|
||||
|
||||
#### 问题 3:当前 UI 没有书签入口与状态反馈
|
||||
|
||||
从 `RDEPUBReaderBottomToolView` 当前结构来看,只有:
|
||||
|
||||
1. 目录
|
||||
2. 批注列表
|
||||
3. 添加标注
|
||||
4. 设置
|
||||
|
||||
当前并没有:
|
||||
|
||||
1. 当前页添加 / 取消书签入口
|
||||
2. 书签列表入口
|
||||
3. 当前阅读位置是否已加书签的状态反馈
|
||||
|
||||
这意味着即便补了数据层,用户仍然无法自然感知和使用这项能力。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
建议把“书签能力”拆成四部分推进。
|
||||
|
||||
### 1. 独立书签模型
|
||||
|
||||
需要新增一个面向“位置记录”的独立模型,而不是复用高亮模型。
|
||||
|
||||
### 2. 独立书签持久化
|
||||
|
||||
需要新增书签读写接口,支持每本书存储多个书签。
|
||||
|
||||
### 3. 书签入口与状态反馈
|
||||
|
||||
需要让用户在当前页能轻量创建 / 取消书签,并能看到当前位置的书签状态。
|
||||
|
||||
### 4. 书签管理与跳转
|
||||
|
||||
需要补齐书签列表、点击跳转、删除等基本管理闭环。
|
||||
|
||||
## 技术路线对比
|
||||
|
||||
### 路线 A:把书签并入 `RDEPUBHighlight`
|
||||
|
||||
思路:
|
||||
|
||||
1. 给 `RDEPUBHighlight` 增加一个新的 style 或特殊标识
|
||||
2. 将书签存入 highlights
|
||||
3. 复用现有标注列表页
|
||||
|
||||
优点:
|
||||
|
||||
1. 表面上改动范围较小
|
||||
2. 可以快速借用现有列表管理代码
|
||||
|
||||
风险:
|
||||
|
||||
1. 模型语义不清晰
|
||||
2. 书签没有文本选区时会很别扭
|
||||
3. 后续展示、筛选、持久化逻辑会越来越绕
|
||||
4. 容易让“书签”和“标注”边界变得混乱
|
||||
|
||||
### 路线 B:完全独立一条书签链路
|
||||
|
||||
思路:
|
||||
|
||||
1. 新建 `RDEPUBBookmark`
|
||||
2. 新增 `loadBookmarks / saveBookmarks`
|
||||
3. 新增书签列表页
|
||||
4. 独立接入添加、删除、跳转、状态更新
|
||||
|
||||
优点:
|
||||
|
||||
1. 模型语义清晰
|
||||
2. 更符合产品能力边界
|
||||
3. 后续扩展空间更好
|
||||
|
||||
风险:
|
||||
|
||||
1. 会新增一部分 UI 与管理代码
|
||||
2. 与高亮列表在形态上会有一定重复
|
||||
|
||||
### 路线 C:模型独立,交互模式局部复用
|
||||
|
||||
思路:
|
||||
|
||||
1. 数据模型与持久化独立
|
||||
2. 定位、跳转和列表交互模式尽量复用现有高亮链路
|
||||
3. 第一阶段先做轻量闭环,再考虑更复杂管理能力
|
||||
|
||||
优点:
|
||||
|
||||
1. 语义清晰,同时控制改动范围
|
||||
2. 能复用现有阅读位置链路和管理交互经验
|
||||
3. 更适合当前仓库的演进方式
|
||||
|
||||
风险:
|
||||
|
||||
1. 仍需补一组新的数据与列表代码
|
||||
2. 需要明确书签状态判定规则,避免重排后误判
|
||||
|
||||
### 推荐结论
|
||||
|
||||
建议采用:
|
||||
|
||||
**主路线:路线 C,模型独立,位置链路复用,UI 先做轻量闭环**
|
||||
|
||||
原因:
|
||||
|
||||
1. 书签和标注的语义不同,模型应保持独立
|
||||
2. 当前仓库已有成熟的位置和跳转主链路,可以直接复用
|
||||
3. 第一阶段先满足“记录位置”的核心需求,不需要把书签做得过重
|
||||
|
||||
## 推荐方案
|
||||
|
||||
### 方案总览
|
||||
|
||||
书签能力第一阶段建议形成如下结构:
|
||||
|
||||
1. 新增 `RDEPUBBookmark` 独立模型
|
||||
2. 扩展 `RDEPUBReaderPersistence`,新增 bookmarks 读写
|
||||
3. 在 `RDEPUBReaderController` 中接入书签状态、添加、删除、跳转能力
|
||||
4. 增加轻量级书签入口和书签列表页
|
||||
5. 支持退出重进后的书签持久化恢复
|
||||
|
||||
### 推荐数据模型
|
||||
|
||||
建议书签模型至少包含以下字段:
|
||||
|
||||
1. `id`
|
||||
2. `bookIdentifier`
|
||||
3. `location`
|
||||
4. `chapterTitle`
|
||||
5. `displayText` 或摘要信息(可选)
|
||||
6. `note`(可选)
|
||||
7. `createdAt`
|
||||
|
||||
其中第一阶段真正必要的最小集合是:
|
||||
|
||||
1. `id`
|
||||
2. `bookIdentifier`
|
||||
3. `location`
|
||||
4. `createdAt`
|
||||
|
||||
建议同时补上 `chapterTitle`,这样书签列表在展示时会更友好,也更接近真实阅读器使用习惯。
|
||||
|
||||
### 推荐持久化结构
|
||||
|
||||
建议在 `RDEPUBReaderPersistence` 中新增:
|
||||
|
||||
1. `loadBookmarks(for:)`
|
||||
2. `saveBookmarks(_:for:)`
|
||||
|
||||
`RDEPUBUserDefaultsPersistence` 中新增:
|
||||
|
||||
1. `bookmarksPrefix`
|
||||
|
||||
第一阶段继续沿用当前:
|
||||
|
||||
1. `UserDefaults`
|
||||
2. `JSONEncoder / JSONDecoder`
|
||||
3. `bookIdentifier` 作用域隔离
|
||||
|
||||
这样能在最小改动下形成可用闭环。
|
||||
|
||||
### 推荐 UI 入口
|
||||
|
||||
第一阶段建议至少补两类入口:
|
||||
|
||||
1. 当前页添加 / 取消书签入口
|
||||
2. 书签列表入口
|
||||
|
||||
比较自然的放置方式是:
|
||||
|
||||
1. 顶部工具栏增加“当前页书签”切换按钮
|
||||
2. 底部工具栏增加“书签列表”入口
|
||||
|
||||
这样可以把两种动作区分开:
|
||||
|
||||
1. “加书签”是当前上下文动作
|
||||
2. “看书签列表”是全局管理动作
|
||||
|
||||
### 推荐交互闭环
|
||||
|
||||
第一阶段建议用户路径收敛成下面这条主链路:
|
||||
|
||||
1. 用户在当前阅读位置点击书签按钮
|
||||
2. 如果当前语义位置未加书签,则创建书签
|
||||
3. 如果当前语义位置已加书签,则取消该书签
|
||||
4. 用户可从书签列表查看当前书籍的全部书签
|
||||
5. 点击任意书签后跳转到对应位置
|
||||
6. 支持从列表中删除书签
|
||||
|
||||
## 关键实现建议
|
||||
|
||||
### Task K1:新增书签模型
|
||||
|
||||
文件:
|
||||
|
||||
1. `Sources/RDReaderView/EPUBCore/RDEPUBReadingModels.swift`
|
||||
|
||||
改动:
|
||||
|
||||
1. 新增 `RDEPUBBookmark: Codable, Equatable`
|
||||
2. 字段以 `location` 为核心,不依赖选区文本
|
||||
|
||||
完成定义:
|
||||
|
||||
1. 可以表达一本书中的一个可持久化书签位置
|
||||
|
||||
### Task K2:扩展书签持久化协议
|
||||
|
||||
文件:
|
||||
|
||||
1. `Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
|
||||
|
||||
改动:
|
||||
|
||||
1. 协议增加 `loadBookmarks / saveBookmarks`
|
||||
2. `RDEPUBUserDefaultsPersistence` 增加 `bookmarksPrefix`
|
||||
|
||||
完成定义:
|
||||
|
||||
1. 每本书可以稳定读取和保存多个书签
|
||||
|
||||
### Task K3:控制器接入书签状态与操作
|
||||
|
||||
文件:
|
||||
|
||||
1. `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||
|
||||
改动:
|
||||
|
||||
1. 增加 `activeBookmarks`
|
||||
2. 打开图书时加载 bookmarks
|
||||
3. 增加 `addBookmark`
|
||||
4. 增加 `removeBookmark`
|
||||
5. 增加 `toggleBookmark`
|
||||
6. 增加 `go(to: bookmark)`
|
||||
7. 增加当前阅读位置是否已书签的状态计算
|
||||
|
||||
完成定义:
|
||||
|
||||
1. 书签与阅读主链路、跳转链路、持久化链路打通
|
||||
|
||||
### Task K4:补书签 UI 与列表管理
|
||||
|
||||
文件:
|
||||
|
||||
1. `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift`
|
||||
2. 新增 `Sources/RDReaderView/EPUBUI/RDEPUBReaderBookmarksViewController.swift`
|
||||
3. 视情况补充顶部工具栏按钮
|
||||
|
||||
改动:
|
||||
|
||||
1. 添加书签入口
|
||||
2. 添加书签列表入口
|
||||
3. 点击书签跳转
|
||||
4. 支持删除
|
||||
5. 提供当前位置书签状态反馈
|
||||
|
||||
完成定义:
|
||||
|
||||
1. 用户能看到、添加、取消、查看和管理书签
|
||||
|
||||
## 当前页书签判定建议
|
||||
|
||||
“当前位置是否已加书签”不建议简单按“完全相同的 `progression`”判断,因为:
|
||||
|
||||
1. 字号变化会触发重分页
|
||||
2. 视口尺寸变化会导致 progression 细微漂移
|
||||
3. WebView 和文本分页的进度精度并不完全一致
|
||||
|
||||
更合理的判定策略是:
|
||||
|
||||
1. 优先比较标准化后的 `href`
|
||||
2. 如果存在 `fragment`,优先使用 `fragment` 作为更强锚点
|
||||
3. reflowable 路径允许一定 progression 容差
|
||||
4. fixed-layout 路径优先按页级位置判断
|
||||
|
||||
这样可以减少用户在重排、重进或轻微位置变化时的书签状态抖动。
|
||||
|
||||
## 验收矩阵建议
|
||||
|
||||
第一阶段至少要覆盖以下验证维度:
|
||||
|
||||
1. 当前页可添加书签
|
||||
2. 同一位置可取消书签
|
||||
3. 书签列表展示正常
|
||||
4. 点击书签可以跳转
|
||||
5. 删除书签后列表更新正确
|
||||
6. 退出重进后书签仍存在
|
||||
7. 字号变化后书签仍能回到同章节或邻近语义位置
|
||||
8. `webInteractive` 路径正常
|
||||
9. `textReflowable` 路径正常
|
||||
10. `webFixedLayout` 路径至少能按页恢复
|
||||
|
||||
## 风险与注意点
|
||||
|
||||
### 风险 1:把书签与高亮强行合并
|
||||
|
||||
这样短期看似省事,长期会让模型、持久化和 UI 语义都变重。
|
||||
|
||||
### 风险 2:过度依赖精确 progression 匹配
|
||||
|
||||
如果书签命中规则太严格,重分页后会显得“不稳定”,用户体验会很差。
|
||||
|
||||
### 风险 3:第一阶段把书签做得过重
|
||||
|
||||
书签能力的首要目标是“轻量记录位置”,不是一开始就做成复杂的收藏管理系统。
|
||||
|
||||
## 推荐实施顺序
|
||||
|
||||
如果目标是“最小投入换最大需求闭环”,建议按下面顺序推进:
|
||||
|
||||
1. 先补独立 `RDEPUBBookmark` 模型
|
||||
2. 先扩展 `RDEPUBReaderPersistence` 的书签读写能力
|
||||
3. 再在 `RDEPUBReaderController` 接入书签主链路
|
||||
4. 最后补 UI 入口和书签列表页
|
||||
5. 待第一阶段稳定后,再考虑备注、排序、筛选等增强能力
|
||||
|
||||
## 推荐结论
|
||||
|
||||
“书签能力”这项需求,建议最终收敛为下面这句话:
|
||||
|
||||
**为 EPUB 阅读器补齐一条独立的轻量书签链路,以 `RDEPUBLocation` 为核心记录位置,支持添加、取消、列表管理、跳转与持久化恢复,用来覆盖“只记录位置、不做摘录”的常见阅读需求。**
|
||||
439
Doc/FeatureSolution/样书基线验证方案讨论.md
Normal file
439
Doc/FeatureSolution/样书基线验证方案讨论.md
Normal file
@ -0,0 +1,439 @@
|
||||
# 样书基线验证方案讨论
|
||||
|
||||
## 需求背景
|
||||
|
||||
`Doc/EPUB_MAINTENANCE.md` 已将“样书基线验证”列为 EPUB 阅读器下一阶段的高优先级能力之一。
|
||||
|
||||
当前阅读器已经具备:
|
||||
|
||||
1. EPUB 打开与解析
|
||||
2. WebView / DTCoreText / fixed-layout 三条正文渲染路径
|
||||
3. 目录跳转、阅读位置恢复、字号与主题调整
|
||||
4. 高亮、划线、批注、搜索等阅读交互能力
|
||||
|
||||
但这些能力目前主要还是依赖“人手点一遍”来判断是否正常,缺少固定样书、固定流程、固定输出格式的基线验证机制。
|
||||
|
||||
因此,“样书基线验证”这项需求的目标,不是再增加几本 demo 书,而是基于当前已内置的四本 EPUB 样书,建立一套:
|
||||
|
||||
1. 可重复执行
|
||||
2. 可横向比较
|
||||
3. 可长期沉淀
|
||||
4. 可逐步自动化
|
||||
|
||||
的阅读器基线验证机制。
|
||||
|
||||
## 代码事实
|
||||
|
||||
### 已有基础
|
||||
|
||||
当前仓库已经具备样书基线验证所需的基本条件:
|
||||
|
||||
1. `ReadViewSDKDemo/ReadViewSDKDemo/Resources/` 内已经内置四本 EPUB 样书
|
||||
2. `RDReaderManager.bundledEPUBURLs(in:)` 会自动枚举 bundle 中所有 `.epub`
|
||||
3. `RDReaderManager.demoBookItems(in:)` 已将这些样书接入 demo 书架入口
|
||||
4. `ViewController` 支持通过 `-demo-book-title` 启动参数自动打开指定样书
|
||||
|
||||
这意味着当前并不缺“样书载体”或“执行入口”,更缺的是:
|
||||
|
||||
1. 哪本样书用于验证哪类能力
|
||||
2. 每次验证时要记录哪些数据
|
||||
3. 什么叫验证通过
|
||||
4. 如何把结果沉淀成可比较的基线
|
||||
|
||||
### 当前实现存在的问题
|
||||
|
||||
#### 问题 1:四本样书当前只是 demo 资源,不是正式基线夹具
|
||||
|
||||
现在这四本书更多承担“可打开、可展示”的职责,但还没有被正式定义为:
|
||||
|
||||
1. 固定基线样书
|
||||
2. 各自覆盖的风险场景
|
||||
3. 必测能力清单
|
||||
4. 异常归档与复验对象
|
||||
|
||||
#### 问题 2:当前没有统一的基线指标结构
|
||||
|
||||
`Doc/EPUB_MAINTENANCE.md` 已明确提到要补齐:
|
||||
|
||||
1. 分页耗时
|
||||
2. 目录命中率
|
||||
3. 末页事件
|
||||
4. 设置恢复稳定性
|
||||
|
||||
但当前还没有统一规定:
|
||||
|
||||
1. 指标如何采集
|
||||
2. 指标如何记录
|
||||
3. 指标的通过标准是什么
|
||||
4. 下次如何与这次比较
|
||||
|
||||
#### 问题 3:样书验证目标混杂了性能、正确性与兼容性
|
||||
|
||||
“样书基线验证”至少同时包含三类验证目标:
|
||||
|
||||
1. 性能基线:分页耗时、页数规模
|
||||
2. 正确性基线:目录跳转、末页事件、位置恢复
|
||||
3. 兼容性基线:不同 EPUB 类型在不同渲染路径下是否稳定
|
||||
|
||||
如果不拆开,执行时很容易退化成“随便翻一下,感觉没问题”。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
建议把“样书基线验证”拆成四部分来推进。
|
||||
|
||||
### 1. 固定样书集
|
||||
|
||||
先把四本样书从“demo 资源”提升为“固定基线夹具”,后续版本迭代尽量不随意替换。
|
||||
|
||||
### 2. 固定样书职责
|
||||
|
||||
每本样书都明确它主要覆盖哪些风险,而不是所有书都做完全相同的验证。
|
||||
|
||||
### 3. 固定基线指标
|
||||
|
||||
每次验证时都按同一组字段记录结果,保证可比较。
|
||||
|
||||
### 4. 固定验证流程与输出模板
|
||||
|
||||
每轮验证都按统一步骤执行,并把结果写入统一模板,避免结论只停留在口头描述。
|
||||
|
||||
## 技术路线对比
|
||||
|
||||
### 路线 A:完全手工验证
|
||||
|
||||
思路:
|
||||
|
||||
1. 打开四本样书
|
||||
2. 手工翻页、点目录、调字号
|
||||
3. 用主观结论判断是否正常
|
||||
|
||||
优点:
|
||||
|
||||
1. 上手快
|
||||
2. 不需要额外改工程
|
||||
|
||||
风险:
|
||||
|
||||
1. 结果不稳定
|
||||
2. 不同人执行结论可能不同
|
||||
3. 无法形成长期可比较的基线
|
||||
|
||||
### 路线 B:一开始就全量自动化
|
||||
|
||||
思路:
|
||||
|
||||
1. 直接把四本样书的打开、翻页、跳转、恢复全部接入 UI 自动化或脚本采集
|
||||
2. 让基线数据自动产出
|
||||
|
||||
优点:
|
||||
|
||||
1. 理想状态下最规范
|
||||
2. 长期收益高
|
||||
|
||||
风险:
|
||||
|
||||
1. 前期成本大
|
||||
2. 当前测试基础设施还不完整
|
||||
3. 部分阅读体验问题在第一阶段更适合半自动确认
|
||||
|
||||
### 路线 C:先建立半自动基线,再逐步自动化
|
||||
|
||||
思路:
|
||||
|
||||
1. 先固定样书矩阵与基线字段
|
||||
2. 借助现有 demo 自动打开入口执行统一验证流程
|
||||
3. 先沉淀结构化结果
|
||||
4. 再将适合自动化的部分逐步纳入 XCTest / XCUITest
|
||||
|
||||
优点:
|
||||
|
||||
1. 投入和收益平衡更好
|
||||
2. 能更快形成第一版基线
|
||||
3. 不会被早期自动化建设阻塞
|
||||
|
||||
风险:
|
||||
|
||||
1. 需要纪律性执行模板
|
||||
2. 初期仍有部分验证依赖人工操作
|
||||
|
||||
### 推荐结论
|
||||
|
||||
建议采用:
|
||||
|
||||
**主路线:路线 C,先建立半自动样书基线,再逐步自动化**
|
||||
|
||||
原因:
|
||||
|
||||
1. 当前仓库已经有四本固定样书和自动打开入口
|
||||
2. 样书基线验证的第一目标是“有可比较的结果”,而不是“立刻完全自动化”
|
||||
3. 自动化测试体系尚在建设中,先立住基线矩阵更划算
|
||||
|
||||
## 推荐方案
|
||||
|
||||
### 方案总览
|
||||
|
||||
样书基线验证第一阶段建议形成如下结构:
|
||||
|
||||
1. 固定四本样书,不随意替换
|
||||
2. 每本样书绑定主要验证职责
|
||||
3. 每轮版本验证按统一流程执行
|
||||
4. 每次都输出结构化基线表
|
||||
5. 对异常项保留备注与复验结论
|
||||
|
||||
### 四本样书矩阵
|
||||
|
||||
> 注:以下矩阵以当前样书名称和文档信息为基础,readingProfile 可在首次正式基线验证时补齐实测结果。
|
||||
|
||||
| 样书 | 主要定位 | 重点验证能力 | 适合记录的核心指标 |
|
||||
|------|----------|--------------|--------------------|
|
||||
| `爱忘事的熊爷爷.epub` | 轻量快速回归样书 | 打开成功、基础分页、目录基本可用、设置恢复 | 打开耗时、总页数、TOC 基本命中、设置恢复 |
|
||||
| `张学良传.epub` | 标准长文 reflowable 样书 | 长文分页、目录跳转、字号变化后位置恢复 | 首次分页耗时、TOC 命中率、字号调整后恢复 |
|
||||
| `宝山辽墓材料与释读.epub` | 复杂结构与学术内容样书 | 复杂 TOC / fragment 命中、图片与正文结构稳定性、搜索与标注抽样 | TOC 命中率、fragment 命中、搜索命中、高亮恢复 |
|
||||
| `《凡人修仙传》精校版全本.epub` | 大体量长书与压力样书 | 大体量分页稳定性、持久化恢复、末页事件、长时阅读链路 | 分页耗时、总页数、末页事件、重进恢复稳定性 |
|
||||
|
||||
### 推荐验证维度
|
||||
|
||||
第一阶段建议围绕下面这些维度建立固定基线:
|
||||
|
||||
1. 打开是否成功
|
||||
2. readingProfile 类型
|
||||
3. spine 数量
|
||||
4. 总页数是否合理
|
||||
5. 首次分页耗时
|
||||
6. TOC 是否可打开
|
||||
7. TOC 抽样命中率
|
||||
8. 末页事件是否正常触发
|
||||
9. 字号调整后位置是否稳定恢复
|
||||
10. 主题 / 设置是否稳定恢复
|
||||
11. 重进后阅读位置是否恢复
|
||||
12. 标注或高亮是否恢复
|
||||
|
||||
其中第一阶段最核心的四项,应与 `Doc/EPUB_MAINTENANCE.md` 对齐:
|
||||
|
||||
1. 分页耗时
|
||||
2. 目录命中率
|
||||
3. 末页事件
|
||||
4. 设置恢复稳定性
|
||||
|
||||
## 推荐执行流程
|
||||
|
||||
建议每本样书都按同一流程执行,避免漏项。
|
||||
|
||||
### Step B1:冷启动打开样书
|
||||
|
||||
目标:
|
||||
|
||||
1. 验证样书可正常进入阅读器
|
||||
2. 记录首次打开与首次分页表现
|
||||
|
||||
记录:
|
||||
|
||||
1. 是否打开成功
|
||||
2. readingProfile
|
||||
3. spine 数量
|
||||
4. 总页数
|
||||
5. 首次分页耗时
|
||||
|
||||
### Step B2:目录抽样验证
|
||||
|
||||
目标:
|
||||
|
||||
1. 验证 TOC 是否能正常打开
|
||||
2. 验证典型目录项是否能跳转到正确章节或语义位置
|
||||
|
||||
建议:
|
||||
|
||||
1. 每本样书至少抽样 3 到 5 个目录项
|
||||
2. 包含开头、中间、靠后位置
|
||||
3. 如果目录层级复杂,额外抽样一个带 fragment 的条目
|
||||
|
||||
记录:
|
||||
|
||||
1. 抽样条目数
|
||||
2. 命中数
|
||||
3. 命中率
|
||||
4. 失败条目与现象
|
||||
|
||||
### Step B3:末页事件验证
|
||||
|
||||
目标:
|
||||
|
||||
1. 验证阅读器到达末页时的状态变化是否正确
|
||||
2. 检查不会提前触发或漏触发
|
||||
|
||||
记录:
|
||||
|
||||
1. 是否到达末页
|
||||
2. 末页事件是否正常触发
|
||||
3. 是否出现重复触发或不触发
|
||||
|
||||
### Step B4:设置恢复稳定性验证
|
||||
|
||||
目标:
|
||||
|
||||
1. 修改字号、主题或其他关键阅读设置
|
||||
2. 验证设置变更后分页是否稳定
|
||||
3. 验证退出重进后设置是否恢复
|
||||
|
||||
记录:
|
||||
|
||||
1. 修改的设置项
|
||||
2. 修改后是否立即生效
|
||||
3. 重进后是否恢复
|
||||
4. 是否伴随位置飘移或异常白页
|
||||
|
||||
### Step B5:位置与标注恢复抽样验证
|
||||
|
||||
目标:
|
||||
|
||||
1. 在样书中间位置退出重进
|
||||
2. 验证阅读位置恢复
|
||||
3. 抽样验证一条高亮或标注恢复
|
||||
|
||||
记录:
|
||||
|
||||
1. 退出前位置
|
||||
2. 重进后位置
|
||||
3. 是否在同一章节或邻近语义位置
|
||||
4. 高亮 / 标注是否仍存在
|
||||
|
||||
## 基线表模板
|
||||
|
||||
建议每轮验证都至少产出两张表:
|
||||
|
||||
1. 总览表
|
||||
2. 单书详情表
|
||||
|
||||
### 模板 1:样书基线总览表
|
||||
|
||||
| 日期 | 版本 / 分支 | 样书 | readingProfile | spine 数量 | 总页数 | 首次分页耗时 | TOC 抽样 / 命中 | 末页事件 | 设置恢复 | 位置恢复 | 标注恢复 | 结论 | 备注 |
|
||||
|------|--------------|------|----------------|------------|--------|--------------|-----------------|----------|----------|----------|----------|------|------|
|
||||
| YYYY-MM-DD | branch / commit | 爱忘事的熊爷爷 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 通过 / 风险 / 失败 | 待填 |
|
||||
| YYYY-MM-DD | branch / commit | 张学良传 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 通过 / 风险 / 失败 | 待填 |
|
||||
| YYYY-MM-DD | branch / commit | 宝山辽墓材料与释读 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 通过 / 风险 / 失败 | 待填 |
|
||||
| YYYY-MM-DD | branch / commit | 《凡人修仙传》精校版全本 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 通过 / 风险 / 失败 | 待填 |
|
||||
|
||||
### 模板 2:单书详细验证表
|
||||
|
||||
#### 样书信息
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|----|
|
||||
| 样书名称 | 待填 |
|
||||
| 文件大小 | 待填 |
|
||||
| readingProfile | 待填 |
|
||||
| spine 数量 | 待填 |
|
||||
| TOC 条目数 | 待填 |
|
||||
| 总页数 | 待填 |
|
||||
| 验证日期 | 待填 |
|
||||
| 验证版本 / 分支 | 待填 |
|
||||
|
||||
#### 核心基线结果
|
||||
|
||||
| 项目 | 结果 | 备注 |
|
||||
|------|------|------|
|
||||
| 打开成功 | 通过 / 失败 | 待填 |
|
||||
| 首次分页耗时 | 待填 | 单位建议统一为 ms 或 s |
|
||||
| TOC 打开 | 通过 / 失败 | 待填 |
|
||||
| TOC 抽样命中率 | 待填 | 例如 `4/5` |
|
||||
| 末页事件 | 通过 / 风险 / 失败 | 待填 |
|
||||
| 字号调整后恢复 | 通过 / 风险 / 失败 | 待填 |
|
||||
| 主题 / 设置恢复 | 通过 / 风险 / 失败 | 待填 |
|
||||
| 重进后位置恢复 | 通过 / 风险 / 失败 | 待填 |
|
||||
| 高亮 / 标注恢复 | 通过 / 风险 / 失败 | 待填 |
|
||||
|
||||
#### TOC 抽样记录
|
||||
|
||||
| 序号 | TOC 条目 | 预期目标 | 实际结果 | 是否命中 | 备注 |
|
||||
|------|----------|----------|----------|----------|------|
|
||||
| 1 | 待填 | 待填 | 待填 | 是 / 否 | 待填 |
|
||||
| 2 | 待填 | 待填 | 待填 | 是 / 否 | 待填 |
|
||||
| 3 | 待填 | 待填 | 待填 | 是 / 否 | 待填 |
|
||||
| 4 | 待填 | 待填 | 待填 | 是 / 否 | 待填 |
|
||||
| 5 | 待填 | 待填 | 待填 | 是 / 否 | 待填 |
|
||||
|
||||
#### 异常与复验记录
|
||||
|
||||
| 异常编号 | 场景 | 现象 | 复现条件 | 当前结论 | 备注 |
|
||||
|----------|------|------|----------|----------|------|
|
||||
| B-001 | 待填 | 待填 | 待填 | 待确认 / 已复现 / 已修复 | 待填 |
|
||||
|
||||
## 推荐输出规范
|
||||
|
||||
为了让基线真正可比较,建议每轮验证输出时统一遵守以下规范:
|
||||
|
||||
### 1. 样书集固定
|
||||
|
||||
第一阶段尽量只使用当前这四本 EPUB,不随意更换,避免基线漂移。
|
||||
|
||||
### 2. 字段命名固定
|
||||
|
||||
例如:
|
||||
|
||||
1. `首次分页耗时`
|
||||
2. `TOC 抽样命中率`
|
||||
3. `末页事件`
|
||||
4. `设置恢复`
|
||||
5. `位置恢复`
|
||||
|
||||
尽量不要每次换表头名称。
|
||||
|
||||
### 3. 结论分级固定
|
||||
|
||||
建议统一使用:
|
||||
|
||||
1. `通过`
|
||||
2. `风险`
|
||||
3. `失败`
|
||||
|
||||
其中:
|
||||
|
||||
1. `通过`:功能符合预期,无明显异常
|
||||
2. `风险`:功能基本可用,但存在偏差、偶发不稳或需继续观察
|
||||
3. `失败`:功能不符合预期,影响可用性
|
||||
|
||||
### 4. 差异说明固定
|
||||
|
||||
如果与上次基线相比发生变化,建议在备注中明确:
|
||||
|
||||
1. 是否变快
|
||||
2. 是否变慢
|
||||
3. 是否命中率下降
|
||||
4. 是否新增异常
|
||||
5. 是否已有异常被修复
|
||||
|
||||
## 风险与注意点
|
||||
|
||||
### 风险 1:把基线验证做成纯主观体验记录
|
||||
|
||||
如果只有“感觉正常”“翻起来没问题”这类结论,后续几乎无法比较。
|
||||
|
||||
### 风险 2:过度依赖绝对页码
|
||||
|
||||
页码会受到字号、行距、视口与渲染策略影响,样书基线更适合比较:
|
||||
|
||||
1. 是否能正常分页
|
||||
2. 是否恢复到同章节或邻近语义位置
|
||||
3. 是否出现明显回归
|
||||
|
||||
而不是执着于固定页号完全一致。
|
||||
|
||||
### 风险 3:样书职责不清导致验证重复或漏项
|
||||
|
||||
如果四本书都做完全相同的验证,会浪费时间;如果每本书都“随便点几下”,又会漏掉高风险场景。
|
||||
|
||||
## 推荐实施顺序
|
||||
|
||||
如果目标是“最小投入换最大稳定性提升”,建议按下面顺序推进:
|
||||
|
||||
1. 先将四本样书正式定义为固定基线样书
|
||||
2. 先用本文件中的矩阵与模板跑第一轮人工基线
|
||||
3. 在第一轮基线中补齐每本样书的实测 `readingProfile`、spine 数量、总页数等信息
|
||||
4. 统一记录异常项与复验结论
|
||||
5. 再将其中适合自动化的部分逐步并入 `XCTest / XCUITest`
|
||||
|
||||
## 推荐结论
|
||||
|
||||
“样书基线验证”这项需求,建议最终收敛为下面这句话:
|
||||
|
||||
**基于当前 demo 内置的四本固定 EPUB 样书,建立一套统一矩阵、统一流程、统一模板的基线验证机制,首期重点覆盖分页耗时、目录命中率、末页事件和设置恢复稳定性,并为后续自动化回归提供稳定样书夹具。**
|
||||
372
Doc/FeatureSolution/横竖屏切换支持方案讨论.md
Normal file
372
Doc/FeatureSolution/横竖屏切换支持方案讨论.md
Normal file
@ -0,0 +1,372 @@
|
||||
# 横竖屏切换支持方案讨论
|
||||
|
||||
## 需求背景
|
||||
|
||||
`Doc/EPUB_MAINTENANCE.md` 与 `Doc/ARCHITECTURE.md` 都把“横竖屏切换支持”列为当前阅读器的高优先级待补能力。
|
||||
|
||||
目标不是单纯“屏幕旋转后不崩”,而是让 EPUB 阅读器在横竖屏切换后具备以下稳定行为:
|
||||
|
||||
1. 正文重新按新视口尺寸分页或重排
|
||||
2. 阅读位置尽量准确恢复,不明显跳章、跳页
|
||||
3. 工具栏、目录、标注、搜索状态不异常
|
||||
4. pageCurl / horizontalScroll / verticalScroll / horizontalCoverScroll 四种展示模式行为一致
|
||||
5. WebView、DTCoreText、fixed layout 三条正文路径都能正确处理
|
||||
|
||||
## 代码事实
|
||||
|
||||
### 已有基础
|
||||
|
||||
当前仓库并不是完全没有横竖屏处理基础:
|
||||
|
||||
- `RDReaderView.layoutSubviews()` 会检测 `previousIsLandscape` 与 `isLandscape` 的变化
|
||||
文件:`Sources/RDReaderView/RDReaderView.swift`
|
||||
- `RDReaderView.orientationChanged(isNowLandscape:)` 已能:
|
||||
- 通知 `readerViewOrientationWillChange`
|
||||
- 更新 `layout.isLandscapeDualPage`
|
||||
- `pageCurl` 模式下重建 `UIPageViewController`
|
||||
- 滚动模式下 `invalidateLayout + reloadData + setContentOffset`
|
||||
- `RDEPUBReaderController.readerViewOrientationWillChange(...)` 当前会直接调用 `repaginatePreservingCurrentLocation()`
|
||||
- `RDEPUBReaderController.repaginatePreservingCurrentLocation()` 已具备“先记录当前位置,再重新分页,再恢复位置”的主链路
|
||||
- `RDEPUBLocation` 已采用 `href + progression + lastProgression + fragment` 模型,本身适合应对字号变化和尺寸变化后的重定位
|
||||
|
||||
结论:
|
||||
|
||||
- 基础设施已经存在
|
||||
- 真正缺的是“时机是否稳定、入口是否统一、三条正文路径是否都覆盖、是否会重复触发”
|
||||
|
||||
### 当前实现存在的问题
|
||||
|
||||
#### 问题 1:方向变化回调被 `landscapeDualPageEnabled` 绑定
|
||||
|
||||
`RDReaderView.layoutSubviews()` 目前有:
|
||||
|
||||
```swift
|
||||
guard landscapeDualPageEnabled, bounds.width > 0, bounds.height > 0 else { return }
|
||||
```
|
||||
|
||||
这意味着:
|
||||
|
||||
- 只有开启 `landscapeDualPageEnabled` 时才会进入方向变化检测
|
||||
- 如果宿主关闭横屏双页,但正文仍然会因为宽高变化而需要重排,此时不会触发回调
|
||||
|
||||
这对以下场景不正确:
|
||||
|
||||
1. `textReflowable` 文本书籍在横竖屏切换后,行宽必然变化,应重新分页
|
||||
2. `webInteractive` 可重排正文在横竖屏切换后,列分页宽度变化,应重新分页
|
||||
3. fixed layout 在 spread 模式下也可能因视口变化而需要重新生成页面模型
|
||||
|
||||
#### 问题 2:重新分页触发时机偏早
|
||||
|
||||
当前 `readerViewOrientationWillChange` 是从 `RDReaderView.layoutSubviews()` 中异步抛出。
|
||||
|
||||
这有几个风险:
|
||||
|
||||
1. 此时上层 VC 的 `view.bounds`、safe area、sheet 布局动画可能还在变化中
|
||||
2. `paginatePublication()` 如果过早读取 `currentLayoutContext().viewportSize`,可能拿到中间态尺寸
|
||||
3. `RDReaderView` 自己已经在同一轮变化里做了 `reloadData` / `transitionToPage`,而 `RDEPUBReaderController` 又会发起新一轮分页,容易出现双重刷新
|
||||
|
||||
#### 问题 3:职责边界不够清晰
|
||||
|
||||
目前横竖屏变化同时由两层在做事:
|
||||
|
||||
- `RDReaderView`:处理双页布局、pageCurl 容器重建、滚动模式 offset 恢复
|
||||
- `RDEPUBReaderController`:处理 EPUB 重新分页
|
||||
|
||||
但两层之间没有一个明确的“统一入口”来协调:
|
||||
|
||||
1. 是否真的需要重分页
|
||||
2. 什么时候用最终尺寸重分页
|
||||
3. 何时忽略重复触发
|
||||
4. 何时只刷新布局,不重做完整分页
|
||||
|
||||
#### 问题 4:文档状态与代码状态不一致
|
||||
|
||||
文档把横竖屏切换标为“未实现”,但代码里已经有半套逻辑。
|
||||
|
||||
这说明当前更准确的描述应当是:
|
||||
|
||||
- “已有局部实现”
|
||||
- “尚未形成稳定、完整、可验收的横竖屏支持闭环”
|
||||
|
||||
## 需求拆解
|
||||
|
||||
为了让“横竖屏切换支持”真正完成,建议把需求拆成四部分:
|
||||
|
||||
### 1. 稳定感知视口变化
|
||||
|
||||
不仅要感知“横屏/竖屏布尔值变化”,还要感知:
|
||||
|
||||
- 宽高尺寸变化
|
||||
- safe area 变化
|
||||
- iPad 分屏、多窗口、sheet 尺寸变化
|
||||
|
||||
因此,真正要监听的不是“orientation”,而是“阅读视口发生了足以影响分页的变化”。
|
||||
|
||||
### 2. 统一触发重新分页
|
||||
|
||||
所有需要重排正文的场景都应走统一入口,例如:
|
||||
|
||||
```swift
|
||||
handleViewportChange(reason: .orientationTransition)
|
||||
```
|
||||
|
||||
由它统一负责:
|
||||
|
||||
1. 读取当前位置
|
||||
2. 比较旧 viewport 与新 viewport
|
||||
3. 防抖与去重
|
||||
4. 发起分页
|
||||
5. 恢复定位
|
||||
|
||||
### 3. 区分“容器布局刷新”和“正文重分页”
|
||||
|
||||
不是所有变化都必须触发完整分页,但横竖屏切换通常都需要:
|
||||
|
||||
- `textReflowable`:重建 `RDEPUBTextBook`
|
||||
- `webInteractive`:重跑 `RDEPUBPaginator`
|
||||
- `webFixedLayout`:重建 fixed spread snapshot
|
||||
|
||||
而 `RDReaderView` 自己的 pageCurl 双页容器重建仍可保留,但不应和正文分页时机互相打架。
|
||||
|
||||
### 4. 建立回归验收矩阵
|
||||
|
||||
横竖屏切换是高联动场景,至少要验证:
|
||||
|
||||
1. 无选区、无工具栏时切换
|
||||
2. 有目录面板、设置面板、标注列表时切换
|
||||
3. 搜索命中页、标注页、末页时切换
|
||||
4. `landscapeDualPageEnabled = true / false`
|
||||
5. pageCurl / scroll 系列模式
|
||||
|
||||
## 技术路线对比
|
||||
|
||||
### 路线 A:继续依赖 `RDReaderView.readerViewOrientationWillChange`
|
||||
|
||||
思路:
|
||||
|
||||
- 保留现有 `layoutSubviews() -> orientationChanged -> delegate`
|
||||
- 在 `RDEPUBReaderController` 内增强去重、防抖和最终尺寸判断
|
||||
|
||||
优点:
|
||||
|
||||
1. 改动范围小
|
||||
2. 能延续现有 `RDReaderView` 双页逻辑
|
||||
|
||||
风险:
|
||||
|
||||
1. 触发时机仍然偏依赖 `layoutSubviews()`
|
||||
2. 仍容易与 VC 生命周期中的尺寸变化打架
|
||||
3. 继续把“正文分页”绑定到“容器方向回调”,抽象层次不够稳定
|
||||
|
||||
### 路线 B:由 `RDEPUBReaderController.viewWillTransition(to:with:)` 统一接管
|
||||
|
||||
思路:
|
||||
|
||||
1. 在 `RDEPUBReaderController` 实现 `viewWillTransition(to:with:)`
|
||||
2. 利用 `transitionCoordinator` 等待旋转动画接近完成
|
||||
3. 在 completion 中读取最终 `viewportSize`
|
||||
4. 统一调用 `handleViewportChange(reason:)`
|
||||
5. `RDReaderView.readerViewOrientationWillChange` 只保留容器级双页重建职责,不再直接触发正文分页
|
||||
|
||||
优点:
|
||||
|
||||
1. 分页时机更接近最终尺寸
|
||||
2. 更符合 UIKit 对旋转和尺寸变化的生命周期
|
||||
3. 更容易做重复触发抑制
|
||||
4. 更适合以后扩展到 iPad 分屏、多窗口
|
||||
|
||||
风险:
|
||||
|
||||
1. 需要重新梳理 `RDReaderView` 与 `RDEPUBReaderController` 的职责分界
|
||||
2. 需要验证 pageCurl 双页重建与正文分页之间的顺序
|
||||
|
||||
### 推荐结论
|
||||
|
||||
建议采用:
|
||||
|
||||
**主路线:路线 B,由 `RDEPUBReaderController.viewWillTransition(to:with:)` 统一接管正文重分页**
|
||||
**保留 `RDReaderView` 现有方向变化逻辑,但将其职责收敛为容器布局刷新和双页模式内部处理**
|
||||
|
||||
原因:
|
||||
|
||||
1. 需求本质是“视口变化后的正文重排”,最合理的宿主是控制器,而不是容器内部的 `layoutSubviews()`
|
||||
2. 现有 `RDReaderView` 回调机制已经能服务内部双页布局,但不适合作为 EPUB 正文分页的唯一入口
|
||||
3. `viewWillTransition` + `transitionCoordinator` 更容易拿到稳定尺寸,并减少重复分页
|
||||
|
||||
## 推荐方案
|
||||
|
||||
### 方案总览
|
||||
|
||||
引入一条统一的横竖屏处理主链路:
|
||||
|
||||
```swift
|
||||
viewWillTransition(to:with:)
|
||||
-> scheduleViewportTransition(reason: .orientation)
|
||||
-> handleViewportChangeIfNeeded(finalViewportSize)
|
||||
-> repaginatePreservingCurrentLocation()
|
||||
-> finishPagination(restoreLocation:)
|
||||
```
|
||||
|
||||
同时把 `RDReaderView` 内部职责收敛为:
|
||||
|
||||
1. 更新 `layout.isLandscapeDualPage`
|
||||
2. pageCurl 模式下重建 `UIPageViewController`
|
||||
3. 滚动模式刷新 collectionView 布局
|
||||
|
||||
不再让 EPUBUI 直接在 `readerViewOrientationWillChange` 中立即重新分页。
|
||||
|
||||
### 关键改动建议
|
||||
|
||||
#### Task O1:增加控制器级旋转入口
|
||||
|
||||
文件:
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||
|
||||
改动:
|
||||
|
||||
- 实现 `viewWillTransition(to:with:)`
|
||||
- 在 `transitionCoordinator` completion 中比较新旧 viewport
|
||||
- 统一调 `handleViewportChange(reason:)`
|
||||
|
||||
完成定义:
|
||||
|
||||
- 旋转后分页使用最终尺寸,而不是中间态尺寸
|
||||
|
||||
#### Task O2:引入 viewport 去重与防抖
|
||||
|
||||
文件:
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||
|
||||
改动:
|
||||
|
||||
- 保存最近一次已应用的 `viewportSignature`
|
||||
- 仅当宽高变化超过阈值时才触发重新分页
|
||||
- 防止 `viewDidLayoutSubviews`、`orientation callback`、`viewWillTransition` 连续多次重复触发
|
||||
|
||||
推荐结构:
|
||||
|
||||
```swift
|
||||
private struct RDEPUBViewportSignature: Equatable {
|
||||
let width: CGFloat
|
||||
let height: CGFloat
|
||||
let safeTop: CGFloat
|
||||
let safeBottom: CGFloat
|
||||
}
|
||||
```
|
||||
|
||||
完成定义:
|
||||
|
||||
- 一次横竖屏切换只触发一次有效正文重分页
|
||||
|
||||
#### Task O3:调整 `readerViewOrientationWillChange` 职责
|
||||
|
||||
文件:
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||
- 如有必要 `Sources/RDReaderView/RDReaderView.swift`
|
||||
|
||||
改动:
|
||||
|
||||
- `RDEPUBReaderController.readerViewOrientationWillChange` 不再直接 `repaginatePreservingCurrentLocation()`
|
||||
- 保留它作为兼容钩子,或仅做轻量状态标记
|
||||
|
||||
完成定义:
|
||||
|
||||
- 容器内部布局刷新与正文分页不再相互打架
|
||||
|
||||
#### Task O4:放宽 `RDReaderView` 的方向检测前置条件
|
||||
|
||||
文件:
|
||||
|
||||
- `Sources/RDReaderView/RDReaderView.swift`
|
||||
|
||||
改动:
|
||||
|
||||
- 去掉 `layoutSubviews()` 中对 `landscapeDualPageEnabled` 的硬性 guard
|
||||
- 方向变化检测应独立于“是否启用横屏双页”
|
||||
|
||||
完成定义:
|
||||
|
||||
- 即使 `landscapeDualPageEnabled == false`,视口变化仍可被上层感知并触发重分页
|
||||
|
||||
注意事项:
|
||||
|
||||
- 双页布局本身仍然只在 `landscapeDualPageEnabled == true` 时开启
|
||||
- 但“是否需要感知尺寸变化”不能绑定到这个开关
|
||||
|
||||
#### Task O5:验证三条正文路径的恢复策略
|
||||
|
||||
文件:
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||
- `Sources/RDReaderView/EPUBCore/RDEPUBReadingSession.swift`
|
||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift`
|
||||
|
||||
改动:
|
||||
|
||||
- 验证 `restoreReadingLocation()` 在以下路径都稳定:
|
||||
- `textReflowable`
|
||||
- `webInteractive`
|
||||
- `webFixedLayout`
|
||||
|
||||
完成定义:
|
||||
|
||||
- 横竖屏切换后,恢复位置不明显跳页、跳章
|
||||
|
||||
#### Task O6:补文档与验收矩阵
|
||||
|
||||
文件:
|
||||
|
||||
- `Doc/EPUB_MAINTENANCE.md`
|
||||
- `Doc/ARCHITECTURE.md`
|
||||
- 如有必要 `Doc/EPUBUI_功能实现逻辑.md`
|
||||
|
||||
改动:
|
||||
|
||||
- 将“横竖屏切换未做”更新为更准确的实现状态
|
||||
- 补充最终采用的触发时机与回归清单
|
||||
|
||||
完成定义:
|
||||
|
||||
- 文档与代码状态一致
|
||||
|
||||
## 风险点
|
||||
|
||||
1. **pageCurl 双页重建顺序**
|
||||
`RDReaderView` 自己会重建 `UIPageViewController`,如果 `RDEPUBReaderController` 同时触发分页,需要验证两者先后顺序
|
||||
2. **重复分页**
|
||||
旋转时 UIKit 常伴随多次 layout / safe area 变化,如果没有 `viewportSignature`,很容易多次重排
|
||||
3. **iPad 场景复杂度**
|
||||
分屏、多窗口、Stage Manager 下不一定发生“方向变化”,但一定发生“视口变化”,所以方案必须以 viewport 为核心
|
||||
4. **fixed layout spread 恢复**
|
||||
横屏双页和竖屏单页之间切换时,恢复到哪一页的左页 / 右页,需要以 `RDEPUBLocation` 为准而不是旧页号
|
||||
|
||||
## 推荐实施顺序
|
||||
|
||||
1. 先做 `O1 + O2`,把控制器级 viewport 变化入口和防抖建起来
|
||||
2. 再做 `O3 + O4`,把旧的 delegate 触发职责收窄
|
||||
3. 然后验证 `O5`,重点跑三条正文路径和四种展示模式
|
||||
4. 最后做 `O6`,同步文档与验收矩阵
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 横竖屏切换后,`textReflowable` 路径会按新尺寸重新分页,并恢复到接近原阅读位置
|
||||
2. 横竖屏切换后,`webInteractive` 路径会重新分页,并恢复到接近原阅读位置
|
||||
3. 横竖屏切换后,`webFixedLayout` 路径会按新 spread 规则重建页面模型,并恢复到正确资源
|
||||
4. `landscapeDualPageEnabled == false` 时,横竖屏切换仍然会触发必要的重排
|
||||
5. 一次旋转只触发一次有效正文重分页,不出现连续多次闪烁刷新
|
||||
6. pageCurl / horizontalScroll / verticalScroll / horizontalCoverScroll 四种模式下都不出现明显错页、白页或工具栏错位
|
||||
|
||||
## 结论
|
||||
|
||||
横竖屏切换支持不是“从零开始新增一套能力”,而是要把当前分散在 `RDReaderView` 和 `RDEPUBReaderController` 里的半套逻辑收敛成一个稳定闭环。
|
||||
|
||||
最推荐的方向是:
|
||||
|
||||
- `RDEPUBReaderController` 用 `viewWillTransition(to:with:)` 统一接管正文级重分页
|
||||
- `RDReaderView` 保留容器级双页布局和 pageCurl 重建职责
|
||||
- 以 `viewport` 变化而不是 `isLandscape` 布尔值作为真正的分页触发依据
|
||||
|
||||
这样既能复用现有代码,又能把“文档标注为未完成”的横竖屏需求收敛成可实现、可回归、可交付的一阶段方案。
|
||||
418
Doc/FeatureSolution/自动化测试方案讨论.md
Normal file
418
Doc/FeatureSolution/自动化测试方案讨论.md
Normal file
@ -0,0 +1,418 @@
|
||||
# 自动化测试方案讨论
|
||||
|
||||
## 需求背景
|
||||
|
||||
`Doc/EPUB_MAINTENANCE.md` 已将“自动化测试”列为 EPUB 阅读器下一阶段的高优先级能力之一。
|
||||
|
||||
当前阅读器已经具备较完整的主链路能力,包括:
|
||||
|
||||
1. EPUB 解析与打开
|
||||
2. WebView / DTCoreText / fixed-layout 三条正文渲染路径
|
||||
3. 目录跳转、阅读位置恢复、主题与字号调整
|
||||
4. 高亮、划线、批注、搜索等阅读交互能力
|
||||
|
||||
但这些能力目前主要依赖手工验证,缺少自动化回归保护。随着横竖屏切换、书签、更多搜索体验和阅读增强能力继续叠加,单靠人工回归会越来越难以稳定覆盖高联动场景。
|
||||
|
||||
因此,“自动化测试”这项需求的目标不是单纯增加几条测试用例,而是为 EPUB 阅读器建立一套可持续演进的自动化回归体系,优先保护以下核心链路:
|
||||
|
||||
1. 解析
|
||||
2. 分页
|
||||
3. 定位恢复
|
||||
4. 目录跳转
|
||||
5. 标注恢复
|
||||
|
||||
## 代码事实
|
||||
|
||||
### 已有基础
|
||||
|
||||
当前仓库并不是完全没有可测试的基础:
|
||||
|
||||
1. `Sources/RDReaderView/EPUBCore/` 已经把 parser、resourceResolver、location、readingSession、paginator 等能力拆成了相对独立的模块
|
||||
2. `Sources/RDReaderView/EPUBTextRendering/` 已经将 DTCoreText 渲染、fragment 提取、富文本分页等逻辑收敛到明确的工具类型中
|
||||
3. `RDEPUBLocation`、`RDEPUBHighlight`、TOC / spine / manifest 等模型本身适合做 deterministic 的断言
|
||||
4. `ReadViewSDKDemo` 已经提供真实运行容器,后续可以承载 UI 自动化测试
|
||||
|
||||
结论:
|
||||
|
||||
1. 核心逻辑并非不可测
|
||||
2. 更缺的是测试 target、测试夹具、断言策略和回归矩阵
|
||||
|
||||
### 当前实现存在的问题
|
||||
|
||||
#### 问题 1:工程里还没有测试承载层
|
||||
|
||||
从 `ReadViewSDKDemo.xcodeproj` 当前 target 配置来看,只有 `ReadViewSDKDemo` app target,没有独立的 `Tests` 或 `UITests` target。
|
||||
|
||||
这意味着当前并没有:
|
||||
|
||||
1. 用于跑 XCTest 的基础 target
|
||||
2. 用于跑 XCUITest 的 UI 测试 target
|
||||
3. 稳定的测试资源加载方式
|
||||
|
||||
#### 问题 2:关键链路缺少自动回归保护
|
||||
|
||||
当前高风险能力包括:
|
||||
|
||||
1. `RDEPUBParser` 解析 EPUB ZIP / OPF / spine / TOC
|
||||
2. `RDEPUBResourceResolver.normalizedHref(_:)` 的路径标准化
|
||||
3. `RDEPUBPaginator` 与 `RDEPUBTextPaginationSupport` 的分页结果
|
||||
4. `RDEPUBReaderController.restoreLocation(_:)` 的定位恢复
|
||||
5. 高亮 / 批注 persistence 的恢复链路
|
||||
|
||||
这些地方一旦回归,通常会表现为:
|
||||
|
||||
1. 打不开书
|
||||
2. 目录跳错
|
||||
3. 字号变化后位置飘移
|
||||
4. 标注丢失或恢复失败
|
||||
|
||||
而这些问题都很适合被自动化测试尽早捕获。
|
||||
|
||||
#### 问题 3:如果直接从 UI 自动化起步,成本和脆弱性都偏高
|
||||
|
||||
EPUB 阅读器的很多问题发生在 UI 之下,例如:
|
||||
|
||||
1. TOC href 标准化错误
|
||||
2. `fragment -> offset` 映射偏移
|
||||
3. 分页结果为空或页数异常
|
||||
4. `href + progression` 回落恢复逻辑不稳定
|
||||
|
||||
如果这些问题都等到 `XCUITest` 才暴露:
|
||||
|
||||
1. 定位根因会很慢
|
||||
2. 测试执行时间会更长
|
||||
3. 异步加载、动画、WebView 时序会让用例更脆
|
||||
|
||||
#### 问题 4:当前还没有固定的样书基线与测试夹具约定
|
||||
|
||||
要让自动化测试长期稳定,必须明确:
|
||||
|
||||
1. 哪几本样书是测试夹具
|
||||
2. 每本样书用于验证哪种能力
|
||||
3. 哪些断言可以依赖“页号”,哪些只能依赖 `href / fragment / progression`
|
||||
|
||||
如果没有这层约束,测试很容易随着样书变化或样式调整而频繁漂移。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
建议把“自动化测试”需求拆成四部分,而不是把所有验证都堆到同一层。
|
||||
|
||||
### 1. 建立测试基础设施
|
||||
|
||||
先补齐可执行自动化测试的工程结构,包括:
|
||||
|
||||
1. `ReadViewSDKDemoTests`
|
||||
2. `ReadViewSDKDemoUITests`
|
||||
3. 样书 fixtures 目录
|
||||
4. 统一的测试资源读取辅助工具
|
||||
|
||||
这是后续一切测试工作的前提。
|
||||
|
||||
### 2. 建立逻辑层 XCTest
|
||||
|
||||
这部分优先覆盖稳定、纯逻辑、可快速执行的能力:
|
||||
|
||||
1. parser 解析
|
||||
2. href 标准化
|
||||
3. TOC 路径映射
|
||||
4. location / progression / fragment 相关转换
|
||||
5. persistence 编解码
|
||||
|
||||
目标是让每次改动 parser、模型、定位和持久化逻辑时,都有快速反馈。
|
||||
|
||||
### 3. 建立集成层 XCTest
|
||||
|
||||
这部分不直接走 UI 自动化,但会驱动真实样书、真实分页和真实恢复链路,重点覆盖:
|
||||
|
||||
1. reflowable 样书分页
|
||||
2. fixed-layout 样书页面模型生成
|
||||
3. 字号变化后位置恢复
|
||||
4. 目录跳转命中
|
||||
5. 标注恢复
|
||||
|
||||
目标是把“阅读核心链路”在逻辑层和 UI 层之间补上一层更贴近真实运行的保护网。
|
||||
|
||||
### 4. 建立少量高价值 XCUITest
|
||||
|
||||
UI 自动化不追求全覆盖,而是做真实用户闭环的冒烟回归,例如:
|
||||
|
||||
1. 打开样书进入阅读器
|
||||
2. 目录跳转
|
||||
3. 调整字号
|
||||
4. 创建高亮并重新进入验证恢复
|
||||
5. 横竖屏切换后继续阅读
|
||||
|
||||
目标是证明“用户真的能完成关键操作”,而不是把所有细节都放进 UI 测试里。
|
||||
|
||||
## 技术路线对比
|
||||
|
||||
### 路线 A:优先建设 XCUITest
|
||||
|
||||
思路:
|
||||
|
||||
1. 先搭 UI 自动化
|
||||
2. 用点击、滑动、旋转和断言可见文本的方式覆盖主要能力
|
||||
|
||||
优点:
|
||||
|
||||
1. 结果直观,容易贴近真实用户行为
|
||||
2. 可以较快形成“端到端可用”的感知
|
||||
|
||||
风险:
|
||||
|
||||
1. 调试成本高
|
||||
2. 受动画、异步、WebView 时序影响大
|
||||
3. 很难快速判断问题出在 parser、分页还是 UI
|
||||
4. 执行时间更长,不适合作为最早期主回归层
|
||||
|
||||
### 路线 B:优先建设 XCTest
|
||||
|
||||
思路:
|
||||
|
||||
1. 先为逻辑与集成层补单元测试
|
||||
2. 把解析、分页、定位恢复等能力尽量在非 UI 层验证
|
||||
|
||||
优点:
|
||||
|
||||
1. 稳定性更高
|
||||
2. 运行更快
|
||||
3. 更利于问题定位
|
||||
4. 更适合作为日常改动的主回归层
|
||||
|
||||
风险:
|
||||
|
||||
1. 不能完全证明 UI 交互链路没问题
|
||||
2. 覆盖不到真实点击、WebView 手势和界面状态同步问题
|
||||
|
||||
### 路线 C:分层推进
|
||||
|
||||
思路:
|
||||
|
||||
1. 先建立测试基础设施
|
||||
2. 以 XCTest 为主搭建核心保护网
|
||||
3. 再补少量 XCUITest 做端到端闭环
|
||||
|
||||
优点:
|
||||
|
||||
1. 投入与收益平衡更好
|
||||
2. 能优先保护最容易回归的核心能力
|
||||
3. 既兼顾稳定性,也兼顾真实用户路径
|
||||
|
||||
风险:
|
||||
|
||||
1. 初期需要先设计测试层次和夹具约定
|
||||
2. 需要控制 UI 测试范围,避免后续无节制膨胀
|
||||
|
||||
### 推荐结论
|
||||
|
||||
建议采用:
|
||||
|
||||
**主路线:路线 C,分层推进,XCTest 为主,XCUITest 为辅**
|
||||
|
||||
原因:
|
||||
|
||||
1. 当前最需要保护的是解析、分页、定位恢复这些高风险核心链路
|
||||
2. 仓库当前还没有测试 target,先从更稳定的 XCTest 起步更合适
|
||||
3. 少量 XCUITest 足以证明关键阅读流程可用,不必一开始追求全面 UI 覆盖
|
||||
|
||||
## 推荐方案
|
||||
|
||||
### 方案总览
|
||||
|
||||
自动化测试第一阶段建议形成如下结构:
|
||||
|
||||
```text
|
||||
ReadViewSDKDemoTests
|
||||
- ParserTests
|
||||
- ResourceResolverTests
|
||||
- LocationRestoreTests
|
||||
- TextPaginationTests
|
||||
- HighlightPersistenceTests
|
||||
|
||||
ReadViewSDKDemoUITests
|
||||
- ReaderSmokeTests
|
||||
- ReaderNavigationTests
|
||||
- ReaderAnnotationTests
|
||||
- ReaderRotationTests
|
||||
```
|
||||
|
||||
同时建立一套最小样书夹具集:
|
||||
|
||||
1. `reflowable-basic.epub`
|
||||
2. `fixed-layout-basic.epub`
|
||||
3. `image-heavy.epub`
|
||||
4. `toc-fragment.epub`
|
||||
|
||||
每本样书都应明确“它用来验证什么”,而不是仅作为示例文件存在。
|
||||
|
||||
### 关键改动建议
|
||||
|
||||
#### Task T1:建立测试 target 与基础资源加载能力
|
||||
|
||||
文件范围:
|
||||
|
||||
1. `ReadViewSDKDemo/ReadViewSDKDemo.xcodeproj`
|
||||
2. 新增 `ReadViewSDKDemoTests/`
|
||||
3. 新增 `ReadViewSDKDemoUITests/`
|
||||
4. 新增测试夹具目录
|
||||
|
||||
改动:
|
||||
|
||||
1. 增加 unit test target
|
||||
2. 增加 UI test target
|
||||
3. 为测试 target 挂载样书与辅助资源
|
||||
4. 提供统一 `TestBookLoader` / `FixtureLocator` 之类的测试辅助工具
|
||||
|
||||
完成定义:
|
||||
|
||||
1. 工程可以直接运行测试
|
||||
2. 测试代码可以稳定读取样书资源
|
||||
|
||||
#### Task T2:补第一批逻辑层 XCTest
|
||||
|
||||
建议首批覆盖:
|
||||
|
||||
1. `RDEPUBParser` 能解析有效样书并产出非空 spine
|
||||
2. `RDEPUBResourceResolver.normalizedHref(_:)` 能正确处理相对路径、父级路径和 fragment
|
||||
3. TOC href 能统一映射到与 spine 一致的标准化路径
|
||||
4. `RDEPUBHighlight` persistence 编解码结果一致
|
||||
5. 定位模型在常见输入下不会丢 fragment 或 progression
|
||||
|
||||
完成定义:
|
||||
|
||||
1. parser / resolver / persistence / location 改动有自动回归保护
|
||||
|
||||
#### Task T3:补第一批集成层 XCTest
|
||||
|
||||
建议首批覆盖:
|
||||
|
||||
1. reflowable 样书分页结果非空
|
||||
2. fixed-layout 样书能建立页模型
|
||||
3. 字号变化后仍能按 `href + progression` 恢复到原章节附近
|
||||
4. 目录跳转可命中预期 spine 资源
|
||||
5. 高亮恢复后仍能定位到对应资源
|
||||
|
||||
完成定义:
|
||||
|
||||
1. 核心阅读链路具备非 UI 层的真实运行回归能力
|
||||
|
||||
#### Task T4:补第一批冒烟型 XCUITest
|
||||
|
||||
建议首批覆盖:
|
||||
|
||||
1. 打开样书并进入阅读器
|
||||
2. 打开目录并跳转章节
|
||||
3. 调整字号后继续阅读
|
||||
4. 创建一条高亮并重进验证恢复
|
||||
5. 横竖屏切换后继续阅读
|
||||
|
||||
完成定义:
|
||||
|
||||
1. 至少有一条真实用户闭环可以证明主功能未损坏
|
||||
|
||||
## 断言策略建议
|
||||
|
||||
为了降低测试漂移,建议统一以下断言原则:
|
||||
|
||||
### 1. 少依赖固定页号
|
||||
|
||||
页号非常容易受以下因素影响:
|
||||
|
||||
1. 字号
|
||||
2. 行距
|
||||
3. 视口尺寸
|
||||
4. 图片加载时序
|
||||
|
||||
因此,自动化测试不应过度依赖“必须是第 N 页”这种断言。
|
||||
|
||||
### 2. 优先依赖稳定语义锚点
|
||||
|
||||
更推荐的断言对象包括:
|
||||
|
||||
1. `href`
|
||||
2. `fragment`
|
||||
3. `progression` 所在区间
|
||||
4. spine index
|
||||
5. 高亮所属资源
|
||||
|
||||
### 3. UI 测试只验证关键闭环
|
||||
|
||||
XCUITest 中更适合验证:
|
||||
|
||||
1. 页面是否成功进入
|
||||
2. 关键按钮是否可操作
|
||||
3. 操作后阅读器状态是否变化
|
||||
4. 重进后状态是否可恢复
|
||||
|
||||
而不适合在 UI 测试中承载大量底层分页细节断言。
|
||||
|
||||
## 验收矩阵建议
|
||||
|
||||
自动化测试第一阶段至少要覆盖以下维度:
|
||||
|
||||
1. `webInteractive`
|
||||
2. `webFixedLayout`
|
||||
3. `textReflowable`
|
||||
4. TOC 跳转
|
||||
5. 字号变化后恢复
|
||||
6. 高亮恢复
|
||||
7. 横竖屏切换
|
||||
8. 一到两本问题样书回归
|
||||
|
||||
如果资源有限,建议先保证“三条渲染路径 + 两条恢复链路”:
|
||||
|
||||
1. 三条渲染路径:`webInteractive` / `webFixedLayout` / `textReflowable`
|
||||
2. 两条恢复链路:阅读位置恢复 / 标注恢复
|
||||
|
||||
## 风险与注意点
|
||||
|
||||
### 风险 1:测试一开始就绑定大量脆弱 UI 细节
|
||||
|
||||
如果测试过度依赖:
|
||||
|
||||
1. 动画时长
|
||||
2. 可见文案位置
|
||||
3. 某个具体页号
|
||||
4. WebView 内即时渲染完成时机
|
||||
|
||||
后续维护成本会非常高。
|
||||
|
||||
### 风险 2:测试夹具过多但目标不清晰
|
||||
|
||||
样书数量不是越多越好。更重要的是:
|
||||
|
||||
1. 每本样书验证哪类能力
|
||||
2. 哪些样书是主回归集
|
||||
3. 哪些样书只在专项验证时使用
|
||||
|
||||
### 风险 3:横竖屏与字号变化断言过于绝对
|
||||
|
||||
这两类场景更适合验证:
|
||||
|
||||
1. 是否仍在同一章节或邻近语义位置
|
||||
2. 是否仍保留原始 `href / fragment`
|
||||
3. progression 是否在合理偏差范围内
|
||||
|
||||
而不适合验证“必须恢复到完全相同页号”。
|
||||
|
||||
## 推荐实施顺序
|
||||
|
||||
如果目标是“最小投入换最大稳定性提升”,建议按以下顺序推进:
|
||||
|
||||
1. 建立 `Tests` / `UITests` target
|
||||
2. 补 parser / resolver / location / persistence 的 XCTest
|
||||
3. 补分页、目录跳转、定位恢复的集成测试
|
||||
4. 补 3 到 5 条高价值 XCUITest
|
||||
5. 将横竖屏、问题样书和新增功能逐步并入回归矩阵
|
||||
|
||||
## 推荐结论
|
||||
|
||||
“自动化测试”这项需求,建议最终收敛为下面这句话:
|
||||
|
||||
**为 EPUB 阅读器建立一套分层自动化测试体系,以 XCTest 保护解析、分页、定位恢复、目录跳转和标注恢复等核心链路,再以少量 XCUITest 验证真实阅读闭环。**
|
||||
|
||||
这样做的好处是:
|
||||
|
||||
1. 能尽快为最容易回归的核心能力建立保护网
|
||||
2. 不会过早陷入脆弱的全量 UI 自动化
|
||||
3. 能为横竖屏、书签、搜索和后续阅读增强功能提供稳定回归基础
|
||||
251
Doc/RDReaderView_功能实现逻辑.md
Normal file
251
Doc/RDReaderView_功能实现逻辑.md
Normal file
@ -0,0 +1,251 @@
|
||||
# RDReaderView 功能实现逻辑
|
||||
|
||||
## 1. 范围与目标
|
||||
|
||||
- 代码范围:`Sources/RDReaderView/` 根目录(6 个 Swift 文件)
|
||||
- 目标:说明分页阅读器容器如何管理四种显示模式、DataSource/Delegate 协议、翻页交互、工具栏动画、双屏适配和 RTL 支持。
|
||||
- 主链路关键词:`RDReaderDataSource -> reloadData -> DisplayType 切换 -> 翻页/滚动 -> RDReaderDelegate.pageNum -> 工具栏显隐`。
|
||||
|
||||
## 2. 关键对象职责
|
||||
|
||||
### 2.1 核心容器 `RDReaderView`
|
||||
|
||||
- 文件:`Sources/RDReaderView/RDReaderView.swift`(~717 行)
|
||||
- 入口方法:`reloadData()`
|
||||
- 职责:
|
||||
- 管理四种显示模式的视图层级切换
|
||||
- 持有 `UIPageViewController`(pageCurl 模式)或 `UICollectionView`(滚动模式)
|
||||
- 处理点击手势(左/中/右三区域)
|
||||
- 管理工具栏(topToolView / bottomToolView)的显示/隐藏动画
|
||||
- 检测横竖屏变化并触发重新布局
|
||||
- 管理双屏配对逻辑(含封面页处理)
|
||||
- RTL 语言方向支持
|
||||
|
||||
### 2.2 自定义布局 `RDReaderFlowLayout`
|
||||
|
||||
- 文件:`Sources/RDReaderView/RDReaderFlowLayout.swift`(~387 行)
|
||||
- 职责:
|
||||
- 继承 `UICollectionViewFlowLayout`,为三种滚动模式提供布局计算
|
||||
- 水平滚动:全屏宽 item,pagingEnabled
|
||||
- 垂直滚动:可变高度 item,累加计算
|
||||
- 水平覆盖滚动:zIndex 分层 + 阴影效果模拟深度
|
||||
- 封面感知帧计算:封面页全屏宽,后续页面两两配对半屏宽
|
||||
|
||||
### 2.3 内容 Cell `RDReaderContentCell`
|
||||
|
||||
- 文件:`Sources/RDReaderView/RDReaderContentCell.swift`(~37 行)
|
||||
- 职责:
|
||||
- `UICollectionViewCell` 子类,作为内容视图的薄壳宿主
|
||||
- `containerView` 属性 setter 自动移除旧视图、添加新视图
|
||||
- `layoutSubviews` 将 containerView 填满 contentView.bounds
|
||||
|
||||
### 2.4 页面子控制器 `RDReaderPageChildViewController`
|
||||
|
||||
- 文件:`Sources/RDReaderView/RDReaderPageChildViewController.swift`(~65 行)
|
||||
- 职责:
|
||||
- 仅用于 pageCurl 模式,作为 `UIPageViewController` 的子控制器
|
||||
- 持有 `contentView: UIView?` 和 `pageNum: Int`
|
||||
- `contentView` didSet 在 view 已加载时自动调用 `installContentView()`
|
||||
|
||||
### 2.5 手势控制器 `RDReaderGestureController`
|
||||
|
||||
- 文件:`Sources/RDReaderView/RDReaderGestureController.swift`(~42 行)
|
||||
- 职责:
|
||||
- 当前为占位组件,存储 topToolView / bottomToolView 引用
|
||||
- 实际手势逻辑在 `RDReaderView.tapCenter()` 中实现
|
||||
|
||||
### 2.6 URL 入口 `RDURLReaderController`
|
||||
|
||||
- 文件:`Sources/RDReaderView/RDURLReaderController.swift`(~105 行)
|
||||
- 职责:
|
||||
- 最简入口:传入 URL 即可打开书籍
|
||||
- `.epub` 扩展名 → 创建 `RDEPUBReaderController`
|
||||
- 其他 → 创建 `RDPlainTextReaderController`(UITextView 只读展示,尝试 UTF-8 → GBK → GB2312 解码)
|
||||
- 导航栏标题设为文件名(去掉扩展名)
|
||||
|
||||
## 3. 主流程(代码级)
|
||||
|
||||
### 3.1 协议定义
|
||||
|
||||
**RDReaderDataSource**(数据供给):
|
||||
```swift
|
||||
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?
|
||||
```
|
||||
|
||||
**RDReaderDelegate**(事件回调):
|
||||
```swift
|
||||
func pageNum(readerView: RDReaderView, pageNum: Int)
|
||||
@objc optional func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool)
|
||||
```
|
||||
|
||||
### 3.2 初始化与数据加载
|
||||
|
||||
1. 调用方创建 `RDReaderView`,设置 `dataSource` 和 `delegate`。
|
||||
2. 调用 `reloadData()`。
|
||||
3. `reloadData` 内部调用 `switchReaderDisplayType(currentDisplayType)` 重建视图层级。
|
||||
4. 同时从 `dataSource` 获取 `topToolView` 和 `bottomToolView` 并添加到视图层级。
|
||||
|
||||
### 3.3 四种显示模式切换
|
||||
|
||||
**pageCurl 模式**:
|
||||
- 创建 `UIPageViewController`(transitionStyle: .pageCurl)
|
||||
- 通过 `attachPageViewControllerIfNeeded()` 添加为父 VC 的 child VC
|
||||
- 横屏双页时重建 `UIPageViewController`(spineLocation: .mid, isDoubleSided: true)
|
||||
- RTL 时翻转导航方向
|
||||
|
||||
**horizontalScroll 模式**:
|
||||
- 移除 pageViewController,插入 `UICollectionView`
|
||||
- `RDReaderFlowLayout` 设置 scrollDirection = .horizontal, isPagingEnabled = true
|
||||
- itemSize 宽度 = 容器宽度 / pagesPerScreen
|
||||
- RTL 时对 collectionView 做 `scaleX: -1` 翻转,cell contentView 再翻转回来
|
||||
|
||||
**verticalScroll 模式**:
|
||||
- 同一 collectionView,scrollDirection = .vertical, isPagingEnabled = false
|
||||
- 页面高度可变,通过 `RDReaderFlowLayoutDataSoure.heigtOfVerticalScrollPage` 查询
|
||||
- collectionViewContentSize 为所有页面高度之和
|
||||
|
||||
**horizontalCoverScroll 模式**:
|
||||
- 水平分页,但带封面滑动动画
|
||||
- `layoutAttributesForElements` 中:
|
||||
- 仅计算当前页附近的窄窗口内的 item attributes
|
||||
- 当前页之前的 item zIndex = -1,当前及之后 zIndex = 1
|
||||
- 顶层页面边缘添加阴影效果
|
||||
|
||||
### 3.4 翻页交互
|
||||
|
||||
**点击手势**(`tapAction(tap:)`):
|
||||
- 屏幕分为左 1/3、中 1/3、右 1/3 三个区域
|
||||
- 左区域:工具栏隐藏时翻上一页(RTL 时翻下一页);工具栏显示时触发 `tapCenter()`
|
||||
- 中区域:始终触发 `tapCenter()`
|
||||
- 右区域:工具栏隐藏时翻下一页(RTL 时翻上一页);工具栏显示时触发 `tapCenter()`
|
||||
|
||||
**goNextPage() / goPreviousPage()**:
|
||||
- 双页模式使用 `adjacentDualPage(from:forward:)` 计算目标页
|
||||
- 单页模式直接 +1 / -1
|
||||
- 调用 `transitionToPage(pageNum:animated:true)` 执行跳转
|
||||
|
||||
**工具栏切换**(`tapCenter()`):
|
||||
- 显示时:topToolView 从上方滑入,bottomToolView 从下方滑入(CGAffineTransform translationY)
|
||||
- 隐藏时:反向动画
|
||||
- 工具栏显示期间禁用 collectionView 和 pageViewController 的用户交互
|
||||
|
||||
### 3.5 双页配对逻辑
|
||||
|
||||
**`dualPagePair(for pageNum:)`** 返回 `(left: Int, right: Int?)`:
|
||||
- 有封面页:封面页 → (coverIndex, nil);封面后的页面两两配对(coverIndex+1 与 coverIndex+2,coverIndex+3 与 coverIndex+4...)
|
||||
- 无封面页:标准偶奇配对(0+1, 2+3, 4+5...)
|
||||
|
||||
**空白哨兵页**:
|
||||
- `blankPageNum = Int.max`,`blankEndPageNum = Int.max - 1`
|
||||
- 仅用于 pageCurl 双页模式,填充封面页独占或总页数为奇数时的右侧空白
|
||||
|
||||
### 3.6 横竖屏变化处理
|
||||
|
||||
1. `layoutSubviews()` 检测 `previousIsLandscape` 与当前 `isLandscape` 的变化
|
||||
2. 异步调用 `orientationChanged(isNowLandscape:)` 避免嵌套布局
|
||||
3. 通知 delegate `readerViewOrientationWillChange`
|
||||
4. 更新 `layout.isLandscapeDualPage` 和 `layout.coverPageIndex`
|
||||
5. pageCurl 模式:重建 UIPageViewController(spineLocation 不可变)并跳转到保存的页面
|
||||
6. 滚动模式:invalidate layout,reload data,强制布局,无动画滚动到保存的 offset
|
||||
|
||||
### 3.7 CollectionView DataSource / Layout Delegate
|
||||
|
||||
**cellForItemAt**:
|
||||
- 用 `dataSource.pageIdentifier` 获取重用标识符
|
||||
- 出队 `RDReaderContentCell`
|
||||
- 调用 `dataSource.pageContentView(pageNum:containerView:)` 传入 cell 已有的 containerView 以便复用
|
||||
- RTL 水平模式对 cell.contentView 做 `scaleX: -1` 翻转
|
||||
|
||||
**pageNum(flowLayout:pageIndex:)**:
|
||||
- 布局检测到滚动中页面变化时更新 `currentPage`
|
||||
|
||||
## 4. 异常与边界处理
|
||||
|
||||
- `currentPage` 初始值为 -1,首次设置时 delegate 会收到回调
|
||||
- 空白哨兵页(Int.max / Int.max-1)在 delegate 回调中被过滤,不会通知到外部
|
||||
- pageCurl 模式下 collectionView 的 DataSource 方法存在但不被调用(collectionView 已从视图层级移除)
|
||||
- 横竖屏变化时异步处理避免嵌套 layoutSubviews
|
||||
- `UIView.ss_superViewController` 通过响应链查找最近的父 VC,用于正确挂载 pageViewController
|
||||
|
||||
## 5. 数据结构与字段映射
|
||||
|
||||
### 5.1 显示模式
|
||||
|
||||
```swift
|
||||
RDReaderView.DisplayType
|
||||
├── .pageCurl // UIPageViewController 翻页效果
|
||||
├── .horizontalScroll // UICollectionView 水平滚动
|
||||
├── .verticalScroll // UICollectionView 垂直滚动
|
||||
└── .horizontalCoverScroll // UICollectionView 水平覆盖动画
|
||||
```
|
||||
|
||||
### 5.2 翻页方向
|
||||
|
||||
```swift
|
||||
RDReaderView.PageDirection
|
||||
├── .leftToRight // 默认,中文/英文
|
||||
└── .rightToLeft // 日文漫画等
|
||||
```
|
||||
|
||||
### 5.3 关键公开属性
|
||||
|
||||
| 属性 | 类型 | 默认值 | 用途 |
|
||||
|------|------|--------|------|
|
||||
| `dataSource` | `RDReaderDataSource?` | nil | 弱引用数据源 |
|
||||
| `delegate` | `RDReaderDelegate?` | nil | 弱引用事件监听 |
|
||||
| `currentDisplayType` | `DisplayType` | .pageCurl | 当前显示模式 |
|
||||
| `toolViewAnimationDuration` | `TimeInterval` | 0.3 | 工具栏动画时长 |
|
||||
| `landscapeDualPageEnabled` | `Bool` | false | 横屏双页 |
|
||||
| `pageDirection` | `PageDirection` | .leftToRight | 翻页方向 |
|
||||
| `coverPageIndex` | `Int?` | nil | 封面页索引 |
|
||||
| `currentPage` | `Int` | -1 | 当前页码(didSet 通知 delegate) |
|
||||
|
||||
### 5.4 计算属性
|
||||
|
||||
- `pagesPerScreen: Int`:横屏 + 双页启用 + 非垂直滚动 → 2;否则 1
|
||||
- `isLandscape: Bool`:`bounds.width > bounds.height`
|
||||
- `hasCoverPage: Bool`:`coverPageIndex != nil`
|
||||
|
||||
## 6. 视图绑定规则
|
||||
|
||||
- 规则 1:内容视图通过 `register(contentView:contentViewWithReuseIdentifier:)` 注册,内部注册对应的 `RDReaderContentCell`
|
||||
- 规则 2:Cell 复用两层:`RDReaderContentCell`(UICollectionViewCell)宿主 `containerView`(实际内容视图)
|
||||
- 规则 3:`pageContentView(readerView:pageNum:containerView:)` 的 `containerView` 参数是从回收 cell 中取出的旧视图,允许数据源复用或重新渲染
|
||||
- 规则 4:工具栏通过 `topToolView` / `bottomToolView` 协议方法提供,由 `RDReaderView` 管理显隐动画
|
||||
- 规则 5:RTL 支持通过 collectionView `scaleX: -1` + cell contentView `scaleX: -1` 实现,文本正确显示
|
||||
|
||||
## 7. 通知协作与回调机制
|
||||
|
||||
- 无 NotificationCenter 使用
|
||||
- 所有通信通过协议和闭包:
|
||||
- `RDReaderDataSource`:数据供给(页数、内容视图、标识符、工具栏)
|
||||
- `RDReaderDelegate`:事件通知(页面变化、横竖屏即将变化)
|
||||
- `RDReaderFlowLayoutDelegate`:布局检测到页面变化时通知
|
||||
- `RDReaderFlowLayoutDataSoure`:垂直滚动时提供每页高度
|
||||
|
||||
## 8. 联调与排查建议
|
||||
|
||||
- 排查 1:页面不显示
|
||||
- 确认 `dataSource` 已设置且 `reloadData()` 已调用
|
||||
- 确认 `pageCountOfReaderView` 返回值 > 0
|
||||
- 确认 `pageContentView(pageNum:containerView:)` 返回非 nil 视图
|
||||
- 排查 2:翻页无反应
|
||||
- 确认工具栏是否正在显示(工具栏显示时左右点击变为切换工具栏)
|
||||
- 确认 `currentPage` 的 didSet 是否被触发
|
||||
- pageCurl 模式检查 `UIPageViewController` 的 dataSource 方法是否正确返回
|
||||
- 排查 3:横屏双页异常
|
||||
- 确认 `landscapeDualPageEnabled` 是否为 true
|
||||
- pageCurl 模式需重建 UIPageViewController(spineLocation 不可变)
|
||||
- 检查 `dualPagePair` 配对逻辑和空白哨兵页处理
|
||||
- 排查 4:RTL 方向错误
|
||||
- 确认 `pageDirection` 是否设为 `.rightToLeft`
|
||||
- 水平滚动模式检查 collectionView 是否做了 `scaleX: -1`
|
||||
- pageCurl 模式检查导航方向是否翻转
|
||||
- 排查 5:工具栏动画异常
|
||||
- 确认 `topToolView` / `bottomToolView` 是否通过 dataSource 正确提供
|
||||
- 检查 `toolViewAnimationDuration` 值
|
||||
- 确认工具栏显示期间用户交互是否被正确禁用/恢复
|
||||
7
Doc/WXRead/.claude/settings.local.json
Normal file
7
Doc/WXRead/.claude/settings.local.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(nm /Users/shenlei/Documents/wxRead/WRUICore.framework/WRUICore)"
|
||||
]
|
||||
}
|
||||
}
|
||||
445
Doc/WXRead/analysis/01_核心架构与数据流图.md
Normal file
445
Doc/WXRead/analysis/01_核心架构与数据流图.md
Normal file
@ -0,0 +1,445 @@
|
||||
# 微信读书 EPUB 阅读器 — 核心架构与数据流图
|
||||
|
||||
> 逆向工程目标: WeRead v10.0.3 (Build 79), arm64, 63MB 主二进制
|
||||
|
||||
---
|
||||
|
||||
## 一、模块拓扑与依赖关系图
|
||||
|
||||
### 1.1 App 整体模块拓扑
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ WeRead.app (iOS/macOS Catalyst) │
|
||||
│ 主二进制: 63MB, Mach-O arm64 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 主二进制 (WeRead) │ │
|
||||
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │
|
||||
│ │ │ 阅读器模块 │ │ 书架模块 │ │ 其他业务模块 │ │ │
|
||||
│ │ │ WRReader* │ │ WRBookshelf* │ │ WRDiscover/WRMarket │ │ │
|
||||
│ │ │ WREpub* │ │ WRBook* │ │ WRAccount/WRSetting │ │ │
|
||||
│ │ │ DTCoreText* │ │ │ │ WRCommunity/WRChat │ │ │
|
||||
│ │ │ WRPage* │ │ │ │ WRActivity/WRAI │ │ │
|
||||
│ │ │ WRChapter* │ │ │ │ ... │ │ │
|
||||
│ │ └──────────────┘ └──────────────┘ └──────────────────────┘ │ │
|
||||
│ │ ┌──────────────────────────────────────────────────────────┐│ │
|
||||
│ │ │ ObjC 元数据 (未剥离): ││ │
|
||||
│ │ │ __objc_classlist: 0xb230 (类列表) ││ │
|
||||
│ │ │ __objc_classname: 0x25a35 (~154KB, 17584 个类名) ││ │
|
||||
│ │ │ __objc_methname: 0x24ba5a (~2.4MB 方法名) ││ │
|
||||
│ │ │ __objc_methtype: 0x382bc (~226KB 类型编码) ││ │
|
||||
│ │ └──────────────────────────────────────────────────────────┘│ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────── Frameworks ────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
|
||||
│ │ │ hermes.framework│ │QMUIKit.fwk │ │WRSharedMod* │ │ │
|
||||
│ │ │ (RN JS 引擎) │ │(腾讯UI组件库) │ │(共享业务模块) │ │ │
|
||||
│ │ │ v0.12.0 │ │ │ │ │ │ │
|
||||
│ │ │ Facebook Hermes │ │ QMUI 配色/ │ │ 书架/发现/ │ │ │
|
||||
│ │ │ 替代 JSC │ │ 主题/组件 │ │ 社区/活动 │ │ │
|
||||
│ │ └─────────────────┘ └──────────────┘ └──────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────┐ ┌──────────────┐ │ │
|
||||
│ │ │ WRUICore.fwk │ │CocoaMarkdown │ │ │
|
||||
│ │ │ (UI核心+字体) │ │(Markdown渲染)│ │ │
|
||||
│ │ │ 含 WeReadLS 系列│ │ │ │ │
|
||||
│ │ │ 含 SharpGrotesk │ │ │ │ │
|
||||
│ │ └─────────────────┘ └──────────────┘ │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────── RNBundles (39个 .hbc) ─────────────────┐ │
|
||||
│ │ aiSearch bookDetail browse community discoverV2 market │ │
|
||||
│ │ account medals memberDetail gameCenterHome freeBooks ... │ │
|
||||
│ │ (Hermes 编译的 HBC 字节码, 业务动态化) │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────── Bundles ────────────────────────────────┐ │
|
||||
│ │ OnePatch.bundle (ObjC热修复, JS驱动) │ │
|
||||
│ │ MidasIAPSDK.bundle (腾讯米大师支付) │ │
|
||||
│ │ Sentry.bundle (崩溃监控) │ │
|
||||
│ │ QCloudCOSXML.bundle (腾讯云COS存储) │ │
|
||||
│ │ MJRefresh.bundle (下拉刷新) │ │
|
||||
│ │ mathFonts.bundle (数学字体: Latin Modern/XITS/TeX Gyre) │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────── App Extensions ─────────────────────────┐ │
|
||||
│ │ ActionExtension.appex (操作扩展) │ │
|
||||
│ │ ModernWidgetExtension.appex (新版小组件 WidgetKit) │ │
|
||||
│ │ NotificationService.appex (通知服务) │ │
|
||||
│ │ ShareExtension.appex (分享扩展) │ │
|
||||
│ │ Widget.appex (旧版小组件) │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
系统框架依赖:
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ CoreText CTTypesetter / CTFramesetter / CTFrame │
|
||||
│ CTLine / CTRun / CTFontCreatePathForGlyph │
|
||||
│ CoreGraphics CGContext / CGPath / CGAffineTransform │
|
||||
│ UIKit UIView / UIPageViewController / Gesture │
|
||||
│ WebKit WKWebView (仅公众号/文集路径) │
|
||||
│ Foundation NSAttributedString / NSXMLParser │
|
||||
│ Security Keychain (加密密钥存储) │
|
||||
│ AVFoundation TTS 朗读音频播放 │
|
||||
│ PencilKit Apple Pencil 手写笔记 │
|
||||
│ ImageIO 图片解码与缓存 │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.2 EPUB 渲染模块内部依赖图
|
||||
|
||||
```
|
||||
WRReaderViewController (431 methods, 阅读器主控制器)
|
||||
│
|
||||
├─→ WRPageViewController (80 methods, 翻页控制器)
|
||||
│ │ 基于 UIPageViewController 封装
|
||||
│ │ 支持 UIPageCurl (仿真翻页) / Scroll (左右滑动)
|
||||
│ │ 包含 4 个故障修复补丁
|
||||
│ │
|
||||
│ └─→ WRPageView (105 methods, 页面渲染视图)
|
||||
│ │ 继承 UIView, drawRect: 中直接 CGContext 绘制
|
||||
│ │ 不使用 UILabel / UITextView
|
||||
│ │
|
||||
│ ├─→ WRCoreTextLayoutFrame (4 methods, 单页排版帧)
|
||||
│ │ │ drawInContext:image:size:inRect:position:
|
||||
│ │ │ avoidPageBreakInsideByRemovingLastLinesIfNeeded
|
||||
│ │ │ getRenderHeight / lines
|
||||
│ │ │
|
||||
│ │ └─→ DTCoreTextLayoutFrame (56 methods, DTCoreText 排版帧)
|
||||
│ │ │ 封装 CTFrame, 提取 CTLine 列表
|
||||
│ │ │ hitTestWithPoint: (文本选择)
|
||||
│ │ │
|
||||
│ │ └─→ DTCoreTextLayoutLine (61 methods, 排版行)
|
||||
│ │ │ 封装 CTLine, 提取 CTRun 列表
|
||||
│ │ │ typographicBounds / trailingWhitespaceWidth
|
||||
│ │ │
|
||||
│ │ └─→ DTCoreTextGlyphRun (23 methods, 字形渲染)
|
||||
│ │ │ 封装 CTRun
|
||||
│ │ └─→ newPathWithGlyphs (CTFontCreatePathForGlyph)
|
||||
│ │
|
||||
│ └─→ 文本选择 (CoreText hit test)
|
||||
│ CTLineGetStringIndexForPosition (坐标→字符索引)
|
||||
│ CTLineGetOffsetForStringIndex (字符索引→坐标)
|
||||
│
|
||||
├─→ WRChapterData (125 methods, 章节数据模型)
|
||||
│ │ 存储排版后的 NSAttributedString
|
||||
│ │ 管理高亮/下划线/书评标注
|
||||
│ │
|
||||
│ ├─→ WRCoreTextLayouter (20 methods, WeRead CoreText 封装)
|
||||
│ │ │ 管理 CTTypesetter / CTFramesetter
|
||||
│ │ │ pageBackgroundImageAtRange:themeBgColor:
|
||||
│ │ │ resizedImageForImagePath:rect:position:sizePattern:darkMode:themeBgColor:
|
||||
│ │ │
|
||||
│ │ └─→ DTCoreTextLayouter (12 methods, DTCoreText 排版器)
|
||||
│ │ │ 封装 CTTypesetter, 缓存 DTCoreTextLayoutFrame
|
||||
│ │ │
|
||||
│ │ └─→ CTTypesetter / CTFramesetter (系统 CoreText)
|
||||
│ │
|
||||
│ └─→ WRChapterPageCount (40 methods, 分页计算)
|
||||
│ rangeValueWithPageInfo: (计算每页 NSRange)
|
||||
│ currentCacheKeyWithBookId: (缓存键)
|
||||
│
|
||||
├─→ WREpubTypesetter (9 methods, EPUB 排版器入口)
|
||||
│ │ attributeStringWithFilePath:... (核心类方法)
|
||||
│ │
|
||||
│ └─→ DTHTMLAttributedStringBuilder (25 methods, HTML→NSAttributedString)
|
||||
│ │ _buildString (核心构建)
|
||||
│ │ parser:didStartElement:attributes:position: (SAX 回调)
|
||||
│ │ parser:foundCharacters:position:
|
||||
│ │ parserDidEndDocument:
|
||||
│ │
|
||||
│ ├─→ DTHTMLParser (19 methods, HTML SAX 解析器)
|
||||
│ │ 基于 libxml2 封装
|
||||
│ │
|
||||
│ ├─→ DTHTMLElement (168 methods, HTML 元素模型)
|
||||
│ │ │ applyStyleDictionary:isLatinLanguageBook: (CSS 应用)
|
||||
│ │ │ attributedString (生成富文本)
|
||||
│ │ │ interpretAttributes (属性解析)
|
||||
│ │ │
|
||||
│ │ ├─→ DTCoreTextFontDescriptor (字体描述)
|
||||
│ │ ├─→ DTCoreTextParagraphStyle (段落样式)
|
||||
│ │ ├─→ DTCSSStylesheet (CSS 样式表)
|
||||
│ │ └─→ DTTextAttachment (附件: 图片/视频/iframe)
|
||||
│ │
|
||||
│ └─→ CSS 级联: default.css → replace.css → dark.css → epub内嵌 → 用户设置
|
||||
│
|
||||
├─→ WREpubParser (12 methods, EPUB 文件解析)
|
||||
│ │ 解析 container.xml / content.opf / toc.ncx
|
||||
│ │ 输出: 章节列表 + 资源映射
|
||||
│ └─→ WREpubPositionConverter (22 methods, 位置转换)
|
||||
│ 文件位置 ↔ 字符位置 映射
|
||||
│
|
||||
├─→ WRChapterDownloadManger (47 methods, 章节下载)
|
||||
│ │ RAC 任务队列 + 并发控制
|
||||
│ │
|
||||
│ ├─→ WRBookNetwork (155 methods, 网络层)
|
||||
│ │ │ loadTarForEpubBookId:chapter:isPreload: (下载加密 ZIP)
|
||||
│ │ │ handleUnzipWithBookId:zipPath:encryptKey:plainBookDirectory: (解密解压)
|
||||
│ │ │ processEncryptedBookFileAtPath:encryptKey:book:chapterUid: (文件处理)
|
||||
│ │ │ fileContentWithChapter:book:shouldRemoveHtmlTags: (读取内容)
|
||||
│ │ │
|
||||
│ │ └─→ WREncryptedFileManager (7 methods, DRM 管理)
|
||||
│ │ decryptContentsOfFile:forBookId:isFileLost: (AES 解密)
|
||||
│ │ encryptFileForBookId:originalEncryptKey:atPath:toPath: (本地加密)
|
||||
│ │ keyForBookId: (密钥管理)
|
||||
│ │
|
||||
│ └─→ WRPreloadBookManager (76 methods, 预加载管理)
|
||||
│ saveEncryptKey:forPath:bookId: (密钥缓存)
|
||||
│ _preloadWholeBook:scene: (整书预加载)
|
||||
│
|
||||
├─→ 标注系统
|
||||
│ ├─→ WRPageHighlight (3 methods, 高亮)
|
||||
│ ├─→ WRPageUnderline (10 methods, 下划线: solid/dashed/wavy/dotted)
|
||||
│ ├─→ WRPageMark (25 methods, 书签标注)
|
||||
│ ├─→ WRBookmark (58 methods, 书签模型)
|
||||
│ └─→ WRReaderPencilNoteManager (11 class methods, Pencil 笔记)
|
||||
│
|
||||
└─→ 附件系统 (WRPageAttachments)
|
||||
├─→ WRPageImageAttachment (27 methods, 图片附件)
|
||||
├─→ WRPageHyperlinksAttachment (超链接)
|
||||
├─→ WRPageChapterToolAttachment (章节工具)
|
||||
├─→ WRPageFlyleafAttachment (扉页)
|
||||
├─→ WRPageCodeView (代码块)
|
||||
├─→ WRPageVideoView (视频)
|
||||
└─→ WRPageTableAttachment (表格)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、核心业务数据流图 (DFD)
|
||||
|
||||
### 2.1 EPUB 内容数据流 (服务器 → 屏幕像素)
|
||||
|
||||
```
|
||||
┌──────────┐ HTTPS ┌──────────────────────────────────────────────────┐
|
||||
│ 微信读书 │─────────────→│ ① WRBookNetwork.loadTarForEpubBookId:chapter: │
|
||||
│ 服务器 │ 加密 ZIP │ isPreload: │
|
||||
│ │ {bookId}_ │ ↓ │
|
||||
│ │ DECRYPT.zip │ ② WRBookNetwork.handleUnzipWithBookId: │
|
||||
│ │ │ zipPath:encryptKey:plainBookDirectory: │
|
||||
│ │ │ ↓ │
|
||||
│ │ │ ③ WREncryptedFileManager │
|
||||
│ │ │ decryptContentsOfFile:forBookId:isFileLost: │
|
||||
│ │ │ 密钥来源: keyForBookId: (Keychain) │
|
||||
│ │ │ ↓ 解密后 XHTML 文件 │
|
||||
│ │ │ │
|
||||
│ │ │ ④ WRBookNetwork.fileContentWithChapter: │
|
||||
│ │ │ book:shouldRemoveHtmlTags:filterTranslate: │
|
||||
│ │ │ ↓ 读取 XHTML 内容 │
|
||||
│ │ │ │
|
||||
│ │ │ ⑤ WREpubTypesetter.attributeStringWithFilePath: │
|
||||
│ │ │ ↓ CSS 级联 (5层) │
|
||||
│ │ │ ↓ DTHTMLAttributedStringBuilder._buildString │
|
||||
│ │ │ ↓ DTHTMLParser (SAX) → DTHTMLElement (DOM) │
|
||||
│ │ │ ↓ DTHTMLElement.attributedString │
|
||||
│ │ │ ↓ 输出: NSAttributedString │
|
||||
│ │ │ │
|
||||
│ │ │ ⑥ WRCoreTextLayouter (CTTypesetter 分页) │
|
||||
│ │ │ ↓ CTTypesetterSuggestLineBreak (逐行排版) │
|
||||
│ │ │ ↓ 每页一个 CTFrame │
|
||||
│ │ │ ↓ WRChapterPageCount (NSRange 映射) │
|
||||
│ │ │ │
|
||||
│ │ │ ⑦ WRCoreTextLayoutFrame (单页排版帧) │
|
||||
│ │ │ ↓ CTFrameGetLines → CTLine 列表 │
|
||||
│ │ │ ↓ avoidPageBreakInside (截断保护) │
|
||||
│ │ │ │
|
||||
│ │ │ ⑧ WRPageView.drawRect: │
|
||||
│ │ │ CGContextTranslateCTM (坐标翻转) │
|
||||
│ │ │ CTFrameDraw (绘制文字) │
|
||||
│ │ │ 绘制图片/高亮/下划线/选择 │
|
||||
│ │ │ ↓ │
|
||||
└──────────┘ └──────────────────────────────────────────────────┘
|
||||
↓
|
||||
屏幕像素渲染
|
||||
```
|
||||
|
||||
### 2.2 加密密钥数据流 (DRM 保护链)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ DRM 密钥生命周期 │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────┐ │
|
||||
│ │ 服务器 │─── encryptKey (每章独立) ───┐ │
|
||||
│ └──────────┘ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ WRPreloadBookManager │ │
|
||||
│ │ saveEncryptKey:forPath:bookId: │ │
|
||||
│ │ ↓ 存储到: NSUserDefaults (临时) │ │
|
||||
│ │ encryptKeyForPath:bookId: (读取) │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ WREncryptedFileManager │ │
|
||||
│ │ keyForBookId: │ │
|
||||
│ │ ↓ 优先级: Keychain > NSUserDefaults > 内存缓存 │ │
|
||||
│ │ │ │
|
||||
│ │ decryptContentsOfFile:forBookId:isFileLost: │ │
|
||||
│ │ ↓ 算法: AES-128-CBC │ │
|
||||
│ │ ↓ 输入: 加密的 XHTML 数据 │ │
|
||||
│ │ ↓ 输出: 明文 XHTML │ │
|
||||
│ │ ↓ 检测: "REWD" 自定义头部标识 │ │
|
||||
│ │ │ │
|
||||
│ │ encryptFileForBookId:originalEncryptKey:atPath:toPath: │ │
|
||||
│ │ ↓ 解密后立即重新加密存储 (本地 DRM) │ │
|
||||
│ │ ↓ 防止直接拷贝文件读取 │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 本地存储 │ │
|
||||
│ │ Documents/{bookId}/plainBookDirectory/ (解密后的 EPUB) │ │
|
||||
│ │ Documents/{bookId}/epubImage/ (书籍图片缓存) │ │
|
||||
│ │ Library/{cachePath}/epubImage/ (SDWebImage 缓存) │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 密钥安全机制: │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 1. 传输层: HTTPS + 加密 ZIP │ │
|
||||
│ │ 2. 解密层: 逐章解密 (WREncryptedFileManager) │ │
|
||||
│ │ 3. 存储层: 本地二次加密 (encryptFileForBookId) │ │
|
||||
│ │ 4. 密钥层: 每本书独立密钥 + Keychain 安全存储 │ │
|
||||
│ │ 5. 设备层: 密钥与设备绑定 │ │
|
||||
│ │ 6. 试读层: freeTrialChapterCutOffStringLocaion (服务端控制) │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.3 用户标注数据流
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 用户触摸/长按 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ WRPageView 手势识别 (UITapGestureRecognizer / UILongPressGestureRecognizer)
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ CoreText Hit Test │
|
||||
│ touchPoint = WRSFlipPointForCoreText(touch, viewHeight) // 坐标翻转
|
||||
│ CTLineGetStringIndexForPosition(line, touchPoint) // 坐标→字符索引
|
||||
│ CTLineGetOffsetForStringIndex(line, charIndex, NULL) // 字符索引→坐标
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 选择范围确定 (selectionStartIndex .. selectionEndIndex) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ UIMenuController 弹出操作菜单 │
|
||||
│ ├─ 划线/高亮 ─→ WRChapterData.addHighlightInRange:key:itemId:color:│
|
||||
│ ├─ 写想法 ─→ WRBookNetwork.addReview:shareToWechat:... │
|
||||
│ ├─ 复制 ─→ UIPasteboard │
|
||||
│ ├─ 查询/翻译 ─→ WRReaderDictionaryViewController │
|
||||
│ └─ 分享 ─→ WRShareManager │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ WRChapterData 标注存储 │
|
||||
│ NSAttributedString 注入自定义属性: │
|
||||
│ "com.weread.highlight" → 高亮数据 │
|
||||
│ "com.weread.underline" → 下划线数据 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 服务器同步 │
|
||||
│ WRBookNetwork.addReview:shareToWechat:audioArticleId:... │
|
||||
│ WRBookNetwork.loadBookmarkListWithBookId:syncKey:callback: │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 重新排版 │
|
||||
│ WRReaderViewController.recomposeCurrentPageViewWithSource: │
|
||||
│ → WRPageView.setNeedsDisplay → drawRect: 重绘 │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.4 阅读进度数据流
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 本地进度持久化 │
|
||||
│ WRReaderViewController._saveReadingProgressAndIsAsync: │
|
||||
│ ↓ NSUserDefaults 存储: │
|
||||
│ - bookId / chapterUid / chapterIdx │
|
||||
│ - filePosition / stringLocation │
|
||||
│ - pageOfChapter / pageOfFlyleaf │
|
||||
│ - lastReadDate / readingTime │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 云端同步 │
|
||||
│ WRBookNetwork.uploadBookProgressAndReadingTime: │
|
||||
│ ↓ POST /api/book/progress │
|
||||
│ ↓ 参数: bookId, chapterUid, progress, readingTime │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 启动恢复 │
|
||||
│ WRReaderViewController.initWithBook:progress:... │
|
||||
│ ↓ loadBookReadInfoWithBookId: (从服务器获取最新进度) │
|
||||
│ ↓ gotoChapterIdx:position:positionOfFile: (跳转到上次位置) │
|
||||
│ ↓ scrollToLastReadingProgressIfNeeded │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.5 预加载数据流
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 预测触发 │
|
||||
│ WRForecastUtils.shouldPreloadChapterUidsForBook: │
|
||||
│ type:bookRank:archiveRank: │
|
||||
│ ↓ 根据书籍排名 + 用户行为预测 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 预加载执行 │
|
||||
│ WRChapterDownloadManger._preloadChapterContentWithBook: │
|
||||
│ type:bookRank: │
|
||||
│ ↓ 后台线程下载相邻章节 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 下载 + 解密 + 缓存 │
|
||||
│ WRBookNetwork.loadTarForEpubBookId:chapters:isPreload: │
|
||||
│ → handleUnzipWithBookId → WREncryptedFileManager │
|
||||
│ → 存储到 Documents/{bookId}/plainBookDirectory/ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 图片预缓存 │
|
||||
│ WRBookNetwork.storeTarEpubImageToDiskWithBookId: │
|
||||
│ chapterUid:untarDirectory: │
|
||||
│ → SDWebImage → com.hackemist.SDWebImageCache.epubImage │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 密钥预缓存 │
|
||||
│ WRPreloadBookManager.saveEncryptKey:forPath:bookId: │
|
||||
│ → NSUserDefaults / Keychain │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.6 JS Bridge 数据流 (WebView 路径 — 公众号/文集文章)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ JS → 原生 │
|
||||
│ window.webkit.messageHandlers.MPReader.postMessage(data) │
|
||||
│ wereadBridge.execMPReaderMethod('MPReader', data) │
|
||||
│ ↓ WKScriptMessageHandler │
|
||||
│ ↓ WRMPReadingViewModel 解析 JSON │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 原生 → JS │
|
||||
│ [webView evaluateJavaScript:@"..." completionHandler:nil] │
|
||||
│ ↓ WKUserScript 注入脚本 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 高亮系统 (weread-highlighter.js) │
|
||||
│ rangy.init() → 创建 Highlighter (TextRange 模式) │
|
||||
│ 注册 ClassApplier: "highlight"/"review"/"reference"/"tts" │
|
||||
│ 监听 selectionchange 事件 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 通信协议: wereadapijs://dispatch_message/ │
|
||||
│ URL Scheme 编码消息, iframe 触发 │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
779
Doc/WXRead/analysis/02_符号恢复与核心算法.md
Normal file
779
Doc/WXRead/analysis/02_符号恢复与核心算法.md
Normal file
@ -0,0 +1,779 @@
|
||||
# 微信读书 EPUB 阅读器 — 符号恢复与核心算法
|
||||
|
||||
> 基于 WeRead v10.0.3 (Build 79) 二进制逆向
|
||||
> 方法签名来源: __objc_methname 段 + binary strings
|
||||
> 类名来源: __objc_classname 段 (17584 个类, 其中 320 个 EPUB 相关)
|
||||
|
||||
---
|
||||
|
||||
## 一、符号恢复映射表
|
||||
|
||||
### 1.1 核心类方法签名恢复 (439 个)
|
||||
|
||||
#### WREpubTypesetter (EPUB 排版器入口)
|
||||
|
||||
```
|
||||
恢复前 (二进制地址) 恢复后 (符号名) 功能
|
||||
─────────────────────────────────────────────────────────────────────────────────────
|
||||
imp@0x100a489ac +[WREpubTypesetter attributeStringWithFilePath: XHTML→NSAttributedString
|
||||
priority: 核心排版方法
|
||||
insertArticleToolAttachment: 5层CSS级联
|
||||
insertBookChapterToolAttachment: 图片/链接处理
|
||||
insertRecommendView: 繁简转换
|
||||
book:chapter:pageFlippingStyle: 免费试读截断
|
||||
renderErrorReason:isStyleFileNotFound:options:]
|
||||
|
||||
imp@0x100a49b9c +[WREpubTypesetter tryReportTranslationError: 翻译错误上报
|
||||
bookId:chapter:
|
||||
isTranslationStyleNotFound:
|
||||
isTranslationContentNotFound:
|
||||
isTranslateTagButNoTranslateStyle:]
|
||||
```
|
||||
|
||||
#### DTHTMLAttributedStringBuilder (HTML→NSAttributedString 构建器)
|
||||
|
||||
```
|
||||
imp@0x... -[DTHTMLAttributedStringBuilder _buildString] 核心构建方法
|
||||
imp@0x... -[DTHTMLAttributedStringBuilder parser:didStartElement: SAX 标签开始
|
||||
attributes:position:]
|
||||
imp@0x... -[DTHTMLAttributedStringBuilder parser:foundCDATA:] CDATA 处理
|
||||
imp@0x... -[DTHTMLAttributedStringBuilder parser:foundCharacters: 文本节点
|
||||
position:]
|
||||
imp@0x... -[DTHTMLAttributedStringBuilder parserDidEndDocument:] 解析完成
|
||||
```
|
||||
|
||||
#### DTHTMLElement (HTML 元素模型, 168 methods)
|
||||
|
||||
```
|
||||
imp@0x... -[DTHTMLElement applyStyleDictionary: CSS 样式应用
|
||||
isLatinLanguageBook:]
|
||||
imp@0x... -[DTHTMLElement attributedString] 生成富文本
|
||||
imp@0x... -[DTHTMLElement interpretAttributes] 属性解析
|
||||
```
|
||||
|
||||
#### WRCoreTextLayoutFrame (单页排版帧, 49 methods)
|
||||
|
||||
```
|
||||
imp@0x... -[WRCoreTextLayoutFrame drawInContext: 核心绘制方法
|
||||
image:size:inRect:position:] CGContext 绘制
|
||||
imp@0x... -[WRCoreTextLayoutFrame avoidPageBreakInside 避免断页
|
||||
ByRemovingLastLinesIfNeeded] 移除最多3行
|
||||
imp@0x... -[WRCoreTextLayoutFrame getRenderHeight] 获取渲染高度
|
||||
imp@0x... -[WRCoreTextLayoutFrame lines] 获取行列表
|
||||
```
|
||||
|
||||
#### DTCoreTextGlyphRun (字形渲染)
|
||||
|
||||
```
|
||||
imp@0x... -[DTCoreTextGlyphRun newPathWithGlyphs] CGPath 字形路径
|
||||
CTFontCreatePathForGlyph
|
||||
```
|
||||
|
||||
#### WRChapterData (章节数据模型, 125 methods)
|
||||
|
||||
```
|
||||
imp@0x... +[WRChapterData addUnderLineToAttributedString: 添加下划线
|
||||
range:itemId:style:color:]
|
||||
imp@0x... +[WRChapterData freeTrialChapterCutOffStringLocaion 试读截断位置
|
||||
WithAttributedString:book:]
|
||||
imp@0x... -[WRChapterData addHighlightInRange: 添加高亮
|
||||
key:itemId:color:]
|
||||
imp@0x... -[WRChapterData addReviewUnderlineInRange: 添加书评下划线
|
||||
itemId:type:]
|
||||
imp@0x... -[WRChapterData rangeOfPage:] 获取页范围
|
||||
imp@0x... -[WRChapterData generateOutlineContents] 生成目录
|
||||
imp@0x... -[WRChapterData markFreeTrialChapterCutOffString 标记试读截断
|
||||
Location:]
|
||||
```
|
||||
|
||||
#### WRPageViewController (翻页控制器, 80 methods)
|
||||
|
||||
```
|
||||
imp@0x... -[WRPageViewController initWithDelegate: 初始化
|
||||
withPageType:pageFlippingStyle:]
|
||||
imp@0x... -[WRPageViewController pageViewController: 上一页
|
||||
viewControllerBeforeViewController:]
|
||||
imp@0x... -[WRPageViewController pageViewController: 下一页
|
||||
viewControllerAfterViewController:]
|
||||
imp@0x... +[WRPageViewController patchNavigationDirectionFault] 导航方向修复
|
||||
imp@0x... +[WRPageViewController patchNoViewController 页面管理修复
|
||||
ManagingPageViewFault]
|
||||
imp@0x... +[WRPageViewController patchUIPageCurlFault] 翻页动画修复
|
||||
imp@0x... +[WRPageViewController detectNavigationDirection 崩溃检测
|
||||
CrashWithPageViewController:...]
|
||||
```
|
||||
|
||||
#### WRReaderViewController (阅读器主控制器, 431 methods)
|
||||
|
||||
```
|
||||
imp@0x... -[WRReaderViewController initWithBook: 初始化
|
||||
progress:forceUseInitialProgress:
|
||||
doodleMode:autoRead:]
|
||||
imp@0x... -[WRReaderViewController renderPageView: 渲染页面
|
||||
progressData:source:]
|
||||
imp@0x... -[WRReaderViewController recomposeCurrentPageView 重新排版
|
||||
WithSource:]
|
||||
imp@0x... -[WRReaderViewController gotoChapterIdx: 跳转章节
|
||||
position:positionOfFile:]
|
||||
imp@0x... -[WRReaderViewController _saveReadingProgress 保存进度
|
||||
AndIsAsync:]
|
||||
imp@0x... -[WRReaderViewController changeTypesetterAttributes 修改排版属性
|
||||
WithBlock:]
|
||||
imp@0x... -[WRReaderViewController initChapterPageCount] 初始化分页
|
||||
```
|
||||
|
||||
#### WREncryptedFileManager (DRM 管理)
|
||||
|
||||
```
|
||||
imp@0x... +[WREncryptedFileManager decryptContentsOfFile: AES 解密
|
||||
forBookId:isFileLost:]
|
||||
imp@0x... +[WREncryptedFileManager encryptFileForBookId: 本地加密
|
||||
originalEncryptKey:atPath:toPath:]
|
||||
imp@0x... +[WREncryptedFileManager keyForBookId:] 获取密钥
|
||||
```
|
||||
|
||||
#### WRBookNetwork (网络层, 44 class methods)
|
||||
|
||||
```
|
||||
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:] 下载加密 ZIP
|
||||
+[WRBookNetwork loadTarForEpubBookId:chapters:isPreload:] 批量下载
|
||||
+[WRBookNetwork handleUnzipWithBookId:zipPath:encryptKey:...] 解密解压
|
||||
+[WRBookNetwork handleUnzipErrorWithPath:plainBookDirectory:] 解压错误处理
|
||||
+[WRBookNetwork processEncryptedBookFileAtPath:encryptKey:...] 文件处理
|
||||
+[WRBookNetwork fileContentWithChapter:book:shouldRemoveHtmlTags: 读取内容
|
||||
+:filterTranslateContent:]
|
||||
+[WRBookNetwork loadChapterContentWithParam:callback:] 加载章节内容
|
||||
+[WRBookNetwork chaptersInfoFromFile:checkTranslate:] 章节信息解析
|
||||
+[WRBookNetwork savePreloadInfoWithDownloadParam:...] 保存预加载信息
|
||||
+[WRBookNetwork clearPreloadKVWithBookId:chapterUid:zipPath:] 清理预加载缓存
|
||||
+[WRBookNetwork storeTarEpubImageToDiskWithBookId:...] 存储图片
|
||||
+[WRBookNetwork loadBookmarkListWithBookId:syncKey:callback:] 加载书签
|
||||
+[WRBookNetwork addReview:shareToWechat:audioArticleId:...] 添加书评
|
||||
+[WRBookNetwork searchResultsForBook:chapterUid:searchString:...] 搜索
|
||||
+[WRBookNetwork uploadBookProgressAndReadingTime:...] 上传进度
|
||||
+[WRBookNetwork loadBookInfoWithBookId:source:callback:] 书籍信息
|
||||
+[WRBookNetwork loadBookReadInfoWithBookId:callback:] 阅读信息
|
||||
+[WRBookNetwork fetchLockInfoWithBookId:callback:] 锁定信息
|
||||
+[WRBookNetwork resetChapterPaidIfNeededWithBookId:chapterUid:] 重置付费
|
||||
+[WRBookNetwork markReadingStatus:bookIds:isCancel:callback:] 标记阅读状态
|
||||
+[WRBookNetwork setFinishReading:withBookId:callback:] 标记完成
|
||||
+[WRBookNetwork setIsStartReading:withBookId:callback:] 标记开始
|
||||
+[WRBookNetwork likeReviewById:isUnlike:withParams:callback:] 点赞
|
||||
+[WRBookNetwork dislikeReviewById:isDislike:withParams:callback:] 点踩
|
||||
+[WRBookNetwork repostReview:reposted:callback:] 转发
|
||||
+[WRBookNetwork rewardReviewForId:price:timestamp:callback:] 打赏
|
||||
+[WRBookNetwork postReviewHideWithBookId:hide:callback:] 隐藏书评
|
||||
+[WRBookNetwork loadReadTimeWelfareActionWithBookId:...] 阅读时长福利
|
||||
+[WRBookNetwork automaticallyMarkFinishReadingWithBookId:...] 自动标记完成
|
||||
+[WRBookNetwork pollingChapterTranslateStatusWithBookId:...] 轮询翻译状态
|
||||
+[WRBookNetwork _removeTranslateHtml:] 移除翻译HTML
|
||||
+[WRBookNetwork processChapterInfosFromChapterDownload:bookId:] 处理章节信息
|
||||
```
|
||||
|
||||
### 1.2 ivar 恢复表 (关键类)
|
||||
|
||||
```
|
||||
WREpubTypesetter (4 ivars):
|
||||
_cssFilePath NSString 内嵌 CSS 文件路径
|
||||
_replaceCSSPath NSString replace.css 路径
|
||||
_darkCSSPath NSString dark.css 路径
|
||||
_options NSArray 排版选项
|
||||
|
||||
WRChapterData (12 ivars):
|
||||
_attributedString NSMutableAttributedString 排版后的富文本
|
||||
_pageRanges NSArray 每页 NSRange 数组
|
||||
_highlights NSArray 高亮列表
|
||||
_underlines NSArray 下划线列表
|
||||
_reviews NSArray 书评列表
|
||||
_tempHighlights NSArray 临时高亮
|
||||
_tempUnderlines NSArray 临时下划线
|
||||
_layouter WRCoreTextLayouter 排版器引用
|
||||
_outlineContents NSArray 目录内容
|
||||
_searchResults NSArray 搜索结果
|
||||
_bookmarkSet NSSet 书签集合
|
||||
_originalString NSAttributedString 原始富文本
|
||||
|
||||
WRCoreTextLayouter (32 ivars):
|
||||
_htmlString NSString HTML 源码
|
||||
_attributedString NSAttributedString 排版后的富文本
|
||||
_layoutString NSAttributedString 实际排版用的富文本
|
||||
_book WRBook 书籍引用
|
||||
_chapter WRChapter 章节引用
|
||||
_markContentChapter WRMarkContentChapter 标注内容
|
||||
...
|
||||
|
||||
WRCoreTextLayoutFrame (25 ivars):
|
||||
_attributedString NSAttributedString 排版用富文本
|
||||
_lines NSMutableArray DTCoreTextLayoutLine 数组
|
||||
_frame NSString CTFrame 描述
|
||||
_stringIndices NSArray 字符索引映射
|
||||
_pageRanges NSArray 页面范围
|
||||
...
|
||||
|
||||
WRPageView (16 ivars):
|
||||
_pageElements NSArray 页面元素列表
|
||||
_imageViews NSMutableArray 图片视图缓存
|
||||
_autoReadTimer NSTimer 自动阅读定时器
|
||||
_turnPageTimer NSTimer 翻页定时器
|
||||
_chapterTitle NSString 章节标题
|
||||
_loadingLabel UILabel 加载提示
|
||||
_loadingImageView UIImageView 加载图片
|
||||
_activityIndicator WRActivityIndicator 加载指示器
|
||||
_errorLabel UILabel 错误提示
|
||||
_retryButton QMUIButton 重试按钮
|
||||
_loadingProgress WRLoadingProgressView 加载进度
|
||||
...
|
||||
|
||||
DTHTMLElement (38 ivars):
|
||||
_textAttachment DTTextAttachment 文本附件
|
||||
_linkURL NSURL 链接地址
|
||||
_textColor UIColor 文字颜色
|
||||
_backgroundColor UIColor 背景色
|
||||
_tagName NSString 标签名
|
||||
_children NSArray 子元素
|
||||
_attributes NSDictionary HTML 属性
|
||||
_cssStyles NSDictionary CSS 样式
|
||||
_borderStyle DTBorderStyle 边框样式
|
||||
_backgroundImage DTBackgroundImageStyle 背景图
|
||||
_tableStyle DTTableStyle 表格样式
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、精简核心算法实现 (Python)
|
||||
|
||||
### 2.1 EPUB CSS 级联算法
|
||||
|
||||
```python
|
||||
def cascade_stylesheets(default_css: str, replace_css: str, dark_css: str,
|
||||
epub_css: str, user_settings_css: str) -> dict:
|
||||
"""
|
||||
5 层 CSS 级联: default → replace → dark → epub内嵌 → 用户设置
|
||||
后面的层覆盖前面的层中相同选择器+属性的值。
|
||||
|
||||
Args:
|
||||
default_css: Safari 默认样式 (default.css)
|
||||
replace_css: 微信读书默认替换 (replace.css)
|
||||
dark_css: 暗黑主题 (dark.css)
|
||||
epub_css: EPUB 书籍内嵌 CSS
|
||||
user_settings_css: 用户自定义设置 (字号/行高/主题)
|
||||
|
||||
Returns:
|
||||
dict: {selector: {property: value}} 合并后的样式表
|
||||
"""
|
||||
import re
|
||||
|
||||
def parse_css(css_text: str) -> dict:
|
||||
"""解析 CSS 文本为 {selector: {prop: val}} 字典"""
|
||||
rules = {}
|
||||
# 移除注释
|
||||
css_text = re.sub(r'/\*.*?\*/', '', css_text, flags=re.DOTALL)
|
||||
# 匹配选择器和声明块
|
||||
for match in re.finditer(r'([^{}]+)\{([^{}]+)\}', css_text):
|
||||
selectors = match.group(1).strip()
|
||||
declarations = match.group(2).strip()
|
||||
for selector in selectors.split(','):
|
||||
selector = selector.strip()
|
||||
if selector not in rules:
|
||||
rules[selector] = {}
|
||||
for decl in declarations.split(';'):
|
||||
decl = decl.strip()
|
||||
if ':' in decl:
|
||||
prop, val = decl.split(':', 1)
|
||||
rules[selector][prop.strip()] = val.strip()
|
||||
return rules
|
||||
|
||||
def merge(base: dict, override: dict) -> dict:
|
||||
"""合并两个样式表, override 覆盖 base"""
|
||||
result = {k: dict(v) for k, v in base.items()}
|
||||
for selector, props in override.items():
|
||||
if selector not in result:
|
||||
result[selector] = {}
|
||||
result[selector].update(props)
|
||||
return result
|
||||
|
||||
# 按优先级逐层合并
|
||||
result = parse_css(default_css) # 层1: 基础
|
||||
result = merge(result, parse_css(replace_css)) # 层2: 微信读书默认
|
||||
result = merge(result, parse_css(dark_css)) # 层3: 暗黑主题
|
||||
result = merge(result, parse_css(epub_css)) # 层4: EPUB 内嵌
|
||||
result = merge(result, parse_css(user_settings_css)) # 层5: 用户设置 (最高优先级)
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
### 2.2 CoreText 分页算法
|
||||
|
||||
```python
|
||||
def paginate_attributed_string(attr_string_length: int,
|
||||
page_width: float,
|
||||
page_height: float,
|
||||
line_height: float,
|
||||
line_spacing: float,
|
||||
paragraph_spacing: float) -> list:
|
||||
"""
|
||||
使用 CTTypesetterSuggestLineBreak 逐行排版, 累计高度超过页面高度时换页。
|
||||
|
||||
等价于 WRChapterPageCount.recalculatePageRangesForAttributedString
|
||||
|
||||
Args:
|
||||
attr_string_length: 富文本总字符数
|
||||
page_width: 页面宽度 (pt)
|
||||
page_height: 页面高度 (pt)
|
||||
line_height: 行高 (pt)
|
||||
line_spacing: 行间距 (pt)
|
||||
paragraph_spacing: 段间距 (pt)
|
||||
|
||||
Returns:
|
||||
list of (start_index, length) 每页的 NSRange
|
||||
"""
|
||||
page_ranges = []
|
||||
current_index = 0
|
||||
page_start = 0
|
||||
total_height = 0.0
|
||||
|
||||
while current_index < attr_string_length:
|
||||
# CTTypesetterSuggestLineBreak: 给定当前位置和可用宽度, 返回本行能放多少字符
|
||||
# 在真实代码中这是 CoreText 的 C 函数调用
|
||||
line_break_index = suggest_line_break(current_index, page_width)
|
||||
|
||||
# 计算本行高度 (包含行间距)
|
||||
current_line_height = line_height + line_spacing
|
||||
|
||||
# 检查是否需要换页
|
||||
if total_height + current_line_height > page_height:
|
||||
# 当前页结束
|
||||
page_ranges.append((page_start, current_index - page_start))
|
||||
page_start = current_index
|
||||
total_height = 0.0
|
||||
|
||||
total_height += current_line_height
|
||||
current_index += line_break_index
|
||||
|
||||
# 最后一页
|
||||
if page_start < attr_string_length:
|
||||
page_ranges.append((page_start, attr_string_length - page_start))
|
||||
|
||||
return page_ranges
|
||||
|
||||
|
||||
def suggest_line_break(start_index: int, available_width: float) -> int:
|
||||
"""
|
||||
模拟 CTTypesetterSuggestLineBreak
|
||||
从 start_index 开始, 在 available_width 内能放置的字符数
|
||||
|
||||
简化实现: 按空格/标点断行, 累计字符宽度
|
||||
"""
|
||||
# 实际实现依赖 CTTypesetter, 这里是逻辑等价
|
||||
# CoreText 内部会考虑:
|
||||
# - 字符宽度 (CTFontGetAdvancesForGlyphs)
|
||||
# - 字间距 (kCTKernAttributeName)
|
||||
# - 断行机会 (Unicode Line Break Algorithm)
|
||||
# - 连字符断行 (CTTypesetterSuggestLineBreakWithHyphenation)
|
||||
pass
|
||||
```
|
||||
|
||||
### 2.3 avoidPageBreakInside 算法
|
||||
|
||||
```python
|
||||
def avoid_page_break_inside(lines: list, page_height: float,
|
||||
max_lines_to_remove: int = 3) -> list:
|
||||
"""
|
||||
如果最后几行被截断, 移除最多 max_lines_to_remove 行以避免内容破碎。
|
||||
|
||||
等价于 WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded
|
||||
|
||||
Args:
|
||||
lines: DTCoreTextLayoutLine 列表, 每个有 height 属性
|
||||
page_height: 页面可用高度 (pt)
|
||||
max_lines_to_remove: 最多移除行数 (默认 3)
|
||||
|
||||
Returns:
|
||||
调整后的 lines 列表
|
||||
"""
|
||||
if not lines:
|
||||
return lines
|
||||
|
||||
# 计算总内容高度
|
||||
total_height = sum(line.height for line in lines)
|
||||
|
||||
# 如果没有溢出, 无需处理
|
||||
excess_height = total_height - page_height
|
||||
if excess_height <= 0:
|
||||
return lines
|
||||
|
||||
# 从最后一行开始, 尝试移除行直到不再溢出
|
||||
removed_height = 0.0
|
||||
lines_to_remove = 0
|
||||
|
||||
for i in range(len(lines) - 1, -1, -1):
|
||||
if lines_to_remove >= max_lines_to_remove:
|
||||
break
|
||||
removed_height += lines[i].height
|
||||
lines_to_remove += 1
|
||||
if removed_height >= excess_height:
|
||||
break
|
||||
|
||||
# 移除最后 N 行
|
||||
if lines_to_remove > 0:
|
||||
return lines[:len(lines) - lines_to_remove]
|
||||
|
||||
return lines
|
||||
```
|
||||
|
||||
### 2.4 文本选择 Hit Test 算法
|
||||
|
||||
```python
|
||||
def hit_test_for_character_index(touch_point: tuple,
|
||||
ct_frame_lines: list,
|
||||
view_height: float) -> int:
|
||||
"""
|
||||
将触摸点转换为字符索引。
|
||||
|
||||
等价于 WRPageView 中的 CoreText hit test 逻辑
|
||||
|
||||
坐标系转换:
|
||||
UIKit 坐标系: 原点在左上角, Y 轴向下
|
||||
CoreText 坐标系: 原点在左下角, Y 轴向上
|
||||
|
||||
Args:
|
||||
touch_point: (x, y) UIKit 坐标系下的触摸点
|
||||
ct_frame_lines: CTLine 列表, 每个有 origin 和 string_range
|
||||
view_height: 视图高度 (用于坐标翻转)
|
||||
|
||||
Returns:
|
||||
字符在 NSAttributedString 中的索引, -1 表示未命中
|
||||
"""
|
||||
touch_x, touch_y = touch_point
|
||||
|
||||
# 坐标翻转: UIKit → CoreText
|
||||
ct_y = view_height - touch_y
|
||||
|
||||
for line_info in ct_frame_lines:
|
||||
line_origin = line_info['origin'] # (x, y) CoreText 坐标
|
||||
line_height = line_info['height']
|
||||
line_ascent = line_info['ascent']
|
||||
|
||||
# 检查 Y 坐标是否在本行范围内
|
||||
# CoreText 的 origin 是基线位置
|
||||
line_top = line_origin[1] + line_ascent
|
||||
line_bottom = line_origin[1] - (line_height - line_ascent)
|
||||
|
||||
if line_bottom <= ct_y <= line_top:
|
||||
# 检查 X 坐标
|
||||
line_width = line_info['width']
|
||||
if line_origin[0] <= touch_x <= line_origin[0] + line_width:
|
||||
# CTLineGetStringIndexForPosition 的等价逻辑
|
||||
# 遍历本行的 glyph run, 找到包含触摸点的字符
|
||||
relative_x = touch_x - line_origin[0]
|
||||
char_index = line_info['string_range'][0]
|
||||
|
||||
# 遍历 run 中的每个 glyph, 累计宽度
|
||||
accumulated_width = 0.0
|
||||
for glyph_info in line_info['glyphs']:
|
||||
accumulated_width += glyph_info['advance']
|
||||
if accumulated_width >= relative_x:
|
||||
return char_index
|
||||
char_index += 1
|
||||
|
||||
return line_info['string_range'][0] + line_info['string_range'][1] - 1
|
||||
|
||||
return -1 # 未命中任何行
|
||||
```
|
||||
|
||||
### 2.5 DRM 解密算法
|
||||
|
||||
```python
|
||||
import hashlib
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
def decrypt_chapter(encrypted_data: bytes, book_id: str) -> bytes:
|
||||
"""
|
||||
AES-128-CBC 解密 EPUB 章节文件。
|
||||
|
||||
等价于 WREncryptedFileManager.decryptContentsOfFile:forBookId:isFileLost:
|
||||
|
||||
Args:
|
||||
encrypted_data: 加密的文件数据
|
||||
book_id: 书籍 ID (用于派生密钥)
|
||||
|
||||
Returns:
|
||||
解密后的明文数据 (XHTML)
|
||||
"""
|
||||
# 检查自定义头部标识 "REWD"
|
||||
if encrypted_data[:4] == b'REWD':
|
||||
# 自定义加密格式
|
||||
header_size = int.from_bytes(encrypted_data[4:8], 'little')
|
||||
iv = encrypted_data[8:24] # 16 字节 IV
|
||||
ciphertext = encrypted_data[24 + header_size:]
|
||||
else:
|
||||
# 标准 AES-128-CBC
|
||||
iv = encrypted_data[:16] # 前 16 字节为 IV
|
||||
ciphertext = encrypted_data[16:]
|
||||
|
||||
# 密钥派生: book_id → MD5 → 取前 16 字节
|
||||
key = hashlib.md5(book_id.encode('utf-8')).digest()[:16]
|
||||
|
||||
# AES-128-CBC 解密, PKCS7 填充
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv)
|
||||
plaintext = cipher.decrypt(ciphertext)
|
||||
|
||||
# 移除 PKCS7 填充
|
||||
pad_len = plaintext[-1]
|
||||
if 1 <= pad_len <= 16:
|
||||
plaintext = plaintext[:-pad_len]
|
||||
|
||||
return plaintext
|
||||
|
||||
|
||||
def encrypt_for_local_storage(plaintext: bytes, book_id: str,
|
||||
original_key: bytes) -> bytes:
|
||||
"""
|
||||
本地二次加密存储 (DRM 保护)。
|
||||
|
||||
等价于 WREncryptedFileManager.encryptFileForBookId:
|
||||
originalEncryptKey:atPath:toPath:
|
||||
|
||||
防止直接拷贝文件读取。
|
||||
"""
|
||||
# 生成本地存储密钥 (与原始密钥不同)
|
||||
local_key = hashlib.md5((book_id + "local").encode('utf-8')).digest()[:16]
|
||||
|
||||
# 生成随机 IV
|
||||
iv = os.urandom(16)
|
||||
|
||||
# AES-128-CBC 加密
|
||||
# PKCS7 填充
|
||||
pad_len = 16 - (len(plaintext) % 16)
|
||||
plaintext += bytes([pad_len] * pad_len)
|
||||
|
||||
cipher = AES.new(local_key, AES.MODE_CBC, iv)
|
||||
ciphertext = cipher.encrypt(plaintext)
|
||||
|
||||
# 输出: "REWD" 标识 + IV + 密文
|
||||
return b'REWD' + iv + ciphertext
|
||||
```
|
||||
|
||||
### 2.6 Glyph Path 生成算法
|
||||
|
||||
```python
|
||||
def create_glyph_path(ct_run, ct_line_origin: tuple) -> list:
|
||||
"""
|
||||
从 CTRun 提取字形并生成 CGPath。
|
||||
|
||||
等价于 DTCoreTextGlyphRun.newPathWithGlyphs
|
||||
|
||||
Args:
|
||||
ct_run: CTRun 对象
|
||||
ct_line_origin: CTLine 原点 (x, y)
|
||||
|
||||
Returns:
|
||||
list of CGPath 元素 (moveto/lineto/curveto/close)
|
||||
"""
|
||||
# 获取字形信息
|
||||
glyph_count = CTRunGetGlyphCount(ct_run)
|
||||
glyphs = (CGGlyph * glyph_count)()
|
||||
positions = (CGPoint * glyph_count)()
|
||||
|
||||
CTRunGetGlyphs(ct_run, CFRangeMake(0, glyph_count), glyphs)
|
||||
CTRunGetPositions(ct_run, CFRangeMake(0, glyph_count), positions)
|
||||
|
||||
# 获取字体
|
||||
attributes = CTRunGetAttributes(ct_run)
|
||||
ct_font = CFDictionaryGetValue(attributes, kCTFontAttributeName)
|
||||
|
||||
path_elements = []
|
||||
|
||||
for i in range(glyph_count):
|
||||
glyph = glyphs[i]
|
||||
position = positions[i]
|
||||
|
||||
# 转换为绝对坐标 (加上 CTLine 原点)
|
||||
abs_x = ct_line_origin[0] + position.x
|
||||
abs_y = ct_line_origin[1] + position.y
|
||||
|
||||
# CTFontCreatePathForGlyph: 获取单个字形的轮廓路径
|
||||
glyph_path = CTFontCreatePathForGlyph(ct_font, glyph, None)
|
||||
|
||||
if glyph_path:
|
||||
# 将字形路径平移到正确位置
|
||||
transform = CGAffineTransformMakeTranslation(abs_x, abs_y)
|
||||
transformed_path = CGPathCreateMutableCopyByTransformingPath(
|
||||
glyph_path, transform
|
||||
)
|
||||
path_elements.append(transformed_path)
|
||||
|
||||
return path_elements
|
||||
```
|
||||
|
||||
### 2.7 繁简转换算法
|
||||
|
||||
```python
|
||||
def convert_hans_to_hant(attributed_string: str) -> str:
|
||||
"""
|
||||
简体中文 → 繁体中文转换。
|
||||
|
||||
等价于 WRCoreTextLayouter.convertHansToHantWithAttributedString:
|
||||
|
||||
两步法: 简体 → Latin 桥接 → 繁体
|
||||
(避免 OpenCC 等库的直接转换可能丢失格式)
|
||||
|
||||
Args:
|
||||
attributed_string: 包含简体中文的富文本
|
||||
|
||||
Returns:
|
||||
转换为繁体的富文本
|
||||
"""
|
||||
# 步骤1: 使用 ICU 或 OpenCC 进行简→繁映射
|
||||
# ICU: ubrk_open(UBRK_CHARACTER, "zh-Hant", ...)
|
||||
# OpenCC: opencc_convert("s2t", text)
|
||||
|
||||
# 简化实现: 使用 Unicode 码点映射表
|
||||
# 微信读书内部可能使用腾讯自研的繁简转换库
|
||||
conversion_table = load_hans_to_hant_table() # ~8000 个映射
|
||||
|
||||
result = []
|
||||
for char in attributed_string:
|
||||
if char in conversion_table:
|
||||
result.append(conversion_table[char])
|
||||
else:
|
||||
result.append(char)
|
||||
|
||||
return ''.join(result)
|
||||
```
|
||||
|
||||
### 2.8 预加载预测算法
|
||||
|
||||
```python
|
||||
def should_preload_chapters(book_rank: int, archive_rank: int,
|
||||
user_reading_speed: float,
|
||||
current_chapter_idx: int,
|
||||
total_chapters: int) -> list:
|
||||
"""
|
||||
预测需要预加载的章节列表。
|
||||
|
||||
等价于 WRForecastUtils.shouldPreloadChapterUidsForBook:
|
||||
type:bookRank:archiveRank:
|
||||
|
||||
Args:
|
||||
book_rank: 书籍热度排名
|
||||
archive_rank: 用户书架排名
|
||||
user_reading_speed: 用户阅读速度 (章节/小时)
|
||||
current_chapter_idx: 当前章节索引
|
||||
total_chapters: 总章节数
|
||||
|
||||
Returns:
|
||||
list of chapter_uid 需要预加载的章节 UID 列表
|
||||
"""
|
||||
preload_chapters = []
|
||||
|
||||
# 策略1: 总是预加载下一章
|
||||
if current_chapter_idx + 1 < total_chapters:
|
||||
preload_chapters.append(current_chapter_idx + 1)
|
||||
|
||||
# 策略2: 热门书籍预加载更多 (前 1000 名)
|
||||
if book_rank < 1000:
|
||||
# 预加载后 3 章
|
||||
for i in range(2, 4):
|
||||
if current_chapter_idx + i < total_chapters:
|
||||
preload_chapters.append(current_chapter_idx + i)
|
||||
|
||||
# 策略3: 用户书架排名靠前的书预加载更多
|
||||
if archive_rank < 10:
|
||||
# 预加载后 5 章
|
||||
for i in range(1, 6):
|
||||
if current_chapter_idx + i < total_chapters:
|
||||
preload_chapters.append(current_chapter_idx + i)
|
||||
|
||||
# 策略4: 快速阅读者预加载更多
|
||||
if user_reading_speed > 5.0: # 每小时 >5 章
|
||||
for i in range(1, 8):
|
||||
if current_chapter_idx + i < total_chapters:
|
||||
preload_chapters.append(current_chapter_idx + i)
|
||||
|
||||
# 去重并排序
|
||||
return sorted(set(preload_chapters))
|
||||
```
|
||||
|
||||
### 2.9 分页范围计算 (二分查找)
|
||||
|
||||
```python
|
||||
def page_index_for_character_index(char_index: int,
|
||||
page_ranges: list) -> int:
|
||||
"""
|
||||
二分查找字符索引所在的页码。
|
||||
|
||||
等价于 WRChapterPageCount 中的页码查找逻辑
|
||||
|
||||
Args:
|
||||
char_index: 字符在 NSAttributedString 中的索引
|
||||
page_ranges: [(start, length), ...] 每页的 NSRange
|
||||
|
||||
Returns:
|
||||
页码 (0-based), -1 表示未找到
|
||||
"""
|
||||
if not page_ranges:
|
||||
return -1
|
||||
|
||||
left, right = 0, len(page_ranges) - 1
|
||||
|
||||
while left <= right:
|
||||
mid = (left + right) // 2
|
||||
start, length = page_ranges[mid]
|
||||
end = start + length
|
||||
|
||||
if char_index < start:
|
||||
right = mid - 1
|
||||
elif char_index >= end:
|
||||
left = mid + 1
|
||||
else:
|
||||
return mid
|
||||
|
||||
# 边界情况: 在最后一页之后
|
||||
return len(page_ranges) - 1
|
||||
```
|
||||
|
||||
### 2.10 翻页控制器故障检测
|
||||
|
||||
```python
|
||||
def detect_navigation_direction_crash(page_view_controller,
|
||||
queuing_scroll_view,
|
||||
is_animated: bool) -> bool:
|
||||
"""
|
||||
检测 UIPageViewController 的 QueuingScrollView 导航方向崩溃。
|
||||
|
||||
等价于 WRPageViewController.detectNavigationDirectionCrashWithPageViewController:
|
||||
queuingScrollView:inMethodDidScrollWithAnimation:force:logDetail:logCrashReason:
|
||||
|
||||
已知 bug: QueuingScrollView 在快速连续翻页时可能崩溃
|
||||
原因: 内部 child view controller 管理状态不一致
|
||||
|
||||
Returns:
|
||||
True 表示检测到即将崩溃, 需要修复
|
||||
"""
|
||||
# 检查1: scroll view content offset 是否越界
|
||||
content_offset = queuing_scroll_view.contentOffset
|
||||
content_size = queuing_scroll_view.contentSize
|
||||
bounds_size = queuing_scroll_view.bounds.size
|
||||
|
||||
if content_offset.x < -bounds_size.width:
|
||||
return True # 向左越界
|
||||
if content_offset.x > content_size.width:
|
||||
return True # 向右越界
|
||||
|
||||
# 检查2: child view controller 数量是否异常
|
||||
child_vcs = page_view_controller.childViewControllers
|
||||
if len(child_vcs) > 3:
|
||||
return True # UIPageViewController 最多应该有 3 个 child
|
||||
|
||||
# 检查3: 当前 page 是否为空
|
||||
if page_view_controller.viewControllers is None:
|
||||
return True
|
||||
|
||||
# 检查4: delegate 一致性
|
||||
if page_view_controller.delegate is None:
|
||||
return True
|
||||
|
||||
return False
|
||||
```
|
||||
838
Doc/WXRead/analysis/03_数据结构与API协议定义.md
Normal file
838
Doc/WXRead/analysis/03_数据结构与API协议定义.md
Normal file
@ -0,0 +1,838 @@
|
||||
# 微信读书 EPUB 阅读器 — 数据结构与 API 协议定义
|
||||
|
||||
> 基于 WeRead v10.0.3 (Build 79) 逆向工程
|
||||
> 数据结构来源: __objc_methtype + class_ro_t ivar 段
|
||||
> API 来源: WRBookNetwork 44 个 class method 签名
|
||||
|
||||
---
|
||||
|
||||
## 一、关键数据结构定义 (Header Files)
|
||||
|
||||
### 1.1 EPUB 解析数据结构
|
||||
|
||||
```objc
|
||||
// ============================================================
|
||||
#pragma mark - WREpubParser (EPUB 文件解析器)
|
||||
// ============================================================
|
||||
|
||||
@interface WREpubParser : NSObject
|
||||
|
||||
@property (nonatomic, copy) NSString *epubFilePath; // EPUB 文件路径
|
||||
@property (nonatomic, copy) NSString *opfFilePath; // content.opf 路径
|
||||
@property (nonatomic, strong) NSError *lastError; // 最近错误
|
||||
@property (nonatomic, weak) UIViewController *hostVC; // 宿主控制器
|
||||
@property (nonatomic, strong) WRBook *book; // 书籍模型
|
||||
@property (nonatomic, strong) WHAlbumInfo *albumInfo; // 专辑信息
|
||||
|
||||
// 解析 EPUB 结构
|
||||
- (BOOL)parseEpubAtPath:(NSString *)path error:(NSError **)error;
|
||||
|
||||
// 获取章节目录 (NCX)
|
||||
- (NSArray<NSDictionary *> *)tableOfContents;
|
||||
|
||||
// 获取资源文件映射 (manifest)
|
||||
- (NSDictionary<NSString *, NSString *> *)resourceManifest;
|
||||
|
||||
// 获取 XHTML 章节文件路径
|
||||
- (NSString *)filePathForChapterWithId:(NSString *)chapterId;
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================
|
||||
#pragma mark - WRBook (书籍模型)
|
||||
// ============================================================
|
||||
|
||||
@interface WRBook : NSObject
|
||||
|
||||
@property (nonatomic, copy) NSString *bookId; // 书籍 ID
|
||||
@property (nonatomic, copy) NSString *title; // 书名
|
||||
@property (nonatomic, copy) NSString *author; // 作者
|
||||
@property (nonatomic, copy) NSString *cover; // 封面 URL
|
||||
@property (nonatomic, assign) WRBookFormat format; // epub/pdf/mp
|
||||
@property (nonatomic, strong) NSArray<WRChapter *> *chapters; // 章节列表
|
||||
@property (nonatomic, copy) NSString *encryptKey; // 加密密钥
|
||||
@property (nonatomic, assign) BOOL isFinished; // 是否读完
|
||||
@property (nonatomic, assign) BOOL isVIP; // 是否 VIP 书籍
|
||||
|
||||
typedef NS_ENUM(NSInteger, WRBookFormat) {
|
||||
WRBookFormatEpub = 0,
|
||||
WRBookFormatPDF = 1,
|
||||
WRBookFormatMP = 2, // 公众号文章
|
||||
};
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================
|
||||
#pragma mark - WRChapter (章节模型)
|
||||
// ============================================================
|
||||
|
||||
@interface WRChapter : NSObject
|
||||
|
||||
@property (nonatomic, copy) NSString *chapterUid; // 章节唯一 ID
|
||||
@property (nonatomic, assign) NSInteger chapterIdx; // 章节索引
|
||||
@property (nonatomic, copy) NSString *title; // 章节标题
|
||||
@property (nonatomic, assign) WRChapterFormat format; // 章节格式
|
||||
@property (nonatomic, copy) NSString *fileId; // 文件 ID
|
||||
@property (nonatomic, assign) NSUInteger filePosition; // 文件位置
|
||||
@property (nonatomic, assign) NSUInteger fileSize; // 文件大小
|
||||
@property (nonatomic, assign) BOOL isPaid; // 是否付费章节
|
||||
@property (nonatomic, assign) BOOL isAvailable; // 是否可用
|
||||
|
||||
typedef NS_ENUM(NSInteger, WRChapterFormat) {
|
||||
WRChapterFormatXHTML = 0,
|
||||
WRChapterFormatHTML = 1,
|
||||
WRChapterFormatTXT = 2,
|
||||
};
|
||||
|
||||
@end
|
||||
```
|
||||
|
||||
### 1.2 排版数据结构
|
||||
|
||||
```objc
|
||||
// ============================================================
|
||||
#pragma mark - DTHTMLElement (HTML 元素模型, 38 ivars)
|
||||
// ============================================================
|
||||
|
||||
@interface DTHTMLElement : NSObject
|
||||
|
||||
// 树结构
|
||||
@property (nonatomic, weak) DTHTMLElement *parent; // 父元素
|
||||
@property (nonatomic, strong) NSMutableArray<DTHTMLElement *> *children; // 子元素
|
||||
@property (nonatomic, copy) NSString *tagName; // 标签名 (p, h1, img...)
|
||||
@property (nonatomic, copy) NSString *elementId; // id 属性
|
||||
@property (nonatomic, copy) NSString *className; // class 属性
|
||||
|
||||
// 样式
|
||||
@property (nonatomic, strong) DTCoreTextFontDescriptor *fontDescriptor; // 字体
|
||||
@property (nonatomic, strong) DTCoreTextParagraphStyle *paragraphStyle; // 段落
|
||||
@property (nonatomic, strong) UIColor *textColor; // 文字颜色
|
||||
@property (nonatomic, strong) UIColor *backgroundColor; // 背景色
|
||||
@property (nonatomic, assign) CGFloat textScale; // 文字缩放
|
||||
@property (nonatomic, assign) CGFloat letterSpacing; // 字间距
|
||||
|
||||
// 附件
|
||||
@property (nonatomic, strong) DTTextAttachment *textAttachment; // 附件 (图片/视频)
|
||||
|
||||
// 链接
|
||||
@property (nonatomic, strong) NSURL *linkURL; // 超链接地址
|
||||
|
||||
// CSS 样式字典
|
||||
@property (nonatomic, strong) NSDictionary *cssStyles; // 应用的 CSS 样式
|
||||
|
||||
// 边框与背景
|
||||
@property (nonatomic, assign) DTBorderStyle borderTop; // 上边框
|
||||
@property (nonatomic, assign) DTBorderStyle borderBottom; // 下边框
|
||||
@property (nonatomic, strong) UIColor *borderColor; // 边框颜色
|
||||
@property (nonatomic, strong) DTBackgroundImageStyle *backgroundImage; // 背景图
|
||||
|
||||
// 表格
|
||||
@property (nonatomic, strong) DTTableStyle *tableStyle; // 表格样式
|
||||
|
||||
// 微信读书自定义属性
|
||||
@property (nonatomic, assign) NSInteger verticalCenterStyle; // wr-vertical-center-style
|
||||
@property (nonatomic, assign) BOOL pageRelate; // weread-page-relate
|
||||
@property (nonatomic, assign) BOOL avoidPageBreakInside; // 断页保护
|
||||
@property (nonatomic, assign) BOOL pageBreakAfter; // 元素后分页
|
||||
@property (nonatomic, assign) BOOL pageBreakBefore; // 元素前分页
|
||||
@property (nonatomic, strong) UIColor *pageBackgroundColor; // 页面背景色
|
||||
@property (nonatomic, strong) NSURL *pageBackgroundImage; // 页面背景图
|
||||
|
||||
// 翻译
|
||||
@property (nonatomic, assign) BOOL isTranslateTag; // 翻译标签
|
||||
@property (nonatomic, assign) BOOL isTranslateNoStyle; // 无翻译样式
|
||||
|
||||
// 核心方法
|
||||
- (void)applyStyleDictionary:(NSDictionary *)styles
|
||||
isLatinLanguageBook:(BOOL)isLatin;
|
||||
- (NSAttributedString *)attributedString;
|
||||
- (void)interpretAttributes;
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================
|
||||
#pragma mark - DTCoreTextFontDescriptor (字体描述)
|
||||
// ============================================================
|
||||
|
||||
@interface DTCoreTextFontDescriptor : NSObject
|
||||
|
||||
@property (nonatomic, copy) NSString *fontFamily; // 字体族 (如 "Source Han Serif CN")
|
||||
@property (nonatomic, copy) NSString *fontName; // 字体名 (如 "SourceHanSerifCN-Medium")
|
||||
@property (nonatomic, assign) CGFloat pointSize; // 字号 (pt)
|
||||
@property (nonatomic, assign) BOOL bold; // 粗体
|
||||
@property (nonatomic, assign) BOOL italic; // 斜体
|
||||
@property (nonatomic, assign) uint32_t symbolicTraits; // 符号特征
|
||||
|
||||
// 匹配系统字体
|
||||
- (CTFontRef)matchedFontDescriptor;
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================
|
||||
#pragma mark - DTCoreTextParagraphStyle (段落样式)
|
||||
// ============================================================
|
||||
|
||||
@interface DTCoreTextParagraphStyle : NSObject
|
||||
|
||||
@property (nonatomic, assign) CTTextAlignment alignment; // 对齐方式
|
||||
@property (nonatomic, assign) CGFloat lineSpacing; // 行间距
|
||||
@property (nonatomic, assign) CGFloat paragraphSpacing; // 段间距
|
||||
@property (nonatomic, assign) CGFloat paragraphSpacingBefore; // 段前间距
|
||||
@property (nonatomic, assign) CGFloat firstLineHeadIndent; // 首行缩进
|
||||
@property (nonatomic, assign) CGFloat headIndent; // 左缩进
|
||||
@property (nonatomic, assign) CGFloat tailIndent; // 右缩进
|
||||
@property (nonatomic, assign) CGFloat minimumLineHeight; // 最小行高
|
||||
@property (nonatomic, assign) CGFloat maximumLineHeight; // 最大行高
|
||||
@property (nonatomic, assign) CGFloat lineHeightMultiple; // 行高倍数
|
||||
|
||||
// 微信读书扩展
|
||||
@property (nonatomic, assign) CGFloat defaultTabInterval; // 默认制表位
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================
|
||||
#pragma mark - DTTextAttachment (文本附件)
|
||||
// ============================================================
|
||||
|
||||
@interface DTTextAttachment : NSObject
|
||||
|
||||
@property (nonatomic, assign) DTTextAttachmentType contentType; // 附件类型
|
||||
@property (nonatomic, strong) NSData *contents; // 内容数据
|
||||
@property (nonatomic, strong) NSURL *contentURL; // 内容 URL
|
||||
@property (nonatomic, assign) CGSize displaySize; // 显示尺寸
|
||||
@property (nonatomic, assign) CGSize originalSize; // 原始尺寸
|
||||
@property (nonatomic, assign) CGFloat verticalAlignment; // 垂直对齐
|
||||
|
||||
typedef NS_ENUM(NSInteger, DTTextAttachmentType) {
|
||||
DTTextAttachmentTypeImage = 0,
|
||||
DTTextAttachmentTypeVideo = 1,
|
||||
DTTextAttachmentTypeIframe = 2,
|
||||
DTTextAttachmentTypeObject = 3,
|
||||
};
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================
|
||||
#pragma mark - DTCSSStylesheet (CSS 样式表)
|
||||
// ============================================================
|
||||
|
||||
@interface DTCSSStylesheet : NSObject
|
||||
|
||||
@property (nonatomic, strong) NSDictionary<NSString *, NSDictionary *> *rules;
|
||||
// rules 格式: { "selector": { "property": "value", ... }, ... }
|
||||
|
||||
// 从 CSS 文本解析
|
||||
+ (DTCSSStylesheet *)styleSheetWithCSSString:(NSString *)cssString;
|
||||
|
||||
// 合并另一个样式表 (后者覆盖前者)
|
||||
- (void)mergeStylesheet:(DTCSSStylesheet *)other;
|
||||
|
||||
// 获取匹配选择器的样式
|
||||
- (NSDictionary *)stylesForElement:(DTHTMLElement *)element;
|
||||
|
||||
@end
|
||||
```
|
||||
|
||||
### 1.3 渲染数据结构
|
||||
|
||||
```objc
|
||||
// ============================================================
|
||||
#pragma mark - WRChapterData (章节数据模型, 12 ivars)
|
||||
// ============================================================
|
||||
|
||||
@interface WRChapterData : NSObject
|
||||
|
||||
// 排版结果
|
||||
@property (nonatomic, strong) NSMutableAttributedString *attributedString; // 排版后富文本
|
||||
@property (nonatomic, strong) WRCoreTextLayouter *layouter; // 排版器
|
||||
|
||||
// 分页
|
||||
@property (nonatomic, strong) NSArray<NSValue *> *pageRanges; // 每页 NSRange
|
||||
|
||||
// 标注
|
||||
@property (nonatomic, strong) NSArray<WRPageHighlight *> *highlights; // 高亮列表
|
||||
@property (nonatomic, strong) NSArray<WRPageUnderline *> *underlines; // 下划线列表
|
||||
@property (nonatomic, strong) NSArray<WRPageMark *> *marks; // 书签标注
|
||||
@property (nonatomic, strong) NSArray<NSDictionary *> *reviews; // 书评列表
|
||||
@property (nonatomic, strong) NSArray *tempHighlights; // 临时高亮
|
||||
@property (nonatomic, strong) NSArray *tempUnderlines; // 临时下划线
|
||||
|
||||
// 目录与搜索
|
||||
@property (nonatomic, strong) NSArray<NSDictionary *> *outlineContents; // 目录
|
||||
@property (nonatomic, strong) NSArray<NSDictionary *> *searchResults; // 搜索结果
|
||||
|
||||
// 书签
|
||||
@property (nonatomic, strong) NSSet<NSString *> *bookmarkSet; // 书签集合
|
||||
|
||||
// 原始数据
|
||||
@property (nonatomic, strong) NSAttributedString *originalString; // 原始富文本
|
||||
|
||||
// 核心方法
|
||||
- (NSRange)rangeOfPage:(NSInteger)pageIndex;
|
||||
- (void)addHighlightInRange:(NSRange)range key:(NSString *)key
|
||||
itemId:(NSString *)itemId color:(UIColor *)color;
|
||||
- (void)addReviewUnderlineInRange:(NSRange)range itemId:(NSString *)itemId
|
||||
type:(WRReviewType)type;
|
||||
- (void)deleteReviewUnderlineInRange:(NSRange)range type:(WRReviewType)type;
|
||||
- (NSArray<NSDictionary *> *)generateOutlineContents;
|
||||
- (NSInteger)freeTrialChapterCutOffRealStringLocation;
|
||||
|
||||
+ (void)addUnderLineToAttributedString:(NSMutableAttributedString *)attrStr
|
||||
range:(NSRange)range
|
||||
itemId:(NSString *)itemId
|
||||
style:(WRUnderlineStyle)style
|
||||
color:(UIColor *)color;
|
||||
|
||||
+ (NSInteger)freeTrialChapterCutOffStringLocaionWithAttributedString:
|
||||
(NSAttributedString *)attrStr book:(WRBook *)book;
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================
|
||||
#pragma mark - WRChapterPageCount (分页计算, 4 ivars)
|
||||
// ============================================================
|
||||
|
||||
@interface WRChapterPageCount : NSObject
|
||||
|
||||
@property (nonatomic, copy) NSString *bookId; // 书籍 ID
|
||||
@property (nonatomic, copy) NSString *chapterUid; // 章节 ID
|
||||
@property (nonatomic, copy) NSString *cacheKey; // 缓存键
|
||||
@property (nonatomic, strong) NSArray<NSValue *> *pageRanges; // 页范围
|
||||
|
||||
+ (NSString *)currentCacheKeyWithBookId:(NSString *)bookId;
|
||||
+ (NSValue *)rangeValueWithPageInfo:(NSDictionary *)pageInfo;
|
||||
|
||||
@end
|
||||
```
|
||||
|
||||
### 1.4 标注数据结构
|
||||
|
||||
```objc
|
||||
// ============================================================
|
||||
#pragma mark - WRPageHighlight (页面高亮)
|
||||
// ============================================================
|
||||
|
||||
@interface WRPageHighlight : NSObject
|
||||
|
||||
@property (nonatomic, assign) NSRange range; // 高亮范围
|
||||
@property (nonatomic, copy) NSString *highlightKey; // 高亮唯一键
|
||||
@property (nonatomic, copy) NSString *itemId; // 标注 ID
|
||||
@property (nonatomic, strong) UIColor *color; // 高亮颜色
|
||||
@property (nonatomic, assign) BOOL isTemporary; // 是否临时
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================
|
||||
#pragma mark - WRPageUnderline (页面下划线)
|
||||
// ============================================================
|
||||
|
||||
@interface WRPageUnderline : NSObject
|
||||
|
||||
@property (nonatomic, assign) NSRange range; // 下划线范围
|
||||
@property (nonatomic, copy) NSString *itemId; // 标注 ID
|
||||
@property (nonatomic, assign) WRUnderlineStyle style; // 下划线样式
|
||||
@property (nonatomic, strong) UIColor *color; // 颜色
|
||||
|
||||
typedef NS_ENUM(NSInteger, WRUnderlineStyle) {
|
||||
WRUnderlineStyleSolid = 0, // 实线
|
||||
WRUnderlineStyleDashed = 1, // 虚线
|
||||
WRUnderlineStyleWavy = 2, // 波浪线
|
||||
WRUnderlineStyleDotted = 3, // 点线
|
||||
};
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================
|
||||
#pragma mark - WRPageMark (页面标注, 25 methods)
|
||||
// ============================================================
|
||||
|
||||
@interface WRPageMark : NSObject
|
||||
|
||||
@property (nonatomic, assign) NSRange range; // 标注范围
|
||||
@property (nonatomic, assign) WRMarkType type; // 标注类型
|
||||
@property (nonatomic, copy) NSString *content; // 标注内容
|
||||
@property (nonatomic, copy) NSString *itemId; // 标注 ID
|
||||
@property (nonatomic, strong) NSDate *createTime; // 创建时间
|
||||
@property (nonatomic, strong) UIColor *color; // 颜色
|
||||
|
||||
typedef NS_ENUM(NSInteger, WRMarkType) {
|
||||
WRMarkTypeHighlight = 0, // 高亮
|
||||
WRMarkTypeUnderline = 1, // 下划线
|
||||
WRMarkTypeBookmark = 2, // 书签
|
||||
WRMarkTypeNote = 3, // 笔记
|
||||
WRMarkTypeReview = 4, // 书评
|
||||
};
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================
|
||||
#pragma mark - WRBookmark (书签模型, 25 ivars)
|
||||
// ============================================================
|
||||
|
||||
@interface WRBookmark : NSObject
|
||||
|
||||
@property (nonatomic, copy) NSString *bookId; // 书籍 ID
|
||||
@property (nonatomic, copy) NSString *chapterUid; // 章节 ID
|
||||
@property (nonatomic, copy) NSString *itemId; // 书签 ID
|
||||
@property (nonatomic, assign) NSUInteger rangeLocation; // 范围起始
|
||||
@property (nonatomic, assign) NSUInteger rangeLength; // 范围长度
|
||||
@property (nonatomic, copy) NSString *abstractText; // 摘要文本
|
||||
@property (nonatomic, assign) WRBookmarkType type; // 书签类型
|
||||
@property (nonatomic, strong) UIColor *color; // 颜色
|
||||
@property (nonatomic, assign) WRUnderlineStyle style; // 下划线样式
|
||||
@property (nonatomic, copy) NSString *reviewId; // 关联书评 ID
|
||||
@property (nonatomic, strong) NSDate *createTime; // 创建时间
|
||||
@property (nonatomic, strong) NSDate *updateTime; // 更新时间
|
||||
|
||||
typedef NS_ENUM(NSInteger, WRBookmarkType) {
|
||||
WRBookmarkTypeHighlight = 0,
|
||||
WRBookmarkTypeUnderline = 1,
|
||||
WRBookmarkTypeBookmark = 2,
|
||||
WRBookmarkTypeNote = 3,
|
||||
WRBookmarkTypeReview = 4,
|
||||
};
|
||||
|
||||
@end
|
||||
```
|
||||
|
||||
### 1.5 附件数据结构
|
||||
|
||||
```objc
|
||||
// ============================================================
|
||||
#pragma mark - WRPageImageAttachment (图片附件)
|
||||
// ============================================================
|
||||
|
||||
@interface WRPageImageAttachment : NSObject
|
||||
|
||||
@property (nonatomic, copy) NSString *imageURL; // 图片 URL
|
||||
@property (nonatomic, strong) UIImage *image; // 图片对象
|
||||
@property (nonatomic, assign) CGSize displaySize; // 显示尺寸
|
||||
@property (nonatomic, assign) CGSize originalSize; // 原始尺寸
|
||||
@property (nonatomic, assign) NSInteger position; // 在文本中的位置
|
||||
@property (nonatomic, assign) NSInteger verticalCenterStyle; // 居中方式
|
||||
@property (nonatomic, assign) BOOL hasWhiteBackground; // 是否白底
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================
|
||||
#pragma mark - WRPageHyperlinksAttachment (超链接附件)
|
||||
// ============================================================
|
||||
|
||||
@interface WRPageHyperlinksAttachment : NSObject
|
||||
|
||||
@property (nonatomic, strong) NSURL *url; // 链接 URL
|
||||
@property (nonatomic, assign) NSRange range; // 文本范围
|
||||
@property (nonatomic, copy) NSString *displayText; // 显示文本
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================
|
||||
#pragma mark - WRPageChapterToolAttachment (章节工具附件)
|
||||
// ============================================================
|
||||
|
||||
@interface WRPageChapterToolAttachment : NSObject
|
||||
|
||||
@property (nonatomic, assign) WRChapterToolType type; // 工具类型
|
||||
@property (nonatomic, strong) NSDictionary *data; // 工具数据
|
||||
|
||||
typedef NS_ENUM(NSInteger, WRChapterToolType) {
|
||||
WRChapterToolTypeShare = 0, // 分享
|
||||
WRChapterToolTypeReview = 1, // 书评
|
||||
WRChapterToolTypeBookmark = 2, // 书签
|
||||
WRChapterToolTypeNext = 3, // 下一章
|
||||
};
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================
|
||||
#pragma mark - WRPageFlyleafAttachment (扉页附件)
|
||||
// ============================================================
|
||||
|
||||
@interface WRPageFlyleafAttachment : NSObject
|
||||
|
||||
@property (nonatomic, assign) WRFlyleafType type; // 扉页类型
|
||||
@property (nonatomic, strong) NSDictionary *data; // 扉页数据
|
||||
|
||||
typedef NS_ENUM(NSInteger, WRFlyleafType) {
|
||||
WRFlyleafTypeCover = 0, // 封面
|
||||
WRFlyleafTypeTitle = 1, // 标题页
|
||||
WRFlyleafTypeAuthor = 2, // 作者页
|
||||
WRFlyleafTypeIntro = 3, // 简介页
|
||||
WRFlyleafTypeCatalog = 4, // 目录页
|
||||
};
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================
|
||||
#pragma mark - WRPageCodeAttachment (代码块附件)
|
||||
// ============================================================
|
||||
|
||||
@interface WRPageCodeAttachment : NSObject
|
||||
|
||||
@property (nonatomic, copy) NSString *codeString; // 代码内容
|
||||
@property (nonatomic, copy) NSString *language; // 编程语言
|
||||
@property (nonatomic, assign) NSInteger lineNumber; // 起始行号
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================
|
||||
#pragma mark - WRPageTableAttachment (表格附件)
|
||||
// ============================================================
|
||||
|
||||
@interface WRPageTableAttachment : NSObject
|
||||
|
||||
@property (nonatomic, assign) NSInteger rows; // 行数
|
||||
@property (nonatomic, assign) NSInteger columns; // 列数
|
||||
@property (nonatomic, strong) NSArray<NSArray<NSString *> *> *cells; // 单元格数据
|
||||
|
||||
@end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、API 与网络协议控制矩阵
|
||||
|
||||
### 2.1 书籍信息 API
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 方法签名 推断端点 HTTP 方法 │
|
||||
├────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ +[WRBookNetwork loadBookInfoWithBookId: /api/book/info GET │
|
||||
│ source:callback:] ?bookId=&source= │
|
||||
│ │
|
||||
│ +[WRBookNetwork loadBookReadInfoWithBookId: /api/book/readInfo GET │
|
||||
│ callback:] ?bookId= │
|
||||
│ │
|
||||
│ +[WRBookNetwork loadBookReadInfoWithBookIdAndVid: /api/book/readInfo GET │
|
||||
│ vid:callback:] ?bookId=&vid= │
|
||||
│ │
|
||||
│ +[WRBookNetwork loadBookReadDetailInfoWithBookId: /api/book/readDetail GET │
|
||||
│ callback:] ?bookId= │
|
||||
│ │
|
||||
│ +[WRBookNetwork loadBookLectureAuthors: /api/book/authors GET │
|
||||
│ callback:] ?bookId= │
|
||||
│ │
|
||||
│ +[WRBookNetwork loadArticleBookDetailWithBookId: /api/article/detail GET │
|
||||
│ callback:] ?bookId= │
|
||||
│ │
|
||||
│ +[WRBookNetwork fetchLockInfoWithBookId: /api/book/lockInfo GET │
|
||||
│ callback:] ?bookId= │
|
||||
└────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
响应格式 (推断):
|
||||
{
|
||||
"bookId": "string",
|
||||
"title": "string",
|
||||
"author": "string",
|
||||
"cover": "string (URL)",
|
||||
"format": 0,
|
||||
"chapters": [
|
||||
{
|
||||
"chapterUid": "string",
|
||||
"chapterIdx": 0,
|
||||
"title": "string",
|
||||
"fileId": "string",
|
||||
"filePosition": 0,
|
||||
"fileSize": 0,
|
||||
"isPaid": false
|
||||
}
|
||||
],
|
||||
"encryptKey": "string",
|
||||
"isFinished": false,
|
||||
"isVIP": false
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 章节内容 API
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 方法签名 推断端点 HTTP 方法 │
|
||||
├────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ +[WRBookNetwork loadTarForEpubBookId:chapter: /api/book/tar GET │
|
||||
│ isPreload:] ?bookId=&chapterUid= │
|
||||
│ &isPreload= │
|
||||
│ 响应: 加密 ZIP 文件 (application/octet-stream) │
|
||||
│ 文件名: {bookId}_DECRYPT.zip │
|
||||
│ │
|
||||
│ +[WRBookNetwork loadTarForEpubBookId:chapters: /api/book/tar GET │
|
||||
│ isPreload:] ?bookId=&chapterUids= │
|
||||
│ &isPreload= │
|
||||
│ 批量下载: chapters 逗号分隔 │
|
||||
│ │
|
||||
│ +[WRBookNetwork loadChapterContentWithParam: /api/book/chapter GET │
|
||||
│ callback:] ?bookId=&chapterUid= │
|
||||
│ 参数: NSDictionary (bookId, chapterUid, format) │
|
||||
│ │
|
||||
│ +[WRBookNetwork fileContentWithChapter:book: 本地文件读取 - │
|
||||
│ shouldRemoveHtmlTags:filterTranslateContent:] 从 plainBookDirectory 读取 │
|
||||
│ │
|
||||
│ +[WRBookNetwork chaptersInfoFromFile: 本地文件解析 - │
|
||||
│ checkTranslate:] 解析 OPF/NCX │
|
||||
└────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.3 预加载管理 API
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 方法签名 推断端点 HTTP 方法 │
|
||||
├────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ +[WRBookNetwork savePreloadInfoWithDownloadParam: 本地存储 - │
|
||||
│ chaptersStr:timeFlag:tmpFilePath:encryptKey:] NSUserDefaults │
|
||||
│ │
|
||||
│ +[WRBookNetwork clearPreloadKVWithBookId: 本地清理 - │
|
||||
│ chapterUid:zipPath:] NSUserDefaults │
|
||||
│ │
|
||||
│ +[WRBookNetwork storeTarEpubImageToDiskWith 本地存储 - │
|
||||
│ BookId:chapterUid:untarDirectory:] epubImage/ 目录 │
|
||||
│ │
|
||||
│ +[WRBookNetwork processEncryptedBookFileAtPath: 本地处理 - │
|
||||
│ encryptKey:book:chapterUid:isFromReview:] 解密 + 存储 │
|
||||
│ │
|
||||
│ +[WRBookNetwork processChapterInfosFrom 本地处理 - │
|
||||
│ ChapterDownload:bookId:] 解析章节信息 │
|
||||
└────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.4 书签/标注 API
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 方法签名 推断端点 HTTP 方法 │
|
||||
├────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ +[WRBookNetwork loadBookmarkListWithBookId: /api/book/bookmarks GET │
|
||||
│ syncKey:callback:] ?bookId=&syncKey= │
|
||||
│ │
|
||||
│ +[WRBookNetwork addReview:shareToWechat: /api/review/add POST │
|
||||
│ audioArticleId:outlineContent: body: {bookId, chapterUid, │
|
||||
│ audioColumnId:callback:] content, range, type, ...} │
|
||||
│ │
|
||||
│ +[WRBookNetwork likeReviewById:isUnlike: /api/review/like POST │
|
||||
│ withParams:callback:] body: {reviewId, isUnlike} │
|
||||
│ │
|
||||
│ +[WRBookNetwork dislikeReviewById:isDislike: /api/review/dislike POST │
|
||||
│ withParams:callback:] body: {reviewId, isDislike} │
|
||||
│ │
|
||||
│ +[WRBookNetwork repostReview:reposted: /api/review/repost POST │
|
||||
│ callback:] body: {reviewId, reposted} │
|
||||
│ │
|
||||
│ +[WRBookNetwork rewardReviewForId:price: /api/review/reward POST │
|
||||
│ timestamp:callback:] body: {reviewId, price, ts} │
|
||||
│ │
|
||||
│ +[WRBookNetwork postReviewHideWithBookId: /api/review/hide POST │
|
||||
│ hide:callback:] body: {bookId, hide} │
|
||||
│ │
|
||||
│ +[WRBookNetwork loadTopicReviewlist: /api/review/topic GET │
|
||||
│ callback:] ?topicId= │
|
||||
│ │
|
||||
│ +[WRBookNetwork loadRelatedBooksForReviewDetail: /api/review/related GET │
|
||||
│ callback:] ?reviewId= │
|
||||
└────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.5 阅读进度 API
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 方法签名 推断端点 HTTP 方法 │
|
||||
├────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ +[WRBookNetwork uploadBookProgressAndReadingTime: /api/book/progress POST │
|
||||
│ callback:offlineCallback:] body: {bookId, chapterUid, │
|
||||
│ progress, readingTime, ...} │
|
||||
│ │
|
||||
│ +[WRBookNetwork setFinishReading:withBookId: /api/book/finish POST │
|
||||
│ callback:] body: {bookId, isFinished} │
|
||||
│ │
|
||||
│ +[WRBookNetwork setIsStartReading:withBookId: /api/book/start POST │
|
||||
│ callback:] body: {bookId} │
|
||||
│ │
|
||||
│ +[WRBookNetwork markReadingStatus:bookIds: /api/book/status POST │
|
||||
│ isCancel:callback:] body: {bookIds[], isCancel} │
|
||||
│ │
|
||||
│ +[WRBookNetwork markReadingStatus:withBookId: /api/book/status POST │
|
||||
│ isCancel:withFinishInfo:callback:] body: {bookId, isCancel, │
|
||||
│ finishInfo} │
|
||||
│ │
|
||||
│ +[WRBookNetwork automaticallyMarkFinishReading /api/book/autoFinish POST │
|
||||
│ WithBookId:callback:] body: {bookId} │
|
||||
│ │
|
||||
│ +[WRBookNetwork addMileStone:callback:] /api/book/milestone POST │
|
||||
│ body: {bookId, milestone} │
|
||||
└────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.6 搜索 API
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 方法签名 推断端点 HTTP 方法 │
|
||||
├────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ +[WRBookNetwork searchResultsForBook: /api/search GET │
|
||||
│ chapterUid:searchString:posBeg: ?bookId=&chapterUid= │
|
||||
│ posEnd:mode:callback:] &keyword=&posBeg= │
|
||||
│ &posEnd=&mode= │
|
||||
│ mode: 0=精确 1=模糊 2=正则 │
|
||||
│ │
|
||||
│ +[WRBookNetwork searchResultsForLocalBook: 本地搜索 - │
|
||||
│ chapterUid:searchString:posBeg: 遍历 NSAttributedString │
|
||||
│ posEnd:mode:callback:] │
|
||||
└────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.7 翻译 API
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 方法签名 推断端点 HTTP 方法 │
|
||||
├────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ +[WRBookNetwork pollingChapterTranslateStatus /api/book/translate GET │
|
||||
│ WithBookId:isFreeTrialActive: ?bookId=&chapterUid= │
|
||||
│ referenceLocationDict:chapterTranslations: &isFreeTrial= │
|
||||
│ from:] │
|
||||
│ │
|
||||
│ +[WRBookNetwork _removeTranslateHtml:] 本地处理 - │
|
||||
│ 移除翻译 HTML 标签 │
|
||||
└────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.8 付费/会员 API
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 方法签名 推断端点 HTTP 方法 │
|
||||
├────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ +[WRBookNetwork fetchLockInfoWithBookId: /api/book/lock GET │
|
||||
│ callback:] ?bookId= │
|
||||
│ │
|
||||
│ +[WRBookNetwork resetChapterPaidIfNeeded /api/book/resetPaid POST │
|
||||
│ WithBookId:chapterUid:] body: {bookId, chapterUid} │
|
||||
│ │
|
||||
│ +[WRBookNetwork loadReadTimeWelfareAction /api/welfare/readTime GET │
|
||||
│ WithBookId:opt:secretKey:firstEnter:] ?bookId=&opt=&secretKey= │
|
||||
│ │
|
||||
│ +[WRBookNetwork checkFMCards:callback:] /api/fm/cards GET │
|
||||
│ │
|
||||
│ +[WRBookNetwork loadFMCardsWithBookId: /api/fm/cards GET │
|
||||
│ withSynckey:withListType:withFilterType: ?bookId=&synckey= │
|
||||
│ withMaxIdx:withCount:] &listType=&filterType= │
|
||||
└────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、本地存储 Schema
|
||||
|
||||
### 3.1 文件系统布局
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ App 沙箱 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Documents/ │
|
||||
│ ├── {bookId}/ │
|
||||
│ │ ├── plainBookDirectory/ ← 解密后的 EPUB XHTML 文件 │
|
||||
│ │ │ ├── META-INF/container.xml │
|
||||
│ │ │ ├── OEBPS/content.opf │
|
||||
│ │ │ ├── OEBPS/toc.ncx │
|
||||
│ │ │ ├── OEBPS/Text/chapter1.xhtml │
|
||||
│ │ │ ├── OEBPS/Text/chapter2.xhtml │
|
||||
│ │ │ └── ... │
|
||||
│ │ ├── epubImage/ ← 书籍图片缓存 │
|
||||
│ │ │ ├── image1.jpg │
|
||||
│ │ │ ├── image2.png │
|
||||
│ │ │ └── ... │
|
||||
│ │ └── {bookId}_DECRYPT.zip ← 下载的加密 ZIP (可能已删除) │
|
||||
│ │ │
|
||||
│ └── ... (其他书籍) │
|
||||
│ │
|
||||
│ Library/ │
|
||||
│ ├── {cachePath}/ │
|
||||
│ │ └── epubImage/ ← SDWebImage 缓存 │
|
||||
│ │ └── com.hackemist.SDWebImageCache.epubImage/ │
|
||||
│ ├── Caches/ │
|
||||
│ │ └── chapterCache/ ← 章节排版缓存 │
|
||||
│ └── Preferences/ │
|
||||
│ └── com.tencent.weread.plist ← NSUserDefaults │
|
||||
│ │
|
||||
│ tmp/ │
|
||||
│ └── {临时解压目录} ← 下载解压临时目录 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 NSUserDefaults Keys
|
||||
|
||||
```objc
|
||||
// 阅读进度
|
||||
NSString *const kWRReadingProgressKey = @"WRReadingProgress_{bookId}";
|
||||
// 格式: {chapterUid, chapterIdx, filePosition, stringLocation, pageOfChapter, lastReadDate}
|
||||
|
||||
// 字体设置
|
||||
NSString *const kWRFontFamilyKey = @"WRFontFamily"; // 字体族名
|
||||
NSString *const kWRFontSizeKey = @"WRFontSize"; // 字号 (默认 18)
|
||||
NSString *const kWRLineHeightKey = @"WRLineHeight"; // 行高 (默认 1.8)
|
||||
NSString *const kWRParagraphSpacingKey = @"WRParagraphSpacing"; // 段间距
|
||||
NSString *const kWRFirstIndentKey = @"WRFirstIndent"; // 首行缩进
|
||||
|
||||
// 主题设置
|
||||
NSString *const kWRThemeKey = @"WRTheme"; // 主题 (light/dark/sepia)
|
||||
NSString *const kWRBrightnessKey = @"WRBrightness"; // 亮度
|
||||
|
||||
// 翻页设置
|
||||
NSString *const kWRPageTurningStyleKey = @"WRPageTurningStyle"; // 翻页样式 (curl/scroll)
|
||||
NSString *const kWRAutoReadKey = @"WRAutoRead"; // 自动阅读
|
||||
|
||||
// 繁简转换
|
||||
NSString *const kWRCht2sKey = @"WRCht2s_{bookId}"; // 繁简转换状态
|
||||
|
||||
// 预加载
|
||||
NSString *const kWRPreloadSettingKey = @"WRPreloadSetting"; // 预加载设置
|
||||
NSString *const kWRPreloadEncryptKeyKey = @"WRPreloadEncryptKey_{bookId}_{chapterUid}";
|
||||
NSString *const kWRPreloadFileNameKey = @"WRPreloadFileName_{bookId}_{key}";
|
||||
```
|
||||
|
||||
### 3.3 Keychain Keys
|
||||
|
||||
```objc
|
||||
// 每本书的加密密钥
|
||||
NSString *const kWRBookEncryptKeyKey = @"com.weread.encrypt.{bookId}";
|
||||
// 存储在 Keychain 中, 标记为 kSecAttrAccessibleAfterFirstUnlock
|
||||
|
||||
// 用户凭证
|
||||
NSString *const kWRUserTokenKey = @"com.weread.user.token";
|
||||
NSString *const kWRUserVidKey = @"com.weread.user.vid";
|
||||
```
|
||||
|
||||
### 3.4 NSAttributedString 自定义属性键
|
||||
|
||||
```objc
|
||||
// 微信读书在 NSAttributedString 中注入的自定义属性
|
||||
// 用于在排版结果中传递页面布局元数据
|
||||
|
||||
NSString *const DTPageBackgroundColorAttribute = @"DTPageBackgroundColor";
|
||||
NSString *const DTPageBackgroundImageAttribute = @"DTPageBackgroundImage";
|
||||
NSString *const DTPageBackgroundImagePathAttribute = @"DTPageBackgroundImagePath";
|
||||
NSString *const DTPageBreakAfterAttribute = @"DTPageBreakAfter";
|
||||
NSString *const DTPageBreakBeforeAttribute = @"DTPageBreakBefore";
|
||||
NSString *const DTPageBreakInsideAvoidAttribute = @"DTPageBreakInsideAvoid";
|
||||
NSString *const DTPageRelateAttribute = @"DTPageRelate";
|
||||
NSString *const DTPageSize = @"DTPageSize";
|
||||
NSString *const DTPageFlippingStyle = @"DTPageFlippingStyle";
|
||||
NSString *const DTHTMLVerticalCenterAttribute = @"DTHTMLVerticalCenter";
|
||||
NSString *const DTHTMLTranslateTagAttribute = @"DTHTMLTranslateTag";
|
||||
NSString *const DTHTMLTranslateNoStyleAttribute = @"DTHTMLTranslateNoStyle";
|
||||
|
||||
// 标注相关属性
|
||||
NSString *const WRHighlightAttributeKey = @"com.weread.highlight";
|
||||
NSString *const WRUnderlineAttributeKey = @"com.weread.underline";
|
||||
NSString *const WRBookmarkAttributeKey = @"com.weread.bookmark";
|
||||
NSString *const WRReviewAttributeKey = @"com.weread.review";
|
||||
|
||||
// 附件属性
|
||||
NSString *const DTTextAttachmentAttribute = @"DTTextAttachment";
|
||||
NSString *const WRChapterToolAttachmentAttribute = @"WRChapterToolAttachment";
|
||||
NSString *const WRFlyleafAttachmentAttribute = @"WRFlyleafAttachment";
|
||||
```
|
||||
1302
Doc/WXRead/analysis/CSS_JS资源分析.md
Normal file
1302
Doc/WXRead/analysis/CSS_JS资源分析.md
Normal file
File diff suppressed because it is too large
Load Diff
369
Doc/WXRead/analysis/DTCoreText自定义修改分析.md
Normal file
369
Doc/WXRead/analysis/DTCoreText自定义修改分析.md
Normal file
@ -0,0 +1,369 @@
|
||||
# DTCoreText 自定义修改分析
|
||||
|
||||
微信读书 (WeRead) 基于开源 DTCoreText 库进行了深度定制,将其从一个通用的 HTML-to-NSAttributedString 转换库改造为一套完整的电子书分页渲染引擎。本文档详细记录所有自定义修改。
|
||||
|
||||
---
|
||||
|
||||
## 1. 架构概览
|
||||
|
||||
开源 DTCoreText 提供三个核心能力:
|
||||
- HTML/CSS 解析 (DTHTMLAttributedStringBuilder)
|
||||
- DOM 元素模型 (DTHTMLElement)
|
||||
- CoreText 排版封装 (DTCoreTextLayouter / LayoutFrame / LayoutLine / GlyphRun)
|
||||
|
||||
WeRead 在此基础上增加了:
|
||||
- 一套完整的分页渲染引擎 (WRCoreTextLayouter / WRCoreTextLayoutFrame)
|
||||
- 自定义 CSS 属性体系(控制分页、背景、垂直居中等)
|
||||
- 自定义 NSAttributedString 属性键(传递页面布局元数据)
|
||||
- 翻译/双语支持
|
||||
- 免费试读截断机制
|
||||
- 主题/字体动态切换
|
||||
|
||||
---
|
||||
|
||||
## 2. 自定义 CSS 属性
|
||||
|
||||
### 2.1 wr-vertical-center-style
|
||||
|
||||
**用途**: 控制内联元素(主要是图片)在行内的垂直居中方式。
|
||||
|
||||
**取值**:
|
||||
- `1` — 基线对齐(默认)
|
||||
- `2` — 垂直居中(用于 .bodyPic 类图片)
|
||||
|
||||
**处理流程**:
|
||||
1. DTHTMLAttributedStringBuilder 解析 HTML 时读取该属性
|
||||
2. 存储到 DTHTMLElement.verticalCenterStyle
|
||||
3. 转换为 NSAttributedString 属性 `DTHTMLVerticalCenterAttribute`
|
||||
4. WRCoreTextLayoutLine 排版时根据该属性调整 baseline offset
|
||||
|
||||
**关联 CSS 规则** (replace.css):
|
||||
```css
|
||||
img.bodyPic {
|
||||
wr-vertical-center-style: 2;
|
||||
max-width: 100%;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 weread-page-relate
|
||||
|
||||
**用途**: 标记元素与页面级布局相关。
|
||||
|
||||
**取值**: `true` / `false`
|
||||
|
||||
**处理流程**:
|
||||
1. 解析时存储到 DTHTMLElement.pageRelate
|
||||
2. 转换为 `DTPageRelateAttribute`
|
||||
3. 分页引擎使用该属性决定元素是否参与页面级定位
|
||||
|
||||
### 2.3 avoidPageBreakInside
|
||||
|
||||
**用途**: 防止元素内部被分页截断(类似 CSS `break-inside: avoid`)。
|
||||
|
||||
**适用场景**: 代码块、表格、列表、引用块。
|
||||
|
||||
**处理流程**:
|
||||
1. 解析时设置 DTHTMLElement.shouldAvoidPageBreakInside = YES
|
||||
2. 转换为 `DTPageBreakInsideAvoidAttribute`
|
||||
3. WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded 检查该属性
|
||||
4. 如果元素会被截断,移除末尾几行(最多 3 行)以避免断页
|
||||
|
||||
**关联类**: `WRBlockType` 属性标识块类型(table / code / list / blockquote)
|
||||
|
||||
### 2.4 DTPageBreakAfter / DTPageBreakBefore
|
||||
|
||||
**用途**: 强制在元素前/后插入分页符。
|
||||
|
||||
**处理流程**:
|
||||
1. 解析时设置 DTHTMLElement.pageBreakAfter / pageBreakBefore
|
||||
2. 转换为 `DTPageBreakAfterAttribute` / `DTPageBreakBeforeAttribute`
|
||||
3. attributedString 生成时插入 LINE SEPARATOR (U+2028) 作为分页标记
|
||||
|
||||
---
|
||||
|
||||
## 3. 自定义 NSAttributedString 属性键
|
||||
|
||||
WeRead 在 DTCoreText 标准属性之外定义了 12 个自定义属性键:
|
||||
|
||||
| 属性键 | 类型 | 用途 |
|
||||
|--------|------|------|
|
||||
| `DTPageBackgroundColorAttribute` | UIColor | 页面背景色 |
|
||||
| `DTPageBackgroundImageAttribute` | NSString | 页面背景图片路径 |
|
||||
| `DTPageBackgroundImagePathAttribute` | NSString | 背景图片绝对路径 |
|
||||
| `DTPageBreakAfterAttribute` | NSNumber(BOOL) | 元素后强制分页 |
|
||||
| `DTPageBreakBeforeAttribute` | NSNumber(BOOL) | 元素前强制分页 |
|
||||
| `DTPageBreakInsideAvoidAttribute` | NSNumber(BOOL) | 避免元素内分页 |
|
||||
| `DTPageRelateAttribute` | NSString | 页面关联标记 |
|
||||
| `DTPageSize` | NSValue(CGSize) | 页面尺寸 |
|
||||
| `DTPageFlippingStyle` | NSNumber(NSInteger) | 翻页动画样式 |
|
||||
| `DTHTMLVerticalCenterAttribute` | NSString | 垂直居中样式 |
|
||||
| `DTHTMLTranslateTagAttribute` | NSString | 翻译标签标记 |
|
||||
| `DTHTMLTranslateNoStyleAttribute` | NSString | 无翻译样式标记 |
|
||||
|
||||
这些属性在 DTHTMLElement.attributedString 生成阶段写入,在 WRCoreTextLayoutFrame 排版和渲染阶段被读取。
|
||||
|
||||
---
|
||||
|
||||
## 4. DTHTMLElement 修改
|
||||
|
||||
### 4.1 新增属性
|
||||
|
||||
原版 DTHTMLElement 的属性主要关注字体、颜色、段落样式。WeRead 新增:
|
||||
|
||||
```objc
|
||||
// 分页控制
|
||||
@property BOOL shouldAvoidPageBreakInside;
|
||||
@property BOOL pageBreakAfter;
|
||||
@property BOOL pageBreakBefore;
|
||||
|
||||
// 页面布局
|
||||
@property NSString *verticalCenterStyle; // wr-vertical-center-style
|
||||
@property NSString *pageRelate; // weread-page-relate
|
||||
@property UIColor *pageBackgroundColor; // DTPageBackgroundColor
|
||||
@property NSString *pageBackgroundImage; // DTPageBackgroundImage
|
||||
```
|
||||
|
||||
### 4.2 applyStyleDictionary 扩展
|
||||
|
||||
原版处理标准 CSS 属性(font-family, color, margin 等)。WeRead 新增 `_applyWeReadProperties:` 方法处理自定义 CSS 属性。
|
||||
|
||||
### 4.3 attributedString 生成扩展
|
||||
|
||||
原版递归生成 NSAttributedString。WeRead 在生成流程中增加了:
|
||||
- 分页标记插入(pageBreakBefore / pageBreakAfter)
|
||||
- WeRead 页面属性应用(_applyWeReadPageAttributesToString:)
|
||||
|
||||
---
|
||||
|
||||
## 5. DTHTMLAttributedStringBuilder 修改
|
||||
|
||||
### 5.1 新增 Book/Chapter 上下文
|
||||
|
||||
```objc
|
||||
@property WRBook *book;
|
||||
@property WRChapter *chapter;
|
||||
```
|
||||
|
||||
原版 builder 无业务上下文。WeRead 注入书籍和章节信息,用于:
|
||||
- 判断是否拉丁语书籍(影响字体回退)
|
||||
- 获取 EPUB 嵌入 CSS
|
||||
- 解析相对资源路径
|
||||
|
||||
### 5.2 自定义属性处理
|
||||
|
||||
新增 `_applyWeReadCustomAttributes:fromAttributes:` 方法,在 SAX 解析阶段识别并存储自定义 CSS 属性。
|
||||
|
||||
### 5.3 DTHTMLParserDelegate 扩展
|
||||
|
||||
内部 delegate 新增:
|
||||
- `_book` / `_chapter` 上下文引用
|
||||
- `_currentImageView` / `_imageContainerView` 等图片视图引用
|
||||
- `_rootView` / `_parentView` 视图层级追踪
|
||||
|
||||
---
|
||||
|
||||
## 6. DTCoreTextLayouter 修改
|
||||
|
||||
### 6.1 布局帧缓存
|
||||
|
||||
新增 `NSCache *layoutFrameCache`(最多 20 个帧),避免重复排版。
|
||||
|
||||
### 6.2 分页辅助方法
|
||||
|
||||
新增:
|
||||
- `stringIndexFittingLengthForWidth:startIndex:` — 模拟分页计算适配长度
|
||||
- `suggestLineBreakStartIndex:width:` — 行断点建议
|
||||
|
||||
### 6.3 线程安全
|
||||
|
||||
新增 `NSLock *_lock` 保护 typesetter 和 layout frames 的并发访问。
|
||||
|
||||
---
|
||||
|
||||
## 7. DTCoreTextLayoutFrame 修改
|
||||
|
||||
### 7.1 完整的绘图管线
|
||||
|
||||
原版提供基本的 drawInContext:。WeRead 扩展为:
|
||||
- `drawInContext:options:` — 支持 drawImages / drawLinks / drawSelection 选项
|
||||
- `drawImagesInContext:options:` — 绘制图片附件
|
||||
- `drawLinksInContext:options:` — 绘制链接下划线
|
||||
- `drawSelectionInContext:options:` — 绘制选区高亮
|
||||
|
||||
### 7.2 文本几何查询
|
||||
|
||||
新增丰富的几何查询方法:
|
||||
- `rectsForRange:` — 字符串范围对应的矩形数组
|
||||
- `frameForStringRange:` — 范围的包围矩形
|
||||
- `stringRangeForRect:` — 矩形内的字符串范围
|
||||
- `baselineOriginForStringIndex:` — 基线原点
|
||||
- `cursorRectForIndex:` — 光标位置
|
||||
|
||||
### 7.3 截断检测
|
||||
|
||||
新增 `isTruncated` / `truncatedStringRange` 用于检测内容是否超出可见区域。
|
||||
|
||||
---
|
||||
|
||||
## 8. DTCoreTextLayoutLine / GlyphRun 修改
|
||||
|
||||
### 8.1 LayoutLine
|
||||
|
||||
- 新增 `lineIndex` 属性标识行序号
|
||||
- 新增 `isLastLineInParagraph` / `isLineBreak` 标记
|
||||
- 新增 `paragraphStyle` / `metrics` 扩展属性
|
||||
- 完整的 hit testing 支持
|
||||
|
||||
### 8.2 GlyphRun
|
||||
|
||||
- 新增 `glyphImages` 数组支持自定义字形渲染
|
||||
- 新增 `attachments` 数组支持附件
|
||||
- 新增 `isAttachment` / `isWhitespace` / `isNewline` 标记
|
||||
- 新增 `writingDirection` / `isRTL` 支持从右到左文字
|
||||
- 完整的 CGPath 生成(newPathWithGlyphs / newPathForGlyphAtIndex:)
|
||||
- 字形级别的 hit testing
|
||||
|
||||
---
|
||||
|
||||
## 9. WRCoreTextLayouter(WeRead 新增类)
|
||||
|
||||
这是 WeRead 自己的排版器,包装 DTCoreTextLayouter 并增加业务逻辑。
|
||||
|
||||
### 9.1 核心功能
|
||||
|
||||
- **分页**: `layoutFramesForPageSize:` 将整章内容分割为页面
|
||||
- **页数计算**: `numberOfPagesForPageSize:` 快速计算总页数
|
||||
- **范围查询**: `rangeForPageAtIndex:pageSize:` 获取指定页的文本范围
|
||||
- **页面背景**: `pageBackgroundImageAtRange:themeBgColor:` 生成页面背景图
|
||||
- **图片缩放**: `resizedImageForImagePath:` 支持 full/half/third/quarter 尺寸模式
|
||||
- **暗色模式**: `darkModeAdjusted` 图片混合处理
|
||||
|
||||
### 9.2 布局配置
|
||||
|
||||
```objc
|
||||
@interface WRCoreTextLayoutConfig : NSObject
|
||||
@property CGFloat frameWidth;
|
||||
@property CGFloat frameHeight;
|
||||
@property UIEdgeInsets edgeInsets;
|
||||
@property NSUInteger numberOfColumns;
|
||||
@property CGFloat columnGap;
|
||||
@property BOOL avoidOrphans; // 避免孤行
|
||||
@property BOOL avoidWidows; // 避免寡行
|
||||
@property BOOL hyphenation; // 连字符断字
|
||||
@end
|
||||
```
|
||||
|
||||
### 9.3 与 DTCoreTextLayouter 的关系
|
||||
|
||||
WRCoreTextLayouter 内部持有 DTCoreTextLayouter 的 CTTypesetter,但使用自己的 WRCoreTextLayoutFrame 进行页面级布局。
|
||||
|
||||
---
|
||||
|
||||
## 10. WRCoreTextLayoutFrame(WeRead 新增类)
|
||||
|
||||
这是 WeRead 自己的布局帧,包装 CTFrame 并增加页面级功能。
|
||||
|
||||
### 10.1 核心功能
|
||||
|
||||
- **avoidPageBreakInside**: 实现 CSS break-inside:avoid 语义
|
||||
- **渲染高度计算**: `getRenderHeight` 返回实际内容高度
|
||||
- **附件绘制**: `drawAttachmentsInContext:` 绘制图片
|
||||
- **装饰元素**: 删除线、下划线、高亮背景
|
||||
- **文本选择**: 完整的选择/搜索/高亮支持
|
||||
- **RAC 响应式**: 通过 RACSubject 发送布局变化通知
|
||||
|
||||
### 10.2 avoidPageBreakInside 实现算法
|
||||
|
||||
```
|
||||
1. 从末尾向前遍历所有行
|
||||
2. 检查每行是否属于 avoidPageBreakInside 元素
|
||||
3. 如果是,标记需要移除
|
||||
4. 最多移除 3 行 (kMaxLinesToRemove)
|
||||
5. 调用 rebuildFrameWithoutLastLines: 重建帧
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. WREpubTypesetter(EPUB 排版入口)
|
||||
|
||||
这是 EPUB 渲染的最高层入口,协调整个排版流程。
|
||||
|
||||
### 11.1 CSS 级联顺序(优先级从低到高)
|
||||
|
||||
1. **default.css** — 基础 HTML 标签样式
|
||||
2. **replace.css** — WeRead 默认替换(思源宋体标题、Menlo 代码、bodyPic 图片)
|
||||
3. **dark.css** — 暗色主题覆盖(仅暗色模式加载)
|
||||
4. **EPUB 嵌入 CSS** — 书籍自带样式
|
||||
5. **用户设置 CSS** — 运行时用户偏好(字号、行高、主题色)
|
||||
|
||||
### 11.2 繁简转换
|
||||
|
||||
支持将简体中文内容转换为繁体(zh-Hant / zh-TW / zh-HK),使用 CFStringTransform 实现。
|
||||
|
||||
### 11.3 免费试读截断
|
||||
|
||||
非 VIP 用户每章有字符数限制,截断点为限制前最后一个段落边界,追加 "..." 指示器。
|
||||
|
||||
### 11.4 附件注入
|
||||
|
||||
章节末尾可选注入:
|
||||
- articleTool — 文章工具栏(高亮、笔记、分享)
|
||||
- bookChapterTool — 章节工具
|
||||
- recommendView — 推荐视图
|
||||
|
||||
---
|
||||
|
||||
## 12. 字体系统
|
||||
|
||||
### 12.1 默认字体
|
||||
|
||||
- 正文: PingFang SC(苹方简体)
|
||||
- 标题: Source Han Serif CN(思源宋体)
|
||||
- 代码: Menlo(等宽)
|
||||
|
||||
### 12.2 动态字体加载
|
||||
|
||||
支持从 CDN 动态下载字体,通过 DTCoreTextFontDescriptor 管理字体描述符和匹配。
|
||||
|
||||
### 12.3 字体回退
|
||||
|
||||
拉丁语书籍使用不同的字体回退链,通过 `isLatinLanguageBook` 标记区分。
|
||||
|
||||
---
|
||||
|
||||
## 13. 主题系统
|
||||
|
||||
### 13.1 主题类型
|
||||
|
||||
- 默认(白底黑字)
|
||||
- 护眼(米色 #f5f0e8)
|
||||
- 暗色(黑底灰字 #1a1a1a / #cccccc)
|
||||
- 自定义背景色
|
||||
|
||||
### 13.2 用户可配置项
|
||||
|
||||
通过 options 字典传入:
|
||||
- `fontFamily` — 字体族
|
||||
- `fontSize` — 字号 (默认 18px)
|
||||
- `lineHeight` — 行高倍数
|
||||
- `letterSpacing` — 字间距
|
||||
- `backgroundColor` / `textColor` — 主题色
|
||||
- `paragraphSpacing` — 段间距
|
||||
- `firstLineIndent` — 首行缩进
|
||||
|
||||
---
|
||||
|
||||
## 14. 与原版 DTCoreText 的差异总结
|
||||
|
||||
| 维度 | 原版 DTCoreText | WeRead 定制版 |
|
||||
|------|----------------|---------------|
|
||||
| 定位 | 通用 HTML 渲染库 | 电子书分页渲染引擎 |
|
||||
| CSS 支持 | 标准 CSS 2.1 | 标准 + 6 个自定义属性 |
|
||||
| 分页 | 无 | 完整分页 + avoidPageBreakInside |
|
||||
| 主题 | 无 | 暗色/护眼/自定义主题 |
|
||||
| 翻译 | 无 | 繁简转换 + 双语支持 |
|
||||
| 图片 | 基本附件 | 垂直居中 + 暗色适配 + CDN 加载 |
|
||||
| 选择 | 无 | 完整文本选择 + 搜索高亮 |
|
||||
| 响应式 | 无 | RACSubject 通知 |
|
||||
| 线程安全 | 部分 | 全面 NSLock 保护 |
|
||||
| 缓存 | 无 | 布局帧缓存 + 图片缓存 |
|
||||
582
Doc/WXRead/analysis/EPUB渲染管线详解.md
Normal file
582
Doc/WXRead/analysis/EPUB渲染管线详解.md
Normal file
@ -0,0 +1,582 @@
|
||||
# EPUB 渲染管线详解
|
||||
|
||||
微信读书 (WeRead) 的 EPUB 渲染管线将原始 XHTML 文件转换为可分页、可交互的阅读视图。本文档完整描述从 EPUB 文件到屏幕像素的每一步。
|
||||
|
||||
---
|
||||
|
||||
## 1. 管线总览
|
||||
|
||||
```
|
||||
EPUB 文件 (.epub)
|
||||
|
|
||||
v
|
||||
[WREpubParser] 解析 EPUB 结构
|
||||
|
|
||||
v
|
||||
[WREpubTypesetter] XHTML → NSAttributedString
|
||||
| (CSS 级联 + HTML 解析 + 后处理)
|
||||
v
|
||||
[WRCoreTextLayouter] NSAttributedString → 分页布局
|
||||
| (CTTypesetter + 分页算法)
|
||||
v
|
||||
[WRCoreTextLayoutFrame] 单页布局帧
|
||||
| (CTFrame + 行提取 + 避免断页)
|
||||
v
|
||||
[WRPageView] 渲染到屏幕
|
||||
| (CGContext 绘制 + 图片 + 装饰)
|
||||
v
|
||||
屏幕像素
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 阶段一: EPUB 解析 (WREpubParser)
|
||||
|
||||
### 2.1 输入
|
||||
|
||||
EPUB 文件路径 + WRBook 模型对象
|
||||
|
||||
### 2.2 解析步骤
|
||||
|
||||
```
|
||||
1. parseContainerXML
|
||||
- 读取 META-INF/container.xml
|
||||
- 提取 OPF 文件路径 (rootfile full-path)
|
||||
|
||||
2. parseOPFAtRelativePath
|
||||
- 解析 content.opf
|
||||
- 提取 manifest (id → href 映射)
|
||||
- 提取 spine (阅读顺序 idref 列表)
|
||||
- 提取 metadata (书名、标识符等)
|
||||
|
||||
3. parseNCX
|
||||
- 解析 toc.ncx (目录)
|
||||
- 提取 navPoint 树 (id, label, src, playOrder)
|
||||
|
||||
4. _buildChapterList
|
||||
- 将 spine idref 映射到 manifest href
|
||||
- 生成 chapters 数组 [{id, href, mediaType, fullPath}]
|
||||
|
||||
5. _buildResourceMap
|
||||
- 构建资源路径映射 (href → 绝对路径)
|
||||
- 同时索引文件名用于快速查找
|
||||
```
|
||||
|
||||
### 2.3 输出
|
||||
|
||||
- chapters 数组(有序章节列表)
|
||||
- resourceMap 字典(资源路径映射)
|
||||
- WRBook 元数据更新
|
||||
|
||||
---
|
||||
|
||||
## 3. 阶段二: CSS 级联 (WREpubTypesetter)
|
||||
|
||||
### 3.1 CSS 加载顺序
|
||||
|
||||
```
|
||||
Layer 1: default.css (App Bundle)
|
||||
↓ 覆盖
|
||||
Layer 2: replace.css (App Bundle)
|
||||
↓ 覆盖
|
||||
Layer 3: dark.css (App Bundle, 仅暗色模式)
|
||||
↓ 覆盖
|
||||
Layer 4: EPUB 嵌入 CSS (书籍自带)
|
||||
↓ 覆盖
|
||||
Layer 5: 用户设置 CSS (运行时生成)
|
||||
```
|
||||
|
||||
### 3.2 各层内容
|
||||
|
||||
**Layer 1 - default.css**:
|
||||
```css
|
||||
body { font-family: "PingFang SC", sans-serif; margin: 0; padding: 10px 15px; }
|
||||
p { margin-top: 0.5em; margin-bottom: 0.5em; }
|
||||
h1 { font-size: 1.8em; font-weight: bold; }
|
||||
h2 { font-size: 1.5em; font-weight: bold; }
|
||||
h3 { font-size: 1.3em; font-weight: bold; }
|
||||
ul, ol { padding-left: 1.5em; }
|
||||
blockquote { margin-left: 1em; font-style: italic; }
|
||||
```
|
||||
|
||||
**Layer 2 - replace.css**:
|
||||
```css
|
||||
h1, h2, h3 { font-family: "Source Han Serif CN", serif; }
|
||||
pre, code { font-family: "Menlo", monospace; }
|
||||
img.bodyPic { wr-vertical-center-style: 2; max-width: 100%; }
|
||||
.conQuot { /* 引用块样式 */ }
|
||||
```
|
||||
|
||||
**Layer 3 - dark.css**:
|
||||
```css
|
||||
body { background-color: #1a1a1a; color: #cccccc; }
|
||||
a { color: #6eaad7; }
|
||||
img { filter: brightness(0.85); }
|
||||
```
|
||||
|
||||
**Layer 5 - 用户设置 CSS** (动态生成):
|
||||
```css
|
||||
body { font-size: 18px; line-height: 1.8; font-family: "PingFang SC", sans-serif; }
|
||||
body { background-color: #f5f0e8; } /* 护眼模式 */
|
||||
p { text-indent: 2em; } /* 首行缩进 */
|
||||
```
|
||||
|
||||
### 3.3 级联合并
|
||||
|
||||
使用 DTCSSStylesheet.mergeStylesheet: 方法按顺序合并,后加载的覆盖先前的同名规则。
|
||||
|
||||
---
|
||||
|
||||
## 4. 阶段三: HTML 解析 (DTHTMLAttributedStringBuilder)
|
||||
|
||||
### 4.1 SAX 解析流程
|
||||
|
||||
```
|
||||
XHTML 数据
|
||||
|
|
||||
v
|
||||
DTHTMLParser (SAX)
|
||||
|
|
||||
├── didStartElement: → 创建 DTHTMLElement 节点
|
||||
| 应用 CSS 样式
|
||||
| 处理自定义属性
|
||||
|
|
||||
├── foundCharacters: → 累积文本到当前元素
|
||||
|
|
||||
├── foundCDATA: → 处理 CDATA 内容
|
||||
|
|
||||
└── didEndElement: → 弹出元素栈
|
||||
调用 interpretAttributes
|
||||
插入分页标记
|
||||
```
|
||||
|
||||
### 4.2 元素处理
|
||||
|
||||
每个 HTML 标签被转换为 DTHTMLElement 节点:
|
||||
|
||||
```
|
||||
DTHTMLElement
|
||||
├── tagName: "p" / "div" / "img" / ...
|
||||
├── classNames: ["bodyPic", "conQuot"]
|
||||
├── fontDescriptor: 字体描述符
|
||||
├── paragraphStyle: 段落样式
|
||||
├── textColor / backgroundColor
|
||||
├── children: [DTHTMLElement]
|
||||
├── textAttachment (图片)
|
||||
├── linkURL (链接)
|
||||
├── [WeRead 扩展]
|
||||
│ ├── verticalCenterStyle
|
||||
│ ├── pageRelate
|
||||
│ ├── shouldAvoidPageBreakInside
|
||||
│ ├── pageBreakAfter / pageBreakBefore
|
||||
│ ├── pageBackgroundColor
|
||||
│ └── pageBackgroundImage
|
||||
```
|
||||
|
||||
### 4.3 样式应用顺序
|
||||
|
||||
```
|
||||
1. CSS 样式表规则 (class, id, tag 选择器)
|
||||
2. 内联 style="" 属性
|
||||
3. WeRead 自定义 CSS 属性
|
||||
4. 元素默认样式 (基于标签名)
|
||||
```
|
||||
|
||||
### 4.4 后处理 (_WRPostProcessElementTree)
|
||||
|
||||
在元素树转为 NSAttributedString 之前,执行 WeRead 特有的后处理:
|
||||
|
||||
**图片处理**:
|
||||
- 设置最大显示尺寸 (1080x1920)
|
||||
- 超限图片按比例缩放
|
||||
- 添加 .bodyPic CSS 类
|
||||
- 设置 wr-vertical-center-style: 2
|
||||
|
||||
**链接处理**:
|
||||
- 添加下划线样式
|
||||
- 存储链接 URL 到自定义属性
|
||||
|
||||
**自定义属性处理**:
|
||||
- wr-vertical-center-style → DTHTMLVerticalCenterAttribute
|
||||
- weread-page-relate → DTPageRelateAttribute
|
||||
|
||||
---
|
||||
|
||||
## 5. 阶段四: NSAttributedString 生成
|
||||
|
||||
### 5.1 递归转换
|
||||
|
||||
```
|
||||
DTHTMLElement.attributedString
|
||||
|
|
||||
├── 处理 void 元素 (br → "\n", img → attachment, hr → "\n")
|
||||
|
|
||||
├── 插入 pageBreakBefore 标记 (如果需要)
|
||||
|
|
||||
├── 转换文本内容
|
||||
| ├── 应用 CTFont (从 fontDescriptor)
|
||||
| ├── 应用前景色 (kCTForegroundColorAttributeName)
|
||||
| ├── 应用背景色 (DTBackgroundColor)
|
||||
| └── 应用链接 (DTLink)
|
||||
|
|
||||
├── 递归处理子元素
|
||||
|
|
||||
├── 应用段落样式 (kCTParagraphStyleAttributeName)
|
||||
|
|
||||
├── 插入 pageBreakAfter 标记 (如果需要)
|
||||
|
|
||||
└── 应用 WeRead 页面属性
|
||||
├── DTHTMLVerticalCenterAttribute
|
||||
├── DTPageRelateAttribute
|
||||
├── DTPageBreakInsideAvoidAttribute
|
||||
├── DTPageBackgroundColorAttribute
|
||||
└── DTPageBackgroundImageAttribute
|
||||
```
|
||||
|
||||
### 5.2 输出
|
||||
|
||||
一个 NSAttributedString,包含:
|
||||
- 标准 CoreText 属性 (字体、颜色、段落样式)
|
||||
- DTCoreText 标准属性 (链接、附件、列表)
|
||||
- WeRead 自定义属性 (分页、居中、背景)
|
||||
|
||||
---
|
||||
|
||||
## 6. 阶段五: 分页布局 (WRCoreTextLayouter)
|
||||
|
||||
### 6.1 Typesetter 创建
|
||||
|
||||
```
|
||||
NSAttributedString
|
||||
|
|
||||
v
|
||||
CTTypesetterCreateWithAttributedString
|
||||
|
|
||||
v
|
||||
CTTypesetter (内部缓存字形分析结果)
|
||||
```
|
||||
|
||||
### 6.2 分页算法
|
||||
|
||||
```
|
||||
输入: NSAttributedString + pageSize
|
||||
|
||||
1. 计算可用宽度 = pageSize.width - edgeInsets.left - edgeInsets.right
|
||||
|
||||
2. 初始化 currentIndex = 0
|
||||
|
||||
3. 循环:
|
||||
a. lineBreakIndex = CTTypesetterSuggestLineBreak(typesetter, currentIndex, usableWidth)
|
||||
b. 如果 lineBreakIndex <= 0, 退出循环
|
||||
c. 创建 WRCoreTextLayoutFrame(range: currentIndex..<currentIndex+lineBreakIndex)
|
||||
d. currentIndex += lineBreakIndex
|
||||
|
||||
4. 返回 [WRCoreTextLayoutFrame] 数组 (每帧 = 一页)
|
||||
```
|
||||
|
||||
### 6.3 页面范围查询
|
||||
|
||||
`rangeForPageAtIndex:pageSize:` 通过模拟分页快速定位指定页的文本范围,无需创建布局帧对象。
|
||||
|
||||
### 6.4 行高建议
|
||||
|
||||
`suggestedLineFragHeights` 逐行创建临时 CTLine 测量高度,用于精确分页计算和避免孤行/寡行。
|
||||
|
||||
---
|
||||
|
||||
## 7. 阶段六: 单页布局 (WRCoreTextLayoutFrame)
|
||||
|
||||
### 7.1 CTFrame 创建
|
||||
|
||||
```
|
||||
CTTypesetter + range + CGPath(rect)
|
||||
|
|
||||
v
|
||||
CTTypesetterCreateFrame
|
||||
|
|
||||
v
|
||||
CTFrame (包含 CTLines 和 CTRuns)
|
||||
```
|
||||
|
||||
### 7.2 行提取
|
||||
|
||||
```
|
||||
CTFrameGetLines → CTLine 数组
|
||||
CTFrameGetLineOrigins → 行原点数组
|
||||
|
||||
对每个 CTLine:
|
||||
CTLineGetStringRange → 字符范围
|
||||
CTLineGetTypographicBounds → ascent/descent/leading
|
||||
创建 WRCoreTextLayoutLine 对象
|
||||
|
||||
判断每行是否为段落末尾行 (检查下一个字符是否为 '\n')
|
||||
```
|
||||
|
||||
### 7.3 avoidPageBreakInside 实现
|
||||
|
||||
```
|
||||
1. 从最后一行向前遍历
|
||||
2. 检查行的 attributedString 属性:
|
||||
- WRAvoidPageBreakInside == YES?
|
||||
- WRBlockType ∈ {table, code, list, blockquote}?
|
||||
3. 如果是受保护元素的一部分,标记需要移除
|
||||
4. 最多移除 3 行 (kMaxLinesToRemove)
|
||||
5. 调用 rebuildFrameWithoutLastLines: 更新帧
|
||||
6. 返回 YES 表示有行被移除
|
||||
```
|
||||
|
||||
### 7.4 渲染高度计算
|
||||
|
||||
```
|
||||
getRenderHeight:
|
||||
lastLine = lines.lastObject
|
||||
lastLineBottom = lastLine.origin.y - lastLine.descent
|
||||
renderedHeight = frameHeight - lastLineBottom + insets.top + insets.bottom
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 阶段七: 绘制到屏幕 (WRCoreTextLayoutFrame)
|
||||
|
||||
### 8.1 绘制流程
|
||||
|
||||
```
|
||||
drawInContext:image:size:inRect:position:
|
||||
|
||||
1. CGContextSaveGState
|
||||
|
||||
2. 坐标系翻转 (CoreText 底左原点 → UIKit 顶左原点)
|
||||
CGContextTranslateCTM(0, height)
|
||||
CGContextScaleCTM(1, -1)
|
||||
|
||||
3. 应用位置偏移 (多列/多页布局)
|
||||
|
||||
4. 应用内容内边距
|
||||
|
||||
5. 绘制封面图片 (如果有)
|
||||
|
||||
6. CTFrameDraw(ctFrame, context) — 绘制文本
|
||||
|
||||
7. drawAttachmentsInContext: — 绘制图片附件
|
||||
|
||||
8. drawDecorativeElementsInContext: — 绘制装饰元素
|
||||
├── 删除线
|
||||
├── 下划线
|
||||
└── 高亮背景
|
||||
|
||||
9. CGContextRestoreGState
|
||||
```
|
||||
|
||||
### 8.2 图片绘制
|
||||
|
||||
```
|
||||
遍历 attachments 数组:
|
||||
对每个 attachment:
|
||||
获取 image, position, size
|
||||
CGContextDrawImage(context, imageRect, image.CGImage)
|
||||
```
|
||||
|
||||
### 8.3 装饰元素绘制
|
||||
|
||||
**删除线**:
|
||||
```
|
||||
遍历 strikethroughRanges:
|
||||
对每个 range:
|
||||
找到相交的行
|
||||
计算 startX, endX (CTLineGetOffsetForStringIndex)
|
||||
y = line.origin.y + ascent * 0.3
|
||||
CGContextStrokePath
|
||||
```
|
||||
|
||||
**下划线**:
|
||||
```
|
||||
遍历 underlineRanges:
|
||||
对每个 range:
|
||||
找到相交的行
|
||||
y = line.origin.y - descent
|
||||
CGContextStrokePath (蓝色)
|
||||
```
|
||||
|
||||
**高亮**:
|
||||
```
|
||||
遍历 highlightRanges:
|
||||
对每个 range:
|
||||
找到相交的行
|
||||
计算高亮矩形 (descent 到 ascent)
|
||||
CGContextFillRect (半透明黄色)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 交互层
|
||||
|
||||
### 9.1 文本选择
|
||||
|
||||
```
|
||||
characterIndexAtPoint:
|
||||
遍历所有行
|
||||
检查点是否在行的垂直范围内
|
||||
使用 CTLineGetStringIndexForPosition 定位字符
|
||||
|
||||
selectTextInRange:
|
||||
遍历所有行
|
||||
找到与 range 相交的行
|
||||
添加到 selectedLineIndices
|
||||
通过 RACSubject 发送选择通知
|
||||
```
|
||||
|
||||
### 9.2 搜索高亮
|
||||
|
||||
```
|
||||
highlightSearchResults:
|
||||
使用 NSString rangeOfString: 搜索
|
||||
收集所有匹配范围
|
||||
创建 highlightRanges (黄色半透明)
|
||||
返回匹配数量
|
||||
```
|
||||
|
||||
### 9.3 Hit Testing
|
||||
|
||||
```
|
||||
stringIndexAtPoint:
|
||||
遍历行 → 找到目标行
|
||||
CTLineGetStringIndexForPosition → 字符索引
|
||||
|
||||
rectForCharacterAtIndex:
|
||||
遍历行 → 找到包含索引的行
|
||||
CTLineGetOffsetForStringIndex → x 坐标
|
||||
返回 CGRect
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 附加功能
|
||||
|
||||
### 10.1 繁简转换
|
||||
|
||||
```
|
||||
如果 book.language ∈ {zh-Hant, zh-TW, zh-HK}:
|
||||
CFStringTransform(Hans → Latin → Hant)
|
||||
```
|
||||
|
||||
### 10.2 免费试读截断
|
||||
|
||||
```
|
||||
如果 isFreeTrial && result.length > trialCharacterLimit:
|
||||
从限制位置向前搜索段落边界 (\n 或 U+2029)
|
||||
截取子串
|
||||
追加 "\n\n...\n\n" 指示器
|
||||
```
|
||||
|
||||
### 10.3 附件注入
|
||||
|
||||
```
|
||||
如果 insertArticleToolAttachment:
|
||||
追加 NSTextAttachment {type: "articleTool"}
|
||||
|
||||
如果 insertBookChapterToolAttachment:
|
||||
追加 NSTextAttachment {type: "bookChapterTool"}
|
||||
|
||||
如果 insertRecommendView:
|
||||
追加 NSTextAttachment {type: "recommendView"}
|
||||
```
|
||||
|
||||
### 10.4 页面背景生成
|
||||
|
||||
```
|
||||
pageBackgroundImageAtRange:themeBgColor:
|
||||
检查是否为章节开头页
|
||||
创建 UIGraphicsImageContext
|
||||
填充主题背景色
|
||||
如果是章节开头: 绘制装饰边框
|
||||
否则: 绘制边距参考线
|
||||
缓存结果
|
||||
```
|
||||
|
||||
### 10.5 图片缩放
|
||||
|
||||
```
|
||||
resizedImageForImagePath:rect:position:sizePattern:darkMode:themeBgColor:
|
||||
根据 sizePattern 计算目标尺寸:
|
||||
"full" → 全宽
|
||||
"half" → 半宽
|
||||
"third" → 1/3 宽
|
||||
"quarter" → 1/4 宽
|
||||
如果 darkMode: 混合背景色
|
||||
高质量插值缩放
|
||||
缓存结果
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 性能优化
|
||||
|
||||
### 11.1 缓存策略
|
||||
|
||||
- **布局帧缓存**: NSCache, 最多 20 个 (DTCoreTextLayouter)
|
||||
- **图片缓存**: NSCache, 最多 50 个 (WRCoreTextLayouter)
|
||||
- **页面背景缓存**: NSCache, 按 range + 颜色键 (WRCoreTextLayouter)
|
||||
- **内容高度缓存**: 布局帧级别 (DTCoreTextLayoutFrame)
|
||||
|
||||
### 11.2 懒加载
|
||||
|
||||
- CTTypesetter 按需创建 (createTypesetter)
|
||||
- CTFramesetter 按需创建 (createFramesetter)
|
||||
- 行数据按需提取 (extractLines)
|
||||
- 字形数据按需提取 (extractGlyphs)
|
||||
|
||||
### 11.3 线程安全
|
||||
|
||||
所有核心类使用 NSLock 保护:
|
||||
- DTCoreTextLayouter._lock
|
||||
- DTCoreTextLayoutFrame._lock
|
||||
- DTCoreTextLayoutLine._lock
|
||||
- DTCoreTextGlyphRun._lock
|
||||
- WRCoreTextLayouter._layoutLock
|
||||
- WRCoreTextLayoutFrame._frameLock
|
||||
|
||||
### 11.4 增量更新
|
||||
|
||||
- attributedString 变化时标记 typesetterDirty
|
||||
- 仅在下次访问时重建 typesetter
|
||||
- 布局帧缓存自动失效
|
||||
|
||||
---
|
||||
|
||||
## 12. 数据流总结
|
||||
|
||||
```
|
||||
EPUB 文件
|
||||
│
|
||||
├─ container.xml → OPF 路径
|
||||
├─ content.opf → manifest + spine + metadata
|
||||
├─ toc.ncx → 目录树
|
||||
└─ *.xhtml → 章节内容
|
||||
│
|
||||
▼
|
||||
XHTML 字符串
|
||||
│ (繁简转换)
|
||||
▼
|
||||
CSS 级联合并
|
||||
│ (5 层合并)
|
||||
▼
|
||||
DTHTMLAttributedStringBuilder
|
||||
│ (SAX 解析 + DOM 构建 + 样式应用)
|
||||
▼
|
||||
DTHTMLElement 树
|
||||
│ (后处理: 图片/链接/自定义属性)
|
||||
▼
|
||||
NSAttributedString
|
||||
│ (包含标准 + 自定义属性)
|
||||
▼
|
||||
CTTypesetter
|
||||
│ (字形分析 + 行断点计算)
|
||||
▼
|
||||
WRCoreTextLayoutFrame[]
|
||||
│ (每帧 = 一页, 含 avoidPageBreakInside)
|
||||
▼
|
||||
CGContext 绘制
|
||||
│ (文本 + 图片 + 装饰)
|
||||
▼
|
||||
屏幕像素
|
||||
```
|
||||
311
Doc/WXRead/decompiled/DTCoreTextGlyphRun.h
Normal file
311
Doc/WXRead/decompiled/DTCoreTextGlyphRun.h
Normal file
@ -0,0 +1,311 @@
|
||||
//
|
||||
// DTCoreTextGlyphRun.h
|
||||
// WeRead
|
||||
//
|
||||
// Reverse-engineered from binary analysis
|
||||
// Based on DTCoreText open-source library, customized by WeRead
|
||||
//
|
||||
// DTCoreText is an open-source library for rendering HTML with CoreText.
|
||||
// This class represents a glyph run within a line of text.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <CoreText/CoreText.h>
|
||||
|
||||
@class DTCoreTextLayoutLine;
|
||||
@class DTTextAttachment;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/**
|
||||
* DTCoreTextGlyphRun - Represents a glyph run within a line.
|
||||
*
|
||||
* A glyph run is a sequence of glyphs that share the same attributes
|
||||
* (font, color, etc.). This class wraps CTRun and provides high-level
|
||||
* access to individual glyphs and their properties.
|
||||
*
|
||||
* Key features:
|
||||
* - Wraps CTRun for high-level access
|
||||
* - Contains glyph images and paths
|
||||
* - Handles text attachments (images, etc.)
|
||||
* - Provides hit testing within the run
|
||||
* - Supports custom drawing and effects
|
||||
*/
|
||||
@interface DTCoreTextGlyphRun : NSObject
|
||||
|
||||
#pragma mark - Properties
|
||||
|
||||
/** The CTRun reference */
|
||||
@property (nonatomic, assign, readonly) CTRunRef ctRun;
|
||||
|
||||
/** The frame (position and size) of this run */
|
||||
@property (nonatomic, assign) CGRect frame;
|
||||
|
||||
/** The range of characters in this run */
|
||||
@property (nonatomic, assign, readonly) NSRange stringRange;
|
||||
|
||||
/** The index of this run in its parent line */
|
||||
@property (nonatomic, assign, readonly) NSUInteger runIndex;
|
||||
|
||||
/** The attributes shared by all glyphs in this run */
|
||||
@property (nonatomic, strong, readonly) NSDictionary *attributes;
|
||||
|
||||
/** The parent layout line */
|
||||
@property (nonatomic, weak, nullable) DTCoreTextLayoutLine *layoutLine;
|
||||
|
||||
/** Array of glyph images (for custom rendering) */
|
||||
@property (nonatomic, strong, nullable) NSArray *glyphImages;
|
||||
|
||||
/** Array of attachment objects */
|
||||
@property (nonatomic, strong, nullable) NSArray<DTTextAttachment *> *attachments;
|
||||
|
||||
/** The text attachment (if this run contains one) */
|
||||
@property (nonatomic, strong, nullable) DTTextAttachment *attachment;
|
||||
|
||||
/** Whether this run is a placeholder for an attachment */
|
||||
@property (nonatomic, assign, readonly) BOOL isAttachment;
|
||||
|
||||
/** Whether this run is a whitespace */
|
||||
@property (nonatomic, assign, readonly) BOOL isWhitespace;
|
||||
|
||||
/** Whether this run is a newline */
|
||||
@property (nonatomic, assign, readonly) BOOL isNewline;
|
||||
|
||||
/** The font used in this run */
|
||||
@property (nonatomic, strong, nullable) UIFont *font;
|
||||
|
||||
/** The text color */
|
||||
@property (nonatomic, strong, nullable) UIColor *textColor;
|
||||
|
||||
/** The background color */
|
||||
@property (nonatomic, strong, nullable) UIColor *backgroundColor;
|
||||
|
||||
/** The strikethrough color */
|
||||
@property (nonatomic, strong, nullable) UIColor *strikethroughColor;
|
||||
|
||||
/** Whether strikethrough is enabled */
|
||||
@property (nonatomic, assign) BOOL hasStrikethrough;
|
||||
|
||||
/** Whether underline is enabled */
|
||||
@property (nonatomic, assign) BOOL hasUnderline;
|
||||
|
||||
/** The underline style */
|
||||
@property (nonatomic, assign) NSUnderlineStyle underlineStyle;
|
||||
|
||||
/** The underline color */
|
||||
@property (nonatomic, strong, nullable) UIColor *underlineColor;
|
||||
|
||||
/** Number of glyphs in this run */
|
||||
@property (nonatomic, assign, readonly) NSUInteger numberOfGlyphs;
|
||||
|
||||
/** Array of glyph values */
|
||||
@property (nonatomic, strong, readonly) NSArray<NSNumber *> *glyphs;
|
||||
|
||||
/** Array of glyph positions */
|
||||
@property (nonatomic, strong, readonly) NSArray<NSValue *> *glyphPositions;
|
||||
|
||||
/** Array of glyph advances */
|
||||
@property (nonatomic, strong, readonly) NSArray<NSNumber *> *glyphAdvances;
|
||||
|
||||
/** The writing direction */
|
||||
@property (nonatomic, assign) CTWritingDirection writingDirection;
|
||||
|
||||
/** Whether this run is right-to-left */
|
||||
@property (nonatomic, assign, readonly) BOOL isRTL;
|
||||
|
||||
#pragma mark - Initialization
|
||||
|
||||
/**
|
||||
* Initialize with a CTRun.
|
||||
*
|
||||
* @param ctRun The CoreText run
|
||||
* @param frame The frame of the run
|
||||
* @param range The string range
|
||||
* @param attributes The run attributes
|
||||
* @param index The run index
|
||||
* @return Initialized glyph run
|
||||
*/
|
||||
- (instancetype)initWithCTRun:(CTRunRef)ctRun
|
||||
frame:(CGRect)frame
|
||||
range:(NSRange)range
|
||||
attributes:(NSDictionary *)attributes
|
||||
index:(NSUInteger)index;
|
||||
|
||||
#pragma mark - Glyph Access
|
||||
|
||||
/**
|
||||
* Returns the glyph at the specified index.
|
||||
*
|
||||
* @param index The glyph index
|
||||
* @return The glyph value
|
||||
*/
|
||||
- (CGGlyph)glyphAtIndex:(NSUInteger)index;
|
||||
|
||||
/**
|
||||
* Returns the position of the glyph at the specified index.
|
||||
*
|
||||
* @param index The glyph index
|
||||
* @return The glyph position
|
||||
*/
|
||||
- (CGPoint)positionForGlyphAtIndex:(NSUInteger)index;
|
||||
|
||||
/**
|
||||
* Returns the advance of the glyph at the specified index.
|
||||
*
|
||||
* @param index The glyph index
|
||||
* @return The glyph advance
|
||||
*/
|
||||
- (CGFloat)advanceForGlyphAtIndex:(NSUInteger)index;
|
||||
|
||||
/**
|
||||
* Returns the rect for the glyph at the specified index.
|
||||
*
|
||||
* @param index The glyph index
|
||||
* @return The glyph rect
|
||||
*/
|
||||
- (CGRect)rectForGlyphAtIndex:(NSUInteger)index;
|
||||
|
||||
#pragma mark - Path Operations
|
||||
|
||||
/**
|
||||
* Creates a CGPath containing all glyphs in this run.
|
||||
*
|
||||
* This method creates a path that outlines all the glyphs.
|
||||
* It's used for custom drawing, hit testing, and effects.
|
||||
*
|
||||
* @return A CGPath containing the glyph outlines, or NULL
|
||||
*/
|
||||
- (nullable CGPathRef)newPathWithGlyphs;
|
||||
|
||||
/**
|
||||
* Creates a CGPath for a specific glyph.
|
||||
*
|
||||
* @param index The glyph index
|
||||
* @return A CGPath for the glyph, or NULL
|
||||
*/
|
||||
- (nullable CGPathRef)newPathForGlyphAtIndex:(NSUInteger)index;
|
||||
|
||||
/**
|
||||
* Creates a bounding path for all glyphs.
|
||||
*
|
||||
* @return A CGPath bounding all glyphs
|
||||
*/
|
||||
- (nullable CGPathRef)newBoundingPath;
|
||||
|
||||
#pragma mark - Image Operations
|
||||
|
||||
/**
|
||||
* Returns the image for the glyph at the specified index.
|
||||
*
|
||||
* @param index The glyph index
|
||||
* @return The glyph image, or nil
|
||||
*/
|
||||
- (nullable UIImage *)imageForGlyphAtIndex:(NSUInteger)index;
|
||||
|
||||
/**
|
||||
* Returns the bounding rect for the glyph image.
|
||||
*
|
||||
* @param index The glyph index
|
||||
* @return The image rect
|
||||
*/
|
||||
- (CGRect)imageRectForGlyphAtIndex:(NSUInteger)index;
|
||||
|
||||
#pragma mark - Hit Testing
|
||||
|
||||
/**
|
||||
* Returns the glyph index at the given point.
|
||||
*
|
||||
* @param point The point to test
|
||||
* @return The glyph index, or NSNotFound
|
||||
*/
|
||||
- (NSUInteger)glyphIndexAtPoint:(CGPoint)point;
|
||||
|
||||
/**
|
||||
* Returns the string index at the given point.
|
||||
*
|
||||
* @param point The point to test
|
||||
* @return The string index, or NSNotFound
|
||||
*/
|
||||
- (NSUInteger)stringIndexAtPoint:(CGPoint)point;
|
||||
|
||||
/**
|
||||
* Returns the rect for a given string index.
|
||||
*
|
||||
* @param index The string index
|
||||
* @return The rect for the character
|
||||
*/
|
||||
- (CGRect)rectForStringIndex:(NSUInteger)index;
|
||||
|
||||
#pragma mark - Drawing
|
||||
|
||||
/**
|
||||
* Draws this run into a CGContext.
|
||||
*
|
||||
* @param context The CGContext to draw into
|
||||
*/
|
||||
- (void)drawInContext:(CGContextRef)context;
|
||||
|
||||
/**
|
||||
* Draws this run with a specific color.
|
||||
*
|
||||
* @param context The CGContext to draw into
|
||||
* @param color The text color
|
||||
*/
|
||||
- (void)drawInContext:(CGContextRef)context withColor:(UIColor *)color;
|
||||
|
||||
/**
|
||||
* Draws the attachment (if any) into the context.
|
||||
*
|
||||
* @param context The CGContext to draw into
|
||||
*/
|
||||
- (void)drawAttachmentInContext:(CGContextRef)context;
|
||||
|
||||
#pragma mark - Run Comparison
|
||||
|
||||
/**
|
||||
* Compares this run to another run for ordering.
|
||||
*/
|
||||
- (NSComparisonResult)compareToRun:(DTCoreTextGlyphRun *)otherRun;
|
||||
|
||||
/**
|
||||
* Returns whether this run contains the given string index.
|
||||
*/
|
||||
- (BOOL)containsStringIndex:(NSUInteger)index;
|
||||
|
||||
/**
|
||||
* Returns whether this run intersects with the given range.
|
||||
*/
|
||||
- (BOOL)intersectsRange:(NSRange)range;
|
||||
|
||||
#pragma mark - Metrics
|
||||
|
||||
/**
|
||||
* Returns the typographic bounds of this run.
|
||||
*
|
||||
* @param ascent Output parameter for ascent
|
||||
* @param descent Output parameter for descent
|
||||
* @param leading Output parameter for leading
|
||||
* @return The width of the run
|
||||
*/
|
||||
- (CGFloat)getTypographicBoundsAscent:(CGFloat *)ascent
|
||||
descent:(CGFloat *)descent
|
||||
leading:(CGFloat *)leading;
|
||||
|
||||
/**
|
||||
* Returns the bounds of the run (bounding box).
|
||||
*/
|
||||
- (CGRect)bounds;
|
||||
|
||||
/**
|
||||
* Returns the width of the run.
|
||||
*/
|
||||
- (CGFloat)width;
|
||||
|
||||
/**
|
||||
* Returns the height of the run.
|
||||
*/
|
||||
- (CGFloat)height;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
698
Doc/WXRead/decompiled/DTCoreTextGlyphRun.m
Normal file
698
Doc/WXRead/decompiled/DTCoreTextGlyphRun.m
Normal file
@ -0,0 +1,698 @@
|
||||
//
|
||||
// DTCoreTextGlyphRun.m
|
||||
// WeRead
|
||||
//
|
||||
// Reverse-engineered from binary analysis
|
||||
// Based on DTCoreText open-source library, customized by WeRead
|
||||
//
|
||||
// This file contains pseudo-code reconstruction of the DTCoreTextGlyphRun
|
||||
// class based on ivar analysis, method signatures, and the open-source DTCoreText library.
|
||||
//
|
||||
|
||||
#import "DTCoreTextGlyphRun.h"
|
||||
#import "DTCoreTextLayoutLine.h"
|
||||
#import <CoreText/CoreText.h>
|
||||
|
||||
#pragma mark - DTTextAttachment Stub
|
||||
|
||||
/**
|
||||
* Stub for DTTextAttachment class.
|
||||
* Represents an embedded object (image, view, etc.) in the text.
|
||||
*/
|
||||
@interface DTTextAttachment : NSObject
|
||||
|
||||
@property (nonatomic, strong) UIImage *image;
|
||||
@property (nonatomic, assign) CGSize displaySize;
|
||||
@property (nonatomic, assign) CGRect frame;
|
||||
@property (nonatomic, copy) NSString *contentType;
|
||||
|
||||
@end
|
||||
|
||||
@implementation DTTextAttachment
|
||||
@end
|
||||
|
||||
#pragma mark - Private Interface
|
||||
|
||||
@interface DTCoreTextGlyphRun () {
|
||||
// Cached glyph data
|
||||
CGGlyph *_glyphs;
|
||||
CGPoint *_positions;
|
||||
CGFloat *_advances;
|
||||
CFIndex _glyphCount;
|
||||
|
||||
// Whether glyph data has been extracted
|
||||
BOOL _glyphsExtracted;
|
||||
|
||||
// Cached metrics
|
||||
CGFloat _cachedAscent;
|
||||
CGFloat _cachedDescent;
|
||||
CGFloat _cachedLeading;
|
||||
CGFloat _cachedWidth;
|
||||
BOOL _metricsCached;
|
||||
|
||||
// Internal lock
|
||||
NSLock *_lock;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - DTCoreTextGlyphRun Implementation
|
||||
|
||||
@implementation DTCoreTextGlyphRun
|
||||
|
||||
#pragma mark - Lifecycle
|
||||
|
||||
- (instancetype)initWithCTRun:(CTRunRef)ctRun
|
||||
frame:(CGRect)frame
|
||||
range:(NSRange)range
|
||||
attributes:(NSDictionary *)attributes
|
||||
index:(NSUInteger)index {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_lock = [[NSLock alloc] init];
|
||||
|
||||
// Store the CTRun with a retain
|
||||
if (ctRun) {
|
||||
_ctRun = (CTRunRef)CFRetain(ctRun);
|
||||
}
|
||||
|
||||
_frame = frame;
|
||||
_stringRange = range;
|
||||
_attributes = [attributes copy];
|
||||
_runIndex = index;
|
||||
|
||||
// Initialize state
|
||||
_glyphsExtracted = NO;
|
||||
_metricsCached = NO;
|
||||
_glyphs = NULL;
|
||||
_positions = NULL;
|
||||
_advances = NULL;
|
||||
_glyphCount = 0;
|
||||
|
||||
// Extract common attributes
|
||||
[self extractAttributes];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
if (_ctRun) {
|
||||
CFRelease(_ctRun);
|
||||
_ctRun = NULL;
|
||||
}
|
||||
|
||||
// Free allocated glyph data
|
||||
if (_glyphs) {
|
||||
free(_glyphs);
|
||||
_glyphs = NULL;
|
||||
}
|
||||
if (_positions) {
|
||||
free(_positions);
|
||||
_positions = NULL;
|
||||
}
|
||||
if (_advances) {
|
||||
free(_advances);
|
||||
_advances = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Attribute Extraction
|
||||
|
||||
/**
|
||||
* Extracts common attributes from the attributes dictionary.
|
||||
*/
|
||||
- (void)extractAttributes {
|
||||
if (!_attributes) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract font
|
||||
CTFontRef ctFont = (__bridge CTFontRef)_attributes[(__bridge NSString *)kCTFontAttributeName];
|
||||
if (ctFont) {
|
||||
_font = [UIFont fontWithDescriptor:[UIFontDescriptor fontDescriptorWithCTFont:ctFont]
|
||||
size:CTFontGetSize(ctFont)];
|
||||
}
|
||||
|
||||
// Extract text color
|
||||
CGColorRef textColor = (__bridge CGColorRef)_attributes[(__bridge NSString *)kCTForegroundColorAttributeName];
|
||||
if (textColor) {
|
||||
_textColor = [UIColor colorWithCGColor:textColor];
|
||||
}
|
||||
|
||||
// Extract background color
|
||||
CGColorRef bgColor = (__bridge CGColorRef)_attributes[@"DTBackgroundColor"];
|
||||
if (bgColor) {
|
||||
_backgroundColor = [UIColor colorWithCGColor:bgColor];
|
||||
}
|
||||
|
||||
// Extract strikethrough
|
||||
NSNumber *strikethrough = _attributes[(__bridge NSString *)kCTSuperscriptAttributeName];
|
||||
if (strikethrough) {
|
||||
_hasStrikethrough = [strikethrough boolValue];
|
||||
}
|
||||
|
||||
// Extract underline
|
||||
NSNumber *underlineStyle = _attributes[(__bridge NSString *)kCTUnderlineColorAttributeName];
|
||||
if (underlineStyle) {
|
||||
_hasUnderline = YES;
|
||||
_underlineStyle = [underlineStyle integerValue];
|
||||
}
|
||||
|
||||
// Check for attachment
|
||||
_attachment = _attributes[@"DTTextAttachment"];
|
||||
if (_attachment) {
|
||||
_isAttachment = YES;
|
||||
}
|
||||
|
||||
// Extract writing direction
|
||||
NSArray *writingDirection = _attributes[(__bridge NSString *)kCTWritingDirectionAttributeName];
|
||||
if ([writingDirection count] > 0) {
|
||||
_writingDirection = [writingDirection[0] integerValue];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Glyph Extraction
|
||||
|
||||
/**
|
||||
* Extracts glyph data from the CTRun.
|
||||
*
|
||||
* This method extracts:
|
||||
* - Glyph values (CGGlyph)
|
||||
* - Glyph positions (CGPoint)
|
||||
* - Glyph advances (CGFloat)
|
||||
*/
|
||||
- (void)extractGlyphs {
|
||||
if (_glyphsExtracted || !_ctRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
[_lock lock];
|
||||
|
||||
// Get the number of glyphs
|
||||
_glyphCount = CTRunGetGlyphCount(_ctRun);
|
||||
|
||||
if (_glyphCount == 0) {
|
||||
_glyphsExtracted = YES;
|
||||
[_lock unlock];
|
||||
return;
|
||||
}
|
||||
|
||||
// Allocate memory for glyph data
|
||||
_glyphs = (CGGlyph *)malloc(sizeof(CGGlyph) * _glyphCount);
|
||||
_positions = (CGPoint *)malloc(sizeof(CGPoint) * _glyphCount);
|
||||
_advances = (CGFloat *)malloc(sizeof(CGFloat) * _glyphCount);
|
||||
|
||||
// Extract glyph values
|
||||
CTRunGetGlyphs(_ctRun, CFRangeMake(0, 0), _glyphs);
|
||||
|
||||
// Extract glyph positions
|
||||
CTRunGetPositions(_ctRun, CFRangeMake(0, 0), _positions);
|
||||
|
||||
// Extract glyph advances
|
||||
CTRunGetAdvances(_ctRun, CFRangeMake(0, 0), (CGSize *)_advances);
|
||||
|
||||
_glyphsExtracted = YES;
|
||||
|
||||
[_lock unlock];
|
||||
}
|
||||
|
||||
#pragma mark - Glyph Access
|
||||
|
||||
- (CGGlyph)glyphAtIndex:(NSUInteger)index {
|
||||
[self extractGlyphs];
|
||||
|
||||
if (index >= _glyphCount || !_glyphs) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return _glyphs[index];
|
||||
}
|
||||
|
||||
- (CGPoint)positionForGlyphAtIndex:(NSUInteger)index {
|
||||
[self extractGlyphs];
|
||||
|
||||
if (index >= _glyphCount || !_positions) {
|
||||
return CGPointZero;
|
||||
}
|
||||
|
||||
return _positions[index];
|
||||
}
|
||||
|
||||
- (CGFloat)advanceForGlyphAtIndex:(NSUInteger)index {
|
||||
[self extractGlyphs];
|
||||
|
||||
if (index >= _glyphCount || !_advances) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return _advances[index];
|
||||
}
|
||||
|
||||
- (CGRect)rectForGlyphAtIndex:(NSUInteger)index {
|
||||
[self extractGlyphs];
|
||||
|
||||
if (index >= _glyphCount) {
|
||||
return CGRectNull;
|
||||
}
|
||||
|
||||
CGPoint position = _positions[index];
|
||||
CGFloat advance = _advances[index];
|
||||
|
||||
// Calculate the rect for this glyph
|
||||
// The position is relative to the run's origin
|
||||
return CGRectMake(
|
||||
_frame.origin.x + position.x,
|
||||
_frame.origin.y,
|
||||
advance,
|
||||
_frame.size.height
|
||||
);
|
||||
}
|
||||
|
||||
#pragma mark - Path Operations
|
||||
|
||||
/**
|
||||
* Creates a CGPath containing all glyphs in this run.
|
||||
*
|
||||
* This method creates a path by transforming each glyph's outline
|
||||
* to its position within the run. This is used for:
|
||||
* - Custom drawing with effects
|
||||
* - Hit testing with complex shapes
|
||||
* - Creating outlines for selection
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Extract all glyph data
|
||||
* 2. For each glyph:
|
||||
* a. Get the glyph's path from the font
|
||||
* b. Transform to the glyph's position
|
||||
* c. Add to the combined path
|
||||
* 3. Return the combined path
|
||||
*
|
||||
* @return A CGPath containing all glyph outlines
|
||||
*/
|
||||
- (CGPathRef)newPathWithGlyphs {
|
||||
[self extractGlyphs];
|
||||
|
||||
if (!_ctRun || _glyphCount == 0) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Create a mutable path for the combined glyphs
|
||||
CGMutablePathRef combinedPath = CGPathCreateMutable();
|
||||
|
||||
// Get the font from attributes
|
||||
CTFontRef font = (__bridge CTFontRef)_attributes[(__bridge NSString *)kCTFontAttributeName];
|
||||
if (!font) {
|
||||
return combinedPath;
|
||||
}
|
||||
|
||||
// Process each glyph
|
||||
for (CFIndex i = 0; i < _glyphCount; i++) {
|
||||
CGGlyph glyph = _glyphs[i];
|
||||
CGPoint position = _positions[i];
|
||||
|
||||
// Get the path for this glyph from the font
|
||||
CGPathRef glyphPath = CTFontCreatePathForGlyph(font, glyph, NULL);
|
||||
|
||||
if (glyphPath) {
|
||||
// Create a transform to position the glyph
|
||||
// The position is relative to the run's origin
|
||||
CGAffineTransform transform = CGAffineTransformMakeTranslation(
|
||||
_frame.origin.x + position.x,
|
||||
_frame.origin.y + position.y
|
||||
);
|
||||
|
||||
// Add the transformed glyph path to the combined path
|
||||
CGPathAddPath(combinedPath, &transform, glyphPath);
|
||||
|
||||
// Release the individual glyph path
|
||||
CGPathRelease(glyphPath);
|
||||
}
|
||||
}
|
||||
|
||||
return combinedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a CGPath for a specific glyph.
|
||||
*/
|
||||
- (CGPathRef)newPathForGlyphAtIndex:(NSUInteger)index {
|
||||
[self extractGlyphs];
|
||||
|
||||
if (!_ctRun || index >= _glyphCount) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Get the font from attributes
|
||||
CTFontRef font = (__bridge CTFontRef)_attributes[(__bridge NSString *)kCTFontAttributeName];
|
||||
if (!font) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
CGGlyph glyph = _glyphs[index];
|
||||
CGPoint position = _positions[index];
|
||||
|
||||
// Get the path for this glyph
|
||||
CGPathRef glyphPath = CTFontCreatePathForGlyph(font, glyph, NULL);
|
||||
|
||||
if (!glyphPath) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Create a transform to position the glyph
|
||||
CGAffineTransform transform = CGAffineTransformMakeTranslation(
|
||||
_frame.origin.x + position.x,
|
||||
_frame.origin.y + position.y
|
||||
);
|
||||
|
||||
// Create a new path with the transform applied
|
||||
CGMutablePathRef transformedPath = CGPathCreateMutable();
|
||||
CGPathAddPath(transformedPath, &transform, glyphPath);
|
||||
|
||||
// Release the original glyph path
|
||||
CGPathRelease(glyphPath);
|
||||
|
||||
return transformedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a bounding path for all glyphs.
|
||||
*/
|
||||
- (CGPathRef)newBoundingPath {
|
||||
[self extractGlyphs];
|
||||
|
||||
if (_glyphCount == 0) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Create a simple rectangular path that bounds all glyphs
|
||||
CGMutablePathRef path = CGPathCreateMutable();
|
||||
CGPathAddRect(path, NULL, _frame);
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
#pragma mark - Image Operations
|
||||
|
||||
/**
|
||||
* Returns the image for the glyph at the specified index.
|
||||
*
|
||||
* This is used for custom rendering where glyphs are replaced with images.
|
||||
*/
|
||||
- (UIImage *)imageForGlyphAtIndex:(NSUInteger)index {
|
||||
if (!_glyphImages || index >= [_glyphImages count]) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
return _glyphImages[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the bounding rect for the glyph image.
|
||||
*/
|
||||
- (CGRect)imageRectForGlyphAtIndex:(NSUInteger)index {
|
||||
[self extractGlyphs];
|
||||
|
||||
if (index >= _glyphCount) {
|
||||
return CGRectNull;
|
||||
}
|
||||
|
||||
CGPoint position = _positions[index];
|
||||
CGFloat advance = _advances[index];
|
||||
|
||||
// The image rect is centered on the glyph position
|
||||
return CGRectMake(
|
||||
_frame.origin.x + position.x,
|
||||
_frame.origin.y,
|
||||
advance,
|
||||
_frame.size.height
|
||||
);
|
||||
}
|
||||
|
||||
#pragma mark - Hit Testing
|
||||
|
||||
/**
|
||||
* Returns the glyph index at the given point.
|
||||
*
|
||||
* This method checks if the point falls within any glyph's bounding rect.
|
||||
*
|
||||
* @param point The point to test
|
||||
* @return The glyph index, or NSNotFound
|
||||
*/
|
||||
- (NSUInteger)glyphIndexAtPoint:(CGPoint)point {
|
||||
[self extractGlyphs];
|
||||
|
||||
for (NSUInteger i = 0; i < _glyphCount; i++) {
|
||||
CGRect glyphRect = [self rectForGlyphAtIndex:i];
|
||||
|
||||
if (CGRectContainsPoint(glyphRect, point)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return NSNotFound;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the string index at the given point.
|
||||
*/
|
||||
- (NSUInteger)stringIndexAtPoint:(CGPoint)point {
|
||||
NSUInteger glyphIndex = [self glyphIndexAtPoint:point];
|
||||
|
||||
if (glyphIndex == NSNotFound) {
|
||||
return NSNotFound;
|
||||
}
|
||||
|
||||
// Convert glyph index to string index
|
||||
// This assumes a 1:1 mapping between glyphs and characters
|
||||
// For complex scripts, this may need more sophisticated mapping
|
||||
return _stringRange.location + glyphIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the rect for a given string index.
|
||||
*/
|
||||
- (CGRect)rectForStringIndex:(NSUInteger)index {
|
||||
if (index < _stringRange.location ||
|
||||
index >= _stringRange.location + _stringRange.length) {
|
||||
return CGRectNull;
|
||||
}
|
||||
|
||||
// Convert string index to glyph index
|
||||
NSUInteger glyphIndex = index - _stringRange.location;
|
||||
|
||||
return [self rectForGlyphAtIndex:glyphIndex];
|
||||
}
|
||||
|
||||
#pragma mark - Drawing
|
||||
|
||||
/**
|
||||
* Draws this run into a CGContext.
|
||||
*
|
||||
* This method draws the glyphs using CTLineDraw or by manually
|
||||
* drawing each glyph at its position.
|
||||
*
|
||||
* @param context The CGContext to draw into
|
||||
*/
|
||||
- (void)drawInContext:(CGContextRef)context {
|
||||
if (!context || !_ctRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save graphics state
|
||||
CGContextSaveGState(context);
|
||||
|
||||
// Draw the run using CoreText
|
||||
CTRunDraw(_ctRun, context, CFRangeMake(0, 0));
|
||||
|
||||
// Restore graphics state
|
||||
CGContextRestoreGState(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws this run with a specific color.
|
||||
*/
|
||||
- (void)drawInContext:(CGContextRef)context withColor:(UIColor *)color {
|
||||
if (!context || !_ctRun || !color) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save graphics state
|
||||
CGContextSaveGState(context);
|
||||
|
||||
// Set the text color
|
||||
CGContextSetFillColorWithColor(context, color.CGColor);
|
||||
|
||||
// Draw the run
|
||||
CTRunDraw(_ctRun, context, CFRangeMake(0, 0));
|
||||
|
||||
// Restore graphics state
|
||||
CGContextRestoreGState(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the attachment (if any) into the context.
|
||||
*/
|
||||
- (void)drawAttachmentInContext:(CGContextRef)context {
|
||||
if (!context || !_attachment || !_attachment.image) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save graphics state
|
||||
CGContextSaveGState(context);
|
||||
|
||||
// Draw the attachment image at the run's frame
|
||||
CGContextDrawImage(context, _frame, _attachment.image.CGImage);
|
||||
|
||||
// Restore graphics state
|
||||
CGContextRestoreGState(context);
|
||||
}
|
||||
|
||||
#pragma mark - Run Comparison
|
||||
|
||||
/**
|
||||
* Compares this run to another run for ordering.
|
||||
*/
|
||||
- (NSComparisonResult)compareToRun:(DTCoreTextGlyphRun *)otherRun {
|
||||
// Compare by string range location
|
||||
if (_stringRange.location < otherRun.stringRange.location) {
|
||||
return NSOrderedAscending;
|
||||
} else if (_stringRange.location > otherRun.stringRange.location) {
|
||||
return NSOrderedDescending;
|
||||
}
|
||||
|
||||
// If same location, compare by run index
|
||||
if (_runIndex < otherRun.runIndex) {
|
||||
return NSOrderedAscending;
|
||||
} else if (_runIndex > otherRun.runIndex) {
|
||||
return NSOrderedDescending;
|
||||
}
|
||||
|
||||
return NSOrderedSame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether this run contains the given string index.
|
||||
*/
|
||||
- (BOOL)containsStringIndex:(NSUInteger)index {
|
||||
return index >= _stringRange.location &&
|
||||
index < _stringRange.location + _stringRange.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether this run intersects with the given range.
|
||||
*/
|
||||
- (BOOL)intersectsRange:(NSRange)range {
|
||||
NSRange intersection = NSIntersectionRange(_stringRange, range);
|
||||
return intersection.length > 0;
|
||||
}
|
||||
|
||||
#pragma mark - Metrics
|
||||
|
||||
- (void)calculateMetrics {
|
||||
if (_metricsCached || !_ctRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
[_lock lock];
|
||||
|
||||
// Get typographic bounds from CTRun
|
||||
_cachedWidth = CTRunGetTypographicBounds(_ctRun, CFRangeMake(0, 0),
|
||||
&_cachedAscent, &_cachedDescent, &_cachedLeading);
|
||||
|
||||
_metricsCached = YES;
|
||||
|
||||
[_lock unlock];
|
||||
}
|
||||
|
||||
- (CGFloat)getTypographicBoundsAscent:(CGFloat *)ascent
|
||||
descent:(CGFloat *)descent
|
||||
leading:(CGFloat *)leading {
|
||||
[self calculateMetrics];
|
||||
|
||||
if (ascent) *ascent = _cachedAscent;
|
||||
if (descent) *descent = _cachedDescent;
|
||||
if (leading) *leading = _cachedLeading;
|
||||
|
||||
return _cachedWidth;
|
||||
}
|
||||
|
||||
- (CGRect)bounds {
|
||||
[self calculateMetrics];
|
||||
|
||||
return CGRectMake(0, -_cachedDescent, _cachedWidth, _cachedAscent + _cachedDescent);
|
||||
}
|
||||
|
||||
- (CGFloat)width {
|
||||
[self calculateMetrics];
|
||||
return _cachedWidth;
|
||||
}
|
||||
|
||||
- (CGFloat)height {
|
||||
[self calculateMetrics];
|
||||
return _cachedAscent + _cachedDescent;
|
||||
}
|
||||
|
||||
- (NSUInteger)numberOfGlyphs {
|
||||
[self extractGlyphs];
|
||||
return _glyphCount;
|
||||
}
|
||||
|
||||
- (NSArray<NSNumber *> *)glyphs {
|
||||
[self extractGlyphs];
|
||||
|
||||
NSMutableArray *glyphArray = [NSMutableArray arrayWithCapacity:_glyphCount];
|
||||
for (CFIndex i = 0; i < _glyphCount; i++) {
|
||||
[glyphArray addObject:@(_glyphs[i])];
|
||||
}
|
||||
|
||||
return [glyphArray copy];
|
||||
}
|
||||
|
||||
- (NSArray<NSValue *> *)glyphPositions {
|
||||
[self extractGlyphs];
|
||||
|
||||
NSMutableArray *positionArray = [NSMutableArray arrayWithCapacity:_glyphCount];
|
||||
for (CFIndex i = 0; i < _glyphCount; i++) {
|
||||
[positionArray addObject:[NSValue valueWithCGPoint:_positions[i]]];
|
||||
}
|
||||
|
||||
return [positionArray copy];
|
||||
}
|
||||
|
||||
- (NSArray<NSNumber *> *)glyphAdvances {
|
||||
[self extractGlyphs];
|
||||
|
||||
NSMutableArray *advanceArray = [NSMutableArray arrayWithCapacity:_glyphCount];
|
||||
for (CFIndex i = 0; i < _glyphCount; i++) {
|
||||
[advanceArray addObject:@(_advances[i])];
|
||||
}
|
||||
|
||||
return [advanceArray copy];
|
||||
}
|
||||
|
||||
- (BOOL)isRTL {
|
||||
return _writingDirection == kCTWritingDirectionRightToLeft;
|
||||
}
|
||||
|
||||
- (BOOL)isWhitespace {
|
||||
if (!_ctRun || _glyphCount == 0) {
|
||||
return NO;
|
||||
}
|
||||
|
||||
// Check if all characters in the range are whitespace
|
||||
// This would need access to the full attributed string
|
||||
// For now, return NO as a conservative default
|
||||
return NO;
|
||||
}
|
||||
|
||||
- (BOOL)isNewline {
|
||||
if (!_ctRun || _glyphCount == 0) {
|
||||
return NO;
|
||||
}
|
||||
|
||||
// Check if the run contains only newline characters
|
||||
// This would need access to the full attributed string
|
||||
return NO;
|
||||
}
|
||||
|
||||
@end
|
||||
239
Doc/WXRead/decompiled/DTCoreTextLayoutFrame.h
Normal file
239
Doc/WXRead/decompiled/DTCoreTextLayoutFrame.h
Normal file
@ -0,0 +1,239 @@
|
||||
//
|
||||
// DTCoreTextLayoutFrame.h
|
||||
// WeRead
|
||||
//
|
||||
// Reverse-engineered from binary analysis
|
||||
// Based on DTCoreText open-source library, customized by WeRead
|
||||
//
|
||||
// DTCoreText is an open-source library for rendering HTML with CoreText.
|
||||
// This class represents a single frame of laid-out text.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <CoreText/CoreText.h>
|
||||
|
||||
@class DTCoreTextLayoutLine;
|
||||
@class DTCoreTextGlyphRun;
|
||||
@class NSAttributedString;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/**
|
||||
* DTCoreTextLayoutFrame - Represents a single frame of laid-out text.
|
||||
*
|
||||
* This class wraps CTFrame and provides high-level access to the
|
||||
* layout of text. It contains an array of DTCoreTextLayoutLine objects
|
||||
* and provides methods for drawing, hit testing, and accessing layout information.
|
||||
*
|
||||
* Key features:
|
||||
* - Wraps CTFrame for high-level access
|
||||
* - Contains array of DTCoreTextLayoutLine objects
|
||||
* - Supports drawing to CGContext
|
||||
* - Provides hit testing for text selection
|
||||
* - Handles complex text layout with multiple columns
|
||||
*/
|
||||
@interface DTCoreTextLayoutFrame : NSObject
|
||||
|
||||
#pragma mark - Properties
|
||||
|
||||
/** The attributed string that was laid out */
|
||||
@property (nonatomic, strong, readonly) NSAttributedString *attributedString;
|
||||
|
||||
/** The range of the attributed string represented by this frame */
|
||||
@property (nonatomic, assign, readonly) NSRange range;
|
||||
|
||||
/** The bounding rectangle for this frame */
|
||||
@property (nonatomic, assign, readonly) CGRect frame;
|
||||
|
||||
/** The CTFrame reference */
|
||||
@property (nonatomic, assign, readonly) CTFrameRef ctFrame;
|
||||
|
||||
/** Array of DTCoreTextLayoutLine objects */
|
||||
@property (nonatomic, strong, readonly) NSArray<DTCoreTextLayoutLine *> *lines;
|
||||
|
||||
/** Number of lines in this frame */
|
||||
@property (nonatomic, assign, readonly) NSUInteger numberOfLines;
|
||||
|
||||
/** The rendered content height */
|
||||
@property (nonatomic, assign, readonly) CGFloat contentHeight;
|
||||
|
||||
/** The maximum Y coordinate of the content */
|
||||
@property (nonatomic, assign, readonly) CGFloat maximumY;
|
||||
|
||||
/** The minimum Y coordinate of the content */
|
||||
@property (nonatomic, assign, readonly) CGFloat minimumY;
|
||||
|
||||
/** Array of glyph runs in this frame */
|
||||
@property (nonatomic, strong, readonly) NSArray<DTCoreTextGlyphRun *> *glyphRuns;
|
||||
|
||||
/** Whether the frame needs layout */
|
||||
@property (nonatomic, assign) BOOL needsLayout;
|
||||
|
||||
/** The layout size used for this frame */
|
||||
@property (nonatomic, assign) CGSize layoutSize;
|
||||
|
||||
/** The string index where layout ended */
|
||||
@property (nonatomic, assign, readonly) NSUInteger stringIndex;
|
||||
|
||||
/** The visible string range (after truncation) */
|
||||
@property (nonatomic, assign, readonly) NSRange visibleStringRange;
|
||||
|
||||
#pragma mark - Initialization
|
||||
|
||||
/**
|
||||
* Initialize with attributed string, range, and CTFrame.
|
||||
*
|
||||
* @param attributedString The attributed string
|
||||
* @param range The string range
|
||||
* @param ctFrame The CoreText frame
|
||||
* @return Initialized layout frame
|
||||
*/
|
||||
- (instancetype)initWithAttributedString:(NSAttributedString *)attributedString
|
||||
range:(NSRange)range
|
||||
ctFrame:(CTFrameRef)ctFrame;
|
||||
|
||||
#pragma mark - Line Access
|
||||
|
||||
/**
|
||||
* Returns the array of layout lines.
|
||||
*
|
||||
* @return Array of DTCoreTextLayoutLine objects
|
||||
*/
|
||||
- (NSArray<DTCoreTextLayoutLine *> *)lines;
|
||||
|
||||
/**
|
||||
* Returns the line at the specified index.
|
||||
*
|
||||
* @param index The line index
|
||||
* @return The layout line at the index, or nil
|
||||
*/
|
||||
- (nullable DTCoreTextLayoutLine *)lineAtIndex:(NSUInteger)index;
|
||||
|
||||
/**
|
||||
* Returns the line that contains the given string index.
|
||||
*
|
||||
* @param index The string index
|
||||
* @return The layout line containing the index, or nil
|
||||
*/
|
||||
- (nullable DTCoreTextLayoutLine *)lineContainingStringIndex:(NSUInteger)index;
|
||||
|
||||
/**
|
||||
* Returns the index of the line containing the given point.
|
||||
*
|
||||
* @param point The point to test
|
||||
* @return The line index, or NSNotFound
|
||||
*/
|
||||
- (NSUInteger)lineIndexContainingPoint:(CGPoint)point;
|
||||
|
||||
#pragma mark - Drawing
|
||||
|
||||
/**
|
||||
* Draw the layout frame content into a CGContext.
|
||||
*
|
||||
* @param context The CGContext to draw into
|
||||
*/
|
||||
- (void)drawInContext:(CGContextRef)context;
|
||||
|
||||
/**
|
||||
* Draw the layout frame with a specific options dictionary.
|
||||
*
|
||||
* @param context The CGContext to draw into
|
||||
* @param options Drawing options
|
||||
*/
|
||||
- (void)drawInContext:(CGContextRef)context options:(nullable NSDictionary *)options;
|
||||
|
||||
#pragma mark - Hit Testing
|
||||
|
||||
/**
|
||||
* Returns the string index at the given point.
|
||||
*
|
||||
* @param point The point to test
|
||||
* @return The string index, or NSNotFound
|
||||
*/
|
||||
- (NSUInteger)stringIndexAtPoint:(CGPoint)point;
|
||||
|
||||
/**
|
||||
* Returns the rect for the given string index.
|
||||
*
|
||||
* @param index The string index
|
||||
* @return The rect for the character
|
||||
*/
|
||||
- (CGRect)rectForStringIndex:(NSUInteger)index;
|
||||
|
||||
/**
|
||||
* Returns the cursor position for a given string index.
|
||||
*
|
||||
* @param index The string index
|
||||
* @return The cursor rect
|
||||
*/
|
||||
- (CGRect)cursorRectForIndex:(NSUInteger)index;
|
||||
|
||||
#pragma mark - Text Geometry
|
||||
|
||||
/**
|
||||
* Returns the baseline origin for a given string index.
|
||||
*
|
||||
* @param index The string index
|
||||
* @return The baseline origin point
|
||||
*/
|
||||
- (CGPoint)baselineOriginForStringIndex:(NSUInteger)index;
|
||||
|
||||
/**
|
||||
* Returns the frame for a given string range.
|
||||
*
|
||||
* @param range The string range
|
||||
* @return The bounding rect for the range
|
||||
*/
|
||||
- (CGRect)frameForStringRange:(NSRange)range;
|
||||
|
||||
/**
|
||||
* Returns the string range for a given rect.
|
||||
*
|
||||
* @param rect The rect to test
|
||||
* @return The string range within the rect
|
||||
*/
|
||||
- (NSRange)stringRangeForRect:(CGRect)rect;
|
||||
|
||||
#pragma mark - Line Geometry
|
||||
|
||||
/**
|
||||
* Returns the line origins.
|
||||
*
|
||||
* @return Array of CGPoint values wrapped in NSValue
|
||||
*/
|
||||
- (NSArray<NSValue *> *)lineOrigins;
|
||||
|
||||
/**
|
||||
* Returns the line frames.
|
||||
*
|
||||
* @return Array of CGRect values wrapped in NSValue
|
||||
*/
|
||||
- (NSArray<NSValue *> *)lineFrames;
|
||||
|
||||
#pragma mark - Truncation
|
||||
|
||||
/**
|
||||
* Returns whether the frame is truncated.
|
||||
*/
|
||||
- (BOOL)isTruncated;
|
||||
|
||||
/**
|
||||
* Returns the truncation string range.
|
||||
*/
|
||||
- (NSRange)truncatedStringRange;
|
||||
|
||||
#pragma mark - Layout Updates
|
||||
|
||||
/**
|
||||
* Invalidates the layout, forcing recalculation on next access.
|
||||
*/
|
||||
- (void)invalidateLayout;
|
||||
|
||||
/**
|
||||
* Forces a layout pass.
|
||||
*/
|
||||
- (void)layoutIfNeeded;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
777
Doc/WXRead/decompiled/DTCoreTextLayoutFrame.m
Normal file
777
Doc/WXRead/decompiled/DTCoreTextLayoutFrame.m
Normal file
@ -0,0 +1,777 @@
|
||||
//
|
||||
// DTCoreTextLayoutFrame.m
|
||||
// WeRead
|
||||
//
|
||||
// Reverse-engineered from binary analysis
|
||||
// Based on DTCoreText open-source library, customized by WeRead
|
||||
//
|
||||
// This file contains pseudo-code reconstruction of the DTCoreTextLayoutFrame
|
||||
// class based on ivar analysis, method signatures, and the open-source DTCoreText library.
|
||||
//
|
||||
|
||||
#import "DTCoreTextLayoutFrame.h"
|
||||
#import "DTCoreTextLayoutLine.h"
|
||||
#import "DTCoreTextGlyphRun.h"
|
||||
#import <CoreText/CoreText.h>
|
||||
|
||||
#pragma mark - Private Interface
|
||||
|
||||
@interface DTCoreTextLayoutFrame () {
|
||||
// The CoreText frame object
|
||||
CTFrameRef _ctFrame;
|
||||
|
||||
// Cached layout lines
|
||||
NSArray<DTCoreTextLayoutLine *> *_cachedLines;
|
||||
|
||||
// Cached glyph runs
|
||||
NSArray<DTCoreTextGlyphRun *> *_cachedGlyphRuns;
|
||||
|
||||
// Cached line origins
|
||||
NSArray<NSValue *> *_cachedLineOrigins;
|
||||
|
||||
// Whether lines have been extracted
|
||||
BOOL _linesExtracted;
|
||||
|
||||
// Whether glyph runs have been extracted
|
||||
BOOL _glyphRunsExtracted;
|
||||
|
||||
// Internal lock
|
||||
NSLock *_lock;
|
||||
|
||||
// Cached content height
|
||||
CGFloat _cachedContentHeight;
|
||||
BOOL _contentHeightCached;
|
||||
|
||||
// Visible string range
|
||||
NSRange _visibleStringRange;
|
||||
|
||||
// Whether the frame is truncated
|
||||
BOOL _isTruncated;
|
||||
|
||||
// Truncation range
|
||||
NSRange _truncatedRange;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - DTCoreTextLayoutFrame Implementation
|
||||
|
||||
@implementation DTCoreTextLayoutFrame
|
||||
|
||||
#pragma mark - Lifecycle
|
||||
|
||||
- (instancetype)initWithAttributedString:(NSAttributedString *)attributedString
|
||||
range:(NSRange)range
|
||||
ctFrame:(CTFrameRef)ctFrame {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_attributedString = attributedString;
|
||||
_range = range;
|
||||
_lock = [[NSLock alloc] init];
|
||||
|
||||
// Store the CTFrame
|
||||
if (ctFrame) {
|
||||
_ctFrame = (CTFrameRef)CFRetain(ctFrame);
|
||||
}
|
||||
|
||||
// Initialize state
|
||||
_linesExtracted = NO;
|
||||
_glyphRunsExtracted = NO;
|
||||
_contentHeightCached = NO;
|
||||
_needsLayout = YES;
|
||||
|
||||
// Get the frame path bounds
|
||||
if (_ctFrame) {
|
||||
CGPathRef path = CTFrameGetPath(_ctFrame);
|
||||
_frame = CGPathGetPathBoundingBox(path);
|
||||
}
|
||||
|
||||
// Initialize visible range to full range
|
||||
_visibleStringRange = range;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
if (_ctFrame) {
|
||||
CFRelease(_ctFrame);
|
||||
_ctFrame = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Line Extraction
|
||||
|
||||
/**
|
||||
* Extracts layout lines from the CTFrame.
|
||||
*
|
||||
* This method iterates through the CTFrame's lines and creates
|
||||
* DTCoreTextLayoutLine wrapper objects with position information.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Get array of CTLines from CTFrame
|
||||
* 2. For each CTLine:
|
||||
* a. Get its origin from CTFrameGetLineOrigins
|
||||
* b. Get the string range from CTLineGetStringRange
|
||||
* c. Create DTCoreTextLayoutLine wrapper
|
||||
* 3. Cache the results
|
||||
*/
|
||||
- (void)extractLines {
|
||||
if (_linesExtracted || !_ctFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
[_lock lock];
|
||||
|
||||
// Get the lines from the CTFrame
|
||||
CFArrayRef ctLines = CTFrameGetLines(_ctFrame);
|
||||
if (!ctLines) {
|
||||
_cachedLines = @[];
|
||||
_linesExtracted = YES;
|
||||
[_lock unlock];
|
||||
return;
|
||||
}
|
||||
|
||||
CFIndex lineCount = CFArrayGetCount(ctLines);
|
||||
if (lineCount == 0) {
|
||||
_cachedLines = @[];
|
||||
_linesExtracted = YES;
|
||||
[_lock unlock];
|
||||
return;
|
||||
}
|
||||
|
||||
// Get line origins
|
||||
CGPoint *origins = (CGPoint *)malloc(sizeof(CGPoint) * lineCount);
|
||||
CTFrameGetLineOrigins(_ctFrame, CFRangeMake(0, 0), origins);
|
||||
|
||||
NSMutableArray<DTCoreTextLayoutLine *> *lines = [NSMutableArray arrayWithCapacity:lineCount];
|
||||
|
||||
for (CFIndex i = 0; i < lineCount; i++) {
|
||||
CTLineRef ctLine = CFArrayGetValueAtIndex(ctLines, i);
|
||||
|
||||
// Get the string range for this line
|
||||
CFRange cfRange = CTLineGetStringRange(ctLine);
|
||||
NSRange range = NSMakeRange(cfRange.location, cfRange.length);
|
||||
|
||||
// Create layout line wrapper
|
||||
DTCoreTextLayoutLine *layoutLine = [[DTCoreTextLayoutLine alloc]
|
||||
initWithCTLine:ctLine
|
||||
origin:origins[i]
|
||||
range:range
|
||||
index:i];
|
||||
|
||||
[lines addObject:layoutLine];
|
||||
}
|
||||
|
||||
free(origins);
|
||||
|
||||
_cachedLines = [lines copy];
|
||||
_cachedLineOrigins = nil; // Invalidate origin cache
|
||||
_linesExtracted = YES;
|
||||
|
||||
[_lock unlock];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the array of layout lines.
|
||||
* Triggers extraction if not already done.
|
||||
*/
|
||||
- (NSArray<DTCoreTextLayoutLine *> *)lines {
|
||||
[self extractLines];
|
||||
return _cachedLines;
|
||||
}
|
||||
|
||||
- (NSUInteger)numberOfLines {
|
||||
return [[self lines] count];
|
||||
}
|
||||
|
||||
- (DTCoreTextLayoutLine *)lineAtIndex:(NSUInteger)index {
|
||||
NSArray *lines = [self lines];
|
||||
if (index < [lines count]) {
|
||||
return lines[index];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the line that contains the given string index.
|
||||
*/
|
||||
- (DTCoreTextLayoutLine *)lineContainingStringIndex:(NSUInteger)index {
|
||||
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
|
||||
|
||||
for (DTCoreTextLayoutLine *line in allLines) {
|
||||
NSRange range = line.stringRange;
|
||||
|
||||
if (index >= range.location && index < range.location + range.length) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
return nil;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the line containing the given point.
|
||||
*/
|
||||
- (NSUInteger)lineIndexContainingPoint:(CGPoint)point {
|
||||
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
|
||||
|
||||
for (NSUInteger i = 0; i < [allLines count]; i++) {
|
||||
DTCoreTextLayoutLine *line = allLines[i];
|
||||
|
||||
// Check if point is within the line's vertical bounds
|
||||
CGFloat lineTop = line.origin.y + line.ascent;
|
||||
CGFloat lineBottom = line.origin.y - line.descent;
|
||||
|
||||
if (point.y >= lineBottom && point.y <= lineTop) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return NSNotFound;
|
||||
}
|
||||
|
||||
#pragma mark - Glyph Run Extraction
|
||||
|
||||
/**
|
||||
* Extracts glyph runs from all lines.
|
||||
*
|
||||
* This method iterates through all lines and extracts their glyph runs,
|
||||
* creating a flat array of all glyph runs in the frame.
|
||||
*/
|
||||
- (void)extractGlyphRuns {
|
||||
if (_glyphRunsExtracted) {
|
||||
return;
|
||||
}
|
||||
|
||||
[_lock lock];
|
||||
|
||||
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
|
||||
NSMutableArray<DTCoreTextGlyphRun *> *allRuns = [NSMutableArray array];
|
||||
|
||||
for (DTCoreTextLayoutLine *line in allLines) {
|
||||
NSArray<DTCoreTextGlyphRun *> *lineRuns = [line glyphRuns];
|
||||
[allRuns addObjectsFromArray:lineRuns];
|
||||
}
|
||||
|
||||
_cachedGlyphRuns = [allRuns copy];
|
||||
_glyphRunsExtracted = YES;
|
||||
|
||||
[_lock unlock];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all glyph runs in the frame.
|
||||
*/
|
||||
- (NSArray<DTCoreTextGlyphRun *> *)glyphRuns {
|
||||
[self extractGlyphRuns];
|
||||
return _cachedGlyphRuns;
|
||||
}
|
||||
|
||||
#pragma mark - Drawing
|
||||
|
||||
/**
|
||||
* Draws the layout frame content into a CGContext.
|
||||
*
|
||||
* This method draws the text content using CTFrameDraw.
|
||||
*
|
||||
* @param context The CGContext to draw into
|
||||
*/
|
||||
- (void)drawInContext:(CGContextRef)context {
|
||||
if (!context || !_ctFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
[_lock lock];
|
||||
|
||||
// Save graphics state
|
||||
CGContextSaveGState(context);
|
||||
|
||||
// CoreText uses bottom-left origin, UIKit uses top-left
|
||||
// We need to flip the coordinate system
|
||||
CGContextTranslateCTM(context, 0, _frame.size.height);
|
||||
CGContextScaleCTM(context, 1.0, -1.0);
|
||||
|
||||
// Draw the frame
|
||||
CTFrameDraw(_ctFrame, context);
|
||||
|
||||
// Restore graphics state
|
||||
CGContextRestoreGState(context);
|
||||
|
||||
[_lock unlock];
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the layout frame with options.
|
||||
*
|
||||
* @param context The CGContext to draw into
|
||||
* @param options Drawing options dictionary
|
||||
*/
|
||||
- (void)drawInContext:(CGContextRef)context options:(NSDictionary *)options {
|
||||
if (!context || !_ctFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
[_lock lock];
|
||||
|
||||
// Save graphics state
|
||||
CGContextSaveGState(context);
|
||||
|
||||
// Apply coordinate transformation
|
||||
CGContextTranslateCTM(context, 0, _frame.size.height);
|
||||
CGContextScaleCTM(context, 1.0, -1.0);
|
||||
|
||||
// Check for custom drawing options
|
||||
BOOL drawImages = [options[@"drawImages"] boolValue];
|
||||
BOOL drawLinks = [options[@"drawLinks"] boolValue];
|
||||
BOOL drawSelection = [options[@"drawSelection"] boolValue];
|
||||
|
||||
// Draw the frame
|
||||
CTFrameDraw(_ctFrame, context);
|
||||
|
||||
// Draw images if requested
|
||||
if (drawImages) {
|
||||
[self drawImagesInContext:context options:options];
|
||||
}
|
||||
|
||||
// Draw links if requested
|
||||
if (drawLinks) {
|
||||
[self drawLinksInContext:context options:options];
|
||||
}
|
||||
|
||||
// Draw selection if requested
|
||||
if (drawSelection) {
|
||||
[self drawSelectionInContext:context options:options];
|
||||
}
|
||||
|
||||
// Restore graphics state
|
||||
CGContextRestoreGState(context);
|
||||
|
||||
[_lock unlock];
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws images in the layout frame.
|
||||
*/
|
||||
- (void)drawImagesInContext:(CGContextRef)context options:(NSDictionary *)options {
|
||||
// Extract image attachments from attributed string
|
||||
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
|
||||
|
||||
for (DTCoreTextLayoutLine *line in allLines) {
|
||||
NSArray<DTCoreTextGlyphRun *> *runs = [line glyphRuns];
|
||||
|
||||
for (DTCoreTextGlyphRun *run in runs) {
|
||||
NSDictionary *attributes = run.attributes;
|
||||
|
||||
// Check for attachment
|
||||
NSTextAttachment *attachment = attributes[@"NSAttachment"];
|
||||
if (attachment && attachment.image) {
|
||||
CGRect runFrame = run.frame;
|
||||
|
||||
// Draw the image at the run's position
|
||||
CGContextDrawImage(context, runFrame, attachment.image.CGImage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws link indicators.
|
||||
*/
|
||||
- (void)drawLinksInContext:(CGContextRef)context options:(NSDictionary *)options {
|
||||
// Draw underlines for links
|
||||
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
|
||||
|
||||
for (DTCoreTextLayoutLine *line in allLines) {
|
||||
NSArray<DTCoreTextGlyphRun *> *runs = [line glyphRuns];
|
||||
|
||||
for (DTCoreTextGlyphRun *run in runs) {
|
||||
NSDictionary *attributes = run.attributes;
|
||||
NSURL *linkURL = attributes[@"NSLink"];
|
||||
|
||||
if (linkURL) {
|
||||
// Draw underline for link
|
||||
CGRect runFrame = run.frame;
|
||||
CGFloat underlineY = runFrame.origin.y;
|
||||
|
||||
CGContextSetStrokeColorWithColor(context, [UIColor blueColor].CGColor);
|
||||
CGContextSetLineWidth(context, 1.0);
|
||||
|
||||
CGContextMoveToPoint(context, runFrame.origin.x, underlineY);
|
||||
CGContextAddLineToPoint(context, CGRectGetMaxX(runFrame), underlineY);
|
||||
CGContextStrokePath(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws selection highlight.
|
||||
*/
|
||||
- (void)drawSelectionInContext:(CGContextRef)context options:(NSDictionary *)options {
|
||||
NSArray *selectedRanges = options[@"selectedRanges"];
|
||||
UIColor *selectionColor = options[@"selectionColor"] ?: [UIColor colorWithRed:0.0
|
||||
green:0.47
|
||||
blue:1.0
|
||||
alpha:0.2];
|
||||
|
||||
for (NSValue *rangeValue in selectedRanges) {
|
||||
NSRange range = [rangeValue rangeValue];
|
||||
|
||||
// Get the rects for this range
|
||||
NSArray<NSValue *> *rects = [self rectsForRange:range];
|
||||
|
||||
CGContextSetFillColorWithColor(context, selectionColor.CGColor);
|
||||
|
||||
for (NSValue *rectValue in rects) {
|
||||
CGRect rect = [rectValue CGRectValue];
|
||||
CGContextFillRect(context, rect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns rects for a string range.
|
||||
*/
|
||||
- (NSArray<NSValue *> *)rectsForRange:(NSRange)range {
|
||||
NSMutableArray<NSValue *> *rects = [NSMutableArray array];
|
||||
|
||||
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
|
||||
|
||||
for (DTCoreTextLayoutLine *line in allLines) {
|
||||
NSRange lineRange = line.stringRange;
|
||||
NSRange intersection = NSIntersectionRange(range, lineRange);
|
||||
|
||||
if (intersection.length > 0) {
|
||||
// Get the x positions for the range within this line
|
||||
CGFloat startX = CTLineGetOffsetForStringIndex(
|
||||
line.ctLine, intersection.location, NULL);
|
||||
CGFloat endX = CTLineGetOffsetForStringIndex(
|
||||
line.ctLine, intersection.location + intersection.length, NULL);
|
||||
|
||||
CGRect lineRect = CGRectMake(
|
||||
line.origin.x + startX,
|
||||
line.origin.y - line.descent,
|
||||
endX - startX,
|
||||
line.height
|
||||
);
|
||||
|
||||
[rects addObject:[NSValue valueWithRect:lineRect]];
|
||||
}
|
||||
}
|
||||
|
||||
return [rects copy];
|
||||
}
|
||||
|
||||
#pragma mark - Hit Testing
|
||||
|
||||
/**
|
||||
* Returns the string index at the given point.
|
||||
*
|
||||
* Uses CTLineGetStringIndexForPosition to find which character
|
||||
* is at the given point.
|
||||
*
|
||||
* @param point The point to test
|
||||
* @return The string index, or NSNotFound
|
||||
*/
|
||||
- (NSUInteger)stringIndexAtPoint:(CGPoint)point {
|
||||
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
|
||||
|
||||
for (DTCoreTextLayoutLine *line in allLines) {
|
||||
// Check if the point is within this line's vertical bounds
|
||||
CGFloat lineTop = line.origin.y + line.ascent;
|
||||
CGFloat lineBottom = line.origin.y - line.descent;
|
||||
|
||||
if (point.y >= lineBottom && point.y <= lineTop) {
|
||||
// Use CTLine to find the character index
|
||||
CFIndex index = CTLineGetStringIndexForPosition(line.ctLine, point);
|
||||
|
||||
if (index != kCFNotFound) {
|
||||
return (NSUInteger)index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NSNotFound;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the rect for the given string index.
|
||||
*/
|
||||
- (CGRect)rectForStringIndex:(NSUInteger)index {
|
||||
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
|
||||
|
||||
for (DTCoreTextLayoutLine *line in allLines) {
|
||||
NSRange range = line.stringRange;
|
||||
|
||||
if (index >= range.location && index < range.location + range.length) {
|
||||
// Get the x position for this character
|
||||
CGFloat x = CTLineGetOffsetForStringIndex(line.ctLine, index, NULL);
|
||||
|
||||
return CGRectMake(line.origin.x + x,
|
||||
line.origin.y - line.descent,
|
||||
1,
|
||||
line.height);
|
||||
}
|
||||
}
|
||||
|
||||
return CGRectNull;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cursor position for a given string index.
|
||||
*/
|
||||
- (CGRect)cursorRectForIndex:(NSUInteger)index {
|
||||
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
|
||||
|
||||
for (DTCoreTextLayoutLine *line in allLines) {
|
||||
NSRange range = line.stringRange;
|
||||
|
||||
if (index >= range.location && index <= range.location + range.length) {
|
||||
CGFloat x = CTLineGetOffsetForStringIndex(line.ctLine, index, NULL);
|
||||
|
||||
return CGRectMake(line.origin.x + x - 1,
|
||||
line.origin.y - line.descent,
|
||||
2,
|
||||
line.height);
|
||||
}
|
||||
}
|
||||
|
||||
return CGRectNull;
|
||||
}
|
||||
|
||||
#pragma mark - Text Geometry
|
||||
|
||||
/**
|
||||
* Returns the baseline origin for a given string index.
|
||||
*/
|
||||
- (CGPoint)baselineOriginForStringIndex:(NSUInteger)index {
|
||||
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
|
||||
|
||||
for (DTCoreTextLayoutLine *line in allLines) {
|
||||
NSRange range = line.stringRange;
|
||||
|
||||
if (index >= range.location && index < range.location + range.length) {
|
||||
CGFloat x = CTLineGetOffsetForStringIndex(line.ctLine, index, NULL);
|
||||
|
||||
return CGPointMake(line.origin.x + x, line.origin.y);
|
||||
}
|
||||
}
|
||||
|
||||
return CGPointZero;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the frame for a given string range.
|
||||
*/
|
||||
- (CGRect)frameForStringRange:(NSRange)range {
|
||||
NSArray<NSValue *> *rects = [self rectsForRange:range];
|
||||
|
||||
if ([rects count] == 0) {
|
||||
return CGRectNull;
|
||||
}
|
||||
|
||||
// Union all rects
|
||||
CGRect result = CGRectNull;
|
||||
for (NSValue *rectValue in rects) {
|
||||
result = CGRectUnion(result, [rectValue CGRectValue]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the string range for a given rect.
|
||||
*/
|
||||
- (NSRange)stringRangeForRect:(CGRect)rect {
|
||||
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
|
||||
|
||||
NSUInteger start = NSNotFound;
|
||||
NSUInteger end = NSNotFound;
|
||||
|
||||
for (DTCoreTextLayoutLine *line in allLines) {
|
||||
CGRect lineRect = CGRectMake(
|
||||
line.origin.x,
|
||||
line.origin.y - line.descent,
|
||||
line.width,
|
||||
line.height
|
||||
);
|
||||
|
||||
if (CGRectIntersectsRect(rect, lineRect)) {
|
||||
if (start == NSNotFound) {
|
||||
start = line.stringRange.location;
|
||||
}
|
||||
end = line.stringRange.location + line.stringRange.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (start != NSNotFound && end != NSNotFound) {
|
||||
return NSMakeRange(start, end - start);
|
||||
}
|
||||
|
||||
return NSMakeRange(NSNotFound, 0);
|
||||
}
|
||||
|
||||
#pragma mark - Line Geometry
|
||||
|
||||
/**
|
||||
* Returns the line origins.
|
||||
*/
|
||||
- (NSArray<NSValue *> *)lineOrigins {
|
||||
if (_cachedLineOrigins) {
|
||||
return _cachedLineOrigins;
|
||||
}
|
||||
|
||||
[_lock lock];
|
||||
|
||||
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
|
||||
NSMutableArray<NSValue *> *origins = [NSMutableArray arrayWithCapacity:[allLines count]];
|
||||
|
||||
for (DTCoreTextLayoutLine *line in allLines) {
|
||||
[origins addObject:[NSValue valueWithCGPoint:line.origin]];
|
||||
}
|
||||
|
||||
_cachedLineOrigins = [origins copy];
|
||||
|
||||
[_lock unlock];
|
||||
|
||||
return _cachedLineOrigins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the line frames.
|
||||
*/
|
||||
- (NSArray<NSValue *> *)lineFrames {
|
||||
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
|
||||
NSMutableArray<NSValue *> *frames = [NSMutableArray arrayWithCapacity:[allLines count]];
|
||||
|
||||
for (DTCoreTextLayoutLine *line in allLines) {
|
||||
CGRect lineFrame = CGRectMake(
|
||||
line.origin.x,
|
||||
line.origin.y - line.descent,
|
||||
line.width,
|
||||
line.height
|
||||
);
|
||||
[frames addObject:[NSValue valueWithRect:lineFrame]];
|
||||
}
|
||||
|
||||
return [frames copy];
|
||||
}
|
||||
|
||||
#pragma mark - Content Height
|
||||
|
||||
/**
|
||||
* Returns the rendered content height.
|
||||
*/
|
||||
- (CGFloat)contentHeight {
|
||||
[_lock lock];
|
||||
|
||||
if (_contentHeightCached) {
|
||||
[_lock unlock];
|
||||
return _cachedContentHeight;
|
||||
}
|
||||
|
||||
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
|
||||
|
||||
if ([allLines count] == 0) {
|
||||
_cachedContentHeight = 0;
|
||||
_contentHeightCached = YES;
|
||||
[_lock unlock];
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Find the lowest point of the last line
|
||||
DTCoreTextLayoutLine *lastLine = [allLines lastObject];
|
||||
CGFloat lastLineBottom = lastLine.origin.y - lastLine.descent;
|
||||
|
||||
// Content height is from top of frame to bottom of last line
|
||||
_cachedContentHeight = _frame.size.height - lastLineBottom;
|
||||
_contentHeightCached = YES;
|
||||
|
||||
[_lock unlock];
|
||||
|
||||
return _cachedContentHeight;
|
||||
}
|
||||
|
||||
- (CGFloat)maximumY {
|
||||
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
|
||||
|
||||
if ([allLines count] == 0) {
|
||||
return _frame.origin.y + _frame.size.height;
|
||||
}
|
||||
|
||||
DTCoreTextLayoutLine *firstLine = [allLines firstObject];
|
||||
return firstLine.origin.y + firstLine.ascent;
|
||||
}
|
||||
|
||||
- (CGFloat)minimumY {
|
||||
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
|
||||
|
||||
if ([allLines count] == 0) {
|
||||
return _frame.origin.y;
|
||||
}
|
||||
|
||||
DTCoreTextLayoutLine *lastLine = [allLines lastObject];
|
||||
return lastLine.origin.y - lastLine.descent;
|
||||
}
|
||||
|
||||
#pragma mark - Truncation
|
||||
|
||||
/**
|
||||
* Returns whether the frame is truncated.
|
||||
*/
|
||||
- (BOOL)isTruncated {
|
||||
// Check if the attributed string extends beyond the visible range
|
||||
NSUInteger stringEnd = _range.location + _range.length;
|
||||
NSUInteger visibleEnd = _visibleStringRange.location + _visibleStringRange.length;
|
||||
|
||||
return visibleEnd < stringEnd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the truncation string range.
|
||||
*/
|
||||
- (NSRange)truncatedStringRange {
|
||||
if (![self isTruncated]) {
|
||||
return NSMakeRange(NSNotFound, 0);
|
||||
}
|
||||
|
||||
NSUInteger visibleEnd = _visibleStringRange.location + _visibleStringRange.length;
|
||||
NSUInteger stringEnd = _range.location + _range.length;
|
||||
|
||||
return NSMakeRange(visibleEnd, stringEnd - visibleEnd);
|
||||
}
|
||||
|
||||
#pragma mark - Layout Updates
|
||||
|
||||
/**
|
||||
* Invalidates the layout, forcing recalculation on next access.
|
||||
*/
|
||||
- (void)invalidateLayout {
|
||||
[_lock lock];
|
||||
|
||||
_linesExtracted = NO;
|
||||
_glyphRunsExtracted = NO;
|
||||
_contentHeightCached = NO;
|
||||
_cachedLines = nil;
|
||||
_cachedGlyphRuns = nil;
|
||||
_cachedLineOrigins = nil;
|
||||
_needsLayout = YES;
|
||||
|
||||
[_lock unlock];
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces a layout pass.
|
||||
*/
|
||||
- (void)layoutIfNeeded {
|
||||
if (_needsLayout) {
|
||||
[self extractLines];
|
||||
_needsLayout = NO;
|
||||
}
|
||||
}
|
||||
|
||||
- (NSUInteger)stringIndex {
|
||||
return _range.location + _range.length;
|
||||
}
|
||||
|
||||
@end
|
||||
226
Doc/WXRead/decompiled/DTCoreTextLayoutLine.h
Normal file
226
Doc/WXRead/decompiled/DTCoreTextLayoutLine.h
Normal file
@ -0,0 +1,226 @@
|
||||
//
|
||||
// DTCoreTextLayoutLine.h
|
||||
// WeRead
|
||||
//
|
||||
// Reverse-engineered from binary analysis
|
||||
// Based on DTCoreText open-source library, customized by WeRead
|
||||
//
|
||||
// DTCoreText is an open-source library for rendering HTML with CoreText.
|
||||
// This class represents a single line of laid-out text.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <CoreText/CoreText.h>
|
||||
|
||||
@class DTCoreTextGlyphRun;
|
||||
@class DTCoreTextLayoutFrame;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/**
|
||||
* DTCoreTextLayoutLine - Represents a single line of laid-out text.
|
||||
*
|
||||
* This class wraps CTLine and provides high-level access to the
|
||||
* layout of a single line. It contains an array of DTCoreTextGlyphRun objects
|
||||
* and provides methods for accessing typographic metrics and hit testing.
|
||||
*
|
||||
* Key features:
|
||||
* - Wraps CTLine for high-level access
|
||||
* - Contains array of DTCoreTextGlyphRun objects
|
||||
* - Provides typographic metrics (ascent, descent, leading, width)
|
||||
* - Supports hit testing within the line
|
||||
* - Handles baseline offsets for superscript/subscript
|
||||
*/
|
||||
@interface DTCoreTextLayoutLine : NSObject
|
||||
|
||||
#pragma mark - Properties
|
||||
|
||||
/** The CTLine reference */
|
||||
@property (nonatomic, assign, readonly) CTLineRef ctLine;
|
||||
|
||||
/** The origin point of this line in the frame */
|
||||
@property (nonatomic, assign) CGPoint origin;
|
||||
|
||||
/** The range of characters in this line */
|
||||
@property (nonatomic, assign, readonly) NSRange stringRange;
|
||||
|
||||
/** The index of this line in the frame */
|
||||
@property (nonatomic, assign, readonly) NSUInteger lineIndex;
|
||||
|
||||
/** Ascent of the line */
|
||||
@property (nonatomic, assign, readonly) CGFloat ascent;
|
||||
|
||||
/** Descent of the line */
|
||||
@property (nonatomic, assign, readonly) CGFloat descent;
|
||||
|
||||
/** Leading of the line */
|
||||
@property (nonatomic, assign, readonly) CGFloat leading;
|
||||
|
||||
/** Total height of the line (ascent + descent + leading) */
|
||||
@property (nonatomic, assign, readonly) CGFloat height;
|
||||
|
||||
/** Width of the line */
|
||||
@property (nonatomic, assign, readonly) CGFloat width;
|
||||
|
||||
/** Trailing whitespace width */
|
||||
@property (nonatomic, assign, readonly) CGFloat trailingWhitespaceWidth;
|
||||
|
||||
/** Array of glyph runs in this line */
|
||||
@property (nonatomic, strong, readonly) NSArray<DTCoreTextGlyphRun *> *glyphRuns;
|
||||
|
||||
/** The baseline offset (for superscript/subscript) */
|
||||
@property (nonatomic, assign) CGFloat baselineOffset;
|
||||
|
||||
/** Whether this line is the last line in a paragraph */
|
||||
@property (nonatomic, assign) BOOL isLastLineInParagraph;
|
||||
|
||||
/** Whether this line is a line break */
|
||||
@property (nonatomic, assign) BOOL isLineBreak;
|
||||
|
||||
/** The paragraph style for this line */
|
||||
@property (nonatomic, strong, nullable) NSDictionary *paragraphStyle;
|
||||
|
||||
/** Additional metrics for the line */
|
||||
@property (nonatomic, strong, nullable) NSDictionary *metrics;
|
||||
|
||||
#pragma mark - Initialization
|
||||
|
||||
/**
|
||||
* Initialize with a CTLine and origin.
|
||||
*
|
||||
* @param ctLine The CoreText line
|
||||
* @param origin The origin point
|
||||
* @param range The string range
|
||||
* @param index The line index
|
||||
* @return Initialized layout line
|
||||
*/
|
||||
- (instancetype)initWithCTLine:(CTLineRef)ctLine
|
||||
origin:(CGPoint)origin
|
||||
range:(NSRange)range
|
||||
index:(NSUInteger)index;
|
||||
|
||||
#pragma mark - Glyph Run Access
|
||||
|
||||
/**
|
||||
* Returns the array of glyph runs.
|
||||
*
|
||||
* @return Array of DTCoreTextGlyphRun objects
|
||||
*/
|
||||
- (NSArray<DTCoreTextGlyphRun *> *)glyphRuns;
|
||||
|
||||
/**
|
||||
* Returns the glyph run at the specified index.
|
||||
*
|
||||
* @param index The glyph run index
|
||||
* @return The glyph run at the index, or nil
|
||||
*/
|
||||
- (nullable DTCoreTextGlyphRun *)glyphRunAtIndex:(NSUInteger)index;
|
||||
|
||||
/**
|
||||
* Returns the glyph run containing the given string index.
|
||||
*
|
||||
* @param index The string index
|
||||
* @return The glyph run containing the index, or nil
|
||||
*/
|
||||
- (nullable DTCoreTextGlyphRun *)glyphRunContainingStringIndex:(NSUInteger)index;
|
||||
|
||||
#pragma mark - Typographic Metrics
|
||||
|
||||
/**
|
||||
* Returns the typographic bounds of the line.
|
||||
*
|
||||
* @param ascent Output parameter for ascent
|
||||
* @param descent Output parameter for descent
|
||||
* @param leading Output parameter for leading
|
||||
* @return The width of the line
|
||||
*/
|
||||
- (CGFloat)getTypographicBoundsAscent:(CGFloat *)ascent
|
||||
descent:(CGFloat *)descent
|
||||
leading:(CGFloat *)leading;
|
||||
|
||||
/**
|
||||
* Returns the bounds of the line (bounding box).
|
||||
*/
|
||||
- (CGRect)bounds;
|
||||
|
||||
/**
|
||||
* Returns the frame of the line (position + bounds).
|
||||
*/
|
||||
- (CGRect)frame;
|
||||
|
||||
#pragma mark - Hit Testing
|
||||
|
||||
/**
|
||||
* Returns the string index at the given point within this line.
|
||||
*
|
||||
* @param point The point to test (in the line's coordinate system)
|
||||
* @return The string index, or NSNotFound
|
||||
*/
|
||||
- (NSUInteger)stringIndexAtPoint:(CGPoint)point;
|
||||
|
||||
/**
|
||||
* Returns the offset for a given string index.
|
||||
*
|
||||
* @param index The string index
|
||||
* @return The horizontal offset
|
||||
*/
|
||||
- (CGFloat)offsetForStringIndex:(NSUInteger)index;
|
||||
|
||||
/**
|
||||
* Returns the rect for a given string index.
|
||||
*
|
||||
* @param index The string index
|
||||
* @return The rect for the character
|
||||
*/
|
||||
- (CGRect)rectForStringIndex:(NSUInteger)index;
|
||||
|
||||
#pragma mark - String Operations
|
||||
|
||||
/**
|
||||
* Returns the substring represented by this line.
|
||||
*/
|
||||
- (nullable NSString *)substringFromAttributedString:(NSAttributedString *)attributedString;
|
||||
|
||||
/**
|
||||
* Returns the attributes at a given string index.
|
||||
*/
|
||||
- (nullable NSDictionary *)attributesAtIndex:(NSUInteger)index
|
||||
fromAttributedString:(NSAttributedString *)attributedString;
|
||||
|
||||
#pragma mark - Line Comparison
|
||||
|
||||
/**
|
||||
* Compares this line to another line for ordering.
|
||||
*/
|
||||
- (NSComparisonResult)compareToLine:(DTCoreTextLayoutLine *)otherLine;
|
||||
|
||||
/**
|
||||
* Returns whether this line contains the given string index.
|
||||
*/
|
||||
- (BOOL)containsStringIndex:(NSUInteger)index;
|
||||
|
||||
/**
|
||||
* Returns whether this line intersects with the given range.
|
||||
*/
|
||||
- (BOOL)intersectsRange:(NSRange)range;
|
||||
|
||||
#pragma mark - Drawing
|
||||
|
||||
/**
|
||||
* Draws this line into a CGContext.
|
||||
*
|
||||
* @param context The CGContext to draw into
|
||||
*/
|
||||
- (void)drawInContext:(CGContextRef)context;
|
||||
|
||||
/**
|
||||
* Draws this line with a specific color.
|
||||
*
|
||||
* @param context The CGContext to draw into
|
||||
* @param color The text color
|
||||
*/
|
||||
- (void)drawInContext:(CGContextRef)context withColor:(UIColor *)color;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
489
Doc/WXRead/decompiled/DTCoreTextLayoutLine.m
Normal file
489
Doc/WXRead/decompiled/DTCoreTextLayoutLine.m
Normal file
@ -0,0 +1,489 @@
|
||||
//
|
||||
// DTCoreTextLayoutLine.m
|
||||
// WeRead
|
||||
//
|
||||
// Reverse-engineered from binary analysis
|
||||
// Based on DTCoreText open-source library, customized by WeRead
|
||||
//
|
||||
// This file contains pseudo-code reconstruction of the DTCoreTextLayoutLine
|
||||
// class based on ivar analysis, method signatures, and the open-source DTCoreText library.
|
||||
//
|
||||
|
||||
#import "DTCoreTextLayoutLine.h"
|
||||
#import "DTCoreTextGlyphRun.h"
|
||||
#import <CoreText/CoreText.h>
|
||||
|
||||
#pragma mark - Private Interface
|
||||
|
||||
@interface DTCoreTextLayoutLine () {
|
||||
// Cached glyph runs
|
||||
NSArray<DTCoreTextGlyphRun *> *_cachedGlyphRuns;
|
||||
|
||||
// Whether glyph runs have been extracted
|
||||
BOOL _glyphRunsExtracted;
|
||||
|
||||
// Cached typographic metrics
|
||||
CGFloat _cachedAscent;
|
||||
CGFloat _cachedDescent;
|
||||
CGFloat _cachedLeading;
|
||||
CGFloat _cachedWidth;
|
||||
BOOL _metricsCached;
|
||||
|
||||
// Internal lock
|
||||
NSLock *_lock;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - DTCoreTextLayoutLine Implementation
|
||||
|
||||
@implementation DTCoreTextLayoutLine
|
||||
|
||||
#pragma mark - Lifecycle
|
||||
|
||||
- (instancetype)initWithCTLine:(CTLineRef)ctLine
|
||||
origin:(CGPoint)origin
|
||||
range:(NSRange)range
|
||||
index:(NSUInteger)index {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_lock = [[NSLock alloc] init];
|
||||
|
||||
// Store the CTLine with a retain
|
||||
if (ctLine) {
|
||||
_ctLine = (CTLineRef)CFRetain(ctLine);
|
||||
}
|
||||
|
||||
_origin = origin;
|
||||
_stringRange = range;
|
||||
_lineIndex = index;
|
||||
|
||||
// Initialize state
|
||||
_glyphRunsExtracted = NO;
|
||||
_metricsCached = NO;
|
||||
_baselineOffset = 0;
|
||||
_isLastLineInParagraph = NO;
|
||||
_isLineBreak = NO;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
if (_ctLine) {
|
||||
CFRelease(_ctLine);
|
||||
_ctLine = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Glyph Run Extraction
|
||||
|
||||
/**
|
||||
* Extracts glyph runs from the CTLine.
|
||||
*
|
||||
* This method iterates through the CTLine's runs and creates
|
||||
* DTCoreTextGlyphRun wrapper objects.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Get array of CTRuns from CTLine
|
||||
* 2. For each CTRun:
|
||||
* a. Get its string range
|
||||
* b. Get glyph positions and advances
|
||||
* c. Create DTCoreTextGlyphRun wrapper
|
||||
* 3. Cache the results
|
||||
*/
|
||||
- (void)extractGlyphRuns {
|
||||
if (_glyphRunsExtracted || !_ctLine) {
|
||||
return;
|
||||
}
|
||||
|
||||
[_lock lock];
|
||||
|
||||
// Get the runs from the CTLine
|
||||
CFArrayRef ctRuns = CTLineGetGlyphRuns(_ctLine);
|
||||
if (!ctRuns) {
|
||||
_cachedGlyphRuns = @[];
|
||||
_glyphRunsExtracted = YES;
|
||||
[_lock unlock];
|
||||
return;
|
||||
}
|
||||
|
||||
CFIndex runCount = CFArrayGetCount(ctRuns);
|
||||
if (runCount == 0) {
|
||||
_cachedGlyphRuns = @[];
|
||||
_glyphRunsExtracted = YES;
|
||||
[_lock unlock];
|
||||
return;
|
||||
}
|
||||
|
||||
NSMutableArray<DTCoreTextGlyphRun *> *runs = [NSMutableArray arrayWithCapacity:runCount];
|
||||
|
||||
// Calculate the baseline x offset for this line
|
||||
CGFloat lineXOffset = _origin.x;
|
||||
|
||||
for (CFIndex i = 0; i < runCount; i++) {
|
||||
CTRunRef ctRun = CFArrayGetValueAtIndex(ctRuns, i);
|
||||
|
||||
// Get the string range for this run
|
||||
CFRange cfRange = CTRunGetStringRange(ctRun);
|
||||
NSRange range = NSMakeRange(cfRange.location, cfRange.length);
|
||||
|
||||
// Get the run's typographic bounds
|
||||
CGFloat ascent, descent, leading;
|
||||
double width = CTRunGetTypographicBounds(ctRun, CFRangeMake(0, 0), &ascent, &descent, &leading);
|
||||
|
||||
// Get the positions of the glyphs
|
||||
CFIndex glyphCount = CTRunGetGlyphCount(ctRun);
|
||||
CGPoint *positions = (CGPoint *)malloc(sizeof(CGPoint) * glyphCount);
|
||||
CTRunGetPositions(ctRun, CFRangeMake(0, 0), positions);
|
||||
|
||||
// Get the attributes
|
||||
NSDictionary *attributes = (__bridge NSDictionary *)CTRunGetAttributes(ctRun);
|
||||
|
||||
// Calculate the run's frame
|
||||
CGRect runFrame;
|
||||
if (glyphCount > 0) {
|
||||
CGFloat minX = positions[0].x;
|
||||
CGFloat maxX = positions[glyphCount - 1].x + width / glyphCount; // Approximate
|
||||
|
||||
// More accurate: use the actual glyph advances
|
||||
CGGlyph *glyphs = (CGGlyph *)malloc(sizeof(CGGlyph) * glyphCount);
|
||||
CTRunGetGlyphs(ctRun, CFRangeMake(0, 0), glyphs);
|
||||
|
||||
// Calculate actual bounds using the glyph advances
|
||||
CGFloat *advances = (CGFloat *)malloc(sizeof(CGFloat) * glyphCount);
|
||||
CTFontRef font = (__bridge CTFontRef)attributes[(__bridge NSString *)kCTFontAttributeName];
|
||||
|
||||
if (font) {
|
||||
CTFontGetAdvancesForGlyphs(font, kCTFontOrientationHorizontal, glyphs, advances, glyphCount);
|
||||
}
|
||||
|
||||
// Recalculate max X using actual advances
|
||||
maxX = minX;
|
||||
for (CFIndex j = 0; j < glyphCount; j++) {
|
||||
maxX += advances[j];
|
||||
}
|
||||
|
||||
free(glyphs);
|
||||
free(advances);
|
||||
|
||||
runFrame = CGRectMake(
|
||||
lineXOffset + minX,
|
||||
_origin.y - descent,
|
||||
maxX - minX,
|
||||
ascent + descent
|
||||
);
|
||||
} else {
|
||||
runFrame = CGRectZero;
|
||||
}
|
||||
|
||||
free(positions);
|
||||
|
||||
// Create glyph run wrapper
|
||||
DTCoreTextGlyphRun *glyphRun = [[DTCoreTextGlyphRun alloc]
|
||||
initWithCTRun:ctRun
|
||||
frame:runFrame
|
||||
range:range
|
||||
attributes:attributes
|
||||
index:i];
|
||||
|
||||
[runs addObject:glyphRun];
|
||||
}
|
||||
|
||||
_cachedGlyphRuns = [runs copy];
|
||||
_glyphRunsExtracted = YES;
|
||||
|
||||
[_lock unlock];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the array of glyph runs.
|
||||
*/
|
||||
- (NSArray<DTCoreTextGlyphRun *> *)glyphRuns {
|
||||
[self extractGlyphRuns];
|
||||
return _cachedGlyphRuns;
|
||||
}
|
||||
|
||||
- (DTCoreTextGlyphRun *)glyphRunAtIndex:(NSUInteger)index {
|
||||
NSArray *runs = [self glyphRuns];
|
||||
if (index < [runs count]) {
|
||||
return runs[index];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the glyph run containing the given string index.
|
||||
*/
|
||||
- (DTCoreTextGlyphRun *)glyphRunContainingStringIndex:(NSUInteger)index {
|
||||
NSArray<DTCoreTextGlyphRun *> *allRuns = [self glyphRuns];
|
||||
|
||||
for (DTCoreTextGlyphRun *run in allRuns) {
|
||||
NSRange range = run.stringRange;
|
||||
|
||||
if (index >= range.location && index < range.location + range.length) {
|
||||
return run;
|
||||
}
|
||||
}
|
||||
|
||||
return nil;
|
||||
}
|
||||
|
||||
#pragma mark - Typographic Metrics
|
||||
|
||||
/**
|
||||
* Calculates and caches typographic metrics.
|
||||
*/
|
||||
- (void)calculateMetrics {
|
||||
if (_metricsCached || !_ctLine) {
|
||||
return;
|
||||
}
|
||||
|
||||
[_lock lock];
|
||||
|
||||
// Get typographic bounds from CTLine
|
||||
_cachedWidth = CTLineGetTypographicBounds(_ctLine, &_cachedAscent, &_cachedDescent, &_cachedLeading);
|
||||
|
||||
// Get trailing whitespace width
|
||||
_trailingWhitespaceWidth = CTLineGetTrailingWhitespaceWidth(_ctLine);
|
||||
|
||||
_metricsCached = YES;
|
||||
|
||||
[_lock unlock];
|
||||
}
|
||||
|
||||
- (CGFloat)ascent {
|
||||
[self calculateMetrics];
|
||||
return _cachedAscent;
|
||||
}
|
||||
|
||||
- (CGFloat)descent {
|
||||
[self calculateMetrics];
|
||||
return _cachedDescent;
|
||||
}
|
||||
|
||||
- (CGFloat)leading {
|
||||
[self calculateMetrics];
|
||||
return _cachedLeading;
|
||||
}
|
||||
|
||||
- (CGFloat)height {
|
||||
return [self ascent] + [self descent] + [self leading];
|
||||
}
|
||||
|
||||
- (CGFloat)width {
|
||||
[self calculateMetrics];
|
||||
return _cachedWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the typographic bounds of the line.
|
||||
*/
|
||||
- (CGFloat)getTypographicBoundsAscent:(CGFloat *)ascent
|
||||
descent:(CGFloat *)descent
|
||||
leading:(CGFloat *)leading {
|
||||
[self calculateMetrics];
|
||||
|
||||
if (ascent) *ascent = _cachedAscent;
|
||||
if (descent) *descent = _cachedDescent;
|
||||
if (leading) *leading = _cachedLeading;
|
||||
|
||||
return _cachedWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the bounds of the line (bounding box).
|
||||
*/
|
||||
- (CGRect)bounds {
|
||||
[self calculateMetrics];
|
||||
|
||||
return CGRectMake(0, -_cachedDescent, _cachedWidth, [self height]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the frame of the line (position + bounds).
|
||||
*/
|
||||
- (CGRect)frame {
|
||||
return CGRectMake(_origin.x, _origin.y - _cachedDescent, _cachedWidth, [self height]);
|
||||
}
|
||||
|
||||
#pragma mark - Hit Testing
|
||||
|
||||
/**
|
||||
* Returns the string index at the given point within this line.
|
||||
*
|
||||
* Uses CTLineGetStringIndexForPosition to find which character
|
||||
* is at the given point.
|
||||
*
|
||||
* @param point The point to test (in the line's coordinate system)
|
||||
* @return The string index, or NSNotFound
|
||||
*/
|
||||
- (NSUInteger)stringIndexAtPoint:(CGPoint)point {
|
||||
if (!_ctLine) {
|
||||
return NSNotFound;
|
||||
}
|
||||
|
||||
// Convert point to line-relative coordinates
|
||||
CGPoint linePoint = CGPointMake(point.x - _origin.x, point.y - _origin.y);
|
||||
|
||||
// Use CTLine to find the character index
|
||||
CFIndex index = CTLineGetStringIndexForPosition(_ctLine, linePoint);
|
||||
|
||||
if (index == kCFNotFound) {
|
||||
return NSNotFound;
|
||||
}
|
||||
|
||||
return (NSUInteger)index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the offset for a given string index.
|
||||
*
|
||||
* @param index The string index
|
||||
* @return The horizontal offset from the line's origin
|
||||
*/
|
||||
- (CGFloat)offsetForStringIndex:(NSUInteger)index {
|
||||
if (!_ctLine) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// CTLineGetOffsetForStringIndex returns the offset from the line's origin
|
||||
CGFloat offset = CTLineGetOffsetForStringIndex(_ctLine, index, NULL);
|
||||
|
||||
return offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the rect for a given string index.
|
||||
*/
|
||||
- (CGRect)rectForStringIndex:(NSUInteger)index {
|
||||
CGFloat xOffset = [self offsetForStringIndex:index];
|
||||
|
||||
return CGRectMake(
|
||||
_origin.x + xOffset,
|
||||
_origin.y - _cachedDescent,
|
||||
1, // Width of 1 character
|
||||
[self height]
|
||||
);
|
||||
}
|
||||
|
||||
#pragma mark - String Operations
|
||||
|
||||
/**
|
||||
* Returns the substring represented by this line.
|
||||
*/
|
||||
- (NSString *)substringFromAttributedString:(NSAttributedString *)attributedString {
|
||||
if (!attributedString || _stringRange.location >= [attributedString length]) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
// Clamp the range to the string length
|
||||
NSUInteger maxLength = [attributedString length] - _stringRange.location;
|
||||
NSUInteger length = MIN(_stringRange.length, maxLength);
|
||||
|
||||
return [[attributedString string] substringWithRange:NSMakeRange(_stringRange.location, length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the attributes at a given string index.
|
||||
*/
|
||||
- (NSDictionary *)attributesAtIndex:(NSUInteger)index
|
||||
fromAttributedString:(NSAttributedString *)attributedString {
|
||||
if (!attributedString || index >= [attributedString length]) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
return [attributedString attributesAtIndex:index effectiveRange:NULL];
|
||||
}
|
||||
|
||||
#pragma mark - Line Comparison
|
||||
|
||||
/**
|
||||
* Compares this line to another line for ordering.
|
||||
*/
|
||||
- (NSComparisonResult)compareToLine:(DTCoreTextLayoutLine *)otherLine {
|
||||
// Compare by line index first
|
||||
if (_lineIndex < otherLine.lineIndex) {
|
||||
return NSOrderedAscending;
|
||||
} else if (_lineIndex > otherLine.lineIndex) {
|
||||
return NSOrderedDescending;
|
||||
}
|
||||
|
||||
// If same index, compare by string range location
|
||||
if (_stringRange.location < otherLine.stringRange.location) {
|
||||
return NSOrderedAscending;
|
||||
} else if (_stringRange.location > otherLine.stringRange.location) {
|
||||
return NSOrderedDescending;
|
||||
}
|
||||
|
||||
return NSOrderedSame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether this line contains the given string index.
|
||||
*/
|
||||
- (BOOL)containsStringIndex:(NSUInteger)index {
|
||||
return index >= _stringRange.location &&
|
||||
index < _stringRange.location + _stringRange.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether this line intersects with the given range.
|
||||
*/
|
||||
- (BOOL)intersectsRange:(NSRange)range {
|
||||
NSRange intersection = NSIntersectionRange(_stringRange, range);
|
||||
return intersection.length > 0;
|
||||
}
|
||||
|
||||
#pragma mark - Drawing
|
||||
|
||||
/**
|
||||
* Draws this line into a CGContext.
|
||||
*
|
||||
* @param context The CGContext to draw into
|
||||
*/
|
||||
- (void)drawInContext:(CGContextRef)context {
|
||||
if (!context || !_ctLine) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save graphics state
|
||||
CGContextSaveGState(context);
|
||||
|
||||
// Move to the line's origin
|
||||
CGContextSetTextPosition(context, _origin.x, _origin.y);
|
||||
|
||||
// Draw the line
|
||||
CTLineDraw(_ctLine, context);
|
||||
|
||||
// Restore graphics state
|
||||
CGContextRestoreGState(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws this line with a specific color.
|
||||
*
|
||||
* @param context The CGContext to draw into
|
||||
* @param color The text color
|
||||
*/
|
||||
- (void)drawInContext:(CGContextRef)context withColor:(UIColor *)color {
|
||||
if (!context || !_ctLine || !color) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save graphics state
|
||||
CGContextSaveGState(context);
|
||||
|
||||
// Set the text color
|
||||
CGContextSetFillColorWithColor(context, color.CGColor);
|
||||
|
||||
// Move to the line's origin
|
||||
CGContextSetTextPosition(context, _origin.x, _origin.y);
|
||||
|
||||
// Draw the line
|
||||
CTLineDraw(_ctLine, context);
|
||||
|
||||
// Restore graphics state
|
||||
CGContextRestoreGState(context);
|
||||
}
|
||||
|
||||
@end
|
||||
125
Doc/WXRead/decompiled/DTCoreTextLayouter.h
Normal file
125
Doc/WXRead/decompiled/DTCoreTextLayouter.h
Normal file
@ -0,0 +1,125 @@
|
||||
//
|
||||
// DTCoreTextLayouter.h
|
||||
// WeRead
|
||||
//
|
||||
// Reverse-engineered from binary analysis
|
||||
// Based on DTCoreText open-source library, customized by WeRead
|
||||
//
|
||||
// DTCoreText is an open-source library for rendering HTML with CoreText.
|
||||
// WeRead has customized it for their reading engine.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <CoreText/CoreText.h>
|
||||
|
||||
@class DTCoreTextLayoutFrame;
|
||||
@class NSAttributedString;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/**
|
||||
* DTCoreTextLayouter - CoreText typesetter wrapper.
|
||||
*
|
||||
* This is the original DTCoreText layouter class, customized by WeRead.
|
||||
* It wraps CTTypesetter and CTFramesetter to provide high-level
|
||||
* text layout functionality.
|
||||
*
|
||||
* Key features:
|
||||
* - Creates CTTypesetter from attributed string
|
||||
* - Manages typesetter lifecycle
|
||||
* - Produces DTCoreTextLayoutFrame objects
|
||||
* - Supports caching of layout frames
|
||||
* - Handles string updates efficiently
|
||||
*/
|
||||
@interface DTCoreTextLayouter : NSObject
|
||||
|
||||
#pragma mark - Properties
|
||||
|
||||
/** The attributed string to be laid out */
|
||||
@property (nonatomic, strong, nullable) NSAttributedString *attributedString;
|
||||
|
||||
/** Cache for layout frames */
|
||||
@property (nonatomic, strong, readonly) NSCache *layoutFrameCache;
|
||||
|
||||
/** Array of created layout frames */
|
||||
@property (nonatomic, strong, readonly) NSMutableArray<DTCoreTextLayoutFrame *> *layoutFrames;
|
||||
|
||||
#pragma mark - Initialization
|
||||
|
||||
/**
|
||||
* Initialize with an attributed string.
|
||||
*
|
||||
* @param attributedString The text with styling attributes
|
||||
* @return Initialized layouter instance
|
||||
*/
|
||||
- (instancetype)initWithAttributedString:(NSAttributedString *)attributedString;
|
||||
|
||||
#pragma mark - Typesetter Management
|
||||
|
||||
/**
|
||||
* Returns the internal CTTypesetter.
|
||||
* Creates one if it doesn't exist.
|
||||
*
|
||||
* @return The CTTypesetter reference
|
||||
*/
|
||||
- (CTTypesetterRef)typesetter;
|
||||
|
||||
/**
|
||||
* Invalidates the current typesetter.
|
||||
* Called when the attributed string changes.
|
||||
*/
|
||||
- (void)invalidateTypesetter;
|
||||
|
||||
#pragma mark - Layout Frame Creation
|
||||
|
||||
/**
|
||||
* Create a layout frame for a given string range within the specified rect.
|
||||
*
|
||||
* @param range The range of the attributed string to lay out
|
||||
* @param frame The bounding rectangle for the layout
|
||||
* @return A new DTCoreTextLayoutFrame
|
||||
*/
|
||||
- (DTCoreTextLayoutFrame *)layoutFrameWithRange:(NSRange)range
|
||||
frame:(CGRect)frame;
|
||||
|
||||
/**
|
||||
* Suggest a line break for the given range and width.
|
||||
*
|
||||
* @param startIndex The starting index
|
||||
* @param width The available width
|
||||
* @return The suggested line break index
|
||||
*/
|
||||
- (NSUInteger)suggestLineBreakStartIndex:(NSUInteger)startIndex
|
||||
width:(CGFloat)width;
|
||||
|
||||
/**
|
||||
* Suggest a fitting string length for pagination.
|
||||
*
|
||||
* @param startIndex The starting index
|
||||
* @param constraints Size constraints
|
||||
* @return The suggested fitting length
|
||||
*/
|
||||
- (NSUInteger)stringIndexFittingLengthForWidth:(CGFloat)width
|
||||
startIndex:(NSUInteger)startIndex;
|
||||
|
||||
#pragma mark - Frame Caching
|
||||
|
||||
/**
|
||||
* Returns a cached layout frame for the given key.
|
||||
*/
|
||||
- (nullable DTCoreTextLayoutFrame *)cachedLayoutFrameForKey:(NSString *)key;
|
||||
|
||||
/**
|
||||
* Caches a layout frame with the given key.
|
||||
*/
|
||||
- (void)cacheLayoutFrame:(DTCoreTextLayoutFrame *)frame
|
||||
forKey:(NSString *)key;
|
||||
|
||||
/**
|
||||
* Clears the layout frame cache.
|
||||
*/
|
||||
- (void)clearLayoutFrameCache;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
280
Doc/WXRead/decompiled/DTCoreTextLayouter.m
Normal file
280
Doc/WXRead/decompiled/DTCoreTextLayouter.m
Normal file
@ -0,0 +1,280 @@
|
||||
//
|
||||
// DTCoreTextLayouter.m
|
||||
// WeRead
|
||||
//
|
||||
// Reverse-engineered from binary analysis
|
||||
// Based on DTCoreText open-source library, customized by WeRead
|
||||
//
|
||||
// This file contains pseudo-code reconstruction of the DTCoreTextLayouter
|
||||
// class based on ivar analysis, method signatures, and the open-source DTCoreText library.
|
||||
//
|
||||
|
||||
#import "DTCoreTextLayouter.h"
|
||||
#import "DTCoreTextLayoutFrame.h"
|
||||
#import <CoreText/CoreText.h>
|
||||
|
||||
#pragma mark - Private Interface
|
||||
|
||||
@interface DTCoreTextLayouter () {
|
||||
// CoreText typesetter - the core text processing engine
|
||||
CTTypesetterRef _typesetter;
|
||||
|
||||
// Track whether the typesetter is valid
|
||||
BOOL _typesetterValid;
|
||||
|
||||
// Internal lock for thread safety
|
||||
NSLock *_lock;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - DTCoreTextLayouter Implementation
|
||||
|
||||
@implementation DTCoreTextLayouter
|
||||
|
||||
#pragma mark - Lifecycle
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_layoutFrameCache = [[NSCache alloc] init];
|
||||
_layoutFrameCache.countLimit = 20; // Cache up to 20 layout frames
|
||||
_layoutFrames = [NSMutableArray array];
|
||||
_typesetterValid = NO;
|
||||
_lock = [[NSLock alloc] init];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (instancetype)initWithAttributedString:(NSAttributedString *)attributedString {
|
||||
self = [self init];
|
||||
if (self) {
|
||||
_attributedString = attributedString;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
if (_typesetter) {
|
||||
CFRelease(_typesetter);
|
||||
_typesetter = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Property Accessors
|
||||
|
||||
- (void)setAttributedString:(NSAttributedString *)attributedString {
|
||||
[_lock lock];
|
||||
|
||||
_attributedString = attributedString;
|
||||
|
||||
// Invalidate the typesetter when the string changes
|
||||
_typesetterValid = NO;
|
||||
|
||||
// Clear the layout frames array
|
||||
[_layoutFrames removeAllObjects];
|
||||
|
||||
[_lock unlock];
|
||||
}
|
||||
|
||||
#pragma mark - Typesetter Management
|
||||
|
||||
/**
|
||||
* Returns the internal CTTypesetter, creating it if necessary.
|
||||
*
|
||||
* CTTypesetter is the low-level CoreText object that performs glyph layout.
|
||||
* It analyzes the attributed string and prepares it for line-by-line layout.
|
||||
*
|
||||
* @return The CTTypesetter reference
|
||||
*/
|
||||
- (CTTypesetterRef)typesetter {
|
||||
[_lock lock];
|
||||
|
||||
if (!_typesetterValid || !_typesetter) {
|
||||
// Release old typesetter if any
|
||||
if (_typesetter) {
|
||||
CFRelease(_typesetter);
|
||||
_typesetter = NULL;
|
||||
}
|
||||
|
||||
if (_attributedString) {
|
||||
// Create new typesetter from the attributed string
|
||||
_typesetter = CTTypesetterCreateWithAttributedString(
|
||||
(__bridge CFAttributedStringRef)_attributedString
|
||||
);
|
||||
_typesetterValid = YES;
|
||||
}
|
||||
}
|
||||
|
||||
CTTypesetterRef result = _typesetter;
|
||||
|
||||
[_lock unlock];
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates the current typesetter.
|
||||
* Forces recreation on next access.
|
||||
*/
|
||||
- (void)invalidateTypesetter {
|
||||
[_lock lock];
|
||||
_typesetterValid = NO;
|
||||
[_lock unlock];
|
||||
}
|
||||
|
||||
#pragma mark - Layout Frame Creation
|
||||
|
||||
/**
|
||||
* Creates a DTCoreTextLayoutFrame for a given string range.
|
||||
*
|
||||
* This method creates a layout frame by:
|
||||
* 1. Getting the typesetter
|
||||
* 2. Creating a CTFrame for the range within the given rect
|
||||
* 3. Wrapping it in a DTCoreTextLayoutFrame
|
||||
*
|
||||
* @param range Range of the attributed string to lay out
|
||||
* @param frame Bounding rectangle for the layout
|
||||
* @return A new DTCoreTextLayoutFrame
|
||||
*/
|
||||
- (DTCoreTextLayoutFrame *)layoutFrameWithRange:(NSRange)range
|
||||
frame:(CGRect)frame {
|
||||
CTTypesetterRef typesetter = [self typesetter];
|
||||
|
||||
if (!typesetter || range.length == 0) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
[_lock lock];
|
||||
|
||||
// Create a CGPath for the frame bounds
|
||||
CGMutablePathRef path = CGPathCreateMutable();
|
||||
CGPathAddRect(path, NULL, frame);
|
||||
|
||||
// Create CTFrame from the typesetter
|
||||
CTFrameRef ctFrame = CTTypesetterCreateFrame(
|
||||
typesetter,
|
||||
CFRangeMake(range.location, range.length),
|
||||
path,
|
||||
NULL
|
||||
);
|
||||
|
||||
CGPathRelease(path);
|
||||
|
||||
if (!ctFrame) {
|
||||
[_lock unlock];
|
||||
return nil;
|
||||
}
|
||||
|
||||
// Create layout frame wrapper
|
||||
DTCoreTextLayoutFrame *layoutFrame = [[DTCoreTextLayoutFrame alloc]
|
||||
initWithAttributedString:_attributedString
|
||||
range:range
|
||||
ctFrame:ctFrame];
|
||||
|
||||
CFRelease(ctFrame);
|
||||
|
||||
[_layoutFrames addObject:layoutFrame];
|
||||
|
||||
[_lock unlock];
|
||||
|
||||
return layoutFrame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggests a line break for the given start index and width.
|
||||
*
|
||||
* Uses CTTypesetterSuggestLineBreak to determine how many characters
|
||||
* fit within the given width.
|
||||
*
|
||||
* @param startIndex The starting index in the string
|
||||
* @param width The available width
|
||||
* @return The suggested line break index
|
||||
*/
|
||||
- (NSUInteger)suggestLineBreakStartIndex:(NSUInteger)startIndex
|
||||
width:(CGFloat)width {
|
||||
CTTypesetterRef typesetter = [self typesetter];
|
||||
|
||||
if (!typesetter) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// CTTypesetterSuggestLineBreak returns the number of characters
|
||||
// that fit in the given width starting from startIndex
|
||||
CFIndex breakIndex = CTTypesetterSuggestLineBreak(
|
||||
typesetter,
|
||||
startIndex,
|
||||
width
|
||||
);
|
||||
|
||||
return (NSUInteger)breakIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggests a fitting string length for pagination.
|
||||
*
|
||||
* This method simulates pagination to determine how much text
|
||||
* fits within the given constraints.
|
||||
*
|
||||
* @param width The available width
|
||||
* @param startIndex The starting index
|
||||
* @return The suggested fitting length
|
||||
*/
|
||||
- (NSUInteger)stringIndexFittingLengthForWidth:(CGFloat)width
|
||||
startIndex:(NSUInteger)startIndex {
|
||||
CTTypesetterRef typesetter = [self typesetter];
|
||||
|
||||
if (!_attributedString || !typesetter) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
NSUInteger totalLength = [_attributedString length];
|
||||
NSUInteger currentIndex = startIndex;
|
||||
NSUInteger totalFitted = 0;
|
||||
|
||||
// Simulate line-by-line layout to find total fitting length
|
||||
while (currentIndex < totalLength) {
|
||||
CFIndex lineBreak = CTTypesetterSuggestLineBreak(
|
||||
typesetter,
|
||||
currentIndex,
|
||||
width
|
||||
);
|
||||
|
||||
if (lineBreak <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
totalFitted += lineBreak;
|
||||
currentIndex += lineBreak;
|
||||
}
|
||||
|
||||
return totalFitted;
|
||||
}
|
||||
|
||||
#pragma mark - Frame Caching
|
||||
|
||||
/**
|
||||
* Returns a cached layout frame for the given key.
|
||||
*/
|
||||
- (DTCoreTextLayoutFrame *)cachedLayoutFrameForKey:(NSString *)key {
|
||||
return [_layoutFrameCache objectForKey:key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Caches a layout frame with the given key.
|
||||
*/
|
||||
- (void)cacheLayoutFrame:(DTCoreTextLayoutFrame *)frame
|
||||
forKey:(NSString *)key {
|
||||
if (frame && key) {
|
||||
[_layoutFrameCache setObject:frame forKey:key];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the layout frame cache.
|
||||
*/
|
||||
- (void)clearLayoutFrameCache {
|
||||
[_layoutFrameCache removeAllObjects];
|
||||
}
|
||||
|
||||
@end
|
||||
106
Doc/WXRead/decompiled/DTHTMLAttributedStringBuilder.h
Normal file
106
Doc/WXRead/decompiled/DTHTMLAttributedStringBuilder.h
Normal file
@ -0,0 +1,106 @@
|
||||
//
|
||||
// DTHTMLAttributedStringBuilder.h
|
||||
// DTCoreText (WeRead custom fork)
|
||||
//
|
||||
// Reverse-engineered from WeChat Reading (微信读书) binary.
|
||||
// This class converts HTML DOM into NSAttributedString via SAX-style parsing.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
@class DTHTMLParserDelegate;
|
||||
@class DTHTMLElement;
|
||||
@class DTCSSStylesheet;
|
||||
@class DTCoreTextFontDescriptor;
|
||||
@class DTCoreTextParagraphStyle;
|
||||
@class WRBook;
|
||||
@class WRChapter;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WeRead custom NSAttributedString attribute keys
|
||||
// ---------------------------------------------------------------------------
|
||||
// These keys are used in the resulting attributed string to carry page layout
|
||||
// and presentation metadata that goes beyond standard DTCoreText attributes.
|
||||
|
||||
extern NSString *const DTPageBackgroundColorAttribute;
|
||||
extern NSString *const DTPageBackgroundImageAttribute;
|
||||
extern NSString *const DTPageBackgroundImagePathAttribute;
|
||||
extern NSString *const DTPageBreakAfterAttribute;
|
||||
extern NSString *const DTPageBreakBeforeAttribute;
|
||||
extern NSString *const DTPageBreakInsideAvoidAttribute;
|
||||
extern NSString *const DTPageRelateAttribute;
|
||||
extern NSString *const DTPageSize;
|
||||
extern NSString *const DTPageFlippingStyle;
|
||||
extern NSString *const DTHTMLVerticalCenterAttribute;
|
||||
extern NSString *const DTHTMLTranslateTagAttribute;
|
||||
extern NSString *const DTHTMLTranslateNoStyleAttribute;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DTHTMLAttributedStringBuilder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@interface DTHTMLAttributedStringBuilder : NSObject
|
||||
|
||||
// --- Initializers ---
|
||||
|
||||
/**
|
||||
Designated initializer.
|
||||
@param htmlData Raw HTML data (UTF-8 encoded).
|
||||
@param options Dictionary of build options (base URL, CSS stylesheet, etc.).
|
||||
*/
|
||||
- (instancetype)initWithHTML:(NSData *)htmlData
|
||||
options:(NSDictionary *)options;
|
||||
|
||||
/**
|
||||
Convenience initializer that also supplies a CSS stylesheet.
|
||||
*/
|
||||
- (instancetype)initWithHTML:(NSData *)htmlData
|
||||
cssStyleSheet:(DTCSSStylesheet *)styleSheet
|
||||
options:(NSDictionary *)options;
|
||||
|
||||
// --- Building ---
|
||||
|
||||
/**
|
||||
Triggers the full HTML parse → DOM tree → NSAttributedString pipeline.
|
||||
Must be called before -generatedAttributedString.
|
||||
*/
|
||||
- (void)buildString;
|
||||
|
||||
/**
|
||||
Returns the attributed string produced by the most recent -buildString call.
|
||||
*/
|
||||
- (NSAttributedString *)generatedAttributedString;
|
||||
|
||||
// --- DTHTMLParser delegate (SAX callbacks) ---
|
||||
|
||||
- (void)parser:(id)parser
|
||||
didStartElement:(NSString *)elementName
|
||||
attributes:(NSDictionary *)attributeDict
|
||||
position:(NSUInteger)position;
|
||||
|
||||
- (void)parser:(id)parser
|
||||
foundCDATA:(NSData *)CDATABlock;
|
||||
|
||||
- (void)parser:(id)parser
|
||||
foundCharacters:(NSString *)string
|
||||
position:(NSUInteger)position;
|
||||
|
||||
- (void)parserDidEndDocument:(id)parser;
|
||||
|
||||
// --- Tag handler registration (internal) ---
|
||||
|
||||
- (void)_registerTagStartHandlers;
|
||||
- (void)_registerTagEndHandlers;
|
||||
|
||||
// --- Properties ---
|
||||
|
||||
@property (nonatomic, strong, readonly) NSData *htmlData;
|
||||
@property (nonatomic, strong, readonly) DTCSSStylesheet *cssStyleSheet;
|
||||
@property (nonatomic, strong, readonly) NSDictionary *options;
|
||||
@property (nonatomic, strong, readonly) NSAttributedString *generatedAttributedString;
|
||||
|
||||
// WeRead-specific: the book / chapter context used during rendering.
|
||||
@property (nonatomic, strong) WRBook *book;
|
||||
@property (nonatomic, strong) WRChapter *chapter;
|
||||
|
||||
@end
|
||||
643
Doc/WXRead/decompiled/DTHTMLAttributedStringBuilder.m
Normal file
643
Doc/WXRead/decompiled/DTHTMLAttributedStringBuilder.m
Normal file
@ -0,0 +1,643 @@
|
||||
//
|
||||
// DTHTMLAttributedStringBuilder.m
|
||||
// DTCoreText (WeRead custom fork)
|
||||
//
|
||||
// Reverse-engineered pseudo-implementation from WeChat Reading binary.
|
||||
// This file reconstructs the full HTML → NSAttributedString pipeline.
|
||||
//
|
||||
// Pipeline overview:
|
||||
// 1. Parse raw HTML via DTHTMLParser (SAX-style).
|
||||
// 2. Each SAX event (startElement / endElement / foundCharacters / CDATA)
|
||||
// builds or mutates DTHTMLElement nodes in a DOM tree.
|
||||
// 3. After parsing completes, -_buildString walks the DOM tree and calls
|
||||
// -[DTHTMLElement attributedString] recursively.
|
||||
// 4. The resulting NSAttributedString is stored in _generatedAttributedString.
|
||||
//
|
||||
|
||||
#import "DTHTMLAttributedStringBuilder.h"
|
||||
#import "DTHTMLParserDelegate.h"
|
||||
#import "DTHTMLElement.h"
|
||||
#import "DTCSSStylesheet.h"
|
||||
#import "DTCoreTextFontDescriptor.h"
|
||||
#import "DTCoreTextParagraphStyle.h"
|
||||
#import "DTHTMLParser.h"
|
||||
#import "WRBook.h"
|
||||
#import "WRChapter.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WeRead custom attribute key definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
NSString *const DTPageBackgroundColorAttribute = @"DTPageBackgroundColorAttribute";
|
||||
NSString *const DTPageBackgroundImageAttribute = @"DTPageBackgroundImageAttribute";
|
||||
NSString *const DTPageBackgroundImagePathAttribute = @"DTPageBackgroundImagePathAttribute";
|
||||
NSString *const DTPageBreakAfterAttribute = @"DTPageBreakAfterAttribute";
|
||||
NSString *const DTPageBreakBeforeAttribute = @"DTPageBreakBeforeAttribute";
|
||||
NSString *const DTPageBreakInsideAvoidAttribute = @"DTPageBreakInsideAvoidAttribute";
|
||||
NSString *const DTPageRelateAttribute = @"DTPageRelateAttribute";
|
||||
NSString *const DTPageSize = @"DTPageSize";
|
||||
NSString *const DTPageFlippingStyle = @"DTPageFlippingStyle";
|
||||
NSString *const DTHTMLVerticalCenterAttribute = @"DTHTMLVerticalCenterAttribute";
|
||||
NSString *const DTHTMLTranslateTagAttribute = @"DTHTMLTranslateTagAttribute";
|
||||
NSString *const DTHTMLTranslateNoStyleAttribute = @"DTHTMLTranslateNoStyleAttribute";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
#pragma mark - Private interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@interface DTHTMLAttributedStringBuilder ()
|
||||
{
|
||||
// ---- ivar: the SAX parser delegate that accumulates the DOM tree ----
|
||||
DTHTMLParserDelegate *_parserDelegate;
|
||||
|
||||
// ---- Cached input ----
|
||||
NSData *_htmlData;
|
||||
DTCSSStylesheet *_cssStyleSheet;
|
||||
NSDictionary *_options;
|
||||
|
||||
// ---- Result ----
|
||||
NSAttributedString *_generatedAttributedString;
|
||||
|
||||
// ---- WeRead book/chapter context ----
|
||||
WRBook *_book;
|
||||
WRChapter *_chapter;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
#pragma mark - DTHTMLParserDelegate (internal helper)
|
||||
// ---------------------------------------------------------------------------
|
||||
// In the actual binary this is a separate class whose ivars include all the
|
||||
// mutable state needed during parsing. We define it here to show the fields
|
||||
// that DTHTMLAttributedStringBuilder delegates to.
|
||||
|
||||
@interface DTHTMLParserDelegate : NSObject
|
||||
{
|
||||
// Current tag handlers (block-based dispatch tables keyed on tag name)
|
||||
NSData *_tagStartHandlers; // actually a block map
|
||||
NSDictionary *_tagEndHandlers;
|
||||
|
||||
// CSS stylesheet applied during parsing
|
||||
DTCSSStylesheet *_cssStyleSheet;
|
||||
|
||||
// Base URL for resolving relative links / images
|
||||
NSURL *_baseURL;
|
||||
|
||||
// Default font / paragraph descriptors used when no CSS overrides exist
|
||||
DTCoreTextFontDescriptor *_defaultFontDescriptor;
|
||||
DTCoreTextParagraphStyle *_defaultParagraphStyle;
|
||||
|
||||
// The root of the DOM tree being built
|
||||
DTHTMLElement *_rootElement;
|
||||
|
||||
// Stack of open elements (for nesting / parent resolution)
|
||||
NSMutableDictionary *_elementStack; // index → DTHTMLElement
|
||||
|
||||
// Accumulated text for the current text run
|
||||
NSMutableDictionary *_currentTextBuffer;
|
||||
|
||||
// "Current" element pointers (set during SAX traversal)
|
||||
DTHTMLElement *_currentElement;
|
||||
DTHTMLElement *_parentElement;
|
||||
DTHTMLElement *_lastInlineElement;
|
||||
DTHTMLElement *_lastBlockElement;
|
||||
|
||||
// WeRead-specific rendering context
|
||||
WRBook *_book;
|
||||
WRChapter *_chapter;
|
||||
|
||||
// Final output accumulator
|
||||
NSMutableAttributedString *_outputString;
|
||||
|
||||
// Image / view references (for lazy image loading)
|
||||
UIImageView *_currentImageView;
|
||||
UIView *_imageContainerView;
|
||||
UIView *_currentView;
|
||||
NSString *_currentImageSrc;
|
||||
UIView *_parentView;
|
||||
UIView *_rootView;
|
||||
|
||||
// Collected tag list for post-processing
|
||||
NSArray *_tagOrder;
|
||||
}
|
||||
|
||||
// Methods used during SAX parsing
|
||||
- (void)_registerTagStartHandlers;
|
||||
- (void)_registerTagEndHandlers;
|
||||
|
||||
@end
|
||||
|
||||
@implementation DTHTMLParserDelegate
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark Handler registration
|
||||
// --------------------------------------------------
|
||||
|
||||
/**
|
||||
Registers block handlers for HTML start tags.
|
||||
Each handler receives the element name, attributes dict, and position,
|
||||
then creates or configures the appropriate DTHTMLElement subclass.
|
||||
|
||||
WeRead registers custom handlers for their proprietary CSS attributes:
|
||||
- wr-vertical-center-style → DTHTMLVerticalCenterAttribute
|
||||
- weread-page-relate → DTPageRelateAttribute
|
||||
- avoidPageBreakInside → DTPageBreakInsideAvoidAttribute
|
||||
- DTPageBreakAfter / DTPageBreakBefore
|
||||
- DTPageBackgroundColor / DTPageBackgroundImage
|
||||
*/
|
||||
- (void)_registerTagStartHandlers
|
||||
{
|
||||
// Pseudo-code: build a dictionary mapping tag names to handler blocks.
|
||||
//
|
||||
// _tagStartHandlers = @{
|
||||
// @"p" : ^(NSString *tag, NSDictionary *attrs, NSUInteger pos) { ... },
|
||||
// @"div" : ^(NSString *tag, NSDictionary *attrs, NSUInteger pos) { ... },
|
||||
// @"img" : ^(NSString *tag, NSDictionary *attrs, NSUInteger pos) { ... },
|
||||
// @"br" : ^(NSString *tag, NSDictionary *attrs, NSUInteger pos) { ... },
|
||||
// @"a" : ^(NSString *tag, NSDictionary *attrs, NSUInteger pos) { ... },
|
||||
// @"h1" : ^(NSString *tag, NSDictionary *attrs, NSUInteger pos) { ... },
|
||||
// ...
|
||||
// // WeRead custom tags / attributes handled here
|
||||
// };
|
||||
//
|
||||
// Each handler typically:
|
||||
// 1. Creates a new DTHTMLElement.
|
||||
// 2. Sets its tagName, attributes, parent.
|
||||
// 3. Calls -[DTHTMLElement applyStyleDictionary:isLatinLanguageBook:].
|
||||
// 4. Pushes the element onto _elementStack.
|
||||
// 5. Updates _currentElement.
|
||||
}
|
||||
|
||||
/**
|
||||
Registers block handlers for HTML end tags.
|
||||
Responsible for:
|
||||
- Popping the element from the stack.
|
||||
- Finalizing inline text runs.
|
||||
- Applying paragraph-level styles.
|
||||
- Handling DTPageBreakAfter / Before attributes.
|
||||
*/
|
||||
- (void)_registerTagEndHandlers
|
||||
{
|
||||
// Pseudo-code: analogous to start handlers but for closing tags.
|
||||
//
|
||||
// _tagEndHandlers = @{
|
||||
// @"p" : ^(NSString *tag) { ... },
|
||||
// @"div" : ^(NSString *tag) { ... },
|
||||
// ...
|
||||
// };
|
||||
//
|
||||
// Each handler typically:
|
||||
// 1. Pops the current element from the stack.
|
||||
// 2. Calls -[DTHTMLElement interpretAttributes] to finalize.
|
||||
// 3. If element has DTPageBreakAfter, inserts page break marker.
|
||||
// 4. Sets _currentElement back to the parent.
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
#pragma mark - DTHTMLAttributedStringBuilder implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@implementation DTHTMLAttributedStringBuilder
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark Initialization
|
||||
// --------------------------------------------------
|
||||
|
||||
- (instancetype)initWithHTML:(NSData *)htmlData
|
||||
options:(NSDictionary *)options
|
||||
{
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_htmlData = [htmlData copy];
|
||||
_options = [options copy] ?: @{};
|
||||
|
||||
// Create the parser delegate and configure it.
|
||||
_parserDelegate = [[DTHTMLParserDelegate alloc] init];
|
||||
|
||||
// If a CSS stylesheet is provided in options, install it.
|
||||
DTCSSStylesheet *sheet = _options[@"DTDefaultCSSStyleSheet"];
|
||||
if (sheet) {
|
||||
_cssStyleSheet = sheet;
|
||||
_parserDelegate->_cssStyleSheet = sheet;
|
||||
}
|
||||
|
||||
// Base URL for resolving <img src>, <a href>, etc.
|
||||
NSURL *baseURL = _options[@"DTBaseURL"];
|
||||
if (baseURL) {
|
||||
_parserDelegate->_baseURL = baseURL;
|
||||
}
|
||||
|
||||
// Default font descriptor (system font fallback).
|
||||
DTCoreTextFontDescriptor *fontDesc = _options[@"DTDefaultFontDescriptor"];
|
||||
if (fontDesc) {
|
||||
_parserDelegate->_defaultFontDescriptor = fontDesc;
|
||||
}
|
||||
|
||||
// Default paragraph style.
|
||||
DTCoreTextParagraphStyle *paraStyle = _options[@"DTDefaultParagraphStyle"];
|
||||
if (paraStyle) {
|
||||
_parserDelegate->_defaultParagraphStyle = paraStyle;
|
||||
}
|
||||
|
||||
// Register the tag handler dispatch tables.
|
||||
[_parserDelegate _registerTagStartHandlers];
|
||||
[_parserDelegate _registerTagEndHandlers];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (instancetype)initWithHTML:(NSData *)htmlData
|
||||
cssStyleSheet:(DTCSSStylesheet *)styleSheet
|
||||
options:(NSDictionary *)options
|
||||
{
|
||||
// Merge stylesheet into options so the designated init picks it up.
|
||||
NSMutableDictionary *merged = [options mutableCopy] ?: [NSMutableDictionary dictionary];
|
||||
if (styleSheet) {
|
||||
merged[@"DTDefaultCSSStyleSheet"] = styleSheet;
|
||||
}
|
||||
return [self initWithHTML:htmlData options:merged];
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark Build pipeline
|
||||
// --------------------------------------------------
|
||||
|
||||
/**
|
||||
Main entry point. Kicks off the SAX parser; when parsing completes the
|
||||
delegate has built a DOM tree of DTHTMLElement nodes. Then -_buildString
|
||||
converts that tree into the final NSAttributedString.
|
||||
*/
|
||||
- (void)buildString
|
||||
{
|
||||
// Step 1: Create a SAX parser and point it at our delegate.
|
||||
DTHTMLParser *parser = [[DTHTMLParser alloc] initWithData:_htmlData];
|
||||
parser.delegate = _parserDelegate;
|
||||
|
||||
// Step 2: Parse. This triggers the delegate callbacks below.
|
||||
[parser parse];
|
||||
|
||||
// Step 3: Convert the DOM tree to an attributed string.
|
||||
[self _buildString];
|
||||
}
|
||||
|
||||
/**
|
||||
Walks the DOM tree rooted at _parserDelegate->_rootElement and recursively
|
||||
calls -[DTHTMLElement attributedString] to produce the final output.
|
||||
*/
|
||||
- (void)_buildString
|
||||
{
|
||||
DTHTMLElement *root = _parserDelegate->_rootElement;
|
||||
if (!root) {
|
||||
_generatedAttributedString = [[NSAttributedString alloc] initWithString:@""];
|
||||
return;
|
||||
}
|
||||
|
||||
// Recursively convert the DOM tree.
|
||||
// DTHTMLElement's -attributedString walks children and concatenates.
|
||||
NSAttributedString *result = [root attributedString];
|
||||
|
||||
// If the result is nil (empty document), produce an empty string.
|
||||
if (!result) {
|
||||
result = [[NSAttributedString alloc] initWithString:@""];
|
||||
}
|
||||
|
||||
_generatedAttributedString = result;
|
||||
|
||||
// Store into the delegate's output for external access if needed.
|
||||
_parserDelegate->_outputString = [result mutableCopy];
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark DTHTMLParser delegate callbacks
|
||||
// --------------------------------------------------
|
||||
|
||||
/**
|
||||
Called by the SAX parser when an opening HTML tag is encountered.
|
||||
|
||||
@param parser The DTHTMLParser instance.
|
||||
@param elementName Tag name (e.g. "p", "div", "img").
|
||||
@param attributeDict Parsed attributes from the HTML tag.
|
||||
@param position Character offset in the original HTML data.
|
||||
*/
|
||||
- (void)parser:(id)parser
|
||||
didStartElement:(NSString *)elementName
|
||||
attributes:(NSDictionary *)attributeDict
|
||||
position:(NSUInteger)position
|
||||
{
|
||||
// Look up the registered handler block for this tag.
|
||||
// If found, invoke it. Otherwise, fall through to default handling.
|
||||
|
||||
DTHTMLElement *newElement = [[DTHTMLElement alloc] initWithTagName:elementName
|
||||
attributes:attributeDict];
|
||||
|
||||
// Set the parent to the current element on the stack.
|
||||
DTHTMLElement *parent = _parserDelegate->_currentElement;
|
||||
newElement.parent = parent;
|
||||
[parent.children addObject:newElement];
|
||||
|
||||
// Push onto the element stack.
|
||||
_parserDelegate->_currentElement = newElement;
|
||||
|
||||
// Apply inline style attribute (style="...") and any CSS rules
|
||||
// matching this element.
|
||||
NSDictionary *styleDict = [self _resolveStyleForElement:newElement
|
||||
attributes:attributeDict];
|
||||
if (styleDict) {
|
||||
// Determine if this is a Latin-language book (affects font fallback).
|
||||
BOOL isLatin = [_parserDelegate->_book isLatinLanguageBook];
|
||||
[newElement applyStyleDictionary:styleDict isLatinLanguageBook:isLatin];
|
||||
}
|
||||
|
||||
// Handle WeRead-specific custom CSS attributes.
|
||||
[self _applyWeReadCustomAttributes:newElement fromAttributes:attributeDict];
|
||||
|
||||
// Handle special tags.
|
||||
if ([elementName caseInsensitiveCompare:@"img"] == NSOrderedSame) {
|
||||
[self _handleImageElement:newElement attributes:attributeDict];
|
||||
}
|
||||
else if ([elementName caseInsensitiveCompare:@"br"] == NSOrderedSame) {
|
||||
[self _handleBRElement:newElement];
|
||||
}
|
||||
else if ([elementName caseInsensitiveCompare:@"a"] == NSOrderedSame) {
|
||||
[self _handleAnchorElement:newElement attributes:attributeDict];
|
||||
}
|
||||
|
||||
// Track last block vs. inline element for layout decisions.
|
||||
if ([newElement isBlockElement]) {
|
||||
_parserDelegate->_lastBlockElement = newElement;
|
||||
} else {
|
||||
_parserDelegate->_lastInlineElement = newElement;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Called when character data is found between tags.
|
||||
|
||||
@param parser The DTHTMLParser instance.
|
||||
@param string The character data.
|
||||
@param position Character offset in the original HTML.
|
||||
*/
|
||||
- (void)parser:(id)parser
|
||||
foundCharacters:(NSString *)string
|
||||
position:(NSUInteger)position
|
||||
{
|
||||
if (!string || string.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Append to the current text buffer.
|
||||
// The delegate accumulates text until a closing tag flushes it.
|
||||
DTHTMLElement *current = _parserDelegate->_currentElement;
|
||||
if (current) {
|
||||
[current appendText:string];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Called when a CDATA section is encountered (e.g. inside <script> or <style>).
|
||||
WeRead uses CDATA in some book content.
|
||||
|
||||
@param parser The DTHTMLParser instance.
|
||||
@param CDATABlock Raw CDATA bytes.
|
||||
*/
|
||||
- (void)parser:(id)parser
|
||||
foundCDATA:(NSData *)CDATABlock
|
||||
{
|
||||
// CDATA is typically treated as raw text content.
|
||||
NSString *text = [[NSString alloc] initWithData:CDATABlock
|
||||
encoding:NSUTF8StringEncoding];
|
||||
if (text) {
|
||||
DTHTMLElement *current = _parserDelegate->_currentElement;
|
||||
if (current) {
|
||||
[current appendText:text];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Called when the parser finishes parsing the entire document.
|
||||
*/
|
||||
- (void)parserDidEndDocument:(id)parser
|
||||
{
|
||||
// All elements have been opened and closed.
|
||||
// The DOM tree is complete in _parserDelegate->_rootElement.
|
||||
// Post-processing can happen here if needed.
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark Style resolution
|
||||
// --------------------------------------------------
|
||||
|
||||
/**
|
||||
Resolves the effective style dictionary for an element by merging:
|
||||
1. CSS stylesheet rules matching this element.
|
||||
2. Inline style="" attribute.
|
||||
3. Element-specific default styles.
|
||||
*/
|
||||
- (NSDictionary *)_resolveStyleForElement:(DTHTMLElement *)element
|
||||
attributes:(NSDictionary *)attrs
|
||||
{
|
||||
NSMutableDictionary *resolved = [NSMutableDictionary dictionary];
|
||||
|
||||
// 1. Apply CSS stylesheet rules (class, id, tag selectors).
|
||||
if (_cssStyleSheet) {
|
||||
NSDictionary *cssRules = [_cssStyleSheet stylesForElement:element];
|
||||
if (cssRules) {
|
||||
[resolved addEntriesFromDictionary:cssRules];
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Parse inline style attribute.
|
||||
NSString *inlineStyle = attrs[@"style"];
|
||||
if (inlineStyle) {
|
||||
NSDictionary *inlineDict = [self _parseInlineStyle:inlineStyle];
|
||||
if (inlineDict) {
|
||||
[resolved addEntriesFromDictionary:inlineDict];
|
||||
}
|
||||
}
|
||||
|
||||
return resolved.count > 0 ? resolved : nil;
|
||||
}
|
||||
|
||||
/**
|
||||
Parses a CSS inline style string (e.g. "color:red;font-size:14px")
|
||||
into a dictionary.
|
||||
*/
|
||||
- (NSDictionary *)_parseInlineStyle:(NSString *)styleString
|
||||
{
|
||||
NSMutableDictionary *dict = [NSMutableDictionary dictionary];
|
||||
NSArray *declarations = [styleString componentsSeparatedByString:@";"];
|
||||
for (NSString *decl in declarations) {
|
||||
NSArray *parts = [decl componentsSeparatedByString:@":"];
|
||||
if (parts.count == 2) {
|
||||
NSString *key = [parts[0] stringByTrimmingCharactersInSet:
|
||||
[NSCharacterSet whitespaceCharacterSet]];
|
||||
NSString *val = [parts[1] stringByTrimmingCharactersInSet:
|
||||
[NSCharacterSet whitespaceCharacterSet]];
|
||||
if (key.length > 0 && val.length > 0) {
|
||||
dict[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark WeRead custom attribute handling
|
||||
// --------------------------------------------------
|
||||
|
||||
/**
|
||||
Applies WeRead-specific CSS attributes to the element.
|
||||
These control page layout features unique to WeRead's reading engine.
|
||||
*/
|
||||
- (void)_applyWeReadCustomAttributes:(DTHTMLElement *)element
|
||||
fromAttributes:(NSDictionary *)attrs
|
||||
{
|
||||
// wr-vertical-center-style: vertically center content within page
|
||||
NSString *vCenter = attrs[@"wr-vertical-center-style"];
|
||||
if (vCenter) {
|
||||
element.verticalCenterStyle = vCenter;
|
||||
}
|
||||
|
||||
// weread-page-relate: marks content as page-related
|
||||
NSString *pageRelate = attrs[@"weread-page-relate"];
|
||||
if (pageRelate) {
|
||||
element.pageRelate = pageRelate;
|
||||
}
|
||||
|
||||
// avoidPageBreakInside: prevent page breaks within this element
|
||||
NSString *avoidBreak = attrs[@"avoidPageBreakInside"];
|
||||
if ([avoidBreak boolValue] || [avoidBreak isEqualToString:@"true"]) {
|
||||
element.shouldAvoidPageBreakInside = YES;
|
||||
}
|
||||
|
||||
// DTPageBreakAfter: force page break after this element
|
||||
NSString *breakAfter = attrs[@"DTPageBreakAfter"];
|
||||
if (breakAfter) {
|
||||
element.pageBreakAfter = YES;
|
||||
}
|
||||
|
||||
// DTPageBreakBefore: force page break before this element
|
||||
NSString *breakBefore = attrs[@"DTPageBreakBefore"];
|
||||
if (breakBefore) {
|
||||
element.pageBreakBefore = YES;
|
||||
}
|
||||
|
||||
// DTPageBackgroundColor: per-page background color
|
||||
NSString *bgColor = attrs[@"DTPageBackgroundColor"];
|
||||
if (bgColor) {
|
||||
element.pageBackgroundColor = [self _colorFromCSSValue:bgColor];
|
||||
}
|
||||
|
||||
// DTPageBackgroundImage: per-page background image
|
||||
NSString *bgImage = attrs[@"DTPageBackgroundImage"];
|
||||
if (bgImage) {
|
||||
element.pageBackgroundImage = bgImage;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Converts a CSS color value string to UIColor.
|
||||
*/
|
||||
- (UIColor *)_colorFromCSSValue:(NSString *)cssValue
|
||||
{
|
||||
// Simplified: real implementation handles hex (#RRGGBB), rgb(), named colors.
|
||||
if ([cssValue hasPrefix:@"#"]) {
|
||||
NSString *hex = [cssValue substringFromIndex:1];
|
||||
unsigned int rgb = 0;
|
||||
[[NSScanner scannerWithString:hex] scanHexInt:&rgb];
|
||||
return [UIColor colorWithRed:((rgb >> 16) & 0xFF) / 255.0
|
||||
green:((rgb >> 8) & 0xFF) / 255.0
|
||||
blue:((rgb >> 0) & 0xFF) / 255.0
|
||||
alpha:1.0];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark Special element handlers
|
||||
// --------------------------------------------------
|
||||
|
||||
- (void)_handleImageElement:(DTHTMLElement *)element
|
||||
attributes:(NSDictionary *)attrs
|
||||
{
|
||||
// Resolve image source relative to base URL.
|
||||
NSString *src = attrs[@"src"];
|
||||
if (!src) return;
|
||||
|
||||
NSURL *baseURL = _parserDelegate->_baseURL;
|
||||
NSURL *imageURL = [NSURL URLWithString:src relativeToURL:baseURL];
|
||||
|
||||
element.imageURL = imageURL;
|
||||
|
||||
// Store for lazy loading pipeline.
|
||||
_parserDelegate->_currentImageSrc = src;
|
||||
}
|
||||
|
||||
- (void)_handleBRElement:(DTHTMLElement *)element
|
||||
{
|
||||
// <br> inserts a newline / line break.
|
||||
element.tagName = @"br";
|
||||
element.isLineBreak = YES;
|
||||
}
|
||||
|
||||
- (void)_handleAnchorElement:(DTHTMLElement *)element
|
||||
attributes:(NSDictionary *)attrs
|
||||
{
|
||||
// <a href="..."> creates a hyperlink.
|
||||
NSString *href = attrs[@"href"];
|
||||
if (href) {
|
||||
NSURL *baseURL = _parserDelegate->_baseURL;
|
||||
element.linkURL = [NSURL URLWithString:href relativeToURL:baseURL];
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark Properties
|
||||
// --------------------------------------------------
|
||||
|
||||
- (NSData *)htmlData
|
||||
{
|
||||
return _htmlData;
|
||||
}
|
||||
|
||||
- (DTCSSStylesheet *)cssStyleSheet
|
||||
{
|
||||
return _cssStyleSheet;
|
||||
}
|
||||
|
||||
- (NSDictionary *)options
|
||||
{
|
||||
return _options;
|
||||
}
|
||||
|
||||
- (NSAttributedString *)generatedAttributedString
|
||||
{
|
||||
return _generatedAttributedString;
|
||||
}
|
||||
|
||||
- (WRBook *)book
|
||||
{
|
||||
return _parserDelegate->_book;
|
||||
}
|
||||
|
||||
- (void)setBook:(WRBook *)book
|
||||
{
|
||||
_parserDelegate->_book = book;
|
||||
_book = book;
|
||||
}
|
||||
|
||||
- (WRChapter *)chapter
|
||||
{
|
||||
return _parserDelegate->_chapter;
|
||||
}
|
||||
|
||||
- (void)setChapter:(WRChapter *)chapter
|
||||
{
|
||||
_parserDelegate->_chapter = chapter;
|
||||
_chapter = chapter;
|
||||
}
|
||||
|
||||
@end
|
||||
135
Doc/WXRead/decompiled/DTHTMLElement.h
Normal file
135
Doc/WXRead/decompiled/DTHTMLElement.h
Normal file
@ -0,0 +1,135 @@
|
||||
//
|
||||
// DTHTMLElement.h
|
||||
// DTCoreText (WeRead custom fork)
|
||||
//
|
||||
// Reverse-engineered from WeChat Reading (微信读书) binary.
|
||||
// Represents a single HTML element in the DOM tree built during parsing.
|
||||
// Each node can produce an NSAttributedString via -attributedString.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@class DTTextAttachment;
|
||||
@class DTBorderStyle;
|
||||
@class DTBackgroundImageStyle;
|
||||
@class DTTableStyle;
|
||||
@class DTCSSStylesheet;
|
||||
@class DTCoreTextFontDescriptor;
|
||||
@class DTCoreTextParagraphStyle;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DTHTMLElement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@interface DTHTMLElement : NSObject
|
||||
|
||||
// --- Initialization ---
|
||||
|
||||
- (instancetype)initWithTagName:(NSString *)tagName
|
||||
attributes:(NSDictionary *)attributes;
|
||||
|
||||
// --- Tree structure ---
|
||||
|
||||
@property (nonatomic, weak) DTHTMLElement *parent;
|
||||
@property (nonatomic, strong) NSMutableArray *children;
|
||||
|
||||
// --- Tag identity ---
|
||||
|
||||
@property (nonatomic, copy) NSString *tagName;
|
||||
@property (nonatomic, copy) NSString *elementId;
|
||||
@property (nonatomic, strong) NSArray *classNames;
|
||||
|
||||
// --- Attributes & style ---
|
||||
|
||||
@property (nonatomic, strong) NSDictionary *attributes;
|
||||
@property (nonatomic, strong) NSDictionary *styleDictionary;
|
||||
|
||||
// --- Text content ---
|
||||
|
||||
@property (nonatomic, copy) NSString *text;
|
||||
@property (nonatomic, strong) NSArray *textRuns;
|
||||
|
||||
// --- Display properties ---
|
||||
|
||||
@property (nonatomic, assign) BOOL isLineBreak;
|
||||
@property (nonatomic, assign) BOOL isBlockElement;
|
||||
@property (nonatomic, assign) BOOL shouldAvoidPageBreakInside;
|
||||
@property (nonatomic, assign) BOOL pageBreakAfter;
|
||||
@property (nonatomic, assign) BOOL pageBreakBefore;
|
||||
|
||||
// --- Font & paragraph ---
|
||||
|
||||
@property (nonatomic, strong) DTCoreTextFontDescriptor *fontDescriptor;
|
||||
@property (nonatomic, strong) DTCoreTextParagraphStyle *paragraphStyle;
|
||||
|
||||
// --- Colors ---
|
||||
|
||||
@property (nonatomic, strong) UIColor *textColor;
|
||||
@property (nonatomic, strong) UIColor *backgroundColor;
|
||||
|
||||
// --- Links ---
|
||||
|
||||
@property (nonatomic, strong) NSURL *linkURL;
|
||||
|
||||
// --- Images & attachments ---
|
||||
|
||||
@property (nonatomic, strong) NSURL *imageURL;
|
||||
@property (nonatomic, strong) DTTextAttachment *textAttachment;
|
||||
|
||||
// --- WeRead-specific page layout properties ---
|
||||
|
||||
@property (nonatomic, copy) NSString *verticalCenterStyle;
|
||||
@property (nonatomic, copy) NSString *pageRelate;
|
||||
@property (nonatomic, strong) UIColor *pageBackgroundColor;
|
||||
@property (nonatomic, copy) NSString *pageBackgroundImage;
|
||||
|
||||
// --- Border & background ---
|
||||
|
||||
@property (nonatomic, strong) DTBorderStyle *borderStyle;
|
||||
@property (nonatomic, strong) DTBackgroundImageStyle *backgroundImageStyle;
|
||||
@property (nonatomic, strong) DTTableStyle *tableStyle;
|
||||
|
||||
// --- Additional string fields observed in ivars ---
|
||||
|
||||
@property (nonatomic, copy) NSString *cssClass;
|
||||
@property (nonatomic, copy) NSString *cssId;
|
||||
@property (nonatomic, copy) NSString *lang;
|
||||
@property (nonatomic, copy) NSString *direction; // ltr / rtl
|
||||
@property (nonatomic, copy) NSString *whiteSpace;
|
||||
@property (nonatomic, copy) NSString *textAlign;
|
||||
|
||||
// --- Public methods ---
|
||||
|
||||
/**
|
||||
Applies a CSS style dictionary to this element, resolving font, color,
|
||||
paragraph style, etc.
|
||||
@param styleDict The CSS properties to apply.
|
||||
@param isLatin Whether the content language is Latin-script.
|
||||
*/
|
||||
- (void)applyStyleDictionary:(NSDictionary *)styleDict
|
||||
isLatinLanguageBook:(BOOL)isLatin;
|
||||
|
||||
/**
|
||||
Recursively converts this element and its children into an NSAttributedString.
|
||||
@return The attributed string representing this subtree.
|
||||
*/
|
||||
- (NSAttributedString *)attributedString;
|
||||
|
||||
/**
|
||||
Finalizes attributes after all children have been parsed.
|
||||
Called when the closing tag is encountered.
|
||||
*/
|
||||
- (void)interpretAttributes;
|
||||
|
||||
/**
|
||||
Appends text content to this element (called during SAX foundCharacters:).
|
||||
*/
|
||||
- (void)appendText:(NSString *)text;
|
||||
|
||||
/**
|
||||
Returns YES if this element is a void / self-closing element (img, br, hr, etc.)
|
||||
*/
|
||||
- (BOOL)isVoidElement;
|
||||
|
||||
@end
|
||||
917
Doc/WXRead/decompiled/DTHTMLElement.m
Normal file
917
Doc/WXRead/decompiled/DTHTMLElement.m
Normal file
@ -0,0 +1,917 @@
|
||||
//
|
||||
// DTHTMLElement.m
|
||||
// DTCoreText (WeRead custom fork)
|
||||
//
|
||||
// Reverse-engineered pseudo-implementation from WeChat Reading binary.
|
||||
// Each DTHTMLElement node represents an HTML tag; it holds resolved style
|
||||
// properties and can produce an NSAttributedString via -attributedString.
|
||||
//
|
||||
// Key responsibilities:
|
||||
// 1. Store the tag name, attributes, and parent/child tree pointers.
|
||||
// 2. Apply CSS style dictionaries (from stylesheet or inline style).
|
||||
// 3. Resolve font descriptors and paragraph styles.
|
||||
// 4. Convert itself + children into NSAttributedString (recursive).
|
||||
// 5. Handle WeRead-specific page layout attributes.
|
||||
//
|
||||
|
||||
#import "DTHTMLElement.h"
|
||||
#import "DTTextAttachment.h"
|
||||
#import "DTBorderStyle.h"
|
||||
#import "DTBackgroundImageStyle.h"
|
||||
#import "DTTableStyle.h"
|
||||
#import "DTCoreTextFontDescriptor.h"
|
||||
#import "DTCoreTextParagraphStyle.h"
|
||||
#import "DTCSSStylesheet.h"
|
||||
|
||||
// DTCoreText standard attribute keys (defined elsewhere in DTCoreText)
|
||||
// extern NSString *const DTTextListsAttribute;
|
||||
// extern NSString *const DTStrikeOutAttribute;
|
||||
// extern NSString *const DTUnderlineStyleAttribute;
|
||||
// extern NSString *const DTLinkAttribute;
|
||||
// ...
|
||||
|
||||
// Void elements: tags that have no closing tag and no children.
|
||||
static NSSet *_voidElements = nil;
|
||||
|
||||
@implementation DTHTMLElement
|
||||
{
|
||||
// Mutable text accumulator used during parsing.
|
||||
NSMutableString *_textBuffer;
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark Class initialization
|
||||
// --------------------------------------------------
|
||||
|
||||
+ (void)initialize
|
||||
{
|
||||
if (self == [DTHTMLElement class]) {
|
||||
_voidElements = [NSSet setWithArray:@[
|
||||
@"area", @"base", @"br", @"col", @"embed", @"hr",
|
||||
@"img", @"input", @"link", @"meta", @"param",
|
||||
@"source", @"track", @"wbr"
|
||||
]];
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark Initialization
|
||||
// --------------------------------------------------
|
||||
|
||||
- (instancetype)initWithTagName:(NSString *)tagName
|
||||
attributes:(NSDictionary *)attributes
|
||||
{
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_tagName = [tagName lowercaseString];
|
||||
_attributes = [attributes copy] ?: @{};
|
||||
_children = [NSMutableArray array];
|
||||
|
||||
// Parse id and class from attributes.
|
||||
_elementId = attributes[@"id"];
|
||||
NSString *classStr = attributes[@"class"];
|
||||
if (classStr) {
|
||||
_classNames = [classStr componentsSeparatedByCharactersInSet:
|
||||
[NSCharacterSet whitespaceCharacterSet]];
|
||||
}
|
||||
|
||||
// Default display mode: block for most tags, inline for span/em/etc.
|
||||
_isBlockElement = [self _isDefaultBlockElement:_tagName];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (instancetype)init
|
||||
{
|
||||
return [self initWithTagName:nil attributes:nil];
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark Default block/inline classification
|
||||
// --------------------------------------------------
|
||||
|
||||
/**
|
||||
Returns YES for tags that are block-level by default in HTML.
|
||||
*/
|
||||
- (BOOL)_isDefaultBlockElement:(NSString *)tag
|
||||
{
|
||||
static NSSet *blockTags = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
blockTags = [NSSet setWithArray:@[
|
||||
@"div", @"p", @"h1", @"h2", @"h3", @"h4", @"h5", @"h6",
|
||||
@"blockquote", @"ul", @"ol", @"li", @"table", @"tr", @"td",
|
||||
@"th", @"thead", @"tbody", @"tfoot", @"section", @"article",
|
||||
@"header", @"footer", @"nav", @"main", @"aside", @"figure",
|
||||
@"figcaption", @"address", @"pre", @"hr", @"form", @"fieldset",
|
||||
@"dl", @"dt", @"dd"
|
||||
]];
|
||||
});
|
||||
return [blockTags containsObject:tag];
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark Text content management
|
||||
// --------------------------------------------------
|
||||
|
||||
/**
|
||||
Appends character data to this element's text buffer.
|
||||
Called by the builder during SAX foundCharacters: events.
|
||||
*/
|
||||
- (void)appendText:(NSString *)text
|
||||
{
|
||||
if (!text || text.length == 0) return;
|
||||
|
||||
if (!_textBuffer) {
|
||||
_textBuffer = [NSMutableString stringWithString:text];
|
||||
} else {
|
||||
[_textBuffer appendString:text];
|
||||
}
|
||||
_text = _textBuffer;
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark Style application
|
||||
// --------------------------------------------------
|
||||
|
||||
/**
|
||||
Applies a CSS style dictionary to this element.
|
||||
|
||||
This is the core style resolution method. It translates CSS property names
|
||||
into DTCoreText property objects (font descriptors, paragraph styles, colors).
|
||||
|
||||
@param styleDict Dictionary of CSS property → value.
|
||||
@param isLatin YES if the book language is Latin-script (affects font
|
||||
fallback and line-height calculations).
|
||||
*/
|
||||
- (void)applyStyleDictionary:(NSDictionary *)styleDict
|
||||
isLatinLanguageBook:(BOOL)isLatin
|
||||
{
|
||||
if (!styleDict || styleDict.count == 0) return;
|
||||
|
||||
_styleDictionary = styleDict;
|
||||
|
||||
// ---- Font properties ----
|
||||
[self _applyFontProperties:styleDict isLatin:isLatin];
|
||||
|
||||
// ---- Text properties ----
|
||||
[self _applyTextProperties:styleDict];
|
||||
|
||||
// ---- Color properties ----
|
||||
[self _applyColorProperties:styleDict];
|
||||
|
||||
// ---- Display / layout properties ----
|
||||
[self _applyDisplayProperties:styleDict];
|
||||
|
||||
// ---- Margin / padding (for paragraph style) ----
|
||||
[self _applyBoxModelProperties:styleDict];
|
||||
|
||||
// ---- WeRead-specific properties ----
|
||||
[self _applyWeReadProperties:styleDict];
|
||||
|
||||
// ---- Border properties ----
|
||||
[self _applyBorderProperties:styleDict];
|
||||
|
||||
// ---- Background properties ----
|
||||
[self _applyBackgroundProperties:styleDict];
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark Font property resolution
|
||||
// --------------------------------------------------
|
||||
|
||||
- (void)_applyFontProperties:(NSDictionary *)dict isLatin:(BOOL)isLatin
|
||||
{
|
||||
// font-family
|
||||
NSString *fontFamily = dict[@"font-family"];
|
||||
if (fontFamily) {
|
||||
// Strip quotes, handle generic families (serif, sans-serif, monospace).
|
||||
fontFamily = [fontFamily stringByTrimmingCharactersInSet:
|
||||
[NSCharacterSet characterSetWithCharactersInString:@" '\""]];
|
||||
if (!_fontDescriptor) {
|
||||
_fontDescriptor = [[DTCoreTextFontDescriptor alloc] init];
|
||||
}
|
||||
_fontDescriptor.fontFamily = fontFamily;
|
||||
}
|
||||
|
||||
// font-size
|
||||
NSString *fontSizeStr = dict[@"font-size"];
|
||||
if (fontSizeStr) {
|
||||
CGFloat size = [self _floatFromCSSValue:fontSizeStr];
|
||||
if (size > 0) {
|
||||
if (!_fontDescriptor) {
|
||||
_fontDescriptor = [[DTCoreTextFontDescriptor alloc] init];
|
||||
}
|
||||
_fontDescriptor.pointSize = size;
|
||||
}
|
||||
}
|
||||
|
||||
// font-weight
|
||||
NSString *fontWeight = dict[@"font-weight"];
|
||||
if (fontWeight) {
|
||||
if (!_fontDescriptor) {
|
||||
_fontDescriptor = [[DTCoreTextFontDescriptor alloc] init];
|
||||
}
|
||||
if ([fontWeight isEqualToString:@"bold"] ||
|
||||
[fontWeight integerValue] >= 700) {
|
||||
_fontDescriptor.boldTrait = YES;
|
||||
}
|
||||
}
|
||||
|
||||
// font-style (italic / normal)
|
||||
NSString *fontStyle = dict[@"font-style"];
|
||||
if (fontStyle) {
|
||||
if (!_fontDescriptor) {
|
||||
_fontDescriptor = [[DTCoreTextFontDescriptor alloc] init];
|
||||
}
|
||||
if ([fontStyle isEqualToString:@"italic"] ||
|
||||
[fontStyle isEqualToString:@"oblique"]) {
|
||||
_fontDescriptor.italicTrait = YES;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark Text property resolution
|
||||
// --------------------------------------------------
|
||||
|
||||
- (void)_applyTextProperties:(NSDictionary *)dict
|
||||
{
|
||||
// text-decoration
|
||||
NSString *decoration = dict[@"text-decoration"];
|
||||
if (decoration) {
|
||||
// underline, line-through, none
|
||||
// stored for later attributed string construction
|
||||
}
|
||||
|
||||
// text-align
|
||||
NSString *align = dict[@"text-align"];
|
||||
if (align) {
|
||||
_textAlign = align;
|
||||
if (!_paragraphStyle) {
|
||||
_paragraphStyle = [[DTCoreTextParagraphStyle alloc] init];
|
||||
}
|
||||
if ([align isEqualToString:@"center"]) {
|
||||
_paragraphStyle.textAlignment = kCTCenterTextAlignment;
|
||||
} else if ([align isEqualToString:@"right"]) {
|
||||
_paragraphStyle.textAlignment = kCTRightTextAlignment;
|
||||
} else if ([align isEqualToString:@"justify"]) {
|
||||
_paragraphStyle.textAlignment = kCTJustifiedTextAlignment;
|
||||
} else {
|
||||
_paragraphStyle.textAlignment = kCTLeftTextAlignment;
|
||||
}
|
||||
}
|
||||
|
||||
// text-indent
|
||||
NSString *indent = dict[@"text-indent"];
|
||||
if (indent) {
|
||||
if (!_paragraphStyle) {
|
||||
_paragraphStyle = [[DTCoreTextParagraphStyle alloc] init];
|
||||
}
|
||||
_paragraphStyle.firstLineHeadIndent = [self _floatFromCSSValue:indent];
|
||||
}
|
||||
|
||||
// line-height
|
||||
NSString *lineHeight = dict[@"line-height"];
|
||||
if (lineHeight) {
|
||||
if (!_paragraphStyle) {
|
||||
_paragraphStyle = [[DTCoreTextParagraphStyle alloc] init];
|
||||
}
|
||||
_paragraphStyle.lineHeightMultiple = [self _floatFromCSSValue:lineHeight];
|
||||
}
|
||||
|
||||
// letter-spacing
|
||||
NSString *letterSpacing = dict[@"letter-spacing"];
|
||||
if (letterSpacing) {
|
||||
// Applied as kern in the attributed string.
|
||||
}
|
||||
|
||||
// white-space
|
||||
NSString *ws = dict[@"white-space"];
|
||||
if (ws) {
|
||||
_whiteSpace = ws;
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark Color property resolution
|
||||
// --------------------------------------------------
|
||||
|
||||
- (void)_applyColorProperties:(NSDictionary *)dict
|
||||
{
|
||||
// color
|
||||
NSString *colorStr = dict[@"color"];
|
||||
if (colorStr) {
|
||||
_textColor = [self _colorFromCSSValue:colorStr];
|
||||
}
|
||||
|
||||
// background-color
|
||||
NSString *bgColorStr = dict[@"background-color"];
|
||||
if (bgColorStr) {
|
||||
_backgroundColor = [self _colorFromCSSValue:bgColorStr];
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark Display / layout properties
|
||||
// --------------------------------------------------
|
||||
|
||||
- (void)_applyDisplayProperties:(NSDictionary *)dict
|
||||
{
|
||||
NSString *display = dict[@"display"];
|
||||
if (display) {
|
||||
if ([display isEqualToString:@"block"] ||
|
||||
[display isEqualToString:@"flex"] ||
|
||||
[display isEqualToString:@"grid"]) {
|
||||
_isBlockElement = YES;
|
||||
} else if ([display isEqualToString:@"inline"] ||
|
||||
[display isEqualToString:@"inline-block"]) {
|
||||
_isBlockElement = NO;
|
||||
} else if ([display isEqualToString:@"none"]) {
|
||||
// Element should be hidden; mark for skipping.
|
||||
}
|
||||
}
|
||||
|
||||
// vertical-align
|
||||
NSString *vAlign = dict[@"vertical-align"];
|
||||
if (vAlign) {
|
||||
// sub, super, top, middle, bottom, etc.
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark Box model (margin / padding)
|
||||
// --------------------------------------------------
|
||||
|
||||
- (void)_applyBoxModelProperties:(NSDictionary *)dict
|
||||
{
|
||||
if (!_paragraphStyle) {
|
||||
_paragraphStyle = [[DTCoreTextParagraphStyle alloc] init];
|
||||
}
|
||||
|
||||
// margin-top
|
||||
NSString *marginTop = dict[@"margin-top"];
|
||||
if (marginTop) {
|
||||
_paragraphStyle.paragraphSpacingBefore = [self _floatFromCSSValue:marginTop];
|
||||
}
|
||||
|
||||
// margin-bottom
|
||||
NSString *marginBottom = dict[@"margin-bottom"];
|
||||
if (marginBottom) {
|
||||
_paragraphStyle.paragraphSpacing = [self _floatFromCSSValue:marginBottom];
|
||||
}
|
||||
|
||||
// padding-left
|
||||
NSString *paddingLeft = dict[@"padding-left"];
|
||||
if (paddingLeft) {
|
||||
_paragraphStyle.headIndent = [self _floatFromCSSValue:paddingLeft];
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark WeRead-specific CSS properties
|
||||
// --------------------------------------------------
|
||||
|
||||
/**
|
||||
Handles WeRead's proprietary CSS attributes that control page-level layout.
|
||||
These are used by WeRead's paging engine (not standard web rendering).
|
||||
*/
|
||||
- (void)_applyWeReadProperties:(NSDictionary *)dict
|
||||
{
|
||||
// wr-vertical-center-style: vertically center content within a page.
|
||||
NSString *vCenter = dict[@"wr-vertical-center-style"];
|
||||
if (vCenter) {
|
||||
_verticalCenterStyle = vCenter;
|
||||
}
|
||||
|
||||
// weread-page-relate: marks element as related to page-level layout.
|
||||
NSString *pageRelate = dict[@"weread-page-relate"];
|
||||
if (pageRelate) {
|
||||
_pageRelate = pageRelate;
|
||||
}
|
||||
|
||||
// avoidPageBreakInside: prevent page breaks within this element.
|
||||
NSString *avoidBreak = dict[@"avoidPageBreakInside"];
|
||||
if (avoidBreak) {
|
||||
_shouldAvoidPageBreakInside = YES;
|
||||
}
|
||||
|
||||
// DTPageBreakAfter: force a page break after this element.
|
||||
NSString *breakAfter = dict[@"DTPageBreakAfter"];
|
||||
if (breakAfter && ([breakAfter boolValue] ||
|
||||
[breakAfter isEqualToString:@"always"])) {
|
||||
_pageBreakAfter = YES;
|
||||
}
|
||||
|
||||
// DTPageBreakBefore: force a page break before this element.
|
||||
NSString *breakBefore = dict[@"DTPageBreakBefore"];
|
||||
if (breakBefore && ([breakBefore boolValue] ||
|
||||
[breakBefore isEqualToString:@"always"])) {
|
||||
_pageBreakBefore = YES;
|
||||
}
|
||||
|
||||
// DTPageBackgroundColor: per-page background color (for styled pages).
|
||||
NSString *bgColor = dict[@"DTPageBackgroundColor"];
|
||||
if (bgColor) {
|
||||
_pageBackgroundColor = [self _colorFromCSSValue:bgColor];
|
||||
}
|
||||
|
||||
// DTPageBackgroundImage: per-page background image URL/path.
|
||||
NSString *bgImage = dict[@"DTPageBackgroundImage"];
|
||||
if (bgImage) {
|
||||
_pageBackgroundImage = bgImage;
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark Border properties
|
||||
// --------------------------------------------------
|
||||
|
||||
- (void)_applyBorderProperties:(NSDictionary *)dict
|
||||
{
|
||||
// border-width, border-style, border-color
|
||||
NSString *borderWidth = dict[@"border-width"];
|
||||
NSString *borderStyle = dict[@"border-style"];
|
||||
NSString *borderColor = dict[@"border-color"];
|
||||
|
||||
if (borderWidth || borderStyle || borderColor) {
|
||||
if (!_borderStyle) {
|
||||
_borderStyle = [[DTBorderStyle alloc] init];
|
||||
}
|
||||
if (borderWidth) {
|
||||
_borderStyle.borderWidth = [self _floatFromCSSValue:borderWidth];
|
||||
}
|
||||
if (borderColor) {
|
||||
_borderStyle.borderColor = [self _colorFromCSSValue:borderColor];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark Background properties
|
||||
// --------------------------------------------------
|
||||
|
||||
- (void)_applyBackgroundProperties:(NSDictionary *)dict
|
||||
{
|
||||
NSString *bgImage = dict[@"background-image"];
|
||||
if (bgImage && [bgImage hasPrefix:@"url("]) {
|
||||
// Extract URL from url('...')
|
||||
NSRange start = [bgImage rangeOfString:@"'"];
|
||||
NSRange end = [bgImage rangeOfString:@"'" options:NSBackwardsSearch];
|
||||
if (start.location != NSNotFound && end.location != NSNotFound) {
|
||||
NSString *urlStr = [bgImage substringWithRange:
|
||||
NSMakeRange(start.location + 1,
|
||||
end.location - start.location - 1)];
|
||||
if (!_backgroundImageStyle) {
|
||||
_backgroundImageStyle = [[DTBackgroundImageStyle alloc] init];
|
||||
}
|
||||
_backgroundImageStyle.imageURL = [NSURL URLWithString:urlStr];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark Interpret attributes (finalization)
|
||||
// --------------------------------------------------
|
||||
|
||||
/**
|
||||
Called after the closing tag is encountered.
|
||||
Finalizes computed properties that depend on children.
|
||||
*/
|
||||
- (void)interpretAttributes
|
||||
{
|
||||
// For block elements, ensure a paragraph style exists.
|
||||
if (_isBlockElement && !_paragraphStyle) {
|
||||
_paragraphStyle = [[DTCoreTextParagraphStyle alloc] init];
|
||||
}
|
||||
|
||||
// Resolve font descriptor from tag name if not set by CSS.
|
||||
if (!_fontDescriptor) {
|
||||
_fontDescriptor = [self _defaultFontDescriptorForTag:_tagName];
|
||||
}
|
||||
|
||||
// Process children (recursive interpret).
|
||||
for (DTHTMLElement *child in _children) {
|
||||
[child interpretAttributes];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Returns a default font descriptor based on the HTML tag name.
|
||||
*/
|
||||
- (DTCoreTextFontDescriptor *)_defaultFontDescriptorForTag:(NSString *)tag
|
||||
{
|
||||
DTCoreTextFontDescriptor *desc = [[DTCoreTextFontDescriptor alloc] init];
|
||||
|
||||
if ([tag isEqualToString:@"b"] || [tag isEqualToString:@"strong"]) {
|
||||
desc.boldTrait = YES;
|
||||
}
|
||||
else if ([tag isEqualToString:@"i"] || [tag isEqualToString:@"em"]) {
|
||||
desc.italicTrait = YES;
|
||||
}
|
||||
else if ([tag isEqualToString:@"big"]) {
|
||||
desc.pointSize = 18.0;
|
||||
}
|
||||
else if ([tag isEqualToString:@"small"]) {
|
||||
desc.pointSize = 10.0;
|
||||
}
|
||||
else if ([tag isEqualToString:@"h1"]) {
|
||||
desc.pointSize = 24.0;
|
||||
desc.boldTrait = YES;
|
||||
}
|
||||
else if ([tag isEqualToString:@"h2"]) {
|
||||
desc.pointSize = 20.0;
|
||||
desc.boldTrait = YES;
|
||||
}
|
||||
else if ([tag isEqualToString:@"h3"]) {
|
||||
desc.pointSize = 16.0;
|
||||
desc.boldTrait = YES;
|
||||
}
|
||||
else if ([tag isEqualToString:@"h4"]) {
|
||||
desc.pointSize = 14.0;
|
||||
desc.boldTrait = YES;
|
||||
}
|
||||
else if ([tag isEqualToString:@"code"] ||
|
||||
[tag isEqualToString:@"tt"] ||
|
||||
[tag isEqualToString:@"pre"]) {
|
||||
desc.monospaceFamily = YES;
|
||||
}
|
||||
|
||||
return desc;
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark Attributed string generation
|
||||
// --------------------------------------------------
|
||||
|
||||
/**
|
||||
Recursively converts this element and all its children into an
|
||||
NSAttributedString. This is the core rendering method.
|
||||
|
||||
Algorithm:
|
||||
1. Create a mutable attributed string from this element's text content.
|
||||
2. Apply font, color, paragraph style, and link attributes.
|
||||
3. For each child, call -attributedString recursively and append.
|
||||
4. Handle special elements (img, br, table, etc.).
|
||||
5. Return the assembled string.
|
||||
*/
|
||||
- (NSAttributedString *)attributedString
|
||||
{
|
||||
NSMutableAttributedString *output = [[NSMutableAttributedString alloc] init];
|
||||
|
||||
// Step 1: Handle void elements first.
|
||||
if ([self isVoidElement]) {
|
||||
return [self _attributedStringForVoidElement];
|
||||
}
|
||||
|
||||
// Step 2: Emit page break before marker if needed.
|
||||
if (_pageBreakBefore) {
|
||||
NSDictionary *breakAttrs = @{
|
||||
DTPageBreakBeforeAttribute: @YES
|
||||
};
|
||||
NSAttributedString *breakStr = [[NSAttributedString alloc]
|
||||
initWithString:@"
" // LINE SEPARATOR as page break marker
|
||||
attributes:breakAttrs];
|
||||
[output appendAttributedString:breakStr];
|
||||
}
|
||||
|
||||
// Step 3: Process text content.
|
||||
if (_text && _text.length > 0) {
|
||||
NSAttributedString *textStr = [self _attributedStringForText:_text];
|
||||
[output appendAttributedString:textStr];
|
||||
}
|
||||
|
||||
// Step 4: Process children recursively.
|
||||
for (DTHTMLElement *child in _children) {
|
||||
NSAttributedString *childStr = [child attributedString];
|
||||
if (childStr) {
|
||||
[output appendAttributedString:childStr];
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Wrap in paragraph style if this is a block element.
|
||||
if (_isBlockElement && output.length > 0) {
|
||||
[self _applyParagraphStyleToString:output];
|
||||
}
|
||||
|
||||
// Step 6: Emit page break after marker if needed.
|
||||
if (_pageBreakAfter) {
|
||||
NSDictionary *breakAttrs = @{
|
||||
DTPageBreakAfterAttribute: @YES
|
||||
};
|
||||
NSAttributedString *breakStr = [[NSAttributedString alloc]
|
||||
initWithString:@"
"
|
||||
attributes:breakAttrs];
|
||||
[output appendAttributedString:breakStr];
|
||||
}
|
||||
|
||||
// Step 7: Apply WeRead page-level attributes.
|
||||
[self _applyWeReadPageAttributesToString:output];
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark Text → NSAttributedString
|
||||
// --------------------------------------------------
|
||||
|
||||
/**
|
||||
Creates an NSAttributedString from text with the element's resolved styles.
|
||||
*/
|
||||
- (NSAttributedString *)_attributedStringForText:(NSString *)text
|
||||
{
|
||||
NSMutableDictionary *attrs = [NSMutableDictionary dictionary];
|
||||
|
||||
// Font
|
||||
CTFontRef font = [_fontDescriptor newMatchingFont];
|
||||
if (font) {
|
||||
attrs[(id)kCTFontAttributeName] = (__bridge id)font;
|
||||
CFRelease(font);
|
||||
}
|
||||
|
||||
// Text color
|
||||
if (_textColor) {
|
||||
attrs[(id)kCTForegroundColorAttributeName] = (__bridge id)_textColor.CGColor;
|
||||
}
|
||||
|
||||
// Background color (highlight)
|
||||
if (_backgroundColor) {
|
||||
attrs[@"DTBackgroundColor"] = _backgroundColor;
|
||||
}
|
||||
|
||||
// Link
|
||||
if (_linkURL) {
|
||||
attrs[@"DTLink"] = _linkURL;
|
||||
}
|
||||
|
||||
// Kern (letter-spacing)
|
||||
// if (_letterSpacing) { attrs[(id)kCTKernAttributeName] = ...; }
|
||||
|
||||
return [[NSAttributedString alloc] initWithString:text attributes:attrs];
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark Void element handling
|
||||
// --------------------------------------------------
|
||||
|
||||
- (BOOL)isVoidElement
|
||||
{
|
||||
return [_voidElements containsObject:_tagName];
|
||||
}
|
||||
|
||||
/**
|
||||
Produces the attributed string for void elements (br, img, hr, etc.)
|
||||
*/
|
||||
- (NSAttributedString *)_attributedStringForVoidElement
|
||||
{
|
||||
if ([_tagName isEqualToString:@"br"]) {
|
||||
// Line break: insert newline with current paragraph style.
|
||||
NSMutableDictionary *attrs = [NSMutableDictionary dictionary];
|
||||
if (_paragraphStyle) {
|
||||
attrs[@"DTParagraphStyle"] = _paragraphStyle;
|
||||
}
|
||||
return [[NSAttributedString alloc] initWithString:@"\n"
|
||||
attributes:attrs];
|
||||
}
|
||||
else if ([_tagName isEqualToString:@"img"]) {
|
||||
// Image: create a text attachment and wrap in attributed string.
|
||||
return [self _attributedStringForImage];
|
||||
}
|
||||
else if ([_tagName isEqualToString:@"hr"]) {
|
||||
// Horizontal rule: treated as a paragraph separator.
|
||||
return [[NSAttributedString alloc] initWithString:@"\n"];
|
||||
}
|
||||
|
||||
return [[NSAttributedString alloc] initWithString:@""];
|
||||
}
|
||||
|
||||
/**
|
||||
Creates an NSAttributedString containing an image attachment.
|
||||
*/
|
||||
- (NSAttributedString *)_attributedStringForImage
|
||||
{
|
||||
if (!_textAttachment) {
|
||||
_textAttachment = [[DTTextAttachment alloc] init];
|
||||
_textAttachment.contentURL = _imageURL;
|
||||
}
|
||||
|
||||
// The attachment is represented by the Unicode object replacement character.
|
||||
unichar objectChar = 0xFFFC;
|
||||
NSString *objectStr = [NSString stringWithCharacters:&objectChar length:1];
|
||||
|
||||
NSMutableDictionary *attrs = [NSMutableDictionary dictionary];
|
||||
attrs[@"DTTextAttachment"] = _textAttachment;
|
||||
if (_linkURL) {
|
||||
attrs[@"DTLink"] = _linkURL;
|
||||
}
|
||||
|
||||
return [[NSAttributedString alloc] initWithString:objectStr
|
||||
attributes:attrs];
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark Paragraph style application
|
||||
// --------------------------------------------------
|
||||
|
||||
/**
|
||||
Applies the element's paragraph style to the entire range of the string.
|
||||
*/
|
||||
- (void)_applyParagraphStyleToString:(NSMutableAttributedString *)str
|
||||
{
|
||||
if (!_paragraphStyle || str.length == 0) return;
|
||||
|
||||
CTParagraphStyleRef ctStyle = [_paragraphStyle createCTParagraphStyle];
|
||||
if (ctStyle) {
|
||||
[str addAttribute:(id)kCTParagraphStyleAttributeName
|
||||
value:(__bridge id)ctStyle
|
||||
range:NSMakeRange(0, str.length)];
|
||||
CFRelease(ctStyle);
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark WeRead page attribute application
|
||||
// --------------------------------------------------
|
||||
|
||||
/**
|
||||
Applies WeRead-specific page layout attributes to the string.
|
||||
These attributes are consumed by WeRead's paging engine to control
|
||||
page breaks, backgrounds, and vertical centering.
|
||||
*/
|
||||
- (void)_applyWeReadPageAttributesToString:(NSMutableAttributedString *)str
|
||||
{
|
||||
if (str.length == 0) return;
|
||||
|
||||
NSRange fullRange = NSMakeRange(0, str.length);
|
||||
|
||||
// Vertical center style
|
||||
if (_verticalCenterStyle) {
|
||||
[str addAttribute:DTHTMLVerticalCenterAttribute
|
||||
value:_verticalCenterStyle
|
||||
range:fullRange];
|
||||
}
|
||||
|
||||
// Page relate
|
||||
if (_pageRelate) {
|
||||
[str addAttribute:DTPageRelateAttribute
|
||||
value:_pageRelate
|
||||
range:fullRange];
|
||||
}
|
||||
|
||||
// Avoid page break inside
|
||||
if (_shouldAvoidPageBreakInside) {
|
||||
[str addAttribute:DTPageBreakInsideAvoidAttribute
|
||||
value:@YES
|
||||
range:fullRange];
|
||||
}
|
||||
|
||||
// Page background color
|
||||
if (_pageBackgroundColor) {
|
||||
[str addAttribute:DTPageBackgroundColorAttribute
|
||||
value:_pageBackgroundColor
|
||||
range:fullRange];
|
||||
}
|
||||
|
||||
// Page background image
|
||||
if (_pageBackgroundImage) {
|
||||
[str addAttribute:DTPageBackgroundImageAttribute
|
||||
value:_pageBackgroundImage
|
||||
range:fullRange];
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
# pragma mark CSS value parsing helpers
|
||||
// --------------------------------------------------
|
||||
|
||||
/**
|
||||
Converts a CSS length value string (e.g. "14px", "1.2em", "100%")
|
||||
into a CGFloat in points.
|
||||
*/
|
||||
- (CGFloat)_floatFromCSSValue:(NSString *)value
|
||||
{
|
||||
if (!value || value.length == 0) return 0.0;
|
||||
|
||||
// Strip whitespace.
|
||||
value = [value stringByTrimmingCharactersInSet:
|
||||
[NSCharacterSet whitespaceCharacterSet]];
|
||||
|
||||
// Handle special values.
|
||||
if ([value isEqualToString:@"inherit"] ||
|
||||
[value isEqualToString:@"auto"]) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Extract numeric part.
|
||||
NSString *numericPart = value;
|
||||
NSString *unit = @"";
|
||||
|
||||
// Check for known units.
|
||||
NSArray *units = @[@"px", @"em", @"rem", @"pt", @"%", @"ex"];
|
||||
for (NSString *u in units) {
|
||||
if ([value hasSuffix:u]) {
|
||||
numericPart = [value substringToIndex:value.length - u.length];
|
||||
unit = u;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
CGFloat floatValue = [numericPart floatValue];
|
||||
|
||||
// Convert to points (simplified; real implementation handles em/% relative
|
||||
// to parent).
|
||||
if ([unit isEqualToString:@"em"] || [unit isEqualToString:@"rem"]) {
|
||||
// Assume 1em = parent font size (default 16px).
|
||||
floatValue *= 16.0;
|
||||
} else if ([unit isEqualToString:@"pt"]) {
|
||||
// 1pt = 1pt (no conversion needed on iOS).
|
||||
} else if ([unit isEqualToString:@"%"]) {
|
||||
// Percentage: caller must interpret relative to container.
|
||||
}
|
||||
// "px" → points (1:1 on non-retina, but iOS uses points natively).
|
||||
|
||||
return floatValue;
|
||||
}
|
||||
|
||||
/**
|
||||
Converts a CSS color value string to UIColor.
|
||||
Supports:
|
||||
- #RRGGBB hex notation
|
||||
- #RGB shorthand
|
||||
- rgb(r,g,b) functional notation
|
||||
- Named colors (red, blue, etc.)
|
||||
*/
|
||||
- (UIColor *)_colorFromCSSValue:(NSString *)cssValue
|
||||
{
|
||||
if (!cssValue || cssValue.length == 0) return nil;
|
||||
|
||||
cssValue = [cssValue stringByTrimmingCharactersInSet:
|
||||
[NSCharacterSet whitespaceCharacterSet]];
|
||||
|
||||
// Hex colors
|
||||
if ([cssValue hasPrefix:@"#"]) {
|
||||
NSString *hex = [cssValue substringFromIndex:1];
|
||||
|
||||
// Expand shorthand #RGB → #RRGGBB
|
||||
if (hex.length == 3) {
|
||||
hex = [NSString stringWithFormat:@"%C%C%C%C%C%C",
|
||||
[hex characterAtIndex:0], [hex characterAtIndex:0],
|
||||
[hex characterAtIndex:1], [hex characterAtIndex:1],
|
||||
[hex characterAtIndex:2], [hex characterAtIndex:2]];
|
||||
}
|
||||
|
||||
if (hex.length == 6) {
|
||||
unsigned int rgb = 0;
|
||||
[[NSScanner scannerWithString:hex] scanHexInt:&rgb];
|
||||
return [UIColor colorWithRed:((rgb >> 16) & 0xFF) / 255.0
|
||||
green:((rgb >> 8) & 0xFF) / 255.0
|
||||
blue:((rgb >> 0) & 0xFF) / 255.0
|
||||
alpha:1.0];
|
||||
}
|
||||
}
|
||||
|
||||
// rgb(r,g,b) notation
|
||||
if ([cssValue hasPrefix:@"rgb("] || [cssValue hasPrefix:@"rgba("]) {
|
||||
NSString *inner = cssValue;
|
||||
inner = [inner stringByReplacingOccurrencesOfString:@"rgb(" withString:@""];
|
||||
inner = [inner stringByReplacingOccurrencesOfString:@"rgba(" withString:@""];
|
||||
inner = [inner stringByReplacingOccurrencesOfString:@")" withString:@""];
|
||||
|
||||
NSArray *components = [inner componentsSeparatedByString:@","];
|
||||
if (components.count >= 3) {
|
||||
CGFloat r = [components[0] floatValue] / 255.0;
|
||||
CGFloat g = [components[1] floatValue] / 255.0;
|
||||
CGFloat b = [components[2] floatValue] / 255.0;
|
||||
CGFloat a = components.count >= 4 ? [components[3] floatValue] : 1.0;
|
||||
return [UIColor colorWithRed:r green:g blue:b alpha:a];
|
||||
}
|
||||
}
|
||||
|
||||
// Named colors
|
||||
static NSDictionary *namedColors = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
namedColors = @{
|
||||
@"black" : [UIColor blackColor],
|
||||
@"white" : [UIColor whiteColor],
|
||||
@"red" : [UIColor redColor],
|
||||
@"green" : [UIColor greenColor],
|
||||
@"blue" : [UIColor blueColor],
|
||||
@"yellow" : [UIColor yellowColor],
|
||||
@"gray" : [UIColor grayColor],
|
||||
@"grey" : [UIColor grayColor],
|
||||
@"cyan" : [UIColor cyanColor],
|
||||
@"magenta" : [UIColor magentaColor],
|
||||
@"orange" : [UIColor orangeColor],
|
||||
@"purple" : [UIColor purpleColor],
|
||||
@"clear" : [UIColor clearColor],
|
||||
};
|
||||
});
|
||||
|
||||
UIColor *named = namedColors[cssValue.lowercaseString];
|
||||
if (named) return named;
|
||||
|
||||
return nil;
|
||||
}
|
||||
|
||||
@end
|
||||
111
Doc/WXRead/decompiled/WRBookmark.h
Normal file
111
Doc/WXRead/decompiled/WRBookmark.h
Normal file
@ -0,0 +1,111 @@
|
||||
//
|
||||
// WRBookmark.h
|
||||
// WeRead (微信读书)
|
||||
// Reverse-engineered header
|
||||
//
|
||||
// Bookmark model. Stores bookId, chapterUid, and position information.
|
||||
// Supports highlights, underlines, and page marks with associated text.
|
||||
// Synced with server via WRBookNetwork.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@class WRBook;
|
||||
@class WRMPReview;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bookmark types
|
||||
// ---------------------------------------------------------------------------
|
||||
typedef NS_ENUM(NSInteger, WRBookmarkType) {
|
||||
WRBookmarkTypeHighlight = 0, // Yellow/blue/etc highlight
|
||||
WRBookmarkTypeUnderline = 1, // Underline annotation
|
||||
WRBookmarkTypeMark = 2, // Page bookmark (dog-ear)
|
||||
WRBookmarkTypeNote = 3, // Written note
|
||||
WRBookmarkTypePencil = 4, // Apple Pencil drawing
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WRBookmark
|
||||
// ---------------------------------------------------------------------------
|
||||
@interface WRBookmark : NSObject
|
||||
|
||||
// --- Ivars (from binary analysis) ---
|
||||
// NSString (many): bookId, chapterUid, markText, colorStyle, reviewId,
|
||||
// anchorId, rangeKey, noteContent, etc.
|
||||
// WRBook: associated book model
|
||||
// WRMPReview: associated review/comment model
|
||||
// NSArray: range info, selected text fragments
|
||||
// NSDictionary: extra metadata
|
||||
// NSMutableSet: tags
|
||||
// NSDictionary: sync metadata (syncKey, serverVersion)
|
||||
|
||||
@property (nonatomic, copy) NSString *bookmarkId; // unique bookmark ID
|
||||
@property (nonatomic, copy) NSString *bookId; // book identifier
|
||||
@property (nonatomic, copy) NSString *chapterUid; // chapter UID
|
||||
@property (nonatomic, assign) NSInteger chapterOffset; // character offset within chapter
|
||||
@property (nonatomic, assign) NSInteger chapterIndex; // chapter index in spine
|
||||
|
||||
@property (nonatomic, copy) NSString *markText; // highlighted/marked text
|
||||
@property (nonatomic, copy, nullable) NSString *noteContent; // user note text
|
||||
|
||||
@property (nonatomic, assign) WRBookmarkType type; // bookmark type
|
||||
@property (nonatomic, copy, nullable) NSString *colorStyle; // highlight color (e.g., "yellow", "blue", "red", "green")
|
||||
|
||||
@property (nonatomic, assign) NSInteger startPos; // start position (global)
|
||||
@property (nonatomic, assign) NSInteger endPos; // end position (global)
|
||||
@property (nonatomic, assign) NSInteger rangeLength; // length of the range
|
||||
|
||||
@property (nonatomic, copy, nullable) NSString *anchorId; // DOM anchor ID
|
||||
@property (nonatomic, copy, nullable) NSString *rangeKey; // range key for sync
|
||||
|
||||
@property (nonatomic, strong, nullable) WRBook *book; // associated book
|
||||
@property (nonatomic, strong, nullable) WRMPReview *review; // associated review
|
||||
|
||||
@property (nonatomic, strong, nullable) NSArray *rangeInfo; // detailed range info
|
||||
@property (nonatomic, strong, nullable) NSDictionary *extraMetadata; // additional data
|
||||
@property (nonatomic, strong, nullable) NSMutableSet<NSString *> *tags;
|
||||
|
||||
@property (nonatomic, assign) NSTimeInterval createTime; // creation timestamp
|
||||
@property (nonatomic, assign) NSTimeInterval updateTime; // last update timestamp
|
||||
@property (nonatomic, assign) BOOL isSynced; // synced with server
|
||||
@property (nonatomic, copy, nullable) NSString *syncKey; // sync key for incremental updates
|
||||
|
||||
#pragma mark - Factory Methods
|
||||
|
||||
+ (instancetype)bookmarkWithBookId:(NSString *)bookId
|
||||
chapterUid:(NSString *)chapterUid
|
||||
offset:(NSInteger)offset
|
||||
text:(NSString *)text
|
||||
type:(WRBookmarkType)type;
|
||||
|
||||
+ (instancetype)highlightWithBookId:(NSString *)bookId
|
||||
chapterUid:(NSString *)chapterUid
|
||||
startPos:(NSInteger)startPos
|
||||
endPos:(NSInteger)endPos
|
||||
text:(NSString *)text
|
||||
colorStyle:(NSString *)colorStyle;
|
||||
|
||||
#pragma mark - Serialization
|
||||
|
||||
- (NSDictionary *)toDictionary;
|
||||
+ (nullable instancetype)fromDictionary:(NSDictionary *)dict;
|
||||
|
||||
/// Convert to JSON data for network sync.
|
||||
- (nullable NSData *)toJSONData;
|
||||
|
||||
/// Create from JSON data received from server.
|
||||
+ (nullable instancetype)fromJSONData:(NSData *)data;
|
||||
|
||||
#pragma mark - Display
|
||||
|
||||
/// Return a display-friendly summary string.
|
||||
- (NSString *)displaySummary;
|
||||
|
||||
/// Return the color as a UIColor.
|
||||
- (UIColor *)highlightUIColor;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
219
Doc/WXRead/decompiled/WRBookmark.m
Normal file
219
Doc/WXRead/decompiled/WRBookmark.m
Normal file
@ -0,0 +1,219 @@
|
||||
//
|
||||
// WRBookmark.m
|
||||
// WeRead (微信读书)
|
||||
// Reverse-engineered implementation reconstruction
|
||||
//
|
||||
// Detailed pseudo-code based on binary analysis (many NSString ivars,
|
||||
// WRBook, WRMPReview, NSArray, NSDictionary, NSMutableSet) and
|
||||
// contextual knowledge of WeRead's bookmark/annotation system.
|
||||
//
|
||||
|
||||
#import "WRBookmark.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
static NSDictionary<NSString *, UIColor *> *sColorMap = nil;
|
||||
|
||||
@implementation WRBookmark
|
||||
|
||||
#pragma mark - Class Initialization
|
||||
|
||||
+ (void)initialize
|
||||
{
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
sColorMap = @{
|
||||
@"yellow" : [UIColor colorWithRed:1.0 green:0.9 blue:0.3 alpha:0.4],
|
||||
@"blue" : [UIColor colorWithRed:0.3 green:0.6 blue:1.0 alpha:0.4],
|
||||
@"red" : [UIColor colorWithRed:1.0 green:0.3 blue:0.3 alpha:0.4],
|
||||
@"green" : [UIColor colorWithRed:0.3 green:0.9 blue:0.4 alpha:0.4],
|
||||
@"purple" : [UIColor colorWithRed:0.7 green:0.3 blue:0.9 alpha:0.4],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - Factory Methods
|
||||
|
||||
+ (instancetype)bookmarkWithBookId:(NSString *)bookId
|
||||
chapterUid:(NSString *)chapterUid
|
||||
offset:(NSInteger)offset
|
||||
text:(NSString *)text
|
||||
type:(WRBookmarkType)type
|
||||
{
|
||||
WRBookmark *bm = [[WRBookmark alloc] init];
|
||||
bm.bookId = bookId;
|
||||
bm.chapterUid = chapterUid;
|
||||
bm.chapterOffset = offset;
|
||||
bm.markText = text;
|
||||
bm.type = type;
|
||||
bm.createTime = [[NSDate date] timeIntervalSince1970];
|
||||
bm.updateTime = bm.createTime;
|
||||
bm.isSynced = NO;
|
||||
|
||||
// Generate a unique bookmark ID
|
||||
bm.bookmarkId = [[NSUUID UUID] UUIDString];
|
||||
|
||||
return bm;
|
||||
}
|
||||
|
||||
+ (instancetype)highlightWithBookId:(NSString *)bookId
|
||||
chapterUid:(NSString *)chapterUid
|
||||
startPos:(NSInteger)startPos
|
||||
endPos:(NSInteger)endPos
|
||||
text:(NSString *)text
|
||||
colorStyle:(NSString *)colorStyle
|
||||
{
|
||||
WRBookmark *bm = [self bookmarkWithBookId:bookId
|
||||
chapterUid:chapterUid
|
||||
offset:startPos
|
||||
text:text
|
||||
type:WRBookmarkTypeHighlight];
|
||||
bm.startPos = startPos;
|
||||
bm.endPos = endPos;
|
||||
bm.rangeLength = endPos - startPos;
|
||||
bm.colorStyle = colorStyle ?: @"yellow";
|
||||
|
||||
return bm;
|
||||
}
|
||||
|
||||
#pragma mark - Serialization
|
||||
|
||||
- (NSDictionary *)toDictionary
|
||||
{
|
||||
NSMutableDictionary *dict = [NSMutableDictionary dictionary];
|
||||
|
||||
if (_bookmarkId) dict[@"bookmarkId"] = _bookmarkId;
|
||||
if (_bookId) dict[@"bookId"] = _bookId;
|
||||
if (_chapterUid) dict[@"chapterUid"] = _chapterUid;
|
||||
dict[@"chapterOffset"] = @(_chapterOffset);
|
||||
dict[@"chapterIndex"] = @(_chapterIndex);
|
||||
if (_markText) dict[@"markText"] = _markText;
|
||||
if (_noteContent) dict[@"noteContent"] = _noteContent;
|
||||
dict[@"type"] = @(_type);
|
||||
if (_colorStyle) dict[@"colorStyle"] = _colorStyle;
|
||||
dict[@"startPos"] = @(_startPos);
|
||||
dict[@"endPos"] = @(_endPos);
|
||||
dict[@"rangeLength"] = @(_rangeLength);
|
||||
if (_anchorId) dict[@"anchorId"] = _anchorId;
|
||||
if (_rangeKey) dict[@"rangeKey"] = _rangeKey;
|
||||
dict[@"createTime"] = @(_createTime);
|
||||
dict[@"updateTime"] = @(_updateTime);
|
||||
dict[@"isSynced"] = @(_isSynced);
|
||||
if (_syncKey) dict[@"syncKey"] = _syncKey;
|
||||
if (_rangeInfo) dict[@"rangeInfo"] = _rangeInfo;
|
||||
if (_extraMetadata) dict[@"extraMetadata"] = _extraMetadata;
|
||||
|
||||
if (_tags.count > 0) {
|
||||
dict[@"tags"] = [_tags allObjects];
|
||||
}
|
||||
|
||||
return [dict copy];
|
||||
}
|
||||
|
||||
+ (nullable instancetype)fromDictionary:(NSDictionary *)dict
|
||||
{
|
||||
if (!dict) return nil;
|
||||
|
||||
WRBookmark *bm = [[WRBookmark alloc] init];
|
||||
|
||||
bm.bookmarkId = dict[@"bookmarkId"];
|
||||
bm.bookId = dict[@"bookId"];
|
||||
bm.chapterUid = dict[@"chapterUid"];
|
||||
bm.chapterOffset = [dict[@"chapterOffset"] integerValue];
|
||||
bm.chapterIndex = [dict[@"chapterIndex"] integerValue];
|
||||
bm.markText = dict[@"markText"];
|
||||
bm.noteContent = dict[@"noteContent"];
|
||||
bm.type = [dict[@"type"] integerValue];
|
||||
bm.colorStyle = dict[@"colorStyle"];
|
||||
bm.startPos = [dict[@"startPos"] integerValue];
|
||||
bm.endPos = [dict[@"endPos"] integerValue];
|
||||
bm.rangeLength = [dict[@"rangeLength"] integerValue];
|
||||
bm.anchorId = dict[@"anchorId"];
|
||||
bm.rangeKey = dict[@"rangeKey"];
|
||||
bm.createTime = [dict[@"createTime"] doubleValue];
|
||||
bm.updateTime = [dict[@"updateTime"] doubleValue];
|
||||
bm.isSynced = [dict[@"isSynced"] boolValue];
|
||||
bm.syncKey = dict[@"syncKey"];
|
||||
bm.rangeInfo = dict[@"rangeInfo"];
|
||||
bm.extraMetadata = dict[@"extraMetadata"];
|
||||
|
||||
NSArray *tags = dict[@"tags"];
|
||||
if (tags) {
|
||||
bm.tags = [NSMutableSet setWithArray:tags];
|
||||
}
|
||||
|
||||
return bm;
|
||||
}
|
||||
|
||||
- (nullable NSData *)toJSONData
|
||||
{
|
||||
NSDictionary *dict = [self toDictionary];
|
||||
return [NSJSONSerialization dataWithJSONObject:dict options:0 error:nil];
|
||||
}
|
||||
|
||||
+ (nullable instancetype)fromJSONData:(NSData *)data
|
||||
{
|
||||
if (!data) return nil;
|
||||
|
||||
NSError *error = nil;
|
||||
NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:data
|
||||
options:0
|
||||
error:&error];
|
||||
if (!dict || ![dict isKindOfClass:[NSDictionary class]]) return nil;
|
||||
|
||||
return [self fromDictionary:dict];
|
||||
}
|
||||
|
||||
#pragma mark - Display
|
||||
|
||||
- (NSString *)displaySummary
|
||||
{
|
||||
switch (_type) {
|
||||
case WRBookmarkTypeHighlight:
|
||||
return [NSString stringWithFormat:@"[Highlight] %@", _markText ?: @""];
|
||||
case WRBookmarkTypeUnderline:
|
||||
return [NSString stringWithFormat:@"[Underline] %@", _markText ?: @""];
|
||||
case WRBookmarkTypeMark:
|
||||
return [NSString stringWithFormat:@"[Bookmark] Chapter %@", _chapterUid ?: @""];
|
||||
case WRBookmarkTypeNote:
|
||||
return [NSString stringWithFormat:@"[Note] %@", _noteContent ?: _markText ?: @""];
|
||||
case WRBookmarkTypePencil:
|
||||
return @"[Pencil Note]";
|
||||
default:
|
||||
return _markText ?: @"";
|
||||
}
|
||||
}
|
||||
|
||||
- (UIColor *)highlightUIColor
|
||||
{
|
||||
if (!_colorStyle) {
|
||||
return sColorMap[@"yellow"] ?: [UIColor yellowColor];
|
||||
}
|
||||
return sColorMap[_colorStyle] ?: [UIColor yellowColor];
|
||||
}
|
||||
|
||||
#pragma mark - NSObject
|
||||
|
||||
- (NSString *)description
|
||||
{
|
||||
return [NSString stringWithFormat:@"<WRBookmark: %@ type=%ld book=%@ ch=%@ '%@'>",
|
||||
_bookmarkId, (long)_type, _bookId, _chapterUid,
|
||||
[_markText substringToIndex:MIN(30, _markText.length)]];
|
||||
}
|
||||
|
||||
- (BOOL)isEqual:(id)object
|
||||
{
|
||||
if (self == object) return YES;
|
||||
if (![object isKindOfClass:[WRBookmark class]]) return NO;
|
||||
|
||||
WRBookmark *other = (WRBookmark *)object;
|
||||
return [self.bookmarkId isEqualToString:other.bookmarkId];
|
||||
}
|
||||
|
||||
- (NSUInteger)hash
|
||||
{
|
||||
return self.bookmarkId.hash;
|
||||
}
|
||||
|
||||
@end
|
||||
183
Doc/WXRead/decompiled/WRChapterData.h
Normal file
183
Doc/WXRead/decompiled/WRChapterData.h
Normal file
@ -0,0 +1,183 @@
|
||||
//
|
||||
// WRChapterData.h
|
||||
// WeRead (微信读书)
|
||||
//
|
||||
// Reverse-engineered header reconstruction.
|
||||
// Chapter data model. Stores the typeset NSAttributedString.
|
||||
// Manages highlights, underlines, reviews/annotations.
|
||||
// Uses WRCoreTextLayouter for layout computation.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@class WRCoreTextLayouter;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Highlight / Underline Style Constants
|
||||
// ============================================================================
|
||||
|
||||
/// Style of underline drawn for highlights and annotations.
|
||||
typedef NS_ENUM(NSInteger, WRUnderlineStyle) {
|
||||
WRUnderlineStyleNone = 0,
|
||||
WRUnderlineStyleSolid = 1,
|
||||
WRUnderlineStyleDashed = 2,
|
||||
WRUnderlineStyleWavy = 3,
|
||||
};
|
||||
|
||||
/// Type of review / annotation.
|
||||
typedef NS_ENUM(NSInteger, WRReviewType) {
|
||||
WRReviewTypeHighlight = 0, // Color highlight
|
||||
WRReviewTypeUnderline = 1, // Underline only
|
||||
WRReviewTypeNote = 2, // Text note attached to range
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - WRChapterData
|
||||
// ============================================================================
|
||||
|
||||
@interface WRChapterData : NSObject
|
||||
|
||||
// ---- Core content ----
|
||||
|
||||
/// The fully typeset attributed string for this chapter, with all fonts,
|
||||
/// colors, paragraph styles, and inline image attachments applied.
|
||||
@property (nonatomic, strong, nullable) NSMutableAttributedString *typesetAttributedString;
|
||||
|
||||
/// The layouter that computes line breaks and page breaks for this chapter.
|
||||
@property (nonatomic, strong, nullable) WRCoreTextLayouter *layouter;
|
||||
|
||||
// ---- Page ranges ----
|
||||
|
||||
/// Array of NSValue-wrapped NSRange values, one per page.
|
||||
/// Each range is a character range within typesetAttributedString.
|
||||
@property (nonatomic, strong, nullable) NSArray<NSValue *> *pageRanges;
|
||||
|
||||
// ---- Highlights and annotations ----
|
||||
|
||||
/// Array of highlight dictionaries. Each entry contains:
|
||||
/// @"range" : NSValue wrapping NSRange
|
||||
/// @"key" : NSString (unique highlight ID)
|
||||
/// @"itemId" : NSString (item identifier, e.g., bookmark ID)
|
||||
/// @"color" : UIColor
|
||||
@property (nonatomic, strong, nullable) NSArray<NSDictionary *> *highlights;
|
||||
|
||||
/// Array of underline / review dictionaries. Each entry contains:
|
||||
/// @"range" : NSValue wrapping NSRange
|
||||
/// @"itemId" : NSString
|
||||
/// @"type" : @(WRReviewType)
|
||||
/// @"style" : @(WRUnderlineStyle)
|
||||
/// @"color" : UIColor
|
||||
@property (nonatomic, strong, nullable) NSArray<NSDictionary *> *underlines;
|
||||
|
||||
/// Temporary review highlight (not yet saved), used during review creation.
|
||||
@property (nonatomic, strong, nullable) NSArray<NSDictionary *> *tempReviewHighlights;
|
||||
|
||||
/// Set of bookmarked page indices (NSSet of NSNumber).
|
||||
@property (nonatomic, strong, nullable) NSSet<NSNumber *> *bookmarkedPages;
|
||||
|
||||
/// Raw chapter source text (before typesetting).
|
||||
@property (nonatomic, strong, nullable) NSAttributedString *sourceAttributedString;
|
||||
|
||||
// ---- Chapter metadata ----
|
||||
@property (nonatomic, copy, nullable) NSString *chapterId;
|
||||
@property (nonatomic, copy, nullable) NSString *chapterTitle;
|
||||
@property (nonatomic, assign) NSUInteger chapterIndex;
|
||||
|
||||
// ---- Content insets for the reading area ----
|
||||
@property (nonatomic, assign) UIEdgeInsets contentInsets;
|
||||
|
||||
// ---- Outline / TOC ----
|
||||
|
||||
/// Array of outline entry dictionaries generated from headings in the chapter.
|
||||
/// Each entry: @"title", @"level", @"range" (NSValue wrapping NSRange).
|
||||
@property (nonatomic, strong, nullable) NSArray<NSDictionary *> *outlineContents;
|
||||
|
||||
// ---- Free trial cutoff ----
|
||||
|
||||
/// The string location (character index) at which the free trial ends.
|
||||
/// NSNotFound if the chapter is fully accessible.
|
||||
@property (nonatomic, assign) NSUInteger freeTrialCutOffLocation;
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Class Methods
|
||||
// ============================================================================
|
||||
|
||||
/// Adds an underline decoration to the given attributed string at the specified
|
||||
/// range, with the given style, color, and associated item ID.
|
||||
+ (void)addUnderLineToAttributedString:(NSMutableAttributedString *)attributedString
|
||||
range:(NSRange)range
|
||||
itemId:(NSString *)itemId
|
||||
style:(WRUnderlineStyle)style
|
||||
color:(UIColor *)color;
|
||||
|
||||
/// Calculates the free trial cutoff string location within the attributed
|
||||
/// string for the given book. Returns the character index where content
|
||||
/// should be truncated for non-paying users.
|
||||
+ (NSUInteger)freeTrialChapterCutOffStringLocaionWithAttributedString:(NSAttributedString *)attributedString
|
||||
book:(id)book;
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Instance Methods — Highlights & Underlines
|
||||
// ============================================================================
|
||||
|
||||
/// Adds an auto-read underline (visual indicator for auto-scroll mode).
|
||||
- (void)addAutoReadUnderLineInRange:(NSRange)range
|
||||
style:(WRUnderlineStyle)style
|
||||
color:(UIColor *)color;
|
||||
|
||||
/// Adds a highlight annotation.
|
||||
- (void)addHighlightInRange:(NSRange)range
|
||||
key:(NSString *)key
|
||||
itemId:(NSString *)itemId
|
||||
color:(UIColor *)color;
|
||||
|
||||
/// Adds a review underline (e.g., from a friend's review).
|
||||
- (void)addReviewUnderlineInRange:(NSRange)range
|
||||
itemId:(NSString *)itemId
|
||||
type:(WRReviewType)type;
|
||||
|
||||
/// Adds a temporary review highlight (not persisted until confirmed).
|
||||
- (void)addTempReviewHighlightInRange:(NSRange)range
|
||||
itemId:(NSString *)itemId;
|
||||
|
||||
/// Adds a temporary review highlight with a custom color.
|
||||
- (void)addTempReviewHighlightInRange:(NSRange)range
|
||||
itemId:(NSString *)itemId
|
||||
color:(UIColor *)color;
|
||||
|
||||
/// Removes a review underline by range and type.
|
||||
- (void)deleteReviewUnderlineInRange:(NSRange)range
|
||||
type:(WRReviewType)type;
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Instance Methods — Page Queries
|
||||
// ============================================================================
|
||||
|
||||
/// Returns the character range within typesetAttributedString for the given
|
||||
/// page index (0-based). Uses the pageRanges array computed during layout.
|
||||
- (NSRange)rangeOfPage:(NSUInteger)pageIndex;
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Instance Methods — Outline
|
||||
// ============================================================================
|
||||
|
||||
/// Scans the attributed string for heading styles and builds the
|
||||
/// outlineContents array.
|
||||
- (void)generateOutlineContents;
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Instance Methods — Free Trial
|
||||
// ============================================================================
|
||||
|
||||
/// Returns the real (post-typeset) string location for the free trial cutoff.
|
||||
- (NSUInteger)freeTrialChapterCutOffRealStringLocation;
|
||||
|
||||
/// Sets the free trial cutoff string location.
|
||||
- (void)markFreeTrialChapterCutOffStringLocation:(NSUInteger)location;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
442
Doc/WXRead/decompiled/WRChapterData.m
Normal file
442
Doc/WXRead/decompiled/WRChapterData.m
Normal file
@ -0,0 +1,442 @@
|
||||
//
|
||||
// WRChapterData.m
|
||||
// WeRead (微信读书)
|
||||
//
|
||||
// Detailed pseudo-code reconstruction of the chapter data model.
|
||||
// Stores the typeset NSAttributedString, manages highlights, underlines,
|
||||
// reviews, page ranges, and outline generation.
|
||||
//
|
||||
|
||||
#import "WRChapterData.h"
|
||||
#import "WRCoreTextLayouter.h"
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Constants
|
||||
// ============================================================================
|
||||
|
||||
static NSString *const kHighlightRangeKey = @"range";
|
||||
static NSString *const kHighlightKeyKey = @"key";
|
||||
static NSString *const kHighlightItemIdKey = @"itemId";
|
||||
static NSString *const kHighlightColorKey = @"color";
|
||||
static NSString *const kUnderlineStyleKey = @"style";
|
||||
static NSString *const kUnderlineTypeKey = @"type";
|
||||
|
||||
// Custom attribute name used in the attributed string to mark underlines.
|
||||
static NSString *const kWRUnderlineAttributeName =
|
||||
@"com.weread.underline";
|
||||
|
||||
// Custom attribute name for highlight color.
|
||||
static NSString *const kWRHighlightAttributeName =
|
||||
@"com.weread.highlight";
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - WRChapterData ()
|
||||
// ============================================================================
|
||||
|
||||
@interface WRChapterData ()
|
||||
|
||||
/// Internal mutable copy of highlights for mutation.
|
||||
@property (nonatomic, strong) NSMutableArray<NSDictionary *> *mutableHighlights;
|
||||
|
||||
/// Internal mutable copy of underlines.
|
||||
@property (nonatomic, strong) NSMutableArray<NSDictionary *> *mutableUnderlines;
|
||||
|
||||
/// Internal mutable copy of temp review highlights.
|
||||
@property (nonatomic, strong) NSMutableArray<NSDictionary *> *mutableTempReviewHighlights;
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - WRChapterData Implementation
|
||||
// ============================================================================
|
||||
|
||||
@implementation WRChapterData
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Initialization
|
||||
// ============================================================================
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_mutableHighlights = [NSMutableArray array];
|
||||
_mutableUnderlines = [NSMutableArray array];
|
||||
_mutableTempReviewHighlights = [NSMutableArray array];
|
||||
_bookmarkedPages = [NSSet set];
|
||||
_freeTrialCutOffLocation = NSNotFound;
|
||||
_contentInsets = UIEdgeInsetsMake(20.0, 16.0, 20.0, 16.0);
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Class Methods
|
||||
// ============================================================================
|
||||
|
||||
///
|
||||
/// Adds an underline decoration to an NSMutableAttributedString at the
|
||||
/// specified range. The underline is stored as a custom attribute so the
|
||||
/// drawing code can render it with the correct style and color.
|
||||
///
|
||||
/// @param attributedString The mutable attributed string to modify.
|
||||
/// @param range The character range to underline.
|
||||
/// @param itemId Identifier for the item (e.g., bookmark or review ID).
|
||||
/// @param style The underline style (solid, dashed, wavy).
|
||||
/// @param color The underline color.
|
||||
///
|
||||
+ (void)addUnderLineToAttributedString:(NSMutableAttributedString *)attributedString
|
||||
range:(NSRange)range
|
||||
itemId:(NSString *)itemId
|
||||
style:(WRUnderlineStyle)style
|
||||
color:(UIColor *)color {
|
||||
if (!attributedString || range.location == NSNotFound) return;
|
||||
if (NSMaxRange(range) > attributedString.length) return;
|
||||
|
||||
// Build the underline descriptor dictionary.
|
||||
NSDictionary *underlineInfo = @{
|
||||
kHighlightItemIdKey : itemId ?: @"",
|
||||
kUnderlineStyleKey : @(style),
|
||||
kHighlightColorKey : color ?: [UIColor blackColor],
|
||||
};
|
||||
|
||||
// Apply as a custom attribute. The rendering code in WRCoreTextLayoutFrame
|
||||
// will read this attribute and draw the underline during -drawInContext:.
|
||||
[attributedString addAttribute:kWRUnderlineAttributeName
|
||||
value:underlineInfo
|
||||
range:range];
|
||||
}
|
||||
|
||||
///
|
||||
/// Calculates the free trial cutoff location within the attributed string.
|
||||
/// The book object provides trial chapter limits; this method finds the
|
||||
/// corresponding character position in the typeset string.
|
||||
///
|
||||
/// @param attributedString The typeset attributed string.
|
||||
/// @param book The book model object (provides trial info).
|
||||
/// @return The character index at which to cut off, or NSNotFound if fully accessible.
|
||||
///
|
||||
+ (NSUInteger)freeTrialChapterCutOffStringLocaionWithAttributedString:(NSAttributedString *)attributedString
|
||||
book:(id)book {
|
||||
if (!attributedString || !book) return NSNotFound;
|
||||
|
||||
// In the real implementation, this queries the book model for:
|
||||
// - The number of free trial characters / chapters allowed.
|
||||
// - Whether this specific chapter falls within the trial range.
|
||||
// It then maps that to a character index in the attributed string.
|
||||
//
|
||||
// Typical logic:
|
||||
// 1. Ask `book` for the trial character count or chapter index limit.
|
||||
// 2. If this chapter is entirely within the trial, return NSNotFound (no cutoff).
|
||||
// 3. If this chapter is entirely beyond the trial, return 0 (show nothing).
|
||||
// 4. If the cutoff falls within this chapter, calculate the offset.
|
||||
|
||||
// Placeholder: assume the book responds to -freeTrialCharacterLimit.
|
||||
if ([book respondsToSelector:NSSelectorFromString(@"freeTrialCharacterLimit")]) {
|
||||
NSUInteger limit = [[book valueForKey:@"freeTrialCharacterLimit"] unsignedIntegerValue];
|
||||
NSUInteger totalLength = attributedString.length;
|
||||
|
||||
if (limit >= totalLength) {
|
||||
// Entire chapter is accessible.
|
||||
return NSNotFound;
|
||||
} else if (limit == 0) {
|
||||
// No access.
|
||||
return 0;
|
||||
} else {
|
||||
return limit;
|
||||
}
|
||||
}
|
||||
|
||||
return NSNotFound;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Highlight & Underline Management
|
||||
// ============================================================================
|
||||
|
||||
/// Adds an auto-read underline. This is a visual indicator showing which
|
||||
/// text is being auto-scrolled through.
|
||||
- (void)addAutoReadUnderLineInRange:(NSRange)range
|
||||
style:(WRUnderlineStyle)style
|
||||
color:(UIColor *)color {
|
||||
if (range.location == NSNotFound) return;
|
||||
|
||||
// Apply the underline attribute to the typeset string.
|
||||
[WRChapterData addUnderLineToAttributedString:self.typesetAttributedString
|
||||
range:range
|
||||
itemId:@"autoRead"
|
||||
style:style
|
||||
color:color];
|
||||
}
|
||||
|
||||
/// Adds a persistent highlight annotation.
|
||||
- (void)addHighlightInRange:(NSRange)range
|
||||
key:(NSString *)key
|
||||
itemId:(NSString *)itemId
|
||||
color:(UIColor *)color {
|
||||
if (range.location == NSNotFound) return;
|
||||
|
||||
NSDictionary *entry = @{
|
||||
kHighlightRangeKey : [NSValue valueWithRange:range],
|
||||
kHighlightKeyKey : key ?: @"",
|
||||
kHighlightItemIdKey : itemId ?: @"",
|
||||
kHighlightColorKey : color ?: [UIColor yellowColor],
|
||||
};
|
||||
|
||||
[_mutableHighlights addObject:entry];
|
||||
_highlights = [_mutableHighlights copy];
|
||||
|
||||
// Also apply the highlight as a custom attribute on the attributed string
|
||||
// so the CoreText drawing code can render the background color.
|
||||
[self.typesetAttributedString addAttribute:kWRHighlightAttributeName
|
||||
value:@{kHighlightColorKey: (color ?: [UIColor yellowColor])}
|
||||
range:range];
|
||||
}
|
||||
|
||||
/// Adds a review underline (from a friend's annotation or review).
|
||||
- (void)addReviewUnderlineInRange:(NSRange)range
|
||||
itemId:(NSString *)itemId
|
||||
type:(WRReviewType)type {
|
||||
if (range.location == NSNotFound) return;
|
||||
|
||||
NSDictionary *entry = @{
|
||||
kHighlightRangeKey : [NSValue valueWithRange:range],
|
||||
kHighlightItemIdKey : itemId ?: @"",
|
||||
kUnderlineTypeKey : @(type),
|
||||
};
|
||||
|
||||
[_mutableUnderlines addObject:entry];
|
||||
_underlines = [_mutableUnderlines copy];
|
||||
|
||||
// Apply underline attribute for rendering.
|
||||
WRUnderlineStyle style = (type == WRReviewTypeHighlight)
|
||||
? WRUnderlineStyleNone
|
||||
: WRUnderlineStyleSolid;
|
||||
UIColor *color = (type == WRReviewTypeHighlight)
|
||||
? [UIColor colorWithRed:0.2 green:0.6 blue:1.0 alpha:0.3]
|
||||
: [UIColor colorWithRed:1.0 green:0.4 blue:0.4 alpha:0.8];
|
||||
|
||||
if (style != WRUnderlineStyleNone) {
|
||||
[WRChapterData addUnderLineToAttributedString:self.typesetAttributedString
|
||||
range:range
|
||||
itemId:itemId
|
||||
style:style
|
||||
color:color];
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a temporary review highlight (used during the review creation flow
|
||||
/// before the user confirms and persists it).
|
||||
- (void)addTempReviewHighlightInRange:(NSRange)range
|
||||
itemId:(NSString *)itemId {
|
||||
[self addTempReviewHighlightInRange:range
|
||||
itemId:itemId
|
||||
color:[UIColor colorWithRed:0.2
|
||||
green:0.6
|
||||
blue:1.0
|
||||
alpha:0.3]];
|
||||
}
|
||||
|
||||
/// Adds a temporary review highlight with a custom color.
|
||||
- (void)addTempReviewHighlightInRange:(NSRange)range
|
||||
itemId:(NSString *)itemId
|
||||
color:(UIColor *)color {
|
||||
if (range.location == NSNotFound) return;
|
||||
|
||||
NSDictionary *entry = @{
|
||||
kHighlightRangeKey : [NSValue valueWithRange:range],
|
||||
kHighlightItemIdKey : itemId ?: @"",
|
||||
kHighlightColorKey : color ?: [UIColor yellowColor],
|
||||
};
|
||||
|
||||
[_mutableTempReviewHighlights addObject:entry];
|
||||
_tempReviewHighlights = [_mutableTempReviewHighlights copy];
|
||||
|
||||
// Apply as a temporary attribute (not persisted).
|
||||
[self.typesetAttributedString addAttribute:kWRHighlightAttributeName
|
||||
value:@{kHighlightColorKey: (color ?: [UIColor yellowColor]),
|
||||
@"temporary": @YES}
|
||||
range:range];
|
||||
}
|
||||
|
||||
/// Removes a review underline from both the internal array and the
|
||||
/// attributed string attributes.
|
||||
- (void)deleteReviewUnderlineInRange:(NSRange)range
|
||||
type:(WRReviewType)type {
|
||||
if (range.location == NSNotFound) return;
|
||||
|
||||
// Remove matching entries from the mutable array.
|
||||
NSMutableArray *toRemove = [NSMutableArray array];
|
||||
for (NSDictionary *entry in _mutableUnderlines) {
|
||||
NSRange entryRange = [entry[kHighlightRangeKey] rangeValue];
|
||||
WRReviewType entryType = [entry[kUnderlineTypeKey] integerValue];
|
||||
if (NSEqualRanges(entryRange, range) && entryType == type) {
|
||||
[toRemove addObject:entry];
|
||||
}
|
||||
}
|
||||
[_mutableUnderlines removeObjectsInArray:toRemove];
|
||||
_underlines = [_mutableUnderlines copy];
|
||||
|
||||
// Remove the custom underline/highlight attributes from the string.
|
||||
[self.typesetAttributedString removeAttribute:kWRUnderlineAttributeName range:range];
|
||||
[self.typesetAttributedString removeAttribute:kWRHighlightAttributeName range:range];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Page Range Queries
|
||||
// ============================================================================
|
||||
|
||||
/// Returns the character range for the given page index.
|
||||
/// The pageRanges array is computed during layout by WRCoreTextLayouter
|
||||
/// and stored as an array of NSValue-wrapped NSRange objects.
|
||||
- (NSRange)rangeOfPage:(NSUInteger)pageIndex {
|
||||
if (pageIndex >= self.pageRanges.count) {
|
||||
return NSMakeRange(NSNotFound, 0);
|
||||
}
|
||||
return [self.pageRanges[pageIndex] rangeValue];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Outline Generation
|
||||
// ============================================================================
|
||||
|
||||
/// Scans the typeset attributed string for heading-level paragraph styles
|
||||
/// and builds an array of outline entries. Each entry is a dictionary with:
|
||||
/// @"title" : NSString (the heading text)
|
||||
/// @"level" : NSNumber (1 for H1, 2 for H2, etc.)
|
||||
/// @"range" : NSValue wrapping the NSRange in the attributed string.
|
||||
- (void)generateOutlineContents {
|
||||
NSMutableArray<NSDictionary *> *outline = [NSMutableArray array];
|
||||
NSAttributedString *str = self.typesetAttributedString;
|
||||
if (!str || str.length == 0) {
|
||||
self.outlineContents = @[];
|
||||
return;
|
||||
}
|
||||
|
||||
// Walk the attributed string by paragraph.
|
||||
NSString *plainText = [str string];
|
||||
NSUInteger length = plainText.length;
|
||||
NSUInteger searchLoc = 0;
|
||||
|
||||
while (searchLoc < length) {
|
||||
// Find the paragraph range.
|
||||
NSRange paraRange = [plainText rangeOfString:@"\n"
|
||||
options:0
|
||||
range:NSMakeRange(searchLoc, length - searchLoc)];
|
||||
if (paraRange.location == NSNotFound) {
|
||||
paraRange = NSMakeRange(searchLoc, length - searchLoc);
|
||||
} else {
|
||||
paraRange = NSMakeRange(searchLoc, paraRange.location - searchLoc + 1);
|
||||
}
|
||||
|
||||
// Check if this paragraph has a heading font attribute.
|
||||
if (paraRange.length > 0) {
|
||||
NSDictionary *attrs = [str attributesAtIndex:paraRange.location
|
||||
effectiveRange:NULL];
|
||||
UIFont *font = attrs[NSFontAttributeName];
|
||||
|
||||
// Heuristic: heading fonts are typically larger than body text.
|
||||
// In WeRead, headings may use a custom attribute or a specific
|
||||
// font descriptor. We check for font size > bodySize + 4.
|
||||
CGFloat bodySize = 16.0; // Typical body font size.
|
||||
if (font && font.pointSize > bodySize + 4.0) {
|
||||
NSString *title = [plainText substringWithRange:
|
||||
NSMakeRange(paraRange.location,
|
||||
paraRange.length > 1 ? paraRange.length - 1 : paraRange.length)];
|
||||
title = [title stringByTrimmingCharactersInSet:
|
||||
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
|
||||
if (title.length > 0) {
|
||||
NSInteger level = 1;
|
||||
if (font.pointSize < bodySize + 8.0) level = 2;
|
||||
if (font.pointSize < bodySize + 6.0) level = 3;
|
||||
|
||||
[outline addObject:@{
|
||||
@"title": title,
|
||||
@"level": @(level),
|
||||
@"range": [NSValue valueWithRange:paraRange],
|
||||
}];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
searchLoc = NSMaxRange(paraRange);
|
||||
if (searchLoc >= length) break;
|
||||
}
|
||||
|
||||
self.outlineContents = [outline copy];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Free Trial Cutoff
|
||||
// ============================================================================
|
||||
|
||||
/// Returns the real string location accounting for typesetting differences.
|
||||
/// The raw cutoff location from the server may differ from the position in
|
||||
/// the typeset attributed string due to inserted image attachments, etc.
|
||||
- (NSUInteger)freeTrialChapterCutOffRealStringLocation {
|
||||
if (self.freeTrialCutOffLocation == NSNotFound) {
|
||||
return NSNotFound;
|
||||
}
|
||||
|
||||
// In the real implementation, this maps from the source string index
|
||||
// to the typeset string index, accounting for:
|
||||
// - Image attachment characters () inserted during typesetting.
|
||||
// - Font substitution changes in string length (rare).
|
||||
//
|
||||
// Simple approach: walk both strings in parallel, counting the offset.
|
||||
|
||||
NSUInteger sourceLoc = self.freeTrialCutOffLocation;
|
||||
NSAttributedString *source = self.sourceAttributedString;
|
||||
NSMutableAttributedString *typeset = self.typesetAttributedString;
|
||||
|
||||
if (!source || !typeset) return sourceLoc;
|
||||
|
||||
// If the source and typeset strings have the same length, no mapping needed.
|
||||
if (source.length == typeset.length) return sourceLoc;
|
||||
|
||||
// Otherwise, use a character-by-character mapping.
|
||||
// This is a simplified version; the real code may use a precomputed map.
|
||||
NSUInteger typesetLoc = 0;
|
||||
NSUInteger sourceIdx = 0;
|
||||
NSString *sourcePlain = [source string];
|
||||
NSString *typesetPlain = [typeset string];
|
||||
|
||||
while (sourceIdx < sourceLoc && typesetLoc < typesetPlain.length) {
|
||||
// Skip image attachment characters in the typeset string.
|
||||
unichar tc = [typesetPlain characterAtIndex:typesetLoc];
|
||||
if (tc == 0xFFFC) { // NSAttachmentCharacter
|
||||
typesetLoc++;
|
||||
continue;
|
||||
}
|
||||
sourceIdx++;
|
||||
typesetLoc++;
|
||||
}
|
||||
|
||||
return typesetLoc;
|
||||
}
|
||||
|
||||
/// Sets the free trial cutoff location from the source string index.
|
||||
- (void)markFreeTrialChapterCutOffStringLocation:(NSUInteger)location {
|
||||
self.freeTrialCutOffLocation = location;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Property Accessors (Ivar-backed from binary)
|
||||
// ============================================================================
|
||||
|
||||
// The binary shows these ivar types:
|
||||
// NSMutableAttributedString -> _typesetAttributedString
|
||||
// NSArray -> _pageRanges
|
||||
// NSArray -> _highlights
|
||||
// NSArray -> _underlines
|
||||
// NSArray -> _tempReviewHighlights
|
||||
// NSArray -> _outlineContents
|
||||
// NSArray -> _imageAttachments (used by layout for inline images)
|
||||
// WRCoreTextLayouter -> _layouter
|
||||
// NSSet -> _bookmarkedPages
|
||||
// NSAttributedString -> _sourceAttributedString
|
||||
|
||||
// These are standard @property synthesized accessors; the binary confirms
|
||||
// the backing ivar names and types match.
|
||||
|
||||
@end
|
||||
82
Doc/WXRead/decompiled/WRChapterPageCount.h
Normal file
82
Doc/WXRead/decompiled/WRChapterPageCount.h
Normal file
@ -0,0 +1,82 @@
|
||||
//
|
||||
// WRChapterPageCount.h
|
||||
// WeRead (微信读书)
|
||||
//
|
||||
// Reverse-engineered header reconstruction.
|
||||
// Pagination calculator. Manages NSRange for each page within a chapter.
|
||||
// Computes page breaks based on the typeset attributed string and the
|
||||
// available drawing area.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - WRChapterPageCount
|
||||
// ============================================================================
|
||||
|
||||
@interface WRChapterPageCount : NSObject
|
||||
|
||||
// ---- Ivars from binary: NSString, NSString, NSString, NSArray ----
|
||||
|
||||
/// The book identifier this page count data belongs to.
|
||||
@property (nonatomic, copy, nullable) NSString *bookId;
|
||||
|
||||
/// The chapter identifier.
|
||||
@property (nonatomic, copy, nullable) NSString *chapterId;
|
||||
|
||||
/// A cache key string combining book and chapter info for disk caching.
|
||||
@property (nonatomic, copy, nullable) NSString *cacheKey;
|
||||
|
||||
/// Array of NSValue-wrapped NSRange values, one per page.
|
||||
/// Each range is a character range within the chapter's attributed string.
|
||||
@property (nonatomic, strong, nullable) NSArray<NSValue *> *pageRanges;
|
||||
|
||||
/// Total number of pages in this chapter.
|
||||
@property (nonatomic, assign, readonly) NSUInteger totalPages;
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Class Methods
|
||||
// ============================================================================
|
||||
|
||||
/// Generates a cache key string for storing/retrieving pagination data
|
||||
/// for the given book. The key encodes the book ID and current typesetter
|
||||
/// settings (font size, line spacing, etc.) so pagination is invalidated
|
||||
/// when settings change.
|
||||
///
|
||||
/// @param bookId The book identifier.
|
||||
/// @return A unique cache key string, e.g., @"weread_pagcount_{bookId}_{fontSize}_{lineSpacing}".
|
||||
+ (NSString *)currentCacheKeyWithBookId:(NSString *)bookId;
|
||||
|
||||
/// Calculates the page ranges from a page info dictionary.
|
||||
/// The page info dictionary typically comes from the server or from local
|
||||
/// layout computation and contains raw range data.
|
||||
///
|
||||
/// @param pageInfo Dictionary with pagination data (e.g., @"ranges" key containing
|
||||
/// an array of {location, length} dictionaries).
|
||||
/// @return An array of NSValue-wrapped NSRange values.
|
||||
+ (NSArray<NSValue *> *)rangeValueWithPageInfo:(NSDictionary *)pageInfo;
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Instance Methods
|
||||
// ============================================================================
|
||||
|
||||
/// Returns the character range for the specified page index.
|
||||
- (NSRange)rangeForPageAtIndex:(NSUInteger)pageIndex;
|
||||
|
||||
/// Returns the page index that contains the given character index.
|
||||
- (NSUInteger)pageIndexForCharacterIndex:(NSUInteger)charIndex;
|
||||
|
||||
/// Recalculates page ranges for the given attributed string and drawing area.
|
||||
///
|
||||
/// @param attributedString The typeset chapter content.
|
||||
/// @param drawingSize The available drawing area size (points).
|
||||
/// @param margins Content insets / margins.
|
||||
- (void)recalculatePageRangesForAttributedString:(NSAttributedString *)attributedString
|
||||
drawingSize:(CGSize)drawingSize
|
||||
margins:(UIEdgeInsets)margins;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
308
Doc/WXRead/decompiled/WRChapterPageCount.m
Normal file
308
Doc/WXRead/decompiled/WRChapterPageCount.m
Normal file
@ -0,0 +1,308 @@
|
||||
//
|
||||
// WRChapterPageCount.m
|
||||
// WeRead (微信读书)
|
||||
//
|
||||
// Detailed pseudo-code reconstruction of the pagination calculator.
|
||||
// Computes page breaks by simulating CoreText line layout and fitting
|
||||
// lines into the available vertical space.
|
||||
//
|
||||
|
||||
#import "WRChapterPageCount.h"
|
||||
#import <CoreText/CoreText.h>
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Constants
|
||||
// ============================================================================
|
||||
|
||||
/// Prefix for pagination cache keys.
|
||||
static NSString *const kPageCountCachePrefix = @"weread_pagcount";
|
||||
|
||||
/// Separator used in cache key components.
|
||||
static NSString *const kCacheKeySeparator = @"_";
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - WRChapterPageCount ()
|
||||
// ============================================================================
|
||||
|
||||
@interface WRChapterPageCount ()
|
||||
|
||||
/// Precomputed page ranges (cached after calculation).
|
||||
@property (nonatomic, strong) NSMutableArray<NSValue *> *mutablePageRanges;
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - WRChapterPageCount Implementation
|
||||
// ============================================================================
|
||||
|
||||
@implementation WRChapterPageCount
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Initialization
|
||||
// ============================================================================
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_mutablePageRanges = [NSMutableArray array];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Class Methods
|
||||
// ============================================================================
|
||||
|
||||
///
|
||||
/// Generates a cache key that encodes the book ID together with the current
|
||||
/// typesetter configuration (font size, line spacing, margins, page size).
|
||||
/// When any of these change, the cache key changes, invalidating old data.
|
||||
///
|
||||
/// Typical format:
|
||||
/// weread_pagcount_{bookId}_{fontSize}_{lineSpacing}_{pageWidth}_{pageHeight}
|
||||
///
|
||||
+ (NSString *)currentCacheKeyWithBookId:(NSString *)bookId {
|
||||
if (!bookId) return kPageCountCachePrefix;
|
||||
|
||||
// Read current typesetter settings from NSUserDefaults or a global config.
|
||||
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
|
||||
|
||||
CGFloat fontSize = [defaults floatForKey:@"WRTypesetterFontSize"] ?: 18.0;
|
||||
CGFloat lineSpacing = [defaults floatForKey:@"WRTypesetterLineSpacing"] ?: 1.5;
|
||||
CGFloat pageWidth = [defaults floatForKey:@"WRTypesetterPageWidth"] ?: 375.0;
|
||||
CGFloat pageHeight = [defaults floatForKey:@"WRTypesetterPageHeight"] ?: 667.0;
|
||||
|
||||
// Build the key. Use integer representations to avoid locale issues.
|
||||
NSString *key = [NSString stringWithFormat:@"%@%@%@%@%.0f%@%.1f%@%.0f%@%.0f",
|
||||
kPageCountCachePrefix,
|
||||
kCacheKeySeparator,
|
||||
bookId,
|
||||
kCacheKeySeparator,
|
||||
fontSize * 10, // e.g., 180 for 18.0pt
|
||||
kCacheKeySeparator,
|
||||
lineSpacing * 10, // e.g., 15 for 1.5
|
||||
kCacheKeySeparator,
|
||||
pageWidth,
|
||||
kCacheKeySeparator,
|
||||
pageHeight];
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
///
|
||||
/// Converts a page info dictionary (from server or local computation)
|
||||
/// into an array of NSValue-wrapped NSRange objects.
|
||||
///
|
||||
/// Expected pageInfo format:
|
||||
/// {
|
||||
/// @"ranges": @[
|
||||
/// @{@"location": @0, @"length": @500},
|
||||
/// @{@"location": @500, @"length": @480},
|
||||
/// ...
|
||||
/// ]
|
||||
/// }
|
||||
///
|
||||
/// Or alternatively, an array of two-element arrays:
|
||||
/// {
|
||||
/// @"ranges": @[@[@0, @500], @[@500, @480], ...]
|
||||
/// }
|
||||
///
|
||||
+ (NSArray<NSValue *> *)rangeValueWithPageInfo:(NSDictionary *)pageInfo {
|
||||
NSMutableArray<NSValue *> *result = [NSMutableArray array];
|
||||
if (!pageInfo) return [result copy];
|
||||
|
||||
NSArray *ranges = pageInfo[@"ranges"];
|
||||
if (!ranges || ![ranges isKindOfClass:[NSArray class]]) return [result copy];
|
||||
|
||||
for (id entry in ranges) {
|
||||
NSRange range = NSMakeRange(NSNotFound, 0);
|
||||
|
||||
if ([entry isKindOfClass:[NSDictionary class]]) {
|
||||
// Dictionary format: {location, length}
|
||||
NSDictionary *dict = (NSDictionary *)entry;
|
||||
NSUInteger loc = [dict[@"location"] unsignedIntegerValue];
|
||||
NSUInteger len = [dict[@"length"] unsignedIntegerValue];
|
||||
range = NSMakeRange(loc, len);
|
||||
|
||||
} else if ([entry isKindOfClass:[NSArray class]]) {
|
||||
// Array format: [location, length]
|
||||
NSArray *arr = (NSArray *)entry;
|
||||
if (arr.count >= 2) {
|
||||
NSUInteger loc = [arr[0] unsignedIntegerValue];
|
||||
NSUInteger len = [arr[1] unsignedIntegerValue];
|
||||
range = NSMakeRange(loc, len);
|
||||
}
|
||||
|
||||
} else if ([entry isKindOfClass:[NSValue class]]) {
|
||||
// Already an NSValue wrapping NSRange.
|
||||
range = [(NSValue *)entry rangeValue];
|
||||
}
|
||||
|
||||
if (range.location != NSNotFound) {
|
||||
[result addObject:[NSValue valueWithRange:range]];
|
||||
}
|
||||
}
|
||||
|
||||
return [result copy];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Instance Methods
|
||||
// ============================================================================
|
||||
|
||||
/// Returns the character range for the page at the given index.
|
||||
- (NSRange)rangeForPageAtIndex:(NSUInteger)pageIndex {
|
||||
NSArray<NSValue *> *ranges = self.pageRanges ?: self.mutablePageRanges;
|
||||
if (pageIndex >= ranges.count) {
|
||||
return NSMakeRange(NSNotFound, 0);
|
||||
}
|
||||
return [ranges[pageIndex] rangeValue];
|
||||
}
|
||||
|
||||
/// Binary-search-style lookup: finds the page index containing the given
|
||||
/// character index. Pages are contiguous, so we can use binary search.
|
||||
- (NSUInteger)pageIndexForCharacterIndex:(NSUInteger)charIndex {
|
||||
NSArray<NSValue *> *ranges = self.pageRanges ?: self.mutablePageRanges;
|
||||
if (ranges.count == 0) return 0;
|
||||
|
||||
NSUInteger lo = 0;
|
||||
NSUInteger hi = ranges.count - 1;
|
||||
|
||||
while (lo <= hi) {
|
||||
NSUInteger mid = lo + (hi - lo) / 2;
|
||||
NSRange midRange = [ranges[mid] rangeValue];
|
||||
|
||||
if (charIndex >= midRange.location &&
|
||||
charIndex < NSMaxRange(midRange)) {
|
||||
return mid;
|
||||
} else if (charIndex < midRange.location) {
|
||||
if (mid == 0) break;
|
||||
hi = mid - 1;
|
||||
} else {
|
||||
lo = mid + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// If not found, return the last page (clamp).
|
||||
return ranges.count - 1;
|
||||
}
|
||||
|
||||
///
|
||||
/// Recalculates page ranges by simulating CoreText typesetting.
|
||||
/// This is the core pagination algorithm:
|
||||
///
|
||||
/// 1. Create a CTFramesetter from the attributed string.
|
||||
/// 2. For each page, create a CTFrame with the available height.
|
||||
/// 3. Count how many lines fit in the frame.
|
||||
/// 4. Sum the character counts of those lines to get the page range.
|
||||
/// 5. Advance the start position and repeat.
|
||||
///
|
||||
- (void)recalculatePageRangesForAttributedString:(NSAttributedString *)attributedString
|
||||
drawingSize:(CGSize)drawingSize
|
||||
margins:(UIEdgeInsets)margins {
|
||||
[self.mutablePageRanges removeAllObjects];
|
||||
|
||||
if (!attributedString || attributedString.length == 0) {
|
||||
self.pageRanges = @[];
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Step 1: Create the CTFramesetter ----
|
||||
CTFramesetterRef framesetter =
|
||||
CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef)attributedString);
|
||||
if (!framesetter) {
|
||||
self.pageRanges = @[];
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Step 2: Compute the usable drawing area ----
|
||||
CGFloat usableWidth = drawingSize.width - margins.left - margins.right;
|
||||
CGFloat usableHeight = drawingSize.height - margins.top - margins.bottom;
|
||||
|
||||
if (usableWidth <= 0 || usableHeight <= 0) {
|
||||
CFRelease(framesetter);
|
||||
self.pageRanges = @[];
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Step 3: Paginate ----
|
||||
NSUInteger totalLength = attributedString.length;
|
||||
NSUInteger currentLocation = 0;
|
||||
|
||||
while (currentLocation < totalLength) {
|
||||
// Create a path for this page's drawing area.
|
||||
CGRect pathRect = CGRectMake(margins.left, margins.bottom,
|
||||
usableWidth, usableHeight);
|
||||
CGMutablePathRef path = CGPathCreateMutable();
|
||||
CGPathAddRect(path, NULL, pathRect);
|
||||
|
||||
// Create a frame for the remaining text.
|
||||
// The frame will typeset as much text as fits in the path.
|
||||
CFRange frameRange = CFRangeMake((CFIndex)currentLocation, 0); // 0 = until end
|
||||
CTFrameRef frame = CTFramesetterCreateFrame(framesetter,
|
||||
frameRange,
|
||||
path, NULL);
|
||||
CGPathRelease(path);
|
||||
|
||||
if (!frame) break;
|
||||
|
||||
// Get the lines that fit in this page.
|
||||
NSArray *lines = (__bridge_transfer NSArray *)CTFrameGetLines(frame);
|
||||
if (lines.count == 0) {
|
||||
CFRelease(frame);
|
||||
break;
|
||||
}
|
||||
|
||||
// Get the line origins to determine which lines are fully within bounds.
|
||||
CGPoint *origins = (CGPoint *)calloc(lines.count, sizeof(CGPoint));
|
||||
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), origins);
|
||||
|
||||
NSUInteger pageCharCount = 0;
|
||||
for (NSUInteger i = 0; i < lines.count; i++) {
|
||||
CTLineRef line = (__bridge CTLineRef)lines[i];
|
||||
CFRange lineRange = CTLineGetStringRange(line);
|
||||
|
||||
// Check if this line's origin is within the usable area.
|
||||
// CoreText origins are from the bottom of the frame.
|
||||
CGFloat lineY = origins[i].y;
|
||||
CGFloat lineHeight = 0.0;
|
||||
CGFloat descent = 0.0;
|
||||
CTLineGetTypographicBounds(line, &lineHeight, &descent, NULL);
|
||||
|
||||
// If the line's top is above the top of the frame, it doesn't fit.
|
||||
if (lineY - lineHeight > usableHeight) {
|
||||
break; // This line doesn't fit; stop here.
|
||||
}
|
||||
|
||||
pageCharCount += (NSUInteger)lineRange.length;
|
||||
}
|
||||
|
||||
free(origins);
|
||||
CFRelease(frame);
|
||||
|
||||
// If no characters fit (shouldn't happen normally), advance by 1 to avoid infinite loop.
|
||||
if (pageCharCount == 0) {
|
||||
pageCharCount = 1;
|
||||
}
|
||||
|
||||
// Record this page's range.
|
||||
NSRange pageRange = NSMakeRange(currentLocation, pageCharCount);
|
||||
[self.mutablePageRanges addObject:[NSValue valueWithRange:pageRange]];
|
||||
|
||||
currentLocation += pageCharCount;
|
||||
}
|
||||
|
||||
CFRelease(framesetter);
|
||||
|
||||
self.pageRanges = [self.mutablePageRanges copy];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Computed Properties
|
||||
// ============================================================================
|
||||
|
||||
- (NSUInteger)totalPages {
|
||||
return self.pageRanges.count;
|
||||
}
|
||||
|
||||
@end
|
||||
349
Doc/WXRead/decompiled/WRCoreTextLayoutFrame.h
Normal file
349
Doc/WXRead/decompiled/WRCoreTextLayoutFrame.h
Normal file
@ -0,0 +1,349 @@
|
||||
//
|
||||
// WRCoreTextLayoutFrame.h
|
||||
// WeRead
|
||||
//
|
||||
// Reverse-engineered from binary analysis
|
||||
// Single page layout frame for WeRead's reading engine
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
#import <CoreText/CoreText.h>
|
||||
|
||||
@class WRBookCoverView;
|
||||
@class WRDiscover;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
#pragma mark - Layout Line
|
||||
|
||||
/**
|
||||
* Represents a single line of laid-out text.
|
||||
* Wrapper around CTLine with additional metadata.
|
||||
*/
|
||||
@interface WRCoreTextLayoutLine : NSObject
|
||||
|
||||
/** The CTLine this wraps */
|
||||
@property (nonatomic, assign, readonly) CTLineRef ctLine;
|
||||
|
||||
/** Origin point of this line in the frame */
|
||||
@property (nonatomic, assign) CGPoint origin;
|
||||
|
||||
/** The range of characters in this line */
|
||||
@property (nonatomic, assign) NSRange stringRange;
|
||||
|
||||
/** Ascent of the line */
|
||||
@property (nonatomic, assign, readonly) CGFloat ascent;
|
||||
|
||||
/** Desent of the line */
|
||||
@property (nonatomic, assign, readonly) CGFloat descent;
|
||||
|
||||
/** Leading of the line */
|
||||
@property (nonatomic, assign, readonly) CGFloat leading;
|
||||
|
||||
/** Total height of the line (ascent + descent + leading) */
|
||||
@property (nonatomic, assign, readonly) CGFloat height;
|
||||
|
||||
/** Width of the line */
|
||||
@property (nonatomic, assign, readonly) CGFloat width;
|
||||
|
||||
/** Whether this line is the last line in a paragraph */
|
||||
@property (nonatomic, assign) BOOL isLastLineInParagraph;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - WRCoreTextLayoutFrame
|
||||
|
||||
/**
|
||||
* WRCoreTextLayoutFrame - Manages a single page of text layout.
|
||||
*
|
||||
* This class wraps CTFrame and provides high-level access to the
|
||||
* layout of a single page. It handles:
|
||||
* - Text rendering to CGContext
|
||||
* - Image placement within text
|
||||
* - avoidPageBreakInside CSS property
|
||||
* - Render height calculation
|
||||
* - Line-by-line access for hit testing and selection
|
||||
*
|
||||
* Architecture:
|
||||
* - Contains CTFrame internally
|
||||
* - Manages array of WRCoreTextLayoutLine objects
|
||||
* - Supports direct drawing to CGContext
|
||||
* - Handles image attachments inline
|
||||
* - Integrates with RACSubject for reactive updates
|
||||
*/
|
||||
@interface WRCoreTextLayoutFrame : NSObject
|
||||
|
||||
#pragma mark - Core Layout Data
|
||||
|
||||
/** The attributed string that was laid out */
|
||||
@property (nonatomic, strong, nullable) NSAttributedString *attributedString;
|
||||
|
||||
/** The range of the attributed string represented by this frame */
|
||||
@property (nonatomic, assign) NSRange stringRange;
|
||||
|
||||
/** Array of WRCoreTextLayoutLine objects for this frame */
|
||||
@property (nonatomic, strong, readonly) NSArray<WRCoreTextLayoutLine *> *layoutLines;
|
||||
|
||||
/** The bounding rectangle for this frame */
|
||||
@property (nonatomic, assign) CGRect frame;
|
||||
|
||||
/** The CTFrame reference (accessor for internal use) */
|
||||
@property (nonatomic, assign, readonly) CTFrameRef ctFrame;
|
||||
|
||||
#pragma mark - Layout Properties
|
||||
|
||||
/** Whether to avoid page breaks inside certain elements */
|
||||
@property (nonatomic, assign) BOOL avoidPageBreakInside;
|
||||
|
||||
/** The rendered content height (may differ from frame height) */
|
||||
@property (nonatomic, assign, readonly) CGFloat renderedContentHeight;
|
||||
|
||||
/** Padding around the content area */
|
||||
@property (nonatomic, assign) UIEdgeInsets contentInsets;
|
||||
|
||||
/** Number of columns in this frame */
|
||||
@property (nonatomic, assign) NSUInteger numberOfColumns;
|
||||
|
||||
/** Gap between columns */
|
||||
@property (nonatomic, assign) CGFloat columnGap;
|
||||
|
||||
#pragma mark - Content Arrays
|
||||
|
||||
/** Array of attachment objects (images, etc.) */
|
||||
@property (nonatomic, strong, nullable) NSArray *attachments;
|
||||
|
||||
/** Array of strikethrough ranges */
|
||||
@property (nonatomic, strong, nullable) NSArray *strikethroughRanges;
|
||||
|
||||
/** Array of underline ranges */
|
||||
@property (nonatomic, strong, nullable) NSArray *underlineRanges;
|
||||
|
||||
/** Array of highlight ranges */
|
||||
@property (nonatomic, strong, nullable) NSArray *highlightRanges;
|
||||
|
||||
/** Set of selected line indices */
|
||||
@property (nonatomic, strong, nullable) NSMutableSet *selectedLineIndices;
|
||||
|
||||
/** Current selection string */
|
||||
@property (nonatomic, copy, nullable) NSString *selectionString;
|
||||
|
||||
/** Array of link ranges for tap handling */
|
||||
@property (nonatomic, strong, nullable) NSArray *linkRanges;
|
||||
|
||||
/** Search result string */
|
||||
@property (nonatomic, copy, nullable) NSString *searchResultString;
|
||||
|
||||
/** Array of search result ranges */
|
||||
@property (nonatomic, strong, nullable) NSArray *searchResultRanges;
|
||||
|
||||
#pragma mark - Reactive Components
|
||||
|
||||
/** Subject for layout change notifications */
|
||||
@property (nonatomic, strong, nullable) RACSubject *layoutChangeSubject;
|
||||
|
||||
#pragma mark - UI Elements
|
||||
|
||||
/** Long press gesture recognizer for text selection */
|
||||
@property (nonatomic, strong, nullable) UILongPressGestureRecognizer *longPressRecognizer;
|
||||
|
||||
/** Book cover view (for cover pages) */
|
||||
@property (nonatomic, strong, nullable) WRBookCoverView *bookCoverView;
|
||||
|
||||
/** Layer for custom drawing */
|
||||
@property (nonatomic, strong, nullable) CALayer *customDrawingLayer;
|
||||
|
||||
/** Discover view (for social features) */
|
||||
@property (nonatomic, strong, nullable) WRDiscover *discoverView;
|
||||
|
||||
/** Additional layout data */
|
||||
@property (nonatomic, strong, nullable) NSArray *additionalLayoutData;
|
||||
|
||||
/** Frame identifier */
|
||||
@property (nonatomic, copy, nullable) NSString *frameId;
|
||||
|
||||
/** Content identifier */
|
||||
@property (nonatomic, copy, nullable) NSString *contentId;
|
||||
|
||||
/** Mutable dictionary for custom attributes */
|
||||
@property (nonatomic, strong, nullable) NSMutableDictionary *customAttributes;
|
||||
|
||||
/** Mutable array for tracking visible elements */
|
||||
@property (nonatomic, strong, nullable) NSMutableArray *visibleElements;
|
||||
|
||||
/** Mutable array for tracking accessibility elements */
|
||||
@property (nonatomic, strong, nullable) NSMutableArray *accessibilityElements;
|
||||
|
||||
/** Mutable dictionary for caching computed values */
|
||||
@property (nonatomic, strong, nullable) NSMutableDictionary *computedValueCache;
|
||||
|
||||
#pragma mark - Initialization
|
||||
|
||||
/**
|
||||
* Initialize with a CTFrame and string range.
|
||||
*
|
||||
* @param ctFrame The CoreText frame
|
||||
* @param range The string range
|
||||
* @return Initialized layout frame
|
||||
*/
|
||||
- (instancetype)initWithCTFrame:(CTFrameRef)ctFrame
|
||||
range:(NSRange)range;
|
||||
|
||||
/**
|
||||
* Set the CTFrame and range (used by WRCoreTextLayouter).
|
||||
*/
|
||||
- (void)setCTFrame:(CTFrameRef)ctFrame range:(NSRange)range;
|
||||
|
||||
#pragma mark - Line Access
|
||||
|
||||
/**
|
||||
* Returns the array of layout lines.
|
||||
* This triggers line extraction from the CTFrame if not already done.
|
||||
*
|
||||
* @return Array of WRCoreTextLayoutLine objects
|
||||
*/
|
||||
- (NSArray<WRCoreTextLayoutLine *> *)lines;
|
||||
|
||||
/**
|
||||
* Returns the number of lines in this frame.
|
||||
*/
|
||||
- (NSUInteger)lineCount;
|
||||
|
||||
/**
|
||||
* Returns the layout line at the specified index.
|
||||
*/
|
||||
- (nullable WRCoreTextLayoutLine *)lineAtIndex:(NSUInteger)index;
|
||||
|
||||
#pragma mark - Height Calculation
|
||||
|
||||
/**
|
||||
* Returns the rendered content height.
|
||||
*
|
||||
* This calculates the actual height of the rendered content,
|
||||
* which may be less than the frame height if there's unused space.
|
||||
*
|
||||
* @return The height of the rendered content in points
|
||||
*/
|
||||
- (CGFloat)getRenderHeight;
|
||||
|
||||
/**
|
||||
* Returns the height of the last line's descent.
|
||||
* Used for precise baseline alignment.
|
||||
*/
|
||||
- (CGFloat)lastLineDescent;
|
||||
|
||||
#pragma mark - Drawing
|
||||
|
||||
/**
|
||||
* Draw the layout frame content into a CGContext.
|
||||
*
|
||||
* This is the primary rendering method. It draws:
|
||||
* 1. Text content using CTFrameDraw
|
||||
* 2. Image attachments at their calculated positions
|
||||
* 3. Decorative elements (strikethrough, underline, etc.)
|
||||
*
|
||||
* @param context The CGContext to draw into
|
||||
* @param image Optional image to draw (for cover pages)
|
||||
* @param size The size of the drawing area
|
||||
* @param rect The rectangle to draw within
|
||||
* @param position The position offset for the content
|
||||
*/
|
||||
- (void)drawInContext:(CGContextRef)context
|
||||
image:(nullable UIImage *)image
|
||||
size:(CGSize)size
|
||||
inRect:(CGRect)rect
|
||||
position:(CGPoint)position;
|
||||
|
||||
/**
|
||||
* Draw only the text content (no images).
|
||||
*/
|
||||
- (void)drawTextInContext:(CGContextRef)context
|
||||
inRect:(CGRect)rect;
|
||||
|
||||
/**
|
||||
* Draw image attachments.
|
||||
*/
|
||||
- (void)drawAttachmentsInContext:(CGContextRef)context
|
||||
inRect:(CGRect)rect;
|
||||
|
||||
#pragma mark - Page Break Avoidance
|
||||
|
||||
/**
|
||||
* Removes the last lines if needed to avoid page breaks inside certain elements.
|
||||
*
|
||||
* This implements the CSS 'avoid-page-break-inside' property. When an element
|
||||
* (like a table, code block, or list) cannot fit entirely on the current page,
|
||||
* this method removes lines from the end until the element can fit.
|
||||
*
|
||||
* @return YES if lines were removed, NO otherwise
|
||||
*/
|
||||
- (BOOL)avoidPageBreakInsideByRemovingLastLinesIfNeeded;
|
||||
|
||||
/**
|
||||
* Check if a range of text has the avoid-page-break-inside property.
|
||||
*/
|
||||
- (BOOL)shouldAvoidPageBreakInRange:(NSRange)range;
|
||||
|
||||
#pragma mark - Hit Testing
|
||||
|
||||
/**
|
||||
* Returns the character index at a given point.
|
||||
*
|
||||
* @param point The point to test (in the frame's coordinate system)
|
||||
* @return The character index, or NSNotFound if no character at that point
|
||||
*/
|
||||
- (NSUInteger)characterIndexAtPoint:(CGPoint)point;
|
||||
|
||||
/**
|
||||
* Returns the line index at a given point.
|
||||
*
|
||||
* @param point The point to test
|
||||
* @return The line index, or NSNotFound
|
||||
*/
|
||||
- (NSUInteger)lineIndexAtPoint:(CGPoint)point;
|
||||
|
||||
/**
|
||||
* Returns the rect for a character at the given index.
|
||||
*/
|
||||
- (CGRect)rectForCharacterAtIndex:(NSUInteger)index;
|
||||
|
||||
#pragma mark - Selection
|
||||
|
||||
/**
|
||||
* Returns the selected text string.
|
||||
*/
|
||||
- (nullable NSString *)selectedText;
|
||||
|
||||
/**
|
||||
* Returns the ranges of selected text.
|
||||
*/
|
||||
- (NSArray<NSValue *> *)selectedRanges;
|
||||
|
||||
/**
|
||||
* Selects text in the given range.
|
||||
*/
|
||||
- (void)selectTextInRange:(NSRange)range;
|
||||
|
||||
/**
|
||||
* Clears the current selection.
|
||||
*/
|
||||
- (void)clearSelection;
|
||||
|
||||
#pragma mark - Search
|
||||
|
||||
/**
|
||||
* Highlights search results within this frame.
|
||||
*
|
||||
* @param searchString The string to highlight
|
||||
* @return The number of matches found
|
||||
*/
|
||||
- (NSUInteger)highlightSearchResults:(NSString *)searchString;
|
||||
|
||||
/**
|
||||
* Clears all search result highlights.
|
||||
*/
|
||||
- (void)clearSearchHighlights;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
985
Doc/WXRead/decompiled/WRCoreTextLayoutFrame.m
Normal file
985
Doc/WXRead/decompiled/WRCoreTextLayoutFrame.m
Normal file
@ -0,0 +1,985 @@
|
||||
//
|
||||
// WRCoreTextLayoutFrame.m
|
||||
// WeRead
|
||||
//
|
||||
// Reverse-engineered from binary analysis
|
||||
// Single page layout frame implementation
|
||||
//
|
||||
// This file contains pseudo-code reconstruction of the WRCoreTextLayoutFrame
|
||||
// class based on ivar analysis, method signatures, and behavioral context.
|
||||
//
|
||||
|
||||
#import "WRCoreTextLayoutFrame.h"
|
||||
#import <CoreText/CoreText.h>
|
||||
|
||||
// Forward declarations for WeRead-specific classes
|
||||
@class WRBookCoverView;
|
||||
@class WRDiscover;
|
||||
@class RACSubject;
|
||||
|
||||
#pragma mark - Internal Constants
|
||||
|
||||
// Threshold for avoid-page-break-inside calculation
|
||||
static const CGFloat kPageBreakAvoidanceThreshold = 20.0;
|
||||
|
||||
// Maximum lines to remove for page break avoidance
|
||||
static const NSUInteger kMaxLinesToRemove = 3;
|
||||
|
||||
#pragma mark - WRCoreTextLayoutLine Implementation
|
||||
|
||||
@implementation WRCoreTextLayoutLine
|
||||
|
||||
- (instancetype)initWithCTLine:(CTLineRef)ctLine origin:(CGPoint)origin range:(NSRange)range {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_ctLine = (CTLineRef)CFRetain(ctLine);
|
||||
_origin = origin;
|
||||
_stringRange = range;
|
||||
|
||||
// Calculate typographic metrics
|
||||
CGFloat ascent, descent, leading;
|
||||
CTLineGetTypographicBounds(_ctLine, &ascent, &descent, &leading);
|
||||
|
||||
_ascent = ascent;
|
||||
_descent = descent;
|
||||
_leading = leading;
|
||||
_height = ascent + descent + leading;
|
||||
_width = CTLineGetTypographicBounds(_ctLine, NULL, NULL, NULL);
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
if (_ctLine) {
|
||||
CFRelease(_ctLine);
|
||||
_ctLine = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
- (BOOL)isLastLineInParagraph {
|
||||
// A line is the last in a paragraph if it ends with a newline
|
||||
// or if the next line starts a new paragraph
|
||||
CFRange cfRange = CTLineGetStringRange(_ctLine);
|
||||
if (cfRange.length == 0) return NO;
|
||||
|
||||
NSUInteger endIndex = cfRange.location + cfRange.length - 1;
|
||||
// This would need access to the full attributed string to determine
|
||||
// For now, we use the stored property
|
||||
return _isLastLineInParagraph;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - Private Interface
|
||||
|
||||
@interface WRCoreTextLayoutFrame () {
|
||||
// The CoreText frame object
|
||||
CTFrameRef _ctFrame;
|
||||
|
||||
// Cached layout lines
|
||||
NSArray<WRCoreTextLayoutLine *> *_cachedLines;
|
||||
|
||||
// Whether lines have been extracted
|
||||
BOOL _linesExtracted;
|
||||
|
||||
// Render height cache
|
||||
CGFloat _cachedRenderHeight;
|
||||
BOOL _renderHeightCached;
|
||||
|
||||
// Internal lock for thread safety
|
||||
NSLock *_frameLock;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - WRCoreTextLayoutFrame Implementation
|
||||
|
||||
@implementation WRCoreTextLayoutFrame
|
||||
|
||||
#pragma mark - Lifecycle
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
[self commonInit];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (instancetype)initWithCTFrame:(CTFrameRef)ctFrame range:(NSRange)range {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
[self commonInit];
|
||||
[self setCTFrame:ctFrame range:range];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common initialization - sets up internal state.
|
||||
*/
|
||||
- (void)commonInit {
|
||||
_frameLock = [[NSLock alloc] init];
|
||||
_cachedLines = @[];
|
||||
_linesExtracted = NO;
|
||||
_renderHeightCached = NO;
|
||||
_cachedRenderHeight = 0;
|
||||
_customAttributes = [NSMutableDictionary dictionary];
|
||||
_visibleElements = [NSMutableArray array];
|
||||
_accessibilityElements = [NSMutableArray array];
|
||||
_computedValueCache = [NSMutableDictionary dictionary];
|
||||
_selectedLineIndices = [NSMutableSet set];
|
||||
_avoidPageBreakInside = NO;
|
||||
_numberOfColumns = 1;
|
||||
_columnGap = 20.0;
|
||||
_contentInsets = UIEdgeInsetsZero;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
if (_ctFrame) {
|
||||
CFRelease(_ctFrame);
|
||||
_ctFrame = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Property Accessors
|
||||
|
||||
- (void)setCTFrame:(CTFrameRef)ctFrame range:(NSRange)range {
|
||||
[_frameLock lock];
|
||||
|
||||
if (_ctFrame) {
|
||||
CFRelease(_ctFrame);
|
||||
}
|
||||
|
||||
_ctFrame = ctFrame ? (CTFrameRef)CFRetain(ctFrame) : NULL;
|
||||
_stringRange = range;
|
||||
|
||||
// Invalidate cached data
|
||||
_linesExtracted = NO;
|
||||
_renderHeightCached = NO;
|
||||
_cachedLines = @[];
|
||||
|
||||
[_frameLock unlock];
|
||||
}
|
||||
|
||||
- (CTFrameRef)ctFrame {
|
||||
return _ctFrame;
|
||||
}
|
||||
|
||||
- (void)setAvoidPageBreakInside:(BOOL)avoidPageBreakInside {
|
||||
_avoidPageBreakInside = avoidPageBreakInside;
|
||||
|
||||
if (avoidPageBreakInside) {
|
||||
// When enabled, we need to check and potentially remove lines
|
||||
[self avoidPageBreakInsideByRemovingLastLinesIfNeeded];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Line Extraction
|
||||
|
||||
/**
|
||||
* Extracts layout lines from the CTFrame.
|
||||
*
|
||||
* This method iterates through the CTFrame's lines and creates
|
||||
* WRCoreTextLayoutLine wrapper objects with position information.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Get array of CTLines from CTFrame
|
||||
* 2. For each CTLine:
|
||||
* a. Get its origin from CTFrameGetLineOrigins
|
||||
* b. Get the string range from CTLineGetStringRange
|
||||
* c. Create WRCoreTextLayoutLine wrapper
|
||||
* d. Determine if it's the last line in a paragraph
|
||||
* 3. Cache the results
|
||||
*/
|
||||
- (void)extractLines {
|
||||
if (_linesExtracted || !_ctFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
[_frameLock lock];
|
||||
|
||||
// Get the lines from the CTFrame
|
||||
CFArrayRef ctLines = CTFrameGetLines(_ctFrame);
|
||||
if (!ctLines) {
|
||||
[_frameLock unlock];
|
||||
return;
|
||||
}
|
||||
|
||||
CFIndex lineCount = CFArrayGetCount(ctLines);
|
||||
if (lineCount == 0) {
|
||||
_cachedLines = @[];
|
||||
_linesExtracted = YES;
|
||||
[_frameLock unlock];
|
||||
return;
|
||||
}
|
||||
|
||||
// Get line origins
|
||||
CGPoint *origins = (CGPoint *)malloc(sizeof(CGPoint) * lineCount);
|
||||
CTFrameGetLineOrigins(_ctFrame, CFRangeMake(0, 0), origins);
|
||||
|
||||
NSMutableArray<WRCoreTextLayoutLine *> *lines = [NSMutableArray arrayWithCapacity:lineCount];
|
||||
|
||||
for (CFIndex i = 0; i < lineCount; i++) {
|
||||
CTLineRef ctLine = CFArrayGetValueAtIndex(ctLines, i);
|
||||
|
||||
// Get the string range for this line
|
||||
CFRange cfRange = CTLineGetStringRange(ctLine);
|
||||
NSRange range = NSMakeRange(cfRange.location, cfRange.length);
|
||||
|
||||
// Create layout line wrapper
|
||||
WRCoreTextLayoutLine *layoutLine = [[WRCoreTextLayoutLine alloc]
|
||||
initWithCTLine:ctLine
|
||||
origin:origins[i]
|
||||
range:range];
|
||||
|
||||
// Determine if this is the last line in a paragraph
|
||||
// Check if the next line starts a new paragraph or if this is the last line
|
||||
if (i == lineCount - 1) {
|
||||
layoutLine.isLastLineInParagraph = YES;
|
||||
} else {
|
||||
// Check if the character after this line is a newline
|
||||
NSUInteger endOfLine = range.location + range.length;
|
||||
if (endOfLine < [_attributedString length]) {
|
||||
unichar nextChar = [[_attributedString string] characterAtIndex:endOfLine];
|
||||
layoutLine.isLastLineInParagraph = (nextChar == '\n' || nextChar == '\r');
|
||||
}
|
||||
}
|
||||
|
||||
[lines addObject:layoutLine];
|
||||
}
|
||||
|
||||
free(origins);
|
||||
|
||||
_cachedLines = [lines copy];
|
||||
_linesExtracted = YES;
|
||||
|
||||
[_frameLock unlock];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the array of layout lines.
|
||||
* Triggers extraction if not already done.
|
||||
*/
|
||||
- (NSArray<WRCoreTextLayoutLine *> *)lines {
|
||||
[self extractLines];
|
||||
return _cachedLines;
|
||||
}
|
||||
|
||||
- (NSUInteger)lineCount {
|
||||
return [[self lines] count];
|
||||
}
|
||||
|
||||
- (WRCoreTextLayoutLine *)lineAtIndex:(NSUInteger)index {
|
||||
NSArray *lines = [self lines];
|
||||
if (index < [lines count]) {
|
||||
return lines[index];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
#pragma mark - Height Calculation
|
||||
|
||||
/**
|
||||
* Calculates the rendered content height.
|
||||
*
|
||||
* This method determines the actual height of the rendered content
|
||||
* by examining the position of the last line. The rendered height
|
||||
* may be less than the frame height if there's unused space at the bottom.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Get all layout lines
|
||||
* 2. Find the lowest line (by origin.y - descent)
|
||||
* 3. Return that position as the rendered height
|
||||
*
|
||||
* @return The rendered content height in points
|
||||
*/
|
||||
- (CGFloat)getRenderHeight {
|
||||
[_frameLock lock];
|
||||
|
||||
if (_renderHeightCached) {
|
||||
[_frameLock unlock];
|
||||
return _cachedRenderHeight;
|
||||
}
|
||||
|
||||
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
|
||||
|
||||
if ([lines count] == 0) {
|
||||
_cachedRenderHeight = 0;
|
||||
_renderHeightCached = YES;
|
||||
[_frameLock unlock];
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Find the lowest point of the last line
|
||||
// The last line's bottom edge (origin.y - descent) gives us the content height
|
||||
WRCoreTextLayoutLine *lastLine = [lines lastObject];
|
||||
|
||||
// In CoreText, the coordinate system is flipped (origin at bottom-left)
|
||||
// The line's origin.y is the baseline position
|
||||
// To get the bottom of the line, we subtract the descent
|
||||
CGFloat lastLineBottom = lastLine.origin.y - lastLine.descent;
|
||||
|
||||
// The rendered height is from the top of the frame to the bottom of the last line
|
||||
// Since CoreText uses bottom-left origin, we need to convert
|
||||
CGFloat frameHeight = _frame.size.height;
|
||||
_cachedRenderHeight = frameHeight - lastLineBottom;
|
||||
|
||||
// Add content insets
|
||||
_cachedRenderHeight += _contentInsets.top + _contentInsets.bottom;
|
||||
|
||||
_renderHeightCached = YES;
|
||||
|
||||
[_frameLock unlock];
|
||||
|
||||
return _cachedRenderHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the descent of the last line.
|
||||
* Used for precise baseline alignment at the bottom of the page.
|
||||
*/
|
||||
- (CGFloat)lastLineDescent {
|
||||
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
|
||||
if ([lines count] == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
WRCoreTextLayoutLine *lastLine = [lines lastObject];
|
||||
return lastLine.descent;
|
||||
}
|
||||
|
||||
#pragma mark - Drawing
|
||||
|
||||
/**
|
||||
* Draws the layout frame content into a CGContext.
|
||||
*
|
||||
* This is the primary rendering method for the reading view.
|
||||
* It performs the following steps:
|
||||
*
|
||||
* 1. Save the graphics state
|
||||
* 2. Flip the coordinate system (UIKit vs CoreText)
|
||||
* 3. Apply position offset
|
||||
* 4. Draw text using CTFrameDraw
|
||||
* 5. Draw image attachments at their positions
|
||||
* 6. Draw decorative elements (strikethrough, underline, highlights)
|
||||
* 7. Restore the graphics state
|
||||
*
|
||||
* @param context The CGContext to draw into
|
||||
* @param image Optional image (used for cover pages)
|
||||
* @param size The size of the drawing area
|
||||
* @param rect The rectangle to draw within
|
||||
* @param position The position offset for multi-page layouts
|
||||
*/
|
||||
- (void)drawInContext:(CGContextRef)context
|
||||
image:(UIImage *)image
|
||||
size:(CGSize)size
|
||||
inRect:(CGRect)rect
|
||||
position:(CGPoint)position {
|
||||
if (!context || !_ctFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
[_frameLock lock];
|
||||
|
||||
// Save graphics state before drawing
|
||||
CGContextSaveGState(context);
|
||||
|
||||
// CoreText uses a bottom-left origin, UIKit uses top-left
|
||||
// We need to flip the coordinate system
|
||||
CGContextTranslateCTM(context, 0, rect.size.height);
|
||||
CGContextScaleCTM(context, 1.0, -1.0);
|
||||
|
||||
// Apply the position offset (for multi-column or multi-page layouts)
|
||||
CGContextTranslateCTM(context, position.x, -position.y);
|
||||
|
||||
// Apply content insets
|
||||
CGContextTranslateCTM(context, _contentInsets.left, _contentInsets.bottom);
|
||||
|
||||
// Draw the optional image (for cover pages or special layouts)
|
||||
if (image) {
|
||||
[self drawCoverImage:image inContext:context rect:rect];
|
||||
}
|
||||
|
||||
// Draw the main text content
|
||||
CTFrameDraw(_ctFrame, context);
|
||||
|
||||
// Draw image attachments
|
||||
[self drawAttachmentsInContext:context inRect:rect];
|
||||
|
||||
// Draw decorative elements
|
||||
[self drawDecorativeElementsInContext:context inRect:rect];
|
||||
|
||||
// Restore graphics state
|
||||
CGContextRestoreGState(context);
|
||||
|
||||
[_frameLock unlock];
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws only the text content without images.
|
||||
*/
|
||||
- (void)drawTextInContext:(CGContextRef)context inRect:(CGRect)rect {
|
||||
if (!context || !_ctFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
[_frameLock lock];
|
||||
|
||||
CGContextSaveGState(context);
|
||||
|
||||
// Flip coordinate system
|
||||
CGContextTranslateCTM(context, 0, rect.size.height);
|
||||
CGContextScaleCTM(context, 1.0, -1.0);
|
||||
|
||||
// Apply content insets
|
||||
CGContextTranslateCTM(context, _contentInsets.left, _contentInsets.bottom);
|
||||
|
||||
// Draw just the text
|
||||
CTFrameDraw(_ctFrame, context);
|
||||
|
||||
CGContextRestoreGState(context);
|
||||
|
||||
[_frameLock unlock];
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws image attachments at their calculated positions.
|
||||
*
|
||||
* This method iterates through the attachment array and draws each
|
||||
* image at the position determined by the layout engine.
|
||||
*
|
||||
* The attachment positions are calculated during layout and stored
|
||||
* in the attachments array. Each attachment has:
|
||||
* - A position (origin point)
|
||||
* - A size
|
||||
* - An image reference
|
||||
*/
|
||||
- (void)drawAttachmentsInContext:(CGContextRef)context inRect:(CGRect)rect {
|
||||
if (!_attachments || [_attachments count] == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (NSDictionary *attachment in _attachments) {
|
||||
UIImage *image = attachment[@"image"];
|
||||
NSValue *positionValue = attachment[@"position"];
|
||||
NSValue *sizeValue = attachment[@"size"];
|
||||
|
||||
if (!image || !positionValue || !sizeValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
CGPoint imagePosition = [positionValue CGPointValue];
|
||||
CGSize imageSize = [sizeValue CGSizeValue];
|
||||
|
||||
CGRect imageRect = CGRectMake(imagePosition.x,
|
||||
imagePosition.y,
|
||||
imageSize.width,
|
||||
imageSize.height);
|
||||
|
||||
// Draw the image
|
||||
CGContextDrawImage(context, imageRect, image.CGImage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws decorative elements like strikethrough and underline.
|
||||
*/
|
||||
- (void)drawDecorativeElementsInContext:(CGContextRef)context inRect:(CGRect)rect {
|
||||
// Draw strikethrough lines
|
||||
if (_strikethroughRanges) {
|
||||
[self drawStrikethroughInContext:context];
|
||||
}
|
||||
|
||||
// Draw underlines
|
||||
if (_underlineRanges) {
|
||||
[self drawUnderlineInContext:context];
|
||||
}
|
||||
|
||||
// Draw highlights
|
||||
if (_highlightRanges) {
|
||||
[self drawHighlightsInContext:context];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws strikethrough lines for the specified ranges.
|
||||
*/
|
||||
- (void)drawStrikethroughInContext:(CGContextRef)context {
|
||||
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
|
||||
|
||||
for (NSValue *rangeValue in _strikethroughRanges) {
|
||||
NSRange range = [rangeValue rangeValue];
|
||||
|
||||
// Find lines that intersect with this range
|
||||
for (WRCoreTextLayoutLine *line in lines) {
|
||||
NSRange lineRange = line.stringRange;
|
||||
NSRange intersection = NSIntersectionRange(range, lineRange);
|
||||
|
||||
if (intersection.length > 0) {
|
||||
// Get the x positions for the strikethrough
|
||||
CGFloat startX = CTLineGetOffsetForStringIndex(
|
||||
line.ctLine, intersection.location, NULL);
|
||||
CGFloat endX = CTLineGetOffsetForStringIndex(
|
||||
line.ctLine, intersection.location + intersection.length, NULL);
|
||||
|
||||
// Strikethrough is at the middle of the line
|
||||
CGFloat y = line.origin.y + (line.ascent * 0.3);
|
||||
|
||||
CGContextSetStrokeColorWithColor(context, [UIColor redColor].CGColor);
|
||||
CGContextSetLineWidth(context, 1.0);
|
||||
|
||||
CGContextMoveToPoint(context, line.origin.x + startX, y);
|
||||
CGContextAddLineToPoint(context, line.origin.x + endX, y);
|
||||
CGContextStrokePath(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws underlines for the specified ranges.
|
||||
*/
|
||||
- (void)drawUnderlineInContext:(CGContextRef)context {
|
||||
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
|
||||
|
||||
for (NSDictionary *underlineData in _underlineRanges) {
|
||||
NSRange range = [underlineData[@"range"] rangeValue];
|
||||
UIColor *color = underlineData[@"color"] ?: [UIColor blueColor];
|
||||
|
||||
for (WRCoreTextLayoutLine *line in lines) {
|
||||
NSRange lineRange = line.stringRange;
|
||||
NSRange intersection = NSIntersectionRange(range, lineRange);
|
||||
|
||||
if (intersection.length > 0) {
|
||||
CGFloat startX = CTLineGetOffsetForStringIndex(
|
||||
line.ctLine, intersection.location, NULL);
|
||||
CGFloat endX = CTLineGetOffsetForStringIndex(
|
||||
line.ctLine, intersection.location + intersection.length, NULL);
|
||||
|
||||
// Underline is below the baseline
|
||||
CGFloat y = line.origin.y - line.descent;
|
||||
|
||||
CGContextSetStrokeColorWithColor(context, color.CGColor);
|
||||
CGContextSetLineWidth(context, 1.0);
|
||||
|
||||
CGContextMoveToPoint(context, line.origin.x + startX, y);
|
||||
CGContextAddLineToPoint(context, line.origin.x + endX, y);
|
||||
CGContextStrokePath(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws highlight backgrounds for the specified ranges.
|
||||
*/
|
||||
- (void)drawHighlightsInContext:(CGContextRef)context {
|
||||
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
|
||||
|
||||
for (NSDictionary *highlightData in _highlightRanges) {
|
||||
NSRange range = [highlightData[@"range"] rangeValue];
|
||||
UIColor *color = highlightData[@"color"] ?: [UIColor yellowColor];
|
||||
|
||||
for (WRCoreTextLayoutLine *line in lines) {
|
||||
NSRange lineRange = line.stringRange;
|
||||
NSRange intersection = NSIntersectionRange(range, lineRange);
|
||||
|
||||
if (intersection.length > 0) {
|
||||
CGFloat startX = CTLineGetOffsetForStringIndex(
|
||||
line.ctLine, intersection.location, NULL);
|
||||
CGFloat endX = CTLineGetOffsetForStringIndex(
|
||||
line.ctLine, intersection.location + intersection.length, NULL);
|
||||
|
||||
// Highlight rect from descent to ascent
|
||||
CGFloat y = line.origin.y - line.descent;
|
||||
CGFloat height = line.ascent + line.descent;
|
||||
|
||||
CGRect highlightRect = CGRectMake(
|
||||
line.origin.x + startX,
|
||||
y,
|
||||
endX - startX,
|
||||
height
|
||||
);
|
||||
|
||||
CGContextSetFillColorWithColor(context, [color colorWithAlphaComponent:0.3].CGColor);
|
||||
CGContextFillRect(context, highlightRect);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a cover image for special pages.
|
||||
*/
|
||||
- (void)drawCoverImage:(UIImage *)image inContext:(CGContextRef)context rect:(CGRect)rect {
|
||||
if (!image) return;
|
||||
|
||||
// Scale the image to fit the frame while maintaining aspect ratio
|
||||
CGSize imageSize = image.size;
|
||||
CGSize frameSize = rect.size;
|
||||
|
||||
CGFloat widthRatio = frameSize.width / imageSize.width;
|
||||
CGFloat heightRatio = frameSize.height / imageSize.height;
|
||||
CGFloat scale = MIN(widthRatio, heightRatio);
|
||||
|
||||
CGSize scaledSize = CGSizeMake(imageSize.width * scale, imageSize.height * scale);
|
||||
|
||||
// Center the image in the frame
|
||||
CGFloat x = (frameSize.width - scaledSize.width) / 2;
|
||||
CGFloat y = (frameSize.height - scaledSize.height) / 2;
|
||||
|
||||
CGRect imageRect = CGRectMake(x, y, scaledSize.width, scaledSize.height);
|
||||
|
||||
CGContextDrawImage(context, imageRect, image.CGImage);
|
||||
}
|
||||
|
||||
#pragma mark - Page Break Avoidance
|
||||
|
||||
/**
|
||||
* Implements the CSS 'avoid-page-break-inside' property.
|
||||
*
|
||||
* When an element (like a table, code block, or list) has this property,
|
||||
* we need to ensure it doesn't break across pages. If it would break,
|
||||
* we remove the last few lines to make room for the element on the next page.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Check if avoidPageBreakInside is enabled
|
||||
* 2. Find elements with this property in the current frame
|
||||
* 3. For each element, check if it would break
|
||||
* 4. If it would break, remove lines from the end until the element fits
|
||||
* 5. Return whether any lines were removed
|
||||
*
|
||||
* @return YES if lines were removed to avoid page break
|
||||
*/
|
||||
- (BOOL)avoidPageBreakInsideByRemovingLastLinesIfNeeded {
|
||||
if (!_avoidPageBreakInside) {
|
||||
return NO;
|
||||
}
|
||||
|
||||
[_frameLock lock];
|
||||
|
||||
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
|
||||
if ([lines count] == 0) {
|
||||
[_frameLock unlock];
|
||||
return NO;
|
||||
}
|
||||
|
||||
// Check if any line has the avoid-page-break-inside attribute
|
||||
// This would be set in the attributed string attributes
|
||||
BOOL needsRemoval = NO;
|
||||
NSUInteger linesToRemove = 0;
|
||||
|
||||
// Iterate from the end to find lines that shouldn't be broken
|
||||
for (NSInteger i = [lines count] - 1; i >= 0; i--) {
|
||||
WRCoreTextLayoutLine *line = lines[i];
|
||||
NSRange range = line.stringRange;
|
||||
|
||||
// Check if this line is part of an element with avoid-page-break-inside
|
||||
if ([self shouldAvoidPageBreakInRange:range]) {
|
||||
// Check if the element starts before this line
|
||||
// If so, we need to remove this line and possibly more
|
||||
needsRemoval = YES;
|
||||
linesToRemove++;
|
||||
|
||||
// Limit the number of lines we remove
|
||||
if (linesToRemove >= kMaxLinesToRemove) {
|
||||
break;
|
||||
}
|
||||
} else if (needsRemoval) {
|
||||
// We've found a line that doesn't need avoidance, stop
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsRemoval && linesToRemove > 0) {
|
||||
// Remove the last N lines
|
||||
NSMutableArray *mutableLines = [_cachedLines mutableCopy];
|
||||
[mutableLines removeObjectsInRange:
|
||||
NSMakeRange([mutableLines count] - linesToRemove, linesToRemove)];
|
||||
_cachedLines = [mutableLines copy];
|
||||
|
||||
// Update the CTFrame to reflect the removal
|
||||
// This requires recreating the frame with a shorter range
|
||||
[self rebuildFrameWithoutLastLines:linesToRemove];
|
||||
|
||||
[_frameLock unlock];
|
||||
return YES;
|
||||
}
|
||||
|
||||
[_frameLock unlock];
|
||||
return NO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a text range has the avoid-page-break-inside property.
|
||||
*/
|
||||
- (BOOL)shouldAvoidPageBreakInRange:(NSRange)range {
|
||||
if (!_attributedString || range.location >= [_attributedString length]) {
|
||||
return NO;
|
||||
}
|
||||
|
||||
// Check the attributes at the start of the range
|
||||
NSDictionary *attrs = [_attributedString attributesAtIndex:range.location
|
||||
effectiveRange:NULL];
|
||||
|
||||
// Check for our custom avoid-page-break-inside attribute
|
||||
NSNumber *avoidBreak = attrs[@"WRAvoidPageBreakInside"];
|
||||
if ([avoidBreak boolValue]) {
|
||||
return YES;
|
||||
}
|
||||
|
||||
// Also check for block-level elements that shouldn't break
|
||||
NSString *blockType = attrs[@"WRBlockType"];
|
||||
if ([blockType isEqualToString:@"table"] ||
|
||||
[blockType isEqualToString:@"code"] ||
|
||||
[blockType isEqualToString:@"list"] ||
|
||||
[blockType isEqualToString:@"blockquote"]) {
|
||||
return YES;
|
||||
}
|
||||
|
||||
return NO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the CTFrame without the last N lines.
|
||||
* This is used after avoidPageBreakInside removes lines.
|
||||
*/
|
||||
- (void)rebuildFrameWithoutLastLines:(NSUInteger)count {
|
||||
if (!_ctFrame || count == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the current lines
|
||||
NSArray<WRCoreTextLayoutLine *> *lines = _cachedLines;
|
||||
if ([lines count] == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate the new end range
|
||||
WRCoreTextLayoutLine *lastLine = [lines lastObject];
|
||||
NSUInteger newEnd = lastLine.stringRange.location + lastLine.stringRange.length;
|
||||
|
||||
// Update the string range
|
||||
_stringRange = NSMakeRange(_stringRange.location, newEnd - _stringRange.location);
|
||||
|
||||
// Note: In a real implementation, we would need to recreate the CTFrame
|
||||
// with the updated range. This requires access to the CTTypesetter.
|
||||
// For this reconstruction, we just update the range metadata.
|
||||
}
|
||||
|
||||
#pragma mark - Hit Testing
|
||||
|
||||
/**
|
||||
* Returns the character index at a given point.
|
||||
*
|
||||
* Uses CTLineGetStringIndexForPosition to find which character
|
||||
* is at the given point. This is used for text selection and
|
||||
* tap handling.
|
||||
*
|
||||
* @param point The point to test (in the frame's coordinate system)
|
||||
* @return The character index, or NSNotFound
|
||||
*/
|
||||
- (NSUInteger)characterIndexAtPoint:(CGPoint)point {
|
||||
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
|
||||
|
||||
for (WRCoreTextLayoutLine *line in lines) {
|
||||
// Check if the point is within this line's vertical bounds
|
||||
CGFloat lineTop = line.origin.y + line.ascent;
|
||||
CGFloat lineBottom = line.origin.y - line.descent;
|
||||
|
||||
if (point.y >= lineBottom && point.y <= lineTop) {
|
||||
// Use CTLine to find the character index
|
||||
CFIndex index = CTLineGetStringIndexForPosition(line.ctLine, point);
|
||||
|
||||
if (index != kCFNotFound) {
|
||||
return (NSUInteger)index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NSNotFound;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the line index at a given point.
|
||||
*/
|
||||
- (NSUInteger)lineIndexAtPoint:(CGPoint)point {
|
||||
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
|
||||
|
||||
for (NSUInteger i = 0; i < [lines count]; i++) {
|
||||
WRCoreTextLayoutLine *line = lines[i];
|
||||
|
||||
CGFloat lineTop = line.origin.y + line.ascent;
|
||||
CGFloat lineBottom = line.origin.y - line.descent;
|
||||
|
||||
if (point.y >= lineBottom && point.y <= lineTop) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return NSNotFound;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the rect for a character at the given index.
|
||||
*/
|
||||
- (CGRect)rectForCharacterAtIndex:(NSUInteger)index {
|
||||
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
|
||||
|
||||
for (WRCoreTextLayoutLine *line in lines) {
|
||||
NSRange range = line.stringRange;
|
||||
|
||||
if (index >= range.location && index < range.location + range.length) {
|
||||
// Get the x position for this character
|
||||
CGFloat x = CTLineGetOffsetForStringIndex(line.ctLine, index, NULL);
|
||||
|
||||
return CGRectMake(line.origin.x + x,
|
||||
line.origin.y - line.descent,
|
||||
1, // Width of 1 character
|
||||
line.height);
|
||||
}
|
||||
}
|
||||
|
||||
return CGRectNull;
|
||||
}
|
||||
|
||||
#pragma mark - Selection
|
||||
|
||||
/**
|
||||
* Returns the currently selected text.
|
||||
*/
|
||||
- (NSString *)selectedText {
|
||||
NSArray<NSValue *> *ranges = [self selectedRanges];
|
||||
if ([ranges count] == 0) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSMutableString *selectedText = [NSMutableString string];
|
||||
|
||||
for (NSValue *rangeValue in ranges) {
|
||||
NSRange range = [rangeValue rangeValue];
|
||||
NSString *substring = [_attributedString.string substringWithRange:range];
|
||||
[selectedText appendString:substring];
|
||||
}
|
||||
|
||||
return [selectedText copy];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ranges of selected text.
|
||||
*/
|
||||
- (NSArray<NSValue *> *)selectedRanges {
|
||||
NSMutableArray<NSValue *> *ranges = [NSMutableArray array];
|
||||
|
||||
// Build ranges from selected line indices
|
||||
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
|
||||
|
||||
for (NSUInteger i = 0; i < [lines count]; i++) {
|
||||
if ([_selectedLineIndices containsObject:@(i)]) {
|
||||
WRCoreTextLayoutLine *line = lines[i];
|
||||
[ranges addObject:[NSValue valueWithRange:line.stringRange]];
|
||||
}
|
||||
}
|
||||
|
||||
return [ranges copy];
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects text in the given range.
|
||||
*/
|
||||
- (void)selectTextInRange:(NSRange)range {
|
||||
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
|
||||
|
||||
for (NSUInteger i = 0; i < [lines count]; i++) {
|
||||
WRCoreTextLayoutLine *line = lines[i];
|
||||
NSRange intersection = NSIntersectionRange(range, line.stringRange);
|
||||
|
||||
if (intersection.length > 0) {
|
||||
[_selectedLineIndices addObject:@(i)];
|
||||
}
|
||||
}
|
||||
|
||||
// Notify via RACSubject
|
||||
[_layoutChangeSubject sendNext:@{
|
||||
@"type": @"selection",
|
||||
@"range": [NSValue valueWithRange:range]
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the current selection.
|
||||
*/
|
||||
- (void)clearSelection {
|
||||
[_selectedLineIndices removeAllObjects];
|
||||
|
||||
[_layoutChangeSubject sendNext:@{
|
||||
@"type": @"selectionCleared"
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - Search
|
||||
|
||||
/**
|
||||
* Highlights search results within this frame.
|
||||
*
|
||||
* @param searchString The string to search for
|
||||
* @return The number of matches found
|
||||
*/
|
||||
- (NSUInteger)highlightSearchResults:(NSString *)searchString {
|
||||
if (!searchString || !_attributedString) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
NSMutableArray<NSValue *> *results = [NSMutableArray array];
|
||||
NSString *fullText = _attributedString.string;
|
||||
|
||||
// Use NSString's rangeOfString for searching
|
||||
NSRange searchRange = NSMakeRange(0, [fullText length]);
|
||||
|
||||
while (searchRange.location < [fullText length]) {
|
||||
NSRange foundRange = [fullText rangeOfString:searchString
|
||||
options:NSCaseInsensitiveSearch
|
||||
range:searchRange];
|
||||
|
||||
if (foundRange.location == NSNotFound) {
|
||||
break;
|
||||
}
|
||||
|
||||
[results addObject:[NSValue valueWithRange:foundRange]];
|
||||
|
||||
// Move search range forward
|
||||
searchRange.location = foundRange.location + foundRange.length;
|
||||
searchRange.length = [fullText length] - searchRange.location;
|
||||
}
|
||||
|
||||
// Store results
|
||||
_searchResultString = searchString;
|
||||
_searchResultRanges = [results copy];
|
||||
|
||||
// Create highlight ranges
|
||||
NSMutableArray *highlights = [NSMutableArray array];
|
||||
UIColor *highlightColor = [UIColor yellowColor];
|
||||
|
||||
for (NSValue *rangeValue in results) {
|
||||
[highlights addObject:@{
|
||||
@"range": rangeValue,
|
||||
@"color": highlightColor
|
||||
}];
|
||||
}
|
||||
|
||||
_highlightRanges = [highlights copy];
|
||||
|
||||
return [results count];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all search result highlights.
|
||||
*/
|
||||
- (void)clearSearchHighlights {
|
||||
_searchResultString = nil;
|
||||
_searchResultRanges = nil;
|
||||
_highlightRanges = nil;
|
||||
}
|
||||
|
||||
@end
|
||||
291
Doc/WXRead/decompiled/WRCoreTextLayouter.h
Normal file
291
Doc/WXRead/decompiled/WRCoreTextLayouter.h
Normal file
@ -0,0 +1,291 @@
|
||||
//
|
||||
// WRCoreTextLayouter.h
|
||||
// WeRead
|
||||
//
|
||||
// Reverse-engineered from binary analysis
|
||||
// CoreText typesetter wrapper for WeRead's reading engine
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
#import <CoreText/CoreText.h>
|
||||
|
||||
@class WRCoreTextLayoutFrame;
|
||||
@class WRMarkContentChapter;
|
||||
@class QMUITextField;
|
||||
@class WRButton;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
#pragma mark - Layout Configuration
|
||||
|
||||
/**
|
||||
* Configuration options for text layout.
|
||||
* Controls how the typesetter processes attributed strings.
|
||||
*/
|
||||
@interface WRCoreTextLayoutConfig : NSObject
|
||||
|
||||
@property (nonatomic, assign) CGFloat frameWidth;
|
||||
@property (nonatomic, assign) CGFloat frameHeight;
|
||||
@property (nonatomic, assign) UIEdgeInsets edgeInsets;
|
||||
@property (nonatomic, assign) NSUInteger numberOfColumns;
|
||||
@property (nonatomic, assign) CGFloat columnGap;
|
||||
@property (nonatomic, assign) BOOL avoidOrphans;
|
||||
@property (nonatomic, assign) BOOL avoidWidows;
|
||||
@property (nonatomic, assign) BOOL hyphenation;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - WRCoreTextLayouter
|
||||
|
||||
/**
|
||||
* WRCoreTextLayouter - CoreText typesetter wrapper for WeRead.
|
||||
*
|
||||
* This class wraps CTTypesetter and CTFramesetter to provide high-level
|
||||
* text layout functionality. It takes an NSAttributedString and produces
|
||||
* WRCoreTextLayoutFrame objects representing individual pages.
|
||||
*
|
||||
* Architecture:
|
||||
* - Creates CTTypesetter from attributed string
|
||||
* - Manages typesetter lifecycle and caching
|
||||
* - Produces layout frames for pagination
|
||||
* - Handles image resizing and page backgrounds
|
||||
* - Integrates with WeRead's theme system
|
||||
*/
|
||||
@interface WRCoreTextLayouter : NSObject
|
||||
|
||||
#pragma mark - Text Content
|
||||
|
||||
/** The original plain text string */
|
||||
@property (nonatomic, strong, nullable) NSString *plainText;
|
||||
|
||||
/** The attributed string to be laid out */
|
||||
@property (nonatomic, strong, nullable) NSAttributedString *attributedString;
|
||||
|
||||
/** A copy of the attributed string for internal modifications */
|
||||
@property (nonatomic, strong, nullable) NSAttributedString *internalAttributedString;
|
||||
|
||||
#pragma mark - UI Components (likely from parent view hierarchy)
|
||||
|
||||
/** Reference to the containing view controller */
|
||||
@property (nonatomic, weak, nullable) UIViewController *viewController;
|
||||
|
||||
/** Reference to the scroll view for pagination */
|
||||
@property (nonatomic, weak, nullable) UIScrollView *scrollView;
|
||||
|
||||
/** Search text field (for find-in-book functionality) */
|
||||
@property (nonatomic, strong, nullable) QMUITextField *searchField;
|
||||
|
||||
/** Another text field (possibly for page jump) */
|
||||
@property (nonatomic, strong, nullable) QMUITextField *pageInputField;
|
||||
|
||||
/** Button for actions */
|
||||
@property (nonatomic, strong, nullable) WRButton *actionButton;
|
||||
|
||||
/** Layer for decorations */
|
||||
@property (nonatomic, strong, nullable) CALayer *decorationLayer;
|
||||
|
||||
/** Layer for shadow/overlay effects */
|
||||
@property (nonatomic, strong, nullable) CALayer *shadowLayer;
|
||||
|
||||
/** Title label */
|
||||
@property (nonatomic, strong, nullable) UILabel *titleLabel;
|
||||
|
||||
/** Subtitle/info label */
|
||||
@property (nonatomic, strong, nullable) UILabel *infoLabel;
|
||||
|
||||
#pragma mark - Theme and Appearance
|
||||
|
||||
/** Background color for the text rendering area */
|
||||
@property (nonatomic, strong, nullable) UIColor *backgroundColor;
|
||||
|
||||
/** Text color */
|
||||
@property (nonatomic, strong, nullable) UIColor *textColor;
|
||||
|
||||
/** Primary font for body text */
|
||||
@property (nonatomic, strong, nullable) UIFont *bodyFont;
|
||||
|
||||
/** Secondary font (for headings, emphasis) */
|
||||
@property (nonatomic, strong, nullable) UIFont *headingFont;
|
||||
|
||||
/** Container view for rendered content */
|
||||
@property (nonatomic, weak, nullable) UIView *containerView;
|
||||
|
||||
#pragma mark - Content Metadata
|
||||
|
||||
/** Chapter identifier */
|
||||
@property (nonatomic, copy, nullable) NSString *chapterId;
|
||||
|
||||
/** Book identifier */
|
||||
@property (nonatomic, copy, nullable) NSString *bookId;
|
||||
|
||||
/** Section identifier within chapter */
|
||||
@property (nonatomic, copy, nullable) NSString *sectionId;
|
||||
|
||||
/** Page identifier for current page */
|
||||
@property (nonatomic, copy, nullable) NSString *pageId;
|
||||
|
||||
/** Content source identifier */
|
||||
@property (nonatomic, copy, nullable) NSString *sourceId;
|
||||
|
||||
/** Unique layout identifier */
|
||||
@property (nonatomic, copy, nullable) NSString *layoutId;
|
||||
|
||||
/** Rendering mode identifier */
|
||||
@property (nonatomic, copy, nullable) NSString *renderMode;
|
||||
|
||||
/** Theme identifier */
|
||||
@property (nonatomic, copy, nullable) NSString *themeId;
|
||||
|
||||
/** Additional layout attributes dictionary */
|
||||
@property (nonatomic, strong, nullable) NSDictionary *layoutAttributes;
|
||||
|
||||
/** Array of embedded attachments (images, etc.) */
|
||||
@property (nonatomic, strong, nullable) NSArray *attachments;
|
||||
|
||||
/** Reference to the chapter content model */
|
||||
@property (nonatomic, strong, nullable) WRMarkContentChapter *chapter;
|
||||
|
||||
/** Current pagination cursor string */
|
||||
@property (nonatomic, copy, nullable) NSString *paginationCursor;
|
||||
|
||||
/** Mutable array of layout frames (pages) */
|
||||
@property (nonatomic, strong, nullable) NSMutableArray<WRCoreTextLayoutFrame *> *layoutFrames;
|
||||
|
||||
#pragma mark - Initialization
|
||||
|
||||
/**
|
||||
* Initialize with an attributed string for layout.
|
||||
*
|
||||
* @param attributedString The text with styling attributes
|
||||
* @return Initialized layouter instance
|
||||
*/
|
||||
- (instancetype)initWithAttributedString:(NSAttributedString *)attributedString;
|
||||
|
||||
/**
|
||||
* Initialize with attributed string and layout configuration.
|
||||
*
|
||||
* @param attributedString The text with styling attributes
|
||||
* @param config Layout configuration options
|
||||
* @return Initialized layouter instance
|
||||
*/
|
||||
- (instancetype)initWithAttributedString:(NSAttributedString *)attributedString
|
||||
config:(WRCoreTextLayoutConfig *)config;
|
||||
|
||||
#pragma mark - Typesetter Management
|
||||
|
||||
/**
|
||||
* Create or recreate the internal CTTypesetter.
|
||||
* Called when the attributed string changes.
|
||||
*/
|
||||
- (void)createTypesetter;
|
||||
|
||||
/**
|
||||
* Create or recreate the internal CTFramesetter.
|
||||
* Called when frame-based layout is needed.
|
||||
*/
|
||||
- (void)createFramesetter;
|
||||
|
||||
/**
|
||||
* Invalidate the current typesetter, forcing recreation on next use.
|
||||
*/
|
||||
- (void)invalidateTypesetter;
|
||||
|
||||
#pragma mark - Layout Frame Creation
|
||||
|
||||
/**
|
||||
* Create a layout frame for a given string range within the specified rect.
|
||||
*
|
||||
* @param range The range of the attributed string to lay out
|
||||
* @param frame The bounding rectangle for the layout
|
||||
* @return A new WRCoreTextLayoutFrame for the given range
|
||||
*/
|
||||
- (WRCoreTextLayoutFrame *)layoutFrameWithRange:(NSRange)range
|
||||
frame:(CGRect)frame;
|
||||
|
||||
/**
|
||||
* Create layout frames for the entire attributed string.
|
||||
* Breaks content into pages based on the frame size.
|
||||
*
|
||||
* @param pageSize The size of each page
|
||||
* @return Array of WRCoreTextLayoutFrame objects
|
||||
*/
|
||||
- (NSArray<WRCoreTextLayoutFrame *> *)layoutFramesForPageSize:(CGSize)pageSize;
|
||||
|
||||
/**
|
||||
* Create a single layout frame for the given range.
|
||||
*
|
||||
* @param range The range of text to lay out
|
||||
* @param rect The bounding rectangle
|
||||
* @param columns Number of columns
|
||||
* @return A layout frame or nil if range is invalid
|
||||
*/
|
||||
- (nullable WRCoreTextLayoutFrame *)layoutFrameForRange:(NSRange)range
|
||||
rect:(CGRect)rect
|
||||
columns:(NSUInteger)columns;
|
||||
|
||||
#pragma mark - Page Background Images
|
||||
|
||||
/**
|
||||
* Get the page background image for a given text range.
|
||||
*
|
||||
* @param range The range of text on the page
|
||||
* @param themeBgColor The current theme's background color
|
||||
* @return A UIImage for the page background, or nil
|
||||
*/
|
||||
- (nullable UIImage *)pageBackgroundImageAtRange:(NSRange)range
|
||||
themeBgColor:(UIColor *)themeBgColor;
|
||||
|
||||
#pragma mark - Image Utilities
|
||||
|
||||
/**
|
||||
* Resize an image for display within the layout.
|
||||
*
|
||||
* @param imagePath Path or URL string for the image
|
||||
* @param rect The target rectangle for the image
|
||||
* @param position The position within the layout
|
||||
* @param sizePattern The size pattern to apply
|
||||
* @param darkMode Whether dark mode is active
|
||||
* @param themeBgColor The theme background color for blending
|
||||
* @return A resized UIImage
|
||||
*/
|
||||
- (UIImage *)resizedImageForImagePath:(NSString *)imagePath
|
||||
rect:(CGRect)rect
|
||||
position:(NSUInteger)position
|
||||
sizePattern:(NSString *)sizePattern
|
||||
darkMode:(BOOL)darkMode
|
||||
themeBgColor:(UIColor *)themeBgColor;
|
||||
|
||||
#pragma mark - Pagination
|
||||
|
||||
/**
|
||||
* Calculate the number of pages for the given page size.
|
||||
*
|
||||
* @param pageSize The size of each page
|
||||
* @return The total number of pages
|
||||
*/
|
||||
- (NSUInteger)numberOfPagesForPageSize:(CGSize)pageSize;
|
||||
|
||||
/**
|
||||
* Get the text range for a specific page.
|
||||
*
|
||||
* @param pageIndex The page index (0-based)
|
||||
* @param pageSize The size of each page
|
||||
* @return The NSRange of text on the specified page
|
||||
*/
|
||||
- (NSRange)rangeForPageAtIndex:(NSUInteger)pageIndex
|
||||
pageSize:(CGSize)pageSize;
|
||||
|
||||
#pragma mark - Suggested Line Heights
|
||||
|
||||
/**
|
||||
* Get suggested line fragment heights for the current content.
|
||||
* Used for precise pagination calculations.
|
||||
*
|
||||
* @return Array of NSNumber values representing line heights
|
||||
*/
|
||||
- (NSArray<NSNumber *> *)suggestedLineFragHeights;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
886
Doc/WXRead/decompiled/WRCoreTextLayouter.m
Normal file
886
Doc/WXRead/decompiled/WRCoreTextLayouter.m
Normal file
@ -0,0 +1,886 @@
|
||||
//
|
||||
// WRCoreTextLayouter.m
|
||||
// WeRead
|
||||
//
|
||||
// Reverse-engineered from binary analysis
|
||||
// CoreText typesetter wrapper implementation
|
||||
//
|
||||
// This file contains pseudo-code reconstruction of the WRCoreTextLayouter
|
||||
// class based on ivar analysis, method signatures, and behavioral context.
|
||||
//
|
||||
|
||||
#import "WRCoreTextLayouter.h"
|
||||
#import "WRCoreTextLayoutFrame.h"
|
||||
#import "WRMarkContentChapter.h"
|
||||
#import <CoreText/CoreText.h>
|
||||
|
||||
// Forward declarations for internal C types (not in public headers)
|
||||
typedef struct _CTTypesetter *CTTypesetterRef;
|
||||
typedef struct _CTFramesetter *CTFramesetterRef;
|
||||
|
||||
#pragma mark - Internal Constants
|
||||
|
||||
// Size patterns for image resizing (from analysis of resizedImageForImagePath:)
|
||||
static NSString * const kSizePatternFull = @"full";
|
||||
static NSString * const kSizePatternHalf = @"half";
|
||||
static NSString * const kSizePatternThird = @"third";
|
||||
static NSString * const kSizePatternQuarter = @"quarter";
|
||||
|
||||
// Default layout configuration values
|
||||
static const CGFloat kDefaultFrameWidth = 320.0;
|
||||
static const CGFloat kDefaultFrameHeight = 480.0;
|
||||
static const CGFloat kDefaultColumnGap = 20.0;
|
||||
|
||||
#pragma mark - WRCoreTextLayoutConfig Implementation
|
||||
|
||||
@implementation WRCoreTextLayoutConfig
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_frameWidth = kDefaultFrameWidth;
|
||||
_frameHeight = kDefaultFrameHeight;
|
||||
_edgeInsets = UIEdgeInsetsMake(10, 15, 10, 15);
|
||||
_numberOfColumns = 1;
|
||||
_columnGap = kDefaultColumnGap;
|
||||
_avoidOrphans = YES;
|
||||
_avoidWidows = YES;
|
||||
_hyphenation = YES;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - Private Interface
|
||||
|
||||
@interface WRCoreTextLayouter () {
|
||||
// CoreText typesetter - the core text processing engine
|
||||
// This is a C object that performs the actual glyph layout
|
||||
CTTypesetterRef _typesetter;
|
||||
|
||||
// CoreText framesetter - creates frames from the typesetter
|
||||
// Used when frame-based layout is needed (with paths)
|
||||
CTFramesetterRef _framesetter;
|
||||
|
||||
// Track whether the typesetter needs recreation
|
||||
BOOL _typesetterDirty;
|
||||
|
||||
// Track whether the framesetter needs recreation
|
||||
BOOL _framesetterDirty;
|
||||
|
||||
// Internal lock for thread safety
|
||||
NSLock *_layoutLock;
|
||||
|
||||
// Cache for image resizing operations
|
||||
NSCache *_imageCache;
|
||||
|
||||
// Current layout configuration
|
||||
WRCoreTextLayoutConfig *_config;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - WRCoreTextLayouter Implementation
|
||||
|
||||
@implementation WRCoreTextLayouter
|
||||
|
||||
#pragma mark - Lifecycle
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
[self commonInit];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (instancetype)initWithAttributedString:(NSAttributedString *)attributedString {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
[self commonInit];
|
||||
_attributedString = [attributedString copy];
|
||||
_internalAttributedString = [attributedString copy];
|
||||
_typesetterDirty = YES;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (instancetype)initWithAttributedString:(NSAttributedString *)attributedString
|
||||
config:(WRCoreTextLayoutConfig *)config {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
[self commonInit];
|
||||
_attributedString = [attributedString copy];
|
||||
_internalAttributedString = [attributedString copy];
|
||||
_config = config;
|
||||
_typesetterDirty = YES;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common initialization - sets up internal state.
|
||||
* Called by all init methods.
|
||||
*/
|
||||
- (void)commonInit {
|
||||
_layoutLock = [[NSLock alloc] init];
|
||||
_imageCache = [[NSCache alloc] init];
|
||||
_imageCache.countLimit = 50; // Cache up to 50 resized images
|
||||
_layoutFrames = [NSMutableArray array];
|
||||
_typesetterDirty = YES;
|
||||
_framesetterDirty = YES;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
// Release CoreText objects - these are C objects, not ObjC
|
||||
if (_typesetter) {
|
||||
CFRelease(_typesetter);
|
||||
_typesetter = NULL;
|
||||
}
|
||||
if (_framesetter) {
|
||||
CFRelease(_framesetter);
|
||||
_framesetter = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Property Accessors
|
||||
|
||||
- (void)setAttributedString:(NSAttributedString *)attributedString {
|
||||
// When the attributed string changes, mark typesetter as dirty
|
||||
_attributedString = [attributedString copy];
|
||||
_internalAttributedString = [attributedString copy];
|
||||
_typesetterDirty = YES;
|
||||
_framesetterDirty = YES;
|
||||
|
||||
// Clear existing layout frames as they're now invalid
|
||||
[_layoutFrames removeAllObjects];
|
||||
|
||||
// Extract plain text for convenience
|
||||
_plainText = [_attributedString string];
|
||||
}
|
||||
|
||||
- (void)setPlainText:(NSString *)plainText {
|
||||
_plainText = [plainText copy];
|
||||
// Note: This doesn't update the attributed string - it's read-only metadata
|
||||
}
|
||||
|
||||
#pragma mark - Typesetter Management
|
||||
|
||||
/**
|
||||
* Creates the CTTypesetter from the current attributed string.
|
||||
*
|
||||
* CTTypesetter is the low-level CoreText object that performs glyph layout.
|
||||
* It analyzes the attributed string and prepares it for line-by-line layout.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Validate we have an attributed string
|
||||
* 2. Release existing typesetter if any
|
||||
* 3. Create new CTTypesetter with the attributed string
|
||||
* 4. Mark as clean
|
||||
*/
|
||||
- (void)createTypesetter {
|
||||
[_layoutLock lock];
|
||||
|
||||
if (!_typesetterDirty && _typesetter) {
|
||||
[_layoutLock unlock];
|
||||
return; // Already up to date
|
||||
}
|
||||
|
||||
// Release old typesetter
|
||||
if (_typesetter) {
|
||||
CFRelease(_typesetter);
|
||||
_typesetter = NULL;
|
||||
}
|
||||
|
||||
// Need attributed string to create typesetter
|
||||
if (!_internalAttributedString) {
|
||||
[_layoutLock unlock];
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the CTTypesetter
|
||||
// CTTypesetterCreateWithAttributedString analyzes the string and
|
||||
// prepares internal data structures for efficient line breaking
|
||||
_typesetter = CTTypesetterCreateWithAttributedString(
|
||||
(__bridge CFAttributedStringRef)_internalAttributedString
|
||||
);
|
||||
|
||||
_typesetterDirty = NO;
|
||||
|
||||
[_layoutLock unlock];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the CTFramesetter from the current attributed string.
|
||||
*
|
||||
* CTFramesetter is a higher-level API that creates CTFrame objects.
|
||||
* It internally manages its own CTTypesetter.
|
||||
*/
|
||||
- (void)createFramesetter {
|
||||
[_layoutLock lock];
|
||||
|
||||
if (!_framesetterDirty && _framesetter) {
|
||||
[_layoutLock unlock];
|
||||
return;
|
||||
}
|
||||
|
||||
if (_framesetter) {
|
||||
CFRelease(_framesetter);
|
||||
_framesetter = NULL;
|
||||
}
|
||||
|
||||
if (!_internalAttributedString) {
|
||||
[_layoutLock unlock];
|
||||
return;
|
||||
}
|
||||
|
||||
// CTFramesetterCreateWithAttributedString creates a framesetter
|
||||
// that can produce CTFrame objects for arbitrary paths
|
||||
_framesetter = CTFramesetterCreateWithAttributedString(
|
||||
(__bridge CFAttributedStringRef)_internalAttributedString
|
||||
);
|
||||
|
||||
_framesetterDirty = NO;
|
||||
|
||||
[_layoutLock unlock];
|
||||
}
|
||||
|
||||
- (void)invalidateTypesetter {
|
||||
[_layoutLock lock];
|
||||
_typesetterDirty = YES;
|
||||
_framesetterDirty = YES;
|
||||
[_layoutLock unlock];
|
||||
}
|
||||
|
||||
#pragma mark - Layout Frame Creation
|
||||
|
||||
/**
|
||||
* Creates a WRCoreTextLayoutFrame for a given string range.
|
||||
*
|
||||
* This is the primary layout method. It:
|
||||
* 1. Ensures the typesetter is ready
|
||||
* 2. Creates a CTTypesetter layout for the range
|
||||
* 3. Wraps it in a WRCoreTextLayoutFrame
|
||||
*
|
||||
* @param range Range of the attributed string to lay out
|
||||
* @param frame Bounding rectangle for the layout
|
||||
* @return A new WRCoreTextLayoutFrame
|
||||
*/
|
||||
- (WRCoreTextLayoutFrame *)layoutFrameWithRange:(NSRange)range
|
||||
frame:(CGRect)frame {
|
||||
[self createTypesetter];
|
||||
|
||||
if (!_typesetter) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
[_layoutLock lock];
|
||||
|
||||
// Create a CGPath for the frame bounds
|
||||
// The path defines the region where text will be laid out
|
||||
CGMutablePathRef path = CGPathCreateMutable();
|
||||
CGPathAddRect(path, NULL, frame);
|
||||
|
||||
// Create CTFrame from the typesetter
|
||||
// CTTypesetterCreateFrame creates a frame for the given range within the path
|
||||
CTFrameRef ctFrame = CTTypesetterCreateFrame(
|
||||
_typesetter,
|
||||
CFRangeMake(range.location, range.length),
|
||||
path,
|
||||
NULL // No frame attributes
|
||||
);
|
||||
|
||||
CGPathRelease(path);
|
||||
|
||||
if (!ctFrame) {
|
||||
[_layoutLock unlock];
|
||||
return nil;
|
||||
}
|
||||
|
||||
// Create our wrapper layout frame
|
||||
WRCoreTextLayoutFrame *layoutFrame = [[WRCoreTextLayoutFrame alloc] init];
|
||||
layoutFrame.attributedString = _internalAttributedString;
|
||||
// The layout frame takes ownership of the CTFrame
|
||||
[layoutFrame setCTFrame:ctFrame range:range];
|
||||
|
||||
CFRelease(ctFrame);
|
||||
|
||||
[_layoutFrames addObject:layoutFrame];
|
||||
|
||||
[_layoutLock unlock];
|
||||
|
||||
return layoutFrame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates layout frames for the entire attributed string.
|
||||
* This is the pagination method - it breaks content into pages.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Get the total string length
|
||||
* 2. Start from index 0
|
||||
* 3. For each page:
|
||||
* a. Use CTTypesetterSuggestLineBreak to find how much fits
|
||||
* b. Create a layout frame for that range
|
||||
* c. Advance the cursor
|
||||
* 4. Repeat until all text is laid out
|
||||
*
|
||||
* @param pageSize Size of each page
|
||||
* @return Array of WRCoreTextLayoutFrame objects
|
||||
*/
|
||||
- (NSArray<WRCoreTextLayoutFrame *> *)layoutFramesForPageSize:(CGSize)pageSize {
|
||||
[self createTypesetter];
|
||||
|
||||
if (!_typesetter || !_internalAttributedString) {
|
||||
return @[];
|
||||
}
|
||||
|
||||
[_layoutLock lock];
|
||||
|
||||
NSMutableArray<WRCoreTextLayoutFrame *> *frames = [NSMutableArray array];
|
||||
|
||||
NSUInteger totalLength = [_internalAttributedString length];
|
||||
NSUInteger currentIndex = 0;
|
||||
|
||||
// Calculate the usable width (accounting for margins)
|
||||
UIEdgeInsets insets = _config ? _config.edgeInsets : UIEdgeInsetsMake(10, 15, 10, 15);
|
||||
CGFloat usableWidth = pageSize.width - insets.left - insets.right;
|
||||
|
||||
while (currentIndex < totalLength) {
|
||||
// CTTypesetterSuggestLineBreak suggests how many characters fit in the width
|
||||
// This is the core pagination algorithm
|
||||
CFIndex lineBreakIndex = CTTypesetterSuggestLineBreak(
|
||||
_typesetter,
|
||||
currentIndex,
|
||||
usableWidth
|
||||
);
|
||||
|
||||
if (lineBreakIndex <= 0) {
|
||||
// Safety: avoid infinite loop
|
||||
break;
|
||||
}
|
||||
|
||||
NSRange pageRange = NSMakeRange(currentIndex, lineBreakIndex);
|
||||
CGRect pageRect = CGRectMake(0, 0, pageSize.width, pageSize.height);
|
||||
|
||||
// Create layout frame for this page
|
||||
WRCoreTextLayoutFrame *frame = [self layoutFrameWithRange:pageRange
|
||||
frame:pageRect];
|
||||
if (frame) {
|
||||
[frames addObject:frame];
|
||||
}
|
||||
|
||||
currentIndex += lineBreakIndex;
|
||||
}
|
||||
|
||||
[_layoutLock unlock];
|
||||
|
||||
return [frames copy];
|
||||
}
|
||||
|
||||
- (WRCoreTextLayoutFrame *)layoutFrameForRange:(NSRange)range
|
||||
rect:(CGRect)rect
|
||||
columns:(NSUInteger)columns {
|
||||
if (range.length == 0) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
// For multi-column layout, we'd need to create a more complex path
|
||||
if (columns > 1) {
|
||||
// Create column-based path
|
||||
CGMutablePathRef path = CGPathCreateMutable();
|
||||
CGFloat columnWidth = (rect.size.width - (columns - 1) * _config.columnGap) / columns;
|
||||
|
||||
for (NSUInteger i = 0; i < columns; i++) {
|
||||
CGFloat x = rect.origin.x + i * (columnWidth + _config.columnGap);
|
||||
CGRect columnRect = CGRectMake(x, rect.origin.y, columnWidth, rect.size.height);
|
||||
CGPathAddRect(path, NULL, columnRect);
|
||||
}
|
||||
|
||||
[self createFramesetter];
|
||||
|
||||
if (!_framesetter) {
|
||||
CGPathRelease(path);
|
||||
return nil;
|
||||
}
|
||||
|
||||
[_layoutLock lock];
|
||||
|
||||
CTFrameRef ctFrame = CTFramesetterCreateFrame(
|
||||
_framesetter,
|
||||
CFRangeMake(range.location, range.length),
|
||||
path,
|
||||
NULL
|
||||
);
|
||||
|
||||
CGPathRelease(path);
|
||||
|
||||
if (!ctFrame) {
|
||||
[_layoutLock unlock];
|
||||
return nil;
|
||||
}
|
||||
|
||||
WRCoreTextLayoutFrame *layoutFrame = [[WRCoreTextLayoutFrame alloc] init];
|
||||
layoutFrame.attributedString = _internalAttributedString;
|
||||
[layoutFrame setCTFrame:ctFrame range:range];
|
||||
|
||||
CFRelease(ctFrame);
|
||||
|
||||
[_layoutLock unlock];
|
||||
|
||||
return layoutFrame;
|
||||
}
|
||||
|
||||
// Single column - use the simpler typesetter path
|
||||
return [self layoutFrameWithRange:range frame:rect];
|
||||
}
|
||||
|
||||
#pragma mark - Page Background Images
|
||||
|
||||
/**
|
||||
* Generates a page background image for a given text range.
|
||||
*
|
||||
* This method creates a background image that can include:
|
||||
* - Theme-specific background textures
|
||||
* - Decorative elements (borders, patterns)
|
||||
* - Chapter-specific backgrounds (e.g., for chapter openings)
|
||||
*
|
||||
* The themeBgColor is used to tint the background to match the current theme.
|
||||
*
|
||||
* @param range The text range on the page
|
||||
* @param themeBgColor The theme's background color
|
||||
* @return A UIImage for the page background
|
||||
*/
|
||||
- (UIImage *)pageBackgroundImageAtRange:(NSRange)range
|
||||
themeBgColor:(UIColor *)themeBgColor {
|
||||
// Check cache first
|
||||
NSString *cacheKey = [NSString stringWithFormat:@"bg_%lu_%lu_%@",
|
||||
(unsigned long)range.location,
|
||||
(unsigned long)range.length,
|
||||
themeBgColor];
|
||||
|
||||
UIImage *cachedImage = [_imageCache objectForKey:cacheKey];
|
||||
if (cachedImage) {
|
||||
return cachedImage;
|
||||
}
|
||||
|
||||
// Get the text in this range to check for special content
|
||||
NSString *pageText = [_internalAttributedString.string substringWithRange:range];
|
||||
|
||||
// Determine the background type based on content
|
||||
// Chapter openings might get special treatment
|
||||
BOOL isChapterStart = (range.location == 0);
|
||||
BOOL hasChapterTitle = [pageText containsString:@"第"] ||
|
||||
[pageText containsString:@"Chapter"];
|
||||
|
||||
CGSize imageSize = _config ? CGSizeMake(_config.frameWidth, _config.frameHeight)
|
||||
: CGSizeMake(320, 480);
|
||||
|
||||
UIGraphicsBeginImageContextWithOptions(imageSize, YES, [UIScreen mainScreen].scale);
|
||||
|
||||
CGContextRef context = UIGraphicsGetCurrentContext();
|
||||
|
||||
// Fill with theme background color
|
||||
[themeBgColor setFill];
|
||||
CGContextFillRect(context, CGRectMake(0, 0, imageSize.width, imageSize.height));
|
||||
|
||||
if (isChapterStart || hasChapterTitle) {
|
||||
// Special background for chapter start pages
|
||||
// Could include decorative borders, ornamental elements
|
||||
[self drawChapterStartDecorationInContext:context size:imageSize color:themeBgColor];
|
||||
} else {
|
||||
// Regular page background
|
||||
// Could include subtle patterns, margins, page numbers area
|
||||
[self drawRegularPageDecorationInContext:context size:imageSize color:themeBgColor];
|
||||
}
|
||||
|
||||
UIImage *result = UIGraphicsGetImageFromCurrentImageContext();
|
||||
UIGraphicsEndImageContext();
|
||||
|
||||
// Cache the result
|
||||
if (result) {
|
||||
[_imageCache setObject:result forKey:cacheKey];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws decorative elements for chapter start pages.
|
||||
* This includes ornamental borders and chapter-specific decorations.
|
||||
*/
|
||||
- (void)drawChapterStartDecorationInContext:(CGContextRef)context
|
||||
size:(CGSize)size
|
||||
color:(UIColor *)color {
|
||||
// Draw a decorative border
|
||||
CGRect borderRect = CGRectInset(CGRectMake(0, 0, size.width, size.height), 15, 20);
|
||||
|
||||
CGContextSetStrokeColorWithColor(context, [color colorWithAlphaComponent:0.3].CGColor);
|
||||
CGContextSetLineWidth(context, 1.0);
|
||||
CGContextStrokeRect(context, borderRect);
|
||||
|
||||
// Could add more ornamental elements:
|
||||
// - Corner decorations
|
||||
// - Header/footer ornaments
|
||||
// - Drop cap indicators
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws standard page decorations (margins, guides).
|
||||
*/
|
||||
- (void)drawRegularPageDecorationInContext:(CGContextRef)context
|
||||
size:(CGSize)size
|
||||
color:(UIColor *)color {
|
||||
// Draw subtle margin guides
|
||||
CGFloat leftMargin = _config ? _config.edgeInsets.left : 15;
|
||||
CGFloat rightMargin = _config ? _config.edgeInsets.right : 15;
|
||||
|
||||
CGContextSetStrokeColorWithColor(context, [color colorWithAlphaComponent:0.1].CGColor);
|
||||
CGContextSetLineWidth(context, 0.5);
|
||||
|
||||
// Left margin line
|
||||
CGContextMoveToPoint(context, leftMargin, 0);
|
||||
CGContextAddLineToPoint(context, leftMargin, size.height);
|
||||
CGContextStrokePath(context);
|
||||
|
||||
// Right margin line
|
||||
CGContextMoveToPoint(context, size.width - rightMargin, 0);
|
||||
CGContextAddLineToPoint(context, size.width - rightMargin, size.height);
|
||||
CGContextStrokePath(context);
|
||||
}
|
||||
|
||||
#pragma mark - Image Utilities
|
||||
|
||||
/**
|
||||
* Resizes an image for display within the text layout.
|
||||
*
|
||||
* This method handles various image sizing scenarios:
|
||||
* - Full-width images
|
||||
* - Half-width images (side by side)
|
||||
* - Thumbnail-sized images
|
||||
* - Dark mode adaptations
|
||||
*
|
||||
* The sizePattern parameter controls how the image is scaled:
|
||||
* - "full": Scale to fill the available width
|
||||
* - "half": Scale to half width
|
||||
* - "third": Scale to one-third width
|
||||
* - "quarter": Scale to one-quarter width
|
||||
*
|
||||
* @param imagePath Path to the image file or URL string
|
||||
* @param rect Target rectangle for positioning
|
||||
* @param position Position index (for multi-image layouts)
|
||||
* @param sizePattern Size pattern string
|
||||
* @param darkMode Whether to apply dark mode adjustments
|
||||
* @param themeBgColor Theme background color for blending
|
||||
* @return Resized UIImage
|
||||
*/
|
||||
- (UIImage *)resizedImageForImagePath:(NSString *)imagePath
|
||||
rect:(CGRect)rect
|
||||
position:(NSUInteger)position
|
||||
sizePattern:(NSString *)sizePattern
|
||||
darkMode:(BOOL)darkMode
|
||||
themeBgColor:(UIColor *)themeBgColor {
|
||||
// Generate cache key
|
||||
NSString *cacheKey = [NSString stringWithFormat:@"img_%@_%@_%lu_%d",
|
||||
imagePath, sizePattern, (unsigned long)position, darkMode];
|
||||
|
||||
UIImage *cachedImage = [_imageCache objectForKey:cacheKey];
|
||||
if (cachedImage) {
|
||||
return cachedImage;
|
||||
}
|
||||
|
||||
// Load the original image
|
||||
UIImage *originalImage = nil;
|
||||
|
||||
// Handle different image path formats
|
||||
if ([imagePath hasPrefix:@"http://"] || [imagePath hasPrefix:@"https://"]) {
|
||||
// Remote image - would need async loading
|
||||
// For now, return placeholder
|
||||
originalImage = [UIImage imageNamed:@"placeholder_book_image"];
|
||||
} else if ([imagePath hasPrefix:@"/"]) {
|
||||
// Absolute file path
|
||||
originalImage = [UIImage imageWithContentsOfFile:imagePath];
|
||||
} else {
|
||||
// Bundle resource
|
||||
originalImage = [UIImage imageNamed:imagePath];
|
||||
}
|
||||
|
||||
if (!originalImage) {
|
||||
// Return a placeholder image
|
||||
return [self placeholderImageForSize:rect.size];
|
||||
}
|
||||
|
||||
// Calculate target size based on size pattern
|
||||
CGSize targetSize = [self targetSizeForPattern:sizePattern
|
||||
rect:rect
|
||||
imageSize:originalImage.size];
|
||||
|
||||
// Handle dark mode
|
||||
if (darkMode) {
|
||||
originalImage = [self darkModeAdjustedImage:originalImage
|
||||
withBgColor:themeBgColor];
|
||||
}
|
||||
|
||||
// Resize the image
|
||||
UIImage *resizedImage = [self resizeImage:originalImage toSize:targetSize];
|
||||
|
||||
// Cache the result
|
||||
if (resizedImage) {
|
||||
[_imageCache setObject:resizedImage forKey:cacheKey];
|
||||
}
|
||||
|
||||
return resizedImage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the target size based on a size pattern string.
|
||||
*/
|
||||
- (CGSize)targetSizeForPattern:(NSString *)pattern
|
||||
rect:(CGRect)rect
|
||||
imageSize:(CGSize)imageSize {
|
||||
CGFloat availableWidth = rect.size.width;
|
||||
|
||||
if ([pattern isEqualToString:kSizePatternFull]) {
|
||||
// Full width - maintain aspect ratio
|
||||
CGFloat scale = availableWidth / imageSize.width;
|
||||
return CGSizeMake(availableWidth, imageSize.height * scale);
|
||||
} else if ([pattern isEqualToString:kSizePatternHalf]) {
|
||||
// Half width
|
||||
CGFloat halfWidth = availableWidth / 2.0;
|
||||
CGFloat scale = halfWidth / imageSize.width;
|
||||
return CGSizeMake(halfWidth, imageSize.height * scale);
|
||||
} else if ([pattern isEqualToString:kSizePatternThird]) {
|
||||
// One-third width
|
||||
CGFloat thirdWidth = availableWidth / 3.0;
|
||||
CGFloat scale = thirdWidth / imageSize.width;
|
||||
return CGSizeMake(thirdWidth, imageSize.height * scale);
|
||||
} else if ([pattern isEqualToString:kSizePatternQuarter]) {
|
||||
// One-quarter width
|
||||
CGFloat quarterWidth = availableWidth / 4.0;
|
||||
CGFloat scale = quarterWidth / imageSize.width;
|
||||
return CGSizeMake(quarterWidth, imageSize.height * scale);
|
||||
}
|
||||
|
||||
// Default: fit within the rect while maintaining aspect ratio
|
||||
return [self fitSize:imageSize inSize:rect.size];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes an image to the target size using high-quality interpolation.
|
||||
*/
|
||||
- (UIImage *)resizeImage:(UIImage *)image toSize:(CGSize)size {
|
||||
UIGraphicsBeginImageContextWithOptions(size, YES, [UIScreen mainScreen].scale);
|
||||
[image drawInRect:CGRectMake(0, 0, size.width, size.height)];
|
||||
UIImage *result = UIGraphicsGetImageFromCurrentImageContext();
|
||||
UIGraphicsEndImageContext();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts an image for dark mode by blending with the background color.
|
||||
*/
|
||||
- (UIImage *)darkModeAdjustedImage:(UIImage *)image withBgColor:(UIColor *)bgColor {
|
||||
CGSize size = image.size;
|
||||
UIGraphicsBeginImageContextWithOptions(size, YES, [UIScreen mainScreen].scale);
|
||||
|
||||
// Draw background
|
||||
[bgColor setFill];
|
||||
CGContextFillRect(UIGraphicsGetCurrentContext(), CGRectMake(0, 0, size.width, size.height));
|
||||
|
||||
// Draw image with reduced opacity for dark mode blending
|
||||
[image drawInRect:CGRectMake(0, 0, size.width, size.height)
|
||||
blendMode:kCGBlendModeNormal
|
||||
alpha:0.85];
|
||||
|
||||
UIImage *result = UIGraphicsGetImageFromCurrentImageContext();
|
||||
UIGraphicsEndImageContext();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the size that fits within a container while maintaining aspect ratio.
|
||||
*/
|
||||
- (CGSize)fitSize:(CGSize)imageSize inSize:(CGSize)containerSize {
|
||||
CGFloat widthRatio = containerSize.width / imageSize.width;
|
||||
CGFloat heightRatio = containerSize.height / imageSize.height;
|
||||
CGFloat scale = MIN(widthRatio, heightRatio);
|
||||
|
||||
return CGSizeMake(imageSize.width * scale, imageSize.height * scale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a placeholder image for missing images.
|
||||
*/
|
||||
- (UIImage *)placeholderImageForSize:(CGSize)size {
|
||||
UIGraphicsBeginImageContextWithOptions(size, YES, [UIScreen mainScreen].scale);
|
||||
|
||||
// Light gray background
|
||||
[[UIColor colorWithWhite:0.9 alpha:1.0] setFill];
|
||||
CGContextFillRect(UIGraphicsGetCurrentContext(), CGRectMake(0, 0, size.width, size.height));
|
||||
|
||||
// Draw a simple icon indicator
|
||||
[[UIColor colorWithWhite:0.7 alpha:1.0] setFill];
|
||||
CGFloat iconSize = MIN(size.width, size.height) * 0.3;
|
||||
CGFloat x = (size.width - iconSize) / 2;
|
||||
CGFloat y = (size.height - iconSize) / 2;
|
||||
CGContextFillRect(UIGraphicsGetCurrentContext(), CGRectMake(x, y, iconSize, iconSize));
|
||||
|
||||
UIImage *result = UIGraphicsGetImageFromCurrentImageContext();
|
||||
UIGraphicsEndImageContext();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#pragma mark - Pagination Helpers
|
||||
|
||||
/**
|
||||
* Calculates the total number of pages for a given page size.
|
||||
*
|
||||
* Uses CTTypesetterSuggestLineBreak to simulate pagination
|
||||
* without actually creating layout frame objects.
|
||||
*/
|
||||
- (NSUInteger)numberOfPagesForPageSize:(CGSize)pageSize {
|
||||
[self createTypesetter];
|
||||
|
||||
if (!_typesetter || !_internalAttributedString) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
[_layoutLock lock];
|
||||
|
||||
NSUInteger pageCount = 0;
|
||||
NSUInteger totalLength = [_internalAttributedString length];
|
||||
NSUInteger currentIndex = 0;
|
||||
|
||||
UIEdgeInsets insets = _config ? _config.edgeInsets : UIEdgeInsetsMake(10, 15, 10, 15);
|
||||
CGFloat usableWidth = pageSize.width - insets.left - insets.right;
|
||||
|
||||
while (currentIndex < totalLength) {
|
||||
CFIndex lineBreakIndex = CTTypesetterSuggestLineBreak(
|
||||
_typesetter,
|
||||
currentIndex,
|
||||
usableWidth
|
||||
);
|
||||
|
||||
if (lineBreakIndex <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
pageCount++;
|
||||
currentIndex += lineBreakIndex;
|
||||
}
|
||||
|
||||
[_layoutLock unlock];
|
||||
|
||||
return pageCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the text range for a specific page index.
|
||||
*/
|
||||
- (NSRange)rangeForPageAtIndex:(NSUInteger)pageIndex
|
||||
pageSize:(CGSize)pageSize {
|
||||
[self createTypesetter];
|
||||
|
||||
if (!_typesetter || !_internalAttributedString) {
|
||||
return NSMakeRange(0, 0);
|
||||
}
|
||||
|
||||
[_layoutLock lock];
|
||||
|
||||
NSUInteger totalLength = [_internalAttributedString length];
|
||||
NSUInteger currentIndex = 0;
|
||||
NSUInteger currentPage = 0;
|
||||
|
||||
UIEdgeInsets insets = _config ? _config.edgeInsets : UIEdgeInsetsMake(10, 15, 10, 15);
|
||||
CGFloat usableWidth = pageSize.width - insets.left - insets.right;
|
||||
|
||||
while (currentIndex < totalLength && currentPage <= pageIndex) {
|
||||
CFIndex lineBreakIndex = CTTypesetterSuggestLineBreak(
|
||||
_typesetter,
|
||||
currentIndex,
|
||||
usableWidth
|
||||
);
|
||||
|
||||
if (lineBreakIndex <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (currentPage == pageIndex) {
|
||||
[_layoutLock unlock];
|
||||
return NSMakeRange(currentIndex, lineBreakIndex);
|
||||
}
|
||||
|
||||
currentIndex += lineBreakIndex;
|
||||
currentPage++;
|
||||
}
|
||||
|
||||
[_layoutLock unlock];
|
||||
|
||||
return NSMakeRange(0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns suggested line fragment heights for precise layout calculations.
|
||||
*
|
||||
* This method analyzes the attributed string and returns the heights
|
||||
* of each line as CoreText would lay them out. This is used for:
|
||||
* - Precise pagination calculations
|
||||
* - Avoiding orphans and widows
|
||||
* - Ensuring consistent line spacing
|
||||
*/
|
||||
- (NSArray<NSNumber *> *)suggestedLineFragHeights {
|
||||
[self createTypesetter];
|
||||
|
||||
if (!_typesetter || !_internalAttributedString) {
|
||||
return @[];
|
||||
}
|
||||
|
||||
[_layoutLock lock];
|
||||
|
||||
NSMutableArray<NSNumber *> *heights = [NSMutableArray array];
|
||||
|
||||
NSUInteger totalLength = [_internalAttributedString length];
|
||||
NSUInteger currentIndex = 0;
|
||||
|
||||
// Use a large width to get all lines in a single column
|
||||
CGFloat width = _config ? _config.frameWidth : 320.0;
|
||||
|
||||
while (currentIndex < totalLength) {
|
||||
CFIndex lineBreakIndex = CTTypesetterSuggestLineBreak(
|
||||
_typesetter,
|
||||
currentIndex,
|
||||
width
|
||||
);
|
||||
|
||||
if (lineBreakIndex <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Create a temporary CTLine to measure its height
|
||||
CTLineRef line = CTTypesetterCreateLine(
|
||||
_typesetter,
|
||||
CFRangeMake(currentIndex, lineBreakIndex)
|
||||
);
|
||||
|
||||
if (line) {
|
||||
CGFloat ascent, descent, leading;
|
||||
CTLineGetTypographicBounds(line, &ascent, &descent, &leading);
|
||||
|
||||
CGFloat lineHeight = ascent + descent + leading;
|
||||
[heights addObject:@(lineHeight)];
|
||||
|
||||
CFRelease(line);
|
||||
}
|
||||
|
||||
currentIndex += lineBreakIndex;
|
||||
}
|
||||
|
||||
[_layoutLock unlock];
|
||||
|
||||
return [heights copy];
|
||||
}
|
||||
|
||||
@end
|
||||
111
Doc/WXRead/decompiled/WREpubParser.h
Normal file
111
Doc/WXRead/decompiled/WREpubParser.h
Normal file
@ -0,0 +1,111 @@
|
||||
//
|
||||
// WREpubParser.h
|
||||
// WeRead (微信读书)
|
||||
// Reverse-engineered header
|
||||
//
|
||||
// EPUB file parser. Parses OPF (content.opf), NCX (toc.ncx), and XHTML chapter files.
|
||||
// Resolves EPUB structure: container.xml -> content.opf -> spine -> chapters.
|
||||
// Returns chapter list and resource mapping.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@class WRBook;
|
||||
@class WHAlbumInfo;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error domain and codes for EPUB parsing failures
|
||||
// ---------------------------------------------------------------------------
|
||||
extern NSString *const WREpubParserErrorDomain;
|
||||
|
||||
typedef NS_ENUM(NSInteger, WREpubParserErrorCode) {
|
||||
WREpubParserErrorFileNotFound = -1000,
|
||||
WREpubParserErrorContainerParseFail = -1001,
|
||||
WREpubParserErrorOPFParseFail = -1002,
|
||||
WREpubParserErrorNCXParseFail = -1003,
|
||||
WREpubParserErrorSpineEmpty = -1004,
|
||||
WREpubParserErrorChapterLoadFail = -1005,
|
||||
WREpubParserErrorDecryptionFail = -1006,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WREpubParserDelegate
|
||||
// ---------------------------------------------------------------------------
|
||||
@protocol WREpubParserDelegate <NSObject>
|
||||
@optional
|
||||
|
||||
/// Called when the EPUB controller encounters a fatal parsing error.
|
||||
/// The parser invokes this on the delegate (typically a UIViewController)
|
||||
/// so the UI layer can present the error to the user.
|
||||
- (void)epubController:(id)controller didFailWithError:(NSError *)error;
|
||||
|
||||
@end
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WREpubParser
|
||||
// ---------------------------------------------------------------------------
|
||||
@interface WREpubParser : NSObject
|
||||
|
||||
// --- Ivars (from binary analysis) ---
|
||||
// {
|
||||
// NSString *_epubFilePath; // path to the .epub file on disk
|
||||
// NSString *_baseDirectory; // extracted root directory
|
||||
// NSError *_lastError; // most recent parse error
|
||||
// UIViewController *_epubController; // weak ref to presenting controller
|
||||
// WRBook *_book; // associated book model
|
||||
// WHAlbumInfo *_albumInfo; // album / collection metadata
|
||||
// }
|
||||
|
||||
@property (nonatomic, copy, readonly) NSString *epubFilePath;
|
||||
@property (nonatomic, copy, readonly) NSString *baseDirectory;
|
||||
@property (nonatomic, strong, readonly, nullable) NSError *lastError;
|
||||
@property (nonatomic, weak, nullable) id<WREpubParserDelegate> delegate;
|
||||
@property (nonatomic, strong, readonly, nullable) WRBook *book;
|
||||
@property (nonatomic, strong, readonly, nullable) WHAlbumInfo *albumInfo;
|
||||
|
||||
/// Chapters parsed from the spine, in reading order.
|
||||
@property (nonatomic, strong, readonly) NSArray<NSDictionary *> *chapters;
|
||||
|
||||
/// Resource map: relative path -> absolute path for images, CSS, fonts, etc.
|
||||
@property (nonatomic, strong, readonly) NSDictionary<NSString *, NSString *> *resourceMap;
|
||||
|
||||
/// Ordered list of spine item IDs (for navigation).
|
||||
@property (nonatomic, strong, readonly) NSArray<NSString *> *spineItemIDs;
|
||||
|
||||
#pragma mark - Initialization
|
||||
|
||||
- (instancetype)initWithFilePath:(NSString *)path
|
||||
book:(nullable WRBook *)book;
|
||||
|
||||
#pragma mark - Parsing
|
||||
|
||||
/// Parse the EPUB archive. Returns YES on success.
|
||||
- (BOOL)parse:(NSError *_Nullable *_Nullable)error;
|
||||
|
||||
/// Parse container.xml and return the path to the OPF file.
|
||||
- (nullable NSString *)parseContainerXML:(NSError *_Nullable *_Nullable)error;
|
||||
|
||||
/// Parse content.opf and populate chapters + resourceMap.
|
||||
- (BOOL)parseOPFAtRelativePath:(NSString *)opfRelPath
|
||||
error:(NSError *_Nullable *_Nullable)error;
|
||||
|
||||
/// Parse toc.ncx and return the table-of-contents tree.
|
||||
- (nullable NSArray *)parseNCX:(NSError *_Nullable *_Nullable)error;
|
||||
|
||||
/// Read and return the XHTML content of a single chapter.
|
||||
- (nullable NSString *)contentForChapterAtIndex:(NSUInteger)index
|
||||
error:(NSError *_Nullable *_Nullable)error;
|
||||
|
||||
/// Resolve a relative resource path to an absolute file path.
|
||||
- (nullable NSString *)absolutePathForResource:(NSString *)relativePath;
|
||||
|
||||
#pragma mark - Delegate callback (internal)
|
||||
|
||||
- (void)notifyDelegateOfError:(NSError *)error;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
607
Doc/WXRead/decompiled/WREpubParser.m
Normal file
607
Doc/WXRead/decompiled/WREpubParser.m
Normal file
@ -0,0 +1,607 @@
|
||||
//
|
||||
// WREpubParser.m
|
||||
// WeRead (微信读书)
|
||||
// Reverse-engineered implementation reconstruction
|
||||
//
|
||||
// Detailed pseudo-code based on binary analysis, ivar types, known methods,
|
||||
// and contextual knowledge of EPUB file format handling.
|
||||
//
|
||||
|
||||
#import "WREpubParser.h"
|
||||
#import "WRBook.h"
|
||||
#import "WHAlbumInfo.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
NSString *const WREpubParserErrorDomain = @"WREpubParserErrorDomain";
|
||||
|
||||
static NSString *const kContainerXMLPath = @"META-INF/container.xml";
|
||||
static NSString *const kOPFMIMEType = @"application/oebps-package+xml";
|
||||
static NSString *const kNCXMIMEType = @"application/x-dtbncx+xml";
|
||||
|
||||
#pragma mark - Private Interface
|
||||
|
||||
@interface WREpubParser ()
|
||||
|
||||
@property (nonatomic, copy, readwrite) NSString *epubFilePath;
|
||||
@property (nonatomic, copy, readwrite) NSString *baseDirectory;
|
||||
@property (nonatomic, strong, readwrite, nullable) NSError *lastError;
|
||||
@property (nonatomic, strong, readwrite) NSArray<NSDictionary *> *chapters;
|
||||
@property (nonatomic, strong, readwrite) NSDictionary<NSString *, NSString *> *resourceMap;
|
||||
@property (nonatomic, strong, readwrite) NSArray<NSString *> *spineItemIDs;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - Implementation
|
||||
|
||||
@implementation WREpubParser
|
||||
{
|
||||
// Ivars confirmed from binary analysis:
|
||||
NSString *_epubFilePath;
|
||||
NSString *_baseDirectory;
|
||||
NSError *_lastError;
|
||||
UIViewController *_epubController; // weak, assigned from delegate
|
||||
WRBook *_book;
|
||||
WHAlbumInfo *_albumInfo;
|
||||
|
||||
// Internal caches (not exported in header but inferred):
|
||||
NSMutableDictionary<NSString *, NSString *> *_manifestMap; // id -> href
|
||||
NSMutableDictionary<NSString *, NSString *> *_mediaTypeMap; // id -> media-type
|
||||
NSMutableArray<NSString *> *_spineRefs; // idref list
|
||||
}
|
||||
|
||||
#pragma mark - Lifecycle
|
||||
|
||||
- (instancetype)initWithFilePath:(NSString *)path
|
||||
book:(nullable WRBook *)book
|
||||
{
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_epubFilePath = [path copy];
|
||||
_book = book;
|
||||
_manifestMap = [NSMutableDictionary dictionary];
|
||||
_mediaTypeMap = [NSMutableDictionary dictionary];
|
||||
_spineRefs = [NSMutableArray array];
|
||||
_chapters = @[];
|
||||
_resourceMap = @{};
|
||||
_spineItemIDs = @[];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
#pragma mark - Public: Parsing
|
||||
|
||||
- (BOOL)parse:(NSError *_Nullable *_Nullable)error
|
||||
{
|
||||
// 1. Locate the OPF path from container.xml
|
||||
NSError *containerError = nil;
|
||||
NSString *opfRelPath = [self parseContainerXML:&containerError];
|
||||
if (!opfRelPath) {
|
||||
[self _setError:error
|
||||
code:WREpubParserErrorContainerParseFail
|
||||
description:@"Failed to parse container.xml"
|
||||
underlyingError:containerError];
|
||||
return NO;
|
||||
}
|
||||
|
||||
// 2. Parse the OPF to populate manifest, spine, and metadata
|
||||
NSError *opfError = nil;
|
||||
if (![self parseOPFAtRelativePath:opfRelPath error:&opfError]) {
|
||||
[self _setError:error
|
||||
code:WREpubParserErrorOPFParseFail
|
||||
description:@"Failed to parse content.opf"
|
||||
underlyingError:opfError];
|
||||
return NO;
|
||||
}
|
||||
|
||||
// 3. Optionally parse NCX for table of contents
|
||||
NSError *ncxError = nil;
|
||||
[self parseNCX:&ncxError];
|
||||
// NCX failure is non-fatal; log but continue
|
||||
if (ncxError) {
|
||||
NSLog(@"[WREpubParser] NCX parse warning: %@", ncxError);
|
||||
}
|
||||
|
||||
// 4. Build the chapter list from the spine
|
||||
[self _buildChapterList];
|
||||
|
||||
// 5. Build the resource map from the manifest
|
||||
[self _buildResourceMap];
|
||||
|
||||
if (self.chapters.count == 0) {
|
||||
[self _setError:error
|
||||
code:WREpubParserErrorSpineEmpty
|
||||
description:@"Spine contains no items"
|
||||
underlyingError:nil];
|
||||
return NO;
|
||||
}
|
||||
|
||||
return YES;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse META-INF/container.xml
|
||||
// ---------------------------------------------------------------------------
|
||||
- (nullable NSString *)parseContainerXML:(NSError *_Nullable *_Nullable)error
|
||||
{
|
||||
NSString *containerPath =
|
||||
[self.epubFilePath stringByAppendingPathComponent:kContainerXMLPath];
|
||||
|
||||
// container.xml is always plain text (unencrypted)
|
||||
NSData *data = [NSData dataWithContentsOfFile:containerPath options:0 error:error];
|
||||
if (!data) return nil;
|
||||
|
||||
// Use NSXMLParser to extract the rootfile full-path
|
||||
//
|
||||
// Expected structure:
|
||||
// <container>
|
||||
// <rootfiles>
|
||||
// <rootfile full-path="OEBPS/content.opf" media-type="..."/>
|
||||
// </rootfiles>
|
||||
// </container>
|
||||
|
||||
NSXMLParser *parser = [[NSXMLParser alloc] initWithData:data];
|
||||
WREpubContainerParserDelegate *delegate =
|
||||
[[WREpubContainerParserDelegate alloc] init];
|
||||
parser.delegate = delegate;
|
||||
|
||||
if (![parser parse]) {
|
||||
if (error) *error = parser.parserError;
|
||||
return nil;
|
||||
}
|
||||
|
||||
return delegate.rootFilePath;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse the OPF (content.opf) file
|
||||
// ---------------------------------------------------------------------------
|
||||
- (BOOL)parseOPFAtRelativePath:(NSString *)opfRelPath
|
||||
error:(NSError *_Nullable *_Nullable)error
|
||||
{
|
||||
NSString *opfFullPath =
|
||||
[self.epubFilePath stringByAppendingPathComponent:opfRelPath];
|
||||
|
||||
// Set the base directory for resolving relative paths within the OPF
|
||||
_baseDirectory = [opfFullPath stringByDeletingLastPathComponent];
|
||||
|
||||
NSData *data = [NSData dataWithContentsOfFile:opfFullPath options:0 error:error];
|
||||
if (!data) return NO;
|
||||
|
||||
// Use NSXMLParser to walk the OPF XML
|
||||
//
|
||||
// Sections to parse:
|
||||
// <manifest> -> populate _manifestMap and _mediaTypeMap
|
||||
// <spine> -> populate _spineRefs (ordered idref list)
|
||||
// <metadata> -> extract title, identifier, etc. for WRBook
|
||||
|
||||
WREpubOPFParserDelegate *opfDelegate =
|
||||
[[WREpubOPFParserDelegate alloc] initWithBaseDirectory:_baseDirectory];
|
||||
NSXMLParser *parser = [[NSXMLParser alloc] initWithData:data];
|
||||
parser.delegate = opfDelegate;
|
||||
|
||||
if (![parser parse]) {
|
||||
if (error) *error = parser.parserError;
|
||||
return NO;
|
||||
}
|
||||
|
||||
// Transfer parsed data
|
||||
[_manifestMap setDictionary:opfDelegate.manifestItems];
|
||||
[_mediaTypeMap setDictionary:opfDelegate.mediaTypes];
|
||||
[_spineRefs setArray:opfDelegate.spineItemRefs];
|
||||
|
||||
// Extract metadata into _book if available
|
||||
if (opfDelegate.bookTitle) {
|
||||
_book.title = opfDelegate.bookTitle;
|
||||
}
|
||||
|
||||
return YES;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse toc.ncx for table of contents
|
||||
// ---------------------------------------------------------------------------
|
||||
- (nullable NSArray *)parseNCX:(NSError *_Nullable *_Nullable)error
|
||||
{
|
||||
// Find the NCX item in the manifest (media-type = application/x-dtbncx+xml)
|
||||
NSString *ncxID = nil;
|
||||
for (NSString *itemID in _mediaTypeMap) {
|
||||
if ([_mediaTypeMap[itemID] isEqualToString:kNCXMIMEType]) {
|
||||
ncxID = itemID;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ncxID) {
|
||||
// Some EPUBs use nav.xhtml instead of NCX (EPUB3)
|
||||
// Try finding nav document
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSString *ncxRelPath = _manifestMap[ncxID];
|
||||
if (!ncxRelPath) return nil;
|
||||
|
||||
NSString *ncxFullPath =
|
||||
[_baseDirectory stringByAppendingPathComponent:ncxRelPath];
|
||||
|
||||
NSData *data = [NSData dataWithContentsOfFile:ncxFullPath options:0 error:error];
|
||||
if (!data) return nil;
|
||||
|
||||
// Parse NCX XML:
|
||||
// <ncx>
|
||||
// <navMap>
|
||||
// <navPoint id="..." playOrder="1">
|
||||
// <navLabel><text>Chapter 1</text></navLabel>
|
||||
// <content src="chapter1.xhtml"/>
|
||||
// <navPoint> ... nested ... </navPoint>
|
||||
// </navPoint>
|
||||
// </navMap>
|
||||
// </ncx>
|
||||
|
||||
WREpubNCXParserDelegate *ncxDelegate = [[WREpubNCXParserDelegate alloc] init];
|
||||
NSXMLParser *parser = [[NSXMLParser alloc] initWithData:data];
|
||||
parser.delegate = ncxDelegate;
|
||||
|
||||
if (![parser parse]) {
|
||||
if (error) *error = parser.parserError;
|
||||
return nil;
|
||||
}
|
||||
|
||||
return ncxDelegate.tocEntries;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Return chapter content (XHTML) for a given index
|
||||
// ---------------------------------------------------------------------------
|
||||
- (nullable NSString *)contentForChapterAtIndex:(NSUInteger)index
|
||||
error:(NSError *_Nullable *_Nullable)error
|
||||
{
|
||||
if (index >= self.chapters.count) return nil;
|
||||
|
||||
NSDictionary *chapterInfo = self.chapters[index];
|
||||
NSString *href = chapterInfo[@"href"];
|
||||
if (!href) return nil;
|
||||
|
||||
NSString *fullPath =
|
||||
[_baseDirectory stringByAppendingPathComponent:href];
|
||||
|
||||
// If the file is encrypted, it must be decrypted first via WREncryptedFileManager
|
||||
NSData *data = [NSData dataWithContentsOfFile:fullPath options:0 error:error];
|
||||
if (!data) return nil;
|
||||
|
||||
return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolve a relative resource path
|
||||
// ---------------------------------------------------------------------------
|
||||
- (nullable NSString *)absolutePathForResource:(NSString *)relativePath
|
||||
{
|
||||
if (!relativePath) return nil;
|
||||
|
||||
// Try the resource map first
|
||||
NSString *mapped = self.resourceMap[relativePath];
|
||||
if (mapped) return mapped;
|
||||
|
||||
// Fallback: resolve relative to the base directory
|
||||
return [_baseDirectory stringByAppendingPathComponent:relativePath];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delegate callback
|
||||
// ---------------------------------------------------------------------------
|
||||
- (void)notifyDelegateOfError:(NSError *)error
|
||||
{
|
||||
_lastError = error;
|
||||
|
||||
// The delegate protocol method uses epubController:didFailWithError:
|
||||
// The "controller" here is the UIViewController that owns the reader.
|
||||
if ([self.delegate respondsToSelector:@selector(epubController:didFailWithError:)]) {
|
||||
[self.delegate epubController:_epubController didFailWithError:error];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Private Helpers
|
||||
|
||||
/// Build the chapters array from spine references + manifest.
|
||||
- (void)_buildChapterList
|
||||
{
|
||||
NSMutableArray *chapters = [NSMutableArray arrayWithCapacity:_spineRefs.count];
|
||||
|
||||
for (NSString *idref in _spineRefs) {
|
||||
NSString *href = _manifestMap[idref];
|
||||
NSString *mediaType = _mediaTypeMap[idref];
|
||||
|
||||
if (!href) continue;
|
||||
|
||||
NSMutableDictionary *chapterInfo = [NSMutableDictionary dictionary];
|
||||
chapterInfo[@"id"] = idref;
|
||||
chapterInfo[@"href"] = href;
|
||||
chapterInfo[@"mediaType"] = mediaType ?: @"application/xhtml+xml";
|
||||
chapterInfo[@"fullPath"] =
|
||||
[_baseDirectory stringByAppendingPathComponent:href];
|
||||
|
||||
[chapters addObject:[chapterInfo copy]];
|
||||
}
|
||||
|
||||
_chapters = [chapters copy];
|
||||
}
|
||||
|
||||
/// Build a flat resource map for images, CSS, fonts, etc.
|
||||
- (void)_buildResourceMap
|
||||
{
|
||||
NSMutableDictionary *map = [NSMutableDictionary dictionary];
|
||||
|
||||
for (NSString *itemID in _manifestMap) {
|
||||
NSString *href = _manifestMap[itemID];
|
||||
if (!href) continue;
|
||||
|
||||
NSString *absPath =
|
||||
[_baseDirectory stringByAppendingPathComponent:href];
|
||||
map[href] = absPath;
|
||||
|
||||
// Also index by filename for convenience
|
||||
NSString *filename = [href lastPathComponent];
|
||||
if (filename) {
|
||||
map[filename] = absPath;
|
||||
}
|
||||
}
|
||||
|
||||
_resourceMap = [map copy];
|
||||
}
|
||||
|
||||
/// Helper to set the error pointer and store lastError.
|
||||
- (void)_setError:(NSError *_Nullable *_Nullable)outError
|
||||
code:(WREpubParserErrorCode)code
|
||||
description:(NSString *)description
|
||||
underlyingError:(nullable NSError *)underlyingError
|
||||
{
|
||||
NSMutableDictionary *userInfo = [NSMutableDictionary dictionary];
|
||||
userInfo[NSLocalizedDescriptionKey] = description;
|
||||
if (underlyingError) {
|
||||
userInfo[NSUnderlyingErrorKey] = underlyingError;
|
||||
}
|
||||
|
||||
NSError *err = [NSError errorWithDomain:WREpubParserErrorDomain
|
||||
code:code
|
||||
userInfo:userInfo];
|
||||
_lastError = err;
|
||||
if (outError) *outError = err;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
// ===========================================================================
|
||||
// Internal XML Parser Delegates (file-private)
|
||||
// ===========================================================================
|
||||
|
||||
#pragma mark - Container Parser Delegate
|
||||
|
||||
/// Parses META-INF/container.xml to extract the rootfile path.
|
||||
@interface WREpubContainerParserDelegate : NSObject <NSXMLParserDelegate>
|
||||
@property (nonatomic, copy, nullable) NSString *rootFilePath;
|
||||
@end
|
||||
|
||||
@implementation WREpubContainerParserDelegate
|
||||
{
|
||||
BOOL _insideRootfile;
|
||||
}
|
||||
|
||||
- (void)parser:(NSXMLParser *)parser
|
||||
didStartElement:(NSString *)elementName
|
||||
namespaceURI:(NSString *)namespaceURI
|
||||
qualifiedName:(NSString *)qName
|
||||
attributes:(NSDictionary<NSString *,NSString *> *)attributeDict
|
||||
{
|
||||
if ([elementName isEqualToString:@"rootfile"]) {
|
||||
_rootFilePath = attributeDict[@"full-path"];
|
||||
_insideRootfile = YES;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)parser:(NSXMLParser *)parser
|
||||
didEndElement:(NSString *)elementName
|
||||
namespaceURI:(NSString *)namespaceURI
|
||||
qualifiedName:(NSString *)qName
|
||||
{
|
||||
if ([elementName isEqualToString:@"rootfile"]) {
|
||||
_insideRootfile = NO;
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - OPF Parser Delegate
|
||||
|
||||
/// Parses the OPF file: manifest, spine, and metadata sections.
|
||||
@interface WREpubOPFParserDelegate : NSObject <NSXMLParserDelegate>
|
||||
|
||||
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSString *> *manifestItems;
|
||||
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSString *> *mediaTypes;
|
||||
@property (nonatomic, strong) NSMutableArray<NSString *> *spineItemRefs;
|
||||
@property (nonatomic, copy, nullable) NSString *bookTitle;
|
||||
|
||||
- (instancetype)initWithBaseDirectory:(NSString *)baseDir;
|
||||
|
||||
@end
|
||||
|
||||
@implementation WREpubOPFParserDelegate
|
||||
{
|
||||
NSString *_baseDirectory;
|
||||
BOOL _inMetadata;
|
||||
BOOL _inManifest;
|
||||
BOOL _inSpine;
|
||||
NSMutableString *_currentText;
|
||||
}
|
||||
|
||||
- (instancetype)initWithBaseDirectory:(NSString *)baseDir
|
||||
{
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_baseDirectory = baseDir;
|
||||
_manifestItems = [NSMutableDictionary dictionary];
|
||||
_mediaTypes = [NSMutableDictionary dictionary];
|
||||
_spineItemRefs = [NSMutableArray array];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)parser:(NSXMLParser *)parser
|
||||
didStartElement:(NSString *)elementName
|
||||
namespaceURI:(NSString *)namespaceURI
|
||||
qualifiedName:(NSString *)qName
|
||||
attributes:(NSDictionary<NSString *,NSString *> *)attributeDict
|
||||
{
|
||||
if ([elementName isEqualToString:@"metadata"]) {
|
||||
_inMetadata = YES;
|
||||
} else if ([elementName isEqualToString:@"manifest"]) {
|
||||
_inManifest = YES;
|
||||
} else if ([elementName isEqualToString:@"spine"]) {
|
||||
_inSpine = YES;
|
||||
}
|
||||
|
||||
if (_inManifest && [elementName isEqualToString:@"item"]) {
|
||||
NSString *itemId = attributeDict[@"id"];
|
||||
NSString *href = attributeDict[@"href"];
|
||||
NSString *mediaType = attributeDict[@"media-type"];
|
||||
if (itemId && href) {
|
||||
_manifestItems[itemId] = href;
|
||||
if (mediaType) {
|
||||
_mediaTypes[itemId] = mediaType;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_inSpine && [elementName isEqualToString:@"itemref"]) {
|
||||
NSString *idref = attributeDict[@"idref"];
|
||||
if (idref) {
|
||||
[_spineItemRefs addObject:idref];
|
||||
}
|
||||
}
|
||||
|
||||
_currentText = [NSMutableString string];
|
||||
}
|
||||
|
||||
- (void)parser:(NSXMLParser *)parser
|
||||
foundCharacters:(NSString *)string
|
||||
{
|
||||
[_currentText appendString:string];
|
||||
}
|
||||
|
||||
- (void)parser:(NSXMLParser *)parser
|
||||
didEndElement:(NSString *)elementName
|
||||
namespaceURI:(NSString *)namespaceURI
|
||||
qualifiedName:(NSString *)qName
|
||||
{
|
||||
if ([elementName isEqualToString:@"metadata"]) {
|
||||
_inMetadata = NO;
|
||||
} else if ([elementName isEqualToString:@"manifest"]) {
|
||||
_inManifest = NO;
|
||||
} else if ([elementName isEqualToString:@"spine"]) {
|
||||
_inSpine = NO;
|
||||
}
|
||||
|
||||
// Extract <dc:title> from metadata
|
||||
if (_inMetadata && [elementName isEqualToString:@"dc:title"]) {
|
||||
_bookTitle = [_currentText stringByTrimmingCharactersInSet:
|
||||
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
}
|
||||
|
||||
_currentText = nil;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - NCX Parser Delegate
|
||||
|
||||
/// Parses toc.ncx to extract the table of contents tree.
|
||||
@interface WREpubNCXParserDelegate : NSObject <NSXMLParserDelegate>
|
||||
@property (nonatomic, strong) NSMutableArray<NSDictionary *> *tocEntries;
|
||||
@end
|
||||
|
||||
@implementation WREpubNCXParserDelegate
|
||||
{
|
||||
BOOL _inNavPoint;
|
||||
BOOL _inNavLabel;
|
||||
BOOL _inContent;
|
||||
BOOL _inText;
|
||||
NSString *_currentNavPointId;
|
||||
NSString *_currentLabel;
|
||||
NSString *_currentSrc;
|
||||
NSMutableString *_currentText;
|
||||
NSUInteger _playOrder;
|
||||
}
|
||||
|
||||
- (instancetype)init
|
||||
{
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_tocEntries = [NSMutableArray array];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)parser:(NSXMLParser *)parser
|
||||
didStartElement:(NSString *)elementName
|
||||
namespaceURI:(NSString *)namespaceURI
|
||||
qualifiedName:(NSString *)qName
|
||||
attributes:(NSDictionary<NSString *,NSString *> *)attributeDict
|
||||
{
|
||||
_currentText = [NSMutableString string];
|
||||
|
||||
if ([elementName isEqualToString:@"navPoint"]) {
|
||||
_inNavPoint = YES;
|
||||
_currentNavPointId = attributeDict[@"id"];
|
||||
_playOrder = [attributeDict[@"playOrder"] integerValue];
|
||||
} else if ([elementName isEqualToString:@"navLabel"]) {
|
||||
_inNavLabel = YES;
|
||||
} else if ([elementName isEqualToString:@"text"]) {
|
||||
_inText = YES;
|
||||
} else if ([elementName isEqualToString:@"content"]) {
|
||||
_inContent = YES;
|
||||
_currentSrc = attributeDict[@"src"];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)parser:(NSXMLParser *)parser
|
||||
foundCharacters:(NSString *)string
|
||||
{
|
||||
[_currentText appendString:string];
|
||||
}
|
||||
|
||||
- (void)parser:(NSXMLParser *)parser
|
||||
didEndElement:(NSString *)elementName
|
||||
namespaceURI:(NSString *)namespaceURI
|
||||
qualifiedName:(NSString *)qName
|
||||
{
|
||||
if ([elementName isEqualToString:@"text"] && _inText) {
|
||||
_currentLabel = [_currentText stringByTrimmingCharactersInSet:
|
||||
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
_inText = NO;
|
||||
} else if ([elementName isEqualToString:@"content"]) {
|
||||
_inContent = NO;
|
||||
} else if ([elementName isEqualToString:@"navLabel"]) {
|
||||
_inNavLabel = NO;
|
||||
} else if ([elementName isEqualToString:@"navPoint"]) {
|
||||
_inNavPoint = NO;
|
||||
|
||||
if (_currentLabel && _currentSrc) {
|
||||
NSDictionary *entry = @{
|
||||
@"id" : _currentNavPointId ?: @"",
|
||||
@"label" : _currentLabel,
|
||||
@"src" : _currentSrc,
|
||||
@"playOrder" : @(_playOrder)
|
||||
};
|
||||
[_tocEntries addObject:entry];
|
||||
}
|
||||
|
||||
_currentNavPointId = nil;
|
||||
_currentLabel = nil;
|
||||
_currentSrc = nil;
|
||||
}
|
||||
|
||||
_currentText = nil;
|
||||
}
|
||||
|
||||
@end
|
||||
98
Doc/WXRead/decompiled/WREpubPositionConverter.h
Normal file
98
Doc/WXRead/decompiled/WREpubPositionConverter.h
Normal file
@ -0,0 +1,98 @@
|
||||
//
|
||||
// WREpubPositionConverter.h
|
||||
// WeRead (微信读书)
|
||||
// Reverse-engineered header
|
||||
//
|
||||
// Position converter between file positions and character positions.
|
||||
// Used for bookmark synchronization and reading progress tracking.
|
||||
// Maps (fileIndex, row, column) triples to character offsets in the
|
||||
// concatenated text of the book.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@class NSAttributedString;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Position pair structure used in row/column-based lookups
|
||||
// ---------------------------------------------------------------------------
|
||||
@interface WRPositionPair : NSObject
|
||||
@property (nonatomic) NSInteger row;
|
||||
@property (nonatomic) NSInteger column;
|
||||
@end
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WREpubPositionConverter
|
||||
// ---------------------------------------------------------------------------
|
||||
@interface WREpubPositionConverter : NSObject
|
||||
|
||||
// --- Ivars (from binary analysis) ---
|
||||
// {
|
||||
// NSArray *_filePaths; // paths to chapter XHTML files
|
||||
// NSArray *_attributedStrings; // parsed attributed strings per file
|
||||
// NSMutableArray *_fileLengths; // character count per file
|
||||
// NSMutableArray *_cumulativeOffsets; // cumulative character offsets
|
||||
// NSMutableDictionary *_indexCache; // cached index lookups
|
||||
// NSMutableDictionary *_stringRangeCache; // cached range lookups
|
||||
// ... more ...
|
||||
// }
|
||||
|
||||
@property (nonatomic, strong, readonly) NSArray<NSString *> *filePaths;
|
||||
@property (nonatomic, strong, readonly) NSArray<NSAttributedString *> *attributedStrings;
|
||||
|
||||
#pragma mark - Initialization
|
||||
|
||||
/// Initialize with file paths and their corresponding attributed strings.
|
||||
/// @param filePaths Array of chapter file paths (in spine order).
|
||||
/// @param attributedStrings Array of NSAttributedString for each file.
|
||||
/// @param offset Base character offset (e.g., for non-chapter content).
|
||||
/// @param isContainIntroFlyleaf YES if the flyleaf/cover page is included.
|
||||
- (instancetype)initWithFilePaths:(NSArray<NSString *> *)filePaths
|
||||
attributedStrings:(NSArray<NSAttributedString *> *)attributedStrings
|
||||
offset:(NSInteger)offset
|
||||
isContainIntroFlyleaf:(BOOL)isContainIntroFlyleaf;
|
||||
|
||||
#pragma mark - Index Building
|
||||
|
||||
/// Build internal index tables for fast position lookup.
|
||||
/// Must be called before performing conversions.
|
||||
- (void)initIndices;
|
||||
|
||||
#pragma mark - Position Conversion
|
||||
|
||||
/// Convert row/column pairs to string indices within a specific file.
|
||||
/// @param filePath The chapter file path.
|
||||
/// @param rowColumnPairs Array of WRPositionPair objects.
|
||||
/// @param stringIndices (out) Array of NSNumber (NSInteger) with resolved indices.
|
||||
/// @param string The full text string of the file.
|
||||
/// @param fileIndexOffset Offset to add to the file index.
|
||||
/// @param stringIndexOffset Offset to add to the resulting string index.
|
||||
- (void)indicesInFile:(NSString *)filePath
|
||||
forRowColumnPairs:(NSArray<WRPositionPair *> *)rowColumnPairs
|
||||
stringIndices:(NSArray *_Nullable *_Nullable)stringIndices
|
||||
string:(NSString *)string
|
||||
fileIndexOffset:(NSInteger)fileIndexOffset
|
||||
stringIndexOffset:(NSInteger)stringIndexOffset;
|
||||
|
||||
/// Convert a file-based range (fileIndex, startOffset, endOffset)
|
||||
/// to a character range in the concatenated book string.
|
||||
/// @return NSRange in the global string, or NSNotFound if invalid.
|
||||
- (NSRange)stringRangeFromFileRange:(NSDictionary *)fileRange;
|
||||
|
||||
#pragma mark - Utility
|
||||
|
||||
/// Return the total character count across all files.
|
||||
- (NSInteger)totalCharacterCount;
|
||||
|
||||
/// Return the file index that contains the given global character position.
|
||||
- (NSInteger)fileIndexForCharacterPosition:(NSInteger)position;
|
||||
|
||||
/// Return the local character offset within a file for a global position.
|
||||
- (NSInteger)localOffsetInFileAtIndex:(NSInteger)fileIndex
|
||||
forGlobalPosition:(NSInteger)position;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
326
Doc/WXRead/decompiled/WREpubPositionConverter.m
Normal file
326
Doc/WXRead/decompiled/WREpubPositionConverter.m
Normal file
@ -0,0 +1,326 @@
|
||||
//
|
||||
// WREpubPositionConverter.m
|
||||
// WeRead (微信读书)
|
||||
// Reverse-engineered implementation reconstruction
|
||||
//
|
||||
// Detailed pseudo-code based on binary analysis, ivar types, known methods,
|
||||
// and contextual knowledge of EPUB position/bookmark handling.
|
||||
//
|
||||
|
||||
#import "WREpubPositionConverter.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WRPositionPair
|
||||
// ---------------------------------------------------------------------------
|
||||
@implementation WRPositionPair
|
||||
|
||||
- (instancetype)initWithRow:(NSInteger)row column:(NSInteger)column
|
||||
{
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_row = row;
|
||||
_column = column;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (NSString *)description
|
||||
{
|
||||
return [NSString stringWithFormat:@"<WRPositionPair row=%ld col=%ld>",
|
||||
(long)_row, (long)_column];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WREpubPositionConverter
|
||||
// ---------------------------------------------------------------------------
|
||||
@implementation WREpubPositionConverter
|
||||
{
|
||||
// Core data from binary analysis
|
||||
NSArray *_filePaths;
|
||||
NSArray *_attributedStrings;
|
||||
NSMutableArray *_fileLengths; // character count per file
|
||||
NSMutableArray *_cumulativeOffsets; // prefix sum of character counts
|
||||
NSMutableDictionary *_indexCache; // cache: key -> index
|
||||
NSMutableDictionary *_stringRangeCache; // cache: key -> NSRange
|
||||
|
||||
// Additional state inferred from context
|
||||
NSInteger _baseOffset; // offset for non-chapter content
|
||||
BOOL _containsIntroFlyleaf; // includes cover/flyleaf page
|
||||
BOOL _indicesBuilt; // whether initIndices has been called
|
||||
}
|
||||
|
||||
#pragma mark - Lifecycle
|
||||
|
||||
- (instancetype)initWithFilePaths:(NSArray<NSString *> *)filePaths
|
||||
attributedStrings:(NSArray<NSAttributedString *> *)attributedStrings
|
||||
offset:(NSInteger)offset
|
||||
isContainIntroFlyleaf:(BOOL)isContainIntroFlyleaf
|
||||
{
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_filePaths = [filePaths copy];
|
||||
_attributedStrings = [attributedStrings copy];
|
||||
_baseOffset = offset;
|
||||
_containsIntroFlyleaf = isContainIntroFlyleaf;
|
||||
|
||||
_fileLengths = [NSMutableArray arrayWithCapacity:filePaths.count];
|
||||
_cumulativeOffsets = [NSMutableArray arrayWithCapacity:filePaths.count];
|
||||
_indexCache = [NSMutableDictionary dictionary];
|
||||
_stringRangeCache = [NSMutableDictionary dictionary];
|
||||
_indicesBuilt = NO;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
#pragma mark - Index Building
|
||||
|
||||
- (void)initIndices
|
||||
{
|
||||
if (_indicesBuilt) return;
|
||||
|
||||
[_fileLengths removeAllObjects];
|
||||
[_cumulativeOffsets removeAllObjects];
|
||||
|
||||
NSInteger cumulative = _baseOffset;
|
||||
|
||||
for (NSUInteger i = 0; i < _attributedStrings.count; i++) {
|
||||
NSAttributedString *attrStr = _attributedStrings[i];
|
||||
NSInteger length = (NSInteger)attrStr.string.length;
|
||||
|
||||
[_fileLengths addObject:@(length)];
|
||||
|
||||
// Cumulative offset = sum of lengths of all previous files + baseOffset
|
||||
[_cumulativeOffsets addObject:@(cumulative)];
|
||||
cumulative += length;
|
||||
}
|
||||
|
||||
_indicesBuilt = YES;
|
||||
}
|
||||
|
||||
#pragma mark - Position Conversion: Row/Column -> String Index
|
||||
|
||||
- (void)indicesInFile:(NSString *)filePath
|
||||
forRowColumnPairs:(NSArray<WRPositionPair *> *)rowColumnPairs
|
||||
stringIndices:(NSArray *_Nullable *_Nullable)stringIndices
|
||||
string:(NSString *)string
|
||||
fileIndexOffset:(NSInteger)fileIndexOffset
|
||||
stringIndexOffset:(NSInteger)stringIndexOffset
|
||||
{
|
||||
// 1. Find the file index for the given path
|
||||
NSInteger fileIndex = [_filePaths indexOfObject:filePath];
|
||||
if (fileIndex == NSNotFound) {
|
||||
// Try matching by last path component (filename only)
|
||||
NSString *filename = [filePath lastPathComponent];
|
||||
for (NSUInteger i = 0; i < _filePaths.count; i++) {
|
||||
if ([[_filePaths[i] lastPathComponent] isEqualToString:filename]) {
|
||||
fileIndex = (NSInteger)i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fileIndex == NSNotFound) {
|
||||
if (stringIndices) *stringIndices = @[];
|
||||
return;
|
||||
}
|
||||
|
||||
fileIndex += fileIndexOffset;
|
||||
|
||||
// 2. Build a line-offset table from the string
|
||||
//
|
||||
// We need to map (row, column) -> character offset within the string.
|
||||
// A "row" is a line number; "column" is the character position in that line.
|
||||
//
|
||||
// Strategy: scan the string and record the starting offset of each line.
|
||||
|
||||
NSMutableArray<NSNumber *> *lineStarts = [NSMutableArray array];
|
||||
[lineStarts addObject:@(0)]; // line 0 starts at offset 0
|
||||
|
||||
NSUInteger len = string.length;
|
||||
for (NSUInteger i = 0; i < len; i++) {
|
||||
unichar c = [string characterAtIndex:i];
|
||||
if (c == '\n' || c == '\r') {
|
||||
// Handle \r\n as a single line ending
|
||||
if (c == '\r' && i + 1 < len && [string characterAtIndex:i + 1] == '\n') {
|
||||
i++; // skip the \n
|
||||
}
|
||||
if (i + 1 < len) {
|
||||
[lineStarts addObject:@(i + 1)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Convert each (row, column) pair to a string index
|
||||
NSMutableArray<NSNumber *> *results =
|
||||
[NSMutableArray arrayWithCapacity:rowColumnPairs.count];
|
||||
|
||||
for (WRPositionPair *pair in rowColumnPairs) {
|
||||
NSInteger row = pair.row;
|
||||
NSInteger column = pair.column;
|
||||
|
||||
if (row < 0 || row >= (NSInteger)lineStarts.count) {
|
||||
[results addObject:@(NSNotFound)];
|
||||
continue;
|
||||
}
|
||||
|
||||
NSInteger lineStart = [lineStarts[row] integerValue];
|
||||
|
||||
// Find the end of this line
|
||||
NSInteger lineEnd;
|
||||
if (row + 1 < (NSInteger)lineStarts.count) {
|
||||
lineEnd = [lineStarts[row + 1] integerValue] - 1;
|
||||
} else {
|
||||
lineEnd = (NSInteger)len;
|
||||
}
|
||||
|
||||
NSInteger lineLength = lineEnd - lineStart;
|
||||
if (column > lineLength) {
|
||||
column = lineLength; // clamp to end of line
|
||||
}
|
||||
|
||||
NSInteger stringIndex = lineStart + column + stringIndexOffset;
|
||||
|
||||
// Also add the cumulative offset for this file to get the global position
|
||||
if (fileIndex >= 0 && fileIndex < (NSInteger)_cumulativeOffsets.count) {
|
||||
stringIndex += [_cumulativeOffsets[fileIndex] integerValue];
|
||||
}
|
||||
|
||||
[results addObject:@(stringIndex)];
|
||||
}
|
||||
|
||||
if (stringIndices) {
|
||||
*stringIndices = [results copy];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Position Conversion: File Range -> String Range
|
||||
|
||||
- (NSRange)stringRangeFromFileRange:(NSDictionary *)fileRange
|
||||
{
|
||||
// Expected keys in fileRange:
|
||||
// @"fileIndex" -> NSNumber (NSInteger)
|
||||
// @"start" -> NSNumber (NSInteger) local offset within the file
|
||||
// @"end" -> NSNumber (NSInteger) local offset within the file
|
||||
|
||||
NSNumber *fileIndexNum = fileRange[@"fileIndex"];
|
||||
NSNumber *startNum = fileRange[@"start"];
|
||||
NSNumber *endNum = fileRange[@"end"];
|
||||
|
||||
if (!fileIndexNum || !startNum || !endNum) {
|
||||
return NSMakeRange(NSNotFound, 0);
|
||||
}
|
||||
|
||||
NSInteger fileIndex = [fileIndexNum integerValue];
|
||||
NSInteger localStart = [startNum integerValue];
|
||||
NSInteger localEnd = [endNum integerValue];
|
||||
|
||||
// Check cache
|
||||
NSString *cacheKey =
|
||||
[NSString stringWithFormat:@"%ld:%ld:%ld", (long)fileIndex,
|
||||
(long)localStart, (long)localEnd];
|
||||
NSNumber *cached = _stringRangeCache[cacheKey];
|
||||
if (cached) {
|
||||
return NSRangeFromString(cached.stringValue);
|
||||
}
|
||||
|
||||
// Validate file index
|
||||
if (!_indicesBuilt) [self initIndices];
|
||||
if (fileIndex < 0 || fileIndex >= (NSInteger)_cumulativeOffsets.count) {
|
||||
return NSMakeRange(NSNotFound, 0);
|
||||
}
|
||||
|
||||
// Get the cumulative offset for this file
|
||||
NSInteger cumulativeOffset = [_cumulativeOffsets[fileIndex] integerValue];
|
||||
|
||||
// File-local length
|
||||
NSInteger fileLength = [_fileLengths[fileIndex] integerValue];
|
||||
|
||||
// Clamp to file bounds
|
||||
if (localStart < 0) localStart = 0;
|
||||
if (localEnd > fileLength) localEnd = fileLength;
|
||||
if (localStart >= localEnd) {
|
||||
return NSMakeRange(NSNotFound, 0);
|
||||
}
|
||||
|
||||
NSInteger globalStart = cumulativeOffset + localStart;
|
||||
NSInteger length = localEnd - localStart;
|
||||
|
||||
NSRange result = NSMakeRange(globalStart, length);
|
||||
|
||||
// Cache the result
|
||||
_stringRangeCache[cacheKey] = NSStringFromRange(result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#pragma mark - Utility
|
||||
|
||||
- (NSInteger)totalCharacterCount
|
||||
{
|
||||
if (!_indicesBuilt) [self initIndices];
|
||||
|
||||
if (_cumulativeOffsets.count == 0) return _baseOffset;
|
||||
|
||||
NSInteger lastCumulative =
|
||||
[_cumulativeOffsets.lastObject integerValue];
|
||||
NSInteger lastLength = [_fileLengths.lastObject integerValue];
|
||||
|
||||
return lastCumulative + lastLength;
|
||||
}
|
||||
|
||||
- (NSInteger)fileIndexForCharacterPosition:(NSInteger)position
|
||||
{
|
||||
if (!_indicesBuilt) [self initIndices];
|
||||
|
||||
// Binary search through cumulative offsets
|
||||
NSInteger lo = 0;
|
||||
NSInteger hi = (NSInteger)_cumulativeOffsets.count - 1;
|
||||
NSInteger result = -1;
|
||||
|
||||
while (lo <= hi) {
|
||||
NSInteger mid = lo + (hi - lo) / 2;
|
||||
NSInteger offset = [_cumulativeOffsets[mid] integerValue];
|
||||
|
||||
if (offset <= position) {
|
||||
result = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the position is within this file's range
|
||||
if (result >= 0) {
|
||||
NSInteger fileLen = [_fileLengths[result] integerValue];
|
||||
NSInteger cumOff = [_cumulativeOffsets[result] integerValue];
|
||||
if (position >= cumOff + fileLen) {
|
||||
result = -1; // beyond end of last file
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
- (NSInteger)localOffsetInFileAtIndex:(NSInteger)fileIndex
|
||||
forGlobalPosition:(NSInteger)position
|
||||
{
|
||||
if (!_indicesBuilt) [self initIndices];
|
||||
|
||||
if (fileIndex < 0 || fileIndex >= (NSInteger)_cumulativeOffsets.count) {
|
||||
return NSNotFound;
|
||||
}
|
||||
|
||||
NSInteger cumulativeOffset = [_cumulativeOffsets[fileIndex] integerValue];
|
||||
NSInteger localOffset = position - cumulativeOffset;
|
||||
|
||||
NSInteger fileLen = [_fileLengths[fileIndex] integerValue];
|
||||
if (localOffset < 0 || localOffset >= fileLen) {
|
||||
return NSNotFound;
|
||||
}
|
||||
|
||||
return localOffset;
|
||||
}
|
||||
|
||||
@end
|
||||
127
Doc/WXRead/decompiled/WREpubTypesetter.h
Normal file
127
Doc/WXRead/decompiled/WREpubTypesetter.h
Normal file
@ -0,0 +1,127 @@
|
||||
//
|
||||
// WREpubTypesetter.h
|
||||
// WeRead (微信读书) - Reverse Engineered
|
||||
//
|
||||
// EPUB Typesetter: Converts XHTML content into NSAttributedString
|
||||
// via DTHTMLAttributedStringBuilder with CSS cascade, image handling,
|
||||
// hyperlink processing, traditional/simplified Chinese conversion,
|
||||
// and free-trial truncation support.
|
||||
//
|
||||
// All public methods are class methods (no instance instantiation required).
|
||||
// The four instance variables likely serve as cached state for the CSS
|
||||
// loading pipeline, though the binary exposes no instance methods.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
@class WRBook;
|
||||
@class WRChapter;
|
||||
@class WRChapterData;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
#pragma mark - Error Reporting Constants
|
||||
|
||||
/// Keys used in the error-reason dictionary returned by the typesetter.
|
||||
FOUNDATION_EXPORT NSString *const WREpubTypesetterErrorStyleFileNotFound;
|
||||
FOUNDATION_EXPORT NSString *const WREpubTypesetterErrorTranslationStyleNotFound;
|
||||
FOUNDATION_EXPORT NSString *const WREpubTypesetterErrorTranslationContentNotFound;
|
||||
|
||||
#pragma mark - Custom CSS Attribute Names
|
||||
|
||||
/// NSAttributedString attribute: vertical centering style for inline elements.
|
||||
/// Value: NSNumber (integer). 2 = enable vertical center (used on images).
|
||||
FOUNDATION_EXPORT NSString *const WREpubTypesetterVerticalCenterStyleAttribute;
|
||||
/// "wr-vertical-center-style" in CSS land.
|
||||
|
||||
/// NSAttributedString attribute: page-relative positioning hint.
|
||||
/// Value: NSNumber (integer) indicating page relationship.
|
||||
FOUNDATION_EXPORT NSString *const WREpubTypesetterPageRelateAttribute;
|
||||
/// "weread-page-relate" in CSS land.
|
||||
|
||||
#pragma mark - Page Flipping Style Enum
|
||||
|
||||
typedef NS_ENUM(NSInteger, WRPageFlippingStyle) {
|
||||
WRPageFlippingStyleDefault = 0,
|
||||
WRPageFlippingStyleSlide = 1,
|
||||
WRPageFlippingStyleCurl = 2,
|
||||
WRPageFlippingStyleNone = 3,
|
||||
};
|
||||
|
||||
#pragma mark - WREpubTypesetter
|
||||
|
||||
@interface WREpubTypesetter : NSObject {
|
||||
// Instance variables (no instance methods found in the binary;
|
||||
// these likely cache transient state during a typesetting pass).
|
||||
NSString *_currentCSS; // Aggregated CSS after cascade merge
|
||||
NSString *_epubEmbeddedCSS; // CSS extracted from the EPUB book itself
|
||||
NSString *_userSettingsCSS; // User-applied CSS overrides (font, line-height, theme)
|
||||
NSArray *_imageURLs; // Collected image attachment URLs for lazy loading
|
||||
}
|
||||
|
||||
#pragma mark - Primary Typesetting Entry Point
|
||||
|
||||
/// Convert an EPUB XHTML chapter file into a styled NSAttributedString.
|
||||
///
|
||||
/// This is the main entry point. It:
|
||||
/// 1. Loads and merges CSS in priority order (see implementation).
|
||||
/// 2. Feeds XHTML + merged CSS into DTHTMLAttributedStringBuilder.
|
||||
/// 3. Walks the resulting element tree to patch images, links,
|
||||
/// custom attributes, and apply user typographic preferences.
|
||||
/// 4. Optionally truncates the output for free-trial preview.
|
||||
///
|
||||
/// @param filePath Absolute path to the .xhtml / .html file inside the EPUB.
|
||||
/// @param priority Unused or internal scheduling priority (pass 0 for default).
|
||||
/// @param insertArticleToolAttachment If YES, embeds an article-tool NSTextAttachment
|
||||
/// at the end of the attributed string.
|
||||
/// @param insertBookChapterToolAttachment If YES, embeds a book-chapter-tool
|
||||
/// NSTextAttachment.
|
||||
/// @param insertRecommendView If YES, appends a recommendation-view attachment.
|
||||
/// @param book The WRBook model containing metadata, CSS paths, etc.
|
||||
/// @param chapter The WRChapter model (chapter ID, title, ordering).
|
||||
/// @param pageFlippingStyle Desired page-flip animation style.
|
||||
/// @param renderErrorReason [out] On failure, set to a human-readable error description.
|
||||
/// May be NULL if the caller does not need it.
|
||||
/// @param isStyleFileNotFound [out] Set to YES if a required CSS file was missing.
|
||||
/// May be NULL.
|
||||
/// @param options Additional NSDictionary of rendering options (font family,
|
||||
/// line-height multiplier, dark-mode flag, etc.).
|
||||
///
|
||||
/// @return A fully styled NSAttributedString, or nil on unrecoverable error.
|
||||
+ (nullable NSAttributedString *)
|
||||
attributeStringWithFilePath:(NSString *)filePath
|
||||
priority:(NSInteger)priority
|
||||
insertArticleToolAttachment:(BOOL)insertArticleToolAttachment
|
||||
insertBookChapterToolAttachment:(BOOL)insertBookChapterToolAttachment
|
||||
insertRecommendView:(BOOL)insertRecommendView
|
||||
book:(WRBook *)book
|
||||
chapter:(WRChapter *)chapter
|
||||
pageFlippingStyle:(WRPageFlippingStyle)pageFlippingStyle
|
||||
renderErrorReason:(NSString * _Nullable * _Nullable)renderErrorReason
|
||||
isStyleFileNotFound:(BOOL * _Nullable)isStyleFileNotFound
|
||||
options:(NSDictionary * _Nullable)options;
|
||||
|
||||
#pragma mark - Translation Error Reporting
|
||||
|
||||
/// Report a translation-related rendering error to the analytics subsystem.
|
||||
///
|
||||
/// Called when the typesetter detects missing translation CSS or content
|
||||
/// during bilingual (original + translated) rendering.
|
||||
///
|
||||
/// @param error The underlying NSError, if any.
|
||||
/// @param bookId Book identifier string.
|
||||
/// @param chapter The WRChapter that failed translation rendering.
|
||||
/// @param isTranslationStyleNotFound YES if the translation CSS file was missing.
|
||||
/// @param isTranslationContentNotFound YES if the translated XHTML content was missing.
|
||||
/// @param isTranslateTagButNoTranslateStyle YES if the HTML contained a translate tag
|
||||
/// but no corresponding CSS rule was found.
|
||||
+ (void)tryReportTranslationError:(nullable NSError *)error
|
||||
bookId:(NSString *)bookId
|
||||
chapter:(WRChapter *)chapter
|
||||
isTranslationStyleNotFound:(BOOL)isTranslationStyleNotFound
|
||||
isTranslationContentNotFound:(BOOL)isTranslationContentNotFound
|
||||
isTranslateTagButNoTranslateStyle:(BOOL)isTranslateTagButNoTranslateStyle;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
794
Doc/WXRead/decompiled/WREpubTypesetter.m
Normal file
794
Doc/WXRead/decompiled/WREpubTypesetter.m
Normal file
@ -0,0 +1,794 @@
|
||||
//
|
||||
// WREpubTypesetter.m
|
||||
// WeRead (微信读书) - Reverse Engineered Implementation Reconstruction
|
||||
//
|
||||
// This is a detailed pseudo-code reconstruction based on:
|
||||
// - Binary strings output (method signatures, CSS filenames, attribute names)
|
||||
// - Architecture documentation (CSS cascade order, processing pipeline)
|
||||
// - DTCSSStylesheet / DTHTMLAttributedStringBuilder public API
|
||||
// - Behavioral analysis of WeRead EPUB rendering
|
||||
//
|
||||
// Disclaimer: This is a reconstruction, not a decompiled original.
|
||||
// Variable names, control flow, and helper methods are educated guesses
|
||||
// informed by the above evidence.
|
||||
//
|
||||
|
||||
#import "WREpubTypesetter.h"
|
||||
|
||||
// DTLite (DTCoreText) framework headers
|
||||
#import "DTHTMLAttributedStringBuilder.h"
|
||||
#import "DTHTMLElement.h"
|
||||
#import "DTCoreTextFontDescriptor.h"
|
||||
#import "DTCSSStylesheet.h"
|
||||
#import "DTTextAttachment.h"
|
||||
#import "DTLinkButton.h"
|
||||
#import "DTColor.h"
|
||||
|
||||
// WeRead internal
|
||||
#import "WRBook.h"
|
||||
#import "WRChapter.h"
|
||||
#import "WRChapterData.h"
|
||||
#import "WRCoreTextLayouter.h"
|
||||
|
||||
#pragma mark - Constants
|
||||
|
||||
NSString *const WREpubTypesetterVerticalCenterStyleAttribute = @"wr-vertical-center-style";
|
||||
NSString *const WREpubTypesetterPageRelateAttribute = @"weread-page-relate";
|
||||
|
||||
NSString *const WREpubTypesetterErrorStyleFileNotFound = @"WREpubTypesetterErrorStyleFileNotFound";
|
||||
NSString *const WREpubTypesetterErrorTranslationStyleNotFound = @"WREpubTypesetterErrorTranslationStyleNotFound";
|
||||
NSString *const WREpubTypesetterErrorTranslationContentNotFound = @"WREpubTypesetterErrorTranslationContentNotFound";
|
||||
|
||||
#pragma mark - File-Private Helpers (Forward Declarations)
|
||||
|
||||
/// Merge multiple CSS sources into a single DTCSSStylesheet in priority order.
|
||||
static DTCSSStylesheet *_WRCascadeStylesheets(
|
||||
NSString *defaultCSSPath,
|
||||
NSString *replaceCSSPath,
|
||||
NSString *darkCSSPath,
|
||||
NSString *epubEmbeddedCSSString,
|
||||
NSString *userSettingsCSSString
|
||||
);
|
||||
|
||||
/// Load the contents of a CSS file from the app bundle or EPUB container.
|
||||
/// Returns nil if the file does not exist; sets *outFound to NO.
|
||||
static NSString *_WRLoadCSSFileAtPath(NSString *path, BOOL *outFound);
|
||||
|
||||
/// Convert simplified Chinese (Hans) to traditional Chinese (Hant) for
|
||||
/// locales that require it (zh-Hant / zh-TW / zh-HK).
|
||||
static NSString *_WRConvertHansToHantIfNeeded(NSString *htmlString, NSString *languageCode);
|
||||
|
||||
/// Build the user-settings CSS string from the options dictionary.
|
||||
static NSString *_WRBuildUserSettingsCSS(NSDictionary *options);
|
||||
|
||||
/// Truncate the attributed string at the free-trial boundary.
|
||||
/// Returns a sub-string ending at the last paragraph break before the limit.
|
||||
static NSAttributedString *_WRTruncateForFreeTrial(
|
||||
NSAttributedString *fullString,
|
||||
NSUInteger maxCharacterCount
|
||||
);
|
||||
|
||||
/// Walk the DTHTMLElement tree and patch custom attributes (vertical center,
|
||||
/// page-relate), rewrite image attachments, and resolve hyperlinks.
|
||||
static void _WRPostProcessElementTree(DTHTMLElement *root, NSDictionary *options);
|
||||
|
||||
/// Build the combined rendering options dict that DTHTMLAttributedStringBuilder expects.
|
||||
static NSDictionary *_WRBuildDTOptions(
|
||||
DTCSSStylesheet *stylesheet,
|
||||
WRBook *book,
|
||||
NSDictionary *userOptions
|
||||
);
|
||||
|
||||
#pragma mark - Implementation
|
||||
|
||||
@implementation WREpubTypesetter
|
||||
|
||||
#pragma mark - Primary Typesetting Entry Point
|
||||
|
||||
+ (NSAttributedString *)
|
||||
attributeStringWithFilePath:(NSString *)filePath
|
||||
priority:(NSInteger)priority
|
||||
insertArticleToolAttachment:(BOOL)insertArticleToolAttachment
|
||||
insertBookChapterToolAttachment:(BOOL)insertBookChapterToolAttachment
|
||||
insertRecommendView:(BOOL)insertRecommendView
|
||||
book:(WRBook *)book
|
||||
chapter:(WRChapter *)chapter
|
||||
pageFlippingStyle:(WRPageFlippingStyle)pageFlippingStyle
|
||||
renderErrorReason:(NSString * _Nullable * _Nullable)renderErrorReason
|
||||
isStyleFileNotFound:(BOOL * _Nullable)isStyleFileNotFound
|
||||
options:(NSDictionary * _Nullable)options
|
||||
{
|
||||
// ========================================================================
|
||||
// Step 0: Guard — validate inputs
|
||||
// ========================================================================
|
||||
if (!filePath.length || !book || !chapter) {
|
||||
if (renderErrorReason) {
|
||||
*renderErrorReason = @"Invalid parameters: filePath, book, or chapter is nil.";
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// Default options to empty dict
|
||||
NSDictionary *effectiveOptions = options ?: @{};
|
||||
|
||||
// ========================================================================
|
||||
// Step 1: Load the XHTML source
|
||||
// ========================================================================
|
||||
//
|
||||
// Read the raw XHTML/HTML from the EPUB file on disk.
|
||||
// If the file is missing or unreadable, bail out early.
|
||||
//
|
||||
NSError *readError = nil;
|
||||
NSData *htmlData = [NSData dataWithContentsOfFile:filePath
|
||||
options:0
|
||||
error:&readError];
|
||||
if (!htmlData) {
|
||||
if (renderErrorReason) {
|
||||
*renderErrorReason = [NSString stringWithFormat:
|
||||
@"Failed to read XHTML file at path: %@ — %@", filePath, readError.localizedDescription];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// Detect encoding: EPUB 3 defaults to UTF-8; older EPUBs may use UTF-16.
|
||||
// DTHTMLAttributedStringBuilder handles encoding detection, but we convert
|
||||
// NSData → NSString here for the Hans-to-Hant step.
|
||||
NSString *htmlString = [[NSString alloc] initWithData:htmlData encoding:NSUTF8StringEncoding];
|
||||
if (!htmlString) {
|
||||
// Fallback to ASCII lossy conversion
|
||||
htmlString = [[NSString alloc] initWithData:htmlData
|
||||
encoding:NSASCIIStringEncoding];
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Step 1.5: Traditional / Simplified Chinese conversion
|
||||
// ========================================================================
|
||||
//
|
||||
// WeRead supports reading in Traditional Chinese even when the book
|
||||
// is authored in Simplified. If the user's locale / book language is
|
||||
// zh-Hant, convert all Simplified characters to Traditional.
|
||||
//
|
||||
NSString *bookLanguage = book.language ?: @"zh-Hans";
|
||||
if ([bookLanguage containsString:@"Hant"] ||
|
||||
[bookLanguage containsString:@"TW"] ||
|
||||
[bookLanguage containsString:@"HK"])
|
||||
{
|
||||
htmlString = _WRConvertHansToHantIfNeeded(htmlString, bookLanguage);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Step 2: Load and cascade CSS stylesheets
|
||||
// ========================================================================
|
||||
//
|
||||
// CSS priority (lowest → highest, i.e., later sources override earlier):
|
||||
// 1. default.css — Base HTML tag styles (body, p, h1-h6, ul, ol, etc.)
|
||||
// 2. replace.css — WeRead's default replacements:
|
||||
// • headings → Source Han Serif CN (思源宋体)
|
||||
// • code blocks → Menlo
|
||||
// • images → .bodyPic with wr-vertical-center-style:2
|
||||
// 3. dark.css — Dark-theme color overrides (background, text color)
|
||||
// 4. EPUB embedded — The book's own <style> blocks and linked CSS
|
||||
// 5. User settings — Runtime user prefs (font size, line-height, theme)
|
||||
//
|
||||
NSString *bundlePath = [[NSBundle mainBundle] bundlePath];
|
||||
|
||||
NSString *defaultCSSPath = [bundlePath stringByAppendingPathComponent:@"default.css"];
|
||||
NSString *replaceCSSPath = [bundlePath stringByAppendingPathComponent:@"replace.css"];
|
||||
NSString *darkCSSPath = [bundlePath stringByAppendingPathComponent:@"dark.css"];
|
||||
|
||||
// Dark theme: only load dark.css if the user has selected dark mode.
|
||||
BOOL isDarkMode = [effectiveOptions[@"darkMode"] boolValue];
|
||||
if (!isDarkMode) {
|
||||
darkCSSPath = nil; // skip dark.css
|
||||
}
|
||||
|
||||
// EPUB embedded CSS: extracted from the book's <style> and <link> tags
|
||||
// during EPUB parsing. Stored on WRChapterData or WRBook.
|
||||
NSString *epubEmbeddedCSS = book.epubEmbeddedCSS ?: @"";
|
||||
|
||||
// User settings CSS: built from the user's runtime preferences.
|
||||
NSString *userSettingsCSS = _WRBuildUserSettingsCSS(effectiveOptions);
|
||||
|
||||
// Cascade merge
|
||||
DTCSSStylesheet *mergedStylesheet = _WRCascadeStylesheets(
|
||||
defaultCSSPath,
|
||||
replaceCSSPath,
|
||||
darkCSSPath,
|
||||
epubEmbeddedCSS,
|
||||
userSettingsCSS
|
||||
);
|
||||
|
||||
// Track whether any required CSS file was missing.
|
||||
BOOL styleFileMissing = NO;
|
||||
{
|
||||
// Quick existence check — the loader already sets this, but we
|
||||
// double-check for the primary stylesheet.
|
||||
BOOL dummyFound = YES;
|
||||
_WRLoadCSSFileAtPath(defaultCSSPath, &dummyFound);
|
||||
if (!dummyFound) {
|
||||
styleFileMissing = YES;
|
||||
}
|
||||
}
|
||||
if (isStyleFileNotFound) {
|
||||
*isStyleFileNotFound = styleFileMissing;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Step 3: Configure DTHTMLAttributedStringBuilder
|
||||
// ========================================================================
|
||||
//
|
||||
// DTHTMLAttributedStringBuilder is a third-party (DTCoreText / DTLite)
|
||||
// library that parses HTML/CSS into a tree of DTHTMLElement nodes, then
|
||||
// lays out the tree via CoreText to produce an NSAttributedString.
|
||||
//
|
||||
NSDictionary *dtOptions = _WRBuildDTOptions(mergedStylesheet, book, effectiveOptions);
|
||||
|
||||
DTHTMLAttributedStringBuilder *builder =
|
||||
[[DTHTMLAttributedStringBuilder alloc] initWithHTML:[htmlString dataUsingEncoding:NSUTF8StringEncoding]
|
||||
options:dtOptions
|
||||
documentAttributes:nil];
|
||||
|
||||
// Wire up custom post-processing via the builder's delegate.
|
||||
// DTHTMLAttributedStringBuilder fires willFlushCallback for each
|
||||
// DTHTMLElement before it is converted to NSAttributedString runs.
|
||||
[builder setWillFlushCallback:^(DTHTMLElement *element) {
|
||||
_WRPostProcessElementTree(element, effectiveOptions);
|
||||
}];
|
||||
|
||||
// ========================================================================
|
||||
// Step 4: Build the attributed string
|
||||
// ========================================================================
|
||||
NSAttributedString *result = [builder generatedAttributedString];
|
||||
|
||||
if (!result) {
|
||||
if (renderErrorReason) {
|
||||
*renderErrorReason = @"DTHTMLAttributedStringBuilder returned nil — HTML parsing may have failed.";
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Step 5: Free-trial truncation
|
||||
// ========================================================================
|
||||
//
|
||||
// WeRead offers free preview of N characters per chapter. If the
|
||||
// chapter exceeds the trial limit, truncate at the last paragraph
|
||||
// boundary before the limit.
|
||||
//
|
||||
BOOL isFreeTrial = [effectiveOptions[@"freeTrial"] boolValue];
|
||||
NSUInteger trialCharacterLimit = [effectiveOptions[@"trialCharacterLimit"] unsignedIntegerValue];
|
||||
|
||||
if (isFreeTrial && trialCharacterLimit > 0 && result.length > trialCharacterLimit) {
|
||||
result = _WRTruncateForFreeTrial(result, trialCharacterLimit);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Step 6: Append tool / recommendation attachments
|
||||
// ========================================================================
|
||||
//
|
||||
// WeRead optionally appends interactive tool bars and recommendation
|
||||
// views as NSTextAttachment objects at the end of the chapter.
|
||||
//
|
||||
NSMutableAttributedString *finalResult = [result mutableCopy];
|
||||
|
||||
if (insertArticleToolAttachment) {
|
||||
// Create a zero-width text attachment that the layout engine will
|
||||
// render as the article toolbar (highlight, note, share buttons).
|
||||
NSTextAttachment *articleToolAttachment = [[NSTextAttachment alloc] initWithData:nil
|
||||
ofType:nil];
|
||||
// Attach a custom data payload so the rendering layer can identify it.
|
||||
articleToolAttachment.userInfo = @{
|
||||
@"type": @"articleTool",
|
||||
@"chapterId": chapter.chapterId ?: @""
|
||||
};
|
||||
NSAttributedString *attachmentStr =
|
||||
[NSAttributedString attributedStringWithAttachment:articleToolAttachment];
|
||||
[finalResult appendAttributedString:attachmentStr];
|
||||
}
|
||||
|
||||
if (insertBookChapterToolAttachment) {
|
||||
NSTextAttachment *chapterToolAttachment = [[NSTextAttachment alloc] initWithData:nil
|
||||
ofType:nil];
|
||||
chapterToolAttachment.userInfo = @{
|
||||
@"type": @"bookChapterTool",
|
||||
@"chapterId": chapter.chapterId ?: @""
|
||||
};
|
||||
[finalResult appendAttributedString:
|
||||
[NSAttributedString attributedStringWithAttachment:chapterToolAttachment]];
|
||||
}
|
||||
|
||||
if (insertRecommendView) {
|
||||
NSTextAttachment *recommendAttachment = [[NSTextAttachment alloc] initWithData:nil
|
||||
ofType:nil];
|
||||
recommendAttachment.userInfo = @{
|
||||
@"type": @"recommendView",
|
||||
@"bookId": book.bookId ?: @""
|
||||
};
|
||||
[finalResult appendAttributedString:
|
||||
[NSAttributedString attributedStringWithAttachment:recommendAttachment]];
|
||||
}
|
||||
|
||||
return [finalResult copy];
|
||||
}
|
||||
|
||||
#pragma mark - Translation Error Reporting
|
||||
|
||||
+ (void)tryReportTranslationError:(nullable NSError *)error
|
||||
bookId:(NSString *)bookId
|
||||
chapter:(WRChapter *)chapter
|
||||
isTranslationStyleNotFound:(BOOL)isTranslationStyleNotFound
|
||||
isTranslationContentNotFound:(BOOL)isTranslationContentNotFound
|
||||
isTranslateTagButNoTranslateStyle:(BOOL)isTranslateTagButNoTranslateStyle
|
||||
{
|
||||
// ========================================================================
|
||||
// Build a structured analytics event and fire it via WeRead's telemetry.
|
||||
//
|
||||
// Translation errors occur when the user requests bilingual mode
|
||||
// (original + translated text) but the translation data or CSS is
|
||||
// missing from the EPUB package.
|
||||
// ========================================================================
|
||||
|
||||
// Only report if at least one error flag is set.
|
||||
if (!isTranslationStyleNotFound &&
|
||||
!isTranslationContentNotFound &&
|
||||
!isTranslateTagButNoTranslateStyle)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
NSMutableDictionary *eventPayload = [NSMutableDictionary dictionary];
|
||||
eventPayload[@"bookId"] = bookId ?: @"";
|
||||
eventPayload[@"chapterId"] = chapter.chapterId ?: @"";
|
||||
eventPayload[@"chapterTitle"] = chapter.title ?: @"";
|
||||
eventPayload[@"isTranslationStyleNotFound"] = @(isTranslationStyleNotFound);
|
||||
eventPayload[@"isTranslationContentNotFound"] = @(isTranslationContentNotFound);
|
||||
eventPayload[@"isTranslateTagButNoTranslateStyle"] = @(isTranslateTagButNoTranslateStyle);
|
||||
|
||||
if (error) {
|
||||
eventPayload[@"errorCode"] = @(error.code);
|
||||
eventPayload[@"errorDomain"] = error.domain ?: @"";
|
||||
eventPayload[@"errorMessage"] = error.localizedDescription ?: @"";
|
||||
}
|
||||
|
||||
// WeRead uses a custom telemetry SDK (likely based on Tencent's MTA or
|
||||
// a proprietary solution). The event name is likely "epub_translation_error".
|
||||
//
|
||||
// [WRAnalytics reportEvent:@"epub_translation_error" params:eventPayload];
|
||||
|
||||
NSLog(@"[WREpubTypesetter] Translation error for book %@ chapter %@: %@",
|
||||
bookId, chapter.chapterId, eventPayload);
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - File-Private Helper Implementations
|
||||
|
||||
// ============================================================================
|
||||
// _WRCascadeStylesheets
|
||||
// ============================================================================
|
||||
//
|
||||
// Merge multiple CSS sources into a single DTCSSStylesheet.
|
||||
// Later sources override earlier ones — this is the "cascade" in CSS.
|
||||
//
|
||||
static DTCSSStylesheet *_WRCascadeStylesheets(
|
||||
NSString *defaultCSSPath,
|
||||
NSString *replaceCSSPath,
|
||||
NSString *darkCSSPath,
|
||||
NSString *epubEmbeddedCSSString,
|
||||
NSString *userSettingsCSSString)
|
||||
{
|
||||
DTCSSStylesheet *merged = [[DTCSSStylesheet alloc] init];
|
||||
|
||||
// --- Layer 1: default.css (base HTML tag styles) ---
|
||||
//
|
||||
// This file defines how standard HTML elements look:
|
||||
// body { font-family: ...; margin: 0; }
|
||||
// p { margin-top: 0.5em; margin-bottom: 0.5em; }
|
||||
// h1 { font-size: 1.8em; font-weight: bold; }
|
||||
// ... etc.
|
||||
//
|
||||
if (defaultCSSPath) {
|
||||
NSString *css = _WRLoadCSSFileAtPath(defaultCSSPath, NULL);
|
||||
if (css) {
|
||||
DTCSSStylesheet *layer = [[DTCSSStylesheet alloc] initWithStyleBlock:css];
|
||||
[merged mergeStylesheet:layer];
|
||||
}
|
||||
}
|
||||
|
||||
// --- Layer 2: replace.css (WeRead's default replacements) ---
|
||||
//
|
||||
// Overrides specific elements with WeRead's preferred styling:
|
||||
// h1, h2, h3 { font-family: "Source Han Serif CN", serif; }
|
||||
// pre, code { font-family: "Menlo", monospace; }
|
||||
// img.bodyPic { wr-vertical-center-style: 2; max-width: 100%; }
|
||||
//
|
||||
// This ensures a consistent reading experience across different EPUBs.
|
||||
//
|
||||
if (replaceCSSPath) {
|
||||
NSString *css = _WRLoadCSSFileAtPath(replaceCSSPath, NULL);
|
||||
if (css) {
|
||||
DTCSSStylesheet *layer = [[DTCSSStylesheet alloc] initWithStyleBlock:css];
|
||||
[merged mergeStylesheet:layer];
|
||||
}
|
||||
}
|
||||
|
||||
// --- Layer 3: dark.css (dark theme overrides) ---
|
||||
//
|
||||
// Only loaded when the user is in dark / night mode:
|
||||
// body { background-color: #1a1a1a; color: #cccccc; }
|
||||
// a { color: #6eaad7; }
|
||||
// img { filter: brightness(0.85); }
|
||||
//
|
||||
if (darkCSSPath) {
|
||||
NSString *css = _WRLoadCSSFileAtPath(darkCSSPath, NULL);
|
||||
if (css) {
|
||||
DTCSSStylesheet *layer = [[DTCSSStylesheet alloc] initWithStyleBlock:css];
|
||||
[merged mergeStylesheet:layer];
|
||||
}
|
||||
}
|
||||
|
||||
// --- Layer 4: EPUB embedded CSS ---
|
||||
//
|
||||
// The book author's own styles, extracted from <style> blocks and
|
||||
// linked .css files inside the EPUB container.
|
||||
//
|
||||
if (epubEmbeddedCSSString.length) {
|
||||
DTCSSStylesheet *layer = [[DTCSSStylesheet alloc] initWithStyleBlock:epubEmbeddedCSSString];
|
||||
[merged mergeStylesheet:layer];
|
||||
}
|
||||
|
||||
// --- Layer 5: User settings CSS ---
|
||||
//
|
||||
// Dynamically generated from the user's runtime preferences:
|
||||
// body {
|
||||
// font-size: 18px;
|
||||
// line-height: 1.8;
|
||||
// font-family: "PingFang SC", sans-serif;
|
||||
// background-color: #f5f0e8; (sepia theme)
|
||||
// }
|
||||
//
|
||||
if (userSettingsCSSString.length) {
|
||||
DTCSSStylesheet *layer = [[DTCSSStylesheet alloc] initWithStyleBlock:userSettingsCSSString];
|
||||
[merged mergeStylesheet:layer];
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// _WRLoadCSSFileAtPath
|
||||
// ============================================================================
|
||||
//
|
||||
// Read a CSS file from disk. Returns the string contents, or nil if
|
||||
// the file does not exist. Optionally reports existence via outFound.
|
||||
//
|
||||
static NSString *_WRLoadCSSFileAtPath(NSString *path, BOOL *outFound)
|
||||
{
|
||||
if (!path.length) {
|
||||
if (outFound) *outFound = NO;
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSFileManager *fm = [NSFileManager defaultManager];
|
||||
if (![fm fileExistsAtPath:path]) {
|
||||
if (outFound) *outFound = NO;
|
||||
return nil;
|
||||
}
|
||||
|
||||
if (outFound) *outFound = YES;
|
||||
|
||||
NSError *error = nil;
|
||||
NSString *contents = [NSString stringWithContentsOfFile:path
|
||||
encoding:NSUTF8StringEncoding
|
||||
error:&error];
|
||||
if (!contents) {
|
||||
NSLog(@"[WREpubTypesetter] Failed to load CSS at %@: %@", path, error.localizedDescription);
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// _WRConvertHansToHantIfNeeded
|
||||
// ============================================================================
|
||||
//
|
||||
// Convert Simplified Chinese characters to Traditional Chinese.
|
||||
// WeRead supports bilingual mode and locale-based character conversion.
|
||||
//
|
||||
// The actual conversion likely uses Apple's CFStringTransform with
|
||||
// kCFStringTransformToLatin + kCFStringTransformLatinHant, or a
|
||||
// custom lookup table for high-fidelity conversion.
|
||||
//
|
||||
static NSString *_WRConvertHansToHantIfNeeded(NSString *htmlString, NSString *languageCode)
|
||||
{
|
||||
if (!htmlString.length) return htmlString;
|
||||
|
||||
// CFStringTransform approach (simplified):
|
||||
// 1. Convert Hans → Latin (pinyin)
|
||||
// 2. Convert Latin → Hant (traditional)
|
||||
//
|
||||
// This is lossy for some characters. WeRead may use a custom
|
||||
// dictionary (OpenCC or similar) for better accuracy.
|
||||
//
|
||||
NSMutableString *mutable = [htmlString mutableCopy];
|
||||
|
||||
// Apple's built-in transliteration:
|
||||
// Hans → Hant is not directly supported via CFStringTransform,
|
||||
// so a two-step Latin bridge is used.
|
||||
//
|
||||
CFStringRef str = (__bridge CFStringRef)mutable;
|
||||
CFStringTransform(str, NULL, kCFStringTransformToLatin, NO);
|
||||
CFStringTransform(str, NULL, kCFStringTransformLatinHant, NO);
|
||||
|
||||
return [mutable copy];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// _WRBuildUserSettingsCSS
|
||||
// ============================================================================
|
||||
//
|
||||
// Build a CSS string from the user's runtime preferences dictionary.
|
||||
// These override any CSS from the book or WeRead defaults.
|
||||
//
|
||||
static NSString *_WRBuildUserSettingsCSS(NSDictionary *options)
|
||||
{
|
||||
if (!options.count) return @"";
|
||||
|
||||
NSMutableString *css = [NSMutableString string];
|
||||
|
||||
// Font family
|
||||
NSString *fontFamily = options[@"fontFamily"];
|
||||
if (fontFamily.length) {
|
||||
[css appendFormat:@"body { font-family: \"%@\", sans-serif; }\n", fontFamily];
|
||||
}
|
||||
|
||||
// Font size
|
||||
NSNumber *fontSize = options[@"fontSize"];
|
||||
if (fontSize) {
|
||||
[css appendFormat:@"body { font-size: %ldpx; }\n", (long)fontSize.integerValue];
|
||||
}
|
||||
|
||||
// Line height
|
||||
NSNumber *lineHeight = options[@"lineHeight"];
|
||||
if (lineHeight) {
|
||||
[css appendFormat:@"body { line-height: %.2f; }\n", lineHeight.floatValue];
|
||||
}
|
||||
|
||||
// Letter spacing (character spacing)
|
||||
NSNumber *letterSpacing = options[@"letterSpacing"];
|
||||
if (letterSpacing) {
|
||||
[css appendFormat:@"body { letter-spacing: %ldpx; }\n", (long)letterSpacing.integerValue];
|
||||
}
|
||||
|
||||
// Theme background color
|
||||
NSString *backgroundColor = options[@"backgroundColor"];
|
||||
if (backgroundColor.length) {
|
||||
[css appendFormat:@"body { background-color: %@; }\n", backgroundColor];
|
||||
}
|
||||
|
||||
// Theme text color
|
||||
NSString *textColor = options[@"textColor"];
|
||||
if (textColor.length) {
|
||||
[css appendFormat:@"body, p, span, div { color: %@; }\n", textColor];
|
||||
}
|
||||
|
||||
// Paragraph margin / first-line indent
|
||||
NSNumber *paragraphSpacing = options[@"paragraphSpacing"];
|
||||
if (paragraphSpacing) {
|
||||
[css appendFormat:@"p { margin-top: %ldpx; margin-bottom: %ldpx; }\n",
|
||||
(long)paragraphSpacing.integerValue,
|
||||
(long)paragraphSpacing.integerValue];
|
||||
}
|
||||
|
||||
NSNumber *firstLineIndent = options[@"firstLineIndent"];
|
||||
if (firstLineIndent) {
|
||||
[css appendFormat:@"p { text-indent: %ldpx; }\n",
|
||||
(long)firstLineIndent.integerValue];
|
||||
}
|
||||
|
||||
return [css copy];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// _WRBuildDTOptions
|
||||
// ============================================================================
|
||||
//
|
||||
// Build the options dictionary for DTHTMLAttributedStringBuilder.
|
||||
//
|
||||
static NSDictionary *_WRBuildDTOptions(
|
||||
DTCSSStylesheet *stylesheet,
|
||||
WRBook *book,
|
||||
NSDictionary *userOptions)
|
||||
{
|
||||
NSMutableDictionary *opts = [NSMutableDictionary dictionary];
|
||||
|
||||
// Base URL for resolving relative image paths inside the EPUB.
|
||||
// This is the directory containing the XHTML file.
|
||||
if (book.epubBasePath) {
|
||||
opts[DTBaseURL] = [NSURL fileURLWithPath:book.epubBasePath];
|
||||
}
|
||||
|
||||
// Apply the merged CSS stylesheet
|
||||
if (stylesheet) {
|
||||
opts[DTDefaultStylesheet] = stylesheet;
|
||||
}
|
||||
|
||||
// CoreText font descriptor for the default body font.
|
||||
// WeRead uses system fonts (PingFang SC) or user-chosen fonts.
|
||||
DTCoreTextFontDescriptor *fontDesc = [[DTCoreTextFontDescriptor alloc] init];
|
||||
NSString *userFont = userOptions[@"fontFamily"];
|
||||
if (userFont.length) {
|
||||
fontDesc.fontName = userFont;
|
||||
} else {
|
||||
fontDesc.fontName = @"PingFang SC";
|
||||
}
|
||||
NSNumber *fontSize = userOptions[@"fontSize"];
|
||||
fontDesc.pointSize = fontSize ? fontSize.floatValue : 18.0f;
|
||||
opts[DTDefaultFontDescriptor] = fontDesc;
|
||||
|
||||
// Text color
|
||||
opts[DTDefaultTextColor] = [DTColor blackColor];
|
||||
|
||||
// Link color
|
||||
opts[DTDefaultLinkColor] = [DTColor colorWithRed:0.0 green:0.478 blue:1.0 alpha:1.0];
|
||||
|
||||
// Disable image downloading — WeRead handles images locally from the EPUB.
|
||||
opts[DTIgnoreInlineStyles] = @NO;
|
||||
opts[DTMaxImageSize] = @(CGSizeMake(1080, 1920)); // Max display size
|
||||
|
||||
return [opts copy];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// _WRPostProcessElementTree
|
||||
// ============================================================================
|
||||
//
|
||||
// Walk the DTHTMLElement tree before it is flushed to NSAttributedString
|
||||
// and apply WeRead-specific patches:
|
||||
//
|
||||
// 1. Images → wrap in NSTextAttachment with .bodyPic class, apply
|
||||
// wr-vertical-center-style:2 so images center vertically.
|
||||
//
|
||||
// 2. Hyperlinks → attach DTLinkButton metadata, set link color.
|
||||
//
|
||||
// 3. Custom attributes:
|
||||
// - wr-vertical-center-style: element is vertically centered
|
||||
// - weread-page-relate: element has page-relative positioning
|
||||
//
|
||||
// 4. Font patching: if the book specifies a custom font, override
|
||||
// font descriptors in heading and paragraph nodes.
|
||||
//
|
||||
static void _WRPostProcessElementTree(DTHTMLElement *element, NSDictionary *options)
|
||||
{
|
||||
if (!element) return;
|
||||
|
||||
// --- Image handling ---
|
||||
//
|
||||
// DTHTMLElement nodes with a textAttachment represent <img> tags.
|
||||
// WeRead patches image attachments to:
|
||||
// - Set a maximum display size
|
||||
// - Add the "bodyPic" CSS class
|
||||
// - Apply vertical centering (wr-vertical-center-style:2)
|
||||
//
|
||||
if (element.textAttachment) {
|
||||
DTTextAttachment *attachment = element.textAttachment;
|
||||
|
||||
// Enforce max image dimensions from user options or defaults.
|
||||
CGSize maxSize = CGSizeMake(1080, 1920);
|
||||
NSNumber *maxW = options[@"maxImageWidth"];
|
||||
if (maxW) maxSize.width = maxW.floatValue;
|
||||
|
||||
if (attachment.originalSize.width > maxSize.width) {
|
||||
CGFloat scale = maxSize.width / attachment.originalSize.width;
|
||||
attachment.displaySize = CGSizeMake(
|
||||
attachment.originalSize.width * scale,
|
||||
attachment.originalSize.height * scale
|
||||
);
|
||||
}
|
||||
|
||||
// Mark as vertically centered (matches replace.css rule:
|
||||
// img.bodyPic { wr-vertical-center-style: 2; })
|
||||
//
|
||||
// The custom attribute "wr-vertical-center-style" is read by
|
||||
// WRCoreTextLayouter during line layout to adjust the baseline
|
||||
// offset so the image sits vertically centered on the line.
|
||||
//
|
||||
element.textAttachment.attributes = @{
|
||||
WREpubTypesetterVerticalCenterStyleAttribute: @(2)
|
||||
};
|
||||
|
||||
// Add "bodyPic" CSS class for styling consistency.
|
||||
[element addClass:@"bodyPic"];
|
||||
}
|
||||
|
||||
// --- Hyperlink handling ---
|
||||
//
|
||||
// <a href="..."> tags produce DTHTMLElement nodes with a link.
|
||||
// WeRead stores the link URL in the element's attribute dictionary
|
||||
// so that WRCoreTextLayouter can render a tappable link.
|
||||
//
|
||||
NSString *linkURL = element.link;
|
||||
if (linkURL.length) {
|
||||
// Internal EPUB links (e.g., "#footnote-1") are resolved relative
|
||||
// to the current file. External links open in a browser.
|
||||
//
|
||||
// Store the URL as a custom attribute that the rendering layer
|
||||
// will pick up.
|
||||
element.fontDescriptor.underlineTrait = YES;
|
||||
}
|
||||
|
||||
// --- Custom CSS attributes ---
|
||||
//
|
||||
// Scan the element's style dictionary for WeRead-specific properties.
|
||||
//
|
||||
NSString *verticalCenter = element.styleAttributes[WREpubTypesetterVerticalCenterStyleAttribute];
|
||||
if (verticalCenter) {
|
||||
// Store as an NSNumber on the element for the layout engine.
|
||||
// The value "2" means "center the element on the line."
|
||||
element.textAttachment.attributes = @{
|
||||
WREpubTypesetterVerticalCenterStyleAttribute: @([verticalCenter integerValue])
|
||||
};
|
||||
}
|
||||
|
||||
NSString *pageRelate = element.styleAttributes[WREpubTypesetterPageRelateAttribute];
|
||||
if (pageRelate) {
|
||||
// Page-relative positioning: used for elements that should
|
||||
// appear at a fixed position relative to the page (e.g., headers).
|
||||
}
|
||||
|
||||
// --- Recurse into children ---
|
||||
//
|
||||
for (DTHTMLElement *child in element.childNodes) {
|
||||
_WRPostProcessElementTree(child, options);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// _WRTruncateForFreeTrial
|
||||
// ============================================================================
|
||||
//
|
||||
// Truncate an NSAttributedString at the free-trial boundary.
|
||||
//
|
||||
// WeRead lets non-VIP users read a limited number of characters per
|
||||
// chapter. The truncation point is the last paragraph break (\n or
|
||||
// NSParagraphSeparator) before the character limit.
|
||||
//
|
||||
static NSAttributedString *_WRTruncateForFreeTrial(
|
||||
NSAttributedString *fullString,
|
||||
NSUInteger maxCharacterCount)
|
||||
{
|
||||
if (fullString.length <= maxCharacterCount) {
|
||||
return fullString;
|
||||
}
|
||||
|
||||
// Walk backwards from the limit to find a paragraph boundary.
|
||||
NSString *plainText = fullString.string;
|
||||
NSUInteger truncateAt = maxCharacterCount;
|
||||
|
||||
while (truncateAt > 0) {
|
||||
unichar c = [plainText characterAtIndex:truncateAt - 1];
|
||||
if (c == '\n' || c == 0x2029 /* NSParagraphSeparator */) {
|
||||
break;
|
||||
}
|
||||
truncateAt--;
|
||||
}
|
||||
|
||||
// If we couldn't find a paragraph break, just cut at the limit.
|
||||
if (truncateAt == 0) {
|
||||
truncateAt = maxCharacterCount;
|
||||
}
|
||||
|
||||
// Create a sub-attributed-string up to the truncation point.
|
||||
NSRange range = NSMakeRange(0, truncateAt);
|
||||
NSMutableAttributedString *truncated =
|
||||
[[fullString attributedSubstringFromRange:range] mutableCopy];
|
||||
|
||||
// Append a "..." indicator so the reader knows the chapter continues.
|
||||
NSDictionary *lastAttributes = [truncated attributesAtIndex:truncated.length - 1
|
||||
effectiveRange:NULL];
|
||||
NSAttributedString *ellipsis =
|
||||
[[NSAttributedString alloc] initWithString:@"\n\n...\n\n"
|
||||
attributes:lastAttributes];
|
||||
[truncated appendAttributedString:ellipsis];
|
||||
|
||||
return [truncated copy];
|
||||
}
|
||||
67
Doc/WXRead/decompiled/WRPageHighlight.h
Normal file
67
Doc/WXRead/decompiled/WRPageHighlight.h
Normal file
@ -0,0 +1,67 @@
|
||||
//
|
||||
// WRPageHighlight.h
|
||||
// WeRead (微信读书)
|
||||
// Reverse-engineered header
|
||||
//
|
||||
// Annotation model for text highlights. Stores the highlighted text range,
|
||||
// color, and associated metadata. 3 methods identified from binary.
|
||||
//
|
||||
// Inherits from or relates to WRPageMark (base class).
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@class WRBook;
|
||||
@class WRBookmark;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Highlight color presets
|
||||
// ---------------------------------------------------------------------------
|
||||
typedef NS_ENUM(NSInteger, WRHighlightColor) {
|
||||
WRHighlightColorYellow = 0,
|
||||
WRHighlightColorBlue = 1,
|
||||
WRHighlightColorRed = 2,
|
||||
WRHighlightColorGreen = 3,
|
||||
WRHighlightColorPurple = 4,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WRPageHighlight
|
||||
// ---------------------------------------------------------------------------
|
||||
@interface WRPageHighlight : NSObject
|
||||
|
||||
@property (nonatomic, copy) NSString *highlightId; // unique ID
|
||||
@property (nonatomic, copy) NSString *bookId;
|
||||
@property (nonatomic, copy) NSString *chapterUid;
|
||||
@property (nonatomic, assign) NSInteger startPos; // global start position
|
||||
@property (nonatomic, assign) NSInteger endPos; // global end position
|
||||
@property (nonatomic, assign) NSInteger chapterOffset; // offset within chapter
|
||||
@property (nonatomic, copy) NSString *markedText; // the highlighted text
|
||||
@property (nonatomic, assign) WRHighlightColor color; // highlight color enum
|
||||
@property (nonatomic, copy, nullable) NSString *colorStyle; // color name string
|
||||
@property (nonatomic, assign) NSInteger pageIndex; // page in the reader
|
||||
@property (nonatomic, assign) NSTimeInterval createTime;
|
||||
@property (nonatomic, assign) BOOL isSynced;
|
||||
|
||||
#pragma mark - Methods (3 identified from binary)
|
||||
|
||||
/// Initialize a highlight with the given range and color.
|
||||
- (instancetype)initWithBookId:(NSString *)bookId
|
||||
chapterUid:(NSString *)chapterUid
|
||||
startPos:(NSInteger)startPos
|
||||
endPos:(NSInteger)endPos
|
||||
text:(NSString *)text
|
||||
color:(WRHighlightColor)color;
|
||||
|
||||
/// Convert this highlight to a WRBookmark for persistence/sync.
|
||||
- (WRBookmark *)toBookmark;
|
||||
|
||||
/// Return the highlight color as a UIColor for rendering.
|
||||
- (UIColor *)uiColor;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
105
Doc/WXRead/decompiled/WRPageHighlight.m
Normal file
105
Doc/WXRead/decompiled/WRPageHighlight.m
Normal file
@ -0,0 +1,105 @@
|
||||
//
|
||||
// WRPageHighlight.m
|
||||
// WeRead (微信读书)
|
||||
// Reverse-engineered implementation reconstruction
|
||||
//
|
||||
// 3 methods identified from binary. WRPageHighlight is a lightweight
|
||||
// model for text highlights on a page.
|
||||
//
|
||||
|
||||
#import "WRPageHighlight.h"
|
||||
#import "WRBookmark.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color utility
|
||||
// ---------------------------------------------------------------------------
|
||||
static UIColor *UIColorForHighlightColor(WRHighlightColor color)
|
||||
{
|
||||
switch (color) {
|
||||
case WRHighlightColorYellow:
|
||||
return [UIColor colorWithRed:1.0 green:0.92 blue:0.23 alpha:0.35];
|
||||
case WRHighlightColorBlue:
|
||||
return [UIColor colorWithRed:0.26 green:0.65 blue:0.96 alpha:0.35];
|
||||
case WRHighlightColorRed:
|
||||
return [UIColor colorWithRed:0.96 green:0.26 blue:0.26 alpha:0.35];
|
||||
case WRHighlightColorGreen:
|
||||
return [UIColor colorWithRed:0.30 green:0.85 blue:0.39 alpha:0.35];
|
||||
case WRHighlightColorPurple:
|
||||
return [UIColor colorWithRed:0.67 green:0.33 blue:0.97 alpha:0.35];
|
||||
default:
|
||||
return [UIColor colorWithRed:1.0 green:0.92 blue:0.23 alpha:0.35];
|
||||
}
|
||||
}
|
||||
|
||||
static NSString *NSStringFromHighlightColor(WRHighlightColor color)
|
||||
{
|
||||
switch (color) {
|
||||
case WRHighlightColorYellow: return @"yellow";
|
||||
case WRHighlightColorBlue: return @"blue";
|
||||
case WRHighlightColorRed: return @"red";
|
||||
case WRHighlightColorGreen: return @"green";
|
||||
case WRHighlightColorPurple: return @"purple";
|
||||
default: return @"yellow";
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Implementation
|
||||
|
||||
@implementation WRPageHighlight
|
||||
|
||||
- (instancetype)initWithBookId:(NSString *)bookId
|
||||
chapterUid:(NSString *)chapterUid
|
||||
startPos:(NSInteger)startPos
|
||||
endPos:(NSInteger)endPos
|
||||
text:(NSString *)text
|
||||
color:(WRHighlightColor)color
|
||||
{
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_highlightId = [[NSUUID UUID] UUIDString];
|
||||
_bookId = [bookId copy];
|
||||
_chapterUid = [chapterUid copy];
|
||||
_startPos = startPos;
|
||||
_endPos = endPos;
|
||||
_chapterOffset = startPos; // Simplified; real impl maps global->local
|
||||
_markedText = [text copy];
|
||||
_color = color;
|
||||
_colorStyle = NSStringFromHighlightColor(color);
|
||||
_createTime = [[NSDate date] timeIntervalSince1970];
|
||||
_isSynced = NO;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (WRBookmark *)toBookmark
|
||||
{
|
||||
WRBookmark *bookmark = [WRBookmark highlightWithBookId:_bookId
|
||||
chapterUid:_chapterUid
|
||||
startPos:_startPos
|
||||
endPos:_endPos
|
||||
text:_markedText
|
||||
colorStyle:_colorStyle];
|
||||
bookmark.bookmarkId = _highlightId;
|
||||
bookmark.chapterOffset = _chapterOffset;
|
||||
bookmark.pageIndex = _pageIndex;
|
||||
bookmark.createTime = _createTime;
|
||||
bookmark.isSynced = _isSynced;
|
||||
|
||||
return bookmark;
|
||||
}
|
||||
|
||||
- (UIColor *)uiColor
|
||||
{
|
||||
return UIColorForHighlightColor(_color);
|
||||
}
|
||||
|
||||
#pragma mark - NSObject
|
||||
|
||||
- (NSString *)description
|
||||
{
|
||||
return [NSString stringWithFormat:@"<WRPageHighlight: %@ [%@] '%@'>",
|
||||
_highlightId, _colorStyle,
|
||||
[_markedText substringToIndex:MIN(40, _markedText.length)]];
|
||||
}
|
||||
|
||||
@end
|
||||
139
Doc/WXRead/decompiled/WRPageMark.h
Normal file
139
Doc/WXRead/decompiled/WRPageMark.h
Normal file
@ -0,0 +1,139 @@
|
||||
//
|
||||
// WRPageMark.h
|
||||
// WeRead (微信读书)
|
||||
// Reverse-engineered header
|
||||
//
|
||||
// Annotation model for page bookmarks/marks (dog-ear style).
|
||||
// 25 methods identified from binary. The most feature-rich annotation type.
|
||||
// Supports bookmark management, sorting, filtering, and display.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@class WRBook;
|
||||
@class WRBookmark;
|
||||
@class WRPageHighlight;
|
||||
@class WRPageUnderline;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mark display mode
|
||||
// ---------------------------------------------------------------------------
|
||||
typedef NS_ENUM(NSInteger, WRPageMarkDisplayMode) {
|
||||
WRPageMarkDisplayModeIcon = 0, // Show bookmark icon
|
||||
WRPageMarkDisplayModeList = 1, // Show in list view
|
||||
WRPageMarkDisplayModeInline = 2, // Show inline in text
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WRPageMark
|
||||
// ---------------------------------------------------------------------------
|
||||
@interface WRPageMark : NSObject
|
||||
|
||||
@property (nonatomic, copy) NSString *markId;
|
||||
@property (nonatomic, copy) NSString *bookId;
|
||||
@property (nonatomic, copy) NSString *chapterUid;
|
||||
@property (nonatomic, assign) NSInteger chapterOffset;
|
||||
@property (nonatomic, assign) NSInteger pageIndex;
|
||||
@property (nonatomic, copy) NSString *chapterTitle; // display title
|
||||
@property (nonatomic, copy, nullable) NSString *excerptText; // text near the mark
|
||||
@property (nonatomic, assign) NSTimeInterval createTime;
|
||||
@property (nonatomic, assign) NSTimeInterval updateTime;
|
||||
@property (nonatomic, assign) BOOL isSynced;
|
||||
@property (nonatomic, copy, nullable) NSString *syncKey;
|
||||
|
||||
// Color/style for the mark icon
|
||||
@property (nonatomic, strong, nullable) UIColor *markColor;
|
||||
@property (nonatomic, assign) WRPageMarkDisplayMode displayMode;
|
||||
|
||||
// Linked annotations at the same position
|
||||
@property (nonatomic, strong, nullable) NSArray<WRPageHighlight *> *linkedHighlights;
|
||||
@property (nonatomic, strong, nullable) NSArray<WRPageUnderline *> *linkedUnderlines;
|
||||
|
||||
#pragma mark - Initialization
|
||||
|
||||
/// Create a page mark at the given position.
|
||||
- (instancetype)initWithBookId:(NSString *)bookId
|
||||
chapterUid:(NSString *)chapterUid
|
||||
chapterOffset:(NSInteger)offset
|
||||
pageIndex:(NSInteger)pageIndex
|
||||
chapterTitle:(NSString *)title;
|
||||
|
||||
#pragma mark - Conversion (25 methods, reconstructed)
|
||||
|
||||
/// Convert to WRBookmark for persistence.
|
||||
- (WRBookmark *)toBookmark;
|
||||
|
||||
/// Create from WRBookmark.
|
||||
+ (nullable instancetype)fromBookmark:(WRBookmark *)bookmark;
|
||||
|
||||
#pragma mark - Linked Annotations
|
||||
|
||||
/// Add a highlight linked to this mark.
|
||||
- (void)addLinkedHighlight:(WRPageHighlight *)highlight;
|
||||
|
||||
/// Add an underline linked to this mark.
|
||||
- (void)addLinkedUnderline:(WRPageUnderline *)underline;
|
||||
|
||||
/// Remove a linked highlight by ID.
|
||||
- (void)removeLinkedHighlightWithId:(NSString *)highlightId;
|
||||
|
||||
/// Remove a linked underline by ID.
|
||||
- (void)removeLinkedUnderlineWithId:(NSString *)underlineId;
|
||||
|
||||
/// Return all linked annotation IDs.
|
||||
- (NSArray<NSString *> *)allLinkedAnnotationIds;
|
||||
|
||||
#pragma mark - Display
|
||||
|
||||
/// Return the title for display in the bookmark list.
|
||||
- (NSString *)displayTitle;
|
||||
|
||||
/// Return the subtitle/detail text.
|
||||
- (NSString *)displaySubtitle;
|
||||
|
||||
/// Return a formatted date string.
|
||||
- (NSString *)formattedDate;
|
||||
|
||||
/// Return the mark icon image.
|
||||
- (UIImage *)markIcon;
|
||||
|
||||
#pragma mark - Sorting & Filtering
|
||||
|
||||
/// Compare marks by position (for sorting in reading order).
|
||||
- (NSComparisonResult)compareByPosition:(WRPageMark *)other;
|
||||
|
||||
/// Compare marks by creation date.
|
||||
- (NSComparisonResult)compareByDate:(WRPageMark *)other;
|
||||
|
||||
/// Check if this mark is in the given chapter.
|
||||
- (BOOL)isInChapter:(NSString *)chapterUid;
|
||||
|
||||
#pragma mark - Serialization
|
||||
|
||||
- (NSDictionary *)toDictionary;
|
||||
+ (nullable instancetype)fromDictionary:(NSDictionary *)dict;
|
||||
- (nullable NSData *)toJSONData;
|
||||
+ (nullable instancetype)fromJSONData:(NSData *)data;
|
||||
|
||||
#pragma mark - Batch Operations (class methods)
|
||||
|
||||
/// Sort an array of marks by position.
|
||||
+ (NSArray<WRPageMark *> *)marksSortedByPosition:(NSArray<WRPageMark *> *)marks;
|
||||
|
||||
/// Sort an array of marks by date.
|
||||
+ (NSArray<WRPageMark *> *)marksSortedByDate:(NSArray<WRPageMark *> *)marks;
|
||||
|
||||
/// Filter marks for a specific chapter.
|
||||
+ (NSArray<WRPageMark *> *)marksInChapter:(NSString *)chapterUid
|
||||
fromMarks:(NSArray<WRPageMark *> *)marks;
|
||||
|
||||
/// Filter marks within a position range.
|
||||
+ (NSArray<WRPageMark *> *)marksInRange:(NSRange)range
|
||||
fromMarks:(NSArray<WRPageMark *> *)marks;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
381
Doc/WXRead/decompiled/WRPageMark.m
Normal file
381
Doc/WXRead/decompiled/WRPageMark.m
Normal file
@ -0,0 +1,381 @@
|
||||
//
|
||||
// WRPageMark.m
|
||||
// WeRead (微信读书)
|
||||
// Reverse-engineered implementation reconstruction
|
||||
//
|
||||
// 25 methods identified from binary. WRPageMark is the most comprehensive
|
||||
// annotation model, handling page bookmarks with linked highlights
|
||||
// and underlines.
|
||||
//
|
||||
|
||||
#import "WRPageMark.h"
|
||||
#import "WRBookmark.h"
|
||||
#import "WRPageHighlight.h"
|
||||
#import "WRPageUnderline.h"
|
||||
|
||||
#pragma mark - Implementation
|
||||
|
||||
@implementation WRPageMark
|
||||
{
|
||||
NSMutableArray<WRPageHighlight *> *_mutableHighlights;
|
||||
NSMutableArray<WRPageUnderline *> *_mutableUnderlines;
|
||||
}
|
||||
|
||||
#pragma mark - Initialization
|
||||
|
||||
- (instancetype)initWithBookId:(NSString *)bookId
|
||||
chapterUid:(NSString *)chapterUid
|
||||
chapterOffset:(NSInteger)offset
|
||||
pageIndex:(NSInteger)pageIndex
|
||||
chapterTitle:(NSString *)title
|
||||
{
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_markId = [[NSUUID UUID] UUIDString];
|
||||
_bookId = [bookId copy];
|
||||
_chapterUid = [chapterUid copy];
|
||||
_chapterOffset = offset;
|
||||
_pageIndex = pageIndex;
|
||||
_chapterTitle = [title copy];
|
||||
_createTime = [[NSDate date] timeIntervalSince1970];
|
||||
_updateTime = _createTime;
|
||||
_isSynced = NO;
|
||||
_displayMode = WRPageMarkDisplayModeIcon;
|
||||
_markColor = [UIColor colorWithRed:0.9 green:0.3 blue:0.2 alpha:1.0];
|
||||
|
||||
_mutableHighlights = [NSMutableArray array];
|
||||
_mutableUnderlines = [NSMutableArray array];
|
||||
_linkedHighlights = @[];
|
||||
_linkedUnderlines = @[];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
#pragma mark - Conversion
|
||||
|
||||
- (WRBookmark *)toBookmark
|
||||
{
|
||||
WRBookmark *bm = [WRBookmark bookmarkWithBookId:_bookId
|
||||
chapterUid:_chapterUid
|
||||
offset:_chapterOffset
|
||||
text:_excerptText ?: @""
|
||||
type:WRBookmarkTypeMark];
|
||||
bm.bookmarkId = _markId;
|
||||
bm.chapterIndex = _pageIndex;
|
||||
bm.chapterOffset = _chapterOffset;
|
||||
bm.createTime = _createTime;
|
||||
bm.updateTime = _updateTime;
|
||||
bm.isSynced = _isSynced;
|
||||
bm.syncKey = _syncKey;
|
||||
|
||||
// Store linked annotation info in extra metadata
|
||||
NSMutableDictionary *meta = [NSMutableDictionary dictionary];
|
||||
if (_linkedHighlights.count > 0) {
|
||||
NSMutableArray *ids = [NSMutableArray array];
|
||||
for (WRPageHighlight *h in _linkedHighlights) {
|
||||
[ids addObject:h.highlightId];
|
||||
}
|
||||
meta[@"linkedHighlightIds"] = ids;
|
||||
}
|
||||
if (_linkedUnderlines.count > 0) {
|
||||
NSMutableArray *ids = [NSMutableArray array];
|
||||
for (WRPageUnderline *u in _linkedUnderlines) {
|
||||
[ids addObject:u.underlineId];
|
||||
}
|
||||
meta[@"linkedUnderlineIds"] = ids;
|
||||
}
|
||||
bm.extraMetadata = meta;
|
||||
|
||||
return bm;
|
||||
}
|
||||
|
||||
+ (nullable instancetype)fromBookmark:(WRBookmark *)bookmark
|
||||
{
|
||||
if (!bookmark || bookmark.type != WRBookmarkTypeMark) return nil;
|
||||
|
||||
WRPageMark *mark = [[WRPageMark alloc]
|
||||
initWithBookId:bookmark.bookId
|
||||
chapterUid:bookmark.chapterUid
|
||||
chapterOffset:bookmark.chapterOffset
|
||||
pageIndex:bookmark.chapterIndex
|
||||
chapterTitle:@""]; // Title resolved separately
|
||||
|
||||
mark.markId = bookmark.bookmarkId;
|
||||
mark.excerptText = bookmark.markText;
|
||||
mark.createTime = bookmark.createTime;
|
||||
mark.updateTime = bookmark.updateTime;
|
||||
mark.isSynced = bookmark.isSynced;
|
||||
mark.syncKey = bookmark.syncKey;
|
||||
|
||||
return mark;
|
||||
}
|
||||
|
||||
#pragma mark - Linked Annotations
|
||||
|
||||
- (void)addLinkedHighlight:(WRPageHighlight *)highlight
|
||||
{
|
||||
if (!highlight) return;
|
||||
[_mutableHighlights addObject:highlight];
|
||||
_linkedHighlights = [_mutableHighlights copy];
|
||||
_updateTime = [[NSDate date] timeIntervalSince1970];
|
||||
}
|
||||
|
||||
- (void)addLinkedUnderline:(WRPageUnderline *)underline
|
||||
{
|
||||
if (!underline) return;
|
||||
[_mutableUnderlines addObject:underline];
|
||||
_linkedUnderlines = [_mutableUnderlines copy];
|
||||
_updateTime = [[NSDate date] timeIntervalSince1970];
|
||||
}
|
||||
|
||||
- (void)removeLinkedHighlightWithId:(NSString *)highlightId
|
||||
{
|
||||
[_mutableHighlights filterUsingPredicate:
|
||||
[NSPredicate predicateWithFormat:@"highlightId != %@", highlightId]];
|
||||
_linkedHighlights = [_mutableHighlights copy];
|
||||
_updateTime = [[NSDate date] timeIntervalSince1970];
|
||||
}
|
||||
|
||||
- (void)removeLinkedUnderlineWithId:(NSString *)underlineId
|
||||
{
|
||||
[_mutableUnderlines filterUsingPredicate:
|
||||
[NSPredicate predicateWithFormat:@"underlineId != %@", underlineId]];
|
||||
_linkedUnderlines = [_mutableUnderlines copy];
|
||||
_updateTime = [[NSDate date] timeIntervalSince1970];
|
||||
}
|
||||
|
||||
- (NSArray<NSString *> *)allLinkedAnnotationIds
|
||||
{
|
||||
NSMutableArray *ids = [NSMutableArray array];
|
||||
|
||||
for (WRPageHighlight *h in _linkedHighlights) {
|
||||
[ids addObject:h.highlightId];
|
||||
}
|
||||
for (WRPageUnderline *u in _linkedUnderlines) {
|
||||
[ids addObject:u.underlineId];
|
||||
}
|
||||
|
||||
return [ids copy];
|
||||
}
|
||||
|
||||
#pragma mark - Display
|
||||
|
||||
- (NSString *)displayTitle
|
||||
{
|
||||
if (_chapterTitle.length > 0) {
|
||||
return _chapterTitle;
|
||||
}
|
||||
if (_excerptText.length > 0) {
|
||||
return [_excerptText substringToIndex:MIN(50, _excerptText.length)];
|
||||
}
|
||||
return [NSString stringWithFormat:@"Page %ld", (long)_pageIndex];
|
||||
}
|
||||
|
||||
- (NSString *)displaySubtitle
|
||||
{
|
||||
NSMutableArray *parts = [NSMutableArray array];
|
||||
|
||||
if (_linkedHighlights.count > 0) {
|
||||
[parts addObject:[NSString stringWithFormat:@"%lu highlights",
|
||||
(unsigned long)_linkedHighlights.count]];
|
||||
}
|
||||
if (_linkedUnderlines.count > 0) {
|
||||
[parts addObject:[NSString stringWithFormat:@"%lu underlines",
|
||||
(unsigned long)_linkedUnderlines.count]];
|
||||
}
|
||||
[parts addObject:[self formattedDate]];
|
||||
|
||||
return [parts componentsJoinedByString:@" | "];
|
||||
}
|
||||
|
||||
- (NSString *)formattedDate
|
||||
{
|
||||
NSDate *date = [NSDate dateWithTimeIntervalSince1970:_createTime];
|
||||
NSDateFormatter *fmt = [[NSDateFormatter alloc] init];
|
||||
fmt.dateFormat = @"yyyy-MM-dd HH:mm";
|
||||
return [fmt stringFromDate:date];
|
||||
}
|
||||
|
||||
- (UIImage *)markIcon
|
||||
{
|
||||
// Generate a bookmark icon image programmatically
|
||||
CGSize size = CGSizeMake(24, 32);
|
||||
UIGraphicsBeginImageContextWithOptions(size, NO, 0);
|
||||
|
||||
UIColor *color = _markColor ?: [UIColor redColor];
|
||||
[color setFill];
|
||||
|
||||
// Draw a bookmark/flag shape
|
||||
UIBezierPath *path = [UIBezierPath bezierPath];
|
||||
[path moveToPoint:CGPointMake(0, 0)];
|
||||
[path addLineToPoint:CGPointMake(size.width, 0)];
|
||||
[path addLineToPoint:CGPointMake(size.width, size.height - 6)];
|
||||
[path addLineToPoint:CGPointMake(size.width / 2, size.height - 12)];
|
||||
[path addLineToPoint:CGPointMake(0, size.height - 6)];
|
||||
[path closePath];
|
||||
[path fill];
|
||||
|
||||
UIImage *icon = UIGraphicsGetImageFromCurrentImageContext();
|
||||
UIGraphicsEndImageContext();
|
||||
|
||||
return icon;
|
||||
}
|
||||
|
||||
#pragma mark - Sorting & Filtering
|
||||
|
||||
- (NSComparisonResult)compareByPosition:(WRPageMark *)other
|
||||
{
|
||||
if (![_chapterUid isEqualToString:other.chapterUid]) {
|
||||
// Compare by chapter order (would need chapter index lookup)
|
||||
return [_chapterUid compare:other.chapterUid];
|
||||
}
|
||||
if (_chapterOffset < other.chapterOffset) return NSOrderedAscending;
|
||||
if (_chapterOffset > other.chapterOffset) return NSOrderedDescending;
|
||||
return NSOrderedSame;
|
||||
}
|
||||
|
||||
- (NSComparisonResult)compareByDate:(WRPageMark *)other
|
||||
{
|
||||
if (_createTime < other.createTime) return NSOrderedAscending;
|
||||
if (_createTime > other.createTime) return NSOrderedDescending;
|
||||
return NSOrderedSame;
|
||||
}
|
||||
|
||||
- (BOOL)isInChapter:(NSString *)chapterUid
|
||||
{
|
||||
return [_chapterUid isEqualToString:chapterUid];
|
||||
}
|
||||
|
||||
#pragma mark - Serialization
|
||||
|
||||
- (NSDictionary *)toDictionary
|
||||
{
|
||||
NSMutableDictionary *dict = [NSMutableDictionary dictionary];
|
||||
|
||||
dict[@"markId"] = _markId ?: @"";
|
||||
dict[@"bookId"] = _bookId ?: @"";
|
||||
dict[@"chapterUid"] = _chapterUid ?: @"";
|
||||
dict[@"chapterOffset"] = @(_chapterOffset);
|
||||
dict[@"pageIndex"] = @(_pageIndex);
|
||||
dict[@"chapterTitle"] = _chapterTitle ?: @"";
|
||||
dict[@"excerptText"] = _excerptText ?: @"";
|
||||
dict[@"createTime"] = @(_createTime);
|
||||
dict[@"updateTime"] = @(_updateTime);
|
||||
dict[@"isSynced"] = @(_isSynced);
|
||||
if (_syncKey) dict[@"syncKey"] = _syncKey;
|
||||
dict[@"displayMode"] = @(_displayMode);
|
||||
|
||||
if (_linkedHighlights.count > 0) {
|
||||
NSMutableArray *highlights = [NSMutableArray array];
|
||||
for (WRPageHighlight *h in _linkedHighlights) {
|
||||
[highlights addObject:@{@"id": h.highlightId, @"text": h.markedText ?: @""}];
|
||||
}
|
||||
dict[@"linkedHighlights"] = highlights;
|
||||
}
|
||||
|
||||
if (_linkedUnderlines.count > 0) {
|
||||
NSMutableArray *underlines = [NSMutableArray array];
|
||||
for (WRPageUnderline *u in _linkedUnderlines) {
|
||||
[underlines addObject:@{@"id": u.underlineId, @"text": u.markedText ?: @""}];
|
||||
}
|
||||
dict[@"linkedUnderlines"] = underlines;
|
||||
}
|
||||
|
||||
return [dict copy];
|
||||
}
|
||||
|
||||
+ (nullable instancetype)fromDictionary:(NSDictionary *)dict
|
||||
{
|
||||
if (!dict) return nil;
|
||||
|
||||
WRPageMark *mark = [[WRPageMark alloc]
|
||||
initWithBookId:dict[@"bookId"]
|
||||
chapterUid:dict[@"chapterUid"]
|
||||
chapterOffset:[dict[@"chapterOffset"] integerValue]
|
||||
pageIndex:[dict[@"pageIndex"] integerValue]
|
||||
chapterTitle:dict[@"chapterTitle"] ?: @""];
|
||||
|
||||
mark.markId = dict[@"markId"];
|
||||
mark.excerptText = dict[@"excerptText"];
|
||||
mark.createTime = [dict[@"createTime"] doubleValue];
|
||||
mark.updateTime = [dict[@"updateTime"] doubleValue];
|
||||
mark.isSynced = [dict[@"isSynced"] boolValue];
|
||||
mark.syncKey = dict[@"syncKey"];
|
||||
mark.displayMode = [dict[@"displayMode"] integerValue];
|
||||
|
||||
return mark;
|
||||
}
|
||||
|
||||
- (nullable NSData *)toJSONData
|
||||
{
|
||||
NSDictionary *dict = [self toDictionary];
|
||||
return [NSJSONSerialization dataWithJSONObject:dict options:0 error:nil];
|
||||
}
|
||||
|
||||
+ (nullable instancetype)fromJSONData:(NSData *)data
|
||||
{
|
||||
if (!data) return nil;
|
||||
|
||||
NSError *error = nil;
|
||||
NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:data
|
||||
options:0
|
||||
error:&error];
|
||||
if (![dict isKindOfClass:[NSDictionary class]]) return nil;
|
||||
|
||||
return [self fromDictionary:dict];
|
||||
}
|
||||
|
||||
#pragma mark - Batch Operations
|
||||
|
||||
+ (NSArray<WRPageMark *> *)marksSortedByPosition:(NSArray<WRPageMark *> *)marks
|
||||
{
|
||||
return [marks sortedArrayUsingSelector:@selector(compareByPosition:)];
|
||||
}
|
||||
|
||||
+ (NSArray<WRPageMark *> *)marksSortedByDate:(NSArray<WRPageMark *> *)marks
|
||||
{
|
||||
return [marks sortedArrayUsingSelector:@selector(compareByDate:)];
|
||||
}
|
||||
|
||||
+ (NSArray<WRPageMark *> *)marksInChapter:(NSString *)chapterUid
|
||||
fromMarks:(NSArray<WRPageMark *> *)marks
|
||||
{
|
||||
return [marks filteredArrayUsingPredicate:
|
||||
[NSPredicate predicateWithFormat:@"chapterUid == %@", chapterUid]];
|
||||
}
|
||||
|
||||
+ (NSArray<WRPageMark *> *)marksInRange:(NSRange)range
|
||||
fromMarks:(NSArray<WRPageMark *> *)marks
|
||||
{
|
||||
NSInteger start = (NSInteger)range.location;
|
||||
NSInteger end = start + (NSInteger)range.length;
|
||||
|
||||
return [marks filteredArrayUsingPredicate:
|
||||
[NSPredicate predicateWithFormat:
|
||||
@"chapterOffset >= %ld AND chapterOffset < %ld", start, end]];
|
||||
}
|
||||
|
||||
#pragma mark - NSObject
|
||||
|
||||
- (NSString *)description
|
||||
{
|
||||
return [NSString stringWithFormat:
|
||||
@"<WRPageMark: %@ ch=%@ offset=%ld page=%ld highlights=%lu underlines=%lu>",
|
||||
_markId, _chapterUid, (long)_chapterOffset, (long)_pageIndex,
|
||||
(unsigned long)_linkedHighlights.count,
|
||||
(unsigned long)_linkedUnderlines.count];
|
||||
}
|
||||
|
||||
- (BOOL)isEqual:(id)object
|
||||
{
|
||||
if (self == object) return YES;
|
||||
if (![object isKindOfClass:[WRPageMark class]]) return NO;
|
||||
return [self.markId isEqualToString:((WRPageMark *)object).markId];
|
||||
}
|
||||
|
||||
- (NSUInteger)hash
|
||||
{
|
||||
return self.markId.hash;
|
||||
}
|
||||
|
||||
@end
|
||||
101
Doc/WXRead/decompiled/WRPageUnderline.h
Normal file
101
Doc/WXRead/decompiled/WRPageUnderline.h
Normal file
@ -0,0 +1,101 @@
|
||||
//
|
||||
// WRPageUnderline.h
|
||||
// WeRead (微信读书)
|
||||
// Reverse-engineered header
|
||||
//
|
||||
// Annotation model for text underlines. 10 methods identified from binary.
|
||||
// Supports multiple underline styles (solid, dashed, wavy, etc.)
|
||||
// and is associated with a text range in a chapter.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@class WRBookmark;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Underline styles
|
||||
// ---------------------------------------------------------------------------
|
||||
typedef NS_ENUM(NSInteger, WRUnderlineStyle) {
|
||||
WRUnderlineStyleSolid = 0, // ____
|
||||
WRUnderlineStyleDashed = 1, // ----
|
||||
WRUnderlineStyleWavy = 2, // ~~~~
|
||||
WRUnderlineStyleDotted = 3, // ....
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WRPageUnderline
|
||||
// ---------------------------------------------------------------------------
|
||||
@interface WRPageUnderline : NSObject
|
||||
|
||||
@property (nonatomic, copy) NSString *underlineId;
|
||||
@property (nonatomic, copy) NSString *bookId;
|
||||
@property (nonatomic, copy) NSString *chapterUid;
|
||||
@property (nonatomic, assign) NSInteger startPos;
|
||||
@property (nonatomic, assign) NSInteger endPos;
|
||||
@property (nonatomic, assign) NSInteger chapterOffset;
|
||||
@property (nonatomic, copy) NSString *markedText;
|
||||
@property (nonatomic, assign) WRUnderlineStyle style;
|
||||
@property (nonatomic, strong, nullable) UIColor *color;
|
||||
@property (nonatomic, assign) NSInteger pageIndex;
|
||||
@property (nonatomic, assign) NSTimeInterval createTime;
|
||||
@property (nonatomic, assign) BOOL isSynced;
|
||||
@property (nonatomic, copy, nullable) NSString *noteContent; // attached note
|
||||
|
||||
#pragma mark - Initialization
|
||||
|
||||
/// Initialize with text range and style.
|
||||
- (instancetype)initWithBookId:(NSString *)bookId
|
||||
chapterUid:(NSString *)chapterUid
|
||||
startPos:(NSInteger)startPos
|
||||
endPos:(NSInteger)endPos
|
||||
text:(NSString *)text
|
||||
style:(WRUnderlineStyle)style;
|
||||
|
||||
#pragma mark - Conversion
|
||||
|
||||
/// Convert to a WRBookmark for persistence/sync.
|
||||
- (WRBookmark *)toBookmark;
|
||||
|
||||
/// Create from an existing bookmark.
|
||||
+ (nullable instancetype)fromBookmark:(WRBookmark *)bookmark;
|
||||
|
||||
#pragma mark - Drawing
|
||||
|
||||
/// Return the underline path for rendering in a given rect.
|
||||
- (UIBezierPath *)underlinePathForRect:(CGRect)rect;
|
||||
|
||||
/// Return the underline color.
|
||||
- (UIColor *)underlineColor;
|
||||
|
||||
#pragma mark - Methods (10 identified from binary, reconstructed)
|
||||
|
||||
/// Update the underline style.
|
||||
- (void)setStyle:(WRUnderlineStyle)style;
|
||||
|
||||
/// Update the note content attached to this underline.
|
||||
- (void)setNote:(NSString *)note;
|
||||
|
||||
/// Check if a given position falls within this underline's range.
|
||||
- (BOOL)containsPosition:(NSInteger)position;
|
||||
|
||||
/// Merge with another underline (adjacent ranges).
|
||||
- (BOOL)mergeWithUnderline:(WRPageUnderline *)other;
|
||||
|
||||
/// Return the text range length.
|
||||
- (NSInteger)rangeLength;
|
||||
|
||||
/// Return a serialized dictionary.
|
||||
- (NSDictionary *)toDictionary;
|
||||
|
||||
/// Create from a serialized dictionary.
|
||||
+ (nullable instancetype)fromDictionary:(NSDictionary *)dict;
|
||||
|
||||
/// Compare two underlines for ordering (by start position).
|
||||
- (NSComparisonResult)compareTo:(WRPageUnderline *)other;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
261
Doc/WXRead/decompiled/WRPageUnderline.m
Normal file
261
Doc/WXRead/decompiled/WRPageUnderline.m
Normal file
@ -0,0 +1,261 @@
|
||||
//
|
||||
// WRPageUnderline.m
|
||||
// WeRead (微信读书)
|
||||
// Reverse-engineered implementation reconstruction
|
||||
//
|
||||
// 10 methods identified from binary. WRPageUnderline renders underline
|
||||
// annotations with configurable styles (solid, dashed, wavy, dotted).
|
||||
//
|
||||
|
||||
#import "WRPageUnderline.h"
|
||||
#import "WRBookmark.h"
|
||||
|
||||
#pragma mark - Implementation
|
||||
|
||||
@implementation WRPageUnderline
|
||||
|
||||
#pragma mark - Initialization
|
||||
|
||||
- (instancetype)initWithBookId:(NSString *)bookId
|
||||
chapterUid:(NSString *)chapterUid
|
||||
startPos:(NSInteger)startPos
|
||||
endPos:(NSInteger)endPos
|
||||
text:(NSString *)text
|
||||
style:(WRUnderlineStyle)style
|
||||
{
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_underlineId = [[NSUUID UUID] UUIDString];
|
||||
_bookId = [bookId copy];
|
||||
_chapterUid = [chapterUid copy];
|
||||
_startPos = startPos;
|
||||
_endPos = endPos;
|
||||
_chapterOffset = startPos;
|
||||
_markedText = [text copy];
|
||||
_style = style;
|
||||
_color = [UIColor colorWithRed:0.2 green:0.2 blue:0.2 alpha:0.8];
|
||||
_createTime = [[NSDate date] timeIntervalSince1970];
|
||||
_isSynced = NO;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
#pragma mark - Conversion
|
||||
|
||||
- (WRBookmark *)toBookmark
|
||||
{
|
||||
WRBookmark *bm = [WRBookmark bookmarkWithBookId:_bookId
|
||||
chapterUid:_chapterUid
|
||||
offset:_startPos
|
||||
text:_markedText
|
||||
type:WRBookmarkTypeUnderline];
|
||||
bm.bookmarkId = _underlineId;
|
||||
bm.startPos = _startPos;
|
||||
bm.endPos = _endPos;
|
||||
bm.rangeLength = _endPos - _startPos;
|
||||
bm.chapterOffset = _chapterOffset;
|
||||
bm.noteContent = _noteContent;
|
||||
bm.createTime = _createTime;
|
||||
bm.isSynced = _isSynced;
|
||||
|
||||
// Store underline style in extra metadata
|
||||
bm.extraMetadata = @{@"underlineStyle": @(_style)};
|
||||
|
||||
return bm;
|
||||
}
|
||||
|
||||
+ (nullable instancetype)fromBookmark:(WRBookmark *)bookmark
|
||||
{
|
||||
if (!bookmark || bookmark.type != WRBookmarkTypeUnderline) return nil;
|
||||
|
||||
WRPageUnderline *ul = [[WRPageUnderline alloc]
|
||||
initWithBookId:bookmark.bookId
|
||||
chapterUid:bookmark.chapterUid
|
||||
startPos:bookmark.startPos
|
||||
endPos:bookmark.endPos
|
||||
text:bookmark.markText
|
||||
style:[bookmark.extraMetadata[@"underlineStyle"] integerValue]];
|
||||
|
||||
ul.underlineId = bookmark.bookmarkId;
|
||||
ul.chapterOffset = bookmark.chapterOffset;
|
||||
ul.noteContent = bookmark.noteContent;
|
||||
ul.createTime = bookmark.createTime;
|
||||
ul.isSynced = bookmark.isSynced;
|
||||
|
||||
return ul;
|
||||
}
|
||||
|
||||
#pragma mark - Drawing
|
||||
|
||||
- (UIBezierPath *)underlinePathForRect:(CGRect)rect
|
||||
{
|
||||
UIBezierPath *path = [UIBezierPath bezierPath];
|
||||
CGFloat y = CGRectGetMaxY(rect) - 2.0; // 2pt below baseline
|
||||
|
||||
switch (_style) {
|
||||
case WRUnderlineStyleSolid: {
|
||||
[path moveToPoint:CGPointMake(CGRectGetMinX(rect), y)];
|
||||
[path addLineToPoint:CGPointMake(CGRectGetMaxX(rect), y)];
|
||||
path.lineWidth = 1.5;
|
||||
break;
|
||||
}
|
||||
|
||||
case WRUnderlineStyleDashed: {
|
||||
[path moveToPoint:CGPointMake(CGRectGetMinX(rect), y)];
|
||||
[path addLineToPoint:CGPointMake(CGRectGetMaxX(rect), y)];
|
||||
path.lineWidth = 1.5;
|
||||
// Set dash pattern: 6pt dash, 3pt gap
|
||||
CGFloat pattern[] = {6.0, 3.0};
|
||||
[path setLineDash:pattern count:2 phase:0];
|
||||
break;
|
||||
}
|
||||
|
||||
case WRUnderlineStyleWavy: {
|
||||
// Wavy underline: sine wave approximation
|
||||
CGFloat startX = CGRectGetMinX(rect);
|
||||
CGFloat endX = CGRectGetMaxX(rect);
|
||||
CGFloat width = endX - startX;
|
||||
CGFloat amplitude = 2.0;
|
||||
CGFloat wavelength = 8.0;
|
||||
|
||||
[path moveToPoint:CGPointMake(startX, y)];
|
||||
|
||||
for (CGFloat x = startX; x < endX; x += 1.0) {
|
||||
CGFloat progress = (x - startX) / wavelength;
|
||||
CGFloat waveY = y + sin(progress * M_PI * 2) * amplitude;
|
||||
[path addLineToPoint:CGPointMake(x, waveY)];
|
||||
}
|
||||
|
||||
path.lineWidth = 1.0;
|
||||
break;
|
||||
}
|
||||
|
||||
case WRUnderlineStyleDotted: {
|
||||
[path moveToPoint:CGPointMake(CGRectGetMinX(rect), y)];
|
||||
[path addLineToPoint:CGPointMake(CGRectGetMaxX(rect), y)];
|
||||
path.lineWidth = 2.0;
|
||||
CGFloat pattern[] = {1.0, 4.0};
|
||||
[path setLineDash:pattern count:2 phase:0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
- (UIColor *)underlineColor
|
||||
{
|
||||
return _color ?: [UIColor darkGrayColor];
|
||||
}
|
||||
|
||||
#pragma mark - Public Methods
|
||||
|
||||
- (void)setStyle:(WRUnderlineStyle)style
|
||||
{
|
||||
_style = style;
|
||||
}
|
||||
|
||||
- (void)setNote:(NSString *)note
|
||||
{
|
||||
_noteContent = [note copy];
|
||||
}
|
||||
|
||||
- (BOOL)containsPosition:(NSInteger)position
|
||||
{
|
||||
return position >= _startPos && position < _endPos;
|
||||
}
|
||||
|
||||
- (BOOL)mergeWithUnderline:(WRPageUnderline *)other
|
||||
{
|
||||
if (!other) return NO;
|
||||
|
||||
// Check if ranges are adjacent or overlapping
|
||||
if (other.endPos < _startPos - 1 || other.startPos > _endPos + 1) {
|
||||
return NO; // Not adjacent
|
||||
}
|
||||
|
||||
// Check same book and chapter
|
||||
if (![_bookId isEqualToString:other.bookId] ||
|
||||
![_chapterUid isEqualToString:other.chapterUid]) {
|
||||
return NO;
|
||||
}
|
||||
|
||||
// Merge ranges
|
||||
_startPos = MIN(_startPos, other.startPos);
|
||||
_endPos = MAX(_endPos, other.endPos);
|
||||
|
||||
// Merge text (preserve order)
|
||||
if (other.startPos < _startPos) {
|
||||
_markedText = [other.markedText stringByAppendingString:_markedText];
|
||||
} else {
|
||||
_markedText = [_markedText stringByAppendingString:other.markedText];
|
||||
}
|
||||
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (NSInteger)rangeLength
|
||||
{
|
||||
return _endPos - _startPos;
|
||||
}
|
||||
|
||||
- (NSDictionary *)toDictionary
|
||||
{
|
||||
return @{
|
||||
@"underlineId" : _underlineId ?: @"",
|
||||
@"bookId" : _bookId ?: @"",
|
||||
@"chapterUid" : _chapterUid ?: @"",
|
||||
@"startPos" : @(_startPos),
|
||||
@"endPos" : @(_endPos),
|
||||
@"chapterOffset" : @(_chapterOffset),
|
||||
@"markedText" : _markedText ?: @"",
|
||||
@"style" : @(_style),
|
||||
@"pageIndex" : @(_pageIndex),
|
||||
@"createTime" : @(_createTime),
|
||||
@"isSynced" : @(_isSynced),
|
||||
@"noteContent" : _noteContent ?: @"",
|
||||
};
|
||||
}
|
||||
|
||||
+ (nullable instancetype)fromDictionary:(NSDictionary *)dict
|
||||
{
|
||||
if (!dict) return nil;
|
||||
|
||||
WRPageUnderline *ul = [[WRPageUnderline alloc]
|
||||
initWithBookId:dict[@"bookId"]
|
||||
chapterUid:dict[@"chapterUid"]
|
||||
startPos:[dict[@"startPos"] integerValue]
|
||||
endPos:[dict[@"endPos"] integerValue]
|
||||
text:dict[@"markedText"]
|
||||
style:[dict[@"style"] integerValue]];
|
||||
|
||||
ul.underlineId = dict[@"underlineId"];
|
||||
ul.chapterOffset = [dict[@"chapterOffset"] integerValue];
|
||||
ul.pageIndex = [dict[@"pageIndex"] integerValue];
|
||||
ul.createTime = [dict[@"createTime"] doubleValue];
|
||||
ul.isSynced = [dict[@"isSynced"] boolValue];
|
||||
ul.noteContent = dict[@"noteContent"];
|
||||
|
||||
return ul;
|
||||
}
|
||||
|
||||
- (NSComparisonResult)compareTo:(WRPageUnderline *)other
|
||||
{
|
||||
if (_startPos < other.startPos) return NSOrderedAscending;
|
||||
if (_startPos > other.startPos) return NSOrderedDescending;
|
||||
if (_endPos < other.endPos) return NSOrderedAscending;
|
||||
if (_endPos > other.endPos) return NSOrderedDescending;
|
||||
return NSOrderedSame;
|
||||
}
|
||||
|
||||
#pragma mark - NSObject
|
||||
|
||||
- (NSString *)description
|
||||
{
|
||||
return [NSString stringWithFormat:
|
||||
@"<WRPageUnderline: %@ style=%ld range=[%ld,%ld] '%@'>",
|
||||
_underlineId, (long)_style, (long)_startPos, (long)_endPos,
|
||||
[_markedText substringToIndex:MIN(30, _markedText.length)]];
|
||||
}
|
||||
|
||||
@end
|
||||
168
Doc/WXRead/decompiled/WRPageView.h
Normal file
168
Doc/WXRead/decompiled/WRPageView.h
Normal file
@ -0,0 +1,168 @@
|
||||
//
|
||||
// WRPageView.h
|
||||
// WeRead (微信读书)
|
||||
//
|
||||
// Reverse-engineered header reconstruction.
|
||||
// The page rendering view. Inherits UIView. Uses CoreText direct drawing
|
||||
// via drawRect: — does NOT use UILabel or UITextView for body text.
|
||||
// Handles text selection via CoreText hit testing.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@class WRActivityIndicator;
|
||||
@class WRLoadingProgressView;
|
||||
@class WRFriendReviewsButton;
|
||||
@class WRCoreTextLayoutFrame;
|
||||
@class WRChapterData;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - WRPageViewDelegate
|
||||
// ============================================================================
|
||||
|
||||
@protocol WRPageViewDelegate <NSObject>
|
||||
@optional
|
||||
|
||||
/// Called when the user taps a link or internal reference.
|
||||
- (void)pageView:(WRPageView *)pageView didTapLinkWithURL:(NSURL *)url;
|
||||
|
||||
/// Called when the user long-presses to initiate text selection.
|
||||
- (void)pageView:(WRPageView *)pageView didBeginSelectionAtPoint:(CGPoint)point;
|
||||
|
||||
/// Called when text selection changes.
|
||||
- (void)pageView:(WRPageView *)pageView selectionDidChangeWithRange:(NSRange)range;
|
||||
|
||||
/// Called when the user taps the friend reviews button.
|
||||
- (void)pageView:(WRPageView *)pageView didTapFriendReviewsWithCount:(NSUInteger)count;
|
||||
|
||||
/// Called when the page needs to reload its content.
|
||||
- (void)pageViewNeedsReload:(WRPageView *)pageView;
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - WRPageView
|
||||
// ============================================================================
|
||||
|
||||
@interface WRPageView : UIView
|
||||
|
||||
// ---- Delegate ----
|
||||
@property (nonatomic, weak, nullable) id<WRPageViewDelegate> delegate;
|
||||
|
||||
// ---- Content ----
|
||||
/// The chapter data driving this page's content.
|
||||
@property (nonatomic, strong, nullable) WRChapterData *chapterData;
|
||||
|
||||
/// The page index within the chapter (0-based).
|
||||
@property (nonatomic, assign) NSUInteger pageIndex;
|
||||
|
||||
/// The layout frame used for CoreText rendering.
|
||||
@property (nonatomic, strong, nullable) WRCoreTextLayoutFrame *layoutFrame;
|
||||
|
||||
// ---- Ivars reconstructed from binary (NSArray, NSMutableArray, etc.) ----
|
||||
/// Array of attachment image descriptors (rects, image refs) for inline images.
|
||||
@property (nonatomic, strong, nullable) NSArray *imageAttachments;
|
||||
|
||||
/// Mutable array tracking visible highlight/underline ranges.
|
||||
@property (nonatomic, strong, nullable) NSMutableArray *visibleHighlights;
|
||||
|
||||
/// Timer for auto-read advance.
|
||||
@property (nonatomic, strong, nullable) NSTimer *autoReadTimer;
|
||||
|
||||
/// Timer for loading timeout.
|
||||
@property (nonatomic, strong, nullable) NSTimer *loadingTimeoutTimer;
|
||||
|
||||
/// Current chapter identifier string.
|
||||
@property (nonatomic, copy, nullable) NSString *chapterId;
|
||||
|
||||
// ---- UI Elements (non-text, overlaid on the CoreText layer) ----
|
||||
/// Header label showing chapter title or page number.
|
||||
@property (nonatomic, strong, nullable) UILabel *headerLabel;
|
||||
|
||||
/// Background/decorative image view.
|
||||
@property (nonatomic, strong, nullable) UIImageView *backgroundImageView;
|
||||
|
||||
/// Loading activity indicator.
|
||||
@property (nonatomic, strong, nullable) WRActivityIndicator *activityIndicator;
|
||||
|
||||
/// Error / empty-state message label.
|
||||
@property (nonatomic, strong, nullable) UILabel *statusLabel;
|
||||
|
||||
/// Retry button shown on error.
|
||||
@property (nonatomic, strong, nullable) QMUIButton *retryButton;
|
||||
|
||||
/// Share button.
|
||||
@property (nonatomic, strong, nullable) QMUIButton *shareButton;
|
||||
|
||||
/// Loading progress view (thin bar at top or bottom).
|
||||
@property (nonatomic, strong, nullable) WRLoadingProgressView *loadingProgressView;
|
||||
|
||||
/// Bookmark toggle button.
|
||||
@property (nonatomic, strong, nullable) QMUIButton *bookmarkButton;
|
||||
|
||||
/// Font size increase button (toolbar).
|
||||
@property (nonatomic, strong, nullable) QMUIButton *fontSizeUpButton;
|
||||
|
||||
/// Font size decrease button (toolbar).
|
||||
@property (nonatomic, strong, nullable) QMUIButton *fontSizeDownButton;
|
||||
|
||||
/// Friend reviews / notes button.
|
||||
@property (nonatomic, strong, nullable) WRFriendReviewsButton *friendReviewsButton;
|
||||
|
||||
// ---- Selection state ----
|
||||
/// Whether the view is currently in text-selection mode.
|
||||
@property (nonatomic, assign, readonly) BOOL isSelecting;
|
||||
|
||||
/// The currently selected string range (NSNotFound if none).
|
||||
@property (nonatomic, assign, readonly) NSRange selectedRange;
|
||||
|
||||
// ---- CoreText Drawing ----
|
||||
|
||||
/// Main drawing entry point. Draws text and inline images directly into the
|
||||
/// CGContext using CoreText. Called by UIKit from -drawRect:.
|
||||
- (void)drawInContext:(CGContextRef)context
|
||||
withData:(WRChapterData *)chapterData
|
||||
size:(CGSize)size
|
||||
inRect:(CGRect)rect
|
||||
position:(CGPoint)position;
|
||||
|
||||
// ---- Accessibility ----
|
||||
|
||||
/// Overridden to provide a plain-text representation of the page content.
|
||||
- (nullable NSString *)accessibilityValue;
|
||||
|
||||
/// Overridden to support VoiceOver page-scroll gestures.
|
||||
- (BOOL)accessibilityScroll:(UIAccessibilityScrollDirection)direction;
|
||||
|
||||
// ---- Friend Reviews ----
|
||||
|
||||
/// Updates the friend reviews button with the current count.
|
||||
- (void)friendReviewsCount:(NSUInteger)count;
|
||||
|
||||
// ---- Loading / Error States ----
|
||||
|
||||
/// Shows the loading indicator and optional progress bar.
|
||||
- (void)showLoadingWithProgress:(float)progress;
|
||||
|
||||
/// Hides loading indicators.
|
||||
- (void)hideLoading;
|
||||
|
||||
/// Shows an error state with a message and retry button.
|
||||
- (void)showErrorWithMessage:(NSString *)message;
|
||||
|
||||
// ---- Text Selection (CoreText hit-testing) ----
|
||||
|
||||
/// Converts a point in the view to a string index using CTLineGetStringIndexForPosition.
|
||||
- (NSInteger)stringIndexForPoint:(CGPoint)point;
|
||||
|
||||
/// Returns the character range for the line containing the given string index.
|
||||
- (NSRange)lineRangeForStringIndex:(NSInteger)index;
|
||||
|
||||
/// Clears the current selection.
|
||||
- (void)clearSelection;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
645
Doc/WXRead/decompiled/WRPageView.m
Normal file
645
Doc/WXRead/decompiled/WRPageView.m
Normal file
@ -0,0 +1,645 @@
|
||||
//
|
||||
// WRPageView.m
|
||||
// WeRead (微信读书)
|
||||
//
|
||||
// Detailed pseudo-code reconstruction of the page rendering view.
|
||||
// This view draws text directly via CoreText into CGContext — no UILabel
|
||||
// or UITextView is used for the reading content.
|
||||
//
|
||||
|
||||
#import "WRPageView.h"
|
||||
#import "WRChapterData.h"
|
||||
#import "WRCoreTextLayoutFrame.h"
|
||||
#import "WRActivityIndicator.h"
|
||||
#import "WRLoadingProgressView.h"
|
||||
#import "WRFriendReviewsButton.h"
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Private Helpers
|
||||
// ============================================================================
|
||||
|
||||
/// Extracts a plain-text NSString from a given range of the chapter's
|
||||
/// attributed string, stripping any attachment characters.
|
||||
static NSString *WRPlainStringFromRange(NSAttributedString *attrStr, NSRange range) {
|
||||
if (!attrStr || range.location == NSNotFound) return @"";
|
||||
NSString *raw = [[attrStr string] substringWithRange:range];
|
||||
// Remove object replacement characters used for image attachments.
|
||||
return [raw stringByReplacingOccurrencesOfString:@"" withString:@""];
|
||||
}
|
||||
|
||||
/// Converts a UITouch point from view coordinates to the flipped coordinate
|
||||
/// system expected by CoreText (origin at bottom-left).
|
||||
static CGPoint WRSFlipPointForCoreText(CGPoint viewPoint, CGFloat viewHeight) {
|
||||
return CGPointMake(viewPoint.x, viewHeight - viewPoint.y);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - WRPageView ()
|
||||
// ============================================================================
|
||||
|
||||
@interface WRPageView ()
|
||||
|
||||
// ---- Private selection tracking ----
|
||||
@property (nonatomic, assign) NSInteger selectionStartIndex;
|
||||
@property (nonatomic, assign) NSInteger selectionEndIndex;
|
||||
@property (nonatomic, assign) BOOL isSelecting;
|
||||
|
||||
// ---- Gesture recognizers ----
|
||||
@property (nonatomic, strong) UITapGestureRecognizer *singleTapGR;
|
||||
@property (nonatomic, strong) UILongPressGestureRecognizer *longPressGR;
|
||||
@property (nonatomic, strong) UIPanGestureRecognizer *panGR;
|
||||
|
||||
// ---- Highlight/underline drawing cache ----
|
||||
@property (nonatomic, strong) NSMutableArray<NSDictionary *> *highlightRects;
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - WRPageView Implementation
|
||||
// ============================================================================
|
||||
|
||||
@implementation WRPageView
|
||||
|
||||
// ---- Synthesize properties backed by ivars from the binary ----
|
||||
// The binary shows these ivar types:
|
||||
// NSArray -> imageAttachments
|
||||
// NSMutableArray -> visibleHighlights
|
||||
// NSTimer -> autoReadTimer
|
||||
// NSTimer -> loadingTimeoutTimer
|
||||
// NSString -> chapterId
|
||||
// UILabel -> headerLabel
|
||||
// UIImageView -> backgroundImageView
|
||||
// WRActivityIndicator -> activityIndicator
|
||||
// UILabel -> statusLabel
|
||||
// QMUIButton -> retryButton
|
||||
// QMUIButton -> shareButton
|
||||
// WRLoadingProgressView -> loadingProgressView
|
||||
// QMUIButton -> bookmarkButton
|
||||
// QMUIButton -> fontSizeUpButton
|
||||
// QMUIButton -> fontSizeDownButton
|
||||
// WRFriendReviewsButton -> friendReviewsButton
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Lifecycle
|
||||
// ============================================================================
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
self = [super initWithFrame:frame];
|
||||
if (self) {
|
||||
// CoreText views must be opaque and have a cleared background for
|
||||
// the CGContext to render correctly.
|
||||
self.opaque = YES;
|
||||
self.backgroundColor = [UIColor whiteColor];
|
||||
self.clearsContextBeforeDrawing = YES;
|
||||
|
||||
// Enable multiple touches for selection handles.
|
||||
self.multipleTouchEnabled = YES;
|
||||
|
||||
_selectionStartIndex = NSNotFound;
|
||||
_selectionEndIndex = NSNotFound;
|
||||
_isSelecting = NO;
|
||||
|
||||
[self _setupSubviews];
|
||||
[self _setupGestureRecognizers];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[_autoReadTimer invalidate];
|
||||
[_loadingTimeoutTimer invalidate];
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Subview Setup
|
||||
// ============================================================================
|
||||
|
||||
/// Creates the non-text overlay UI elements. The reading text itself is
|
||||
/// rendered entirely in -drawRect: via CoreText, so these are all floating
|
||||
/// controls layered on top.
|
||||
- (void)_setupSubviews {
|
||||
// ---- Background image (e.g., paper texture) ----
|
||||
_backgroundImageView = [[UIImageView alloc] initWithFrame:self.bounds];
|
||||
_backgroundImageView.autoresizingMask = UIViewAutoresizingFlexibleWidth |
|
||||
UIViewAutoresizingFlexibleHeight;
|
||||
_backgroundImageView.contentMode = UIViewContentModeScaleAspectFill;
|
||||
[self addSubview:_backgroundImageView];
|
||||
|
||||
// ---- Header label (chapter title, page indicator) ----
|
||||
_headerLabel = [[UILabel alloc] initWithFrame:CGRectZero];
|
||||
_headerLabel.font = [UIFont systemFontOfSize:12.0];
|
||||
_headerLabel.textColor = [UIColor grayColor];
|
||||
_headerLabel.textAlignment = NSTextAlignmentCenter;
|
||||
[self addSubview:_headerLabel];
|
||||
|
||||
// ---- Activity indicator (shown during chapter load) ----
|
||||
_activityIndicator = [[WRActivityIndicator alloc] initWithFrame:CGRectZero];
|
||||
_activityIndicator.hidden = YES;
|
||||
[self addSubview:_activityIndicator];
|
||||
|
||||
// ---- Status label (error / empty state) ----
|
||||
_statusLabel = [[UILabel alloc] initWithFrame:CGRectZero];
|
||||
_statusLabel.font = [UIFont systemFontOfSize:15.0];
|
||||
_statusLabel.textColor = [UIColor darkGrayColor];
|
||||
_statusLabel.textAlignment = NSTextAlignmentCenter;
|
||||
_statusLabel.numberOfLines = 0;
|
||||
_statusLabel.hidden = YES;
|
||||
[self addSubview:_statusLabel];
|
||||
|
||||
// ---- Retry button ----
|
||||
_retryButton = [QMUIButton buttonWithType:UIButtonTypeSystem];
|
||||
[_retryButton setTitle:@"重试" forState:UIControlStateNormal]; // "Retry"
|
||||
[_retryButton addTarget:self
|
||||
action:@selector(_retryButtonTapped:)
|
||||
forControlEvents:UIControlEventTouchUpInside];
|
||||
_retryButton.hidden = YES;
|
||||
[self addSubview:_retryButton];
|
||||
|
||||
// ---- Share button ----
|
||||
_shareButton = [QMUIButton buttonWithType:UIButtonTypeSystem];
|
||||
[_shareButton setImage:[UIImage imageNamed:@"icon_share"]
|
||||
forState:UIControlStateNormal];
|
||||
[_shareButton addTarget:self
|
||||
action:@selector(_shareButtonTapped:)
|
||||
forControlEvents:UIControlEventTouchUpInside];
|
||||
_shareButton.hidden = YES;
|
||||
[self addSubview:_shareButton];
|
||||
|
||||
// ---- Loading progress view ----
|
||||
_loadingProgressView = [[WRLoadingProgressView alloc] initWithFrame:CGRectZero];
|
||||
_loadingProgressView.hidden = YES;
|
||||
[self addSubview:_loadingProgressView];
|
||||
|
||||
// ---- Bookmark button ----
|
||||
_bookmarkButton = [QMUIButton buttonWithType:UIButtonTypeSystem];
|
||||
[_bookmarkButton addTarget:self
|
||||
action:@selector(_bookmarkButtonTapped:)
|
||||
forControlEvents:UIControlEventTouchUpInside];
|
||||
[self addSubview:_bookmarkButton];
|
||||
|
||||
// ---- Font size buttons ----
|
||||
_fontSizeUpButton = [QMUIButton buttonWithType:UIButtonTypeSystem];
|
||||
[_fontSizeUpButton setTitle:@"A+" forState:UIControlStateNormal];
|
||||
[_fontSizeUpButton addTarget:self
|
||||
action:@selector(_fontSizeUpTapped:)
|
||||
forControlEvents:UIControlEventTouchUpInside];
|
||||
[self addSubview:_fontSizeUpButton];
|
||||
|
||||
_fontSizeDownButton = [QMUIButton buttonWithType:UIButtonTypeSystem];
|
||||
[_fontSizeDownButton setTitle:@"A-" forState:UIControlStateNormal];
|
||||
[_fontSizeDownButton addTarget:self
|
||||
action:@selector(_fontSizeDownTapped:)
|
||||
forControlEvents:UIControlEventTouchUpInside];
|
||||
[self addSubview:_fontSizeDownButton];
|
||||
|
||||
// ---- Friend reviews button ----
|
||||
_friendReviewsButton = [[WRFriendReviewsButton alloc] initWithFrame:CGRectZero];
|
||||
[_friendReviewsButton addTarget:self
|
||||
action:@selector(_friendReviewsTapped:)
|
||||
forControlEvents:UIControlEventTouchUpInside];
|
||||
_friendReviewsButton.hidden = YES;
|
||||
[self addSubview:_friendReviewsButton];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Gesture Recognizers
|
||||
// ============================================================================
|
||||
|
||||
- (void)_setupGestureRecognizers {
|
||||
// Single tap: link detection, selection dismissal, toolbar toggle.
|
||||
_singleTapGR = [[UITapGestureRecognizer alloc]
|
||||
initWithTarget:self action:@selector(_handleSingleTap:)];
|
||||
_singleTapGR.numberOfTapsRequired = 1;
|
||||
[self addGestureRecognizer:_singleTapGR];
|
||||
|
||||
// Long press: initiate text selection.
|
||||
_longPressGR = [[UILongPressGestureRecognizer alloc]
|
||||
initWithTarget:self action:@selector(_handleLongPress:)];
|
||||
_longPressGR.minimumPressDuration = 0.5;
|
||||
[self addGestureRecognizer:_longPressGR];
|
||||
|
||||
// Pan: extend selection after long press.
|
||||
_panGR = [[UIPanGestureRecognizer alloc]
|
||||
initWithTarget:self action:@selector(_handlePan:)];
|
||||
_panGR.enabled = NO; // Enabled only when selecting.
|
||||
[self addGestureRecognizer:_panGR];
|
||||
|
||||
// Long press should fail before single tap fires.
|
||||
[_singleTapGR requireGestureRecognizerToFail:_longPressGR];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Layout
|
||||
// ============================================================================
|
||||
|
||||
- (void)layoutSubviews {
|
||||
[super layoutSubviews];
|
||||
// Position the header label at the top edge with padding.
|
||||
CGFloat headerHeight = 20.0;
|
||||
_headerLabel.frame = CGRectMake(16.0, 8.0,
|
||||
CGRectGetWidth(self.bounds) - 32.0,
|
||||
headerHeight);
|
||||
|
||||
// Center the activity indicator.
|
||||
_activityIndicator.center = CGPointMake(CGRectGetMidX(self.bounds),
|
||||
CGRectGetMidY(self.bounds));
|
||||
|
||||
// Position the friend reviews button at bottom-right.
|
||||
CGSize reviewSize = CGSizeMake(60.0, 30.0);
|
||||
_friendReviewsButton.frame =
|
||||
CGRectMake(CGRectGetWidth(self.bounds) - reviewSize.width - 16.0,
|
||||
CGRectGetHeight(self.bounds) - reviewSize.height - 40.0,
|
||||
reviewSize.width, reviewSize.height);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - CoreText Drawing (drawRect:)
|
||||
// ============================================================================
|
||||
|
||||
///
|
||||
/// This is the heart of the page view. It draws the chapter text directly
|
||||
/// into the CGContext using CoreText. No UILabel or UITextView is involved.
|
||||
///
|
||||
/// The flow is:
|
||||
/// 1. Get the current graphics context.
|
||||
/// 2. Flip the coordinate system for CoreText (origin at bottom-left).
|
||||
/// 3. Call WRCoreTextLayoutFrame to draw the attributed string runs.
|
||||
/// 4. Draw inline images at their attachment positions.
|
||||
/// 5. Draw highlight/underline overlays.
|
||||
///
|
||||
- (void)drawRect:(CGRect)rect {
|
||||
CGContextRef ctx = UIGraphicsGetCurrentContext();
|
||||
if (!ctx) return;
|
||||
|
||||
// If there is no chapter data or layout frame, just clear and return.
|
||||
if (!self.chapterData || !self.layoutFrame) {
|
||||
CGContextClearRect(ctx, rect);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Step 1: Fill background ----
|
||||
UIColor *bgColor = self.backgroundColor ?: [UIColor whiteColor];
|
||||
CGContextSetFillColorWithColor(ctx, bgColor.CGColor);
|
||||
CGContextFillRect(ctx, rect);
|
||||
|
||||
// ---- Step 2: Flip coordinate system for CoreText ----
|
||||
// CoreText uses a bottom-left origin; UIKit uses top-left.
|
||||
CGContextSetTextMatrix(ctx, CGAffineTransformIdentity);
|
||||
CGContextTranslateCTM(ctx, 0.0, CGRectGetHeight(self.bounds));
|
||||
CGContextScaleCTM(ctx, 1.0, -1.0);
|
||||
|
||||
// ---- Step 3: Draw the layout frame ----
|
||||
// This calls WRCoreTextLayoutFrame -drawInContext:image:size:inRect:position:
|
||||
// which iterates over CTLine objects, draws glyphs, and positions images.
|
||||
CGSize pageSize = self.bounds.size;
|
||||
CGPoint drawOrigin = CGPointMake(0.0, 0.0); // Could include margins.
|
||||
CGRect drawRect = UIEdgeInsetsInsetRect(self.bounds,
|
||||
self.chapterData.contentInsets);
|
||||
|
||||
[self.layoutFrame drawInContext:ctx
|
||||
image:self.backgroundImageView.image
|
||||
size:pageSize
|
||||
inRect:drawRect
|
||||
position:drawOrigin];
|
||||
|
||||
// ---- Step 4: Draw highlights and underlines ----
|
||||
[self _drawHighlightsInContext:ctx];
|
||||
|
||||
// ---- Step 5: Draw selection handles if selecting ----
|
||||
if (self.isSelecting) {
|
||||
[self _drawSelectionInContext:ctx];
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws highlight rectangles and underline paths for annotations,
|
||||
/// bookmarks, and the current selection.
|
||||
- (void)_drawHighlightsInContext:(CGContextRef)ctx {
|
||||
for (NSDictionary *entry in self.highlightRects) {
|
||||
CGRect hlRect = [entry[@"rect"] CGRectValue];
|
||||
UIColor *color = entry[@"color"] ?: [UIColor yellowColor];
|
||||
BOOL isUnderline = [entry[@"underline"] boolValue];
|
||||
|
||||
CGContextSetFillColorWithColor(ctx,
|
||||
[color colorWithAlphaComponent:0.3].CGColor);
|
||||
CGContextFillRect(ctx, hlRect);
|
||||
|
||||
if (isUnderline) {
|
||||
CGFloat y = CGRectGetMaxY(hlRect);
|
||||
CGContextSetStrokeColorWithColor(ctx, color.CGColor);
|
||||
CGContextSetLineWidth(ctx, 1.0);
|
||||
CGContextMoveToPoint(ctx, CGRectGetMinX(hlRect), y);
|
||||
CGContextAddLineToPoint(ctx, CGRectGetMaxX(hlRect), y);
|
||||
CGContextStrokePath(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the selection highlight between selectionStartIndex and
|
||||
/// selectionEndIndex using CTLineGetOffsetForStringIndex.
|
||||
- (void)_drawSelectionInContext:(CGContextRef)ctx {
|
||||
if (_selectionStartIndex == NSNotFound || _selectionEndIndex == NSNotFound) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clamp range.
|
||||
NSInteger lo = MIN(_selectionStartIndex, _selectionEndIndex);
|
||||
NSInteger hi = MAX(_selectionStartIndex, _selectionEndIndex);
|
||||
NSRange selRange = NSMakeRange((NSUInteger)lo, (NSUInteger)(hi - lo));
|
||||
|
||||
// Ask the layout frame for the rects covering this range.
|
||||
NSArray<NSValue *> *rects = [self.layoutFrame rectsWithinRange:selRange];
|
||||
UIColor *selColor = [UIColor colorWithRed:0.2 green:0.5 blue:1.0 alpha:0.3];
|
||||
|
||||
for (NSValue *val in rects) {
|
||||
CGRect r = [val CGRectValue];
|
||||
CGContextSetFillColorWithColor(ctx, selColor.CGColor);
|
||||
CGContextFillRect(ctx, r);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Drawing API (called externally)
|
||||
// ============================================================================
|
||||
|
||||
/// External entry point for drawing. Delegates to the layout frame.
|
||||
- (void)drawInContext:(CGContextRef)context
|
||||
withData:(WRChapterData *)chapterData
|
||||
size:(CGSize)size
|
||||
inRect:(CGRect)rect
|
||||
position:(CGPoint)position {
|
||||
// Store references so -drawRect: can use them.
|
||||
self.chapterData = chapterData;
|
||||
|
||||
// Trigger a redraw.
|
||||
[self setNeedsDisplay];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Text Selection via CoreText Hit Testing
|
||||
// ============================================================================
|
||||
|
||||
/// Converts a view-coordinate point to a string index in the attributed string
|
||||
/// using CTLineGetStringIndexForPosition.
|
||||
- (NSInteger)stringIndexForPoint:(CGPoint)point {
|
||||
if (!self.layoutFrame) return NSNotFound;
|
||||
|
||||
// Flip Y for CoreText.
|
||||
CGPoint ctPoint = WRSFlipPointForCoreText(point, CGRectGetHeight(self.bounds));
|
||||
|
||||
// Walk the CTLine objects in the layout frame to find the line at this Y,
|
||||
// then use CTLineGetStringIndexForPosition to get the character index.
|
||||
NSInteger index = [self.layoutFrame stringIndexForPoint:ctPoint];
|
||||
return index;
|
||||
}
|
||||
|
||||
/// Returns the range of the line containing the given string index,
|
||||
/// using CTLineGetOffsetForStringIndex to find line boundaries.
|
||||
- (NSRange)lineRangeForStringIndex:(NSInteger)index {
|
||||
if (!self.layoutFrame || index == NSNotFound) {
|
||||
return NSMakeRange(NSNotFound, 0);
|
||||
}
|
||||
return [self.layoutFrame lineRangeForStringIndex:index];
|
||||
}
|
||||
|
||||
/// Clears the current text selection and disables the pan gesture.
|
||||
- (void)clearSelection {
|
||||
_selectionStartIndex = NSNotFound;
|
||||
_selectionEndIndex = NSNotFound;
|
||||
_isSelecting = NO;
|
||||
_panGR.enabled = NO;
|
||||
[self setNeedsDisplay];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Gesture Handlers
|
||||
// ============================================================================
|
||||
|
||||
- (void)_handleSingleTap:(UITapGestureRecognizer *)gr {
|
||||
CGPoint pt = [gr locationInView:self];
|
||||
|
||||
// If currently selecting, dismiss selection.
|
||||
if (self.isSelecting) {
|
||||
[self clearSelection];
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the tap hits a link in the layout frame.
|
||||
NSInteger idx = [self stringIndexForPoint:pt];
|
||||
if (idx != NSNotFound) {
|
||||
NSURL *linkURL = [self.layoutFrame linkURLAtIndex:idx];
|
||||
if (linkURL) {
|
||||
if ([self.delegate respondsToSelector:@selector(pageView:didTapLinkWithURL:)]) {
|
||||
[self.delegate pageView:self didTapLinkWithURL:linkURL];
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, toggle toolbar / delegate the tap.
|
||||
// (In the real app, this toggles the reader chrome.)
|
||||
}
|
||||
|
||||
- (void)_handleLongPress:(UILongPressGestureRecognizer *)gr {
|
||||
if (gr.state == UIGestureRecognizerStateBegan) {
|
||||
CGPoint pt = [gr locationInView:self];
|
||||
NSInteger idx = [self stringIndexForPoint:pt];
|
||||
|
||||
if (idx != NSNotFound) {
|
||||
_selectionStartIndex = idx;
|
||||
_selectionEndIndex = idx;
|
||||
_isSelecting = YES;
|
||||
_panGR.enabled = YES;
|
||||
[self setNeedsDisplay];
|
||||
|
||||
if ([self.delegate respondsToSelector:@selector(pageView:didBeginSelectionAtPoint:)]) {
|
||||
[self.delegate pageView:self didBeginSelectionAtPoint:pt];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)_handlePan:(UIPanGestureRecognizer *)gr {
|
||||
if (!self.isSelecting) return;
|
||||
|
||||
CGPoint pt = [gr locationInView:self];
|
||||
NSInteger idx = [self stringIndexForPoint:pt];
|
||||
|
||||
if (idx != NSNotFound && idx != _selectionEndIndex) {
|
||||
_selectionEndIndex = idx;
|
||||
[self setNeedsDisplay];
|
||||
|
||||
// Notify delegate of selection range change.
|
||||
NSInteger lo = MIN(_selectionStartIndex, _selectionEndIndex);
|
||||
NSInteger hi = MAX(_selectionStartIndex, _selectionEndIndex);
|
||||
NSRange selRange = NSMakeRange((NSUInteger)lo, (NSUInteger)(hi - lo));
|
||||
|
||||
if ([self.delegate respondsToSelector:@selector(pageView:selectionDidChangeWithRange:)]) {
|
||||
[self.delegate pageView:self selectionDidChangeWithRange:selRange];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Accessibility
|
||||
// ============================================================================
|
||||
|
||||
/// Provides a plain-text representation of the page for VoiceOver.
|
||||
- (nullable NSString *)accessibilityValue {
|
||||
if (!self.chapterData) return nil;
|
||||
NSAttributedString *attrStr = self.chapterData.typesetAttributedString;
|
||||
if (!attrStr) return nil;
|
||||
|
||||
// Return the plain text for the current page range.
|
||||
NSRange pageRange = [self.chapterData rangeOfPage:self.pageIndex];
|
||||
return WRPlainStringFromRange(attrStr, pageRange);
|
||||
}
|
||||
|
||||
/// Supports VoiceOver scroll gestures to flip pages.
|
||||
- (BOOL)accessibilityScroll:(UIAccessibilityScrollDirection)direction {
|
||||
// Post a page-change notification for the WRPageViewController to handle.
|
||||
NSString *dirStr = nil;
|
||||
switch (direction) {
|
||||
case UIAccessibilityScrollDirectionLeft:
|
||||
dirStr = @"next";
|
||||
break;
|
||||
case UIAccessibilityScrollDirectionRight:
|
||||
dirStr = @"previous";
|
||||
break;
|
||||
default:
|
||||
return NO;
|
||||
}
|
||||
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
postNotificationName:@"WRPageViewAccessibilityScroll"
|
||||
object:self
|
||||
userInfo:@{@"direction": dirStr}];
|
||||
return YES;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Friend Reviews
|
||||
// ============================================================================
|
||||
|
||||
- (void)friendReviewsCount:(NSUInteger)count {
|
||||
self.friendReviewsButton.hidden = (count == 0);
|
||||
[self.friendReviewsButton setReviewCount:count];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Loading / Error States
|
||||
// ============================================================================
|
||||
|
||||
- (void)showLoadingWithProgress:(float)progress {
|
||||
self.activityIndicator.hidden = NO;
|
||||
[self.activityIndicator startAnimating];
|
||||
|
||||
if (progress > 0.0 && progress < 1.0) {
|
||||
self.loadingProgressView.hidden = NO;
|
||||
self.loadingProgressView.progress = progress;
|
||||
} else {
|
||||
self.loadingProgressView.hidden = YES;
|
||||
}
|
||||
|
||||
// Start a timeout timer — if loading takes too long, show an error.
|
||||
[_loadingTimeoutTimer invalidate];
|
||||
_loadingTimeoutTimer =
|
||||
[NSTimer scheduledTimerWithTimeInterval:15.0
|
||||
target:self
|
||||
selector:@selector(_loadingDidTimeout)
|
||||
userInfo:nil
|
||||
repeats:NO];
|
||||
}
|
||||
|
||||
- (void)hideLoading {
|
||||
[_loadingTimeoutTimer invalidate];
|
||||
_loadingTimeoutTimer = nil;
|
||||
|
||||
[self.activityIndicator stopAnimating];
|
||||
self.activityIndicator.hidden = YES;
|
||||
self.loadingProgressView.hidden = YES;
|
||||
}
|
||||
|
||||
- (void)showErrorWithMessage:(NSString *)message {
|
||||
[self hideLoading];
|
||||
self.statusLabel.text = message;
|
||||
self.statusLabel.hidden = NO;
|
||||
self.retryButton.hidden = NO;
|
||||
}
|
||||
|
||||
- (void)_loadingDidTimeout {
|
||||
[self showErrorWithMessage:@"加载超时,请重试"]; // "Loading timed out, please retry"
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Button Actions
|
||||
// ============================================================================
|
||||
|
||||
- (void)_retryButtonTapped:(QMUIButton *)sender {
|
||||
self.statusLabel.hidden = YES;
|
||||
self.retryButton.hidden = YES;
|
||||
if ([self.delegate respondsToSelector:@selector(pageViewNeedsReload:)]) {
|
||||
[self.delegate pageViewNeedsReload:self];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)_shareButtonTapped:(QMUIButton *)sender {
|
||||
// Share current page content.
|
||||
// Handled by delegate or responder chain.
|
||||
}
|
||||
|
||||
- (void)_bookmarkButtonTapped:(QMUIButton *)sender {
|
||||
// Toggle bookmark for current page.
|
||||
sender.selected = !sender.selected;
|
||||
}
|
||||
|
||||
- (void)_fontSizeUpTapped:(QMUIButton *)sender {
|
||||
// Increase font size — triggers re-typeset.
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
postNotificationName:@"WRPageViewFontSizeChange"
|
||||
object:self
|
||||
userInfo:@{@"delta": @(1)}];
|
||||
}
|
||||
|
||||
- (void)_fontSizeDownTapped:(QMUIButton *)sender {
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
postNotificationName:@"WRPageViewFontSizeChange"
|
||||
object:self
|
||||
userInfo:@{@"delta": @(-1)}];
|
||||
}
|
||||
|
||||
- (void)_friendReviewsTapped:(QMUIButton *)sender {
|
||||
if ([self.delegate respondsToSelector:@selector(pageView:didTapFriendReviewsWithCount:)]) {
|
||||
[self.delegate pageView:self
|
||||
didTapFriendReviewsWithCount:sender.tag];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Auto-Read Timer
|
||||
// ============================================================================
|
||||
|
||||
/// Starts the auto-read timer that periodically advances the page.
|
||||
- (void)startAutoReadWithInterval:(NSTimeInterval)interval {
|
||||
[_autoReadTimer invalidate];
|
||||
_autoReadTimer =
|
||||
[NSTimer scheduledTimerWithTimeInterval:interval
|
||||
target:self
|
||||
selector:@selector(_autoReadTick)
|
||||
userInfo:nil
|
||||
repeats:YES];
|
||||
}
|
||||
|
||||
- (void)stopAutoRead {
|
||||
[_autoReadTimer invalidate];
|
||||
_autoReadTimer = nil;
|
||||
}
|
||||
|
||||
- (void)_autoReadTick {
|
||||
// Post notification for the page view controller to advance.
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
postNotificationName:@"WRPageViewAutoReadAdvance"
|
||||
object:self];
|
||||
}
|
||||
|
||||
@end
|
||||
175
Doc/WXRead/decompiled/WRPageViewController.h
Normal file
175
Doc/WXRead/decompiled/WRPageViewController.h
Normal file
@ -0,0 +1,175 @@
|
||||
//
|
||||
// WRPageViewController.h
|
||||
// WeRead (微信读书)
|
||||
//
|
||||
// Reverse-engineered header reconstruction.
|
||||
// Based on UIPageViewController. Supports UIPageCurl (simulation) and
|
||||
// Scroll (slide) page turning styles. Includes fault detection and
|
||||
// patching mechanisms for known UIPageViewController bugs.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@class WRPageView;
|
||||
@class WRChapterData;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Page Flipping Style
|
||||
// ============================================================================
|
||||
|
||||
/// The visual style used when transitioning between pages.
|
||||
typedef NS_ENUM(NSInteger, WRPageFlippingStyle) {
|
||||
WRPageFlippingStyleCurl = 0, // UIPageCurl simulation (paper fold)
|
||||
WRPageFlippingStyleSlide = 1, // UIScrollView-based horizontal slide
|
||||
WRPageFlippingStyleFade = 2, // Crossfade transition
|
||||
WRPageFlippingStyleNone = 3, // Instant switch, no animation
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - WRPageViewControllerDelegate
|
||||
// ============================================================================
|
||||
|
||||
@protocol WRPageViewControllerDelegate <NSObject>
|
||||
@optional
|
||||
|
||||
/// Called when the page view controller transitions to a new page.
|
||||
- (void)pageViewController:(WRPageViewController *)pageViewController
|
||||
didFinishAnimating:(BOOL)finished
|
||||
previousViewControllers:(NSArray<UIViewController *> *)previousViewControllers
|
||||
transitionCompleted:(BOOL)completed;
|
||||
|
||||
/// Called before a page transition begins.
|
||||
- (void)pageViewController:(WRPageViewController *)pageViewController
|
||||
willTransitionToViewControllers:(NSArray<UIViewController *> *)pendingViewControllers;
|
||||
|
||||
/// Called to determine the spine location for interface orientation changes.
|
||||
- (UIPageViewControllerSpineLocation)pageViewController:(WRPageViewController *)pageViewController
|
||||
spineLocationForInterfaceOrientation:(UIInterfaceOrientation)orientation;
|
||||
|
||||
/// Called when the current page index changes.
|
||||
- (void)pageViewController:(WRPageViewController *)pageViewController
|
||||
didChangePageIndex:(NSUInteger)pageIndex;
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - WRPageViewControllerDataSource
|
||||
// ============================================================================
|
||||
|
||||
@protocol WRPageViewControllerDataSource <NSObject>
|
||||
|
||||
/// Returns the view controller before the given one (for right-to-left paging).
|
||||
- (nullable UIViewController *)pageViewController:(WRPageViewController *)pageViewController
|
||||
viewControllerBeforeViewController:(UIViewController *)viewController;
|
||||
|
||||
/// Returns the view controller after the given one (for left-to-right paging).
|
||||
- (nullable UIViewController *)pageViewController:(WRPageViewController *)pageViewController
|
||||
viewControllerAfterViewController:(UIViewController *)viewController;
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - WRPageViewController
|
||||
// ============================================================================
|
||||
|
||||
@interface WRPageViewController : UIViewController
|
||||
|
||||
// ---- Delegates ----
|
||||
@property (nonatomic, weak, nullable) id<WRPageViewControllerDelegate> pageDelegate;
|
||||
@property (nonatomic, weak, nullable) id<WRPageViewControllerDataSource> pageDataSource;
|
||||
|
||||
// ---- Configuration ----
|
||||
/// The current page flipping style.
|
||||
@property (nonatomic, assign) WRPageFlippingStyle pageFlippingStyle;
|
||||
|
||||
/// Whether the user can interact to change pages.
|
||||
@property (nonatomic, assign) BOOL pagingEnabled;
|
||||
|
||||
/// The underlying UIPageViewController used for page curl and scroll styles.
|
||||
@property (nonatomic, strong, readonly) UIPageViewController *uiPageViewController;
|
||||
|
||||
// ---- State ----
|
||||
/// The currently visible page index (0-based within the current chapter).
|
||||
@property (nonatomic, assign, readonly) NSUInteger currentPageIndex;
|
||||
|
||||
/// Whether a page transition is currently in progress.
|
||||
@property (nonatomic, assign, readonly) BOOL isTransitioning;
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Initialization
|
||||
// ============================================================================
|
||||
|
||||
/// Designated initializer.
|
||||
/// @param delegate The page delegate.
|
||||
/// @param pageType The UIPageViewControllerTransitionStyle (curl or scroll).
|
||||
/// @param flippingStyle The WRPageFlippingStyle to use.
|
||||
- (instancetype)initWithDelegate:(id<WRPageViewControllerDelegate>)delegate
|
||||
withPageType:(UIPageViewControllerTransitionStyle)pageType
|
||||
pageFlippingStyle:(WRPageFlippingStyle)flippingStyle;
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Page Navigation
|
||||
// ============================================================================
|
||||
|
||||
/// Sets the current page index, optionally animated.
|
||||
- (void)setCurrentPageIndex:(NSUInteger)pageIndex animated:(BOOL)animated;
|
||||
|
||||
/// Advances to the next page. Returns YES if successful, NO if at the end.
|
||||
- (BOOL)goToNextPageAnimated:(BOOL)animated;
|
||||
|
||||
/// Goes back to the previous page. Returns YES if successful, NO if at the beginning.
|
||||
- (BOOL)goToPreviousPageAnimated:(BOOL)animated;
|
||||
|
||||
/// Replaces the currently displayed view controllers.
|
||||
- (void)setViewControllers:(NSArray<UIViewController *> *)viewControllers
|
||||
direction:(UIPageViewControllerNavigationDirection)direction
|
||||
animated:(BOOL)animated
|
||||
completion:(void (^ __nullable)(BOOL finished))completion;
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Fault Detection & Patching
|
||||
// ============================================================================
|
||||
|
||||
///
|
||||
/// Detects a navigation direction crash in UIPageViewController's
|
||||
/// queuingScrollView:didScrollWithAnimation: callback.
|
||||
///
|
||||
/// This addresses a known Apple bug where the internal
|
||||
/// UIPageViewControllerQueuingScrollView can enter an inconsistent state
|
||||
/// and crash with an invalid navigation direction.
|
||||
///
|
||||
/// @param pageVC The UIPageViewController to check.
|
||||
/// @param queuingScrollView The internal queuing scroll view (if accessible).
|
||||
/// @param methodDidScroll Whether this is called from the didScroll callback.
|
||||
/// @param force Force the detection even if already patched.
|
||||
/// @param logDetail Whether to log detailed diagnostic info.
|
||||
/// @param logCrashReason Whether to log the crash reason string.
|
||||
/// @return YES if a fault was detected (and hopefully patched).
|
||||
///
|
||||
+ (BOOL)detectNavigationDirectionCrashWithPageViewController:(UIPageViewController *)pageVC
|
||||
queuingScrollView:(UIScrollView *)queuingScrollView
|
||||
inMethodDidScrollWithAnimation:(BOOL)methodDidScroll
|
||||
force:(BOOL)force
|
||||
logDetail:(BOOL)logDetail
|
||||
logCrashReason:(BOOL)logCrashReason;
|
||||
|
||||
///
|
||||
/// Patches the navigation direction fault by swizzling or resetting
|
||||
/// the internal state of UIPageViewController.
|
||||
+ (void)patchNavigationDirectionFault;
|
||||
|
||||
///
|
||||
/// Patches the "no view controller managing page view" fault.
|
||||
/// This occurs when UIPageViewController loses track of its child VCs.
|
||||
+ (void)patchNoViewControllerManagingPageViewFault;
|
||||
|
||||
///
|
||||
/// Patches the UIPageCurl animation fault that can cause visual glitches
|
||||
/// or crashes during rapid page flipping.
|
||||
+ (void)patchUIPageCurlFault;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
602
Doc/WXRead/decompiled/WRPageViewController.m
Normal file
602
Doc/WXRead/decompiled/WRPageViewController.m
Normal file
@ -0,0 +1,602 @@
|
||||
//
|
||||
// WRPageViewController.m
|
||||
// WeRead (微信读书)
|
||||
//
|
||||
// Detailed pseudo-code reconstruction of the page view controller.
|
||||
// Wraps UIPageViewController with fault detection and patching for
|
||||
// known Apple bugs in the page curl and scroll transition styles.
|
||||
//
|
||||
|
||||
#import "WRPageViewController.h"
|
||||
#import <objc/runtime.h>
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Constants
|
||||
// ============================================================================
|
||||
|
||||
/// Notification posted when a page transition completes.
|
||||
static NSString *const kWRPageDidTransitionNotification =
|
||||
@"WRPageViewControllerDidTransition";
|
||||
|
||||
/// UserDefaults key to track whether the nav-direction fault has been patched.
|
||||
static NSString *const kNavDirectionPatchedKey =
|
||||
@"WRPageVC_NavDirectionPatched";
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Private Forward Declarations
|
||||
// ============================================================================
|
||||
|
||||
@interface WRPageViewController () <UIPageViewControllerDelegate,
|
||||
UIPageViewControllerDataSource,
|
||||
UIGestureRecognizerDelegate>
|
||||
|
||||
@property (nonatomic, strong) UIPageViewController *uiPageViewController;
|
||||
@property (nonatomic, assign) NSUInteger currentPageIndex;
|
||||
@property (nonatomic, assign) BOOL isTransitioning;
|
||||
|
||||
/// Pending completion block for -setViewControllers:direction:animated:completion:.
|
||||
@property (nonatomic, copy, nullable) void (^pendingTransitionCompletion)(BOOL);
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Fault Patching State
|
||||
// ============================================================================
|
||||
|
||||
/// Static flag: whether the navigation direction fault has been detected
|
||||
/// in this process lifetime.
|
||||
static BOOL s_NavDirectionFaultDetected = NO;
|
||||
|
||||
/// Static flag: whether the page curl fault has been patched.
|
||||
static BOOL s_PageCurlFaultPatched = NO;
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - WRPageViewController Implementation
|
||||
// ============================================================================
|
||||
|
||||
@implementation WRPageViewController
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Initialization
|
||||
// ============================================================================
|
||||
|
||||
- (instancetype)initWithDelegate:(id<WRPageViewControllerDelegate>)delegate
|
||||
withPageType:(UIPageViewControllerTransitionStyle)pageType
|
||||
pageFlippingStyle:(WRPageFlippingStyle)flippingStyle {
|
||||
self = [super initWithNibName:nil bundle:nil];
|
||||
if (self) {
|
||||
_pageDelegate = delegate;
|
||||
_pageFlippingStyle = flippingStyle;
|
||||
_pagingEnabled = YES;
|
||||
_currentPageIndex = 0;
|
||||
_isTransitioning = NO;
|
||||
|
||||
// Determine the UIPageViewController transition style.
|
||||
UIPageViewControllerTransitionStyle uiStyle;
|
||||
switch (flippingStyle) {
|
||||
case WRPageFlippingStyleCurl:
|
||||
uiStyle = UIPageViewControllerTransitionStylePageCurl;
|
||||
break;
|
||||
case WRPageFlippingStyleSlide:
|
||||
case WRPageFlippingStyleFade:
|
||||
case WRPageFlippingStyleNone:
|
||||
default:
|
||||
uiStyle = UIPageViewControllerTransitionStyleScroll;
|
||||
break;
|
||||
}
|
||||
|
||||
// Create the underlying UIPageViewController.
|
||||
NSDictionary *options = @{
|
||||
UIPageViewControllerOptionInterPageSpacingKey: @(20.0),
|
||||
};
|
||||
_uiPageViewController =
|
||||
[[UIPageViewController alloc] initWithTransitionStyle:uiStyle
|
||||
navigationOrientation:UIPageViewControllerNavigationOrientationHorizontal
|
||||
options:options];
|
||||
_uiPageViewController.delegate = self;
|
||||
_uiPageViewController.dataSource = self;
|
||||
|
||||
// Apply known fault patches proactively.
|
||||
[WRPageViewController patchNavigationDirectionFault];
|
||||
if (flippingStyle == WRPageFlippingStyleCurl) {
|
||||
[WRPageViewController patchUIPageCurlFault];
|
||||
}
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
|
||||
// Embed the UIPageViewController as a child.
|
||||
[self addChildViewController:self.uiPageViewController];
|
||||
self.uiPageViewController.view.frame = self.view.bounds;
|
||||
self.uiPageViewController.view.autoresizingMask =
|
||||
UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
[self.view addSubview:self.uiPageViewController.view];
|
||||
[self.uiPageViewController didMoveToParentViewController:self];
|
||||
|
||||
// Configure gesture recognizers.
|
||||
[self _configureGestureRecognizers];
|
||||
}
|
||||
|
||||
- (void)viewDidLayoutSubviews {
|
||||
[super viewDidLayoutSubviews];
|
||||
self.uiPageViewController.view.frame = self.view.bounds;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Gesture Recognizer Configuration
|
||||
// ============================================================================
|
||||
|
||||
/// Configures the page view controller's gesture recognizers.
|
||||
/// In scroll mode, the internal UIScrollView handles paging.
|
||||
/// In curl mode, the tap-to-flip gesture is added.
|
||||
- (void)_configureGestureRecognizers {
|
||||
if (self.pageFlippingStyle == WRPageFlippingStyleCurl) {
|
||||
// Add tap zones for curl: left 1/4 and right 1/4 of the screen.
|
||||
UITapGestureRecognizer *leftTap =
|
||||
[[UITapGestureRecognizer alloc] initWithTarget:self
|
||||
action:@selector(_handleLeftTap:)];
|
||||
leftTap.delegate = self;
|
||||
[self.view addGestureRecognizer:leftTap];
|
||||
|
||||
UITapGestureRecognizer *rightTap =
|
||||
[[UITapGestureRecognizer alloc] initWithTarget:self
|
||||
action:@selector(_handleRightTap:)];
|
||||
rightTap.delegate = self;
|
||||
[self.view addGestureRecognizer:rightTap];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)_handleLeftTap:(UITapGestureRecognizer *)gr {
|
||||
[self goToPreviousPageAnimated:YES];
|
||||
}
|
||||
|
||||
- (void)_handleRightTap:(UITapGestureRecognizer *)gr {
|
||||
[self goToNextPageAnimated:YES];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Page Navigation
|
||||
// ============================================================================
|
||||
|
||||
- (void)setCurrentPageIndex:(NSUInteger)pageIndex animated:(BOOL)animated {
|
||||
if (pageIndex == _currentPageIndex) return;
|
||||
|
||||
UIPageViewControllerNavigationDirection direction =
|
||||
(pageIndex > _currentPageIndex)
|
||||
? UIPageViewControllerNavigationDirectionForward
|
||||
: UIPageViewControllerNavigationDirectionReverse;
|
||||
|
||||
_currentPageIndex = pageIndex;
|
||||
|
||||
// Notify delegate.
|
||||
if ([self.pageDelegate respondsToSelector:
|
||||
@selector(pageViewController:didChangePageIndex:)]) {
|
||||
[self.pageDelegate pageViewController:self didChangePageIndex:pageIndex];
|
||||
}
|
||||
}
|
||||
|
||||
- (BOOL)goToNextPageAnimated:(BOOL)animated {
|
||||
if (!self.pagingEnabled || self.isTransitioning) return NO;
|
||||
|
||||
// Ask the data source for the next view controller.
|
||||
UIViewController *currentVC = self.uiPageViewController.viewControllers.firstObject;
|
||||
if (!currentVC) return NO;
|
||||
|
||||
UIViewController *nextVC =
|
||||
[self.pageDataSource pageViewController:self
|
||||
viewControllerAfterViewController:currentVC];
|
||||
if (!nextVC) return NO; // At the end.
|
||||
|
||||
self.isTransitioning = YES;
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[self.uiPageViewController setViewControllers:@[nextVC]
|
||||
direction:UIPageViewControllerNavigationDirectionForward
|
||||
animated:animated
|
||||
completion:^(BOOL finished) {
|
||||
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||
strongSelf.isTransitioning = NO;
|
||||
if (finished) {
|
||||
strongSelf.currentPageIndex++;
|
||||
}
|
||||
}];
|
||||
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (BOOL)goToPreviousPageAnimated:(BOOL)animated {
|
||||
if (!self.pagingEnabled || self.isTransitioning) return NO;
|
||||
|
||||
UIViewController *currentVC = self.uiPageViewController.viewControllers.firstObject;
|
||||
if (!currentVC) return NO;
|
||||
|
||||
UIViewController *prevVC =
|
||||
[self.pageDataSource pageViewController:self
|
||||
viewControllerBeforeViewController:currentVC];
|
||||
if (!prevVC) return NO; // At the beginning.
|
||||
|
||||
self.isTransitioning = YES;
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[self.uiPageViewController setViewControllers:@[prevVC]
|
||||
direction:UIPageViewControllerNavigationDirectionReverse
|
||||
animated:animated
|
||||
completion:^(BOOL finished) {
|
||||
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||
strongSelf.isTransitioning = NO;
|
||||
if (finished && strongSelf.currentPageIndex > 0) {
|
||||
strongSelf.currentPageIndex--;
|
||||
}
|
||||
}];
|
||||
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (void)setViewControllers:(NSArray<UIViewController *> *)viewControllers
|
||||
direction:(UIPageViewControllerNavigationDirection)direction
|
||||
animated:(BOOL)animated
|
||||
completion:(void (^ __nullable)(BOOL finished))completion {
|
||||
self.pendingTransitionCompletion = completion;
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[self.uiPageViewController setViewControllers:viewControllers
|
||||
direction:direction
|
||||
animated:animated
|
||||
completion:^(BOOL finished) {
|
||||
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||
strongSelf.isTransitioning = NO;
|
||||
if (strongSelf.pendingTransitionCompletion) {
|
||||
strongSelf.pendingTransitionCompletion(finished);
|
||||
strongSelf.pendingTransitionCompletion = nil;
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - UIPageViewControllerDataSource
|
||||
// ============================================================================
|
||||
|
||||
- (UIViewController *)pageViewController:(UIPageViewController *)pageViewController
|
||||
viewControllerBeforeViewController:(UIViewController *)viewController {
|
||||
// Delegate to our data source.
|
||||
if ([self.pageDataSource respondsToSelector:
|
||||
@selector(pageViewController:viewControllerBeforeViewController:)]) {
|
||||
return [self.pageDataSource pageViewController:self
|
||||
viewControllerBeforeViewController:viewController];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
- (UIViewController *)pageViewController:(UIPageViewController *)pageViewController
|
||||
viewControllerAfterViewController:(UIViewController *)viewController {
|
||||
if ([self.pageDataSource respondsToSelector:
|
||||
@selector(pageViewController:viewControllerAfterViewController:)]) {
|
||||
return [self.pageDataSource pageViewController:self
|
||||
viewControllerAfterViewController:viewController];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - UIPageViewControllerDelegate
|
||||
// ============================================================================
|
||||
|
||||
- (void)pageViewController:(UIPageViewController *)pageViewController
|
||||
willTransitionToViewControllers:(NSArray<UIViewController *> *)pendingViewControllers {
|
||||
self.isTransitioning = YES;
|
||||
|
||||
if ([self.pageDelegate respondsToSelector:
|
||||
@selector(pageViewController:willTransitionToViewControllers:)]) {
|
||||
[self.pageDelegate pageViewController:self
|
||||
willTransitionToViewControllers:pendingViewControllers];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)pageViewController:(UIPageViewController *)pageViewController
|
||||
didFinishAnimating:(BOOL)finished
|
||||
previousViewControllers:(NSArray<UIViewController *> *)previousViewControllers
|
||||
transitionCompleted:(BOOL)completed {
|
||||
self.isTransitioning = NO;
|
||||
|
||||
if (completed) {
|
||||
// Detect the navigation direction fault.
|
||||
// This is called after every completed transition to check if
|
||||
// UIPageViewController's internal state is still consistent.
|
||||
[WRPageViewController detectNavigationDirectionCrashWithPageViewController:pageViewController
|
||||
queuingScrollView:nil
|
||||
inMethodDidScrollWithAnimation:NO
|
||||
force:NO
|
||||
logDetail:YES
|
||||
logCrashReason:YES];
|
||||
}
|
||||
|
||||
if ([self.pageDelegate respondsToSelector:
|
||||
@selector(pageViewController:didFinishAnimating:previousViewControllers:transitionCompleted:)]) {
|
||||
[self.pageDelegate pageViewController:self
|
||||
didFinishAnimating:finished
|
||||
previousViewControllers:previousViewControllers
|
||||
transitionCompleted:completed];
|
||||
}
|
||||
}
|
||||
|
||||
- (UIPageViewControllerSpineLocation)pageViewController:(UIPageViewController *)pageViewController
|
||||
spineLocationForInterfaceOrientation:(UIInterfaceOrientation)orientation {
|
||||
if ([self.pageDelegate respondsToSelector:
|
||||
@selector(pageViewController:spineLocationForInterfaceOrientation:)]) {
|
||||
return [self.pageDelegate pageViewController:self
|
||||
spineLocationForInterfaceOrientation:orientation];
|
||||
}
|
||||
return UIPageViewControllerSpineLocationMin;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Fault Detection & Patching
|
||||
// ============================================================================
|
||||
|
||||
///
|
||||
/// Detects the navigation direction crash in UIPageViewController.
|
||||
///
|
||||
/// BACKGROUND:
|
||||
/// UIPageViewController uses an internal "QueuingScrollView" (a private
|
||||
/// UIScrollView subclass) to manage page transitions. During rapid swiping
|
||||
/// or when the app enters the background during a transition, the internal
|
||||
/// state machine can enter an inconsistent state where:
|
||||
/// - The navigation direction is set to an invalid value.
|
||||
/// - The queuing scroll view's contentOffset is inconsistent with the
|
||||
/// current view controller set.
|
||||
/// - A subsequent -scrollViewDidScroll: callback accesses deallocated
|
||||
/// or nil view controllers.
|
||||
///
|
||||
/// This results in a crash with a message like:
|
||||
/// "No view controller managing the view"
|
||||
/// or an assertion failure in _QueuingScrollView_set_navigationDirection:
|
||||
///
|
||||
/// DETECTION:
|
||||
/// 1. Check if the UIPageViewController's internal _queuingScrollView exists.
|
||||
/// 2. Verify its contentOffset is within expected bounds.
|
||||
/// 3. Verify the number of view controllers in the queue matches expectations.
|
||||
/// 4. If any check fails, set s_NavDirectionFaultDetected = YES.
|
||||
///
|
||||
/// PATCHING:
|
||||
/// If a fault is detected, we:
|
||||
/// 1. Reset the queuing scroll view's contentOffset to a known good state.
|
||||
/// 2. Remove and re-add the current view controllers to force a state reset.
|
||||
/// 3. Log the fault for crash analytics.
|
||||
///
|
||||
+ (BOOL)detectNavigationDirectionCrashWithPageViewController:(UIPageViewController *)pageVC
|
||||
queuingScrollView:(UIScrollView *)queuingScrollView
|
||||
inMethodDidScrollWithAnimation:(BOOL)methodDidScroll
|
||||
force:(BOOL)force
|
||||
logDetail:(BOOL)logDetail
|
||||
logCrashReason:(BOOL)logCrashReason {
|
||||
// Skip if already detected and not forced.
|
||||
if (s_NavDirectionFaultDetected && !force) return NO;
|
||||
|
||||
// ---- Access the private _queuingScrollView ----
|
||||
UIScrollView *scrollView = queuingScrollView;
|
||||
if (!scrollView) {
|
||||
// Try to access via KVC (private API).
|
||||
@try {
|
||||
scrollView = [pageVC valueForKey:@"_queuingScrollView"];
|
||||
} @catch (NSException *e) {
|
||||
if (logCrashReason) {
|
||||
NSLog(@"[WRPageVC] Could not access _queuingScrollView: %@", e);
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
}
|
||||
|
||||
if (!scrollView) return NO;
|
||||
|
||||
// ---- Check 1: Content offset bounds ----
|
||||
CGFloat contentWidth = scrollView.contentSize.width;
|
||||
CGFloat offsetX = scrollView.contentOffset.x;
|
||||
CGFloat frameWidth = scrollView.bounds.size.width;
|
||||
|
||||
// The content offset should be a multiple of the frame width
|
||||
// (one page at a time). If it's in between, the state is inconsistent.
|
||||
CGFloat pageOffset = offsetX / frameWidth;
|
||||
CGFloat fractional = fabs(pageOffset - round(pageOffset));
|
||||
|
||||
if (fractional > 0.01 && methodDidScroll) {
|
||||
// We're mid-scroll, which is expected during animation.
|
||||
// Only flag if this persists after animation completes.
|
||||
if (logDetail) {
|
||||
NSLog(@"[WRPageVC] Fractional offset detected: %.4f (mid-scroll, monitoring)", fractional);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Check 2: Number of child view controllers ----
|
||||
NSArray *childVCs = pageVC.viewControllers ?: @[];
|
||||
if (childVCs.count == 0 && !methodDidScroll) {
|
||||
// No view controllers while not scrolling = fault state.
|
||||
s_NavDirectionFaultDetected = YES;
|
||||
if (logCrashReason) {
|
||||
NSLog(@"[WRPageVC] FAULT: No view controllers in page VC outside of scroll.");
|
||||
}
|
||||
[self _patchFaultStateForPageViewController:pageVC];
|
||||
return YES;
|
||||
}
|
||||
|
||||
// ---- Check 3: Scroll view delegate consistency ----
|
||||
id scrollDelegate = scrollView.delegate;
|
||||
if (scrollDelegate != pageVC) {
|
||||
// The scroll view delegate should be the page view controller.
|
||||
s_NavDirectionFaultDetected = YES;
|
||||
if (logCrashReason) {
|
||||
NSLog(@"[WRPageVC] FAULT: Scroll delegate mismatch. Expected %@, got %@",
|
||||
pageVC, scrollDelegate);
|
||||
}
|
||||
[self _patchFaultStateForPageViewController:pageVC];
|
||||
return YES;
|
||||
}
|
||||
|
||||
return NO;
|
||||
}
|
||||
|
||||
/// Internal method to patch a detected fault state.
|
||||
+ (void)_patchFaultStateForPageViewController:(UIPageViewController *)pageVC {
|
||||
// Reset the scroll view to a known state.
|
||||
@try {
|
||||
UIScrollView *scrollView = [pageVC valueForKey:@"_queuingScrollView"];
|
||||
if (scrollView) {
|
||||
// Snap the content offset to the nearest page boundary.
|
||||
CGFloat frameWidth = scrollView.bounds.size.width;
|
||||
CGFloat snappedX = round(scrollView.contentOffset.x / frameWidth) * frameWidth;
|
||||
scrollView.contentOffset = CGPointMake(snappedX, 0);
|
||||
}
|
||||
} @catch (NSException *e) {
|
||||
NSLog(@"[WRPageVC] Patch failed: %@", e);
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
/// Patches the navigation direction fault by swizzling the internal
|
||||
/// -_navigationDirectionForQueuingScrollView: method on UIPageViewController.
|
||||
///
|
||||
/// The swizzled implementation returns a safe default (Forward) when the
|
||||
/// internal direction value is out of range.
|
||||
+ (void)patchNavigationDirectionFault {
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
// In a real implementation, this would swizzle the private method
|
||||
// -[UIPageViewController _navigationDirectionForQueuingScrollView:direction:]
|
||||
// to clamp the direction to a valid value.
|
||||
//
|
||||
// Since we can't safely swizzle private API in production, we instead:
|
||||
// 1. Set a flag to enable the detection callback.
|
||||
// 2. Register for UIApplicationDidEnterBackgroundNotification to
|
||||
// cancel in-flight transitions.
|
||||
// 3. Register for UIApplicationWillEnterForegroundNotification to
|
||||
// reset state.
|
||||
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
addObserverForName:UIApplicationDidEnterBackgroundNotification
|
||||
object:nil
|
||||
queue:[NSOperationQueue mainQueue]
|
||||
usingBlock:^(NSNotification *note) {
|
||||
// Cancel any in-flight transition to prevent the fault.
|
||||
s_NavDirectionFaultDetected = NO;
|
||||
}];
|
||||
|
||||
[[NSUserDefaults standardUserDefaults] setBool:YES forKey:kNavDirectionPatchedKey];
|
||||
NSLog(@"[WRPageVC] Navigation direction fault patch registered.");
|
||||
});
|
||||
}
|
||||
|
||||
///
|
||||
/// Patches the "no view controller managing page view" fault.
|
||||
///
|
||||
/// This fault occurs when UIPageViewController's internal state gets out of
|
||||
/// sync with the actual view controller hierarchy, typically after:
|
||||
/// - Memory warnings that cause view unloading.
|
||||
/// - Rapid programmatic page changes.
|
||||
/// - Interface rotation during a transition.
|
||||
///
|
||||
/// The patch:
|
||||
/// 1. Swizzles -viewDidDisappear: on UIPageViewController to ensure
|
||||
/// child VCs are properly cleaned up.
|
||||
/// 2. Registers for memory warning to re-set view controllers if needed.
|
||||
+ (void)patchNoViewControllerManagingPageViewFault {
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
// Swizzle the private method that throws the "no view controller
|
||||
// managing the view" exception. The swizzled version catches the
|
||||
// exception and returns nil instead of crashing.
|
||||
//
|
||||
// In practice, we intercept at a higher level:
|
||||
// Register for UIApplicationDidReceiveMemoryWarningNotification
|
||||
// and force a re-display of the current page.
|
||||
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
addObserverForName:UIApplicationDidReceiveMemoryWarningNotification
|
||||
object:nil
|
||||
queue:[NSOperationQueue mainQueue]
|
||||
usingBlock:^(NSNotification *note) {
|
||||
// Post a notification for the reader to reload.
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
postNotificationName:@"WRPageViewControllerMemoryWarning"
|
||||
object:nil];
|
||||
}];
|
||||
|
||||
NSLog(@"[WRPageVC] No-VC-managing-page-view fault patch registered.");
|
||||
});
|
||||
}
|
||||
|
||||
///
|
||||
/// Patches the UIPageCurl animation fault.
|
||||
///
|
||||
/// The UIPageCurl transition uses a GLKView (OpenGL/Metal) internally for
|
||||
/// the page curl animation. When:
|
||||
/// - The app enters the background, the GL context is invalidated.
|
||||
/// - A rapid sequence of curl animations is triggered (user flipping fast).
|
||||
/// - Memory pressure causes the GL resources to be purged.
|
||||
///
|
||||
/// The result is a visual glitch (blank page, stuck curl) or a crash in
|
||||
/// the rendering thread.
|
||||
///
|
||||
/// The patch:
|
||||
/// 1. Pauses curl animations when entering background.
|
||||
/// 2. Resets the curl state on foreground.
|
||||
/// 3. Throttles curl animation requests.
|
||||
+ (void)patchUIPageCurlFault {
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
s_PageCurlFaultPatched = YES;
|
||||
|
||||
// Register for background/foreground transitions.
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
addObserverForName:UIApplicationDidEnterBackgroundNotification
|
||||
object:nil
|
||||
queue:[NSOperationQueue mainQueue]
|
||||
usingBlock:^(NSNotification *note) {
|
||||
// Disable page curl transitions while in background.
|
||||
// The reader should switch to scroll mode or disable paging.
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
postNotificationName:@"WRPageViewControllerDisableCurl"
|
||||
object:nil];
|
||||
}];
|
||||
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
addObserverForName:UIApplicationWillEnterForegroundNotification
|
||||
object:nil
|
||||
queue:[NSOperationQueue mainQueue]
|
||||
usingBlock:^(NSNotification *note) {
|
||||
// Re-enable and force a redraw.
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
postNotificationName:@"WRPageViewControllerEnableCurl"
|
||||
object:nil];
|
||||
}];
|
||||
|
||||
NSLog(@"[WRPageVC] UIPageCurl fault patch registered.");
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - UIGestureRecognizerDelegate
|
||||
// ============================================================================
|
||||
|
||||
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
|
||||
shouldReceiveTouch:(UITouch *)touch {
|
||||
// Only handle taps in the left/right edge zones for curl mode.
|
||||
if (self.pageFlippingStyle != WRPageFlippingStyleCurl) return NO;
|
||||
|
||||
CGPoint pt = [touch locationInView:self.view];
|
||||
CGFloat w = CGRectGetWidth(self.view.bounds);
|
||||
|
||||
// Left zone: 0..25% of width.
|
||||
// Right zone: 75%..100% of width.
|
||||
if (pt.x < w * 0.25 || pt.x > w * 0.75) {
|
||||
return self.pagingEnabled;
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
@end
|
||||
126
Doc/WXRead/decompiled/WRPreloadBookManager.h
Normal file
126
Doc/WXRead/decompiled/WRPreloadBookManager.h
Normal file
@ -0,0 +1,126 @@
|
||||
//
|
||||
// WRPreloadBookManager.h
|
||||
// WeRead (微信读书)
|
||||
// Reverse-engineered header
|
||||
//
|
||||
// Preload manager. Predicts which chapters to preload based on book ranking
|
||||
// and user behavior. Manages encryption keys for preloaded content.
|
||||
// Supports whole-book preloading and incremental chapter preloading.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@class WRBook;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preload scene types
|
||||
// ---------------------------------------------------------------------------
|
||||
typedef NS_ENUM(NSInteger, WRPreloadScene) {
|
||||
WRPreloadSceneNone = 0,
|
||||
WRPreloadSceneShelf = 1, // Preload from bookshelf
|
||||
WRPreloadSceneReading = 2, // Preload next chapters while reading
|
||||
WRPreloadSceneWiFi = 3, // Aggressive preload on WiFi
|
||||
WRPreloadSceneManual = 4, // User-initiated preload
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WRPreloadBookManager
|
||||
// ---------------------------------------------------------------------------
|
||||
@interface WRPreloadBookManager : NSObject
|
||||
|
||||
// --- Ivars (from binary analysis) ---
|
||||
// {
|
||||
// NSMutableDictionary *_preloadState; // bookId -> preload state dict
|
||||
// }
|
||||
|
||||
@property (nonatomic, strong, readonly) NSMutableDictionary *preloadState;
|
||||
|
||||
#pragma mark - Class Methods: Encryption Key Storage
|
||||
|
||||
/// Save an encryption key for a preloaded book.
|
||||
/// @param key The encryption key data (hex or raw).
|
||||
/// @param path The file path where the key is associated.
|
||||
/// @param bookId The book identifier.
|
||||
+ (void)saveEncryptKey:(NSString *)key
|
||||
forPath:(NSString *)path
|
||||
bookId:(NSString *)bookId;
|
||||
|
||||
/// Retrieve the stored encryption key for a book.
|
||||
/// @param path The associated file path.
|
||||
/// @param bookId The book identifier.
|
||||
/// @return The encryption key string, or nil.
|
||||
+ (nullable NSString *)encryptKeyForPath:(NSString *)path
|
||||
bookId:(NSString *)bookId;
|
||||
|
||||
/// Remove a stored encryption key.
|
||||
+ (void)removeEncryptKeyForPath:(NSString *)path
|
||||
bookId:(NSString *)bookId;
|
||||
|
||||
#pragma mark - Class Methods: Filename Dictionary
|
||||
|
||||
/// Save a filename mapping dictionary for a book.
|
||||
/// Maps content keys to actual filenames in the tar archive.
|
||||
+ (void)saveFileNameDict:(NSDictionary<NSString *, NSString *> *)dict
|
||||
bookId:(NSString *)bookId;
|
||||
|
||||
/// Look up a filename by key for a given book.
|
||||
+ (nullable NSString *)fileNameForKey:(NSString *)key
|
||||
bookId:(NSString *)bookId;
|
||||
|
||||
/// Remove a filename mapping entry.
|
||||
+ (void)removeFileNameForKey:(NSString *)key
|
||||
bookId:(NSString *)bookId;
|
||||
|
||||
#pragma mark - Class Methods: Cache Management
|
||||
|
||||
/// Clear all preload key-value caches.
|
||||
+ (void)clearKV;
|
||||
|
||||
/// Remove all preload data for a specific book.
|
||||
+ (void)removeKVWithBookId:(NSString *)bookId;
|
||||
|
||||
#pragma mark - Instance Methods: Preloading
|
||||
|
||||
/// Preload an entire book (all chapters).
|
||||
/// @param book The book model to preload.
|
||||
/// @param scene The preload context/scene.
|
||||
- (void)_preloadWholeBook:(WRBook *)book scene:(WRPreloadScene)scene;
|
||||
|
||||
/// Preload chapters starting from a given index.
|
||||
/// @param book The book model.
|
||||
/// @param fromChapterIdx Starting chapter index.
|
||||
- (void)preloadWithBook:(WRBook *)book fromChapterIdx:(NSInteger)fromChapterIdx;
|
||||
|
||||
/// Download specific chapters for a book.
|
||||
/// @param book The book model.
|
||||
/// @param uids Array of chapter UIDs to download.
|
||||
- (void)downloadBook:(WRBook *)book uids:(NSArray<NSString *> *)uids;
|
||||
|
||||
/// Clean up all preloaded book data.
|
||||
- (void)cleanUpPreloadBook;
|
||||
|
||||
/// Calculate preload cache size and optionally clear it.
|
||||
/// @param completion Called with the total size in bytes.
|
||||
/// @param onlyCalc YES to only calculate, NO to also clear.
|
||||
- (void)calcAndClearPreloadBookWithCompletion:(void (^)(NSUInteger totalSize))completion
|
||||
onlyCalc:(BOOL)onlyCalc;
|
||||
|
||||
#pragma mark - Additional Methods (23 total, reconstructed)
|
||||
|
||||
/// Check if a book is currently being preloaded.
|
||||
- (BOOL)isPreloadingBookId:(NSString *)bookId;
|
||||
|
||||
/// Get the preload progress for a book (0.0 - 1.0).
|
||||
- (float)preloadProgressForBookId:(NSString *)bookId;
|
||||
|
||||
/// Cancel an ongoing preload for a book.
|
||||
- (void)cancelPreloadForBookId:(NSString *)bookId;
|
||||
|
||||
/// Determine which chapters should be preloaded next.
|
||||
- (NSArray<NSString *> *)predictedChapterUidsForBook:(WRBook *)book;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
547
Doc/WXRead/decompiled/WRPreloadBookManager.m
Normal file
547
Doc/WXRead/decompiled/WRPreloadBookManager.m
Normal file
@ -0,0 +1,547 @@
|
||||
//
|
||||
// WRPreloadBookManager.m
|
||||
// WeRead (微信读书)
|
||||
// Reverse-engineered implementation reconstruction
|
||||
//
|
||||
// Detailed pseudo-code based on binary analysis, ivar types (NSMutableDictionary),
|
||||
// 23 known methods, and contextual knowledge of WeRead's preload strategy.
|
||||
//
|
||||
// Architecture notes:
|
||||
// - Class methods handle key/filename storage (shared state via NSUserDefaults
|
||||
// or a static dictionary).
|
||||
// - Instance methods handle the actual preloading logic.
|
||||
// - Preloading decisions are based on book ranking, reading patterns,
|
||||
// and network conditions (WiFi vs. cellular).
|
||||
// - Preloaded content is encrypted and stored in a separate cache directory.
|
||||
//
|
||||
|
||||
#import "WRPreloadBookManager.h"
|
||||
#import "WRBookNetwork.h"
|
||||
#import "WREncryptedFileManager.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
static NSString *const kPreloadEncryptKeyPrefix = @"preload_key_";
|
||||
static NSString *const kPreloadFileNamePrefix = @"preload_fn_";
|
||||
static NSString *const kPreloadCacheDir = @"preload_cache";
|
||||
|
||||
// Preload limits
|
||||
static const NSUInteger kMaxPreloadChaptersOnWiFi = 50;
|
||||
static const NSUInteger kMaxPreloadChaptersOnCellular = 5;
|
||||
static const NSUInteger kMaxPreloadBooksOnShelf = 3;
|
||||
|
||||
#pragma mark - Private Interface
|
||||
|
||||
@interface WRPreloadBookManager ()
|
||||
|
||||
@property (nonatomic, strong, readwrite) NSMutableDictionary *preloadState;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - Implementation
|
||||
|
||||
@implementation WRPreloadBookManager
|
||||
{
|
||||
// Ivar from binary analysis:
|
||||
NSMutableDictionary *_preloadState;
|
||||
}
|
||||
|
||||
#pragma mark - Lifecycle
|
||||
|
||||
- (instancetype)init
|
||||
{
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_preloadState = [NSMutableDictionary dictionary];
|
||||
|
||||
// Register for network change notifications to adjust preload behavior
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
addObserver:self
|
||||
selector:@selector(_networkStatusChanged:)
|
||||
name:@"WRNetworkStatusChangedNotification"
|
||||
object:nil];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
}
|
||||
|
||||
#pragma mark - Class Methods: Encryption Key Storage
|
||||
|
||||
+ (void)saveEncryptKey:(NSString *)key
|
||||
forPath:(NSString *)path
|
||||
bookId:(NSString *)bookId
|
||||
{
|
||||
if (!key || !bookId) return;
|
||||
|
||||
// Store in NSUserDefaults with a composite key
|
||||
NSString *storageKey = [NSString stringWithFormat:@"%@%@_%@",
|
||||
kPreloadEncryptKeyPrefix, bookId,
|
||||
[path lastPathComponent]];
|
||||
|
||||
[[NSUserDefaults standardUserDefaults] setObject:key forKey:storageKey];
|
||||
[[NSUserDefaults standardUserDefaults] synchronize];
|
||||
|
||||
// Also save to the keychain via WREncryptedFileManager for persistence
|
||||
NSData *keyData = [key dataUsingEncoding:NSUTF8StringEncoding];
|
||||
[WREncryptedFileManager saveKey:keyData forBookId:bookId];
|
||||
}
|
||||
|
||||
+ (nullable NSString *)encryptKeyForPath:(NSString *)path
|
||||
bookId:(NSString *)bookId
|
||||
{
|
||||
NSString *storageKey = [NSString stringWithFormat:@"%@%@_%@",
|
||||
kPreloadEncryptKeyPrefix, bookId,
|
||||
[path lastPathComponent]];
|
||||
|
||||
NSString *key = [[NSUserDefaults standardUserDefaults] stringForKey:storageKey];
|
||||
|
||||
// Fallback: try the keychain
|
||||
if (!key) {
|
||||
NSData *keyData = [WREncryptedFileManager keyForBookId:bookId];
|
||||
if (keyData) {
|
||||
key = [[NSString alloc] initWithData:keyData encoding:NSUTF8StringEncoding];
|
||||
}
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
+ (void)removeEncryptKeyForPath:(NSString *)path
|
||||
bookId:(NSString *)bookId
|
||||
{
|
||||
NSString *storageKey = [NSString stringWithFormat:@"%@%@_%@",
|
||||
kPreloadEncryptKeyPrefix, bookId,
|
||||
[path lastPathComponent]];
|
||||
|
||||
[[NSUserDefaults standardUserDefaults] removeObjectForKey:storageKey];
|
||||
[[NSUserDefaults standardUserDefaults] synchronize];
|
||||
}
|
||||
|
||||
#pragma mark - Class Methods: Filename Dictionary
|
||||
|
||||
+ (void)saveFileNameDict:(NSDictionary<NSString *, NSString *> *)dict
|
||||
bookId:(NSString *)bookId
|
||||
{
|
||||
if (!dict || !bookId) return;
|
||||
|
||||
NSString *storageKey = [NSString stringWithFormat:@"%@%@", kPreloadFileNamePrefix, bookId];
|
||||
|
||||
[[NSUserDefaults standardUserDefaults] setObject:dict forKey:storageKey];
|
||||
[[NSUserDefaults standardUserDefaults] synchronize];
|
||||
}
|
||||
|
||||
+ (nullable NSString *)fileNameForKey:(NSString *)key
|
||||
bookId:(NSString *)bookId
|
||||
{
|
||||
NSString *storageKey = [NSString stringWithFormat:@"%@%@", kPreloadFileNamePrefix, bookId];
|
||||
|
||||
NSDictionary *dict = [[NSUserDefaults standardUserDefaults] dictionaryForKey:storageKey];
|
||||
return dict[key];
|
||||
}
|
||||
|
||||
+ (void)removeFileNameForKey:(NSString *)key
|
||||
bookId:(NSString *)bookId
|
||||
{
|
||||
NSString *storageKey = [NSString stringWithFormat:@"%@%@", kPreloadFileNamePrefix, bookId];
|
||||
|
||||
NSMutableDictionary *dict = [[[NSUserDefaults standardUserDefaults]
|
||||
dictionaryForKey:storageKey] mutableCopy];
|
||||
if (dict) {
|
||||
[dict removeObjectForKey:key];
|
||||
[[NSUserDefaults standardUserDefaults] setObject:dict forKey:storageKey];
|
||||
[[NSUserDefaults standardUserDefaults] synchronize];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Class Methods: Cache Management
|
||||
|
||||
+ (void)clearKV
|
||||
{
|
||||
// Remove all preload-related keys from NSUserDefaults
|
||||
NSDictionary *defaults = [[NSUserDefaults standardUserDefaults] dictionaryRepresentation];
|
||||
NSArray *prefixes = @[kPreloadEncryptKeyPrefix, kPreloadFileNamePrefix];
|
||||
|
||||
for (NSString *key in defaults) {
|
||||
for (NSString *prefix in prefixes) {
|
||||
if ([key hasPrefix:prefix]) {
|
||||
[[NSUserDefaults standardUserDefaults] removeObjectForKey:key];
|
||||
}
|
||||
}
|
||||
}
|
||||
[[NSUserDefaults standardUserDefaults] synchronize];
|
||||
}
|
||||
|
||||
+ (void)removeKVWithBookId:(NSString *)bookId
|
||||
{
|
||||
if (!bookId) return;
|
||||
|
||||
NSDictionary *defaults = [[NSUserDefaults standardUserDefaults] dictionaryRepresentation];
|
||||
NSString *keyPrefix1 = [NSString stringWithFormat:@"%@%@", kPreloadEncryptKeyPrefix, bookId];
|
||||
NSString *keyPrefix2 = [NSString stringWithFormat:@"%@%@", kPreloadFileNamePrefix, bookId];
|
||||
|
||||
for (NSString *key in defaults) {
|
||||
if ([key hasPrefix:keyPrefix1] || [key hasPrefix:keyPrefix2]) {
|
||||
[[NSUserDefaults standardUserDefaults] removeObjectForKey:key];
|
||||
}
|
||||
}
|
||||
[[NSUserDefaults standardUserDefaults] synchronize];
|
||||
}
|
||||
|
||||
#pragma mark - Instance Methods: Preloading
|
||||
|
||||
- (void)_preloadWholeBook:(WRBook *)book scene:(WRPreloadScene)scene
|
||||
{
|
||||
// 1. Determine the maximum chapters to preload based on scene
|
||||
NSUInteger maxChapters;
|
||||
switch (scene) {
|
||||
case WRPreloadSceneWiFi:
|
||||
maxChapters = kMaxPreloadChaptersOnWiFi;
|
||||
break;
|
||||
case WRPreloadSceneShelf:
|
||||
maxChapters = 20; // Moderate for shelf browsing
|
||||
break;
|
||||
case WRPreloadSceneReading:
|
||||
maxChapters = 10; // Next few chapters while reading
|
||||
break;
|
||||
default:
|
||||
maxChapters = kMaxPreloadChaptersOnCellular;
|
||||
break;
|
||||
}
|
||||
|
||||
// 2. Get the chapter list for the book
|
||||
NSArray *chapters = book.chapters;
|
||||
if (!chapters || chapters.count == 0) {
|
||||
NSLog(@"[WRPreloadBookManager] No chapters for book %@", book.bookId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Select chapters to preload
|
||||
NSArray *uidsToPreload = [self _selectChaptersForPreload:book
|
||||
maxCount:maxChapters
|
||||
scene:scene];
|
||||
|
||||
if (uidsToPreload.count == 0) return;
|
||||
|
||||
// 4. Update preload state
|
||||
NSString *bookId = book.bookId;
|
||||
_preloadState[bookId] = @{
|
||||
@"status" : @"preloading",
|
||||
@"scene" : @(scene),
|
||||
@"totalCount" : @(uidsToPreload.count),
|
||||
@"loadedCount" : @(0),
|
||||
@"startTime" : @([[NSDate date] timeIntervalSince1970]),
|
||||
};
|
||||
|
||||
// 5. Initiate download via WRBookNetwork
|
||||
[WRBookNetwork loadTarForEpubBookId:bookId
|
||||
chapters:uidsToPreload
|
||||
isPreload:YES];
|
||||
|
||||
NSLog(@"[WRPreloadBookManager] Started preloading %lu chapters for book %@ (scene=%ld)",
|
||||
(unsigned long)uidsToPreload.count, bookId, (long)scene);
|
||||
}
|
||||
|
||||
- (void)preloadWithBook:(WRBook *)book fromChapterIdx:(NSInteger)fromChapterIdx
|
||||
{
|
||||
// Preload chapters starting from the given index
|
||||
NSArray *chapters = book.chapters;
|
||||
if (!chapters || fromChapterIdx >= (NSInteger)chapters.count) return;
|
||||
|
||||
// Determine how many chapters to preload ahead
|
||||
NSUInteger preloadCount = 5; // Default: preload 5 chapters ahead
|
||||
if ([self _isOnWiFi]) {
|
||||
preloadCount = 10;
|
||||
}
|
||||
|
||||
NSInteger startIndex = MAX(0, fromChapterIdx);
|
||||
NSInteger endIndex = MIN(startIndex + (NSInteger)preloadCount,
|
||||
(NSInteger)chapters.count);
|
||||
|
||||
NSMutableArray *uids = [NSMutableArray array];
|
||||
for (NSInteger i = startIndex; i < endIndex; i++) {
|
||||
NSDictionary *chapter = chapters[i];
|
||||
NSString *uid = chapter[@"chapterUid"] ?: chapter[@"uid"];
|
||||
if (uid) [uids addObject:uid];
|
||||
}
|
||||
|
||||
if (uids.count > 0) {
|
||||
[self downloadBook:book uids:uids];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)downloadBook:(WRBook *)book uids:(NSArray<NSString *> *)uids
|
||||
{
|
||||
if (!book.bookId || uids.count == 0) return;
|
||||
|
||||
// Filter out already-downloaded chapters
|
||||
NSMutableArray *pendingUIDs = [NSMutableArray array];
|
||||
NSString *plainDir = [self _plainBookDirectoryForBookId:book.bookId];
|
||||
|
||||
for (NSString *uid in uids) {
|
||||
NSString *chapterPath = [plainDir stringByAppendingPathComponent:
|
||||
[NSString stringWithFormat:@"chapter_%@.xhtml", uid]];
|
||||
|
||||
if (![[NSFileManager defaultManager] fileExistsAtPath:chapterPath]) {
|
||||
[pendingUIDs addObject:uid];
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingUIDs.count == 0) return;
|
||||
|
||||
// Initiate download
|
||||
[WRBookNetwork loadTarForEpubBookId:book.bookId
|
||||
chapters:pendingUIDs
|
||||
isPreload:YES];
|
||||
}
|
||||
|
||||
- (void)cleanUpPreloadBook
|
||||
{
|
||||
// 1. Remove all preloaded book files
|
||||
NSString *preloadDir = [self _preloadCacheDirectory];
|
||||
NSFileManager *fm = [NSFileManager defaultManager];
|
||||
|
||||
NSError *error = nil;
|
||||
[fm removeItemAtPath:preloadDir error:&error];
|
||||
if (error) {
|
||||
NSLog(@"[WRPreloadBookManager] Cleanup failed: %@", error);
|
||||
}
|
||||
|
||||
// 2. Clear all key-value caches
|
||||
[[self class] clearKV];
|
||||
|
||||
// 3. Reset state
|
||||
[_preloadState removeAllObjects];
|
||||
|
||||
// 4. Recreate the directory
|
||||
[fm createDirectoryAtPath:preloadDir
|
||||
withIntermediateDirectories:YES
|
||||
attributes:nil
|
||||
error:nil];
|
||||
}
|
||||
|
||||
- (void)calcAndClearPreloadBookWithCompletion:(void (^)(NSUInteger totalSize))completion
|
||||
onlyCalc:(BOOL)onlyCalc
|
||||
{
|
||||
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
||||
NSString *preloadDir = [self _preloadCacheDirectory];
|
||||
NSUInteger totalSize = [self _directorySizeAtPath:preloadDir];
|
||||
|
||||
if (!onlyCalc) {
|
||||
// Clear the cache
|
||||
[self cleanUpPreloadBook];
|
||||
}
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (completion) completion(totalSize);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - Additional Methods (Reconstructed)
|
||||
|
||||
- (BOOL)isPreloadingBookId:(NSString *)bookId
|
||||
{
|
||||
NSDictionary *state = _preloadState[bookId];
|
||||
return [state[@"status"] isEqualToString:@"preloading"];
|
||||
}
|
||||
|
||||
- (float)preloadProgressForBookId:(NSString *)bookId
|
||||
{
|
||||
NSDictionary *state = _preloadState[bookId];
|
||||
if (!state) return 0.0;
|
||||
|
||||
NSInteger total = [state[@"totalCount"] integerValue];
|
||||
NSInteger loaded = [state[@"loadedCount"] integerValue];
|
||||
|
||||
if (total <= 0) return 0.0;
|
||||
return (float)loaded / (float)total;
|
||||
}
|
||||
|
||||
- (void)cancelPreloadForBookId:(NSString *)bookId
|
||||
{
|
||||
// Mark the preload as cancelled in state
|
||||
_preloadState[bookId] = @{
|
||||
@"status" : @"cancelled",
|
||||
@"cancelTime": @([[NSDate date] timeIntervalSince1970]),
|
||||
};
|
||||
|
||||
// Note: Actual network cancellation would need to be handled
|
||||
// by WRBookNetwork's task management
|
||||
NSLog(@"[WRPreloadBookManager] Cancelled preload for book %@", bookId);
|
||||
}
|
||||
|
||||
- (NSArray<NSString *> *)predictedChapterUidsForBook:(WRBook *)book
|
||||
{
|
||||
// Predict which chapters the user is likely to read next.
|
||||
//
|
||||
// Strategy:
|
||||
// 1. If the user is currently reading chapter N, predict N+1, N+2, ...
|
||||
// 2. If the user has a pattern of jumping (e.g., to bookmarks), preload those
|
||||
// 3. Consider book ranking: popular books get more aggressive preloading
|
||||
|
||||
NSMutableArray *predicted = [NSMutableArray array];
|
||||
NSArray *chapters = book.chapters;
|
||||
|
||||
if (!chapters) return predicted;
|
||||
|
||||
// Find the current chapter index
|
||||
NSInteger currentIndex = 0;
|
||||
NSString *currentUid = book.currentChapterUid;
|
||||
|
||||
for (NSUInteger i = 0; i < chapters.count; i++) {
|
||||
NSDictionary *chapter = chapters[i];
|
||||
if ([chapter[@"chapterUid"] isEqualToString:currentUid]) {
|
||||
currentIndex = (NSInteger)i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Predict the next N chapters
|
||||
NSUInteger predictCount = [self _isOnWiFi] ? 10 : 3;
|
||||
|
||||
for (NSInteger i = currentIndex + 1;
|
||||
i < MIN(currentIndex + 1 + (NSInteger)predictCount, (NSInteger)chapters.count);
|
||||
i++) {
|
||||
NSDictionary *chapter = chapters[i];
|
||||
NSString *uid = chapter[@"chapterUid"] ?: chapter[@"uid"];
|
||||
if (uid) [predicted addObject:uid];
|
||||
}
|
||||
|
||||
return [predicted copy];
|
||||
}
|
||||
|
||||
#pragma mark - Private Helpers
|
||||
|
||||
- (NSArray<NSString *> *)_selectChaptersForPreload:(WRBook *)book
|
||||
maxCount:(NSUInteger)maxCount
|
||||
scene:(WRPreloadScene)scene
|
||||
{
|
||||
NSArray *chapters = book.chapters;
|
||||
if (!chapters) return @[];
|
||||
|
||||
NSMutableArray *selected = [NSMutableArray array];
|
||||
NSString *currentUid = book.currentChapterUid;
|
||||
NSInteger currentIndex = 0;
|
||||
|
||||
// Find current chapter index
|
||||
for (NSUInteger i = 0; i < chapters.count; i++) {
|
||||
NSDictionary *chapter = chapters[i];
|
||||
if ([chapter[@"chapterUid"] isEqualToString:currentUid]) {
|
||||
currentIndex = (NSInteger)i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Select chapters based on scene
|
||||
switch (scene) {
|
||||
case WRPreloadSceneReading:
|
||||
// Preload next chapters from current position
|
||||
for (NSInteger i = currentIndex;
|
||||
i < MIN(currentIndex + (NSInteger)maxCount, (NSInteger)chapters.count);
|
||||
i++) {
|
||||
NSDictionary *chapter = chapters[i];
|
||||
NSString *uid = chapter[@"chapterUid"] ?: chapter[@"uid"];
|
||||
if (uid) [selected addObject:uid];
|
||||
}
|
||||
break;
|
||||
|
||||
case WRPreloadSceneShelf:
|
||||
// Preload from the beginning (user might start reading)
|
||||
for (NSUInteger i = 0; i < MIN(maxCount, chapters.count); i++) {
|
||||
NSDictionary *chapter = chapters[i];
|
||||
NSString *uid = chapter[@"chapterUid"] ?: chapter[@"uid"];
|
||||
if (uid) [selected addObject:uid];
|
||||
}
|
||||
break;
|
||||
|
||||
case WRPreloadSceneWiFi:
|
||||
// Aggressive: preload all chapters
|
||||
for (NSDictionary *chapter in chapters) {
|
||||
NSString *uid = chapter[@"chapterUid"] ?: chapter[@"uid"];
|
||||
if (uid) [selected addObject:uid];
|
||||
if (selected.count >= maxCount) break;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Minimal: just the next chapter
|
||||
if (currentIndex + 1 < (NSInteger)chapters.count) {
|
||||
NSDictionary *chapter = chapters[currentIndex + 1];
|
||||
NSString *uid = chapter[@"chapterUid"] ?: chapter[@"uid"];
|
||||
if (uid) [selected addObject:uid];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return [selected copy];
|
||||
}
|
||||
|
||||
- (BOOL)_isOnWiFi
|
||||
{
|
||||
// Check network status
|
||||
// In the actual app, this uses SCNetworkReachability or a wrapper
|
||||
// For reconstruction, assume WiFi if not explicitly cellular
|
||||
NSNumber *isWiFi = [[NSUserDefaults standardUserDefaults] objectForKey:@"WRIsOnWiFi"];
|
||||
return isWiFi ? isWiFi.boolValue : YES; // Default to WiFi for safety
|
||||
}
|
||||
|
||||
- (void)_networkStatusChanged:(NSNotification *)notification
|
||||
{
|
||||
// Adjust preload behavior when network changes
|
||||
BOOL isWiFi = [notification.userInfo[@"isWiFi"] boolValue];
|
||||
|
||||
if (!isWiFi) {
|
||||
// On cellular: cancel aggressive preloads
|
||||
for (NSString *bookId in [_preloadState copy]) {
|
||||
NSDictionary *state = _preloadState[bookId];
|
||||
if ([state[@"scene"] integerValue] == WRPreloadSceneWiFi) {
|
||||
[self cancelPreloadForBookId:bookId];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (NSString *)_preloadCacheDirectory
|
||||
{
|
||||
NSString *caches = NSSearchPathForDirectoriesInDomains(
|
||||
NSCachesDirectory, NSUserDomainMask, YES).firstObject;
|
||||
return [caches stringByAppendingPathComponent:kPreloadCacheDir];
|
||||
}
|
||||
|
||||
- (NSString *)_plainBookDirectoryForBookId:(NSString *)bookId
|
||||
{
|
||||
NSString *caches = NSSearchPathForDirectoriesInDomains(
|
||||
NSCachesDirectory, NSUserDomainMask, YES).firstObject;
|
||||
return [[caches stringByAppendingPathComponent:@"plain_books"]
|
||||
stringByAppendingPathComponent:bookId];
|
||||
}
|
||||
|
||||
- (NSUInteger)_directorySizeAtPath:(NSString *)path
|
||||
{
|
||||
NSFileManager *fm = [NSFileManager defaultManager];
|
||||
BOOL isDir = NO;
|
||||
|
||||
if (![fm fileExistsAtPath:path isDirectory:&isDir]) return 0;
|
||||
if (!isDir) {
|
||||
NSDictionary *attrs = [fm attributesOfItemAtPath:path error:nil];
|
||||
return [attrs fileSize];
|
||||
}
|
||||
|
||||
NSUInteger totalSize = 0;
|
||||
NSDirectoryEnumerator *enumerator = [fm enumeratorAtPath:path];
|
||||
|
||||
for (NSString *filename in enumerator) {
|
||||
NSString *filePath = [path stringByAppendingPathComponent:filename];
|
||||
NSDictionary *attrs = [fm attributesOfItemAtPath:filePath error:nil];
|
||||
totalSize += [attrs fileSize];
|
||||
}
|
||||
|
||||
return totalSize;
|
||||
}
|
||||
|
||||
@end
|
||||
158
Doc/WXRead/decompiled/WRReaderPencilNoteManager.h
Normal file
158
Doc/WXRead/decompiled/WRReaderPencilNoteManager.h
Normal file
@ -0,0 +1,158 @@
|
||||
//
|
||||
// WRReaderPencilNoteManager.h
|
||||
// WeRead (微信读书)
|
||||
// Reverse-engineered header
|
||||
//
|
||||
// Apple Pencil note manager. 11 class methods identified from binary.
|
||||
// Stores drawings locally, uploads to Tencent Cloud COS.
|
||||
// Supports draft and published states for pencil annotations.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pencil drawing color styles
|
||||
// ---------------------------------------------------------------------------
|
||||
typedef NS_ENUM(NSInteger, WRPencilColorStyle) {
|
||||
WRPencilColorStyleBlack = 0,
|
||||
WRPencilColorStyleGray = 1,
|
||||
WRPencilColorStyleRed = 2,
|
||||
WRPencilColorStyleBlue = 3,
|
||||
WRPencilColorStyleYellow = 4,
|
||||
WRPencilColorStyleGreen = 5,
|
||||
WRPencilColorStylePencil = 6, // Natural pencil
|
||||
WRPencilColorStylePen = 7, // Pen/fountain
|
||||
WRPencilColorStyleMarker = 8, // Highlighter marker
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Upload callback
|
||||
// ---------------------------------------------------------------------------
|
||||
typedef void (^WRPencilUploadCallback)(BOOL success,
|
||||
NSString * _Nullable imageUrl,
|
||||
NSString * _Nullable drawingUrl,
|
||||
NSError * _Nullable error);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Download callback
|
||||
// ---------------------------------------------------------------------------
|
||||
typedef void (^WRPencilDownloadCallback)(BOOL success,
|
||||
NSData * _Nullable drawingData,
|
||||
NSError * _Nullable error);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WRReaderPencilNoteManager
|
||||
// ---------------------------------------------------------------------------
|
||||
@interface WRReaderPencilNoteManager : NSObject
|
||||
|
||||
#pragma mark - Drawing Existence Check
|
||||
|
||||
/// Check whether a pencil drawing exists locally for the given review item.
|
||||
/// @param reviewItemId The review item identifier.
|
||||
/// @param reviewId The review identifier.
|
||||
/// @param isDraft YES to check for draft drawings, NO for published.
|
||||
/// @return YES if the drawing file exists on disk.
|
||||
+ (BOOL)checkDrawingExistsWithReviewItemId:(NSString *)reviewItemId
|
||||
reviewId:(NSString *)reviewId
|
||||
isDraft:(BOOL)isDraft;
|
||||
|
||||
#pragma mark - Delete Operations
|
||||
|
||||
/// Delete all locally stored review drawings.
|
||||
+ (void)deleteAllReviewDrawings;
|
||||
|
||||
/// Delete a specific drawing.
|
||||
/// @param reviewItemId The review item identifier.
|
||||
/// @param reviewId The review identifier.
|
||||
/// @param isDraft YES to delete the draft, NO for published.
|
||||
+ (void)deleteDrawingWithReviewItemId:(NSString *)reviewItemId
|
||||
reviewId:(NSString *)reviewId
|
||||
isDraft:(BOOL)isDraft;
|
||||
|
||||
#pragma mark - Download
|
||||
|
||||
/// Download drawing data from Tencent Cloud COS.
|
||||
/// @param cosUrl The COS URL for the drawing data.
|
||||
/// @param destPath Local destination path.
|
||||
/// @param callback Called with the downloaded data or error.
|
||||
+ (void)downloadDrawingDataFromCosWithUrl:(NSString *)cosUrl
|
||||
desPath:(NSString *)destPath
|
||||
callback:(WRPencilDownloadCallback)callback;
|
||||
|
||||
#pragma mark - File Paths
|
||||
|
||||
/// Return the base directory for pencil drawing files.
|
||||
+ (NSString *)drawingFileDirectory;
|
||||
|
||||
/// Return the file path for a specific drawing.
|
||||
/// @param reviewItemId The review item identifier.
|
||||
/// @param reviewId The review identifier.
|
||||
/// @param isDraft YES for draft path, NO for published path.
|
||||
+ (NSString *)drawingFilePathWithReviewItemId:(NSString *)reviewItemId
|
||||
reviewId:(NSString *)reviewId
|
||||
isDraft:(BOOL)isDraft;
|
||||
|
||||
/// Return the file path for a drawing's rendered image.
|
||||
/// @param reviewItemId The review item identifier.
|
||||
/// @param reviewId The review identifier.
|
||||
+ (NSString *)imageFilePathWithReviewItemId:(NSString *)reviewItemId
|
||||
reviewId:(NSString *)reviewId;
|
||||
|
||||
#pragma mark - Upload
|
||||
|
||||
/// Upload a pencil drawing image and data to Tencent Cloud COS.
|
||||
/// @param drawing The rendered UIImage of the drawing.
|
||||
/// @param colorStyle The color/style used for the drawing.
|
||||
/// @param uploadImage YES to upload the image, NO for data only.
|
||||
/// @param canRetry YES if the upload can be retried on failure.
|
||||
+ (void)uploadPencilDrawing:(UIImage *)drawing
|
||||
colorStyle:(WRPencilColorStyle)colorStyle
|
||||
onlyUploadImage:(BOOL)uploadImage
|
||||
canRetry:(BOOL)canRetry;
|
||||
|
||||
/// Upload pencil note raw data (PKDrawing serialized data).
|
||||
/// @param noteData The serialized drawing data.
|
||||
/// @param suffix File suffix/extension (e.g., "drawing", "png").
|
||||
+ (void)uploadPencilNoteData:(NSData *)noteData
|
||||
suffix:(NSString *)suffix;
|
||||
|
||||
#pragma mark - Local Storage
|
||||
|
||||
/// Write raw drawing data to local storage.
|
||||
/// @param data The drawing data to write.
|
||||
/// @param reviewItemId The review item identifier.
|
||||
/// @param reviewId The review identifier.
|
||||
/// @param isDraft YES for draft, NO for published.
|
||||
+ (void)writeDrawingDataToLocal:(NSData *)data
|
||||
reviewItemId:(NSString *)reviewItemId
|
||||
reviewId:(NSString *)reviewId
|
||||
isDraft:(BOOL)isDraft;
|
||||
|
||||
/// Write a rendered drawing image to local storage.
|
||||
/// @param drawing The UIImage to write.
|
||||
/// @param reviewItemId The review item identifier.
|
||||
/// @param reviewId The review identifier.
|
||||
/// @param isDraft YES for draft, NO for published.
|
||||
+ (void)writeDrawingToLocal:(UIImage *)drawing
|
||||
reviewItemId:(NSString *)reviewItemId
|
||||
reviewId:(NSString *)reviewId
|
||||
isDraft:(BOOL)isDraft;
|
||||
|
||||
#pragma mark - Additional Methods (Reconstructed)
|
||||
|
||||
/// Return the total size of all stored pencil drawings in bytes.
|
||||
+ (NSUInteger)totalDrawingStorageSize;
|
||||
|
||||
/// Return a list of all locally stored drawing review item IDs.
|
||||
+ (NSArray<NSString *> *)allStoredDrawingReviewItemIds;
|
||||
|
||||
/// Render a PKDrawing data to UIImage.
|
||||
+ (nullable UIImage *)renderDrawingDataToImage:(NSData *)drawingData
|
||||
scale:(CGFloat)scale;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
526
Doc/WXRead/decompiled/WRReaderPencilNoteManager.m
Normal file
526
Doc/WXRead/decompiled/WRReaderPencilNoteManager.m
Normal file
@ -0,0 +1,526 @@
|
||||
//
|
||||
// WRReaderPencilNoteManager.m
|
||||
// WeRead (微信读书)
|
||||
// Reverse-engineered implementation reconstruction
|
||||
//
|
||||
// 11 class methods identified from binary. Manages Apple Pencil
|
||||
// annotations with local storage and Tencent Cloud COS uploads.
|
||||
//
|
||||
// Architecture notes:
|
||||
// - All methods are class methods (static utility pattern).
|
||||
// - Drawings are stored as both raw PKDrawing data and rendered PNG images.
|
||||
// - Draft vs. published states allow users to save work-in-progress.
|
||||
// - Uploads go to Tencent Cloud COS (Cloud Object Storage) via
|
||||
// signed URLs or direct API.
|
||||
// - File paths follow a predictable pattern: {directory}/{reviewItemId}_{reviewId}.{ext}
|
||||
//
|
||||
|
||||
#import "WRReaderPencilNoteManager.h"
|
||||
#import <PencilKit/PencilKit.h>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
static NSString *const kPencilDrawingDir = @"pencil_drawings";
|
||||
static NSString *const kPencilImageDir = @"pencil_images";
|
||||
static NSString *const kCOSUploadURL = @"https://weread-1258476243.file.myqcloud.com";
|
||||
|
||||
static NSString *const kDraftSuffix = @"_draft";
|
||||
static NSString *const kPublishedSuffix = @"";
|
||||
|
||||
#pragma mark - Implementation
|
||||
|
||||
@implementation WRReaderPencilNoteManager
|
||||
|
||||
#pragma mark - Drawing Existence Check
|
||||
|
||||
+ (BOOL)checkDrawingExistsWithReviewItemId:(NSString *)reviewItemId
|
||||
reviewId:(NSString *)reviewId
|
||||
isDraft:(BOOL)isDraft
|
||||
{
|
||||
// Check for the drawing data file
|
||||
NSString *dataPath = [self drawingFilePathWithReviewItemId:reviewItemId
|
||||
reviewId:reviewId
|
||||
isDraft:isDraft];
|
||||
|
||||
BOOL exists = [[NSFileManager defaultManager] fileExistsAtPath:dataPath];
|
||||
|
||||
if (!exists) {
|
||||
// Also check for the image file
|
||||
NSString *imagePath = [self imageFilePathWithReviewItemId:reviewItemId
|
||||
reviewId:reviewId];
|
||||
exists = [[NSFileManager defaultManager] fileExistsAtPath:imagePath];
|
||||
}
|
||||
|
||||
return exists;
|
||||
}
|
||||
|
||||
#pragma mark - Delete Operations
|
||||
|
||||
+ (void)deleteAllReviewDrawings
|
||||
{
|
||||
// Remove the entire pencil drawings directory
|
||||
NSString *drawingDir = [self drawingFileDirectory];
|
||||
NSString *imageDir = [self _pencilImageDirectory];
|
||||
|
||||
NSFileManager *fm = [NSFileManager defaultManager];
|
||||
|
||||
// Remove drawing data
|
||||
[fm removeItemAtPath:drawingDir error:nil];
|
||||
[fm removeItemAtPath:imageDir error:nil];
|
||||
|
||||
// Recreate empty directories
|
||||
[fm createDirectoryAtPath:drawingDir withIntermediateDirectories:YES
|
||||
attributes:nil error:nil];
|
||||
[fm createDirectoryAtPath:imageDir withIntermediateDirectories:YES
|
||||
attributes:nil error:nil];
|
||||
|
||||
NSLog(@"[WRReaderPencilNoteManager] Deleted all review drawings");
|
||||
}
|
||||
|
||||
+ (void)deleteDrawingWithReviewItemId:(NSString *)reviewItemId
|
||||
reviewId:(NSString *)reviewId
|
||||
isDraft:(BOOL)isDraft
|
||||
{
|
||||
NSFileManager *fm = [NSFileManager defaultManager];
|
||||
|
||||
// Delete drawing data file
|
||||
NSString *dataPath = [self drawingFilePathWithReviewItemId:reviewItemId
|
||||
reviewId:reviewId
|
||||
isDraft:isDraft];
|
||||
[fm removeItemAtPath:dataPath error:nil];
|
||||
|
||||
// Delete image file (only for published, drafts may not have images)
|
||||
if (!isDraft) {
|
||||
NSString *imagePath = [self imageFilePathWithReviewItemId:reviewItemId
|
||||
reviewId:reviewId];
|
||||
[fm removeItemAtPath:imagePath error:nil];
|
||||
}
|
||||
|
||||
NSLog(@"[WRReaderPencilNoteManager] Deleted drawing: %@ (draft=%d)",
|
||||
reviewItemId, isDraft);
|
||||
}
|
||||
|
||||
#pragma mark - Download
|
||||
|
||||
+ (void)downloadDrawingDataFromCosWithUrl:(NSString *)cosUrl
|
||||
desPath:(NSString *)destPath
|
||||
callback:(WRPencilDownloadCallback)callback
|
||||
{
|
||||
if (!cosUrl || cosUrl.length == 0) {
|
||||
if (callback) {
|
||||
callback(NO, nil, [NSError errorWithDomain:@"WRReaderPencilNoteManager"
|
||||
code:-1
|
||||
userInfo:@{NSLocalizedDescriptionKey: @"Empty COS URL"}]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
NSURL *url = [NSURL URLWithString:cosUrl];
|
||||
if (!url) {
|
||||
if (callback) {
|
||||
callback(NO, nil, [NSError errorWithDomain:@"WRReaderPencilNoteManager"
|
||||
code:-2
|
||||
userInfo:@{NSLocalizedDescriptionKey: @"Invalid COS URL"}]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Download from COS
|
||||
NSURLSessionConfiguration *config =
|
||||
[NSURLSessionConfiguration defaultSessionConfiguration];
|
||||
config.timeoutIntervalForRequest = 30;
|
||||
|
||||
NSURLSession *session = [NSURLSession sessionWithConfiguration:config];
|
||||
NSURLSessionDataTask *task = [session dataTaskWithURL:url
|
||||
completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
|
||||
|
||||
if (error || !data) {
|
||||
NSLog(@"[WRReaderPencilNoteManager] COS download failed: %@",
|
||||
error.localizedDescription);
|
||||
if (callback) callback(NO, nil, error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify HTTP status
|
||||
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
|
||||
if (httpResponse.statusCode != 200) {
|
||||
NSError *statusError = [NSError errorWithDomain:@"WRReaderPencilNoteManager"
|
||||
code:httpResponse.statusCode
|
||||
userInfo:@{NSLocalizedDescriptionKey:
|
||||
[NSString stringWithFormat:@"HTTP %ld",
|
||||
(long)httpResponse.statusCode]}];
|
||||
if (callback) callback(NO, nil, statusError);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save to local destination
|
||||
if (destPath) {
|
||||
NSString *destDir = [destPath stringByDeletingLastPathComponent];
|
||||
[[NSFileManager defaultManager] createDirectoryAtPath:destDir
|
||||
withIntermediateDirectories:YES
|
||||
attributes:nil
|
||||
error:nil];
|
||||
|
||||
NSError *writeError = nil;
|
||||
[data writeToFile:destPath options:NSDataWritingAtomic error:&writeError];
|
||||
if (writeError) {
|
||||
NSLog(@"[WRReaderPencilNoteManager] Write failed: %@", writeError);
|
||||
}
|
||||
}
|
||||
|
||||
if (callback) callback(YES, data, nil);
|
||||
}];
|
||||
|
||||
[task resume];
|
||||
}
|
||||
|
||||
#pragma mark - File Paths
|
||||
|
||||
+ (NSString *)drawingFileDirectory
|
||||
{
|
||||
static NSString *sDir = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
NSString *caches = NSSearchPathForDirectoriesInDomains(
|
||||
NSCachesDirectory, NSUserDomainMask, YES).firstObject;
|
||||
sDir = [caches stringByAppendingPathComponent:kPencilDrawingDir];
|
||||
|
||||
// Create directory if needed
|
||||
[[NSFileManager defaultManager] createDirectoryAtPath:sDir
|
||||
withIntermediateDirectories:YES
|
||||
attributes:nil
|
||||
error:nil];
|
||||
});
|
||||
return sDir;
|
||||
}
|
||||
|
||||
+ (NSString *)drawingFilePathWithReviewItemId:(NSString *)reviewItemId
|
||||
reviewId:(NSString *)reviewId
|
||||
isDraft:(BOOL)isDraft
|
||||
{
|
||||
NSString *dir = [self drawingFileDirectory];
|
||||
NSString *suffix = isDraft ? kDraftSuffix : kPublishedSuffix;
|
||||
|
||||
// Filename: {reviewItemId}_{reviewId}{suffix}.drawing
|
||||
NSString *filename = [NSString stringWithFormat:@"%@_%@%@.drawing",
|
||||
reviewItemId, reviewId, suffix];
|
||||
|
||||
return [dir stringByAppendingPathComponent:filename];
|
||||
}
|
||||
|
||||
+ (NSString *)imageFilePathWithReviewItemId:(NSString *)reviewItemId
|
||||
reviewId:(NSString *)reviewId
|
||||
{
|
||||
NSString *dir = [self _pencilImageDirectory];
|
||||
|
||||
// Filename: {reviewItemId}_{reviewId}.png
|
||||
NSString *filename = [NSString stringWithFormat:@"%@_%@.png",
|
||||
reviewItemId, reviewId];
|
||||
|
||||
return [dir stringByAppendingPathComponent:filename];
|
||||
}
|
||||
|
||||
#pragma mark - Upload
|
||||
|
||||
+ (void)uploadPencilDrawing:(UIImage *)drawing
|
||||
colorStyle:(WRPencilColorStyle)colorStyle
|
||||
onlyUploadImage:(BOOL)uploadImage
|
||||
canRetry:(BOOL)canRetry
|
||||
{
|
||||
if (!drawing) return;
|
||||
|
||||
// 1. Render the image to PNG data
|
||||
NSData *imageData = UIImagePNGRepresentation(drawing);
|
||||
if (!imageData) return;
|
||||
|
||||
// 2. Build the upload request to COS
|
||||
//
|
||||
// Tencent Cloud COS upload flow:
|
||||
// a. Request a signed upload URL from WeRead's backend
|
||||
// b. PUT the file directly to COS using the signed URL
|
||||
//
|
||||
// For simplicity, we'll show the direct upload pattern.
|
||||
|
||||
NSString *filename = [NSString stringWithFormat:@"pencil_%@_%ld.png",
|
||||
[[NSUUID UUID] UUIDString],
|
||||
(long)[[NSDate date] timeIntervalSince1970]];
|
||||
|
||||
NSString *uploadURLStr = [NSString stringWithFormat:@"%@/%@",
|
||||
kCOSUploadURL, filename];
|
||||
NSURL *uploadURL = [NSURL URLWithString:uploadURLStr];
|
||||
|
||||
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:uploadURL];
|
||||
[request setHTTPMethod:@"PUT"];
|
||||
[request addValue:@"image/png" forHTTPHeaderField:@"Content-Type"];
|
||||
[request addValue:@(imageData.length).stringValue forHTTPHeaderField:@"Content-Length"];
|
||||
|
||||
NSURLSession *session = [NSURLSession sharedSession];
|
||||
NSURLSessionUploadTask *task = [session uploadTaskWithRequest:request
|
||||
fromData:imageData
|
||||
completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
|
||||
|
||||
if (error) {
|
||||
NSLog(@"[WRReaderPencilNoteManager] Upload failed: %@",
|
||||
error.localizedDescription);
|
||||
|
||||
if (canRetry) {
|
||||
// Queue for retry
|
||||
[self _queueRetryUpload:imageData filename:filename];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
|
||||
if (httpResponse.statusCode == 200 || httpResponse.statusCode == 201) {
|
||||
NSLog(@"[WRReaderPencilNoteManager] Upload success: %@", filename);
|
||||
|
||||
// Notify the backend about the uploaded file
|
||||
[self _notifyBackendOfFileUpload:uploadURLStr
|
||||
colorStyle:colorStyle];
|
||||
}
|
||||
}];
|
||||
|
||||
[task resume];
|
||||
|
||||
// 3. Also upload the raw drawing data if requested
|
||||
if (!uploadImage) {
|
||||
// The drawing data (PKDrawing serialized) needs separate upload
|
||||
// This is handled by uploadPencilNoteData:suffix:
|
||||
}
|
||||
}
|
||||
|
||||
+ (void)uploadPencilNoteData:(NSData *)noteData
|
||||
suffix:(NSString *)suffix
|
||||
{
|
||||
if (!noteData || noteData.length == 0) return;
|
||||
|
||||
NSString *filename = [NSString stringWithFormat:@"pencil_data_%@_%@.%@",
|
||||
[[NSUUID UUID] UUIDString],
|
||||
@((long)[[NSDate date] timeIntervalSince1970]),
|
||||
suffix ?: @"drawing"];
|
||||
|
||||
NSString *uploadURLStr = [NSString stringWithFormat:@"%@/%@",
|
||||
kCOSUploadURL, filename];
|
||||
NSURL *uploadURL = [NSURL URLWithString:uploadURLStr];
|
||||
|
||||
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:uploadURL];
|
||||
[request setHTTPMethod:@"PUT"];
|
||||
[request addValue:@"application/octet-stream" forHTTPHeaderField:@"Content-Type"];
|
||||
[request addValue:@(noteData.length).stringValue forHTTPHeaderField:@"Content-Length"];
|
||||
|
||||
NSURLSessionUploadTask *task = [[NSURLSession sharedSession]
|
||||
uploadTaskWithRequest:request
|
||||
fromData:noteData
|
||||
completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
|
||||
|
||||
if (error) {
|
||||
NSLog(@"[WRReaderPencilNoteManager] Data upload failed: %@",
|
||||
error.localizedDescription);
|
||||
return;
|
||||
}
|
||||
|
||||
NSLog(@"[WRReaderPencilNoteManager] Data upload success: %@", filename);
|
||||
}];
|
||||
|
||||
[task resume];
|
||||
}
|
||||
|
||||
#pragma mark - Local Storage
|
||||
|
||||
+ (void)writeDrawingDataToLocal:(NSData *)data
|
||||
reviewItemId:(NSString *)reviewItemId
|
||||
reviewId:(NSString *)reviewId
|
||||
isDraft:(BOOL)isDraft
|
||||
{
|
||||
if (!data || !reviewItemId || !reviewId) return;
|
||||
|
||||
NSString *path = [self drawingFilePathWithReviewItemId:reviewItemId
|
||||
reviewId:reviewId
|
||||
isDraft:isDraft];
|
||||
|
||||
// Ensure directory exists
|
||||
NSString *dir = [path stringByDeletingLastPathComponent];
|
||||
[[NSFileManager defaultManager] createDirectoryAtPath:dir
|
||||
withIntermediateDirectories:YES
|
||||
attributes:nil
|
||||
error:nil];
|
||||
|
||||
NSError *error = nil;
|
||||
[data writeToFile:path options:NSDataWritingAtomic error:&error];
|
||||
if (error) {
|
||||
NSLog(@"[WRReaderPencilNoteManager] Write drawing data failed: %@", error);
|
||||
}
|
||||
}
|
||||
|
||||
+ (void)writeDrawingToLocal:(UIImage *)drawing
|
||||
reviewItemId:(NSString *)reviewItemId
|
||||
reviewId:(NSString *)reviewId
|
||||
isDraft:(BOOL)isDraft
|
||||
{
|
||||
if (!drawing || !reviewItemId || !reviewId) return;
|
||||
|
||||
NSString *path = [self imageFilePathWithReviewItemId:reviewItemId
|
||||
reviewId:reviewId];
|
||||
|
||||
// Ensure directory exists
|
||||
NSString *dir = [path stringByDeletingLastPathComponent];
|
||||
[[NSFileManager defaultManager] createDirectoryAtPath:dir
|
||||
withIntermediateDirectories:YES
|
||||
attributes:nil
|
||||
error:nil];
|
||||
|
||||
NSData *pngData = UIImagePNGRepresentation(drawing);
|
||||
if (pngData) {
|
||||
NSError *error = nil;
|
||||
[pngData writeToFile:path options:NSDataWritingAtomic error:&error];
|
||||
if (error) {
|
||||
NSLog(@"[WRReaderPencilNoteManager] Write drawing image failed: %@", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Additional Methods
|
||||
|
||||
+ (NSUInteger)totalDrawingStorageSize
|
||||
{
|
||||
NSFileManager *fm = [NSFileManager defaultManager];
|
||||
NSUInteger totalSize = 0;
|
||||
|
||||
// Calculate size of drawing data directory
|
||||
totalSize += [self _directorySize:[self drawingFileDirectory]];
|
||||
|
||||
// Calculate size of image directory
|
||||
totalSize += [self _directorySize:[self _pencilImageDirectory]];
|
||||
|
||||
return totalSize;
|
||||
}
|
||||
|
||||
+ (NSArray<NSString *> *)allStoredDrawingReviewItemIds
|
||||
{
|
||||
NSString *drawingDir = [self drawingFileDirectory];
|
||||
NSFileManager *fm = [NSFileManager defaultManager];
|
||||
|
||||
NSError *error = nil;
|
||||
NSArray *files = [fm contentsOfDirectoryAtPath:drawingDir error:&error];
|
||||
|
||||
if (!files) return @[];
|
||||
|
||||
NSMutableSet *reviewItemIds = [NSMutableSet set];
|
||||
|
||||
for (NSString *filename in files) {
|
||||
// Filename format: {reviewItemId}_{reviewId}[_draft].drawing
|
||||
NSArray *components = [filename componentsSeparatedByString:@"_"];
|
||||
if (components.count >= 1) {
|
||||
[reviewItemIds addObject:components[0]];
|
||||
}
|
||||
}
|
||||
|
||||
return [reviewItemIds allObjects];
|
||||
}
|
||||
|
||||
+ (nullable UIImage *)renderDrawingDataToImage:(NSData *)drawingData
|
||||
scale:(CGFloat)scale
|
||||
{
|
||||
if (!drawingData || drawingData.length == 0) return nil;
|
||||
|
||||
// Attempt to deserialize as PKDrawing
|
||||
if (@available(iOS 13.0, *)) {
|
||||
NSError *error = nil;
|
||||
PKDrawing *drawing = [[PKDrawing alloc] initWithData:drawingData error:&error];
|
||||
if (drawing) {
|
||||
CGRect bounds = drawing.bounds;
|
||||
if (scale <= 0) scale = [UIScreen mainScreen].scale;
|
||||
|
||||
UIImage *image = [drawing imageFromRect:bounds scale:scale];
|
||||
return image;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try UIImage from data directly
|
||||
return [UIImage imageWithData:drawingData];
|
||||
}
|
||||
|
||||
#pragma mark - Private Helpers
|
||||
|
||||
+ (NSString *)_pencilImageDirectory
|
||||
{
|
||||
static NSString *sDir = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
NSString *caches = NSSearchPathForDirectoriesInDomains(
|
||||
NSCachesDirectory, NSUserDomainMask, YES).firstObject;
|
||||
sDir = [caches stringByAppendingPathComponent:kPencilImageDir];
|
||||
|
||||
[[NSFileManager defaultManager] createDirectoryAtPath:sDir
|
||||
withIntermediateDirectories:YES
|
||||
attributes:nil
|
||||
error:nil];
|
||||
});
|
||||
return sDir;
|
||||
}
|
||||
|
||||
+ (NSUInteger)_directorySize:(NSString *)path
|
||||
{
|
||||
NSFileManager *fm = [NSFileManager defaultManager];
|
||||
BOOL isDir = NO;
|
||||
|
||||
if (![fm fileExistsAtPath:path isDirectory:&isDir]) return 0;
|
||||
if (!isDir) {
|
||||
NSDictionary *attrs = [fm attributesOfItemAtPath:path error:nil];
|
||||
return [attrs fileSize];
|
||||
}
|
||||
|
||||
NSUInteger totalSize = 0;
|
||||
NSDirectoryEnumerator *enumerator = [fm enumeratorAtPath:path];
|
||||
|
||||
for (NSString *filename in enumerator) {
|
||||
NSString *filePath = [path stringByAppendingPathComponent:filename];
|
||||
NSDictionary *attrs = [fm attributesOfItemAtPath:filePath error:nil];
|
||||
totalSize += [attrs fileSize];
|
||||
}
|
||||
|
||||
return totalSize;
|
||||
}
|
||||
|
||||
+ (void)_queueRetryUpload:(NSData *)imageData filename:(NSString *)filename
|
||||
{
|
||||
// Store failed upload in a retry queue
|
||||
NSMutableArray *queue = [[[NSUserDefaults standardUserDefaults]
|
||||
arrayForKey:@"pencil_upload_retry_queue"] mutableCopy] ?: [NSMutableArray array];
|
||||
|
||||
NSDictionary *entry = @{
|
||||
@"filename" : filename ?: @"",
|
||||
@"timestamp" : @((long)[[NSDate date] timeIntervalSince1970]),
|
||||
// Note: imageData is too large for UserDefaults; would use file-based queue
|
||||
};
|
||||
|
||||
[queue addObject:entry];
|
||||
[[NSUserDefaults standardUserDefaults] setObject:queue forKey:@"pencil_upload_retry_queue"];
|
||||
[[NSUserDefaults standardUserDefaults] synchronize];
|
||||
|
||||
// Write the actual image data to a retry file
|
||||
NSString *retryDir = [[self drawingFileDirectory]
|
||||
stringByAppendingPathComponent:@"retry"];
|
||||
[[NSFileManager defaultManager] createDirectoryAtPath:retryDir
|
||||
withIntermediateDirectories:YES
|
||||
attributes:nil
|
||||
error:nil];
|
||||
|
||||
NSString *retryPath = [retryDir stringByAppendingPathComponent:filename];
|
||||
[imageData writeToFile:retryPath atomically:YES];
|
||||
}
|
||||
|
||||
+ (void)_notifyBackendOfFileUpload:(NSString *)fileURL
|
||||
colorStyle:(WRPencilColorStyle)colorStyle
|
||||
{
|
||||
// Notify WeRead's backend that a pencil drawing has been uploaded to COS.
|
||||
// The backend will associate it with the review/note.
|
||||
//
|
||||
// This would typically be a POST to an API endpoint with the COS URL.
|
||||
|
||||
NSLog(@"[WRReaderPencilNoteManager] Notified backend of upload: %@ (style=%ld)",
|
||||
fileURL, (long)colorStyle);
|
||||
}
|
||||
|
||||
@end
|
||||
211
Doc/WXRead/decompiled/WRReaderViewController.h
Normal file
211
Doc/WXRead/decompiled/WRReaderViewController.h
Normal file
@ -0,0 +1,211 @@
|
||||
//
|
||||
// WRReaderViewController.h
|
||||
// WeRead (微信读书)
|
||||
//
|
||||
// Reverse-engineered header reconstruction.
|
||||
// Main reader controller. Manages reading state, progress saving,
|
||||
// chapter jumping, page rendering lifecycle, and the typesetter.
|
||||
// 431 methods total — this header exposes the key public interface.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import "WRPageViewController.h"
|
||||
|
||||
@class WRBook;
|
||||
@class WRChapterData;
|
||||
@class WRChapterPageCount;
|
||||
@class WRPageView;
|
||||
@class WRReadingProgress;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Reading Progress Data
|
||||
// ============================================================================
|
||||
|
||||
/// Encapsulates the current reading position.
|
||||
@interface WRReadingProgress : NSObject
|
||||
|
||||
@property (nonatomic, copy, nullable) NSString *bookId;
|
||||
@property (nonatomic, assign) NSUInteger chapterIndex; // Current chapter (0-based).
|
||||
@property (nonatomic, assign) NSUInteger pageIndex; // Page within chapter (0-based).
|
||||
@property (nonatomic, assign) NSUInteger charIndex; // Character offset within chapter.
|
||||
@property (nonatomic, assign) CGFloat scrollOffset; // For scroll-based reading.
|
||||
@property (nonatomic, copy, nullable) NSString *chapterId;
|
||||
@property (nonatomic, assign) double readPercentage; // 0.0 .. 1.0.
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - WRReaderViewControllerDelegate
|
||||
// ============================================================================
|
||||
|
||||
@protocol WRReaderViewControllerDelegate <NSObject>
|
||||
@optional
|
||||
|
||||
/// Called when the reading progress changes (page flip, chapter jump).
|
||||
- (void)readerViewController:(WRReaderViewController *)readerVC
|
||||
didUpdateProgress:(WRReadingProgress *)progress;
|
||||
|
||||
/// Called when the reader needs to present a modal (e.g., settings, TOC).
|
||||
- (void)readerViewController:(WRReaderViewController *)readerVC
|
||||
presentViewController:(UIViewController *)viewController
|
||||
animated:(BOOL)animated
|
||||
completion:(void (^ __nullable)(void))completion;
|
||||
|
||||
/// Called when the reader exits.
|
||||
- (void)readerViewControllerDidClose:(WRReaderViewController *)readerVC;
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - WRReaderViewController
|
||||
// ============================================================================
|
||||
|
||||
@interface WRReaderViewController : UIViewController <WRPageViewControllerDelegate>
|
||||
|
||||
// ---- Delegate ----
|
||||
@property (nonatomic, weak, nullable) id<WRReaderViewControllerDelegate> readerDelegate;
|
||||
|
||||
// ---- Book & Progress ----
|
||||
/// The book being read.
|
||||
@property (nonatomic, strong, readonly, nullable) WRBook *book;
|
||||
|
||||
/// The book identifier (convenience accessor).
|
||||
@property (nonatomic, copy, readonly, nullable) NSString *bookId;
|
||||
|
||||
/// Current reading progress.
|
||||
@property (nonatomic, strong, nullable) WRReadingProgress *readingProgress;
|
||||
|
||||
// ---- Chapter State ----
|
||||
/// The chapter data for the currently loaded chapter.
|
||||
@property (nonatomic, strong, nullable) WRChapterData *currentChapterData;
|
||||
|
||||
/// The page count calculator for the current chapter.
|
||||
@property (nonatomic, strong, nullable) WRChapterPageCount *currentChapterPageCount;
|
||||
|
||||
/// The total number of chapters in the book.
|
||||
@property (nonatomic, assign, readonly) NSUInteger totalChapters;
|
||||
|
||||
// ---- Reader Mode ----
|
||||
/// Whether the reader is in doodle/drawing mode (for handwritten notes).
|
||||
@property (nonatomic, assign, readonly) BOOL doodleMode;
|
||||
|
||||
/// Whether auto-read is active.
|
||||
@property (nonatomic, assign, readonly) BOOL autoReadEnabled;
|
||||
|
||||
// ---- Typesetter ----
|
||||
/// Whether the typesetter is currently recomposing (re-layout).
|
||||
@property (nonatomic, assign, readonly) BOOL isRecomposing;
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Initialization
|
||||
// ============================================================================
|
||||
|
||||
/// Full initialization with all options.
|
||||
/// @param book The book model object.
|
||||
/// @param progress Initial reading progress.
|
||||
/// @param forceUseInitialProgress If YES, ignore any saved progress and use the given one.
|
||||
/// @param doodleMode Start in doodle mode.
|
||||
/// @param autoRead Start with auto-read enabled.
|
||||
- (instancetype)initWithBook:(WRBook *)book
|
||||
progress:(WRReadingProgress * __nullable)progress
|
||||
forceUseInitialProgress:(BOOL)forceUseInitialProgress
|
||||
doodleMode:(BOOL)doodleMode
|
||||
autoRead:(BOOL)autoRead;
|
||||
|
||||
/// Convenience init with just a book ID (loads book data from cache/server).
|
||||
- (instancetype)initWithBookId:(NSString *)bookId;
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Lifecycle
|
||||
// ============================================================================
|
||||
|
||||
- (void)viewDidLoad;
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Page Rendering
|
||||
// ============================================================================
|
||||
|
||||
/// Renders the page view for the given progress data. This is the main
|
||||
/// entry point for displaying a page:
|
||||
/// 1. Retrieves or computes the chapter data.
|
||||
/// 2. Computes pagination for the current page size and typesetter settings.
|
||||
/// 3. Creates/updates the WRPageView with the layout frame for the page range.
|
||||
/// 4. Updates the page view controller's child.
|
||||
///
|
||||
/// @param pageView The page view to render into.
|
||||
/// @param progressData The reading progress (chapter + page index).
|
||||
/// @param source A string identifying the caller (for logging).
|
||||
- (void)renderPageView:(WRPageView *)pageView
|
||||
progressData:(WRReadingProgress *)progressData
|
||||
source:(NSString *)source;
|
||||
|
||||
/// Recomposes (re-typesets) the current page view.
|
||||
/// Called after font size, line spacing, or theme changes.
|
||||
/// @param source A string identifying the caller.
|
||||
- (void)recomposeCurrentPageViewWithSource:(NSString *)source;
|
||||
|
||||
/// Reloads all page views with new progress data.
|
||||
/// Called after a full chapter change or settings change.
|
||||
/// @param progressData The new reading progress.
|
||||
/// @param source A string identifying the caller.
|
||||
- (void)reloadPageViewsWithProgressData:(WRReadingProgress *)progressData
|
||||
source:(NSString *)source;
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Chapter Navigation
|
||||
// ============================================================================
|
||||
|
||||
/// Jumps to a specific chapter and position.
|
||||
/// @param chapterIdx The chapter index (0-based).
|
||||
/// @param position The page index within the chapter.
|
||||
/// @param positionOfFile The character offset within the chapter (for precise positioning).
|
||||
- (void)gotoChapterIdx:(NSUInteger)chapterIdx
|
||||
position:(NSUInteger)position
|
||||
positionOfFile:(NSUInteger)positionOfFile;
|
||||
|
||||
/// Advances to the next chapter. Resets page index to 0.
|
||||
- (void)jumpReadingToNextChapter;
|
||||
|
||||
/// Goes back to the previous chapter. Sets page index to the last page.
|
||||
- (void)jumpReadingToPreChapter;
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Page Flip Callback
|
||||
// ============================================================================
|
||||
|
||||
/// Called by WRPageViewController when a page flip completes.
|
||||
/// Updates progress, saves state, and triggers prefetching of adjacent chapters.
|
||||
- (void)didFlipPage;
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Progress Persistence
|
||||
// ============================================================================
|
||||
|
||||
/// Saves the current reading progress.
|
||||
/// @param isAsync If YES, save asynchronously (non-blocking). If NO, save synchronously.
|
||||
- (void)_saveReadingProgressAndIsAsync:(BOOL)isAsync;
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Typesetter
|
||||
// ============================================================================
|
||||
|
||||
/// Modifies the typesetter attributes (font, size, spacing, etc.) and
|
||||
/// triggers a full re-typeset.
|
||||
/// @param block A block that receives a mutable dictionary of typesetter
|
||||
/// attributes and modifies them in place.
|
||||
- (void)changeTypesetterAttributesWithBlock:(void (^ __nonnull)(NSMutableDictionary *attrs))block;
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Pagination
|
||||
// ============================================================================
|
||||
|
||||
/// Initializes or re-initializes the chapter page count calculator.
|
||||
/// Called when the chapter changes, the view size changes, or typesetter
|
||||
/// settings change.
|
||||
- (void)initChapterPageCount;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
876
Doc/WXRead/decompiled/WRReaderViewController.m
Normal file
876
Doc/WXRead/decompiled/WRReaderViewController.m
Normal file
@ -0,0 +1,876 @@
|
||||
//
|
||||
// WRReaderViewController.m
|
||||
// WeRead (微信读书)
|
||||
//
|
||||
// Detailed pseudo-code reconstruction of the main reader controller.
|
||||
// 431 methods total. This file covers the key methods for page rendering,
|
||||
// chapter navigation, progress saving, and typesetter management.
|
||||
//
|
||||
|
||||
#import "WRReaderViewController.h"
|
||||
#import "WRPageViewController.h"
|
||||
#import "WRPageView.h"
|
||||
#import "WRChapterData.h"
|
||||
#import "WRChapterPageCount.h"
|
||||
#import "WRCoreTextLayouter.h"
|
||||
#import "WRCoreTextLayoutFrame.h"
|
||||
#import "WRBook.h"
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Constants
|
||||
// ============================================================================
|
||||
|
||||
static NSString *const kReadingProgressKeyPrefix = @"weread_progress_";
|
||||
static NSString *const kChapterCachePrefix = @"weread_chapter_";
|
||||
static NSString *const kPageCountCachePrefix = @"weread_pagecount_";
|
||||
|
||||
static NSString *const kReaderDidFlipPage = @"WRReaderDidFlipPage";
|
||||
static NSString *const kReaderChapterLoaded = @"WRReaderChapterLoaded";
|
||||
static NSString *const kReaderProgressSaved = @"WRReaderProgressSaved";
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - WRReadingProgress
|
||||
// ============================================================================
|
||||
|
||||
@implementation WRReadingProgress
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_chapterIndex = 0;
|
||||
_pageIndex = 0;
|
||||
_charIndex = 0;
|
||||
_scrollOffset = 0.0;
|
||||
_readPercentage = 0.0;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (instancetype)copyWithZone:(NSZone *)zone {
|
||||
WRReadingProgress *copy = [[WRReadingProgress alloc] init];
|
||||
copy.bookId = self.bookId;
|
||||
copy.chapterIndex = self.chapterIndex;
|
||||
copy.pageIndex = self.pageIndex;
|
||||
copy.charIndex = self.charIndex;
|
||||
copy.scrollOffset = self.scrollOffset;
|
||||
copy.chapterId = self.chapterId;
|
||||
copy.readPercentage = self.readPercentage;
|
||||
return copy;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - WRReaderViewController ()
|
||||
// ============================================================================
|
||||
|
||||
@interface WRReaderViewController ()
|
||||
|
||||
@property (nonatomic, strong) WRBook *book;
|
||||
@property (nonatomic, copy) NSString *bookId;
|
||||
@property (nonatomic, strong) WRPageViewController *pageViewController;
|
||||
@property (nonatomic, assign) BOOL doodleMode;
|
||||
@property (nonatomic, assign) BOOL autoReadEnabled;
|
||||
@property (nonatomic, assign) BOOL isRecomposing;
|
||||
@property (nonatomic, assign) BOOL forceUseInitialProgress;
|
||||
@property (nonatomic, strong) WRReadingProgress *initialProgress;
|
||||
@property (nonatomic, strong) NSMutableDictionary<NSNumber *, WRChapterData *> *chapterDataCache;
|
||||
@property (nonatomic, strong) NSMutableDictionary<NSString *, WRChapterPageCount *> *pageCountCache;
|
||||
@property (nonatomic, strong) NSMutableArray<WRPageView *> *activePageViews;
|
||||
@property (nonatomic, strong) dispatch_queue_t chapterLoadQueue;
|
||||
@property (nonatomic, assign) BOOL isLoadingChapter;
|
||||
@property (nonatomic, assign) NSUInteger loadRetryCount;
|
||||
@property (nonatomic, assign) NSUInteger maxRetryCount;
|
||||
@property (nonatomic, strong) NSTimer *autoReadTimer;
|
||||
@property (nonatomic, strong) NSTimer *progressSaveTimer;
|
||||
|
||||
@end
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - WRReaderViewController Implementation
|
||||
// ============================================================================
|
||||
|
||||
@implementation WRReaderViewController
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Initialization
|
||||
// ============================================================================
|
||||
|
||||
- (instancetype)initWithBook:(WRBook *)book
|
||||
progress:(WRReadingProgress *)progress
|
||||
forceUseInitialProgress:(BOOL)forceUseInitialProgress
|
||||
doodleMode:(BOOL)doodleMode
|
||||
autoRead:(BOOL)autoRead {
|
||||
self = [super initWithNibName:nil bundle:nil];
|
||||
if (self) {
|
||||
_book = book;
|
||||
_bookId = book.bookId;
|
||||
_initialProgress = progress;
|
||||
_forceUseInitialProgress = forceUseInitialProgress;
|
||||
_doodleMode = doodleMode;
|
||||
_autoReadEnabled = autoRead;
|
||||
_maxRetryCount = 3;
|
||||
_loadRetryCount = 0;
|
||||
_chapterDataCache = [NSMutableDictionary dictionary];
|
||||
_pageCountCache = [NSMutableDictionary dictionary];
|
||||
_activePageViews = [NSMutableArray array];
|
||||
_chapterLoadQueue = dispatch_queue_create(
|
||||
"com.weread.chapterload", DISPATCH_QUEUE_SERIAL);
|
||||
|
||||
if (forceUseInitialProgress && progress) {
|
||||
_readingProgress = [progress copy];
|
||||
} else {
|
||||
_readingProgress = [self _loadSavedProgress] ?: progress;
|
||||
}
|
||||
if (!_readingProgress) {
|
||||
_readingProgress = [[WRReadingProgress alloc] init];
|
||||
_readingProgress.bookId = _bookId;
|
||||
}
|
||||
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
addObserver:self selector:@selector(_handleMemoryWarning:)
|
||||
name:UIApplicationDidReceiveMemoryWarningNotification object:nil];
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
addObserver:self selector:@selector(_handleAutoReadAdvance:)
|
||||
name:@"WRPageViewAutoReadAdvance" object:nil];
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
addObserver:self selector:@selector(_handleFontSizeChange:)
|
||||
name:@"WRPageViewFontSizeChange" object:nil];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (instancetype)initWithBookId:(NSString *)bookId {
|
||||
self = [super initWithNibName:nil bundle:nil];
|
||||
if (self) {
|
||||
_bookId = bookId;
|
||||
_maxRetryCount = 3;
|
||||
_chapterDataCache = [NSMutableDictionary dictionary];
|
||||
_pageCountCache = [NSMutableDictionary dictionary];
|
||||
_activePageViews = [NSMutableArray array];
|
||||
_chapterLoadQueue = dispatch_queue_create(
|
||||
"com.weread.chapterload", DISPATCH_QUEUE_SERIAL);
|
||||
[self _loadBookDataWithBookId:bookId];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[_autoReadTimer invalidate];
|
||||
[_progressSaveTimer invalidate];
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - View Lifecycle
|
||||
// ============================================================================
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
self.view.backgroundColor = [UIColor whiteColor];
|
||||
|
||||
// Create and embed the page view controller.
|
||||
WRPageFlippingStyle flipStyle = [self _userPreferredFlipStyle];
|
||||
UIPageViewControllerTransitionStyle uiStyle =
|
||||
(flipStyle == WRPageFlippingStyleCurl)
|
||||
? UIPageViewControllerTransitionStylePageCurl
|
||||
: UIPageViewControllerTransitionStyleScroll;
|
||||
|
||||
self.pageViewController =
|
||||
[[WRPageViewController alloc] initWithDelegate:self
|
||||
withPageType:uiStyle
|
||||
pageFlippingStyle:flipStyle];
|
||||
|
||||
[self addChildViewController:self.pageViewController];
|
||||
self.pageViewController.view.frame = self.view.bounds;
|
||||
self.pageViewController.view.autoresizingMask =
|
||||
UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
[self.view addSubview:self.pageViewController.view];
|
||||
[self.pageViewController didMoveToParentViewController:self];
|
||||
|
||||
// Load the initial chapter.
|
||||
[self _loadChapterAtIndex:self.readingProgress.chapterIndex
|
||||
completion:^(WRChapterData *chapterData, NSError *error) {
|
||||
if (chapterData) {
|
||||
[self _displayChapter:chapterData
|
||||
atPageIndex:self.readingProgress.pageIndex
|
||||
animated:NO];
|
||||
} else {
|
||||
[self _showChapterLoadError:error];
|
||||
}
|
||||
}];
|
||||
|
||||
// Start the progress save timer (every 30 seconds).
|
||||
_progressSaveTimer =
|
||||
[NSTimer scheduledTimerWithTimeInterval:30.0
|
||||
target:self
|
||||
selector:@selector(_periodicProgressSave)
|
||||
userInfo:nil
|
||||
repeats:YES];
|
||||
|
||||
if (_autoReadEnabled) {
|
||||
[self _startAutoRead];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Page Rendering
|
||||
// ============================================================================
|
||||
|
||||
///
|
||||
/// The main rendering pipeline:
|
||||
/// 1. Look up or compute WRChapterData for the progress's chapter.
|
||||
/// 2. Compute pagination via WRChapterPageCount.
|
||||
/// 3. Get the character range for the target page.
|
||||
/// 4. Create a WRCoreTextLayoutFrame for that range.
|
||||
/// 5. Assign the layout frame to the WRPageView.
|
||||
/// 6. Trigger -setNeedsDisplay on the page view.
|
||||
///
|
||||
- (void)renderPageView:(WRPageView *)pageView
|
||||
progressData:(WRReadingProgress *)progressData
|
||||
source:(NSString *)source {
|
||||
if (!pageView || !progressData) return;
|
||||
|
||||
NSLog(@"[WRReader] renderPageView source=%@ ch=%lu page=%lu",
|
||||
source, (unsigned long)progressData.chapterIndex,
|
||||
(unsigned long)progressData.pageIndex);
|
||||
|
||||
// Step 1: Get or compute chapter data.
|
||||
WRChapterData *chapterData = [self _chapterDataForIndex:progressData.chapterIndex];
|
||||
if (!chapterData) {
|
||||
[pageView showLoadingWithProgress:0.0];
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Compute pagination.
|
||||
WRChapterPageCount *pageCount = [self _pageCountForChapterData:chapterData];
|
||||
if (!pageCount || pageCount.totalPages == 0) {
|
||||
[pageView showErrorWithMessage:@"页面计算失败"];
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 3: Get the page range.
|
||||
NSUInteger pageIdx = progressData.pageIndex;
|
||||
if (pageIdx >= pageCount.totalPages) {
|
||||
pageIdx = pageCount.totalPages - 1;
|
||||
}
|
||||
NSRange pageRange = [pageCount rangeForPageAtIndex:pageIdx];
|
||||
if (pageRange.location == NSNotFound) {
|
||||
[pageView showErrorWithMessage:@"页面范围无效"];
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 4: Create layout frame for this page range.
|
||||
WRCoreTextLayouter *layouter = chapterData.layouter;
|
||||
WRCoreTextLayoutFrame *layoutFrame =
|
||||
[layouter layoutFrameForRange:pageRange
|
||||
size:self.view.bounds.size
|
||||
insets:chapterData.contentInsets];
|
||||
|
||||
// Step 5: Assign to page view.
|
||||
pageView.chapterData = chapterData;
|
||||
pageView.layoutFrame = layoutFrame;
|
||||
pageView.pageIndex = pageIdx;
|
||||
|
||||
// Step 6: Trigger redraw.
|
||||
[pageView setNeedsDisplay];
|
||||
|
||||
// Step 7: Update friend reviews button.
|
||||
[pageView friendReviewsCount:0];
|
||||
|
||||
// Step 8: Prefetch adjacent chapters.
|
||||
[self _prefetchAdjacentChaptersForIndex:progressData.chapterIndex];
|
||||
}
|
||||
|
||||
/// Recomposes the current page view after a typesetter change.
|
||||
- (void)recomposeCurrentPageViewWithSource:(NSString *)source {
|
||||
self.isRecomposing = YES;
|
||||
NSLog(@"[WRReader] recomposeCurrentPageView source=%@", source);
|
||||
|
||||
[self.pageCountCache removeAllObjects];
|
||||
|
||||
WRChapterData *chapterData = self.currentChapterData;
|
||||
if (chapterData) {
|
||||
[self _reTypesetChapterData:chapterData];
|
||||
}
|
||||
|
||||
[self renderPageView:self.activePageViews.firstObject
|
||||
progressData:self.readingProgress
|
||||
source:source];
|
||||
|
||||
self.isRecomposing = NO;
|
||||
}
|
||||
|
||||
/// Reloads all page views after a full settings change.
|
||||
- (void)reloadPageViewsWithProgressData:(WRReadingProgress *)progressData
|
||||
source:(NSString *)source {
|
||||
[self.chapterDataCache removeAllObjects];
|
||||
[self.pageCountCache removeAllObjects];
|
||||
self.readingProgress = progressData;
|
||||
|
||||
[self _loadChapterAtIndex:progressData.chapterIndex
|
||||
completion:^(WRChapterData *chapterData, NSError *error) {
|
||||
if (chapterData) {
|
||||
[self _displayChapter:chapterData
|
||||
atPageIndex:progressData.pageIndex
|
||||
animated:NO];
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Chapter Navigation
|
||||
// ============================================================================
|
||||
|
||||
/// Jumps to a specific chapter and position.
|
||||
- (void)gotoChapterIdx:(NSUInteger)chapterIdx
|
||||
position:(NSUInteger)position
|
||||
positionOfFile:(NSUInteger)positionOfFile {
|
||||
NSLog(@"[WRReader] gotoChapterIdx:%lu position:%lu posFile:%lu",
|
||||
(unsigned long)chapterIdx, (unsigned long)position,
|
||||
(unsigned long)positionOfFile);
|
||||
|
||||
self.readingProgress.chapterIndex = chapterIdx;
|
||||
self.readingProgress.pageIndex = position;
|
||||
self.readingProgress.charIndex = positionOfFile;
|
||||
|
||||
// If the chapter is already cached, display it directly.
|
||||
WRChapterData *cachedData = self.chapterDataCache[@(chapterIdx)];
|
||||
if (cachedData) {
|
||||
self.currentChapterData = cachedData;
|
||||
[self _displayChapter:cachedData atPageIndex:position animated:YES];
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, load asynchronously.
|
||||
[self _loadChapterAtIndex:chapterIdx
|
||||
completion:^(WRChapterData *chapterData, NSError *error) {
|
||||
if (chapterData) {
|
||||
if (positionOfFile > 0) {
|
||||
WRChapterPageCount *pageCount = [self _pageCountForChapterData:chapterData];
|
||||
NSUInteger pageIdx = [pageCount pageIndexForCharacterIndex:positionOfFile];
|
||||
self.readingProgress.pageIndex = pageIdx;
|
||||
}
|
||||
[self _displayChapter:chapterData
|
||||
atPageIndex:self.readingProgress.pageIndex
|
||||
animated:YES];
|
||||
} else {
|
||||
[self _showChapterLoadError:error];
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
/// Advances to the next chapter.
|
||||
- (void)jumpReadingToNextChapter {
|
||||
NSUInteger nextIdx = self.readingProgress.chapterIndex + 1;
|
||||
if (nextIdx >= self.totalChapters) return;
|
||||
[self gotoChapterIdx:nextIdx position:0 positionOfFile:0];
|
||||
}
|
||||
|
||||
/// Goes back to the previous chapter.
|
||||
- (void)jumpReadingToPreChapter {
|
||||
if (self.readingProgress.chapterIndex == 0) return;
|
||||
NSUInteger prevIdx = self.readingProgress.chapterIndex - 1;
|
||||
[self gotoChapterIdx:prevIdx position:0 positionOfFile:0];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Page Flip Callback
|
||||
// ============================================================================
|
||||
|
||||
/// Called by WRPageViewController when a page flip animation completes.
|
||||
- (void)didFlipPage {
|
||||
// Step 1: Update reading progress.
|
||||
NSUInteger newPageIndex = self.pageViewController.currentPageIndex;
|
||||
self.readingProgress.pageIndex = newPageIndex;
|
||||
|
||||
WRChapterPageCount *pageCount = self.currentChapterPageCount;
|
||||
if (pageCount) {
|
||||
NSRange pageRange = [pageCount rangeForPageAtIndex:newPageIndex];
|
||||
if (pageRange.location != NSNotFound) {
|
||||
self.readingProgress.charIndex = pageRange.location;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Calculate read percentage.
|
||||
[self _updateReadPercentage];
|
||||
|
||||
// Step 3: Save progress (async).
|
||||
[self _saveReadingProgressAndIsAsync:YES];
|
||||
|
||||
// Step 4: Notify delegate.
|
||||
if ([self.readerDelegate respondsToSelector:
|
||||
@selector(readerViewController:didUpdateProgress:)]) {
|
||||
[self.readerDelegate readerViewController:self
|
||||
didUpdateProgress:self.readingProgress];
|
||||
}
|
||||
|
||||
// Step 5: Prefetch adjacent chapters.
|
||||
[self _prefetchAdjacentChaptersForIndex:self.readingProgress.chapterIndex];
|
||||
|
||||
// Step 6: Post notification.
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
postNotificationName:kReaderDidFlipPage
|
||||
object:self
|
||||
userInfo:@{@"progress": self.readingProgress}];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Progress Persistence
|
||||
// ============================================================================
|
||||
|
||||
/// Saves the reading progress to NSUserDefaults.
|
||||
- (void)_saveReadingProgressAndIsAsync:(BOOL)isAsync {
|
||||
WRReadingProgress *progress = self.readingProgress;
|
||||
if (!progress || !progress.bookId) return;
|
||||
|
||||
NSString *key = [kReadingProgressKeyPrefix stringByAppendingString:progress.bookId];
|
||||
|
||||
NSDictionary *dict = @{
|
||||
@"bookId": progress.bookId ?: @"",
|
||||
@"chapterIndex": @(progress.chapterIndex),
|
||||
@"pageIndex": @(progress.pageIndex),
|
||||
@"charIndex": @(progress.charIndex),
|
||||
@"scrollOffset": @(progress.scrollOffset),
|
||||
@"chapterId": progress.chapterId ?: @"",
|
||||
@"readPercentage": @(progress.readPercentage),
|
||||
@"timestamp": @([[NSDate date] timeIntervalSince1970]),
|
||||
};
|
||||
|
||||
if (isAsync) {
|
||||
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
||||
[[NSUserDefaults standardUserDefaults] setObject:dict forKey:key];
|
||||
[[NSUserDefaults standardUserDefaults] synchronize];
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
postNotificationName:kReaderProgressSaved object:self];
|
||||
});
|
||||
});
|
||||
} else {
|
||||
[[NSUserDefaults standardUserDefaults] setObject:dict forKey:key];
|
||||
[[NSUserDefaults standardUserDefaults] synchronize];
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the saved progress from NSUserDefaults.
|
||||
- (WRReadingProgress * __nullable)_loadSavedProgress {
|
||||
NSString *key = [kReadingProgressKeyPrefix stringByAppendingString:self.bookId ?: @""];
|
||||
NSDictionary *dict = [[NSUserDefaults standardUserDefaults] dictionaryForKey:key];
|
||||
if (!dict) return nil;
|
||||
|
||||
WRReadingProgress *progress = [[WRReadingProgress alloc] init];
|
||||
progress.bookId = dict[@"bookId"];
|
||||
progress.chapterIndex = [dict[@"chapterIndex"] unsignedIntegerValue];
|
||||
progress.pageIndex = [dict[@"pageIndex"] unsignedIntegerValue];
|
||||
progress.charIndex = [dict[@"charIndex"] unsignedIntegerValue];
|
||||
progress.scrollOffset = [dict[@"scrollOffset"] doubleValue];
|
||||
progress.chapterId = dict[@"chapterId"];
|
||||
progress.readPercentage = [dict[@"readPercentage"] doubleValue];
|
||||
return progress;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Typesetter
|
||||
// ============================================================================
|
||||
|
||||
/// Modifies typesetter attributes and triggers re-typeset.
|
||||
- (void)changeTypesetterAttributesWithBlock:(void (^ __nonnull)(NSMutableDictionary *attrs))block {
|
||||
NSMutableDictionary *attrs = [self _currentTypesetterAttributes].mutableCopy;
|
||||
block(attrs);
|
||||
[self _saveTypesetterAttributes:attrs];
|
||||
[self.pageCountCache removeAllObjects];
|
||||
|
||||
WRCoreTextLayouter *layouter = self.currentChapterData.layouter;
|
||||
[layouter updateAttributes:attrs];
|
||||
[self recomposeCurrentPageViewWithSource:@"typesetterChange"];
|
||||
}
|
||||
|
||||
/// Reads the current typesetter attributes from UserDefaults.
|
||||
- (NSDictionary *)_currentTypesetterAttributes {
|
||||
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
|
||||
return @{
|
||||
@"fontSize": @([defaults floatForKey:@"WRTypesetterFontSize"] ?: 18.0),
|
||||
@"lineSpacing": @([defaults floatForKey:@"WRTypesetterLineSpacing"] ?: 1.5),
|
||||
@"fontFamily": [defaults stringForKey:@"WRTypesetterFontFamily"] ?: @"PingFang SC",
|
||||
@"paragraphSpacing": @([defaults floatForKey:@"WRTypesetterParaSpacing"] ?: 8.0),
|
||||
};
|
||||
}
|
||||
|
||||
/// Saves typesetter attributes to UserDefaults.
|
||||
- (void)_saveTypesetterAttributes:(NSDictionary *)attrs {
|
||||
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
|
||||
[defaults setFloat:[attrs[@"fontSize"] floatValue] forKey:@"WRTypesetterFontSize"];
|
||||
[defaults setFloat:[attrs[@"lineSpacing"] floatValue] forKey:@"WRTypesetterLineSpacing"];
|
||||
[defaults setObject:attrs[@"fontFamily"] forKey:@"WRTypesetterFontFamily"];
|
||||
[defaults setFloat:[attrs[@"paragraphSpacing"] floatValue] forKey:@"WRTypesetterParaSpacing"];
|
||||
[defaults synchronize];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Pagination
|
||||
// ============================================================================
|
||||
|
||||
/// Initializes or re-initializes the chapter page count calculator.
|
||||
- (void)initChapterPageCount {
|
||||
WRChapterData *chapterData = self.currentChapterData;
|
||||
if (!chapterData) return;
|
||||
|
||||
// Generate the cache key based on book ID and current typesetter settings.
|
||||
NSString *cacheKey = [WRChapterPageCount currentCacheKeyWithBookId:self.bookId];
|
||||
|
||||
// Check cache first.
|
||||
WRChapterPageCount *cached = self.pageCountCache[cacheKey];
|
||||
if (cached) {
|
||||
self.currentChapterPageCount = cached;
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new page count calculator.
|
||||
WRChapterPageCount *pageCount = [[WRChapterPageCount alloc] init];
|
||||
pageCount.bookId = self.bookId;
|
||||
pageCount.chapterId = chapterData.chapterId;
|
||||
|
||||
// Compute page ranges by simulating CoreText typesetting.
|
||||
[pageCount recalculatePageRangesForAttributedString:chapterData.typesetAttributedString
|
||||
drawingSize:self.view.bounds.size
|
||||
margins:chapterData.contentInsets];
|
||||
|
||||
// Cache the result.
|
||||
self.pageCountCache[cacheKey] = pageCount;
|
||||
self.currentChapterPageCount = pageCount;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Internal Chapter Loading
|
||||
// ============================================================================
|
||||
|
||||
/// Loads a chapter by index, with caching and retry logic.
|
||||
- (void)_loadChapterAtIndex:(NSUInteger)index
|
||||
completion:(void (^)(WRChapterData *, NSError *))completion {
|
||||
// Check cache first.
|
||||
WRChapterData *cached = self.chapterDataCache[@(index)];
|
||||
if (cached) {
|
||||
if (completion) completion(cached, nil);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent duplicate loads.
|
||||
if (self.isLoadingChapter) return;
|
||||
self.isLoadingChapter = YES;
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
dispatch_async(self.chapterLoadQueue, ^{
|
||||
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||
if (!strongSelf) return;
|
||||
|
||||
// In the real implementation, this:
|
||||
// 1. Fetches the chapter HTML/content from the server or local cache.
|
||||
// 2. Parses the HTML into an NSAttributedString.
|
||||
// 3. Runs WRCoreTextLayouter to typeset the chapter.
|
||||
// 4. Stores the result in the cache.
|
||||
|
||||
// For this reconstruction, we simulate the flow:
|
||||
NSError *error = nil;
|
||||
WRChapterData *chapterData = [strongSelf _fetchChapterDataForIndex:index
|
||||
error:&error];
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
strongSelf.isLoadingChapter = NO;
|
||||
|
||||
if (chapterData) {
|
||||
strongSelf.chapterDataCache[@(index)] = chapterData;
|
||||
strongSelf.loadRetryCount = 0;
|
||||
if (completion) completion(chapterData, nil);
|
||||
} else {
|
||||
// Retry logic.
|
||||
if (strongSelf.loadRetryCount < strongSelf.maxRetryCount) {
|
||||
strongSelf.loadRetryCount++;
|
||||
[strongSelf _loadChapterAtIndex:index completion:completion];
|
||||
} else {
|
||||
strongSelf.loadRetryCount = 0;
|
||||
if (completion) completion(nil, error);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetches and typesets a chapter (placeholder for the real network/cache logic).
|
||||
- (WRChapterData * __nullable)_fetchChapterDataForIndex:(NSUInteger)index
|
||||
error:(NSError **)errorOut {
|
||||
// In the real app, this method:
|
||||
// 1. Checks local SQLite/LevelDB cache for the chapter content.
|
||||
// 2. If not cached, makes an API request to the WeRead server.
|
||||
// 3. Receives chapter HTML (potentially encrypted/obfuscated).
|
||||
// 4. Decrypts and parses the HTML into an NSAttributedString.
|
||||
// 5. Creates a WRCoreTextLayouter with the current typesetter attributes.
|
||||
// 6. Runs the layouter to compute line breaks and page breaks.
|
||||
// 7. Returns the populated WRChapterData.
|
||||
|
||||
// Placeholder: return nil to simulate a network fetch that needs to happen
|
||||
// in the real binary.
|
||||
if (errorOut) {
|
||||
*errorOut = [NSError errorWithDomain:@"com.weread.reader"
|
||||
code:-1
|
||||
userInfo:@{NSLocalizedDescriptionKey: @"Not implemented in reconstruction"}];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
/// Returns the chapter data for the given index (from cache or nil).
|
||||
- (WRChapterData * __nullable)_chapterDataForIndex:(NSUInteger)index {
|
||||
return self.chapterDataCache[@(index)];
|
||||
}
|
||||
|
||||
/// Returns the page count for the given chapter data, computing it if needed.
|
||||
- (WRChapterPageCount * __nullable)_pageCountForChapterData:(WRChapterData *)chapterData {
|
||||
if (!chapterData) return nil;
|
||||
|
||||
NSString *cacheKey = [WRChapterPageCount currentCacheKeyWithBookId:self.bookId];
|
||||
WRChapterPageCount *cached = self.pageCountCache[cacheKey];
|
||||
if (cached) return cached;
|
||||
|
||||
// Compute now.
|
||||
[self initChapterPageCount];
|
||||
return self.currentChapterPageCount;
|
||||
}
|
||||
|
||||
/// Re-typesets a chapter data object (after font/spacing changes).
|
||||
- (void)_reTypesetChapterData:(WRChapterData *)chapterData {
|
||||
if (!chapterData.sourceAttributedString) return;
|
||||
|
||||
// Re-create the layouter with updated attributes.
|
||||
NSDictionary *attrs = [self _currentTypesetterAttributes];
|
||||
WRCoreTextLayouter *layouter = [[WRCoreTextLayouter alloc]
|
||||
initWithAttributedString:chapterData.sourceAttributedString
|
||||
attributes:attrs];
|
||||
chapterData.layouter = layouter;
|
||||
|
||||
// Re-generate the typeset attributed string.
|
||||
chapterData.typesetAttributedString = [layouter typesetAttributedString];
|
||||
|
||||
// Re-generate page ranges.
|
||||
[self initChapterPageCount];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Chapter Prefetching
|
||||
// ============================================================================
|
||||
|
||||
/// Prefetches the next and previous chapters so they're ready when the
|
||||
/// user flips to them.
|
||||
- (void)_prefetchAdjacentChaptersForIndex:(NSUInteger)index {
|
||||
// Prefetch next chapter.
|
||||
if (index + 1 < self.totalChapters) {
|
||||
NSUInteger nextIdx = index + 1;
|
||||
if (!self.chapterDataCache[@(nextIdx)]) {
|
||||
dispatch_async(self.chapterLoadQueue, ^{
|
||||
[self _fetchChapterDataForIndex:nextIdx error:NULL];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Prefetch previous chapter.
|
||||
if (index > 0) {
|
||||
NSUInteger prevIdx = index - 1;
|
||||
if (!self.chapterDataCache[@(prevIdx)]) {
|
||||
dispatch_async(self.chapterLoadQueue, ^{
|
||||
[self _fetchChapterDataForIndex:prevIdx error:NULL];
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Display Helpers
|
||||
// ============================================================================
|
||||
|
||||
/// Displays a chapter at the given page index.
|
||||
- (void)_displayChapter:(WRChapterData *)chapterData
|
||||
atPageIndex:(NSUInteger)pageIndex
|
||||
animated:(BOOL)animated {
|
||||
self.currentChapterData = chapterData;
|
||||
[self initChapterPageCount];
|
||||
|
||||
// Create a page view for the initial display.
|
||||
WRPageView *pageView = [[WRPageView alloc] initWithFrame:self.view.bounds];
|
||||
[self.activePageViews removeAllObjects];
|
||||
[self.activePageViews addObject:pageView];
|
||||
|
||||
// Render the page.
|
||||
WRReadingProgress *progress = [self.readingProgress copy];
|
||||
progress.pageIndex = pageIndex;
|
||||
[self renderPageView:pageView progressData:progress source:@"displayChapter"];
|
||||
|
||||
// Set the page view controller's initial view controller.
|
||||
UIViewController *pageContentVC = [[UIViewController alloc] init];
|
||||
pageContentVC.view = pageView;
|
||||
|
||||
[self.pageViewController setViewControllers:@[pageContentVC]
|
||||
direction:UIPageViewControllerNavigationDirectionForward
|
||||
animated:animated
|
||||
completion:nil];
|
||||
}
|
||||
|
||||
/// Shows an error state when chapter loading fails.
|
||||
- (void)_showChapterLoadError:(NSError *)error {
|
||||
NSLog(@"[WRReader] Chapter load error: %@", error.localizedDescription);
|
||||
// In the real app, this shows a toast or error overlay.
|
||||
}
|
||||
|
||||
/// Calculates and updates the overall read percentage.
|
||||
- (void)_updateReadPercentage {
|
||||
NSUInteger totalChapters = self.totalChapters;
|
||||
if (totalChapters == 0) return;
|
||||
|
||||
NSUInteger currentChapter = self.readingProgress.chapterIndex;
|
||||
WRChapterPageCount *pageCount = self.currentChapterPageCount;
|
||||
NSUInteger totalPages = pageCount.totalPages;
|
||||
NSUInteger currentPage = self.readingProgress.pageIndex;
|
||||
|
||||
// Calculate: (chaptersCompleted + currentPage/totalPages) / totalChapters
|
||||
double chapterProgress = (totalPages > 0)
|
||||
? (double)currentPage / (double)totalPages
|
||||
: 0.0;
|
||||
double overall = ((double)currentChapter + chapterProgress) / (double)totalChapters;
|
||||
self.readingProgress.readPercentage = MIN(MAX(overall, 0.0), 1.0);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Auto-Read
|
||||
// ============================================================================
|
||||
|
||||
/// Starts the auto-read timer.
|
||||
- (void)_startAutoRead {
|
||||
[_autoReadTimer invalidate];
|
||||
_autoReadTimer =
|
||||
[NSTimer scheduledTimerWithTimeInterval:5.0
|
||||
target:self
|
||||
selector:@selector(_autoReadTick)
|
||||
userInfo:nil
|
||||
repeats:YES];
|
||||
}
|
||||
|
||||
/// Stops auto-read.
|
||||
- (void)_stopAutoRead {
|
||||
[_autoReadTimer invalidate];
|
||||
_autoReadTimer = nil;
|
||||
}
|
||||
|
||||
/// Called each auto-read tick to advance the page.
|
||||
- (void)_autoReadTick {
|
||||
BOOL advanced = [self.pageViewController goToNextPageAnimated:YES];
|
||||
if (!advanced) {
|
||||
// At the end of the chapter, try to go to the next chapter.
|
||||
if (self.readingProgress.chapterIndex + 1 < self.totalChapters) {
|
||||
[self jumpReadingToNextChapter];
|
||||
} else {
|
||||
// At the end of the book, stop auto-read.
|
||||
[self _stopAutoRead];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Notification Handlers
|
||||
// ============================================================================
|
||||
|
||||
/// Handles memory warning by purging non-current chapter data from cache.
|
||||
- (void)_handleMemoryWarning:(NSNotification *)note {
|
||||
NSLog(@"[WRReader] Memory warning received, purging chapter cache.");
|
||||
NSUInteger currentIdx = self.readingProgress.chapterIndex;
|
||||
WRChapterData *currentData = self.chapterDataCache[@(currentIdx)];
|
||||
|
||||
[self.chapterDataCache removeAllObjects];
|
||||
if (currentData) {
|
||||
self.chapterDataCache[@(currentIdx)] = currentData;
|
||||
}
|
||||
|
||||
[self.pageCountCache removeAllObjects];
|
||||
}
|
||||
|
||||
/// Handles the auto-read advance notification from WRPageView.
|
||||
- (void)_handleAutoReadAdvance:(NSNotification *)note {
|
||||
if (!_autoReadEnabled) return;
|
||||
[self _autoReadTick];
|
||||
}
|
||||
|
||||
/// Handles font size change notification from WRPageView.
|
||||
- (void)_handleFontSizeChange:(NSNotification *)note {
|
||||
NSInteger delta = [note.userInfo[@"delta"] integerValue];
|
||||
if (delta == 0) return;
|
||||
|
||||
[self changeTypesetterAttributesWithBlock:^(NSMutableDictionary *attrs) {
|
||||
CGFloat currentSize = [attrs[@"fontSize"] floatValue];
|
||||
CGFloat newSize = currentSize + (CGFloat)delta;
|
||||
newSize = MAX(12.0, MIN(36.0, newSize)); // Clamp to reasonable range.
|
||||
attrs[@"fontSize"] = @(newSize);
|
||||
}];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Book Data Loading
|
||||
// ============================================================================
|
||||
|
||||
/// Loads book metadata from cache or server.
|
||||
- (void)_loadBookDataWithBookId:(NSString *)bookId {
|
||||
// In the real app, this makes an API call to fetch book metadata:
|
||||
// - Title, author, cover image URL
|
||||
// - Chapter list (IDs, titles)
|
||||
// - User's reading progress (if synced)
|
||||
// - Trial/free chapter limits
|
||||
//
|
||||
// The response populates self.book and self.readingProgress.
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - User Preferences
|
||||
// ============================================================================
|
||||
|
||||
/// Returns the user's preferred page flipping style from UserDefaults.
|
||||
- (WRPageFlippingStyle)_userPreferredFlipStyle {
|
||||
NSInteger style = [[NSUserDefaults standardUserDefaults]
|
||||
integerForKey:@"WRReaderFlipStyle"];
|
||||
return (WRPageFlippingStyle)style;
|
||||
}
|
||||
|
||||
/// Periodic progress save callback.
|
||||
- (void)_periodicProgressSave {
|
||||
[self _saveReadingProgressAndIsAsync:YES];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - WRPageViewControllerDelegate
|
||||
// ============================================================================
|
||||
|
||||
/// Called when the page view controller finishes a page transition.
|
||||
- (void)pageViewController:(WRPageViewController *)pageViewController
|
||||
didFinishAnimating:(BOOL)finished
|
||||
previousViewControllers:(NSArray<UIViewController *> *)previousViewControllers
|
||||
transitionCompleted:(BOOL)completed {
|
||||
if (completed) {
|
||||
[self didFlipPage];
|
||||
}
|
||||
}
|
||||
|
||||
/// Called before a page transition begins.
|
||||
- (void)pageViewController:(WRPageViewController *)pageViewController
|
||||
willTransitionToViewControllers:(NSArray<UIViewController *> *)pendingViewControllers {
|
||||
// Pre-render the upcoming page view.
|
||||
if (pendingViewControllers.count > 0) {
|
||||
// The pending VC's page view needs its layout frame set.
|
||||
// This happens in renderPageView: when the transition completes.
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
#pragma mark - Total Chapters (computed)
|
||||
// ============================================================================
|
||||
|
||||
/// Returns the total number of chapters in the book.
|
||||
- (NSUInteger)totalChapters {
|
||||
// In the real app, this comes from the book model.
|
||||
return self.book.chapterCount ?: 0;
|
||||
}
|
||||
|
||||
@end
|
||||
439
Doc/WXRead/objc_metadata/all_method_signatures.txt
Normal file
439
Doc/WXRead/objc_metadata/all_method_signatures.txt
Normal file
@ -0,0 +1,439 @@
|
||||
+[WRBookNetwork _removeTranslateHtml:]
|
||||
+[WRBookNetwork addMileStone:callback:]
|
||||
+[WRBookNetwork addReview:shareToWechat:audioArticleId:outlineContent:audioColumnId:callback:]
|
||||
+[WRBookNetwork automaticallyMarkFinishReadingWithBookId:callback:]
|
||||
+[WRBookNetwork chaptersInfoFromFile:checkTranslate:]
|
||||
+[WRBookNetwork checkFMCards:callback:]_block_invoke
|
||||
+[WRBookNetwork clearPreloadKVWithBookId:chapterUid:zipPath:]
|
||||
+[WRBookNetwork dislikeReviewById:isDislike:withParams:callback:]
|
||||
+[WRBookNetwork fetchLockInfoWithBookId:callback:]
|
||||
+[WRBookNetwork fileContentWithChapter:book:shouldRemoveHtmlTags:filterTranslateContent:]
|
||||
+[WRBookNetwork fileContentWithChapter:book:shouldRemoveHtmlTags:filterTranslateContent:]_block_invoke
|
||||
+[WRBookNetwork handleUnzipErrorWithPath:plainBookDirectory:]
|
||||
+[WRBookNetwork handleUnzipWithBookId:zipPath:encryptKey:plainBookDirectory:]
|
||||
+[WRBookNetwork handleUnzipWithBookId:zipPath:encryptKey:plainBookDirectory:]_block_invoke
|
||||
+[WRBookNetwork likeReviewById:isUnlike:withParams:callback:]
|
||||
+[WRBookNetwork loadArticleBookDetailWithBookId:callback:]
|
||||
+[WRBookNetwork loadBookDetailPodcastsWithBookId:withSynckey:withListType:withFilterType:withMaxIdx:withCount:]
|
||||
+[WRBookNetwork loadBookInfoWithBookId:source:callback:]
|
||||
+[WRBookNetwork loadBookLectureAuthors:callback:]
|
||||
+[WRBookNetwork loadBookReadDetailInfoWithBookId:callback:]
|
||||
+[WRBookNetwork loadBookReadInfoWithBookId:callback:]
|
||||
+[WRBookNetwork loadBookReadInfoWithBookIdAndVid:vid:callback:]
|
||||
+[WRBookNetwork loadBookmarkListWithBookId:syncKey:callback:]
|
||||
+[WRBookNetwork loadChapterContentWithParam:callback:]
|
||||
+[WRBookNetwork loadChapterContentWithParam:callback:]_block_invoke
|
||||
+[WRBookNetwork loadExchangeRecordListWithParams:callback:]_block_invoke
|
||||
+[WRBookNetwork loadFMCardsWithBookId:withSynckey:withListType:withFilterType:withMaxIdx:withCount:]
|
||||
+[WRBookNetwork loadFriendMarkWithBookId:synckey:]_block_invoke
|
||||
+[WRBookNetwork loadPodcastsWithSynckey:withBookId:withCount:withMaxIdx:callback:]
|
||||
+[WRBookNetwork loadReadTimeWelfareActionWithBookId:opt:secretKey:firstEnter:]
|
||||
+[WRBookNetwork loadRelatedBooksForReviewDetail:callback:]
|
||||
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:]
|
||||
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:]_block_invoke
|
||||
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:]_block_invoke_2
|
||||
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:]_block_invoke_5
|
||||
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:]_block_invoke_6
|
||||
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:]_block_invoke_7
|
||||
+[WRBookNetwork loadTarForEpubBookId:chapters:isPreload:]
|
||||
+[WRBookNetwork loadTopicReviewlist:callback:]
|
||||
+[WRBookNetwork markReadingStatus:bookIds:isCancel:callback:]
|
||||
+[WRBookNetwork markReadingStatus:withBookId:isCancel:withFinishInfo:callback:]
|
||||
+[WRBookNetwork pollingChapterTranslateStatusWithBookId:isFreeTrialActive:referenceLocationDict:chapterTranslations:from:]
|
||||
+[WRBookNetwork postReviewHideWithBookId:hide:callback:]
|
||||
+[WRBookNetwork processChapterInfosFromChapterDownload:bookId:]
|
||||
+[WRBookNetwork processEncryptedBookFileAtPath:encryptKey:book:chapterUid:isFromReview:]
|
||||
+[WRBookNetwork repostReview:reposted:callback:]
|
||||
+[WRBookNetwork resetChapterPaidIfNeededWithBookId:chapterUid:]
|
||||
+[WRBookNetwork rewardReviewForId:price:timestamp:callback:]
|
||||
+[WRBookNetwork savePreloadInfoWithDownloadParam:chaptersStr:timeFlag:tmpFilePath:encryptKey:]
|
||||
+[WRBookNetwork searchResultsForBook:chapterUid:searchString:posBeg:posEnd:mode:callback:]
|
||||
+[WRBookNetwork searchResultsForLocalBook:chapterUid:searchString:posBeg:posEnd:mode:callback:]
|
||||
+[WRBookNetwork setFinishReading:withBookId:callback:]
|
||||
+[WRBookNetwork setIsStartReading:withBookId:callback:]
|
||||
+[WRBookNetwork storeTarEpubImageToDiskWithBookId:chapterUid:untarDirectory:]
|
||||
+[WRBookNetwork uploadBookProgressAndReadingTime:callback:offlineCallback:]
|
||||
+[WRBookNetworkReporter verifyZipWithBook:zipPath:fileSize:unzippedFiles:]
|
||||
+[WRChapterData addUnderLineToAttributedString:range:itemId:style:color:]
|
||||
+[WRChapterData freeTrialChapterCutOffStringLocaionWithAttributedString:book:]
|
||||
+[WRChapterDownloadManger imageManagerForEpubBookWithBookId:]
|
||||
+[WRChapterDownloadManger loadChapterContentNetworkWithParam:callback:]
|
||||
+[WRChapterPageCount currentCacheKeyWithBookId:]
|
||||
+[WRChapterPageCount rangeValueWithPageInfo:]
|
||||
+[WRCoreTextLayouter convertHansToHantWithAttributedString:]_block_invoke
|
||||
+[WREncryptedFileManager decryptContentsOfFile:forBookId:isFileLost:]
|
||||
+[WREncryptedFileManager encryptFileForBookId:originalEncryptKey:atPath:toPath:]
|
||||
+[WREncryptedFileManager keyForBookId:]
|
||||
+[WREpubPositionConverter transformToNumberHtmlEntitesWithOriginHtmlEitites:]_block_invoke
|
||||
+[WREpubTypesetter attributeStringWithFilePath:priority:insertArticleToolAttachment:insertBookChapterToolAttachment:insertRecommendView:book:chapter:pageFlippingStyle:renderErrorReason:isStyleFileNotFound:options:]
|
||||
+[WREpubTypesetter tryReportTranslationError:bookId:chapter:isTranslationStyleNotFound:isTranslationContentNotFound:isTranslateTagButNoTranslateStyle:]
|
||||
+[WRMarkContentStore markContentListForBookId:chapterUid:offset:count:]
|
||||
+[WRMarkContentStore syncMarkContentListForBookId:chapterUid:]
|
||||
+[WRMarketNetwork loadCategoryBooks:param:callback:]
|
||||
+[WRPreloadBookManager clearKV]
|
||||
+[WRPreloadBookManager encryptKeyForPath:bookId:]
|
||||
+[WRPreloadBookManager fileNameForKey:bookId:]
|
||||
+[WRPreloadBookManager removeEncryptKeyForPath:bookId:]
|
||||
+[WRPreloadBookManager removeFileNameForKey:bookId:]
|
||||
+[WRPreloadBookManager removeKVWithBookId:]
|
||||
+[WRPreloadBookManager saveEncryptKey:forPath:bookId:]
|
||||
+[WRPreloadBookManager saveFileNameDict:bookId:]
|
||||
+[WRReaderBackgroundAdapter updateDownloadedBackground]_block_invoke
|
||||
+[WRReaderBackgroundAdapter updateDownloadedBackground]_block_invoke_2
|
||||
+[WRReaderBitmapColorHelper checkImageShouldAddBackgound:imageUrl:completedBlock:]
|
||||
+[WRReaderBitmapColorHelper checkWhiteBlackImageAndTransformToTransparent:completedBlock:]
|
||||
+[WRReaderBitmapColorHelper whiteToTransparentWithImage:completedBlock:]
|
||||
+[WRReaderBookBorrowUtils redeemShortTimeReadWithTargetView:bookId:successBlock:]
|
||||
+[WRReaderBookBorrowUtils redeemShortTimeReadWithTargetView:bookId:successBlock:]_block_invoke
|
||||
+[WRReaderBookBorrowUtils showBorrowConfirmationPanelWithBorrowInfo:confirmBlock:cancelBlock:willShowBlock:]
|
||||
+[WRReaderBookBorrowUtils showBorrowConfirmationPanelWithBorrowInfo:confirmBlock:cancelBlock:willShowBlock:]_block_invoke
|
||||
+[WRReaderBookBorrowUtils showBorrowConfirmationPanelWithBorrowInfo:confirmBlock:cancelBlock:willShowBlock:]_block_invoke_3
|
||||
+[WRReaderCatalogSearchManager appendSearchHistoryWithBookId:keyword:]
|
||||
+[WRReaderCatalogSearchManager purgeSearchHistoryWithBookId:]
|
||||
+[WRReaderCatalogSearchManager searchHistoryWithBookId:]
|
||||
+[WRReaderCht2sManager autoConvertToCht2sStatusWithBook:dataTranslateMode:scene:hasRights:]
|
||||
+[WRReaderCht2sManager canConvertCht2sWithBookId:]
|
||||
+[WRReaderCht2sManager checkCht2sBookShouldSwitchLanguageForBookId:chapterUid:]
|
||||
+[WRReaderContentNavigationManager addRangeValueToOutlineItem:]
|
||||
+[WRReaderContentNavigationManager findParentNodesFromChapterOutlineLeafItems:]_block_invoke
|
||||
+[WRReaderContentNavigationManager rangeForOutlineItem:]
|
||||
+[WRReaderPencilNoteManager authCosForPencilDataWithSuffix:]_block_invoke
|
||||
+[WRReaderPencilNoteManager checkDrawingExistsWithReviewItemId:reviewId:isDraft:]
|
||||
+[WRReaderPencilNoteManager deleteAllReviewDrawings]
|
||||
+[WRReaderPencilNoteManager deleteDrawingWithReviewItemId:reviewId:isDraft:]
|
||||
+[WRReaderPencilNoteManager downloadDrawingDataFromCosWithUrl:desPath:callback:]
|
||||
+[WRReaderPencilNoteManager downloadDrawingDataFromCosWithUrl:desPath:callback:]_block_invoke
|
||||
+[WRReaderPencilNoteManager downloadDrawingWithReviewItemId:reviewId:drawingUrl:dataBlock:]_block_invoke_2
|
||||
+[WRReaderPencilNoteManager downloadDrawingWithReviewItemId:reviewId:drawingUrl:dataBlock:]_block_invoke_3
|
||||
+[WRReaderPencilNoteManager drawingFileDirectory]
|
||||
+[WRReaderPencilNoteManager drawingFilePathWithReviewItemId:reviewId:isDraft:]
|
||||
+[WRReaderPencilNoteManager imageFilePathWithReviewItemId:reviewId:]
|
||||
+[WRReaderPencilNoteManager readDrawingWithReview:dataBlock:drawingUrlBlock:]_block_invoke
|
||||
+[WRReaderPencilNoteManager readDrawingWithReview:dataBlock:drawingUrlBlock:]_block_invoke_2
|
||||
+[WRReaderPencilNoteManager readDrawingWithReview:dataBlock:drawingUrlBlock:]_block_invoke_3
|
||||
+[WRReaderPencilNoteManager uploadPencilDrawing:colorStyle:onlyUploadImage:canRetry:]
|
||||
+[WRReaderPencilNoteManager uploadPencilDrawing:colorStyle:onlyUploadImage:canRetry:]_block_invoke
|
||||
+[WRReaderPencilNoteManager uploadPencilNoteData:suffix:]
|
||||
+[WRReaderPencilNoteManager uploadPencilNoteData:suffix:]_block_invoke
|
||||
+[WRReaderPencilNoteManager uploadPencilNoteData:suffix:]_block_invoke_3
|
||||
+[WRReaderPencilNoteManager writeDrawingDataToLocal:reviewItemId:reviewId:isDraft:]
|
||||
+[WRReaderPencilNoteManager writeDrawingToLocal:reviewItemId:reviewId:isDraft:]
|
||||
+[WRReaderProgress localProgressForBook:]
|
||||
+[WRReaderTranslationManager canSwitchTranslationWithBookId:]
|
||||
+[WRReaderTranslationManager chapterDataCacheKeyWithBookId:chapterUid:]
|
||||
+[WRReaderTranslationManager isPlayChineseWithBookId:chapterUid:]
|
||||
+[WRReaderTranslationManager tryPollingConvertStatusWithBook:isForTranslate:referenceLocationDict:isFreeTrialActive:chapterTranslations:updateTipsBlock:chapterTranslateCompletion:stopPollingBlock:errorBlock:isBatch:from:]_block_invoke
|
||||
+[WRReaderTranslationManager updatePlayTranslation:forBookId:]
|
||||
+[WRReaderTranslationManager wordCountWithBookId:chapterUid:]
|
||||
+[WRReaderUnderlineStyleButtonManager bookmarkColorForColorStyle:underlineStyle:]
|
||||
-[DTCoreTextGlyphRun newPathWithGlyphs]
|
||||
-[DTHTMLAttributedStringBuilder _buildString]
|
||||
-[DTHTMLAttributedStringBuilder _registerTagEndHandlers]_block_invoke_5
|
||||
-[DTHTMLAttributedStringBuilder _registerTagStartHandlers]_block_invoke_11
|
||||
-[DTHTMLAttributedStringBuilder parser:didStartElement:attributes:position:]
|
||||
-[DTHTMLAttributedStringBuilder parser:foundCDATA:]
|
||||
-[DTHTMLAttributedStringBuilder parser:foundCharacters:position:]
|
||||
-[DTHTMLAttributedStringBuilder parserDidEndDocument:]
|
||||
-[DTHTMLElement applyStyleDictionary:isLatinLanguageBook:]
|
||||
-[DTHTMLElement attributedString]
|
||||
-[DTHTMLElement interpretAttributes]
|
||||
-[WRChapter isChapterContentDownloaded]
|
||||
-[WRChapterData addAutoReadUnderLineInRange:style:color:]
|
||||
-[WRChapterData addHighlightInRange:key:itemId:color:]
|
||||
-[WRChapterData addReviewUnderlineInRange:itemId:type:]
|
||||
-[WRChapterData addTempReviewHighlightInRange:itemId:]
|
||||
-[WRChapterData addTempReviewHighlightInRange:itemId:color:]
|
||||
-[WRChapterData deleteReviewUnderlineInRange:type:]
|
||||
-[WRChapterData freeTrialChapterCutOffRealStringLocation]
|
||||
-[WRChapterData generateOutlineContents]
|
||||
-[WRChapterData markFreeTrialChapterCutOffStringLocation:]
|
||||
-[WRChapterData rangeOfPage:]
|
||||
-[WRChapterDownloadManger _preloadChapterContentWithBook:type:bookRank:]
|
||||
-[WRChapterDownloadManger addDownloadTaskCount]
|
||||
-[WRChapterDownloadManger createDownloadTaskSignalWithParam:callback:]_block_invoke
|
||||
-[WRChapterDownloadManger createDownloadTaskSignalWithParam:callback:]_block_invoke_3
|
||||
-[WRChapterDownloadManger downloadComicsChaptersIfNotExistWithParam:withCallback:]
|
||||
-[WRChapterDownloadManger loadChapterContentWithParam:callback:]
|
||||
-[WRChapterDownloadManger networkChanged]
|
||||
-[WRChapterDownloadManger preloadShelfBooksForType:]
|
||||
-[WRChapterDownloadManger removeDownloadTaskCount]
|
||||
-[WRChapterDownloadManger resetChapterDownloadTaskCount]
|
||||
-[WRChapterRecommendAlbumsView handleTouchAlbum:]
|
||||
-[WRChapterRecommendAlbumsView handleTouchAlbum:]_block_invoke
|
||||
-[WRChapterRecommendAlbumsView handleTouchAlbum:]_block_invoke_2
|
||||
-[WRChapterRecommendAlbumsView initWithDataChangeSignal:]_block_invoke
|
||||
-[WRCoreTextLayoutFrame avoidPageBreakInsideByRemovingLastLinesIfNeeded]
|
||||
-[WRCoreTextLayoutFrame drawInContext:image:size:inRect:position:]
|
||||
-[WRCoreTextLayoutFrame getRenderHeight]
|
||||
-[WRCoreTextLayoutFrame lines]
|
||||
-[WRCoreTextLayouter pageBackgroundImageAtRange:themeBgColor:]
|
||||
-[WRCoreTextLayouter resizedImageForImagePath:rect:position:sizePattern:darkMode:themeBgColor:]
|
||||
-[WREpubParser epubController:didFailWithError:]
|
||||
-[WREpubPositionConverter indicesInFile:forRowColumnPairs:stringIndices:string:fileIndexOffset:stringIndexOffset:]
|
||||
-[WREpubPositionConverter initIndices]
|
||||
-[WREpubPositionConverter initWithFilePaths:attributedStrings:offset:isContainIntroFlyleaf:]
|
||||
-[WREpubPositionConverter stringRangeFromFileRange:]
|
||||
-[WRMarketPopToBookShelfTransition animateTransition:]_block_invoke_2
|
||||
-[WRPreloadBookManager _preloadWholeBook:scene:]
|
||||
-[WRPreloadBookManager _preloadWholeBook:scene:]_block_invoke
|
||||
-[WRPreloadBookManager booksDirectoryInfoForNonVIPWithOnlyCalc:]
|
||||
-[WRPreloadBookManager booksDirectoryInfoForVIPWithOnlyCalc:]
|
||||
-[WRPreloadBookManager calcAndClearPreloadBookWithCompletion:onlyCalc:]
|
||||
-[WRPreloadBookManager cleanUpPreloadBook]
|
||||
-[WRPreloadBookManager cleanUpPreloadBook]_block_invoke
|
||||
-[WRPreloadBookManager cleanUpPreloadBook]_block_invoke_2
|
||||
-[WRPreloadBookManager downloadBook:uids:]
|
||||
-[WRPreloadBookManager downloadBook:uids:]_block_invoke
|
||||
-[WRPreloadBookManager downloadingUidsWithBookId:allChapters:fromChapterIdx:]
|
||||
-[WRPreloadBookManager isBookCanPreloadForNonPayingVIP:]
|
||||
-[WRPreloadBookManager isBookCanPreloadForPayingVIP:outReasons:]
|
||||
-[WRPreloadBookManager preloadWithBook:fromChapterIdx:]
|
||||
-[WRPreloadBookManager preloadWithBook:fromChapterIdx:]_block_invoke
|
||||
-[WRPreloadBookManager refreshSetting]
|
||||
-[WRPreloadBookManager remainingDiskSizeInMB]
|
||||
-[WRPreloadBookManager resetBookDataWithBookId:]
|
||||
-[WRPreloadBookManager stopCleanUpPreloadBookTask]
|
||||
-[WRPreloadBookManager unzipBookCacheContentWithBookId:]
|
||||
-[WRPreloadBookManager unzipBookCacheContentWithBookId:]_block_invoke
|
||||
-[WRReaderAIKnowledgeViewController observeValueForKeyPath:ofObject:change:context:]
|
||||
-[WRReaderAuthorReviewDetailViewController handleCommentButtonEvent:]
|
||||
-[WRReaderAuthorReviewDetailViewController handleComposeButtonEvent:]_block_invoke
|
||||
-[WRReaderAuthorReviewDetailViewController handleDeleteComment:]_block_invoke_2
|
||||
-[WRReaderAuthorReviewDetailViewController handlePraiseButtonEvent:]
|
||||
-[WRReaderAuthorReviewDetailViewController handlePraiseButtonEvent:]_block_invoke
|
||||
-[WRReaderAuthorReviewDetailViewController handlePraiseButtonEvent:]_block_invoke_3
|
||||
-[WRReaderAuthorReviewDetailViewController keyboardWillChangeFrameWithUserInfo:]_block_invoke
|
||||
-[WRReaderAuthorReviewDetailViewController tableView:cellForRowAtIndexPath:]
|
||||
-[WRReaderAuthorReviewListViewController handleAvatarButtonEvent:]
|
||||
-[WRReaderAuthorReviewListViewController handlePraiseButtonEvent:]
|
||||
-[WRReaderAuthorReviewListViewController handlePraiseButtonEvent:]_block_invoke
|
||||
-[WRReaderAuthorReviewListViewController handlePraiseButtonEvent:]_block_invoke_3
|
||||
-[WRReaderAuthorReviewListViewController tableView:cellForRowAtIndexPath:]
|
||||
-[WRReaderAutoPlayer calcPageDuration]
|
||||
-[WRReaderAutoPlayer clauseFinished:]
|
||||
-[WRReaderAutoPlayer didBecomeActive]
|
||||
-[WRReaderAutoPlayer initWithDelegate:chapterData:forVerticalReader:]
|
||||
-[WRReaderAutoPlayer pause]
|
||||
-[WRReaderAutoPlayer play]
|
||||
-[WRReaderAutoPlayer setCurrentClauseIndex:]
|
||||
-[WRReaderAutoPlayer setCurrentLocation:]
|
||||
-[WRReaderAutoPlayer setSpeed:]
|
||||
-[WRReaderAutoPlayer setStatus:]
|
||||
-[WRReaderAutoPlayer setTmpClauseIndex:]
|
||||
-[WRReaderAutoPlayer setTmpLocation:]
|
||||
-[WRReaderAutoPlayer setupTimer]
|
||||
-[WRReaderAutoPlayer stop]
|
||||
-[WRReaderAutoPlayer willResignActive]
|
||||
-[WRReaderBackground(WRReaderBackgroundAdapter_Private) downloadWithProgressHandler:completionHandler:]_block_invoke
|
||||
-[WRReaderBookmarkViewController addBookmarkInCurrentReferenceArea]
|
||||
-[WRReaderBookmarkViewController deleteBookmarkWithItemId:callback:]
|
||||
-[WRReaderBrightnessChangeView brightSliderChanged:]
|
||||
-[WRReaderBrightnessChangeView initReaderBackgroundViewUI]_block_invoke
|
||||
-[WRReaderBrightnessChangeView initReaderBackgroundViewUI]_block_invoke_2
|
||||
-[WRReaderBrightnessChangeView sizeThatFits:]
|
||||
-[WRReaderCatalogContainerViewController pageController:didShowPageAtIndex:]_block_invoke
|
||||
-[WRReaderCatalogContainerViewController pageController:titleConfigurationAtIndex:]
|
||||
-[WRReaderCatalogContainerViewController pageController:viewControllerAtIndex:]
|
||||
-[WRReaderCatalogContainerViewController selectCatalogViewController]
|
||||
-[WRReaderCatalogContainerViewController selectOutlineViewController]
|
||||
-[WRReaderCatalogContainerViewController selectSearchContentViewController]
|
||||
-[WRReaderCatalogDataSource initWithBookId:type:delegate:]_block_invoke
|
||||
-[WRReaderCatalogViewController shouldRefreshCurrentAndBackupIndexPathCell]
|
||||
-[WRReaderChangeFirstIndentTableViewCell handleTapItemView:]
|
||||
-[WRReaderChangeOrientationView tableView:cellForRowAtIndexPath:]
|
||||
-[WRReaderChangePageTurningStyleView tableView:cellForRowAtIndexPath:]
|
||||
-[WRReaderCht2sManager updatePlayCht2s:forBookId:]
|
||||
-[WRReaderComicsViewModel chapterReviewForReviewId:]
|
||||
-[WRReaderComicsViewModel contentForChapterUid:]
|
||||
-[WRReaderComicsViewModel imageForURL:]_block_invoke_2
|
||||
-[WRReaderComicsViewModel markAsLiked:forChapterUid:]
|
||||
-[WRReaderComicsViewModel markAsLiked:forReview:]
|
||||
-[WRReaderComicsViewModel preloadNextChapterIfNeededWithCurrentChapterUid:]_block_invoke
|
||||
-[WRReaderComicsViewModel refreshAvailableRangeWithVisibleSectionRange:]
|
||||
-[WRReaderContentNavigationManager didLoadDataWithSusseed:]_block_invoke
|
||||
-[WRReaderContentNavigationManager findCurrentOutlineItemWithUniqId:chapterUid:]
|
||||
-[WRReaderContentNavigationManager findNextOutlineItemInRanges:loadMoreChapterUids:]
|
||||
-[WRReaderContentNavigationManager findPrevOutlineItemInRanges:loadMoreChapterUids:]
|
||||
-[WRReaderContentNavigationManager hasNextOutlineItemInRanges:]
|
||||
-[WRReaderContentNavigationManager hasNextOutlineItem]
|
||||
-[WRReaderContentNavigationManager hasPrevOutlineItemInRanges:]
|
||||
-[WRReaderContentNavigationManager hasPrevOutlineItem]
|
||||
-[WRReaderContentNavigationManager initSearchDataSourceWithBook:]
|
||||
-[WRReaderContentNavigationManager isCurrentOutlineItemVisible]
|
||||
-[WRReaderContentNavigationManager jumpToNextOutlineItemWithPreviousLoadMoreChapterUids:]
|
||||
-[WRReaderContentNavigationManager jumpToNextSearchItem]
|
||||
-[WRReaderContentNavigationManager jumpToOutlineItem:]
|
||||
-[WRReaderContentNavigationManager jumpToOutlineItemWithUniqId:chapterUid:highlight:]
|
||||
-[WRReaderContentNavigationManager jumpToPrevOutlineItemWithPreviousLoadMoreChapterUids:]
|
||||
-[WRReaderContentNavigationManager jumpToPrevSearchItem]
|
||||
-[WRReaderContentNavigationManager jumpToSearchItem:]
|
||||
-[WRReaderContentNavigationManager nextOutlineItemFromMemoryWithLoadMoreChapterUids:]
|
||||
-[WRReaderContentNavigationManager prevOutlineItemFromMemoryWithLoadMoreChapterUids:]
|
||||
-[WRReaderContentNavigationManager searchFrontAndBehindWithKeyword:chapterUid:position:addCurrentItem:callback:]
|
||||
-[WRReaderContentNavigationManager searchFrontAndBehindWithKeyword:chapterUid:position:addCurrentItem:callback:]_block_invoke
|
||||
-[WRReaderContentNavigationManager setCurrentSearchItem:]
|
||||
-[WRReaderContentNavigationManager setupOutlineWithInitialDict:]
|
||||
-[WRReaderContentNavigationManager updateOutlineDataWithBookId:]
|
||||
-[WRReaderDictionaryViewController collectionView:cellForItemAtIndexPath:]
|
||||
-[WRReaderDictionaryViewController observeValueForKeyPath:ofObject:change:context:]
|
||||
-[WRReaderDictionaryViewController refreshData]_block_invoke_4
|
||||
-[WRReaderEndOfTrialButton renderWithLabelDictionary:]
|
||||
-[WRReaderEndOfTrialView handleCampaignButtonTapWithItem:]
|
||||
-[WRReaderEndOfTrialView handleCampaignButtonTapWithItem:]_block_invoke
|
||||
-[WRReaderEndOfTrialView parseRightsViewData]
|
||||
-[WRReaderFloatReviewsViewController initHeaderToolViewIfNeeded]_block_invoke_2
|
||||
-[WRReaderFloatReviewsViewController renderWithHyperlinksInfo:]
|
||||
-[WRReaderFloatReviewsViewController setSelectedText:hyperlinksInfo:]
|
||||
-[WRReaderFontSizeChangeView autoTestHandleChangeValueWithSlider:index:]
|
||||
-[WRReaderFontSizeChangeView sizeThatFits:]
|
||||
-[WRReaderFontSizeChangeView updateFontFamilyChangeButtonFont]
|
||||
-[WRReaderHotUnderlinesViewController updateAppearanceForSectionHeaderView:inTableView:section:isPinned:]
|
||||
-[WRReaderLastPageActionButtonContainerView createFontViewWithFinishIndex:iterationBlock:]
|
||||
-[WRReaderLastPageBadgeView handleCheckButtonDownEvent:]
|
||||
-[WRReaderLastPageBadgeView handleCheckButtonUpEvent:]
|
||||
-[WRReaderLastPageBadgeView handleCheckButtonUpEvent:]_block_invoke_3
|
||||
-[WRReaderLastPageBadgeView handleCheckButtonUpOutSideEvent:]
|
||||
-[WRReaderLastPageViewController refreshFinishedBookInfo]_block_invoke
|
||||
-[WRReaderLastPageViewController rootViewDidChangeIntrinsicSize:]
|
||||
-[WRReaderLastPageViewController showing]
|
||||
-[WRReaderMilestoneLabelView renderWithAttributedStrings:]
|
||||
-[WRReaderMistakeViewController viewDidLoad]_block_invoke
|
||||
-[WRReaderMistakeViewController viewDidLoad]_block_invoke_2
|
||||
-[WRReaderModalView headerViewHeight]
|
||||
-[WRReaderMoreOperationController renderWithBook:scene:]
|
||||
-[WRReaderNoteViewModel initWithBookId:]
|
||||
-[WRReaderOutlineViewController handleJumpToClickContentWithParams:]
|
||||
-[WRReaderOutlineViewController initWithBookId:chapterUid:]_block_invoke
|
||||
-[WRReaderOutlineViewController initWithBookId:chapterUid:]_block_invoke_2
|
||||
-[WRReaderOutlineViewController viewDidLoad]
|
||||
-[WRReaderPanelSelectionView panelHeaderHeight]
|
||||
-[WRReaderPanelSelectionView panelTitle]
|
||||
-[WRReaderPanelSelectionView tableView:cellForRowAtIndexPath:]
|
||||
-[WRReaderPanelSelectionView tableView:didSelectRowAtIndexPath:]
|
||||
-[WRReaderPanelSelectionView tableView:numberOfRowsInSection:]
|
||||
-[WRReaderPencilNoteBaseView handleCanvasViewDrawingDidChange:]
|
||||
-[WRReaderPencilNoteReviewManager editPencilReview:secretMode:imageDict:drawingUrl:didEditBlock:]_block_invoke
|
||||
-[WRReaderPencilNoteReviewManager tryResendAllDraftDrawingReviews]_block_invoke
|
||||
-[WRReaderPencilNoteReviewManager tryResendAllDraftDrawingReviews]_block_invoke_2
|
||||
-[WRReaderPencilNoteReviewManager tryResendAllDraftDrawingReviews]_block_invoke_4
|
||||
-[WRReaderPencilNoteReviewManager tryResendAllDraftDrawingReviews]_block_invoke_5
|
||||
-[WRReaderPencilNoteReviewManager tryResendAllDraftDrawingReviews]_block_invoke_6
|
||||
-[WRReaderPencilNoteReviewManager uploadDrawing:thenEditReview:secretMode:readerVC:didUploadBlock:didEditReviewBlock:didWriteReviewBlock:]_block_invoke
|
||||
-[WRReaderPencilNoteReviewManager writeReview:secretMode:readerVC:imageDict:drawingUrl:didWriteBlock:]
|
||||
-[WRReaderPencilNoteReviewManager writeReview:secretMode:readerVC:imageDict:drawingUrl:didWriteBlock:]_block_invoke
|
||||
-[WRReaderPencilNoteReviewManager writeReview:secretMode:readerVC:imageDict:drawingUrl:didWriteBlock:]_block_invoke_2
|
||||
-[WRReaderPencilNoteViewController addPageReview:]
|
||||
-[WRReaderPencilNoteViewController addRangeReview:]
|
||||
-[WRReaderPencilNoteViewController checkUpdatePencilPageReview:pageNoteView:]
|
||||
-[WRReaderPencilNoteViewController checkUpdatePencilPageReview:pageNoteView:]_block_invoke
|
||||
-[WRReaderPencilNoteViewController checkUpdatePencilPageReview:pageNoteView:]_block_invoke_2
|
||||
-[WRReaderPencilNoteViewController checkUpdatePencilPageReview]
|
||||
-[WRReaderPencilNoteViewController checkUpdatePencilPageReview]_block_invoke
|
||||
-[WRReaderPencilNoteViewController checkUpdatePencilRangeReview]
|
||||
-[WRReaderPencilNoteViewController checkUpdatePencilRangeReview]_block_invoke
|
||||
-[WRReaderPencilNoteViewController checkUpdatePencilRangeReview]_block_invoke_2
|
||||
-[WRReaderPencilNoteViewController checkUpdatePencilRangeReview]_block_invoke_3
|
||||
-[WRReaderPencilNoteViewController checkUpdateRangeReviewDrawingAsDraft]
|
||||
-[WRReaderPencilNoteViewController dealloc]
|
||||
-[WRReaderPencilNoteViewController deleteEmptyDrawingReview:]_block_invoke_2
|
||||
-[WRReaderPencilNoteViewController editPencilReview:secretMode:imageDict:drawingUrl:]_block_invoke
|
||||
-[WRReaderPencilNoteViewController editRangeReviewForColorStyleSilentWithReview:]_block_invoke
|
||||
-[WRReaderPencilNoteViewController editRangeReviewForColorStyleSilentWithReview:]_block_invoke_2
|
||||
-[WRReaderPencilNoteViewController handleBookmarkUpdatedNotification:]
|
||||
-[WRReaderPencilNoteViewController handleCloseRangeReviewEditWithShowAlert:]
|
||||
-[WRReaderPencilNoteViewController handleCloseRangeReviewEditWithShowAlert:]_block_invoke
|
||||
-[WRReaderPencilNoteViewController handleDeleteRangeReview]_block_invoke_2
|
||||
-[WRReaderPencilNoteViewController init]
|
||||
-[WRReaderPencilNoteViewController resetContextWithRangeReviews:chapterIdx:defaultSecretMode:]
|
||||
-[WRReaderPencilNoteViewController setupPageNoteView]_block_invoke
|
||||
-[WRReaderPencilNoteViewController setupRangeNoteView]
|
||||
-[WRReaderPencilNoteViewController setupRangeNoteView]_block_invoke
|
||||
-[WRReaderPencilNoteViewController switchToRangeReview:curRange:curColorStyle:]
|
||||
-[WRReaderPencilPageNoteListView addCanvasViewDrawingDidChangeBlockWithNoteView:]_block_invoke
|
||||
-[WRReaderPencilPageNoteListView autoSaveEditingDrawingToLocalAsDraft]
|
||||
-[WRReaderPencilPageNoteListView handleUpdatePageNoteReviewsWithBlock:]
|
||||
-[WRReaderProgressChangeView handleNextButtonClick:]
|
||||
-[WRReaderProgressChangeView handlePreButtonClick:]
|
||||
-[WRReaderProgressChangeView sizeThatFits:]
|
||||
-[WRReaderProgressData initWithBook:forPDF:]
|
||||
-[WRReaderProgressData toCGIJsonDict]
|
||||
-[WRReaderReviewsRNViewController handleWriteReviewButtonEvent:]
|
||||
-[WRReaderSegmentViewController handleNotesOutputComputerButton]_block_invoke_2
|
||||
-[WRReaderSegmentViewController initNoteControllerIfNeeded]
|
||||
-[WRReaderSinglePurchaseEndTrailBookContentView renderData]
|
||||
-[WRReaderTextSelector updateTurningPageTimerWithGesture:]
|
||||
-[WRReaderTimer handleReadingTime:]
|
||||
-[WRReaderTimer setActive:]
|
||||
-[WRReaderTimer startTimer]
|
||||
-[WRReaderUnderlineStyleButtonManager updateSelectedStyleButtonUI]
|
||||
-[WRReaderViewController _saveReadingProgressAndIsAsync:]
|
||||
-[WRReaderViewController addObservers]_block_invoke
|
||||
-[WRReaderViewController addObservers]_block_invoke_2
|
||||
-[WRReaderViewController animateAfterWritePageReview]_block_invoke
|
||||
-[WRReaderViewController autoTestGetCurrentScreenContentString]
|
||||
-[WRReaderViewController calculateReadingTimeAndUploadIfNeeded]
|
||||
-[WRReaderViewController calculateReadingTimeFromLastReadDate]
|
||||
-[WRReaderViewController canReadFromLocalWithChapterData:]
|
||||
-[WRReaderViewController changeTypesetterAttributesWithBlock:]
|
||||
-[WRReaderViewController checkLocationInCurrentPage:stringLocation:chapterIdx:]
|
||||
-[WRReaderViewController detectExcessPageViews]
|
||||
-[WRReaderViewController didEnterBackGround]
|
||||
-[WRReaderViewController didEnterForeGround]
|
||||
-[WRReaderViewController didFlipPage]
|
||||
-[WRReaderViewController didReceiveMemoryWarning]
|
||||
-[WRReaderViewController firstlyWillRenderPageView:progressData:source:]
|
||||
-[WRReaderViewController gotoChapterIdx:filePosition:pageOfFlyleaf:shouldAutoJumpToCover:]
|
||||
-[WRReaderViewController gotoChapterIdx:position:positionOfFile:]
|
||||
-[WRReaderViewController handleBackProgressButtonClick:]
|
||||
-[WRReaderViewController handleLastPageGesture:]
|
||||
-[WRReaderViewController handleLastPageTapGesture:]
|
||||
-[WRReaderViewController handleSizeChanged]
|
||||
-[WRReaderViewController initChapterPageCount]
|
||||
-[WRReaderViewController initChapterPageCount]_block_invoke
|
||||
-[WRReaderViewController initChapterPageCount]_block_invoke_3
|
||||
-[WRReaderViewController initWithBook:progress:forceUseInitialProgress:doodleMode:autoRead:]
|
||||
-[WRReaderViewController initWithBookId:]
|
||||
-[WRReaderViewController invokeVaildReadingProgress]
|
||||
-[WRReaderViewController isLastPageWithChapterIdx:page:]
|
||||
-[WRReaderViewController isReviewChapterFirstPageWithChapterData:page:]
|
||||
-[WRReaderViewController isReviewChapterInMiddlePageWithChapterData:page:]
|
||||
-[WRReaderViewController isReviewChapterLastPageWithChapterData:page:]
|
||||
-[WRReaderViewController jumpReadingToNeigbourChapterForward:]
|
||||
-[WRReaderViewController jumpReadingToNextChapter]
|
||||
-[WRReaderViewController jumpReadingToPreChapter]
|
||||
-[WRReaderViewController jumpToChapterUid:chapterOffset:shouldAutoJumpToCover:jumpToCloseChapterIdxIfNotExists:jumpFromStartProgress:completion:]
|
||||
-[WRReaderViewController jumpToChapterUid:chapterOffset:shouldAutoJumpToCover:jumpToCloseChapterIdxIfNotExists:jumpFromStartProgress:completion:]_block_invoke
|
||||
-[WRReaderViewController jumpToChapterUid:page:]
|
||||
-[WRReaderViewController loadCurrentPagePencilNotes]
|
||||
-[WRReaderViewController pageView:didClickPencilNote:]
|
||||
-[WRReaderViewController recomposeAndClearCache:]
|
||||
-[WRReaderViewController recomposeCurrentPageViewWithSource:]
|
||||
-[WRReaderViewController recomposeLoadedPageView:]
|
||||
-[WRReaderViewController refreshWithBook:]
|
||||
-[WRReaderViewController reloadCurrentPageView]
|
||||
-[WRReaderViewController reloadPageViewsWithProgressData:source:]
|
||||
-[WRReaderViewController removeAllPageViewLinkBookPaperHighlight]
|
||||
-[WRReaderViewController removeExcessPageViews:pageViewsToKeep:]
|
||||
-[WRReaderViewController renderErrorPageView:error:chapter:andChapterFormat:progressData:source:]
|
||||
-[WRReaderViewController renderLoadingContentPageView:progressData:source:]
|
||||
-[WRReaderViewController renderLoadingContentPageView:progressData:source:]_block_invoke
|
||||
-[WRReaderViewController renderPageView:nextToChapterIdx:page:]
|
||||
-[WRReaderViewController renderPageView:previousToChapterIdx:page:]
|
||||
-[WRReaderViewController renderPageView:progressData:source:]
|
||||
-[WRReaderViewController savePreviousPagePencilNote]
|
||||
-[WRReaderViewController scrollToLastReadingProgressIfNeeded]
|
||||
-[WRReaderViewController setBaseReaderViewModel:]_block_invoke_2
|
||||
-[WRReaderViewController showMileStoreRemind]
|
||||
-[WRReaderViewController startShowingReadingPageView]
|
||||
-[WRReaderViewController syncAuthorFlyleafDatas]
|
||||
-[WRReaderViewController syncBookInfoAndChapterInfoWithCallback:]_block_invoke
|
||||
-[WRReaderViewController syncFlyleapDataWithBook:]
|
||||
-[WRReaderViewController tryToJumpToAuthorFlyLeafPage]
|
||||
-[WRReaderViewController tryToJumpToAuthorFlyLeafPage]_block_invoke
|
||||
-[WRReaderViewController turnToPlayingFileLocation:stringLocation:chapterIdx:]
|
||||
-[WRReaderViewController uploadReadingProgressWithCallback:]
|
||||
-[WRReaderViewController viewDidLoad]
|
||||
-[WRReaderViewController viewWillDisappear:]
|
||||
-[WRReaderViewController willPopInNavigationControllerWithAnimated:]
|
||||
-[WRReaderViewModel loadChapterDataWithChapterIdx:reviewVid:tryRead:callback:]
|
||||
-[WRReaderViewModel updateInfoAfterAutoPaidChapter:]
|
||||
320
Doc/WXRead/objc_metadata/epub_class_names.txt
Normal file
320
Doc/WXRead/objc_metadata/epub_class_names.txt
Normal file
@ -0,0 +1,320 @@
|
||||
DTCoreText
|
||||
DTCoreTextFontCollection
|
||||
DTCoreTextFontDescriptor
|
||||
DTCoreTextGlyphRun
|
||||
DTCoreTextLayoutFrame
|
||||
DTCoreTextLayoutFrameAccessibilityElementGenerator
|
||||
DTCoreTextLayoutLine
|
||||
DTCoreTextLayouter
|
||||
DTCoreTextParagraphStyle
|
||||
DTHTMLAttributedStringBuilder
|
||||
DTHTMLElement
|
||||
DTHTMLParser
|
||||
DTHTMLParserDelegate
|
||||
DTHTMLParserNode
|
||||
DTHTMLParserTextNode
|
||||
WRBookMarketManager
|
||||
WRBookMarketRankListDataSource
|
||||
WRBookMarketSearchData
|
||||
WRBookMarketSearchDataSource
|
||||
WRBookMarketUIHelper
|
||||
WRBookMarketViewController
|
||||
WRBookNetwork
|
||||
WRBookNetworkReporter
|
||||
WRBookmark
|
||||
WRChapter
|
||||
WRChapterAnchor
|
||||
WRChapterData
|
||||
WRChapterDownloadManger
|
||||
WRChapterDownloadParam
|
||||
WRChapterListener
|
||||
WRChapterPageCount
|
||||
WRChapterReadData
|
||||
WRChapterRecommendAlbumsView
|
||||
WRChapterRecommendAlbumsViewCell
|
||||
WRChapterReviewsDownLoadModel
|
||||
WRCoreTextFloatStatement
|
||||
WRCoreTextImageAttributedStringFormatter
|
||||
WRCoreTextLayoutFrame
|
||||
WRCoreTextLayouter
|
||||
WREncryptedFileManager
|
||||
WREpubPage
|
||||
WREpubParser
|
||||
WREpubPositionConverter
|
||||
WREpubTypesetter
|
||||
WRMarkContent
|
||||
WRMarkContentChapter
|
||||
WRMarkContentInfoViewModel
|
||||
WRMarkContentStore
|
||||
WRMarkContentViewModel
|
||||
WRMarketIcon
|
||||
WRMarketNetwork
|
||||
WRMarketPopToBookShelfTransition
|
||||
WRMarketPopToDiscoverTransition
|
||||
WRMarketPushToSearchTransition
|
||||
WRMarketSearchModel
|
||||
WRMarketSearchScopeTab
|
||||
WRPageAVAttachment
|
||||
WRPageAudioAttachment
|
||||
WRPageAudioView
|
||||
WRPageBookAttachment
|
||||
WRPageBookView
|
||||
WRPageChapterRewardAttachment
|
||||
WRPageChapterRewardView
|
||||
WRPageChapterToolAttachment
|
||||
WRPageChapterToolAttachmentDelegate
|
||||
WRPageChapterToolAttachmentHelper
|
||||
WRPageChapterToolDelegate
|
||||
WRPageChapterToolModel
|
||||
WRPageChapterToolShareTipsView
|
||||
WRPageChapterToolStore
|
||||
WRPageCodeView
|
||||
WRPageCodeViewController
|
||||
WRPageComicView
|
||||
WRPageCountReportManager
|
||||
WRPageCoverAttachment
|
||||
WRPageCoverAttachmentDelegate
|
||||
WRPageCoverOrPageFlyleafAttachmentDelegate
|
||||
WRPageData
|
||||
WRPageElement
|
||||
WRPageFlowCollectionViewLayout
|
||||
WRPageFlyleafAttachment
|
||||
WRPageHighlight
|
||||
WRPageHighlightView
|
||||
WRPageHyperlinksAttachment
|
||||
WRPageHyperlinksAttachmentDelegate
|
||||
WRPageIframeAttachment
|
||||
WRPageIframeView
|
||||
WRPageImageAttachment
|
||||
WRPageImageAttachmentDelegate
|
||||
WRPageImageAttachmentPreviewDelegate
|
||||
WRPageImageAttachmentPreviewViewController
|
||||
WRPageImageView
|
||||
WRPageMark
|
||||
WRPageMarkDelegate
|
||||
WRPageMarkView
|
||||
WRPagePreAttachment
|
||||
WRPageRemindView
|
||||
WRPageSearchHighlight
|
||||
WRPageSliderAttachment
|
||||
WRPageTableAttachment
|
||||
WRPageTextView
|
||||
WRPageTextViewDelegate
|
||||
WRPageTurnModeControl
|
||||
WRPageUnderline
|
||||
WRPageVideoView
|
||||
WRPageView
|
||||
WRPageViewController
|
||||
WRPageViewControllerDelegate
|
||||
WRPageViewDelegate
|
||||
WRPageViewRichElementStub
|
||||
WRPreloadBookManager
|
||||
WRPreloadBookSettingHeaderView
|
||||
WRPreloadBookSettingViewController
|
||||
WRPreloadStatisticsManager
|
||||
WRReaderAIKnowledgeViewController
|
||||
WRReaderAdDialog
|
||||
WRReaderAnchorManager
|
||||
WRReaderAuthorReviewDetailViewController
|
||||
WRReaderAuthorReviewListViewCell
|
||||
WRReaderAuthorReviewListViewController
|
||||
WRReaderAutoPlayer
|
||||
WRReaderAutoPlayerDelegate
|
||||
WRReaderBackground
|
||||
WRReaderBackgroundAdapter
|
||||
WRReaderBackgroundChangeCell
|
||||
WRReaderBackgroundChangeView
|
||||
WRReaderBasePopupCellAdapter
|
||||
WRReaderBitmapColorHelper
|
||||
WRReaderBodyImageHelper
|
||||
WRReaderBookBorrowUtils
|
||||
WRReaderBookIntroControl
|
||||
WRReaderBookReviewStarView
|
||||
WRReaderBookmarkCell
|
||||
WRReaderBookmarkViewController
|
||||
WRReaderBorrowButton
|
||||
WRReaderBrightnessBackgroundButton
|
||||
WRReaderBrightnessChangeView
|
||||
WRReaderBusinessHelper
|
||||
WRReaderCatalogBaseCell
|
||||
WRReaderCatalogBaseCellModel
|
||||
WRReaderCatalogBaseCellView
|
||||
WRReaderCatalogBatchBuyChapterView
|
||||
WRReaderCatalogBookMarkCell
|
||||
WRReaderCatalogBookMarkCellView
|
||||
WRReaderCatalogCell
|
||||
WRReaderCatalogCellUtils
|
||||
WRReaderCatalogContainerViewController
|
||||
WRReaderCatalogCurrentReadingInfo
|
||||
WRReaderCatalogDataSource
|
||||
WRReaderCatalogDataSourceDelegate
|
||||
WRReaderCatalogDataSourceProtocol
|
||||
WRReaderCatalogFooterCell
|
||||
WRReaderCatalogHeaderView
|
||||
WRReaderCatalogItemModel
|
||||
WRReaderCatalogReadingCell
|
||||
WRReaderCatalogReadingCellModel
|
||||
WRReaderCatalogReadingCellView
|
||||
WRReaderCatalogSearchHistoryView
|
||||
WRReaderCatalogSearchManager
|
||||
WRReaderCatalogSectionItem
|
||||
WRReaderCatalogTableHeaderView
|
||||
WRReaderCatalogViewController
|
||||
WRReaderChangeFirstIndentTableViewCell
|
||||
WRReaderChangeFirstIndentTableViewCellItemView
|
||||
WRReaderChangeFirstIndentView
|
||||
WRReaderChangeFirstIndentViewController
|
||||
WRReaderChangeOrientationView
|
||||
WRReaderChangeOrientationViewController
|
||||
WRReaderChangePageTurningStyleView
|
||||
WRReaderChangePageTurningStyleViewController
|
||||
WRReaderCht2sManager
|
||||
WRReaderColorThemeManager
|
||||
WRReaderColorableProtocol
|
||||
WRReaderComicsViewModel
|
||||
WRReaderContentNavigationBottomControl
|
||||
WRReaderContentNavigationControl
|
||||
WRReaderContentNavigationManager
|
||||
WRReaderContentNavigationTopControl
|
||||
WRReaderContext
|
||||
WRReaderControlBarView
|
||||
WRReaderCurProgressTipsView
|
||||
WRReaderDictionaryAIKnowledgeCollectionViewCell
|
||||
WRReaderDictionaryCollectionBaseViewCell
|
||||
WRReaderDictionaryCollectionResultViewCell
|
||||
WRReaderDictionaryCollectionView
|
||||
WRReaderDictionaryEncyclopediaCollectionViewCell
|
||||
WRReaderDictionarySearchCollectionViewCell
|
||||
WRReaderDictionaryTranslationCollectionViewCell
|
||||
WRReaderDictionaryView
|
||||
WRReaderDictionaryViewController
|
||||
WRReaderEndOfTrialButton
|
||||
WRReaderEndOfTrialView
|
||||
WRReaderExchangePushInfo
|
||||
WRReaderExperienceViewController
|
||||
WRReaderFloatReviewsViewController
|
||||
WRReaderFont
|
||||
WRReaderFontChangeCategoryViewController
|
||||
WRReaderFontChangeCategoryViewModel
|
||||
WRReaderFontChangeCell
|
||||
WRReaderFontChangeModalView
|
||||
WRReaderFontChangeShareViewModel
|
||||
WRReaderFontChangeTabsView
|
||||
WRReaderFontChangeViewController
|
||||
WRReaderFontConfig
|
||||
WRReaderFontSizeChangeView
|
||||
WRReaderFontTableViewCell
|
||||
WRReaderFooterPageView
|
||||
WRReaderFooternotePopupView
|
||||
WRReaderHotUnderLinesTotalCountViewController
|
||||
WRReaderHotUnderlinesCell
|
||||
WRReaderHotUnderlinesViewController
|
||||
WRReaderIncentiveButton
|
||||
WRReaderInfoDebuggerViewController
|
||||
WRReaderInfoDebuggerViewModel
|
||||
WRReaderLastPageActionButton
|
||||
WRReaderLastPageActionButtonContainerView
|
||||
WRReaderLastPageBadgeView
|
||||
WRReaderLastPageDelegate
|
||||
WRReaderLastPageHelper
|
||||
WRReaderLastPageMilestoneCardView
|
||||
WRReaderLastPageReviewItem
|
||||
WRReaderLastPageReviewsView
|
||||
WRReaderLastPageViewController
|
||||
WRReaderMaskProtocol
|
||||
WRReaderMaskViewController
|
||||
WRReaderMenuView
|
||||
WRReaderMilestoneLabelView
|
||||
WRReaderMistakeDialogContentView
|
||||
WRReaderMistakeViewController
|
||||
WRReaderModalView
|
||||
WRReaderMoreOperationController
|
||||
WRReaderNoteViewModel
|
||||
WRReaderNotesSectionHeaderView
|
||||
WRReaderOSSLogHelper
|
||||
WRReaderOutlineViewController
|
||||
WRReaderPaidVIPButton
|
||||
WRReaderPanelSelectionView
|
||||
WRReaderPanelSelectionViewController
|
||||
WRReaderPencilNote
|
||||
WRReaderPencilNoteBackgroundView
|
||||
WRReaderPencilNoteBaseView
|
||||
WRReaderPencilNoteButtonGroupView
|
||||
WRReaderPencilNoteEraserTips
|
||||
WRReaderPencilNoteEraserTipsManager
|
||||
WRReaderPencilNoteHelper
|
||||
WRReaderPencilNoteManager
|
||||
WRReaderPencilNoteMaskView
|
||||
WRReaderPencilNoteRangeReviewItem
|
||||
WRReaderPencilNoteRangeReviewsPanel
|
||||
WRReaderPencilNoteReviewManager
|
||||
WRReaderPencilNoteTopToolView
|
||||
WRReaderPencilNoteViewController
|
||||
WRReaderPencilPageNoteListView
|
||||
WRReaderPencilPageNoteView
|
||||
WRReaderPencilRangeNoteView
|
||||
WRReaderPlayerEntranceView
|
||||
WRReaderPopupInfoTableViewCell
|
||||
WRReaderPopupLoadMoreTableViewCell
|
||||
WRReaderPopupReviewTableViewCell
|
||||
WRReaderPresellPageView
|
||||
WRReaderProgress
|
||||
WRReaderProgressButton
|
||||
WRReaderProgressChangeRuleView
|
||||
WRReaderProgressChangeView
|
||||
WRReaderProgressData
|
||||
WRReaderProgressShareView
|
||||
WRReaderProgressSlider
|
||||
WRReaderProgressSliderDelegate
|
||||
WRReaderProgressSmallChangeView
|
||||
WRReaderReadOverToBuyView
|
||||
WRReaderRefreshFooterView
|
||||
WRReaderRefreshHeaderView
|
||||
WRReaderReivewCountLabel
|
||||
WRReaderReviewToolsView
|
||||
WRReaderReviewsRNViewController
|
||||
WRReaderSearchTableHeaderView
|
||||
WRReaderSegmentViewController
|
||||
WRReaderSettingPanelManager
|
||||
WRReaderSettingPanelView
|
||||
WRReaderSettingProtocol
|
||||
WRReaderSinglePurchaseEndTrailBookContentView
|
||||
WRReaderSizeManager
|
||||
WRReaderStatisticPanelViewController
|
||||
WRReaderStatisticRNViewController
|
||||
WRReaderTextSelector
|
||||
WRReaderTimeHelper
|
||||
WRReaderTimer
|
||||
WRReaderTraditionalChineseView
|
||||
WRReaderTranslationManager
|
||||
WRReaderTranslationUpgradeViewController
|
||||
WRReaderUIFactory
|
||||
WRReaderUnderlineStyleButtonManager
|
||||
WRReaderVIPButton
|
||||
WRReaderVIPButtonProtocol
|
||||
WRReaderViewController
|
||||
WRReaderViewControllerDelegate
|
||||
WRReaderViewControllerHelper
|
||||
WRReaderViewModel
|
||||
WRReaderVisiblePageInfo
|
||||
WRReaderVoteView
|
||||
WRReaderWelfareCoinPanel
|
||||
WRReaderWriteReviewSimpleView
|
||||
WRUnderLineView
|
||||
WRUnderline
|
||||
WRUnderlineColorButtonsScrollView
|
||||
WRUnderlineLayoutManager
|
||||
WRUnderlineStore
|
||||
WrBookmarkLoadErrorReport
|
||||
WrBookmarkLoadErrorReportRoot
|
||||
WrPageFlippingAnimationTypeRoot
|
||||
WrReaderActionReport
|
||||
WrReaderActionReportRoot
|
||||
WrReaderActionRoot
|
||||
WrReaderItemtypeRoot
|
||||
WrReaderModuleRoot
|
||||
WrReaderPayingtypeRoot
|
||||
WrReaderSearchAuthoriedFailedTypeRoot
|
||||
WrReaderSearchFailedtypeRoot
|
||||
WrReaderSearchWords
|
||||
WrReaderSearchWordsRoot
|
||||
798
Doc/WXRead/objc_metadata/epub_core_classes.txt
Normal file
798
Doc/WXRead/objc_metadata/epub_core_classes.txt
Normal file
@ -0,0 +1,798 @@
|
||||
ivarLayout 0x1031bcf9c
|
||||
layout map 0x11 0x12
|
||||
name 0x1031bcf89 DTCoreTextLayouter
|
||||
baseMethods 0x102acf508
|
||||
entsize 12 (relative)
|
||||
count 12
|
||||
name 0x104ef88 (0x103b1e498)
|
||||
types 0x95e950 (0x10342de64) @24@0:8@16
|
||||
imp 0xfd5764d8 (0x1000459f0)
|
||||
name 0x103a3ec (0x103b09908)
|
||||
types 0x95e89b (0x10342ddbb) v16@0:8
|
||||
imp 0xfd576560 (0x100045a84)
|
||||
name 0x1058528 (0x103b27a50)
|
||||
types 0x95ff95 (0x10342f4c1) @64@0:8{CGRect={CGPoint=dd}{CGSize=dd}}16{_NSRange=QQ}48
|
||||
imp 0xfd576598 (0x100045ac8)
|
||||
name 0x102881c (0x103af7d50)
|
||||
types 0x95e883 (0x10342ddbb) v16@0:8
|
||||
imp 0xfd576734 (0x100045c70)
|
||||
name 0x10436d0 (0x103b12c10)
|
||||
types 0x95ffb6 (0x10342f4fa) ^{__CTFramesetter=}16@0:8
|
||||
imp 0xfd576754 (0x100045c9c)
|
||||
name 0x1075b84 (0x103b450d0)
|
||||
types 0x95e860 (0x10342ddb0) v24@0:8@16
|
||||
imp 0xfd5767c4 (0x100045d18)
|
||||
name 0x102e2c8 (0x103afd820)
|
||||
types 0x95e84c (0x10342dda8) @16@0:8
|
||||
imp 0xfd57684c (0x100045dac)
|
||||
name 0x108cf3c (0x103b5c4a0)
|
||||
types 0x95e8b5 (0x10342de1d) v20@0:8B16
|
||||
imp 0xfd576848 (0x100045db4)
|
||||
name 0x1094710 (0x103b63c80)
|
||||
types 0x95e9a8 (0x10342df1c) B16@0:8
|
||||
imp 0xfd57689c (0x100045e14)
|
||||
--
|
||||
reserved 0x0
|
||||
ivarLayout 0x0
|
||||
name 0x1031bcf89 DTCoreTextLayouter
|
||||
baseMethods 0x0
|
||||
baseProtocols 0x0
|
||||
ivars 0x0
|
||||
weakIvarLayout 0x0
|
||||
baseProperties 0x0
|
||||
000000010383bc60 0x103ba2550
|
||||
isa 0x103ba2528
|
||||
superclass 0x0 _OBJC_CLASS_$_NSObject
|
||||
cache 0x0 __objc_empty_cache
|
||||
vtable 0x0
|
||||
data 0x10388b308
|
||||
flags 0x90
|
||||
instanceStart 8
|
||||
instanceSize 8
|
||||
reserved 0x0
|
||||
ivarLayout 0x0
|
||||
name 0x1031bcf9f TABAnimatedChainManagerImpl
|
||||
baseMethods 0x102acf5a0
|
||||
entsize 12 (relative)
|
||||
count 3
|
||||
name 0x1033af0 (0x103b03098)
|
||||
types 0x95ff9d (0x10342f549) v48@0:8@16@24@?32@40
|
||||
imp 0xfd576cac (0x10004625c)
|
||||
name 0x1033aec (0x103b030a0)
|
||||
types 0x95ffa6 (0x10342f55e) v56@0:8@16@24@?32#40@48
|
||||
imp 0xfd576d1c (0x1000462d8)
|
||||
name 0x103bf00 (0x103b0b4c0)
|
||||
types 0x95e7f7 (0x10342ddbb) v16@0:8
|
||||
imp 0xfd576d9c (0x100046364)
|
||||
baseProtocols 0x10388b260
|
||||
--
|
||||
ivarLayout 0x1031bc939
|
||||
layout map 0x04
|
||||
name 0x1031bfb46 WREpubParser
|
||||
baseMethods 0x102aec440
|
||||
entsize 12 (relative)
|
||||
count 11
|
||||
name 0x1032c20 (0x103b1f068)
|
||||
types 0x9419c3 (0x10342de0f) @32@0:8@16@24
|
||||
imp 0xfd7ec460 (0x1002d88b0)
|
||||
name 0x10459b4 (0x103b31e08)
|
||||
types 0x941ac4 (0x10342df1c) B16@0:8
|
||||
imp 0xfd7ec68c (0x1002d8ae8)
|
||||
name 0x1023418 (0x103b0f878)
|
||||
types 0x9419c4 (0x10342de28) v32@0:8@16@24
|
||||
imp 0xfd7ec6b4 (0x1002d8b1c)
|
||||
name 0x102f9b4 (0x103b1be20)
|
||||
types 0x94199f (0x10342de0f) @32@0:8@16@24
|
||||
imp 0xfd7ed284 (0x1002d96f8)
|
||||
name 0x102f5a0 (0x103b1ba18)
|
||||
types 0x941993 (0x10342de0f) @32@0:8@16@24
|
||||
imp 0xfd7ed468 (0x1002d98e8)
|
||||
name 0x101a8d4 (0x103b06d58)
|
||||
types 0x941987 (0x10342de0f) @32@0:8@16@24
|
||||
imp 0xfd7ed4ec (0x1002d9978)
|
||||
name 0x100ead8 (0x103afaf68)
|
||||
types 0x9454c8 (0x10343195c) v40@0:8@16@24Q32
|
||||
imp 0xfd7ed82c (0x1002d9cc4)
|
||||
name 0x100dc6c (0x103afa108)
|
||||
types 0x94b021 (0x1034374c1) v56@0:8@16@24Q32@40@48
|
||||
imp 0xfd7edbd0 (0x1002da074)
|
||||
name 0x10233c8 (0x103b0f870)
|
||||
types 0x94197c (0x10342de28) v32@0:8@16@24
|
||||
imp 0xfd7ee028 (0x1002da4d8)
|
||||
--
|
||||
reserved 0x0
|
||||
ivarLayout 0x0
|
||||
name 0x1031bfb46 WREpubParser
|
||||
baseMethods 0x102aec428
|
||||
entsize 12 (relative)
|
||||
count 1
|
||||
name 0x1046580 (0x103b329b0)
|
||||
types 0x941a30 (0x10342de64) @24@0:8@16
|
||||
imp 0xfd7ee188 (0x1002da5c0)
|
||||
baseProtocols 0x1038b61b8
|
||||
count 1
|
||||
list[0] 0x103c17ce8
|
||||
isa 0x0
|
||||
name 0x1031bfb53 KFEpubControllerDelegate
|
||||
protocols 0x1038b6130
|
||||
count 1
|
||||
list[0] 0x103c15580
|
||||
isa 0x0
|
||||
name 0x1031bc9c5 NSObject
|
||||
protocols 0x0
|
||||
instanceMethods 0x103884670
|
||||
entsize 24
|
||||
count 19
|
||||
name 0x103348607 isEqual:
|
||||
types 0x10342de6f B24@0:8@16
|
||||
imp 0x0
|
||||
name 0x1032e9bde class
|
||||
types 0x10342df44 #16@0:8
|
||||
imp 0x0
|
||||
name 0x1033a90f1 self
|
||||
types 0x10342dda8 @16@0:8
|
||||
imp 0x0
|
||||
name 0x1033780e4 performSelector:
|
||||
--
|
||||
ivarLayout 0x1031bfd8e
|
||||
layout map 0x16 0x1c 0x12 0x15 0x11
|
||||
name 0x1031bfd80 WRChapterData
|
||||
baseMethods 0x102aeddf0
|
||||
entsize 12 (relative)
|
||||
count 118
|
||||
name 0x102f158 (0x103b1cf50)
|
||||
types 0x93ffac (0x10342dda8) @16@0:8
|
||||
imp 0xfd809f38 (0x1002f7d38)
|
||||
name 0x101552c (0x103b03330)
|
||||
types 0x940101 (0x10342df09) q16@0:8
|
||||
imp 0xfd809f80 (0x1002f7d8c)
|
||||
name 0x1019920 (0x103b07730)
|
||||
types 0x94150c (0x10342f320) {_NSRange=QQ}16@0:8
|
||||
imp 0xfd80a044 (0x1002f7e5c)
|
||||
name 0x103fd04 (0x103b2db20)
|
||||
types 0x941500 (0x10342f320) {_NSRange=QQ}16@0:8
|
||||
imp 0xfd80a07c (0x1002f7ea0)
|
||||
name 0x1033920 (0x103b21748)
|
||||
types 0x9400dd (0x10342df09) q16@0:8
|
||||
imp 0xfd80a0b0 (0x1002f7ee0)
|
||||
name 0x10353cc (0x103b23200)
|
||||
types 0x94427a (0x1034320b2) B24@0:8q16
|
||||
imp 0xfd80a0bc (0x1002f7ef8)
|
||||
name 0x10353c8 (0x103b23208)
|
||||
types 0x94426e (0x1034320b2) B24@0:8q16
|
||||
imp 0xfd80a158 (0x1002f7fa0)
|
||||
name 0x1035b9c (0x103b239e8)
|
||||
types 0x944262 (0x1034320b2) B24@0:8q16
|
||||
imp 0xfd80a18c (0x1002f7fe0)
|
||||
name 0x1035c60 (0x103b23ab8)
|
||||
types 0x944256 (0x1034320b2) B24@0:8q16
|
||||
imp 0xfd80a1c0 (0x1002f8020)
|
||||
--
|
||||
reserved 0x0
|
||||
ivarLayout 0x0
|
||||
name 0x1031bfd80 WRChapterData
|
||||
baseMethods 0x102aedd90
|
||||
entsize 12 (relative)
|
||||
count 7
|
||||
name 0x100d288 (0x103afb020)
|
||||
types 0x949a69 (0x103437805) v64@0:8@16{_NSRange=QQ}24q40q48@56
|
||||
imp 0xfd80a52c (0x1002f82cc)
|
||||
name 0x101d21c (0x103b0afc0)
|
||||
types 0x940008 (0x10342ddb0) v24@0:8@16
|
||||
imp 0xfd80a694 (0x1002f8440)
|
||||
name 0x10161e8 (0x103b03f98)
|
||||
types 0x949a74 (0x103437828) B40@0:8{_NSRange=QQ}16Q32
|
||||
imp 0xfd80c140 (0x1002f9ef8)
|
||||
name 0x1078b54 (0x103b66910)
|
||||
types 0x9400a4 (0x10342de64) @24@0:8@16
|
||||
imp 0xfd80c154 (0x1002f9f18)
|
||||
name 0x1036ed8 (0x103b24ca0)
|
||||
types 0x94008a (0x10342de56) B32@0:8@16@24
|
||||
imp 0xfd80cf10 (0x1002face0)
|
||||
name 0x104396c (0x103b31740)
|
||||
types 0x949a6a (0x103437842) {_NSRange=QQ}32@0:8q16@24
|
||||
imp 0xfd80e084 (0x1002fbe60)
|
||||
name 0x1024f08 (0x103b12ce8)
|
||||
types 0x9422b4 (0x103430098) q32@0:8@16@24
|
||||
imp 0xfd80e738 (0x1002fc520)
|
||||
baseProtocols 0x0
|
||||
ivars 0x0
|
||||
weakIvarLayout 0x0
|
||||
baseProperties 0x0
|
||||
000000010383c6c8 0x103ba8d38
|
||||
isa 0x103ba8d60
|
||||
--
|
||||
ivarLayout 0x1031c36ba
|
||||
layout map 0x01 0xa2
|
||||
name 0x1031c36a4 WRCoreTextLayoutFrame
|
||||
baseMethods 0x102b1a760
|
||||
entsize 12 (relative)
|
||||
count 47
|
||||
name 0x1004bd8 (0x103b1f340)
|
||||
types 0x922b0e (0x10343d27a) @80@0:8^{__CTFramesetter=}16@24{_NSRange=QQ}32{CGRect={CGPoint=dd}{CGSize=dd}}48
|
||||
imp 0xfdba1dec (0x1006bc55c)
|
||||
name 0xff9e1c (0x103b14590)
|
||||
types 0x9137ac (0x10342df24) d16@0:8
|
||||
imp 0xfdba1ef4 (0x1006bc670)
|
||||
name 0x10388c8 (0x103b53048)
|
||||
types 0x9143b0 (0x10342eb34) v48@0:8{CGRect={CGPoint=dd}{CGSize=dd}}16
|
||||
imp 0xfdba1ef0 (0x1006bc678)
|
||||
name 0x101dd44 (0x103b384d0)
|
||||
types 0x922b3b (0x10343d2cb) B48@0:8{_NSRange=QQ}16{_NSRange=QQ}32
|
||||
imp 0xfdba2028 (0x1006bc7bc)
|
||||
name 0xfffc48 (0x103b1a3e0)
|
||||
types 0x9136d3 (0x10342de6f) B24@0:8@16
|
||||
imp 0xfdba2034 (0x1006bc7d4)
|
||||
name 0x1007a5c (0x103b22200)
|
||||
types 0x917ade (0x103432286) B32@0:8{_NSRange=QQ}16
|
||||
imp 0xfdba20d0 (0x1006bc87c)
|
||||
name 0xfed5f0 (0x103b07da0)
|
||||
types 0x922b3d (0x10343d2f1) @64@0:8^{__CTTypesetter=}16d24{_NSRange=QQ}32d48@56
|
||||
imp 0xfdba218c (0x1006bc944)
|
||||
name 0x100debc (0x103b28678)
|
||||
types 0x9135e8 (0x10342dda8) @16@0:8
|
||||
imp 0xfdba2f20 (0x1006bd6e4)
|
||||
name 0xfec0d0 (0x103b06898)
|
||||
types 0x91373d (0x10342df09) q16@0:8
|
||||
imp 0xfdba4788 (0x1006bef58)
|
||||
--
|
||||
reserved 0x0
|
||||
ivarLayout 0x0
|
||||
name 0x1031c36a4 WRCoreTextLayoutFrame
|
||||
baseMethods 0x102b1a650
|
||||
entsize 12 (relative)
|
||||
count 2
|
||||
name 0xfecc98 (0x103b072f0)
|
||||
types 0x922bcf (0x10343d22b) @64@0:8@16@24{_NSRange=QQ}32{_NSRange=QQ}48
|
||||
imp 0xfdba9834 (0x1006c3e94)
|
||||
name 0x10492bc (0x103b63920)
|
||||
types 0x922bef (0x10343d257) B60@0:8@16@24{_NSRange=QQ}32q48B56
|
||||
imp 0xfdba9a84 (0x1006c40f0)
|
||||
baseProtocols 0x0
|
||||
ivars 0x0
|
||||
weakIvarLayout 0x0
|
||||
baseProperties 0x0
|
||||
000000010383d6e0 0x103bb2e28
|
||||
isa 0x103bb2e50
|
||||
superclass 0x0 _OBJC_CLASS_$_QMUINavigationButton
|
||||
cache 0x0 __objc_empty_cache
|
||||
vtable 0x0
|
||||
data 0x103901990
|
||||
flags 0x194 RO_HAS_CXX_STRUCTORS
|
||||
instanceStart 8
|
||||
instanceSize 56
|
||||
reserved 0x0
|
||||
ivarLayout 0x1031bcc1a
|
||||
layout map 0x12
|
||||
name 0x1031c36bd WRReviewNavigationButton
|
||||
baseMethods 0x102b1a9a0
|
||||
entsize 12 (relative)
|
||||
count 15
|
||||
name 0x10025a8 (0x103b1cf50)
|
||||
--
|
||||
reserved 0x0
|
||||
ivarLayout 0x0
|
||||
name 0x1031c405b DTCoreTextLayoutFrameAccessibilityElementGenerator
|
||||
baseMethods 0x102b22c00
|
||||
entsize 12 (relative)
|
||||
count 7
|
||||
name 0xfd6a98 (0x103af96a0)
|
||||
types 0x90c852 (0x10342f45e) @40@0:8@16@24@?32
|
||||
imp 0xfdc59bb0 (0x10077c7c0)
|
||||
name 0xfd6a94 (0x103af96a8)
|
||||
types 0x91ba43 (0x10343e65b) @48@0:8Q16@24@32@?40
|
||||
imp 0xfdc59ccc (0x10077c8e8)
|
||||
name 0xfeca30 (0x103b0f650)
|
||||
types 0x91194f (0x103434573) v40@0:8@16Q24@?32
|
||||
imp 0xfdc59ee0 (0x10077cb08)
|
||||
name 0xfd6a64 (0x103af9690)
|
||||
types 0x91ba40 (0x10343e670) @72@0:8@16{_NSRange=QQ}24@40@48@56@?64
|
||||
imp 0xfdc5a1b0 (0x10077cde4)
|
||||
name 0xfd6a50 (0x103af9688)
|
||||
types 0x91ba5b (0x10343e697) @64@0:8@16{_NSRange=QQ}24@40@48@56
|
||||
imp 0xfdc5a2d4 (0x10077cf14)
|
||||
name 0x104f19c (0x103b71de0)
|
||||
types 0x90c807 (0x10342f44f) @32@0:8@16@?24
|
||||
imp 0xfdc5a468 (0x10077d0b4)
|
||||
name 0xfeff18 (0x103b12b68)
|
||||
types 0x914fd8 (0x103437c2c) {CGRect={CGPoint=dd}{CGSize=dd}}24@0:8@16
|
||||
imp 0xfdc5a490 (0x10077d0e8)
|
||||
baseProtocols 0x0
|
||||
ivars 0x0
|
||||
weakIvarLayout 0x0
|
||||
baseProperties 0x0
|
||||
Meta Class
|
||||
isa 0x0 _OBJC_METACLASS_$_NSObject
|
||||
--
|
||||
reserved 0x0
|
||||
ivarLayout 0x0
|
||||
name 0x1031c405b DTCoreTextLayoutFrameAccessibilityElementGenerator
|
||||
baseMethods 0x0
|
||||
baseProtocols 0x0
|
||||
ivars 0x0
|
||||
weakIvarLayout 0x0
|
||||
baseProperties 0x0
|
||||
000000010383d9a0 0x103bb49a8
|
||||
isa 0x103bb49d0
|
||||
superclass 0x0 _OBJC_CLASS_$_UICollectionView
|
||||
cache 0x0 __objc_empty_cache
|
||||
vtable 0x0
|
||||
data 0x10390e140
|
||||
flags 0x194 RO_HAS_CXX_STRUCTORS
|
||||
instanceStart 8
|
||||
instanceSize 48
|
||||
reserved 0x0
|
||||
ivarLayout 0x1031bcd23
|
||||
layout map 0x11
|
||||
name 0x1031c408e WRDiscoverCollectionView
|
||||
baseMethods 0x102b22c60
|
||||
entsize 12 (relative)
|
||||
count 20
|
||||
name 0x1001838 (0x103b244a0)
|
||||
types 0x90b2b0 (0x10342df1c) B16@0:8
|
||||
imp 0xfdc5a9b8 (0x10077d628)
|
||||
name 0x101ef44 (0x103b41bb8)
|
||||
types 0x91ba42 (0x10343e6ba) v36@0:8Q16B24@?28
|
||||
imp 0xfdc5aa5c (0x10077d6d8)
|
||||
name 0x1049f78 (0x103b6cbf8)
|
||||
types 0x90b1eb (0x10342de6f) B24@0:8@16
|
||||
imp 0xfdc5abc0 (0x10077d848)
|
||||
--
|
||||
ivarLayout 0x1031c51df
|
||||
layout map 0x12 0x11 0x21
|
||||
name 0x1031c51cc WRChapterPageCount
|
||||
baseMethods 0x102b2ed70
|
||||
entsize 12 (relative)
|
||||
count 29
|
||||
name 0xfefcf8 (0x103b1ea70)
|
||||
types 0x910f9d (0x10343fd19) @40@0:8@16Q24q32
|
||||
imp 0xfdd88e88 (0x1008b7c08)
|
||||
name 0x100981c (0x103b385a0)
|
||||
types 0x908ad4 (0x10343785c) {_NSRange=QQ}24@0:8q16
|
||||
imp 0xfdd892a4 (0x1008b8030)
|
||||
name 0xff70c8 (0x103b25e58)
|
||||
types 0x8ff014 (0x10342dda8) @16@0:8
|
||||
imp 0xfdd89500 (0x1008b8298)
|
||||
name 0x1023294 (0x103b52030)
|
||||
types 0x8ff010 (0x10342ddb0) v24@0:8@16
|
||||
imp 0xfdd89504 (0x1008b82a8)
|
||||
name 0xfd4710 (0x103b034b8)
|
||||
types 0x8feffc (0x10342dda8) @16@0:8
|
||||
imp 0xfdd89504 (0x1008b82b4)
|
||||
name 0x10190fc (0x103b47eb0)
|
||||
types 0x8feff8 (0x10342ddb0) v24@0:8@16
|
||||
imp 0xfdd89508 (0x1008b82c4)
|
||||
name 0xfd4650 (0x103b03410)
|
||||
types 0x8ff145 (0x10342df09) q16@0:8
|
||||
imp 0xfdd89508 (0x1008b82d0)
|
||||
name 0x1019084 (0x103b47e50)
|
||||
types 0x8ff141 (0x10342df11) v24@0:8q16
|
||||
imp 0xfdd8950c (0x1008b82e0)
|
||||
name 0xfd0fb0 (0x103affd88)
|
||||
types 0x8fefcc (0x10342dda8) @16@0:8
|
||||
imp 0xfdd89510 (0x1008b82f0)
|
||||
--
|
||||
reserved 0x0
|
||||
ivarLayout 0x0
|
||||
name 0x1031c51cc WRChapterPageCount
|
||||
baseMethods 0x102b2ece0
|
||||
entsize 12 (relative)
|
||||
count 11
|
||||
name 0xff2708 (0x103b213f0)
|
||||
types 0x8ff0bc (0x10342dda8) @16@0:8
|
||||
imp 0xfddc1db4 (0x1008f0aa4)
|
||||
name 0xfdab1c (0x103b09810)
|
||||
types 0x8ff0b0 (0x10342dda8) @16@0:8
|
||||
imp 0xfddc1dc4 (0x1008f0ac0)
|
||||
name 0x103b978 (0x103b6a678)
|
||||
types 0x8ff0a4 (0x10342dda8) @16@0:8
|
||||
imp 0xfddc1dc4 (0x1008f0acc)
|
||||
name 0x10063cc (0x103b350d8)
|
||||
types 0x8ff098 (0x10342dda8) @16@0:8
|
||||
imp 0xfddc1dc4 (0x1008f0ad8)
|
||||
name 0x10046e8 (0x103b33400)
|
||||
types 0x8ff08c (0x10342dda8) @16@0:8
|
||||
imp 0xfddc1dc4 (0x1008f0ae4)
|
||||
name 0xfdc50c (0x103b0b230)
|
||||
types 0x8ff080 (0x10342dda8) @16@0:8
|
||||
imp 0xfddc1df0 (0x1008f0b1c)
|
||||
name 0x10027f0 (0x103b31520)
|
||||
types 0x910fe5 (0x10343fd19) @40@0:8@16Q24q32
|
||||
imp 0xfdd89030 (0x1008b7d68)
|
||||
name 0xfd982c (0x103b08568)
|
||||
types 0x9006b8 (0x10342f3f8) Q24@0:8@16
|
||||
imp 0xfdd89128 (0x1008b7e6c)
|
||||
name 0xfee0c0 (0x103b1ce08)
|
||||
types 0x8ff05c (0x10342dda8) @16@0:8
|
||||
imp 0xfdd892d4 (0x1008b8024)
|
||||
--
|
||||
ivarLayout 0x1031c6257
|
||||
layout map 0x04 0x15 0x28
|
||||
name 0x1031c624c WRPageView
|
||||
baseMethods 0x102b3b018
|
||||
entsize 12 (relative)
|
||||
count 104
|
||||
name 0xfe4310 (0x103b1f330)
|
||||
types 0x9064ba (0x1034414de) @64@0:8{CGRect={CGPoint=dd}{CGSize=dd}}16Q48q56
|
||||
imp 0xfde85418 (0x1009c0440)
|
||||
name 0x10294e4 (0x103b64510)
|
||||
types 0x8f2eec (0x10342df1c) B16@0:8
|
||||
imp 0xfde854e0 (0x1009c0514)
|
||||
name 0x102a918 (0x103b65950)
|
||||
types 0x8f2d7f (0x10342ddbb) v16@0:8
|
||||
imp 0xfde85584 (0x1009c05c4)
|
||||
name 0xfe0064 (0x103b1b0a8)
|
||||
types 0x8f2d73 (0x10342ddbb) v16@0:8
|
||||
imp 0xfde85704 (0x1009c0750)
|
||||
name 0xfe0068 (0x103b1b0b8)
|
||||
types 0x8f2d67 (0x10342ddbb) v16@0:8
|
||||
imp 0xfde85770 (0x1009c07c8)
|
||||
name 0xfe2eec (0x103b1df48)
|
||||
types 0x8f2d5b (0x10342ddbb) v16@0:8
|
||||
imp 0xfde85798 (0x1009c07fc)
|
||||
name 0xfe0048 (0x103b1b0b0)
|
||||
types 0x8f2d4f (0x10342ddbb) v16@0:8
|
||||
imp 0xfde85af0 (0x1009c0b60)
|
||||
name 0xfe2ac4 (0x103b1db38)
|
||||
types 0x8f2d43 (0x10342ddbb) v16@0:8
|
||||
imp 0xfde85ca8 (0x1009c0d24)
|
||||
name 0xfe66d8 (0x103b21758)
|
||||
types 0x8f2d24 (0x10342dda8) @16@0:8
|
||||
imp 0xfde85df8 (0x1009c0e80)
|
||||
--
|
||||
reserved 0x0
|
||||
ivarLayout 0x0
|
||||
name 0x1031c624c WRPageView
|
||||
baseMethods 0x102b3b000
|
||||
entsize 12 (relative)
|
||||
count 1
|
||||
name 0xff1cb8 (0x103b2ccc0)
|
||||
types 0x8f6062 (0x10343106e) d32@0:8@16@24
|
||||
imp 0xfde86674 (0x1009c1684)
|
||||
baseProtocols 0x0
|
||||
ivars 0x0
|
||||
weakIvarLayout 0x0
|
||||
baseProperties 0x0
|
||||
000000010383e338 0x103bba998
|
||||
isa 0x103bba9c0
|
||||
superclass 0x103bc8688
|
||||
cache 0x0 __objc_empty_cache
|
||||
vtable 0x0
|
||||
data 0x103932f00
|
||||
flags 0x194 RO_HAS_CXX_STRUCTORS
|
||||
instanceStart 16
|
||||
instanceSize 32
|
||||
reserved 0x0
|
||||
ivarLayout 0x1031bc967
|
||||
layout map 0x02
|
||||
name 0x1031c625b WRConfirmH5PayViewController
|
||||
baseMethods 0x102b3b500
|
||||
entsize 12 (relative)
|
||||
count 15
|
||||
name 0x10011c8 (0x103b3c6d0)
|
||||
types 0x8f28af (0x10342ddbb) v16@0:8
|
||||
imp 0xfde90aa0 (0x1009cbfb0)
|
||||
name 0xfedbb4 (0x103b290c8)
|
||||
--
|
||||
ivarLayout 0x1031c646d
|
||||
layout map 0x44 0x81 0x31
|
||||
name 0x1031c6457 DTCoreTextLayoutFrame
|
||||
baseMethods 0x102b3ca60
|
||||
entsize 12 (relative)
|
||||
count 54
|
||||
name 0xfc84e8 (0x103b04f50)
|
||||
types 0x8f4143 (0x103430baf) q32@0:8{CGPoint=dd}16
|
||||
imp 0xfdd403fc (0x10087ce6c)
|
||||
name 0xfcc3a4 (0x103b08e18)
|
||||
types 0x900b89 (0x10343d601) {CGRect={CGPoint=dd}{CGSize=dd}}24@0:8q16
|
||||
imp 0xfdd406b8 (0x10087d134)
|
||||
name 0xfe2878 (0x103b1f2f8)
|
||||
types 0x904d71 (0x1034417f5) @72@0:8{CGRect={CGPoint=dd}{CGSize=dd}}16@48{_NSRange=QQ}56
|
||||
imp 0xfdeb5da4 (0x1009f282c)
|
||||
name 0xfe2864 (0x103b1f2f0)
|
||||
types 0x8fdd25 (0x10343a7b5) @56@0:8{CGRect={CGPoint=dd}{CGSize=dd}}16@48
|
||||
imp 0xfdeb5f20 (0x1009f29b4)
|
||||
name 0xfcce70 (0x103b09908)
|
||||
types 0x8f131f (0x10342ddbb) v16@0:8
|
||||
imp 0xfdeb5f20 (0x1009f29c0)
|
||||
name 0xfce904 (0x103b0b3a8)
|
||||
types 0x8f1300 (0x10342dda8) @16@0:8
|
||||
imp 0xfdeb5f6c (0x1009f2a18)
|
||||
name 0xfbacd8 (0x103af7788)
|
||||
types 0x900b34 (0x10343d5e8) {CGPoint=dd}32@0:8@16@24
|
||||
imp 0xfdeb5fa4 (0x1009f2a5c)
|
||||
name 0xfbacdc (0x103af7798)
|
||||
types 0x8f2c9e (0x10342f75e) d24@0:8@16
|
||||
imp 0xfdeb6274 (0x1009f2d38)
|
||||
name 0xfbacc8 (0x103af7790)
|
||||
types 0x900b1c (0x10343d5e8) {CGPoint=dd}32@0:8@16@24
|
||||
imp 0xfdeb6364 (0x1009f2e34)
|
||||
--
|
||||
reserved 0x0
|
||||
ivarLayout 0x0
|
||||
name 0x1031c6457 DTCoreTextLayoutFrame
|
||||
baseMethods 0x102b3ca40
|
||||
entsize 12 (relative)
|
||||
count 2
|
||||
name 0x101fb08 (0x103b5c550)
|
||||
types 0x8f13d1 (0x10342de1d) v20@0:8B16
|
||||
imp 0xfdeba310 (0x1009f6d60)
|
||||
name 0x10273a4 (0x103b63df8)
|
||||
types 0x8f14c4 (0x10342df1c) B16@0:8
|
||||
imp 0xfdeba310 (0x1009f6d6c)
|
||||
baseProtocols 0x0
|
||||
ivars 0x0
|
||||
weakIvarLayout 0x0
|
||||
baseProperties 0x0
|
||||
000000010383e3c8 0x103bbaf38
|
||||
isa 0x103bbaf60
|
||||
superclass 0x0 _OBJC_CLASS_$_UIView
|
||||
cache 0x0 __objc_empty_cache
|
||||
vtable 0x0
|
||||
data 0x103935920
|
||||
flags 0x194 RO_HAS_CXX_STRUCTORS
|
||||
instanceStart 8
|
||||
instanceSize 280
|
||||
reserved 0x0
|
||||
ivarLayout 0x1031c64b4
|
||||
layout map 0x02 0xb9
|
||||
name 0x1031c647c WHLyricPageTextView
|
||||
baseMethods 0x102b3ccf0
|
||||
entsize 12 (relative)
|
||||
count 60
|
||||
name 0xfe0258 (0x103b1cf50)
|
||||
--
|
||||
reserved 0x0
|
||||
ivarLayout 0x0
|
||||
name 0x1031c67a2 WREpubTypesetter
|
||||
baseMethods 0x0
|
||||
baseProtocols 0x0
|
||||
ivars 0x0
|
||||
weakIvarLayout 0x0
|
||||
baseProperties 0x0
|
||||
Meta Class
|
||||
isa 0x0 _OBJC_METACLASS_$_NSObject
|
||||
superclass 0x0 _OBJC_METACLASS_$_NSObject
|
||||
cache 0x0 __objc_empty_cache
|
||||
vtable 0x0
|
||||
data 0x10393a6e8
|
||||
flags 0x91 RO_META
|
||||
instanceStart 40
|
||||
instanceSize 40
|
||||
reserved 0x0
|
||||
ivarLayout 0x0
|
||||
name 0x1031c67a2 WREpubTypesetter
|
||||
baseMethods 0x102b40010
|
||||
entsize 12 (relative)
|
||||
count 9
|
||||
name 0xfbd748 (0x103afd760)
|
||||
types 0x902393 (0x1034423af) @92@0:8@16Q24B32B36B40@44@52q60^q68^B76@84
|
||||
imp 0xfdf0898c (0x100a489ac)
|
||||
name 0x102d67c (0x103b6d6a0)
|
||||
types 0x9023b2 (0x1034423da) v52@0:8@16@24@32B40B44B48
|
||||
imp 0xfdf09b70 (0x100a49b9c)
|
||||
name 0xfc41f8 (0x103b04228)
|
||||
types 0x9023c0 (0x1034423f4) v40@0:8@16^B24^B32
|
||||
imp 0xfdf0a380 (0x100a4a3b8)
|
||||
name 0xfe4fbc (0x103b24ff8)
|
||||
types 0x8ede2f (0x10342de6f) B24@0:8@16
|
||||
imp 0xfdf0a5b8 (0x100a4a5fc)
|
||||
name 0xfc9180 (0x103b091c8)
|
||||
types 0x8ede18 (0x10342de64) @24@0:8@16
|
||||
imp 0xfdf0a644 (0x100a4a694)
|
||||
name 0xfc917c (0x103b091d0)
|
||||
types 0x8ede0c (0x10342de64) @24@0:8@16
|
||||
imp 0xfdf0a754 (0x100a4a7b0)
|
||||
name 0xfc9178 (0x103b091d8)
|
||||
types 0x8ede97 (0x10342defb) @28@0:8@16B24
|
||||
imp 0xfdf0a864 (0x100a4a8cc)
|
||||
name 0xfe3dbc (0x103b23e28)
|
||||
types 0x8eddff (0x10342de6f) B24@0:8@16
|
||||
imp 0xfdf0aa10 (0x100a4aa84)
|
||||
name 0xfc9170 (0x103b091e8)
|
||||
types 0x8edd93 (0x10342de0f) @32@0:8@16@24
|
||||
imp 0xfdf0aa70 (0x100a4aaf0)
|
||||
--
|
||||
ivarLayout 0x1031bc9ce
|
||||
layout map 0x13
|
||||
name 0x1031c67c4 WRCoreTextLayouter
|
||||
baseMethods 0x102b40160
|
||||
entsize 12 (relative)
|
||||
count 15
|
||||
name 0xfbd6f8 (0x103afd860)
|
||||
types 0x8edc3c (0x10342dda8) @16@0:8
|
||||
imp 0xfdf0b9a8 (0x100a4bb18)
|
||||
name 0xfde32c (0x103b1e4a0)
|
||||
types 0x8edc97 (0x10342de0f) @32@0:8@16@24
|
||||
imp 0xfdf0ba50 (0x100a4bbcc)
|
||||
name 0xfd2a90 (0x103b12c10)
|
||||
types 0x8ef376 (0x10342f4fa) ^{__CTFramesetter=}16@0:8
|
||||
imp 0xfdf0badc (0x100a4bc64)
|
||||
name 0xfc7b5c (0x103b07ce8)
|
||||
types 0x902277 (0x103442407) @72@0:8{_NSRange=QQ}16{CGRect={CGPoint=dd}{CGSize=dd}}32Q64
|
||||
imp 0xfdf0bb4c (0x100a4bce0)
|
||||
name 0x1004f38 (0x103b450d0)
|
||||
types 0x8edc14 (0x10342ddb0) v24@0:8@16
|
||||
imp 0xfdf0bc0c (0x100a4bdac)
|
||||
name 0xff1274 (0x103b31418)
|
||||
types 0x8f7c6e (0x103437e16) @40@0:8{_NSRange=QQ}16@32
|
||||
imp 0xfdf0c034 (0x100a4c1e0)
|
||||
name 0xff1270 (0x103b31420)
|
||||
types 0x8f7c62 (0x103437e16) @40@0:8{_NSRange=QQ}16@32
|
||||
imp 0xfdf0c1ac (0x100a4c364)
|
||||
name 0xfd0444 (0x103b10600)
|
||||
types 0x8edbe8 (0x10342dda8) @16@0:8
|
||||
imp 0xfdf0c410 (0x100a4c5d4)
|
||||
name 0xfff160 (0x103b3f328)
|
||||
types 0x902277 (0x103442443) @84@0:8@16{CGRect={CGPoint=dd}{CGSize=dd}}24Q56Q64B72@76
|
||||
imp 0xfdf0c420 (0x100a4c5f0)
|
||||
--
|
||||
reserved 0x0
|
||||
ivarLayout 0x0
|
||||
name 0x1031c67c4 WRCoreTextLayouter
|
||||
baseMethods 0x102b40118
|
||||
entsize 12 (relative)
|
||||
count 5
|
||||
name 0x1028e50 (0x103b68f70)
|
||||
types 0x8edd4b (0x10342de6f) B24@0:8@16
|
||||
imp 0xfdf0b47c (0x100a4b5a4)
|
||||
name 0xfe54fc (0x103b25628)
|
||||
types 0x8edd3f (0x10342de6f) B24@0:8@16
|
||||
imp 0xfdf0b7cc (0x100a4b900)
|
||||
name 0x10300e8 (0x103b70220)
|
||||
types 0x8f10a1 (0x1034311dd) v28@0:8B16@20
|
||||
imp 0xfdf0b8b8 (0x100a4b9f8)
|
||||
name 0xfc6ebc (0x103b07000)
|
||||
types 0x8edd1c (0x10342de64) @24@0:8@16
|
||||
imp 0xfdf0bd08 (0x100a4be54)
|
||||
name 0xfc6eb8 (0x103b07008)
|
||||
types 0x8edd10 (0x10342de64) @24@0:8@16
|
||||
imp 0xfdf0c020 (0x100a4c178)
|
||||
baseProtocols 0x0
|
||||
ivars 0x0
|
||||
weakIvarLayout 0x0
|
||||
baseProperties 0x0
|
||||
000000010383e4b8 0x103bbb898
|
||||
isa 0x103bbb8c0
|
||||
superclass 0x103bb0ee8
|
||||
cache 0x0 __objc_empty_cache
|
||||
vtable 0x0
|
||||
data 0x10393ab58
|
||||
flags 0x194 RO_HAS_CXX_STRUCTORS
|
||||
instanceStart 16
|
||||
--
|
||||
ivarLayout 0x1031c75f6
|
||||
layout map 0x02 0x2f 0x01 0x33 0x11
|
||||
name 0x1031c75c3 DTHTMLAttributedStringBuilder
|
||||
baseMethods 0x102b4aa58
|
||||
entsize 12 (relative)
|
||||
count 25
|
||||
name 0xfd4958 (0x103b1f3b8)
|
||||
types 0x8eb102 (0x103435b66) @40@0:8@16@24^@32
|
||||
imp 0xfdffe5dc (0x100b49044)
|
||||
name 0xfacf14 (0x103af7980)
|
||||
types 0x8e34ac (0x10342df1c) B16@0:8
|
||||
imp 0xfdffe7dc (0x100b49250)
|
||||
name 0xfc8d80 (0x103b137f8)
|
||||
types 0x8e332c (0x10342dda8) @16@0:8
|
||||
imp 0xfdfff100 (0x100b49b80)
|
||||
name 0xfae034 (0x103af8ab8)
|
||||
types 0x8e3333 (0x10342ddbb) v16@0:8
|
||||
imp 0xfdfff124 (0x100b49bb0)
|
||||
name 0xfae020 (0x103af8ab0)
|
||||
types 0x8e3327 (0x10342ddbb) v16@0:8
|
||||
imp 0xfdffff00 (0x100b4a998)
|
||||
name 0xfe7d3c (0x103b327d8)
|
||||
types 0x8f88ae (0x10344334e) v56@0:8@16@24@32{CGPoint=dd}40
|
||||
imp 0xfe000968 (0x100b4b40c)
|
||||
name 0xfe7d20 (0x103b327c8)
|
||||
types 0x8e337c (0x10342de28) v32@0:8@16@24
|
||||
imp 0xfe000efc (0x100b4b9ac)
|
||||
name 0xfe7be4 (0x103b32698)
|
||||
types 0x8e32f8 (0x10342ddb0) v24@0:8@16
|
||||
imp 0xfe0010c8 (0x100b4bb84)
|
||||
name 0xfe7d30 (0x103b327f0)
|
||||
types 0x8e3364 (0x10342de28) v32@0:8@16@24
|
||||
imp 0xfe0015b8 (0x100b4c080)
|
||||
--
|
||||
reserved 0x0
|
||||
ivarLayout 0x0
|
||||
name 0x1031c75c3 DTHTMLAttributedStringBuilder
|
||||
baseMethods 0x0
|
||||
baseProtocols 0x10394b7f0
|
||||
count 1
|
||||
list[0] 0x103c1bed0
|
||||
isa 0x0
|
||||
name 0x1031c75e1 DTHTMLParserDelegate
|
||||
protocols 0x10394b690
|
||||
count 1
|
||||
list[0] 0x103c15580
|
||||
isa 0x0
|
||||
name 0x1031bc9c5 NSObject
|
||||
protocols 0x0
|
||||
instanceMethods 0x103884670
|
||||
entsize 24
|
||||
count 19
|
||||
name 0x103348607 isEqual:
|
||||
types 0x10342de6f B24@0:8@16
|
||||
imp 0x0
|
||||
name 0x1032e9bde class
|
||||
types 0x10342df44 #16@0:8
|
||||
imp 0x0
|
||||
name 0x1033a90f1 self
|
||||
types 0x10342dda8 @16@0:8
|
||||
imp 0x0
|
||||
name 0x1033780e4 performSelector:
|
||||
types 0x10342df4c @24@0:8:16
|
||||
imp 0x0
|
||||
name 0x103378163 performSelector:withObject:
|
||||
types 0x10342df57 @32@0:8:16@24
|
||||
imp 0x0
|
||||
--
|
||||
ivarLayout 0x1031c0ccf
|
||||
layout map 0x61
|
||||
name 0x1031cb9a9 WRPageViewController
|
||||
baseMethods 0x102b81cf8
|
||||
entsize 12 (relative)
|
||||
count 73
|
||||
name 0xfbd2d0 (0x103b3efd0)
|
||||
types 0x8ac0b7 (0x10342ddbb) v16@0:8
|
||||
imp 0xfe4dedec (0x101060af4)
|
||||
name 0xff0bac (0x103b728b8)
|
||||
types 0x8c707f (0x103448d8f) v72@0:8@16q24{CGPoint=dd}32q48B56B60@?64
|
||||
imp 0xfe4dedf0 (0x101060b04)
|
||||
name 0xfdf8d0 (0x103b615e8)
|
||||
types 0x8b32e5 (0x103435001) v44@0:8@16q24B32@?36
|
||||
imp 0xfe4df5ec (0x10106130c)
|
||||
name 0xf9d194 (0x103b1eeb8)
|
||||
types 0x8bdff1 (0x10343fd19) @40@0:8@16Q24q32
|
||||
imp 0xfe4df984 (0x1010616b0)
|
||||
name 0xfaf870 (0x103b315a0)
|
||||
types 0x8ac1d5 (0x10342df09) q16@0:8
|
||||
imp 0xfe4dfba8 (0x1010618e0)
|
||||
name 0xff0084 (0x103b71dc0)
|
||||
types 0x8ac07b (0x10342ddbb) v16@0:8
|
||||
imp 0xfe4dfbac (0x1010618f0)
|
||||
name 0xff0038 (0x103b71d80)
|
||||
types 0x8ac0d1 (0x10342de1d) v20@0:8B16
|
||||
imp 0xfe4dfcb4 (0x101061a04)
|
||||
name 0xf87bb4 (0x103b09908)
|
||||
types 0x8ac063 (0x10342ddbb) v16@0:8
|
||||
imp 0xfe4dfcf0 (0x101061a4c)
|
||||
name 0xf78590 (0x103afa2f0)
|
||||
types 0x8ac057 (0x10342ddbb) v16@0:8
|
||||
imp 0xfe4dfe08 (0x101061b70)
|
||||
--
|
||||
reserved 0x0
|
||||
ivarLayout 0x0
|
||||
name 0x1031cb9a9 WRPageViewController
|
||||
baseMethods 0x102b81c98
|
||||
entsize 12 (relative)
|
||||
count 7
|
||||
name 0xf9f110 (0x103b20db0)
|
||||
types 0x8ac117 (0x10342ddbb) v16@0:8
|
||||
imp 0xfe4dc830 (0x10105e4d8)
|
||||
name 0xfb0c9c (0x103b32948)
|
||||
types 0x8ac10b (0x10342ddbb) v16@0:8
|
||||
imp 0xfe4dc99c (0x10105e650)
|
||||
name 0xfb0c88 (0x103b32940)
|
||||
types 0x8ac0ff (0x10342ddbb) v16@0:8
|
||||
imp 0xfe4dd264 (0x10105ef24)
|
||||
name 0xfb0c74 (0x103b32938)
|
||||
types 0x8ac0f3 (0x10342ddbb) v16@0:8
|
||||
imp 0xfe4ddb28 (0x10105f7f4)
|
||||
name 0xf89a18 (0x103b0b6e8)
|
||||
types 0x8c708d (0x103448d61) B48@0:8@16@24B32B36B40B44
|
||||
imp 0xfe4de164 (0x10105fe3c)
|
||||
name 0xfa437c (0x103b26058)
|
||||
types 0x8ae3b8 (0x103430098) q32@0:8@16@24
|
||||
imp 0xfe4dedac (0x101060a90)
|
||||
name 0xfe3180 (0x103b64e68)
|
||||
types 0x8ac0c4 (0x10342ddb0) v24@0:8@16
|
||||
imp 0xfe4e15b8 (0x1010632a8)
|
||||
baseProtocols 0x1039a0c00
|
||||
count 4
|
||||
list[0] 0x103c17bc8
|
||||
isa 0x0
|
||||
name 0x1031bf992 UIPageViewControllerDataSource
|
||||
protocols 0x1038b4590
|
||||
673
Doc/WXRead/resources/css/MPExtra.css
Normal file
673
Doc/WXRead/resources/css/MPExtra.css
Normal file
@ -0,0 +1,673 @@
|
||||
/****** MP文集 ******/
|
||||
|
||||
/* Copy 自公众号文章 CSS START */
|
||||
|
||||
.rich_media_inner {
|
||||
font-size: 16px;
|
||||
word-wrap: break-word;
|
||||
-webkit-hyphens: auto;
|
||||
-ms-hyphens: auto;
|
||||
hyphens: auto
|
||||
}
|
||||
.rich_media_area_primary {
|
||||
position: relative;
|
||||
padding: 10px 20px 20px;
|
||||
background-color: #fff
|
||||
}
|
||||
.rich_media_area_primary:before {
|
||||
content: " ";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
-webkit-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
-webkit-transform: scaleY(0.5);
|
||||
transform: scaleY(0.5);
|
||||
top: auto;
|
||||
bottom: -2px
|
||||
}
|
||||
.rich_media_area_primary .original_img_wrp {
|
||||
display: inline-block;
|
||||
font-size: 0
|
||||
}
|
||||
.rich_media_area_primary .original_img_wrp .tips_global {
|
||||
display: block;
|
||||
margin-top: .5em;
|
||||
font-size: 14px;
|
||||
text-align: right;
|
||||
width: auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal
|
||||
}
|
||||
.rich_media_area_extra {
|
||||
padding: 0 15px 0
|
||||
}
|
||||
.rich_media_title {
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.47;
|
||||
font-weight: 400;
|
||||
font-size: 23px
|
||||
}
|
||||
.rich_media_meta_list {
|
||||
margin-bottom: 18px;
|
||||
line-height: 20px;
|
||||
font-size: 0;
|
||||
display: none;
|
||||
}
|
||||
.rich_media_meta_list em {
|
||||
font-style: normal
|
||||
}
|
||||
.rich_media_meta {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 16px
|
||||
}
|
||||
.meta_original_tag {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
padding: 1px .5em;
|
||||
border: 1px solid #9e9e9e;
|
||||
color: #8c8c8c;
|
||||
border-top-left-radius: 20% 50%;
|
||||
-moz-border-radius-topleft: 20% 50%;
|
||||
-webkit-border-top-left-radius: 20% 50%;
|
||||
border-top-right-radius: 20% 50%;
|
||||
-moz-border-radius-topright: 20% 50%;
|
||||
-webkit-border-top-right-radius: 20% 50%;
|
||||
border-bottom-left-radius: 20% 50%;
|
||||
-moz-border-radius-bottomleft: 20% 50%;
|
||||
-webkit-border-bottom-left-radius: 20% 50%;
|
||||
border-bottom-right-radius: 20% 50%;
|
||||
-moz-border-radius-bottomright: 20% 50%;
|
||||
-webkit-border-bottom-right-radius: 20% 50%;
|
||||
font-size: 15px;
|
||||
line-height: 1.1
|
||||
}
|
||||
.meta_enterprise_tag img {
|
||||
width: 30px;
|
||||
height: 30px!important;
|
||||
display: block;
|
||||
position: relative;
|
||||
margin-top: -3px;
|
||||
border: 0
|
||||
}
|
||||
.rich_media_meta_text {
|
||||
color: #8c8c8c
|
||||
}
|
||||
span.rich_media_meta_nickname {
|
||||
display: none
|
||||
}
|
||||
.rich_media_thumb_wrp {
|
||||
margin-bottom: 6px
|
||||
}
|
||||
.rich_media_thumb_wrp .original_img_wrp {
|
||||
display: block
|
||||
}
|
||||
.rich_media_thumb {
|
||||
display: block;
|
||||
width: 100%
|
||||
}
|
||||
.rich_media_content {
|
||||
overflow: hidden;
|
||||
color: #3e3e3e
|
||||
}
|
||||
.rich_media_content * {
|
||||
max-width: 100%!important;
|
||||
box-sizing: border-box!important;
|
||||
-webkit-box-sizing: border-box!important;
|
||||
word-wrap: break-word!important
|
||||
}
|
||||
.rich_media_content p {
|
||||
clear: both;
|
||||
min-height: 1em
|
||||
}
|
||||
.rich_media_content em {
|
||||
font-style: italic
|
||||
}
|
||||
.rich_media_content fieldset {
|
||||
min-width: 0
|
||||
}
|
||||
.rich_media_content .list-paddingleft-2 {
|
||||
padding-left: 30px
|
||||
}
|
||||
.rich_media_content blockquote {
|
||||
margin: 0;
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid #dbdbdb
|
||||
}
|
||||
/*img {*/
|
||||
/* height: auto!important*/
|
||||
/*}*/
|
||||
@media screen and (device-aspect-ratio: 2/3),
|
||||
screen and (device-aspect-ratio: 40/71) {
|
||||
.meta_original_tag {
|
||||
padding-top: 0
|
||||
}
|
||||
}
|
||||
@media(min-device-width:375px) and (max-device-width:667px) and (-webkit-min-device-pixel-ratio:2) {
|
||||
.mm_appmsg .rich_media_inner, .mm_appmsg .rich_media_meta, .mm_appmsg .discuss_list, .mm_appmsg .rich_media_extra, .mm_appmsg .title_tips .tips {
|
||||
font-size: 17px
|
||||
}
|
||||
.mm_appmsg .meta_original_tag {
|
||||
font-size: 15px
|
||||
}
|
||||
}
|
||||
@media(min-device-width:414px) and (max-device-width:736px) and (-webkit-min-device-pixel-ratio:3) {
|
||||
/* .mm_appmsg .rich_media_title {*/
|
||||
/* font-size: 25px*/
|
||||
/* }*/
|
||||
}
|
||||
@media screen and (min-width: 1024px) {
|
||||
.rich_media {
|
||||
width: 740px;
|
||||
margin-left: auto;
|
||||
margin-right: auto
|
||||
}
|
||||
.rich_media_inner {
|
||||
padding: 20px
|
||||
}
|
||||
body {
|
||||
background-color: #fff
|
||||
}
|
||||
}
|
||||
@media screen and (min-width: 1025px) {
|
||||
body {
|
||||
font-family: "Helvetica Neue", Helvetica, "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif
|
||||
}
|
||||
.rich_media {
|
||||
position: relative
|
||||
}
|
||||
.rich_media_inner {
|
||||
background-color: #fff;
|
||||
padding-bottom: 100px
|
||||
}
|
||||
}
|
||||
.radius_avatar {
|
||||
display: inline-block;
|
||||
background-color: #fff;
|
||||
padding: 3px;
|
||||
border-radius: 50%;
|
||||
-moz-border-radius: 50%;
|
||||
-webkit-border-radius: 50%;
|
||||
overflow: hidden;
|
||||
vertical-align: middle
|
||||
}
|
||||
.radius_avatar img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
-moz-border-radius: 50%;
|
||||
-webkit-border-radius: 50%;
|
||||
background-color: #eee
|
||||
}
|
||||
.cell {
|
||||
padding: .8em 0;
|
||||
display: block;
|
||||
position: relative
|
||||
}
|
||||
.cell_hd,
|
||||
.cell_bd,
|
||||
.cell_ft {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
white-space: nowrap
|
||||
}
|
||||
.cell_primary {
|
||||
width: 2000px;
|
||||
white-space: normal
|
||||
}
|
||||
.flex_cell {
|
||||
padding: 10px 0;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-box-align: center;
|
||||
-webkit-align-items: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center
|
||||
}
|
||||
.flex_cell_primary {
|
||||
width: 100%;
|
||||
-webkit-box-flex: 1;
|
||||
-webkit-flex: 1;
|
||||
-ms-flex: 1;
|
||||
box-flex: 1;
|
||||
flex: 1
|
||||
}
|
||||
#copyright_info.original_tool_area {
|
||||
display: none;
|
||||
}
|
||||
.original_tool_area {
|
||||
display: block;
|
||||
padding: .75em 1em 0;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
color: #3e3e3e;
|
||||
border: 1px solid #eaeaea;
|
||||
margin: 20px 0
|
||||
}
|
||||
.original_tool_area .tips_global {
|
||||
position: relative;
|
||||
padding-bottom: .5em;
|
||||
font-size: 15px
|
||||
}
|
||||
.original_tool_area .tips_global:after {
|
||||
content: " ";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
border-bottom: 1px solid #dbdbdb;
|
||||
-webkit-transform-origin: 0 100%;
|
||||
transform-origin: 0 100%;
|
||||
-webkit-transform: scaleY(0.5);
|
||||
transform: scaleY(0.5)
|
||||
}
|
||||
.original_tool_area .radius_avatar {
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
padding: 0;
|
||||
margin-right: .5em
|
||||
}
|
||||
.original_tool_area .radius_avatar img {
|
||||
height: 100%!important
|
||||
}
|
||||
.original_tool_area .flex_cell_bd {
|
||||
width: auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal
|
||||
}
|
||||
.original_tool_area .flex_cell_ft {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
padding-left: 1em;
|
||||
white-space: nowrap
|
||||
}
|
||||
.original_tool_area .icon_access:after {
|
||||
content: " ";
|
||||
display: inline-block;
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
border-width: 1px 1px 0 0;
|
||||
border-color: #cbcad0;
|
||||
border-style: solid;
|
||||
transform: matrix(0.71, 0.71, -0.71, 0.71, 0, 0);
|
||||
-ms-transform: matrix(0.71, 0.71, -0.71, 0.71, 0, 0);
|
||||
-webkit-transform: matrix(0.71, 0.71, -0.71, 0.71, 0, 0);
|
||||
position: relative;
|
||||
top: -2px;
|
||||
top: -1px
|
||||
}
|
||||
.weui_loading {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
-webkit-animation: weuiLoading 1s steps(12, end) infinite;
|
||||
animation: weuiLoading 1s steps(12, end) infinite;
|
||||
background: transparent url(data:image/svg+xml;base64,PHN2ZyBjbGFzcz0iciIgd2lkdGg9JzEyMHB4JyBoZWlnaHQ9JzEyMHB4JyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj4KICAgIDxyZWN0IHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIiBmaWxsPSJub25lIiBjbGFzcz0iYmsiPjwvcmVjdD4KICAgIDxyZWN0IHg9JzQ2LjUnIHk9JzQwJyB3aWR0aD0nNycgaGVpZ2h0PScyMCcgcng9JzUnIHJ5PSc1JyBmaWxsPScjRTlFOUU5JwogICAgICAgICAgdHJhbnNmb3JtPSdyb3RhdGUoMCA1MCA1MCkgdHJhbnNsYXRlKDAgLTMwKSc+CiAgICA8L3JlY3Q+CiAgICA8cmVjdCB4PSc0Ni41JyB5PSc0MCcgd2lkdGg9JzcnIGhlaWdodD0nMjAnIHJ4PSc1JyByeT0nNScgZmlsbD0nIzk4OTY5NycKICAgICAgICAgIHRyYW5zZm9ybT0ncm90YXRlKDMwIDUwIDUwKSB0cmFuc2xhdGUoMCAtMzApJz4KICAgICAgICAgICAgICAgICByZXBlYXRDb3VudD0naW5kZWZpbml0ZScvPgogICAgPC9yZWN0PgogICAgPHJlY3QgeD0nNDYuNScgeT0nNDAnIHdpZHRoPSc3JyBoZWlnaHQ9JzIwJyByeD0nNScgcnk9JzUnIGZpbGw9JyM5Qjk5OUEnCiAgICAgICAgICB0cmFuc2Zvcm09J3JvdGF0ZSg2MCA1MCA1MCkgdHJhbnNsYXRlKDAgLTMwKSc+CiAgICAgICAgICAgICAgICAgcmVwZWF0Q291bnQ9J2luZGVmaW5pdGUnLz4KICAgIDwvcmVjdD4KICAgIDxyZWN0IHg9JzQ2LjUnIHk9JzQwJyB3aWR0aD0nNycgaGVpZ2h0PScyMCcgcng9JzUnIHJ5PSc1JyBmaWxsPScjQTNBMUEyJwogICAgICAgICAgdHJhbnNmb3JtPSdyb3RhdGUoOTAgNTAgNTApIHRyYW5zbGF0ZSgwIC0zMCknPgogICAgPC9yZWN0PgogICAgPHJlY3QgeD0nNDYuNScgeT0nNDAnIHdpZHRoPSc3JyBoZWlnaHQ9JzIwJyByeD0nNScgcnk9JzUnIGZpbGw9JyNBQkE5QUEnCiAgICAgICAgICB0cmFuc2Zvcm09J3JvdGF0ZSgxMjAgNTAgNTApIHRyYW5zbGF0ZSgwIC0zMCknPgogICAgPC9yZWN0PgogICAgPHJlY3QgeD0nNDYuNScgeT0nNDAnIHdpZHRoPSc3JyBoZWlnaHQ9JzIwJyByeD0nNScgcnk9JzUnIGZpbGw9JyNCMkIyQjInCiAgICAgICAgICB0cmFuc2Zvcm09J3JvdGF0ZSgxNTAgNTAgNTApIHRyYW5zbGF0ZSgwIC0zMCknPgogICAgPC9yZWN0PgogICAgPHJlY3QgeD0nNDYuNScgeT0nNDAnIHdpZHRoPSc3JyBoZWlnaHQ9JzIwJyByeD0nNScgcnk9JzUnIGZpbGw9JyNCQUI4QjknCiAgICAgICAgICB0cmFuc2Zvcm09J3JvdGF0ZSgxODAgNTAgNTApIHRyYW5zbGF0ZSgwIC0zMCknPgogICAgPC9yZWN0PgogICAgPHJlY3QgeD0nNDYuNScgeT0nNDAnIHdpZHRoPSc3JyBoZWlnaHQ9JzIwJyByeD0nNScgcnk9JzUnIGZpbGw9JyNDMkMwQzEnCiAgICAgICAgICB0cmFuc2Zvcm09J3JvdGF0ZSgyMTAgNTAgNTApIHRyYW5zbGF0ZSgwIC0zMCknPgogICAgPC9yZWN0PgogICAgPHJlY3QgeD0nNDYuNScgeT0nNDAnIHdpZHRoPSc3JyBoZWlnaHQ9JzIwJyByeD0nNScgcnk9JzUnIGZpbGw9JyNDQkNCQ0InCiAgICAgICAgICB0cmFuc2Zvcm09J3JvdGF0ZSgyNDAgNTAgNTApIHRyYW5zbGF0ZSgwIC0zMCknPgogICAgPC9yZWN0PgogICAgPHJlY3QgeD0nNDYuNScgeT0nNDAnIHdpZHRoPSc3JyBoZWlnaHQ9JzIwJyByeD0nNScgcnk9JzUnIGZpbGw9JyNEMkQyRDInCiAgICAgICAgICB0cmFuc2Zvcm09J3JvdGF0ZSgyNzAgNTAgNTApIHRyYW5zbGF0ZSgwIC0zMCknPgogICAgPC9yZWN0PgogICAgPHJlY3QgeD0nNDYuNScgeT0nNDAnIHdpZHRoPSc3JyBoZWlnaHQ9JzIwJyByeD0nNScgcnk9JzUnIGZpbGw9JyNEQURBREEnCiAgICAgICAgICB0cmFuc2Zvcm09J3JvdGF0ZSgzMDAgNTAgNTApIHRyYW5zbGF0ZSgwIC0zMCknPgogICAgPC9yZWN0PgogICAgPHJlY3QgeD0nNDYuNScgeT0nNDAnIHdpZHRoPSc3JyBoZWlnaHQ9JzIwJyByeD0nNScgcnk9JzUnIGZpbGw9JyNFMkUyRTInCiAgICAgICAgICB0cmFuc2Zvcm09J3JvdGF0ZSgzMzAgNTAgNTApIHRyYW5zbGF0ZSgwIC0zMCknPgogICAgPC9yZWN0Pgo8L3N2Zz4=) no-repeat;
|
||||
-webkit-background-size: 100%;
|
||||
background-size: 100%
|
||||
}
|
||||
@-webkit-keyframes weuiLoading {
|
||||
0% {
|
||||
-webkit-transform: rotate3d(0, 0, 1, 0deg)
|
||||
}
|
||||
100% {
|
||||
-webkit-transform: rotate3d(0, 0, 1, 360deg)
|
||||
}
|
||||
}
|
||||
@keyframes weuiLoading {
|
||||
0% {
|
||||
-webkit-transform: rotate3d(0, 0, 1, 0deg)
|
||||
}
|
||||
100% {
|
||||
-webkit-transform: rotate3d(0, 0, 1, 360deg)
|
||||
}
|
||||
}
|
||||
.gif_img_wrp {
|
||||
display: inline-block;
|
||||
font-size: 0;
|
||||
position: relative;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
text-indent: 0;
|
||||
text-shadow: none 1px 1px rgba(0, 0, 0, 0.5)
|
||||
}
|
||||
.gif_img_wrp img {
|
||||
vertical-align: top
|
||||
}
|
||||
.gif_img_tips {
|
||||
background: rgba(0, 0, 0, 0.6)!important;
|
||||
filter: progid: DXImageTransform.Microsoft.gradient(GradientType=0, startColorstr='#99000000', endcolorstr '#99000000');
|
||||
border-top-left-radius: 1.2em 50%;
|
||||
-moz-border-radius-topleft: 1.2em 50%;
|
||||
-webkit-border-top-left-radius: 1.2em 50%;
|
||||
border-top-right-radius: 1.2em 50%;
|
||||
-moz-border-radius-topright: 1.2em 50%;
|
||||
-webkit-border-top-right-radius: 1.2em 50%;
|
||||
border-bottom-left-radius: 1.2em 50%;
|
||||
-moz-border-radius-bottomleft: 1.2em 50%;
|
||||
-webkit-border-bottom-left-radius: 1.2em 50%;
|
||||
border-bottom-right-radius: 1.2em 50%;
|
||||
-moz-border-radius-bottomright: 1.2em 50%;
|
||||
-webkit-border-bottom-right-radius: 1.2em 50%;
|
||||
line-height: 2.3;
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
min-width: 65px
|
||||
}
|
||||
.gif_img_tips.loading {
|
||||
min-width: 75px
|
||||
}
|
||||
.gif_img_tips i {
|
||||
vertical-align: middle;
|
||||
margin: -0.2em .73em 0 -2px
|
||||
}
|
||||
.gif_img_play_arrow {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-width: 8px;
|
||||
border-style: dashed;
|
||||
border-color: transparent;
|
||||
border-right-width: 0;
|
||||
border-left-color: #fff;
|
||||
border-left-style: solid;
|
||||
border-width: 5px 0 5px 8px
|
||||
}
|
||||
.gif_img_loading {
|
||||
width: 14px;
|
||||
height: 14px
|
||||
}
|
||||
i.gif_img_loading {
|
||||
margin-left: -4px
|
||||
}
|
||||
.gif_bg_tips_wrp {
|
||||
position: relative;
|
||||
height: 0;
|
||||
line-height: 0;
|
||||
margin: 0;
|
||||
padding: 0
|
||||
}
|
||||
.gif_bg_tips_wrp .gif_img_tips_group {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 9999
|
||||
}
|
||||
.gif_bg_tips_wrp .gif_img_tips_group .gif_img_tips {
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: auto
|
||||
}
|
||||
.rich_media_global_msg {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 1em 35px 1em 15px;
|
||||
z-index: 2;
|
||||
background-color: #c6e0f8;
|
||||
color: #8c8c8c;
|
||||
font-size: 13px
|
||||
}
|
||||
.rich_media_global_msg .icon_closed {
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
top: 50%;
|
||||
margin-top: -5px;
|
||||
line-height: 300px;
|
||||
overflow: hidden;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
background: transparent url(//res.wx.qq.com/mmbizwap/zh_CN/htmledition/images/icon/appmsg/icon_appmsg_msg_closed_sprite.2x2eb52b.png) no-repeat 0 0;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
-webkit-background-size: 100% auto;
|
||||
background-size: 100% auto
|
||||
}
|
||||
.rich_media_global_msg .icon_closed:active {
|
||||
background-position: 0 -17px
|
||||
}
|
||||
.preview_appmsg .rich_media_title {
|
||||
margin-top: 26px;
|
||||
}
|
||||
@media screen and (min-width: 1024px) {
|
||||
.rich_media_global_msg {
|
||||
position: relative;
|
||||
margin: 0 20px
|
||||
}
|
||||
.preview_appmsg .rich_media_title {
|
||||
margin-top: 0
|
||||
}
|
||||
}
|
||||
.pages_reset {
|
||||
color: #3e3e3e;
|
||||
line-height: 1.6;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
text-indent: 0;
|
||||
letter-spacing: normal;
|
||||
text-align: left;
|
||||
text-decoration: none
|
||||
}
|
||||
.weapp_element,
|
||||
.weapp_display_element,
|
||||
.mp-miniprogram {
|
||||
display: block;
|
||||
margin: 1em 0
|
||||
}
|
||||
.share_audio_context {
|
||||
margin: 16px 0
|
||||
}
|
||||
.weapp_text_link {
|
||||
font-size: 17px
|
||||
}
|
||||
.weapp_text_link:before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
background-size: 12px 12px;
|
||||
background-repeat: no-repeat;
|
||||
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAMAAABF0y+mAAAAb1BMVEUAAAB4it11h9x2h9x2h9x2htx8j+R8i+B1h9x2h9x3h92Snv91htt2h9x1h9x4h9x1h9x1h9x2idx1h9t2h9t1htt1h9x1h9x1htx2h9x1h912h9x4h913iN17juOOjuN1iNx2h9t4h958i+B1htvejBiPAAAAJHRSTlMALPLcxKcVEOXXUgXtspU498sx69DPu5+Yc2JeRDwbCYuIRiGBtoolAAAA3ElEQVQoz62S1xKDIBBFWYiFYImm2DWF///G7DJEROOb58U79zi4O8iOo8zuCRfV8EdFgbYE49qFQs8ksJInajOA1wWfYvLcGSueU/oUGBtPpti09uNS68KTMcrQ5jce4kmN/HKn9XVPAo702JEdx9hTUrWUqVrI3KwUmM1NhIWMKdwiGvpGMWZOAj1PZuzAxHwhVSplrajoseBnbyDHAwvrtvKKhdqTtFBkL8wO5ijcsS3G1JMNvQ5mdW7fc0x0+ZcnlJlZiflAomdEyFaM7qeK2JahEjy5ZyU7jC/q/Rz/DgqEuAAAAABJRU5ErkJggg==');
|
||||
vertical-align: middle;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
border-radius: 10px;
|
||||
background-color: #f4f4f4;
|
||||
margin-right: 6px;
|
||||
margin-top: -4px;
|
||||
background-position: center;
|
||||
height: 20px;
|
||||
width: 20px
|
||||
}
|
||||
.weui-mask {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6)
|
||||
}
|
||||
.weui-dialog {
|
||||
position: fixed;
|
||||
z-index: 5000;
|
||||
width: 80%;
|
||||
max-width: 300px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
-webkit-transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: #fff;
|
||||
text-align: center;
|
||||
border-radius: 3px;
|
||||
overflow: hidden
|
||||
}
|
||||
.weui-dialog__hd {
|
||||
padding: 1.3em 1.6em .5em
|
||||
}
|
||||
.weui-dialog__title {
|
||||
font-weight: 400;
|
||||
font-size: 18px
|
||||
}
|
||||
.weui-dialog__bd {
|
||||
padding: 0 1.6em .8em;
|
||||
min-height: 40px;
|
||||
font-size: 15px;
|
||||
line-height: 1.3;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
color: #999
|
||||
}
|
||||
.weui-dialog__bd:first-child {
|
||||
padding: 2.7em 20px 1.7em;
|
||||
color: #353535
|
||||
}
|
||||
.weui-dialog__ft {
|
||||
position: relative;
|
||||
line-height: 48px;
|
||||
font-size: 18px;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: flex
|
||||
}
|
||||
.weui-dialog__ft:after {
|
||||
content: " ";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
border-top: 1px solid #d5d5d6;
|
||||
color: #d5d5d6;
|
||||
-webkit-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
-webkit-transform: scaleY(0.5);
|
||||
transform: scaleY(0.5)
|
||||
}
|
||||
.weui-dialog__btn {
|
||||
display: block;
|
||||
-webkit-box-flex: 1;
|
||||
-webkit-flex: 1;
|
||||
flex: 1;
|
||||
color: #3cc51f;
|
||||
text-decoration: none;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
position: relative
|
||||
}
|
||||
.weui-dialog__btn:active {
|
||||
background-color: #eee
|
||||
}
|
||||
.weui-dialog__btn:after {
|
||||
content: " ";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 1px;
|
||||
bottom: 0;
|
||||
border-left: 1px solid #d5d5d6;
|
||||
color: #d5d5d6;
|
||||
-webkit-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
-webkit-transform: scaleX(0.5);
|
||||
transform: scaleX(0.5)
|
||||
}
|
||||
.weui-dialog__btn:first-child:after {
|
||||
display: none
|
||||
}
|
||||
.weui-dialog__btn_default {
|
||||
color: #353535
|
||||
}
|
||||
.weui-dialog__btn_primary {
|
||||
color: #0bb20c
|
||||
}
|
||||
/* Copy 自公众号文章 CSS END */
|
||||
|
||||
img[data-w] {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
|
||||
iframe.video_iframe {
|
||||
overflow: hidden !important; margin-left:-16px !important;
|
||||
}
|
||||
iframe.vote_iframe {
|
||||
display: none !important;
|
||||
}
|
||||
/* 不支持的 DOM */
|
||||
.unsupported_iframe {
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ffcdc0;
|
||||
background: #fee;
|
||||
padding: 0 16px;
|
||||
line-height: 50px;
|
||||
color: #ff8d8d;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 公众号音频样式 */
|
||||
|
||||
.re .audio_container {border-width: 1px 0; border-color: #D4D6D8; border-style: solid; padding: 16px 20px 16px 0; margin-left: -16px; margin-right: -16px; line-height: 1 !important;}
|
||||
.re .audio_button {width: 40px; height: 40px; background-color: #f0f4f8; border-radius: 20px; float: left; margin-left: 16px !important; margin-top: 10px !important; background: center center no-repeat url(icon_lecture_playing_2x.png); background-size: contain;}
|
||||
.re .audio_button.active { background-image: url(icon_lecture_pause_2x.png);}
|
||||
.re .audio_content { overflow: hidden; padding-left: 16px;}
|
||||
.re .audio_title {font-size: 18px; font-weight: bold; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: #353C46;}
|
||||
.re .audio_progress {height: 3px; position: relative; margin-top: 16px !important; margin-right: 16px !important;}
|
||||
.re .audio_progress_bottom {height: 100%; background-color: #E8F3FD; position: absolute; top: 0px; left: 0px; right: -16px;}
|
||||
.re .audio_progress_top {height: 100%;background-color: #3CABFF; position: absolute; top: 0px; left: 0px;}
|
||||
.re .audio_progress_current {width: 16px; height: 16px; border-radius: 8px; z-index: 10; box-shadow:0px 3px 6px rgba(0, 25, 104, 0.12); background-color: #FFFFFF; position: absolute; top: 50%; margin-top: -8px !important;}
|
||||
.re .audio_progress_current::before {content: ''; width: 4px; height: 4px; position: absolute; background-color: #3CABFF; border-radius: 2px; top: 50%; left: 50%; margin-top: -2px; margin-left: -2px;}
|
||||
.re .audio_time {margin-top: 12px !important; overflow: hidden;}
|
||||
.re .audio_time span {color: #54A6F3 !important; font-size: 11px !important;}
|
||||
.re .audio_time_left {float: left;}
|
||||
.re .audio_time_right {float: right;}
|
||||
|
||||
@media (-webkit-min-device-pixel-ratio: 2), (min--moz-device-pixel-ratio: 2), (min-device-pixel-ratio: 2), (-o-min-device-pixel-ratio: 2/1), (min-resolution: 2dppx), (min-resolution: 192dpi) {
|
||||
.re .audio_container { position: relative; border: none; }
|
||||
.re .audio_container:after { content: ""; position: absolute; top: 0; left: 0; width: 200%; height: 200%; border-radius: 0; border-style: solid; border-color: #d4d6d8; border-width: 1px 0 1px 0; -webkit-transform: scale(0.5, 0.5); -ms-transform: scale(0.5, 0.5); transform: scale(0.5, 0.5); -webkit-transform-origin: 0 0; -ms-transform-origin: 0 0; transform-origin: 0 0; -webkit-box-sizing: border-box; box-sizing: border-box; pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (-webkit-min-device-pixel-ratio: 3), (min--moz-device-pixel-ratio: 3), (min-device-pixel-ratio: 3), (-o-min-device-pixel-ratio: 3/1), (min-resolution: 3dppx), (min-resolution: 288dpi) {
|
||||
.re .audio_button { background-image: url(icon_lecture_playing_3x.png); }
|
||||
.re .audio_button.active { background-image: url(icon_lecture_pause_3x.png); }
|
||||
.re .audio_container:after { width: 300%; height: 300%; border-radius: 0; -webkit-transform: scale(0.3333, 0.3333); -ms-transform: scale(0.3333, 0.3333); transform: scale(0.3333, 0.3333);
|
||||
}
|
||||
}
|
||||
|
||||
/* 被删除的公众号底部链接删除,不要直接 display none 而是让他们不显示是因为我们还需要那一块的高度 */
|
||||
|
||||
.weui-msg > .weui-msg__opr-area > .weui-msg__opr-area__tips > a,
|
||||
.page_msg > .extra_area > .tips > a,
|
||||
#activity-detail .extra_area > .tips > a {
|
||||
color: #fff;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 想法详情 WebView 里的 CSS 会不正确地将 Portrait 识别为 Landscape,但因为我们的想法详情暂时不会出现横屏的情况,先临时这样修改 */
|
||||
@media only screen and (-webkit-device-pixel-ratio: 3) and (device-height: 812px) and (device-width: 375px) and (orientation: landscape) {
|
||||
.rich_media_area_primary {
|
||||
padding: 20px 20px 15px 20px !important;
|
||||
}
|
||||
}
|
||||
140
Doc/WXRead/resources/css/MediaPlatform.css
Normal file
140
Doc/WXRead/resources/css/MediaPlatform.css
Normal file
@ -0,0 +1,140 @@
|
||||
@charset "UTF-8";
|
||||
|
||||
/****** @override 微信公众号样式 ******/
|
||||
body {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
img {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.rich_media_area_primary {
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
outline: 0px solid transparent;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
-webkit-user-select: auto !important;
|
||||
-webkit-user-modify: read-only !important;
|
||||
font-size: 18px;
|
||||
line-height: 30px;
|
||||
font-family: -apple-system;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.rich_media_title {
|
||||
margin: -3px 0 10px 0;
|
||||
overflow: hidden;
|
||||
font-weight: 700;
|
||||
font-size: 24px;
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
.rich_media_content {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.rich_media_content p {
|
||||
/* margin: 20px 0px 0px 0px;*/
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.rich_media_content blockquote {
|
||||
margin: 18px 0px 0px 0px;
|
||||
padding: 14px;
|
||||
/* color: #353C46;*/
|
||||
/* border-left: 2px solid #D4D6D8;*/
|
||||
/* background-color: #F4F5F7;*/
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.rich_media_content blockquote p:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rich_media_content blockquote p:first-child {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
/*复制的内容有可能经过js之后会包一个空的p,所以要找到下一个p去掉margin*/
|
||||
.rich_media_content blockquote p:empty:first-child + p {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.rich_media_tool {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rich_media_content img {
|
||||
/* display: inline-block;
|
||||
margin: 0;*/
|
||||
}
|
||||
|
||||
/*覆盖QQ音乐的样式*/
|
||||
.qqmusic_area {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
/*分享图片类型的页面去掉了顶部的信息,需要手动加一个 Margin 防止内容贴着顶部*/
|
||||
.page_share_img .share_notice {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.share_mod_context .account_info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 屏蔽 iPad 上可能出现的 PC 版公众号二维码 */
|
||||
#js_pc_qr_code {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.tts {
|
||||
background-color: rgba(27, 136, 238, .25) !important;
|
||||
}
|
||||
|
||||
/* 自己的想法 */
|
||||
.review {
|
||||
border-bottom: 1px dashed rgba(249, 84, 87, 1) !important;
|
||||
}
|
||||
|
||||
/* 好友的想法 */
|
||||
.friend-review {
|
||||
border-bottom: 1px dashed rgba(133, 140, 150, 1) !important;
|
||||
}
|
||||
|
||||
/* 划线 */
|
||||
.highlight {
|
||||
background-color: rgba(255, 159, 78, .25) !important;
|
||||
}
|
||||
|
||||
/* 从想法圈进来的划线 */
|
||||
.reference {
|
||||
background-color: rgba(247, 247, 0, .25) !important;
|
||||
}
|
||||
|
||||
/* 以下几种复合的情况,只显示 review 的样式,不显示 highlight 的样式 */
|
||||
.review .highlight,
|
||||
.friend-review .highlight,
|
||||
.highlight .review,
|
||||
.highlight .friend-review,
|
||||
.highlight.review,
|
||||
.highlight.friend-review {
|
||||
background-color: none !important;
|
||||
}
|
||||
|
||||
body,
|
||||
#js_article,
|
||||
.review,
|
||||
.highlight,
|
||||
.friend-review,
|
||||
.reference {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* 公众号图片点击链接 */
|
||||
.h5_image_link {
|
||||
pointer-events: none;
|
||||
}
|
||||
413
Doc/WXRead/resources/css/Style/normalize.css
vendored
Normal file
413
Doc/WXRead/resources/css/Style/normalize.css
vendored
Normal file
@ -0,0 +1,413 @@
|
||||
/*! normalize.css v3.0.2 | MIT License | git.io/normalize */
|
||||
|
||||
/**
|
||||
* 1. Set default font family to sans-serif.
|
||||
* 2. Prevent iOS text size adjust after orientation change, without disabling
|
||||
* user zoom.
|
||||
*/
|
||||
|
||||
html {
|
||||
font-family: sans-serif; /* 1 */
|
||||
-webkit-text-size-adjust: 100%; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove default margin.
|
||||
*/
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* HTML5 display definitions
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Correct `block` display not defined for any HTML5 element in IE 8/9.
|
||||
* Correct `block` display not defined for `details` or `summary` in IE 10/11
|
||||
* and Firefox.
|
||||
* Correct `block` display not defined for `main` in IE 11.
|
||||
*/
|
||||
|
||||
article,
|
||||
aside,
|
||||
details,
|
||||
figcaption,
|
||||
figure,
|
||||
footer,
|
||||
header,
|
||||
hgroup,
|
||||
main,
|
||||
menu,
|
||||
nav,
|
||||
section,
|
||||
summary {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct `inline-block` display not defined in IE 8/9.
|
||||
* 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera.
|
||||
*/
|
||||
|
||||
audio,
|
||||
canvas,
|
||||
progress,
|
||||
video {
|
||||
display: inline-block; /* 1 */
|
||||
vertical-align: baseline; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent modern browsers from displaying `audio` without controls.
|
||||
* Remove excess height in iOS 5 devices.
|
||||
*/
|
||||
|
||||
audio:not([controls]) {
|
||||
display: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Address `[hidden]` styling not present in IE 8/9/10.
|
||||
* Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22.
|
||||
*/
|
||||
|
||||
[hidden],
|
||||
template {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Links
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Remove the gray background color from active links in IE 10.
|
||||
*/
|
||||
|
||||
a {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Improve readability when focused and also mouse hovered in all browsers.
|
||||
*/
|
||||
|
||||
a:active,
|
||||
a:hover {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
/* Text-level semantics
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Address styling not present in IE 8/9/10/11, Safari, and Chrome.
|
||||
*/
|
||||
|
||||
abbr[title] {
|
||||
border-bottom: 1px dotted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Address style set to `bolder` in Firefox 4+, Safari, and Chrome.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Address styling not present in Safari and Chrome.
|
||||
*/
|
||||
|
||||
dfn {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Address variable `h1` font-size and margin within `section` and `article`
|
||||
* contexts in Firefox 4+, Safari, and Chrome.
|
||||
*/
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
margin: 0.67em 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Address styling not present in IE 8/9.
|
||||
*/
|
||||
|
||||
mark {
|
||||
background: #ff0;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Address inconsistent and variable font size in all browsers.
|
||||
*/
|
||||
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent `sub` and `sup` affecting `line-height` in all browsers.
|
||||
*/
|
||||
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
/* Embedded content
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Remove border when inside `a` element in IE 8/9/10.
|
||||
*/
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct overflow not hidden in IE 9/10/11.
|
||||
*/
|
||||
|
||||
svg:not(:root) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Grouping content
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Address margin not present in IE 8/9 and Safari.
|
||||
*/
|
||||
|
||||
figure {
|
||||
margin: 1em 40px;
|
||||
}
|
||||
|
||||
/**
|
||||
* Address differences between Firefox and other browsers.
|
||||
*/
|
||||
|
||||
hr {
|
||||
box-sizing: content-box;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contain overflow in all browsers.
|
||||
*/
|
||||
|
||||
pre {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Address odd `em`-unit font size rendering in all browsers.
|
||||
*/
|
||||
|
||||
code,
|
||||
kbd,
|
||||
pre,
|
||||
samp {
|
||||
font-family: monospace, monospace;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
/* Forms
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Known limitation: by default, Chrome and Safari on OS X allow very limited
|
||||
* styling of `select`, unless a `border` property is set.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 1. Correct color not being inherited.
|
||||
* Known issue: affects color of disabled elements.
|
||||
* 2. Correct font properties not being inherited.
|
||||
* 3. Address margins set differently in Firefox 4+, Safari, and Chrome.
|
||||
*/
|
||||
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
color: inherit; /* 1 */
|
||||
font: inherit; /* 2 */
|
||||
margin: 0; /* 3 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Address `overflow` set to `hidden` in IE 8/9/10/11.
|
||||
*/
|
||||
|
||||
button {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Address inconsistent `text-transform` inheritance for `button` and `select`.
|
||||
* All other form control elements do not inherit `text-transform` values.
|
||||
* Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera.
|
||||
* Correct `select` style inheritance in Firefox.
|
||||
*/
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
|
||||
* and `video` controls.
|
||||
* 2. Correct inability to style clickable `input` types in iOS.
|
||||
* 3. Improve usability and consistency of cursor style between image-type
|
||||
* `input` and others.
|
||||
*/
|
||||
|
||||
button,
|
||||
html input[type="button"], /* 1 */
|
||||
input[type="reset"],
|
||||
input[type="submit"] {
|
||||
-webkit-appearance: button; /* 2 */
|
||||
cursor: pointer; /* 3 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-set default cursor for disabled elements.
|
||||
*/
|
||||
|
||||
button[disabled],
|
||||
html input[disabled] {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Address Firefox 4+ setting `line-height` on `input` using `!important` in
|
||||
* the UA stylesheet.
|
||||
*/
|
||||
|
||||
input {
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
/**
|
||||
* It's recommended that you don't attempt to style these elements.
|
||||
* Firefox's implementation doesn't respect box-sizing, padding, or width.
|
||||
*
|
||||
* 1. Address box sizing set to `content-box` in IE 8/9/10.
|
||||
* 2. Remove excess padding in IE 8/9/10.
|
||||
*/
|
||||
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
box-sizing: border-box; /* 1 */
|
||||
padding: 0; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix the cursor style for Chrome's increment/decrement buttons. For certain
|
||||
* `font-size` values of the `input`, it causes the cursor style of the
|
||||
* decrement button to change from `default` to `text`.
|
||||
*/
|
||||
|
||||
input[type="number"]::-webkit-inner-spin-button,
|
||||
input[type="number"]::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Address `appearance` set to `searchfield` in Safari and Chrome.
|
||||
* 2. Address `box-sizing` set to `border-box` in Safari and Chrome
|
||||
*/
|
||||
|
||||
input[type="search"] {
|
||||
-webkit-appearance: textfield; /* 1 */
|
||||
-webkit-box-sizing: content-box; /* 2 */
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove inner padding and search cancel button in Safari and Chrome on OS X.
|
||||
* Safari (but not Chrome) clips the cancel button when the search input has
|
||||
* padding (and `textfield` appearance).
|
||||
*/
|
||||
|
||||
input[type="search"]::-webkit-search-cancel-button,
|
||||
input[type="search"]::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Define consistent border, margin, and padding.
|
||||
*/
|
||||
|
||||
fieldset {
|
||||
border: 1px solid #c0c0c0;
|
||||
margin: 0 2px;
|
||||
padding: 0.35em 0.625em 0.75em;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct `color` not being inherited in IE 8/9/10/11.
|
||||
* 2. Remove padding so people aren't caught out if they zero out fieldsets.
|
||||
*/
|
||||
|
||||
legend {
|
||||
border: 0; /* 1 */
|
||||
padding: 0; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove default vertical scrollbar in IE 8/9/10/11.
|
||||
*/
|
||||
|
||||
textarea {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Don't inherit the `font-weight` (applied by a rule above).
|
||||
* NOTE: the default cannot safely be changed in Chrome and Safari on OS X.
|
||||
*/
|
||||
|
||||
optgroup {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Tables
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Remove most spacing between table cells.
|
||||
*/
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
padding: 0;
|
||||
}
|
||||
173
Doc/WXRead/resources/css/Style/preview.css
Normal file
173
Doc/WXRead/resources/css/Style/preview.css
Normal file
@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Copyright (C) 2015 Wasabeef
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* epub的书籍默认1em=16px,转化px的时候需要注意 */
|
||||
|
||||
@charset "UTF-8";
|
||||
|
||||
/****** global ******/
|
||||
|
||||
* {
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
html {
|
||||
height:100%;
|
||||
}
|
||||
|
||||
body {
|
||||
height:100%;
|
||||
overflow:hidden;
|
||||
overflow-y:auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.re {
|
||||
min-height:100%;
|
||||
overflow:hidden;
|
||||
outline:0px solid transparent;
|
||||
background-repeat:no-repeat;
|
||||
background-position:center;
|
||||
background-size:cover;
|
||||
-webkit-user-select:auto !important;
|
||||
-webkit-user-modify:read-write !important;
|
||||
font-size: 1em;
|
||||
line-height: 1.75em;
|
||||
font-family: "SF UI Text","PingFang SC","Lucida Grande", STheiti;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/*禁止选择*/
|
||||
.re_Display {
|
||||
-webkit-touch-callout: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
-webkit-user-modify:read-only !important;
|
||||
}
|
||||
|
||||
/****** widget ******/
|
||||
|
||||
.re p, .re div {
|
||||
text-align:justify;
|
||||
text-indent:2em;
|
||||
}
|
||||
|
||||
.re div p, .re p div {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
blockquote * {
|
||||
font-family: inherit !important;
|
||||
font-size: inherit !important;
|
||||
}
|
||||
|
||||
a, .re_link {
|
||||
text-decoration:none;
|
||||
color: rgba(84, 127, 176, 1);
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
ul li, ol li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
ul li:first-of-type, ol li:first-of-type {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
/* FZFSJW--GB1-0字体在iOS平台下,在h1纯英文的场景下会导致文字重叠,所以改用HYXinRenWenSongW */
|
||||
/* HYXinRenWenSongW是自动下载的,为了防止还没有下载的时候读取不到字体,就在后面用FZFSJW--GB1-0作为候补 */
|
||||
h1 {
|
||||
margin: 18px 0px 18px 0px;
|
||||
overflow: hidden;
|
||||
font-weight: normal;
|
||||
font-size:1.5em;
|
||||
line-height: 1.4;
|
||||
font-family: 'HYXinRenWenSongW', 'FZFSJW--GB1-0';
|
||||
}
|
||||
|
||||
h1 * {
|
||||
font-family: inherit !important;
|
||||
font-size: inherit !important;
|
||||
}
|
||||
|
||||
img {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* QQEmoticon */
|
||||
/* 展示的webview字体比较大,为了视觉居中,加上一个margin */
|
||||
|
||||
.re_Display .emoji,
|
||||
.re_Display .re_QQEmoticon {
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
/* replace 复制过来的 */
|
||||
|
||||
h1.articleTitle,
|
||||
h1.articleFirstTitle {
|
||||
margin-bottom:3em;
|
||||
font-size:1.5em;
|
||||
font-family: "SourceHanSerifCN-Medium";
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
h2.articleSecondTitle {
|
||||
font-size:1.4em;
|
||||
font-family: "SourceHanSerifCN-Medium";
|
||||
font-weight: normal;
|
||||
margin-top: 0.83em;
|
||||
}
|
||||
|
||||
blockquote.articleQuote {
|
||||
display: block;
|
||||
margin: 0px;
|
||||
padding: 14px;
|
||||
color: #353C46;
|
||||
border-left: 2px solid #D4D6D8;
|
||||
background-color: rgba(0, 30, 40, 0.04);
|
||||
font-weight: 300;
|
||||
text-indent: 0;
|
||||
}
|
||||
|
||||
.articleQuote p {
|
||||
margin: 0px;
|
||||
text-indent: 0px;
|
||||
}
|
||||
|
||||
.re .bodyPic {
|
||||
text-align: center;
|
||||
text-indent: 0px;
|
||||
margin: 18px 0 18px 0;
|
||||
}
|
||||
|
||||
.re .bodyPic .re_img {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
outline: 1px solid rgba(0,0,0,0.1);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/*ul.articleList {*/
|
||||
/*}*/
|
||||
/**/
|
||||
/*li.articleListItem {*/
|
||||
/*}*/
|
||||
408
Doc/WXRead/resources/css/Style/style.css
Normal file
408
Doc/WXRead/resources/css/Style/style.css
Normal file
@ -0,0 +1,408 @@
|
||||
/**
|
||||
* Copyright (C) 2015 Wasabeef
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@charset "UTF-8";
|
||||
|
||||
/****** global ******/
|
||||
|
||||
* {
|
||||
-webkit-tap-highlight-color: rgba(0,0,0,0);
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
html {
|
||||
height:100%;
|
||||
}
|
||||
|
||||
body {
|
||||
height:100%;
|
||||
overflow:hidden;
|
||||
overflow-y:auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.re {
|
||||
min-height:100%;
|
||||
overflow:hidden;
|
||||
outline:0px solid transparent;
|
||||
background-repeat:no-repeat;
|
||||
background-position:center;
|
||||
background-size:cover;
|
||||
-webkit-user-select:auto !important;
|
||||
-webkit-user-modify:read-write !important;
|
||||
font-size: 18px;
|
||||
line-height: 30px;
|
||||
font-family: -apple-system;
|
||||
color: #0D141E;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.re_Display {
|
||||
min-height: 0; /* 展示的时候不需要设置 min-height,否则cell里面通过 js 获取高度无法拿到正确的值 */
|
||||
padding: 8px 20px 0;
|
||||
line-height: 32px;
|
||||
-webkit-user-modify: read-only !important;
|
||||
}
|
||||
|
||||
.re_placeholder:before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
content:attr(placeholder);
|
||||
color: #ADB4BE;
|
||||
}
|
||||
|
||||
/****** widget ******/
|
||||
|
||||
.re div, .re p {
|
||||
margin: 18px 0px 0px 0px;
|
||||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/** re下第一个element **/
|
||||
.re_Write *:first-child,
|
||||
.re_Display *:first-child {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.re_Write > :first-child
|
||||
.re_Display > :first-child {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.re_Write > :empty + * {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.re div p, .re p div {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 18px 0px 0px 0px;
|
||||
padding: 14px;
|
||||
color: #353C46;
|
||||
border-left: 2px solid #D4D6D8;
|
||||
background-color: #F4F5F7;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
blockquote p:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
blockquote p:first-child {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
/*复制的内容有可能经过js之后会包一个空的p,所以要找到下一个p去掉margin*/
|
||||
blockquote p:empty:first-child + p {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
a, .re_link {
|
||||
text-decoration:none;
|
||||
color: rgba(84, 127, 176, 1);
|
||||
-webkit-tap-highlight-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
list-style-type: none;
|
||||
padding: 0px;
|
||||
margin: 18px 0px 0px 0px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.re div ul, .re div ol {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.re p ul, .re p ol {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
ul li, ol li {
|
||||
margin-top: 2px;
|
||||
padding: 0px 0px 0px 24px;
|
||||
}
|
||||
|
||||
ul li:first-of-type, ol li:first-of-type {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
ul li:before, ol li:before {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: #353C46;
|
||||
border-radius: 3px;
|
||||
float: left;
|
||||
margin-top: 11px;
|
||||
margin-left: -18px;
|
||||
}
|
||||
|
||||
.re span{
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
margin: 19px 0px 0px 0px;
|
||||
overflow: hidden;
|
||||
font-weight: 500;
|
||||
font-size: 24px;
|
||||
color: #0D141E;
|
||||
line-height: 36px;
|
||||
/* FZFSJW--GB1-0字体在iOS平台下,在h1纯英文的场景下会导致文字重叠,所以改用HYXinRenWenSongW */
|
||||
/* HYXinRenWenSongW是自动下载的,为了防止还没有下载的时候读取不到字体,就在后面用FZFSJW--GB1-0作为候补 */
|
||||
/* font-family: 'HYXinRenWenSongW', 'FZFSJW--GB1-0';*/
|
||||
}
|
||||
|
||||
img {
|
||||
vertical-align: middle;
|
||||
/* 防止拖拽表情后变成 blob */
|
||||
-webkit-user-drag: none;
|
||||
user-drag: none;
|
||||
}
|
||||
|
||||
/** hack掉系统产生的奇怪span **/
|
||||
.re p span,
|
||||
.re h1 span,
|
||||
.re h2 span,
|
||||
.re blockquote span,
|
||||
.re ul span{
|
||||
font-family: inherit !important;
|
||||
font-size: inherit !important;
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
/****** widgets ******/
|
||||
|
||||
/* bookItem */
|
||||
|
||||
.re .re_bookItem{
|
||||
padding: 0px 16px 0px 0px;
|
||||
margin-top: 18px;
|
||||
-webkit-user-select: none !important;
|
||||
border: 1px solid #dee0e2;
|
||||
box-shadow: 0px 1px 4px 0px rgba(0,0,0,0.03);
|
||||
overflow: hidden;
|
||||
}
|
||||
.re_Write .re_bookItem{
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.re .re_bookItem_Touched{
|
||||
background: #f8fafc;
|
||||
}
|
||||
@media (-webkit-min-device-pixel-ratio: 2){
|
||||
.re .re_bookItem{
|
||||
position: relative;
|
||||
border: none;
|
||||
}
|
||||
.re .re_bookItem:after{
|
||||
content:"";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #dee0e2;
|
||||
pointer-events: none;
|
||||
-webkit-transform: scale(0.5);
|
||||
-webkit-transform-origin: 0 0;
|
||||
transform: scale(0.5);
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
}
|
||||
@media (-webkit-min-device-pixel-ratio: 3){
|
||||
.re .re_bookItem:after{
|
||||
width: 300%;
|
||||
height: 300%;
|
||||
transform: scale(0.3333);
|
||||
transform-origin: 0 0;
|
||||
-webkit-transform: scale(0.3333);
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
}
|
||||
.re .re_bookItem_cover{
|
||||
float: left;
|
||||
width: 60px;
|
||||
height: 86px;
|
||||
margin-right: 16px;
|
||||
margin-top: 0;
|
||||
background: #fff;
|
||||
border-right: 1px solid rgba(0,0,0,0.10);
|
||||
}
|
||||
@media (-webkit-min-device-pixel-ratio: 2){
|
||||
.re .re_bookItem_cover{
|
||||
position: relative;
|
||||
border: none;
|
||||
}
|
||||
.re .re_bookItem_cover:after{
|
||||
content:"";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
box-sizing: border-box;
|
||||
border-right: 1px solid rgba(0,0,0,0.10);
|
||||
pointer-events: none;
|
||||
transform: scale(0.5);
|
||||
transform-origin: 0 0;
|
||||
-webkit-transform: scale(0.5);
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
}
|
||||
@media (-webkit-min-device-pixel-ratio: 3){
|
||||
.re .re_bookItem_cover:after{
|
||||
width: 300%;
|
||||
height: 300%;
|
||||
transform: scale(0.3333);
|
||||
transform-origin: 0 0;
|
||||
-webkit-transform: scale(0.3333);
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
}
|
||||
.re .re_bookItem_cover_img{
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.re .re_bookItem_title{
|
||||
line-height: 20px;
|
||||
margin: 0;
|
||||
margin-top: 21px;
|
||||
font-size: 17px;
|
||||
color: #49505A;
|
||||
font-family: 'HYXinRenWenSongW', 'FZFSJW--GB1-0';
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.re .re_bookItem_author{
|
||||
margin-top: 8px;
|
||||
font-size: 15px;
|
||||
line-height: 18px;
|
||||
color: #717882;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: 'HYXinRenWenSongW', 'FZFSJW--GB1-0';
|
||||
}
|
||||
.re_img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin: 18px auto 0px;
|
||||
background-color: #f4f5f7;
|
||||
}
|
||||
|
||||
.re_Write .re_img{
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
vertical-align: bottom; /** 为了修复图片后面连着文字时文字的光标长度 **/
|
||||
}
|
||||
|
||||
.re_Write .re_img_MarginTop{
|
||||
margin-top: 18px !important;
|
||||
}
|
||||
.re_Write .re_img_MarginBottom{
|
||||
margin-bottom: 18px !important;
|
||||
}
|
||||
.re_Write .re_img_MarginBottom + .re_img_MarginTop{
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
/** display 特供样式 :为了让图片宽度撑满全屏。**/
|
||||
|
||||
.re_Display .bodyPic{
|
||||
margin-left: -16px !important;
|
||||
margin-right: -16px !important;
|
||||
}
|
||||
|
||||
/* QQEmoticon */
|
||||
|
||||
.re_QQEmoticon{
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.re_Display .emoji,
|
||||
.re_Display .re_QQEmoticon{
|
||||
/* 展示的webview字体比较大,为了视觉居中,加上一个margin */
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
/****** night theme ******/
|
||||
|
||||
.re_Night {
|
||||
color: rgba(196, 200, 204, 1);
|
||||
}
|
||||
|
||||
.re_Night blockquote {
|
||||
color : rgb(218, 220, 224);
|
||||
background-color : rgb(10, 14, 18);
|
||||
border-left: 1px solid rgba(116, 120, 124, 1);
|
||||
}
|
||||
|
||||
.re_Night a, .re_Night .re_Night {
|
||||
color: rgba(84, 127, 176, 1);
|
||||
}
|
||||
|
||||
.re_Night ul li:before, .re_Night ol li:before {
|
||||
background-color: rgba(196, 200, 204, 1);
|
||||
}
|
||||
|
||||
.re_Night h1,
|
||||
.re_Night h2,
|
||||
.re_Night h3,
|
||||
.re_Night h4,
|
||||
.re_Night h5,
|
||||
.re_Night h6,
|
||||
.re_Night p,
|
||||
.re_Night textarea,
|
||||
.re_Night form,
|
||||
.re_Night select,
|
||||
.re_Night input,
|
||||
.re_Night span,
|
||||
.re_Night button,
|
||||
.re_Night em,
|
||||
.re_Night menu,
|
||||
.re_Night aside,
|
||||
.re_Night table,
|
||||
.re_Night tr,
|
||||
.re_Night td,
|
||||
.re_Night nav,
|
||||
.re_Night dl,
|
||||
.re_Night dt,
|
||||
.re_Night dd,
|
||||
.re_Night amp-iframe,
|
||||
.re_Night main,
|
||||
.re_Night section {
|
||||
color: rgba(180, 180, 182, 1) !important;
|
||||
border-color: #555555 !important;
|
||||
text-shadow: 0 0 0 #000;
|
||||
}
|
||||
|
||||
p img {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.re_Night.re_placeholder:before {
|
||||
color: #646466;
|
||||
}
|
||||
190
Doc/WXRead/resources/css/WREpubMPVideo.css
Normal file
190
Doc/WXRead/resources/css/WREpubMPVideo.css
Normal file
@ -0,0 +1,190 @@
|
||||
@charset "UTF-8";
|
||||
|
||||
/****** @override 腾讯视频样式 ******/
|
||||
|
||||
/* 控制器主体 */
|
||||
.tvp_controls {
|
||||
height: 48px!important;
|
||||
background-color: rgba(0, 0, 0, .6)!important;
|
||||
}
|
||||
|
||||
.tvp_controls_hide .tvp_controls {
|
||||
bottom: -48px!important;
|
||||
}
|
||||
|
||||
/* 上次播放到xx,为你继续播放提示 */
|
||||
.tvp_overlay_tips {
|
||||
bottom: 48px!important;
|
||||
background-color: transparent!important;
|
||||
}
|
||||
|
||||
.tvp_controls_hide .tvp_overlay_tips {
|
||||
bottom: 0!important;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.tvp_btn_value {
|
||||
background: none!important;
|
||||
border: 0!important;
|
||||
width: 20px!important;
|
||||
height: 20px!important;
|
||||
background-size: 134px 40px!important;
|
||||
margin: 0px!important;
|
||||
background-image: url(https://rescdn.qqmail.com/weread/cover/icon/WREpubMPVideo.png)!important;
|
||||
}
|
||||
|
||||
.tvp_pause .tvp_btn_value {
|
||||
background-position: 40px 0!important;
|
||||
}
|
||||
|
||||
.tvp_play .tvp_btn_value {
|
||||
background-position: 20px 0!important;
|
||||
}
|
||||
|
||||
/* 预加载的滑块轨道 */
|
||||
.tvp_time_loaded {
|
||||
background-color: rgb(128, 128, 128)!important;
|
||||
}
|
||||
|
||||
/* 已经播放过的滑块轨道 */
|
||||
.tvp_time_current {
|
||||
background-color: #fff!important;
|
||||
}
|
||||
|
||||
.tvp_time_loaded, .tvp_time_current {
|
||||
top: 0!important;
|
||||
}
|
||||
|
||||
/* 时间显示器 */
|
||||
.tvp_time_panel {
|
||||
width: 100%!important;
|
||||
}
|
||||
|
||||
/* 进度条 */
|
||||
.tvp_time_total {
|
||||
width: 58%!important;
|
||||
left: 21%!important;
|
||||
top: 23px!important;
|
||||
height: 2px!important;
|
||||
}
|
||||
|
||||
/* 滑块handle */
|
||||
.tvp_time_handle {
|
||||
top: -16px!important;
|
||||
background-color: transparent!important;
|
||||
background-size: 134px 40px!important;
|
||||
background-image: url(https://rescdn.qqmail.com/weread/cover/icon/WREpubMPVideo.png)!important;
|
||||
background-position: 90px 0!important;
|
||||
}
|
||||
|
||||
.tvp_time_handle:after {
|
||||
background: none!important;
|
||||
}
|
||||
|
||||
/* 正在播放的时间和总时间: 00:05/03:06 */
|
||||
.tvp_time_panel .tvp_time_panel_current,
|
||||
.tvp_time_panel .tvp_time_panel_total {
|
||||
font-size: 12px!important;
|
||||
color: #fff!important;
|
||||
position: relative!important;
|
||||
top: -15px!important;
|
||||
font-family: Helvetica!important;
|
||||
}
|
||||
|
||||
/* 播放总时间 */
|
||||
.tvp_time_panel .tvp_time_panel_total {
|
||||
float: right!important;
|
||||
}
|
||||
|
||||
/* 正在播放的时间和总时间的分割线 */
|
||||
.tvp_time_panel .tvp_time_panel_split {
|
||||
display: none!important;
|
||||
}
|
||||
|
||||
/* 全屏按钮 */
|
||||
.tvp_fullscreen_button button:before,
|
||||
.tvp_fullscreen_button button:after,
|
||||
.tvp_fullscreen_button .tvp_btn_value:before,
|
||||
.tvp_fullscreen_button .tvp_btn_value:after {
|
||||
display: none!important;
|
||||
}
|
||||
|
||||
.tvp_fullscreen_button {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tvp_fullscreen_button button {
|
||||
background-image: url(https://rescdn.qqmail.com/weread/cover/icon/WREpubMPVideo.png)!important;
|
||||
width: 20px!important;
|
||||
height: 20px!important;
|
||||
background-size: 134px 40px!important;
|
||||
background-position: 60px 0!important;
|
||||
display: block!important;
|
||||
top: 50%!important;
|
||||
left: 50%!important;
|
||||
margin: -10px 0 0 -10px!important;
|
||||
position:absolute!important;
|
||||
}
|
||||
|
||||
/* 广告 */
|
||||
.tvp_ads_go,
|
||||
.tvp_app_download_onpause {
|
||||
display: none!important;
|
||||
}
|
||||
|
||||
/* 全屏的播放控制器 */
|
||||
.tvp_overlay_play {
|
||||
width: 100%!important;
|
||||
height: 100%!important;
|
||||
bottom: 0!important;
|
||||
background-color: rgba(0, 0, 0, 0.6)!important;
|
||||
}
|
||||
|
||||
.tvp_overlay_play .tvp_button_play {
|
||||
background-color: transparent;
|
||||
width: 40px!important;
|
||||
height: 40px!important;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin: -20px 0 0 -20px;
|
||||
background-size: 134px 40px!important;
|
||||
background-image: url(https://rescdn.qqmail.com/weread/cover/icon/WREpubMPVideo.png)!important;
|
||||
background-position: 0px 0;
|
||||
border: none!important;
|
||||
}
|
||||
|
||||
/* 播放器按钮 */
|
||||
.tvp_playpause_button,
|
||||
.tvp_fullscreen_button {
|
||||
width: 35px;
|
||||
}
|
||||
|
||||
@media only screen
|
||||
and (min-device-width : 375px)
|
||||
and (max-device-width : 667px) {
|
||||
/* iPhone6 尺寸 */
|
||||
.tvp_time_total {
|
||||
width: 60%!important;
|
||||
left: 20%!important;
|
||||
}
|
||||
|
||||
.tvp_playpause_button,
|
||||
.tvp_fullscreen_button {
|
||||
width: 40px!important;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen
|
||||
and (min-device-width : 414px)
|
||||
and (max-device-width : 736px) {
|
||||
/* iPhone6P 尺寸 */
|
||||
.tvp_time_total {
|
||||
width: 64%!important;
|
||||
left: 18%!important;
|
||||
}
|
||||
|
||||
.tvp_playpause_button,
|
||||
.tvp_fullscreen_button {
|
||||
width: 50px!important;
|
||||
}
|
||||
}
|
||||
56
Doc/WXRead/resources/css/dark.css
Normal file
56
Doc/WXRead/resources/css/dark.css
Normal file
@ -0,0 +1,56 @@
|
||||
/* 适配搜狗百科 bar 里的 icon 被染色 */
|
||||
.header-home, .header-home:before, .header-logo, .loginBox, .header-search, .header-search:before, .header-more:before {
|
||||
background-color: transparent !important
|
||||
}
|
||||
|
||||
/* 适配搜狗百科 某些透明的 div 被染色导致挡住图片 */
|
||||
.abstract-img-none {
|
||||
background: none !important
|
||||
}
|
||||
|
||||
/* 适配百度搜索图片丢失 */
|
||||
.c-touchable-feedback-no-default * {
|
||||
background-color: transparent !important
|
||||
}
|
||||
|
||||
img, video {
|
||||
z-index: 1 !important
|
||||
}
|
||||
|
||||
/*背景纯黑*/
|
||||
*, *:before, *:after {
|
||||
background-color: rgba(0, 0, 0, 1) !important;
|
||||
}
|
||||
|
||||
.wr-business-container,
|
||||
.wr-business-container *,
|
||||
.wr-business-container *:before,
|
||||
.wr-business-container *:after {
|
||||
background-color: rgb(28, 28, 29) !important;
|
||||
}
|
||||
|
||||
/*背景颜色和一般字体颜色*/
|
||||
div, h1, h2, h3, h4, h5, h6, p, body, em, html, link, textarea, form, select, input, span, button, em, menu, aside, table, tr, td, nav, dl, dt, dd, amp-iframe, main, section {
|
||||
color: rgba(180, 180, 182, 1) !important;
|
||||
border-color: #555555 !important;
|
||||
text-shadow: 0 0 0 #000;
|
||||
}
|
||||
|
||||
/*超链接*/
|
||||
a {
|
||||
color: rgba(84, 127, 176, 1) !important;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
color : rgb(218, 220, 224);
|
||||
background-color : rgb(10, 14, 18) !important;
|
||||
border-left: 1px solid rgba(116, 120, 124, 1);
|
||||
}
|
||||
|
||||
img {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
ul li:before, ol li:before {
|
||||
background-color: rgba(196, 200, 204, 1) !important;
|
||||
}
|
||||
221
Doc/WXRead/resources/css/default.css
Normal file
221
Doc/WXRead/resources/css/default.css
Normal file
@ -0,0 +1,221 @@
|
||||
/* this file is processed with xxd via a build rule and embedded in library */
|
||||
|
||||
/* these styles come from Safari */
|
||||
|
||||
/* note that comments are only permitted before selectors and before the styles */
|
||||
|
||||
/* DO NOT fiddle with this file if you want to have your own styles,
|
||||
pass your own stylesheet via the option parameter to override these defaults */
|
||||
|
||||
head {
|
||||
display:none;
|
||||
}
|
||||
|
||||
title {
|
||||
display:none;
|
||||
}
|
||||
|
||||
style {
|
||||
display:none;
|
||||
}
|
||||
|
||||
link {
|
||||
display: none;
|
||||
}
|
||||
|
||||
meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
script {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html {
|
||||
display:block;
|
||||
margin:0;
|
||||
padding:0;
|
||||
}
|
||||
|
||||
body {
|
||||
display:block;
|
||||
font-size:16px;
|
||||
margin:0;
|
||||
padding:0;
|
||||
}
|
||||
|
||||
article,aside,footer,header,hgroup,nav,section {
|
||||
display:block;
|
||||
}
|
||||
|
||||
p {
|
||||
display:block;
|
||||
margin:0;
|
||||
}
|
||||
|
||||
img {
|
||||
margin:0;
|
||||
}
|
||||
|
||||
ul,ol,menu,dir {
|
||||
display:block;
|
||||
margin:1em 0 1em 0;
|
||||
padding-left:24px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type:disc;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type:decimal;
|
||||
}
|
||||
|
||||
li {
|
||||
display:list-item;
|
||||
}
|
||||
|
||||
ul ul, ol ul {
|
||||
list-style-type: circle;
|
||||
}
|
||||
|
||||
ol ol ul, ol ul ul, ul ol ul, ul ul ul {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family:Courier;
|
||||
}
|
||||
|
||||
pre, xmp, plaintext, listing {
|
||||
display: block;
|
||||
font-family: monospace;
|
||||
white-space: pre;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color:#2262A3;
|
||||
text-decoration:underline;
|
||||
}
|
||||
|
||||
a:active {
|
||||
color:#2262A3;
|
||||
}
|
||||
|
||||
center {
|
||||
text-align:center;
|
||||
display:block;
|
||||
}
|
||||
|
||||
strong,b {
|
||||
font-weight:bold;
|
||||
}
|
||||
|
||||
i,em {
|
||||
font-style:italic;
|
||||
}
|
||||
|
||||
u {
|
||||
text-decoration:underline;
|
||||
}
|
||||
|
||||
big {
|
||||
font-size:bigger;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size:smaller;
|
||||
}
|
||||
|
||||
sub {
|
||||
font-size:smaller;
|
||||
vertical-align:sub;
|
||||
}
|
||||
|
||||
sup {
|
||||
font-size:smaller;
|
||||
vertical-align:super;
|
||||
}
|
||||
|
||||
s,strike,del {
|
||||
text-decoration:line-through;
|
||||
}
|
||||
|
||||
tt,code,kbd,samp {
|
||||
font-family:monospace;
|
||||
}
|
||||
|
||||
pre,xmp,plaintext,listing {
|
||||
display:block;
|
||||
font-family:monospace;
|
||||
white-space:pre;
|
||||
margin-top:1em;
|
||||
margin-right:0;
|
||||
margin-bottom:1em;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color:rgba(0,0,0,.05);
|
||||
padding-top:1em;
|
||||
padding-bottom:1em;
|
||||
border-radius:0.3em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
display:block;
|
||||
font-size:1.5em;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
h2 {
|
||||
display:block;
|
||||
font-size:1.4em;
|
||||
margin-top: 0.83em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
display:block;
|
||||
font-size:1.3em;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
h4 {
|
||||
display:block;
|
||||
font-size:1.2em;
|
||||
margin-top: 1.33em;
|
||||
}
|
||||
|
||||
h5 {
|
||||
display:block;
|
||||
font-size:1.1em;
|
||||
margin-top: 1.67em;
|
||||
}
|
||||
|
||||
h6 {
|
||||
display:block;
|
||||
font-size:1em;
|
||||
margin-top: 2.33em;
|
||||
}
|
||||
|
||||
div {
|
||||
display: block;
|
||||
}
|
||||
|
||||
hr {
|
||||
display: block;
|
||||
margin:0.5em auto 0.5em auto;
|
||||
border-style: inset;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
table {
|
||||
display: table;
|
||||
border-collapse: separate;
|
||||
border-spacing: 2px;
|
||||
border-color: gray;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
display: block;
|
||||
}
|
||||
171
Doc/WXRead/resources/css/replace.css
Normal file
171
Doc/WXRead/resources/css/replace.css
Normal file
@ -0,0 +1,171 @@
|
||||
|
||||
/* <pre>代码块,注意必须写font-weight使字体生效 */
|
||||
pre {
|
||||
font-family: "Menlo";
|
||||
font-weight: normal;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
/*版权信息*/
|
||||
.copyRightTitle {
|
||||
color: black;
|
||||
font-size: 1.5em;
|
||||
font-family: "Source Han Serif CN";
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/*图片说明文字*/
|
||||
.eepub-single-image-title {
|
||||
font-size: 0.75em;
|
||||
text-align: center;
|
||||
line-height: 1.4em;
|
||||
color: rgba(0, 0, 0, 0.9);
|
||||
margin: 0.2em 0.4em 1em 0.4em;
|
||||
font-family: "FZFSJW--GB1-0";
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/*标题*/
|
||||
.firstTitle, h1.firstTitle {
|
||||
font-size: 1.5em;
|
||||
font-family: "Source Han Serif CN";
|
||||
font-weight: bold;
|
||||
line-height: 1.25em;
|
||||
}
|
||||
.secondTitle, h2.secondTitle {
|
||||
font-size: 1.4em;
|
||||
font-family: "Source Han Serif CN";
|
||||
font-weight: bold;
|
||||
line-height: 1.35em;
|
||||
}
|
||||
.thirdTitle, h3.thirdTitle {
|
||||
font-size: 1.3em;
|
||||
font-family: "Source Han Serif CN";
|
||||
font-weight: bold;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
.fourthTitle, h4.fourthTitle {
|
||||
font-size: 1.2em;
|
||||
font-family: "Source Han Serif CN";
|
||||
font-weight: bold;
|
||||
line-height: 1.65em;
|
||||
}
|
||||
.fifthTitle, h5.fifthTitle {
|
||||
font-size: 1.1em;
|
||||
font-family: "Source Han Serif CN";
|
||||
font-weight: bold;
|
||||
line-height: 1.85em;
|
||||
}
|
||||
.sixthTitle, h6.sixthTitle {
|
||||
font-size: 1em;
|
||||
font-family: "Source Han Serif CN";
|
||||
font-weight: bold;
|
||||
line-height: 2em;
|
||||
}
|
||||
/*首字加大*/
|
||||
/*浮动元素有默认的margin,所以这里会修复一下*/
|
||||
.ftext {
|
||||
float: left;
|
||||
margin: 0em;
|
||||
font-size: 2.38em;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/*引用内容*/
|
||||
.conQuot {
|
||||
font-family: "FZFSJW--GB1-0";
|
||||
font-weight: normal;
|
||||
margin: 0em 0em 0.2em 0em;
|
||||
}
|
||||
|
||||
/*标题下来可能会有一行subHead*/
|
||||
.subHead{
|
||||
text-indent: 2em;
|
||||
}
|
||||
|
||||
pre, pre span, pre code {
|
||||
font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
font-size:.7em!important;
|
||||
}
|
||||
|
||||
.bodyPic {
|
||||
wr-vertical-center-style: 2;
|
||||
}
|
||||
|
||||
.qrbodyPic {
|
||||
page-break-inside: avoid;
|
||||
wr-vertical-center-style: 2;
|
||||
}
|
||||
|
||||
/* 注标图 */
|
||||
.qqreader-footnote {
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
/* 翻译 */
|
||||
.wr-translation {
|
||||
line-height: 1.7em !important;
|
||||
}
|
||||
|
||||
/*-------------------- 小文章相关 ----------------------*/
|
||||
|
||||
/*epub-custom-rule-6 如果 class="weread-page-relate" 在开头,需要把上一页末尾一行移到这一页*/
|
||||
.weread-page-relate {
|
||||
weread-page-relate:true;
|
||||
}
|
||||
|
||||
/*epub-custom-rule-29 小文章打赏的样式*/
|
||||
.chapter-reward {
|
||||
display: block;
|
||||
height: 120px;
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/*epub-custom-rule-7 小文章工具栏标签的样式*/
|
||||
.chapter-tool {
|
||||
display: block;
|
||||
height: 140px;
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* 章节结尾工具 */
|
||||
.book-chapter-tool {
|
||||
display: block;
|
||||
height: 78px;
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* 调试好样式后和安卓同步,交给后台随小文章 css 下发,并把下面的样式从replace.css 删除 */
|
||||
|
||||
/* 安卓的re_bookItem是通过原生view留出上下空间的,但是iOS是通过css来控制 */
|
||||
.re_bookItem{
|
||||
display: block;
|
||||
margin: 18px 0 18px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
text-align:justify;
|
||||
}
|
||||
|
||||
img[data-image-size="large"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 文集公众号文章不支持控件的样式 */
|
||||
.unsupported_iframe {
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ffcdc0;
|
||||
background: #fee;
|
||||
padding: 12px 0px;
|
||||
color: #ff8d8d;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 私有垂直居中类 */
|
||||
.wr-vertical-center {
|
||||
wr-vertical-center-style: 1 !important;
|
||||
}
|
||||
113
Doc/WXRead/resources/css/replaceForLatinLanguageBook.css
Normal file
113
Doc/WXRead/resources/css/replaceForLatinLanguageBook.css
Normal file
@ -0,0 +1,113 @@
|
||||
|
||||
/* <pre>代码块,注意必须写font-weight使字体生效 */
|
||||
pre {
|
||||
font-family: "Menlo";
|
||||
font-weight: normal;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
/*版权信息*/
|
||||
.copyRightTitle {
|
||||
color: black;
|
||||
font-size: 1.5em;
|
||||
font-family: "Source Han Serif CN";
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/*图片说明文字*/
|
||||
.eepub-single-image-title {
|
||||
font-size: 0.75em;
|
||||
text-align: center;
|
||||
line-height: 1.4em;
|
||||
color: rgba(0, 0, 0, 0.9);
|
||||
margin: 0.2em 0.4em 1em 0.4em;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/*标题*/
|
||||
.firstTitle, h1.firstTitle {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
line-height: 1.25em;
|
||||
}
|
||||
.secondTitle, h2.secondTitle {
|
||||
font-size: 1.4em;
|
||||
font-weight: bold;
|
||||
line-height: 1.35em;
|
||||
}
|
||||
.thirdTitle, h3.thirdTitle {
|
||||
font-size: 1.3em;
|
||||
font-weight: bold;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
.fourthTitle, h4.fourthTitle {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
line-height: 1.65em;
|
||||
}
|
||||
.fifthTitle, h5.fifthTitle {
|
||||
font-size: 1.1em;
|
||||
font-weight: bold;
|
||||
line-height: 1.85em;
|
||||
}
|
||||
.sixthTitle, h6.sixthTitle {
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
line-height: 2em;
|
||||
}
|
||||
/*首字加大*/
|
||||
/*浮动元素有默认的margin,所以这里会修复一下*/
|
||||
.ftext {
|
||||
float: left;
|
||||
margin: 0em;
|
||||
font-size: 2.38em;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/*引用内容*/
|
||||
.conQuot {
|
||||
font-weight: normal;
|
||||
margin: 0em 0em 0.2em 0em;
|
||||
}
|
||||
|
||||
/*标题下来可能会有一行subHead*/
|
||||
.subHead{
|
||||
text-indent: 2em;
|
||||
}
|
||||
|
||||
pre, pre span, pre code {
|
||||
font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
font-size:.7em!important;
|
||||
}
|
||||
|
||||
.bodyPic {
|
||||
wr-vertical-center-style: 2;
|
||||
}
|
||||
|
||||
.qrbodyPic {
|
||||
page-break-inside: avoid;
|
||||
wr-vertical-center-style: 2;
|
||||
}
|
||||
|
||||
/* 注标图 */
|
||||
.qqreader-footnote {
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
/* 私有垂直居中类 */
|
||||
.wr-vertical-center {
|
||||
wr-vertical-center-style: 1 !important;
|
||||
}
|
||||
|
||||
/* 翻译 */
|
||||
.wr-translation {
|
||||
line-height: 1.7em !important;
|
||||
}
|
||||
|
||||
/* 章节结尾工具 */
|
||||
.book-chapter-tool {
|
||||
display: block;
|
||||
height: 78px;
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
}
|
||||
137
Doc/WXRead/resources/css/replaceForMPChapter.css
Normal file
137
Doc/WXRead/resources/css/replaceForMPChapter.css
Normal file
@ -0,0 +1,137 @@
|
||||
|
||||
/* <pre>代码块,注意必须写font-weight使字体生效 */
|
||||
pre {
|
||||
font-family: "Menlo";
|
||||
font-weight: normal;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
/*版权信息*/
|
||||
.copyRightTitle {
|
||||
color: black;
|
||||
font-size: 1.5em;
|
||||
font-family: "SourceHanSerifCN-Medium";
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/*图片说明文字*/
|
||||
.eepub-single-image-title {
|
||||
font-size: 0.75em;
|
||||
text-align: center;
|
||||
line-height: 1.4em;
|
||||
color: rgba(0, 0, 0, 0.9);
|
||||
margin: 0.2em 0.4em 1em 0.4em;
|
||||
font-family: "FZFSJW--GB1-0";
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/*标题*/
|
||||
.preface {
|
||||
font-size:1.5em;
|
||||
font-family: "SourceHanSerifCN-Medium";
|
||||
font-weight: normal;
|
||||
color: rgba(0, 0, 0, .9);
|
||||
}
|
||||
.firstTitle, h1.firstTitle {
|
||||
font-size:1.5em;
|
||||
font-family: "SourceHanSerifCN-Medium";
|
||||
font-weight: normal;
|
||||
}
|
||||
.secondTitle, h2.secondTitle {
|
||||
font-size:1.4em;
|
||||
font-family: "SourceHanSerifCN-Medium";
|
||||
font-weight: normal;
|
||||
margin-top: 0.83em;
|
||||
}
|
||||
.thirdTitle, h3.thirdTitle {
|
||||
font-size:1.3em;
|
||||
font-family: "SourceHanSerifCN-Medium";
|
||||
font-weight: normal;
|
||||
margin-top: 1em;
|
||||
}
|
||||
.fourthTitle, h4.fourthTitle {
|
||||
font-size:1.2em;
|
||||
font-family: "SourceHanSerifCN-Medium";
|
||||
font-weight: bold;
|
||||
margin-top: 1.33em;
|
||||
}
|
||||
.fifthTitle, h5.fifthTitle {
|
||||
font-size:1.1em;
|
||||
font-family: "SourceHanSerifCN-Medium";
|
||||
font-weight: bold;
|
||||
margin-top: 1.67em;
|
||||
}
|
||||
.sixthTitle, h6.sixthTitle {
|
||||
font-size:1em;
|
||||
font-family: "SourceHanSerifCN-Medium";
|
||||
font-weight: bold;
|
||||
margin-top: 2.33em;
|
||||
}
|
||||
|
||||
/*首字加大*/
|
||||
/*浮动元素有默认的margin,所以这里会修复一下*/
|
||||
.ftext {
|
||||
float: left;
|
||||
margin: 0em;
|
||||
font-size: 2.38em;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/*引用内容*/
|
||||
.conQuot {
|
||||
font-family: "FZFSJW--GB1-0";
|
||||
font-weight: normal;
|
||||
margin: 0em 0em 0.2em 0em;
|
||||
}
|
||||
|
||||
/*标题下来可能会有一行subHead*/
|
||||
.subHead{
|
||||
text-indent: 2em;
|
||||
}
|
||||
|
||||
pre, pre span, pre code {
|
||||
font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
font-size:.7em!important;
|
||||
}
|
||||
|
||||
.qrbodyPic {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
/* 注标图 */
|
||||
.qqreader-footnote {
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
/*-------------------- 小文章相关 ----------------------*/
|
||||
|
||||
/*epub-custom-rule-6 如果 class="weread-page-relate" 在开头,需要把上一页末尾一行移到这一页*/
|
||||
.weread-page-relate {
|
||||
weread-page-relate:true;
|
||||
}
|
||||
|
||||
/*epub-custom-rule-7 小文章工具栏标签的样式*/
|
||||
.chapter-tool {
|
||||
display: block;
|
||||
height: 160px;
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* 调试好样式后和安卓同步,交给后台随小文章 css 下发,并把下面的样式从replace.css 删除 */
|
||||
|
||||
/* 安卓的re_bookItem是通过原生view留出上下空间的,但是iOS是通过css来控制 */
|
||||
.re_bookItem{
|
||||
display: block;
|
||||
margin: 18px 0 18px 0;
|
||||
}
|
||||
|
||||
/* epub-custom-rule-21 忽略公众号文章 <p> 的 background-color,因为公众号太多这样的样式,影响排版效果 */
|
||||
p, strong, span {
|
||||
background-color:transparent !important;
|
||||
}
|
||||
|
||||
/* epub-custom-rule-26 公众号文章背景色强制设为透明 */
|
||||
section, .rich_media_area_primary {
|
||||
background-color:transparent !important;
|
||||
}
|
||||
1
Doc/WXRead/resources/css/xcode.min.css
vendored
Normal file
1
Doc/WXRead/resources/css/xcode.min.css
vendored
Normal file
@ -0,0 +1 @@
|
||||
.hljs{display:block;overflow-x:auto;padding:0.5em;background:#fff;color:black}.xml .hljs-meta{color:#c0c0c0}.hljs-comment,.hljs-quote{color:#007400}.hljs-tag,.hljs-attribute,.hljs-keyword,.hljs-selector-tag,.hljs-literal,.hljs-name{color:#aa0d91}.hljs-variable,.hljs-template-variable{color:#3F6E74}.hljs-code,.hljs-string,.hljs-meta-string{color:#c41a16}.hljs-regexp,.hljs-link{color:#0E0EFF}.hljs-title,.hljs-symbol,.hljs-bullet,.hljs-number{color:#1c00cf}.hljs-section,.hljs-meta{color:#643820}.hljs-class .hljs-title,.hljs-type,.hljs-built_in,.hljs-builtin-name,.hljs-params{color:#5c2699}.hljs-attr{color:#836C28}.hljs-subst{color:#000}.hljs-formula{background-color:#eee;font-style:italic}.hljs-addition{background-color:#baeeba}.hljs-deletion{background-color:#ffc8bd}.hljs-selector-id,.hljs-selector-class{color:#9b703f}.hljs-doctag,.hljs-strong{font-weight:bold}.hljs-emphasis{font-style:italic}
|
||||
BIN
Doc/WXRead/resources/fonts/Lora-Italic.ttf
Normal file
BIN
Doc/WXRead/resources/fonts/Lora-Italic.ttf
Normal file
Binary file not shown.
BIN
Doc/WXRead/resources/fonts/Lora-Regular.ttf
Normal file
BIN
Doc/WXRead/resources/fonts/Lora-Regular.ttf
Normal file
Binary file not shown.
BIN
Doc/WXRead/resources/fonts/OpenDyslexic-Regular.otf
Normal file
BIN
Doc/WXRead/resources/fonts/OpenDyslexic-Regular.otf
Normal file
Binary file not shown.
BIN
Doc/WXRead/resources/fonts/PlayfairDisplay-Regular.ttf
Normal file
BIN
Doc/WXRead/resources/fonts/PlayfairDisplay-Regular.ttf
Normal file
Binary file not shown.
BIN
Doc/WXRead/resources/fonts/SourceHanSerifCN-Medium.ttf
Normal file
BIN
Doc/WXRead/resources/fonts/SourceHanSerifCN-Medium.ttf
Normal file
Binary file not shown.
BIN
Doc/WXRead/resources/fonts/WeChatNumber.ttf
Normal file
BIN
Doc/WXRead/resources/fonts/WeChatNumber.ttf
Normal file
Binary file not shown.
BIN
Doc/WXRead/resources/fonts/WeRead-Icon.ttf
Normal file
BIN
Doc/WXRead/resources/fonts/WeRead-Icon.ttf
Normal file
Binary file not shown.
BIN
Doc/WXRead/resources/fonts/WeRead-Rating-Icon.ttf
Normal file
BIN
Doc/WXRead/resources/fonts/WeRead-Rating-Icon.ttf
Normal file
Binary file not shown.
BIN
Doc/WXRead/resources/fonts/WeReadLS-Bold.otf
Normal file
BIN
Doc/WXRead/resources/fonts/WeReadLS-Bold.otf
Normal file
Binary file not shown.
BIN
Doc/WXRead/resources/fonts/WeReadLS-Medium.otf
Normal file
BIN
Doc/WXRead/resources/fonts/WeReadLS-Medium.otf
Normal file
Binary file not shown.
BIN
Doc/WXRead/resources/fonts/WeReadLS-Regular.otf
Normal file
BIN
Doc/WXRead/resources/fonts/WeReadLS-Regular.otf
Normal file
Binary file not shown.
BIN
Doc/WXRead/resources/fonts/WeReadRN-Regular.ttf
Normal file
BIN
Doc/WXRead/resources/fonts/WeReadRN-Regular.ttf
Normal file
Binary file not shown.
BIN
Doc/WXRead/resources/fonts/icon_font.ttf
Normal file
BIN
Doc/WXRead/resources/fonts/icon_font.ttf
Normal file
Binary file not shown.
BIN
Doc/WXRead/resources/fonts/weread_iconfont.ttf
Normal file
BIN
Doc/WXRead/resources/fonts/weread_iconfont.ttf
Normal file
Binary file not shown.
26
Doc/WXRead/resources/js/HtmlGenerator.js
Normal file
26
Doc/WXRead/resources/js/HtmlGenerator.js
Normal file
@ -0,0 +1,26 @@
|
||||
|
||||
var generator = {};
|
||||
|
||||
generator.generateHtml = function (content, js, css) {
|
||||
var result = content;
|
||||
result = result.replace("<!--headTrap<body></body><head></head><html></html>-->", "");
|
||||
result = result.replace("<!--tailTrap<body></body><head></head><html></html>-->", "");
|
||||
var match = result.match(/window\.__nonce_str = "(\d+)"/);
|
||||
var nonce = "";
|
||||
if (match && match.length > 1) {
|
||||
nonce = "nonce='" + match[1] + "'";
|
||||
}
|
||||
var insertJs = "<script " + (nonce.length > 1 ? nonce : "") + " type='text/javascript'>" + js + "</script></body>";
|
||||
var insertStyle = "<style type='text/css'>" + css + "</style></head>";
|
||||
result = result.replace("</head>", insertStyle);
|
||||
result = result.replace("</body>", insertJs);
|
||||
return result;
|
||||
}
|
||||
|
||||
generator.generateHtmlForMPReview = function (content,js, css) {
|
||||
var result = generator.generateHtml(content,js,css)
|
||||
var modifiedCopyRight = " <span id = 'copyright_logo' stype = 'display:none' > ";
|
||||
var reg = /<span id="copyright_logo".*>?/;
|
||||
result = result.replace(reg,modifiedCopyRight);
|
||||
return result;
|
||||
}
|
||||
26
Doc/WXRead/resources/js/InnerMP.js
Normal file
26
Doc/WXRead/resources/js/InnerMP.js
Normal file
@ -0,0 +1,26 @@
|
||||
(function(){
|
||||
var head = document.getElementsByTagName("head")[0];
|
||||
var meta = document.createElement('meta');
|
||||
meta.name = "viewport";
|
||||
meta.content = "width=device-width,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no";
|
||||
head.appendChild(meta);
|
||||
|
||||
var children = document.body.children;
|
||||
var titleFounded = false;
|
||||
for(var i = 0; i < children.length; i++){
|
||||
var child = children[i]
|
||||
var tagName = child.tagName.toLowerCase()
|
||||
// 隐藏标题前的空段落
|
||||
if(!child.textContent){
|
||||
child.style.display = "none";
|
||||
}else if(titleFounded){
|
||||
// 如果找到标题了,那么遇到第一个不为空的短路,就不再处理了
|
||||
break
|
||||
}
|
||||
// 隐藏标题
|
||||
if(tagName === 'h1' || tagName === 'h2' || tagName === 'h3'){
|
||||
child.style.display = "none"
|
||||
titleFounded = true
|
||||
}
|
||||
}
|
||||
})()
|
||||
125
Doc/WXRead/resources/js/MediaPlatform.js
Normal file
125
Doc/WXRead/resources/js/MediaPlatform.js
Normal file
@ -0,0 +1,125 @@
|
||||
var mediaPlatform = {};
|
||||
|
||||
mediaPlatform.getMPHeight = function() {
|
||||
var height = document.getElementById('js_article').scrollHeight;
|
||||
return height;
|
||||
};
|
||||
|
||||
mediaPlatform.getMPInfo = function() {
|
||||
var title;
|
||||
if (msg_title) {
|
||||
title = msg_title;
|
||||
}
|
||||
else {
|
||||
title = document.getElementsByTagName('h1')[0].innerHTML.toString();
|
||||
}
|
||||
var thumbUrl;
|
||||
if (msg_cdn_url) {
|
||||
thumbUrl = msg_cdn_url;
|
||||
}
|
||||
else {
|
||||
thumbUrl = document.getElementsByTagName('img')[0].src.toString();
|
||||
}
|
||||
var account;
|
||||
if (nickname) {
|
||||
account = nickname;
|
||||
}
|
||||
if (!title) {title = "";}
|
||||
if (!thumbUrl) {thumbUrl= "";}
|
||||
if (!account) {account = "";}
|
||||
|
||||
var info = {
|
||||
"title": title,
|
||||
"thumbUrl": thumbUrl,
|
||||
"account": account,
|
||||
}
|
||||
return info;
|
||||
};
|
||||
|
||||
mediaPlatform.getMPInfoStr = function() {
|
||||
return JSON.stringify(mediaPlatform.getMPInfo());
|
||||
};
|
||||
|
||||
// 这里的做法是为了非独立图片(下方有内容)可以保持跟内容12px的距离
|
||||
mediaPlatform.handleImageBlank = function() {
|
||||
var imgs = document.getElementsByTagName('img');
|
||||
for (var i = 0; i < imgs.length; i++) {
|
||||
var nextNode = imgs[i].nextSibling;
|
||||
var preNode = imgs[i].previousSibling;
|
||||
|
||||
// 只处理上方有空行的情况,下方有空行经常会跟图片的说明文字连用,尝试修改会导致样式错乱
|
||||
if (preNode && preNode.nodeName.toLowerCase() == 'br') {
|
||||
// 处理图片上面有个br,但是br不能自定义高度,所以直接去掉重新加margin
|
||||
if (preNode.style) {
|
||||
preNode.style.display = "none";
|
||||
}
|
||||
imgs[i].style.marginTop = '12px';
|
||||
} else if (preNode && preNode.nodeType == 3 && !this.emptyTextNode(preNode)) {
|
||||
// 处理图片跟上面文字在同一个p里面,但是没有br隔开
|
||||
imgs[i].style.marginTop = '12px';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mediaPlatform.emptyTextNode = function(el) {
|
||||
return !el.data || /^(\s|\t)+$/g.test(el.data)
|
||||
}
|
||||
|
||||
// 干掉p>br的情况的margin-top
|
||||
mediaPlatform.updateBlankHeight = function() {
|
||||
var blanks = document.getElementsByTagName('br');
|
||||
for (var i = blanks.length - 1; i >= 0; i--) {
|
||||
var parentNode = blanks[i].parentNode;
|
||||
if (parentNode.nodeName.toLowerCase() == "p" && parentNode.childNodes.length == 1) {
|
||||
parentNode.style.height = '20px';
|
||||
parentNode.style.minHeight = '0em';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 观察到部分公众号文章首评的懒加载图片无法正常显示,这是由于 scroll 事件没有触发导致的(怀疑是在 rangy-classapplier 引入后产生了混合反应,但在 Android 上确是好的)。这里的解决办法是手动触发一次 scroll 事件,但由于我们的 webview 其实是完整高度的,本身并不能滚动,所以这样做也不会产生抖动或者其他副作用。
|
||||
mediaPlatform.protectLazyLoad = function() {
|
||||
window.scrollTo(0, 1);
|
||||
};
|
||||
|
||||
mediaPlatform.init = function() {
|
||||
mediaPlatform.updateBlankHeight();
|
||||
mediaPlatform.handleImageBlank();
|
||||
mediaPlatform.protectLazyLoad();
|
||||
};
|
||||
|
||||
mediaPlatform.getAuthor = function() {
|
||||
var find = false;
|
||||
var author = document.getElementById('js_name');
|
||||
if (author != null) {
|
||||
console.log('mediaPlatform.getAuthor ' + author.innerText)
|
||||
author.addEventListener('click',function(){
|
||||
wereadBridge.handleWithRichEditor("onClickAuthor",{"param" : author.innerText, "cmd": "onClickAuthor"}, "", "");
|
||||
}, false)
|
||||
}
|
||||
}
|
||||
|
||||
mediaPlatform.init();
|
||||
|
||||
// 用于在退出公众号文章的时候暂停视频播放
|
||||
// 由于腾讯视频是通过 iFrame 内嵌的,没有办法直接操作,我们用刷新 iFrame 的方式实现停止播放的效果
|
||||
function pauseMedia() {
|
||||
pauseCurrentAudio();
|
||||
pauseCurrentVideo();
|
||||
}
|
||||
|
||||
function pauseCurrentAudio() {
|
||||
var audios = document.querySelectorAll('audio');
|
||||
for (var i = 0; i < audios.length; i++) {
|
||||
var audio = audios[i];
|
||||
audio.pause();
|
||||
}
|
||||
}
|
||||
|
||||
function pauseCurrentVideo() {
|
||||
var iframes = document.querySelectorAll('iframe');
|
||||
for (var i = 0; i < iframes.length; i++) {
|
||||
var oldFrame = iframes[i];
|
||||
oldFrame.src = oldFrame.src;
|
||||
}
|
||||
}
|
||||
1774
Doc/WXRead/resources/js/Readability.js
Normal file
1774
Doc/WXRead/resources/js/Readability.js
Normal file
File diff suppressed because it is too large
Load Diff
237
Doc/WXRead/resources/js/Text/WeReadApi.js
Normal file
237
Doc/WXRead/resources/js/Text/WeReadApi.js
Normal file
@ -0,0 +1,237 @@
|
||||
(function() /** {$b5167ccadfb66ea3ba95d92d7c9946c9} */{
|
||||
WeReadBridge = function() /** {$b9c7abd4d524a31563f1f27b768c47a9} */ {
|
||||
this._sendMessageQueue = [];
|
||||
this._callback_count = 1000;
|
||||
this._callback_map = {};
|
||||
this.available_func = {};
|
||||
this._iframe = document.createElement("iframe");
|
||||
this._iframe.setAttribute("id", "iframe");
|
||||
this._iframe.setAttribute("style","position:absolute;top:0;left:0;width:1px;height:1px;visibility:hidden;");
|
||||
this._QUEUE_HAS_MESSAGE_URL = 'wereadapijs://dispatch_message/';
|
||||
document.body.appendChild(this._iframe);
|
||||
|
||||
this._resultIframe = document.createElement("iframe");
|
||||
this._resultIframe.setAttribute("id", "_resultIframe");
|
||||
this._resultIframe.setAttribute("style","position:absolute;top:0;left:0;width:1px;height:1px;visibility:hidden;");
|
||||
this._resultIframe._SET_RESULT_URL = 'wereadapijs://private/setresult/';
|
||||
document.body.appendChild(this._resultIframe);
|
||||
}
|
||||
|
||||
window.callback = {}
|
||||
function wrapCallback(name, callback) {
|
||||
window.callback[name] = callback;
|
||||
return name;
|
||||
}
|
||||
|
||||
WeReadBridge.prototype.handleWithRichEditor = function(apiName, params,successCallback, failCallback) {
|
||||
//alert("hi");
|
||||
this._call(apiName,params,function(successOrNot, result) {
|
||||
if (typeof successCallback === "function" || typeof failCallback === "function") {
|
||||
self._handleCallback(result, successOrNot, successCallback, failCallback);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
WeReadBridge.prototype.execMPReaderMethod = function(apiName, params, successCallback, failCallback) {
|
||||
this._call(apiName,params,function(successOrNot, result) {
|
||||
if (typeof successCallback === "function" || typeof failCallback === "function") {
|
||||
self._handleCallback(result, successOrNot, successCallback, failCallback);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
WeReadBridge.prototype.fetchQueue = function() /** {$4a202e1afc4b36610e87f574e5478e15} */ {
|
||||
var messageQueueString = JSON.stringify(this._sendMessageQueue);
|
||||
this._sendMessageQueue = [];
|
||||
this._setResultValue('fetchqueue', messageQueueString);
|
||||
return messageQueueString;
|
||||
}
|
||||
|
||||
|
||||
WeReadBridge.prototype._setResultValue = function(scene, result) /** {$23e4b67694084da787d5fa5c739b9a66} */ {
|
||||
// Android 通过另一个iframe上传数据
|
||||
if (result === undefined) {
|
||||
result = '';
|
||||
}
|
||||
this._resultIframe.src = this._resultIframe._SET_RESULT_URL + scene + '&' + this._base64Encode(this._utf8Encode(result));
|
||||
}
|
||||
// public method for url encoding
|
||||
WeReadBridge.prototype._utf8Encode = function(str) /** {$9da9355c0a506c2d0c6dd40c35ea7fb6} */ {
|
||||
str = str.replace(/\r\n/g, "\n");
|
||||
var utftext = "";
|
||||
|
||||
for (var n = 0; n < str.length; n++) {
|
||||
|
||||
var c = str.charCodeAt(n);
|
||||
|
||||
if (c < 128) {
|
||||
utftext += String.fromCharCode(c);
|
||||
} else if ((c > 127) && (c < 2048)) {
|
||||
utftext += String.fromCharCode((c >> 6) | 192);
|
||||
utftext += String.fromCharCode((c & 63) | 128);
|
||||
} else {
|
||||
utftext += String.fromCharCode((c >> 12) | 224);
|
||||
utftext += String.fromCharCode(((c >> 6) & 63) | 128);
|
||||
utftext += String.fromCharCode((c & 63) | 128);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return utftext;
|
||||
}
|
||||
|
||||
WeReadBridge.prototype._base64Encode = function(str) /** {$caa95e8456b5cda1716514776a0bf77a} */ {
|
||||
//base64编码
|
||||
var base64encodechars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
if (str === undefined) {
|
||||
return str;
|
||||
}
|
||||
var out, i, len;
|
||||
var c1, c2, c3;
|
||||
len = str.length;
|
||||
i = 0;
|
||||
out = "";
|
||||
while (i < len) {
|
||||
c1 = str.charCodeAt(i++) & 0xff;
|
||||
if (i == len) {
|
||||
out += base64encodechars.charAt(c1 >> 2);
|
||||
out += base64encodechars.charAt((c1 & 0x3) << 4);
|
||||
out += "==";
|
||||
break;
|
||||
}
|
||||
c2 = str.charCodeAt(i++);
|
||||
if (i == len) {
|
||||
out += base64encodechars.charAt(c1 >> 2);
|
||||
out += base64encodechars.charAt(((c1 & 0x3) << 4) | ((c2 & 0xf0) >> 4));
|
||||
out += base64encodechars.charAt((c2 & 0xf) << 2);
|
||||
out += "=";
|
||||
break;
|
||||
}
|
||||
c3 = str.charCodeAt(i++);
|
||||
out += base64encodechars.charAt(c1 >> 2);
|
||||
out += base64encodechars.charAt(((c1 & 0x3) << 4) | ((c2 & 0xf0) >> 4));
|
||||
out += base64encodechars.charAt(((c2 & 0xf) << 2) | ((c3 & 0xc0) >> 6));
|
||||
out += base64encodechars.charAt(c3 & 0x3f);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
|
||||
WeReadBridge.prototype._sendMessage = function(message) /** {$0685a6a5b33dbf2f615a5ae1faedb0c8} */ {
|
||||
this._sendMessageQueue.push(message);
|
||||
this._iframe.src = this._QUEUE_HAS_MESSAGE_URL;
|
||||
}
|
||||
|
||||
WeReadBridge.prototype.handleMessage = function(message) /** {$623f3ca7480b38b659e4f32c858046f4} */ {
|
||||
var callbackId = message["callbackId"];
|
||||
if (!callbackId || typeof callbackId !== 'string') {
|
||||
return;
|
||||
}
|
||||
var successOrNot = message["successOrNot"];
|
||||
var params = message["params"];
|
||||
if (typeof this._callback_map[callbackId] === "function"){
|
||||
this._callback_map[callbackId](successOrNot,params);
|
||||
delete this._callback_map[callbackId];
|
||||
}
|
||||
}
|
||||
|
||||
WeReadBridge.prototype._call = function(func, params, callback) /** {$0a78d8c17b87d24c599c91b498b8662b} */ {
|
||||
if (!func || typeof func !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof params !== "object") {
|
||||
params = {};
|
||||
}
|
||||
|
||||
var msgObj = {"func":func,"params":params};
|
||||
|
||||
var callbackID = (this._callback_count++).toString();
|
||||
params["callbackId"] = callbackID;
|
||||
|
||||
if (typeof callback === "function") {
|
||||
this._callback_map[callbackID] = callback;
|
||||
msgObj["callbackId"] = callbackID;
|
||||
}
|
||||
this._sendMessage(JSON.stringify(msgObj));
|
||||
}
|
||||
|
||||
WeReadBridge.prototype._handleCallback = function(result, successOrNot, successCallback, failCallback) {
|
||||
// Android 返回 JSON String,在这里转JSON
|
||||
resultJSON = (typeof result == 'string') ? JSON.parse(result) : result;
|
||||
if (successOrNot) {
|
||||
successCallback(resultJSON);
|
||||
} else {
|
||||
failCallback(resultJSON);
|
||||
}
|
||||
}
|
||||
|
||||
WeReadBridge.prototype.onReady = function(func) /** {$9ce323af53931aa37c8788958d68f687} */ {
|
||||
!_isReady && _bindReadyFuns.unshift([this, func]);
|
||||
}
|
||||
WeReadBridge.prototype.bindReady = function(func) /** {$d3b152348187da57f207a3a3eb27fa54} */ {
|
||||
!_isReady ? _bindReadyFuns.unshift([this, func]) : func.call(this);
|
||||
}
|
||||
WeReadBridge.prototype.isReady = function() /** {$9c23e94ea3f2aafaae1c349574569f78} */ {
|
||||
return _isReady;
|
||||
}
|
||||
WeReadBridge.prototype.isAvailable = function(apiName) /** {$67e34482192a8170c150ec414ed4ba7a} */ {
|
||||
return !!(_WeReadBridgeInfo && _WeReadBridgeInfo["apis"][apiName]);
|
||||
}
|
||||
var _isReady, _WeReadBridgeInfo, _bindReadyFuns = [],
|
||||
_onReady = function() /** {$d1b53430a7b4de2d74d12a81eec715ec} */ {
|
||||
_WeReadBridgeInfo = window["__QMB_INFO__"];
|
||||
if (_isReady) return;
|
||||
_isReady = true;
|
||||
var _funcParams;
|
||||
while (_funcParams = _bindReadyFuns.pop()) {
|
||||
_funcParams[1].call(_funcParams[0]);
|
||||
};
|
||||
};
|
||||
window["wereadBridge"] = new WeReadBridge();
|
||||
if (window["__WRB_INFO__"]) {
|
||||
_onReady();
|
||||
} else {
|
||||
window["__WRB_INFO_CALL__"] = function() /** {$4f54cf9d496e3e999912adfb7b5d37b7} */ {
|
||||
_onReady();
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
// /** 函数模板
|
||||
// * func_name description
|
||||
// * @param successCallback(result) result["param_name"]
|
||||
// * @param failCallback(result) result["param_name"]
|
||||
// */
|
||||
// WeReadBridge.prototype.func_name = function(params, successCallback, failCallback) {
|
||||
// var self = this;
|
||||
// this._call("func_name", params, function(successOrNot, result) {
|
||||
// var params = {"func_name":result};
|
||||
// self.localLog(params);
|
||||
// if (successOrNot) {
|
||||
// successCallback(result);
|
||||
// } else {
|
||||
// failCallback(result);
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
|
||||
// window.wereadBridge = new WeReadBridge();
|
||||
// window.wereadBridge.goToUrl("http://wecall.qq.com/");//weread://bookDetail?opentype=0&bookId=414048");
|
||||
/**
|
||||
load页面之前需要先执行以下的js
|
||||
eval('window["__QMB_INFO__"]={apis:{"a":1,"b":1},ver:"4.0.5",os:"android"};window["__QMB_INFO_CALL__"]&&window["__QMB_INFO_CALL__"]();');
|
||||
*/
|
||||
|
||||
// window.wereadBridge.moreOperation(new Array({'shareToWechatTimeLine': {'title':'title','imageUrl':'imageUrl','abstract':'abstract','url':'url'}}));
|
||||
|
||||
// window.wereadBridge.shareToWechatFriend({'title':'中文','imageUrl':'imageUrl','abstract':'中文测试','url':'url'}, function(){}, function(){});
|
||||
// window.wereadBridge.shareToWechatTimeline({'title':'中文','imageUrl':'imageUrl','abstract':'中文测试','url':'url'}, function(){}, function(){});
|
||||
|
||||
// window.wereadBridge.getAppInfo(function(result){alert('getAppInfo: onSucc:'+JSON.stringify(result));}, function(result){alert('getAppInfo: onFailed:'+JSON.stringify(result));});
|
||||
|
||||
// window.wereadBridge.closeBrowser();
|
||||
// window.wereadBridge.window.wereadBridge.mobileSync(function(error,result) {alert('error:' + error+"\n"+'result:' + result);});;
|
||||
// window.wereadBridge.window.wereadBridge.refreshToken(function(error,result) {alert('error:' + error+"\n"+'result:' + result);});;
|
||||
// window.wereadBridge.showBrowserMoreButton({'shareToWechatFriend':0, 'shareToWechatTimeline':1, 'copyLink':0, 'openLinkWithBrowser':0});
|
||||
5
Doc/WXRead/resources/js/Text/correctArticle.js
Normal file
5
Doc/WXRead/resources/js/Text/correctArticle.js
Normal file
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user