commit daa36d8fe72a20e2a1de519157dbaca1a49ba67c Author: shen <> Date: Thu May 21 19:40:51 2026 +0800 Epub阅读器0.0.1 diff --git a/.claude/skills/discuss-feature-solution.md b/.claude/skills/discuss-feature-solution.md new file mode 100644 index 0000000..0df7f99 --- /dev/null +++ b/.claude/skills/discuss-feature-solution.md @@ -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)是方案讨论的核心参照框架,任何方案都必须明确标注落点层级 diff --git a/.claude/skills/start-sdk-feature-dev-lite.md b/.claude/skills/start-sdk-feature-dev-lite.md new file mode 100644 index 0000000..e56ba03 --- /dev/null +++ b/.claude/skills/start-sdk-feature-dev-lite.md @@ -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 四层架构是执行的核心参照框架,单层改动也必须明确标注落点层级 diff --git a/.claude/skills/start-sdk-feature-dev.md b/.claude/skills/start-sdk-feature-dev.md new file mode 100644 index 0000000..cac3869 --- /dev/null +++ b/.claude/skills/start-sdk-feature-dev.md @@ -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)是执行的核心参照框架,所有改动必须明确标注落点层级和依赖方向 diff --git a/Doc/ARCHITECTURE.md b/Doc/ARCHITECTURE.md new file mode 100644 index 0000000..c446d5a --- /dev/null +++ b/Doc/ARCHITECTURE.md @@ -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/` 访问,由 `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 层依赖 diff --git a/Doc/CODING_STYLE.md b/Doc/CODING_STYLE.md new file mode 100644 index 0000000..4423385 --- /dev/null +++ b/Doc/CODING_STYLE.md @@ -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 中同时做“大规模命名迁移 + 业务逻辑重构”。 +- 若改名会影响外部接入,必须先补迁移文档再合并代码。 diff --git a/Doc/EPUBCore_功能实现逻辑.md b/Doc/EPUBCore_功能实现逻辑.md new file mode 100644 index 0000000..2ea7e0b --- /dev/null +++ b/Doc/EPUBCore_功能实现逻辑.md @@ -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/` 映射到解压后的文件系统路径,缺失的可选资源(字体/图片/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/--/` + - 若目录已存在则跳过解压 + - 否则用 ZIPFoundation 解压 ZIP 到缓存目录 +3. 解析 `META-INF/container.xml`: + - `ContainerXMLParserDelegate`(SAX)找到第一个 `` 的 `full-path` 属性 +4. 解析 OPF 文档: + - `OPFPackageParserDelegate`(SAX)分段解析 metadata / manifest / spine + - 提取 `` 版本和 unique-identifier + - 提取 `` / `` / `` / `` + - 提取 `` 判断 fixed/reflowable + - 提取 manifest 每个 `` 为 `RDEPUBManifestItem` + - 提取 spine 每个 `` 为 `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 中每个资源的 ` + + """ + }.joined() + + let background = request.backgroundColorCSS ?? "#FFFFFF" + let viewportWidth = max(1, Int((request.viewportSize.width - request.contentInset.left - request.contentInset.right).rounded(.down))) + let viewportHeight = max(1, Int((request.viewportSize.height - request.contentInset.top - request.contentInset.bottom).rounded(.down))) + let insetTop = Int(request.contentInset.top.rounded(.down)) + let insetRight = Int(request.contentInset.right.rounded(.down)) + let insetBottom = Int(request.contentInset.bottom.rounded(.down)) + let insetLeft = Int(request.contentInset.left.rounded(.down)) + return RDEPUBAssetRepository.string( + for: .fixedLayoutTemplate, + replacements: [ + "BACKGROUND": background, + "INSET_TOP": String(insetTop), + "INSET_RIGHT": String(insetRight), + "INSET_BOTTOM": String(insetBottom), + "INSET_LEFT": String(insetLeft), + "VIEWPORT_WIDTH": String(viewportWidth), + "VIEWPORT_HEIGHT": String(viewportHeight), + "PANES": panes, + "FIT_MODE": request.fit.rawValue, + "FIXED_LAYOUT_READY_MESSAGE": RDEPUBJavaScriptBridgeMessage.fixedLayoutReady.rawValue + ] + ) + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift b/Sources/RDReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift new file mode 100644 index 0000000..2dbb26c --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift @@ -0,0 +1,266 @@ +import Foundation + +enum RDEPUBJavaScriptBridgeMessage: String, CaseIterable { + case progressionChanged = "ssReaderProgressionChanged" + case selectionChanged = "ssReaderSelectionChanged" + case internalLink = "ssReaderInternalLink" + case externalLink = "ssReaderExternalLink" + case javaScriptError = "ssReaderJSError" + case fixedLayoutReady = "ssReaderFixedLayoutReady" +} + +enum RDEPUBJavaScriptBridge { + static var messageNames: [String] { + RDEPUBJavaScriptBridgeMessage.allCases.map(\.rawValue) + } + + static var userScript: String { + RDEPUBAssetRepository.string( + for: .bridgeScript, + replacements: [ + "PROGRESSION_CHANGED_MESSAGE": RDEPUBJavaScriptBridgeMessage.progressionChanged.rawValue, + "SELECTION_CHANGED_MESSAGE": RDEPUBJavaScriptBridgeMessage.selectionChanged.rawValue, + "INTERNAL_LINK_MESSAGE": RDEPUBJavaScriptBridgeMessage.internalLink.rawValue, + "EXTERNAL_LINK_MESSAGE": RDEPUBJavaScriptBridgeMessage.externalLink.rawValue, + "JAVASCRIPT_ERROR_MESSAGE": RDEPUBJavaScriptBridgeMessage.javaScriptError.rawValue, + "FIXED_LAYOUT_READY_MESSAGE": RDEPUBJavaScriptBridgeMessage.fixedLayoutReady.rawValue + ] + ) + } + + static func applyPresentationScript(for request: RDEPUBReflowableRenderRequest) -> String { + let style = escapedJavaScriptTemplateLiteral(RDEPUBStyleSheetBuilder.renderCSS(for: request.presentation)) + let pageStride = max(1, request.presentation.viewportSize.width) + let searchScript = applySearchScript(for: request.searchPresentation) + + return """ + (function() { + if (!window.RDReaderBridge) { return false; } + window.RDReaderBridge.applyPagination(`\(style)`); + window.RDReaderBridge.setPageMetrics(\(request.totalPagesInChapter), \(pageStride)); + window.RDReaderBridge.clearHighlights(); + window.RDReaderBridge.setHighlights(\(jsonString(from: highlightsPayload(request.highlights), fallback: "[]"))); + if (\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")) !== null) { + window.RDReaderBridge.scrollToLocation(\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")), \(request.pageIndex)); + } else { + window.RDReaderBridge.scrollToPage(\(request.pageIndex)); + } + window.RDReaderBridge.reportProgression(); + return true; + })(); + \(searchScript) + """ + } + + static func applySearchScript(for presentation: RDEPUBSearchPresentation?) -> String { + let payload = jsonString(from: searchPayload(presentation), fallback: "null") + return """ + (function() { + var payload = \(payload); + function normalizeHref(rawHref) { + if (!rawHref) { return ''; } + var value = String(rawHref); + value = value.replace(/^.*?:\\/\\/[^/]+\\//, ''); + value = value.split('#')[0]; + value = value.replace(/^\\/+/, ''); + try { + value = decodeURIComponent(value); + } catch (error) { + } + return value; + } + + function ensureStyle(doc) { + if (!doc || !doc.head || doc.getElementById('ss-reader-search-style')) { return; } + var style = doc.createElement('style'); + style.id = 'ss-reader-search-style'; + style.textContent = '.ss-reader-search-mark{background:rgba(248,225,108,0.55);color:inherit;border-radius:2px;padding:0;}.ss-reader-search-mark-active{background:rgba(255,159,67,0.75);color:inherit;border-radius:2px;padding:0;}'; + doc.head.appendChild(style); + } + + function clearMarks(doc) { + if (!doc || !doc.body) { return; } + var marks = doc.querySelectorAll('span.ss-reader-search-mark, span.ss-reader-search-mark-active'); + marks.forEach(function(mark) { + var textNode = doc.createTextNode(mark.textContent || ''); + if (mark.parentNode) { + mark.parentNode.replaceChild(textNode, mark); + } + }); + doc.body.normalize(); + } + + function searchableDocuments(rootDoc) { + var items = []; + if (!rootDoc) { return items; } + items.push({ doc: rootDoc, href: normalizeHref(rootDoc.location && rootDoc.location.href) }); + var frames = rootDoc.querySelectorAll ? rootDoc.querySelectorAll('iframe') : []; + frames.forEach(function(frame) { + try { + if (frame.contentDocument) { + items.push({ + doc: frame.contentDocument, + href: normalizeHref(frame.contentWindow && frame.contentWindow.location && frame.contentWindow.location.href) + }); + } + } catch (error) { + } + }); + return items; + } + + function textNodes(doc) { + if (!doc || !doc.body) { return []; } + var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, { + acceptNode: function(node) { + if (!node || !node.nodeValue || !node.nodeValue.trim()) { + return NodeFilter.FILTER_REJECT; + } + var parent = node.parentNode; + if (!parent || !parent.nodeName) { + return NodeFilter.FILTER_REJECT; + } + var name = parent.nodeName.toLowerCase(); + if (['script', 'style', 'noscript'].indexOf(name) >= 0) { + return NodeFilter.FILTER_REJECT; + } + if (parent.classList && (parent.classList.contains('ss-reader-search-mark') || parent.classList.contains('ss-reader-search-mark-active'))) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + } + }); + + var nodes = []; + while (walker.nextNode()) { + nodes.push(walker.currentNode); + } + return nodes; + } + + function applyToDocument(doc, resourceState, keyword) { + clearMarks(doc); + ensureStyle(doc); + if (!doc || !doc.body || !resourceState || !keyword || resourceState.matchCount <= 0) { + return; + } + + var lowerKeyword = String(keyword).toLowerCase(); + var activeIndex = typeof resourceState.activeLocalMatchIndex === 'number' ? resourceState.activeLocalMatchIndex : null; + var currentLocalIndex = 0; + var activeElement = null; + + textNodes(doc).forEach(function(node) { + var source = node.nodeValue || ''; + var lowerSource = source.toLowerCase(); + var cursor = 0; + var replaced = false; + var fragment = doc.createDocumentFragment(); + + while (cursor < source.length) { + var foundIndex = lowerSource.indexOf(lowerKeyword, cursor); + if (foundIndex < 0) { + break; + } + + replaced = true; + if (foundIndex > cursor) { + fragment.appendChild(doc.createTextNode(source.slice(cursor, foundIndex))); + } + + var mark = doc.createElement('span'); + mark.className = currentLocalIndex === activeIndex ? 'ss-reader-search-mark-active' : 'ss-reader-search-mark'; + mark.textContent = source.slice(foundIndex, foundIndex + keyword.length); + fragment.appendChild(mark); + if (currentLocalIndex === activeIndex) { + activeElement = mark; + } + + currentLocalIndex += 1; + cursor = foundIndex + keyword.length; + } + + if (!replaced) { + return; + } + + if (cursor < source.length) { + fragment.appendChild(doc.createTextNode(source.slice(cursor))); + } + if (node.parentNode) { + node.parentNode.replaceChild(fragment, node); + } + }); + + if (activeElement && activeElement.scrollIntoView) { + activeElement.scrollIntoView({ block: 'center', inline: 'center', behavior: 'auto' }); + } + } + + var docs = searchableDocuments(document); + var states = new Map(); + if (payload && Array.isArray(payload.resources)) { + payload.resources.forEach(function(resource) { + states.set(normalizeHref(resource.href), resource); + }); + } + + docs.forEach(function(item) { + applyToDocument(item.doc, states.get(item.href), payload && payload.keyword ? payload.keyword : ''); + }); + return true; + })(); + """ + } + + private static func escapedJavaScriptTemplateLiteral(_ string: String) -> String { + string + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "`", with: "\\`") + } + + private static func highlightsPayload(_ highlights: [RDEPUBHighlight]) -> [[String: String]] { + highlights.compactMap { highlight in + guard let rangeInfo = highlight.rangeInfo, !rangeInfo.isEmpty else { return nil } + return [ + "id": highlight.id, + "rangeInfo": rangeInfo, + "color": highlight.color, + "style": highlight.style.rawValue + ] + } + } + + private static func targetLocationPayload(_ location: RDEPUBLocation?) -> [String: Any]? { + guard let location else { return nil } + return [ + "progression": location.progression, + "lastProgression": location.lastProgression ?? location.progression, + "fragment": location.fragment as Any + ] + } + + private static func searchPayload(_ presentation: RDEPUBSearchPresentation?) -> [String: Any]? { + guard let presentation else { return nil } + return [ + "keyword": presentation.keyword, + "resources": presentation.resources.map { resource in + [ + "href": resource.href, + "matchCount": resource.matchCount, + "activeLocalMatchIndex": resource.activeLocalMatchIndex as Any + ] + } + ] + } + + private static func jsonString(from object: Any?, fallback: String) -> String { + guard let object else { return fallback } + guard JSONSerialization.isValidJSONObject(object), + let data = try? JSONSerialization.data(withJSONObject: object, options: []), + let string = String(data: data, encoding: .utf8) else { + return fallback + } + return string + } +} diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBModels.swift b/Sources/RDReaderView/EPUBCore/RDEPUBModels.swift new file mode 100644 index 0000000..2b67c49 --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBModels.swift @@ -0,0 +1,163 @@ +import Foundation + +public enum RDEPUBLayout: String, Codable { + case reflowable + case fixed +} + +public enum RDEPUBReadingProfile: String, Codable { + case webInteractive + case webFixedLayout + case textReflowable +} + +public enum RDEPUBReadingProgression: String, Codable { + case ltr + case rtl + case auto +} + +public enum RDEPUBPageSpread: String, Codable { + case left + case right + case center +} + +public struct RDEPUBMetadata: Codable, Equatable { + public var identifier: String? + public var title: String + public var author: String? + public var language: String? + public var version: String? + public var layout: RDEPUBLayout + public var spread: String? + public var readingProgression: RDEPUBReadingProgression + + public init( + identifier: String? = nil, + title: String = "", + author: String? = nil, + language: String? = nil, + version: String? = nil, + layout: RDEPUBLayout = .reflowable, + spread: String? = nil, + readingProgression: RDEPUBReadingProgression = .auto + ) { + self.identifier = identifier + self.title = title + self.author = author + self.language = language + self.version = version + self.layout = layout + self.spread = spread + self.readingProgression = readingProgression + } +} + +public struct RDEPUBManifestItem: Codable, Equatable { + public var id: String + public var href: String + public var mediaType: String + public var properties: [String] + public var fallback: String? + public var mediaOverlay: String? + public var title: String? + + public init( + id: String, + href: String, + mediaType: String, + properties: [String] = [], + fallback: String? = nil, + mediaOverlay: String? = nil, + title: String? = nil + ) { + self.id = id + self.href = href + self.mediaType = mediaType + self.properties = properties + self.fallback = fallback + self.mediaOverlay = mediaOverlay + self.title = title + } + + public var isNavigationDocument: Bool { + properties.contains("nav") + } + + public var isNCX: Bool { + mediaType == "application/x-dtbncx+xml" + } +} + +public struct RDEPUBSpineItem: Codable, Equatable { + public var idref: String + public var href: String + public var mediaType: String + public var title: String + public var linear: Bool + public var properties: [String] + public var pageSpread: RDEPUBPageSpread? + public var layout: RDEPUBLayout? + + public init( + idref: String, + href: String, + mediaType: String, + title: String, + linear: Bool = true, + properties: [String] = [], + pageSpread: RDEPUBPageSpread? = nil, + layout: RDEPUBLayout? = nil + ) { + self.idref = idref + self.href = href + self.mediaType = mediaType + self.title = title + self.linear = linear + self.properties = properties + self.pageSpread = pageSpread + self.layout = layout + } +} + +public struct EPUBTableOfContentsItem: Codable, Equatable { + public var title: String + public var href: String + public var children: [EPUBTableOfContentsItem] + + public init(title: String, href: String, children: [EPUBTableOfContentsItem] = []) { + self.title = title + self.href = href + self.children = children + } +} + +public enum RDEPUBParserError: LocalizedError { + case archiveOpenFailed(URL) + case missingContainerXML + case missingRootFile + case invalidRootFilePath(String) + case invalidXML(URL) + case missingManifestItem(idref: String) + case emptySpine + + public var errorDescription: String? { + switch self { + case .archiveOpenFailed(let url): + return "无法打开 EPUB 压缩包: \(url.lastPathComponent)" + case .missingContainerXML: + return "EPUB 缺少 META-INF/container.xml" + case .missingRootFile: + return "container.xml 中未找到 rootfile" + case .invalidRootFilePath(let path): + return "OPF 路径无效: \(path)" + case .invalidXML(let url): + return "XML 解析失败: \(url.lastPathComponent)" + case .missingManifestItem(let idref): + return "spine itemref 找不到对应 manifest 项: \(idref)" + case .emptySpine: + return "OPF spine 为空" + } + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBNavigatorLayoutContext.swift b/Sources/RDReaderView/EPUBCore/RDEPUBNavigatorLayoutContext.swift new file mode 100644 index 0000000..ea70db6 --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBNavigatorLayoutContext.swift @@ -0,0 +1,45 @@ +import UIKit + +public struct RDEPUBNavigatorLayoutContext: Equatable { + public var containerSize: CGSize + public var pagesPerScreen: Int + public var safeAreaInsets: UIEdgeInsets + public var userInterfaceIdiom: UIUserInterfaceIdiom + public var reflowableContentInsets: UIEdgeInsets + + public init( + containerSize: CGSize, + pagesPerScreen: Int = 1, + safeAreaInsets: UIEdgeInsets = .zero, + userInterfaceIdiom: UIUserInterfaceIdiom = .phone, + reflowableContentInsets: UIEdgeInsets = UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16) + ) { + self.containerSize = containerSize + self.pagesPerScreen = max(1, pagesPerScreen) + self.safeAreaInsets = safeAreaInsets + self.userInterfaceIdiom = userInterfaceIdiom + self.reflowableContentInsets = reflowableContentInsets + } + + public var viewportSize: CGSize { + let width: CGFloat + if pagesPerScreen > 1 { + width = containerSize.width / CGFloat(pagesPerScreen) + } else { + width = containerSize.width + } + return CGSize(width: width, height: containerSize.height) + } + + public var fixedContentInset: UIEdgeInsets { + var insets = safeAreaInsets + if userInterfaceIdiom != .phone { + insets = .zero + } + + let horizontalInsets = max(insets.left, insets.right) + insets.left = horizontalInsets + insets.right = horizontalInsets + return insets + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBNavigatorState.swift b/Sources/RDReaderView/EPUBCore/RDEPUBNavigatorState.swift new file mode 100644 index 0000000..ef51d89 --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBNavigatorState.swift @@ -0,0 +1,14 @@ +import Foundation + +public enum RDEPUBNavigatorState: String, Codable { + case initializing + case loading + case idle + case jumping + case moving + case repaginating + + public var isStableForSnapshotApplication: Bool { + self == .idle + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift b/Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift new file mode 100644 index 0000000..ee5894f --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift @@ -0,0 +1,339 @@ +import UIKit +import WebKit + +public final class RDEPUBPaginator: NSObject { + private var parser: RDEPUBParser? + private weak var hostingView: UIView? + private var presentation = RDEPUBPresentationStyle( + viewportSize: .zero, + contentInsets: .zero, + fontSize: 16, + lineHeightMultiple: 1.5 + ) + private var completion: (([Int]) -> Void)? + private var singlePageCountCompletion: ((Int) -> Void)? + private var pageCounts: [Int] = [] + private var measurementIndices: [Int] = [] + private var currentMeasurementOffset = 0 + private var pendingMeasurementValue = 1 + private var measurementPass = 0 + private var activeSessionID = UUID() + private let debugScope = "PaginatorWebView" + + private lazy var webView: WKWebView = { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = self + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.isScrollEnabled = false + webView.scrollView.bounces = false + webView.scrollView.showsHorizontalScrollIndicator = false + webView.scrollView.showsVerticalScrollIndicator = false + webView.isUserInteractionEnabled = false + if #available(iOS 16.4, *) { + webView.isInspectable = true + } else { + // Fallback on earlier versions + } + RDEPUBWebViewDebug.log("PaginatorWebView", message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView))") + return webView + }() + + public override init() { + super.init() + } + + deinit { + cleanupMeasurementState() + webView.navigationDelegate = nil + webView.stopLoading() + webView.removeFromSuperview() + } + + public func calculate( + parser: RDEPUBParser, + hostingView: UIView, + presentation: RDEPUBPresentationStyle, + completion: @escaping ([Int]) -> Void + ) { + self.parser = parser + self.hostingView = hostingView + self.presentation = presentation + self.completion = completion + self.singlePageCountCompletion = nil + self.pageCounts = Array(repeating: 1, count: parser.spine.count) + self.measurementIndices = Array(parser.spine.indices) + self.currentMeasurementOffset = 0 + self.activeSessionID = UUID() + RDEPUBWebViewDebug.log(debugScope, message: "calculate session=\(activeSessionID) spineCount=\(parser.spine.count)") + + guard parser.metadata.layout != .fixed else { + completion(self.pageCounts) + return + } + + webView.navigationDelegate = self + if webView.superview == nil { + hostingView.addSubview(webView) + } + webView.frame = CGRect(origin: .zero, size: presentation.viewportSize) + measureNextSpineItem() + } + + public func calculate( + parser: RDEPUBParser, + hostingView: UIView, + viewportSize: CGSize, + padding: UIEdgeInsets, + fontSize: CGFloat, + lineHeightMultiple: CGFloat, + completion: @escaping ([Int]) -> Void + ) { + calculate( + parser: parser, + hostingView: hostingView, + presentation: RDEPUBPresentationStyle( + viewportSize: viewportSize, + contentInsets: padding, + fontSize: fontSize, + lineHeightMultiple: lineHeightMultiple + ), + completion: completion + ) + } + + public func calculateSingleSpinePageCount( + parser: RDEPUBParser, + spineIndex: Int, + hostingView: UIView, + presentation: RDEPUBPresentationStyle, + completion: @escaping (Int) -> Void + ) { + self.parser = parser + self.hostingView = hostingView + self.presentation = presentation + self.completion = nil + self.singlePageCountCompletion = completion + self.pageCounts = Array(repeating: 1, count: parser.spine.count) + self.measurementIndices = parser.spine.indices.contains(spineIndex) ? [spineIndex] : [] + self.currentMeasurementOffset = 0 + self.activeSessionID = UUID() + RDEPUBWebViewDebug.log(debugScope, message: "calculateSingle session=\(activeSessionID) spineIndex=\(spineIndex)") + + guard parser.metadata.layout != .fixed, !measurementIndices.isEmpty else { + completion(1) + return + } + + webView.navigationDelegate = self + if webView.superview == nil { + hostingView.addSubview(webView) + } + webView.frame = CGRect(origin: .zero, size: presentation.viewportSize) + measureNextSpineItem() + } + + public func calculateSingleSpinePageCount( + parser: RDEPUBParser, + spineIndex: Int, + hostingView: UIView, + viewportSize: CGSize, + padding: UIEdgeInsets, + fontSize: CGFloat, + lineHeightMultiple: CGFloat, + completion: @escaping (Int) -> Void + ) { + calculateSingleSpinePageCount( + parser: parser, + spineIndex: spineIndex, + hostingView: hostingView, + presentation: RDEPUBPresentationStyle( + viewportSize: viewportSize, + contentInsets: padding, + fontSize: fontSize, + lineHeightMultiple: lineHeightMultiple + ), + completion: completion + ) + } + + public static func injectPaginationCSS( + into html: String, + viewportSize: CGSize, + padding: UIEdgeInsets, + fontSize: CGFloat, + lineHeightMultiple: CGFloat, + themeBackgroundColor: String? = nil, + themeTextColor: String? = nil + ) -> String { + RDEPUBStyleSheetBuilder.injectPaginationCSS( + into: html, + presentation: RDEPUBPresentationStyle( + viewportSize: viewportSize, + contentInsets: padding, + fontSize: fontSize, + lineHeightMultiple: lineHeightMultiple, + themeBackgroundColor: themeBackgroundColor, + themeTextColor: themeTextColor + ) + ) + } + + private func measureNextSpineItem() { + guard let parser else { + finishIfNeeded() + return + } + + guard currentMeasurementOffset < measurementIndices.count else { + finishIfNeeded() + return + } + + let currentSpineIndex = measurementIndices[currentMeasurementOffset] + + let item = parser.spine[currentSpineIndex] + guard isRenderablePage(item: item), + let opfDirectoryURL = parser.opfDirectoryURL else { + pageCounts[currentSpineIndex] = 1 + currentMeasurementOffset += 1 + measureNextSpineItem() + return + } + + let fileURL = opfDirectoryURL.appendingPathComponent(item.href) + guard FileManager.default.fileExists(atPath: fileURL.path) else { + pageCounts[currentSpineIndex] = 1 + currentMeasurementOffset += 1 + measureNextSpineItem() + return + } + + pendingMeasurementValue = 1 + measurementPass = 0 + let readAccessURL = parser.extractionRootURL ?? opfDirectoryURL + RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "measure-load", url: fileURL) + webView.loadFileURL(fileURL, allowingReadAccessTo: readAccessURL) + } + + private func currentSpineIndexForMeasurement() -> Int? { + measurementIndices[safe: currentMeasurementOffset] + } + + private func finishIfNeeded() { + if let singlePageCountCompletion { + let measuredSpineIndex = measurementIndices.first ?? 0 + singlePageCountCompletion(max(1, pageCounts[safe: measuredSpineIndex] ?? 1)) + self.singlePageCountCompletion = nil + cleanupMeasurementState() + return + } + guard let completion else { return } + completion(pageCounts) + self.completion = nil + cleanupMeasurementState() + } + + private func cleanupMeasurementState() { + parser = nil + hostingView = nil + completion = nil + singlePageCountCompletion = nil + measurementIndices = [] + currentMeasurementOffset = 0 + pendingMeasurementValue = 1 + measurementPass = 0 + activeSessionID = UUID() + webView.stopLoading() + webView.navigationDelegate = nil + webView.removeFromSuperview() + } + + private func isRenderablePage(item: RDEPUBSpineItem) -> Bool { + let mediaType = item.mediaType.lowercased() + return mediaType.contains("html") || mediaType.contains("xhtml") || mediaType.contains("xml") + } + + private func scheduleMeasurementPass() { + let sessionID = activeSessionID + let delays: [TimeInterval] = [0.0, 0.08, 0.18] + guard measurementPass < delays.count else { + guard sessionID == activeSessionID, + let currentSpineIndex = currentSpineIndexForMeasurement() else { + return + } + pageCounts[currentSpineIndex] = max(1, pendingMeasurementValue) + currentMeasurementOffset += 1 + measureNextSpineItem() + return + } + + let delay = delays[measurementPass] + measurementPass += 1 + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self, self.activeSessionID == sessionID else { return } + self.measureCurrentDocument(sessionID: sessionID) + } + } + + private func measureCurrentDocument(sessionID: UUID) { + let script = RDEPUBStyleSheetBuilder.measurementScript(for: presentation) + RDEPUBWebViewDebug.logJavaScript(debugScope, webView: webView, action: "measure", details: "session=\(sessionID) pass=\(measurementPass)") + + webView.evaluateJavaScript(script) { [weak self] value, _ in + guard let self else { return } + guard self.activeSessionID == sessionID, + self.currentSpineIndexForMeasurement() != nil else { + return + } + if let number = value as? NSNumber { + self.pendingMeasurementValue = max(self.pendingMeasurementValue, number.intValue) + } else if let intValue = value as? Int { + self.pendingMeasurementValue = max(self.pendingMeasurementValue, intValue) + } + RDEPUBWebViewDebug.log(self.debugScope, message: "measurement value=\(self.pendingMeasurementValue) session=\(sessionID)") + self.scheduleMeasurementPass() + } + } + +} + +extension RDEPUBPaginator: WKNavigationDelegate { + public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didStart", url: webView.url) + } + + public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFinish", url: webView.url) + guard currentSpineIndexForMeasurement() != nil else { return } + scheduleMeasurementPass() + } + + public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFail", url: webView.url, error: error) + let currentSpineIndex = measurementIndices[safe: currentMeasurementOffset] ?? 0 + pageCounts[currentSpineIndex] = 1 + currentMeasurementOffset += 1 + measureNextSpineItem() + } + + public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error) + let currentSpineIndex = measurementIndices[safe: currentMeasurementOffset] ?? 0 + pageCounts[currentSpineIndex] = 1 + currentMeasurementOffset += 1 + measureNextSpineItem() + } + + public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "processTerminated", url: webView.url) + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift b/Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift new file mode 100644 index 0000000..7d94a1d --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift @@ -0,0 +1,98 @@ +import Foundation +import ZIPFoundation + +extension RDEPUBParser { + func parseContainerRootFile(at containerURL: URL) throws -> String { + guard let parser = XMLParser(contentsOf: containerURL) else { + throw RDEPUBParserError.invalidXML(containerURL) + } + + let delegate = ContainerXMLParserDelegate() + parser.shouldProcessNamespaces = false + parser.delegate = delegate + + guard parser.parse() else { + throw parser.parserError ?? RDEPUBParserError.invalidXML(containerURL) + } + + guard let rootFilePath = delegate.rootFilePath, !rootFilePath.isEmpty else { + throw RDEPUBParserError.missingRootFile + } + + return rootFilePath + } + + func extractArchiveIfNeeded(epubURL: URL) throws -> URL { + let fileManager = FileManager.default + let extractionURL = temporaryExtractionDirectory(for: epubURL) + + if fileManager.fileExists(atPath: extractionURL.path) { + return extractionURL + } + + guard let archive = Archive(url: epubURL, accessMode: .read) else { + throw RDEPUBParserError.archiveOpenFailed(epubURL) + } + + try fileManager.createDirectory(at: extractionURL, withIntermediateDirectories: true) + + for entry in archive { + let destinationURL = extractionURL.appendingPathComponent(entry.path) + switch entry.type { + case .directory: + try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true) + case .file: + try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true) + _ = try archive.extract(entry, to: destinationURL) + case .symlink: + continue + } + } + + return extractionURL + } + + func temporaryExtractionDirectory(for epubURL: URL) -> URL { + let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first? + .appendingPathComponent("ssreaderview-epub", isDirectory: true) + ?? FileManager.default.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true) + let fileAttributes = try? FileManager.default.attributesOfItem(atPath: epubURL.path) + let fileSize = (fileAttributes?[.size] as? NSNumber)?.stringValue ?? "0" + let modifiedAt = (fileAttributes?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 + let slug = epubURL.deletingPathExtension().lastPathComponent + .replacingOccurrences(of: " ", with: "-") + let signature = String(format: "%.0f", modifiedAt) + return baseURL.appendingPathComponent("\(slug)-\(fileSize)-\(signature)", isDirectory: true) + } + + func reset() { + extractionRootURL = nil + opfURL = nil + resetPublicationState() + } + + func resetPublicationState() { + metadata = RDEPUBMetadata() + manifest = [:] + spine = [] + tableOfContents = [] + } +} + +private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate { + private(set) var rootFilePath: String? + + func parser( + _ parser: XMLParser, + didStartElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String?, + attributes attributeDict: [String: String] = [:] + ) { + let name = XMLName.localName(from: qName ?? elementName) + guard name == "rootfile", rootFilePath == nil else { + return + } + rootFilePath = attributeDict["full-path"]?.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBParser+Package.swift b/Sources/RDReaderView/EPUBCore/RDEPUBParser+Package.swift new file mode 100644 index 0000000..4122158 --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBParser+Package.swift @@ -0,0 +1,358 @@ +import Foundation + +extension RDEPUBParser { + func buildSpine(from packageDocument: OPFPackageDocument, opfURL: URL) throws -> [RDEPUBSpineItem] { + let opfDirectoryURL = opfURL.deletingLastPathComponent() + let publicationLayout = packageDocument.metadata.layout + + let spineItems = try packageDocument.spineReferences.map { reference in + guard let manifestItem = packageDocument.manifestByID[reference.idref] else { + throw RDEPUBParserError.missingManifestItem(idref: reference.idref) + } + + let normalizedHref = normalize(href: manifestItem.href, relativeTo: opfDirectoryURL) + let title = manifestItem.title?.trimmingCharacters(in: .whitespacesAndNewlines) + let fallbackTitle = normalizedHref + .split(separator: "/") + .last + .map(String.init) + .map { $0.replacingOccurrences(of: ".xhtml", with: "") } + .map { $0.replacingOccurrences(of: ".html", with: "") } + .flatMap { $0.isEmpty ? nil : $0 } + ?? reference.idref + + let effectiveLayout: RDEPUBLayout? + if reference.properties.contains("rendition:layout-pre-paginated") || manifestItem.properties.contains("rendition:layout-pre-paginated") { + effectiveLayout = .fixed + } else if reference.properties.contains("rendition:layout-reflowable") || manifestItem.properties.contains("rendition:layout-reflowable") { + effectiveLayout = .reflowable + } else { + effectiveLayout = publicationLayout == .fixed ? .fixed : nil + } + + return RDEPUBSpineItem( + idref: reference.idref, + href: normalizedHref, + mediaType: manifestItem.mediaType, + title: title?.isEmpty == false ? title! : fallbackTitle, + linear: reference.linear, + properties: reference.properties, + pageSpread: reference.pageSpread, + layout: effectiveLayout + ) + } + + guard !spineItems.isEmpty else { + throw RDEPUBParserError.emptySpine + } + + return spineItems + } +} + +struct OPFPackageDocument { + var metadata: RDEPUBMetadata + var manifest: [RDEPUBManifestItem] + var manifestByID: [String: RDEPUBManifestItem] + var spineReferences: [OPFSpineReference] + var ncxItem: RDEPUBManifestItem? + var navigationItem: RDEPUBManifestItem? +} + +struct OPFSpineReference { + var idref: String + var linear: Bool + var properties: [String] + var pageSpread: RDEPUBPageSpread? +} + +final class OPFPackageParserDelegate: NSObject, XMLParserDelegate { + private var metadata = RDEPUBMetadata() + private var manifestItems: [RDEPUBManifestItem] = [] + private var spineReferences: [OPFSpineReference] = [] + private let xmlContext = XMLParserContext() + private var uniqueIdentifierID: String? + private var currentIdentifierElementID: String? + private var currentMetaProperty: String? + private var identifierByID: [String: String] = [:] + private var manifestTitleByID: [String: String] = [:] + private var currentMetaRefinesID: String? + private var ncxID: String? + + func packageDocument() -> OPFPackageDocument { + if metadata.identifier == nil, let fallbackIdentifier = identifierByID.values.first { + metadata.identifier = fallbackIdentifier + } + metadata.title = metadata.title.trimmingCharacters(in: .whitespacesAndNewlines) + metadata.author = metadata.author?.trimmingCharacters(in: .whitespacesAndNewlines) + metadata.language = metadata.language?.trimmingCharacters(in: .whitespacesAndNewlines) + + let manifest = manifestItems.map { item in + var updatedItem = item + updatedItem.title = manifestTitleByID[item.id] + return updatedItem + } + + return OPFPackageDocument( + metadata: metadata, + manifest: manifest, + manifestByID: Dictionary(uniqueKeysWithValues: manifest.map { ($0.id, $0) }), + spineReferences: spineReferences, + ncxItem: manifest.first(where: { $0.id == ncxID || $0.isNCX }), + navigationItem: manifest.first(where: { $0.isNavigationDocument }) + ) + } + + func parser( + _ parser: XMLParser, + didStartElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String?, + attributes attributeDict: [String: String] = [:] + ) { + let name = xmlContext.startElement(named: qName ?? elementName) + + switch name { + case "package": + metadata.version = attributeDict["version"] + uniqueIdentifierID = attributeDict["unique-identifier"] + case "identifier": + if currentSection == .metadata { + currentIdentifierElementID = attributeDict["id"] + } + case "item": + guard currentSection == .manifest, + let id = attributeDict["id"], + let href = attributeDict["href"], + let mediaType = attributeDict["media-type"] else { + return + } + manifestItems.append( + RDEPUBManifestItem( + id: id, + href: href, + mediaType: mediaType, + properties: XMLName.tokenize(attributeDict["properties"]), + fallback: attributeDict["fallback"], + mediaOverlay: attributeDict["media-overlay"] + ) + ) + case "itemref": + guard currentSection == .spine, let idref = attributeDict["idref"] else { + return + } + spineReferences.append( + OPFSpineReference( + idref: idref, + linear: (attributeDict["linear"]?.lowercased() ?? "yes") != "no", + properties: XMLName.tokenize(attributeDict["properties"]), + pageSpread: pageSpread(from: attributeDict) + ) + ) + case "meta": + currentMetaProperty = attributeDict["property"] ?? attributeDict["name"] + currentMetaRefinesID = attributeDict["refines"].flatMap(XMLName.refinedID(from:)) + if let property = currentMetaProperty?.lowercased(), + property == "rendition:layout", + let value = attributeDict["content"] { + metadata.layout = layout(from: value) + } + if let property = currentMetaProperty?.lowercased(), + property == "rendition:spread" { + metadata.spread = attributeDict["content"] + } + if let property = currentMetaProperty?.lowercased(), + property == "title", + let refinesID = currentMetaRefinesID, + let value = attributeDict["content"], + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + manifestTitleByID[refinesID] = value.trimmingCharacters(in: .whitespacesAndNewlines) + } + default: + break + } + + if name == "spine" { + if let direction = attributeDict["page-progression-direction"]?.lowercased() { + metadata.readingProgression = direction == "ltr" ? .ltr : (direction == "rtl" ? .rtl : .auto) + } + ncxID = attributeDict["toc"] + } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + xmlContext.appendCharacters(string) + } + + func parser( + _ parser: XMLParser, + didEndElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String? + ) { + let name = XMLName.localName(from: qName ?? elementName) + let text = xmlContext.trimmedCharacters + + switch (currentSection, name) { + case (.metadata, "identifier"): + if let identifierID = currentIdentifierElementID, !text.isEmpty { + identifierByID[identifierID] = text + if identifierID == uniqueIdentifierID { + metadata.identifier = text + } + } else if metadata.identifier == nil, !text.isEmpty { + metadata.identifier = text + } + currentIdentifierElementID = nil + case (.metadata, "title"): + if metadata.title.isEmpty, !text.isEmpty { + metadata.title = text + } + case (.metadata, "creator"): + if metadata.author == nil, !text.isEmpty { + metadata.author = text + } + case (.metadata, "language"): + if metadata.language == nil, !text.isEmpty { + metadata.language = text + } + case (.metadata, "meta"): + applyMetaValue(text) + currentMetaProperty = nil + currentMetaRefinesID = nil + default: + break + } + + xmlContext.endElement() + } + + private var currentSection: XMLSection { + if xmlContext.containsElement(named: "manifest") { + return .manifest + } + if xmlContext.containsElement(named: "spine") { + return .spine + } + if xmlContext.containsElement(named: "metadata") { + return .metadata + } + return .other + } + + private func applyMetaValue(_ value: String) { + let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedValue.isEmpty, let property = currentMetaProperty?.lowercased() else { + return + } + + switch property { + case "rendition:layout": + metadata.layout = layout(from: normalizedValue) + case "rendition:spread": + metadata.spread = normalizedValue + case "dcterms:identifier", "identifier": + if metadata.identifier == nil { + metadata.identifier = normalizedValue + } + case "title": + if let refinesID = currentMetaRefinesID { + manifestTitleByID[refinesID] = normalizedValue + } + default: + break + } + } + + private func pageSpread(from attributes: [String: String]) -> RDEPUBPageSpread? { + if let properties = attributes["properties"]?.lowercased() { + if properties.contains("page-spread-left") { + return .left + } + if properties.contains("page-spread-right") { + return .right + } + if properties.contains("page-spread-center") { + return .center + } + } + + if let legacySpread = attributes["page-spread"]?.lowercased() { + switch legacySpread { + case "left": + return .left + case "right": + return .right + case "center": + return .center + default: + return nil + } + } + + return nil + } + + private func layout(from rawValue: String) -> RDEPUBLayout { + rawValue.lowercased().contains("pre-paginated") ? .fixed : .reflowable + } +} + +enum XMLSection { + case metadata + case manifest + case spine + case other +} + +enum XMLName { + static func localName(from rawName: String) -> String { + rawName.split(separator: ":").last.map(String.init) ?? rawName + } + + static func tokenize(_ rawValue: String?) -> [String] { + guard let rawValue else { return [] } + return rawValue + .split(whereSeparator: { $0 == " " || $0 == "\n" || $0 == "\t" || $0 == "\r" }) + .map(String.init) + } + + static func refinedID(from rawValue: String) -> String? { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("#") else { + return nil + } + return String(trimmed.dropFirst()) + } +} + +final class XMLParserContext { + private var elementStack: [String] = [] + private var currentCharacters = "" + + var trimmedCharacters: String { + currentCharacters.trimmingCharacters(in: .whitespacesAndNewlines) + } + + func startElement(named rawName: String) -> String { + let name = XMLName.localName(from: rawName) + elementStack.append(name) + currentCharacters = "" + return name + } + + func appendCharacters(_ string: String) { + currentCharacters += string + } + + func endElement() { + if !elementStack.isEmpty { + _ = elementStack.removeLast() + } + currentCharacters = "" + } + + func containsElement(named name: String) -> Bool { + elementStack.contains(name) + } +} diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift b/Sources/RDReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift new file mode 100644 index 0000000..54897ec --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift @@ -0,0 +1,35 @@ +import Foundation + +extension RDEPUBParser { + public func readingProfile() -> RDEPUBReadingProfile { + if metadata.layout == .fixed { + return .webFixedLayout + } + return hasInteractiveContent() ? .webInteractive : .textReflowable + } + + public func hasInteractiveContent() -> Bool { + let interactiveMediaTypes = [ + "application/javascript", + "text/javascript", + "application/ecmascript" + ] + if manifest.values.contains(where: { item in + interactiveMediaTypes.contains(item.mediaType.lowercased()) || item.properties.contains("scripted") + }) { + return true + } + + let interactivePattern = #"<(script|iframe|video|audio|canvas|svg|form)\b|\bon(load|click|touchstart|touchend|mouseover)=|hype_generated_script|swiper|webview"# + for item in spine where item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) { + guard let html = htmlString(forRelativePath: item.href) else { + continue + } + if html.range(of: interactivePattern, options: [.regularExpression, .caseInsensitive]) != nil { + return true + } + } + + return false + } +} diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBParser+Resources.swift b/Sources/RDReaderView/EPUBCore/RDEPUBParser+Resources.swift new file mode 100644 index 0000000..b46f5dc --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBParser+Resources.swift @@ -0,0 +1,94 @@ +import Foundation +import UIKit + +extension RDEPUBParser { + public func manifestItem(for id: String) -> RDEPUBManifestItem? { + manifest[id] + } + + public func manifestItem(forHref href: String) -> RDEPUBManifestItem? { + let target = href.components(separatedBy: "#").first ?? href + return manifest.values.first { item in + normalize(href: item.href, relativeTo: opfDirectoryURL ?? URL(fileURLWithPath: "/")) == target + } + } + + public func href(forSpineIndex index: Int) -> String? { + guard spine.indices.contains(index) else { return nil } + return spine[index].href + } + + public func fileURL(forRelativePath relativePath: String) -> URL? { + guard let opfDirectoryURL else { return nil } + let path = relativePath.components(separatedBy: "#").first ?? relativePath + guard !path.isEmpty else { return nil } + let resolvedURL = URL(fileURLWithPath: path, relativeTo: opfDirectoryURL).standardizedFileURL + guard let rootURL = extractionRootURL?.standardizedFileURL else { + return resolvedURL + } + guard resolvedURL.path.hasPrefix(rootURL.path) else { + return nil + } + return resolvedURL + } + + public func resourceURL(forRelativePath relativePath: String) -> URL? { + let normalizedPath = relativePath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard !normalizedPath.isEmpty else { return nil } + guard let encodedPath = normalizedPath.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { + return nil + } + var components = URLComponents() + components.scheme = RDEPUBResourceURLSchemeHandler.scheme + components.host = RDEPUBResourceURLSchemeHandler.host + components.percentEncodedPath = "/" + encodedPath + return components.url + } + + public func fileURL(forResourceURL resourceURL: URL) -> URL? { + guard resourceURL.scheme == RDEPUBResourceURLSchemeHandler.scheme, + resourceURL.host == RDEPUBResourceURLSchemeHandler.host else { + return nil + } + let relativePath = resourceURL.path.removingPercentEncoding?.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? "" + return fileURL(forRelativePath: relativePath) + } + + public func coverImage() -> UIImage? { + let coverCandidates = manifest.values.filter { item in + item.properties.contains("cover-image") || item.id.lowercased().contains("cover") + } + for item in coverCandidates { + if let fileURL = fileURL(forRelativePath: item.href), + let image = UIImage(contentsOfFile: fileURL.path) { + return image + } + } + return nil + } + + public func htmlString(forSpineIndex index: Int) -> String? { + guard spine.indices.contains(index) else { + return nil + } + return htmlString(forRelativePath: spine[index].href) + } + + public func htmlString(forRelativePath relativePath: String) -> String? { + guard let fileURL = fileURL(forRelativePath: relativePath) else { + return nil + } + return try? String(contentsOf: fileURL) + } + + func normalize(href: String, relativeTo directoryURL: URL) -> String { + guard let resolvedURL = URL(string: href, relativeTo: directoryURL)?.standardizedFileURL else { + return href + } + let prefix = directoryURL.standardizedFileURL.path + "/" + if resolvedURL.path.hasPrefix(prefix) { + return String(resolvedURL.path.dropFirst(prefix.count)) + } + return href + } +} diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBParser+TOC.swift b/Sources/RDReaderView/EPUBCore/RDEPUBParser+TOC.swift new file mode 100644 index 0000000..05d8c02 --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBParser+TOC.swift @@ -0,0 +1,299 @@ +import Foundation + +extension RDEPUBParser { + func parseTOC(from packageDocument: OPFPackageDocument, opfURL: URL) -> [EPUBTableOfContentsItem] { + let opfDirectoryURL = opfURL.deletingLastPathComponent() + + if let ncxItem = packageDocument.ncxItem ?? packageDocument.manifest.first(where: { $0.isNCX }) { + let ncxURL = opfDirectoryURL.appendingPathComponent(ncxItem.href) + let items = parseNCXDocumentItems(at: ncxURL) + if !items.isEmpty { + return items + } + } + + if let navItem = packageDocument.navigationItem { + let navURL = opfDirectoryURL.appendingPathComponent(navItem.href) + let items = parseNavDocumentItems(at: navURL, baseURL: navURL.deletingLastPathComponent()) + if !items.isEmpty { + return items + } + } + + return spine.map { EPUBTableOfContentsItem(title: $0.title, href: $0.href) } + } + + func parseNCXDocumentItems(at ncxURL: URL) -> [EPUBTableOfContentsItem] { + guard FileManager.default.fileExists(atPath: ncxURL.path), + let parser = XMLParser(contentsOf: ncxURL) else { + return [] + } + + let delegate = NCXParserDelegate(baseURL: ncxURL.deletingLastPathComponent()) { [weak self] href, baseURL in + self?.normalizeTOCHref(href, relativeTo: baseURL) + } + parser.shouldProcessNamespaces = false + parser.delegate = delegate + guard parser.parse() else { + return [] + } + return delegate.items + } + + func parseNavDocumentItems(at navURL: URL, baseURL: URL) -> [EPUBTableOfContentsItem] { + guard FileManager.default.fileExists(atPath: navURL.path), + let parser = XMLParser(contentsOf: navURL) else { + return [] + } + + let delegate = NavDocumentParserDelegate(baseURL: baseURL) { [weak self] href, currentBaseURL in + self?.normalizeTOCHref(href, relativeTo: currentBaseURL) + } + parser.shouldProcessNamespaces = false + parser.delegate = delegate + guard parser.parse() else { + return [] + } + return delegate.items + } + + func normalizeTOCHref(_ href: String, relativeTo baseURL: URL) -> String? { + let components = href.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false) + let pathPart = components.first.map(String.init) ?? href + let fragment = components.count > 1 ? String(components[1]) : nil + + let normalizedPath: String + if pathPart.isEmpty { + normalizedPath = "" + } else { + normalizedPath = normalize(href: pathPart, relativeTo: baseURL) + } + + if let fragment, !fragment.isEmpty { + if normalizedPath.isEmpty { + return "#\(fragment)" + } + return "\(normalizedPath)#\(fragment)" + } + + return normalizedPath.isEmpty ? nil : normalizedPath + } +} + +private final class TOCNode { + var title = "" + var href: String? + var children: [TOCNode] = [] + + func asItem() -> EPUBTableOfContentsItem? { + let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard let href, !href.isEmpty else { + return nil + } + return EPUBTableOfContentsItem( + title: trimmedTitle.isEmpty ? href : trimmedTitle, + href: href, + children: children.compactMap { $0.asItem() } + ) + } +} + +private final class TOCTreeBuilder { + private var nodeStack: [TOCNode] = [] + private var rootNodes: [TOCNode] = [] + + var items: [EPUBTableOfContentsItem] { + rootNodes.compactMap { $0.asItem() } + } + + func beginNode() { + nodeStack.append(TOCNode()) + } + + func updateCurrentHref(_ href: String?) { + nodeStack.last?.href = href + } + + func appendCurrentTitle(_ text: String) { + nodeStack.last?.title += text + } + + func endNode() { + guard let node = nodeStack.popLast() else { + return + } + if let parent = nodeStack.last { + parent.children.append(node) + } else { + rootNodes.append(node) + } + } + + func reset() { + nodeStack.removeAll() + } +} + +private final class NCXParserDelegate: NSObject, XMLParserDelegate { + private let baseURL: URL + private let hrefNormalizer: (String, URL) -> String? + private let treeBuilder = TOCTreeBuilder() + private var readingLabelText = false + + init(baseURL: URL, hrefNormalizer: @escaping (String, URL) -> String?) { + self.baseURL = baseURL + self.hrefNormalizer = hrefNormalizer + } + + var items: [EPUBTableOfContentsItem] { + treeBuilder.items + } + + func parser( + _ parser: XMLParser, + didStartElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String?, + attributes attributeDict: [String: String] = [:] + ) { + let name = XMLName.localName(from: qName ?? elementName) + + switch name { + case "navPoint": + treeBuilder.beginNode() + case "content": + guard let src = attributeDict["src"] else { + return + } + treeBuilder.updateCurrentHref(hrefNormalizer(src, baseURL)) + case "text": + readingLabelText = true + default: + break + } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + if readingLabelText { + treeBuilder.appendCurrentTitle(string) + } + } + + func parser( + _ parser: XMLParser, + didEndElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String? + ) { + let name = XMLName.localName(from: qName ?? elementName) + + switch name { + case "text": + readingLabelText = false + case "navPoint": + treeBuilder.endNode() + default: + break + } + } +} + +private final class NavDocumentParserDelegate: NSObject, XMLParserDelegate { + private let baseURL: URL + private let hrefNormalizer: (String, URL) -> String? + private var insideTOCNav = false + private var listDepth = 0 + private let treeBuilder = TOCTreeBuilder() + private var collectingAnchorText = false + + init(baseURL: URL, hrefNormalizer: @escaping (String, URL) -> String?) { + self.baseURL = baseURL + self.hrefNormalizer = hrefNormalizer + } + + var items: [EPUBTableOfContentsItem] { + treeBuilder.items + } + + func parser( + _ parser: XMLParser, + didStartElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String?, + attributes attributeDict: [String: String] = [:] + ) { + let name = XMLName.localName(from: qName ?? elementName) + + switch name { + case "nav": + insideTOCNav = isTOCNav(attributes: attributeDict) + case "ol": + if insideTOCNav { + listDepth += 1 + } + case "li": + if insideTOCNav, listDepth > 0 { + treeBuilder.beginNode() + } + case "a": + guard insideTOCNav else { + return + } + collectingAnchorText = true + if let href = attributeDict["href"] { + treeBuilder.updateCurrentHref(hrefNormalizer(href, baseURL)) + } + default: + break + } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + if collectingAnchorText { + treeBuilder.appendCurrentTitle(string) + } + } + + func parser( + _ parser: XMLParser, + didEndElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String? + ) { + let name = XMLName.localName(from: qName ?? elementName) + + switch name { + case "a": + collectingAnchorText = false + case "li": + guard insideTOCNav else { + break + } + treeBuilder.endNode() + case "ol": + if insideTOCNav { + listDepth = max(0, listDepth - 1) + } + case "nav": + insideTOCNav = false + listDepth = 0 + treeBuilder.reset() + collectingAnchorText = false + default: + break + } + } + + private func isTOCNav(attributes: [String: String]) -> Bool { + if let epubType = attributes["epub:type"]?.lowercased(), epubType.contains("toc") { + return true + } + if let type = attributes["type"]?.lowercased(), type.contains("toc") { + return true + } + if let role = attributes["role"]?.lowercased(), role.contains("doc-toc") { + return true + } + return false + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBParser.swift b/Sources/RDReaderView/EPUBCore/RDEPUBParser.swift new file mode 100644 index 0000000..28860c8 --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBParser.swift @@ -0,0 +1,71 @@ +import Foundation + +public final class RDEPUBParser { + public internal(set) var metadata = RDEPUBMetadata() + public internal(set) var manifest: [String: RDEPUBManifestItem] = [:] + public internal(set) var spine: [RDEPUBSpineItem] = [] + public internal(set) var tableOfContents: [EPUBTableOfContentsItem] = [] + public internal(set) var extractionRootURL: URL? + public internal(set) var opfURL: URL? + + public var opfDirectoryURL: URL? { + opfURL?.deletingLastPathComponent() + } + + public init() {} + + public func makePublication() -> RDEPUBPublication { + RDEPUBPublication(parser: self) + } + + public func parse(epubURL: URL) throws { + reset() + + let extractionURL = try extractArchiveIfNeeded(epubURL: epubURL) + extractionRootURL = extractionURL + + let containerURL = extractionURL.appendingPathComponent("META-INF/container.xml") + guard FileManager.default.fileExists(atPath: containerURL.path) else { + throw RDEPUBParserError.missingContainerXML + } + + let rootFilePath = try parseContainerRootFile(at: containerURL) + let packageURL = extractionURL.appendingPathComponent(rootFilePath) + guard FileManager.default.fileExists(atPath: packageURL.path) else { + throw RDEPUBParserError.invalidRootFilePath(rootFilePath) + } + + try parseOPF(at: packageURL) + } + + public func parseOPF(at opfURL: URL) throws { + resetPublicationState() + + guard let parser = XMLParser(contentsOf: opfURL) else { + throw RDEPUBParserError.invalidXML(opfURL) + } + + let delegate = OPFPackageParserDelegate() + parser.shouldProcessNamespaces = false + parser.delegate = delegate + + guard parser.parse() else { + throw parser.parserError ?? RDEPUBParserError.invalidXML(opfURL) + } + + let packageDocument = delegate.packageDocument() + self.opfURL = opfURL + self.metadata = packageDocument.metadata + self.manifest = Dictionary(uniqueKeysWithValues: packageDocument.manifest.map { ($0.id, $0) }) + self.spine = try buildSpine(from: packageDocument, opfURL: opfURL) + self.tableOfContents = parseTOC(from: packageDocument, opfURL: opfURL) + } + + public func parseTOC() -> [EPUBTableOfContentsItem] { + tableOfContents + } + + public func parseNavDocument(_ navURL: URL, baseURL: URL? = nil) -> [EPUBTableOfContentsItem] { + parseNavDocumentItems(at: navURL, baseURL: baseURL ?? navURL.deletingLastPathComponent()) + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBPreferences.swift b/Sources/RDReaderView/EPUBCore/RDEPUBPreferences.swift new file mode 100644 index 0000000..d0722b0 --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBPreferences.swift @@ -0,0 +1,85 @@ +import UIKit + +public struct RDEPUBPreferences: Equatable { + public var fontSize: CGFloat + public var lineHeightMultiple: CGFloat + public var reflowableContentInsets: UIEdgeInsets + public var fixedContentInset: UIEdgeInsets + public var themeBackgroundColor: String? + public var themeTextColor: String? + public var fixedBackgroundColor: String? + public var fixedLayoutFit: RDEPUBFixedLayoutFit + public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode + + public init( + fontSize: CGFloat, + lineHeightMultiple: CGFloat, + reflowableContentInsets: UIEdgeInsets, + fixedContentInset: UIEdgeInsets, + themeBackgroundColor: String? = nil, + themeTextColor: String? = nil, + fixedBackgroundColor: String? = nil, + fixedLayoutFit: RDEPUBFixedLayoutFit = .page, + fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic + ) { + self.fontSize = fontSize + self.lineHeightMultiple = lineHeightMultiple + self.reflowableContentInsets = reflowableContentInsets + self.fixedContentInset = fixedContentInset + self.themeBackgroundColor = themeBackgroundColor + self.themeTextColor = themeTextColor + self.fixedBackgroundColor = fixedBackgroundColor + self.fixedLayoutFit = fixedLayoutFit + self.fixedLayoutSpreadMode = fixedLayoutSpreadMode + } + + public func presentationStyle(viewportSize: CGSize) -> RDEPUBPresentationStyle { + RDEPUBPresentationStyle( + viewportSize: viewportSize, + contentInsets: reflowableContentInsets, + fontSize: fontSize, + lineHeightMultiple: lineHeightMultiple, + themeBackgroundColor: themeBackgroundColor, + themeTextColor: themeTextColor + ) + } + + public func renderRequest( + for page: EPUBPage, + publication: RDEPUBPublication, + viewportSize: CGSize, + targetLocation: RDEPUBLocation? = nil, + highlights: [RDEPUBHighlight] = [], + searchPresentation: RDEPUBSearchPresentation? = nil + ) -> RDEPUBRenderRequest? { + if publication.layout == .fixed, let fixedSpread = page.fixedSpread { + return .fixed( + RDEPUBFixedRenderRequest( + spread: fixedSpread, + viewportSize: viewportSize, + contentInset: fixedContentInset, + backgroundColorCSS: fixedBackgroundColor, + fit: fixedLayoutFit, + searchPresentation: searchPresentation + ) + ) + } + + guard publication.spine.indices.contains(page.spineIndex) else { + return nil + } + + return .reflowable( + RDEPUBReflowableRenderRequest( + spineIndex: page.spineIndex, + href: publication.spine[page.spineIndex].href, + pageIndex: page.pageIndexInChapter, + totalPagesInChapter: page.totalPagesInChapter, + presentation: presentationStyle(viewportSize: viewportSize), + targetLocation: targetLocation, + highlights: highlights, + searchPresentation: searchPresentation + ) + ) + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBPublication.swift b/Sources/RDReaderView/EPUBCore/RDEPUBPublication.swift new file mode 100644 index 0000000..30903ff --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBPublication.swift @@ -0,0 +1,68 @@ +import Foundation + +public final class RDEPUBPublication { + public let parser: RDEPUBParser + public let resourceResolver: RDEPUBResourceResolver + + public init(parser: RDEPUBParser) { + self.parser = parser + self.resourceResolver = RDEPUBResourceResolver(parser: parser) + } + + public var metadata: RDEPUBMetadata { + parser.metadata + } + + public var manifest: [String: RDEPUBManifestItem] { + parser.manifest + } + + public var spine: [RDEPUBSpineItem] { + parser.spine + } + + public var tableOfContents: [EPUBTableOfContentsItem] { + parser.tableOfContents + } + + public var layout: RDEPUBLayout { + metadata.layout + } + + public var readingProfile: RDEPUBReadingProfile { + parser.readingProfile() + } + + public var readingProgression: RDEPUBReadingProgression { + metadata.readingProgression + } + + public var bookIdentifier: String? { + metadata.identifier + } + + public func fixedLayoutSpreadEnabled(for preferences: RDEPUBPreferences, viewportSize: CGSize) -> Bool { + guard layout == .fixed else { + return false + } + guard metadata.spread?.lowercased() != "none" else { + return false + } + + switch preferences.fixedLayoutSpreadMode { + case .never: + return false + case .always: + return spine.count > 1 + case .automatic: + return viewportSize.width > viewportSize.height + } + } + + public func makeFixedSpreads(preferences: RDEPUBPreferences, viewportSize: CGSize) -> [EPUBFixedSpread] { + EPUBFixedSpread.makeSpreads( + spine: spine, + spreadEnabled: fixedLayoutSpreadEnabled(for: preferences, viewportSize: viewportSize) + ) + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBReadingModels.swift b/Sources/RDReaderView/EPUBCore/RDEPUBReadingModels.swift new file mode 100644 index 0000000..0faa2cd --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBReadingModels.swift @@ -0,0 +1,411 @@ +import Foundation + +public struct RDEPUBLocation: Codable, Equatable { + public var bookIdentifier: String? + public var href: String + public var progression: Double + public var lastProgression: Double? + public var fragment: String? + + public init( + bookIdentifier: String? = nil, + href: String, + progression: Double, + lastProgression: Double? = nil, + fragment: String? = nil + ) { + self.bookIdentifier = bookIdentifier + self.href = href + self.progression = Self.clamp(progression) + self.lastProgression = lastProgression.map(Self.clamp) + self.fragment = fragment?.nilIfEmpty + } + + public var navigationProgression: Double { + let end = lastProgression ?? progression + if end.isNaN || end.isInfinite { + return progression + } + return Self.clamp((progression + end) / 2.0) + } + + private static func clamp(_ value: Double) -> Double { + guard value.isFinite else { return 0 } + return min(1, max(0, value)) + } +} + +public struct RDEPUBViewportResource: Codable, Equatable { + public var href: String + public var spineIndex: Int + public var progression: Double + public var lastProgression: Double? + public var fragment: String? + + public init( + href: String, + spineIndex: Int, + progression: Double, + lastProgression: Double? = nil, + fragment: String? = nil + ) { + self.href = href + self.spineIndex = spineIndex + self.progression = progression + self.lastProgression = lastProgression + self.fragment = fragment + } +} + +public struct RDEPUBViewport: Codable, Equatable { + public var resources: [RDEPUBViewportResource] + public var visiblePageNumber: Int + public var chapterIndex: Int? + public var isFixedLayout: Bool + + public init( + resources: [RDEPUBViewportResource], + visiblePageNumber: Int, + chapterIndex: Int? = nil, + isFixedLayout: Bool + ) { + self.resources = resources + self.visiblePageNumber = visiblePageNumber + self.chapterIndex = chapterIndex + self.isFixedLayout = isFixedLayout + } +} + +public struct RDEPUBReadingContext: Codable, Equatable { + public var location: RDEPUBLocation + public var viewport: RDEPUBViewport + public var pageNumber: Int + public var chapterIndex: Int? + + public init( + location: RDEPUBLocation, + viewport: RDEPUBViewport, + pageNumber: Int, + chapterIndex: Int? = nil + ) { + self.location = location + self.viewport = viewport + self.pageNumber = pageNumber + self.chapterIndex = chapterIndex + } +} + +public struct RDEPUBSelection: Codable, Equatable { + public var bookIdentifier: String? + public var location: RDEPUBLocation + public var text: String + public var rangeInfo: String? + public var createdAt: Date + + public init( + bookIdentifier: String? = nil, + location: RDEPUBLocation, + text: String, + rangeInfo: String? = nil, + createdAt: Date = Date() + ) { + self.bookIdentifier = bookIdentifier + self.location = location + self.text = text + self.rangeInfo = rangeInfo?.nilIfEmpty + self.createdAt = createdAt + } + + public var isEmpty: Bool { + text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || rangeInfo?.isEmpty != false + } +} + +public enum RDEPUBHighlightStyle: String, Codable { + case highlight + case underline +} + +public enum RDEPUBAnnotationMenuAction: Equatable { + case copy + case highlight + case annotate +} + +public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable { + public var kind: String + public var href: String + public var start: Int + public var end: Int + + public init(href: String, start: Int, end: Int) { + self.kind = "text-offset" + self.href = href + self.start = start + self.end = end + } + + public var nsRange: NSRange? { + guard end > start else { return nil } + return NSRange(location: start, length: end - start) + } + + public func jsonString() -> String? { + guard let data = try? JSONEncoder().encode(self) else { return nil } + return String(data: data, encoding: .utf8) + } + + public static func decode(from string: String?) -> RDEPUBTextOffsetRangeInfo? { + guard let string, + let data = string.data(using: .utf8), + let rangeInfo = try? JSONDecoder().decode(RDEPUBTextOffsetRangeInfo.self, from: data), + rangeInfo.kind == "text-offset", + rangeInfo.end > rangeInfo.start else { + return nil + } + return rangeInfo + } +} + +public struct RDEPUBHighlight: Codable, Equatable { + public var id: String + public var bookIdentifier: String? + public var location: RDEPUBLocation + public var text: String + public var rangeInfo: String? + public var style: RDEPUBHighlightStyle + public var color: String + public var note: String? + public var createdAt: Date + + public init( + id: String = UUID().uuidString, + bookIdentifier: String? = nil, + location: RDEPUBLocation, + text: String, + rangeInfo: String? = nil, + style: RDEPUBHighlightStyle = .highlight, + color: String = "#F8E16C", + note: String? = nil, + createdAt: Date = Date() + ) { + self.id = id + self.bookIdentifier = bookIdentifier + self.location = location + self.text = text + self.rangeInfo = rangeInfo?.nilIfEmpty + self.style = style + self.color = color + self.note = note?.nilIfEmpty + self.createdAt = createdAt + } + + public var hasNote: Bool { + note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + } + + private enum CodingKeys: String, CodingKey { + case id + case bookIdentifier + case location + case text + case rangeInfo + case style + case color + case note + case createdAt + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + bookIdentifier = try container.decodeIfPresent(String.self, forKey: .bookIdentifier) + location = try container.decode(RDEPUBLocation.self, forKey: .location) + text = try container.decode(String.self, forKey: .text) + rangeInfo = try container.decodeIfPresent(String.self, forKey: .rangeInfo)?.nilIfEmpty + if let rawStyle = try container.decodeIfPresent(String.self, forKey: .style), + let decodedStyle = RDEPUBHighlightStyle(rawValue: rawStyle) { + style = decodedStyle + } else { + style = .highlight + } + color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C" + note = try container.decodeIfPresent(String.self, forKey: .note)?.nilIfEmpty + createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encodeIfPresent(bookIdentifier, forKey: .bookIdentifier) + try container.encode(location, forKey: .location) + try container.encode(text, forKey: .text) + try container.encodeIfPresent(rangeInfo, forKey: .rangeInfo) + try container.encode(style, forKey: .style) + try container.encode(color, forKey: .color) + try container.encodeIfPresent(note, forKey: .note) + try container.encode(createdAt, forKey: .createdAt) + } +} + +public struct RDEPUBBookmark: Codable, Equatable { + public var id: String + public var bookIdentifier: String? + public var location: RDEPUBLocation + public var chapterTitle: String? + public var note: String? + public var createdAt: Date + + public init( + id: String = UUID().uuidString, + bookIdentifier: String? = nil, + location: RDEPUBLocation, + chapterTitle: String? = nil, + note: String? = nil, + createdAt: Date = Date() + ) { + self.id = id + self.bookIdentifier = bookIdentifier + self.location = location + self.chapterTitle = chapterTitle?.nilIfEmpty + self.note = note?.nilIfEmpty + self.createdAt = createdAt + } + + public var hasNote: Bool { + note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + } +} + +public struct EPUBChapterInfo: Codable, Equatable { + public var spineIndex: Int + public var title: String + public var pageCount: Int + + public init(spineIndex: Int, title: String, pageCount: Int) { + self.spineIndex = spineIndex + self.title = title + self.pageCount = pageCount + } +} + +public struct EPUBPage: Codable, Equatable { + public var spineIndex: Int + public var chapterIndex: Int + public var pageIndexInChapter: Int + public var totalPagesInChapter: Int + public var chapterTitle: String + public var fixedSpread: EPUBFixedSpread? + + public init( + spineIndex: Int, + chapterIndex: Int, + pageIndexInChapter: Int, + totalPagesInChapter: Int, + chapterTitle: String, + fixedSpread: EPUBFixedSpread? + ) { + self.spineIndex = spineIndex + self.chapterIndex = chapterIndex + self.pageIndexInChapter = pageIndexInChapter + self.totalPagesInChapter = totalPagesInChapter + self.chapterTitle = chapterTitle + self.fixedSpread = fixedSpread + } +} + +public struct EPUBFixedSpreadResource: Codable, Equatable { + public var spineIndex: Int + public var href: String + public var title: String + public var pageSpread: RDEPUBPageSpread? + + public init(spineIndex: Int, href: String, title: String, pageSpread: RDEPUBPageSpread? = nil) { + self.spineIndex = spineIndex + self.href = href + self.title = title + self.pageSpread = pageSpread + } +} + +public struct EPUBFixedSpread: Codable, Equatable { + public var resources: [EPUBFixedSpreadResource] + + public init(resources: [EPUBFixedSpreadResource]) { + self.resources = resources + } + + public var primaryResource: EPUBFixedSpreadResource { + resources.first ?? EPUBFixedSpreadResource(spineIndex: 0, href: "", title: "") + } + + public func contains(normalizedHref: String, normalizer: (String) -> String?) -> Bool { + resources.contains { resource in + normalizer(resource.href) == normalizedHref + } + } + + public static func makeSpreads(spine: [RDEPUBSpineItem], spreadEnabled: Bool) -> [EPUBFixedSpread] { + let resources = spine.enumerated().map { index, item in + EPUBFixedSpreadResource( + spineIndex: index, + href: item.href, + title: item.title, + pageSpread: item.pageSpread + ) + } + + guard spreadEnabled, resources.count > 1 else { + return resources.map { EPUBFixedSpread(resources: [$0]) } + } + + var spreads: [EPUBFixedSpread] = [] + var cursor = 0 + + while cursor < resources.count { + let current = resources[cursor] + + guard cursor + 1 < resources.count else { + spreads.append(EPUBFixedSpread(resources: [current])) + break + } + + let next = resources[cursor + 1] + if shouldPair(current: current, next: next) { + spreads.append(EPUBFixedSpread(resources: [current, next])) + cursor += 2 + } else { + spreads.append(EPUBFixedSpread(resources: [current])) + cursor += 1 + } + } + + return spreads + } + + public static func makeSpreads(parser: RDEPUBParser, spreadEnabled: Bool) -> [EPUBFixedSpread] { + makeSpreads(spine: parser.spine, spreadEnabled: spreadEnabled) + } + + private static func shouldPair(current: EPUBFixedSpreadResource, next: EPUBFixedSpreadResource) -> Bool { + if current.pageSpread == .center || next.pageSpread == .center { + return false + } + + switch (current.pageSpread, next.pageSpread) { + case (.left, .right), (.right, .left): + return true + case (.left, .left), (.right, .right): + return false + default: + return true + } + } +} + +private extension String { + var nilIfEmpty: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBReadingSession.swift b/Sources/RDReaderView/EPUBCore/RDEPUBReadingSession.swift new file mode 100644 index 0000000..11700ae --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBReadingSession.swift @@ -0,0 +1,326 @@ +import Foundation + +public final class RDEPUBReadingSession { + public typealias PaginationSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo]) + + public let publication: RDEPUBPublication + + public private(set) var navigatorState: RDEPUBNavigatorState = .initializing + public private(set) var activePages: [EPUBPage] = [] + public private(set) var activeChapters: [EPUBChapterInfo] = [] + public private(set) var stagedPages: [EPUBPage]? + public private(set) var stagedChapters: [EPUBChapterInfo]? + public private(set) var stagedRestoreLocation: RDEPUBLocation? + public private(set) var pendingNavigationLocation: RDEPUBLocation? + public private(set) var pendingNavigationPageNum: Int? + public private(set) var currentViewport: RDEPUBViewport? + public private(set) var currentReadingContext: RDEPUBReadingContext? + + public init(publication: RDEPUBPublication) { + self.publication = publication + } + + public var resourceResolver: RDEPUBResourceResolver { + publication.resourceResolver + } + + public func transition(to state: RDEPUBNavigatorState) { + navigatorState = state + } + + public func resetRuntimeState() { + navigatorState = .initializing + activePages = [] + activeChapters = [] + clearPendingNavigation() + clearStagedSnapshot() + currentViewport = nil + currentReadingContext = nil + } + + public func setActiveSnapshot(_ snapshot: PaginationSnapshot) { + activePages = snapshot.pages + activeChapters = snapshot.chapters + } + + public func stageSnapshot(_ snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?) { + stagedPages = snapshot.pages + stagedChapters = snapshot.chapters + stagedRestoreLocation = restoreLocation + } + + public func stagedSnapshot() -> PaginationSnapshot? { + guard let stagedPages, let stagedChapters else { + return nil + } + return (stagedPages, stagedChapters) + } + + public func consumeStagedSnapshotIfAllowed() -> (snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?)? { + guard navigatorState.isStableForSnapshotApplication, + let stagedPages, + let stagedChapters else { + return nil + } + let restoreLocation = stagedRestoreLocation + clearStagedSnapshot() + return ((stagedPages, stagedChapters), restoreLocation) + } + + public func clearStagedSnapshot() { + stagedPages = nil + stagedChapters = nil + stagedRestoreLocation = nil + } + + public func clearPendingNavigation() { + pendingNavigationLocation = nil + pendingNavigationPageNum = nil + } + + public func pendingLocation(forPageNumber pageNumber: Int, spineIndex: Int?) -> RDEPUBLocation? { + guard pendingNavigationPageNum == pageNumber, + let pendingNavigationLocation else { + return nil + } + guard let spineIndex else { + return pendingNavigationLocation + } + let pageHref = resourceResolver.href(forSpineIndex: spineIndex) + guard resourceResolver.normalizedHref(pageHref ?? "") == resourceResolver.normalizedHref(pendingNavigationLocation.href) else { + return nil + } + return pendingNavigationLocation + } + + public func pageContains(spineIndex: Int, in page: EPUBPage) -> Bool { + if let fixedSpread = page.fixedSpread { + return fixedSpread.resources.contains(where: { $0.spineIndex == spineIndex }) + } + return page.spineIndex == spineIndex + } + + public func fallbackLocation(for page: EPUBPage, bookIdentifier: String?) -> RDEPUBLocation? { + if let fixedSpread = page.fixedSpread { + return RDEPUBLocation( + bookIdentifier: bookIdentifier, + href: fixedSpread.primaryResource.href, + progression: 0, + lastProgression: 1 + ) + } + + guard let href = resourceResolver.href(forSpineIndex: page.spineIndex) else { + return nil + } + + let progression: Double + if page.totalPagesInChapter <= 1 { + progression = 0 + } else { + progression = Double(page.pageIndexInChapter) / Double(page.totalPagesInChapter - 1) + } + + return RDEPUBLocation( + bookIdentifier: bookIdentifier, + href: href, + progression: progression, + lastProgression: progression + ) + } + + public func currentVisibleLocation(currentPageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? { + guard currentPageNumber > 0, + activePages.indices.contains(currentPageNumber - 1) else { + return nil + } + return fallbackLocation(for: activePages[currentPageNumber - 1], bookIdentifier: bookIdentifier) + } + + public func initialSpineIndex(for location: RDEPUBLocation?) -> Int { + guard let location, + let normalizedHref = resourceResolver.normalizedHref(location.href), + let spineIndex = publication.spine.firstIndex(where: { resourceResolver.normalizedHref($0.href) == normalizedHref }) else { + return 0 + } + return spineIndex + } + + public func pageIndex(for location: RDEPUBLocation, bookIdentifier: String?) -> Int? { + guard location.bookIdentifier == nil || location.bookIdentifier == bookIdentifier else { + return nil + } + guard let targetHref = resourceResolver.normalizedHref(location.href) else { + return nil + } + + if publication.layout == .fixed { + return activePages.firstIndex(where: { page in + if let fixedSpread = page.fixedSpread { + return fixedSpread.contains(normalizedHref: targetHref, normalizer: { self.resourceResolver.normalizedHref($0) }) + } + guard let href = self.resourceResolver.href(forSpineIndex: page.spineIndex) else { + return false + } + return self.resourceResolver.normalizedHref(href) == targetHref + }) + } + + guard let spineIndex = publication.spine.firstIndex(where: { resourceResolver.normalizedHref($0.href) == targetHref }) else { + return nil + } + + let candidates = activePages.enumerated().filter { $0.element.spineIndex == spineIndex } + guard !candidates.isEmpty else { + return nil + } + if candidates.count == 1 { + return candidates[0].offset + } + + let navigationProgression = location.navigationProgression + let localIndex = min( + candidates.count - 1, + max(0, Int(round(navigationProgression * Double(candidates.count - 1)))) + ) + return candidates[localIndex].offset + } + + public func queueNavigation( + to location: RDEPUBLocation, + relativeToSpineIndex spineIndex: Int? = nil, + bookIdentifier: String? + ) -> Int? { + guard let normalizedLocation = resourceResolver.normalizedLocation( + location, + relativeToSpineIndex: spineIndex, + bookIdentifier: bookIdentifier + ), let pageIndex = pageIndex(for: normalizedLocation, bookIdentifier: bookIdentifier) else { + return nil + } + + pendingNavigationLocation = normalizedLocation.fragment == nil ? nil : normalizedLocation + pendingNavigationPageNum = normalizedLocation.fragment == nil ? nil : pageIndex + 1 + transition(to: .jumping) + return pageIndex + 1 + } + + public func updateReadingContext( + pageNumber: Int, + location: RDEPUBLocation, + spineIndex: Int, + chapterIndex: Int?, + bookIdentifier: String? + ) { + let normalizedLocation = resourceResolver.normalizedLocation( + location, + relativeToSpineIndex: spineIndex, + bookIdentifier: bookIdentifier + ) ?? RDEPUBLocation( + bookIdentifier: bookIdentifier, + href: location.href, + progression: location.progression, + lastProgression: location.lastProgression, + fragment: location.fragment + ) + + let resource = RDEPUBViewportResource( + href: normalizedLocation.href, + spineIndex: spineIndex, + progression: normalizedLocation.progression, + lastProgression: normalizedLocation.lastProgression, + fragment: normalizedLocation.fragment + ) + let viewport = RDEPUBViewport( + resources: [resource], + visiblePageNumber: pageNumber, + chapterIndex: chapterIndex, + isFixedLayout: publication.layout == .fixed + ) + currentViewport = viewport + currentReadingContext = RDEPUBReadingContext( + location: normalizedLocation, + viewport: viewport, + pageNumber: pageNumber, + chapterIndex: chapterIndex + ) + + if pendingNavigationPageNum == pageNumber { + clearPendingNavigation() + } + if navigatorState == .jumping || navigatorState == .moving { + transition(to: .idle) + } + } + + public func currentReadingLocation(bookIdentifier: String?) -> RDEPUBLocation? { + currentReadingContext?.location ?? currentVisibleLocation(currentPageNumber: currentViewport?.visiblePageNumber ?? 0, bookIdentifier: bookIdentifier) + } + + public func makePaginationSnapshot( + pageCounts: [Int], + preferences: RDEPUBPreferences, + layoutContext: RDEPUBNavigatorLayoutContext + ) -> PaginationSnapshot { + if publication.layout == .fixed { + let spreads = publication.makeFixedSpreads( + preferences: preferences, + viewportSize: layoutContext.viewportSize + ) + + let chapters = spreads.enumerated().map { spreadIndex, spread in + EPUBChapterInfo( + spineIndex: spread.primaryResource.spineIndex, + title: spread.primaryResource.title, + pageCount: 1 + ) + } + + let pages = spreads.enumerated().map { spreadIndex, spread in + EPUBPage( + spineIndex: spread.primaryResource.spineIndex, + chapterIndex: spreadIndex, + pageIndexInChapter: 0, + totalPagesInChapter: 1, + chapterTitle: spread.primaryResource.title, + fixedSpread: spread + ) + } + + return (pages, chapters) + } + + var pages: [EPUBPage] = [] + var chapters: [EPUBChapterInfo] = [] + + for (spineIndex, pageCount) in pageCounts.enumerated() { + guard pageCount > 0, spineIndex < publication.spine.count else { continue } + + let chapterIndex = chapters.count + let title = publication.spine[spineIndex].title + + chapters.append( + EPUBChapterInfo( + spineIndex: spineIndex, + title: title, + pageCount: pageCount + ) + ) + + for pageIndex in 0.. URL? { + parser.fileURL(forRelativePath: relativePath) + } + + public func resourceURL(forRelativePath relativePath: String) -> URL? { + parser.resourceURL(forRelativePath: relativePath) + } + + public func fileURL(forResourceURL resourceURL: URL) -> URL? { + parser.fileURL(forResourceURL: resourceURL) + } + + public func normalizedHref(_ href: String, relativeToSpineIndex spineIndex: Int? = nil) -> String? { + guard let opfDirectoryURL else { + return href.components(separatedBy: "#").first + } + + let pathPart = href.components(separatedBy: "#").first ?? href + if pathPart.isEmpty { + guard let spineIndex, parser.spine.indices.contains(spineIndex) else { + return nil + } + return parser.spine[spineIndex].href.components(separatedBy: "#").first + } + + let baseURL: URL + if let spineIndex, parser.spine.indices.contains(spineIndex) { + baseURL = opfDirectoryURL + .appendingPathComponent(parser.spine[spineIndex].href) + .deletingLastPathComponent() + } else { + baseURL = opfDirectoryURL + } + + guard let resolvedURL = URL(string: pathPart, relativeTo: baseURL)?.standardizedFileURL else { + return pathPart + } + + let opfPath = opfDirectoryURL.standardizedFileURL.path + "/" + let resolvedPath = resolvedURL.path + if resolvedPath.hasPrefix(opfPath) { + return String(resolvedPath.dropFirst(opfPath.count)) + } + return pathPart + } + + public func normalizedLocation( + _ location: RDEPUBLocation, + relativeToSpineIndex spineIndex: Int? = nil, + bookIdentifier: String? + ) -> RDEPUBLocation? { + let rawHref = location.href + let hrefParts = rawHref.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false) + let pathPart = hrefParts.first.map(String.init) ?? rawHref + let fragment = location.fragment ?? (hrefParts.count > 1 ? String(hrefParts[1]) : nil) + + let normalizedTargetHref: String + if pathPart.isEmpty { + guard let spineIndex, parser.spine.indices.contains(spineIndex) else { + return nil + } + normalizedTargetHref = parser.spine[spineIndex].href.components(separatedBy: "#").first ?? parser.spine[spineIndex].href + } else { + guard let href = normalizedHref(pathPart, relativeToSpineIndex: spineIndex) else { + return nil + } + normalizedTargetHref = href + } + + return RDEPUBLocation( + bookIdentifier: bookIdentifier, + href: normalizedTargetHref, + progression: location.progression, + lastProgression: location.lastProgression, + fragment: fragment + ) + } + + public func spineIndex(forNormalizedHref normalizedHref: String) -> Int? { + parser.spine.firstIndex { item in + self.normalizedHref(item.href) == normalizedHref + } + } + + public func spineIndex(for location: RDEPUBLocation) -> Int? { + guard let normalizedHref = normalizedHref(location.href) else { + return nil + } + return spineIndex(forNormalizedHref: normalizedHref) + } + + public func href(forSpineIndex spineIndex: Int) -> String? { + guard parser.spine.indices.contains(spineIndex) else { + return nil + } + return parser.spine[spineIndex].href + } + + public func title(forSpineIndex spineIndex: Int) -> String? { + guard parser.spine.indices.contains(spineIndex) else { + return nil + } + return parser.spine[spineIndex].title + } + + public func manifestItem(forHref href: String) -> RDEPUBManifestItem? { + let targetHref = normalizedHref(href) ?? href.components(separatedBy: "#").first ?? href + return parser.manifest.values.first { + normalizedHref($0.href) == targetHref + } + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift b/Sources/RDReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift new file mode 100644 index 0000000..ea8a4ce --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift @@ -0,0 +1,148 @@ +import Foundation +import WebKit + +public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler { + public static let scheme = "ss-reader" + public static let host = "book" + + private weak var parser: RDEPUBParser? + private let fileManager = FileManager.default + private let syncQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler") + private var activeTasks: [ObjectIdentifier: Bool] = [:] + + public init(parser: RDEPUBParser) { + self.parser = parser + super.init() + } + + public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) { + let taskID = ObjectIdentifier(urlSchemeTask as AnyObject) + syncQueue.sync { + activeTasks[taskID] = true + } + + let requestURL = urlSchemeTask.request.url + RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "start") + + guard let parser, + let requestURL, + let fileURL = parser.fileURL(forResourceURL: requestURL), + fileManager.fileExists(atPath: fileURL.path) else { + if let requestURL, Self.isOptionalResource(requestURL) { + RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "missing-optional-resource") + let response = URLResponse( + url: requestURL, + mimeType: Self.mimeType(for: requestURL.pathExtension), + expectedContentLength: 0, + textEncodingName: Self.textEncodingName(for: requestURL.pathExtension) + ) + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(Data()) + urlSchemeTask.didFinish() + } else { + RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "missing-file") + urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist)) + } + clearTask(taskID) + return + } + + RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "resolved") + + do { + let data = try Data(contentsOf: fileURL) + guard isTaskActive(taskID) else { return } + + let response = URLResponse( + url: requestURL, + mimeType: Self.mimeType(for: fileURL.pathExtension), + expectedContentLength: data.count, + textEncodingName: Self.textEncodingName(for: fileURL.pathExtension) + ) + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(data) + urlSchemeTask.didFinish() + RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished") + } catch { + guard isTaskActive(taskID) else { return } + RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "failed", error: error) + urlSchemeTask.didFailWithError(error) + } + + clearTask(taskID) + } + + public func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) { + RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: urlSchemeTask.request.url, event: "stop") + clearTask(ObjectIdentifier(urlSchemeTask as AnyObject)) + } + + private func clearTask(_ id: ObjectIdentifier) { + syncQueue.sync { + activeTasks[id] = nil + } + } + + private func isTaskActive(_ id: ObjectIdentifier) -> Bool { + syncQueue.sync { + activeTasks[id] == true + } + } + + private static func mimeType(for pathExtension: String) -> String { + switch pathExtension.lowercased() { + case "html", "htm": + return "text/html" + case "xhtml": + return "application/xhtml+xml" + case "css": + return "text/css" + case "js": + return "application/javascript" + case "xml", "opf", "ncx": + return "application/xml" + case "svg": + return "image/svg+xml" + case "jpg", "jpeg": + return "image/jpeg" + case "png": + return "image/png" + case "gif": + return "image/gif" + case "webp": + return "image/webp" + case "ttf": + return "font/ttf" + case "otf": + return "font/otf" + case "woff": + return "font/woff" + case "woff2": + return "font/woff2" + case "mp3": + return "audio/mpeg" + case "mp4": + return "video/mp4" + default: + return "application/octet-stream" + } + } + + private static func textEncodingName(for pathExtension: String) -> String? { + switch pathExtension.lowercased() { + case "html", "htm", "xhtml", "css", "js", "xml", "opf", "ncx", "txt": + return "utf-8" + default: + return nil + } + } + + private static func isOptionalResource(_ url: URL) -> Bool { + switch url.pathExtension.lowercased() { + case "ttf", "otf", "woff", "woff2", "css", "js", "jpg", "jpeg", "png", "gif", "webp", "svg": + return true + default: + return false + } + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBSearchEngine.swift b/Sources/RDReaderView/EPUBCore/RDEPUBSearchEngine.swift new file mode 100644 index 0000000..462ac5c --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBSearchEngine.swift @@ -0,0 +1,116 @@ +import Foundation +import UIKit + +protocol RDEPUBSearchEngine { + func search(keyword: String) -> [RDEPUBSearchMatch] +} + +final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine { + private let parser: RDEPUBParser + private let publication: RDEPUBPublication + + init(parser: RDEPUBParser, publication: RDEPUBPublication) { + self.parser = parser + self.publication = publication + } + + func search(keyword: String) -> [RDEPUBSearchMatch] { + let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedKeyword.isEmpty else { + return [] + } + + var searchMatches: [RDEPUBSearchMatch] = [] + for item in publication.spine where item.linear { + guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"), + let html = parser.htmlString(forRelativePath: item.href) else { + continue + } + + let plainText = plainText(fromHTML: html, baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent()) + let normalizedHref = publication.resourceResolver.normalizedHref(item.href) ?? item.href + searchMatches.append(contentsOf: matches(in: plainText, href: normalizedHref, keyword: normalizedKeyword)) + } + + return searchMatches + } + + private func plainText(fromHTML html: String, baseURL: URL?) -> String { + guard let data = html.data(using: .utf8) else { + return fallbackPlainText(fromHTML: html) + } + + var options: [NSAttributedString.DocumentReadingOptionKey: Any] = [ + .documentType: NSAttributedString.DocumentType.html, + .characterEncoding: String.Encoding.utf8.rawValue + ] + if let baseURL { + options[NSAttributedString.DocumentReadingOptionKey(rawValue: "NSBaseURLDocumentOption")] = baseURL + } + + if let attributed = try? NSAttributedString(data: data, options: options, documentAttributes: nil) { + return attributed.string + } + return fallbackPlainText(fromHTML: html) + } + + private func fallbackPlainText(fromHTML html: String) -> String { + let stripped = html.replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression) + return stripped + .replacingOccurrences(of: " ", with: " ") + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "'", with: "'") + .replacingOccurrences(of: """, with: "\"") + } + + private func matches(in text: String, href: String, keyword: String) -> [RDEPUBSearchMatch] { + let source = text as NSString + let fullLength = source.length + guard fullLength > 0 else { + return [] + } + + var results: [RDEPUBSearchMatch] = [] + var localMatchIndex = 0 + var searchRange = NSRange(location: 0, length: fullLength) + + while searchRange.length > 0 { + let foundRange = source.range(of: keyword, options: [.caseInsensitive], range: searchRange) + guard foundRange.location != NSNotFound else { + break + } + + let progressionDenominator = max(fullLength - 1, 1) + let progression = Double(foundRange.location) / Double(progressionDenominator) + results.append( + RDEPUBSearchMatch( + href: href, + progression: progression, + previewText: previewText(in: source, matchRange: foundRange), + localMatchIndex: localMatchIndex, + rangeLocation: foundRange.location, + rangeLength: foundRange.length + ) + ) + + localMatchIndex += 1 + let nextLocation = foundRange.location + max(foundRange.length, 1) + if nextLocation >= fullLength { + break + } + searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation) + } + + return results + } + + private func previewText(in text: NSString, matchRange: NSRange) -> String { + let previewRadius = 12 + let start = max(matchRange.location - previewRadius, 0) + let end = min(matchRange.location + matchRange.length + previewRadius, text.length) + let range = NSRange(location: start, length: max(end - start, 0)) + return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBSearchModels.swift b/Sources/RDReaderView/EPUBCore/RDEPUBSearchModels.swift new file mode 100644 index 0000000..d6937aa --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBSearchModels.swift @@ -0,0 +1,96 @@ +import Foundation + +public struct RDEPUBSearchMatch: Codable, Equatable { + public var href: String + public var progression: Double + public var previewText: String + public var localMatchIndex: Int + public var rangeLocation: Int? + public var rangeLength: Int + + public init( + href: String, + progression: Double, + previewText: String, + localMatchIndex: Int, + rangeLocation: Int? = nil, + rangeLength: Int + ) { + self.href = href + self.progression = progression + self.previewText = previewText + self.localMatchIndex = localMatchIndex + self.rangeLocation = rangeLocation + self.rangeLength = rangeLength + } +} + +public struct RDEPUBSearchResult: Codable, Equatable { + public var keyword: String + public var totalMatchCount: Int + public var currentMatchIndex: Int? + public var currentMatch: RDEPUBSearchMatch? + + public init( + keyword: String, + totalMatchCount: Int, + currentMatchIndex: Int?, + currentMatch: RDEPUBSearchMatch? + ) { + self.keyword = keyword + self.totalMatchCount = totalMatchCount + self.currentMatchIndex = currentMatchIndex + self.currentMatch = currentMatch + } +} + +public struct RDEPUBSearchState: Codable, Equatable { + public var keyword: String + public var matches: [RDEPUBSearchMatch] + public var currentMatchIndex: Int? + + public init(keyword: String, matches: [RDEPUBSearchMatch], currentMatchIndex: Int? = nil) { + self.keyword = keyword + self.matches = matches + self.currentMatchIndex = currentMatchIndex + } + + public var currentMatch: RDEPUBSearchMatch? { + guard let currentMatchIndex, + matches.indices.contains(currentMatchIndex) else { + return nil + } + return matches[currentMatchIndex] + } + + public var result: RDEPUBSearchResult { + RDEPUBSearchResult( + keyword: keyword, + totalMatchCount: matches.count, + currentMatchIndex: currentMatchIndex.map { $0 + 1 }, + currentMatch: currentMatch + ) + } +} + +public struct RDEPUBSearchPresentationResource: Codable, Equatable { + public var href: String + public var matchCount: Int + public var activeLocalMatchIndex: Int? + + public init(href: String, matchCount: Int, activeLocalMatchIndex: Int? = nil) { + self.href = href + self.matchCount = matchCount + self.activeLocalMatchIndex = activeLocalMatchIndex + } +} + +public struct RDEPUBSearchPresentation: Codable, Equatable { + public var keyword: String + public var resources: [RDEPUBSearchPresentationResource] + + public init(keyword: String, resources: [RDEPUBSearchPresentationResource]) { + self.keyword = keyword + self.resources = resources + } +} diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBStyleSheetBuilder.swift b/Sources/RDReaderView/EPUBCore/RDEPUBStyleSheetBuilder.swift new file mode 100644 index 0000000..f2b8c77 --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBStyleSheetBuilder.swift @@ -0,0 +1,204 @@ +import Foundation + +public enum RDEPUBStyleSheetBuilder { + public static func injectPaginationCSS( + into html: String, + presentation: RDEPUBPresentationStyle + ) -> String { + let styleTag = "" + if html.range(of: "", options: .caseInsensitive) != nil { + return html.replacingOccurrences(of: "", with: styleTag + "", options: .caseInsensitive) + } + if html.range(of: " String { + let values = cssValues(for: presentation) + let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? "" + let textCSS = presentation.themeTextColor.map { "color: \($0) !important;" } ?? "" + + return """ + html { + margin: 0 !important; + padding: 0 !important; + width: \(values.viewportWidth)px !important; + height: \(values.viewportHeight)px !important; + min-height: \(values.viewportHeight)px !important; + overflow-x: hidden !important; + overflow-y: hidden !important; + } + body { + margin: 0 !important; + padding: 0 !important; + box-sizing: border-box !important; + position: relative !important; + width: \(values.viewportWidth)px !important; + height: \(values.viewportHeight)px !important; + min-height: \(values.viewportHeight)px !important; + overflow: hidden !important; + background: transparent !important; + } + #ss-reader-viewport { + position: relative !important; + width: \(values.viewportWidth)px !important; + height: \(values.viewportHeight)px !important; + min-height: \(values.viewportHeight)px !important; + overflow: hidden !important; + background: transparent !important; + } + #ss-reader-content { + position: relative !important; + box-sizing: border-box !important; + width: \(values.viewportWidth)px !important; + height: \(values.viewportHeight)px !important; + min-height: \(values.viewportHeight)px !important; + overflow: visible !important; + padding: \(values.paddingTop)px \(values.paddingRight)px \(values.paddingBottom)px \(values.paddingLeft)px !important; + font-size: \(values.fontSize)px !important; + line-height: \(values.lineHeight) !important; + -webkit-column-width: \(values.contentWidth)px !important; + column-width: \(values.contentWidth)px !important; + -webkit-column-gap: \(values.columnGap)px !important; + column-gap: \(values.columnGap)px !important; + -webkit-column-fill: auto !important; + column-fill: auto !important; + transform-origin: top left !important; + will-change: transform !important; + \(backgroundCSS) + \(textCSS) + } + #ss-reader-content img, #ss-reader-content svg, #ss-reader-content video, #ss-reader-content canvas, #ss-reader-content iframe { + max-width: 100% !important; + height: auto !important; + break-inside: avoid-column !important; + -webkit-column-break-inside: avoid !important; + } + #ss-reader-content mark.ss-reader-highlight { + padding: 0; + border-radius: 2px; + color: inherit !important; + } + #ss-reader-content mark.ss-reader-highlight-highlight { + text-decoration: none !important; + color: inherit !important; + } + #ss-reader-content mark.ss-reader-highlight-underline { + background: transparent !important; + color: inherit !important; + text-decoration-line: underline !important; + text-decoration-thickness: 2px !important; + text-decoration-skip-ink: auto !important; + } + """ + } + + public static func measurementCSS(for presentation: RDEPUBPresentationStyle) -> String { + let values = cssValues(for: presentation) + let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? "" + let textCSS = presentation.themeTextColor.map { "color: \($0) !important;" } ?? "" + + return """ + html { + margin: 0 !important; + padding: 0 !important; + width: \(values.viewportWidth)px !important; + height: \(values.viewportHeight)px !important; + min-height: \(values.viewportHeight)px !important; + overflow: hidden !important; + } + body { + margin: 0 !important; + box-sizing: border-box !important; + position: relative !important; + width: \(values.viewportWidth)px !important; + height: \(values.viewportHeight)px !important; + min-height: \(values.viewportHeight)px !important; + overflow: visible !important; + padding: \(values.paddingTop)px \(values.paddingRight)px \(values.paddingBottom)px \(values.paddingLeft)px !important; + font-size: \(values.fontSize)px !important; + line-height: \(values.lineHeight) !important; + -webkit-column-width: \(values.contentWidth)px !important; + column-width: \(values.contentWidth)px !important; + -webkit-column-gap: \(values.columnGap)px !important; + column-gap: \(values.columnGap)px !important; + -webkit-column-fill: auto !important; + column-fill: auto !important; + transform-origin: top left !important; + \(backgroundCSS) + \(textCSS) + } + img, svg, video, canvas, iframe { + max-width: 100% !important; + height: auto !important; + break-inside: avoid-column !important; + -webkit-column-break-inside: avoid !important; + } + """ + } + + public static func measurementScript(for presentation: RDEPUBPresentationStyle) -> String { + let style = measurementCSS(for: presentation) + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "`", with: "\\`") + + let viewportWidth = Double(max(1, presentation.viewportSize.width)) + let viewportWidthJS = String(format: "%.3f", viewportWidth) + + return """ + (function() { + var style = document.getElementById('ss-reader-pagination'); + if (!style) { + style = document.createElement('style'); + style.id = 'ss-reader-pagination'; + document.head.appendChild(style); + } + style.textContent = `\(style)`; + var scrollingElement = document.scrollingElement || document.documentElement || document.body; + var totalWidth = Math.max( + scrollingElement ? scrollingElement.scrollWidth : 0, + scrollingElement ? scrollingElement.offsetWidth : 0, + document.body ? document.body.scrollWidth : 0, + document.body ? document.body.offsetWidth : 0, + document.documentElement ? document.documentElement.scrollWidth : 0, + document.documentElement ? document.documentElement.offsetWidth : 0 + ); + return Math.max(1, Math.ceil(totalWidth / \(viewportWidthJS))); + })(); + """ + } + + private static func cssValues(for presentation: RDEPUBPresentationStyle) -> ( + viewportWidth: String, + viewportHeight: String, + contentWidth: String, + columnGap: String, + paddingTop: String, + paddingRight: String, + paddingBottom: String, + paddingLeft: String, + fontSize: String, + lineHeight: String + ) { + let viewportWidth = max(1, presentation.viewportSize.width) + let viewportHeight = max(1, presentation.viewportSize.height) + let contentInsets = presentation.contentInsets + let contentWidth = max(1, viewportWidth - contentInsets.left - contentInsets.right) + let columnGap = max(0, contentInsets.left + contentInsets.right) + + return ( + viewportWidth: String(format: "%.3f", viewportWidth), + viewportHeight: String(format: "%.3f", viewportHeight), + contentWidth: String(format: "%.3f", contentWidth), + columnGap: String(format: "%.3f", columnGap), + paddingTop: String(format: "%.3f", contentInsets.top), + paddingRight: String(format: "%.3f", contentInsets.right), + paddingBottom: String(format: "%.3f", contentInsets.bottom), + paddingLeft: String(format: "%.3f", contentInsets.left), + fontSize: String(format: "%.3f", presentation.fontSize), + lineHeight: String(format: "%.3f", max(1, presentation.lineHeightMultiple)) + ) + } +} diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBWebView+Configuration.swift b/Sources/RDReaderView/EPUBCore/RDEPUBWebView+Configuration.swift new file mode 100644 index 0000000..5763d6b --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBWebView+Configuration.swift @@ -0,0 +1,107 @@ +import UIKit +import WebKit + +extension RDEPUBWebView { + func configureWebViewIfNeeded(publication: RDEPUBPublication) { + let parser = publication.parser + let publicationKey = parser.opfURL?.path ?? parser.extractionRootURL?.path ?? UUID().uuidString + if publicationKey == configuredPublicationKey, webView != nil { + return + } + + teardownWebView() + + let userContentController = WKUserContentController() + RDEPUBJavaScriptBridge.messageNames.forEach { + userContentController.add(self, name: $0) + } + userContentController.addUserScript( + WKUserScript( + source: RDEPUBJavaScriptBridge.userScript, + injectionTime: .atDocumentEnd, + forMainFrameOnly: true + ) + ) + + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + configuration.userContentController = userContentController + let schemeHandler = RDEPUBResourceURLSchemeHandler(parser: parser) + configuration.setURLSchemeHandler(schemeHandler, forURLScheme: RDEPUBResourceURLSchemeHandler.scheme) + + let webView = RDEPUBAnnotationWebView(frame: bounds, configuration: configuration) + webView.onSelectionAction = { [weak self] action in + guard let self else { return } + self.handleSelectionMenuAction(action) + } + UIMenuController.shared.menuItems = [ + UIMenuItem(title: "拷贝", action: #selector(RDEPUBAnnotationWebView.rd_copy(_:))), + UIMenuItem(title: "高亮", action: #selector(RDEPUBAnnotationWebView.rd_highlight(_:))), + UIMenuItem(title: "批注", action: #selector(RDEPUBAnnotationWebView.rd_annotate(_:))) + ] + webView.navigationDelegate = self + webView.scrollView.isScrollEnabled = false + webView.scrollView.showsHorizontalScrollIndicator = false + webView.scrollView.showsVerticalScrollIndicator = false + webView.scrollView.bounces = false + webView.scrollView.alwaysBounceHorizontal = false + webView.scrollView.alwaysBounceVertical = false + webView.scrollView.clipsToBounds = true + webView.scrollView.panGestureRecognizer.isEnabled = false + webView.scrollView.pinchGestureRecognizer?.isEnabled = false + webView.isOpaque = false + webView.backgroundColor = .clear + webView.clipsToBounds = true + if #available(iOS 16.4, *) { + webView.isInspectable = true + } + + addSubview(webView) + webView.frame = bounds + webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + + self.schemeHandler = schemeHandler + self.webView = webView + self.configuredPublicationKey = publicationKey + RDEPUBWebViewDebug.log(debugScope, message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView)) publicationKey=\(publicationKey)") + } + + func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction) { + delegate?.epubWebView(self, didRequestSelectionAction: action) + clearWebSelection() + } + + func clearWebSelection() { + webView?.evaluateJavaScript("window.getSelection && window.getSelection().removeAllRanges();") + } + + func teardownWebView() { + cancelFixedLayoutReadyFallback() + if let webView { + RDEPUBWebViewDebug.log(debugScope, message: "teardown webView=\(RDEPUBWebViewDebug.webViewID(webView))") + } + webView?.navigationDelegate = nil + if let userContentController = webView?.configuration.userContentController { + RDEPUBJavaScriptBridge.messageNames.forEach { + userContentController.removeScriptMessageHandler(forName: $0) + } + } + (webView as? RDEPUBAnnotationWebView)?.onSelectionAction = nil + webView?.removeFromSuperview() + webView = nil + schemeHandler = nil + configuredPublicationKey = nil + currentLoadSignature = nil + } +} + +extension UIColor { + var ss_hexString: String { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return String(format: "#%02X%02X%02X", Int(red * 255), Int(green * 255), Int(blue * 255)) + } +} diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBWebView+FixedLayout.swift b/Sources/RDReaderView/EPUBCore/RDEPUBWebView+FixedLayout.swift new file mode 100644 index 0000000..84c4f4d --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBWebView+FixedLayout.swift @@ -0,0 +1,86 @@ +import UIKit +import WebKit + +extension RDEPUBWebView { + public func loadFixedSpread( + parser: RDEPUBParser, + spread: EPUBFixedSpread, + viewportSize: CGSize, + contentInset: UIEdgeInsets, + backgroundColor: UIColor? + ) { + let request = RDEPUBRenderRequest.fixed( + RDEPUBFixedRenderRequest( + spread: spread, + viewportSize: viewportSize, + contentInset: contentInset, + backgroundColorCSS: backgroundColor?.ss_hexString, + fit: .page, + searchPresentation: nil + ) + ) + load(publication: parser.makePublication(), request: request) + } + + func handleFixedLayoutLoad( + publication: RDEPUBPublication, + request: RDEPUBFixedRenderRequest, + loadSignature: String, + in webView: WKWebView + ) { + currentPageIndex = 0 + currentTotalPagesInChapter = 1 + viewportSize = request.viewportSize + currentPadding = request.contentInset + currentFontSize = 16 + currentLineHeightMultiple = 1.5 + currentThemeBackgroundColor = request.backgroundColorCSS + currentThemeTextColor = nil + targetLocation = nil + pendingHighlights = [] + fixedSpread = request.spread + isFixedLayout = true + pendingProgressionRequest = false + currentLoadSignature = loadSignature + scheduleFixedLayoutReadyFallback() + + let html = RDEPUBFixedLayoutTemplate.html(for: request, publication: publication) + RDEPUBWebViewDebug.log(debugScope, message: "load fixed spread resources=\(request.spread.resources.map(\.href).joined(separator: ",")) fit=\(request.fit.rawValue)") + webView.loadHTMLString( + html, + baseURL: URL(string: "\(RDEPUBResourceURLSchemeHandler.scheme)://\(RDEPUBResourceURLSchemeHandler.host)/") + ) + } + + func fixedLayoutLoadSignature(publicationKey: String, request: RDEPUBFixedRenderRequest) -> String { + [ + publicationKey, + "fixed", + request.spread.resources.map(\.href).joined(separator: "|"), + String(format: "%.2f", request.viewportSize.width), + String(format: "%.2f", request.viewportSize.height), + String(format: "%.2f", request.contentInset.top), + String(format: "%.2f", request.contentInset.left), + String(format: "%.2f", request.contentInset.bottom), + String(format: "%.2f", request.contentInset.right), + request.backgroundColorCSS ?? "", + request.fit.rawValue + ].joined(separator: "#") + } + + func scheduleFixedLayoutReadyFallback() { + let workItem = DispatchWorkItem { [weak self] in + RDEPUBWebViewDebug.log(self?.debugScope ?? "ReaderWebView", message: "fixed ready fallback fired") + self?.applySearchDecorationsIfNeeded { + self?.rendered() + } + } + fixedLayoutReadyWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: workItem) + } + + func cancelFixedLayoutReadyFallback() { + fixedLayoutReadyWorkItem?.cancel() + fixedLayoutReadyWorkItem = nil + } +} diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift b/Sources/RDReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift new file mode 100644 index 0000000..7da9314 --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift @@ -0,0 +1,108 @@ +import Foundation +import WebKit + +extension RDEPUBWebView { + func internalLocation(from url: URL) -> RDEPUBLocation? { + let href = url.path.removingPercentEncoding?.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? "" + let fragment = url.fragment + guard !href.isEmpty || fragment != nil else { return nil } + return RDEPUBLocation(href: href, progression: 0, fragment: fragment) + } + + func currentLocation(from body: [String: Any]) -> RDEPUBLocation { + let progression = (body["progression"] as? NSNumber)?.doubleValue ?? 0 + let lastProgression = (body["lastProgression"] as? NSNumber)?.doubleValue + let fragment = body["fragment"] as? String + return RDEPUBLocation(href: currentHref, progression: progression, lastProgression: lastProgression, fragment: fragment) + } +} + +extension RDEPUBWebView: WKNavigationDelegate { + public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didStart", url: webView.url) + } + + public func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { + RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didCommit", url: webView.url) + } + + public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFinish", url: webView.url) + applyPresentation() + } + + public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "processTerminated", url: webView.url) + } + + public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFail", url: webView.url, error: error) + } + + public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error) + } + + public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "decidePolicy", url: navigationAction.request.url) + guard navigationAction.navigationType == .linkActivated, + let url = navigationAction.request.url else { + decisionHandler(.allow) + return + } + + if url.scheme == RDEPUBResourceURLSchemeHandler.scheme, + let location = internalLocation(from: url) { + delegate?.epubWebView(self, didActivateInternalLink: location, fromSpineIndex: currentSpineIndex) + decisionHandler(.cancel) + return + } + + if let scheme = url.scheme?.lowercased(), ["http", "https", "mailto", "tel"].contains(scheme) { + delegate?.epubWebView(self, didActivateExternalLink: url) + decisionHandler(.cancel) + return + } + + decisionHandler(.allow) + } +} + +extension RDEPUBWebView: WKScriptMessageHandler { + public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + RDEPUBWebViewDebug.logMessage(debugScope, webView: message.webView, name: message.name, body: message.body) + switch message.name { + case RDEPUBJavaScriptBridgeMessage.progressionChanged.rawValue: + guard let body = message.body as? [String: Any] else { return } + delegate?.epubWebView(self, didUpdateLocation: currentLocation(from: body), spineIndex: currentSpineIndex) + case RDEPUBJavaScriptBridgeMessage.selectionChanged.rawValue: + if let body = message.body as? [String: Any], + let text = body["text"] as? String, + let rangeInfo = body["rangeInfo"] as? String { + let location = currentLocation(from: body) + let selection = RDEPUBSelection(location: location, text: text, rangeInfo: rangeInfo) + delegate?.epubWebView(self, didChangeSelection: selection, spineIndex: currentSpineIndex) + } else { + delegate?.epubWebView(self, didChangeSelection: nil, spineIndex: currentSpineIndex) + } + case RDEPUBJavaScriptBridgeMessage.internalLink.rawValue: + guard let body = message.body as? [String: Any], + let href = body["href"] as? String else { return } + let location = RDEPUBLocation(href: href, progression: 0) + delegate?.epubWebView(self, didActivateInternalLink: location, fromSpineIndex: currentSpineIndex) + case RDEPUBJavaScriptBridgeMessage.externalLink.rawValue: + guard let body = message.body as? [String: Any], + let urlString = body["url"] as? String, + let url = URL(string: urlString) else { return } + delegate?.epubWebView(self, didActivateExternalLink: url) + case RDEPUBJavaScriptBridgeMessage.javaScriptError.rawValue: + delegate?.epubWebView(self, didLogJavaScriptError: "\(message.body)") + case RDEPUBJavaScriptBridgeMessage.fixedLayoutReady.rawValue: + applySearchDecorationsIfNeeded { + self.rendered() + } + default: + break + } + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBWebView+Reflowable.swift b/Sources/RDReaderView/EPUBCore/RDEPUBWebView+Reflowable.swift new file mode 100644 index 0000000..58ad6e7 --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBWebView+Reflowable.swift @@ -0,0 +1,145 @@ +import UIKit +import WebKit + +extension RDEPUBWebView { + public func loadPage( + parser: RDEPUBParser, + spineIndex: Int, + pageIndex: Int, + totalPagesInChapter: Int, + viewportSize: CGSize, + padding: UIEdgeInsets, + fontSize: CGFloat, + lineHeightMultiple: CGFloat, + themeBackgroundColor: String?, + themeTextColor: String?, + targetLocation: RDEPUBLocation?, + highlights: [RDEPUBHighlight] + ) { + let href = parser.spine.indices.contains(spineIndex) ? parser.spine[spineIndex].href : "" + let request = RDEPUBRenderRequest.reflowable( + RDEPUBReflowableRenderRequest( + spineIndex: spineIndex, + href: href, + pageIndex: pageIndex, + totalPagesInChapter: totalPagesInChapter, + presentation: RDEPUBPresentationStyle( + viewportSize: viewportSize, + contentInsets: padding, + fontSize: fontSize, + lineHeightMultiple: lineHeightMultiple, + themeBackgroundColor: themeBackgroundColor, + themeTextColor: themeTextColor + ), + targetLocation: targetLocation, + highlights: highlights, + searchPresentation: nil + ) + ) + load(publication: parser.makePublication(), request: request) + } + + func handleReflowableLoad( + publication: RDEPUBPublication, + request: RDEPUBReflowableRenderRequest, + loadSignature: String, + in webView: WKWebView + ) { + guard publication.spine.indices.contains(request.spineIndex), + let requestURL = publication.resourceResolver.resourceURL(forRelativePath: request.href) else { + return + } + + currentPageIndex = request.pageIndex + currentTotalPagesInChapter = max(1, request.totalPagesInChapter) + viewportSize = request.presentation.viewportSize + currentPadding = request.presentation.contentInsets + currentFontSize = request.presentation.fontSize + currentLineHeightMultiple = request.presentation.lineHeightMultiple + currentThemeBackgroundColor = request.presentation.themeBackgroundColor + currentThemeTextColor = request.presentation.themeTextColor + targetLocation = request.targetLocation + pendingHighlights = request.highlights + fixedSpread = nil + isFixedLayout = false + pendingProgressionRequest = true + + if currentLoadSignature == loadSignature { + if didRenderCurrentRequest || webView.isLoading { + let reason = didRenderCurrentRequest ? "already-rendered" : "still-loading" + RDEPUBWebViewDebug.log(debugScope, message: "skip duplicate request href=\(request.href) page=\(request.pageIndex) reason=\(reason)") + return + } + + if webView.url == requestURL { + RDEPUBWebViewDebug.log(debugScope, message: "reuse existing document href=\(request.href) page=\(request.pageIndex)") + applyPresentation() + return + } + } + + currentLoadSignature = loadSignature + RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "load-request", url: requestURL) + webView.load(URLRequest(url: requestURL)) + } + + func reflowableLoadSignature(publicationKey: String, request: RDEPUBReflowableRenderRequest) -> String { + let targetSignature = [ + request.targetLocation?.href ?? "", + String(request.targetLocation?.progression ?? 0), + String(request.targetLocation?.lastProgression ?? 0), + request.targetLocation?.fragment ?? "" + ].joined(separator: "|") + let highlightSignature = request.highlights + .map { [$0.id, $0.rangeInfo ?? "", $0.color, $0.style.rawValue].joined(separator: "|") } + .joined(separator: ",") + let presentation = request.presentation + return [ + publicationKey, + "reflowable", + String(request.spineIndex), + request.href, + String(request.pageIndex), + String(request.totalPagesInChapter), + String(format: "%.2f", presentation.viewportSize.width), + String(format: "%.2f", presentation.viewportSize.height), + String(format: "%.2f", presentation.contentInsets.top), + String(format: "%.2f", presentation.contentInsets.left), + String(format: "%.2f", presentation.contentInsets.bottom), + String(format: "%.2f", presentation.contentInsets.right), + String(format: "%.2f", presentation.fontSize), + String(format: "%.2f", presentation.lineHeightMultiple), + presentation.themeBackgroundColor ?? "", + presentation.themeTextColor ?? "", + targetSignature, + highlightSignature + ].joined(separator: "#") + } + + func applyPresentation() { + guard let webView, let currentRenderRequest else { return } + guard case .reflowable(let request) = currentRenderRequest else { + return + } + + let script = RDEPUBJavaScriptBridge.applyPresentationScript(for: request) + RDEPUBWebViewDebug.logJavaScript( + debugScope, + webView: webView, + action: "applyPresentation", + details: "spine=\(request.spineIndex) page=\(request.pageIndex) total=\(request.totalPagesInChapter)" + ) + + webView.evaluateJavaScript(script) { [weak self] _, error in + guard let self else { return } + if let error { + RDEPUBWebViewDebug.logJavaScript(self.debugScope, webView: webView, action: "applyPresentationFailed", details: error.localizedDescription) + self.delegate?.epubWebView(self, didLogJavaScriptError: error.localizedDescription) + return + } + self.applySearchDecorationsIfNeeded { + self.rendered() + } + } + } +} diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBWebView+Search.swift b/Sources/RDReaderView/EPUBCore/RDEPUBWebView+Search.swift new file mode 100644 index 0000000..517385c --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBWebView+Search.swift @@ -0,0 +1,32 @@ +import WebKit + +extension RDEPUBWebView { + func applySearchDecorationsIfNeeded(completion: (() -> Void)? = nil) { + guard let webView else { + completion?() + return + } + + let presentation: RDEPUBSearchPresentation? + switch currentRenderRequest { + case .reflowable(let request): + presentation = request.searchPresentation + case .fixed(let request): + presentation = request.searchPresentation + case .none: + presentation = nil + } + + let script = RDEPUBJavaScriptBridge.applySearchScript(for: presentation) + webView.evaluateJavaScript(script) { [weak self] _, error in + guard let self else { + completion?() + return + } + if let error { + self.delegate?.epubWebView(self, didLogJavaScriptError: error.localizedDescription) + } + completion?() + } + } +} diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBWebView.swift b/Sources/RDReaderView/EPUBCore/RDEPUBWebView.swift new file mode 100644 index 0000000..108ad03 --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBWebView.swift @@ -0,0 +1,169 @@ +import UIKit +import WebKit + +public protocol RDEPUBWebViewDelegate: AnyObject { + func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) + func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) + func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) + func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) + func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL) + func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String) + func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView) +} + +public extension RDEPUBWebViewDelegate { + func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {} +} + +final class RDEPUBAnnotationWebView: WKWebView { + var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)? + + override var canBecomeFirstResponder: Bool { + true + } + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + switch action { + case #selector(rd_copy(_:)), + #selector(rd_highlight(_:)), + #selector(rd_annotate(_:)): + return true + default: + return false + } + } + + @objc func rd_copy(_ sender: Any?) { + onSelectionAction?(.copy) + } + + @objc func rd_highlight(_ sender: Any?) { + onSelectionAction?(.highlight) + } + + @objc func rd_annotate(_ sender: Any?) { + onSelectionAction?(.annotate) + } +} + +public final class RDEPUBWebView: UIView { + public weak var delegate: RDEPUBWebViewDelegate? + public var onRendered: (() -> Void)? + + var publication: RDEPUBPublication? + var currentRenderRequest: RDEPUBRenderRequest? + var webView: WKWebView? + var schemeHandler: RDEPUBResourceURLSchemeHandler? + var configuredPublicationKey: String? + var currentLoadSignature: String? + var currentSpineIndex = 0 + var currentHref = "" + var currentPageIndex = 0 + var currentTotalPagesInChapter = 1 + var viewportSize: CGSize = .zero + var currentPadding: UIEdgeInsets = .zero + var currentFontSize: CGFloat = 16 + var currentLineHeightMultiple: CGFloat = 1.5 + var currentThemeBackgroundColor: String? + var currentThemeTextColor: String? + var targetLocation: RDEPUBLocation? + var pendingHighlights: [RDEPUBHighlight] = [] + var fixedSpread: EPUBFixedSpread? + var isFixedLayout = false + var pendingProgressionRequest = false + var fixedLayoutReadyWorkItem: DispatchWorkItem? + var didRenderCurrentRequest = false + let debugScope = "ReaderWebView" + + public override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + teardownWebView() + } + + public override func layoutSubviews() { + super.layoutSubviews() + webView?.frame = bounds + } + + public func reset() { + delegate = nil + onRendered = nil + publication = nil + currentRenderRequest = nil + currentSpineIndex = 0 + currentHref = "" + currentPageIndex = 0 + currentTotalPagesInChapter = 1 + viewportSize = .zero + currentPadding = .zero + currentFontSize = 16 + currentLineHeightMultiple = 1.5 + currentThemeBackgroundColor = nil + currentThemeTextColor = nil + targetLocation = nil + pendingHighlights = [] + fixedSpread = nil + isFixedLayout = false + pendingProgressionRequest = false + didRenderCurrentRequest = false + teardownWebView() + } + + public func load(publication: RDEPUBPublication, request: RDEPUBRenderRequest) { + configureWebViewIfNeeded(publication: publication) + guard let webView else { return } + + self.publication = publication + self.currentRenderRequest = request + self.currentSpineIndex = request.primarySpineIndex + self.currentHref = request.primaryHref + self.didRenderCurrentRequest = false + cancelFixedLayoutReadyFallback() + + let publicationKey = publication.parser.opfURL?.path ?? publication.parser.extractionRootURL?.path ?? "" + let loadSignature = pageLoadSignature(publicationKey: publicationKey, request: request) + RDEPUBWebViewDebug.log( + debugScope, + message: "load request=\(request.isFixedLayout ? "fixed" : "reflowable") signature=\(loadSignature) webView=\(RDEPUBWebViewDebug.webViewID(webView))" + ) + + switch request { + case .reflowable(let reflowableRequest): + handleReflowableLoad(publication: publication, request: reflowableRequest, loadSignature: loadSignature, in: webView) + case .fixed(let fixedRequest): + handleFixedLayoutLoad(publication: publication, request: fixedRequest, loadSignature: loadSignature, in: webView) + } + } + + func pageLoadSignature( + publicationKey: String, + request: RDEPUBRenderRequest + ) -> String { + switch request { + case .reflowable(let request): + return reflowableLoadSignature(publicationKey: publicationKey, request: request) + case .fixed(let request): + return fixedLayoutLoadSignature(publicationKey: publicationKey, request: request) + } + } + + func rendered() { + guard !didRenderCurrentRequest else { return } + didRenderCurrentRequest = true + cancelFixedLayoutReadyFallback() + RDEPUBWebViewDebug.log(debugScope, message: "rendered spine=\(currentSpineIndex) href=\(currentHref) fixed=\(isFixedLayout)") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + guard let self else { return } + self.delegate?.epubWebViewDidFinishRendering(self) + self.onRendered?() + } + } +} diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBWebViewDebug.swift b/Sources/RDReaderView/EPUBCore/RDEPUBWebViewDebug.swift new file mode 100644 index 0000000..9ce1c79 --- /dev/null +++ b/Sources/RDReaderView/EPUBCore/RDEPUBWebViewDebug.swift @@ -0,0 +1,69 @@ +import Foundation +import WebKit + +enum RDEPUBWebViewDebug { + static var isEnabled: Bool = { + if let configured = UserDefaults.standard.object(forKey: "RDEPUBWebViewDebugEnabled") as? Bool { + return configured + } +#if DEBUG + return true +#else + return false +#endif + }() + + static func webViewID(_ webView: WKWebView?) -> String { + guard let webView else { return "nil-webview" } + return String(ObjectIdentifier(webView).hashValue, radix: 16) + } + + static func log(_ scope: String, message: String) { + guard isEnabled else { return } + print("[RDReaderWK][\(scope)] \(message)") + } + + static func logNavigationEvent(_ scope: String, webView: WKWebView?, event: String, url: URL? = nil, error: Error? = nil) { + guard isEnabled else { return } + let webViewToken = webViewID(webView) + let urlText = summarizedURL(url) + if let error { + log(scope, message: "webView=\(webViewToken) event=\(event) url=\(urlText) error=\(error.localizedDescription)") + } else { + log(scope, message: "webView=\(webViewToken) event=\(event) url=\(urlText)") + } + } + + static func logJavaScript(_ scope: String, webView: WKWebView?, action: String, details: String) { + guard isEnabled else { return } + log(scope, message: "webView=\(webViewID(webView)) js=\(action) \(details)") + } + + static func logMessage(_ scope: String, webView: WKWebView?, name: String, body: Any) { + guard isEnabled else { return } + log(scope, message: "webView=\(webViewID(webView)) message=\(name) body=\(String(describing: body))") + } + + static func logSchemeTask(_ scope: String, requestURL: URL?, fileURL: URL? = nil, event: String, error: Error? = nil) { + guard isEnabled else { return } + let requestText = summarizedURL(requestURL) + let fileText = summarizedURL(fileURL) + if let error { + log(scope, message: "scheme=\(event) request=\(requestText) file=\(fileText) error=\(error.localizedDescription)") + } else { + log(scope, message: "scheme=\(event) request=\(requestText) file=\(fileText)") + } + } + + static func summarizedURL(_ url: URL?) -> String { + guard let url else { return "nil" } + if let scheme = url.scheme, scheme == RDEPUBResourceURLSchemeHandler.scheme { + return url.absoluteString + } + let path = url.path + if path.isEmpty { + return url.absoluteString + } + return url.lastPathComponent.isEmpty ? path : url.lastPathComponent + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift new file mode 100644 index 0000000..feb561b --- /dev/null +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift @@ -0,0 +1,80 @@ +import UIKit + +#if canImport(DTCoreText) +import DTCoreText +#endif + +public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer { + public init() {} + + public static var isAvailable: Bool { +#if canImport(DTCoreText) + return true +#else + return false +#endif + } + + public func renderChapter( + html: String, + baseURL: URL?, + style: RDEPUBTextRenderStyle + ) throws -> RDEPUBRenderedChapterContent { +#if canImport(DTCoreText) + let markedHTML = RDEPUBTextRendererSupport.injectFragmentMarkers(into: html) + guard let data = markedHTML.data(using: .utf8) else { + throw RDEPUBTextRenderingError.htmlEncodingFailed + } + + guard let rendered = makeAttributedString(from: data, baseURL: baseURL, style: style) else { + return fallbackRenderedContent(for: markedHTML, style: style) + } + + let attributedString = NSMutableAttributedString(attributedString: rendered) + let fragmentOffsets = RDEPUBTextRendererSupport.extractFragmentOffsets(from: attributedString) + RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: style) + return RDEPUBRenderedChapterContent(attributedString: attributedString, fragmentOffsets: fragmentOffsets) +#else + let markedHTML = RDEPUBTextRendererSupport.injectFragmentMarkers(into: html) + return fallbackRenderedContent(for: markedHTML, style: style) +#endif + } + + private func fallbackRenderedContent(for html: String, style: RDEPUBTextRenderStyle) -> RDEPUBRenderedChapterContent { + let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: html, style: style) + let fragmentOffsets = RDEPUBTextRendererSupport.extractFragmentOffsets(from: attributedString) + RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: style) + return RDEPUBRenderedChapterContent(attributedString: attributedString, fragmentOffsets: fragmentOffsets) + } + +#if canImport(DTCoreText) + private func makeAttributedString(from data: Data, baseURL: URL?, style: RDEPUBTextRenderStyle) -> NSAttributedString? { + let builder = DTHTMLAttributedStringBuilder( + html: data, + options: dtOptions(baseURL: baseURL, style: style), + documentAttributes: nil + ) + return builder?.generatedAttributedString() + } + + private func dtOptions(baseURL: URL?, style: RDEPUBTextRenderStyle) -> [AnyHashable: Any] { + var options: [AnyHashable: Any] = [ + NSTextSizeMultiplierDocumentOption: 1.0, + DTDefaultFontFamily: style.font.familyName, + DTDefaultFontName: style.font.fontName, + DTDefaultFontSize: style.font.pointSize, + DTDefaultLineHeightMultiplier: max((style.font.lineHeight + style.lineSpacing) / max(style.font.lineHeight, 1), 1), + DTUseiOS6Attributes: true + ] + + if let baseURL { + options[NSBaseURLDocumentOption] = baseURL + } + if let textColor = style.textColor { + options[DTDefaultTextColor] = textColor + } + + return options + } +#endif +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift new file mode 100644 index 0000000..c8d1a93 --- /dev/null +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift @@ -0,0 +1,205 @@ +import UIKit + +public struct RDEPUBTextPage: Equatable { + public var absolutePageIndex: Int + public var chapterIndex: Int + public var spineIndex: Int + public var href: String + public var chapterTitle: String + public var pageIndexInChapter: Int + public var totalPagesInChapter: Int + public var content: NSAttributedString + public var contentRange: NSRange + public var pageStartOffset: Int + public var pageEndOffset: Int +} + +public struct RDEPUBTextChapter: Equatable { + public var chapterIndex: Int + public var spineIndex: Int + public var href: String + public var title: String + public var attributedContent: NSAttributedString + public var fragmentOffsets: [String: Int] + public var pages: [RDEPUBTextPage] +} + +public struct RDEPUBTextBook: Equatable { + public var chapters: [RDEPUBTextChapter] + public var pages: [RDEPUBTextPage] + + public init(chapters: [RDEPUBTextChapter], pages: [RDEPUBTextPage]) { + self.chapters = chapters + self.pages = pages + } + + public func page(at pageNumber: Int) -> RDEPUBTextPage? { + guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else { + return nil + } + return pages[pageNumber - 1] + } + + public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? { + guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier), + let chapter = chapters.first(where: { $0.href == normalizedLocation.href }) else { + return nil + } + + let targetOffset: Int + if let fragment = normalizedLocation.fragment, let fragmentOffset = chapter.fragmentOffsets[fragment] { + targetOffset = fragmentOffset + } else { + let lastOffset = max(chapter.attributedContent.length - 1, 0) + targetOffset = min(lastOffset, max(0, Int(round(Double(lastOffset) * normalizedLocation.navigationProgression)))) + } + + if let page = chapter.pages.first(where: { targetOffset >= $0.pageStartOffset && targetOffset <= $0.pageEndOffset }) { + return page.absolutePageIndex + 1 + } + + return chapter.pages.last.map { $0.absolutePageIndex + 1 } + } + + public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? { + guard let page = page(at: pageNumber), + let chapter = chapters.first(where: { $0.chapterIndex == page.chapterIndex }) else { + return nil + } + + let totalLength = max(chapter.attributedContent.length - 1, 1) + return RDEPUBLocation( + bookIdentifier: bookIdentifier, + href: page.href, + progression: Double(page.pageStartOffset) / Double(totalLength), + lastProgression: Double(page.pageEndOffset) / Double(totalLength), + fragment: nil + ) + } +} + +public final class RDEPUBTextBookBuilder { + private let renderer: RDEPUBTextRenderer + + public init(renderer: RDEPUBTextRenderer) { + self.renderer = renderer + } + + public convenience init() { + self.init(renderer: RDEPUBDTCoreTextRenderer()) + } + + public func build( + parser: RDEPUBParser, + publication: RDEPUBPublication, + pageSize: CGSize, + style: RDEPUBTextRenderStyle + ) throws -> RDEPUBTextBook { + var chapters: [RDEPUBTextChapter] = [] + var flatPages: [RDEPUBTextPage] = [] + + for (spineIndex, item) in publication.spine.enumerated() where item.linear { + guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"), + let rawHTML = parser.htmlString(forRelativePath: item.href) else { + continue + } + + let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents) + let normalizedHTML = normalizeHTML(rawHTML) + let rendered = try renderer.renderChapter( + html: normalizedHTML, + baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(), + style: style + ) + + let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines) + if shouldSkipChapter(item: item, text: plainText) { + continue + } + + let chapterIndex = chapters.count + let content = NSMutableAttributedString(attributedString: rendered.attributedString) + let pageRanges = content.length > 0 ? content.ss_pageRanges(size: pageSize) : [] + let effectivePageRanges = pageRanges.isEmpty && content.length > 0 + ? [NSRange(location: 0, length: content.length)] + : pageRanges + + let pages = effectivePageRanges.enumerated().map { localPageIndex, range in + RDEPUBTextPage( + absolutePageIndex: flatPages.count + localPageIndex, + chapterIndex: chapterIndex, + spineIndex: spineIndex, + href: item.href, + chapterTitle: chapterTitle, + pageIndexInChapter: localPageIndex, + totalPagesInChapter: effectivePageRanges.count, + content: content.attributedSubstring(from: range), + contentRange: range, + pageStartOffset: range.location, + pageEndOffset: range.location + max(range.length - 1, 0) + ) + } + + chapters.append( + RDEPUBTextChapter( + chapterIndex: chapterIndex, + spineIndex: spineIndex, + href: item.href, + title: chapterTitle, + attributedContent: content.copy() as! NSAttributedString, + fragmentOffsets: rendered.fragmentOffsets, + pages: pages + ) + ) + flatPages.append(contentsOf: pages) + } + + return RDEPUBTextBook(chapters: chapters, pages: flatPages) + } + + private func resolvedChapterTitle(for item: RDEPUBSpineItem, toc: [EPUBTableOfContentsItem]) -> String { + if let title = flattenedTOCItems(from: toc).first(where: { tocItem in + tocItem.href.components(separatedBy: "#").first == item.href + })?.title.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { + return title + } + let trimmedTitle = item.title.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedTitle.isEmpty ? item.href : trimmedTitle + } + + private func flattenedTOCItems(from items: [EPUBTableOfContentsItem]) -> [EPUBTableOfContentsItem] { + items.flatMap { item in + [item] + flattenedTOCItems(from: item.children) + } + } + + private func shouldSkipChapter(item: RDEPUBSpineItem, text: String) -> Bool { + let lowercasedHref = item.href.lowercased() + if text.isEmpty && (lowercasedHref.contains("cover") || lowercasedHref.contains("title")) { + return true + } + return false + } + + private func normalizeHTML(_ html: String) -> String { + var cleanedHTML = html + let replacements: [(pattern: String, template: String)] = [ + (#"分页符"#, ""), + (#"\r"#, "\n"), + (#"\n+"#, "\n") + ] + + for replacement in replacements { + if let regex = try? NSRegularExpression(pattern: replacement.pattern, options: [.caseInsensitive]) { + cleanedHTML = regex.stringByReplacingMatches( + in: cleanedHTML, + options: [], + range: NSRange(location: 0, length: cleanedHTML.utf16.count), + withTemplate: replacement.template + ) + } + } + + return cleanedHTML + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPaginationSupport.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPaginationSupport.swift new file mode 100644 index 0000000..cc97297 --- /dev/null +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPaginationSupport.swift @@ -0,0 +1,35 @@ +import CoreText +import UIKit + +extension NSAttributedString { + func ss_pageRanges(size: CGSize) -> [NSRange] { + var ranges: [NSRange] = [] + let framesetter = CTFramesetterCreateWithAttributedString(self) + let path = CGPath(rect: CGRect(origin: .zero, size: size), transform: nil) + var visibleRange = CFRangeMake(0, 0) + var location = 0 + + while visibleRange.location + visibleRange.length < length { + let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(location, 0), path, nil) + visibleRange = CTFrameGetVisibleStringRange(frame) + guard visibleRange.length > 0 else { + break + } + ranges.append(NSRange(location: location, length: visibleRange.length)) + location += visibleRange.length + } + + return ranges + } +} + +extension UIColor { + var ss_cssString: String { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return String(format: "rgba(%d, %d, %d, %.3f)", Int(red * 255), Int(green * 255), Int(blue * 255), alpha) + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift new file mode 100644 index 0000000..8ab988a --- /dev/null +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift @@ -0,0 +1,47 @@ +import UIKit + +public struct RDEPUBTextRenderStyle { + public var font: UIFont + public var lineSpacing: CGFloat + public var textColor: UIColor? + public var backgroundColor: UIColor? + + public init(font: UIFont, lineSpacing: CGFloat, textColor: UIColor? = nil, backgroundColor: UIColor? = nil) { + self.font = font + self.lineSpacing = lineSpacing + self.textColor = textColor + self.backgroundColor = backgroundColor + } +} + +public struct RDEPUBRenderedChapterContent { + public var attributedString: NSAttributedString + public var fragmentOffsets: [String: Int] + + public init(attributedString: NSAttributedString, fragmentOffsets: [String: Int]) { + self.attributedString = attributedString + self.fragmentOffsets = fragmentOffsets + } +} + +public protocol RDEPUBTextRenderer { + func renderChapter( + html: String, + baseURL: URL?, + style: RDEPUBTextRenderStyle + ) throws -> RDEPUBRenderedChapterContent +} + +public enum RDEPUBTextRenderingError: LocalizedError { + case htmlEncodingFailed + case htmlImportFailed + + public var errorDescription: String? { + switch self { + case .htmlEncodingFailed: + return "HTML 编码失败" + case .htmlImportFailed: + return "HTML 富文本导入失败" + } + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift new file mode 100644 index 0000000..546410a --- /dev/null +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift @@ -0,0 +1,88 @@ +import UIKit + +enum RDEPUBTextRendererSupport { + static func injectFragmentMarkers(into html: String) -> String { + guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else { + return html + } + return regex.stringByReplacingMatches( + in: html, + options: [], + range: NSRange(location: 0, length: html.utf16.count), + withTemplate: "${id=$2}$1" + ) + } + + static func extractFragmentOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] { + let markerPattern = #"\$\{id=([^}]+)\}"# + guard let regex = try? NSRegularExpression(pattern: markerPattern, options: []) else { + return [:] + } + + let mutableString = NSMutableString(string: attributedString.string) + var fragmentOffsets: [String: Int] = [:] + var searchRange = NSRange(location: 0, length: mutableString.length) + var offsetAdjustment = 0 + + while let match = regex.firstMatch(in: mutableString as String, options: [], range: searchRange) { + let fullMatch = mutableString.substring(with: match.range) as NSString + let fragmentID = fullMatch + .replacingOccurrences(of: #"\$\{id="#, with: "", options: .regularExpression, range: NSRange(location: 0, length: fullMatch.length)) + .replacingOccurrences(of: #"\}"#, with: "", options: .regularExpression) + + let adjustedLocation = max(0, match.range.location + offsetAdjustment) + fragmentOffsets[fragmentID] = adjustedLocation + attributedString.deleteCharacters(in: match.range) + mutableString.deleteCharacters(in: match.range) + offsetAdjustment -= match.range.length + searchRange = NSRange(location: match.range.location, length: mutableString.length - match.range.location) + } + + return fragmentOffsets + } + + static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, style: RDEPUBTextRenderStyle) { + let fullRange = NSRange(location: 0, length: attributedString.length) + attributedString.enumerateAttributes(in: fullRange) { attributes, range, _ in + let sourceFont = attributes[.font] as? UIFont + let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: style.lineSpacing) + paragraph.lineSpacing = style.lineSpacing + paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, style.lineSpacing / 2) + + var updatedAttributes = attributes + updatedAttributes[.font] = normalizedFont(from: sourceFont, baseFont: style.font) + updatedAttributes[.paragraphStyle] = paragraph + if let textColor = style.textColor { + updatedAttributes[.foregroundColor] = textColor + } + attributedString.setAttributes(updatedAttributes, range: range) + } + } + + static func fallbackAttributedString(for html: String, style: RDEPUBTextRenderStyle) -> NSMutableAttributedString { + let fallbackAttributes: [NSAttributedString.Key: Any] = [ + .font: style.font, + .paragraphStyle: paragraphStyle(lineSpacing: style.lineSpacing), + .foregroundColor: style.textColor ?? UIColor.black + ] + return NSMutableAttributedString(string: html, attributes: fallbackAttributes) + } + + private static func normalizedFont(from sourceFont: UIFont?, baseFont: UIFont) -> UIFont { + guard let sourceFont else { + return baseFont + } + let traits = sourceFont.fontDescriptor.symbolicTraits.intersection([.traitBold, .traitItalic]) + if let descriptor = baseFont.fontDescriptor.withSymbolicTraits(traits) { + return UIFont(descriptor: descriptor, size: baseFont.pointSize) + } + return baseFont + } + + private static func paragraphStyle(lineSpacing: CGFloat) -> NSMutableParagraphStyle { + let style = NSMutableParagraphStyle() + style.lineSpacing = lineSpacing + style.paragraphSpacing = max(6, lineSpacing / 2) + return style + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift new file mode 100644 index 0000000..94911b2 --- /dev/null +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift @@ -0,0 +1,68 @@ +import Foundation + +final class RDEPUBTextSearchEngine: RDEPUBSearchEngine { + private let textBook: RDEPUBTextBook + private let publication: RDEPUBPublication + + init(textBook: RDEPUBTextBook, publication: RDEPUBPublication) { + self.textBook = textBook + self.publication = publication + } + + func search(keyword: String) -> [RDEPUBSearchMatch] { + let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedKeyword.isEmpty else { + return [] + } + + var matches: [RDEPUBSearchMatch] = [] + for chapter in textBook.chapters { + let source = chapter.attributedContent.string as NSString + let fullLength = source.length + guard fullLength > 0 else { + continue + } + + let normalizedHref = publication.resourceResolver.normalizedHref(chapter.href) ?? chapter.href + var localMatchIndex = 0 + var searchRange = NSRange(location: 0, length: fullLength) + + while searchRange.length > 0 { + let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange) + guard foundRange.location != NSNotFound else { + break + } + + let progressionDenominator = max(fullLength - 1, 1) + let progression = Double(foundRange.location) / Double(progressionDenominator) + matches.append( + RDEPUBSearchMatch( + href: normalizedHref, + progression: progression, + previewText: previewText(in: source, matchRange: foundRange), + localMatchIndex: localMatchIndex, + rangeLocation: foundRange.location, + rangeLength: foundRange.length + ) + ) + + localMatchIndex += 1 + let nextLocation = foundRange.location + max(foundRange.length, 1) + if nextLocation >= fullLength { + break + } + searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation) + } + } + + return matches + } + + private func previewText(in text: NSString, matchRange: NSRange) -> String { + let previewRadius = 12 + let start = max(matchRange.location - previewRadius, 0) + let end = min(matchRange.location + matchRange.length + previewRadius, text.length) + let range = NSRange(location: start, length: max(end - start, 0)) + return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift b/Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift new file mode 100644 index 0000000..501b105 --- /dev/null +++ b/Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift @@ -0,0 +1,129 @@ +import UIKit + +/// 将纯文本文件构建为 RDEPUBTextBook,复用 EPUB 文本渲染和分页管线 +public final class RDPlainTextBookBuilder { + private let renderer: RDEPUBTextRenderer + + public init(renderer: RDEPUBTextRenderer = RDEPUBDTCoreTextRenderer()) { + self.renderer = renderer + } + + public func build( + textFileURL: URL, + pageSize: CGSize, + style: RDEPUBTextRenderStyle + ) throws -> RDEPUBTextBook { + let rawText = rd_decodeTextFile(url: textFileURL) + let chapterSpecs = splitChapters(from: rawText) + + var chapters: [RDEPUBTextChapter] = [] + var flatPages: [RDEPUBTextPage] = [] + + for (index, spec) in chapterSpecs.enumerated() { + let html = wrapTextAsHTML(spec.content) + let rendered = try renderer.renderChapter(html: html, baseURL: nil, style: style) + + let content = NSMutableAttributedString(attributedString: rendered.attributedString) + let pageRanges = content.length > 0 ? content.ss_pageRanges(size: pageSize) : [] + let effectivePageRanges = pageRanges.isEmpty && content.length > 0 + ? [NSRange(location: 0, length: content.length)] + : pageRanges + + let href = "chapter_\(index).xhtml" + let pages = effectivePageRanges.enumerated().map { localPageIndex, range in + RDEPUBTextPage( + absolutePageIndex: flatPages.count + localPageIndex, + chapterIndex: index, + spineIndex: index, + href: href, + chapterTitle: spec.title ?? "第 \(index + 1) 章", + pageIndexInChapter: localPageIndex, + totalPagesInChapter: effectivePageRanges.count, + content: content.attributedSubstring(from: range), + contentRange: range, + pageStartOffset: range.location, + pageEndOffset: range.location + max(range.length - 1, 0) + ) + } + + chapters.append( + RDEPUBTextChapter( + chapterIndex: index, + spineIndex: index, + href: href, + title: spec.title ?? "第 \(index + 1) 章", + attributedContent: content.copy() as! NSAttributedString, + fragmentOffsets: [:], + pages: pages + ) + ) + flatPages.append(contentsOf: pages) + } + + return RDEPUBTextBook(chapters: chapters, pages: flatPages) + } + + // MARK: - 章节拆分 + + private struct ChapterSpec { + let title: String? + let content: String + } + + /// 按中文章节正则拆分文本。无匹配时整体作为一章。 + private func splitChapters(from text: String) -> [ChapterSpec] { + let pattern = #"^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else { + return [ChapterSpec(title: nil, content: text)] + } + + let nsText = text as NSString + let matches = regex.matches(in: text, range: NSRange(location: 0, length: nsText.length)) + guard !matches.isEmpty else { + return [ChapterSpec(title: nil, content: text)] + } + + var specs: [ChapterSpec] = [] + for (i, match) in matches.enumerated() { + let titleRange = match.range(at: 1) + let title = nsText.substring(with: titleRange).trimmingCharacters(in: .whitespacesAndNewlines) + let contentStart = titleRange.location + titleRange.length + let contentEnd = (i + 1 < matches.count) ? matches[i + 1].range.location : nsText.length + let contentRange = NSRange(location: contentStart, length: contentEnd - contentStart) + let content = nsText.substring(with: contentRange).trimmingCharacters(in: .whitespacesAndNewlines) + if !content.isEmpty { + specs.append(ChapterSpec(title: title, content: content)) + } + } + + // 拆分结果为空(所有章节内容为空),整体作为一章 + if specs.isEmpty { + return [ChapterSpec(title: nil, content: text)] + } + return specs + } + + // MARK: - HTML 包装 + + /// 将纯文本包装为 HTML 段落 + private func wrapTextAsHTML(_ text: String) -> String { + let paragraphs = text.components(separatedBy: "\n").filter { !$0.isEmpty } + let body = paragraphs.map { "

\($0)

" }.joined(separator: "\n") + return "\(body)" + } + + // MARK: - 文本解码 + + private func rd_decodeTextFile(url: URL) -> String { + if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String { + return content + } + if let content = try? NSString(contentsOf: url, encoding: 0x80000632) as String { + return content + } + if let content = try? NSString(contentsOf: url, encoding: 0x80000631) as String { + return content + } + return "" + } +} diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift new file mode 100644 index 0000000..be6f99a --- /dev/null +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift @@ -0,0 +1,136 @@ +import UIKit + +final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView { + var onShowTableOfContents: (() -> Void)? + var onShowBookmarks: (() -> Void)? + var onShowHighlights: (() -> Void)? + var onAddHighlight: (() -> Void)? + var onShowSettings: (() -> Void)? + + private let stackView: UIStackView = { + let view = UIStackView() + view.axis = .horizontal + view.distribution = .fillEqually + view.alignment = .fill + view.spacing = 16 + return view + }() + + private let chapterButton = RDEPUBReaderTintButton(type: .system) + private let bookmarksButton = RDEPUBReaderTintButton(type: .system) + private let highlightsButton = RDEPUBReaderTintButton(type: .system) + private let addHighlightButton = RDEPUBReaderTintButton(type: .system) + private let settingsButton = RDEPUBReaderTintButton(type: .system) + + override init(frame: CGRect) { + super.init(frame: frame) + + addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(chapterButton) + stackView.addArrangedSubview(bookmarksButton) + stackView.addArrangedSubview(highlightsButton) + stackView.addArrangedSubview(addHighlightButton) + stackView.addArrangedSubview(settingsButton) + + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + stackView.topAnchor.constraint(equalTo: topAnchor), + stackView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor) + ]) + + configureButton(chapterButton, systemName: "list.bullet", fallbackTitle: "目录") + configureButton(bookmarksButton, systemName: "bookmark", fallbackTitle: "书签") + configureButton(highlightsButton, systemName: "note.text", fallbackTitle: "批注") + configureButton(addHighlightButton, systemName: "highlighter", fallbackTitle: "标注") + configureButton(settingsButton, systemName: "textformat.size", fallbackTitle: "设置") + chapterButton.accessibilityIdentifier = "epub.reader.toc" + bookmarksButton.accessibilityIdentifier = "epub.reader.bookmarks" + highlightsButton.accessibilityIdentifier = "epub.reader.highlights" + addHighlightButton.accessibilityIdentifier = "epub.reader.add-highlight" + settingsButton.accessibilityIdentifier = "epub.reader.settings" + + [chapterButton, bookmarksButton, highlightsButton, addHighlightButton, settingsButton].forEach { button in + button.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true + } + + chapterButton.addTarget(self, action: #selector(chapterAction), for: .touchUpInside) + bookmarksButton.addTarget(self, action: #selector(bookmarksAction), for: .touchUpInside) + highlightsButton.addTarget(self, action: #selector(highlightsAction), for: .touchUpInside) + addHighlightButton.addTarget(self, action: #selector(highlightAction), for: .touchUpInside) + settingsButton.addTarget(self, action: #selector(settingsAction), for: .touchUpInside) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func lineFrame(in bounds: CGRect) -> CGRect { + CGRect(x: 0, y: 0, width: bounds.width, height: 0.5) + } + + override func apply(theme: RDEPUBReaderTheme) { + super.apply(theme: theme) + [chapterButton, bookmarksButton, highlightsButton, addHighlightButton, settingsButton].forEach { button in + button.tintColor = .black + button.setTitleColor(.black, for: .normal) + } + } + + func setAddHighlightEnabled(_ isEnabled: Bool) { + addHighlightButton.isEnabled = isEnabled + addHighlightButton.alpha = isEnabled ? 1 : 0.45 + } + + func setHighlightsEnabled(_ isEnabled: Bool) { + highlightsButton.isEnabled = isEnabled + highlightsButton.alpha = isEnabled ? 1 : 0.45 + } + + func setBookmarksEnabled(_ isEnabled: Bool) { + bookmarksButton.isEnabled = isEnabled + bookmarksButton.alpha = isEnabled ? 1 : 0.45 + } + + func updateVisibility( + showsTableOfContents: Bool, + allowsHighlights: Bool, + showsSettingsPanel: Bool + ) { + chapterButton.isHidden = !showsTableOfContents + bookmarksButton.isHidden = false + highlightsButton.isHidden = !allowsHighlights + addHighlightButton.isHidden = !allowsHighlights + settingsButton.isHidden = !showsSettingsPanel + } + + private func configureButton(_ button: UIButton, systemName: String, fallbackTitle: String) { + button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .medium) + if #available(iOS 13.0, *), let image = UIImage(systemName: systemName) { + button.setImage(image.withRenderingMode(.alwaysTemplate), for: .normal) + } else { + button.setTitle(fallbackTitle, for: .normal) + } + } + + @objc private func chapterAction() { + onShowTableOfContents?() + } + + @objc private func bookmarksAction() { + onShowBookmarks?() + } + + @objc private func highlightsAction() { + onShowHighlights?() + } + + @objc private func highlightAction() { + onAddHighlight?() + } + + @objc private func settingsAction() { + onShowSettings?() + } +} diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderChapterListController.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderChapterListController.swift new file mode 100644 index 0000000..9ca11ad --- /dev/null +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderChapterListController.swift @@ -0,0 +1,60 @@ +import UIKit + +final class RDEPUBReaderChapterListController: UITableViewController { + var onSelectItem: ((RDEPUBReaderTableOfContentsItem) -> Void)? + + private let items: [RDEPUBReaderTableOfContentsItem] + private let currentItem: RDEPUBReaderTableOfContentsItem? + private let theme: RDEPUBReaderTheme + + init( + items: [RDEPUBReaderTableOfContentsItem], + currentItem: RDEPUBReaderTableOfContentsItem?, + theme: RDEPUBReaderTheme + ) { + self.items = items + self.currentItem = currentItem + self.theme = theme + super.init(style: .plain) + title = "目录" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + tableView.tableFooterView = UIView(frame: .zero) + tableView.backgroundColor = theme.contentBackgroundColor + navigationController?.navigationBar.tintColor = theme.toolControlTextColor + navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor + navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor] + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + items.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let item = items[indexPath.row] + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) + cell.backgroundColor = theme.contentBackgroundColor + cell.textLabel?.numberOfLines = 2 + cell.textLabel?.text = item.title + cell.textLabel?.textColor = isCurrentItem(item) ? .systemBlue : theme.contentTextColor + cell.indentationLevel = item.depth + cell.indentationWidth = 18 + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + onSelectItem?(items[indexPath.row]) + } + + private func isCurrentItem(_ item: RDEPUBReaderTableOfContentsItem) -> Bool { + guard let currentItem else { return false } + return currentItem.href == item.href + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderConfiguration.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderConfiguration.swift new file mode 100644 index 0000000..e103cec --- /dev/null +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderConfiguration.swift @@ -0,0 +1,69 @@ +import UIKit + +public enum RDEPUBTextRenderingEngine: Equatable { + case dtCoreText +} + +public struct RDEPUBReaderConfiguration: Equatable { + public var fontSize: CGFloat + public var lineHeightMultiple: CGFloat + public var displayType: RDReaderView.DisplayType + public var landscapeDualPageEnabled: Bool + public var showsTableOfContents: Bool + public var allowsHighlights: Bool + public var showsSettingsPanel: Bool + public var reflowableContentInsets: UIEdgeInsets + public var fixedContentInset: UIEdgeInsets + public var theme: RDEPUBReaderTheme + public var fixedLayoutFit: RDEPUBFixedLayoutFit + public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode + public var textRenderingEngine: RDEPUBTextRenderingEngine + + public init( + fontSize: CGFloat = 15, + lineHeightMultiple: CGFloat = 1.6, + displayType: RDReaderView.DisplayType = .pageCurl, + landscapeDualPageEnabled: Bool = true, + showsTableOfContents: Bool = true, + allowsHighlights: Bool = true, + showsSettingsPanel: Bool = true, + reflowableContentInsets: UIEdgeInsets = UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16), + fixedContentInset: UIEdgeInsets = .zero, + theme: RDEPUBReaderTheme = .light, + fixedLayoutFit: RDEPUBFixedLayoutFit = .page, + fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic, + textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText + ) { + self.fontSize = fontSize + self.lineHeightMultiple = lineHeightMultiple + self.displayType = displayType + self.landscapeDualPageEnabled = landscapeDualPageEnabled + self.showsTableOfContents = showsTableOfContents + self.allowsHighlights = allowsHighlights + self.showsSettingsPanel = showsSettingsPanel + self.reflowableContentInsets = reflowableContentInsets + self.fixedContentInset = fixedContentInset + self.theme = theme + self.fixedLayoutFit = fixedLayoutFit + self.fixedLayoutSpreadMode = fixedLayoutSpreadMode + self.textRenderingEngine = textRenderingEngine + } + + public static let `default` = RDEPUBReaderConfiguration() +} + +extension RDEPUBReaderConfiguration { + func makePreferences() -> RDEPUBPreferences { + RDEPUBPreferences( + fontSize: fontSize, + lineHeightMultiple: lineHeightMultiple, + reflowableContentInsets: reflowableContentInsets, + fixedContentInset: fixedContentInset, + themeBackgroundColor: theme.themeBackgroundColorCSS, + themeTextColor: theme.themeTextColorCSS, + fixedBackgroundColor: theme.themeBackgroundColorCSS, + fixedLayoutFit: fixedLayoutFit, + fixedLayoutSpreadMode: fixedLayoutSpreadMode + ) + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift new file mode 100644 index 0000000..8d61261 --- /dev/null +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift @@ -0,0 +1,1733 @@ +import UIKit + +public final class RDEPUBReaderController: UIViewController { + private struct RDEPUBViewportSignature: Equatable { + let width: CGFloat + let height: CGFloat + let safeTop: CGFloat + let safeLeft: CGFloat + let safeBottom: CGFloat + let safeRight: CGFloat + + func differsSignificantly(from other: RDEPUBViewportSignature, threshold: CGFloat = 1) -> Bool { + abs(width - other.width) > threshold || + abs(height - other.height) > threshold || + abs(safeTop - other.safeTop) > threshold || + abs(safeLeft - other.safeLeft) > threshold || + abs(safeBottom - other.safeBottom) > threshold || + abs(safeRight - other.safeRight) > threshold + } + } + + private enum RDEPUBViewportChangeReason { + case viewLayout + case orientationTransition + case pendingRepagination + } + + public weak var delegate: RDEPUBReaderDelegate? + + public var configuration: RDEPUBReaderConfiguration { + didSet { + persistReaderSettingsIfNeeded() + guard isViewLoaded else { return } + let oldConfiguration = oldValue + applyReaderViewConfiguration() + + if isExternalTextBook { + if requiresRepagination(from: oldConfiguration, to: configuration) { + rebuildExternalTextBook() + } else if requiresVisibleRefresh(from: oldConfiguration, to: configuration) { + refreshVisibleContentPreservingLocation() + } + return + } + + guard publication != nil else { return } + if requiresRepagination(from: oldConfiguration, to: configuration) { + repaginatePreservingCurrentLocation() + } else if requiresVisibleRefresh(from: oldConfiguration, to: configuration) { + refreshVisibleContentPreservingLocation() + } + } + } + + public var currentLocation: RDEPUBLocation? { + currentVisibleLocation() + } + + public private(set) var currentSelection: RDEPUBSelection? + + public var highlights: [RDEPUBHighlight] { + activeHighlights + } + + public var bookmarks: [RDEPUBBookmark] { + activeBookmarks + } + + public var tableOfContents: [EPUBTableOfContentsItem] { + publication?.tableOfContents ?? [] + } + + public var flattenedTableOfContents: [RDEPUBReaderTableOfContentsItem] { + guard let publication else { return [] } + return flattenedTableOfContentsItems(from: publication.tableOfContents) + } + + public var currentTableOfContentsItem: RDEPUBReaderTableOfContentsItem? { + resolvedCurrentTableOfContentsItem() + } + + private let epubURL: URL + fileprivate let persistence: RDEPUBReaderPersistence? + private let readerView = RDReaderView() + private let loadingIndicator: UIActivityIndicatorView = { + if #available(iOS 13.0, *) { + return UIActivityIndicatorView(style: .large) + } + return UIActivityIndicatorView(style: .gray) + }() + private let errorLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + label.textAlignment = .center + label.textColor = .darkGray + label.isHidden = true + return label + }() + private let paginationHostView = UIView() + + private var parser: RDEPUBParser? + fileprivate var publication: RDEPUBPublication? + private var readingSession: RDEPUBReadingSession? + private var textBook: RDEPUBTextBook? + private var activePages: [EPUBPage] = [] + private var activeChapters: [EPUBChapterInfo] = [] + fileprivate var activeBookmarks: [RDEPUBBookmark] = [] + private var activeHighlights: [RDEPUBHighlight] = [] + fileprivate lazy var topToolView = makeTopToolView() + fileprivate lazy var bottomToolView = makeBottomToolView() + fileprivate var currentBookIdentifier: String? + private var currentBrightness: CGFloat + private var didStartInitialLoad = false + private var isRepaginating = false + private var paginationToken = UUID() + private var paginator: RDEPUBPaginator? + private var searchState: RDEPUBSearchState? + private var lastAppliedViewportSignature: RDEPUBViewportSignature? + private var pendingViewportChangeReason: RDEPUBViewportChangeReason? + private var isWaitingForViewportTransitionCompletion = false + /// 标记是否通过外部 TextBook 初始化(跳过 EPUB 解析流程) + private var isExternalTextBook = false + /// 外部 TextBook 的原始文件 URL(用于重建 TextBook) + private var textFileURL: URL? + + public init( + epubURL: URL, + configuration: RDEPUBReaderConfiguration = .default, + persistence: RDEPUBReaderPersistence? = RDEPUBUserDefaultsPersistence() + ) { + let persistedSettings = persistence?.loadReaderSettings() + self.epubURL = epubURL + self.configuration = persistedSettings?.applying(to: configuration) ?? configuration + self.persistence = persistence + self.currentBrightness = max(0, min(1, persistedSettings?.brightness ?? CGFloat(UIScreen.main.brightness))) + super.init(nibName: nil, bundle: nil) + UIScreen.main.brightness = currentBrightness + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 使用已构建的 TextBook 初始化,跳过 EPUB 解析流程(用于 TXT 等纯文本文件) + public convenience init( + textBook: RDEPUBTextBook, + bookIdentifier: String, + title: String, + textFileURL: URL? = nil, + configuration: RDEPUBReaderConfiguration = .default, + persistence: RDEPUBReaderPersistence? = RDEPUBUserDefaultsPersistence() + ) { + let persistedSettings = persistence?.loadReaderSettings() + let resolvedConfig = persistedSettings?.applying(to: configuration) ?? configuration + let brightness = max(0, min(1, persistedSettings?.brightness ?? CGFloat(UIScreen.main.brightness))) + + self.init(epubURL: URL(string: "about:blank")!, configuration: resolvedConfig, persistence: persistence) + self.isExternalTextBook = true + self.textBook = textBook + self.textFileURL = textFileURL + self.currentBookIdentifier = bookIdentifier + self.title = title + self.didStartInitialLoad = true + } + + public override func viewDidLoad() { + super.viewDidLoad() + UIScreen.main.brightness = currentBrightness + view.backgroundColor = configuration.theme.contentBackgroundColor + setupReaderView() + setupLoadingIndicator() + setupErrorLabel() + delegate?.epubReader(self, configureTopToolView: topToolView) + + if isExternalTextBook { + let restoreLocation = currentBookIdentifier.flatMap { persistence?.loadLocation(for: $0) } + if let id = currentBookIdentifier { + activeBookmarks = persistence?.loadBookmarks(for: id) ?? [] + activeHighlights = persistence?.loadHighlights(for: id) ?? [] + } + finishPagination(restoreLocation: restoreLocation) + } + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(true, animated: animated) + navigationController?.interactivePopGestureRecognizer?.delegate = self + navigationController?.interactivePopGestureRecognizer?.isEnabled = true + } + + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + navigationController?.setNavigationBarHidden(false, animated: animated) + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + startInitialLoadIfNeeded() + } + + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + guard let viewportSignature = currentViewportSignature() else { return } + + if !didStartInitialLoad { + lastAppliedViewportSignature = viewportSignature + startInitialLoadIfNeeded() + return + } + + guard publication != nil || isExternalTextBook else { + lastAppliedViewportSignature = viewportSignature + return + } + + guard !isWaitingForViewportTransitionCompletion else { + return + } + + handleViewportChangeIfNeeded(reason: .viewLayout, viewportSignature: viewportSignature) + } + + public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + guard didStartInitialLoad else { return } + isWaitingForViewportTransitionCompletion = true + + coordinator.animate(alongsideTransition: nil) { [weak self] _ in + guard let self else { return } + self.isWaitingForViewportTransitionCompletion = false + self.view.layoutIfNeeded() + self.handleViewportChangeIfNeeded(reason: .orientationTransition) + } + } + + public func reloadBook() { + didStartInitialLoad = false + parser = nil + publication = nil + readingSession = nil + textBook = nil + activePages = [] + activeChapters = [] + activeBookmarks = [] + activeHighlights = [] + searchState = nil + lastAppliedViewportSignature = currentViewportSignature() + pendingViewportChangeReason = nil + isWaitingForViewportTransitionCompletion = false + updateCurrentSelection(nil) + readerView.reloadData() + startInitialLoadIfNeeded() + } + + public func go(to location: RDEPUBLocation) { + guard publication != nil else { return } + restoreReadingLocation(location) + } + + public func clearSelection() { + updateCurrentSelection(nil) + } + + public func bookmark(withID id: String) -> RDEPUBBookmark? { + activeBookmarks.first { $0.id == id } + } + + public func highlight(withID id: String) -> RDEPUBHighlight? { + activeHighlights.first { $0.id == id } + } + + @discardableResult + public func addHighlight( + from selection: RDEPUBSelection? = nil, + color: String = "#F8E16C", + note: String? = nil + ) -> RDEPUBHighlight? { + addAnnotation(from: selection, style: .highlight, color: color, note: note) + } + + @discardableResult + public func addAnnotation( + from selection: RDEPUBSelection? = nil, + style: RDEPUBHighlightStyle, + color: String = "#F8E16C", + note: String? = nil + ) -> RDEPUBHighlight? { + let sourceSelection = selection ?? currentSelection + guard let sourceSelection, + let scopedSelection = scopedSelection(sourceSelection, relativeToSpineIndex: nil), + !scopedSelection.isEmpty else { + return nil + } + + let newHighlight = RDEPUBHighlight( + bookIdentifier: currentBookIdentifier, + location: scopedSelection.location, + text: scopedSelection.text, + rangeInfo: scopedSelection.rangeInfo, + style: style, + color: color, + note: note + ) + + let isDuplicate = activeHighlights.contains { highlight in + highlight.location.href == newHighlight.location.href && + highlight.location.fragment == newHighlight.location.fragment && + highlight.text == newHighlight.text && + highlight.rangeInfo == newHighlight.rangeInfo && + highlight.style == newHighlight.style + } + guard !isDuplicate else { + return nil + } + + activeHighlights.append(newHighlight) + persistHighlightsAndRefreshContent() + updateCurrentSelection(nil) + return newHighlight + } + + @discardableResult + public func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? { + guard let scopedHighlight = scopedHighlight(highlight) else { + return nil + } + + if let index = activeHighlights.firstIndex(where: { $0.id == scopedHighlight.id }) { + activeHighlights[index] = scopedHighlight + } else { + activeHighlights.append(scopedHighlight) + } + persistHighlightsAndRefreshContent() + return scopedHighlight + } + + @discardableResult + public func removeHighlight(id: String) -> RDEPUBHighlight? { + guard let index = activeHighlights.firstIndex(where: { $0.id == id }) else { + return nil + } + let removed = activeHighlights.remove(at: index) + persistHighlightsAndRefreshContent() + return removed + } + + @discardableResult + public func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? { + guard let index = activeHighlights.firstIndex(where: { $0.id == id }) else { + return nil + } + activeHighlights[index].note = normalizedNote(note) + persistHighlightsAndRefreshContent() + return activeHighlights[index] + } + + @discardableResult + public func go(toHighlightID id: String, animated: Bool = true) -> Bool { + guard let highlight = highlight(withID: id) else { + return false + } + return restoreReadingLocation(highlight.location, animated: animated) + } + + public func removeAllHighlights() { + guard !activeHighlights.isEmpty else { return } + activeHighlights.removeAll() + persistHighlightsAndRefreshContent() + } + + @discardableResult + public func go(toTableOfContentsItem item: EPUBTableOfContentsItem, animated: Bool = true) -> Bool { + go(toTableOfContentsHref: item.href, animated: animated) + } + + @discardableResult + public func go(toTableOfContentsItem item: RDEPUBReaderTableOfContentsItem, animated: Bool = true) -> Bool { + go(toTableOfContentsHref: item.href, animated: animated) + } + + @discardableResult + public func go(toTableOfContentsHref href: String, animated: Bool = true) -> Bool { + guard publication != nil else { return false } + + let components = href.components(separatedBy: "#") + let baseHref = components.first ?? href + let fragment = components.count > 1 ? components[1] : nil + let location = RDEPUBLocation( + bookIdentifier: currentBookIdentifier, + href: baseHref, + progression: 0, + fragment: fragment + ) + return restoreReadingLocation(location, animated: animated) + } + + private func setupReaderView() { + readerView.dataSource = self + readerView.delegate = self + readerView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(readerView) + NSLayoutConstraint.activate([ + readerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + readerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + readerView.topAnchor.constraint(equalTo: view.topAnchor), + readerView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + readerView.register(contentView: RDEPUBTextContentView.self, contentViewWithReuseIdentifier: NSStringFromClass(RDEPUBTextContentView.self)) + readerView.register(contentView: RDEPUBWebContentView.self, contentViewWithReuseIdentifier: NSStringFromClass(RDEPUBWebContentView.self)) + applyReaderViewConfiguration() + } + + private func makeTopToolView() -> RDEPUBReaderTopToolView { + let toolView = RDEPUBReaderTopToolView() + toolView.onBack = { [weak self] in + self?.handleBackAction() + } + toolView.onToggleBookmark = { [weak self] in + _ = self?.toggleBookmark() + } + return toolView + } + + private func makeBottomToolView() -> RDEPUBReaderBottomToolView { + let toolView = RDEPUBReaderBottomToolView() + toolView.onShowTableOfContents = { [weak self] in + self?.presentTableOfContents() + } + toolView.onShowBookmarks = { [weak self] in + self?.presentBookmarksManager() + } + toolView.onShowHighlights = { [weak self] in + self?.presentHighlightsManager() + } + toolView.onAddHighlight = { [weak self] in + self?.presentAnnotationCreation() + } + toolView.onShowSettings = { [weak self] in + self?.presentSettings() + } + return toolView + } + + private func setupLoadingIndicator() { + loadingIndicator.hidesWhenStopped = true + loadingIndicator.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(loadingIndicator) + NSLayoutConstraint.activate([ + loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + loadingIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } + + private func setupErrorLabel() { + errorLabel.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(errorLabel) + NSLayoutConstraint.activate([ + errorLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), + errorLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), + errorLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } + + private func applyReaderViewConfiguration() { + view.backgroundColor = configuration.theme.contentBackgroundColor + readerView.landscapeDualPageEnabled = configuration.landscapeDualPageEnabled + readerView.pageDirection = resolvedPageDirection() + updateReaderChrome() + if readerView.currentDisplayType != configuration.displayType { + readerView.switchReaderDisplayType(configuration.displayType) + } + } + + private func startInitialLoadIfNeeded() { + guard !didStartInitialLoad, readerView.bounds.width > 0, readerView.bounds.height > 0 else { + return + } + didStartInitialLoad = true + loadPublication() + } + + private func loadPublication() { + showLoading() + let loadToken = UUID() + paginationToken = loadToken + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self = self else { return } + let parser = RDEPUBParser() + + do { + try parser.parse(epubURL: self.epubURL) + let publication = parser.makePublication() + let bookIdentifier = parser.metadata.identifier ?? self.epubURL.lastPathComponent + let restoreLocation = self.persistence?.loadLocation(for: bookIdentifier) + let bookmarks = self.persistence?.loadBookmarks(for: bookIdentifier) ?? [] + let highlights = self.persistence?.loadHighlights(for: bookIdentifier) ?? [] + + DispatchQueue.main.async { + guard self.paginationToken == loadToken else { return } + self.applyParsedPublication( + parser: parser, + publication: publication, + bookIdentifier: bookIdentifier, + restoreLocation: restoreLocation, + bookmarks: bookmarks, + highlights: highlights + ) + } + } catch { + DispatchQueue.main.async { + guard self.paginationToken == loadToken else { return } + self.handle(error: error) + } + } + } + } + + private func applyParsedPublication( + parser: RDEPUBParser, + publication: RDEPUBPublication, + bookIdentifier: String, + restoreLocation: RDEPUBLocation?, + bookmarks: [RDEPUBBookmark], + highlights: [RDEPUBHighlight] + ) { + self.parser = parser + self.publication = publication + self.currentBookIdentifier = bookIdentifier + self.activeBookmarks = bookmarks + self.activeHighlights = highlights + self.readingSession = RDEPUBReadingSession(publication: publication) + self.readingSession?.transition(to: .loading) + self.title = parser.metadata.title.isEmpty ? epubURL.deletingPathExtension().lastPathComponent : parser.metadata.title + applyReaderViewConfiguration() + updateReaderChrome() + delegate?.epubReader(self, didOpen: publication) + paginatePublication(restoreLocation: restoreLocation) + } + + private func paginatePublication(restoreLocation: RDEPUBLocation?) { + guard let parser, let publication, let readingSession else { return } + + isRepaginating = true + errorLabel.isHidden = true + showLoading() + let token = UUID() + paginationToken = token + + if publication.readingProfile == .textReflowable { + let renderer = resolvedTextRenderer() + let builder = RDEPUBTextBookBuilder(renderer: renderer) + let pageSize = currentTextPageSize() + let renderStyle = currentTextRenderStyle() + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self = self else { return } + do { + let textBook = try builder.build( + parser: parser, + publication: publication, + pageSize: pageSize, + style: renderStyle + ) + DispatchQueue.main.async { + guard self.paginationToken == token else { return } + self.applyTextBook(textBook, restoreLocation: restoreLocation) + } + } catch { + DispatchQueue.main.async { + guard self.paginationToken == token else { return } + self.handle(error: error) + } + } + } + return + } + + if publication.layout == .fixed { + let snapshot = readingSession.makePaginationSnapshot( + pageCounts: Array(repeating: 1, count: publication.spine.count), + preferences: currentPreferences(), + layoutContext: currentLayoutContext() + ) + applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation) + return + } + + let paginator = RDEPUBPaginator() + self.paginator = paginator + paginator.calculate( + parser: parser, + hostingView: ensurePaginationHostView(), + presentation: currentPreferences().presentationStyle(viewportSize: currentLayoutContext().viewportSize) + ) { [weak self] pageCounts in + guard let self = self, self.paginationToken == token else { return } + let snapshot = readingSession.makePaginationSnapshot( + pageCounts: pageCounts, + preferences: self.currentPreferences(), + layoutContext: self.currentLayoutContext() + ) + self.paginator = nil + self.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation) + } + } + + private func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) { + self.textBook = textBook + self.activePages = [] + self.activeChapters = [] + readingSession?.setActiveSnapshot((pages: [], chapters: [])) + + guard !textBook.pages.isEmpty else { + handle(error: RDEPUBParserError.emptySpine) + return + } + + finishPagination(restoreLocation: restoreLocation) + } + + private func applyPaginationSnapshot( + _ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]), + restoreLocation: RDEPUBLocation? + ) { + textBook = nil + activePages = snapshot.pages + activeChapters = snapshot.chapters + readingSession?.setActiveSnapshot(snapshot) + + guard !snapshot.pages.isEmpty else { + handle(error: RDEPUBParserError.emptySpine) + return + } + + finishPagination(restoreLocation: restoreLocation) + } + + private func finishPagination(restoreLocation: RDEPUBLocation?) { + isRepaginating = false + hideLoading() + readerView.reloadData() + if let targetLocation = restoreLocation { + restoreReadingLocation(targetLocation) + } else { + readerView.transitionToPage(pageNum: 0) + readingSession?.transition(to: .idle) + } + + if pendingViewportChangeReason != nil { + let pendingReason = pendingViewportChangeReason ?? .pendingRepagination + pendingViewportChangeReason = nil + DispatchQueue.main.async { [weak self] in + self?.handleViewportChangeIfNeeded(reason: pendingReason) + } + } + } + + private func repaginatePreservingCurrentLocation() { + guard publication != nil else { return } + let restoreLocation = currentVisibleLocation() ?? persistenceLocation() + paginatePublication(restoreLocation: restoreLocation) + } + + @discardableResult + fileprivate func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool { + if let textBook { + guard let publication, let targetPageNumber = textBook.pageNumber( + for: location, + resolver: publication.resourceResolver, + bookIdentifier: currentBookIdentifier + ) else { + readerView.transitionToPage(pageNum: 0) + readingSession?.transition(to: .idle) + return false + } + readerView.transitionToPage(pageNum: max(targetPageNumber - 1, 0), animated: animated) + readingSession?.transition(to: .idle) + return true + } + + guard let readingSession, + let targetPageNumber = readingSession.queueNavigation( + to: location, + relativeToSpineIndex: nil, + bookIdentifier: currentBookIdentifier + ) else { + readerView.transitionToPage(pageNum: 0) + self.readingSession?.transition(to: .idle) + return false + } + readerView.transitionToPage(pageNum: max(targetPageNumber - 1, 0), animated: animated) + return true + } + + fileprivate func currentVisibleLocation() -> RDEPUBLocation? { + if let textBook, readerView.currentPage >= 0 { + return textBook.location( + forPageNumber: readerView.currentPage + 1, + bookIdentifier: currentBookIdentifier + ) + } + return readingSession?.currentReadingLocation(bookIdentifier: currentBookIdentifier) + } + + private func persistenceLocation() -> RDEPUBLocation? { + guard let currentBookIdentifier else { return nil } + return persistence?.loadLocation(for: currentBookIdentifier) + } + + private func persist(location: RDEPUBLocation) { + guard let currentBookIdentifier else { return } + persistence?.saveLocation(location, for: currentBookIdentifier) + delegate?.epubReader(self, didUpdateLocation: location) + delegate?.epubReader(self, didUpdateCurrentTableOfContentsItem: resolvedCurrentTableOfContentsItem()) + updateBookmarkChrome() + } + + private func updateCurrentSelection(_ selection: RDEPUBSelection?) { + currentSelection = selection?.isEmpty == false ? selection : nil + bottomToolView.setAddHighlightEnabled(configuration.allowsHighlights && currentSelection != nil) + delegate?.epubReader(self, didChangeSelection: currentSelection) + } + + private func scopedSelection( + _ selection: RDEPUBSelection, + relativeToSpineIndex spineIndex: Int? + ) -> RDEPUBSelection? { + guard let publication else { return nil } + let normalizedLocation = publication.resourceResolver.normalizedLocation( + selection.location, + relativeToSpineIndex: spineIndex, + bookIdentifier: currentBookIdentifier + ) ?? RDEPUBLocation( + bookIdentifier: currentBookIdentifier, + href: selection.location.href, + progression: selection.location.progression, + lastProgression: selection.location.lastProgression, + fragment: selection.location.fragment + ) + return RDEPUBSelection( + bookIdentifier: currentBookIdentifier, + location: normalizedLocation, + text: selection.text, + rangeInfo: selection.rangeInfo, + createdAt: selection.createdAt + ) + } + + private func scopedHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? { + guard let publication else { return nil } + let normalizedLocation = publication.resourceResolver.normalizedLocation( + highlight.location, + relativeToSpineIndex: nil, + bookIdentifier: currentBookIdentifier + ) ?? RDEPUBLocation( + bookIdentifier: currentBookIdentifier, + href: highlight.location.href, + progression: highlight.location.progression, + lastProgression: highlight.location.lastProgression, + fragment: highlight.location.fragment + ) + return RDEPUBHighlight( + id: highlight.id, + bookIdentifier: currentBookIdentifier, + location: normalizedLocation, + text: highlight.text, + rangeInfo: highlight.rangeInfo, + style: highlight.style, + color: highlight.color, + note: highlight.note, + createdAt: highlight.createdAt + ) + } + + private func persistHighlightsAndRefreshContent() { + guard let currentBookIdentifier else { return } + persistence?.saveHighlights(activeHighlights, for: currentBookIdentifier) + delegate?.epubReader(self, didUpdateHighlights: activeHighlights) + updateReaderChrome() + refreshVisibleContentPreservingLocation() + } + + private func refreshVisibleContentPreservingLocation() { + let restoreLocation = currentVisibleLocation() ?? persistenceLocation() + readerView.reloadData() + if let restoreLocation { + _ = restoreReadingLocation(restoreLocation) + } + } + + private func rebuildExternalTextBook() { + guard let textFileURL else { return } + let restoreLocation = currentVisibleLocation() ?? persistenceLocation() + let pageSize = currentTextPageSize() + let style = currentTextRenderStyle() + let builder = RDPlainTextBookBuilder() + if let newBook = try? builder.build(textFileURL: textFileURL, pageSize: pageSize, style: style) { + applyTextBook(newBook, restoreLocation: restoreLocation) + } + } + + private func updateReaderChrome() { + topToolView.apply(theme: configuration.theme) + topToolView.setTitle(title ?? parser?.metadata.title ?? epubURL.deletingPathExtension().lastPathComponent) + topToolView.setBookmarkEnabled(currentBookIdentifier != nil) + bottomToolView.apply(theme: configuration.theme) + bottomToolView.updateVisibility( + showsTableOfContents: configuration.showsTableOfContents, + allowsHighlights: configuration.allowsHighlights, + showsSettingsPanel: configuration.showsSettingsPanel + ) + bottomToolView.setBookmarksEnabled(!activeBookmarks.isEmpty) + bottomToolView.setAddHighlightEnabled(configuration.allowsHighlights && currentSelection != nil) + bottomToolView.setHighlightsEnabled(configuration.allowsHighlights && !activeHighlights.isEmpty) + updateBookmarkChrome() + } + + private func presentHighlightsManager() { + guard configuration.allowsHighlights else { return } + guard !activeHighlights.isEmpty else { return } + + let highlightsController = RDEPUBReaderHighlightsViewController( + highlights: activeHighlights, + theme: configuration.theme, + sectionTitleProvider: { [weak self] highlight in + self?.titleForHighlight(highlight) + } + ) + highlightsController.onSelectHighlight = { [weak self, weak highlightsController] highlight in + guard let self else { return } + highlightsController?.dismiss(animated: true) { + _ = self.go(to: highlight.location) + } + } + highlightsController.onUpdateHighlight = { [weak self] highlight in + _ = self?.updateHighlightNote(id: highlight.id, note: highlight.note) + } + highlightsController.onDeleteHighlight = { [weak self] highlight in + _ = self?.removeHighlight(id: highlight.id) + } + + let navigationController = UINavigationController(rootViewController: highlightsController) + navigationController.modalPresentationStyle = .pageSheet + present(navigationController, animated: true) + } + + private func presentAnnotationCreation() { + guard configuration.allowsHighlights, + let currentSelection else { + return + } + presentAnnotationActionSheet(for: currentSelection) + } + + private func presentAnnotationActionSheet(for selection: RDEPUBSelection) { + let alert = UIAlertController(title: "创建标注", message: nil, preferredStyle: .actionSheet) + alert.addAction(UIAlertAction(title: "高亮", style: .default) { [weak self] _ in + self?.createAnnotation(from: selection, style: .highlight) + }) + alert.addAction(UIAlertAction(title: "划线", style: .default) { [weak self] _ in + self?.createAnnotation(from: selection, style: .underline) + }) + alert.addAction(UIAlertAction(title: "批注", style: .default) { [weak self] _ in + self?.presentAnnotationNoteEditor(for: selection) + }) + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + + if let popover = alert.popoverPresentationController { + popover.sourceView = bottomToolView + popover.sourceRect = bottomToolView.bounds + } + + present(alert, animated: true) + } + + private func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) { + guard let selection else { return } + switch action { + case .copy: + UIPasteboard.general.string = selection.text + updateCurrentSelection(nil) + case .highlight: + createAnnotation(from: selection, style: .highlight) + case .annotate: + presentAnnotationNoteEditor(for: selection) + } + } + + private func createAnnotation(from selection: RDEPUBSelection, style: RDEPUBHighlightStyle, note: String? = nil) { + _ = addAnnotation(from: selection, style: style, note: note) + } + + private func presentAnnotationNoteEditor(for selection: RDEPUBSelection) { + let alert = UIAlertController(title: "添加批注", message: selection.text, preferredStyle: .alert) + alert.addTextField { textField in + textField.placeholder = "输入批注内容" + } + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self, weak alert] _ in + self?.createAnnotation( + from: selection, + style: .highlight, + note: alert?.textFields?.first?.text + ) + }) + present(alert, animated: true) + } + + private func presentSettings() { + guard configuration.showsSettingsPanel else { return } + let settingsController = RDEPUBReaderSettingsViewController( + configuration: configuration, + brightness: currentBrightness + ) + settingsController.onBrightnessChange = { [weak self] brightness in + self?.setScreenBrightness(brightness) + } + settingsController.onFontSizeChange = { [weak self] fontSize in + self?.updateConfiguration { $0.fontSize = fontSize } + } + settingsController.onLineHeightChange = { [weak self] lineHeightMultiple in + self?.updateConfiguration { $0.lineHeightMultiple = lineHeightMultiple } + } + settingsController.onDisplayTypeChange = { [weak self] displayType in + self?.updateConfiguration { $0.displayType = displayType } + } + settingsController.onThemeChange = { [weak self] theme in + self?.updateConfiguration { $0.theme = theme } + } + + let navigationController = UINavigationController(rootViewController: settingsController) + navigationController.modalPresentationStyle = .pageSheet + present(navigationController, animated: true) + } + + private func titleForHighlight(_ highlight: RDEPUBHighlight) -> String? { + guard let publication, + let normalizedHighlightHref = publication.resourceResolver.normalizedHref(highlight.location.href) else { + return nil + } + + return flattenedTableOfContents.first { item in + let rawHref = item.href.components(separatedBy: "#").first ?? item.href + return publication.resourceResolver.normalizedHref(rawHref) == normalizedHighlightHref + }?.title + } + + private func updateConfiguration(_ update: (inout RDEPUBReaderConfiguration) -> Void) { + var nextConfiguration = configuration + update(&nextConfiguration) + configuration = nextConfiguration + } + + private func setScreenBrightness(_ brightness: CGFloat) { + currentBrightness = max(0, min(1, brightness)) + UIScreen.main.brightness = currentBrightness + persistReaderSettingsIfNeeded() + } + + private func persistReaderSettingsIfNeeded() { + let settings = RDEPUBReaderSettings.capture( + configuration: configuration, + brightness: currentBrightness + ) + persistence?.saveReaderSettings(settings) + } + + private func normalizedNote(_ note: String?) -> String? { + let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private func requiresRepagination( + from oldConfiguration: RDEPUBReaderConfiguration, + to newConfiguration: RDEPUBReaderConfiguration + ) -> Bool { + oldConfiguration.fontSize != newConfiguration.fontSize || + oldConfiguration.lineHeightMultiple != newConfiguration.lineHeightMultiple || + oldConfiguration.reflowableContentInsets != newConfiguration.reflowableContentInsets || + oldConfiguration.fixedContentInset != newConfiguration.fixedContentInset || + oldConfiguration.fixedLayoutFit != newConfiguration.fixedLayoutFit || + oldConfiguration.fixedLayoutSpreadMode != newConfiguration.fixedLayoutSpreadMode || + oldConfiguration.textRenderingEngine != newConfiguration.textRenderingEngine + } + + private func requiresVisibleRefresh( + from oldConfiguration: RDEPUBReaderConfiguration, + to newConfiguration: RDEPUBReaderConfiguration + ) -> Bool { + oldConfiguration.theme != newConfiguration.theme + } + + private func presentTableOfContents() { + guard configuration.showsTableOfContents else { return } + let items = flattenedTableOfContents + guard !items.isEmpty else { return } + + let chapterController = RDEPUBReaderChapterListController( + items: items, + currentItem: currentTableOfContentsItem, + theme: configuration.theme + ) + chapterController.onSelectItem = { [weak self] item in + guard let self else { return } + chapterController.dismiss(animated: true) { + _ = self.go(toTableOfContentsItem: item, animated: true) + } + } + + let navigationController = UINavigationController(rootViewController: chapterController) + navigationController.modalPresentationStyle = .pageSheet + present(navigationController, animated: true) + } + + private func handleBackAction() { + if let navigationController, navigationController.viewControllers.first != self { + navigationController.popViewController(animated: true) + return + } + dismiss(animated: true) + } + + private func resolvedCurrentTableOfContentsItem() -> RDEPUBReaderTableOfContentsItem? { + let items = flattenedTableOfContents + guard !items.isEmpty else { return nil } + + let currentPageNumber = max(readerView.currentPage + 1, 1) + let pageAnchoredMatch = items.last { item in + guard let pageNumber = item.pageNumber else { return false } + return pageNumber <= currentPageNumber + } + if let pageAnchoredMatch { + return pageAnchoredMatch + } + + guard let publication, + let currentLocation = currentVisibleLocation(), + let normalizedCurrentHref = publication.resourceResolver.normalizedHref(currentLocation.href) else { + return nil + } + + return items.last { item in + guard let normalizedItemHref = publication.resourceResolver.normalizedHref(item.href.components(separatedBy: "#").first ?? item.href) else { + return false + } + return normalizedItemHref == normalizedCurrentHref + } + } + + private func flattenedTableOfContentsItems( + from items: [EPUBTableOfContentsItem], + depth: Int = 0 + ) -> [RDEPUBReaderTableOfContentsItem] { + items.flatMap { item in + let location = RDEPUBLocation(bookIdentifier: currentBookIdentifier, href: item.href, progression: 0) + let pageNumber: Int? + if let textBook, let publication { + pageNumber = textBook.pageNumber( + for: location, + resolver: publication.resourceResolver, + bookIdentifier: currentBookIdentifier + ) + } else if let readingSession { + pageNumber = readingSession.pageIndex(for: location, bookIdentifier: currentBookIdentifier).map { $0 + 1 } + } else { + pageNumber = nil + } + + let current = RDEPUBReaderTableOfContentsItem( + title: item.title, + href: item.href, + depth: depth, + pageNumber: pageNumber + ) + return [current] + flattenedTableOfContentsItems(from: item.children, depth: depth + 1) + } + } + + private func currentLayoutContext() -> RDEPUBNavigatorLayoutContext { + let containerSize = readerView.bounds.size == .zero ? view.bounds.size : readerView.bounds.size + return RDEPUBNavigatorLayoutContext( + containerSize: containerSize, + pagesPerScreen: readerView.pagesPerScreen, + safeAreaInsets: view.safeAreaInsets, + userInterfaceIdiom: traitCollection.userInterfaceIdiom, + reflowableContentInsets: configuration.reflowableContentInsets + ) + } + + private func currentPreferences() -> RDEPUBPreferences { + configuration.makePreferences() + } + + private func currentTextPageSize() -> CGSize { + let viewportSize = currentLayoutContext().viewportSize + let insets = configuration.reflowableContentInsets + return CGSize( + width: max(viewportSize.width - insets.left - insets.right, 1), + height: max(viewportSize.height - insets.top - insets.bottom, 1) + ) + } + + private func currentTextRenderStyle() -> RDEPUBTextRenderStyle { + let font = UIFont.systemFont(ofSize: configuration.fontSize) + let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4) + return RDEPUBTextRenderStyle( + font: font, + lineSpacing: lineSpacing, + textColor: configuration.theme.contentTextColor, + backgroundColor: configuration.theme.contentBackgroundColor + ) + } + + private func resolvedTextRenderer() -> RDEPUBTextRenderer { + RDEPUBDTCoreTextRenderer() + } + + private func resolvedPageDirection() -> RDReaderView.PageDirection { + publication?.readingProgression == .rtl ? .rightToLeft : .leftToRight + } + + private func ensurePaginationHostView() -> UIView { + let viewportSize = currentLayoutContext().viewportSize + let hostFrame = CGRect(x: -viewportSize.width - 32, y: 0, width: viewportSize.width, height: viewportSize.height) + if paginationHostView.superview == nil { + view.addSubview(paginationHostView) + view.sendSubviewToBack(paginationHostView) + } + paginationHostView.frame = hostFrame + return paginationHostView + } + + private func request(for pageIndex: Int) -> RDEPUBRenderRequest? { + guard let publication, activePages.indices.contains(pageIndex) else { + return nil + } + let page = activePages[pageIndex] + let pendingLocation = readingSession?.pendingLocation(forPageNumber: pageIndex + 1, spineIndex: page.spineIndex) + return currentPreferences().renderRequest( + for: page, + publication: publication, + viewportSize: currentLayoutContext().viewportSize, + targetLocation: pendingLocation, + highlights: highlights(for: page), + searchPresentation: searchPresentation(for: page) + ) + } + + private func highlights(for page: EPUBPage) -> [RDEPUBHighlight] { + guard let publication else { return [] } + if let spread = page.fixedSpread { + let hrefs = Set(spread.resources.compactMap { publication.resourceResolver.normalizedHref($0.href) }) + return activeHighlights.filter { highlight in + guard let normalizedHref = publication.resourceResolver.normalizedHref(highlight.location.href) else { + return false + } + return hrefs.contains(normalizedHref) + } + } + + guard publication.spine.indices.contains(page.spineIndex) else { return [] } + let href = publication.spine[page.spineIndex].href + let normalizedHref = publication.resourceResolver.normalizedHref(href) + return activeHighlights.filter { highlight in + publication.resourceResolver.normalizedHref(highlight.location.href) == normalizedHref + } + } + + private func fallbackLocation(for pageIndex: Int) -> RDEPUBLocation? { + guard activePages.indices.contains(pageIndex) else { return nil } + return readingSession?.fallbackLocation(for: activePages[pageIndex], bookIdentifier: currentBookIdentifier) + } + + private func handle(error: Error) { + isRepaginating = false + hideLoading() + activePages = [] + activeChapters = [] + textBook = nil + readerView.reloadData() + errorLabel.text = error.localizedDescription + errorLabel.isHidden = false + delegate?.epubReader(self, didFailWithError: error) + } + + private func showLoading() { + errorLabel.isHidden = true + loadingIndicator.startAnimating() + } + + private func hideLoading() { + loadingIndicator.stopAnimating() + } + + private func currentViewportSignature() -> RDEPUBViewportSignature? { + let containerSize = readerView.bounds.size == .zero ? view.bounds.size : readerView.bounds.size + guard containerSize.width > 0, containerSize.height > 0 else { return nil } + let safeAreaInsets = view.safeAreaInsets + return RDEPUBViewportSignature( + width: containerSize.width, + height: containerSize.height, + safeTop: safeAreaInsets.top, + safeLeft: safeAreaInsets.left, + safeBottom: safeAreaInsets.bottom, + safeRight: safeAreaInsets.right + ) + } + + private func handleViewportChangeIfNeeded( + reason: RDEPUBViewportChangeReason, + viewportSignature: RDEPUBViewportSignature? = nil + ) { + guard didStartInitialLoad, + let signature = viewportSignature ?? currentViewportSignature() else { + return + } + + if isRepaginating { + pendingViewportChangeReason = reason + return + } + + if let lastAppliedViewportSignature, + !signature.differsSignificantly(from: lastAppliedViewportSignature) { + return + } + + lastAppliedViewportSignature = signature + + if isExternalTextBook { + rebuildExternalTextBook() + return + } + + guard publication != nil else { return } + repaginatePreservingCurrentLocation() + } +} + +extension RDEPUBReaderController { + @discardableResult + public func addBookmark(note: String? = nil) -> RDEPUBBookmark? { + guard let location = scopedBookmarkLocation(currentVisibleLocation()) else { + return nil + } + guard bookmark(matching: location) == nil else { + return nil + } + + let newBookmark = RDEPUBBookmark( + bookIdentifier: currentBookIdentifier, + location: location, + chapterTitle: titleForBookmarkLocation(location), + note: normalizedBookmarkNote(note) + ) + activeBookmarks.append(newBookmark) + persistBookmarksAndRefreshChrome() + return newBookmark + } + + @discardableResult + public func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? { + guard let location = scopedBookmarkLocation(currentVisibleLocation()) else { + return nil + } + + if let existingBookmark = bookmark(matching: location) { + _ = removeBookmark(id: existingBookmark.id) + return nil + } + + return addBookmark(note: note) + } + + @discardableResult + public func removeBookmark(id: String) -> RDEPUBBookmark? { + guard let index = activeBookmarks.firstIndex(where: { $0.id == id }) else { + return nil + } + let removed = activeBookmarks.remove(at: index) + persistBookmarksAndRefreshChrome() + return removed + } + + @discardableResult + public func go(toBookmarkID id: String, animated: Bool = true) -> Bool { + guard let bookmark = bookmark(withID: id) else { + return false + } + return restoreReadingLocation(bookmark.location, animated: animated) + } + + private func updateBookmarkChrome() { + topToolView.setBookmarkSelected(currentBookmark() != nil) + bottomToolView.setBookmarksEnabled(!activeBookmarks.isEmpty) + } + + private func presentBookmarksManager() { + guard !activeBookmarks.isEmpty else { return } + + let bookmarksController = RDEPUBReaderBookmarksViewController( + bookmarks: activeBookmarks, + theme: configuration.theme + ) + bookmarksController.onSelectBookmark = { [weak self, weak bookmarksController] bookmark in + guard let self else { return } + bookmarksController?.dismiss(animated: true) { + _ = self.restoreReadingLocation(bookmark.location, animated: true) + } + } + bookmarksController.onDeleteBookmark = { [weak self] bookmark in + _ = self?.removeBookmark(id: bookmark.id) + } + + let navigationController = UINavigationController(rootViewController: bookmarksController) + navigationController.modalPresentationStyle = .pageSheet + present(navigationController, animated: true) + } + + private func titleForBookmarkLocation(_ location: RDEPUBLocation) -> String? { + if let currentLocation = currentVisibleLocation(), + bookmarkHref(for: currentLocation) == bookmarkHref(for: location) { + return currentTableOfContentsItem?.title + } + + return flattenedTableOfContents.last { item in + bookmarkHref(forTableOfContentsHref: item.href) == bookmarkHref(for: location) + }?.title + } + + private func persistBookmarksAndRefreshChrome() { + guard let currentBookIdentifier else { return } + persistence?.saveBookmarks(activeBookmarks, for: currentBookIdentifier) + delegate?.epubReader(self, didUpdateBookmarks: activeBookmarks) + updateBookmarkChrome() + } + + private func currentBookmark() -> RDEPUBBookmark? { + guard let location = scopedBookmarkLocation(currentVisibleLocation()) else { + return nil + } + return bookmark(matching: location) + } + + private func bookmark(matching location: RDEPUBLocation?) -> RDEPUBBookmark? { + guard let location else { return nil } + return activeBookmarks.first { bookmarkMatchesLocation($0, location: location) } + } + + private func bookmarkMatchesLocation(_ bookmark: RDEPUBBookmark, location: RDEPUBLocation) -> Bool { + guard bookmarkHref(for: bookmark.location) == bookmarkHref(for: location) else { + return false + } + + if let bookmarkFragment = bookmark.location.fragment, + let locationFragment = location.fragment { + return bookmarkFragment == locationFragment + } + + let progressionDelta = abs(bookmark.location.navigationProgression - location.navigationProgression) + let threshold: Double = publication?.layout == .fixed ? 0.01 : 0.05 + return progressionDelta <= threshold + } + + private func bookmarkHref(for location: RDEPUBLocation) -> String { + publication?.resourceResolver.normalizedHref(location.href) ?? location.href + } + + private func bookmarkHref(forTableOfContentsHref href: String) -> String { + let rawHref = href.components(separatedBy: "#").first ?? href + return publication?.resourceResolver.normalizedHref(rawHref) ?? rawHref + } + + private func scopedBookmarkLocation(_ location: RDEPUBLocation?) -> RDEPUBLocation? { + guard let location else { return nil } + guard let publication else { + return RDEPUBLocation( + bookIdentifier: currentBookIdentifier, + href: location.href, + progression: location.progression, + lastProgression: location.lastProgression, + fragment: location.fragment + ) + } + + return publication.resourceResolver.normalizedLocation( + location, + relativeToSpineIndex: nil, + bookIdentifier: currentBookIdentifier + ) ?? RDEPUBLocation( + bookIdentifier: currentBookIdentifier, + href: location.href, + progression: location.progression, + lastProgression: location.lastProgression, + fragment: location.fragment + ) + } + + private func normalizedBookmarkNote(_ note: String?) -> String? { + let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + public func search(keyword: String) { + let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedKeyword.isEmpty else { + clearSearch() + return + } + + let matches = resolvedSearchMatches(for: normalizedKeyword) + searchState = RDEPUBSearchState( + keyword: normalizedKeyword, + matches: matches, + currentMatchIndex: matches.isEmpty ? nil : 0 + ) + notifySearchStateChanged() + + if matches.isEmpty { + refreshVisibleContentPreservingLocation() + } else { + _ = navigateToCurrentSearchMatch(animated: false) + } + } + + @discardableResult + public func searchNext() -> Bool { + advanceSearch(by: 1) + } + + @discardableResult + public func searchPrevious() -> Bool { + advanceSearch(by: -1) + } + + public func clearSearch() { + searchState = nil + notifySearchStateChanged() + refreshVisibleContentPreservingLocation() + } + + private func resolvedSearchMatches(for keyword: String) -> [RDEPUBSearchMatch] { + if let textBook, let publication { + return RDEPUBTextSearchEngine(textBook: textBook, publication: publication).search(keyword: keyword) + } + if let parser, let publication { + return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword) + } + return [] + } + + private func advanceSearch(by delta: Int) -> Bool { + guard var searchState, !searchState.matches.isEmpty else { + return false + } + + let currentIndex = searchState.currentMatchIndex ?? 0 + let nextIndex = (currentIndex + delta + searchState.matches.count) % searchState.matches.count + searchState.currentMatchIndex = nextIndex + self.searchState = searchState + notifySearchStateChanged() + return navigateToCurrentSearchMatch(animated: true) + } + + private func notifySearchStateChanged() { + delegate?.epubReader(self, didUpdateSearchResult: searchState?.result) + delegate?.epubReader(self, didChangeCurrentSearchMatch: searchState?.currentMatch) + } + + private func navigateToCurrentSearchMatch(animated: Bool) -> Bool { + guard let searchMatch = searchState?.currentMatch else { + refreshVisibleContentPreservingLocation() + return false + } + + if let targetPageNumber = pageNumber(for: searchMatch), + readerView.currentPage == targetPageNumber - 1 { + refreshVisibleContentPreservingLocation() + return true + } + + let location = RDEPUBLocation( + bookIdentifier: currentBookIdentifier, + href: searchMatch.href, + progression: searchMatch.progression, + lastProgression: searchMatch.progression, + fragment: nil + ) + return restoreReadingLocation(location, animated: animated) + } + + private func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? { + let location = RDEPUBLocation( + bookIdentifier: currentBookIdentifier, + href: searchMatch.href, + progression: searchMatch.progression, + lastProgression: searchMatch.progression, + fragment: nil + ) + + if let textBook, let publication { + return textBook.pageNumber( + for: location, + resolver: publication.resourceResolver, + bookIdentifier: currentBookIdentifier + ) + } + + return readingSession?.pageIndex(for: location, bookIdentifier: currentBookIdentifier).map { $0 + 1 } + } + + private func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? { + guard let searchState, let publication else { + return nil + } + + let pageHrefs: [String] + if let fixedSpread = page.fixedSpread { + pageHrefs = fixedSpread.resources.map { publication.resourceResolver.normalizedHref($0.href) ?? $0.href } + } else if publication.spine.indices.contains(page.spineIndex) { + pageHrefs = [publication.resourceResolver.normalizedHref(publication.spine[page.spineIndex].href) ?? publication.spine[page.spineIndex].href] + } else { + pageHrefs = [] + } + + guard !pageHrefs.isEmpty else { + return nil + } + + let currentMatch = searchState.currentMatch + let resources = pageHrefs.map { href in + let matchCount = searchState.matches.filter { $0.href == href }.count + let activeLocalMatchIndex = currentMatch?.href == href ? currentMatch?.localMatchIndex : nil + return RDEPUBSearchPresentationResource( + href: href, + matchCount: matchCount, + activeLocalMatchIndex: activeLocalMatchIndex + ) + } + return RDEPUBSearchPresentation(keyword: searchState.keyword, resources: resources) + } +} + +extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate { + public func pageCountOfReaderView(readerView: RDReaderView) -> Int { + textBook?.pages.count ?? activePages.count + } + + public func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView { + if let textBook, let page = textBook.page(at: pageNum + 1) { + let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView() + contentView.delegate = self + contentView.configure( + page: page, + pageNumber: pageNum + 1, + totalPages: textBook.pages.count, + configuration: configuration, + highlights: textHighlights(for: page), + searchState: searchState + ) + return contentView + } + + guard let publication, + let request = request(for: pageNum) else { + return containerView ?? UIView() + } + + let contentView = (containerView as? RDEPUBWebContentView) ?? RDEPUBWebContentView() + contentView.delegate = self + contentView.configure( + publication: publication, + request: request, + pageNumber: pageNum + 1, + totalPages: activePages.count, + theme: configuration.theme + ) + return contentView + } + + public func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? { + textBook == nil + ? NSStringFromClass(RDEPUBWebContentView.self) + : NSStringFromClass(RDEPUBTextContentView.self) + } + + private func textHighlights(for page: RDEPUBTextPage) -> [RDEPUBHighlight] { + activeHighlights.filter { $0.location.href == page.href } + } + + public func topToolView(readerView: RDReaderView) -> UIView? { + topToolView + } + + public func bottomToolView(readerView: RDReaderView) -> UIView? { + bottomToolView + } + + public func pageNum(readerView: RDReaderView, pageNum: Int) { + updateCurrentSelection(nil) + + let totalPages = pageCountOfReaderView(readerView: readerView) + if totalPages > 0, pageNum == totalPages - 1 { + delegate?.epubReaderDidReachEnd(self) + } + + if let textBook, + let location = textBook.location(forPageNumber: pageNum + 1, bookIdentifier: currentBookIdentifier) { + persist(location: location) + readingSession?.transition(to: .idle) + return + } + + if let location = fallbackLocation(for: pageNum) { + persist(location: location) + } + if readingSession?.navigatorState == .jumping || readingSession?.navigatorState == .moving { + readingSession?.transition(to: .idle) + } + } + + public func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool) { + _ = isLandscape + } +} + +extension RDEPUBReaderController: RDEPUBWebContentViewDelegate { + func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) { + guard readerView.currentPage >= 0, + activePages.indices.contains(readerView.currentPage) else { + return + } + + let currentPage = activePages[readerView.currentPage] + guard readingSession?.pageContains(spineIndex: spineIndex, in: currentPage) == true else { + return + } + + persist(location: location) + readingSession?.updateReadingContext( + pageNumber: readerView.currentPage + 1, + location: location, + spineIndex: spineIndex, + chapterIndex: currentPage.chapterIndex, + bookIdentifier: currentBookIdentifier + ) + } + + func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) { + if let selection { + updateCurrentSelection(scopedSelection(selection, relativeToSpineIndex: spineIndex)) + } else { + updateCurrentSelection(nil) + } + } + + func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) { + handleSelectionMenuAction(action, selection: currentSelection) + } + + func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) { + guard let readingSession, + let pageNumber = readingSession.queueNavigation( + to: location, + relativeToSpineIndex: fromSpineIndex, + bookIdentifier: currentBookIdentifier + ) else { + return + } + readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: true) + } + + func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) { + delegate?.epubReader(self, didActivateExternalLink: url) + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + + func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) { + print("EPUB JS Error: \(message)") + } +} + +extension RDEPUBReaderController: RDEPUBTextContentViewDelegate { + func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) { + guard let selection else { + updateCurrentSelection(nil) + return + } + updateCurrentSelection(normalizedTextSelection(selection)) + } + + func textContentView(_ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) { + handleSelectionMenuAction(action, selection: currentSelection) + contentView.clearSelection() + } + + private func normalizedTextSelection(_ selection: RDEPUBSelection) -> RDEPUBSelection? { + guard let textBook, + let chapter = textBook.chapters.first(where: { $0.href == selection.location.href }) else { + return scopedSelection(selection, relativeToSpineIndex: nil) + } + guard let payload = RDEPUBTextOffsetRangeInfo.decode(from: selection.rangeInfo) else { + return scopedSelection(selection, relativeToSpineIndex: nil) + } + + let lastOffset = max(chapter.attributedContent.length - 1, 1) + let start = max(0, min(payload.start, lastOffset)) + let end = max(start, min(payload.end, lastOffset)) + return RDEPUBSelection( + bookIdentifier: currentBookIdentifier, + location: RDEPUBLocation( + bookIdentifier: currentBookIdentifier, + href: selection.location.href, + progression: Double(start) / Double(lastOffset), + lastProgression: Double(end) / Double(lastOffset), + fragment: nil + ), + text: selection.text, + rangeInfo: selection.rangeInfo, + createdAt: selection.createdAt + ) + } +} + +extension RDEPUBReaderController: UIGestureRecognizerDelegate { + public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + true + } +} diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderDelegate.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderDelegate.swift new file mode 100644 index 0000000..9acf29a --- /dev/null +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderDelegate.swift @@ -0,0 +1,31 @@ +import UIKit + +public protocol RDEPUBReaderDelegate: AnyObject { + func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) + func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) + func epubReaderDidReachEnd(_ reader: UIViewController) + func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?) + func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight]) + func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark]) + func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?) + func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?) + func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?) + func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) + func epubReader(_ reader: UIViewController, didFailWithError error: Error) + func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView) +} + +public extension RDEPUBReaderDelegate { + func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {} + func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {} + func epubReaderDidReachEnd(_ reader: UIViewController) {} + func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?) {} + func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight]) {} + func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark]) {} + func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?) {} + func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?) {} + func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?) {} + func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) {} + func epubReader(_ reader: UIViewController, didFailWithError error: Error) {} + func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView) {} +} diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderHighlightsViewController.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderHighlightsViewController.swift new file mode 100644 index 0000000..b46a42e --- /dev/null +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderHighlightsViewController.swift @@ -0,0 +1,332 @@ +import UIKit + +final class RDEPUBReaderHighlightsViewController: UITableViewController { + var onSelectHighlight: ((RDEPUBHighlight) -> Void)? + var onUpdateHighlight: ((RDEPUBHighlight) -> Void)? + var onDeleteHighlight: ((RDEPUBHighlight) -> Void)? + + private var highlights: [RDEPUBHighlight] + private let theme: RDEPUBReaderTheme + private let sectionTitleProvider: (RDEPUBHighlight) -> String? + private let filterControl = UISegmentedControl(items: ["全部", "批注", "高亮"]) + + private var filteredHighlights: [RDEPUBHighlight] { + switch filterControl.selectedSegmentIndex { + case 1: + return highlights.filter(\.hasNote) + case 2: + return highlights.filter { $0.style == .highlight } + default: + return highlights + } + } + + init( + highlights: [RDEPUBHighlight], + theme: RDEPUBReaderTheme, + sectionTitleProvider: @escaping (RDEPUBHighlight) -> String? + ) { + self.highlights = highlights.sorted { $0.createdAt > $1.createdAt } + self.theme = theme + self.sectionTitleProvider = sectionTitleProvider + super.init(style: .plain) + title = "标注" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.tableFooterView = UIView(frame: .zero) + tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) + configureFilterControl() + applyTheme() + updateEmptyState() + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + filteredHighlights.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cellIdentifier = "HighlightCell" + let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier) + let highlight = filteredHighlights[indexPath.row] + + cell.backgroundColor = theme.contentBackgroundColor + cell.textLabel?.textColor = theme.contentTextColor + cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium) + cell.textLabel?.numberOfLines = 2 + cell.textLabel?.text = titleText(for: highlight) + + cell.detailTextLabel?.textColor = theme.contentTextColor.withAlphaComponent(0.7) + cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12) + cell.detailTextLabel?.numberOfLines = 3 + cell.detailTextLabel?.text = detailText(for: highlight) + cell.accessoryType = .disclosureIndicator + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + presentActions(for: filteredHighlights[indexPath.row], sourceIndexPath: indexPath) + } + + private func configureFilterControl() { + filterControl.selectedSegmentIndex = 0 + filterControl.addTarget(self, action: #selector(filterChangedAction), for: .valueChanged) + navigationItem.titleView = filterControl + } + + private func applyTheme() { + tableView.backgroundColor = theme.contentBackgroundColor + navigationController?.navigationBar.tintColor = theme.toolControlTextColor + navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor + navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor] + } + + private func updateEmptyState() { + guard filteredHighlights.isEmpty else { + tableView.backgroundView = nil + return + } + + let label = UILabel() + label.text = emptyStateText() + label.textAlignment = .center + label.textColor = theme.contentTextColor.withAlphaComponent(0.7) + label.numberOfLines = 0 + tableView.backgroundView = label + } + + @objc private func filterChangedAction() { + tableView.reloadData() + updateEmptyState() + } + + private func emptyStateText() -> String { + switch filterControl.selectedSegmentIndex { + case 1: + return "暂无批注" + case 2: + return "暂无划线" + default: + return "暂无标注" + } + } + + private func detailText(for highlight: RDEPUBHighlight) -> String { + let style = styleDescription(for: highlight) + let chapter = sectionTitleProvider(highlight) + let note = normalizedNote(highlight.note) + let parts = [style, chapter, note].compactMap { $0 } + if !parts.isEmpty { + return parts.joined(separator: "\n") + } + return highlight.location.href + } + + private func titleText(for highlight: RDEPUBHighlight) -> String { + let text = highlight.text.trimmingCharacters(in: .whitespacesAndNewlines) + if highlight.hasNote { + return "批注: \(text)" + } + return text + } + + private func styleDescription(for highlight: RDEPUBHighlight) -> String { + switch highlight.style { + case .highlight: + return highlight.hasNote ? "高亮批注" : "高亮" + case .underline: + return highlight.hasNote ? "划线批注" : "划线" + } + } + + private func presentActions(for highlight: RDEPUBHighlight, sourceIndexPath: IndexPath) { + let alert = UIAlertController(title: "标注管理", message: nil, preferredStyle: .actionSheet) + alert.addAction(UIAlertAction(title: "跳转到位置", style: .default) { [weak self] _ in + self?.onSelectHighlight?(highlight) + }) + alert.addAction(UIAlertAction(title: "编辑批注", style: .default) { [weak self] _ in + self?.presentNoteEditor(for: highlight) + }) + alert.addAction(UIAlertAction(title: "删除标注", style: .destructive) { [weak self] _ in + self?.deleteHighlight(highlight) + }) + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + + if let popover = alert.popoverPresentationController, + let cell = tableView.cellForRow(at: sourceIndexPath) { + popover.sourceView = cell + popover.sourceRect = cell.bounds + } + + present(alert, animated: true) + } + + private func presentNoteEditor(for highlight: RDEPUBHighlight) { + let alert = UIAlertController(title: "编辑批注", message: nil, preferredStyle: .alert) + alert.addTextField { textField in + textField.placeholder = "输入批注内容" + textField.text = highlight.note + } + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self, weak alert] _ in + guard let self, + let note = alert?.textFields?.first?.text, + let index = self.highlights.firstIndex(where: { $0.id == highlight.id }) else { + return + } + + var updated = highlight + updated.note = self.normalizedNote(note) + self.highlights[index] = updated + self.tableView.reloadData() + self.onUpdateHighlight?(updated) + self.updateEmptyState() + }) + present(alert, animated: true) + } + + private func deleteHighlight(_ highlight: RDEPUBHighlight) { + guard let index = highlights.firstIndex(where: { $0.id == highlight.id }) else { return } + highlights.remove(at: index) + tableView.reloadData() + onDeleteHighlight?(highlight) + updateEmptyState() + } + + private func normalizedNote(_ note: String?) -> String? { + let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } +} + +final class RDEPUBReaderBookmarksViewController: UITableViewController { + var onSelectBookmark: ((RDEPUBBookmark) -> Void)? + var onDeleteBookmark: ((RDEPUBBookmark) -> Void)? + + private var bookmarks: [RDEPUBBookmark] + private let theme: RDEPUBReaderTheme + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm" + return formatter + }() + + init(bookmarks: [RDEPUBBookmark], theme: RDEPUBReaderTheme) { + self.bookmarks = bookmarks.sorted { $0.createdAt > $1.createdAt } + self.theme = theme + super.init(style: .plain) + title = "书签" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.tableFooterView = UIView(frame: .zero) + tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) + applyTheme() + updateEmptyState() + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + bookmarks.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cellIdentifier = "BookmarkCell" + let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier) + let bookmark = bookmarks[indexPath.row] + + cell.backgroundColor = theme.contentBackgroundColor + cell.textLabel?.textColor = theme.contentTextColor + cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium) + cell.textLabel?.numberOfLines = 2 + cell.textLabel?.text = titleText(for: bookmark) + + cell.detailTextLabel?.textColor = theme.contentTextColor.withAlphaComponent(0.7) + cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12) + cell.detailTextLabel?.numberOfLines = 3 + cell.detailTextLabel?.text = detailText(for: bookmark) + cell.accessoryType = .disclosureIndicator + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + presentActions(for: bookmarks[indexPath.row], sourceIndexPath: indexPath) + } + + private func applyTheme() { + tableView.backgroundColor = theme.contentBackgroundColor + navigationController?.navigationBar.tintColor = theme.toolControlTextColor + navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor + navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor] + } + + private func updateEmptyState() { + guard bookmarks.isEmpty == false else { + let label = UILabel() + label.text = "暂无书签" + label.textAlignment = .center + label.textColor = theme.contentTextColor.withAlphaComponent(0.7) + label.numberOfLines = 0 + tableView.backgroundView = label + return + } + tableView.backgroundView = nil + } + + private func titleText(for bookmark: RDEPUBBookmark) -> String { + if let chapterTitle = bookmark.chapterTitle, !chapterTitle.isEmpty { + return chapterTitle + } + return bookmark.location.href + } + + private func detailText(for bookmark: RDEPUBBookmark) -> String { + let parts = [ + dateFormatter.string(from: bookmark.createdAt), + bookmark.note?.trimmingCharacters(in: .whitespacesAndNewlines), + bookmark.location.href + ].compactMap { value -> String? in + guard let value, !value.isEmpty else { return nil } + return value + } + return parts.joined(separator: "\n") + } + + private func presentActions(for bookmark: RDEPUBBookmark, sourceIndexPath: IndexPath) { + let alert = UIAlertController(title: "书签管理", message: nil, preferredStyle: .actionSheet) + alert.addAction(UIAlertAction(title: "跳转到位置", style: .default) { [weak self] _ in + self?.onSelectBookmark?(bookmark) + }) + alert.addAction(UIAlertAction(title: "删除书签", style: .destructive) { [weak self] _ in + self?.deleteBookmark(bookmark) + }) + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + + if let popover = alert.popoverPresentationController, + let cell = tableView.cellForRow(at: sourceIndexPath) { + popover.sourceView = cell + popover.sourceRect = cell.bounds + } + + present(alert, animated: true) + } + + private func deleteBookmark(_ bookmark: RDEPUBBookmark) { + guard let index = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else { return } + bookmarks.remove(at: index) + tableView.reloadData() + onDeleteBookmark?(bookmark) + updateEmptyState() + } +} diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift new file mode 100644 index 0000000..c5f5f34 --- /dev/null +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift @@ -0,0 +1,110 @@ +import Foundation + +public protocol RDEPUBReaderPersistence: AnyObject { + func loadLocation(for bookIdentifier: String) -> RDEPUBLocation? + func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String) + func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] + func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) + func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight] + func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String) + func loadReaderSettings() -> RDEPUBReaderSettings? + func saveReaderSettings(_ settings: RDEPUBReaderSettings) +} + +public extension RDEPUBReaderPersistence { + func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] { + _ = bookIdentifier + return [] + } + + func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) { + _ = bookmarks + _ = bookIdentifier + } + + func loadReaderSettings() -> RDEPUBReaderSettings? { + nil + } + + func saveReaderSettings(_ settings: RDEPUBReaderSettings) { + _ = settings + } +} + +public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence { + private let defaults: UserDefaults + private let locationPrefix: String + private let bookmarksPrefix: String + private let highlightsPrefix: String + private let settingsKey: String + + public init( + defaults: UserDefaults = .standard, + locationPrefix: String = "ssreader.epub.location.", + bookmarksPrefix: String = "ssreader.epub.bookmarks.", + highlightsPrefix: String = "ssreader.epub.highlights.", + settingsKey: String = "ssreader.epub.settings" + ) { + self.defaults = defaults + self.locationPrefix = locationPrefix + self.bookmarksPrefix = bookmarksPrefix + self.highlightsPrefix = highlightsPrefix + self.settingsKey = settingsKey + } + + public func loadLocation(for bookIdentifier: String) -> RDEPUBLocation? { + guard let data = defaults.data(forKey: locationPrefix + bookIdentifier) else { + return nil + } + return try? JSONDecoder().decode(RDEPUBLocation.self, from: data) + } + + public func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String) { + guard let data = try? JSONEncoder().encode(location) else { + return + } + defaults.set(data, forKey: locationPrefix + bookIdentifier) + } + + public func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] { + guard let data = defaults.data(forKey: bookmarksPrefix + bookIdentifier) else { + return [] + } + return (try? JSONDecoder().decode([RDEPUBBookmark].self, from: data)) ?? [] + } + + public func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) { + guard let data = try? JSONEncoder().encode(bookmarks) else { + return + } + defaults.set(data, forKey: bookmarksPrefix + bookIdentifier) + } + + public func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight] { + guard let data = defaults.data(forKey: highlightsPrefix + bookIdentifier) else { + return [] + } + return (try? JSONDecoder().decode([RDEPUBHighlight].self, from: data)) ?? [] + } + + public func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String) { + guard let data = try? JSONEncoder().encode(highlights) else { + return + } + defaults.set(data, forKey: highlightsPrefix + bookIdentifier) + } + + public func loadReaderSettings() -> RDEPUBReaderSettings? { + guard let data = defaults.data(forKey: settingsKey) else { + return nil + } + return try? JSONDecoder().decode(RDEPUBReaderSettings.self, from: data) + } + + public func saveReaderSettings(_ settings: RDEPUBReaderSettings) { + guard let data = try? JSONEncoder().encode(settings) else { + return + } + defaults.set(data, forKey: settingsKey) + } +} diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderSettings.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderSettings.swift new file mode 100644 index 0000000..0423094 --- /dev/null +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderSettings.swift @@ -0,0 +1,129 @@ +import UIKit + +public enum RDEPUBReaderDisplayMode: String, Codable, Equatable { + case pageCurl + case horizontalScroll + case verticalScroll + case horizontalCoverScroll + + init(displayType: RDReaderView.DisplayType) { + switch displayType { + case .pageCurl: + self = .pageCurl + case .horizontalScroll: + self = .horizontalScroll + case .verticalScroll: + self = .verticalScroll + } + } + + var displayType: RDReaderView.DisplayType { + switch self { + case .pageCurl: + return .pageCurl + case .horizontalScroll, .horizontalCoverScroll: + return .horizontalScroll + case .verticalScroll: + return .verticalScroll + } + } +} + +public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable { + case light + case yellow + case green + case pink + case blue + case dark + + public var theme: RDEPUBReaderTheme { + switch self { + case .light: + return .light + case .yellow: + return .yellow + case .green: + return .green + case .pink: + return .pink + case .blue: + return .blue + case .dark: + return .dark + } + } + + public init?(theme: RDEPUBReaderTheme) { + switch theme { + case .light: + self = .light + case .yellow: + self = .yellow + case .green: + self = .green + case .pink: + self = .pink + case .blue: + self = .blue + case .dark: + self = .dark + default: + return nil + } + } +} + +public struct RDEPUBReaderSettings: Codable, Equatable { + public var brightness: CGFloat? + public var fontSize: CGFloat? + public var lineHeightMultiple: CGFloat? + public var displayMode: RDEPUBReaderDisplayMode? + public var themePreset: RDEPUBReaderThemePreset? + + public init( + brightness: CGFloat? = nil, + fontSize: CGFloat? = nil, + lineHeightMultiple: CGFloat? = nil, + displayMode: RDEPUBReaderDisplayMode? = nil, + themePreset: RDEPUBReaderThemePreset? = nil + ) { + self.brightness = brightness + self.fontSize = fontSize + self.lineHeightMultiple = lineHeightMultiple + self.displayMode = displayMode + self.themePreset = themePreset + } + + public func applying(to configuration: RDEPUBReaderConfiguration) -> RDEPUBReaderConfiguration { + var resolvedConfiguration = configuration + + if let fontSize { + resolvedConfiguration.fontSize = fontSize + } + if let lineHeightMultiple { + resolvedConfiguration.lineHeightMultiple = lineHeightMultiple + } + if let displayMode { + resolvedConfiguration.displayType = displayMode.displayType + } + if let themePreset { + resolvedConfiguration.theme = themePreset.theme + } + + return resolvedConfiguration + } + + public static func capture( + configuration: RDEPUBReaderConfiguration, + brightness: CGFloat + ) -> RDEPUBReaderSettings { + RDEPUBReaderSettings( + brightness: max(0, min(1, brightness)), + fontSize: configuration.fontSize, + lineHeightMultiple: configuration.lineHeightMultiple, + displayMode: RDEPUBReaderDisplayMode(displayType: configuration.displayType), + themePreset: RDEPUBReaderThemePreset(theme: configuration.theme) + ) + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderSettingsViewController.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderSettingsViewController.swift new file mode 100644 index 0000000..d4ece6d --- /dev/null +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderSettingsViewController.swift @@ -0,0 +1,295 @@ +import UIKit + +final class RDEPUBReaderSettingsViewController: UIViewController { + var onBrightnessChange: ((CGFloat) -> Void)? + var onFontSizeChange: ((CGFloat) -> Void)? + var onLineHeightChange: ((CGFloat) -> Void)? + var onDisplayTypeChange: ((RDReaderView.DisplayType) -> Void)? + var onThemeChange: ((RDEPUBReaderTheme) -> Void)? + + private enum ThemePreset: Int, CaseIterable { + case light + case yellow + case green + case pink + case blue + case dark + + var title: String { + switch self { + case .light: return "浅色" + case .yellow: return "米黄" + case .green: return "青绿" + case .pink: return "粉色" + case .blue: return "蓝灰" + case .dark: return "夜间" + } + } + + var theme: RDEPUBReaderTheme { + switch self { + case .light: return .light + case .yellow: return .yellow + case .green: return .green + case .pink: return .pink + case .blue: return .blue + case .dark: return .dark + } + } + } + + private let scrollView = UIScrollView() + private let contentStack: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = 20 + return stackView + }() + private let brightnessSlider = UISlider() + private let fontValueLabel = UILabel() + private let decreaseFontButton = UIButton(type: .system) + private let increaseFontButton = UIButton(type: .system) + private let lineHeightControl = UISegmentedControl(items: ["紧凑", "标准", "宽松"]) + private let displayTypeControl = UISegmentedControl(items: ["仿真", "横滑", "竖滑"]) + private let themeStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.distribution = .fillEqually + stackView.spacing = 12 + return stackView + }() + private var themeButtons: [UIButton] = [] + + private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9] + private var currentConfiguration: RDEPUBReaderConfiguration + + init(configuration: RDEPUBReaderConfiguration, brightness: CGFloat) { + self.currentConfiguration = configuration + super.init(nibName: nil, bundle: nil) + brightnessSlider.value = Float(brightness) + title = "样式设置" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupNavigationItems() + setupViews() + syncControls() + applyTheme(currentConfiguration.theme) + } + + private func setupNavigationItems() { + navigationItem.rightBarButtonItem = UIBarButtonItem(title: "完成", style: .done, target: self, action: #selector(doneAction)) + } + + private func setupViews() { + view.addSubview(scrollView) + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(contentStack) + contentStack.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + contentStack.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 16), + contentStack.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -16), + contentStack.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: 20), + contentStack.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -24), + contentStack.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -32) + ]) + + brightnessSlider.minimumValue = 0 + brightnessSlider.maximumValue = 1 + brightnessSlider.addTarget(self, action: #selector(brightnessChanged(_:)), for: .valueChanged) + + fontValueLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 16, weight: .semibold) + fontValueLabel.textAlignment = .center + fontValueLabel.setContentHuggingPriority(.required, for: .horizontal) + + configureFontButton(decreaseFontButton, title: "A-") + configureFontButton(increaseFontButton, title: "A+") + decreaseFontButton.addTarget(self, action: #selector(decreaseFontAction), for: .touchUpInside) + increaseFontButton.addTarget(self, action: #selector(increaseFontAction), for: .touchUpInside) + + lineHeightControl.addTarget(self, action: #selector(lineHeightChanged(_:)), for: .valueChanged) + displayTypeControl.addTarget(self, action: #selector(displayTypeChanged(_:)), for: .valueChanged) + + ThemePreset.allCases.forEach { preset in + let button = UIButton(type: .system) + button.tag = preset.rawValue + button.layer.cornerRadius = 18 + button.layer.borderWidth = 1.5 + button.backgroundColor = preset.theme.contentBackgroundColor + button.accessibilityLabel = preset.title + button.addTarget(self, action: #selector(themeButtonAction(_:)), for: .touchUpInside) + themeButtons.append(button) + themeStackView.addArrangedSubview(button) + NSLayoutConstraint.activate([ + button.heightAnchor.constraint(equalToConstant: 36) + ]) + } + + contentStack.addArrangedSubview(makeSection(title: "亮度", content: brightnessSlider)) + contentStack.addArrangedSubview(makeSection(title: "字号", content: makeFontSizeRow())) + contentStack.addArrangedSubview(makeSection(title: "行距", content: lineHeightControl)) + contentStack.addArrangedSubview(makeSection(title: "翻页方式", content: displayTypeControl)) + contentStack.addArrangedSubview(makeSection(title: "主题", content: themeStackView)) + } + + private func makeSection(title: String, content: UIView) -> UIView { + let container = UIStackView() + container.axis = .vertical + container.spacing = 10 + + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = UIFont.systemFont(ofSize: 14, weight: .semibold) + + content.translatesAutoresizingMaskIntoConstraints = false + container.addArrangedSubview(titleLabel) + container.addArrangedSubview(content) + return container + } + + private func makeFontSizeRow() -> UIView { + let stackView = UIStackView(arrangedSubviews: [decreaseFontButton, fontValueLabel, increaseFontButton]) + stackView.axis = .horizontal + stackView.alignment = .center + stackView.distribution = .fill + stackView.spacing = 12 + + NSLayoutConstraint.activate([ + decreaseFontButton.widthAnchor.constraint(equalToConstant: 64), + increaseFontButton.widthAnchor.constraint(equalToConstant: 64), + decreaseFontButton.heightAnchor.constraint(equalToConstant: 36), + increaseFontButton.heightAnchor.constraint(equalToConstant: 36) + ]) + return stackView + } + + private func configureFontButton(_ button: UIButton, title: String) { + button.setTitle(title, for: .normal) + button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .semibold) + button.layer.cornerRadius = 18 + button.layer.borderWidth = 1 + } + + private func syncControls() { + fontValueLabel.text = String(Int(currentConfiguration.fontSize.rounded())) + + let lineHeightIndex = lineHeightValues.enumerated().min { abs($0.element - currentConfiguration.lineHeightMultiple) < abs($1.element - currentConfiguration.lineHeightMultiple) }?.offset ?? 1 + lineHeightControl.selectedSegmentIndex = lineHeightIndex + + switch currentConfiguration.displayType { + case .pageCurl: + displayTypeControl.selectedSegmentIndex = 0 + case .horizontalScroll: + displayTypeControl.selectedSegmentIndex = 1 + case .verticalScroll: + displayTypeControl.selectedSegmentIndex = 2 + } + + let selectedPreset = ThemePreset.allCases.first(where: { $0.theme == currentConfiguration.theme }) ?? .light + updateThemeSelection(selectedPreset) + } + + private func applyTheme(_ theme: RDEPUBReaderTheme) { + view.backgroundColor = theme.contentBackgroundColor + scrollView.backgroundColor = theme.contentBackgroundColor + contentStack.arrangedSubviews + .compactMap { $0 as? UIStackView } + .flatMap { $0.arrangedSubviews } + .compactMap { $0 as? UILabel } + .forEach { $0.textColor = theme.contentTextColor } + + fontValueLabel.textColor = theme.contentTextColor + [decreaseFontButton, increaseFontButton].forEach { button in + button.setTitleColor(theme.toolControlTextColor, for: .normal) + button.layer.borderColor = theme.toolControlBorderUnselectColor.cgColor + button.backgroundColor = theme.toolBackgroundColor + } + + [lineHeightControl, displayTypeControl].forEach { control in + control.backgroundColor = theme.toolBackgroundColor + if #available(iOS 13.0, *) { + control.selectedSegmentTintColor = theme.toolControlTextColor.withAlphaComponent(0.14) + } else { + control.tintColor = theme.toolControlTextColor + } + control.setTitleTextAttributes([.foregroundColor: theme.contentTextColor], for: .normal) + control.setTitleTextAttributes([.foregroundColor: theme.toolControlTextColor], for: .selected) + } + + navigationController?.navigationBar.tintColor = theme.toolControlTextColor + navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor + navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor] + updateThemeSelection(ThemePreset.allCases.first(where: { $0.theme == theme }) ?? .light) + } + + private func updateThemeSelection(_ preset: ThemePreset) { + themeButtons.forEach { button in + let isSelected = button.tag == preset.rawValue + button.layer.borderWidth = isSelected ? 2 : 1 + button.layer.borderColor = isSelected ? currentConfiguration.theme.toolControlTextColor.cgColor : currentConfiguration.theme.toolControlBorderUnselectColor.cgColor + } + } + + @objc private func doneAction() { + dismiss(animated: true) + } + + @objc private func brightnessChanged(_ slider: UISlider) { + onBrightnessChange?(CGFloat(slider.value)) + } + + @objc private func decreaseFontAction() { + let nextValue = max(12, currentConfiguration.fontSize - 1) + guard nextValue != currentConfiguration.fontSize else { return } + currentConfiguration.fontSize = nextValue + fontValueLabel.text = String(Int(nextValue.rounded())) + onFontSizeChange?(nextValue) + } + + @objc private func increaseFontAction() { + let nextValue = min(36, currentConfiguration.fontSize + 1) + guard nextValue != currentConfiguration.fontSize else { return } + currentConfiguration.fontSize = nextValue + fontValueLabel.text = String(Int(nextValue.rounded())) + onFontSizeChange?(nextValue) + } + + @objc private func lineHeightChanged(_ control: UISegmentedControl) { + let index = max(0, min(control.selectedSegmentIndex, lineHeightValues.count - 1)) + let value = lineHeightValues[index] + currentConfiguration.lineHeightMultiple = value + onLineHeightChange?(value) + } + + @objc private func displayTypeChanged(_ control: UISegmentedControl) { + let displayType: RDReaderView.DisplayType + switch control.selectedSegmentIndex { + case 1: + displayType = .horizontalScroll + case 2: + displayType = .verticalScroll + default: + displayType = .pageCurl + } + currentConfiguration.displayType = displayType + onDisplayTypeChange?(displayType) + } + + @objc private func themeButtonAction(_ sender: UIButton) { + guard let preset = ThemePreset(rawValue: sender.tag) else { return } + currentConfiguration.theme = preset.theme + applyTheme(preset.theme) + onThemeChange?(preset.theme) + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderTableOfContentsItem.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderTableOfContentsItem.swift new file mode 100644 index 0000000..cb00988 --- /dev/null +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderTableOfContentsItem.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct RDEPUBReaderTableOfContentsItem: Equatable { + public var title: String + public var href: String + public var depth: Int + public var pageNumber: Int? + + public init(title: String, href: String, depth: Int, pageNumber: Int? = nil) { + self.title = title + self.href = href + self.depth = depth + self.pageNumber = pageNumber + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderTheme.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderTheme.swift new file mode 100644 index 0000000..58caa9b --- /dev/null +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderTheme.swift @@ -0,0 +1,90 @@ +import UIKit + +public struct RDEPUBReaderTheme: Equatable { + public var contentBackgroundColor: UIColor + public var contentTextColor: UIColor + public var toolBackgroundColor: UIColor + public var toolControlTextColor: UIColor + public var toolControlBorderUnselectColor: UIColor + public var toolLineColor: UIColor + + public init( + contentBackgroundColor: UIColor, + contentTextColor: UIColor, + toolBackgroundColor: UIColor, + toolControlTextColor: UIColor, + toolControlBorderUnselectColor: UIColor, + toolLineColor: UIColor + ) { + self.contentBackgroundColor = contentBackgroundColor + self.contentTextColor = contentTextColor + self.toolBackgroundColor = toolBackgroundColor + self.toolControlTextColor = toolControlTextColor + self.toolControlBorderUnselectColor = toolControlBorderUnselectColor + self.toolLineColor = toolLineColor + } + + public static let light = RDEPUBReaderTheme( + contentBackgroundColor: .white, + contentTextColor: .black, + toolBackgroundColor: .white, + toolControlTextColor: .black, + toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5), + toolLineColor: UIColor.lightGray.withAlphaComponent(0.5) + ) + + public static let dark = RDEPUBReaderTheme( + contentBackgroundColor: .black, + contentTextColor: .white, + toolBackgroundColor: .black, + toolControlTextColor: .white, + toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5), + toolLineColor: UIColor.lightGray.withAlphaComponent(0.5) + ) + + public static let yellow = RDEPUBReaderTheme( + contentBackgroundColor: UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1), + contentTextColor: .black, + toolBackgroundColor: UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1), + toolControlTextColor: .black, + toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5), + toolLineColor: UIColor.lightGray.withAlphaComponent(0.5) + ) + + public static let green = RDEPUBReaderTheme( + contentBackgroundColor: UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1), + contentTextColor: .black, + toolBackgroundColor: UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1), + toolControlTextColor: .black, + toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5), + toolLineColor: UIColor.lightGray.withAlphaComponent(0.5) + ) + + public static let pink = RDEPUBReaderTheme( + contentBackgroundColor: UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1), + contentTextColor: .black, + toolBackgroundColor: UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1), + toolControlTextColor: .black, + toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5), + toolLineColor: UIColor.lightGray.withAlphaComponent(0.5) + ) + + public static let blue = RDEPUBReaderTheme( + contentBackgroundColor: UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1), + contentTextColor: .black, + toolBackgroundColor: UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1), + toolControlTextColor: .black, + toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5), + toolLineColor: UIColor.lightGray.withAlphaComponent(0.5) + ) +} + +extension RDEPUBReaderTheme { + var themeBackgroundColorCSS: String { + contentBackgroundColor.ss_cssString + } + + var themeTextColorCSS: String { + contentTextColor.ss_cssString + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderToolView.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderToolView.swift new file mode 100644 index 0000000..67eae0e --- /dev/null +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderToolView.swift @@ -0,0 +1,33 @@ +import UIKit + +open class RDEPUBReaderToolView: UIView { + private let lineView = UIView() + private let lineHeight: CGFloat = 0.5 + + override init(frame: CGRect) { + super.init(frame: frame) + translatesAutoresizingMaskIntoConstraints = false + addSubview(lineView) + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + open override func layoutSubviews() { + super.layoutSubviews() + lineView.frame = lineFrame(in: bounds) + } + + open func apply(theme: RDEPUBReaderTheme) { + backgroundColor = .white + lineView.backgroundColor = UIColor.lightGray.withAlphaComponent(0.5) + } + + open func lineFrame(in bounds: CGRect) -> CGRect { + CGRect(x: 0, y: 0, width: bounds.width, height: lineHeight) + } +} + +final class RDEPUBReaderTintButton: UIButton { +} \ No newline at end of file diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift new file mode 100644 index 0000000..f6f08b7 --- /dev/null +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift @@ -0,0 +1,111 @@ +import UIKit + +public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView { + var onBack: (() -> Void)? + var onToggleBookmark: (() -> Void)? + + private let backButton = RDEPUBReaderTintButton(type: .system) + private let bookmarkButton = RDEPUBReaderTintButton(type: .system) + private let titleLabel: UILabel = { + let label = UILabel() + label.textAlignment = .center + label.font = UIFont.systemFont(ofSize: 16, weight: .semibold) + label.numberOfLines = 1 + return label + }() + private var isBookmarked = false + + override init(frame: CGRect) { + super.init(frame: frame) + self.backgroundColor = .white + addSubview(backButton) + addSubview(bookmarkButton) + addSubview(titleLabel) + + backButton.translatesAutoresizingMaskIntoConstraints = false + bookmarkButton.translatesAutoresizingMaskIntoConstraints = false + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + backButton.addTarget(self, action: #selector(backAction), for: .touchUpInside) + bookmarkButton.addTarget(self, action: #selector(bookmarkAction), for: .touchUpInside) + + NSLayoutConstraint.activate([ + backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), + backButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 4), + backButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), + backButton.widthAnchor.constraint(equalToConstant: 44), + backButton.heightAnchor.constraint(equalToConstant: 44), + + bookmarkButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), + bookmarkButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 4), + bookmarkButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), + bookmarkButton.widthAnchor.constraint(equalToConstant: 44), + bookmarkButton.heightAnchor.constraint(equalToConstant: 44), + + titleLabel.leadingAnchor.constraint(equalTo: backButton.trailingAnchor, constant: 8), + titleLabel.trailingAnchor.constraint(equalTo: bookmarkButton.leadingAnchor, constant: -8), + titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor) + ]) + + if #available(iOS 13.0, *) { + backButton.setImage(UIImage(systemName: "chevron.left")?.withRenderingMode(.alwaysTemplate), for: .normal) + } else { + backButton.setTitle("返回", for: .normal) + } + backButton.accessibilityIdentifier = "epub.reader.back" + bookmarkButton.accessibilityIdentifier = "epub.reader.bookmark" + titleLabel.accessibilityIdentifier = "epub.reader.title" + updateBookmarkButtonAppearance() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func lineFrame(in bounds: CGRect) -> CGRect { + CGRect(x: 0, y: bounds.height - 0.5, width: bounds.width, height: 0.5) + } + + override public func apply(theme: RDEPUBReaderTheme) { + super.apply(theme: theme) + titleLabel.textColor = .black + backButton.tintColor = .black + bookmarkButton.tintColor = .black + if #unavailable(iOS 13.0) { + backButton.setTitleColor(.black, for: .normal) + bookmarkButton.setTitleColor(.black, for: .normal) + } + updateBookmarkButtonAppearance() + } + + func setTitle(_ title: String?) { + titleLabel.text = title + } + + func setBookmarkSelected(_ isSelected: Bool) { + isBookmarked = isSelected + updateBookmarkButtonAppearance() + } + + func setBookmarkEnabled(_ isEnabled: Bool) { + bookmarkButton.isEnabled = isEnabled + bookmarkButton.alpha = isEnabled ? 1 : 0.45 + } + + @objc private func backAction() { + onBack?() + } + + @objc private func bookmarkAction() { + onToggleBookmark?() + } + + private func updateBookmarkButtonAppearance() { + if #available(iOS 13.0, *) { + let imageName = isBookmarked ? "bookmark.fill" : "bookmark" + bookmarkButton.setImage(UIImage(systemName: imageName)?.withRenderingMode(.alwaysTemplate), for: .normal) + } else { + bookmarkButton.setTitle(isBookmarked ? "已签" : "书签", for: .normal) + } + } +} diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift b/Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift new file mode 100644 index 0000000..1c325ef --- /dev/null +++ b/Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift @@ -0,0 +1,237 @@ +import UIKit +import Foundation + +protocol RDEPUBTextContentViewDelegate: AnyObject { + func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) + func textContentView(_ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) +} + +final class RDEPUBSelectableTextView: UITextView { + var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)? + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + switch action { + case #selector(rd_copy(_:)), + #selector(rd_highlight(_:)), + #selector(rd_annotate(_:)): + return selectedRange.location != NSNotFound && selectedRange.length > 0 + default: + return false + } + } + + @objc func rd_copy(_ sender: Any?) { + onSelectionAction?(.copy) + } + + @objc func rd_highlight(_ sender: Any?) { + onSelectionAction?(.highlight) + } + + @objc func rd_annotate(_ sender: Any?) { + onSelectionAction?(.annotate) + } +} + +final class RDEPUBTextContentView: UIView { + private var contentInsets: UIEdgeInsets = .zero + private var currentPage: RDEPUBTextPage? + private var highlightedRanges: [RDEPUBHighlight] = [] + weak var delegate: RDEPUBTextContentViewDelegate? + + private let textView: RDEPUBSelectableTextView = { + let view = RDEPUBSelectableTextView() + view.isEditable = false + view.isScrollEnabled = false + view.isSelectable = true + view.backgroundColor = .clear + view.textContainerInset = .zero + view.textContainer.lineFragmentPadding = 0 + return view + }() + + private let pageNumberLabel: UILabel = { + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 13) + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + addSubview(textView) + addSubview(pageNumberLabel) + textView.delegate = self + textView.onSelectionAction = { [weak self] action in + guard let self else { return } + self.delegate?.textContentView(self, didRequestSelectionAction: action) + } + UIMenuController.shared.menuItems = [ + UIMenuItem(title: "拷贝", action: #selector(RDEPUBSelectableTextView.rd_copy(_:))), + UIMenuItem(title: "高亮", action: #selector(RDEPUBSelectableTextView.rd_highlight(_:))), + UIMenuItem(title: "批注", action: #selector(RDEPUBSelectableTextView.rd_annotate(_:))) + ] + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + textView.frame = bounds.inset(by: contentInsets) + + let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20)) + pageNumberLabel.frame = CGRect( + x: bounds.width - labelSize.width - 24, + y: bounds.height - labelSize.height - 20, + width: labelSize.width, + height: labelSize.height + ) + } + + func configure( + page: RDEPUBTextPage, + pageNumber: Int, + totalPages: Int, + configuration: RDEPUBReaderConfiguration, + highlights: [RDEPUBHighlight] = [], + searchState: RDEPUBSearchState? = nil + ) { + currentPage = page + highlightedRanges = highlights + contentInsets = configuration.reflowableContentInsets + backgroundColor = configuration.theme.contentBackgroundColor + pageNumberLabel.textColor = configuration.theme.contentTextColor + pageNumberLabel.text = "\(pageNumber) / \(totalPages)" + + let displayContent = NSMutableAttributedString(attributedString: page.content) + let fullRange = NSRange(location: 0, length: displayContent.length) + displayContent.addAttribute( + .foregroundColor, + value: configuration.theme.contentTextColor, + range: fullRange + ) + applyHighlights(to: displayContent, page: page) + applySearchHighlights(to: displayContent, page: page, searchState: searchState) + textView.tintColor = configuration.theme.toolControlTextColor + textView.attributedText = displayContent + textView.selectedRange = NSRange(location: 0, length: 0) + delegate?.textContentView(self, didChangeSelection: nil) + setNeedsLayout() + } + + func clearSelection() { + textView.selectedRange = NSRange(location: 0, length: 0) + delegate?.textContentView(self, didChangeSelection: nil) + } + + private func applyHighlights(to content: NSMutableAttributedString, page: RDEPUBTextPage) { + let pageStart = Int(page.pageStartOffset) + let pageEndExclusive = Int(page.pageEndOffset) + 1 + + for highlight in highlightedRanges where highlight.location.href == page.href { + guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { continue } + let overlapStart = max(range.location, pageStart) + let overlapEnd = min(range.location + range.length, pageEndExclusive) + guard overlapStart < overlapEnd else { continue } + + let relativeRange = NSRange( + location: overlapStart - pageStart, + length: overlapEnd - overlapStart + ) + switch highlight.style { + case .highlight: + content.addAttribute( + .backgroundColor, + value: UIColor(hexString: highlight.color, alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45), + range: relativeRange + ) + case .underline: + content.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: relativeRange) + if let color = UIColor(hexString: highlight.color, alpha: 1) { + content.addAttribute(.underlineColor, value: color, range: relativeRange) + } + } + } + } + + private func applySearchHighlights( + to content: NSMutableAttributedString, + page: RDEPUBTextPage, + searchState: RDEPUBSearchState? + ) { + guard let searchState else { return } + + let normalColor = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55) + let activeColor = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75) + let pageStart = Int(page.pageStartOffset) + let pageEndExclusive = Int(page.pageEndOffset) + 1 + + for match in searchState.matches where match.href == page.href { + guard let matchStart = match.rangeLocation else { continue } + let matchEnd = matchStart + match.rangeLength + let overlapStart = max(matchStart, pageStart) + let overlapEnd = min(matchEnd, pageEndExclusive) + guard overlapStart < overlapEnd else { continue } + + let relativeRange = NSRange(location: Int(overlapStart - pageStart), length: Int(overlapEnd - overlapStart)) + let color = match == searchState.currentMatch ? activeColor : normalColor + content.addAttribute(.backgroundColor, value: color, range: relativeRange) + } + } + +} + +extension RDEPUBTextContentView: UITextViewDelegate { + func textViewDidChangeSelection(_ textView: UITextView) { + guard let page = currentPage else { + delegate?.textContentView(self, didChangeSelection: nil) + return + } + + let selectedRange = textView.selectedRange + guard selectedRange.location != NSNotFound, + selectedRange.length > 0, + let attributedText = textView.attributedText else { + delegate?.textContentView(self, didChangeSelection: nil) + return + } + + let source = attributedText.string as NSString + let selectedText = source.substring(with: selectedRange).trimmingCharacters(in: .whitespacesAndNewlines) + guard !selectedText.isEmpty else { + delegate?.textContentView(self, didChangeSelection: nil) + return + } + + let globalStart = page.pageStartOffset + selectedRange.location + let globalEnd = globalStart + selectedRange.length + let totalLength = max(page.content.length - 1, 1) + let selection = RDEPUBSelection( + location: RDEPUBLocation( + href: page.href, + progression: Double(selectedRange.location) / Double(totalLength), + lastProgression: Double(max(selectedRange.location + selectedRange.length - 1, 0)) / Double(totalLength), + fragment: nil + ), + text: selectedText, + rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString() + ) + delegate?.textContentView(self, didChangeSelection: selection) + } +} + +private extension UIColor { + convenience init?(hexString: String, alpha: CGFloat) { + var value = hexString.trimmingCharacters(in: .whitespacesAndNewlines) + value = value.replacingOccurrences(of: "#", with: "") + guard value.count == 6, let hex = Int(value, radix: 16) else { return nil } + self.init( + red: CGFloat((hex >> 16) & 0xFF) / 255, + green: CGFloat((hex >> 8) & 0xFF) / 255, + blue: CGFloat(hex & 0xFF) / 255, + alpha: alpha + ) + } +} diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBWebContentView.swift b/Sources/RDReaderView/EPUBUI/RDEPUBWebContentView.swift new file mode 100644 index 0000000..516427b --- /dev/null +++ b/Sources/RDReaderView/EPUBUI/RDEPUBWebContentView.swift @@ -0,0 +1,95 @@ +import UIKit + +protocol RDEPUBWebContentViewDelegate: AnyObject { + func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) + func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) + func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) + func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) + func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) + func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) +} + +final class RDEPUBWebContentView: UIView { + weak var delegate: RDEPUBWebContentViewDelegate? + + private let epubWebView = RDEPUBWebView() + private let pageNumberLabel: UILabel = { + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 13) + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + clipsToBounds = true + layer.masksToBounds = true + + addSubview(epubWebView) + addSubview(pageNumberLabel) + epubWebView.delegate = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + epubWebView.frame = bounds + + let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20)) + pageNumberLabel.frame = CGRect( + x: bounds.width - labelSize.width - 24, + y: bounds.height - labelSize.height - 20, + width: labelSize.width, + height: labelSize.height + ) + } + + func configure( + publication: RDEPUBPublication, + request: RDEPUBRenderRequest, + pageNumber: Int, + totalPages: Int, + theme: RDEPUBReaderTheme + ) { + backgroundColor = theme.contentBackgroundColor + pageNumberLabel.textColor = theme.contentTextColor + pageNumberLabel.text = "\(pageNumber) / \(totalPages)" + epubWebView.load(publication: publication, request: request) + setNeedsLayout() + } + + func releaseResources() { + epubWebView.reset() + epubWebView.delegate = self + } +} + +extension RDEPUBWebContentView: RDEPUBWebViewDelegate { + func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) { + delegate?.epubWebContentView(self, didUpdateLocation: location, spineIndex: spineIndex) + } + + func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) { + delegate?.epubWebContentView(self, didChangeSelection: selection, spineIndex: spineIndex) + } + + func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) { + delegate?.epubWebContentView(self, didRequestSelectionAction: action) + } + + func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) { + delegate?.epubWebContentView(self, didActivateInternalLink: location, fromSpineIndex: fromSpineIndex) + } + + func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL) { + delegate?.epubWebContentView(self, didActivateExternalLink: url) + } + + func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String) { + delegate?.epubWebContentView(self, didLogJavaScriptError: message) + } + + func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView) {} +} diff --git a/Sources/RDReaderView/LegacyRDReaderController/RDEPUBTextPaging.swift b/Sources/RDReaderView/LegacyRDReaderController/RDEPUBTextPaging.swift new file mode 100644 index 0000000..423ee52 --- /dev/null +++ b/Sources/RDReaderView/LegacyRDReaderController/RDEPUBTextPaging.swift @@ -0,0 +1,304 @@ +import UIKit + +struct EPUBTOCDisplayItem { + var title: String + var href: String + var depth: Int + var pageNumber: Int? +} + +struct LegacyRDEPUBTextPage { + var absolutePageIndex: Int + var chapterIndex: Int + var spineIndex: Int + var href: String + var chapterTitle: String + var pageIndexInChapter: Int + var totalPagesInChapter: Int + var content: NSAttributedString + var contentRange: NSRange + var pageStartOffset: Int + var pageEndOffset: Int +} + +struct LegacyRDEPUBTextChapter { + var chapterIndex: Int + var spineIndex: Int + var href: String + var title: String + var attributedContent: NSAttributedString + var fragmentOffsets: [String: Int] + var pages: [LegacyRDEPUBTextPage] +} + +struct LegacyRDEPUBTextBook { + var chapters: [LegacyRDEPUBTextChapter] + var pages: [LegacyRDEPUBTextPage] + + func page(at pageNumber: Int) -> LegacyRDEPUBTextPage? { + guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else { + return nil + } + return pages[pageNumber - 1] + } + + func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? { + guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier), + let chapter = chapters.first(where: { $0.href == normalizedLocation.href }) else { + return nil + } + + let targetOffset: Int + if let fragment = normalizedLocation.fragment, let fragmentOffset = chapter.fragmentOffsets[fragment] { + targetOffset = fragmentOffset + } else { + let lastOffset = max(chapter.attributedContent.length - 1, 0) + targetOffset = min(lastOffset, max(0, Int(round(Double(lastOffset) * normalizedLocation.navigationProgression)))) + } + + if let page = chapter.pages.first(where: { targetOffset >= $0.pageStartOffset && targetOffset <= $0.pageEndOffset }) { + return page.absolutePageIndex + 1 + } + + return chapter.pages.last.map { $0.absolutePageIndex + 1 } + } + + func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? { + guard let page = page(at: pageNumber), + let chapter = chapters.first(where: { $0.chapterIndex == page.chapterIndex }) else { + return nil + } + + let totalLength = max(chapter.attributedContent.length - 1, 1) + return RDEPUBLocation( + bookIdentifier: bookIdentifier, + href: page.href, + progression: Double(page.pageStartOffset) / Double(totalLength), + lastProgression: Double(page.pageEndOffset) / Double(totalLength), + fragment: nil + ) + } +} + +final class LegacyRDEPUBTextBookBuilder { + static func build( + parser: RDEPUBParser, + publication: RDEPUBPublication, + pageSize: CGSize, + font: UIFont, + lineSpacing: CGFloat + ) -> LegacyRDEPUBTextBook { + var chapters: [LegacyRDEPUBTextChapter] = [] + var flatPages: [LegacyRDEPUBTextPage] = [] + + for (spineIndex, item) in publication.spine.enumerated() where item.linear { + guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"), + let rawHTML = parser.htmlString(forRelativePath: item.href) else { + continue + } + + let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents) + let normalizedHTML = normalizeHTML(rawHTML) + let markedHTML = injectFragmentMarkers(into: normalizedHTML) + guard let attributedContent = attributedHTML( + html: markedHTML, + baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(), + font: font, + lineSpacing: lineSpacing + ) else { + continue + } + + let mutableContent = NSMutableAttributedString(attributedString: attributedContent) + let fragmentOffsets = extractFragmentOffsets(from: mutableContent) + normalizeReadingAttributes(in: mutableContent, font: font, lineSpacing: lineSpacing) + + let plainText = mutableContent.string.trimmingCharacters(in: .whitespacesAndNewlines) + if shouldSkipChapter(item: item, text: plainText) { + continue + } + + let chapterIndex = chapters.count + let pageRanges = mutableContent.length > 0 ? mutableContent.pageRanges(size: pageSize) : [] + let effectivePageRanges = pageRanges.isEmpty && mutableContent.length > 0 + ? [NSRange(location: 0, length: mutableContent.length)] + : pageRanges + + let pages = effectivePageRanges.enumerated().map { localPageIndex, range in + LegacyRDEPUBTextPage( + absolutePageIndex: flatPages.count + localPageIndex, + chapterIndex: chapterIndex, + spineIndex: spineIndex, + href: item.href, + chapterTitle: chapterTitle, + pageIndexInChapter: localPageIndex, + totalPagesInChapter: effectivePageRanges.count, + content: mutableContent.attributedSubstring(from: range), + contentRange: range, + pageStartOffset: range.location, + pageEndOffset: range.location + max(range.length - 1, 0) + ) + } + + let chapter = LegacyRDEPUBTextChapter( + chapterIndex: chapterIndex, + spineIndex: spineIndex, + href: item.href, + title: chapterTitle, + attributedContent: mutableContent.copy() as! NSAttributedString, + fragmentOffsets: fragmentOffsets, + pages: pages + ) + chapters.append(chapter) + flatPages.append(contentsOf: pages) + } + + return LegacyRDEPUBTextBook(chapters: chapters, pages: flatPages) + } + + private static func resolvedChapterTitle(for item: RDEPUBSpineItem, toc: [EPUBTableOfContentsItem]) -> String { + if let title = flattenedTOCItems(from: toc).first(where: { tocItem in + tocItem.href.components(separatedBy: "#").first == item.href + })?.title.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { + return title + } + let trimmedTitle = item.title.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedTitle.isEmpty ? item.href : trimmedTitle + } + + private static func flattenedTOCItems(from items: [EPUBTableOfContentsItem]) -> [EPUBTableOfContentsItem] { + items.flatMap { item in + [item] + flattenedTOCItems(from: item.children) + } + } + + private static func shouldSkipChapter(item: RDEPUBSpineItem, text: String) -> Bool { + let lowercasedHref = item.href.lowercased() + if text.isEmpty && (lowercasedHref.contains("cover") || lowercasedHref.contains("title")) { + return true + } + return false + } + + private static func normalizeHTML(_ html: String) -> String { + var cleanedHTML = html + let replacements: [(pattern: String, template: String)] = [ + (#"分页符"#, ""), + (#"\r"#, "\n"), + (#"\n+"#, "\n") + ] + + for replacement in replacements { + if let regex = try? NSRegularExpression(pattern: replacement.pattern, options: [.caseInsensitive]) { + cleanedHTML = regex.stringByReplacingMatches( + in: cleanedHTML, + options: [], + range: NSRange(location: 0, length: cleanedHTML.utf16.count), + withTemplate: replacement.template + ) + } + } + + return cleanedHTML + } + + private static func injectFragmentMarkers(into html: String) -> String { + guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else { + return html + } + return regex.stringByReplacingMatches( + in: html, + options: [], + range: NSRange(location: 0, length: html.utf16.count), + withTemplate: "${id=$2}$1" + ) + } + + private static func attributedHTML( + html: String, + baseURL: URL?, + font: UIFont, + lineSpacing: CGFloat + ) -> NSAttributedString? { + guard let data = html.data(using: .utf8) else { + return nil + } + + let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [ + .documentType: NSAttributedString.DocumentType.html, + .characterEncoding: String.Encoding.utf8.rawValue, + NSAttributedString.DocumentReadingOptionKey(rawValue: "NSBaseURLDocumentOption"): baseURL as Any + ] + + if let attributed = try? NSMutableAttributedString(data: data, options: options, documentAttributes: nil) { + return attributed + } + + let fallbackAttributes: [NSAttributedString.Key: Any] = [ + .font: font, + .paragraphStyle: paragraphStyle(lineSpacing: lineSpacing) + ] + return NSAttributedString(string: html, attributes: fallbackAttributes) + } + + private static func extractFragmentOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] { + let markerPattern = #"\$\{id=([^}]+)\}"# + guard let regex = try? NSRegularExpression(pattern: markerPattern, options: []) else { + return [:] + } + + let mutableString = NSMutableString(string: attributedString.string) + var fragmentOffsets: [String: Int] = [:] + var searchRange = NSRange(location: 0, length: mutableString.length) + var offsetAdjustment = 0 + + while let match = regex.firstMatch(in: mutableString as String, options: [], range: searchRange) { + let fullMatch = mutableString.substring(with: match.range) as NSString + let fragmentID = fullMatch + .replacingOccurrences(of: #"\$\{id="#, with: "", options: .regularExpression, range: NSRange(location: 0, length: fullMatch.length)) + .replacingOccurrences(of: #"\}"#, with: "", options: .regularExpression) + + let adjustedLocation = max(0, match.range.location + offsetAdjustment) + fragmentOffsets[fragmentID] = adjustedLocation + attributedString.deleteCharacters(in: match.range) + mutableString.deleteCharacters(in: match.range) + offsetAdjustment -= match.range.length + searchRange = NSRange(location: match.range.location, length: mutableString.length - match.range.location) + } + + return fragmentOffsets + } + + private static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, font: UIFont, lineSpacing: CGFloat) { + let fullRange = NSRange(location: 0, length: attributedString.length) + attributedString.enumerateAttributes(in: fullRange) { attributes, range, _ in + let sourceFont = attributes[.font] as? UIFont + let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: lineSpacing) + paragraph.lineSpacing = lineSpacing + paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, lineSpacing / 2) + + var updatedAttributes = attributes + updatedAttributes[.font] = normalizedFont(from: sourceFont, baseFont: font) + updatedAttributes[.paragraphStyle] = paragraph + attributedString.setAttributes(updatedAttributes, range: range) + } + } + + private static func normalizedFont(from sourceFont: UIFont?, baseFont: UIFont) -> UIFont { + guard let sourceFont else { + return baseFont + } + let traits = sourceFont.fontDescriptor.symbolicTraits.intersection([.traitBold, .traitItalic]) + if let descriptor = baseFont.fontDescriptor.withSymbolicTraits(traits) { + return UIFont(descriptor: descriptor, size: baseFont.pointSize) + } + return baseFont + } + + private static func paragraphStyle(lineSpacing: CGFloat) -> NSMutableParagraphStyle { + let style = NSMutableParagraphStyle() + style.lineSpacing = lineSpacing + style.paragraphSpacing = max(6, lineSpacing / 2) + return style + } +} diff --git a/Sources/RDReaderView/LegacyRDReaderController/RDReaderBottomToolView.swift b/Sources/RDReaderView/LegacyRDReaderController/RDReaderBottomToolView.swift new file mode 100644 index 0000000..2ecb7de --- /dev/null +++ b/Sources/RDReaderView/LegacyRDReaderController/RDReaderBottomToolView.swift @@ -0,0 +1,103 @@ +// +// RDReaderBottomToolView.swift +// RDReaderDemo +// +// Created by yangsq on 2021/8/10. +// + +import UIKit +import SnapKit + +public class RDReaderBottomToolView: RDReaderToolView, SSEventTrigger { + enum Event { + case chapterList, highlight, settings, darkAndLight + } + private lazy var containerView: UIStackView = { + let stackView = UIStackView() + stackView.distribution = .fillEqually + stackView.axis = .horizontal + stackView.spacing = 20 + return stackView + }() + + lazy var chapterListButton: TintColorButton = { + let button = TintColorButton(type: .system) + button.setImage(toolbarImage(named: "read_edit_chapterlist", fallbackSystemName: "list.bullet"), for: .normal) + addSubview(button) + button.addTarget(self, action: #selector(chapterListAction), for: .touchUpInside) + return button + }() + + lazy var settingsButton: TintColorButton = { + let button = TintColorButton(type: .system) + button.setImage(toolbarImage(named: "read_edit_font", fallbackSystemName: "textformat"), for: .normal) + addSubview(button) + button.addTarget(self, action: #selector(settingsAction), for: .touchUpInside) + return button + }() + + lazy var highlightButton: TintColorButton = { + let button = TintColorButton(type: .system) + button.setImage(UIImage(systemName: "highlighter"), for: .normal) + addSubview(button) + button.addTarget(self, action: #selector(highlightAction), for: .touchUpInside) + return button + }() + + + lazy var lightModeButton: TintColorButton = { + let button = TintColorButton(type: .system) + button.setImage(toolbarImage(named: "read_edit_night", fallbackSystemName: "moon.fill"), for: .normal) + addSubview(button) + button.addTarget(self, action: #selector(lightAndDarkModeAction), for: .touchUpInside) + return button + }() + + override func makeUI() { + super.makeUI() + addSubview(containerView) + containerView.addArrangedSubview(chapterListButton) + containerView.addArrangedSubview(highlightButton) + containerView.addArrangedSubview(settingsButton) + containerView.addArrangedSubview(lightModeButton) + + containerView.snp.makeConstraints { make in + make.left.equalTo(16) + make.top.equalTo(0) + make.right.equalTo(-16) + make.bottom.equalTo(-RDReaderCommon.safeAreaInsets.bottom) + } + + } + + @objc private func chapterListAction() { + if let trigger = self.triggerEvent { + trigger(.chapterList) + } + } + + @objc private func settingsAction() { + if let trigger = self.triggerEvent { + trigger(.settings) + } + } + + @objc private func highlightAction() { + if let trigger = self.triggerEvent { + trigger(.highlight) + } + } + + @objc private func lightAndDarkModeAction() { + if let trigger = self.triggerEvent { + trigger(.darkAndLight) + } + } + + private func toolbarImage(named: String, fallbackSystemName: String) -> UIImage? { + if let image = UIImage(named: named) { + return image.withRenderingMode(.alwaysOriginal) + } + return UIImage(systemName: fallbackSystemName) + } +} diff --git a/Sources/RDReaderView/LegacyRDReaderController/RDReaderCommon.swift b/Sources/RDReaderView/LegacyRDReaderController/RDReaderCommon.swift new file mode 100644 index 0000000..f646ea7 --- /dev/null +++ b/Sources/RDReaderView/LegacyRDReaderController/RDReaderCommon.swift @@ -0,0 +1,198 @@ +// +// RDReaderCommon.swift +// RDReaderDemo +// +// Created by yangsq on 2021/8/11. +// + +import Foundation +import UIKit +import CoreText + +struct RDReaderCommon { + static var safeAreaInsets: UIEdgeInsets = { + guard #available(iOS 11.0, *) else { + return .zero + } + return UIApplication.shared.windows[0].safeAreaInsets + }() + +} + +extension RDReaderCommon { + enum SpaceType: Int, RDReaderObserverType { + case min = 0 + case meduim + case max + var mulitiple: CGFloat { + switch self { + case .min: return 1.6 + case .meduim: return 2.0 + case .max: return 3.0 + } + } + + var image: UIImage? { + switch self { + case .min: + return UIImage.rdToolbarImage(named: "read_edit_textSpace_3", fallbackSystemName: "text.justify.leading") + case .meduim: + return UIImage.rdToolbarImage(named: "read_edit_textSpace_2", fallbackSystemName: "text.justify") + case .max: + return UIImage.rdToolbarImage(named: "read_edit_textSpace_1", fallbackSystemName: "text.justify.right") + } + } + + var cachesValue: Int { + return rawValue + } + var value: CGFloat { + return mulitiple + } + func transform(value: Any?) -> RDReaderCommon.SpaceType { + guard let value = value as? Int else { + return self + } + return RDReaderCommon.SpaceType(rawValue: value) ?? self + } + } +} + +extension RDReaderCommon { + enum PageCurlType: Int, RDReaderObserverType { + case pageCurl + case horizontalScroll + case verticalScroll + case horizontalCoverScroll + + var title: String { + switch self { + case .pageCurl: + return "仿真" + case .horizontalScroll: + return "左右滑动" + case .verticalScroll: + return "上下滚动" + case .horizontalCoverScroll: + return "左右覆盖" + } + } + + var cachesValue: Int { + return rawValue + } + var value: RDReaderView.DisplayType { + switch self { + case .pageCurl: + return RDReaderView.DisplayType.pageCurl + case .horizontalScroll: + return RDReaderView.DisplayType.horizontalScroll + case .verticalScroll: + return RDReaderView.DisplayType.verticalScroll + case .horizontalCoverScroll: + return RDReaderView.DisplayType.horizontalScroll + } + } + + func transform(value: Any?) -> RDReaderCommon.PageCurlType { + guard let value = value as? Int else { + return self + } + return RDReaderCommon.PageCurlType(rawValue: value) ?? self + } + } +} + +extension RDReaderCommon { + + enum Colors: Int, RDReaderObserverType{ + + case white, dark, pink, yellow, green, blue + var value: RDReaderTheme { + switch self { + case .white: + return whiteTheme + case .dark: + return darkTheme + case .pink: + return pinkTheme + case .yellow: + return yellowTheme + case .green: + return greenTheme + case .blue: + return blueTheme + } + } + var cachesValue: Int { + rawValue + } + + var color: UIColor { + switch self { + case .white: + return UIColor(red: 0.98, green: 0.98, blue: 0.99, alpha: 1) + case .yellow: + return UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1) + case .green: + return UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1) + case .pink: + return UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1) + case .blue: + return UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1) + case .dark: + return UIColor(red: 0.09, green: 0.09, blue: 0.09, alpha: 1) + } + } + + func transform(value: Any?) -> RDReaderCommon.Colors { + guard let value = value as? Int else { + return self + } + return RDReaderCommon.Colors(rawValue: value) ?? self + } + } + static let whiteTheme = WhiteTheme() + static let darkTheme = DarkTheme() + static let pinkTheme = PinkTheme() + static let yellowTheme = YellowTheme() + static let greenTheme = GreenTheme() + static let blueTheme = BlueTheme() + +} + + +extension NSAttributedString { + func pageRanges(size: CGSize) -> [NSRange] { + var ranges = [NSRange]() + let framesetter = CTFramesetterCreateWithAttributedString(self) + let path = CGPath(rect: CGRect(origin: .zero, size: size), transform: nil) + var range = CFRangeMake(0, 0) + var loc = 0 + while range.location + range.length < length { + let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(loc, 0), path, nil) + range = CTFrameGetVisibleStringRange(frame) + ranges.append(NSMakeRange(loc, range.length)) + loc += range.length + } + return ranges + } +} + + +extension String { + static func encodeTextFile(url: URL?) -> String { + var content: String? = nil + guard let url = url else { + return content ?? "" + } + content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String + if content == nil { + content = try? NSString(contentsOf: url, encoding: 0x80000632) as String + } + if content == nil { + content = try? NSString(contentsOf: url, encoding: 0x80000631) as String + } + return content ?? "" + } +} diff --git a/Sources/RDReaderView/LegacyRDReaderController/RDReaderContentView.swift b/Sources/RDReaderView/LegacyRDReaderController/RDReaderContentView.swift new file mode 100644 index 0000000..2555b4e --- /dev/null +++ b/Sources/RDReaderView/LegacyRDReaderController/RDReaderContentView.swift @@ -0,0 +1,102 @@ +// +// RDReaderContentView.swift +// RDReaderDemo +// +// Created by yangsq on 2021/8/11. +// + +import UIKit +import SnapKit + +class RDReaderContentView: UIView { + lazy var textLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + return label + }() + lazy var pageNumLabel: UILabel = { + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 13) + return label + }() + override init(frame: CGRect) { + super.init(frame: frame) + addSubview(textLabel) + addSubview(pageNumLabel) + + textLabel.text = """ +孩子为了不进职校,压力之大,我甚至听说,有初三学生晚上八点睡觉,凌晨两点起床读书做作业,忙到天亮,吃好早饭赶往学校。 + + 平心静气地讲,一个孩子如果不是读书的料,完全可以去职校学一门手艺。但家长却有三重焦虑: + + 第一,一个普通高校的学生可以轻松去读职校的课程,但职校学生要去读普通高校的课程,难上加难。同一年级的各类学校和专业,在学习上是有难易梯度的。家长多不会同意让孩子一开始就选择容易的学校和专业,否则在未来竞争中,将处于不利地位。 + + 第二,初中毕业生社会经验匮乏,实际上根本没有能力做人生规划。选择职校学一门技术,同时也就意味着,将来从事其他工作的门槛是很高的。如果没有继续学习的能力,改行的成本之高,难以想象。 + + 以我的个人经验来讲,初中阶段,一度想去学习屠宰的手艺,毕业时也有上建筑类职高的机会,但都没去,在普通高中混到高三才决定考大学,如果去上职高,很可能就是个小包工头。我不知道这是不是我的真实意愿,但我觉得当下的工作更符合秉性。 + + 第三,中国的一些职校,风评并不十分好,家长并不十分放心把十五六岁的孩子送入这些学校,他们不担心孩子学艺不成,而是担心孩子“学坏了”。 + + 并且,随着科技加速进步,很多好端端的传统职业,忽然消失了。以汽修为例,现在的汽修专业毕业生,谁能保证他的精湛技术在10年之后不会归零?一些新职业出现没几年又消失了,谁能保证中高职教育能够跟上科技潮流? +""" + textLabel.snp.makeConstraints { make in + make.edges.equalTo(UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16)) + } + pageNumLabel.snp.makeConstraints { make in + make.bottom.equalTo(-20) + make.right.equalTo(-30) + } + } + override func layoutSubviews() { + super.layoutSubviews() + + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +final class RDReaderEPUBTextContentView: UIView { + private(set) lazy var textLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + return label + }() + + private(set) lazy var pageNumLabel: UILabel = { + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 13) + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + addSubview(textLabel) + addSubview(pageNumLabel) + + textLabel.snp.makeConstraints { make in + make.edges.equalTo(UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16)) + } + pageNumLabel.snp.makeConstraints { make in + make.bottom.equalTo(-20) + make.right.equalTo(-30) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(page: LegacyRDEPUBTextPage, pageNumber: Int, totalPages: Int, theme: RDReaderTheme) { + backgroundColor = theme.contentBackgroudColor + pageNumLabel.textColor = theme.contentTextColor + pageNumLabel.text = "\(pageNumber) / \(totalPages)" + + let displayContent = NSMutableAttributedString(attributedString: page.content) + let fullRange = NSRange(location: 0, length: displayContent.length) + displayContent.addAttribute(.foregroundColor, value: theme.contentTextColor ?? UIColor.black, range: fullRange) + textLabel.attributedText = displayContent + } +} diff --git a/Sources/RDReaderView/LegacyRDReaderController/RDReaderController+EPUBText.swift b/Sources/RDReaderView/LegacyRDReaderController/RDReaderController+EPUBText.swift new file mode 100644 index 0000000..4984741 --- /dev/null +++ b/Sources/RDReaderView/LegacyRDReaderController/RDReaderController+EPUBText.swift @@ -0,0 +1,93 @@ +import UIKit + +extension RDReaderController { + func currentEPUBTextPageSize() -> CGSize { + let pageWidth: CGFloat + if readerView.pagesPerScreen > 1 { + pageWidth = view.frame.width / CGFloat(readerView.pagesPerScreen) - 16 * 2 + } else { + pageWidth = view.frame.width - 16 * 2 + } + return CGSize(width: max(pageWidth, 1), height: max(view.frame.height - 40 * 2, 1)) + } + + func loadEPUBTextBook( + parser: RDEPUBParser, + publication: RDEPUBPublication, + restoreLocation: RDEPUBLocation? + ) { + let pageSize = currentEPUBTextPageSize() + let font = UIFont.systemFont(ofSize: RDReaderManager.shared.fontValue.currentType) + let lineSpacing = RDReaderManager.shared.lineSpaceType.currentType.mulitiple * 15 + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + let textBook = LegacyRDEPUBTextBookBuilder.build( + parser: parser, + publication: publication, + pageSize: pageSize, + font: font, + lineSpacing: lineSpacing + ) + DispatchQueue.main.async { + self?.applyEPUBTextBook(textBook, publication: publication, restoreLocation: restoreLocation) + } + } + } + + func applyEPUBTextBook( + _ textBook: LegacyRDEPUBTextBook, + publication: RDEPUBPublication, + restoreLocation: RDEPUBLocation? + ) { + epubTextBook = textBook + epubTOCItems = flattenedTOCItems(from: publication.tableOfContents, textBook: textBook) + print("[EPUB] Text book ready chapters=\(textBook.chapters.count) pages=\(textBook.pages.count) toc=\(epubTOCItems.count)") + let resolvedRestorePage = restoreLocation.flatMap { + textBook.pageNumber( + for: $0, + resolver: epubResolver ?? publicationFallbackResolver(), + bookIdentifier: currentEPUBBookIdentifier + ) + } + storeEPUBPaginationValidation( + mode: "textReflowable", + stage: "final", + pageCount: textBook.pages.count, + chapterCount: textBook.chapters.count, + tocCount: epubTOCItems.count, + restoreLocation: restoreLocation, + resolvedRestorePage: resolvedRestorePage + ) + isReaderContentReady = true + hideLoading() + readerView.reloadData() + restoreEPUBLocation(restoreLocation) + if restoreLocation == nil { + epubSession?.transition(to: .idle) + } + } + + func flattenedTOCItems( + from items: [EPUBTableOfContentsItem], + depth: Int = 0, + textBook: LegacyRDEPUBTextBook? = nil + ) -> [EPUBTOCDisplayItem] { + var result: [EPUBTOCDisplayItem] = [] + for item in items { + let pageNumber = textBook.flatMap { book in + book.pageNumber( + for: RDEPUBLocation(bookIdentifier: currentEPUBBookIdentifier, href: item.href, progression: 0), + resolver: epubResolver ?? publicationFallbackResolver(), + bookIdentifier: currentEPUBBookIdentifier + ) + } + result.append(EPUBTOCDisplayItem(title: item.title, href: item.href, depth: depth, pageNumber: pageNumber)) + result.append(contentsOf: flattenedTOCItems(from: item.children, depth: depth + 1, textBook: textBook)) + } + return result + } + + func publicationFallbackResolver() -> RDEPUBResourceResolver { + epubPublication?.resourceResolver ?? RDEPUBResourceResolver(parser: epubParser ?? RDEPUBParser()) + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/LegacyRDReaderController/RDReaderController.swift b/Sources/RDReaderView/LegacyRDReaderController/RDReaderController.swift new file mode 100644 index 0000000..5f9eb49 --- /dev/null +++ b/Sources/RDReaderView/LegacyRDReaderController/RDReaderController.swift @@ -0,0 +1,2141 @@ +// +// RDReaderController.swift +// RDReaderDemo +// +// Created by yangsq on 2021/8/10. +// + +import SSAlertSwift +import UIKit + +/// 书籍来源类型 +public enum BookSource { + /// TXT文件(Bundle内置) + case textFile + /// EPUB文件 + case epubFile(url: URL) +} + +public class RDReaderController: UIViewController { + private static let epubPaginationCacheStorageKey = "epubPaginationCache.key" + private static let epubValidationStateStorageKey = + "ssreader.epub.validation.state" + + public lazy var topToolView: RDReaderTopToolView = { + let view = RDReaderTopToolView( + frame: CGRect( + x: 0, + y: 0, + width: view.frame.width, + height: 44 + UIApplication.shared.statusBarFrame.height + ) + ) + return view + }() + + public lazy var bottomToolView: RDReaderBottomToolView = { + let view = RDReaderBottomToolView( + frame: CGRect( + x: 0, + y: 0, + width: view.frame.width, + height: 55 + RDReaderCommon.safeAreaInsets.bottom + ) + ) + return view + }() + + public lazy var readerView: RDReaderView = { + let readerView = RDReaderView() + return readerView + }() + + // MARK: - TXT模式数据 + var chapterPages = [ChapterPage]() + var book = Book(desc: nil, chapters: []) + var currentChapterNum: Int = 0 + + // MARK: - EPUB模式数据 + var epubParser: RDEPUBParser? + var epubPublication: RDEPUBPublication? + var epubSession: RDEPUBReadingSession? + var epubPaginator: RDEPUBPaginator? + var epubReadingProfile: RDEPUBReadingProfile? + var epubTextBook: LegacyRDEPUBTextBook? + var epubTOCItems: [EPUBTOCDisplayItem] = [] + var epubHighlights: [RDEPUBHighlight] = [] + var currentEPUBBookIdentifier: String? + private var epubPreloadViews: [Int: RDReaderEPUBContentView] = [:] + private var epubPageCurlViews: [Int: RDReaderEPUBContentView] = [:] + private var isBackgroundEPUBPaginationRunning = false + private var backgroundEPUBPaginationApplyWorkItem: DispatchWorkItem? + private var epubValidationRunStartedAt: Date? + private var epubValidationRunReason = "initialLoad" + + private var epubPages: [EPUBPage] { + epubSession?.activePages ?? [] + } + + private var isTextEPUBMode: Bool { + epubReadingProfile == .textReflowable + } + + private var epubChapters: [EPUBChapterInfo] { + epubSession?.activeChapters ?? [] + } + + var epubResolver: RDEPUBResourceResolver? { + epubSession?.resourceResolver ?? epubPublication?.resourceResolver + } + + func storeEPUBValidationState(_ values: [String: Any]) { + var state = + UserDefaults.standard.dictionary( + forKey: Self.epubValidationStateStorageKey + ) ?? [:] + values.forEach { state[$0.key] = $0.value } + state["timestamp"] = ISO8601DateFormatter().string(from: Date()) + UserDefaults.standard + .set(state, forKey: Self.epubValidationStateStorageKey) + } + + func beginEPUBValidationRun( + reason: String, + restoreLocation: RDEPUBLocation? + ) { + epubValidationRunStartedAt = Date() + epubValidationRunReason = reason + + var state = epubValidationSettingsSnapshot() + state["validationReason"] = reason + state["restoreRequestedHref"] = restoreLocation?.href ?? "" + state["restoreRequestedFragment"] = restoreLocation?.fragment ?? "" + storeEPUBValidationState(state) + } + + func storeEPUBPaginationValidation( + mode: String, + stage: String, + pageCount: Int, + chapterCount: Int, + tocCount: Int, + restoreLocation: RDEPUBLocation?, + resolvedRestorePage: Int? + ) { + var state = epubValidationSettingsSnapshot() + state["mode"] = mode + state["validationReason"] = epubValidationRunReason + state["paginationStage"] = stage + state["pageCount"] = pageCount + state["chapterCount"] = chapterCount + state["tocCount"] = tocCount + state["restoreRequestedHref"] = restoreLocation?.href ?? "" + state["restoreRequestedFragment"] = restoreLocation?.fragment ?? "" + state["restoreResolvedPage"] = resolvedRestorePage ?? 0 + if let startedAt = epubValidationRunStartedAt { + state["paginationDurationMs"] = Int( + Date().timeIntervalSince(startedAt) * 1000 + ) + } + storeEPUBValidationState(state) + } + + func storeEPUBTOCValidation(item: EPUBTOCDisplayItem, resolvedPage: Int?) { + storeEPUBValidationState([ + "tocSelectionTitle": item.title, + "tocSelectionHref": item.href, + "tocSelectionDepth": item.depth, + "tocSelectionResolvedPage": resolvedPage ?? 0, + "tocSelectionCount": epubTOCItems.count, + ]) + } + + func storeEPUBEndValidationState(pageNum: Int, reachedEndScreen: Bool) { + storeEPUBValidationState([ + "lastVisitedPage": pageNum, + "didReachReadableEnd": true, + "didReachEndScreen": reachedEndScreen, + ]) + } + + private func epubValidationSettingsSnapshot() -> [String: Any] { + [ + "settingsTheme": RDReaderManager.shared.themeType.currentType + .rawValue, + "settingsFontSize": RDReaderManager.shared.fontValue.currentType, + "settingsLineSpacing": RDReaderManager.shared.lineSpaceType + .currentType.rawValue, + "settingsLineSpacingMultiple": RDReaderManager.shared.lineSpaceType + .currentType.mulitiple, + "settingsDisplayType": RDReaderManager.shared.pageCurlTypeType + .currentType.rawValue, + "settingsBrightness": RDReaderManager.shared.brightValue + .currentType, + ] + } + + private func currentReadableEPUBPageCount() -> Int { + isTextEPUBMode ? (epubTextBook?.pages.count ?? 0) : epubPages.count + } + + private func resolvedEPUBRestorePageNumber(for location: RDEPUBLocation?) + -> Int? + { + guard let location else { return nil } + return epubSession? + .pageIndex(for: location, bookIdentifier: currentEPUBBookIdentifier) + .map { $0 + 1 } + } + + /// 加载指示器 + private lazy var loadingIndicator: UIActivityIndicatorView = { + let indicator: UIActivityIndicatorView + if #available(iOS 13.0, *) { + indicator = UIActivityIndicatorView(style: .large) + } else { + indicator = UIActivityIndicatorView(style: .whiteLarge) + } + indicator.hidesWhenStopped = true + indicator.color = .gray + return indicator + }() + + private lazy var epubPaginationHostView: UIView = { + let hostView = UIView(frame: .zero) + hostView.alpha = 0.01 + hostView.isUserInteractionEnabled = false + hostView.clipsToBounds = true + hostView.accessibilityElementsHidden = true + return hostView + }() + + private lazy var epubPreloadHostView: UIView = { + let hostView = UIView(frame: .zero) + hostView.alpha = 0.01 + hostView.isUserInteractionEnabled = false + hostView.clipsToBounds = true + hostView.accessibilityElementsHidden = true + return hostView + }() + + var isReaderContentReady = false + + /// 书籍来源,默认为TXT文件 + var bookSource: BookSource = .textFile + + /// 便捷初始化,指定EPUB文件 + convenience init(epubURL: URL) { + self.init() + self.bookSource = .epubFile(url: epubURL) + } + + public override func viewDidLoad() { + super.viewDidLoad() + makeUI() + + switch bookSource { + case .textFile: + loadData() + isReaderContentReady = true + readerView.reloadData() + transitionToPage() + case .epubFile(let url): + loadEPUBData(url: url) + } + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + self.navigationController? + .setNavigationBarHidden(true, animated: animated) + } + + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + self.navigationController? + .setNavigationBarHidden(false, animated: animated) + } + + // MARK: - TXT数据加载 + + func loadData() { + updateReaderViewConfiguration() + epubPublication = nil + epubSession = nil + epubReadingProfile = nil + epubTextBook = nil + currentEPUBBookIdentifier = nil + epubTOCItems = [] + epubHighlights = [] + clearStagedBackgroundEPUBPagination() + clearEPUBPreloadCache() + let fontSize = RDReaderManager.shared.fontValue.currentType + let lineSapce = RDReaderManager.shared.lineSpaceType.currentType + let pageWidth: CGFloat + if readerView.pagesPerScreen > 1 { + pageWidth = + view.frame.width + / CGFloat( + readerView.pagesPerScreen + ) - 16 * 2 + } else { + pageWidth = view.frame.width - 16 * 2 + } + let pageSize = CGSize( + width: pageWidth, + height: view.frame.height - 40 * 2 + ) + let font = UIFont.systemFont(ofSize: fontSize) + let lineSpaceValue = lineSapce.mulitiple * 15 + + book = RDReaderManager.shared + .encodeTextFile( + font: font, + lineSapce: lineSpaceValue, + size: pageSize + ) + chapterPages = book.chapters.reduce( + [], + { result, chapter in + result + chapter.pages + } + ) + } + + func transitionToPage(rangeLocation: Int? = nil) { + var rangeLoc = RDReaderManager.shared.readProgress.currentType + if rangeLocation != nil { + rangeLoc = rangeLocation! + } + if let pageNum = chapterPages.firstIndex( + where: { chapterPage in + let chapter = self.book.chapters[chapterPage.chapterSort - 1] + let chapterRange = chapter.range + let range = NSMakeRange( + chapterRange.location + chapterPage.range.location, + chapterPage.range.length + ) + return range.contains(rangeLoc) + }) + { + readerView.transitionToPage(pageNum: pageNum + 1) + } + } + + // MARK: - EPUB数据加载(异步) + + func loadEPUBData(url: URL) { + isReaderContentReady = false + currentEPUBBookIdentifier = nil + epubTOCItems = [] + epubHighlights = [] + clearStagedBackgroundEPUBPagination() + clearEPUBPreloadCache() + // 显示加载指示器 + showLoading() + + // 1. 解析EPUB结构 + let parser = RDEPUBParser() + do { + try parser.parse(epubURL: url) + } catch { + hideLoading() + print("EPUB解析失败: \(error.localizedDescription)") + return + } + self.epubParser = parser + let publication = parser.makePublication() + self.epubPublication = publication + let session = RDEPUBReadingSession(publication: publication) + session.transition(to: .loading) + self.epubSession = session + self.epubReadingProfile = publication.readingProfile + self.epubTextBook = nil + updateReaderViewConfiguration() + self.currentEPUBBookIdentifier = + parser.metadata.identifier ?? url.lastPathComponent + self.epubHighlights = storedEPUBHighlights() + let savedLocation = storedEPUBLocation() + beginEPUBValidationRun( + reason: "initialLoad", + restoreLocation: savedLocation + ) + + let debugBookName = + title ?? url.deletingPathExtension().lastPathComponent + print( + "[EPUB] Load book=\(debugBookName) profile=\(publication.readingProfile.rawValue) spine=\(parser.spine.count)" + ) + storeEPUBValidationState([ + "bookTitle": debugBookName, + "profile": publication.readingProfile.rawValue, + "spineCount": parser.spine.count, + "bookIdentifier": currentEPUBBookIdentifier ?? "", + ]) + + if publication.readingProfile == .textReflowable { + loadEPUBTextBook( + parser: parser, + publication: publication, + restoreLocation: savedLocation + ) + return + } + + // 2. 使用WKWebView预计算每章页数 + let layoutContext = currentEPUBLayoutContext() + let viewportSize = layoutContext.viewportSize + let preferences = RDReaderManager.shared.epubPreferences( + reflowableInsets: layoutContext.reflowableContentInsets, + fixedContentInset: layoutContext.fixedContentInset + ) + if let cacheKey = epubPaginationCacheKey( + parser: parser, + viewportSize: viewportSize, + padding: preferences.reflowableContentInsets, + fontSize: preferences.fontSize, + lineHeightMultiple: preferences.lineHeightMultiple + ), + let cachedPageCounts = cachedEPUBPageCounts( + for: cacheKey, + expectedSpineCount: parser.spine.count + ) + { + finishEPUBPagination( + pageCounts: cachedPageCounts, + restoreLocation: savedLocation, + validationStage: "cache" + ) + return + } + + if parser.metadata.layout != .fixed { + let initialSpineIndex = initialEPUBSpineIndex( + for: savedLocation, + parser: parser + ) + let paginator = RDEPUBPaginator() + self.epubPaginator = paginator + paginator.calculateSingleSpinePageCount( + parser: parser, + spineIndex: initialSpineIndex, + hostingView: ensureEPUBPaginationHostView( + viewportSize: viewportSize + ), + presentation: + preferences + .presentationStyle(viewportSize: viewportSize) + ) { [weak self] pageCount in + guard let self = self else { return } + var provisionalPageCounts = Array( + repeating: 1, + count: parser.spine.count + ) + provisionalPageCounts[initialSpineIndex] = max(1, pageCount) + self.finishEPUBPagination( + pageCounts: provisionalPageCounts, + restoreLocation: savedLocation, + validationStage: "provisional" + ) + self.epubPaginator = nil + self.startBackgroundEPUBPagination( + parser: parser, + viewportSize: viewportSize, + padding: preferences.reflowableContentInsets, + fontSize: preferences.fontSize, + lineHeightMultiple: preferences.lineHeightMultiple, + restoreLocation: savedLocation + ) + } + return + } + + let paginator = RDEPUBPaginator() + self.epubPaginator = paginator + + paginator.calculate( + parser: parser, + hostingView: ensureEPUBPaginationHostView( + viewportSize: viewportSize + ), + presentation: + preferences + .presentationStyle(viewportSize: viewportSize) + ) { [weak self] pageCounts in + guard let self = self else { return } + if let cacheKey = self.epubPaginationCacheKey( + parser: parser, + viewportSize: viewportSize, + padding: preferences.reflowableContentInsets, + fontSize: preferences.fontSize, + lineHeightMultiple: preferences.lineHeightMultiple + ) { + self.saveEPUBPageCounts(pageCounts, for: cacheKey) + } + self.finishEPUBPagination( + pageCounts: pageCounts, + restoreLocation: savedLocation, + validationStage: "initial" + ) + self.epubPaginator = nil + } + } + + /// 重新分页EPUB(字号/行距改变时) + func repaginateEPUB(reason: String = "repaginate") { + guard let parser = epubParser, let session = epubSession else { return } + updateReaderViewConfiguration() + session.transition(to: .repaginating) + + isReaderContentReady = false + showLoading() + clearStagedBackgroundEPUBPagination() + clearEPUBPreloadCache() + + let layoutContext = currentEPUBLayoutContext() + let viewportSize = layoutContext.viewportSize + let preferences = RDReaderManager.shared.epubPreferences( + reflowableInsets: layoutContext.reflowableContentInsets, + fixedContentInset: layoutContext.fixedContentInset + ) + + let savedLocation = storedEPUBLocation() ?? currentVisibleEPUBLocation() + beginEPUBValidationRun(reason: reason, restoreLocation: savedLocation) + if isTextEPUBMode, let publication = epubPublication { + loadEPUBTextBook( + parser: parser, + publication: publication, + restoreLocation: savedLocation + ) + return + } + + if let cacheKey = epubPaginationCacheKey( + parser: parser, + viewportSize: viewportSize, + padding: preferences.reflowableContentInsets, + fontSize: preferences.fontSize, + lineHeightMultiple: preferences.lineHeightMultiple + ), + let cachedPageCounts = cachedEPUBPageCounts( + for: cacheKey, + expectedSpineCount: parser.spine.count + ) + { + finishEPUBPagination( + pageCounts: cachedPageCounts, + restoreLocation: savedLocation, + validationStage: "cache" + ) + return + } + + if parser.metadata.layout != .fixed { + let initialSpineIndex = initialEPUBSpineIndex( + for: savedLocation, + parser: parser + ) + let paginator = RDEPUBPaginator() + self.epubPaginator = paginator + paginator.calculateSingleSpinePageCount( + parser: parser, + spineIndex: initialSpineIndex, + hostingView: ensureEPUBPaginationHostView( + viewportSize: viewportSize + ), + presentation: + preferences + .presentationStyle(viewportSize: viewportSize) + ) { [weak self] pageCount in + guard let self = self else { return } + var provisionalPageCounts = Array( + repeating: 1, + count: parser.spine.count + ) + provisionalPageCounts[initialSpineIndex] = max(1, pageCount) + self.finishEPUBPagination( + pageCounts: provisionalPageCounts, + restoreLocation: savedLocation, + validationStage: "provisional" + ) + self.epubPaginator = nil + self.startBackgroundEPUBPagination( + parser: parser, + viewportSize: viewportSize, + padding: preferences.reflowableContentInsets, + fontSize: preferences.fontSize, + lineHeightMultiple: preferences.lineHeightMultiple, + restoreLocation: savedLocation + ) + } + return + } + + let paginator = RDEPUBPaginator() + self.epubPaginator = paginator + + paginator.calculate( + parser: parser, + hostingView: ensureEPUBPaginationHostView( + viewportSize: viewportSize + ), + presentation: + preferences + .presentationStyle(viewportSize: viewportSize) + ) { [weak self] pageCounts in + guard let self = self else { return } + if let cacheKey = self.epubPaginationCacheKey( + parser: parser, + viewportSize: viewportSize, + padding: preferences.reflowableContentInsets, + fontSize: preferences.fontSize, + lineHeightMultiple: preferences.lineHeightMultiple + ) { + self.saveEPUBPageCounts(pageCounts, for: cacheKey) + } + self.finishEPUBPagination( + pageCounts: pageCounts, + restoreLocation: savedLocation, + validationStage: "initial" + ) + self.epubPaginator = nil + } + } + + private func finishEPUBPagination( + pageCounts: [Int], + restoreLocation: RDEPUBLocation?, + validationStage: String + ) { + guard let session = epubSession else { return } + let snapshot = session.makePaginationSnapshot( + pageCounts: pageCounts, + preferences: currentEPUBPreferences(), + layoutContext: currentEPUBLayoutContext() + ) + applyEPUBPaginationSnapshot( + snapshot, + restoreLocation: restoreLocation, + hideLoadingIndicator: true, + validationStage: validationStage + ) + } + + private func applyEPUBPaginationSnapshot( + _ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]), + restoreLocation: RDEPUBLocation?, + hideLoadingIndicator: Bool, + validationStage: String + ) { + epubSession?.setActiveSnapshot(snapshot) + if let publication = epubPublication { + epubTOCItems = flattenedTOCItems(from: publication.tableOfContents) + } + storeEPUBPaginationValidation( + mode: epubReadingProfile?.rawValue ?? "unknown", + stage: validationStage, + pageCount: snapshot.pages.count, + chapterCount: snapshot.chapters.count, + tocCount: epubTOCItems.count, + restoreLocation: restoreLocation, + resolvedRestorePage: resolvedEPUBRestorePageNumber( + for: restoreLocation + ) + ) + isReaderContentReady = true + if hideLoadingIndicator { + hideLoading() + } + readerView.reloadData() + restoreEPUBLocation(restoreLocation) + if restoreLocation == nil { + epubSession?.transition(to: .idle) + } + } + + private func queueBackgroundEPUBPaginationApplication( + pageCounts: [Int], + restoreLocation: RDEPUBLocation? + ) { + guard let session = epubSession else { return } + let snapshot = session.makePaginationSnapshot( + pageCounts: pageCounts, + preferences: currentEPUBPreferences(), + layoutContext: currentEPUBLayoutContext() + ) + epubSession?.stageSnapshot(snapshot, restoreLocation: restoreLocation) + schedulePendingBackgroundEPUBPaginationApplication() + } + + private func schedulePendingBackgroundEPUBPaginationApplication() { + backgroundEPUBPaginationApplyWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.applyPendingBackgroundEPUBPaginationIfNeeded() + } + backgroundEPUBPaginationApplyWorkItem = workItem + DispatchQueue.main + .asyncAfter(deadline: .now() + 0.35, execute: workItem) + } + + private func applyPendingBackgroundEPUBPaginationIfNeeded() { + if let staged = epubSession?.consumeStagedSnapshotIfAllowed() { + let restoreLocation = + staged.restoreLocation ?? storedEPUBLocation() + ?? currentVisibleEPUBLocation() + backgroundEPUBPaginationApplyWorkItem = nil + applyEPUBPaginationSnapshot( + staged.snapshot, + restoreLocation: restoreLocation, + hideLoadingIndicator: false, + validationStage: "backgroundFinal" + ) + return + } + guard epubSession?.stagedSnapshot() != nil else { return } + schedulePendingBackgroundEPUBPaginationApplication() + } + + private func clearStagedBackgroundEPUBPagination() { + backgroundEPUBPaginationApplyWorkItem?.cancel() + backgroundEPUBPaginationApplyWorkItem = nil + epubSession?.clearStagedSnapshot() + } + + private func initialEPUBSpineIndex( + for location: RDEPUBLocation?, + parser: RDEPUBParser + ) -> Int { + let _ = parser + return epubSession?.initialSpineIndex(for: location) ?? 0 + } + + private func startBackgroundEPUBPagination( + parser: RDEPUBParser, + viewportSize: CGSize, + padding: UIEdgeInsets, + fontSize: CGFloat, + lineHeightMultiple: CGFloat, + restoreLocation: RDEPUBLocation? + ) { + guard !isBackgroundEPUBPaginationRunning else { return } + isBackgroundEPUBPaginationRunning = true + + let paginator = RDEPUBPaginator() + self.epubPaginator = paginator + let preferences = currentEPUBPreferences() + paginator.calculate( + parser: parser, + hostingView: ensureEPUBPaginationHostView( + viewportSize: viewportSize + ), + presentation: + preferences + .presentationStyle(viewportSize: viewportSize) + ) { [weak self] pageCounts in + guard let self = self else { return } + self.isBackgroundEPUBPaginationRunning = false + if let cacheKey = self.epubPaginationCacheKey( + parser: parser, + viewportSize: viewportSize, + padding: padding, + fontSize: fontSize, + lineHeightMultiple: lineHeightMultiple + ) { + self.saveEPUBPageCounts(pageCounts, for: cacheKey) + } + let currentLocation = + self.storedEPUBLocation() ?? self.currentVisibleEPUBLocation() + ?? restoreLocation + self.queueBackgroundEPUBPaginationApplication( + pageCounts: pageCounts, + restoreLocation: currentLocation + ) + self.epubPaginator = nil + } + } + + private func epubPaginationCacheKey( + parser: RDEPUBParser, + viewportSize: CGSize, + padding: UIEdgeInsets, + fontSize: CGFloat, + lineHeightMultiple: CGFloat + ) -> String? { + let bookSignature: String + switch bookSource { + case .textFile: + return nil + case .epubFile(let url): + let attributes = try? FileManager.default.attributesOfItem( + atPath: url.path + ) + let fileSize = (attributes?[.size] as? NSNumber)?.stringValue ?? "0" + let modifiedAt = + (attributes?[.modificationDate] as? Date)?.timeIntervalSince1970 + ?? 0 + bookSignature = [ + url.lastPathComponent, + fileSize, + String(format: "%.0f", modifiedAt), + ] + .joined(separator: "|") + } + + let layoutSignature = [ + Int(viewportSize.width.rounded()).description, + Int(viewportSize.height.rounded()).description, + Int(padding.top.rounded()).description, + Int(padding.left.rounded()).description, + Int(padding.bottom.rounded()).description, + Int(padding.right.rounded()).description, + String(format: "%.2f", fontSize), + String(format: "%.2f", lineHeightMultiple), + String(readerView.pagesPerScreen), + parser.metadata.layout.rawValue, + String(parser.spine.count), + ].joined(separator: "|") + + return bookSignature + "|" + layoutSignature + } + + private func cachedEPUBPageCounts(for key: String, expectedSpineCount: Int) + -> [Int]? + { + let cache = + UserDefaults.standard.dictionary( + forKey: Self.epubPaginationCacheStorageKey + ) ?? [:] + let rawCounts = cache[key] + let counts: [Int]? + if let values = rawCounts as? [Int] { + counts = values + } else if let values = rawCounts as? [NSNumber] { + counts = values.map(\.intValue) + } else { + counts = nil + } + guard let counts, counts.count == expectedSpineCount else { + return nil + } + return counts.map { max(1, $0) } + } + + private func saveEPUBPageCounts(_ pageCounts: [Int], for key: String) { + var cache = + UserDefaults.standard.dictionary( + forKey: Self.epubPaginationCacheStorageKey + ) ?? [:] + cache[key] = pageCounts.map { max(1, $0) } + UserDefaults.standard + .set(cache, forKey: Self.epubPaginationCacheStorageKey) + } + + private func currentEPUBLayoutContext() -> RDEPUBNavigatorLayoutContext { + let readerBounds = + readerView.bounds.size == .zero + ? view.bounds.size : readerView.bounds.size + let safeAreaInsets = view.window?.safeAreaInsets ?? view.safeAreaInsets + return RDEPUBNavigatorLayoutContext( + containerSize: readerBounds, + pagesPerScreen: readerView.pagesPerScreen, + safeAreaInsets: safeAreaInsets, + userInterfaceIdiom: UIDevice.current.userInterfaceIdiom + ) + } + + private func currentEPUBPreferences(theme: RDReaderTheme? = nil) + -> RDEPUBPreferences + { + let layoutContext = currentEPUBLayoutContext() + return RDReaderManager.shared.epubPreferences( + theme: theme, + reflowableInsets: layoutContext.reflowableContentInsets, + fixedContentInset: layoutContext.fixedContentInset + ) + } + + private func renderRequest( + for pageNum: Int, + page: EPUBPage, + publication: RDEPUBPublication, + theme: RDReaderTheme + ) -> RDEPUBRenderRequest? { + let preferences = currentEPUBPreferences(theme: theme) + let layoutContext = currentEPUBLayoutContext() + let targetLocation = epubSession?.pendingLocation( + forPageNumber: pageNum, + spineIndex: page.fixedSpread == nil ? page.spineIndex : nil + ) + return preferences.renderRequest( + for: page, + publication: publication, + viewportSize: layoutContext.viewportSize, + targetLocation: targetLocation, + highlights: highlights(for: page.spineIndex) + ) + } + + private func updateReaderViewConfiguration() { + switch bookSource { + case .textFile: + readerView.landscapeDualPageEnabled = true + readerView.pageDirection = .rightToLeft + case .epubFile: + let isFixedLayout = epubPublication?.layout == .fixed + readerView.landscapeDualPageEnabled = !isFixedLayout + + switch epubPublication?.readingProgression { + case .ltr: + readerView.pageDirection = .leftToRight + case .rtl: + readerView.pageDirection = .rightToLeft + case .some(.auto): + readerView.pageDirection = .rightToLeft + case .none: + readerView.pageDirection = .rightToLeft + } + + let preferredDisplayType = effectiveDisplayType( + requested: RDReaderManager.shared.pageCurlTypeType.currentType + .value + ) + if readerView.currentDisplayType != preferredDisplayType { + readerView.switchReaderDisplayType(preferredDisplayType) + } + } + } + + private func effectiveDisplayType(requested: RDReaderView.DisplayType) + -> RDReaderView.DisplayType + { + guard epubPublication?.layout == .fixed else { + return requested + } + + if requested == .pageCurl { + return .horizontalScroll + } + return requested + } + + private func saveEPUBLocation(_ location: RDEPUBLocation) { + let scopedLocation = RDEPUBLocation( + bookIdentifier: currentEPUBBookIdentifier, + href: location.href, + progression: location.progression, + lastProgression: location.lastProgression, + fragment: location.fragment + ) + guard + let data = try? JSONEncoder().encode(scopedLocation), + let json = String(data: data, encoding: .utf8) + else { + return + } + RDReaderManager.shared.epubReadLocation.currentType = json + } + + private func saveEPUBSelection(_ selection: RDEPUBSelection?) { + guard let selection = selection, !selection.isEmpty else { + RDReaderManager.shared.epubSelection.currentType = "" + return + } + let scopedSelection = RDEPUBSelection( + bookIdentifier: currentEPUBBookIdentifier, + location: RDEPUBLocation( + bookIdentifier: currentEPUBBookIdentifier, + href: selection.location.href, + progression: selection.location.progression, + lastProgression: selection.location.lastProgression, + fragment: selection.location.fragment + ), + text: selection.text, + rangeInfo: selection.rangeInfo, + createdAt: selection.createdAt + ) + guard + let data = try? JSONEncoder().encode(scopedSelection), + let json = String(data: data, encoding: .utf8) + else { + return + } + RDReaderManager.shared.epubSelection.currentType = json + } + + private func storedEPUBSelection() -> RDEPUBSelection? { + let json = RDReaderManager.shared.epubSelection.currentType + guard !json.isEmpty, let data = json.data(using: .utf8) else { + return nil + } + guard + let selection = try? JSONDecoder().decode( + RDEPUBSelection.self, + from: data + ) + else { + return nil + } + guard selection.bookIdentifier == currentEPUBBookIdentifier else { + return nil + } + return selection + } + + private func allStoredEPUBHighlights() -> [RDEPUBHighlight] { + let json = RDReaderManager.shared.epubHighlights.currentType + guard !json.isEmpty, let data = json.data(using: .utf8) else { + return [] + } + return (try? JSONDecoder().decode([RDEPUBHighlight].self, from: data)) + ?? [] + } + + private func storedEPUBHighlights() -> [RDEPUBHighlight] { + allStoredEPUBHighlights() + .filter { $0.bookIdentifier == currentEPUBBookIdentifier } + } + + private func saveEPUBHighlights(_ highlights: [RDEPUBHighlight]) { + let scopedHighlights = highlights.map { highlight in + RDEPUBHighlight( + id: highlight.id, + bookIdentifier: currentEPUBBookIdentifier, + location: RDEPUBLocation( + bookIdentifier: currentEPUBBookIdentifier, + href: highlight.location.href, + progression: highlight.location.progression, + lastProgression: highlight.location.lastProgression, + fragment: highlight.location.fragment + ), + text: highlight.text, + rangeInfo: highlight.rangeInfo, + color: highlight.color, + note: highlight.note, + createdAt: highlight.createdAt + ) + } + let otherBookHighlights = allStoredEPUBHighlights().filter { + $0.bookIdentifier != currentEPUBBookIdentifier + } + let mergedHighlights = otherBookHighlights + scopedHighlights + guard let data = try? JSONEncoder().encode(mergedHighlights), + let json = String(data: data, encoding: .utf8) + else { + return + } + RDReaderManager.shared.epubHighlights.currentType = json + } + + private func addHighlightFromCurrentSelection() { + guard let selection = storedEPUBSelection(), + let normalizedLocation = normalizedEPUBLocation( + selection.location + ), + !selection.isEmpty + else { + return + } + + let newHighlight = RDEPUBHighlight( + bookIdentifier: currentEPUBBookIdentifier, + location: normalizedLocation, + text: selection.text, + rangeInfo: selection.rangeInfo + ) + var highlights = storedEPUBHighlights() + let isDuplicate = highlights.contains { highlight in + highlight.location.href == newHighlight.location.href + && highlight.location.fragment == newHighlight.location.fragment + && highlight.text == newHighlight.text + && highlight.rangeInfo == newHighlight.rangeInfo + } + guard !isDuplicate else { + return + } + + highlights.append(newHighlight) + epubHighlights = highlights + saveEPUBHighlights(highlights) + saveEPUBSelection(nil) + + let currentLocation = currentVisibleEPUBLocation() + if isReaderContentReady { + readerView.reloadData() + restoreEPUBLocation(currentLocation) + } + } + + private func storedEPUBLocation() -> RDEPUBLocation? { + let json = RDReaderManager.shared.epubReadLocation.currentType + guard !json.isEmpty, let data = json.data(using: .utf8) else { + return nil + } + guard + let location = try? JSONDecoder().decode( + RDEPUBLocation.self, + from: data + ) + else { + return nil + } + guard location.bookIdentifier == currentEPUBBookIdentifier else { + return nil + } + return location + } + + private func currentVisibleEPUBLocation() -> RDEPUBLocation? { + if isTextEPUBMode { + return epubTextBook? + .location( + forPageNumber: readerView.currentPage, + bookIdentifier: currentEPUBBookIdentifier + ) + } + if let location = epubSession?.currentReadingContext?.location { + return location + } + return epubSession?.currentVisibleLocation( + currentPageNumber: readerView.currentPage, + bookIdentifier: currentEPUBBookIdentifier + ) + } + + private func pageContains(spineIndex: Int, in page: EPUBPage) -> Bool { + if let fixedSpread = page.fixedSpread { + return fixedSpread.resources + .contains(where: { $0.spineIndex == spineIndex }) + } + return page.spineIndex == spineIndex + } + + private func fallbackEPUBLocation(for page: EPUBPage) -> RDEPUBLocation? { + epubSession? + .fallbackLocation( + for: page, + bookIdentifier: currentEPUBBookIdentifier + ) + } + + private func normalizedEPUBHref( + _ href: String, + relativeTo spineIndex: Int? = nil + ) -> String? { + epubResolver?.normalizedHref(href, relativeToSpineIndex: spineIndex) + ?? href.components(separatedBy: "#").first + } + + private func normalizedEPUBLocation( + _ location: RDEPUBLocation, + relativeTo spineIndex: Int? = nil + ) -> RDEPUBLocation? { + epubResolver?.normalizedLocation( + location, + relativeToSpineIndex: spineIndex, + bookIdentifier: currentEPUBBookIdentifier + ) + } + + private func currentEPUBTOCIndex() -> Int { + guard !epubTOCItems.isEmpty else { + return currentChapterNum + } + if isTextEPUBMode { + let currentPageNumber = readerView.currentPage + guard currentPageNumber > 0 else { + return 0 + } + return + epubTOCItems + .lastIndex( + where: { + ($0.pageNumber ?? Int.max) <= currentPageNumber + }) ?? 0 + } + let referenceLocation = + storedEPUBLocation() ?? currentVisibleEPUBLocation() + guard let location = referenceLocation, + let normalizedLocation = normalizedEPUBLocation(location) + else { + return 0 + } + if let exactIndex = epubTOCItems.firstIndex(where: { item in + guard + let normalizedItem = normalizedEPUBLocation( + RDEPUBLocation( + bookIdentifier: currentEPUBBookIdentifier, + href: item.href, + progression: 0 + ) + ) + else { + return false + } + return normalizedItem.href == normalizedLocation.href + && normalizedItem.fragment == normalizedLocation.fragment + }) { + return exactIndex + } + if let pathIndex = epubTOCItems.lastIndex(where: { item in + guard + let normalizedItem = normalizedEPUBLocation( + RDEPUBLocation( + bookIdentifier: currentEPUBBookIdentifier, + href: item.href, + progression: 0 + ) + ) + else { + return false + } + return normalizedItem.href == normalizedLocation.href + }) { + return pathIndex + } + return 0 + } + + private func pageIndex(for location: RDEPUBLocation) -> Int? { + epubSession? + .pageIndex(for: location, bookIdentifier: currentEPUBBookIdentifier) + } + + private func highlights(for spineIndex: Int) -> [RDEPUBHighlight] { + guard let parser = epubParser, + parser.spine.indices + .contains(spineIndex) + else { + return [] + } + let targetHref = normalizedEPUBHref(parser.spine[spineIndex].href) + return epubHighlights.filter { highlight in + normalizedEPUBHref(highlight.location.href) == targetHref + } + } + + private func restoreEPUBLocationIfNeeded() { + restoreEPUBLocation(storedEPUBLocation()) + } + + func restoreEPUBLocation(_ location: RDEPUBLocation?) { + if isTextEPUBMode { + guard let location, + let targetPageNum = epubTextBook?.pageNumber( + for: location, + resolver: epubResolver ?? publicationFallbackResolver(), + bookIdentifier: currentEPUBBookIdentifier + ) + else { + epubSession?.transition(to: .idle) + return + } + readerView.transitionToPage(pageNum: targetPageNum) + return + } + + guard let session = epubSession, + let location = location, + let targetPageNum = session.queueNavigation( + to: location, + relativeToSpineIndex: nil, + bookIdentifier: currentEPUBBookIdentifier + ) + else { + epubSession?.transition(to: .idle) + return + } + readerView.transitionToPage(pageNum: targetPageNum) + } + + private func navigateToEPUBLink( + _ location: RDEPUBLocation, + fromSpineIndex spineIndex: Int + ) { + guard let session = epubSession, + let targetPageNum = session.queueNavigation( + to: location, + relativeToSpineIndex: spineIndex, + bookIdentifier: currentEPUBBookIdentifier + ) + else { + return + } + readerView.transitionToPage(pageNum: targetPageNum) + } + + // MARK: - Loading Indicator + + private func showLoading() { + view.addSubview(loadingIndicator) + loadingIndicator.center = view.center + loadingIndicator.startAnimating() + } + + func hideLoading() { + loadingIndicator.stopAnimating() + loadingIndicator.removeFromSuperview() + } + + private func ensureEPUBPaginationHostView(viewportSize: CGSize) -> UIView { + let hostFrame = CGRect( + x: -viewportSize.width - 32, + y: 0, + width: max(viewportSize.width, 1), + height: max(viewportSize.height, 1) + ) + + if epubPaginationHostView.superview == nil { + view.addSubview(epubPaginationHostView) + } + epubPaginationHostView.frame = hostFrame + view.sendSubviewToBack(epubPaginationHostView) + return epubPaginationHostView + } + + private func ensureEPUBPreloadHostView(viewportSize: CGSize) -> UIView { + let hostFrame = CGRect( + x: -viewportSize.width - 32, + y: 0, + width: max(viewportSize.width, 1), + height: max(viewportSize.height, 1) + ) + + if epubPreloadHostView.superview == nil { + view.addSubview(epubPreloadHostView) + } + epubPreloadHostView.frame = hostFrame + view.sendSubviewToBack(epubPreloadHostView) + return epubPreloadHostView + } + + private func clearEPUBPreloadCache() { + epubPreloadViews.values.forEach { + $0.releaseResources() + $0.removeFromSuperview() + } + epubPreloadViews.removeAll() + epubPageCurlViews.values.forEach { + $0.releaseResources() + $0.removeFromSuperview() + } + epubPageCurlViews.removeAll() + } + + private func releaseEPUBPageViews(except keepPages: Set) { + epubPreloadViews.forEach { key, value in + guard !keepPages.contains(key) else { return } + value.releaseResources() + value.removeFromSuperview() + } + epubPreloadViews = epubPreloadViews.filter { key, _ in + keepPages.contains(key) + } + + epubPageCurlViews.forEach { key, value in + guard !keepPages.contains(key) else { return } + value.releaseResources() + value.removeFromSuperview() + } + epubPageCurlViews = epubPageCurlViews.filter { key, _ in + keepPages.contains(key) + } + } + + private func trimEPUBPageCurlCache(around pageNum: Int) { + let keepPages = Set( + [ + pageNum - 1, + pageNum, + pageNum + 1, + ].filter { index in + index > 0 + && index < pageCountOfReaderView( + readerView: readerView + ) - 1 + } + ) + releaseEPUBPageViews(except: keepPages) + } + + private func primeEPUBPageCache(around pageNum: Int) { + guard case .epubFile = bookSource, + let publication = epubPublication, + publication.layout != .fixed, + pageNum > 0, + pageNum < pageCountOfReaderView(readerView: readerView) - 1 + else { + return + } + + let validPages = Set( + [pageNum - 1, pageNum + 1].filter { index in + index > 0 + && index < pageCountOfReaderView( + readerView: readerView + ) - 1 + } + ) + + epubPreloadViews = epubPreloadViews.filter { key, value in + let shouldKeep = validPages.contains(key) + if !shouldKeep { + value.releaseResources() + value.removeFromSuperview() + } + return shouldKeep + } + + let theme = RDReaderManager.shared.themeType.currentType.value + let viewportSize = currentEPUBLayoutContext().viewportSize + let preloadHostView = ensureEPUBPreloadHostView( + viewportSize: viewportSize + ) + for targetPage in validPages.sorted() { + let contentView = configuredEPUBContentView( + pageNum: targetPage, + contentView: cachedEPUBPreloadView(pageNum: targetPage), + theme: theme, + publication: publication, + isInteractive: false + ) + if contentView.superview == nil { + preloadHostView.addSubview(contentView) + contentView.frame = preloadHostView.bounds + } + } + } + + private func cachedEPUBPreloadView(pageNum: Int) -> RDReaderEPUBContentView + { + if let cachedView = epubPreloadViews[pageNum] { + return cachedView + } + let contentView = RDReaderEPUBContentView() + epubPreloadViews[pageNum] = contentView + return contentView + } + + private func cachedEPUBPageCurlView(pageNum: Int) -> RDReaderEPUBContentView + { + if let cachedView = epubPageCurlViews[pageNum] { + return cachedView + } + if let preloadedView = epubPreloadViews.removeValue(forKey: pageNum) { + preloadedView.removeFromSuperview() + epubPageCurlViews[pageNum] = preloadedView + return preloadedView + } + let contentView = RDReaderEPUBContentView() + epubPageCurlViews[pageNum] = contentView + return contentView + } + + private func configuredEPUBContentView( + pageNum: Int, + contentView: RDReaderEPUBContentView, + theme: RDReaderTheme, + publication: RDEPUBPublication, + isInteractive: Bool = true + ) -> RDReaderEPUBContentView { + let epubPage = epubPages[pageNum - 1] + contentView.delegate = isInteractive ? self : nil + contentView.backgroundColor = theme.contentBackgroudColor + contentView.pageNumLabel.textColor = theme.contentTextColor + contentView.pageNumLabel.text = "\(pageNum) / \(epubPages.count)" + + let isFixedLayout = publication.layout == .fixed + guard + let request = renderRequest( + for: pageNum, + page: epubPage, + publication: publication, + theme: theme + ) + else { + return contentView + } + + contentView.load(publication: publication, request: request) + return contentView + } + + // MARK: - UI Setup + + func makeUI() { + + readerView.delegate = self + readerView.dataSource = self + readerView.landscapeDualPageEnabled = true + readerView.coverPageIndex = 0 + readerView.pageDirection = .rightToLeft + readerView.frame = CGRect( + x: 0, + y: 0, + width: view.frame.width, + height: view.frame.height + ) + readerView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(readerView) + + readerView + .register( + contentView: RDReaderContentView.self, + contentViewWithReuseIdentifier: NSStringFromClass( + RDReaderContentView.self + ) + ) + readerView + .register( + contentView: RDReaderEPUBContentView.self, + contentViewWithReuseIdentifier: NSStringFromClass( + RDReaderEPUBContentView.self + ) + ) + readerView + .register( + contentView: RDReaderEPUBTextContentView.self, + contentViewWithReuseIdentifier: NSStringFromClass( + RDReaderEPUBTextContentView.self + ) + ) + readerView + .register( + contentView: RDReaderCoverView.self, + contentViewWithReuseIdentifier: NSStringFromClass( + RDReaderCoverView.self + ) + ) + readerView + .register( + contentView: RDReaderEndView.self, + contentViewWithReuseIdentifier: NSStringFromClass( + RDReaderEndView.self + ) + ) + + topToolView.trigger { [weak self] event in + guard let self = self else { return } + switch event { + case .back: + self.navigationController?.popViewController(animated: true) + } + } + + bottomToolView.trigger { [weak self] event in + guard let self = self else { return } + switch event { + case .chapterList: + self.showChapterListView() + case .highlight: + if case .epubFile = self.bookSource { + self.addHighlightFromCurrentSelection() + } + case .settings: + self.showEditView() + case .darkAndLight: + if RDReaderManager.shared.themeType.currentType != .dark { + RDReaderManager.shared.themeType.currentType = .dark + } else { + RDReaderManager.shared.themeType.currentType = .white + } + } + } + + RDReaderManager.shared.themeType.observeChange { [weak self] theme in + guard let self = self else { return } + self.readerView.backgroundColor = theme.contentBackgroudColor + self.topToolView.backgroundColor = theme.toolBackgroudColor + self.bottomToolView.backgroundColor = theme.toolBackgroudColor + self.topToolView.lineView.backgroundColor = theme.toolLineColor + self.bottomToolView.lineView.backgroundColor = theme.toolLineColor + self.topToolView.backButton.tintColor = theme.toolControlTextColor + self.bottomToolView.chapterListButton.tintColor = + theme.toolControlTextColor + self.bottomToolView.highlightButton.tintColor = + theme.toolControlTextColor + self.bottomToolView.settingsButton.tintColor = + theme.toolControlTextColor + self.bottomToolView.lightModeButton.tintColor = + theme.toolControlTextColor + if self.isReaderContentReady { + self.readerView.reloadData() + } + } + + RDReaderManager.shared.pageCurlTypeType + .observeChange { [weak self] type in + guard let self = self else { return } + self.readerView + .switchReaderDisplayType( + self.effectiveDisplayType(requested: type) + ) + if case .epubFile = self.bookSource { + self.storeEPUBValidationState([ + "validationReason": "displayTypeChanged", + "settingsDisplayType": RDReaderManager.shared + .pageCurlTypeType.currentType.rawValue, + "lastVisiblePage": self.readerView.currentPage, + ]) + } + } + + RDReaderManager.shared.fontValue.observeChange { [weak self] _ in + guard let self = self else { return } + guard self.isReaderContentReady else { return } + switch self.bookSource { + case .textFile: + self.loadData() + self.readerView.reloadData() + case .epubFile: + self.repaginateEPUB(reason: "fontSizeChanged") + } + } + + RDReaderManager.shared.lineSpaceType.observeChange { [weak self] _ in + guard let self = self else { return } + guard self.isReaderContentReady else { return } + switch self.bookSource { + case .textFile: + self.loadData() + self.readerView.reloadData() + case .epubFile: + self.repaginateEPUB(reason: "lineSpacingChanged") + } + } + } + + func showChapterListView() { + switch bookSource { + case .textFile: + showTXTChapterList() + case .epubFile: + showEPUBChapterList() + } + } + + private func showTXTChapterList() { + let chapterListVC = SSChapterListController( + chapters: self.book.chapters, + currentChapterNum: currentChapterNum + ) + chapterListVC.view.ss_w = view.frame.width * 0.8 + chapterListVC.view.ss_h = view.frame.height + + let alertView = SSAlertView( + customView: chapterListVC.view, + fromViewController: self, + canPanDimiss: false + ) + let animation = SSAlertDefaultAnmation(state: .fromLeft) + animation.isSpringShowAnimation = false + alertView.animation = animation + alertView.show() + + chapterListVC.selectedChapter { [weak alertView, weak self] number in + guard let self = self else { return } + let chapter = self.book.chapters[number] + self.transitionToPage(rangeLocation: chapter.range.location) + alertView?.hide() + } + } + + private func showEPUBChapterList() { + let currentIndex = currentEPUBTOCIndex() + let chapters: [Chapter] + if !epubTOCItems.isEmpty { + chapters = + epubTOCItems + .enumerated() + .map { (index, item) -> Chapter in + let indent = String(repeating: " ", count: item.depth) + return Chapter( + sort: index + 1, + title: indent + item.title, + pageCount: 0, + content: "", + pages: [], + range: NSMakeRange(0, 0) + ) + } + } else if isTextEPUBMode, let textBook = epubTextBook { + chapters = textBook.chapters.enumerated().map { index, chapter in + Chapter( + sort: index + 1, + title: chapter.title, + pageCount: chapter.pages.count, + content: "", + pages: [], + range: NSMakeRange(0, 0) + ) + } + } else { + chapters = + epubChapters + .enumerated() + .map { (index, info) -> Chapter in + Chapter( + sort: index + 1, + title: info.title, + pageCount: info.pageCount, + content: "", + pages: [], + range: NSMakeRange(0, 0) + ) + } + } + let chapterListVC = SSChapterListController( + chapters: chapters, + currentChapterNum: currentIndex + ) + chapterListVC.view.ss_w = view.frame.width * 0.8 + chapterListVC.view.ss_h = view.frame.height + + let alertView = SSAlertView( + customView: chapterListVC.view, + fromViewController: self, + canPanDimiss: false + ) + let animation = SSAlertDefaultAnmation(state: .fromLeft) + animation.isSpringShowAnimation = false + alertView.animation = animation + alertView.show() + + chapterListVC.selectedChapter { [weak alertView, weak self] number in + guard let self = self else { return } + if !self.epubTOCItems.isEmpty { + let item = self.epubTOCItems[number] + if self.isTextEPUBMode, let pageNumber = item.pageNumber { + self.storeEPUBTOCValidation( + item: item, + resolvedPage: pageNumber + ) + self.readerView.transitionToPage(pageNum: pageNumber) + } else { + let location = RDEPUBLocation( + bookIdentifier: self.currentEPUBBookIdentifier, + href: item.href, + progression: 0, + fragment: nil + ) + let fromSpineIndex = + self.readerView.currentPage > 0 + && self.epubPages.indices.contains( + self.readerView.currentPage - 1 + ) + ? self.epubPages[self.readerView.currentPage - 1] + .spineIndex + : 0 + let resolvedPage = self.epubSession?.pageIndex( + for: location, + bookIdentifier: self.currentEPUBBookIdentifier + ).map { + $0 + 1 + } + self.storeEPUBTOCValidation( + item: item, + resolvedPage: resolvedPage + ) + self.navigateToEPUBLink( + location, + fromSpineIndex: fromSpineIndex + ) + } + } else if self.isTextEPUBMode, + let textPage = self.epubTextBook?.pages.first( + where: { + $0.chapterIndex == number + }) + { + self.readerView + .transitionToPage(pageNum: textPage.absolutePageIndex + 1) + } else if let pageIndex = self.epubPages.firstIndex( + where: { + $0.chapterIndex == number + }) + { + self.readerView.transitionToPage(pageNum: pageIndex + 1) + } + alertView?.hide() + } + } + + func showEditView() { + let customView = RDReaderEditView( + frame: CGRect(x: 0, y: 0, width: view.frame.width, height: 300) + ) + customView.brightSlider + .setValue( + Float(RDReaderManager.shared.brightValue.currentType), + animated: false + ) + customView.fontView.currentFontSize = + RDReaderManager.shared.fontValue.currentType + customView.lineSpaceView.currentType = + RDReaderManager.shared.lineSpaceType.currentType + customView.pageCurlTypeView.currentType = + RDReaderManager.shared.pageCurlTypeType.currentType + customView.colorView.currentType = + RDReaderManager.shared.themeType.currentType + + let alertView = SSAlertView( + customView: customView, + fromViewController: self, + animation: SSAlertDefaultAnmation(state: .fromBottom), + canPanDimiss: false + ) + alertView.show() + + customView.brightSlider.trigger { event in + switch event { + case .valueDidChange(let value): + RDReaderManager.shared.brightValue.currentType = value + } + } + + customView.fontView.trigger { event in + switch event { + case .fontSize(let size): + RDReaderManager.shared.fontValue.currentType = size + } + } + + customView.lineSpaceView.trigger { event in + switch event { + case .chose(let type): + RDReaderManager.shared.lineSpaceType.currentType = type + } + } + + customView.pageCurlTypeView.trigger { event in + switch event { + case .chose(let type): + RDReaderManager.shared.pageCurlTypeType.currentType = type + } + } + + customView.colorView.trigger { event in + switch event { + case .chose(let type): + RDReaderManager.shared.themeType.currentType = type + } + } + } +} + +// MARK: - RDReaderDataSource & RDReaderDelegate + +extension RDReaderController: RDReaderDataSource, RDReaderDelegate { + + public func pageIdentifier(readerView: RDReaderView, pageNum: Int) + -> String? + { + if pageNum == 0 { + return NSStringFromClass(RDReaderCoverView.self) + } else if pageNum == pageCountOfReaderView(readerView: readerView) - 1 { + return NSStringFromClass(RDReaderEndView.self) + } + switch bookSource { + case .textFile: + return NSStringFromClass(RDReaderContentView.self) + case .epubFile: + return isTextEPUBMode + ? NSStringFromClass(RDReaderEPUBTextContentView.self) + : NSStringFromClass(RDReaderEPUBContentView.self) + } + } + + public func pageCountOfReaderView(readerView: RDReaderView) -> Int { + switch bookSource { + case .textFile: + return chapterPages.count + 2 // +cover +end + case .epubFile: + return + (isTextEPUBMode + ? (epubTextBook?.pages.count ?? 0) : epubPages.count) + 2 + } + } + + public func pageContentView( + readerView: RDReaderView, + pageNum: Int, + containerView: UIView? + ) -> UIView { + let totalPages = pageCountOfReaderView(readerView: readerView) + let theme = RDReaderManager.shared.themeType.currentType.value + + // 封面页 + if pageNum == 0 { + let contentView = + (containerView as? RDReaderCoverView) ?? RDReaderCoverView() + contentView.backgroundColor = theme.contentBackgroudColor + contentView.textLabel.textColor = theme.contentTextColor + switch bookSource { + case .textFile: + contentView.textLabel.text = book.desc + case .epubFile: + contentView.textLabel.text = + epubParser?.metadata.title ?? "EPUB" + if let coverImage = epubParser?.coverImage() { + contentView.imageV.image = coverImage + } + } + return contentView + } + + // 结束页 + if pageNum == totalPages - 1 { + let contentView = + (containerView as? RDReaderEndView) ?? RDReaderEndView() + contentView.backgroundColor = theme.contentBackgroudColor + return contentView + } + + // 内容页 + switch bookSource { + case .textFile: + return txtContentView( + pageNum: pageNum, + containerView: containerView, + theme: theme + ) + case .epubFile: + return isTextEPUBMode + ? epubTextContentView( + pageNum: pageNum, + containerView: containerView, + theme: theme + ) + : epubContentView( + pageNum: pageNum, + containerView: containerView, + theme: theme + ) + } + } + + /// TXT内容页 + private func txtContentView( + pageNum: Int, + containerView: UIView?, + theme: RDReaderTheme + ) -> UIView { + let chapter = chapterPages[pageNum - 1] + let contentView = + (containerView as? RDReaderContentView) ?? RDReaderContentView() + contentView.textLabel.textColor = theme.contentTextColor + contentView.pageNumLabel.textColor = theme.contentTextColor + contentView.backgroundColor = theme.contentBackgroudColor + contentView.textLabel.attributedText = chapter.attrContent + contentView.pageNumLabel.text = "\(pageNum) / \(chapterPages.count)" + return contentView + } + + /// EPUB内容页(WKWebView渲染) + private func epubContentView( + pageNum: Int, + containerView: UIView?, + theme: RDReaderTheme + ) -> UIView { + guard let publication = epubPublication else { + return containerView ?? UIView() + } + + let contentView: RDReaderEPUBContentView + if readerView.currentDisplayType == .pageCurl, containerView == nil { + contentView = cachedEPUBPageCurlView(pageNum: pageNum) + } else { + contentView = + (containerView as? RDReaderEPUBContentView) + ?? RDReaderEPUBContentView() + } + + return configuredEPUBContentView( + pageNum: pageNum, + contentView: contentView, + theme: theme, + publication: publication, + isInteractive: true + ) + } + + private func epubTextContentView( + pageNum: Int, + containerView: UIView?, + theme: RDReaderTheme + ) -> UIView { + guard let page = epubTextBook?.page(at: pageNum) else { + return containerView ?? UIView() + } + let contentView = + (containerView as? RDReaderEPUBTextContentView) + ?? RDReaderEPUBTextContentView() + contentView + .configure( + page: page, + pageNumber: pageNum, + totalPages: epubTextBook?.pages.count ?? 0, + theme: theme + ) + return contentView + } + + public func pageNum(readerView: RDReaderView, pageNum: Int) { + switch bookSource { + case .textFile: + if pageNum > 0 && chapterPages.count > pageNum - 1 { + let chapterPage = chapterPages[pageNum - 1] + let chapter = book.chapters[chapterPage.chapterSort - 1] + let chapterRange = chapter.range + let rangeLoc = + chapterRange.location + chapterPage.range.location + RDReaderManager.shared.readProgress.currentType = rangeLoc + currentChapterNum = chapterPage.chapterSort - 1 + } + case .epubFile: + let readablePageCount = currentReadableEPUBPageCount() + if readablePageCount > 0 { + if pageNum == readablePageCount { + storeEPUBEndValidationState( + pageNum: pageNum, + reachedEndScreen: false + ) + } else if pageNum == readablePageCount + 1 { + storeEPUBEndValidationState( + pageNum: pageNum, + reachedEndScreen: true + ) + } + } + if isTextEPUBMode { + guard pageNum > 0, + let page = epubTextBook?.page(at: pageNum), + let location = epubTextBook?.location( + forPageNumber: pageNum, + bookIdentifier: currentEPUBBookIdentifier + ) + else { + return + } + currentChapterNum = page.chapterIndex + saveEPUBLocation(location) + saveEPUBSelection(nil) + epubSession?.transition(to: .idle) + return + } + + if pageNum > 0 && epubPages.count > pageNum - 1 { + let epubPage = epubPages[pageNum - 1] + if epubSession?.navigatorState == .idle { + epubSession?.transition(to: .moving) + } + currentChapterNum = epubPage.chapterIndex + if let location = fallbackEPUBLocation(for: epubPage) { + saveEPUBLocation(location) + } + saveEPUBSelection(nil) + schedulePendingBackgroundEPUBPaginationApplication() + primeEPUBPageCache(around: pageNum) + trimEPUBPageCurlCache(around: pageNum) + if epubSession?.pendingNavigationPageNum == nil { + epubSession?.transition(to: .idle) + } + } + } + } + + public func topToolView(readerView: RDReaderView) -> UIView? { + return topToolView + } + + public func bottomToolView(readerView: RDReaderView) -> UIView? { + return bottomToolView + } + + /// 横竖屏切换时重新分页 + public func readerViewOrientationWillChange( + readerView: RDReaderView, + isLandscape: Bool + ) { + switch bookSource { + case .textFile: + loadData() + transitionToPage() + case .epubFile: + repaginateEPUB(reason: "orientationChanged") + } + } +} + +extension RDReaderController: RDReaderEPUBContentViewDelegate { + func epubContentView( + _ contentView: RDReaderEPUBContentView, + didUpdateLocation location: RDEPUBLocation, + spineIndex: Int + ) { + guard readerView.currentPage > 0, + epubPages.indices + .contains(readerView.currentPage - 1) + else { + return + } + let currentPage = epubPages[readerView.currentPage - 1] + guard + epubSession? + .pageContains(spineIndex: spineIndex, in: currentPage) == true + else { + return + } + saveEPUBLocation(location) + epubSession?.updateReadingContext( + pageNumber: readerView.currentPage, + location: location, + spineIndex: spineIndex, + chapterIndex: currentPage.chapterIndex, + bookIdentifier: currentEPUBBookIdentifier + ) + } + + func epubContentView( + _ contentView: RDReaderEPUBContentView, + didChangeSelection selection: RDEPUBSelection?, + spineIndex: Int + ) { + guard readerView.currentPage > 0, + epubPages.indices + .contains(readerView.currentPage - 1) + else { + return + } + let currentPage = epubPages[readerView.currentPage - 1] + guard + epubSession? + .pageContains(spineIndex: spineIndex, in: currentPage) == true + else { + return + } + saveEPUBSelection(selection) + } + + func epubContentView( + _ contentView: RDReaderEPUBContentView, + didActivateInternalLink location: RDEPUBLocation, + fromSpineIndex: Int + ) { + saveEPUBSelection(nil) + navigateToEPUBLink(location, fromSpineIndex: fromSpineIndex) + } + + func epubContentView( + _ contentView: RDReaderEPUBContentView, + didActivateExternalLink url: URL + ) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + + func epubContentView( + _ contentView: RDReaderEPUBContentView, + didLogJavaScriptError message: String + ) { + print("EPUB JS Error: \(message)") + } +} diff --git a/Sources/RDReaderView/LegacyRDReaderController/RDReaderCoverView.swift b/Sources/RDReaderView/LegacyRDReaderController/RDReaderCoverView.swift new file mode 100644 index 0000000..ce0f1b4 --- /dev/null +++ b/Sources/RDReaderView/LegacyRDReaderController/RDReaderCoverView.swift @@ -0,0 +1,47 @@ +// +// RDReaderCoverView.swift +// RDReaderDemo +// +// Created by yangsq on 2021/8/11. +// + +import UIKit +import SnapKit + +class RDReaderCoverView: UIView { + lazy var textLabel: UILabel = { + let textLabel = UILabel() + textLabel.numberOfLines = 0 + return textLabel + }() + + lazy var imageV: UIImageView = { + let imageV = UIImageView() + imageV.image = UIImage(named: "cover") + return imageV + }() + + override init(frame: CGRect) { + super.init(frame: frame) + addSubview(imageV) + addSubview(textLabel) + + imageV.snp.makeConstraints { make in + make.top.equalTo(60) + make.centerX.equalToSuperview() + make.size.equalTo(CGSize(width: 88 * 1.5, height: 120 * 1.5)) + } + + textLabel.snp.makeConstraints { make in + make.left.equalTo(16) + make.right.equalTo(-16) + make.top.equalTo(imageV.snp.bottom).offset(20) + make.bottom.equalTo(-30) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} diff --git a/Sources/RDReaderView/LegacyRDReaderController/RDReaderEPUBContentView.swift b/Sources/RDReaderView/LegacyRDReaderController/RDReaderEPUBContentView.swift new file mode 100644 index 0000000..fa82e1a --- /dev/null +++ b/Sources/RDReaderView/LegacyRDReaderController/RDReaderEPUBContentView.swift @@ -0,0 +1,127 @@ +import SnapKit +import UIKit + +protocol RDReaderEPUBContentViewDelegate: AnyObject { + func epubContentView(_ contentView: RDReaderEPUBContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) + func epubContentView(_ contentView: RDReaderEPUBContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) + func epubContentView(_ contentView: RDReaderEPUBContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) + func epubContentView(_ contentView: RDReaderEPUBContentView, didActivateExternalLink url: URL) + func epubContentView(_ contentView: RDReaderEPUBContentView, didLogJavaScriptError message: String) +} + +final class RDReaderEPUBContentView: UIView { + weak var delegate: RDReaderEPUBContentViewDelegate? + + lazy var pageNumLabel: UILabel = { + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 13) + return label + }() + + private let epubWebView = RDEPUBWebView() + + override init(frame: CGRect) { + super.init(frame: frame) + clipsToBounds = true + layer.masksToBounds = true + addSubview(epubWebView) + addSubview(pageNumLabel) + + epubWebView.delegate = self + epubWebView.clipsToBounds = true + epubWebView.layer.masksToBounds = true + + epubWebView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + pageNumLabel.snp.makeConstraints { make in + make.right.equalToSuperview().offset(-30) + make.bottom.equalToSuperview().offset(-20) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func load(publication: RDEPUBPublication, request: RDEPUBRenderRequest) { + epubWebView.load(publication: publication, request: request) + } + + func loadPage( + parser: RDEPUBParser, + spineIndex: Int, + pageIndex: Int, + totalPagesInChapter: Int, + viewportSize: CGSize, + padding: UIEdgeInsets, + fontSize: CGFloat, + lineHeightMultiple: CGFloat, + themeBackgroundColor: String?, + themeTextColor: String?, + targetLocation: RDEPUBLocation?, + highlights: [RDEPUBHighlight] + ) { + epubWebView.loadPage( + parser: parser, + spineIndex: spineIndex, + pageIndex: pageIndex, + totalPagesInChapter: totalPagesInChapter, + viewportSize: viewportSize, + padding: padding, + fontSize: fontSize, + lineHeightMultiple: lineHeightMultiple, + themeBackgroundColor: themeBackgroundColor, + themeTextColor: themeTextColor, + targetLocation: targetLocation, + highlights: highlights + ) + } + + func loadFixedSpread( + parser: RDEPUBParser, + spread: EPUBFixedSpread, + viewportSize: CGSize, + contentInset: UIEdgeInsets, + backgroundColor: UIColor? + ) { + epubWebView.loadFixedSpread( + parser: parser, + spread: spread, + viewportSize: viewportSize, + contentInset: contentInset, + backgroundColor: backgroundColor + ) + } + + func releaseResources() { + epubWebView.reset() + epubWebView.delegate = self + } + +} + +extension RDReaderEPUBContentView: RDEPUBWebViewDelegate { + func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) { + delegate?.epubContentView(self, didUpdateLocation: location, spineIndex: spineIndex) + } + + func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) { + delegate?.epubContentView(self, didChangeSelection: selection, spineIndex: spineIndex) + } + + func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) { + delegate?.epubContentView(self, didActivateInternalLink: location, fromSpineIndex: fromSpineIndex) + } + + func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL) { + delegate?.epubContentView(self, didActivateExternalLink: url) + } + + func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String) { + delegate?.epubContentView(self, didLogJavaScriptError: message) + } + + func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView) { + } +} \ No newline at end of file diff --git a/Sources/RDReaderView/LegacyRDReaderController/RDReaderEditView.swift b/Sources/RDReaderView/LegacyRDReaderController/RDReaderEditView.swift new file mode 100644 index 0000000..82ff293 --- /dev/null +++ b/Sources/RDReaderView/LegacyRDReaderController/RDReaderEditView.swift @@ -0,0 +1,499 @@ +// +// RDReaderEditView.swift +// RDReaderDemo +// +// Created by yangsq on 2021/8/19. +// + +import UIKit +import SnapKit + +/// 亮度 +class RDReaderBrightSlider: UISlider, SSEventTrigger { + enum Event { + case valueDidChange(value: CGFloat) + } + override init(frame: CGRect) { + super.init(frame: frame) + makeUI() + } + + override func setValue(_ value: Float, animated: Bool) { + super.setValue(value, animated: animated) + UIScreen.main.brightness = CGFloat(value) + if let trigger = self.triggerEvent { + trigger(.valueDidChange(value: CGFloat(value))) + } + } + + func makeUI() { + let color = UIColor(red: 0.97, green: 0.97, blue: 0.97, alpha: 1) + thumbTintColor = color + let image = UIImage.rdToolbarImage(named: "read_edit_slide", fallbackSystemName: "circle.fill") + setThumbImage(image, for: .normal) + setThumbImage(image, for: .highlighted) + minimumValueImage = UIImage.rdToolbarImage(named: "read_edit_bright_min", fallbackSystemName: "sun.min.fill") + maximumValueImage = UIImage.rdToolbarImage(named: "read_edit_bright_max", fallbackSystemName: "sun.max.fill") + } + + override func trackRect(forBounds bounds: CGRect) -> CGRect { + self.layer.cornerRadius = 2.5 / 2 + return CGRect(x: 30, y: (bounds.height - 2.5)/2, width: bounds.width - 30 * 2, height: 2.5) + } + + override func thumbRect(forBounds bounds: CGRect, trackRect rect: CGRect, value: Float) -> CGRect { +// print(value) + return CGRect(x: 30 + (bounds.width - 30 * 2 - 16) * CGFloat(value), y: (bounds.height - 16)/2, width: 16, height: 16) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// 字号 +class RDReaderFontView: UIView, SSEventTrigger { + enum Event { + case fontSize(size: CGFloat) + } + var maxFontSize: CGFloat = 30 + var minFontSize: CGFloat = 15 + var currentFontSize: CGFloat = 15 { + didSet { + fontLabel.text = "\(Int(currentFontSize))" + if currentFontSize != oldValue { + if let trigger = self.triggerEvent { + trigger(.fontSize(size: currentFontSize)) + } + } + } + } + lazy var reduceButton: UIButton = { + let button = UIButton(type: .system) + button.setImage(UIImage.rdToolbarImage(named: "read_edit_font_reduce", fallbackSystemName: "textformat.size.smaller"), for: .normal) + button.layer.cornerRadius = 37 / 2 + button.backgroundColor = UIColor.lightGray.withAlphaComponent(0.2) + return button + }() + + lazy var increaseButton: UIButton = { + let button = UIButton(type: .system) + button.setImage(UIImage.rdToolbarImage(named: "read_edit_font_increase", fallbackSystemName: "textformat.size.larger"), for: .normal) + button.layer.cornerRadius = 37 / 2 + button.backgroundColor = UIColor.lightGray.withAlphaComponent(0.2) + return button + }() + + lazy var fontLabel: UILabel = { + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 17) + label.textAlignment = .center + return label + }() + + private lazy var containerView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.distribution = .fill + stackView.spacing = 0 + return stackView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + makeUI() + } + + func makeUI() { + addSubview(containerView) + containerView.addArrangedSubview(reduceButton) + containerView.addArrangedSubview(fontLabel) + containerView.addArrangedSubview(increaseButton) + containerView.snp.makeConstraints { make in + make.edges.equalTo(0) + } + reduceButton.snp.makeConstraints { make in + make.width.equalTo(increaseButton.snp.width).priority(.high) + make.height.equalTo(37) + } + increaseButton.snp.makeConstraints { make in + make.height.equalTo(37) + } + fontLabel.snp.makeConstraints { make in + make.width.equalTo(50) + } + + reduceButton.addTarget(self, action: #selector(reduceAction(button:)), for: .touchUpInside) + increaseButton.addTarget(self, action: #selector(increaseAction(button:)), for: .touchUpInside) + fontLabel.text = "\(Int(currentFontSize))" + } + + @objc func reduceAction(button: UIButton) { + if currentFontSize == minFontSize { + return + } + currentFontSize -= 1 + } + + @objc func increaseAction(button: UIButton) { + if currentFontSize == maxFontSize { + return + } + currentFontSize += 1 + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// 行距 +class RDReaderLineSpaceView: UIView, SSEventTrigger { + + enum Event { + case chose(type: RDReaderCommon.SpaceType) + } + + private lazy var containerView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.distribution = .fillEqually + stackView.spacing = 10 + return stackView + }() + + private var buttons = [UIButton]() + let allTypes: [RDReaderCommon.SpaceType] = [.min, .meduim, .max] + var currentType: RDReaderCommon.SpaceType = .min { + didSet { + let index = allTypes.firstIndex(of: currentType) + let button = buttons[index!] + button.sendActions(for: .touchUpInside) + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + makeUI() + } + + func makeUI() { + addSubview(containerView) + containerView.snp.makeConstraints { make in + make.edges.equalTo(0) + } + allTypes.forEach { type in + let button = UIButton(type: .system) + button.layer.cornerRadius = 37 / 2 + button.layer.borderWidth = 1 + button.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor + button.setImage(type.image?.withRenderingMode(.alwaysOriginal), for: .normal) + containerView.addArrangedSubview(button) + button.addTarget(self, action: #selector(buttonAction(button:)), for: .touchUpInside) + button.snp.makeConstraints { make in + make.height.equalTo(37) + } + buttons.append(button) + if type == .min { + button.sendActions(for: .touchUpInside) + } + } + } + + @objc func buttonAction(button: UIButton) { + for btn in buttons { + if btn == button { + btn.layer.borderColor = UIColor.black.cgColor + let index = buttons.firstIndex(of: btn) + let type = allTypes[index!] + if let trigger = self.triggerEvent { + trigger(.chose(type: type)) + } + } else { + btn.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor + } + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// 翻页方式 +class RDReaderPageCurlTypeView: UIView, SSEventTrigger { + + enum Event { + case chose(type: RDReaderCommon.PageCurlType) + } + + private lazy var scrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.showsVerticalScrollIndicator = false + scrollView.showsHorizontalScrollIndicator = false + return scrollView + }() + + private lazy var containerView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.distribution = .equalSpacing + stackView.spacing = 10 + return stackView + }() + + var currentType: RDReaderCommon.PageCurlType = .pageCurl { + didSet { + let index = allTypes.firstIndex(of: currentType) + let button = buttons[index!] + button.sendActions(for: .touchUpInside) + } + } + + private var buttons = [UIButton]() + let allTypes: [RDReaderCommon.PageCurlType] = [.pageCurl, .horizontalScroll, .verticalScroll, .horizontalCoverScroll] + + override init(frame: CGRect) { + super.init(frame: frame) + makeUI() + } + + func makeUI() { + addSubview(scrollView) + scrollView.addSubview(containerView) + scrollView.snp.makeConstraints { make in + make.edges.equalTo(0) + } + containerView.snp.makeConstraints { make in + make.edges.equalTo(0) + } + allTypes.forEach { type in + let button = UIButton(type: .system) + button.layer.cornerRadius = 37 / 2 + button.layer.borderWidth = 1 + button.titleLabel?.font = UIFont.systemFont(ofSize: 17) + button.setTitleColor(UIColor.black, for: .normal) + button.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor + button.setTitle(type.title, for: .normal) + let size = button.titleLabel!.sizeThatFits(CGSize(width: CGFloat(MAXFLOAT), height: 37)) + containerView.addArrangedSubview(button) + button.addTarget(self, action: #selector(buttonAction(button:)), for: .touchUpInside) + button.snp.makeConstraints { make in + make.height.equalTo(37) + make.width.equalTo(size.width + 30) + } + buttons.append(button) + if type == .pageCurl { + button.sendActions(for: .touchUpInside) + } + } + } + + @objc func buttonAction(button: UIButton) { + for btn in buttons { + if btn == button { + btn.layer.borderColor = UIColor.black.cgColor + let index = buttons.firstIndex(of: btn) + let type = allTypes[index!] + if let trigger = self.triggerEvent { + trigger(.chose(type: type)) + } + } else { + btn.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor + } + } + } + + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// 主题颜色 +class RDReaderThemeColorsView: UIView, SSEventTrigger { + enum Event { + case chose(color: RDReaderCommon.Colors) + } + + private lazy var scrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.showsVerticalScrollIndicator = false + scrollView.showsHorizontalScrollIndicator = false + return scrollView + }() + + private lazy var containerView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.distribution = .equalSpacing + stackView.spacing = 30 + return stackView + }() + + var currentType: RDReaderCommon.Colors = .white { + didSet { + let index = allTypes.firstIndex(of: currentType) + let button = buttons[index!] + button.sendActions(for: .touchUpInside) + } + } + + + private var buttons = [UIButton]() + let allTypes: [RDReaderCommon.Colors] = [.white, .yellow, .green, .pink, .blue, .dark] + + override init(frame: CGRect) { + super.init(frame: frame) + makeUI() + } + + func makeUI() { + addSubview(scrollView) + scrollView.addSubview(containerView) + scrollView.snp.makeConstraints { make in + make.edges.equalTo(0) + } + containerView.snp.makeConstraints { make in + make.edges.equalTo(0) + } + allTypes.forEach { type in + let button = UIButton(type: .system) + button.layer.cornerRadius = 37 / 2 + button.titleLabel?.font = UIFont.systemFont(ofSize: 17) + button.layer.borderColor = UIColor.black.cgColor + button.backgroundColor = type.color + containerView.addArrangedSubview(button) + button.addTarget(self, action: #selector(buttonAction(button:)), for: .touchUpInside) + button.snp.makeConstraints { make in + make.height.width.equalTo(37) + } + buttons.append(button) + if type == .white { + button.sendActions(for: .touchUpInside) + } + } + } + + @objc func buttonAction(button: UIButton) { + for btn in buttons { + if btn == button { + btn.layer.borderWidth = 1 + let index = buttons.firstIndex(of: btn) + let type = allTypes[index!] + if let trigger = self.triggerEvent { + trigger(.chose(color: type)) + } + } else { + btn.layer.borderWidth = 0 + } + } + } + + override func layoutSubviews() { + super.layoutSubviews() + let space = (frame.width - CGFloat(allTypes.count) * 37) / CGFloat(allTypes.count - 1) + containerView.spacing = space + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + + +/// 编辑弹窗 +class RDReaderEditView: UIView { + + lazy var lineView: UIView = { + let view = UIView() + view.backgroundColor = UIColor.lightGray + return view + }() + + lazy var brightSlider: RDReaderBrightSlider = { + let slider = RDReaderBrightSlider() + return slider + }() + + lazy var fontView: RDReaderFontView = { + let view = RDReaderFontView() + return view + }() + + lazy var lineSpaceView: RDReaderLineSpaceView = { + let view = RDReaderLineSpaceView() + return view + }() + + lazy var pageCurlTypeView: RDReaderPageCurlTypeView = { + let view = RDReaderPageCurlTypeView() + return view + }() + + lazy var colorView: RDReaderThemeColorsView = { + let view = RDReaderThemeColorsView() + return view + }() + + override init(frame: CGRect) { + super.init(frame: frame) + makeUI() + } + + func makeUI() { + backgroundColor = .white + addSubview(lineView) + addSubview(brightSlider) + addSubview(fontView) + addSubview(lineSpaceView) + addSubview(pageCurlTypeView) + addSubview(colorView) + + lineView.snp.makeConstraints { make in + make.left.right.top.equalTo(0) + make.height.equalTo(0.5) + } + + brightSlider.snp.makeConstraints { make in + make.top.equalTo(20) + make.left.equalTo(16) + make.right.equalTo(-16) + make.height.equalTo(20) + } + + fontView.snp.makeConstraints { make in + make.top.equalTo(brightSlider.snp.bottom).offset(20) + make.left.equalTo(16) + make.right.equalTo(-16) + make.height.equalTo(37) + } + + lineSpaceView.snp.makeConstraints { make in + make.top.equalTo(fontView.snp.bottom).offset(20) + make.left.equalTo(16) + make.right.equalTo(-16) + make.height.equalTo(37) + } + + pageCurlTypeView.snp.makeConstraints { make in + make.top.equalTo(lineSpaceView.snp.bottom).offset(20) + make.left.equalTo(16) + make.right.equalTo(-16) + make.height.equalTo(37) + } + + colorView.snp.makeConstraints { make in + make.top.equalTo(pageCurlTypeView.snp.bottom).offset(20) + make.left.equalTo(16) + make.right.equalTo(-16) + make.height.equalTo(37) + } + + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} diff --git a/Sources/RDReaderView/LegacyRDReaderController/RDReaderEndView.swift b/Sources/RDReaderView/LegacyRDReaderController/RDReaderEndView.swift new file mode 100644 index 0000000..25f34ec --- /dev/null +++ b/Sources/RDReaderView/LegacyRDReaderController/RDReaderEndView.swift @@ -0,0 +1,30 @@ +// +// RDReaderEndView.swift +// RDReaderDemo +// +// Created by yangsq on 2021/8/11. +// + +import UIKit +import SnapKit + +class RDReaderEndView: UIView { + + override init(frame: CGRect) { + super.init(frame: frame) + let textLabel = UILabel() + textLabel.text = "结束页" + textLabel.backgroundColor = .red + textLabel.textAlignment = .center + addSubview(textLabel) + textLabel.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(CGSize(width: 100, height: 100)) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} diff --git a/Sources/RDReaderView/LegacyRDReaderController/RDReaderManager.swift b/Sources/RDReaderView/LegacyRDReaderController/RDReaderManager.swift new file mode 100644 index 0000000..0b47b4e --- /dev/null +++ b/Sources/RDReaderView/LegacyRDReaderController/RDReaderManager.swift @@ -0,0 +1,325 @@ +// +// RDReaderManager.swift +// RDReaderDemo +// +// Created by yangsq on 2021/8/20. +// + +import UIKit + +struct Book { + var desc: String? + var chapters: [Chapter] +} + +struct Chapter { + /// 第几章 + var sort: Int + /// 章节名称 + var title: String? + /// 划分了多少页 + var pageCount: Int + /// 内容 + var content: String + /// 分页 + var pages: [ChapterPage] + + /// 文字的长度 + var range: NSRange +} + +struct ChapterPage { + /// 第几章 + var chapterSort: Int + /// 第几页 + var pageNum: Int + /// 内容 + var content: String + /// 内容 + var attrContent: NSAttributedString + /// 文字的长度 + var range: NSRange +} + +public struct DemoBookItem { + public var title: String + public var source: BookSource + + public init(title: String, source: BookSource) { + self.title = title + self.source = source + } +} + + +extension RDReaderManager { + static let themeTypeKey = "themeType.key" + static let brightValueKey = "brightValue.key" + static let fontValueKey = "fontValue.key" + static let lineSpaceKey = "lineSpace.key" + static let pageCurlTypeKey = "pageCurlType.key" + static let readProgressKey = "readProgress.key" +} + +typealias BrightValue = CGFloat +extension BrightValue: RDReaderObserverType { + var cachesValue: CGFloat { + return self + } + var value: CGFloat { + return self + } + + func transform(value: Any?) -> BrightValue { + return (value as? BrightValue) ?? self + } +} + +extension Int: RDReaderObserverType { + var cachesValue: Int { + return self + } + var value: Int { + return self + } + + func transform(value: Any?) -> Int { + return (value as? Int) ?? self + } +} + +extension String: RDReaderObserverType { + var cachesValue: String { + return self + } + + var value: String { + return self + } + + func transform(value: Any?) -> String { + return (value as? String) ?? self + } +} + +public class RDReaderManager: NSObject { + public static let shared = RDReaderManager() + let themeType = RDReaderObserver(key: RDReaderManager.themeTypeKey, defaultType: RDReaderCommon.Colors.white) + let brightValue = RDReaderObserver(key: RDReaderManager.brightValueKey, defaultType: UIScreen.main.brightness) + let fontValue = RDReaderObserver(key: RDReaderManager.fontValueKey, defaultType: CGFloat(15.0)) + let lineSpaceType = RDReaderObserver(key: RDReaderManager.lineSpaceKey, defaultType: RDReaderCommon.SpaceType.min) + let pageCurlTypeType = RDReaderObserver(key: RDReaderManager.pageCurlTypeKey, defaultType: RDReaderCommon.PageCurlType.pageCurl) + let readProgress = RDReaderObserver(key: RDReaderManager.readProgressKey, defaultType: 0) + let epubReadLocation = RDReaderObserver(key: "epubReadLocation.key", defaultType: "") + let epubSelection = RDReaderObserver(key: "epubSelection.key", defaultType: "") + let epubHighlights = RDReaderObserver(key: "epubHighlights.key", defaultType: "") + +} + +protocol RDReaderObserverType { + associatedtype Value + associatedtype CacheValue + associatedtype CurrentType + var cachesValue: CacheValue { get } + var value: Value { get } + func transform(value: Any?) -> CurrentType +} + + + +class RDReaderObserver where CurrentType: RDReaderObserverType { + private var _currentType: CurrentType? = nil + private var key: String + private var defaultType: CurrentType + typealias Observer = ((CurrentType.Value) -> Void) + private var observers = [WeakObserver]() + var currentType: CurrentType { + get { + if _currentType != nil { + return _currentType! + } else { + _currentType = (self.defaultType.transform(value: UserDefaults.standard.object(forKey: key)) as? CurrentType) ?? self.defaultType + return _currentType! + } + } + set { + _currentType = newValue + UserDefaults.standard.set(newValue.cachesValue, forKey: key) + UserDefaults.standard.synchronize() + observers.forEach { observer in + if let observer = observer.observer?.observer { + observer(_currentType!.value) + } + } + + } + } + + init(key: String, defaultType: CurrentType) { + self.key = key + self.defaultType = defaultType + } + + func observeChange(onNext: Observer?) { + if let onNext = onNext { + onNext(currentType.value) + observers.append(WeakObserver(observer: onNext)) + } + } +} + + +private class WeakObserver { + class _Observer: NSObject { + var observer: T + init(observer: T) { + self.observer = observer + super.init() + } + } + + weak var observer: _Observer? + private var _strongObserver: _Observer? + init(observer: T) { + let t = _Observer(observer: observer) + _strongObserver = t + self.observer = _strongObserver + } +} + + + +extension RDReaderManager { + func epubReaderConfiguration() -> RDEPUBReaderConfiguration { + let theme = themeType.currentType.value + return RDEPUBReaderConfiguration( + fontSize: fontValue.currentType, + lineHeightMultiple: lineSpaceType.currentType.mulitiple, + displayType: pageCurlTypeType.currentType.value, + landscapeDualPageEnabled: true, + reflowableContentInsets: UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16), + fixedContentInset: .zero, + theme: RDEPUBReaderTheme( + contentBackgroundColor: theme.contentBackgroudColor ?? .white, + contentTextColor: theme.contentTextColor ?? .black, + toolBackgroundColor: theme.toolBackgroudColor ?? .white, + toolControlTextColor: theme.toolControlTextColor ?? .black, + toolControlBorderUnselectColor: theme.toolControlBorderUnSelectColor ?? UIColor.lightGray.withAlphaComponent(0.5), + toolLineColor: theme.toolLineColor ?? UIColor.lightGray.withAlphaComponent(0.5) + ), + fixedLayoutFit: .page, + fixedLayoutSpreadMode: .automatic, + textRenderingEngine: .dtCoreText + ) + } + + func epubPreferences( + theme: RDReaderTheme? = nil, + reflowableInsets: UIEdgeInsets, + fixedContentInset: UIEdgeInsets + ) -> RDEPUBPreferences { + let resolvedTheme = theme ?? themeType.currentType.value + let backgroundColorCSS = resolvedTheme.contentBackgroudColor?.ss_cssString + return RDEPUBPreferences( + fontSize: fontValue.currentType, + lineHeightMultiple: lineSpaceType.currentType.mulitiple, + reflowableContentInsets: reflowableInsets, + fixedContentInset: fixedContentInset, + themeBackgroundColor: backgroundColorCSS, + themeTextColor: resolvedTheme.contentTextColor?.ss_cssString, + fixedBackgroundColor: backgroundColorCSS, + fixedLayoutFit: .page, + fixedLayoutSpreadMode: .automatic + ) + } + + func bundledEPUBURLs(in bundle: Bundle = .main) -> [URL] { + (bundle.urls(forResourcesWithExtension: "epub", subdirectory: nil) ?? []) + .sorted { lhs, rhs in + lhs.deletingPathExtension().lastPathComponent.localizedStandardCompare(rhs.deletingPathExtension().lastPathComponent) == .orderedAscending + } + } + + public func demoBookItems(in bundle: Bundle = .main) -> [DemoBookItem] { + let textDemo = DemoBookItem(title: "宠她.txt", source: .textFile) + let epubItems = bundledEPUBURLs(in: bundle).map { url in + DemoBookItem(title: url.deletingPathExtension().lastPathComponent, source: .epubFile(url: url)) + } + return [textDemo] + epubItems + } + + @discardableResult + public func startReading(book item: DemoBookItem, from navigationController: UINavigationController?, animated: Bool = true) -> UIViewController? { + let readerController: UIViewController + switch item.source { + case .textFile: + guard let textURL = Bundle.main.url(forResource: "宠她", withExtension: "txt") else { + return nil + } + readerController = RDURLReaderController(bookURL: textURL) + case .epubFile(let url): + readerController = RDURLReaderController( + bookURL: url, + epubConfiguration: epubReaderConfiguration() + ) + } + readerController.title = item.title + navigationController?.pushViewController(readerController, animated: animated) + return readerController + } + + func encodeTextFile(font: UIFont, lineSapce: CGFloat, size: CGSize) -> Book { + let decodeString = String.encodeTextFile(url: URL(fileURLWithPath: Bundle.main.path(forResource: "宠她", ofType: ".txt")!)) + let parten = "第[0-9一二三四五六七八九十百千]*[章回].*" + var chapters = [Chapter]() + var desc: String? + if let expression = try? NSRegularExpression(pattern: parten, options: .caseInsensitive) { + let results = expression.matches(in: decodeString, options: .reportCompletion, range: NSMakeRange(0, decodeString.count)) + var startCount = -1 + var lastTitle: String? + for result in results { + let index = results.firstIndex(of: result) + let range = result.range + + if startCount != -1 && index! > 0 { + let contentRange = NSMakeRange(startCount, range.location - startCount) + + let content = decodeString.substring(with: Range(contentRange, in: decodeString)!) + let pages = chapterPages(content: content, font: font, lineSapce: lineSapce, size: size, chapterSort: index!) + let chapter = Chapter(sort: index!,title: lastTitle, pageCount: pages.count, content: String(content), pages: pages, range: contentRange) + chapters.append(chapter) + if index == results.count - 1 { + let contentRange = NSMakeRange(range.location, decodeString.count - range.location) + let content = decodeString.substring(with: Range(contentRange, in: decodeString)!) + let title = decodeString.substring(with: Range(range, in: decodeString)!) + let pages = chapterPages(content: content, font: font, lineSapce: lineSapce, size: size, chapterSort: index!) + let chapter = Chapter(sort: index! + 1, title: title, pageCount: pages.count, content: String(content), pages: pages, range: contentRange) + chapters.append(chapter) + } + } + startCount = range.location + lastTitle = decodeString.substring(with: Range(range, in: decodeString)!) + if index == 0 && range.location > 0 { + desc = decodeString.substring(with: Range(NSMakeRange(0, range.location), in: decodeString)!) + } + } + } + return Book(desc: desc, chapters: chapters) + } + + func chapterPages(content: String, font: UIFont, lineSapce: CGFloat, size: CGSize, chapterSort: Int) -> [ChapterPage] { + let attrString = NSMutableAttributedString(string: content) + let style = NSMutableParagraphStyle() + style.lineSpacing = lineSapce + attrString.addAttributes([NSAttributedString.Key.font : font, NSAttributedString.Key.paragraphStyle: style], range: NSMakeRange(0, content.count)) + let pageRanges = attrString.pageRanges(size: size) + let pages = pageRanges.map({ + pageRange -> ChapterPage in + let pageContent = content.substring(with: Range(pageRange, in: content)!) + let pageAttrContent = attrString.attributedSubstring(from: pageRange) + return ChapterPage(chapterSort: chapterSort, pageNum: pageRanges.firstIndex(of: pageRange)!, content: pageContent, attrContent: pageAttrContent, range: pageRange) + }) + + return pages + } +} diff --git a/Sources/RDReaderView/LegacyRDReaderController/RDReaderTheme.swift b/Sources/RDReaderView/LegacyRDReaderController/RDReaderTheme.swift new file mode 100644 index 0000000..e631aa3 --- /dev/null +++ b/Sources/RDReaderView/LegacyRDReaderController/RDReaderTheme.swift @@ -0,0 +1,75 @@ +// +// RDReaderTheme.swift +// RDReaderDemo +// +// Created by yangsq on 2021/8/10. +// + +import Foundation +import UIKit + + +public protocol RDReaderTheme { + var contentBackgroudColor: UIColor? { get } + var contentTextColor: UIColor? { get } + var toolBackgroudColor: UIColor? { get } + var toolControlTextColor: UIColor? { get } + var toolControlBorderUnSelectColor: UIColor? { get } + var toolLineColor: UIColor? { get } +} + +struct WhiteTheme: RDReaderTheme { + var contentBackgroudColor: UIColor? = UIColor.white + var contentTextColor: UIColor? = UIColor.black + var toolBackgroudColor: UIColor? = UIColor.white + var toolControlTextColor: UIColor? = UIColor.black + var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5) + var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5) +} + +struct DarkTheme: RDReaderTheme { + var contentBackgroudColor: UIColor? = UIColor.black + var contentTextColor: UIColor? = UIColor.white + var toolBackgroudColor: UIColor? = UIColor.black + var toolControlTextColor: UIColor? = UIColor.white + var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5) + var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5) +} + +struct YellowTheme: RDReaderTheme { + var contentBackgroudColor: UIColor? = UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1) + var contentTextColor: UIColor? = UIColor.black + var toolBackgroudColor: UIColor? = UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1) + var toolControlTextColor: UIColor? = UIColor.black + var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5) + var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5) +} + + +struct GreenTheme: RDReaderTheme { + var contentBackgroudColor: UIColor? = UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1) + var contentTextColor: UIColor? = UIColor.black + var toolBackgroudColor: UIColor? = UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1) + var toolControlTextColor: UIColor? = UIColor.black + var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5) + var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5) +} + +struct PinkTheme: RDReaderTheme { + var contentBackgroudColor: UIColor? = UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1) + var contentTextColor: UIColor? = UIColor.black + var toolBackgroudColor: UIColor? = UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1) + var toolControlTextColor: UIColor? = UIColor.black + var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5) + var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5) +} + + +struct BlueTheme: RDReaderTheme { + var contentBackgroudColor: UIColor? = UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1) + var contentTextColor: UIColor? = UIColor.black + var toolBackgroudColor: UIColor? = UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1) + var toolControlTextColor: UIColor? = UIColor.black + var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5) + var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5) +} diff --git a/Sources/RDReaderView/LegacyRDReaderController/RDReaderToolView.swift b/Sources/RDReaderView/LegacyRDReaderController/RDReaderToolView.swift new file mode 100644 index 0000000..fed430c --- /dev/null +++ b/Sources/RDReaderView/LegacyRDReaderController/RDReaderToolView.swift @@ -0,0 +1,67 @@ +// +// RDReaderToolView.swift +// RDReaderDemo +// +// Created by yangsq on 2021/8/10. +// + +import UIKit + +public class RDReaderToolView: UIView { + lazy var lineView: UIView = { + let view = UIView() + return view + }() + var lineHeight: CGFloat = 0.5 + override init(frame: CGRect) { + super.init(frame: frame) + makeUI() + } + + + func makeUI() { + addSubview(lineView) + } + + public override func layoutSubviews() { + super.layoutSubviews() + lineView.frame = CGRect(x: 0, y: 0, width: frame.width, height: lineHeight) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + + +public class TintColorButton: UIButton { + public override func tintColorDidChange() { + super.tintColorDidChange() + if let image = self.currentImage { + let image = image.tintColorImage(color: tintColor) + setImage(image, for: .normal) + } + } +} + + +extension UIImage { + static func rdToolbarImage(named: String, fallbackSystemName: String) -> UIImage? { + if let image = UIImage(named: named) { + return image.withRenderingMode(.alwaysOriginal) + } + return UIImage(systemName: fallbackSystemName) + } + + func tintColorImage(color: UIColor) -> UIImage? { + UIGraphicsBeginImageContextWithOptions(size, false, scale) + let rect = CGRect(x: 0, y: 0, width: size.width, height: size.height) + color.set() + UIRectFill(rect) + draw(at: .zero, blendMode: .destinationIn, alpha: 1) + let newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return newImage + } +} diff --git a/Sources/RDReaderView/LegacyRDReaderController/RDReaderTopToolView.swift b/Sources/RDReaderView/LegacyRDReaderController/RDReaderTopToolView.swift new file mode 100644 index 0000000..b7c4333 --- /dev/null +++ b/Sources/RDReaderView/LegacyRDReaderController/RDReaderTopToolView.swift @@ -0,0 +1,54 @@ +// +// RDReaderTopToolView.swift +// RDReaderDemo +// +// Created by yangsq on 2021/8/10. +// + +import UIKit +import SnapKit + + + +public class RDReaderTopToolView: RDReaderToolView,SSEventTrigger { + + + enum Event { + case back + } + public override func layoutSubviews() { + super.layoutSubviews() + lineView.frame = CGRect(x: 0, y: frame.height - lineHeight, width: frame.width, height: lineHeight) + } + + lazy var backButton: TintColorButton = { + let backButton = TintColorButton(type: .system) + backButton.setImage(toolbarImage(named: "arrow_left", fallbackSystemName: "chevron.left"), for: .normal) + return backButton + }() + + override func makeUI() { + super.makeUI() + addSubview(backButton) + backButton.addTarget(self, action: #selector(backAction), for: .touchUpInside) + backButton.snp.makeConstraints { make in + make.left.equalTo(0) + make.bottom.equalTo(0) + make.height.equalTo(44) + make.width.equalTo(50) + } + } + + @objc private func backAction() { + if let trigger = self.triggerEvent { + trigger(.back) + } + } + + private func toolbarImage(named: String, fallbackSystemName: String) -> UIImage? { + if let image = UIImage(named: named) { + return image.withRenderingMode(.alwaysOriginal) + } + return UIImage(systemName: fallbackSystemName) + } +} diff --git a/Sources/RDReaderView/LegacyRDReaderController/SSChapterListController.swift b/Sources/RDReaderView/LegacyRDReaderController/SSChapterListController.swift new file mode 100644 index 0000000..01b887a --- /dev/null +++ b/Sources/RDReaderView/LegacyRDReaderController/SSChapterListController.swift @@ -0,0 +1,50 @@ +// +// SSChapterListController.swift +// RDReaderDemo +// +// Created by yangsq on 2021/8/19. +// + +import UIKit +class SSChapterListController: UITableViewController { + var selectedChapter: ((Int) -> Void)? + var chapters: [Chapter] + var currentChapterNum: Int + init(chapters:[Chapter], currentChapterNum: Int) { + self.chapters = chapters + self.currentChapterNum = currentChapterNum + super.init(style: .plain) + + tableView.tableFooterView = UIView(frame: CGRect(x: 0, y: 0, width: view.frame.width, height: 0)) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "UITableViewCell") + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return self.chapters.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "UITableViewCell") + cell?.textLabel?.text = self.chapters[indexPath.row].title + cell?.textLabel?.textColor = indexPath.row == currentChapterNum ? UIColor.red : UIColor.black + return cell! + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if let selectedChapter = selectedChapter { + selectedChapter(indexPath.row) + } + } + + func selectedChapter(onTrigger: @escaping (Int) -> Void) { + self.selectedChapter = onTrigger + } +} diff --git a/Sources/RDReaderView/LegacyRDReaderController/SSEventTrigger.swift b/Sources/RDReaderView/LegacyRDReaderController/SSEventTrigger.swift new file mode 100644 index 0000000..ce05654 --- /dev/null +++ b/Sources/RDReaderView/LegacyRDReaderController/SSEventTrigger.swift @@ -0,0 +1,38 @@ +// +// EventTrigger.swift +// +// +// Created by yangsq on 2020/11/4. +// + +import Foundation + + protocol SSEventTrigger { + associatedtype Event + typealias TriggerEvent = (Event) -> Void + var triggerEvent: TriggerEvent? { get set } + func trigger(event: TriggerEvent?) +} + +private var triggerEventKey: UInt8 = 0 +extension SSEventTrigger { + private var _triggerEvent:TriggerEvent? { + get {return objc_getAssociatedObject(self, &triggerEventKey) as? Self.TriggerEvent} + set {objc_setAssociatedObject(self, &triggerEventKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)} + } + + var triggerEvent: TriggerEvent? { + get{_triggerEvent} + set{ + if newValue != nil { + _triggerEvent = newValue! + } + } + } + + func trigger(event: TriggerEvent?) { + if event != nil { + objc_setAssociatedObject(self, &triggerEventKey, event!, .OBJC_ASSOCIATION_COPY_NONATOMIC) + } + } +} diff --git a/Sources/RDReaderView/RDReaderContentCell.swift b/Sources/RDReaderView/RDReaderContentCell.swift new file mode 100644 index 0000000..b1c4de2 --- /dev/null +++ b/Sources/RDReaderView/RDReaderContentCell.swift @@ -0,0 +1,36 @@ +// +// RDReaderContentCell.swift +// RDReaderDemo +// +// Created by yangsq on 2021/8/7. +// + +import UIKit + +class RDReaderContentCell: UICollectionViewCell { + private var _containerView: UIView? = nil + var containerView: UIView? { + set { + guard newValue !== _containerView else { return } + _containerView?.removeFromSuperview() + _containerView = newValue + if let containerView = _containerView { + contentView.addSubview(containerView) + setNeedsLayout() + } + } + get { + return _containerView + } + } + override func layoutSubviews() { + super.layoutSubviews() + _containerView?.frame = CGRect(x: 0, y: 0, width: contentView.frame.width, height: contentView.frame.height) + } + override init(frame: CGRect) { + super.init(frame: frame) + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Sources/RDReaderView/RDReaderFlowLayout.swift b/Sources/RDReaderView/RDReaderFlowLayout.swift new file mode 100644 index 0000000..6c112ff --- /dev/null +++ b/Sources/RDReaderView/RDReaderFlowLayout.swift @@ -0,0 +1,311 @@ +// +// RDReaderFlowLayout.swift +// RDReaderDemo +// +// Created by yangsq on 2021/8/6. +// + +import UIKit + +public protocol RDReaderFlowLayoutDataSoure: NSObjectProtocol { + func heigtOfVerticalScrollPage(flowLayout: RDReaderFlowLayout, pageIndex: Int) -> CGFloat? +} + +@objc public protocol RDReaderFlowLayoutDelegate: NSObjectProtocol { + func pageNum(flowLayout: RDReaderFlowLayout, pageIndex: Int) +} + +public class RDReaderFlowLayout: UICollectionViewFlowLayout { + + + var displayType: RDReaderView.DisplayType = .horizontalScroll { + didSet { + invalidateLayout() + } + } + + var isLandscapeDualPage: Bool = false { + didSet { + if oldValue != isLandscapeDualPage { + invalidateLayout() + } + } + } + + var coverPageIndex: Int? = nil { + didSet { + if oldValue != coverPageIndex { + invalidateLayout() + } + } + } + + private var lastPreparedBoundsSize: CGSize = .zero + + var pagesPerScreen: Int { + guard isLandscapeDualPage else { return 1 } + switch displayType { + case .horizontalScroll: + if let cv = collectionView, cv.bounds.width > cv.bounds.height { + return 2 + } + return 1 + default: + return 1 + } + } + + weak var dataSource: RDReaderFlowLayoutDataSoure? = nil + weak var delegate: RDReaderFlowLayoutDelegate? = nil + + private var hasCoverPageInDualMode: Bool { + return pagesPerScreen > 1 && coverPageIndex != nil + } + + private func coverAwareFrame(for index: Int, screenWidth: CGFloat, halfWidth: CGFloat, height: CGFloat) -> CGRect { + guard let coverIndex = coverPageIndex else { + let pairIdx = index / 2 + let side = index % 2 + let x = CGFloat(pairIdx) * screenWidth + CGFloat(side) * halfWidth + return CGRect(x: x, y: 0, width: halfWidth, height: height) + } + if index == coverIndex { + let screensBeforeCover = coverIndex / 2 + let x = CGFloat(screensBeforeCover) * screenWidth + return CGRect(x: x, y: 0, width: screenWidth, height: height) + } + let adjustedIndex = index - (coverIndex + 1) + let pairIdx = adjustedIndex / 2 + let side = adjustedIndex % 2 + let coverScreens = coverIndex / 2 + 1 + let x = CGFloat(coverScreens + pairIdx) * screenWidth + CGFloat(side) * halfWidth + return CGRect(x: x, y: 0, width: halfWidth, height: height) + } + + /// cover-aware screen start index calculation + private func coverAwareStartIndex(for offset: CGFloat, screenWidth: CGFloat) -> Int { + let pps = pagesPerScreen + guard let coverIdx = coverPageIndex, hasCoverPageInDualMode else { + let screenIndex = Int((offset / screenWidth).rounded(.down)) + return screenIndex * pps + } + let coverScreens = coverIdx / 2 + 1 + let coverEnd = CGFloat(coverScreens) * screenWidth + if offset < coverEnd { + let screenIdx = Int((offset / screenWidth).rounded(.down)) + return screenIdx >= coverIdx / 2 ? coverIdx : screenIdx * pps + } else { + let pairIdx = Int(((offset - coverEnd) / screenWidth).rounded(.down)) + return coverIdx + 1 + pairIdx * 2 + } + } + + private var currentPage: Int = 0 { + + didSet { + if let delegate = delegate, delegate.responds(to: #selector(RDReaderFlowLayoutDelegate.pageNum(flowLayout:pageIndex:))), currentPage != oldValue, currentPage >= 0 { + delegate.pageNum(flowLayout: self, pageIndex: currentPage) + } + } + } + + init(displayType: RDReaderView.DisplayType) { + self.displayType = displayType + super.init() + + } + + public override func prepare() { + super.prepare() + guard let collectionView = self.collectionView else { + return + } + guard collectionView.frame.width > 0, collectionView.frame.height > 0 else { + return + } + + lastPreparedBoundsSize = collectionView.bounds.size + + if #available(iOS 11.0, *) { + collectionView.contentInsetAdjustmentBehavior = .never + } else { + collectionView.ss_superViewController?.automaticallyAdjustsScrollViewInsets = false + } + collectionView.showsVerticalScrollIndicator = false + collectionView.showsHorizontalScrollIndicator = false + minimumLineSpacing = 0 + minimumInteritemSpacing = 0 + switch self.displayType { + case .horizontalScroll: + scrollDirection = .horizontal + let columns = CGFloat(pagesPerScreen) + itemSize = CGSize(width: collectionView.frame.width / columns, height: collectionView.frame.height) + collectionView.isPagingEnabled = true + case .verticalScroll: + itemSize = CGSize(width: collectionView.frame.width, height: collectionView.frame.height) + scrollDirection = .vertical + collectionView.isPagingEnabled = false + collectionView.showsVerticalScrollIndicator = true + + default: break + } + + } + public override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { + if newBounds.size != lastPreparedBoundsSize { + return true + } + switch displayType { + case .verticalScroll: + return true + case .horizontalScroll: + return true + default: + return false + } + } + + public override var collectionViewContentSize: CGSize { + var size = super.collectionViewContentSize + guard let collectionView = collectionView else { + return size + } + if displayType == .verticalScroll { + let totalHeight = [Int](0.. [UICollectionViewLayoutAttributes]? { + var attributes = super.layoutAttributesForElements(in: rect) + guard let collectionView = collectionView else { + return attributes + } + + if self.displayType == .verticalScroll { + let rowCount = collectionView.numberOfItems(inSection: 0) + var totalHeight: CGFloat = 0 + for index in 0.. collectionView.contentOffset.y { + self.currentPage = index + break + } + } + (attributes ?? []).forEach({ attr in + let indexPath = attr.indexPath + let lastRow = max(0, indexPath.row - 1) + let height = dataSource?.heigtOfVerticalScrollPage(flowLayout: self, pageIndex: indexPath.row) ?? collectionView.frame.height + var lastTotalHeight: CGFloat = 0 + if indexPath.row > 0 { + lastTotalHeight = [Int](0...lastRow).reduce(0.0) { result, row in + result + (dataSource?.heigtOfVerticalScrollPage(flowLayout: self, pageIndex: row) ?? collectionView.frame.height) + } + } + if let cell = collectionView.cellForItem(at: indexPath) { + cell.layer.shadowOpacity = 0 + } + attr.frame = CGRect(x: 0, y: lastTotalHeight, width: collectionView.frame.width, height: height) + }) + } + + + if self.displayType == .horizontalScroll { + let pps = pagesPerScreen + + if hasCoverPageInDualMode { + let screenWidth = collectionView.frame.width + let halfWidth = screenWidth / 2.0 + let rows = collectionView.numberOfItems(inSection: 0) + + self.currentPage = coverAwareStartIndex(for: collectionView.contentOffset.x, screenWidth: screenWidth) + + var attrs = [UICollectionViewLayoutAttributes]() + for index in 0.. 1 { + let currentPage = Int((collectionView.contentOffset.x / collectionView.frame.width).rounded(.down)) * pps + self.currentPage = currentPage + (attributes ?? []).forEach({ attr in + if let cell = collectionView.cellForItem(at: attr.indexPath) { + cell.layer.shadowOpacity = 0 + } + }) + } else { + let currentPage = Int((collectionView.contentOffset.x / collectionView.frame.width).rounded(.down)) + self.currentPage = currentPage + (attributes ?? []).forEach({ attr in + if let cell = collectionView.cellForItem(at: attr.indexPath) { + cell.layer.shadowOpacity = 0 + } + }) + } + } + + return attributes + } + + func currentContentOffset(count: Int) -> CGPoint { + guard let collectionView = collectionView else { + return .zero + } + guard collectionView.frame.width > 0, collectionView.frame.height > 0 else { + return .zero + } + let safeCount = max(0, count) + switch displayType { + case .verticalScroll: + guard safeCount > 0 else { return .zero } + let totalHeight = [Int](0...(safeCount - 1)).reduce(0.0) { result, pageNum in + result + (dataSource?.heigtOfVerticalScrollPage(flowLayout: self, pageIndex: pageNum) ?? collectionView.frame.height) + } + return CGPoint(x: 0, y: totalHeight) + default: + let pps = pagesPerScreen + let screenWidth = collectionView.frame.width + if hasCoverPageInDualMode, let coverIdx = coverPageIndex { + if safeCount == coverIdx { + let screensBeforeCover = coverIdx / 2 + return CGPoint(x: CGFloat(screensBeforeCover) * screenWidth, y: 0) + } + if safeCount < coverIdx { + return CGPoint(x: CGFloat(safeCount / 2) * screenWidth, y: 0) + } + let coverScreens = coverIdx / 2 + 1 + let adjustedIndex = safeCount - (coverIdx + 1) + let pairIndex = adjustedIndex / 2 + return CGPoint(x: CGFloat(coverScreens + pairIndex) * screenWidth, y: 0) + } else if pps > 1 { + let screenIndex = safeCount / pps + return CGPoint(x: CGFloat(screenIndex) * screenWidth, y: 0) + } else { + return CGPoint(x: CGFloat(safeCount) * itemSize.width, y: 0) + } + } + } + + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Sources/RDReaderView/RDReaderGestureController.swift b/Sources/RDReaderView/RDReaderGestureController.swift new file mode 100644 index 0000000..f2e6d09 --- /dev/null +++ b/Sources/RDReaderView/RDReaderGestureController.swift @@ -0,0 +1,42 @@ +// +// RDReaderGestureController.swift +// RDReaderDemo +// +// Created by yangsq on 2021/8/10. +// + +import UIKit + + + +class RDReaderGestureController: UIViewController { + var topToolView: UIView? + var bottomToolView: UIView? + init(topToolView: UIView?, bottomToolView: UIView?) { + self.topToolView = topToolView + self.bottomToolView = bottomToolView + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + // Do any additional setup after loading the view. + } + + + /* + // MARK: - Navigation + + // In a storyboard-based application, you will often want to do a little preparation before navigation + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + // Get the new view controller using segue.destination. + // Pass the selected object to the new view controller. + } + */ + +} diff --git a/Sources/RDReaderView/RDReaderPageChildViewController.swift b/Sources/RDReaderView/RDReaderPageChildViewController.swift new file mode 100644 index 0000000..d8b2d63 --- /dev/null +++ b/Sources/RDReaderView/RDReaderPageChildViewController.swift @@ -0,0 +1,64 @@ +// +// RDReaderPageChildViewController.swift +// RDReaderDemo +// +// Created by yangsq on 2021/8/9. +// + +import UIKit + +class RDReaderPageChildViewController: UIViewController { + private let contentContainerView = UIView() + var contentView: UIView? { + didSet { + guard isViewLoaded else { return } + installContentView() + } + } + var pageNum: Int = 0 + init(contentView: UIView?, pageNum: Int = 0) { + self.contentView = contentView + self.pageNum = pageNum + super.init(nibName: nil, bundle: nil) + } + + override func loadView() { + view = UIView() + view.backgroundColor = .clear + contentContainerView.frame = view.bounds + contentContainerView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + contentContainerView.backgroundColor = .clear + view.addSubview(contentContainerView) + installContentView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + override func viewDidLoad() { + super.viewDidLoad() + + // Do any additional setup after loading the view. + } + + private func installContentView() { + contentContainerView.subviews.forEach { $0.removeFromSuperview() } + guard let contentView else { return } + contentView.removeFromSuperview() + contentView.frame = contentContainerView.bounds + contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + contentContainerView.addSubview(contentView) + } + + + /* + // MARK: - Navigation + + // In a storyboard-based application, you will often want to do a little preparation before navigation + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + // Get the new view controller using segue.destination. + // Pass the selected object to the new view controller. + } + */ + +} diff --git a/Sources/RDReaderView/RDReaderView.swift b/Sources/RDReaderView/RDReaderView.swift new file mode 100644 index 0000000..a37bdf8 --- /dev/null +++ b/Sources/RDReaderView/RDReaderView.swift @@ -0,0 +1,752 @@ +// +// RDReaderView.swift +// RDReaderDemo +// +// Created by yangsq on 2021/8/6. +// + +import UIKit + + +@objc public protocol RDReaderDataSource: NSObjectProtocol { + func pageCountOfReaderView(readerView: RDReaderView) -> Int + func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView + func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? + @objc optional func topToolView(readerView: RDReaderView) -> UIView? + @objc optional func bottomToolView(readerView: RDReaderView) -> UIView? +} + +@objc public protocol RDReaderDelegate: NSObjectProtocol { + func pageNum(readerView: RDReaderView, pageNum: Int) + /// 横竖屏切换时回调,可在此重新分页 + @objc optional func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool) +} + +extension RDReaderView { + public enum DisplayType { + case pageCurl + case horizontalScroll + case verticalScroll + } + + /// 翻页方向 + public enum PageDirection { + /// 从左往右翻页(默认,适用于中文/英文书籍) + case leftToRight + /// 从右往左翻页(适用于日文漫画等) + case rightToLeft + } +} + +public class RDReaderView: UIView { + + enum TapEvent { + case none, left, center, right + } + + private lazy var pageViewController: UIPageViewController = { + let pageVC = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: nil) + pageVC.delegate = self + pageVC.dataSource = self + return pageVC + }() + + private lazy var layout: RDReaderFlowLayout = { + let layout = RDReaderFlowLayout(displayType: .horizontalScroll) + layout.dataSource = self + layout.delegate = self + return layout + }() + + private lazy var collectionView: UICollectionView = { + + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.backgroundColor = UIColor.clear + return collectionView + }() + + public var currentPage: Int = -1 { + didSet { + if let delegate = delegate, currentPage != oldValue, delegate.responds(to: #selector(RDReaderDelegate.pageNum(readerView:pageNum:))) { + delegate.pageNum(readerView: self, pageNum: currentPage) + } + } + } + + private(set) lazy var tapGestureRecognizer: UITapGestureRecognizer = { + let tap = UITapGestureRecognizer(target: self, action: #selector(tapAction(tap:))) + return tap + }() + + private var tapEvent: TapEvent = .none { + didSet { + let isRTL = pageDirection == .rightToLeft + switch tapEvent { + case .left: + if currentDisplayType != .pageCurl { + if isRTL { goNextPage() } else { goPreviousPage() } + } + case .right: + if currentDisplayType != .pageCurl { + if isRTL { goPreviousPage() } else { goNextPage() } + } + case .center: + tapCenter() + default: + break + } + } + } + + private func goNextPage() { + let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0 + if pagesPerScreen > 1 { + if let target = adjacentDualPage(from: currentPage, forward: true) { + transitionToPage(pageNum: target, animated: true) + } + } else if currentPage + 1 < totalPages { + transitionToPage(pageNum: currentPage + 1, animated: true) + } + } + + private func goPreviousPage() { + if pagesPerScreen > 1 { + if let target = adjacentDualPage(from: currentPage, forward: false) { + transitionToPage(pageNum: target, animated: true) + } + } else if currentPage - 1 >= 0 { + transitionToPage(pageNum: currentPage - 1, animated: true) + } + } + + public weak var dataSource: RDReaderDataSource? = nil + public weak var delegate: RDReaderDelegate? = nil + public var currentDisplayType: RDReaderView.DisplayType = .pageCurl + public var toolViewAnimationDuration: TimeInterval = 0.3 + /// 是否启用横屏双页显示 + public var landscapeDualPageEnabled: Bool = false + /// 翻页方向,默认从左往右(.leftToRight) + public var pageDirection: RDReaderView.PageDirection = .leftToRight + /// 横屏双页模式下,封面页的索引。设置后该页在横屏时独占一屏,后续页面两两配对。 + /// 设为 nil 表示没有封面页(所有页面正常两两配对)。 + public var coverPageIndex: Int? = nil + + /// 是否启用了封面页独占 + private var hasCoverPage: Bool { + return coverPageIndex != nil + } + + /// 当前是否横屏 + private var isLandscape: Bool { + return bounds.width > bounds.height + } + + /// 每屏显示的页数 + public var pagesPerScreen: Int { + if !landscapeDualPageEnabled { return 1 } + if currentDisplayType == .verticalScroll { return 1 } + return isLandscape ? 2 : 1 + } + + /// 判断某页在横屏双页模式下是否独占一屏(封面页) + /// - Parameter pageNum: 页码 + /// - Returns: 是否独占一屏 + public func isFullScreenPage(_ pageNum: Int) -> Bool { + guard landscapeDualPageEnabled, isLandscape, let coverIndex = coverPageIndex else { return false } + return pageNum == coverIndex + } + + /// 根据逻辑页码计算横屏双页模式下的配对信息 + /// 封面页独占一屏,后续页面两两配对 + /// - Parameter pageNum: 逻辑页码 + /// - Returns: (左页码, 右页码(可选)) + private func dualPagePair(for pageNum: Int) -> (left: Int, right: Int?) { + let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0 + if let coverIndex = coverPageIndex { + if pageNum == coverIndex { + // 封面页独占一屏 + return (coverIndex, nil) + } + // 封面之后的页面:偏移1后两两配对 + // 例如封面是0页,则 1+2, 3+4, 5+6... + let adjustedIndex = pageNum - (coverIndex + 1) // 跳过封面后的偏移 + let pairStart = coverIndex + 1 + (adjustedIndex / 2) * 2 + let left = pairStart + let right = pairStart + 1 < totalPages ? pairStart + 1 : nil + return (left, right) + } else { + // 无封面页:正常两两配对 0+1, 2+3, 4+5... + let left = (pageNum / 2) * 2 + let right = left + 1 < totalPages ? left + 1 : nil + return (left, right) + } + } + + /// 计算横屏双页下,某页往前/后翻一屏后的起始页码 + /// - Parameters: + /// - pageNum: 当前页码 + /// - forward: 是否向后翻 + /// - Returns: 目标页码,nil 表示到头了 + private func adjacentDualPage(from pageNum: Int, forward: Bool) -> Int? { + let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0 + let pair = dualPagePair(for: pageNum) + if forward { + let nextStart = (pair.right ?? pair.left) + 1 + return nextStart < totalPages ? nextStart : nil + } else { + let prevEnd = pair.left - 1 + guard prevEnd >= 0 else { return nil } + return dualPagePair(for: prevEnd).left + } + } + + private var previousIsLandscape: Bool? + + private var contentViews = [String : UIView.Type]() + private var willPreviousTransitionToViewController: UIViewController? = nil + private var willNextTransitionToViewController: UIViewController? = nil + private var willTransitionToViewController: UIViewController? = nil + private var topToolView: UIView? + private var bottomToolView: UIView? + private var isShowToolView: Bool = false + private var isTransitioning: Bool = false + private var didBuildUI = false + + /// 用于 pageCurl 双页模式下封面页旁边的空白页 + static let blankPageNum = Int.max + /// 用于 pageCurl 双页模式下末尾不成对页旁边的空白页 + static let blankEndPageNum = Int.max - 1 + + override init(frame: CGRect) { + super.init(frame: frame) + } + + public override func layoutSubviews() { + super.layoutSubviews() + guard bounds.width > 0, bounds.height > 0 else { return } + let nowLandscape = isLandscape + if let prev = previousIsLandscape, prev != nowLandscape { + previousIsLandscape = nowLandscape + // 延迟到下一个 RunLoop 执行,避免在 layoutSubviews 中嵌套触发 reloadData/layoutIfNeeded + // 造成 collectionView 中间态尺寸不一致的问题 + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.orientationChanged(isNowLandscape: nowLandscape) + } + } else if previousIsLandscape == nil { + previousIsLandscape = nowLandscape + layout.isLandscapeDualPage = landscapeDualPageEnabled && nowLandscape + } + } + + /// 横竖屏切换处理 + /// - Parameter isNowLandscape: 当前是否为横屏 + private func orientationChanged(isNowLandscape: Bool) { + let savedPage = max(0, currentPage) + // 1. 通知代理方向变化。正文级重分页由上层控制器统一接管,这里只保留容器级刷新钩子。 + delegate?.readerViewOrientationWillChange?(readerView: self, isLandscape: isNowLandscape) + // 2. 更新布局的横屏双页标记 + layout.isLandscapeDualPage = landscapeDualPageEnabled && isNowLandscape + layout.coverPageIndex = coverPageIndex + + switch currentDisplayType { + case .pageCurl: + // 仿真翻页:通过 spineLocation 原生支持双页,需要重建 PageViewController + rebuildPageViewController() + transitionToPage(pageNum: savedPage) + default: + // 滚动模式:禁用动画防止旋转过渡中出现尺寸抖动 + UIView.performWithoutAnimation { + // 强制使布局完全失效并重新计算 + collectionView.collectionViewLayout.invalidateLayout() + collectionView.reloadData() + collectionView.layoutIfNeeded() + // 3. 根据保存的页码恢复滚动位置 + let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0 + let safePage = min(savedPage, max(0, totalPages - 1)) + let targetOffset = layout.currentContentOffset(count: safePage) + collectionView.setContentOffset(targetOffset, animated: false) + } + } + } + + /// 创建带指定 spine 位置的 UIPageViewController + /// - Parameter isDualPage: 是否双页模式(横屏时书脊在中间:.mid) + private func createPageViewController(isDualPage: Bool) -> UIPageViewController { + let options: [UIPageViewController.OptionsKey: Any]? + if isDualPage { + options = [.spineLocation: NSNumber(value: UIPageViewController.SpineLocation.mid.rawValue)] + } else { + options = nil + } + let pageVC = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: options) + pageVC.delegate = self + pageVC.dataSource = self + pageVC.isDoubleSided = isDualPage + return pageVC + } + + /// 重建 UIPageViewController。横竖屏切换时需要重建,因为 spineLocation 只能在初始化时设置 + private func rebuildPageViewController() { + detachPageViewControllerIfNeeded() + + let isDualPage = landscapeDualPageEnabled && isLandscape + let pageVC = createPageViewController(isDualPage: isDualPage) + pageViewController = pageVC + attachPageViewControllerIfNeeded() + } + + public override func didMoveToSuperview() { + super.didMoveToSuperview() + + if superview != nil { + makeUI() + } + } + + private func attachPageViewControllerIfNeeded() { + guard let parentViewController = self.ss_superViewController else { return } + + var didAddToParent = false + if pageViewController.parent !== parentViewController { + if pageViewController.parent != nil { + pageViewController.willMove(toParent: nil) + pageViewController.view.removeFromSuperview() + pageViewController.removeFromParent() + } + parentViewController.addChild(pageViewController) + didAddToParent = true + } + + pageViewController.view.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height) + pageViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + if pageViewController.view.superview !== self { + insertSubview(pageViewController.view, at: 0) + } + + if didAddToParent { + pageViewController.didMove(toParent: parentViewController) + } + } + + private func detachPageViewControllerIfNeeded() { + if pageViewController.parent != nil { + pageViewController.willMove(toParent: nil) + } + pageViewController.view.removeFromSuperview() + if pageViewController.parent != nil { + pageViewController.removeFromParent() + } + } + + private func makeUI() { + guard !didBuildUI else { + if currentDisplayType == .pageCurl { + attachPageViewControllerIfNeeded() + } + return + } + didBuildUI = true + + collectionView.dataSource = self + collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: NSStringFromClass(UICollectionViewCell.self)) + collectionView.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height) + collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + + if currentDisplayType == .pageCurl { + attachPageViewControllerIfNeeded() + } + + addGestureRecognizer(tapGestureRecognizer) + // 不取消底层触摸事件,确保工具栏按钮(返回等)的 touchUpInside 能正常触发 + tapGestureRecognizer.cancelsTouchesInView = false + + } + + @objc private func tapAction(tap: UITapGestureRecognizer) { + let point = tap.location(in: tap.view) + if isShowToolView { + let hitView = hitTest(point, with: nil) + if let top = topToolView, isHitView(hitView, inside: top, point: point) { return } + if let bottom = bottomToolView, isHitView(hitView, inside: bottom, point: point) { return } + } + let viewFrame = tap.view!.frame + let leftFrame = CGRect(x: 0, y: 0, width: viewFrame.width / 3, height: viewFrame.height) + let centerFrame = CGRect(x: viewFrame.width / 3, y: 0, width: viewFrame.width / 3, height: viewFrame.height) + let rightFrame = CGRect(x: viewFrame.width * 2 / 3, y: 0, width: viewFrame.width / 3, height: viewFrame.height) + if leftFrame.contains(point) { + if isShowToolView { + tapEvent = .center + } else { + tapEvent = .left + } + } + + if centerFrame.contains(point) { + tapEvent = .center + } + + if rightFrame.contains(point) { + if isShowToolView { + tapEvent = .center + } else { + tapEvent = .right + } + } + } + + private func tapCenter() { + isShowToolView = !isShowToolView + if isShowToolView { + if let topToolView = topToolView { + addSubview(topToolView) + topToolView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + topToolView.leadingAnchor.constraint(equalTo: leadingAnchor), + topToolView.trailingAnchor.constraint(equalTo: trailingAnchor), + topToolView.topAnchor.constraint(equalTo: topAnchor) + ]) + layoutIfNeeded() + topToolView.transform = CGAffineTransform(translationX: 0, y: -topToolView.bounds.height) + UIView.animate(withDuration: toolViewAnimationDuration) { + topToolView.transform = .identity + } + } + + if let bottomToolView = bottomToolView { + addSubview(bottomToolView) + bottomToolView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + bottomToolView.leadingAnchor.constraint(equalTo: leadingAnchor), + bottomToolView.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomToolView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + layoutIfNeeded() + bottomToolView.transform = CGAffineTransform(translationX: 0, y: bottomToolView.bounds.height) + UIView.animate(withDuration: toolViewAnimationDuration) { + bottomToolView.transform = .identity + } + } + + collectionView.isUserInteractionEnabled = false + pageViewController.view.isUserInteractionEnabled = false + } else { + if let topToolView = topToolView { + UIView.animate(withDuration: toolViewAnimationDuration, animations: { + topToolView.transform = CGAffineTransform(translationX: 0, y: -topToolView.bounds.height) + }) { _ in + topToolView.removeFromSuperview() + } + } + + if let bottomToolView = bottomToolView { + UIView.animate(withDuration: toolViewAnimationDuration, animations: { + bottomToolView.transform = CGAffineTransform(translationX: 0, y: bottomToolView.bounds.height) + }) { _ in + bottomToolView.removeFromSuperview() + } + } + + collectionView.isUserInteractionEnabled = true + pageViewController.view.isUserInteractionEnabled = true + } + } + + private func isHitView(_ hitView: UIView?, inside toolView: UIView, point: CGPoint) -> Bool { + if toolView.frame.contains(point) { + return true + } + + guard let hitView else { return false } + return hitView === toolView || hitView.isDescendant(of: toolView) + } + + /// 切换翻页模式(仿真/水平滚动/上下滚动) + /// 会重建底层视图(PageViewController 或 CollectionView),并恢复到当前页 + public func switchReaderDisplayType(_ displayType: RDReaderView.DisplayType) { + self.currentDisplayType = displayType + if currentPage == -1 { + currentPage = 0 + } + // 同步横屏双页标记到布局 + layout.isLandscapeDualPage = landscapeDualPageEnabled && isLandscape + layout.coverPageIndex = coverPageIndex + switch displayType { + case .pageCurl: + self.collectionView.removeFromSuperview() + self.collectionView.transform = .identity + attachPageViewControllerIfNeeded() + rebuildPageViewController() + transitionToPage(pageNum: currentPage) + default: + detachPageViewControllerIfNeeded() + // RTL 水平模式翻转 collectionView + if pageDirection == .rightToLeft && displayType != .verticalScroll { + collectionView.transform = CGAffineTransform(scaleX: -1, y: 1) + } else { + collectionView.transform = .identity + } + // 确保 collectionView frame 正确后再触发布局计算 + collectionView.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height) + insertSubview(self.collectionView, at: 0) + layout.displayType = displayType + transitionToPage(pageNum: currentPage) + } + } + + /// 跳转到指定页码 + /// - Parameters: + /// - pageNum: 目标页码(item 索引) + /// - animated: 是否动画过渡 + public func transitionToPage(pageNum: Int, animated: Bool = false) { + switch currentDisplayType { + case .pageCurl: + let isDualPage = landscapeDualPageEnabled && isLandscape + // RTL 时动画方向需要反转 + let direction: UIPageViewController.NavigationDirection + if pageDirection == .rightToLeft { + direction = pageNum > currentPage ? .reverse : .forward + } else { + direction = pageNum > currentPage ? .forward : .reverse + } + if isDualPage { + let pair = dualPagePair(for: pageNum) + let leftContent = dataSource?.pageContentView(readerView: self, pageNum: pair.left, containerView: nil) + let leftVC = RDReaderPageChildViewController(contentView: leftContent, pageNum: pair.left) + if let rightPage = pair.right { + let rightContent = dataSource?.pageContentView(readerView: self, pageNum: rightPage, containerView: nil) + let rightVC = RDReaderPageChildViewController(contentView: rightContent, pageNum: rightPage) + pageViewController.setViewControllers([leftVC, rightVC], direction: animated ? direction : .forward, animated: animated, completion: nil) + } else { + // 封面页独占或奇数最后一页:右侧放空白页 + let blankNum = isFullScreenPage(pair.left) ? RDReaderView.blankPageNum : RDReaderView.blankEndPageNum + let emptyVC = RDReaderPageChildViewController(contentView: UIView(), pageNum: blankNum) + pageViewController.setViewControllers([leftVC, emptyVC], direction: animated ? direction : .forward, animated: animated, completion: nil) + } + currentPage = pair.left + } else { + let contentView = dataSource?.pageContentView(readerView: self, pageNum: pageNum, containerView: nil) + let vc = RDReaderPageChildViewController(contentView: contentView, pageNum: pageNum) + pageViewController.setViewControllers([vc], direction: animated ? direction : .forward, animated: animated, completion: nil) + currentPage = pageNum + } + default: + collectionView.reloadData() + collectionView.layoutIfNeeded() + collectionView.setContentOffset(layout.currentContentOffset(count: pageNum), animated: animated) + } + } + + public func reloadData() { + switchReaderDisplayType(currentDisplayType) + topToolView = self.dataSource?.topToolView?(readerView: self) + bottomToolView = self.dataSource?.bottomToolView?(readerView: self) + } + + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + + + +extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDelegate { + + private func makeSinglePageChildVC(for pageNum: Int) -> RDReaderPageChildViewController { + if pageNum == RDReaderView.blankPageNum || pageNum == RDReaderView.blankEndPageNum { + return RDReaderPageChildViewController(contentView: UIView(), pageNum: pageNum) + } + let contentView = dataSource?.pageContentView(readerView: self, pageNum: pageNum, containerView: nil) + return RDReaderPageChildViewController(contentView: contentView, pageNum: pageNum) + } + + /// 计算某页的"下一页"页码(考虑封面页插入空白页) + private func nextPageNum(after pageNum: Int, isDualPage: Bool) -> Int? { + let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0 + + // 末尾空白页之后没有更多页 + if pageNum == RDReaderView.blankEndPageNum { + return nil + } + + if isDualPage, let coverIndex = coverPageIndex { + if pageNum == coverIndex { + return RDReaderView.blankPageNum + } + if pageNum == RDReaderView.blankPageNum { + let firstContent = coverIndex + 1 + return firstContent < totalPages ? firstContent : nil + } + } + + let next = pageNum + 1 + if next < totalPages { + return next + } + + // pageNum 是最后一页,检查在双页模式下是否需要空白页配对 + if isDualPage { + if let coverIndex = coverPageIndex { + // 有封面时:封面之后的页面两两配对,偶数偏移=左页需要配对 + let adjustedIndex = pageNum - (coverIndex + 1) + if adjustedIndex >= 0 && adjustedIndex % 2 == 0 { + return RDReaderView.blankEndPageNum + } + } else { + // 无封面:偶数索引=左页需要配对 + if pageNum % 2 == 0 { + return RDReaderView.blankEndPageNum + } + } + } + return nil + } + + /// 计算某页的"上一页"页码(考虑封面页插入空白页) + private func prevPageNum(before pageNum: Int, isDualPage: Bool) -> Int? { + // 末尾空白页的前一页是最后一个真实页 + if pageNum == RDReaderView.blankEndPageNum { + let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0 + return totalPages > 0 ? totalPages - 1 : nil + } + if isDualPage, let coverIndex = coverPageIndex { + if pageNum == RDReaderView.blankPageNum { + return coverIndex + } + if pageNum == coverIndex + 1 { + return RDReaderView.blankPageNum + } + } + let prev = pageNum - 1 + return prev >= 0 ? prev : nil + } + + public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { + guard let vc = viewController as? RDReaderPageChildViewController else { return nil } + let isDualPage = landscapeDualPageEnabled && isLandscape + let isRTL = pageDirection == .rightToLeft + + // RTL 时 before/after 语义互换:before(向右翻)= 下一页 + let targetNum = isRTL + ? nextPageNum(after: vc.pageNum, isDualPage: isDualPage) + : prevPageNum(before: vc.pageNum, isDualPage: isDualPage) + + guard let num = targetNum else { return nil } + let targetVC = makeSinglePageChildVC(for: num) + willPreviousTransitionToViewController = targetVC + return targetVC + } + + public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { + guard let vc = viewController as? RDReaderPageChildViewController else { return nil } + let isDualPage = landscapeDualPageEnabled && isLandscape + let isRTL = pageDirection == .rightToLeft + + let targetNum = isRTL + ? prevPageNum(before: vc.pageNum, isDualPage: isDualPage) + : nextPageNum(after: vc.pageNum, isDualPage: isDualPage) + + guard let num = targetNum else { return nil } + let targetVC = makeSinglePageChildVC(for: num) + willNextTransitionToViewController = targetVC + return targetVC + } + + public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { + if completed, let firstVC = pageViewController.viewControllers?.first as? RDReaderPageChildViewController { + let pn = firstVC.pageNum + if pn != RDReaderView.blankPageNum && pn != RDReaderView.blankEndPageNum { + currentPage = pn + } + } + } + + public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) { + willTransitionToViewController = pendingViewControllers.first + } + +} + +extension RDReaderView: UICollectionViewDataSource, RDReaderFlowLayoutDelegate, RDReaderFlowLayoutDataSoure { + public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + + + if let identifer = self.dataSource?.pageIdentifier(readerView: self, pageNum: indexPath.row) { + + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: identifer, for: IndexPath(item: indexPath.row, section: 0)) as! RDReaderContentCell + let conttainerView = self.dataSource?.pageContentView(readerView: self, pageNum: indexPath.row, containerView: cell.containerView) + if let conttainerView = conttainerView { + cell.containerView = conttainerView + } + // RTL 水平模式:翻转 cell 内容使文字方向正常(collectionView 已整体翻转) + if pageDirection == .rightToLeft && currentDisplayType != .verticalScroll { + cell.contentView.transform = CGAffineTransform(scaleX: -1, y: 1) + } else { + cell.contentView.transform = .identity + } + return cell + } + + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: NSStringFromClass(UICollectionViewCell.self), for: indexPath) + + return cell + } + + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return self.dataSource?.pageCountOfReaderView(readerView: self) ?? 0 + } + + public func pageNum(flowLayout: RDReaderFlowLayout, pageIndex: Int) { + currentPage = pageIndex + + } + + public func heigtOfVerticalScrollPage(flowLayout: RDReaderFlowLayout, pageIndex: Int) -> CGFloat? { + return nil + } +} + +extension RDReaderView { + public func register(contentView: UIView.Type, contentViewWithReuseIdentifier identifier: String) { + contentViews[identifier] = contentView + collectionView.register(RDReaderContentCell.self, forCellWithReuseIdentifier: identifier) + } + + + public func dequeueReusableContentView(withReuseIdentifier identifier: String, for pageNum: Int) -> UIView { + if self.currentDisplayType != .pageCurl, let cell = self.collectionView.cellForItem(at: IndexPath(row: pageNum, section: 0)) as? RDReaderContentCell, let containerView = cell.containerView { + return containerView + } + let contentViewClass = contentViews[identifier] + assert(contentViewClass != nil, "请调用register(contentView:contentViewWithReuseIdentifier:)") + var contentView = contentViewClass!.init() + return contentView + } + + public func pageContentView(pageNum: Int) -> UIView? { + if currentDisplayType == .pageCurl { + return (self.pageViewController.viewControllers?.first as? RDReaderPageChildViewController)?.contentView + } else { + let cell = collectionView.cellForItem(at: IndexPath(item: pageNum, section: 0)) as? RDReaderContentCell + return cell?.containerView + } + } +} + +private var cellViewKey: Int8 = 0 +extension UIView { + var ss_superViewController: UIViewController? { + var next = self.next + while next != nil { + if next is UIViewController { + return next as? UIViewController + } else { + next = next!.next + } + } + return nil + } + +} diff --git a/Sources/RDReaderView/RDURLReaderController.swift b/Sources/RDReaderView/RDURLReaderController.swift new file mode 100644 index 0000000..9cc560a --- /dev/null +++ b/Sources/RDReaderView/RDURLReaderController.swift @@ -0,0 +1,105 @@ +import UIKit + +public final class RDURLReaderController: UIViewController { + private let bookURL: URL + private let epubConfiguration: RDEPUBReaderConfiguration + private var embeddedController: UIViewController? + + public init( + bookURL: URL, + epubConfiguration: RDEPUBReaderConfiguration = RDEPUBReaderConfiguration() + ) { + self.bookURL = bookURL + self.epubConfiguration = epubConfiguration + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + title = bookURL.deletingPathExtension().lastPathComponent + embedReaderController() + } + + private func embedReaderController() { + let controller: UIViewController + if bookURL.pathExtension.lowercased() == "epub" { + controller = RDEPUBReaderController( + epubURL: bookURL, + configuration: epubConfiguration + ) + } else { + let bookIdentifier = bookURL.lastPathComponent + let bookTitle = bookURL.deletingPathExtension().lastPathComponent + let pageSize = currentTextPageSize() + let renderStyle = currentTextRenderStyle() + let builder = RDPlainTextBookBuilder() + if let textBook = try? builder.build(textFileURL: bookURL, pageSize: pageSize, style: renderStyle) { + controller = RDEPUBReaderController( + textBook: textBook, + bookIdentifier: bookIdentifier, + title: bookTitle, + textFileURL: bookURL, + configuration: epubConfiguration + ) + } else { + // 分页失败时回退到纯文本展示 + let fallback = UIViewController() + let textView = UITextView() + textView.isEditable = false + textView.text = rd_decodeTextFile(url: bookURL) + fallback.view = textView + controller = fallback + } + } + embeddedController = controller + addChild(controller) + controller.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(controller.view) + NSLayoutConstraint.activate([ + controller.view.topAnchor.constraint(equalTo: view.topAnchor), + controller.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + controller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + controller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + controller.didMove(toParent: self) + } + + private func currentTextPageSize() -> CGSize { + let viewportSize = UIScreen.main.bounds.size + let insets = epubConfiguration.reflowableContentInsets + return CGSize( + width: max(viewportSize.width - insets.left - insets.right, 1), + height: max(viewportSize.height - insets.top - insets.bottom, 1) + ) + } + + private func currentTextRenderStyle() -> RDEPUBTextRenderStyle { + let font = UIFont.systemFont(ofSize: epubConfiguration.fontSize) + let lineSpacing = max(font.lineHeight * (epubConfiguration.lineHeightMultiple - 1), 4) + return RDEPUBTextRenderStyle( + font: font, + lineSpacing: lineSpacing, + textColor: epubConfiguration.theme.contentTextColor, + backgroundColor: epubConfiguration.theme.contentBackgroundColor + ) + } + + private func rd_decodeTextFile(url: URL) -> String { + if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String { + return content + } + if let content = try? NSString(contentsOf: url, encoding: 0x80000632) as String { + return content + } + if let content = try? NSString(contentsOf: url, encoding: 0x80000631) as String { + return content + } + return "" + } +} diff --git a/Sources/RDReaderView/Resources/epub-bridge.js b/Sources/RDReaderView/Resources/epub-bridge.js new file mode 100644 index 0000000..50722db --- /dev/null +++ b/Sources/RDReaderView/Resources/epub-bridge.js @@ -0,0 +1,289 @@ +(function() { + if (window.RDReaderBridge) { return; } + window.addEventListener('error', function(event) { + try { + var message = event && (event.message || (event.error && event.error.message)) || 'Unknown JavaScript error'; + window.webkit.messageHandlers.{{JAVASCRIPT_ERROR_MESSAGE}}.postMessage(message); + } catch (error) { + } + }); + function nodePath(node) { + var path = []; + var current = node; + while (current && current !== document) { + var parent = current.parentNode; + if (!parent) { break; } + var index = Array.prototype.indexOf.call(parent.childNodes, current); + path.unshift(index); + current = parent; + } + return path; + } + function nodeFromPath(path) { + var current = document; + for (var i = 0; i < path.length; i += 1) { + if (!current || !current.childNodes || current.childNodes.length <= path[i]) { return null; } + current = current.childNodes[path[i]]; + } + return current; + } + function serializeRange(range) { + return JSON.stringify({ + kind: 'dom-range', + startPath: nodePath(range.startContainer), + startOffset: range.startOffset, + endPath: nodePath(range.endContainer), + endOffset: range.endOffset + }); + } + function rangeFromInfo(rangeInfo) { + try { + var payload = typeof rangeInfo === 'string' ? JSON.parse(rangeInfo) : rangeInfo; + var startNode = nodeFromPath(payload.startPath || []); + var endNode = nodeFromPath(payload.endPath || []); + if (!startNode || !endNode) { return null; } + var range = document.createRange(); + range.setStart(startNode, payload.startOffset || 0); + range.setEnd(endNode, payload.endOffset || 0); + return range; + } catch (error) { + return null; + } + } + var visiblePageIndex = 0; + var configuredPageCount = 1; + var configuredPageStride = 1; + var relayoutTimer = null; + var fixedLayoutDocument = false; + function isFixedLayoutDocument() { + if (fixedLayoutDocument) { return true; } + fixedLayoutDocument = !!document.getElementById('ss-fixed-root') || !!document.querySelector('.ss-fixed-page'); + return fixedLayoutDocument; + } + function ensurePaginationStructure() { + if (isFixedLayoutDocument()) { + return null; + } + var viewport = document.getElementById('ss-reader-viewport'); + var content = document.getElementById('ss-reader-content'); + if (viewport && content) { + return content; + } + + viewport = document.createElement('div'); + viewport.id = 'ss-reader-viewport'; + content = document.createElement('div'); + content.id = 'ss-reader-content'; + + while (document.body.firstChild) { + content.appendChild(document.body.firstChild); + } + + viewport.appendChild(content); + document.body.appendChild(viewport); + return content; + } + function paginationContent() { + if (isFixedLayoutDocument()) { + return null; + } + return ensurePaginationStructure(); + } + function effectivePageStride() { + return Math.max(1, configuredPageStride || 1); + } + function measuredPageCount() { + if (isFixedLayoutDocument()) { + return 1; + } + var content = paginationContent(); + var scrollingElement = document.scrollingElement || document.documentElement || document.body; + var scrollWidth = Math.max( + content ? content.scrollWidth : 0, + content ? content.offsetWidth : 0, + scrollingElement ? scrollingElement.scrollWidth : 0, + document.documentElement ? document.documentElement.scrollWidth : 0, + document.body ? document.body.scrollWidth : 0, + effectivePageStride() + ); + return Math.max(1, Math.ceil(scrollWidth / effectivePageStride())); + } + function totalPageCount() { + return Math.max(configuredPageCount, measuredPageCount()); + } + function currentPageOffset() { + return Math.round(visiblePageIndex * effectivePageStride()); + } + function reportProgression(fragment) { + var pageCount = totalPageCount(); + var progression = pageCount <= 1 ? 0 : visiblePageIndex / Math.max(pageCount - 1, 1); + var lastProgression = progression; + window.webkit.messageHandlers.{{PROGRESSION_CHANGED_MESSAGE}}.postMessage({ + progression: progression, + lastProgression: lastProgression, + fragment: fragment || null + }); + } + function setVisiblePageIndex(pageIndex) { + if (isFixedLayoutDocument()) { + visiblePageIndex = 0; + return; + } + var pageCount = totalPageCount(); + var maxPageIndex = Math.max(0, pageCount - 1); + var safePageIndex = Math.max(0, Math.min(maxPageIndex, Math.round(pageIndex || 0))); + visiblePageIndex = safePageIndex; + var content = paginationContent(); + if (content) { + content.style.transform = 'translate3d(' + (-currentPageOffset()) + 'px, 0, 0)'; + } + } + function scheduleRelayout() { + if (isFixedLayoutDocument()) { return; } + clearTimeout(relayoutTimer); + relayoutTimer = setTimeout(function() { + setVisiblePageIndex(visiblePageIndex); + reportProgression(null); + }, 60); + } + function pageIndexForElement(target) { + if (isFixedLayoutDocument()) { return 0; } + if (!target) { return 0; } + var rect = target.getBoundingClientRect(); + var absoluteLeft = Math.max(0, rect.left + currentPageOffset()); + return Math.max(0, Math.floor(absoluteLeft / effectivePageStride())); + } + function unwrapHighlights() { + document.querySelectorAll('mark.ss-reader-highlight').forEach(function(mark) { + var parent = mark.parentNode; + if (!parent) { return; } + while (mark.firstChild) { + parent.insertBefore(mark.firstChild, mark); + } + parent.removeChild(mark); + }); + } + function setHighlights(items) { + unwrapHighlights(); + if (!Array.isArray(items)) { return; } + items.forEach(function(item) { + if (!item || !item.rangeInfo) { return; } + var range = rangeFromInfo(item.rangeInfo); + if (!range || range.collapsed) { return; } + try { + var mark = document.createElement('mark'); + var style = item.style || 'highlight'; + mark.className = 'ss-reader-highlight ss-reader-highlight-' + style; + mark.style.color = 'inherit'; + if (style === 'underline') { + mark.style.background = 'transparent'; + mark.style.textDecorationColor = item.color || '#F8E16C'; + } else { + mark.style.background = item.color || '#F8E16C'; + } + mark.dataset.highlightId = item.id || ''; + range.surroundContents(mark); + } catch (error) { + } + }); + } + function selectionPayload() { + var selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + return null; + } + var range = selection.getRangeAt(0); + var text = selection.toString(); + if (!text || !text.trim()) { return null; } + var pageCount = totalPageCount(); + var progression = pageCount <= 1 ? 0 : visiblePageIndex / Math.max(pageCount - 1, 1); + var lastProgression = progression; + return { + text: text, + rangeInfo: serializeRange(range), + progression: progression, + lastProgression: lastProgression + }; + } + var selectionTimer = null; + document.addEventListener('selectionchange', function() { + clearTimeout(selectionTimer); + selectionTimer = setTimeout(function() { + var payload = selectionPayload(); + window.webkit.messageHandlers.{{SELECTION_CHANGED_MESSAGE}}.postMessage(payload); + }, 120); + }); + window.addEventListener('resize', function() { + if (isFixedLayoutDocument()) { return; } + setVisiblePageIndex(visiblePageIndex); + reportProgression(null); + }, { passive: true }); + window.addEventListener('load', scheduleRelayout, { passive: true }); + if (!isFixedLayoutDocument() && document.fonts && document.fonts.ready) { + document.fonts.ready.then(scheduleRelayout).catch(function() {}); + } + if (!isFixedLayoutDocument()) { + Array.prototype.forEach.call(document.querySelectorAll('img, iframe, video'), function(node) { + node.addEventListener('load', scheduleRelayout, { passive: true }); + node.addEventListener('error', scheduleRelayout, { passive: true }); + }); + } + document.addEventListener('click', function(event) { + var anchor = event.target.closest ? event.target.closest('a[href]') : null; + if (!anchor) { return; } + var href = anchor.getAttribute('href'); + if (!href) { return; } + if (/^(https?:|mailto:|tel:)/i.test(href)) { + event.preventDefault(); + window.webkit.messageHandlers.{{EXTERNAL_LINK_MESSAGE}}.postMessage({ url: href }); + return; + } + event.preventDefault(); + window.webkit.messageHandlers.{{INTERNAL_LINK_MESSAGE}}.postMessage({ href: href }); + }, true); + window.RDReaderBridge = { + applyPagination: function(styleText) { + if (isFixedLayoutDocument()) { return; } + var style = document.getElementById('ss-reader-pagination'); + if (!style) { + style = document.createElement('style'); + style.id = 'ss-reader-pagination'; + document.head.appendChild(style); + } + style.textContent = styleText; + ensurePaginationStructure(); + }, + setPageMetrics: function(pageCount, pageStride) { + if (isFixedLayoutDocument()) { return; } + configuredPageCount = Math.max(1, Math.round(pageCount || 1)); + configuredPageStride = Math.max(1, Number(pageStride) || configuredPageStride || 1); + setVisiblePageIndex(visiblePageIndex); + scheduleRelayout(); + }, + scrollToPage: function(pageIndex) { + if (isFixedLayoutDocument()) { return; } + setVisiblePageIndex(pageIndex); + reportProgression(null); + }, + scrollToLocation: function(location, fallbackPageIndex) { + if (location && location.fragment) { + var target = document.getElementById(location.fragment) || document.querySelector('[name="' + location.fragment.replace(/"/g, '\\"') + '"]'); + if (target) { + setVisiblePageIndex(pageIndexForElement(target)); + reportProgression(location.fragment); + return; + } + } + var pageCount = totalPageCount(); + var progression = location && typeof location.progression === 'number' ? location.progression : 0; + var derivedPageIndex = progression > 0 && pageCount > 1 + ? Math.round(progression * Math.max(pageCount - 1, 0)) + : (fallbackPageIndex || 0); + this.scrollToPage(derivedPageIndex); + }, + setHighlights: setHighlights, + clearHighlights: unwrapHighlights, + reportProgression: reportProgression, + selectionPayload: selectionPayload + }; +})(); diff --git a/Sources/RDReaderView/Resources/epub-fixed-layout.html b/Sources/RDReaderView/Resources/epub-fixed-layout.html new file mode 100644 index 0000000..96b6315 --- /dev/null +++ b/Sources/RDReaderView/Resources/epub-fixed-layout.html @@ -0,0 +1,207 @@ + + + + + + + +
{{PANES}}
+ + + \ No newline at end of file