3 Commits
Author SHA1 Message Date
shenandshen 23182e8b5a feat(epub): align wxread css and overlay pagination behavior 2026-05-25 22:15:24 +08:00
shenandshen c0aac56083 docs: 同步 Doc 文档与当前代码状态
- 更新模块文件数(EPUBCore 31+2, EPUBTextRendering 13, ReaderView 5, EPUBUI 19)
- 更新 Swift 版本(5.10)、iOS 版本(15.0)、行数等基本信息
- 补充新增文件文档(TextAnchor, RenderRequest, WebViewDebug, AssetRepository,
  TextIndexTable, TextPerformanceSampler, ChapterData, PageInteractionController 等)
- 更新 RDEPUBLocation 模型(新增 rangeAnchor 字段)
- 更新 RDEPUBReaderConfiguration(13 项配置)、Delegate 签名、Persistence(8 方法)
- 修正 RDURLReaderController 归属(ReaderView → EPUBUI)
- 移除过时的 LegacyRDReaderController 引用
- 标记 SS→RD 命名迁移为已完成
2026-05-25 20:31:10 +08:00
shenandshen 54798ba578 refactor: 添加中文注释 + 优化模块结构
- 给全部 78 个 Swift 源文件添加详细的中文注释(文件级、类级、方法级)
- 删除 LegacyRDReaderController/ 死代码目录(16 文件 4592 行)
- 根目录翻页容器文件移入 ReaderView/ 目录
- Resources/ 移入 EPUBCore/Resources/(与使用者归属一致)
- RDEPUBTextIndexTable.swift 移入 EPUBTextRendering/(消除反向依赖)
- RDURLReaderController.swift 移入 EPUBUI/(入口控制器归入 UI 层)
- 更新 podspec 资源路径
2026-05-25 10:19:14 +08:00
110 changed files with 5285 additions and 6252 deletions
+25 -18
View File
@@ -4,8 +4,8 @@
RDReaderView 是一个 iOS 阅读器组件库(CocoaPods),提供开箱即用的 EPUB 阅读能力,并保留对纯文本翻页的支持。 RDReaderView 是一个 iOS 阅读器组件库(CocoaPods),提供开箱即用的 EPUB 阅读能力,并保留对纯文本翻页的支持。
- **最低 iOS 版本**:15.0(podspec 仍标注 9.0,实际 demo Podfile 要求 15.0) - **最低 iOS 版本**:15.0
- **Swift 版本**:5.0+ - **Swift 版本**:5.10+
- **依赖**:ZIPFoundation(EPUB 解压)、DTCoreText(文本 EPUB 渲染) - **依赖**:ZIPFoundation(EPUB 解压)、DTCoreText(文本 EPUB 渲染)
- **Demo 额外依赖**:SnapKit、SSAlertSwift - **Demo 额外依赖**:SnapKit、SSAlertSwift
@@ -21,10 +21,13 @@ RDReaderView 是一个 iOS 阅读器组件库(CocoaPods),提供开箱即
│ │
┌───────────────────────────▼─────────────────────────────┐ ┌───────────────────────────▼─────────────────────────────┐
│ EPUBUI 层(library 级读者 UI) │ │ EPUBUI 层(library 级读者 UI) │
│ RDEPUBReaderController(开箱即用入口) │ │ RDEPUBReaderController(开箱即用入口,~1995 行) │
│ RDEPUBReaderConfiguration / Theme / Persistence │ │ RDEPUBReaderConfiguration / Theme / Settings / Persistence│
│ TopToolView / BottomToolView / ChapterList / Settings │ │ TopToolView / BottomToolView / ToolView 基类 │
│ ChapterList / Highlights / Bookmarks / Settings 面板 │
│ RDEPUBTextContentView / RDEPUBWebContentView │ │ RDEPUBTextContentView / RDEPUBWebContentView │
│ RDEPUBPageInteractionController / SelectionOverlayView │
│ RDEPUBPageLayoutSnapshot / RDURLReaderController │
└───────────────────────────┬─────────────────────────────┘ └───────────────────────────┬─────────────────────────────┘
│ │
┌───────────────────────────▼─────────────────────────────┐ ┌───────────────────────────▼─────────────────────────────┐
@@ -40,10 +43,13 @@ RDReaderView 是一个 iOS 阅读器组件库(CocoaPods),提供开箱即
│ EPUBCore 层(EPUB 引擎) │ │ EPUBCore 层(EPUB 引擎) │
│ │ │ │
│ Publication 层 │ │ Publication 层 │
│ RDEPUBParser(+Archive / +Package / +TOC / …) │ │ RDEPUBParser(+Archive / +Package / +TOC / │
│ +ReadingProfile / +Resources) │
│ RDEPUBPublication(出版物聚合对象) │ │ RDEPUBPublication(出版物聚合对象) │
│ RDEPUBModels(metadata / manifest / spine 模型) │ │ RDEPUBModels(metadata / manifest / spine 模型) │
│ RDEPUBReadingModels(location / viewport / highlight) │ │ RDEPUBReadingModels(location / viewport / highlight) │
│ RDEPUBTextAnchor / RDEPUBTextRangeAnchor(文本锚点) │
│ RDEPUBRenderRequest(渲染请求模型) │
│ │ │ │
│ Services 层 │ │ Services 层 │
│ RDEPUBResourceResolver(资源 URL 统一入口) │ │ RDEPUBResourceResolver(资源 URL 统一入口) │
@@ -57,11 +63,12 @@ RDReaderView 是一个 iOS 阅读器组件库(CocoaPods),提供开箱即
│ │ │ │
│ Resource View 层 │ │ Resource View 层 │
│ RDEPUBWebView(+Configuration / +Reflowable / │ │ RDEPUBWebView(+Configuration / +Reflowable / │
│ +FixedLayout / +JavaScriptBridge) │ │ +FixedLayout / +JavaScriptBridge / │
│ +Search) │
│ RDEPUBResourceURLSchemeHandler(ss-reader:// 协议) │ │ RDEPUBResourceURLSchemeHandler(ss-reader:// 协议) │
│ RDEPUBStyleSheetBuilder / RDEPUBJavaScriptBridge │ │ RDEPUBStyleSheetBuilder / RDEPUBJavaScriptBridge │
│ RDEPUBFixedLayoutTemplate / RDEPUBAssetRepository │ │ RDEPUBFixedLayoutTemplate / RDEPUBAssetRepository │
│ RDEPUBRenderRequest │ │ RDEPUBWebViewDebug(调试日志工具) │
│ │ │ │
│ Search 层 │ │ Search 层 │
│ RDEPUBSearchEngine(协议)/ RDEPUBHTMLSearchEngine │ │ RDEPUBSearchEngine(协议)/ RDEPUBHTMLSearchEngine │
@@ -75,15 +82,10 @@ RDReaderView 是一个 iOS 阅读器组件库(CocoaPods),提供开箱即
│ RDEPUBTextRendererSupport / RDEPUBTextPaginationSupport │ │ RDEPUBTextRendererSupport / RDEPUBTextPaginationSupport │
│ RDEPUBTextBookBuilder / RDPlainTextBookBuilder │ │ RDEPUBTextBookBuilder / RDPlainTextBookBuilder │
│ RDEPUBTextLayouter / RDEPUBTextLayoutFrame │ │ RDEPUBTextLayouter / RDEPUBTextLayoutFrame │
│ RDEPUBTextBookCache / RDEPUBChapterData │
│ RDEPUBTextIndexTable / RDEPUBTextPerformanceSampler │
│ RDEPUBTextSearchEngine │ │ RDEPUBTextSearchEngine │
└──────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ LegacyRDReaderController/(旧版阅读器,保留兼容) │
│ RDReaderController(旧版)/ RDReaderManager │
│ RDReaderContentView / RDReaderEPUBContentView │
│ SSChapterListController / SSEventTrigger │
└──────────────────────────────────────────────────────────┘
``` ```
--- ---
@@ -239,6 +241,7 @@ session.clearPendingNavigation() // 取消待执行跳转
| `+Reflowable` | 注入分页 CSS、滚动到指定 progression、接收 JS 事件 | | `+Reflowable` | 注入分页 CSS、滚动到指定 progression、接收 JS 事件 |
| `+FixedLayout` | fixed-layout HTML wrapper 生成和加载 | | `+FixedLayout` | fixed-layout HTML wrapper 生成和加载 |
| `+JavaScriptBridge` | JS ↔ Swift 消息路由、选区、高亮、进度上报 | | `+JavaScriptBridge` | JS ↔ Swift 消息路由、选区、高亮、进度上报 |
| `+Search` | 搜索高亮装饰 |
#### ss-reader:// 协议 #### ss-reader:// 协议
@@ -323,7 +326,10 @@ present(controller, animated: true)
| `showsTableOfContents` | `true` | 是否显示目录入口 | | `showsTableOfContents` | `true` | 是否显示目录入口 |
| `allowsHighlights` | `true` | 是否显示高亮入口 | | `allowsHighlights` | `true` | 是否显示高亮入口 |
| `showsSettingsPanel` | `true` | 是否显示设置入口 | | `showsSettingsPanel` | `true` | 是否显示设置入口 |
| `reflowableContentInsets` | (40,16,40,16) | 可重排内容内边距 |
| `fixedContentInset` | .zero | 固定版式内容内边距 |
| `theme` | `.light` | 主题 | | `theme` | `.light` | 主题 |
| `fixedLayoutFit` | `.page` | 固定版式适配模式 |
| `fixedLayoutSpreadMode` | `.automatic` | fixed-layout spread 模式 | | `fixedLayoutSpreadMode` | `.automatic` | fixed-layout spread 模式 |
| `textRenderingEngine` | `.dtCoreText` | 文本 EPUB 渲染引擎 | | `textRenderingEngine` | `.dtCoreText` | 文本 EPUB 渲染引擎 |
@@ -377,6 +383,7 @@ struct RDEPUBLocation: Codable, Equatable {
var progression: Double // 视口起始位置 [0, 1] var progression: Double // 视口起始位置 [0, 1]
var lastProgression: Double? // 视口末尾位置 [0, 1] var lastProgression: Double? // 视口末尾位置 [0, 1]
var fragment: String? // 锚点 var fragment: String? // 锚点
var rangeAnchor: RDEPUBTextRangeAnchor? // 文本范围锚点(用于文本 EPUB 高亮定位)
} }
``` ```
@@ -393,8 +400,8 @@ struct RDEPUBLocation: Codable, Equatable {
| 问题 | 说明 | | 问题 | 说明 |
|------|------| |------|------|
| Demo 层 RDReaderController 过大 | 约 1900+ 行,仍混有 EPUB 加载、UI 管理、数据源逻辑,待进一步拆分 | | RDEPUBReaderController 过大 | 约 1995 行,仍混有 EPUB 加载、UI 管理、数据源逻辑,待进一步拆分 |
| RDEPUBParser.swift 仍偏大 | 约 900 行,虽已拆出多个扩展文件,OPF metadata 细节仍集中 | | 部分 UI 文件仍然偏大 | RDEPUBTextContentView ~728 行、RDEPUBTextBookBuilder ~717 行,需继续拆分 |
| 横竖屏切换已接入一阶段支持 | `RDEPUBReaderController.viewWillTransition()` 统一接管正文重分页,`RDReaderView` 保留容器级双页布局刷新;仍需补齐固定回归矩阵 | | 横竖屏切换已接入一阶段支持 | `RDEPUBReaderController.viewWillTransition()` 统一接管正文重分页,`RDReaderView` 保留容器级双页布局刷新;仍需补齐固定回归矩阵 |
| 固定手势分区比例 | 三等分固定写死,不支持自定义 | | 固定手势分区比例 | 三等分固定写死,不支持自定义 |
| 自动化测试为首批接入状态 | 已有 `ReadViewSDKDemoTests` / `ReadViewSDKDemoUITests` 与 parser / resolver / persistence / smoke 用例,分页、恢复链路和更多 UI 闭环仍需继续补齐 | | 自动化测试为首批接入状态 | 已有 `ReadViewSDKDemoTests` / `ReadViewSDKDemoUITests` 与 parser / resolver / persistence / smoke 用例,分页、恢复链路和更多 UI 闭环仍需继续补齐 |
@@ -416,7 +423,7 @@ pod install
| 依赖 | 用途 | 使用层 | | 依赖 | 用途 | 使用层 |
|------|------|--------| |------|------|--------|
| ZIPFoundation | EPUB ZIP 解压 | RDEPUBParser+Archive | | ZIPFoundation (~> 0.9) | EPUB ZIP 解压 | RDEPUBParser+Archive |
| DTCoreText | HTML → NSAttributedString | RDEPUBDTCoreTextRenderer | | DTCoreText | HTML → NSAttributedString | RDEPUBDTCoreTextRenderer |
| SnapKit | Demo 布局 | Demo 层 | | SnapKit | Demo 布局 | Demo 层 |
| SSAlertSwift | Demo 弹窗 | Demo 层 | | SSAlertSwift | Demo 弹窗 | Demo 层 |
+7 -5
View File
@@ -1,8 +1,8 @@
# ReadSDK 代码规范 # ReadViewSDK 代码规范
## 适用范围 ## 适用范围
本文档适用于 `ReadSDK` 新增代码与重构代码。 本文档适用于 `ReadViewSDK` 新增代码与重构代码。
- 规范覆盖 `Sources/` 与 `RDReaderDemo/` 中的 Swift 代码。 - 规范覆盖 `Sources/` 与 `RDReaderDemo/` 中的 Swift 代码。
- 命名、分层与职责边界以 SDK 可维护性和可扩展性为优先。 - 命名、分层与职责边界以 SDK 可维护性和可扩展性为优先。
@@ -225,15 +225,17 @@ RDEPUBWebView+Reflowable.swift
- 命名先定前缀再落代码:类型一律 `RD` 开头。 - 命名先定前缀再落代码:类型一律 `RD` 开头。
- 若需迁移历史 `SS` 前缀,按模块渐进替换,优先替换新增与重构触达文件。 - 若需迁移历史 `SS` 前缀,按模块渐进替换,优先替换新增与重构触达文件。
## 旧 SS 命名迁移到 RD 的分阶段执行清单 ## 旧 SS 命名迁移到 RD 的执行状态
### 阶段 0:冻结新增 SS 命名(立即执行) **迁移已完成**:源码中已无 `SS` 前缀类型定义,全部使用 `RD`/`RDEPUB` 前缀。新增代码必须继续遵守此规则。
### 阶段 0:冻结新增 SS 命名(已完成)
- 目标:从当前时点开始,不再引入新的 `SS`/`RDEPUB` 类型名。 - 目标:从当前时点开始,不再引入新的 `SS`/`RDEPUB` 类型名。
- 动作: - 动作:
- 新增类型统一使用 `RD`/`RDEPUB` 前缀。 - 新增类型统一使用 `RD`/`RDEPUB` 前缀。
- Code Review 增加命名检查项:发现新增 `SS` 命名必须驳回。 - Code Review 增加命名检查项:发现新增 `SS` 命名必须驳回。
- 在 PR 模板中加入“本次是否新增旧前缀命名”勾选项。 - 在 PR 模板中加入”本次是否新增旧前缀命名”勾选项。
- 验收: - 验收:
- 新提交代码中,新增类型 `SS` 前缀数量为 `0`。 - 新提交代码中,新增类型 `SS` 前缀数量为 `0`。
+52 -7
View File
@@ -2,7 +2,7 @@
## 1. 范围与目标 ## 1. 范围与目标
- 代码范围:`Sources/RDReaderView/EPUBCore/`(30 个 Swift 文件 + 2 个资源文件) - 代码范围:`Sources/RDReaderView/EPUBCore/`(31 个 Swift 文件 + 2 个资源文件)
- 目标:说明 EPUBCore 如何完成 EPUB 解析、资源定位、阅读会话管理、离屏分页、JS 桥接渲染和全文搜索。 - 目标:说明 EPUBCore 如何完成 EPUB 解析、资源定位、阅读会话管理、离屏分页、JS 桥接渲染和全文搜索。
- 主链路关键词:`epubURL -> RDEPUBParser.parse -> container.xml -> OPF -> spine/TOC -> RDEPUBPublication -> RDEPUBReadingSession -> RDEPUBWebView/Paginator -> 分页/渲染`。 - 主链路关键词:`epubURL -> RDEPUBParser.parse -> container.xml -> OPF -> spine/TOC -> RDEPUBPublication -> RDEPUBReadingSession -> RDEPUBWebView/Paginator -> 分页/渲染`。
@@ -40,7 +40,7 @@
### 2.4 WebView 渲染层 `RDEPUBWebView` ### 2.4 WebView 渲染层 `RDEPUBWebView`
- 文件:`EPUBCore/RDEPUBWebView.swift` + 5 个扩展文件 - 文件:`EPUBCore/RDEPUBWebView.swift` + 5 个扩展文件(+Configuration / +Reflowable / +FixedLayout / +JavaScriptBridge / +Search)
- 职责: - 职责:
- 内部持有 WKWebView,配置自定义 scheme handler 和 JS 桥接 - 内部持有 WKWebView,配置自定义 scheme handler 和 JS 桥接
- 可重排内容加载:`loadPage(parser:spineIndex:pageIndex:...)`(`+Reflowable.swift`) - 可重排内容加载:`loadPage(parser:spineIndex:pageIndex:...)`(`+Reflowable.swift`)
@@ -77,13 +77,13 @@
- `ssReaderFixedLayoutReady`:固定版式渲染完成 - `ssReaderFixedLayoutReady`:固定版式渲染完成
- JS 端 `window.RDReaderBridge` 暴露:`applyPagination`、`setPageMetrics`、`scrollToPage`、`scrollToLocation`、`setHighlights`、`clearHighlights`、`reportProgression`、`selectionPayload` - JS 端 `window.RDReaderBridge` 暴露:`applyPagination`、`setPageMetrics`、`scrollToPage`、`scrollToLocation`、`setHighlights`、`clearHighlights`、`reportProgression`、`selectionPayload`
### 2.8 搜索引擎 `RDEPUBHTMLSearchEngine` ### 2.8 搜索引擎 `RDEPUBSearchEngine` / `RDEPUBHTMLSearchEngine`
- 文件:`EPUBCore/RDEPUBSearchEngine.swift`、`EPUBCore/RDEPUBSearchModels.swift` - 文件:`EPUBCore/RDEPUBSearchEngine.swift`、`EPUBCore/RDEPUBSearchModels.swift`
- 职责: - 职责:
- 将 HTML 转为纯文本(NSAttributedString 或正则兜底) - `RDEPUBSearchEngine`:搜索协议定义
- 执行大小写不敏感的全文搜索 - `RDEPUBHTMLSearchEngine`:WebView 路径实现,将 HTML 转为纯文本(NSAttributedString 或正则兜底),执行大小写不敏感的全文搜索
- 返回 `RDEPUBSearchMatch` 数组(含 progression 偏移和预览文本) - 搜索模型:`RDEPUBSearchMatch`、`RDEPUBSearchResult`、`RDEPUBSearchState`、`RDEPUBSearchPresentation`
### 2.9 配置与样式 ### 2.9 配置与样式
@@ -92,6 +92,42 @@
- `RDEPUBStyleSheetBuilder`(`EPUBCore/RDEPUBStyleSheetBuilder.swift`):生成分页 CSS、渲染 CSS 和测量 JS 脚本 - `RDEPUBStyleSheetBuilder`(`EPUBCore/RDEPUBStyleSheetBuilder.swift`):生成分页 CSS、渲染 CSS 和测量 JS 脚本
- `RDEPUBFixedLayoutTemplate`(`EPUBCore/RDEPUBFixedLayoutTemplate.swift`):生成固定版式 HTML 模板 - `RDEPUBFixedLayoutTemplate`(`EPUBCore/RDEPUBFixedLayoutTemplate.swift`):生成固定版式 HTML 模板
### 2.10 文本锚点 `RDEPUBTextAnchor` / `RDEPUBTextRangeAnchor`
- 文件:`EPUBCore/RDEPUBTextAnchor.swift`
- 职责:
- `RDEPUBTextAnchor`:精确定位到 EPUB 中的某个字符位置,包含 fileIndex(spine 索引)、row(行号)、column(列号)、chapterOffset(章节内字符偏移)、fragmentID(最近的 fragment)
- `RDEPUBTextRangeAnchor`:由起止锚点组成的文本区间,可转换为 NSRange
- 支持 Codable 序列化,兼容 `fileIndex` 和 `spineIndex` 两种 key
- 用于文本 EPUB 的高亮精确锚定和跨会话恢复
### 2.11 渲染请求模型 `RDEPUBRenderRequest`
- 文件:`EPUBCore/RDEPUBRenderRequest.swift`
- 职责:
- `RDEPUBFixedLayoutFit`:固定版式适配模式枚举(`.auto` / `.page` / `.width`)
- `RDEPUBFixedLayoutSpreadMode`:Spread 显示模式枚举(`.automatic` / `.always` / `.never`)
- `RDEPUBPresentationStyle`:WebView 和 Paginator 共用的视觉参数(viewportSize、contentInsets、fontSize、lineHeightMultiple、主题色)
- `RDEPUBReflowableRenderRequest`:可重排内容渲染请求(spineIndex、href、pageIndex、presentation、highlights、searchPresentation)
- `RDEPUBFixedRenderRequest`:固定版式渲染请求(spread、viewportSize、fit、searchPresentation)
- `RDEPUBRenderRequest`:统一枚举(`.reflowable` / `.fixed`),WebView 根据此类型选择渲染路径
### 2.12 调试工具 `RDEPUBWebViewDebug`
- 文件:`EPUBCore/RDEPUBWebViewDebug.swift`
- 职责:
- WebView 调试日志工具集,DEBUG 模式默认开启
- 支持导航事件、JS 执行、消息接收、URL Scheme 任务的日志记录
- 可通过 UserDefaults `"RDEPUBWebViewDebugEnabled"` 覆盖开关
### 2.13 资源加载器 `RDEPUBAssetRepository`
- 文件:`EPUBCore/RDEPUBAssetRepository.swift`
- 职责:
- 从资源包加载 JS 桥接脚本(`epub-bridge.js`)和固定版式 HTML 模板(`epub-fixed-layout.html`)
- 支持 `{{token}}` 模板变量替换
- 自动定位 `RDReaderViewAssets.bundle`(优先已解析子 bundle,兜底宿主 bundle)
## 3. 主流程(代码级) ## 3. 主流程(代码级)
### 3.1 EPUB 解析全流程 ### 3.1 EPUB 解析全流程
@@ -237,6 +273,7 @@ RDEPUBLocation
├── progression: Double? (0..1,章节内起始位置) ├── progression: Double? (0..1,章节内起始位置)
├── lastProgression: Double? (0..1,章节内结束位置) ├── lastProgression: Double? (0..1,章节内结束位置)
├── fragment: String? (#anchor) ├── fragment: String? (#anchor)
├── rangeAnchor: RDEPUBTextRangeAnchor? (文本范围锚点,用于高亮精确定位)
└── navigationProgression (computed: midpoint of progression and lastProgression) └── navigationProgression (computed: midpoint of progression and lastProgression)
``` ```
@@ -279,7 +316,8 @@ RDEPUBRenderRequest (enum)
RDEPUBHighlight RDEPUBHighlight
├── id, bookIdentifier ├── id, bookIdentifier
├── location: RDEPUBLocation ├── location: RDEPUBLocation
├── text, rangeInfo (JSON-serialized DOM range) ├── text, rangeInfo (JSON-serialized DOM range 或 text-offset)
├── style: RDEPUBHighlightStyle (.highlight | .underline)
├── color: String, note: String ├── color: String, note: String
└── createdAt: Date └── createdAt: Date
@@ -288,6 +326,13 @@ RDEPUBSelection
├── location: RDEPUBLocation ├── location: RDEPUBLocation
├── text, rangeInfo ├── text, rangeInfo
└── createdAt: Date └── createdAt: Date
RDEPUBBookmark
├── id, bookIdentifier
├── location: RDEPUBLocation
├── title: String?
├── note: String?
└── createdAt: Date
``` ```
### 5.6 搜索模型 ### 5.6 搜索模型
+33 -2
View File
@@ -2,7 +2,7 @@
## 1. 范围与目标 ## 1. 范围与目标
- 代码范围:`Sources/RDReaderView/EPUBTextRendering/`(9 个 Swift 文件) - 代码范围:`Sources/RDReaderView/EPUBTextRendering/`(13 个 Swift 文件)
- 目标:说明文本渲染路径如何将 EPUB HTML 转换为 NSAttributedString、按字符范围分页、构建书籍模型,并支持全文搜索与 textReflowable 标注定位。 - 目标:说明文本渲染路径如何将 EPUB HTML 转换为 NSAttributedString、按字符范围分页、构建书籍模型,并支持全文搜索与 textReflowable 标注定位。
- 主链路关键词:`RDEPUBParser HTML -> DTCoreText 渲染 -> 片段标记注入/提取 -> CoreText 分页 -> RDEPUBTextBook -> 页面查找/位置转换`。 - 主链路关键词:`RDEPUBParser HTML -> DTCoreText 渲染 -> 片段标记注入/提取 -> CoreText 分页 -> RDEPUBTextBook -> 页面查找/位置转换`。
- 适用范围:仅用于 `textReflowable` 阅读配置文件(纯文本可重排 EPUB,如小说)。固定版式和交互式 EPUB 使用 WebView 渲染路径。 - 适用范围:仅用于 `textReflowable` 阅读配置文件(纯文本可重排 EPUB,如小说)。固定版式和交互式 EPUB 使用 WebView 渲染路径。
@@ -64,6 +64,14 @@
- 在已渲染的 NSAttributedString 纯文本上执行线性搜索 - 在已渲染的 NSAttributedString 纯文本上执行线性搜索
- 返回 `RDEPUBSearchMatch` 数组(含 href、progression、预览文本、匹配位置) - 返回 `RDEPUBSearchMatch` 数组(含 href、progression、预览文本、匹配位置)
### 2.6.1 章节数据模型 `RDEPUBChapterData`
- 文件:`EPUBTextRendering/RDEPUBChapterData.swift`
- 职责:
- 每章节的数据聚合模型,管理高亮、搜索结果和页面查询
- 支持按页码范围过滤当前页的高亮列表
- 支持搜索结果在章节内的定位和匹配索引计算
### 2.7 CoreText 分页引擎 `RDEPUBTextLayouter` / `RDEPUBTextLayoutFrame` ### 2.7 CoreText 分页引擎 `RDEPUBTextLayouter` / `RDEPUBTextLayoutFrame`
- 文件:`EPUBTextRendering/RDEPUBTextLayouter.swift`、`EPUBTextRendering/RDEPUBTextLayoutFrame.swift` - 文件:`EPUBTextRendering/RDEPUBTextLayouter.swift`、`EPUBTextRendering/RDEPUBTextLayoutFrame.swift`
@@ -80,6 +88,28 @@
- 将纯文本按段落切分后渲染为 NSAttributedString - 将纯文本按段落切分后渲染为 NSAttributedString
- 按页面尺寸分页,输出与 EPUB 文本路径一致的书籍模型 - 按页面尺寸分页,输出与 EPUB 文本路径一致的书籍模型
### 2.9 文本索引表 `RDEPUBTextIndexTable`
- 文件:`EPUBTextRendering/RDEPUBTextIndexTable.swift`
- 职责:
- 构建全书的文本结构映射:章节起始偏移、href 与章节/spine 索引对应、fragment 偏移映射、行列索引映射
- `RDEPUBRowColumnIndex`:行-列索引条目,记录每行的字符范围
- 支持锚点与位置之间的双向转换:
- `anchor(forAbsoluteIndex:in:)`:绝对索引 → 文本锚点
- `anchor(for:)`:阅读位置 → 文本锚点(优先 rangeAnchor,其次 fragment/progression)
- `absoluteIndex(for:)`:锚点 → 全书绝对索引
- `location(for:in:bookIdentifier:)`:锚点/范围锚点 → 阅读位置
- 支持页码查找:`pageNumber(for:in:)` 通过锚点定位到对应页面
### 2.10 性能采样器 `RDEPUBTextPerformanceSampler`
- 文件:`EPUBTextRendering/RDEPUBTextPerformanceSampler.swift`
- 职责:
- `RDEPUBTextPerformanceSample`:单章节性能数据(chapterHref、renderDuration、paginateDuration、pageCount、attributedStringLength、cacheHit)
- `RDEPUBTextPerformanceSampler`:书籍构建过程的性能采样器
- 由 `RDEPUBTextBookBuilder` 在构建过程中使用,每章记录一个采样点
- `summary()` 输出汇总报告:总渲染/分页耗时和缓存命中率
## 3. 主流程(代码级) ## 3. 主流程(代码级)
### 3.1 完整渲染-分页流程 ### 3.1 完整渲染-分页流程
@@ -295,7 +325,8 @@ RDEPUBTextBook
- **内存**:`RDEPUBTextBook` 同时持有 chapters(含完整 attributedContent)和 pages(含子串切片),存在一定程度的内存重复 - **内存**:`RDEPUBTextBook` 同时持有 chapters(含完整 attributedContent)和 pages(含子串切片),存在一定程度的内存重复
- **后台执行**:`build` 方法在 `DispatchQueue.global(qos: .userInitiated)` 执行,不阻塞主线程 - **后台执行**:`build` 方法在 `DispatchQueue.global(qos: .userInitiated)` 执行,不阻塞主线程
- **搜索性能**:线性扫描每章的 `attributedContent.string`,无索引,搜索时间与总文本量线性相关 - **搜索性能**:线性扫描每章的 `attributedContent.string`,无索引,搜索时间与总文本量线性相关
- **无分页缓存**:字号/行高变化时全书重新渲染和分页,无增量更新 - **分页缓存**:`RDEPUBTextBookCache` 支持磁盘缓存分页结果,字号/行高变化时优先从缓存恢复
- **性能采样**:`RDEPUBTextPerformanceSampler` 记录每章的渲染和分页耗时,支持缓存命中率统计
## 9. 联调与排查建议 ## 9. 联调与排查建议
+44 -31
View File
@@ -2,7 +2,7 @@
## 1. 范围与目标 ## 1. 范围与目标
- 代码范围:`Sources/RDReaderView/EPUBUI/`(16 个 Swift 文件) - 代码范围:`Sources/RDReaderView/EPUBUI/`(19 个 Swift 文件)
- 目标:说明 EPUBUI 如何作为开箱即用的阅读器 UI 层,协调 EPUBCore 解析、EPUBTextRendering 文本渲染、RDReaderView 分页容器,提供完整的阅读体验(工具栏、目录、高亮批注、设置面板、阅读位置持久化、搜索)。 - 目标:说明 EPUBUI 如何作为开箱即用的阅读器 UI 层,协调 EPUBCore 解析、EPUBTextRendering 文本渲染、RDReaderView 分页容器,提供完整的阅读体验(工具栏、目录、高亮批注、设置面板、阅读位置持久化、搜索)。
- 主链路关键词:`RDEPUBReaderController.init -> 解析 EPUB -> 分页 -> 渲染 -> 用户交互(翻页/工具栏/设置/高亮/搜索)-> 持久化`。 - 主链路关键词:`RDEPUBReaderController.init -> 解析 EPUB -> 分页 -> 渲染 -> 用户交互(翻页/工具栏/设置/高亮/搜索)-> 持久化`。
@@ -10,7 +10,7 @@
### 2.1 主控制器 `RDEPUBReaderController` ### 2.1 主控制器 `RDEPUBReaderController`
- 文件:`EPUBUI/RDEPUBReaderController.swift`(~1255 行) - 文件:`EPUBUI/RDEPUBReaderController.swift`(~1995 行)
- 入口方法: - 入口方法:
- `init(epubURL:configuration:persistence:)` — 标准 EPUB 阅读入口 - `init(epubURL:configuration:persistence:)` — 标准 EPUB 阅读入口
- `init(textBook:bookIdentifier:title:textFileURL:configuration:)` — 纯文本书籍阅读入口(由 `RDPlainTextBookBuilder` 构建 `RDEPUBTextBook` 后传入) - `init(textBook:bookIdentifier:title:textFileURL:configuration:)` — 纯文本书籍阅读入口(由 `RDPlainTextBookBuilder` 构建 `RDEPUBTextBook` 后传入)
@@ -29,7 +29,7 @@
- `fontSize`(默认 15)、`lineHeightMultiple`(默认 1.6) - `fontSize`(默认 15)、`lineHeightMultiple`(默认 1.6)
- `displayType`(默认 .pageCurl)、`landscapeDualPageEnabled`(默认 true) - `displayType`(默认 .pageCurl)、`landscapeDualPageEnabled`(默认 true)
- `showsTableOfContents`(默认 true)、`allowsHighlights`(默认 true)、`showsSettingsPanel`(默认 true) - `showsTableOfContents`(默认 true)、`allowsHighlights`(默认 true)、`showsSettingsPanel`(默认 true)
- `reflowableContentInsets`(默认 40/16/40/16)、`fixedContentInset`(默认 .zero) - `reflowableContentInsets`(默认 top:40 left:16 bottom:40 right:16)、`fixedContentInset`(默认 .zero)
- `theme`(默认 .light) - `theme`(默认 .light)
- `fixedLayoutFit`(默认 .page)、`fixedLayoutSpreadMode`(默认 .automatic) - `fixedLayoutFit`(默认 .page)、`fixedLayoutSpreadMode`(默认 .automatic)
- `textRenderingEngine`(默认 .dtCoreText) - `textRenderingEngine`(默认 .dtCoreText)
@@ -41,45 +41,47 @@
- 文件:`EPUBUI/RDEPUBReaderDelegate.swift` - 文件:`EPUBUI/RDEPUBReaderDelegate.swift`
- 12 个可选方法(全部有默认空实现): - 12 个可选方法(全部有默认空实现):
- `didOpen`:成功打开出版物 - `epubReader(_:didOpen:)`:成功打开出版物
- `didUpdateLocation`:翻页或滚动时位置更新 - `epubReader(_:didUpdateLocation:)`:翻页或滚动时位置更新
- `didReachEnd`:到达最后一页 - `epubReaderDidReachEnd(_:)`:到达最后一页
- `didChangeSelection`:文本选择变化或清除 - `epubReader(_:didChangeSelection:)`:文本选择变化或清除
- `didUpdateHighlights`:高亮增删改 - `epubReader(_:didUpdateHighlights:)`:高亮增删改
- `didUpdateBookmarks`:书签增删改 - `epubReader(_:didUpdateBookmarks:)`:书签增删改
- `didUpdateSearchResult`:搜索状态变化 - `epubReader(_:didUpdateSearchResult:)`:搜索状态变化
- `didChangeCurrentSearchMatch`:当前搜索匹配项变化 - `epubReader(_:didChangeCurrentSearchMatch:)`:当前搜索匹配项变化
- `didUpdateCurrentTableOfContentsItem`:翻页时匹配的目录项 - `epubReader(_:didUpdateCurrentTableOfContentsItem:)`:翻页时匹配的目录项
- `didActivateExternalLink`:外部链接点击 - `epubReader(_:didActivateExternalLink:)`:外部链接点击
- `didFailWithError`:解析或分页错误 - `epubReader(_:didFailWithError:)`:解析或分页错误
- `configureTopToolView`:自定义顶部工具栏 - `epubReader(_:configureTopToolView:)`:自定义顶部工具栏
### 2.4 持久化 `RDEPUBReaderPersistence` ### 2.4 持久化 `RDEPUBReaderPersistence`
- 文件:`EPUBUI/RDEPUBReaderPersistence.swift` - 文件:`EPUBUI/RDEPUBReaderPersistence.swift`
- 协议定义 6 个方法:loadLocation / saveLocation / loadHighlights / saveHighlights / loadReaderSettings / saveReaderSettings - 协议定义 8 个方法:loadLocation / saveLocation / loadHighlights / saveHighlights / loadBookmarks / saveBookmarks / loadReaderSettings / saveReaderSettings
- 默认实现 `RDEPUBUserDefaultsPersistence`: - 默认实现 `RDEPUBUserDefaultsPersistence`:
- 位置:`ssreader.epub.location.<bookIdentifier>`(JSON 编码) - 位置:`ssreader.epub.location.<bookIdentifier>`(JSON 编码)
- 高亮:`ssreader.epub.highlights.<bookIdentifier>`(JSON 编码) - 高亮:`ssreader.epub.highlights.<bookIdentifier>`(JSON 编码)
- 书签:`ssreader.epub.bookmarks.<bookIdentifier>`(JSON 编码)
- 设置:`ssreader.epub.settings`(全局,非按书) - 设置:`ssreader.epub.settings`(全局,非按书)
### 2.5 主题 `RDEPUBReaderTheme` ### 2.5 主题 `RDEPUBReaderTheme`
- 文件:`EPUBUI/RDEPUBReaderTheme.swift` - 文件:`EPUBUI/RDEPUBReaderTheme.swift`(~125 行)
- 6 个颜色属性:contentBackgroundColor、contentTextColor、toolBackgroundColor、toolControlTextColor、toolControlBorderUnselectColor、toolLineColor - 6 个颜色属性:contentBackgroundColor、contentTextColor、toolBackgroundColor、toolControlTextColor、toolControlBorderUnselectColor、toolLineColor
- 6 个内置预设:`.light`、`.dark`、`.yellow`、`.green`、`.pink`、`.blue` - 6 个内置预设:`.light`、`.dark`、`.yellow`、`.green`、`.pink`、`.blue`
- `RDEPUBReaderThemePreset` 枚举将预设映射为可序列化值,用于持久化
- 计算属性 `themeBackgroundColorCSS` / `themeTextColorCSS` 用于 Web 渲染路径 - 计算属性 `themeBackgroundColorCSS` / `themeTextColorCSS` 用于 Web 渲染路径
### 2.6 设置面板 `RDEPUBReaderSettingsViewController` ### 2.6 设置面板 `RDEPUBReaderSettingsViewController`
- 文件:`EPUBUI/RDEPUBReaderSettingsViewController.swift` - 文件:`EPUBUI/RDEPUBReaderSettingsViewController.swift`(~311 行)
- 5 个控制项: - 5 个控制项:
- 亮度滑块(0-1) - 亮度滑块(0-1)
- 字号 A-/A+(范围 12-36,步长 1) - 字号 A-/A+(范围 12-36,步长 1)
- 行高分段(紧凑 1.3 / 标准 1.6 / 宽松 1.9) - 行高分段(紧凑 1.3 / 标准 1.6 / 宽松 1.9)
- 显示模式分段(仿真 / 横滑 / 竖滑 / 覆盖) - 显示模式分段(仿真 / 横滑 / 竖滑 / 覆盖)
- 主题选择(6 个圆形色块按钮) - 主题选择(6 个圆形色块按钮)
- 所有变更通过闭包实时回调 - 所有变更通过闭包实时回调:onBrightnessChange / onFontSizeChange / onLineHeightChange / onDisplayTypeChange / onThemeChange
### 2.7 内容视图 ### 2.7 内容视图
@@ -88,10 +90,17 @@
### 2.8 其他 UI 组件 ### 2.8 其他 UI 组件
- `RDEPUBReaderTopToolView`:顶部导航栏(返回按钮 + 标题) - `RDEPUBReaderToolView`(`EPUBUI/RDEPUBReaderToolView.swift`):工具栏基类,提供分隔线和主题适配的通用逻辑
- `RDEPUBReaderBottomToolView`:底部工具栏(目录 / 批注 / 标注 / 设置 4 个按钮) - `RDEPUBReaderTopToolView`:顶部导航栏(返回按钮 + 书签切换 + 标题)
- `RDEPUBReaderBottomToolView`:底部工具栏(目录 / 书签 / 标注 / 设置 4 个按钮)
- `RDEPUBReaderChapterListController`:目录列表(modal UITableViewController,当前项高亮 systemBlue) - `RDEPUBReaderChapterListController`:目录列表(modal UITableViewController,当前项高亮 systemBlue)
- `RDEPUBReaderHighlightsViewController`:标注管理(全部 / 批注 / 划线过滤,列表 + 编辑备注 + 删除 + 跳转,空态按过滤条件提示) - `RDEPUBReaderHighlightsViewController`:标注管理(全部 / 批注 / 划线过滤,列表 + 编辑备注 + 删除 + 跳转,空态按过滤条件提示)
- `RDEPUBReaderSettings`(`EPUBUI/RDEPUBReaderSettings.swift`):可持久化用户设置模型(brightness / fontSize / lineHeightMultiple / displayMode / themePreset),含 `RDEPUBReaderDisplayMode` 和 `RDEPUBReaderThemePreset` 枚举
- `RDEPUBReaderTableOfContentsItem`(`EPUBUI/RDEPUBReaderTableOfContentsItem.swift`):展平后的目录条目(title / href / depth / pageNumber)
- `RDEPUBPageInteractionController`(`EPUBUI/RDEPUBPageInteractionController.swift`):CoreText 页面级交互控制器,处理文本选区和高亮装饰
- `RDEPUBSelectionOverlayView`(`EPUBUI/RDEPUBSelectionOverlayView.swift`):选区覆盖视图,显示选中文本的高亮装饰
- `RDEPUBPageLayoutSnapshot`(`EPUBUI/RDEPUBPageLayoutSnapshot.swift`):页面几何模型,存储 CoreText 渲染的页面布局信息
- `RDURLReaderController`(`EPUBUI/RDURLReaderController.swift`,~221 行):最简 URL 入口,传入 URL 即可打开书籍(.epub → RDEPUBReaderController,其他 → RDPlainTextBookBuilder 构建后传入),分页失败时回退到纯 UITextView 展示
## 3. 主流程(代码级) ## 3. 主流程(代码级)
@@ -229,22 +238,26 @@
| 书签列表 | `ssreader.epub.bookmarks.<bookIdentifier>` | JSON → `[RDEPUBBookmark]` | | 书签列表 | `ssreader.epub.bookmarks.<bookIdentifier>` | JSON → `[RDEPUBBookmark]` |
| 阅读设置 | `ssreader.epub.settings` | JSON → `RDEPUBReaderSettings` | | 阅读设置 | `ssreader.epub.settings` | JSON → `RDEPUBReaderSettings` |
### 5.2 Book Identifier ### 5.2 设置模型
- 优先使用 `parser.metadata.identifier`
- 回退使用 `epubURL.lastPathComponent`
### 5.3 设置快照
```swift ```swift
RDEPUBReaderSettings (Codable) RDEPUBReaderSettings (Codable)
├── brightness: CGFloat ├── brightness: CGFloat?
├── fontSize: CGFloat ├── fontSize: CGFloat?
├── lineHeightMultiple: CGFloat ├── lineHeightMultiple: CGFloat?
├── displayMode: RDEPUBReaderDisplayMode ├── displayMode: RDEPUBReaderDisplayMode?
└── themePreset: RDEPUBReaderThemePreset? └── themePreset: RDEPUBReaderThemePreset?
``` ```
`RDEPUBReaderDisplayMode`:可序列化的翻页模式枚举(pageCurl / horizontalScroll / verticalScroll / horizontalCoverScroll),与 `RDReaderView.DisplayType` 相互转换。
`RDEPUBReaderThemePreset`:可序列化的主题预设枚举(light / yellow / green / pink / blue / dark),与 `RDEPUBReaderTheme` 相互转换。
### 5.3 Book Identifier
- 优先使用 `parser.metadata.identifier`
- 回退使用 `epubURL.lastPathComponent`
### 5.4 目录扁平化项 ### 5.4 目录扁平化项
```swift ```swift
+37 -9
View File
@@ -15,20 +15,27 @@
| 文件 | 职责 | | 文件 | 职责 |
|------|------| |------|------|
| `RDEPUBParser.swift` + 扩展 | EPUB 解压、container.xml、OPF、manifest、spine、TOC/NCX 解析 | | `RDEPUBParser.swift` + 5 扩展 | EPUB 解压、container.xml、OPF、manifest、spine、TOC/NCX 解析、阅读配置文件判断 |
| `RDEPUBPublication.swift` | 解析结果的唯一聚合入口,外部不直接访问 parser 字段 | | `RDEPUBPublication.swift` | 解析结果的唯一聚合入口,外部不直接访问 parser 字段 |
| `RDEPUBModels.swift` | metadata / manifest / spine / TOC 基础模型 | | `RDEPUBModels.swift` | metadata / manifest / spine / TOC 基础模型 |
| `RDEPUBReadingModels.swift` | RDEPUBLocation、RDEPUBViewport、RDEPUBHighlight、选区模型 | | `RDEPUBReadingModels.swift` | RDEPUBLocation、RDEPUBViewport、RDEPUBHighlight、RDEPUBBookmark、选区模型 |
| `RDEPUBResourceResolver.swift` | href → fileURL / schemeURL 的统一入口,`normalizedHref` 标准化路径 | | `RDEPUBResourceResolver.swift` | href → fileURL / schemeURL 的统一入口,`normalizedHref` 标准化路径 |
| `RDEPUBPaginator.swift` | 离屏 WKWebView 分页,计算每个 spine 资源被视口切成多少页 | | `RDEPUBPaginator.swift` | 离屏 WKWebView 分页,计算每个 spine 资源被视口切成多少页 |
| `RDEPUBReadingSession.swift` | 状态机 + 会话协调,管理 staged/active/pending 页模型 | | `RDEPUBReadingSession.swift` | 状态机 + 会话协调,管理 staged/active/pending 页模型 |
| `RDEPUBNavigatorState.swift` | 状态枚举(initializing/loading/idle/jumping/moving/repaginating) | | `RDEPUBNavigatorState.swift` | 状态枚举(initializing/loading/idle/jumping/moving/repaginating) |
| `RDEPUBWebView.swift` + 扩展 | 承载 spine 资源的 WKWebView,分页 CSS 注入、JS bridge、fixed wrapper | | `RDEPUBWebView.swift` + 5 扩展 | 承载 spine 资源的 WKWebView,分页 CSS 注入、JS bridge、fixed wrapper、搜索装饰 |
| `RDEPUBResourceURLSchemeHandler.swift` | `ss-reader://` 协议,从本地解压目录提供所有 EPUB 资源 | | `RDEPUBResourceURLSchemeHandler.swift` | `ss-reader://` 协议,从本地解压目录提供所有 EPUB 资源 |
| `RDEPUBStyleSheetBuilder.swift` | 生成注入 WebView 的 CSS | | `RDEPUBStyleSheetBuilder.swift` | 生成注入 WebView 的 CSS |
| `RDEPUBJavaScriptBridge.swift` | JS ↔ Swift 消息定义和编解码 | | `RDEPUBJavaScriptBridge.swift` | JS ↔ Swift 消息定义和编解码 |
| `RDEPUBSearchEngine.swift` | 搜索协议定义 + WebView 全文搜索引擎 | | `RDEPUBSearchEngine.swift` | 搜索协议定义 + WebView 全文搜索引擎 |
| `RDEPUBSearchModels.swift` | 搜索模型(SearchMatch/Result/State/Presentation) | | `RDEPUBSearchModels.swift` | 搜索模型(SearchMatch/Result/State/Presentation) |
| `RDEPUBTextAnchor.swift` | 文本锚点和范围锚点(精确定位字符位置) |
| `RDEPUBRenderRequest.swift` | 渲染请求模型、展示样式、固定版式适配/Spread 枚举 |
| `RDEPUBWebViewDebug.swift` | WebView 调试日志工具(导航/JS/消息/Scheme) |
| `RDEPUBAssetRepository.swift` | 静态资源加载器(JS 脚本、HTML 模板、模板变量替换) |
| `RDEPUBPreferences.swift` | 用户阅读偏好聚合 |
| `RDEPUBNavigatorLayoutContext.swift` | 容器布局上下文 |
| `RDEPUBFixedLayoutTemplate.swift` | 固定版式 HTML 模板生成 |
### 2.2 EPUBTextRendering 层 ### 2.2 EPUBTextRendering 层
@@ -39,17 +46,38 @@
| `RDEPUBTextRendererSupport.swift` | fragment 标记注入、fragment offset 提取、属性标准化 | | `RDEPUBTextRendererSupport.swift` | fragment 标记注入、fragment offset 提取、属性标准化 |
| `RDEPUBTextPaginationSupport.swift` | NSAttributedString 切页算法 | | `RDEPUBTextPaginationSupport.swift` | NSAttributedString 切页算法 |
| `RDEPUBTextBookBuilder.swift` | 驱动逐章节渲染和分页,生成 RDEPUBTextBook | | `RDEPUBTextBookBuilder.swift` | 驱动逐章节渲染和分页,生成 RDEPUBTextBook |
| `RDEPUBTextLayouter.swift` | CoreText 分页引擎(~810 行,含 4 级语义边界调整) |
| `RDEPUBTextLayoutFrame.swift` | 单帧分页结果模型(contentRange、breakReason、语义提示) |
| `RDEPUBTextBookCache.swift` | 分页结果磁盘缓存 |
| `RDEPUBChapterData.swift` | 章节数据聚合模型(高亮、搜索结果、页面查询) |
| `RDEPUBTextSearchEngine.swift` | 纯文本全文搜索引擎 |
| `RDPlainTextBookBuilder.swift` | 纯文本(.txt)书籍构建器 |
| `RDEPUBTextIndexTable.swift` | 全书文本索引表(章节偏移、行列映射、锚点转换) |
| `RDEPUBTextPerformanceSampler.swift` | 性能采样器(渲染/分页耗时、缓存命中率) |
### 2.3 EPUBUI 层 ### 2.3 EPUBUI 层
| 文件 | 职责 | | 文件 | 职责 |
|------|------| |------|------|
| `RDEPUBReaderController.swift` | 开箱即用读者控制器,整合 session / readerView / UI | | `RDEPUBReaderController.swift` | 开箱即用读者控制器(~1995 行),整合 session / readerView / UI |
| `RDEPUBReaderConfiguration.swift` | 外部配置项(字号、主题、翻页模式等) | | `RDEPUBReaderConfiguration.swift` | 外部配置项(13 个:字号、主题、翻页模式等) |
| `RDEPUBReaderPersistence.swift` | 阅读位置和高亮的本地持久化 | | `RDEPUBReaderPersistence.swift` | 阅读位置、高亮、书签的本地持久化(8 个方法) |
| `RDEPUBReaderSettings.swift` | 运行时设置状态 | | `RDEPUBReaderSettings.swift` | 可持久化用户设置模型 + DisplayMode/ThemePreset 枚举 |
| `RDEPUBReaderTheme.swift` | 6 个内置主题预设 + 颜色属性 |
| `RDEPUBReaderDelegate.swift` | 12 个可选委托方法 |
| `RDEPUBReaderToolView.swift` | 工具栏基类(分隔线 + 主题适配) |
| `RDEPUBReaderTopToolView.swift` | 顶部导航栏(返回 + 书签 + 标题) |
| `RDEPUBReaderBottomToolView.swift` | 底部工具栏(目录/书签/标注/设置) |
| `RDEPUBReaderChapterListController.swift` | 目录列表面板 |
| `RDEPUBReaderHighlightsViewController.swift` | 标注管理面板(过滤、编辑、删除、跳转) |
| `RDEPUBReaderSettingsViewController.swift` | 设置面板(亮度、字号、行高、模式、主题) |
| `RDEPUBReaderTableOfContentsItem.swift` | 展平目录条目模型 |
| `RDEPUBWebContentView.swift` | WKWebView 内容视图,供 RDReaderView 渲染 WebView 类 spine | | `RDEPUBWebContentView.swift` | WKWebView 内容视图,供 RDReaderView 渲染 WebView 类 spine |
| `RDEPUBTextContentView.swift` | 文本内容视图,供 RDReaderView 渲染文本类 spine | | `RDEPUBTextContentView.swift` | 文本内容视图(~728 行),供 RDReaderView 渲染文本类 spine |
| `RDEPUBPageInteractionController.swift` | CoreText 页面级交互控制器 |
| `RDEPUBSelectionOverlayView.swift` | 选区覆盖视图 |
| `RDEPUBPageLayoutSnapshot.swift` | 页面几何模型 |
| `RDURLReaderController.swift` | 最简 URL 入口(.epub/.txt 自动识别) |
--- ---
@@ -289,7 +317,7 @@ webView.loadHTMLString(htmlString, baseURL: nil)
## 7. 后续扩展建议 ## 7. 后续扩展建议
1. **拆分 demo 层 RDReaderController**:约 1900+ 行,EPUB 加载、UI、数据源职责仍混在一起,建议继续向 library 层迁移 1. **拆分 RDEPUBReaderController**:约 1995 行,EPUB 加载、UI、数据源职责仍混在一起,建议继续向 library 层迁移
2. **基线验证**:用四本样书跑一轮分页耗时、目录命中率、末页事件、设置恢复稳定性数据 2. **基线验证**:用四本样书跑一轮分页耗时、目录命中率、末页事件、设置恢复稳定性数据
3. **横竖屏支持回归矩阵**:控制器级 `viewWillTransition()` 与 viewport 去重已接入,下一步重点是补齐 pageCurl / scroll、三条正文路径和 iPad 视口变化场景的固定回归 3. **横竖屏支持回归矩阵**:控制器级 `viewWillTransition()` 与 viewport 去重已接入,下一步重点是补齐 pageCurl / scroll、三条正文路径和 iPad 视口变化场景的固定回归
4. **Pod 分层**:评估是否将 EPUBCore / EPUBTextRendering / EPUBUI 拆为独立 subspec 4. **Pod 分层**:评估是否将 EPUBCore / EPUBTextRendering / EPUBUI 拆为独立 subspec
+6 -16
View File
@@ -2,7 +2,7 @@
## 1. 范围与目标 ## 1. 范围与目标
- 代码范围:`Sources/RDReaderView/` 根目录(6 个 Swift 文件) - 代码范围:`Sources/RDReaderView/ReaderView/`(5 个 Swift 文件)
- 目标:说明分页阅读器容器如何管理四种显示模式、DataSource/Delegate 协议、翻页交互、工具栏动画、双屏适配和 RTL 支持。 - 目标:说明分页阅读器容器如何管理四种显示模式、DataSource/Delegate 协议、翻页交互、工具栏动画、双屏适配和 RTL 支持。
- 主链路关键词:`RDReaderDataSource -> reloadData -> DisplayType 切换 -> 翻页/滚动 -> RDReaderDelegate.pageNum -> 工具栏显隐`。 - 主链路关键词:`RDReaderDataSource -> reloadData -> DisplayType 切换 -> 翻页/滚动 -> RDReaderDelegate.pageNum -> 工具栏显隐`。
@@ -10,7 +10,7 @@
### 2.1 核心容器 `RDReaderView` ### 2.1 核心容器 `RDReaderView`
- 文件:`Sources/RDReaderView/RDReaderView.swift`(~717 行) - 文件:`Sources/RDReaderView/ReaderView/RDReaderView.swift`(~1219 行)
- 入口方法:`reloadData()` - 入口方法:`reloadData()`
- 职责: - 职责:
- 管理四种显示模式的视图层级切换 - 管理四种显示模式的视图层级切换
@@ -23,7 +23,7 @@
### 2.2 自定义布局 `RDReaderFlowLayout` ### 2.2 自定义布局 `RDReaderFlowLayout`
- 文件:`Sources/RDReaderView/RDReaderFlowLayout.swift`(~387 行) - 文件:`Sources/RDReaderView/ReaderView/RDReaderFlowLayout.swift`(~375 行)
- 职责: - 职责:
- 继承 `UICollectionViewFlowLayout`,为三种滚动模式提供布局计算 - 继承 `UICollectionViewFlowLayout`,为三种滚动模式提供布局计算
- 水平滚动:全屏宽 item,pagingEnabled - 水平滚动:全屏宽 item,pagingEnabled
@@ -33,7 +33,7 @@
### 2.3 内容 Cell `RDReaderContentCell` ### 2.3 内容 Cell `RDReaderContentCell`
- 文件:`Sources/RDReaderView/RDReaderContentCell.swift`(~37 行) - 文件:`Sources/RDReaderView/ReaderView/RDReaderContentCell.swift`(~55 行)
- 职责: - 职责:
- `UICollectionViewCell` 子类,作为内容视图的薄壳宿主 - `UICollectionViewCell` 子类,作为内容视图的薄壳宿主
- `containerView` 属性 setter 自动移除旧视图、添加新视图 - `containerView` 属性 setter 自动移除旧视图、添加新视图
@@ -41,7 +41,7 @@
### 2.4 页面子控制器 `RDReaderPageChildViewController` ### 2.4 页面子控制器 `RDReaderPageChildViewController`
- 文件:`Sources/RDReaderView/RDReaderPageChildViewController.swift`(~65 行) - 文件:`Sources/RDReaderView/ReaderView/RDReaderPageChildViewController.swift`(~89 行)
- 职责: - 职责:
- 仅用于 pageCurl 模式,作为 `UIPageViewController` 的子控制器 - 仅用于 pageCurl 模式,作为 `UIPageViewController` 的子控制器
- 持有 `contentView: UIView?` 和 `pageNum: Int` - 持有 `contentView: UIView?` 和 `pageNum: Int`
@@ -49,21 +49,11 @@
### 2.5 手势控制器 `RDReaderGestureController` ### 2.5 手势控制器 `RDReaderGestureController`
- 文件:`Sources/RDReaderView/RDReaderGestureController.swift`(~42 行) - 文件:`Sources/RDReaderView/ReaderView/RDReaderGestureController.swift`(~58 行)
- 职责: - 职责:
- 当前为占位组件,存储 topToolView / bottomToolView 引用 - 当前为占位组件,存储 topToolView / bottomToolView 引用
- 实际手势逻辑在 `RDReaderView.tapCenter()` 中实现 - 实际手势逻辑在 `RDReaderView.tapCenter()` 中实现
### 2.6 URL 入口 `RDURLReaderController`
- 文件:`Sources/RDReaderView/RDURLReaderController.swift`(~105 行)
- 职责:
- 最简入口:传入 URL 即可打开书籍
- `.epub` 扩展名 → 创建 `RDEPUBReaderController(epubURL:configuration:)`
- 其他扩展名 → 使用 `RDPlainTextBookBuilder` 构建 `RDEPUBTextBook`,然后创建 `RDEPUBReaderController(textBook:bookIdentifier:title:textFileURL:configuration:)`
- 分页失败时回退到纯 `UITextView` 展示(尝试 UTF-8 → GBK → GB2312 解码)
- 导航栏标题设为文件名(去掉扩展名)
## 3. 主流程(代码级) ## 3. 主流程(代码级)
### 3.1 协议定义 ### 3.1 协议定义
+4 -4
View File
@@ -12,10 +12,10 @@
| 文档 | 对应模块 | 说明 | | 文档 | 对应模块 | 说明 |
|------|----------|------| |------|----------|------|
| [EPUBCore_功能实现逻辑.md](EPUBCore_功能实现逻辑.md) | `Sources/RDReaderView/EPUBCore/`(30 文件) | EPUB 解析全流程(ZIP→container.xml→OPF→spine/TOC)、阅读会话状态机、离屏分页测量、`ss-reader://` 资源协议、JS 桥接(6 种消息)、WebView 渲染管线、缓存策略 | | [EPUBCore_功能实现逻辑.md](EPUBCore_功能实现逻辑.md) | `Sources/RDReaderView/EPUBCore/`(31 Swift + 2 资源) | EPUB 解析全流程(ZIP→container.xml→OPF→spine/TOC)、阅读会话状态机、离屏分页测量、`ss-reader://` 资源协议、JS 桥接(6 种消息)、WebView 渲染管线、文本锚点定位、渲染请求模型、缓存策略 |
| [EPUBTextRendering_功能实现逻辑.md](EPUBTextRendering_功能实现逻辑.md) | `Sources/RDReaderView/EPUBTextRendering/`(9 文件) | DTCoreText HTML→NSAttributedString 渲染管线、片段标记注入/提取、CoreText 分页引擎(含语义边界调整)、Location↔PageNumber 双向转换、全文搜索引擎、纯文本构建器 | | [EPUBTextRendering_功能实现逻辑.md](EPUBTextRendering_功能实现逻辑.md) | `Sources/RDReaderView/EPUBTextRendering/`(13 文件) | DTCoreText HTML→NSAttributedString 渲染管线、片段标记注入/提取、CoreText 分页引擎(含语义边界调整)、文本索引表、Location↔PageNumber 双向转换、分页缓存、性能采样、全文搜索引擎、纯文本构建器 |
| [RDReaderView_功能实现逻辑.md](RDReaderView_功能实现逻辑.md) | `Sources/RDReaderView/` 根目录(6 文件) | 四种显示模式(pageCurl/horizontalScroll/verticalScroll/horizontalCoverScroll)、DataSource/Delegate 协议、点击三区域翻页、工具栏动画、双页配对与哨兵页、横竖屏适配、RTL 支持 | | [RDReaderView_功能实现逻辑.md](RDReaderView_功能实现逻辑.md) | `Sources/RDReaderView/ReaderView/`(5 文件) | 四种显示模式(pageCurl/horizontalScroll/verticalScroll/horizontalCoverScroll)、DataSource/Delegate 协议、点击三区域翻页、工具栏动画、双页配对与哨兵页、横竖屏适配、RTL 支持 |
| [EPUBUI_功能实现逻辑.md](EPUBUI_功能实现逻辑.md) | `Sources/RDReaderView/EPUBUI/`(16 文件) | RDEPUBReaderController 全生命周期、三条渲染路径分发、配置变更检测与响应、工具栏/目录/高亮/搜索/设置面板交互流、阅读位置持久化、主题管理 | | [EPUBUI_功能实现逻辑.md](EPUBUI_功能实现逻辑.md) | `Sources/RDReaderView/EPUBUI/`(19 文件) | RDEPUBReaderController 全生命周期、三条渲染路径分发、配置变更检测与响应、工具栏/目录/高亮/书签/搜索/设置面板交互流、阅读位置持久化、主题管理、CoreText 页面交互 |
## 方案讨论文档 ## 方案讨论文档
+21 -20
View File
@@ -40,9 +40,10 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
- 5 层 CSS 级联和 EPUB `<link>` 外部样式表内联都已经落地。 - 5 层 CSS 级联和 EPUB `<link>` 外部样式表内联都已经落地。
- CoreText 页面 hit test、长按选区、菜单锚点、复制/高亮/批注主链路已经接入页面几何。 - CoreText 页面 hit test、长按选区、菜单锚点、复制/高亮/批注主链路已经接入页面几何。
- CoreText 路径的高亮、注释、搜索命中已经走页面装饰层,而不是继续改正文布局真值。 - CoreText 路径的高亮、注释、搜索命中已经走页面装饰层,而不是继续改正文布局真值。
- WebView 路径的高亮/批注展示链路已经补齐,不再因为 DOM 包裹失败而“有记录但不着色”。 - WebView 路径的标注绘制、搜索高亮已经改成“JS 产出 rect,原生 overlay 负责 CGContext 绘制”,不再依赖 DOM 包裹着色。
- 分页器已经补上 inline footnote attachment 不整段挪页、标题 `keepWithNext`、`weread-page-relate` 页首借行这几类 WXRead 风格规则。 - 分页器已经补上 inline footnote attachment 不整段挪页、标题 `keepWithNext`、`weread-page-relate` 页首借行这几类 WXRead 风格规则。
- 章节尾部“仅空白/段落分隔符”的尾页丢弃,以及极短尾页回并已经落地,`宝山辽墓材料与释读` 第 31 页空白问题已修复。 - 章节尾部“仅空白/段落分隔符”的尾页丢弃,以及极短尾页回并已经落地,`宝山辽墓材料与释读` 第 31 页空白问题已修复。
- `RDEPUBTextLayoutConfig` 已补齐到 WXRead 同级配置面:`frameWidth/frameHeight/edgeInsets/numberOfColumns/columnGap/avoidOrphans/avoidWidows/hyphenation`,并已接入分页入口与缓存键。
### ⚠️ 有差异/有问题 ### ⚠️ 有差异/有问题
@@ -51,14 +52,14 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
- 位置模型虽然已经有 `rangeAnchor` 和 `fileIndex/row/column` 语义,但还不是完整的 WXRead 双向位置转换器。 - 位置模型虽然已经有 `rangeAnchor` 和 `fileIndex/row/column` 语义,但还不是完整的 WXRead 双向位置转换器。
- `RDEPUBChapterData` 已承担页内高亮/搜索/页码查询职责,但还没完全吸收 controller/book 层的章节语义。 - `RDEPUBChapterData` 已承担页内高亮/搜索/页码查询职责,但还没完全吸收 controller/book 层的章节语义。
- `RDReaderView` 已有 `pageCurl` fault patch、预加载、预测翻页和显示切换位置恢复,但还不是 WXRead 那种完整宿主层补丁集。 - `RDReaderView` 已有 `pageCurl` fault patch、预加载、预测翻页和显示切换位置恢复,但还不是 WXRead 那种完整宿主层补丁集。
- `default.css` / `replace.css` / `dark.css` 的效果已接近,但资源体系和 WXRead 私有 CSS 仍不是一比一镜像。 - `default.css` / `replace.css` / `dark.css` / `replaceForLatinLanguageBook.css` 已切到内置资源镜像,并接入章节语言分支选择。
### ❌ 未实现 ### ❌ 未实现
- 完整 `WRChapterData` 同构模型 - 完整 `WRChapterData` 同构模型
- 完整 `WREpubPositionConverter` 同构模型 - 完整 `WREpubPositionConverter` 同构模型
- 预加载、预测翻页、显示切换一致性的完整宿主层方案 - 预加载、预测翻页、显示切换一致性的完整宿主层方案
- 多栏、字体动态加载、繁简转换 - 字体动态加载、繁简转换
- TTS / DRM / Pencil - TTS / DRM / Pencil
--- ---
@@ -70,10 +71,10 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
| EPUB 文本渲染 | `WRPageView.drawRect:` → `CTFrameDraw` 到 CGContext | `RDEPUBTextContentView` → `RDEPUBDirectCoreTextPageView.draw(_:)` + `DTCoreTextLayoutFrame.draw(in:)` 页面级直绘 | ✅ 已实现 | | EPUB 文本渲染 | `WRPageView.drawRect:` → `CTFrameDraw` 到 CGContext | `RDEPUBTextContentView` → `RDEPUBDirectCoreTextPageView.draw(_:)` + `DTCoreTextLayoutFrame.draw(in:)` 页面级直绘 | ✅ 已实现 |
| WebView 路径 | WKWebView(公众号/文集文章) | WKWebView(interactive/fixed-layout EPUB) | ✅ 已实现 | | WebView 路径 | WKWebView(公众号/文集文章) | WKWebView(interactive/fixed-layout EPUB) | ✅ 已实现 |
| 文本选区 | `CTLineGetStringIndexForPosition` 坐标级 hit test | `RDEPUBPageInteractionController` + `RDEPUBSelectionOverlayView` 已接入主链路 | ✅ 已实现 | | 文本选区 | `CTLineGetStringIndexForPosition` 坐标级 hit test | `RDEPUBPageInteractionController` + `RDEPUBSelectionOverlayView` 已接入主链路 | ✅ 已实现 |
| 标注绘制 | `CTFrame` 层叠加绘制(CGContext) | CoreText 路径已走页面装饰层;WebView 路径仍是 JS/DOM 高亮 | ⚠️ 有差异/有问题 | | 标注绘制 | `CTFrame` 层叠加绘制(CGContext) | CoreText / WebView 两条链路都已收口到原生页面装饰层绘制 | ✅ 已实现 |
| 搜索高亮 | `WRCoreTextLayoutFrame.highlightSearchResults:` 直接绘制 | CoreText 路径已页面化;不同渲染路径仍不是同一套对象模型 | ⚠️ 有差异/有问题 | | 搜索高亮 | `WRCoreTextLayoutFrame.highlightSearchResults:` 直接绘制 | CoreText / WebView 两条链路都已统一为原生页面装饰对象绘制 | ✅ 已实现 |
**结论:** 文本页的“是否页面级 CoreText”已经不是问题,当前差距是页面装饰对象和双路径一致性。 **结论:** 渲染层里“页面级绘制 + 页面装饰对象收口”这一块已经完成,当前剩余差距不再是标注/搜索高亮链路。
--- ---
@@ -88,7 +89,7 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
| 标题 keep-with-next | 标题不能孤悬页尾 | 已补 `keepWithNext` 语义与页尾回退 | ✅ 已实现 | | 标题 keep-with-next | 标题不能孤悬页尾 | 已补 `keepWithNext` 语义与页尾回退 | ✅ 已实现 |
| `weread-page-relate` | 页首关联块需要借上一页一行 | 已补页首 `pageRelate` 借行规则 | ✅ 已实现 | | `weread-page-relate` | 页首关联块需要借上一页一行 | 已补页首 `pageRelate` 借行规则 | ✅ 已实现 |
| 章节尾页收口 | 丢弃空白尾页、合并极短尾页 | 已补尾页空白丢弃与超短尾页回并 | ✅ 已实现 | | 章节尾页收口 | 丢弃空白尾页、合并极短尾页 | 已补尾页空白丢弃与超短尾页回并 | ✅ 已实现 |
| 分页配置 | `WRCoreTextLayoutConfig`(含多栏等) | 已有 `RDEPUBTextLayoutConfig`,但多栏等仍缺 | ⚠️ 有差异/有问题 | | 分页配置 | `WRCoreTextLayoutConfig`(含多栏等) | `RDEPUBTextLayoutConfig` 已补齐同级参数,并接入分页入口/缓存键/列路径构建 | ✅ 已实现 |
| 缓存 | 按书籍 + 排版设置缓存 | 已有 `RDEPUBTextBookCache` 磁盘缓存 | ✅ 已实现 | | 缓存 | 按书籍 + 排版设置缓存 | 已有 `RDEPUBTextBookCache` 磁盘缓存 | ✅ 已实现 |
**结论:** 当前分页质量问题已经进入问题书细抠阶段,不再是“缺分页器”的阶段;章节尾部空白页这类系统性问题也已经开始按 WXRead 规则收口。 **结论:** 当前分页质量问题已经进入问题书细抠阶段,不再是“缺分页器”的阶段;章节尾部空白页这类系统性问题也已经开始按 WXRead 规则收口。
@@ -101,11 +102,11 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|------|---------|-------------|------| |------|---------|-------------|------|
| CSS 级联层级 | 5 级:`default < replace < dark < epub < user` | `RDEPUBTextStyleSheetPackage` 已实际生成并注入 5 层 | ✅ 已实现 | | CSS 级联层级 | 5 级:`default < replace < dark < epub < user` | `RDEPUBTextStyleSheetPackage` 已实际生成并注入 5 层 | ✅ 已实现 |
| EPUB `<link>` CSS | 解析前级联合并外部样式 | 已实现抽取、内联、URL 重写与诊断 | ✅ 已实现 | | EPUB `<link>` CSS | 解析前级联合并外部样式 | 已实现抽取、内联、URL 重写与诊断 | ✅ 已实现 |
| 自定义 CSS 属性 | `wr-vertical-center-style`、`weread-page-relate`、断页相关私有属性 | 已支持 `pageRelate`、`avoidPageBreakInside`、attachment placement;仍非完整 WeRead 属性全集 | ⚠️ 有差异/有问题 | | 自定义 CSS 属性 | `wr-vertical-center-style`、`weread-page-relate`、断页相关私有属性 | 已按 WXRead 语义接入 `wr-vertical-center-style`、`weread-page-relate`、`page-break-* / break-*`、`avoidPageBreakInside` 与 attachment placement | ✅ 已实现 |
| WXRead 私有 CSS 文件 | 使用 WeRead 自带 `default/replace/dark/...` | 当前是等价策略,不是原文件原样镜像 | ⚠️ 有差异/有问题 | | WXRead 私有 CSS 文件 | 使用 WeRead 自带 `default/replace/dark/...` | 已把 `default/replace/dark` 作为 SDK 内置资源镜像注入 5 层级联 | ✅ 已实现 |
| `replaceForLatinLanguageBook.css` | 拉丁语言专项字体规则 | 无对应 | ❌ 未实现 | | `replaceForLatinLanguageBook.css` | 拉丁语言专项字体规则 | 已按章节语言 / 正文拉丁字符占比自动切换 Latin replace 层 | ✅ 已实现 |
**结论:** CSS 主机制已对齐,剩下是规则细节和私有资源体系差异。 **结论:** CSS 主机制、私有资源层和拉丁语言分支都已经按 WXRead 路径收口;这一节当前不再是剩余差距。
--- ---
@@ -156,22 +157,22 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
| 文件 | 读书职责 | ReadViewSDK 状态 | 核查 | | 文件 | 读书职责 | ReadViewSDK 状态 | 核查 |
|------|-------------|-----------------|------| |------|-------------|-----------------|------|
| `default.css` | 基础 HTML 标签样式 | 已有等价基础 CSS | ⚠️ 有差异/有问题 | | `default.css` | 基础 HTML 标签样式 | 已以内置资源镜像接入 | ✅ 已实现 |
| `replace.css` | 标题、图片、引用、分页控制 | 已有等价 replace 层 | ⚠️ 有差异/有问题 | | `replace.css` | 标题、图片、引用、分页控制 | 已以内置资源镜像接入 | ✅ 已实现 |
| `dark.css` | 暗色主题 | 已有 dark 层 | ⚠️ 有差异/有问题 | | `dark.css` | 暗色主题 | 已以内置资源镜像接入,并保留主题色覆盖层 | ✅ 已实现 |
| `replaceForLatinLanguageBook.css` | 拉丁语言字体 | 无 | ❌ 未实现 | | `replaceForLatinLanguageBook.css` | 拉丁语言字体 | 已接入章节语言自动切换 | ✅ 已实现 |
| `replaceForMPChapter.css` | 公众号文章专用 | EPUB 主链路不需要 | ❌ 未实现(暂不需要) | | `replaceForMPChapter.css` | 公众号文章专用 | EPUB 主链路不需要 | ❌ 未实现(暂不需要) |
### JS ### JS
| 文件 | 读书职责 | ReadViewSDK 状态 | 核查 | | 文件 | 读书职责 | ReadViewSDK 状态 | 核查 |
|------|-------------|-----------------|------| |------|-------------|-----------------|------|
| `weread-highlighter.js` | Web 高亮引擎 | `epub-bridge.js` 已接住高亮/批注展示 | ⚠️ 有差异/有问题 | | `weread-highlighter.js` | Web 高亮引擎 | `epub-bridge.js` 现只负责 rect 解析与桥接,最终由原生 overlay 绘制 | ✅ 已实现 |
| `rangy-*` | 选区/高亮库 | CoreText 路径不需要,Web 路径也未采用 | ⚠️ 有差异/有问题 | | `rangy-*` | 选区/高亮库 | CoreText 路径不需要,Web 路径也未采用 | ⚠️ 有差异/有问题 |
| `cssInjector.js` | 动态 CSS 注入 | 现为原生侧静态注入 | ⚠️ 有差异/有问题 | | `cssInjector.js` | 动态 CSS 注入 | 现为原生侧静态注入 | ⚠️ 有差异/有问题 |
| `WeReadApi.js` | JS-Native 桥接 | `window.RDReaderBridge` | ⚠️ 有差异/有问题 | | `WeReadApi.js` | JS-Native 桥接 | `window.RDReaderBridge` | ⚠️ 有差异/有问题 |
**结论:** 不建议也不应该把 WXRead 私有 JS/CSS 资源整包原样搬用;当前应继续走“能力等价、实现自有”的路线。 **结论:** CSS 资源层已经按 WXRead 的文件级职责收口;剩余差距主要在 JS 桥接策略,而不是 CSS 处理能力。
--- ---
@@ -186,7 +187,7 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
| 预加载 | ⚠️ 有差异/有问题 | P3 | | 预加载 | ⚠️ 有差异/有问题 | P3 |
| 预测翻页 | ⚠️ 有差异/有问题 | P3 | | 预测翻页 | ⚠️ 有差异/有问题 | P3 |
| 显示切换一致性 | ⚠️ 有差异/有问题 | P3 | | 显示切换一致性 | ⚠️ 有差异/有问题 | P3 |
| 多栏排版 | ❌ 未实现 | P4 | | 多栏排版 | ⚠️ 引擎配置与列路径已接入,阅读器侧未暴露切换 | P4 |
| 字体动态加载 | ❌ 未实现 | P4 | | 字体动态加载 | ❌ 未实现 | P4 |
| 繁简转换 | ❌ 未实现 | P4 | | 繁简转换 | ❌ 未实现 | P4 |
| TTS / DRM / Pencil | ❌ 未实现 | P4 | | TTS / DRM / Pencil | ❌ 未实现 | P4 |
@@ -221,12 +222,12 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
### P4:外围能力 ### P4:外围能力
- 多栏 - 多栏阅读器开关 / UI 暴露
- 字体系统 - 字体系统
- 繁简转换 - 繁简转换
- TTS / DRM / Pencil - TTS / DRM / Pencil
当前状态:`❌ 未开始` 当前状态:`⚠️ 进行中`
--- ---
+1 -1
View File
@@ -12,7 +12,7 @@ Pod::Spec.new do |s|
s.license = "MIT" s.license = "MIT"
s.source_files = 'Sources/RDReaderView/**/*.{swift}' s.source_files = 'Sources/RDReaderView/**/*.{swift}'
s.resource_bundles = { s.resource_bundles = {
'RDReaderViewAssets' => ['Sources/RDReaderView/Resources/**/*'] 'RDReaderViewAssets' => ['Sources/RDReaderView/EPUBCore/Resources/**/*']
} }
s.dependency 'ZIPFoundation', '~> 0.9' s.dependency 'ZIPFoundation', '~> 0.9'
s.dependency 'DTCoreText' s.dependency 'DTCoreText'
+1 -1
View File
@@ -43,7 +43,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0 DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
RDReaderView: 438edba3372d14a7dde3c0334ebd4cc1945ca926 RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1 SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351 ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
+1 -1
View File
@@ -20,7 +20,7 @@
"source_files": "Sources/RDReaderView/**/*.{swift}", "source_files": "Sources/RDReaderView/**/*.{swift}",
"resource_bundles": { "resource_bundles": {
"RDReaderViewAssets": [ "RDReaderViewAssets": [
"Sources/RDReaderView/Resources/**/*" "Sources/RDReaderView/EPUBCore/Resources/**/*"
] ]
}, },
"dependencies": { "dependencies": {
+1 -1
View File
@@ -43,7 +43,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0 DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
RDReaderView: 438edba3372d14a7dde3c0334ebd4cc1945ca926 RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1 SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351 ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
File diff suppressed because it is too large Load Diff
@@ -8,78 +8,56 @@
<dict> <dict>
<key>isShown</key> <key>isShown</key>
<false/> <false/>
<key>orderHint</key>
<integer>1</integer>
</dict> </dict>
<key>DTCoreText.xcscheme</key> <key>DTCoreText.xcscheme</key>
<dict> <dict>
<key>isShown</key> <key>isShown</key>
<false/> <false/>
<key>orderHint</key>
<integer>0</integer>
</dict> </dict>
<key>DTFoundation.xcscheme</key> <key>DTFoundation.xcscheme</key>
<dict> <dict>
<key>isShown</key> <key>isShown</key>
<false/> <false/>
<key>orderHint</key>
<integer>2</integer>
</dict> </dict>
<key>Pods-ReadViewDemo.xcscheme</key> <key>Pods-ReadViewDemo.xcscheme</key>
<dict> <dict>
<key>isShown</key> <key>isShown</key>
<false/> <false/>
<key>orderHint</key>
<integer>3</integer>
</dict> </dict>
<key>RDReaderView-RDReaderViewAssets.xcscheme</key> <key>RDReaderView-RDReaderViewAssets.xcscheme</key>
<dict> <dict>
<key>isShown</key> <key>isShown</key>
<false/> <false/>
<key>orderHint</key>
<integer>5</integer>
</dict> </dict>
<key>RDReaderView.xcscheme</key> <key>RDReaderView.xcscheme</key>
<dict> <dict>
<key>isShown</key> <key>isShown</key>
<false/> <false/>
<key>orderHint</key>
<integer>4</integer>
</dict> </dict>
<key>SSAlertSwift.xcscheme</key> <key>SSAlertSwift.xcscheme</key>
<dict> <dict>
<key>isShown</key> <key>isShown</key>
<false/> <false/>
<key>orderHint</key>
<integer>8</integer>
</dict> </dict>
<key>SnapKit-SnapKit_Privacy.xcscheme</key> <key>SnapKit-SnapKit_Privacy.xcscheme</key>
<dict> <dict>
<key>isShown</key> <key>isShown</key>
<false/> <false/>
<key>orderHint</key>
<integer>7</integer>
</dict> </dict>
<key>SnapKit.xcscheme</key> <key>SnapKit.xcscheme</key>
<dict> <dict>
<key>isShown</key> <key>isShown</key>
<false/> <false/>
<key>orderHint</key>
<integer>6</integer>
</dict> </dict>
<key>ZIPFoundation-ZIPFoundation_Privacy.xcscheme</key> <key>ZIPFoundation-ZIPFoundation_Privacy.xcscheme</key>
<dict> <dict>
<key>isShown</key> <key>isShown</key>
<false/> <false/>
<key>orderHint</key>
<integer>10</integer>
</dict> </dict>
<key>ZIPFoundation.xcscheme</key> <key>ZIPFoundation.xcscheme</key>
<dict> <dict>
<key>isShown</key> <key>isShown</key>
<false/> <false/>
<key>orderHint</key>
<integer>9</integer>
</dict> </dict>
</dict> </dict>
<key>SuppressBuildableAutocreation</key> <key>SuppressBuildableAutocreation</key>
@@ -185,14 +185,10 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Embed Pods Frameworks"; name = "[CP] Embed Pods Frameworks";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh\"\n";
@@ -1,20 +1,45 @@
// RDEPUBAssetRepository.swift
// EPUBCore 静态资源仓库
// 负责从资源包中加载 EPUB 渲染所需的 JS 桥接脚本和固定版式 HTML 模板,
// 支持模板变量替换({{token}} 占位符)。
import Foundation import Foundation
/// EPUB 内置资源枚举,定义桥接脚本和固定版式模板的文件名及扩展名
enum RDEPUBAsset: String { enum RDEPUBAsset: String {
/// JS 桥接脚本(epub-bridge.js),注入到 WebView 中实现原生-JS 通信
case bridgeScript = "epub-bridge" case bridgeScript = "epub-bridge"
/// 固定版式 HTML 模板(epub-fixed-layout.html),用于渲染 pre-paginated 类型的 EPUB
case fixedLayoutTemplate = "epub-fixed-layout" case fixedLayoutTemplate = "epub-fixed-layout"
/// WXRead 对齐的 default.css
case wxReadDefaultCSS = "wxread-default"
/// WXRead 对齐的 replace.css
case wxReadReplaceCSS = "wxread-replace"
/// WXRead 对齐的 dark.css
case wxReadDarkCSS = "wxread-dark"
/// WXRead 对齐的 replaceForLatinLanguageBook.css
case wxReadLatinReplaceCSS = "wxread-replace-latin"
/// 对应的文件扩展名
var fileExtension: String { var fileExtension: String {
switch self { switch self {
case .bridgeScript: case .bridgeScript:
return "js" return "js"
case .fixedLayoutTemplate: case .fixedLayoutTemplate:
return "html" return "html"
case .wxReadDefaultCSS, .wxReadReplaceCSS, .wxReadDarkCSS, .wxReadLatinReplaceCSS:
return "css"
} }
} }
} }
/// 静态资源加载器,负责从资源包中读取模板文件并执行变量替换
enum RDEPUBAssetRepository { enum RDEPUBAssetRepository {
/// 加载指定资源文件的内容,并将 {{key}} 占位符替换为对应的值
/// - Parameters:
/// - asset: 要加载的资源类型
/// - replacements: 模板变量字典,key 为占位符名称,value 为替换值
/// - Returns: 替换后的文件内容字符串
static func string(for asset: RDEPUBAsset, replacements: [String: String] = [:]) -> String { static func string(for asset: RDEPUBAsset, replacements: [String: String] = [:]) -> String {
guard let url = resourceBundle.url(forResource: asset.rawValue, withExtension: asset.fileExtension), guard let url = resourceBundle.url(forResource: asset.rawValue, withExtension: asset.fileExtension),
var content = try? String(contentsOf: url, encoding: .utf8) else { var content = try? String(contentsOf: url, encoding: .utf8) else {
@@ -28,6 +53,7 @@ enum RDEPUBAssetRepository {
return content return content
} }
/// 获取资源所在的 Bundle(优先使用已解析的子 bundle,兜底到宿主 bundle)
private static var resourceBundle: Bundle { private static var resourceBundle: Bundle {
if let bundle = resolvedBundle { if let bundle = resolvedBundle {
return bundle return bundle
@@ -35,6 +61,7 @@ enum RDEPUBAssetRepository {
return Bundle(for: RDEPUBAssetBundleToken.self) return Bundle(for: RDEPUBAssetBundleToken.self)
} }
/// 延迟解析 RDReaderViewAssets.bundle 的位置,在宿主 bundle 和所有 framework 中查找
private static var resolvedBundle: Bundle? = { private static var resolvedBundle: Bundle? = {
let hostBundles = [Bundle(for: RDEPUBAssetBundleToken.self), Bundle.main] + Bundle.allFrameworks + Bundle.allBundles let hostBundles = [Bundle(for: RDEPUBAssetBundleToken.self), Bundle.main] + Bundle.allFrameworks + Bundle.allBundles
for hostBundle in hostBundles { for hostBundle in hostBundles {
@@ -47,4 +74,5 @@ enum RDEPUBAssetRepository {
}() }()
} }
/// 用于定位当前模块 Bundle 的标记类
private final class RDEPUBAssetBundleToken {} private final class RDEPUBAssetBundleToken {}
@@ -1,12 +1,25 @@
// RDEPUBFixedLayoutTemplate.swift
// 固定版式(Fixed Layout)HTML 模板生成器
// 根据渲染请求和 Publication 信息,将 iframe 拼装到 HTML 模板中,
// 生成适配视口尺寸和内边距的固定版式页面 HTML。
import Foundation import Foundation
/// 固定版式模板生成器,负责将 spine 资源渲染为 iframe 嵌套的 HTML 页面
enum RDEPUBFixedLayoutTemplate { enum RDEPUBFixedLayoutTemplate {
/// 根据渲染请求和 Publication 生成固定版式页面的完整 HTML
/// - Parameters:
/// - request: 固定版式渲染请求(含 spread、视口尺寸、内边距、适配模式等)
/// - publication: EPUB 出版物,用于解析资源 URL
/// - Returns: 完整的 HTML 字符串
static func html(for request: RDEPUBFixedRenderRequest, publication: RDEPUBPublication) -> String { static func html(for request: RDEPUBFixedRenderRequest, publication: RDEPUBPublication) -> String {
// 遍历 spread 中的每个资源,生成对应的 iframe pane HTML
let panes = request.spread.resources.enumerated().compactMap { index, resource -> String? in let panes = request.spread.resources.enumerated().compactMap { index, resource -> String? in
guard let url = publication.resourceResolver.resourceURL(forRelativePath: resource.href)?.absoluteString else { guard let url = publication.resourceResolver.resourceURL(forRelativePath: resource.href)?.absoluteString else {
return nil return nil
} }
// 根据 spread 中的资源数量判断页面类型:单页/左页/右页/居中
let pageType: String let pageType: String
if request.spread.resources.count == 1 { if request.spread.resources.count == 1 {
pageType = "single" pageType = "single"
@@ -23,6 +36,7 @@ enum RDEPUBFixedLayoutTemplate {
""" """
}.joined() }.joined()
// 计算视口区域(去除内边距后的可用区域)
let background = request.backgroundColorCSS ?? "#FFFFFF" let background = request.backgroundColorCSS ?? "#FFFFFF"
let viewportWidth = max(1, Int((request.viewportSize.width - request.contentInset.left - request.contentInset.right).rounded(.down))) 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 viewportHeight = max(1, Int((request.viewportSize.height - request.contentInset.top - request.contentInset.bottom).rounded(.down)))
@@ -30,6 +44,7 @@ enum RDEPUBFixedLayoutTemplate {
let insetRight = Int(request.contentInset.right.rounded(.down)) let insetRight = Int(request.contentInset.right.rounded(.down))
let insetBottom = Int(request.contentInset.bottom.rounded(.down)) let insetBottom = Int(request.contentInset.bottom.rounded(.down))
let insetLeft = Int(request.contentInset.left.rounded(.down)) let insetLeft = Int(request.contentInset.left.rounded(.down))
// 从资源仓库加载 HTML 模板并替换占位符
return RDEPUBAssetRepository.string( return RDEPUBAssetRepository.string(
for: .fixedLayoutTemplate, for: .fixedLayoutTemplate,
replacements: [ replacements: [
@@ -1,19 +1,35 @@
// RDEPUBJavaScriptBridge.swift
// 原生与 WebView 之间的 JavaScript 桥接层
// 定义所有 JS 消息类型(progression/selection/link/error 等),
// 生成注入到 WebView 的用户脚本,以及分页渲染、搜索高亮等执行脚本。
import Foundation import Foundation
/// JS 桥接消息类型枚举,每种消息对应 WebView 向原生发送的一种事件
enum RDEPUBJavaScriptBridgeMessage: String, CaseIterable { enum RDEPUBJavaScriptBridgeMessage: String, CaseIterable {
/// 阅读进度变化事件
case progressionChanged = "ssReaderProgressionChanged" case progressionChanged = "ssReaderProgressionChanged"
/// 文本选择变化事件
case selectionChanged = "ssReaderSelectionChanged" case selectionChanged = "ssReaderSelectionChanged"
/// 内部链接点击事件(ss-reader:// 协议)
case internalLink = "ssReaderInternalLink" case internalLink = "ssReaderInternalLink"
/// 外部链接点击事件(http/https/mailto/tel)
case externalLink = "ssReaderExternalLink" case externalLink = "ssReaderExternalLink"
/// JavaScript 运行时错误事件
case javaScriptError = "ssReaderJSError" case javaScriptError = "ssReaderJSError"
/// 固定版式渲染就绪事件
case fixedLayoutReady = "ssReaderFixedLayoutReady" case fixedLayoutReady = "ssReaderFixedLayoutReady"
} }
/// JavaScript 桥接工具集,负责生成注入脚本和执行脚本
enum RDEPUBJavaScriptBridge { enum RDEPUBJavaScriptBridge {
/// 所有需要注册的 JS 消息名称列表,用于 WKUserContentController 注册
static var messageNames: [String] { static var messageNames: [String] {
RDEPUBJavaScriptBridgeMessage.allCases.map(\.rawValue) RDEPUBJavaScriptBridgeMessage.allCases.map(\.rawValue)
} }
/// 获取注入到 WebView 的桥接用户脚本(在 document end 注入)
/// 将消息类型占位符替换为实际值,使 JS 端能通过 window.webkit.messageHandlers 发送消息
static var userScript: String { static var userScript: String {
RDEPUBAssetRepository.string( RDEPUBAssetRepository.string(
for: .bridgeScript, for: .bridgeScript,
@@ -28,6 +44,10 @@ enum RDEPUBJavaScriptBridge {
) )
} }
/// 生成重排模式下的分页渲染脚本
/// 注入 CSS 分页样式、设置页码参数、高亮和目标位置,最后触发进度汇报
/// - Parameter request: 重排渲染请求,包含展示样式、高亮、目标位置等信息
/// - Returns: 可直接通过 evaluateJavaScript 执行的脚本字符串
static func applyPresentationScript(for request: RDEPUBReflowableRenderRequest) -> String { static func applyPresentationScript(for request: RDEPUBReflowableRenderRequest) -> String {
let style = escapedJavaScriptTemplateLiteral(RDEPUBStyleSheetBuilder.renderCSS(for: request.presentation)) let style = escapedJavaScriptTemplateLiteral(RDEPUBStyleSheetBuilder.renderCSS(for: request.presentation))
let pageStride = max(1, request.presentation.viewportSize.width) let pageStride = max(1, request.presentation.viewportSize.width)
@@ -52,173 +72,39 @@ enum RDEPUBJavaScriptBridge {
""" """
} }
/// 生成搜索高亮脚本
/// 在当前文档和所有 iframe 中查找关键词并用 span 标记高亮,
/// 活跃匹配项使用特殊样式并自动滚动到视图中央
/// - Parameter presentation: 搜索展示信息(关键词、各资源的匹配数量等)
/// - Returns: 搜索高亮脚本字符串
static func applySearchScript(for presentation: RDEPUBSearchPresentation?) -> String { static func applySearchScript(for presentation: RDEPUBSearchPresentation?) -> String {
let payload = jsonString(from: searchPayload(presentation), fallback: "null") let payload = jsonString(from: searchPayload(presentation), fallback: "null")
return """ return """
(function() { (function() {
var payload = \(payload); if (!window.RDReaderBridge) { return false; }
function normalizeHref(rawHref) { window.RDReaderBridge.setSearchPresentation(\(payload));
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; return true;
})(); })();
""" """
} }
static func resolveDecorationsScript() -> String {
"""
(function() {
if (!window.RDReaderBridge) { return { highlights: [], search: [] }; }
return window.RDReaderBridge.resolveDecorations();
})();
"""
}
/// 转义字符串中的反斜杠和反引号,避免 JS 模板字面量语法错误
private static func escapedJavaScriptTemplateLiteral(_ string: String) -> String { private static func escapedJavaScriptTemplateLiteral(_ string: String) -> String {
string string
.replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "`", with: "\\`") .replacingOccurrences(of: "`", with: "\\`")
} }
/// 将高亮数组转换为 JS 可用的字典数组
private static func highlightsPayload(_ highlights: [RDEPUBHighlight]) -> [[String: String]] { private static func highlightsPayload(_ highlights: [RDEPUBHighlight]) -> [[String: String]] {
highlights.compactMap { highlight in highlights.compactMap { highlight in
guard let rangeInfo = highlight.rangeInfo, !rangeInfo.isEmpty else { return nil } guard let rangeInfo = highlight.rangeInfo, !rangeInfo.isEmpty else { return nil }
@@ -231,6 +117,7 @@ enum RDEPUBJavaScriptBridge {
} }
} }
/// 将目标位置转换为 JS 可用的字典(含 progression、fragment)
private static func targetLocationPayload(_ location: RDEPUBLocation?) -> [String: Any]? { private static func targetLocationPayload(_ location: RDEPUBLocation?) -> [String: Any]? {
guard let location else { return nil } guard let location else { return nil }
return [ return [
@@ -240,6 +127,7 @@ enum RDEPUBJavaScriptBridge {
] ]
} }
/// 将搜索展示信息转换为 JS 可用的字典
private static func searchPayload(_ presentation: RDEPUBSearchPresentation?) -> [String: Any]? { private static func searchPayload(_ presentation: RDEPUBSearchPresentation?) -> [String: Any]? {
guard let presentation else { return nil } guard let presentation else { return nil }
return [ return [
@@ -254,6 +142,7 @@ enum RDEPUBJavaScriptBridge {
] ]
} }
/// 将任意对象序列化为 JSON 字符串,失败时返回 fallback
private static func jsonString(from object: Any?, fallback: String) -> String { private static func jsonString(from object: Any?, fallback: String) -> String {
guard let object else { return fallback } guard let object else { return fallback }
guard JSONSerialization.isValidJSONObject(object), guard JSONSerialization.isValidJSONObject(object),
@@ -1,36 +1,71 @@
//
// RDEPUBModels.swift
// RDReaderView
//
// EPUBCore 层核心数据模型定义文件。
// 包含 EPUB 解析过程中所需的全部基础枚举和数据结构:
// 布局类型、阅读方向、页面分布、元数据、清单项、Spine 项、目录项及解析错误类型。
// 这些模型由 RDEPUBParser 解析后填充,并由 RDEPUBPublication 聚合对外暴露。
//
import Foundation import Foundation
/// EPUB 版面布局类型
/// - reflowable: 可重排布局,内容根据屏幕尺寸和字号自动分页(小说类 EPUB)
/// - fixed: 固定版式布局,页面尺寸固定不变(漫画、绘本类 EPUB)
public enum RDEPUBLayout: String, Codable { public enum RDEPUBLayout: String, Codable {
case reflowable case reflowable
case fixed case fixed
} }
/// 阅读配置文件类型,决定使用哪种渲染路径
/// - webInteractive: 可重排 + 含交互脚本,使用 WKWebView 渲染
/// - webFixedLayout: 固定版式 EPUB(漫画、绘本),使用 WKWebView 渲染
/// - textReflowable: 纯文本可重排 EPUB,使用 DTCoreText 渲染(性能更优)
public enum RDEPUBReadingProfile: String, Codable { public enum RDEPUBReadingProfile: String, Codable {
case webInteractive case webInteractive
case webFixedLayout case webFixedLayout
case textReflowable case textReflowable
} }
/// 阅读方向(文字排版方向)
/// - ltr: 从左到右(英文、中文横排)
/// - rtl: 从右到左(日文竖排、阿拉伯文)
/// - auto: 根据语言自动判断
public enum RDEPUBReadingProgression: String, Codable { public enum RDEPUBReadingProgression: String, Codable {
case ltr case ltr
case rtl case rtl
case auto case auto
} }
/// 固定版式页面在 spread(双页展开)中的位置
/// - left: 左页
/// - right: 右页
/// - center: 居中(跨页或单页居中)
public enum RDEPUBPageSpread: String, Codable { public enum RDEPUBPageSpread: String, Codable {
case left case left
case right case right
case center case center
} }
/// EPUB 出版物元数据,对应 OPF 文档中 <metadata> 节点的内容
/// 包含书籍的基本信息:标识符、标题、作者、语言、版本、布局类型等
public struct RDEPUBMetadata: Codable, Equatable { public struct RDEPUBMetadata: Codable, Equatable {
/// 书籍唯一标识符(对应 <dc:identifier>)
public var identifier: String? public var identifier: String?
/// 书名(对应 <dc:title>)
public var title: String public var title: String
/// 作者(对应 <dc:creator>)
public var author: String? public var author: String?
/// 语言代码(对应 <dc:language>,如 "zh"、"en")
public var language: String? public var language: String?
/// EPUB 版本号(如 "2.0"、"3.0")
public var version: String? public var version: String?
/// 版面布局类型(reflowable 或 fixed)
public var layout: RDEPUBLayout public var layout: RDEPUBLayout
/// spread 模式设置(对应 <meta property="rendition:spread">)
public var spread: String? public var spread: String?
/// 阅读方向(ltr / rtl / auto)
public var readingProgression: RDEPUBReadingProgression public var readingProgression: RDEPUBReadingProgression
public init( public init(
@@ -54,13 +89,22 @@ public struct RDEPUBMetadata: Codable, Equatable {
} }
} }
/// EPUB 清单项,对应 OPF 文档 <manifest> 中的每个 <item>
/// 描述一个资源文件的 ID、路径、MIME 类型和附加属性
public struct RDEPUBManifestItem: Codable, Equatable { public struct RDEPUBManifestItem: Codable, Equatable {
/// 清单项的唯一标识符(对应 <item id="...">)
public var id: String public var id: String
/// 资源文件路径,相对于 OPF 文件所在目录
public var href: String public var href: String
/// MIME 类型(如 "application/xhtml+xml"、"image/jpeg"、"text/css")
public var mediaType: String public var mediaType: String
/// 属性列表(如 "nav" 表示导航文档、"scripted" 表示含脚本、"cover-image" 表示封面)
public var properties: [String] public var properties: [String]
/// 回退项 ID(对应 <item fallback="...">)
public var fallback: String? public var fallback: String?
/// 媒体叠加文件 ID(EPUB 3 有声书功能)
public var mediaOverlay: String? public var mediaOverlay: String?
/// 资源标题(用于搜索结果展示等辅助用途)
public var title: String? public var title: String?
public init( public init(
@@ -81,23 +125,37 @@ public struct RDEPUBManifestItem: Codable, Equatable {
self.title = title self.title = title
} }
/// 是否为 EPUB 3 导航文档(Nav Document)
/// 通过检查 properties 中是否包含 "nav" 标记判断
public var isNavigationDocument: Bool { public var isNavigationDocument: Bool {
properties.contains("nav") properties.contains("nav")
} }
/// 是否为 NCX 目录文件(EPUB 2 格式)
/// 通过 MIME 类型 "application/x-dtbncx+xml" 判断
public var isNCX: Bool { public var isNCX: Bool {
mediaType == "application/x-dtbncx+xml" mediaType == "application/x-dtbncx+xml"
} }
} }
/// EPUB Spine 项,对应 OPF 文档 <spine> 中的每个 <itemref>
/// Spine 定义了阅读器中章节内容的线性阅读顺序
public struct RDEPUBSpineItem: Codable, Equatable { public struct RDEPUBSpineItem: Codable, Equatable {
/// 对应 manifest 中的 item id(用于关联资源)
public var idref: String public var idref: String
/// 标准化后的资源路径(相对于 OPF 文件所在目录)
public var href: String public var href: String
/// MIME 类型
public var mediaType: String public var mediaType: String
/// 章节标题(从目录或 manifest 推断)
public var title: String public var title: String
/// 是否为线性内容("no" 表示补充材料,不影响主线阅读进度)
public var linear: Bool public var linear: Bool
/// 属性列表(如 rendition:layout-* 用于单页布局覆盖)
public var properties: [String] public var properties: [String]
/// 页面在 spread 中的位置偏好(左页/右页/居中)
public var pageSpread: RDEPUBPageSpread? public var pageSpread: RDEPUBPageSpread?
/// 单页布局覆盖(显式属性可覆盖出版级别的 layout 设置)
public var layout: RDEPUBLayout? public var layout: RDEPUBLayout?
public init( public init(
@@ -121,9 +179,14 @@ public struct RDEPUBSpineItem: Codable, Equatable {
} }
} }
/// EPUB 目录项,支持树形层级结构
/// 来源于 NCX 文件(EPUB 2)或 Nav Document(EPUB 3)的解析结果
public struct EPUBTableOfContentsItem: Codable, Equatable { public struct EPUBTableOfContentsItem: Codable, Equatable {
/// 目录项标题(显示在目录面板中)
public var title: String public var title: String
/// 目标资源路径(含可能的 fragment 锚点)
public var href: String public var href: String
/// 子目录项列表(递归结构,支持多级目录)
public var children: [EPUBTableOfContentsItem] public var children: [EPUBTableOfContentsItem]
public init(title: String, href: String, children: [EPUBTableOfContentsItem] = []) { public init(title: String, href: String, children: [EPUBTableOfContentsItem] = []) {
@@ -133,13 +196,21 @@ public struct EPUBTableOfContentsItem: Codable, Equatable {
} }
} }
/// EPUB 解析器错误类型,涵盖从 ZIP 解压到 OPF 解析全链路的异常情况
public enum RDEPUBParserError: LocalizedError { public enum RDEPUBParserError: LocalizedError {
/// ZIP 文件无法打开(文件损坏或格式不支持)
case archiveOpenFailed(URL) case archiveOpenFailed(URL)
/// 缺少 META-INF/container.xml 文件(EPUB 格式不合规)
case missingContainerXML case missingContainerXML
/// container.xml 中未找到 <rootfile> 元素
case missingRootFile case missingRootFile
/// OPF 根文件路径无效(文件不存在)
case invalidRootFilePath(String) case invalidRootFilePath(String)
/// XML 解析失败(格式错误或编码问题)
case invalidXML(URL) case invalidXML(URL)
/// spine 中引用的 manifest 项不存在(idref 不匹配)
case missingManifestItem(idref: String) case missingManifestItem(idref: String)
/// 构建完成的 spine 为空(无可阅读的内容)
case emptySpine case emptySpine
public var errorDescription: String? { public var errorDescription: String? {
@@ -1,10 +1,22 @@
// RDEPUBNavigatorLayoutContext.swift
// 导航器布局上下文
// 封装阅读器容器的布局参数:容器尺寸、每屏页数、安全区域、设备类型、
// 重排内容内边距等,供 Paginator 和渲染层使用。
import UIKit import UIKit
/// 导航器布局上下文,描述阅读器容器的布局参数
/// 在 Paginator 计算分页和 WebView 渲染时作为核心输入
public struct RDEPUBNavigatorLayoutContext: Equatable { public struct RDEPUBNavigatorLayoutContext: Equatable {
/// 容器视图的总尺寸
public var containerSize: CGSize public var containerSize: CGSize
/// 每屏显示的页数(横屏双页模式为 2)
public var pagesPerScreen: Int public var pagesPerScreen: Int
/// 安全区域内边距(刘海屏、Home Indicator 等)
public var safeAreaInsets: UIEdgeInsets public var safeAreaInsets: UIEdgeInsets
/// 用户界面设备类型(phone/pad)
public var userInterfaceIdiom: UIUserInterfaceIdiom public var userInterfaceIdiom: UIUserInterfaceIdiom
/// 重排模式下的内容内边距(上下左右留白)
public var reflowableContentInsets: UIEdgeInsets public var reflowableContentInsets: UIEdgeInsets
public init( public init(
@@ -21,6 +33,7 @@ public struct RDEPUBNavigatorLayoutContext: Equatable {
self.reflowableContentInsets = reflowableContentInsets self.reflowableContentInsets = reflowableContentInsets
} }
/// 计算单页视口尺寸(双页模式下宽度除以 2)
public var viewportSize: CGSize { public var viewportSize: CGSize {
let width: CGFloat let width: CGFloat
if pagesPerScreen > 1 { if pagesPerScreen > 1 {
@@ -31,6 +44,7 @@ public struct RDEPUBNavigatorLayoutContext: Equatable {
return CGSize(width: width, height: containerSize.height) return CGSize(width: width, height: containerSize.height)
} }
/// 固定版式模式下的内容内边距(仅手机端使用安全区域,Pad 端归零;左右取较大值对称)
public var fixedContentInset: UIEdgeInsets { public var fixedContentInset: UIEdgeInsets {
var insets = safeAreaInsets var insets = safeAreaInsets
if userInterfaceIdiom != .phone { if userInterfaceIdiom != .phone {
@@ -1,13 +1,33 @@
//
// RDEPUBNavigatorState.swift
// RDReaderView
//
// EPUBCore 层导航状态机定义文件。
// 定义了阅读器导航器的状态枚举,状态流转遵循:
// initializing → loading → idle ↔ jumping/moving/repaginating
// 由 RDEPUBReadingSession 管理状态跃迁,控制分页快照的应用时机。
//
import Foundation import Foundation
/// 导航状态枚举,描述阅读器的当前操作状态
/// 状态机流转:initializing → loading → idle ↔ jumping/moving/repaginating
public enum RDEPUBNavigatorState: String, Codable { public enum RDEPUBNavigatorState: String, Codable {
/// 初始化状态,刚打开书籍,等待初始恢复位置
case initializing case initializing
/// 加载状态,正在生成首轮分页模型
case loading case loading
/// 空闲状态,显示稳定,可接受用户操作和快照应用
case idle case idle
/// 跳转状态,正在执行目录/内部链接跳转
case jumping case jumping
/// 翻页状态,用户正在翻页操作中
case moving case moving
/// 重排状态,正在重新分页(字号/横竖屏变化触发)
case repaginating case repaginating
/// 是否处于可应用分页快照的稳定状态
/// 只有 idle 状态才允许消费 staged snapshot
public var isStableForSnapshotApplication: Bool { public var isStableForSnapshotApplication: Bool {
self == .idle self == .idle
} }
@@ -1,25 +1,45 @@
// RDEPUBPaginator.swift
// EPUB 离屏分页计算器
// 使用隐藏的 WKWebView 加载每个 spine 项,通过 JS 测量 scrollWidth
// 计算每章的页数。采用多轮延迟测量(0ms/80ms/180ms)确保布局稳定。
// 固定版式(Fixed Layout)直接返回 1 页。
import UIKit import UIKit
import WebKit import WebKit
/// EPUB 分页计算器,通过离屏 WebView 测量每个 spine 项的分页数
public final class RDEPUBPaginator: NSObject { public final class RDEPUBPaginator: NSObject {
/// 当前解析器引用
private var parser: RDEPUBParser? private var parser: RDEPUBParser?
/// 承载 WebView 的父视图(弱引用)
private weak var hostingView: UIView? private weak var hostingView: UIView?
/// 当前展示样式(视口尺寸、内边距、字号、行高)
private var presentation = RDEPUBPresentationStyle( private var presentation = RDEPUBPresentationStyle(
viewportSize: .zero, viewportSize: .zero,
contentInsets: .zero, contentInsets: .zero,
fontSize: 16, fontSize: 16,
lineHeightMultiple: 1.5 lineHeightMultiple: 1.5
) )
/// 全量分页完成回调(返回每个 spine 项的页数数组)
private var completion: (([Int]) -> Void)? private var completion: (([Int]) -> Void)?
/// 单个 spine 项分页完成回调
private var singlePageCountCompletion: ((Int) -> Void)? private var singlePageCountCompletion: ((Int) -> Void)?
/// 各 spine 项的页数结果
private var pageCounts: [Int] = [] private var pageCounts: [Int] = []
/// 待测量的 spine 项索引列表
private var measurementIndices: [Int] = [] private var measurementIndices: [Int] = []
/// 当前测量偏移量(在 measurementIndices 中的位置)
private var currentMeasurementOffset = 0 private var currentMeasurementOffset = 0
/// 当前 spine 项的待定页数(多轮测量取最大值)
private var pendingMeasurementValue = 1 private var pendingMeasurementValue = 1
/// 当前测量轮次(0/1/2 对应延迟 0ms/80ms/180ms)
private var measurementPass = 0 private var measurementPass = 0
/// 当前测量会话 ID,用于防并发和取消过期任务
private var activeSessionID = UUID() private var activeSessionID = UUID()
/// 调试日志作用域标识
private let debugScope = "PaginatorWebView" private let debugScope = "PaginatorWebView"
/// 用于分页测量的隐藏 WebView(非持久化数据存储,禁止用户交互)
private lazy var webView: WKWebView = { private lazy var webView: WKWebView = {
let configuration = WKWebViewConfiguration() let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .nonPersistent() configuration.websiteDataStore = .nonPersistent()
@@ -52,6 +72,12 @@ public final class RDEPUBPaginator: NSObject {
webView.removeFromSuperview() webView.removeFromSuperview()
} }
/// 计算所有 spine 项的分页数
/// - Parameters:
/// - parser: EPUB 解析器
/// - hostingView: 承载 WebView 的父视图
/// - presentation: 展示样式(视口、内边距、字号、行高)
/// - completion: 完成回调,返回每个 spine 项的页数数组
public func calculate( public func calculate(
parser: RDEPUBParser, parser: RDEPUBParser,
hostingView: UIView, hostingView: UIView,
@@ -82,6 +108,7 @@ public final class RDEPUBPaginator: NSObject {
measureNextSpineItem() measureNextSpineItem()
} }
/// 便捷方法:使用独立参数计算所有 spine 项的分页数
public func calculate( public func calculate(
parser: RDEPUBParser, parser: RDEPUBParser,
hostingView: UIView, hostingView: UIView,
@@ -104,6 +131,13 @@ public final class RDEPUBPaginator: NSObject {
) )
} }
/// 仅计算单个 spine 项的分页数
/// - Parameters:
/// - parser: EPUB 解析器
/// - spineIndex: 要测量的 spine 项索引
/// - hostingView: 承载 WebView 的父视图
/// - presentation: 展示样式
/// - completion: 完成回调,返回该 spine 项的页数
public func calculateSingleSpinePageCount( public func calculateSingleSpinePageCount(
parser: RDEPUBParser, parser: RDEPUBParser,
spineIndex: Int, spineIndex: Int,
@@ -135,6 +169,7 @@ public final class RDEPUBPaginator: NSObject {
measureNextSpineItem() measureNextSpineItem()
} }
/// 便捷方法:使用独立参数计算单个 spine 项的分页数
public func calculateSingleSpinePageCount( public func calculateSingleSpinePageCount(
parser: RDEPUBParser, parser: RDEPUBParser,
spineIndex: Int, spineIndex: Int,
@@ -159,6 +194,7 @@ public final class RDEPUBPaginator: NSObject {
) )
} }
/// 静态方法:将分页 CSS 注入到 HTML 字符串中(用于预渲染场景)
public static func injectPaginationCSS( public static func injectPaginationCSS(
into html: String, into html: String,
viewportSize: CGSize, viewportSize: CGSize,
@@ -181,6 +217,7 @@ public final class RDEPUBPaginator: NSObject {
) )
} }
/// 测量下一个 spine 项:加载文件到 WebView,跳过不可渲染或文件缺失的项
private func measureNextSpineItem() { private func measureNextSpineItem() {
guard let parser else { guard let parser else {
finishIfNeeded() finishIfNeeded()
@@ -218,10 +255,12 @@ public final class RDEPUBPaginator: NSObject {
webView.loadFileURL(fileURL, allowingReadAccessTo: readAccessURL) webView.loadFileURL(fileURL, allowingReadAccessTo: readAccessURL)
} }
/// 获取当前正在测量的 spine 项索引
private func currentSpineIndexForMeasurement() -> Int? { private func currentSpineIndexForMeasurement() -> Int? {
measurementIndices[safe: currentMeasurementOffset] measurementIndices[safe: currentMeasurementOffset]
} }
/// 检查是否所有 spine 项都已测量完毕,触发对应的完成回调
private func finishIfNeeded() { private func finishIfNeeded() {
if let singlePageCountCompletion { if let singlePageCountCompletion {
let measuredSpineIndex = measurementIndices.first ?? 0 let measuredSpineIndex = measurementIndices.first ?? 0
@@ -236,6 +275,7 @@ public final class RDEPUBPaginator: NSObject {
cleanupMeasurementState() cleanupMeasurementState()
} }
/// 清理测量状态,释放 WebView 和所有引用
private func cleanupMeasurementState() { private func cleanupMeasurementState() {
parser = nil parser = nil
hostingView = nil hostingView = nil
@@ -251,11 +291,13 @@ public final class RDEPUBPaginator: NSObject {
webView.removeFromSuperview() webView.removeFromSuperview()
} }
/// 判断 spine 项是否可渲染(仅 HTML/XHTML/XML 类型可分页)
private func isRenderablePage(item: RDEPUBSpineItem) -> Bool { private func isRenderablePage(item: RDEPUBSpineItem) -> Bool {
let mediaType = item.mediaType.lowercased() let mediaType = item.mediaType.lowercased()
return mediaType.contains("html") || mediaType.contains("xhtml") || mediaType.contains("xml") return mediaType.contains("html") || mediaType.contains("xhtml") || mediaType.contains("xml")
} }
/// 调度多轮测量:0ms/80ms/180ms 三轮延迟,取最大值以应对布局抖动
private func scheduleMeasurementPass() { private func scheduleMeasurementPass() {
let sessionID = activeSessionID let sessionID = activeSessionID
let delays: [TimeInterval] = [0.0, 0.08, 0.18] let delays: [TimeInterval] = [0.0, 0.08, 0.18]
@@ -278,6 +320,7 @@ public final class RDEPUBPaginator: NSObject {
} }
} }
/// 执行当前文档的测量:通过 JS 获取 scrollWidth 并计算页数
private func measureCurrentDocument(sessionID: UUID) { private func measureCurrentDocument(sessionID: UUID) {
let script = RDEPUBStyleSheetBuilder.measurementScript(for: presentation) let script = RDEPUBStyleSheetBuilder.measurementScript(for: presentation)
RDEPUBWebViewDebug.logJavaScript(debugScope, webView: webView, action: "measure", details: "session=\(sessionID) pass=\(measurementPass)") RDEPUBWebViewDebug.logJavaScript(debugScope, webView: webView, action: "measure", details: "session=\(sessionID) pass=\(measurementPass)")
@@ -332,6 +375,7 @@ extension RDEPUBPaginator: WKNavigationDelegate {
} }
} }
/// Array 安全下标扩展,越界时返回 nil 而非崩溃
private extension Array { private extension Array {
subscript(safe index: Int) -> Element? { subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil indices.contains(index) ? self[index] : nil
@@ -1,7 +1,15 @@
// RDEPUBParser+Archive.swift
// EPUB 归档解压与 container.xml 解析
// 负责将 EPUB ZIP 文件解压到缓存目录(~/Library/Caches/ssreaderview-epub/),
// 并解析 META-INF/container.xml 获取 OPF 文件路径。
import Foundation import Foundation
import ZIPFoundation import ZIPFoundation
extension RDEPUBParser { extension RDEPUBParser {
/// 解析 container.xml,提取 OPF(Package Document)的相对路径
/// - Parameter containerURL: container.xml 的文件路径
/// - Returns: OPF 文件的相对路径(如 "OEBPS/content.opf")
func parseContainerRootFile(at containerURL: URL) throws -> String { func parseContainerRootFile(at containerURL: URL) throws -> String {
guard let parser = XMLParser(contentsOf: containerURL) else { guard let parser = XMLParser(contentsOf: containerURL) else {
throw RDEPUBParserError.invalidXML(containerURL) throw RDEPUBParserError.invalidXML(containerURL)
@@ -22,6 +30,10 @@ extension RDEPUBParser {
return rootFilePath return rootFilePath
} }
/// 解压 EPUB ZIP 文件到缓存目录(幂等:已存在则跳过)
/// 目录名由 slug + 文件大小 + 修改时间戳组成,避免冲突
/// - Parameter epubURL: EPUB 文件路径
/// - Returns: 解压目标目录 URL
func extractArchiveIfNeeded(epubURL: URL) throws -> URL { func extractArchiveIfNeeded(epubURL: URL) throws -> URL {
let fileManager = FileManager.default let fileManager = FileManager.default
let extractionURL = temporaryExtractionDirectory(for: epubURL) let extractionURL = temporaryExtractionDirectory(for: epubURL)
@@ -52,6 +64,8 @@ extension RDEPUBParser {
return extractionURL return extractionURL
} }
/// 计算 EPUB 的临时解压目录路径
/// 路径格式:~/Library/Caches/ssreaderview-epub/{slug}-{fileSize}-{modifiedTimestamp}/
func temporaryExtractionDirectory(for epubURL: URL) -> URL { func temporaryExtractionDirectory(for epubURL: URL) -> URL {
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first? let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
.appendingPathComponent("ssreaderview-epub", isDirectory: true) .appendingPathComponent("ssreaderview-epub", isDirectory: true)
@@ -65,12 +79,14 @@ extension RDEPUBParser {
return baseURL.appendingPathComponent("\(slug)-\(fileSize)-\(signature)", isDirectory: true) return baseURL.appendingPathComponent("\(slug)-\(fileSize)-\(signature)", isDirectory: true)
} }
/// 重置解析器状态(清除解压路径和出版物数据)
func reset() { func reset() {
extractionRootURL = nil extractionRootURL = nil
opfURL = nil opfURL = nil
resetPublicationState() resetPublicationState()
} }
/// 重置出版物相关状态(metadata、manifest、spine、目录)
func resetPublicationState() { func resetPublicationState() {
metadata = RDEPUBMetadata() metadata = RDEPUBMetadata()
manifest = [:] manifest = [:]
@@ -79,7 +95,9 @@ extension RDEPUBParser {
} }
} }
/// container.xml 的 SAX 解析代理,提取 <rootfile> 元素的 full-path 属性
private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate { private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate {
/// 解析得到的 OPF 文件相对路径
private(set) var rootFilePath: String? private(set) var rootFilePath: String?
func parser( func parser(
@@ -1,6 +1,18 @@
// RDEPUBParser+Package.swift
// OPF(Package Document)解析与 Spine 构建
// 包含 OPFPackageParserDelegate(SAX 解析 metadata/manifest/spine),
// 从解析结果构建 RDEPUBSpineItem 数组,处理 rendition:layout、page-spread 等属性。
// 同时定义 XMLName、XMLSection、XMLParserContext 等 XML 解析辅助工具。
import Foundation import Foundation
extension RDEPUBParser { extension RDEPUBParser {
/// 从 OPF 解析结果构建 spine 阅读顺序数组
/// 逐项匹配 manifest,解析 href、布局模式(fixed/reflowable)、page-spread 等属性
/// - Parameters:
/// - packageDocument: OPF 解析结果(含 metadata、manifest、spineReferences)
/// - opfURL: OPF 文件路径
/// - Returns: spine 项数组
func buildSpine(from packageDocument: OPFPackageDocument, opfURL: URL) throws -> [RDEPUBSpineItem] { func buildSpine(from packageDocument: OPFPackageDocument, opfURL: URL) throws -> [RDEPUBSpineItem] {
let opfDirectoryURL = opfURL.deletingLastPathComponent() let opfDirectoryURL = opfURL.deletingLastPathComponent()
let publicationLayout = packageDocument.metadata.layout let publicationLayout = packageDocument.metadata.layout
@@ -50,22 +62,33 @@ extension RDEPUBParser {
} }
} }
/// OPF 包文档的解析结果,包含 metadata、manifest、spine 引用及 NCX/nav 项
struct OPFPackageDocument { struct OPFPackageDocument {
var metadata: RDEPUBMetadata var metadata: RDEPUBMetadata
var manifest: [RDEPUBManifestItem] var manifest: [RDEPUBManifestItem]
var manifestByID: [String: RDEPUBManifestItem] var manifestByID: [String: RDEPUBManifestItem]
var spineReferences: [OPFSpineReference] var spineReferences: [OPFSpineReference]
/// NCX 目录文件的 manifest 项(EPUB 2)
var ncxItem: RDEPUBManifestItem? var ncxItem: RDEPUBManifestItem?
/// Navigation Document 的 manifest 项(EPUB 3)
var navigationItem: RDEPUBManifestItem? var navigationItem: RDEPUBManifestItem?
} }
/// OPF 中 spine/itemref 元素的解析结果
struct OPFSpineReference { struct OPFSpineReference {
/// 对应 manifest 中的 item id
var idref: String var idref: String
/// 是否为线性阅读项("no" 表示辅助内容)
var linear: Bool var linear: Bool
/// 属性列表(如 rendition:layout-pre-paginated、page-spread-left 等)
var properties: [String] var properties: [String]
/// 页面展开方向(左/右/居中)
var pageSpread: RDEPUBPageSpread? var pageSpread: RDEPUBPageSpread?
} }
/// OPF 文档的 SAX 解析代理
/// 解析 metadata(identifier/title/creator/language/rendition:layout 等)、
/// manifest(item 元素)、spine(itemref 元素)三个主要区域
final class OPFPackageParserDelegate: NSObject, XMLParserDelegate { final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
private var metadata = RDEPUBMetadata() private var metadata = RDEPUBMetadata()
private var manifestItems: [RDEPUBManifestItem] = [] private var manifestItems: [RDEPUBManifestItem] = []
@@ -79,6 +102,7 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
private var currentMetaRefinesID: String? private var currentMetaRefinesID: String?
private var ncxID: String? private var ncxID: String?
/// 将解析中间结果组装为 OPFPackageDocument 结构体
func packageDocument() -> OPFPackageDocument { func packageDocument() -> OPFPackageDocument {
if metadata.identifier == nil, let fallbackIdentifier = identifierByID.values.first { if metadata.identifier == nil, let fallbackIdentifier = identifierByID.values.first {
metadata.identifier = fallbackIdentifier metadata.identifier = fallbackIdentifier
@@ -227,6 +251,7 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
xmlContext.endElement() xmlContext.endElement()
} }
/// 当前解析所在的 OPF 区域(metadata/manifest/spine/other)
private var currentSection: XMLSection { private var currentSection: XMLSection {
if xmlContext.containsElement(named: "manifest") { if xmlContext.containsElement(named: "manifest") {
return .manifest return .manifest
@@ -240,6 +265,7 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
return .other return .other
} }
/// 处理 <meta> 元素的文本内容,提取 rendition:layout、rendition:spread、identifier 等
private func applyMetaValue(_ value: String) { private func applyMetaValue(_ value: String) {
let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedValue.isEmpty, let property = currentMetaProperty?.lowercased() else { guard !normalizedValue.isEmpty, let property = currentMetaProperty?.lowercased() else {
@@ -264,6 +290,7 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
} }
} }
/// 从属性字典中提取 page-spread 信息(支持 properties 和 page-spread 两种写法)
private func pageSpread(from attributes: [String: String]) -> RDEPUBPageSpread? { private func pageSpread(from attributes: [String: String]) -> RDEPUBPageSpread? {
if let properties = attributes["properties"]?.lowercased() { if let properties = attributes["properties"]?.lowercased() {
if properties.contains("page-spread-left") { if properties.contains("page-spread-left") {
@@ -293,11 +320,13 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
return nil return nil
} }
/// 将 rendition:layout 原始值转换为布局枚举
private func layout(from rawValue: String) -> RDEPUBLayout { private func layout(from rawValue: String) -> RDEPUBLayout {
rawValue.lowercased().contains("pre-paginated") ? .fixed : .reflowable rawValue.lowercased().contains("pre-paginated") ? .fixed : .reflowable
} }
} }
/// OPF 文档的区域枚举,用于区分当前解析位置
enum XMLSection { enum XMLSection {
case metadata case metadata
case manifest case manifest
@@ -305,11 +334,14 @@ enum XMLSection {
case other case other
} }
/// XML 名称解析工具:提取本地名、分词、提取 refines ID
enum XMLName { enum XMLName {
/// 从带命名空间前缀的名称中提取本地名(如 "dc:title" -> "title")
static func localName(from rawName: String) -> String { static func localName(from rawName: String) -> String {
rawName.split(separator: ":").last.map(String.init) ?? rawName rawName.split(separator: ":").last.map(String.init) ?? rawName
} }
/// 将空格分隔的属性值拆分为 token 数组
static func tokenize(_ rawValue: String?) -> [String] { static func tokenize(_ rawValue: String?) -> [String] {
guard let rawValue else { return [] } guard let rawValue else { return [] }
return rawValue return rawValue
@@ -317,6 +349,7 @@ enum XMLName {
.map(String.init) .map(String.init)
} }
/// 从 refines 属性值中提取 ID(去掉 "#" 前缀)
static func refinedID(from rawValue: String) -> String? { static func refinedID(from rawValue: String) -> String? {
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("#") else { guard trimmed.hasPrefix("#") else {
@@ -326,10 +359,14 @@ enum XMLName {
} }
} }
/// XML 解析上下文,维护元素栈和当前文本内容
final class XMLParserContext { final class XMLParserContext {
/// 元素名称栈,用于追踪嵌套层级
private var elementStack: [String] = [] private var elementStack: [String] = []
/// 当前元素累积的文本内容
private var currentCharacters = "" private var currentCharacters = ""
/// 去除空白后的当前文本
var trimmedCharacters: String { var trimmedCharacters: String {
currentCharacters.trimmingCharacters(in: .whitespacesAndNewlines) currentCharacters.trimmingCharacters(in: .whitespacesAndNewlines)
} }
@@ -1,6 +1,13 @@
// RDEPUBParser+ReadingProfile.swift
// EPUB 阅读配置文件判断
// 根据 metadata 的 layout 属性和 manifest/spine 内容,
// 将出版物分类为 webFixedLayout(固定版式)、webInteractive(含脚本/多媒体)或 textReflowable(纯文本重排)。
import Foundation import Foundation
extension RDEPUBParser { extension RDEPUBParser {
/// 判断出版物的阅读配置文件
/// 优先判断固定版式,其次检测交互内容(脚本/iframe/视频等),默认为文本重排
public func readingProfile() -> RDEPUBReadingProfile { public func readingProfile() -> RDEPUBReadingProfile {
if metadata.layout == .fixed { if metadata.layout == .fixed {
return .webFixedLayout return .webFixedLayout
@@ -8,6 +15,9 @@ extension RDEPUBParser {
return hasInteractiveContent() ? .webInteractive : .textReflowable return hasInteractiveContent() ? .webInteractive : .textReflowable
} }
/// 检测出版物是否包含交互内容
/// 通过 manifest 中的脚本媒体类型和 "scripted" 属性,
/// 以及扫描 HTML 中的 script/iframe/video/audio/canvas/svg/form 等标签
public func hasInteractiveContent() -> Bool { public func hasInteractiveContent() -> Bool {
let interactiveMediaTypes = [ let interactiveMediaTypes = [
"application/javascript", "application/javascript",
@@ -1,11 +1,18 @@
// RDEPUBParser+Resources.swift
// EPUB 资源查询与 URL 解析
// 提供按 ID、href 查询 manifest 项,spine 索引与 href 互转,
// 文件路径与 ss-reader:// 协议 URL 互转,封面图提取,HTML 内容读取等能力。
import Foundation import Foundation
import UIKit import UIKit
extension RDEPUBParser { extension RDEPUBParser {
/// 按 ID 查询 manifest 项
public func manifestItem(for id: String) -> RDEPUBManifestItem? { public func manifestItem(for id: String) -> RDEPUBManifestItem? {
manifest[id] manifest[id]
} }
/// 按 href 查询 manifest 项(忽略 fragment)
public func manifestItem(forHref href: String) -> RDEPUBManifestItem? { public func manifestItem(forHref href: String) -> RDEPUBManifestItem? {
let target = href.components(separatedBy: "#").first ?? href let target = href.components(separatedBy: "#").first ?? href
return manifest.values.first { item in return manifest.values.first { item in
@@ -13,11 +20,13 @@ extension RDEPUBParser {
} }
} }
/// 通过 spine 索引获取 href
public func href(forSpineIndex index: Int) -> String? { public func href(forSpineIndex index: Int) -> String? {
guard spine.indices.contains(index) else { return nil } guard spine.indices.contains(index) else { return nil }
return spine[index].href return spine[index].href
} }
/// 将相对路径转换为本地文件 URL,并校验是否在解压根目录内(防路径遍历攻击)
public func fileURL(forRelativePath relativePath: String) -> URL? { public func fileURL(forRelativePath relativePath: String) -> URL? {
guard let opfDirectoryURL else { return nil } guard let opfDirectoryURL else { return nil }
let path = relativePath.components(separatedBy: "#").first ?? relativePath let path = relativePath.components(separatedBy: "#").first ?? relativePath
@@ -32,6 +41,7 @@ extension RDEPUBParser {
return resolvedURL return resolvedURL
} }
/// 将相对路径转换为 ss-reader://book/ 协议的 URL(用于 WebView 加载资源)
public func resourceURL(forRelativePath relativePath: String) -> URL? { public func resourceURL(forRelativePath relativePath: String) -> URL? {
let normalizedPath = relativePath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let normalizedPath = relativePath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard !normalizedPath.isEmpty else { return nil } guard !normalizedPath.isEmpty else { return nil }
@@ -45,6 +55,7 @@ extension RDEPUBParser {
return components.url return components.url
} }
/// 将 ss-reader://book/ 协议的 URL 还原为本地文件 URL
public func fileURL(forResourceURL resourceURL: URL) -> URL? { public func fileURL(forResourceURL resourceURL: URL) -> URL? {
guard resourceURL.scheme == RDEPUBResourceURLSchemeHandler.scheme, guard resourceURL.scheme == RDEPUBResourceURLSchemeHandler.scheme,
resourceURL.host == RDEPUBResourceURLSchemeHandler.host else { resourceURL.host == RDEPUBResourceURLSchemeHandler.host else {
@@ -54,6 +65,7 @@ extension RDEPUBParser {
return fileURL(forRelativePath: relativePath) return fileURL(forRelativePath: relativePath)
} }
/// 提取封面图片(优先查找含 "cover-image" 属性或 ID 含 "cover" 的 manifest 项)
public func coverImage() -> UIImage? { public func coverImage() -> UIImage? {
let coverCandidates = manifest.values.filter { item in let coverCandidates = manifest.values.filter { item in
item.properties.contains("cover-image") || item.id.lowercased().contains("cover") item.properties.contains("cover-image") || item.id.lowercased().contains("cover")
@@ -67,6 +79,7 @@ extension RDEPUBParser {
return nil return nil
} }
/// 通过 spine 索引读取 HTML 内容
public func htmlString(forSpineIndex index: Int) -> String? { public func htmlString(forSpineIndex index: Int) -> String? {
guard spine.indices.contains(index) else { guard spine.indices.contains(index) else {
return nil return nil
@@ -74,6 +87,7 @@ extension RDEPUBParser {
return htmlString(forRelativePath: spine[index].href) return htmlString(forRelativePath: spine[index].href)
} }
/// 通过相对路径读取 HTML 内容
public func htmlString(forRelativePath relativePath: String) -> String? { public func htmlString(forRelativePath relativePath: String) -> String? {
guard let fileURL = fileURL(forRelativePath: relativePath) else { guard let fileURL = fileURL(forRelativePath: relativePath) else {
return nil return nil
@@ -81,6 +95,7 @@ extension RDEPUBParser {
return try? String(contentsOf: fileURL) return try? String(contentsOf: fileURL)
} }
/// 将 href 相对于目录 URL 解析为标准化的相对路径
func normalize(href: String, relativeTo directoryURL: URL) -> String { func normalize(href: String, relativeTo directoryURL: URL) -> String {
guard let resolvedURL = URL(string: href, relativeTo: directoryURL)?.standardizedFileURL else { guard let resolvedURL = URL(string: href, relativeTo: directoryURL)?.standardizedFileURL else {
return href return href
@@ -1,6 +1,14 @@
// RDEPUBParser+TOC.swift
// EPUB 目录解析
// 支持两种目录格式:NCX(EPUB 2)和 Navigation Document(EPUB 3),
// 优先使用 NCX,其次 Nav Document,最终回退到 spine 列表。
// 内部使用 TOCTreeBuilder 构建树状目录结构。
import Foundation import Foundation
extension RDEPUBParser { extension RDEPUBParser {
/// 从 OPF 解析结果中解析目录
/// 优先尝试 NCX,再尝试 Nav Document,两者都失败则回退到 spine 列表
func parseTOC(from packageDocument: OPFPackageDocument, opfURL: URL) -> [EPUBTableOfContentsItem] { func parseTOC(from packageDocument: OPFPackageDocument, opfURL: URL) -> [EPUBTableOfContentsItem] {
let opfDirectoryURL = opfURL.deletingLastPathComponent() let opfDirectoryURL = opfURL.deletingLastPathComponent()
@@ -23,6 +31,7 @@ extension RDEPUBParser {
return spine.map { EPUBTableOfContentsItem(title: $0.title, href: $0.href) } return spine.map { EPUBTableOfContentsItem(title: $0.title, href: $0.href) }
} }
/// 解析 NCX(EPUB 2)目录文件
func parseNCXDocumentItems(at ncxURL: URL) -> [EPUBTableOfContentsItem] { func parseNCXDocumentItems(at ncxURL: URL) -> [EPUBTableOfContentsItem] {
guard FileManager.default.fileExists(atPath: ncxURL.path), guard FileManager.default.fileExists(atPath: ncxURL.path),
let parser = XMLParser(contentsOf: ncxURL) else { let parser = XMLParser(contentsOf: ncxURL) else {
@@ -40,6 +49,7 @@ extension RDEPUBParser {
return delegate.items return delegate.items
} }
/// 解析 Navigation Document(EPUB 3)目录文件
func parseNavDocumentItems(at navURL: URL, baseURL: URL) -> [EPUBTableOfContentsItem] { func parseNavDocumentItems(at navURL: URL, baseURL: URL) -> [EPUBTableOfContentsItem] {
guard FileManager.default.fileExists(atPath: navURL.path), guard FileManager.default.fileExists(atPath: navURL.path),
let parser = XMLParser(contentsOf: navURL) else { let parser = XMLParser(contentsOf: navURL) else {
@@ -57,6 +67,7 @@ extension RDEPUBParser {
return delegate.items return delegate.items
} }
/// 标准化目录 href(拆分路径和 fragment,对路径部分做相对路径解析)
func normalizeTOCHref(_ href: String, relativeTo baseURL: URL) -> String? { func normalizeTOCHref(_ href: String, relativeTo baseURL: URL) -> String? {
let components = href.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false) let components = href.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
let pathPart = components.first.map(String.init) ?? href let pathPart = components.first.map(String.init) ?? href
@@ -80,11 +91,13 @@ extension RDEPUBParser {
} }
} }
/// 目录树节点,存储标题、href 和子节点,最终转换为 EPUBTableOfContentsItem
private final class TOCNode { private final class TOCNode {
var title = "" var title = ""
var href: String? var href: String?
var children: [TOCNode] = [] var children: [TOCNode] = []
/// 将节点及其子节点递归转换为目录项
func asItem() -> EPUBTableOfContentsItem? { func asItem() -> EPUBTableOfContentsItem? {
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard let href, !href.isEmpty else { guard let href, !href.isEmpty else {
@@ -98,10 +111,14 @@ private final class TOCNode {
} }
} }
/// 目录树构建器:通过 beginNode/endNode 操作维护节点栈,构建嵌套目录结构
private final class TOCTreeBuilder { private final class TOCTreeBuilder {
/// 当前嵌套路径上的节点栈
private var nodeStack: [TOCNode] = [] private var nodeStack: [TOCNode] = []
/// 顶层根节点列表
private var rootNodes: [TOCNode] = [] private var rootNodes: [TOCNode] = []
/// 获取构建完成的目录项列表
var items: [EPUBTableOfContentsItem] { var items: [EPUBTableOfContentsItem] {
rootNodes.compactMap { $0.asItem() } rootNodes.compactMap { $0.asItem() }
} }
@@ -134,6 +151,7 @@ private final class TOCTreeBuilder {
} }
} }
/// NCX 文档的 SAX 解析代理,解析 <navPoint>、<content>、<text> 元素
private final class NCXParserDelegate: NSObject, XMLParserDelegate { private final class NCXParserDelegate: NSObject, XMLParserDelegate {
private let baseURL: URL private let baseURL: URL
private let hrefNormalizer: (String, URL) -> String? private let hrefNormalizer: (String, URL) -> String?
@@ -198,6 +216,7 @@ private final class NCXParserDelegate: NSObject, XMLParserDelegate {
} }
} }
/// Navigation Document 的 SAX 解析代理,解析含 epub:type="toc" 的 <nav> 元素
private final class NavDocumentParserDelegate: NSObject, XMLParserDelegate { private final class NavDocumentParserDelegate: NSObject, XMLParserDelegate {
private let baseURL: URL private let baseURL: URL
private let hrefNormalizer: (String, URL) -> String? private let hrefNormalizer: (String, URL) -> String?
@@ -284,6 +303,7 @@ private final class NavDocumentParserDelegate: NSObject, XMLParserDelegate {
} }
} }
/// 判断 <nav> 元素是否为 TOC 导航(检查 epub:type、type、role 属性)
private func isTOCNav(attributes: [String: String]) -> Bool { private func isTOCNav(attributes: [String: String]) -> Bool {
if let epubType = attributes["epub:type"]?.lowercased(), epubType.contains("toc") { if let epubType = attributes["epub:type"]?.lowercased(), epubType.contains("toc") {
return true return true
@@ -1,23 +1,41 @@
// RDEPUBParser.swift
// EPUB 解析器入口
// 解析链路:epubURL → ZIP 解压 → container.xml → OPF → spine/TOC
// 是 EPUBCore Publication 层的核心,解析完成后通过 makePublication() 生成 RDEPUBPublication。
// 具体解析逻辑分散在 +Archive、+Package、+TOC、+Resources、+ReadingProfile 扩展中。
import Foundation import Foundation
/// EPUB 解析器,执行从 epub 文件到出版物数据的完整解析流程
/// 主链路:epubURL → extractArchiveIfNeeded → container.xml → OPF → metadata/manifest/spine/TOC
public final class RDEPUBParser { public final class RDEPUBParser {
/// 出版物元数据(标题、作者、语言、布局模式等)
public internal(set) var metadata = RDEPUBMetadata() public internal(set) var metadata = RDEPUBMetadata()
/// 资源清单字典(key 为 manifest item ID)
public internal(set) var manifest: [String: RDEPUBManifestItem] = [:] public internal(set) var manifest: [String: RDEPUBManifestItem] = [:]
/// 阅读顺序列表(按 spine 排列的资源项)
public internal(set) var spine: [RDEPUBSpineItem] = [] public internal(set) var spine: [RDEPUBSpineItem] = []
/// 目录树(从 NCX 或 Nav Document 解析得到)
public internal(set) var tableOfContents: [EPUBTableOfContentsItem] = [] public internal(set) var tableOfContents: [EPUBTableOfContentsItem] = []
/// EPUB 解压后的根目录路径
public internal(set) var extractionRootURL: URL? public internal(set) var extractionRootURL: URL?
/// OPF 文件的完整路径
public internal(set) var opfURL: URL? public internal(set) var opfURL: URL?
/// OPF 文件所在目录的 URL(用于解析相对路径)
public var opfDirectoryURL: URL? { public var opfDirectoryURL: URL? {
opfURL?.deletingLastPathComponent() opfURL?.deletingLastPathComponent()
} }
public init() {} public init() {}
/// 基于当前解析结果创建 RDEPUBPublication(门面对象)
public func makePublication() -> RDEPUBPublication { public func makePublication() -> RDEPUBPublication {
RDEPUBPublication(parser: self) RDEPUBPublication(parser: self)
} }
/// 完整解析 EPUB 文件:解压 → container.xml → OPF
/// - Parameter epubURL: EPUB 文件的本地路径
public func parse(epubURL: URL) throws { public func parse(epubURL: URL) throws {
reset() reset()
@@ -38,6 +56,8 @@ public final class RDEPUBParser {
try parseOPF(at: packageURL) try parseOPF(at: packageURL)
} }
/// 解析 OPF 文件:metadata/manifest/spine/TOC
/// - Parameter opfURL: OPF 文件路径
public func parseOPF(at opfURL: URL) throws { public func parseOPF(at opfURL: URL) throws {
resetPublicationState() resetPublicationState()
@@ -61,10 +81,12 @@ public final class RDEPUBParser {
self.tableOfContents = parseTOC(from: packageDocument, opfURL: opfURL) self.tableOfContents = parseTOC(from: packageDocument, opfURL: opfURL)
} }
/// 获取已解析的目录
public func parseTOC() -> [EPUBTableOfContentsItem] { public func parseTOC() -> [EPUBTableOfContentsItem] {
tableOfContents tableOfContents
} }
/// 解析指定的 Navigation Document 文件
public func parseNavDocument(_ navURL: URL, baseURL: URL? = nil) -> [EPUBTableOfContentsItem] { public func parseNavDocument(_ navURL: URL, baseURL: URL? = nil) -> [EPUBTableOfContentsItem] {
parseNavDocumentItems(at: navURL, baseURL: baseURL ?? navURL.deletingLastPathComponent()) parseNavDocumentItems(at: navURL, baseURL: baseURL ?? navURL.deletingLastPathComponent())
} }
@@ -1,14 +1,29 @@
// RDEPUBPreferences.swift
// EPUB 阅读偏好设置
// 封装字号、行高、内容内边距、主题颜色、固定版式适配模式等展示参数,
// 并提供从偏好设置生成渲染请求和展示样式的便捷方法。
import UIKit import UIKit
/// EPUB 阅读偏好设置,包含所有影响渲染效果的展示参数
public struct RDEPUBPreferences: Equatable { public struct RDEPUBPreferences: Equatable {
/// 字体大小(pt)
public var fontSize: CGFloat public var fontSize: CGFloat
/// 行高倍数(如 1.5 表示 1.5 倍行高)
public var lineHeightMultiple: CGFloat public var lineHeightMultiple: CGFloat
/// 重排模式下的内容内边距
public var reflowableContentInsets: UIEdgeInsets public var reflowableContentInsets: UIEdgeInsets
/// 固定版式下的内容内边距
public var fixedContentInset: UIEdgeInsets public var fixedContentInset: UIEdgeInsets
/// 主题背景色(CSS 颜色值,如 "#FFFFFF")
public var themeBackgroundColor: String? public var themeBackgroundColor: String?
/// 主题文字色(CSS 颜色值)
public var themeTextColor: String? public var themeTextColor: String?
/// 固定版式背景色
public var fixedBackgroundColor: String? public var fixedBackgroundColor: String?
/// 固定版式适配模式(page/width/height/contain/cover)
public var fixedLayoutFit: RDEPUBFixedLayoutFit public var fixedLayoutFit: RDEPUBFixedLayoutFit
/// 固定版式双页展开模式(never/always/automatic)
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode
public init( public init(
@@ -33,6 +48,7 @@ public struct RDEPUBPreferences: Equatable {
self.fixedLayoutSpreadMode = fixedLayoutSpreadMode self.fixedLayoutSpreadMode = fixedLayoutSpreadMode
} }
/// 从偏好设置生成重排模式的展示样式
public func presentationStyle(viewportSize: CGSize) -> RDEPUBPresentationStyle { public func presentationStyle(viewportSize: CGSize) -> RDEPUBPresentationStyle {
RDEPUBPresentationStyle( RDEPUBPresentationStyle(
viewportSize: viewportSize, viewportSize: viewportSize,
@@ -44,6 +60,7 @@ public struct RDEPUBPreferences: Equatable {
) )
} }
/// 根据页面和偏好设置生成对应的渲染请求(自动区分固定版式和重排)
public func renderRequest( public func renderRequest(
for page: EPUBPage, for page: EPUBPage,
publication: RDEPUBPublication, publication: RDEPUBPublication,
@@ -1,7 +1,16 @@
// RDEPUBPublication.swift
// EPUB 出版物门面(Facade)
// 对 RDEPUBParser 的高层封装,提供 metadata、spine、TOC 等只读访问,
// 以及固定版式双页展开判断和 spread 生成等业务逻辑。
// 是 EPUBCore Publication 层对外暴露的主要接口。
import Foundation import Foundation
/// EPUB 出版物门面对象,封装解析结果并提供高层访问接口
public final class RDEPUBPublication { public final class RDEPUBPublication {
/// 底层解析器(持有解析数据)
public let parser: RDEPUBParser public let parser: RDEPUBParser
/// 资源解析器(URL 转换和路径规范化)
public let resourceResolver: RDEPUBResourceResolver public let resourceResolver: RDEPUBResourceResolver
public init(parser: RDEPUBParser) { public init(parser: RDEPUBParser) {
@@ -9,38 +18,48 @@ public final class RDEPUBPublication {
self.resourceResolver = RDEPUBResourceResolver(parser: parser) self.resourceResolver = RDEPUBResourceResolver(parser: parser)
} }
/// 元数据代理
public var metadata: RDEPUBMetadata { public var metadata: RDEPUBMetadata {
parser.metadata parser.metadata
} }
/// 资源清单代理
public var manifest: [String: RDEPUBManifestItem] { public var manifest: [String: RDEPUBManifestItem] {
parser.manifest parser.manifest
} }
/// 阅读顺序代理
public var spine: [RDEPUBSpineItem] { public var spine: [RDEPUBSpineItem] {
parser.spine parser.spine
} }
/// 目录代理
public var tableOfContents: [EPUBTableOfContentsItem] { public var tableOfContents: [EPUBTableOfContentsItem] {
parser.tableOfContents parser.tableOfContents
} }
/// 布局模式(fixed/reflowable)
public var layout: RDEPUBLayout { public var layout: RDEPUBLayout {
metadata.layout metadata.layout
} }
/// 阅读配置文件(webFixedLayout/webInteractive/textReflowable)
public var readingProfile: RDEPUBReadingProfile { public var readingProfile: RDEPUBReadingProfile {
parser.readingProfile() parser.readingProfile()
} }
/// 阅读方向(ltr/rtl/auto)
public var readingProgression: RDEPUBReadingProgression { public var readingProgression: RDEPUBReadingProgression {
metadata.readingProgression metadata.readingProgression
} }
/// 书籍唯一标识符
public var bookIdentifier: String? { public var bookIdentifier: String? {
metadata.identifier metadata.identifier
} }
/// 判断固定版式是否启用双页展开模式
/// 综合考虑 metadata.spread、偏好设置和视口方向
public func fixedLayoutSpreadEnabled(for preferences: RDEPUBPreferences, viewportSize: CGSize) -> Bool { public func fixedLayoutSpreadEnabled(for preferences: RDEPUBPreferences, viewportSize: CGSize) -> Bool {
guard layout == .fixed else { guard layout == .fixed else {
return false return false
@@ -59,6 +78,7 @@ public final class RDEPUBPublication {
} }
} }
/// 根据偏好和视口生成固定版式的 spread 数组
public func makeFixedSpreads(preferences: RDEPUBPreferences, viewportSize: CGSize) -> [EPUBFixedSpread] { public func makeFixedSpreads(preferences: RDEPUBPreferences, viewportSize: CGSize) -> [EPUBFixedSpread] {
EPUBFixedSpread.makeSpreads( EPUBFixedSpread.makeSpreads(
spine: spine, spine: spine,
@@ -1,11 +1,30 @@
//
// RDEPUBReadingModels.swift
// RDReaderView
//
// EPUBCore 层阅读会话相关数据模型定义文件。
// 包含阅读位置(Location)、视口(Viewport)、阅读上下文(ReadingContext)、
// 文本选择(Selection)、高亮(Highlight)、书签(Bookmark)、
// 章节信息(ChapterInfo)、页面模型(Page)、固定版式 Spread 模型等。
// 这些模型由 RDEPUBReadingSession 管理,贯穿整个阅读生命周期。
//
import Foundation import Foundation
/// 阅读位置模型,EPUB 是可重排内容,使用 href + progression 而非页号来定位
/// progression 取值 [0, 1],表示当前视口在章节中的相对位置
public struct RDEPUBLocation: Codable, Equatable { public struct RDEPUBLocation: Codable, Equatable {
/// 书籍唯一标识符,用于隔离不同书的阅读进度
public var bookIdentifier: String? public var bookIdentifier: String?
/// OPF 相对路径,对应 spine 中的资源文件
public var href: String public var href: String
/// 视口起始位置在章节中的相对进度 [0, 1]
public var progression: Double public var progression: Double
/// 视口末尾位置在章节中的相对进度 [0, 1](可选)
public var lastProgression: Double? public var lastProgression: Double?
/// 锚点标识符(如 #section1),用于精确跳转
public var fragment: String? public var fragment: String?
/// 文本范围锚点(用于文本 EPUB 的高亮定位)
public var rangeAnchor: RDEPUBTextRangeAnchor? public var rangeAnchor: RDEPUBTextRangeAnchor?
public init( public init(
@@ -24,6 +43,8 @@ public struct RDEPUBLocation: Codable, Equatable {
self.rangeAnchor = rangeAnchor self.rangeAnchor = rangeAnchor
} }
/// 导航用的中间进度值,取 progression 和 lastProgression 的中点
/// 用于在字号/横竖屏变化后估算最接近的页号
public var navigationProgression: Double { public var navigationProgression: Double {
let end = lastProgression ?? progression let end = lastProgression ?? progression
if end.isNaN || end.isInfinite { if end.isNaN || end.isInfinite {
@@ -32,17 +53,24 @@ public struct RDEPUBLocation: Codable, Equatable {
return Self.clamp((progression + end) / 2.0) return Self.clamp((progression + end) / 2.0)
} }
/// 将进度值限制在 [0, 1] 范围内,防止越界
private static func clamp(_ value: Double) -> Double { private static func clamp(_ value: Double) -> Double {
guard value.isFinite else { return 0 } guard value.isFinite else { return 0 }
return min(1, max(0, value)) return min(1, max(0, value))
} }
} }
/// 视口中的单个资源信息,表示当前屏幕可见区域覆盖的某个 spine 资源
public struct RDEPUBViewportResource: Codable, Equatable { public struct RDEPUBViewportResource: Codable, Equatable {
/// 资源路径(相对于 OPF 目录)
public var href: String public var href: String
/// 对应的 spine 索引
public var spineIndex: Int public var spineIndex: Int
/// 该资源在视口中的起始进度 [0, 1]
public var progression: Double public var progression: Double
/// 该资源在视口中的结束进度 [0, 1]
public var lastProgression: Double? public var lastProgression: Double?
/// 锚点标识符
public var fragment: String? public var fragment: String?
public init( public init(
@@ -60,10 +88,16 @@ public struct RDEPUBViewportResource: Codable, Equatable {
} }
} }
/// 视口模型,描述当前屏幕可见区域的完整状态
/// 可能跨越多个 spine 资源(如分栏布局时)
public struct RDEPUBViewport: Codable, Equatable { public struct RDEPUBViewport: Codable, Equatable {
/// 当前视口覆盖的资源列表(可能有多个,如分栏或跨页场景)
public var resources: [RDEPUBViewportResource] public var resources: [RDEPUBViewportResource]
/// 当前可见的页码
public var visiblePageNumber: Int public var visiblePageNumber: Int
/// 当前章节索引
public var chapterIndex: Int? public var chapterIndex: Int?
/// 是否为固定版式内容
public var isFixedLayout: Bool public var isFixedLayout: Bool
public init( public init(
@@ -79,10 +113,16 @@ public struct RDEPUBViewport: Codable, Equatable {
} }
} }
/// 阅读上下文,聚合了位置、视口和页码等完整阅读状态
/// 由 RDEPUBReadingSession 维护,是翻页容器层获取当前阅读状态的主要数据源
public struct RDEPUBReadingContext: Codable, Equatable { public struct RDEPUBReadingContext: Codable, Equatable {
/// 当前阅读位置(href + progression)
public var location: RDEPUBLocation public var location: RDEPUBLocation
/// 当前视口状态
public var viewport: RDEPUBViewport public var viewport: RDEPUBViewport
/// 当前扁平页码(跨章节累计)
public var pageNumber: Int public var pageNumber: Int
/// 当前章节索引
public var chapterIndex: Int? public var chapterIndex: Int?
public init( public init(
@@ -98,11 +138,17 @@ public struct RDEPUBReadingContext: Codable, Equatable {
} }
} }
/// 文本选择模型,由 JS 桥接 ssReaderSelectionChanged 消息触发创建
public struct RDEPUBSelection: Codable, Equatable { public struct RDEPUBSelection: Codable, Equatable {
/// 书籍标识符
public var bookIdentifier: String? public var bookIdentifier: String?
/// 选择发生的位置
public var location: RDEPUBLocation public var location: RDEPUBLocation
/// 选中的文本内容
public var text: String public var text: String
/// DOM Range 信息(JSON 序列化字符串,用于精确重建选区)
public var rangeInfo: String? public var rangeInfo: String?
/// 选择创建时间
public var createdAt: Date public var createdAt: Date
public init( public init(
@@ -119,28 +165,44 @@ public struct RDEPUBSelection: Codable, Equatable {
self.createdAt = createdAt self.createdAt = createdAt
} }
/// 判断是否为空选择(无文本或无范围信息)
public var isEmpty: Bool { public var isEmpty: Bool {
text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || rangeInfo?.isEmpty != false text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || rangeInfo?.isEmpty != false
} }
} }
/// 高亮样式类型
/// - highlight: 背景色高亮
/// - underline: 下划线标记
public enum RDEPUBHighlightStyle: String, Codable { public enum RDEPUBHighlightStyle: String, Codable {
case highlight case highlight
case underline case underline
} }
/// 标注菜单操作类型,用于用户长按选中文本后的操作选项
public enum RDEPUBAnnotationMenuAction: Equatable { public enum RDEPUBAnnotationMenuAction: Equatable {
case copy case copy
case highlight case highlight
case annotate case annotate
} }
/// 文本偏移范围信息,用于文本 EPUB 的高亮精确定位
/// 使用字符偏移量而非 DOM Range,适用于 DTCoreText 渲染的纯文本内容
public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable { public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
/// 类型标识符,固定为 "text-offset"
public var kind: String public var kind: String
/// 资源路径
public var href: String public var href: String
/// 起始字符偏移量
public var start: Int public var start: Int
/// 结束字符偏移量
public var end: Int public var end: Int
/// 初始化文本偏移范围
/// - Parameters:
/// - href: 资源路径
/// - start: 起始字符偏移量
/// - end: 结束字符偏移量
public init(href: String, start: Int, end: Int) { public init(href: String, start: Int, end: Int) {
self.kind = "text-offset" self.kind = "text-offset"
self.href = href self.href = href
@@ -148,16 +210,19 @@ public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
self.end = end self.end = end
} }
/// 转换为 NSRange(用于 NSAttributedString 操作)
public var nsRange: NSRange? { public var nsRange: NSRange? {
guard end > start else { return nil } guard end > start else { return nil }
return NSRange(location: start, length: end - start) return NSRange(location: start, length: end - start)
} }
/// 序列化为 JSON 字符串
public func jsonString() -> String? { public func jsonString() -> String? {
guard let data = try? JSONEncoder().encode(self) else { return nil } guard let data = try? JSONEncoder().encode(self) else { return nil }
return String(data: data, encoding: .utf8) return String(data: data, encoding: .utf8)
} }
/// 从 JSON 字符串反序列化,校验 kind 和范围有效性
public static func decode(from string: String?) -> RDEPUBTextOffsetRangeInfo? { public static func decode(from string: String?) -> RDEPUBTextOffsetRangeInfo? {
guard let string, guard let string,
let data = string.data(using: .utf8), let data = string.data(using: .utf8),
@@ -170,28 +235,48 @@ public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
} }
} }
/// 文本分页截断原因,描述为什么在当前位置分页
public enum RDEPUBTextPageBreakReason: String, Codable, Equatable { public enum RDEPUBTextPageBreakReason: String, Codable, Equatable {
/// 章节自然结束
case chapterEnd case chapterEnd
/// 达到帧容量限制
case frameLimit case frameLimit
/// 块级元素边界(如段落、标题)
case blockBoundary case blockBoundary
/// 附件(图片等)边界
case attachmentBoundary case attachmentBoundary
/// 语义边界(如列表、引用块)
case semanticBoundary case semanticBoundary
} }
/// 文本附件类型
public enum RDEPUBTextAttachmentKind: String, Codable, Equatable { public enum RDEPUBTextAttachmentKind: String, Codable, Equatable {
/// 图片附件
case image case image
/// 通用附件
case generic case generic
} }
/// 文本页元数据,记录分页时的上下文信息
/// 用于调试分页问题和优化分页质量
public struct RDEPUBTextPageMetadata: Codable, Equatable { public struct RDEPUBTextPageMetadata: Codable, Equatable {
/// 分页截断原因
public var breakReason: RDEPUBTextPageBreakReason public var breakReason: RDEPUBTextPageBreakReason
/// 本页覆盖的块级元素范围
public var blockRange: NSRange? public var blockRange: NSRange?
/// 本页包含的附件范围列表
public var attachmentRanges: [NSRange] public var attachmentRanges: [NSRange]
/// 附件类型列表
public var attachmentKinds: [RDEPUBTextAttachmentKind] public var attachmentKinds: [RDEPUBTextAttachmentKind]
/// 块级元素类型列表
public var blockKinds: [RDEPUBTextBlockKind] public var blockKinds: [RDEPUBTextBlockKind]
/// 语义提示信息
public var semanticHints: [RDEPUBTextSemanticHint] public var semanticHints: [RDEPUBTextSemanticHint]
/// 附件布局位置信息
public var attachmentPlacements: [RDEPUBTextAttachmentPlacement] public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
/// 本页末尾的 fragment ID(用于恢复位置)
public var trailingFragmentID: String? public var trailingFragmentID: String?
/// 诊断信息列表(调试用)
public var diagnostics: [String] public var diagnostics: [String]
public init( public init(
@@ -217,15 +302,26 @@ public struct RDEPUBTextPageMetadata: Codable, Equatable {
} }
} }
/// 高亮模型,表示用户在阅读中标记的文本片段
/// 支持 Web EPUB(DOM Range 定位)和文本 EPUB(字符偏移定位)两种模式
public struct RDEPUBHighlight: Codable, Equatable { public struct RDEPUBHighlight: Codable, Equatable {
/// 高亮唯一标识符
public var id: String public var id: String
/// 书籍标识符
public var bookIdentifier: String? public var bookIdentifier: String?
/// 高亮发生的位置
public var location: RDEPUBLocation public var location: RDEPUBLocation
/// 高亮的文本内容
public var text: String public var text: String
/// 范围信息(Web EPUB 为 DOM Range JSON,文本 EPUB 为字符偏移 JSON)
public var rangeInfo: String? public var rangeInfo: String?
/// 高亮样式(背景色高亮或下划线)
public var style: RDEPUBHighlightStyle public var style: RDEPUBHighlightStyle
/// 高亮颜色(CSS 颜色值,如 "#F8E16C")
public var color: String public var color: String
/// 用户备注
public var note: String? public var note: String?
/// 创建时间
public var createdAt: Date public var createdAt: Date
public init( public init(
@@ -250,10 +346,12 @@ public struct RDEPUBHighlight: Codable, Equatable {
self.createdAt = createdAt self.createdAt = createdAt
} }
/// 是否包含用户备注
public var hasNote: Bool { public var hasNote: Bool {
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
} }
/// 编码键,用于 Codable 序列化/反序列化
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case id case id
case bookIdentifier case bookIdentifier
@@ -266,6 +364,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
case createdAt case createdAt
} }
/// 自定义解码器,兼容旧版本数据格式(缺失字段使用默认值)
public init(from decoder: Decoder) throws { public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id) id = try container.decode(String.self, forKey: .id)
@@ -284,6 +383,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
} }
/// 自定义编码器
public func encode(to encoder: Encoder) throws { public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self) var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id) try container.encode(id, forKey: .id)
@@ -298,12 +398,19 @@ public struct RDEPUBHighlight: Codable, Equatable {
} }
} }
/// 书签模型,记录用户标记的阅读位置
public struct RDEPUBBookmark: Codable, Equatable { public struct RDEPUBBookmark: Codable, Equatable {
/// 书签唯一标识符
public var id: String public var id: String
/// 书籍标识符
public var bookIdentifier: String? public var bookIdentifier: String?
/// 书签位置
public var location: RDEPUBLocation public var location: RDEPUBLocation
/// 章节标题(用于书签列表展示)
public var chapterTitle: String? public var chapterTitle: String?
/// 用户备注
public var note: String? public var note: String?
/// 创建时间
public var createdAt: Date public var createdAt: Date
public init( public init(
@@ -322,14 +429,19 @@ public struct RDEPUBBookmark: Codable, Equatable {
self.createdAt = createdAt self.createdAt = createdAt
} }
/// 是否包含用户备注
public var hasNote: Bool { public var hasNote: Bool {
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
} }
} }
/// 章节信息模型,描述单个章节的元数据
public struct EPUBChapterInfo: Codable, Equatable { public struct EPUBChapterInfo: Codable, Equatable {
/// 对应的 spine 索引
public var spineIndex: Int public var spineIndex: Int
/// 章节标题
public var title: String public var title: String
/// 该章节的总页数
public var pageCount: Int public var pageCount: Int
public init(spineIndex: Int, title: String, pageCount: Int) { public init(spineIndex: Int, title: String, pageCount: Int) {
@@ -339,12 +451,20 @@ public struct EPUBChapterInfo: Codable, Equatable {
} }
} }
/// 页面模型,描述单页的完整信息
/// 由 RDEPUBReadingSession 根据分页结果构建,是翻页容器层的数据单元
public struct EPUBPage: Codable, Equatable { public struct EPUBPage: Codable, Equatable {
/// 对应的 spine 索引
public var spineIndex: Int public var spineIndex: Int
/// 章节索引(从 0 开始的章节序号)
public var chapterIndex: Int public var chapterIndex: Int
/// 页在章节中的索引(从 0 开始)
public var pageIndexInChapter: Int public var pageIndexInChapter: Int
/// 该章节的总页数
public var totalPagesInChapter: Int public var totalPagesInChapter: Int
/// 章节标题
public var chapterTitle: String public var chapterTitle: String
/// 固定版式 Spread 数据(仅 fixed layout 书籍有值)
public var fixedSpread: EPUBFixedSpread? public var fixedSpread: EPUBFixedSpread?
public init( public init(
@@ -364,10 +484,15 @@ public struct EPUBPage: Codable, Equatable {
} }
} }
/// 固定版式 Spread 中的单个资源
public struct EPUBFixedSpreadResource: Codable, Equatable { public struct EPUBFixedSpreadResource: Codable, Equatable {
/// 对应的 spine 索引
public var spineIndex: Int public var spineIndex: Int
/// 资源路径
public var href: String public var href: String
/// 资源标题
public var title: String public var title: String
/// 在 spread 中的位置偏好
public var pageSpread: RDEPUBPageSpread? public var pageSpread: RDEPUBPageSpread?
public init(spineIndex: Int, href: String, title: String, pageSpread: RDEPUBPageSpread? = nil) { public init(spineIndex: Int, href: String, title: String, pageSpread: RDEPUBPageSpread? = nil) {
@@ -378,23 +503,38 @@ public struct EPUBFixedSpreadResource: Codable, Equatable {
} }
} }
/// 固定版式 Spread(双页展开)模型
/// 将 1-2 个 spine 资源组合为一个显示单元
public struct EPUBFixedSpread: Codable, Equatable { public struct EPUBFixedSpread: Codable, Equatable {
/// 该 spread 包含的资源列表(通常 1-2 个)
public var resources: [EPUBFixedSpreadResource] public var resources: [EPUBFixedSpreadResource]
public init(resources: [EPUBFixedSpreadResource]) { public init(resources: [EPUBFixedSpreadResource]) {
self.resources = resources self.resources = resources
} }
/// 主资源(列表中的第一个),用于标识和定位
public var primaryResource: EPUBFixedSpreadResource { public var primaryResource: EPUBFixedSpreadResource {
resources.first ?? EPUBFixedSpreadResource(spineIndex: 0, href: "", title: "") resources.first ?? EPUBFixedSpreadResource(spineIndex: 0, href: "", title: "")
} }
/// 判断指定 href 的资源是否属于此 spread
/// - Parameters:
/// - normalizedHref: 标准化后的 href
/// - normalizer: href 标准化函数
/// - Returns: 是否包含该资源
public func contains(normalizedHref: String, normalizer: (String) -> String?) -> Bool { public func contains(normalizedHref: String, normalizer: (String) -> String?) -> Bool {
resources.contains { resource in resources.contains { resource in
normalizer(resource.href) == normalizedHref normalizer(resource.href) == normalizedHref
} }
} }
/// 根据 spine 列表和 spread 设置生成 Spread 数组
/// 根据 pageSpread 属性(left/right/center)决定资源配对
/// - Parameters:
/// - spine: spine 项列表
/// - spreadEnabled: 是否启用 spread 模式
/// - Returns: spread 数组
public static func makeSpreads(spine: [RDEPUBSpineItem], spreadEnabled: Bool) -> [EPUBFixedSpread] { public static func makeSpreads(spine: [RDEPUBSpineItem], spreadEnabled: Bool) -> [EPUBFixedSpread] {
let resources = spine.enumerated().map { index, item in let resources = spine.enumerated().map { index, item in
EPUBFixedSpreadResource( EPUBFixedSpreadResource(
@@ -433,10 +573,13 @@ public struct EPUBFixedSpread: Codable, Equatable {
return spreads return spreads
} }
/// 便捷方法:从 parser 构建 spread
public static func makeSpreads(parser: RDEPUBParser, spreadEnabled: Bool) -> [EPUBFixedSpread] { public static func makeSpreads(parser: RDEPUBParser, spreadEnabled: Bool) -> [EPUBFixedSpread] {
makeSpreads(spine: parser.spine, spreadEnabled: spreadEnabled) makeSpreads(spine: parser.spine, spreadEnabled: spreadEnabled)
} }
/// 判断两个资源是否应该配对为一个 spread
/// center 类型不参与配对,left+right 或 right+left 配对
private static func shouldPair(current: EPUBFixedSpreadResource, next: EPUBFixedSpreadResource) -> Bool { private static func shouldPair(current: EPUBFixedSpreadResource, next: EPUBFixedSpreadResource) -> Bool {
if current.pageSpread == .center || next.pageSpread == .center { if current.pageSpread == .center || next.pageSpread == .center {
return false return false
@@ -453,7 +596,9 @@ public struct EPUBFixedSpread: Codable, Equatable {
} }
} }
/// String 扩展:空字符串转为 nil,用于 Codable 容错处理
private extension String { private extension String {
/// 空白字符修剪后如果是空字符串则返回 nil
var nilIfEmpty: String? { var nilIfEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed return trimmed.isEmpty ? nil : trimmed
@@ -1,33 +1,56 @@
// RDEPUBReadingSession.swift
// EPUB 阅读会话(状态机)
// 管理阅读器的运行时状态:导航状态(initializing/loading/idle/jumping/moving/repaginating)、
// 活跃页面列表、待应用的分页快照、挂起的导航请求、当前视口和阅读上下文。
// 提供分页快照生成、位置计算、导航队列、阅读上下文更新等核心业务方法。
import Foundation import Foundation
/// EPUB 阅读会话,管理导航状态、页面列表和阅读进度
/// 状态机:initializing → loading → idle ↔ jumping/moving/repaginating
public final class RDEPUBReadingSession { public final class RDEPUBReadingSession {
/// 分页快照类型别名(页面数组 + 章节信息数组)
public typealias PaginationSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo]) public typealias PaginationSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
/// 关联的出版物
public let publication: RDEPUBPublication public let publication: RDEPUBPublication
/// 当前导航状态
public private(set) var navigatorState: RDEPUBNavigatorState = .initializing public private(set) var navigatorState: RDEPUBNavigatorState = .initializing
/// 当前生效的页面列表
public private(set) var activePages: [EPUBPage] = [] public private(set) var activePages: [EPUBPage] = []
/// 当前生效的章节信息列表
public private(set) var activeChapters: [EPUBChapterInfo] = [] public private(set) var activeChapters: [EPUBChapterInfo] = []
/// 暂存的待应用页面列表(分页计算完成后暂存,等状态允许时才生效)
public private(set) var stagedPages: [EPUBPage]? public private(set) var stagedPages: [EPUBPage]?
/// 暂存的待应用章节信息列表
public private(set) var stagedChapters: [EPUBChapterInfo]? public private(set) var stagedChapters: [EPUBChapterInfo]?
/// 暂存快照对应的恢复位置
public private(set) var stagedRestoreLocation: RDEPUBLocation? public private(set) var stagedRestoreLocation: RDEPUBLocation?
/// 挂起的导航目标位置(用于 fragment 精确定位)
public private(set) var pendingNavigationLocation: RDEPUBLocation? public private(set) var pendingNavigationLocation: RDEPUBLocation?
/// 挂起的导航目标页码
public private(set) var pendingNavigationPageNum: Int? public private(set) var pendingNavigationPageNum: Int?
/// 当前视口信息
public private(set) var currentViewport: RDEPUBViewport? public private(set) var currentViewport: RDEPUBViewport?
/// 当前阅读上下文(位置 + 视口 + 页码 + 章节)
public private(set) var currentReadingContext: RDEPUBReadingContext? public private(set) var currentReadingContext: RDEPUBReadingContext?
public init(publication: RDEPUBPublication) { public init(publication: RDEPUBPublication) {
self.publication = publication self.publication = publication
} }
/// 资源解析器快捷访问
public var resourceResolver: RDEPUBResourceResolver { public var resourceResolver: RDEPUBResourceResolver {
publication.resourceResolver publication.resourceResolver
} }
/// 状态机转换
public func transition(to state: RDEPUBNavigatorState) { public func transition(to state: RDEPUBNavigatorState) {
navigatorState = state navigatorState = state
} }
/// 重置所有运行时状态(回到 initializing)
public func resetRuntimeState() { public func resetRuntimeState() {
navigatorState = .initializing navigatorState = .initializing
activePages = [] activePages = []
@@ -38,17 +61,20 @@ public final class RDEPUBReadingSession {
currentReadingContext = nil currentReadingContext = nil
} }
/// 设置当前生效的分页快照
public func setActiveSnapshot(_ snapshot: PaginationSnapshot) { public func setActiveSnapshot(_ snapshot: PaginationSnapshot) {
activePages = snapshot.pages activePages = snapshot.pages
activeChapters = snapshot.chapters activeChapters = snapshot.chapters
} }
/// 暂存分页快照(待状态允许时才生效)
public func stageSnapshot(_ snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?) { public func stageSnapshot(_ snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?) {
stagedPages = snapshot.pages stagedPages = snapshot.pages
stagedChapters = snapshot.chapters stagedChapters = snapshot.chapters
stagedRestoreLocation = restoreLocation stagedRestoreLocation = restoreLocation
} }
/// 获取暂存的分页快照
public func stagedSnapshot() -> PaginationSnapshot? { public func stagedSnapshot() -> PaginationSnapshot? {
guard let stagedPages, let stagedChapters else { guard let stagedPages, let stagedChapters else {
return nil return nil
@@ -56,6 +82,7 @@ public final class RDEPUBReadingSession {
return (stagedPages, stagedChapters) return (stagedPages, stagedChapters)
} }
/// 如果状态稳定且有暂存快照,则消费并返回(原子操作)
public func consumeStagedSnapshotIfAllowed() -> (snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?)? { public func consumeStagedSnapshotIfAllowed() -> (snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?)? {
guard navigatorState.isStableForSnapshotApplication, guard navigatorState.isStableForSnapshotApplication,
let stagedPages, let stagedPages,
@@ -67,17 +94,20 @@ public final class RDEPUBReadingSession {
return ((stagedPages, stagedChapters), restoreLocation) return ((stagedPages, stagedChapters), restoreLocation)
} }
/// 清除暂存快照
public func clearStagedSnapshot() { public func clearStagedSnapshot() {
stagedPages = nil stagedPages = nil
stagedChapters = nil stagedChapters = nil
stagedRestoreLocation = nil stagedRestoreLocation = nil
} }
/// 清除挂起的导航请求
public func clearPendingNavigation() { public func clearPendingNavigation() {
pendingNavigationLocation = nil pendingNavigationLocation = nil
pendingNavigationPageNum = nil pendingNavigationPageNum = nil
} }
/// 检查指定页码和 spine 索引是否有挂起的导航位置
public func pendingLocation(forPageNumber pageNumber: Int, spineIndex: Int?) -> RDEPUBLocation? { public func pendingLocation(forPageNumber pageNumber: Int, spineIndex: Int?) -> RDEPUBLocation? {
guard pendingNavigationPageNum == pageNumber, guard pendingNavigationPageNum == pageNumber,
let pendingNavigationLocation else { let pendingNavigationLocation else {
@@ -93,6 +123,7 @@ public final class RDEPUBReadingSession {
return pendingNavigationLocation return pendingNavigationLocation
} }
/// 判断指定页面是否包含给定的 spine 索引(固定版式需检查 spread 内所有资源)
public func pageContains(spineIndex: Int, in page: EPUBPage) -> Bool { public func pageContains(spineIndex: Int, in page: EPUBPage) -> Bool {
if let fixedSpread = page.fixedSpread { if let fixedSpread = page.fixedSpread {
return fixedSpread.resources.contains(where: { $0.spineIndex == spineIndex }) return fixedSpread.resources.contains(where: { $0.spineIndex == spineIndex })
@@ -100,6 +131,7 @@ public final class RDEPUBReadingSession {
return page.spineIndex == spineIndex return page.spineIndex == spineIndex
} }
/// 根据页面信息生成回退位置(用于无精确位置时的兜底)
public func fallbackLocation(for page: EPUBPage, bookIdentifier: String?) -> RDEPUBLocation? { public func fallbackLocation(for page: EPUBPage, bookIdentifier: String?) -> RDEPUBLocation? {
if let fixedSpread = page.fixedSpread { if let fixedSpread = page.fixedSpread {
return RDEPUBLocation( return RDEPUBLocation(
@@ -129,6 +161,7 @@ public final class RDEPUBReadingSession {
) )
} }
/// 获取当前可见页面对应的位置
public func currentVisibleLocation(currentPageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? { public func currentVisibleLocation(currentPageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
guard currentPageNumber > 0, guard currentPageNumber > 0,
activePages.indices.contains(currentPageNumber - 1) else { activePages.indices.contains(currentPageNumber - 1) else {
@@ -137,6 +170,7 @@ public final class RDEPUBReadingSession {
return fallbackLocation(for: activePages[currentPageNumber - 1], bookIdentifier: bookIdentifier) return fallbackLocation(for: activePages[currentPageNumber - 1], bookIdentifier: bookIdentifier)
} }
/// 根据位置获取初始 spine 索引
public func initialSpineIndex(for location: RDEPUBLocation?) -> Int { public func initialSpineIndex(for location: RDEPUBLocation?) -> Int {
guard let location, guard let location,
let normalizedHref = resourceResolver.normalizedHref(location.href), let normalizedHref = resourceResolver.normalizedHref(location.href),
@@ -146,6 +180,7 @@ public final class RDEPUBReadingSession {
return spineIndex return spineIndex
} }
/// 根据位置计算对应的页面索引(支持固定版式和重排模式)
public func pageIndex(for location: RDEPUBLocation, bookIdentifier: String?) -> Int? { public func pageIndex(for location: RDEPUBLocation, bookIdentifier: String?) -> Int? {
guard location.bookIdentifier == nil || location.bookIdentifier == bookIdentifier else { guard location.bookIdentifier == nil || location.bookIdentifier == bookIdentifier else {
return nil return nil
@@ -186,6 +221,7 @@ public final class RDEPUBReadingSession {
return candidates[localIndex].offset return candidates[localIndex].offset
} }
/// 排队导航到指定位置:规范化位置、查找页面索引、设置挂起导航、切换到 jumping 状态
public func queueNavigation( public func queueNavigation(
to location: RDEPUBLocation, to location: RDEPUBLocation,
relativeToSpineIndex spineIndex: Int? = nil, relativeToSpineIndex spineIndex: Int? = nil,
@@ -205,6 +241,7 @@ public final class RDEPUBReadingSession {
return pageIndex + 1 return pageIndex + 1
} }
/// 更新阅读上下文:规范化位置、设置视口和阅读上下文、清除挂起导航、回到 idle 状态
public func updateReadingContext( public func updateReadingContext(
pageNumber: Int, pageNumber: Int,
location: RDEPUBLocation, location: RDEPUBLocation,
@@ -253,10 +290,13 @@ public final class RDEPUBReadingSession {
} }
} }
/// 获取当前阅读位置(优先从阅读上下文获取,回退到可见位置)
public func currentReadingLocation(bookIdentifier: String?) -> RDEPUBLocation? { public func currentReadingLocation(bookIdentifier: String?) -> RDEPUBLocation? {
currentReadingContext?.location ?? currentVisibleLocation(currentPageNumber: currentViewport?.visiblePageNumber ?? 0, bookIdentifier: bookIdentifier) currentReadingContext?.location ?? currentVisibleLocation(currentPageNumber: currentViewport?.visiblePageNumber ?? 0, bookIdentifier: bookIdentifier)
} }
/// 根据分页计数和偏好设置生成分页快照
/// 固定版式按 spread 分页,重排模式按 spine 索引逐章分页
public func makePaginationSnapshot( public func makePaginationSnapshot(
pageCounts: [Int], pageCounts: [Int],
preferences: RDEPUBPreferences, preferences: RDEPUBPreferences,
@@ -1,23 +1,49 @@
//
// RDEPUBRenderRequest.swift
// RDReaderView
//
// EPUBCore 层渲染请求模型定义文件。
// 包含渲染所需的展示样式(PresentationStyle)、可重排/固定版式渲染请求(RenderRequest)、
// 固定版式适配模式(Fit)和 Spread 模式(SpreadMode)。
// 由 RDEPUBPreferences 构建,传递给 RDEPUBWebView 执行渲染。
//
import UIKit import UIKit
/// 固定版式页面适配模式
/// - auto: 自动选择最佳适配策略
/// - page: 适配整页(缩放至屏幕大小)
/// - width: 适配宽度(按宽度等比缩放)
public enum RDEPUBFixedLayoutFit: String, Codable { public enum RDEPUBFixedLayoutFit: String, Codable {
case auto case auto
case page case page
case width case width
} }
/// 固定版式 Spread(双页)显示模式
/// - automatic: 根据设备方向和屏幕尺寸自动判断
/// - always: 始终启用双页显示
/// - never: 始终单页显示
public enum RDEPUBFixedLayoutSpreadMode: String, Codable { public enum RDEPUBFixedLayoutSpreadMode: String, Codable {
case automatic case automatic
case always case always
case never case never
} }
/// 渲染展示样式,WebView 和 Paginator 共用的视觉参数
/// 由 RDEPUBPreferences 根据用户设置构建
public struct RDEPUBPresentationStyle: Equatable { public struct RDEPUBPresentationStyle: Equatable {
/// 视口尺寸(容器的可见区域大小)
public var viewportSize: CGSize public var viewportSize: CGSize
/// 内容内边距(上下左右的安全区域偏移)
public var contentInsets: UIEdgeInsets public var contentInsets: UIEdgeInsets
/// 字号(pt)
public var fontSize: CGFloat public var fontSize: CGFloat
/// 行距倍数(1.0 为默认行距)
public var lineHeightMultiple: CGFloat public var lineHeightMultiple: CGFloat
/// 主题背景色(CSS 颜色值,如 "#FFFFFF")
public var themeBackgroundColor: String? public var themeBackgroundColor: String?
/// 主题文字色(CSS 颜色值,如 "#000000")
public var themeTextColor: String? public var themeTextColor: String?
public init( public init(
@@ -37,14 +63,23 @@ public struct RDEPUBPresentationStyle: Equatable {
} }
} }
/// 可重排内容渲染请求,包含加载和渲染一个 reflowable spine 项所需的全部参数
public struct RDEPUBReflowableRenderRequest: Equatable { public struct RDEPUBReflowableRenderRequest: Equatable {
/// spine 索引
public var spineIndex: Int public var spineIndex: Int
/// 资源路径
public var href: String public var href: String
/// 目标页在章节中的索引
public var pageIndex: Int public var pageIndex: Int
/// 该章节的总页数
public var totalPagesInChapter: Int public var totalPagesInChapter: Int
/// 展示样式
public var presentation: RDEPUBPresentationStyle public var presentation: RDEPUBPresentationStyle
/// 目标跳转位置(用于恢复阅读位置或锚点跳转)
public var targetLocation: RDEPUBLocation? public var targetLocation: RDEPUBLocation?
/// 高亮列表
public var highlights: [RDEPUBHighlight] public var highlights: [RDEPUBHighlight]
/// 搜索结果展示信息
public var searchPresentation: RDEPUBSearchPresentation? public var searchPresentation: RDEPUBSearchPresentation?
public init( public init(
@@ -68,12 +103,19 @@ public struct RDEPUBReflowableRenderRequest: Equatable {
} }
} }
/// 固定版式渲染请求,包含加载和渲染一个 fixed spread 所需的全部参数
public struct RDEPUBFixedRenderRequest: Equatable { public struct RDEPUBFixedRenderRequest: Equatable {
/// 要渲染的 spread(包含 1-2 个资源)
public var spread: EPUBFixedSpread public var spread: EPUBFixedSpread
/// 视口尺寸
public var viewportSize: CGSize public var viewportSize: CGSize
/// 内容内边距
public var contentInset: UIEdgeInsets public var contentInset: UIEdgeInsets
/// 背景色 CSS 值
public var backgroundColorCSS: String? public var backgroundColorCSS: String?
/// 页面适配模式(auto/page/width)
public var fit: RDEPUBFixedLayoutFit public var fit: RDEPUBFixedLayoutFit
/// 搜索结果展示信息
public var searchPresentation: RDEPUBSearchPresentation? public var searchPresentation: RDEPUBSearchPresentation?
public init( public init(
@@ -93,10 +135,15 @@ public struct RDEPUBFixedRenderRequest: Equatable {
} }
} }
/// 渲染请求枚举,统一可重排和固定版式两种渲染模式
/// RDEPUBWebView 根据此请求类型选择对应的加载和渲染路径
public enum RDEPUBRenderRequest: Equatable { public enum RDEPUBRenderRequest: Equatable {
/// 可重排内容渲染请求
case reflowable(RDEPUBReflowableRenderRequest) case reflowable(RDEPUBReflowableRenderRequest)
/// 固定版式渲染请求
case fixed(RDEPUBFixedRenderRequest) case fixed(RDEPUBFixedRenderRequest)
/// 是否为固定版式渲染
public var isFixedLayout: Bool { public var isFixedLayout: Bool {
if case .fixed = self { if case .fixed = self {
return true return true
@@ -104,6 +151,7 @@ public enum RDEPUBRenderRequest: Equatable {
return false return false
} }
/// 主资源的 spine 索引
public var primarySpineIndex: Int { public var primarySpineIndex: Int {
switch self { switch self {
case .reflowable(let request): case .reflowable(let request):
@@ -113,6 +161,7 @@ public enum RDEPUBRenderRequest: Equatable {
} }
} }
/// 主资源的路径
public var primaryHref: String { public var primaryHref: String {
switch self { switch self {
case .reflowable(let request): case .reflowable(let request):
@@ -1,28 +1,41 @@
// RDEPUBResourceResolver.swift
// EPUB 资源解析器
// 封装 RDEPUBParser 的资源查询能力,提供 href 规范化、
// spine 索引查找、位置规范化等高层接口。是 Services 层的核心组件,
// 被 RDEPUBPublication 和 RDEPUBReadingSession 广泛使用。
import Foundation import Foundation
/// EPUB 资源解析器,负责 URL 转换、href 规范化和位置解析
public final class RDEPUBResourceResolver { public final class RDEPUBResourceResolver {
/// 底层解析器引用
private let parser: RDEPUBParser private let parser: RDEPUBParser
public init(parser: RDEPUBParser) { public init(parser: RDEPUBParser) {
self.parser = parser self.parser = parser
} }
/// OPF 文件所在目录的 URL
public var opfDirectoryURL: URL? { public var opfDirectoryURL: URL? {
parser.opfDirectoryURL parser.opfDirectoryURL
} }
/// 将相对路径转换为本地文件 URL
public func fileURL(forRelativePath relativePath: String) -> URL? { public func fileURL(forRelativePath relativePath: String) -> URL? {
parser.fileURL(forRelativePath: relativePath) parser.fileURL(forRelativePath: relativePath)
} }
/// 将相对路径转换为 ss-reader://book/ 协议 URL
public func resourceURL(forRelativePath relativePath: String) -> URL? { public func resourceURL(forRelativePath relativePath: String) -> URL? {
parser.resourceURL(forRelativePath: relativePath) parser.resourceURL(forRelativePath: relativePath)
} }
/// 将 ss-reader://book/ 协议 URL 还原为本地文件 URL
public func fileURL(forResourceURL resourceURL: URL) -> URL? { public func fileURL(forResourceURL resourceURL: URL) -> URL? {
parser.fileURL(forResourceURL: resourceURL) parser.fileURL(forResourceURL: resourceURL)
} }
/// 规范化 href:基于 OPF 目录和可选的 spine 索引,将相对路径解析为标准化的 OPF 内路径
public func normalizedHref(_ href: String, relativeToSpineIndex spineIndex: Int? = nil) -> String? { public func normalizedHref(_ href: String, relativeToSpineIndex spineIndex: Int? = nil) -> String? {
guard let opfDirectoryURL else { guard let opfDirectoryURL else {
return href.components(separatedBy: "#").first return href.components(separatedBy: "#").first
@@ -57,6 +70,7 @@ public final class RDEPUBResourceResolver {
return pathPart return pathPart
} }
/// 规范化 href:基于另一个 href 作为基准路径
public func normalizedHref(_ href: String, relativeToHref baseHref: String) -> String? { public func normalizedHref(_ href: String, relativeToHref baseHref: String) -> String? {
guard let opfDirectoryURL else { guard let opfDirectoryURL else {
return href.components(separatedBy: "#").first return href.components(separatedBy: "#").first
@@ -79,6 +93,7 @@ public final class RDEPUBResourceResolver {
return pathPart return pathPart
} }
/// 将相对于另一个 href 的引用转换为本地文件 URL
public func fileURL(forReference href: String, relativeToHref baseHref: String) -> URL? { public func fileURL(forReference href: String, relativeToHref baseHref: String) -> URL? {
guard let normalizedHref = normalizedHref(href, relativeToHref: baseHref) else { guard let normalizedHref = normalizedHref(href, relativeToHref: baseHref) else {
return nil return nil
@@ -86,6 +101,7 @@ public final class RDEPUBResourceResolver {
return fileURL(forRelativePath: normalizedHref) return fileURL(forRelativePath: normalizedHref)
} }
/// 规范化位置对象:解析 href、保留 progression 和 fragment
public func normalizedLocation( public func normalizedLocation(
_ location: RDEPUBLocation, _ location: RDEPUBLocation,
relativeToSpineIndex spineIndex: Int? = nil, relativeToSpineIndex spineIndex: Int? = nil,
@@ -119,12 +135,14 @@ public final class RDEPUBResourceResolver {
) )
} }
/// 通过规范化 href 查找对应的 spine 索引
public func spineIndex(forNormalizedHref normalizedHref: String) -> Int? { public func spineIndex(forNormalizedHref normalizedHref: String) -> Int? {
parser.spine.firstIndex { item in parser.spine.firstIndex { item in
self.normalizedHref(item.href) == normalizedHref self.normalizedHref(item.href) == normalizedHref
} }
} }
/// 通过位置查找对应的 spine 索引
public func spineIndex(for location: RDEPUBLocation) -> Int? { public func spineIndex(for location: RDEPUBLocation) -> Int? {
guard let normalizedHref = normalizedHref(location.href) else { guard let normalizedHref = normalizedHref(location.href) else {
return nil return nil
@@ -132,6 +150,7 @@ public final class RDEPUBResourceResolver {
return spineIndex(forNormalizedHref: normalizedHref) return spineIndex(forNormalizedHref: normalizedHref)
} }
/// 通过 spine 索引获取 href
public func href(forSpineIndex spineIndex: Int) -> String? { public func href(forSpineIndex spineIndex: Int) -> String? {
guard parser.spine.indices.contains(spineIndex) else { guard parser.spine.indices.contains(spineIndex) else {
return nil return nil
@@ -139,6 +158,7 @@ public final class RDEPUBResourceResolver {
return parser.spine[spineIndex].href return parser.spine[spineIndex].href
} }
/// 通过 spine 索引获取标题
public func title(forSpineIndex spineIndex: Int) -> String? { public func title(forSpineIndex spineIndex: Int) -> String? {
guard parser.spine.indices.contains(spineIndex) else { guard parser.spine.indices.contains(spineIndex) else {
return nil return nil
@@ -146,6 +166,7 @@ public final class RDEPUBResourceResolver {
return parser.spine[spineIndex].title return parser.spine[spineIndex].title
} }
/// 通过 href 查询 manifest 项
public func manifestItem(forHref href: String) -> RDEPUBManifestItem? { public func manifestItem(forHref href: String) -> RDEPUBManifestItem? {
let targetHref = normalizedHref(href) ?? href.components(separatedBy: "#").first ?? href let targetHref = normalizedHref(href) ?? href.components(separatedBy: "#").first ?? href
return parser.manifest.values.first { return parser.manifest.values.first {
@@ -1,13 +1,25 @@
// RDEPUBResourceURLSchemeHandler.swift
// ss-reader:// 自定义 URL 协议处理器
// 实现 WKURLSchemeHandler 协议,拦截 ss-reader://book/<path> 请求,
// 将其映射到本地解压的 EPUB 资源文件。支持 MIME 类型推断和可选资源的空响应处理。
import Foundation import Foundation
import WebKit import WebKit
/// 自定义 URL 协议处理器,将 ss-reader://book/ 请求映射到本地 EPUB 资源文件
public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler { public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler {
/// 自定义协议名
public static let scheme = "ss-reader" public static let scheme = "ss-reader"
/// 自定义主机名
public static let host = "book" public static let host = "book"
/// 底层解析器引用(弱引用,避免循环持有)
private weak var parser: RDEPUBParser? private weak var parser: RDEPUBParser?
/// 文件管理器实例
private let fileManager = FileManager.default private let fileManager = FileManager.default
/// 串行队列,用于同步访问活跃任务字典
private let syncQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler") private let syncQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler")
/// 活跃的 URL Scheme 任务集合(用于任务取消检测)
private var activeTasks: [ObjectIdentifier: Bool] = [:] private var activeTasks: [ObjectIdentifier: Bool] = [:]
public init(parser: RDEPUBParser) { public init(parser: RDEPUBParser) {
@@ -15,6 +27,8 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
super.init() super.init()
} }
/// 处理 URL Scheme 请求:解析文件路径、读取数据、返回响应
/// 对缺失的可选资源(字体/图片/CSS/JS)返回空响应,其他返回 404 错误
public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) { public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
let taskID = ObjectIdentifier(urlSchemeTask as AnyObject) let taskID = ObjectIdentifier(urlSchemeTask as AnyObject)
syncQueue.sync { syncQueue.sync {
@@ -72,23 +86,27 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
clearTask(taskID) clearTask(taskID)
} }
/// 取消 URL Scheme 任务
public func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) { public func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) {
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: urlSchemeTask.request.url, event: "stop") RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: urlSchemeTask.request.url, event: "stop")
clearTask(ObjectIdentifier(urlSchemeTask as AnyObject)) clearTask(ObjectIdentifier(urlSchemeTask as AnyObject))
} }
/// 从活跃任务集合中移除已完成的任务
private func clearTask(_ id: ObjectIdentifier) { private func clearTask(_ id: ObjectIdentifier) {
syncQueue.sync { syncQueue.sync {
activeTasks[id] = nil activeTasks[id] = nil
} }
} }
/// 检查任务是否仍然活跃(防止取消后继续发送数据)
private func isTaskActive(_ id: ObjectIdentifier) -> Bool { private func isTaskActive(_ id: ObjectIdentifier) -> Bool {
syncQueue.sync { syncQueue.sync {
activeTasks[id] == true activeTasks[id] == true
} }
} }
/// 根据文件扩展名推断 MIME 类型
private static func mimeType(for pathExtension: String) -> String { private static func mimeType(for pathExtension: String) -> String {
switch pathExtension.lowercased() { switch pathExtension.lowercased() {
case "html", "htm": case "html", "htm":
@@ -128,6 +146,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
} }
} }
/// 根据文件扩展名推断文本编码(文本类文件返回 UTF-8)
private static func textEncodingName(for pathExtension: String) -> String? { private static func textEncodingName(for pathExtension: String) -> String? {
switch pathExtension.lowercased() { switch pathExtension.lowercased() {
case "html", "htm", "xhtml", "css", "js", "xml", "opf", "ncx", "txt": case "html", "htm", "xhtml", "css", "js", "xml", "opf", "ncx", "txt":
@@ -137,6 +156,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
} }
} }
/// 判断是否为可选资源(字体/图片/CSS/JS 等缺失时不报错,返回空响应)
private static func isOptionalResource(_ url: URL) -> Bool { private static func isOptionalResource(_ url: URL) -> Bool {
switch url.pathExtension.lowercased() { switch url.pathExtension.lowercased() {
case "ttf", "otf", "woff", "woff2", "css", "js", "jpg", "jpeg", "png", "gif", "webp", "svg": case "ttf", "otf", "woff", "woff2", "css", "js", "jpg", "jpeg", "png", "gif", "webp", "svg":
@@ -1,12 +1,22 @@
// RDEPUBSearchEngine.swift
// EPUB 全文搜索引擎
// 遍历 spine 中的线性 HTML/XHTML 资源,提取纯文本后执行大小写不敏感的关键词搜索,
// 返回匹配项列表(含 href、progression、previewText 等)。
import Foundation import Foundation
import UIKit import UIKit
/// 搜索引擎协议,定义全文搜索接口
protocol RDEPUBSearchEngine { protocol RDEPUBSearchEngine {
func search(keyword: String) -> [RDEPUBSearchMatch] func search(keyword: String) -> [RDEPUBSearchMatch]
} }
/// HTML 全文搜索引擎实现
/// 通过解析 HTML 为纯文本,逐章执行关键词匹配
final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine { final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
/// EPUB 解析器(用于读取 HTML 内容)
private let parser: RDEPUBParser private let parser: RDEPUBParser
/// EPUB 出版物(用于遍历 spine 和规范化 href)
private let publication: RDEPUBPublication private let publication: RDEPUBPublication
init(parser: RDEPUBParser, publication: RDEPUBPublication) { init(parser: RDEPUBParser, publication: RDEPUBPublication) {
@@ -14,6 +24,7 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
self.publication = publication self.publication = publication
} }
/// 执行全文搜索:遍历所有线性 spine 项,提取纯文本后匹配关键词
func search(keyword: String) -> [RDEPUBSearchMatch] { func search(keyword: String) -> [RDEPUBSearchMatch] {
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKeyword.isEmpty else { guard !normalizedKeyword.isEmpty else {
@@ -35,6 +46,7 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
return searchMatches return searchMatches
} }
/// 将 HTML 转换为纯文本(优先使用 NSAttributedString,失败时用正则兜底)
private func plainText(fromHTML html: String, baseURL: URL?) -> String { private func plainText(fromHTML html: String, baseURL: URL?) -> String {
guard let data = html.data(using: .utf8) else { guard let data = html.data(using: .utf8) else {
return fallbackPlainText(fromHTML: html) return fallbackPlainText(fromHTML: html)
@@ -54,6 +66,7 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
return fallbackPlainText(fromHTML: html) return fallbackPlainText(fromHTML: html)
} }
/// 兜底方案:用正则去除 HTML 标签并解码 HTML 实体
private func fallbackPlainText(fromHTML html: String) -> String { private func fallbackPlainText(fromHTML html: String) -> String {
let stripped = html.replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression) let stripped = html.replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression)
return stripped return stripped
@@ -65,6 +78,7 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
.replacingOccurrences(of: "&quot;", with: "\"") .replacingOccurrences(of: "&quot;", with: "\"")
} }
/// 在纯文本中查找所有关键词匹配项(大小写不敏感)
private func matches(in text: String, href: String, keyword: String) -> [RDEPUBSearchMatch] { private func matches(in text: String, href: String, keyword: String) -> [RDEPUBSearchMatch] {
let source = text as NSString let source = text as NSString
let fullLength = source.length let fullLength = source.length
@@ -106,6 +120,7 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
return results return results
} }
/// 截取匹配位置前后的文本作为预览(前后各 12 个字符)
private func previewText(in text: NSString, matchRange: NSRange) -> String { private func previewText(in text: NSString, matchRange: NSRange) -> String {
let previewRadius = 12 let previewRadius = 12
let start = max(matchRange.location - previewRadius, 0) let start = max(matchRange.location - previewRadius, 0)
@@ -1,12 +1,25 @@
// RDEPUBSearchModels.swift
// EPUB 搜索相关数据模型
// 定义搜索匹配项(RDEPUBSearchMatch)、搜索结果(RDEPUBSearchResult)、
// 搜索状态(RDEPUBSearchState)和搜索展示(RDEPUBSearchPresentation)等数据结构。
import Foundation import Foundation
/// 搜索匹配项,包含匹配位置、进度、预览文本和范围信息
public struct RDEPUBSearchMatch: Codable, Equatable { public struct RDEPUBSearchMatch: Codable, Equatable {
/// 匹配所在资源的规范化 href
public var href: String public var href: String
/// 匹配位置在文本中的进度比例(0.0 ~ 1.0)
public var progression: Double public var progression: Double
/// 匹配位置前后的预览文本
public var previewText: String public var previewText: String
/// 在当前资源内的本地匹配索引
public var localMatchIndex: Int public var localMatchIndex: Int
/// 匹配范围的起始位置(字符偏移量)
public var rangeLocation: Int? public var rangeLocation: Int?
/// 匹配范围的长度
public var rangeLength: Int public var rangeLength: Int
/// 文本范围锚点(用于精确定位)
public var rangeAnchor: RDEPUBTextRangeAnchor? public var rangeAnchor: RDEPUBTextRangeAnchor?
public init( public init(
@@ -28,10 +41,15 @@ public struct RDEPUBSearchMatch: Codable, Equatable {
} }
} }
/// 搜索结果摘要,包含关键词、总匹配数和当前匹配项
public struct RDEPUBSearchResult: Codable, Equatable { public struct RDEPUBSearchResult: Codable, Equatable {
/// 搜索关键词
public var keyword: String public var keyword: String
/// 总匹配数量
public var totalMatchCount: Int public var totalMatchCount: Int
/// 当前匹配项的索引(从 1 开始)
public var currentMatchIndex: Int? public var currentMatchIndex: Int?
/// 当前高亮的匹配项
public var currentMatch: RDEPUBSearchMatch? public var currentMatch: RDEPUBSearchMatch?
public init( public init(
@@ -47,9 +65,13 @@ public struct RDEPUBSearchResult: Codable, Equatable {
} }
} }
/// 搜索状态,管理关键词、匹配列表和当前索引
public struct RDEPUBSearchState: Codable, Equatable { public struct RDEPUBSearchState: Codable, Equatable {
/// 搜索关键词
public var keyword: String public var keyword: String
/// 所有匹配项列表
public var matches: [RDEPUBSearchMatch] public var matches: [RDEPUBSearchMatch]
/// 当前匹配项索引(从 0 开始)
public var currentMatchIndex: Int? public var currentMatchIndex: Int?
public init(keyword: String, matches: [RDEPUBSearchMatch], currentMatchIndex: Int? = nil) { public init(keyword: String, matches: [RDEPUBSearchMatch], currentMatchIndex: Int? = nil) {
@@ -58,6 +80,7 @@ public struct RDEPUBSearchState: Codable, Equatable {
self.currentMatchIndex = currentMatchIndex self.currentMatchIndex = currentMatchIndex
} }
/// 获取当前匹配项
public var currentMatch: RDEPUBSearchMatch? { public var currentMatch: RDEPUBSearchMatch? {
guard let currentMatchIndex, guard let currentMatchIndex,
matches.indices.contains(currentMatchIndex) else { matches.indices.contains(currentMatchIndex) else {
@@ -66,6 +89,7 @@ public struct RDEPUBSearchState: Codable, Equatable {
return matches[currentMatchIndex] return matches[currentMatchIndex]
} }
/// 转换为搜索结果摘要
public var result: RDEPUBSearchResult { public var result: RDEPUBSearchResult {
RDEPUBSearchResult( RDEPUBSearchResult(
keyword: keyword, keyword: keyword,
@@ -76,9 +100,13 @@ public struct RDEPUBSearchState: Codable, Equatable {
} }
} }
/// 搜索展示的单个资源信息
public struct RDEPUBSearchPresentationResource: Codable, Equatable { public struct RDEPUBSearchPresentationResource: Codable, Equatable {
/// 资源的规范化 href
public var href: String public var href: String
/// 该资源内的匹配数量
public var matchCount: Int public var matchCount: Int
/// 当前活跃的本地匹配索引
public var activeLocalMatchIndex: Int? public var activeLocalMatchIndex: Int?
public init(href: String, matchCount: Int, activeLocalMatchIndex: Int? = nil) { public init(href: String, matchCount: Int, activeLocalMatchIndex: Int? = nil) {
@@ -88,8 +116,11 @@ public struct RDEPUBSearchPresentationResource: Codable, Equatable {
} }
} }
/// 搜索展示信息,传递给 JS 桥接脚本用于渲染搜索高亮
public struct RDEPUBSearchPresentation: Codable, Equatable { public struct RDEPUBSearchPresentation: Codable, Equatable {
/// 搜索关键词
public var keyword: String public var keyword: String
/// 各资源的搜索展示信息列表
public var resources: [RDEPUBSearchPresentationResource] public var resources: [RDEPUBSearchPresentationResource]
public init(keyword: String, resources: [RDEPUBSearchPresentationResource]) { public init(keyword: String, resources: [RDEPUBSearchPresentationResource]) {
@@ -1,6 +1,13 @@
// RDEPUBStyleSheetBuilder.swift
// EPUB CSS 样式构建器
// 负责生成重排模式下的分页 CSS(多列布局)和测量 CSS,
// 以及将分页样式注入到 HTML 中。还提供测量脚本(JS)用于计算页数。
import Foundation import Foundation
/// CSS 样式构建工具集,生成分页、渲染和测量所需的 CSS/JS
public enum RDEPUBStyleSheetBuilder { public enum RDEPUBStyleSheetBuilder {
/// 将分页 CSS 注入到 HTML 字符串中(插入 </head> 或 <body 之前)
public static func injectPaginationCSS( public static func injectPaginationCSS(
into html: String, into html: String,
presentation: RDEPUBPresentationStyle presentation: RDEPUBPresentationStyle
@@ -15,6 +22,8 @@ public enum RDEPUBStyleSheetBuilder {
return styleTag + html return styleTag + html
} }
/// 生成重排模式的渲染 CSS(视口、body、#ss-reader-viewport、#ss-reader-content 等)
/// 包含多列布局、字号、行高、主题色、高亮样式等
public static func renderCSS(for presentation: RDEPUBPresentationStyle) -> String { public static func renderCSS(for presentation: RDEPUBPresentationStyle) -> String {
let values = cssValues(for: presentation) let values = cssValues(for: presentation)
let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? "" let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? ""
@@ -95,6 +104,7 @@ public enum RDEPUBStyleSheetBuilder {
""" """
} }
/// 生成分页测量用的 CSS(与渲染 CSS 类似但不含 transform/will-change 等优化属性)
public static func measurementCSS(for presentation: RDEPUBPresentationStyle) -> String { public static func measurementCSS(for presentation: RDEPUBPresentationStyle) -> String {
let values = cssValues(for: presentation) let values = cssValues(for: presentation)
let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? "" let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? ""
@@ -139,6 +149,7 @@ public enum RDEPUBStyleSheetBuilder {
""" """
} }
/// 生成分页测量脚本(JS):注入 CSS 后测量 scrollWidth,计算页数
public static func measurementScript(for presentation: RDEPUBPresentationStyle) -> String { public static func measurementScript(for presentation: RDEPUBPresentationStyle) -> String {
let style = measurementCSS(for: presentation) let style = measurementCSS(for: presentation)
.replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\\", with: "\\\\")
@@ -170,6 +181,7 @@ public enum RDEPUBStyleSheetBuilder {
""" """
} }
/// 从展示样式计算所有 CSS 变量值(视口尺寸、内边距、字号、行高等)
private static func cssValues(for presentation: RDEPUBPresentationStyle) -> ( private static func cssValues(for presentation: RDEPUBPresentationStyle) -> (
viewportWidth: String, viewportWidth: String,
viewportHeight: String, viewportHeight: String,
@@ -1,12 +1,26 @@
// RDEPUBTextAnchor.swift
// EPUB 文本锚点和范围锚点
// 定义精确的文本位置锚点(RDEPUBTextAnchor,含 fileIndex/row/column/chapterOffset/fragmentID),
// 以及文本范围锚点(RDEPUBTextRangeAnchor,起止锚点对)。
// 支持 Codable 序列化(兼容 spineIndex/fileIndex 两种 key)。
import Foundation import Foundation
/// 文本锚点,精确定位到 EPUB 中的某个字符位置
/// 包含文件索引、行号、列号、章节内偏移和可选的 fragment ID
public struct RDEPUBTextAnchor: Codable, Equatable { public struct RDEPUBTextAnchor: Codable, Equatable {
/// 文件索引(等同于 spineIndex)
public let fileIndex: Int public let fileIndex: Int
/// 行号
public let row: Int public let row: Int
/// 列号(行内偏移)
public let column: Int public let column: Int
/// 章节内的绝对字符偏移量
public let chapterOffset: Int public let chapterOffset: Int
/// 最近的 fragment ID(用于 URL 锚点定位)
public let fragmentID: String? public let fragmentID: String?
/// spine 索引的别名(与 fileIndex 等价)
public var spineIndex: Int { fileIndex } public var spineIndex: Int { fileIndex }
public init( public init(
@@ -53,8 +67,11 @@ public struct RDEPUBTextAnchor: Codable, Equatable {
} }
} }
/// 文本范围锚点,由起止锚点组成的区间
public struct RDEPUBTextRangeAnchor: Codable, Equatable { public struct RDEPUBTextRangeAnchor: Codable, Equatable {
/// 起始锚点
public let start: RDEPUBTextAnchor public let start: RDEPUBTextAnchor
/// 结束锚点
public let end: RDEPUBTextAnchor public let end: RDEPUBTextAnchor
public init(start: RDEPUBTextAnchor, end: RDEPUBTextAnchor) { public init(start: RDEPUBTextAnchor, end: RDEPUBTextAnchor) {
@@ -62,6 +79,7 @@ public struct RDEPUBTextRangeAnchor: Codable, Equatable {
self.end = end self.end = end
} }
/// 转换为 NSRange(基于 chapterOffset)
public var nsRange: NSRange { public var nsRange: NSRange {
NSRange(location: start.chapterOffset, length: max(end.chapterOffset - start.chapterOffset, 0)) NSRange(location: start.chapterOffset, length: max(end.chapterOffset - start.chapterOffset, 0))
} }
@@ -1,7 +1,14 @@
// RDEPUBWebView+Configuration.swift
// RDEPUBWebView 的 WKWebView 配置扩展
// 负责按需创建和配置 WKWebView 实例:注册 JS 桥接消息处理器、
// 注入用户脚本、设置自定义 URL Scheme 处理器、配置滚动和外观属性。
// 同时处理选择菜单(拷贝/高亮/批注)和 WebView 销毁清理。
import UIKit import UIKit
import WebKit import WebKit
extension RDEPUBWebView { extension RDEPUBWebView {
/// 按需配置 WKWebView(仅在 Publication 变更或 WebView 不存在时重建)
func configureWebViewIfNeeded(publication: RDEPUBPublication) { func configureWebViewIfNeeded(publication: RDEPUBPublication) {
let parser = publication.parser let parser = publication.parser
let publicationKey = parser.opfURL?.path ?? parser.extractionRootURL?.path ?? UUID().uuidString let publicationKey = parser.opfURL?.path ?? parser.extractionRootURL?.path ?? UUID().uuidString
@@ -66,15 +73,18 @@ extension RDEPUBWebView {
RDEPUBWebViewDebug.log(debugScope, message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView)) publicationKey=\(publicationKey)") RDEPUBWebViewDebug.log(debugScope, message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView)) publicationKey=\(publicationKey)")
} }
/// 处理选择菜单动作(拷贝/高亮/批注),通知委托并清除选择
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction) { func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction) {
delegate?.epubWebView(self, didRequestSelectionAction: action) delegate?.epubWebView(self, didRequestSelectionAction: action)
clearWebSelection() clearWebSelection()
} }
/// 清除 WebView 中的文本选择
func clearWebSelection() { func clearWebSelection() {
webView?.evaluateJavaScript("window.getSelection && window.getSelection().removeAllRanges();") webView?.evaluateJavaScript("window.getSelection && window.getSelection().removeAllRanges();")
} }
/// 销毁 WebView,移除所有消息处理器和引用
func teardownWebView() { func teardownWebView() {
cancelFixedLayoutReadyFallback() cancelFixedLayoutReadyFallback()
if let webView { if let webView {
@@ -96,6 +106,7 @@ extension RDEPUBWebView {
} }
extension UIColor { extension UIColor {
/// 将 UIColor 转换为 CSS 十六进制颜色字符串(如 "#FF0000")
var ss_hexString: String { var ss_hexString: String {
var red: CGFloat = 0 var red: CGFloat = 0
var green: CGFloat = 0 var green: CGFloat = 0
@@ -1,7 +1,13 @@
// RDEPUBWebView+FixedLayout.swift
// RDEPUBWebView 的固定版式渲染扩展
// 处理固定版式(Fixed Layout)的加载流程:生成 HTML 模板、
// 计算加载签名(去重)、设置固定版式就绪回退定时器等。
import UIKit import UIKit
import WebKit import WebKit
extension RDEPUBWebView { extension RDEPUBWebView {
/// 便捷方法:加载固定版式的 spread 页面
public func loadFixedSpread( public func loadFixedSpread(
parser: RDEPUBParser, parser: RDEPUBParser,
spread: EPUBFixedSpread, spread: EPUBFixedSpread,
@@ -22,6 +28,7 @@ extension RDEPUBWebView {
load(publication: parser.makePublication(), request: request) load(publication: parser.makePublication(), request: request)
} }
/// 处理固定版式加载:设置状态、生成 HTML、加载到 WebView
func handleFixedLayoutLoad( func handleFixedLayoutLoad(
publication: RDEPUBPublication, publication: RDEPUBPublication,
request: RDEPUBFixedRenderRequest, request: RDEPUBFixedRenderRequest,
@@ -52,8 +59,19 @@ extension RDEPUBWebView {
) )
} }
/// 生成固定版式加载签名(用于去重相同请求)
func fixedLayoutLoadSignature(publicationKey: String, request: RDEPUBFixedRenderRequest) -> String { func fixedLayoutLoadSignature(publicationKey: String, request: RDEPUBFixedRenderRequest) -> String {
let searchSignature = [
request.searchPresentation?.keyword ?? "",
request.searchPresentation?.resources.map {
[ [
$0.href,
String($0.matchCount),
String($0.activeLocalMatchIndex ?? -1)
].joined(separator: "|")
}.joined(separator: ",") ?? ""
].joined(separator: "#")
return [
publicationKey, publicationKey,
"fixed", "fixed",
request.spread.resources.map(\.href).joined(separator: "|"), request.spread.resources.map(\.href).joined(separator: "|"),
@@ -64,10 +82,12 @@ extension RDEPUBWebView {
String(format: "%.2f", request.contentInset.bottom), String(format: "%.2f", request.contentInset.bottom),
String(format: "%.2f", request.contentInset.right), String(format: "%.2f", request.contentInset.right),
request.backgroundColorCSS ?? "", request.backgroundColorCSS ?? "",
request.fit.rawValue request.fit.rawValue,
searchSignature
].joined(separator: "#") ].joined(separator: "#")
} }
/// 调度固定版式就绪回退定时器(1 秒后如果未收到 ready 消息则手动触发渲染完成)
func scheduleFixedLayoutReadyFallback() { func scheduleFixedLayoutReadyFallback() {
let workItem = DispatchWorkItem { [weak self] in let workItem = DispatchWorkItem { [weak self] in
RDEPUBWebViewDebug.log(self?.debugScope ?? "ReaderWebView", message: "fixed ready fallback fired") RDEPUBWebViewDebug.log(self?.debugScope ?? "ReaderWebView", message: "fixed ready fallback fired")
@@ -79,6 +99,7 @@ extension RDEPUBWebView {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: workItem) DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: workItem)
} }
/// 取消固定版式就绪回退定时器
func cancelFixedLayoutReadyFallback() { func cancelFixedLayoutReadyFallback() {
fixedLayoutReadyWorkItem?.cancel() fixedLayoutReadyWorkItem?.cancel()
fixedLayoutReadyWorkItem = nil fixedLayoutReadyWorkItem = nil
@@ -1,7 +1,14 @@
// RDEPUBWebView+JavaScriptBridge.swift
// RDEPUBWebView 的 JS 桥接和导航委托扩展
// 实现 WKNavigationDelegate 处理页面加载生命周期,
// 实现 WKScriptMessageHandler 接收 JS 消息(进度/选择/链接/错误/固定版式就绪),
// 并将内部链接和外部链接路由到委托方法。
import Foundation import Foundation
import WebKit import WebKit
extension RDEPUBWebView { extension RDEPUBWebView {
/// 从 URL 解析内部位置(ss-reader:// 协议的链接)
func internalLocation(from url: URL) -> RDEPUBLocation? { func internalLocation(from url: URL) -> RDEPUBLocation? {
let href = url.path.removingPercentEncoding?.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? "" let href = url.path.removingPercentEncoding?.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? ""
let fragment = url.fragment let fragment = url.fragment
@@ -9,6 +16,7 @@ extension RDEPUBWebView {
return RDEPUBLocation(href: href, progression: 0, fragment: fragment) return RDEPUBLocation(href: href, progression: 0, fragment: fragment)
} }
/// 从 JS 消息体中构建当前位置对象
func currentLocation(from body: [String: Any]) -> RDEPUBLocation { func currentLocation(from body: [String: Any]) -> RDEPUBLocation {
let progression = (body["progression"] as? NSNumber)?.doubleValue ?? 0 let progression = (body["progression"] as? NSNumber)?.doubleValue ?? 0
let lastProgression = (body["lastProgression"] as? NSNumber)?.doubleValue let lastProgression = (body["lastProgression"] as? NSNumber)?.doubleValue
@@ -43,6 +51,7 @@ extension RDEPUBWebView: WKNavigationDelegate {
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error) RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error)
} }
/// 导航策略决策:区分内部链接(ss-reader://)、外部链接(http/https/mailto/tel)和其他导航
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "decidePolicy", url: navigationAction.request.url) RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "decidePolicy", url: navigationAction.request.url)
guard navigationAction.navigationType == .linkActivated, guard navigationAction.navigationType == .linkActivated,
@@ -69,6 +78,7 @@ extension RDEPUBWebView: WKNavigationDelegate {
} }
extension RDEPUBWebView: WKScriptMessageHandler { extension RDEPUBWebView: WKScriptMessageHandler {
/// 接收 JS 桥接消息,根据消息类型路由到对应的委托方法
public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
RDEPUBWebViewDebug.logMessage(debugScope, webView: message.webView, name: message.name, body: message.body) RDEPUBWebViewDebug.logMessage(debugScope, webView: message.webView, name: message.name, body: message.body)
switch message.name { switch message.name {
@@ -1,7 +1,14 @@
// RDEPUBWebView+Reflowable.swift
// RDEPUBWebView 的重排模式渲染扩展
// 处理重排(Reflowable)模式的加载流程:构建渲染请求、
// 生成加载签名(去重)、通过 JS 桥接应用分页样式和高亮、
// 处理重复请求跳过和已有文档复用等优化逻辑。
import UIKit import UIKit
import WebKit import WebKit
extension RDEPUBWebView { extension RDEPUBWebView {
/// 便捷方法:加载重排模式的指定页面
public func loadPage( public func loadPage(
parser: RDEPUBParser, parser: RDEPUBParser,
spineIndex: Int, spineIndex: Int,
@@ -39,6 +46,7 @@ extension RDEPUBWebView {
load(publication: parser.makePublication(), request: request) load(publication: parser.makePublication(), request: request)
} }
/// 处理重排模式加载:设置状态、去重检查、加载资源或直接应用样式
func handleReflowableLoad( func handleReflowableLoad(
publication: RDEPUBPublication, publication: RDEPUBPublication,
request: RDEPUBReflowableRenderRequest, request: RDEPUBReflowableRenderRequest,
@@ -83,6 +91,7 @@ extension RDEPUBWebView {
webView.load(URLRequest(url: requestURL)) webView.load(URLRequest(url: requestURL))
} }
/// 生成重排模式加载签名(包含所有渲染参数的哈希,用于去重)
func reflowableLoadSignature(publicationKey: String, request: RDEPUBReflowableRenderRequest) -> String { func reflowableLoadSignature(publicationKey: String, request: RDEPUBReflowableRenderRequest) -> String {
let targetSignature = [ let targetSignature = [
request.targetLocation?.href ?? "", request.targetLocation?.href ?? "",
@@ -93,6 +102,16 @@ extension RDEPUBWebView {
let highlightSignature = request.highlights let highlightSignature = request.highlights
.map { [$0.id, $0.rangeInfo ?? "", $0.color, $0.style.rawValue].joined(separator: "|") } .map { [$0.id, $0.rangeInfo ?? "", $0.color, $0.style.rawValue].joined(separator: "|") }
.joined(separator: ",") .joined(separator: ",")
let searchSignature = [
request.searchPresentation?.keyword ?? "",
request.searchPresentation?.resources.map {
[
$0.href,
String($0.matchCount),
String($0.activeLocalMatchIndex ?? -1)
].joined(separator: "|")
}.joined(separator: ",") ?? ""
].joined(separator: "#")
let presentation = request.presentation let presentation = request.presentation
return [ return [
publicationKey, publicationKey,
@@ -112,10 +131,12 @@ extension RDEPUBWebView {
presentation.themeBackgroundColor ?? "", presentation.themeBackgroundColor ?? "",
presentation.themeTextColor ?? "", presentation.themeTextColor ?? "",
targetSignature, targetSignature,
highlightSignature highlightSignature,
searchSignature
].joined(separator: "#") ].joined(separator: "#")
} }
/// 应用分页展示样式:通过 JS 桥接注入 CSS、设置分页参数和高亮,然后触发渲染完成
func applyPresentation() { func applyPresentation() {
guard let webView, let currentRenderRequest else { return } guard let webView, let currentRenderRequest else { return }
guard case .reflowable(let request) = currentRenderRequest else { guard case .reflowable(let request) = currentRenderRequest else {
@@ -1,6 +1,12 @@
// RDEPUBWebView+Search.swift
// RDEPUBWebView 的搜索高亮扩展
// 从当前渲染请求中提取搜索展示信息,生成搜索高亮脚本并注入到 WebView 中执行。
import UIKit
import WebKit import WebKit
extension RDEPUBWebView { extension RDEPUBWebView {
/// 如果当前渲染请求包含搜索信息,注入搜索高亮脚本到 WebView
func applySearchDecorationsIfNeeded(completion: (() -> Void)? = nil) { func applySearchDecorationsIfNeeded(completion: (() -> Void)? = nil) {
guard let webView else { guard let webView else {
completion?() completion?()
@@ -26,7 +32,107 @@ extension RDEPUBWebView {
if let error { if let error {
self.delegate?.epubWebView(self, didLogJavaScriptError: error.localizedDescription) self.delegate?.epubWebView(self, didLogJavaScriptError: error.localizedDescription)
} }
self.refreshNativeDecorationsIfNeeded(completion: completion)
}
}
func refreshNativeDecorationsIfNeeded(completion: (() -> Void)? = nil) {
guard let webView else {
onDecorationsResolved?([])
completion?()
return
}
webView.evaluateJavaScript(RDEPUBJavaScriptBridge.resolveDecorationsScript()) { [weak self] result, error in
guard let self else {
completion?()
return
}
if let error {
self.delegate?.epubWebView(self, didLogJavaScriptError: error.localizedDescription)
self.onDecorationsResolved?([])
completion?()
return
}
let decorations = self.nativeDecorations(from: result)
self.onDecorationsResolved?(decorations)
completion?() completion?()
} }
} }
private func nativeDecorations(from result: Any?) -> [RDEPUBTextOverlayDecoration] {
guard let payload = result as? [String: Any] else { return [] }
let highlightDecorations = decorationList(
from: payload["highlights"] as? [[String: Any]],
defaultColor: overlayColor(hex: "#F8E16C", alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45)
)
let searchDecorations = decorationList(
from: payload["search"] as? [[String: Any]],
defaultColor: UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
)
return highlightDecorations + searchDecorations
}
private func decorationList(
from items: [[String: Any]]?,
defaultColor: UIColor
) -> [RDEPUBTextOverlayDecoration] {
guard let items else { return [] }
return items.enumerated().compactMap { index, item -> RDEPUBTextOverlayDecoration? in
guard let kindString = item["kind"] as? String,
let kind = RDEPUBTextOverlayDecoration.Kind(rawValue: kindString),
let rectPayloads = item["rects"] as? [[String: Any]] else {
return nil
}
let rects = rectPayloads.compactMap { rectPayload -> CGRect? in
guard let x = (rectPayload["x"] as? NSNumber)?.doubleValue,
let y = (rectPayload["y"] as? NSNumber)?.doubleValue,
let width = (rectPayload["width"] as? NSNumber)?.doubleValue,
let height = (rectPayload["height"] as? NSNumber)?.doubleValue else {
return nil
}
return CGRect(x: x, y: y, width: width, height: height)
}
guard !rects.isEmpty else { return nil }
let color: UIColor
switch kind {
case .underline:
let hex = item["color"] as? String ?? "#F8E16C"
color = overlayColor(hex: hex, alpha: 1) ?? defaultColor
case .activeSearch:
color = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
case .search:
color = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
case .highlight:
let hex = item["color"] as? String ?? "#F8E16C"
color = overlayColor(hex: hex, alpha: 0.45) ?? defaultColor
case .selection, .locate:
color = defaultColor
}
return RDEPUBTextOverlayDecoration(
kind: kind,
absoluteRange: NSRange(location: index, length: 1),
rects: rects,
color: color
)
}
}
private func overlayColor(hex: String, alpha: CGFloat) -> UIColor? {
var value = hex.trimmingCharacters(in: .whitespacesAndNewlines)
value = value.replacingOccurrences(of: "#", with: "")
guard value.count == 6, let hexValue = Int(value, radix: 16) else { return nil }
return UIColor(
red: CGFloat((hexValue >> 16) & 0xFF) / 255,
green: CGFloat((hexValue >> 8) & 0xFF) / 255,
blue: CGFloat(hexValue & 0xFF) / 255,
alpha: alpha
)
}
} }
@@ -1,21 +1,39 @@
// RDEPUBWebView.swift
// EPUB WebView 主类
// 封装 WKWebView 的 EPUB 渲染能力,是 Resource View 层的核心组件。
// 统一管理固定版式和重排模式的加载流程,持有当前渲染状态(spineIndex、pageIndex、
// 主题色、高亮等),并通过委托模式通知上层页面渲染和交互事件。
// 具体逻辑分散在 +Configuration、+FixedLayout、+Reflowable、+JavaScriptBridge、+Search 扩展中。
import UIKit import UIKit
import WebKit import WebKit
/// RDEPUBWebView 委托协议,定义 WebView 向上层通知的事件
public protocol RDEPUBWebViewDelegate: AnyObject { public protocol RDEPUBWebViewDelegate: AnyObject {
/// 阅读位置更新事件
func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
/// 文本选择变化事件
func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
/// 选择菜单动作请求事件
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
/// 内部链接点击事件
func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
/// 外部链接点击事件
func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL) func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL)
/// JavaScript 错误事件
func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String) func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String)
/// 页面渲染完成事件
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView) func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView)
} }
/// 委托协议的默认实现(选择菜单动作为可选)
public extension RDEPUBWebViewDelegate { public extension RDEPUBWebViewDelegate {
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {} func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {}
} }
/// 自定义 WKWebView 子类,支持拷贝/高亮/批注选择菜单
final class RDEPUBAnnotationWebView: WKWebView { final class RDEPUBAnnotationWebView: WKWebView {
/// 选择菜单动作回调
var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)? var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)?
override var canBecomeFirstResponder: Bool { override var canBecomeFirstResponder: Bool {
@@ -46,33 +64,63 @@ final class RDEPUBAnnotationWebView: WKWebView {
} }
} }
/// EPUB WebView 主类,封装 WKWebView 的 EPUB 渲染能力
/// Resource View 层核心组件,统一管理固定版式和重排模式的渲染
public final class RDEPUBWebView: UIView { public final class RDEPUBWebView: UIView {
/// 委托对象,接收渲染和交互事件
public weak var delegate: RDEPUBWebViewDelegate? public weak var delegate: RDEPUBWebViewDelegate?
/// 渲染完成回调(与 delegate 并行使用)
public var onRendered: (() -> Void)? public var onRendered: (() -> Void)?
/// 内部原生装饰解析回调
var onDecorationsResolved: (([RDEPUBTextOverlayDecoration]) -> Void)?
/// 当前关联的出版物
var publication: RDEPUBPublication? var publication: RDEPUBPublication?
/// 当前渲染请求
var currentRenderRequest: RDEPUBRenderRequest? var currentRenderRequest: RDEPUBRenderRequest?
/// 内部 WKWebView 实例
var webView: WKWebView? var webView: WKWebView?
/// URL Scheme 处理器
var schemeHandler: RDEPUBResourceURLSchemeHandler? var schemeHandler: RDEPUBResourceURLSchemeHandler?
/// 已配置的出版物标识(用于判断是否需要重建 WebView)
var configuredPublicationKey: String? var configuredPublicationKey: String?
/// 当前加载签名(用于去重相同请求)
var currentLoadSignature: String? var currentLoadSignature: String?
/// 当前 spine 索引
var currentSpineIndex = 0 var currentSpineIndex = 0
/// 当前资源 href
var currentHref = "" var currentHref = ""
/// 当前页码(从 0 开始)
var currentPageIndex = 0 var currentPageIndex = 0
/// 当前章节总页数
var currentTotalPagesInChapter = 1 var currentTotalPagesInChapter = 1
/// 当前视口尺寸
var viewportSize: CGSize = .zero var viewportSize: CGSize = .zero
/// 当前内容内边距
var currentPadding: UIEdgeInsets = .zero var currentPadding: UIEdgeInsets = .zero
/// 当前字号
var currentFontSize: CGFloat = 16 var currentFontSize: CGFloat = 16
/// 当前行高倍数
var currentLineHeightMultiple: CGFloat = 1.5 var currentLineHeightMultiple: CGFloat = 1.5
/// 当前主题背景色
var currentThemeBackgroundColor: String? var currentThemeBackgroundColor: String?
/// 当前主题文字色
var currentThemeTextColor: String? var currentThemeTextColor: String?
/// 目标跳转位置
var targetLocation: RDEPUBLocation? var targetLocation: RDEPUBLocation?
/// 待应用的高亮列表
var pendingHighlights: [RDEPUBHighlight] = [] var pendingHighlights: [RDEPUBHighlight] = []
/// 当前固定版式 spread(固定版式时非空)
var fixedSpread: EPUBFixedSpread? var fixedSpread: EPUBFixedSpread?
/// 是否为固定版式模式
var isFixedLayout = false var isFixedLayout = false
/// 是否有待处理的进度汇报请求
var pendingProgressionRequest = false var pendingProgressionRequest = false
/// 固定版式就绪回退定时器
var fixedLayoutReadyWorkItem: DispatchWorkItem? var fixedLayoutReadyWorkItem: DispatchWorkItem?
/// 当前请求是否已完成渲染
var didRenderCurrentRequest = false var didRenderCurrentRequest = false
/// 调试日志作用域
let debugScope = "ReaderWebView" let debugScope = "ReaderWebView"
public override init(frame: CGRect) { public override init(frame: CGRect) {
@@ -93,9 +141,11 @@ public final class RDEPUBWebView: UIView {
webView?.frame = bounds webView?.frame = bounds
} }
/// 重置所有状态并销毁 WebView
public func reset() { public func reset() {
delegate = nil delegate = nil
onRendered = nil onRendered = nil
onDecorationsResolved = nil
publication = nil publication = nil
currentRenderRequest = nil currentRenderRequest = nil
currentSpineIndex = 0 currentSpineIndex = 0
@@ -117,6 +167,7 @@ public final class RDEPUBWebView: UIView {
teardownWebView() teardownWebView()
} }
/// 加载渲染请求:配置 WebView、生成签名、分发到固定版式或重排处理
public func load(publication: RDEPUBPublication, request: RDEPUBRenderRequest) { public func load(publication: RDEPUBPublication, request: RDEPUBRenderRequest) {
configureWebViewIfNeeded(publication: publication) configureWebViewIfNeeded(publication: publication)
guard let webView else { return } guard let webView else { return }
@@ -143,6 +194,7 @@ public final class RDEPUBWebView: UIView {
} }
} }
/// 生成页面加载签名
func pageLoadSignature( func pageLoadSignature(
publicationKey: String, publicationKey: String,
request: RDEPUBRenderRequest request: RDEPUBRenderRequest
@@ -155,6 +207,7 @@ public final class RDEPUBWebView: UIView {
} }
} }
/// 标记渲染完成:通知委托和回调,延迟 100ms 确保布局稳定
func rendered() { func rendered() {
guard !didRenderCurrentRequest else { return } guard !didRenderCurrentRequest else { return }
didRenderCurrentRequest = true didRenderCurrentRequest = true
@@ -1,7 +1,15 @@
// RDEPUBWebViewDebug.swift
// WebView 调试日志工具
// 提供统一的调试日志输出接口,支持导航事件、JS 执行、
// 消息接收、URL Scheme 任务等场景的日志记录。
// DEBUG 模式默认开启,Release 模式默认关闭,可通过 UserDefaults 覆盖。
import Foundation import Foundation
import WebKit import WebKit
/// WebView 调试日志工具集
enum RDEPUBWebViewDebug { enum RDEPUBWebViewDebug {
/// 调试日志是否启用(DEBUG 默认开启,可通过 UserDefaults "RDEPUBWebViewDebugEnabled" 覆盖)
static var isEnabled: Bool = { static var isEnabled: Bool = {
if let configured = UserDefaults.standard.object(forKey: "RDEPUBWebViewDebugEnabled") as? Bool { if let configured = UserDefaults.standard.object(forKey: "RDEPUBWebViewDebugEnabled") as? Bool {
return configured return configured
@@ -13,16 +21,19 @@ enum RDEPUBWebViewDebug {
#endif #endif
}() }()
/// 获取 WebView 的十六进制标识符(用于日志区分多个 WebView 实例)
static func webViewID(_ webView: WKWebView?) -> String { static func webViewID(_ webView: WKWebView?) -> String {
guard let webView else { return "nil-webview" } guard let webView else { return "nil-webview" }
return String(ObjectIdentifier(webView).hashValue, radix: 16) return String(ObjectIdentifier(webView).hashValue, radix: 16)
} }
/// 输出通用日志
static func log(_ scope: String, message: String) { static func log(_ scope: String, message: String) {
guard isEnabled else { return } guard isEnabled else { return }
print("[RDReaderWK][\(scope)] \(message)") print("[RDReaderWK][\(scope)] \(message)")
} }
/// 输出导航事件日志(含 WebView ID、事件类型、URL、错误信息)
static func logNavigationEvent(_ scope: String, webView: WKWebView?, event: String, url: URL? = nil, error: Error? = nil) { static func logNavigationEvent(_ scope: String, webView: WKWebView?, event: String, url: URL? = nil, error: Error? = nil) {
guard isEnabled else { return } guard isEnabled else { return }
let webViewToken = webViewID(webView) let webViewToken = webViewID(webView)
@@ -34,16 +45,19 @@ enum RDEPUBWebViewDebug {
} }
} }
/// 输出 JavaScript 执行日志
static func logJavaScript(_ scope: String, webView: WKWebView?, action: String, details: String) { static func logJavaScript(_ scope: String, webView: WKWebView?, action: String, details: String) {
guard isEnabled else { return } guard isEnabled else { return }
log(scope, message: "webView=\(webViewID(webView)) js=\(action) \(details)") log(scope, message: "webView=\(webViewID(webView)) js=\(action) \(details)")
} }
/// 输出 JS 消息接收日志(含消息名称和消息体)
static func logMessage(_ scope: String, webView: WKWebView?, name: String, body: Any) { static func logMessage(_ scope: String, webView: WKWebView?, name: String, body: Any) {
guard isEnabled else { return } guard isEnabled else { return }
log(scope, message: "webView=\(webViewID(webView)) message=\(name) body=\(String(describing: body))") log(scope, message: "webView=\(webViewID(webView)) message=\(name) body=\(String(describing: body))")
} }
/// 输出 URL Scheme 任务日志(含请求 URL、文件 URL、事件类型、错误信息)
static func logSchemeTask(_ scope: String, requestURL: URL?, fileURL: URL? = nil, event: String, error: Error? = nil) { static func logSchemeTask(_ scope: String, requestURL: URL?, fileURL: URL? = nil, event: String, error: Error? = nil) {
guard isEnabled else { return } guard isEnabled else { return }
let requestText = summarizedURL(requestURL) let requestText = summarizedURL(requestURL)
@@ -55,6 +69,7 @@ enum RDEPUBWebViewDebug {
} }
} }
/// 截取 URL 的摘要信息(ss-reader:// 协议显示完整 URL,其他显示最后路径组件)
static func summarizedURL(_ url: URL?) -> String { static func summarizedURL(_ url: URL?) -> String {
guard let url else { return "nil" } guard let url else { return "nil" }
if let scheme = url.scheme, scheme == RDEPUBResourceURLSchemeHandler.scheme { if let scheme = url.scheme, scheme == RDEPUBResourceURLSchemeHandler.scheme {
@@ -153,47 +153,195 @@
var absoluteLeft = Math.max(0, rect.left + currentPageOffset()); var absoluteLeft = Math.max(0, rect.left + currentPageOffset());
return Math.max(0, Math.floor(absoluteLeft / effectivePageStride())); return Math.max(0, Math.floor(absoluteLeft / effectivePageStride()));
} }
function unwrapHighlights() { var storedHighlights = [];
document.querySelectorAll('mark.ss-reader-highlight').forEach(function(mark) { var storedSearchPresentation = null;
var parent = mark.parentNode; function normalizeHref(rawHref) {
if (!parent) { return; } if (!rawHref) { return ''; }
while (mark.firstChild) { var value = String(rawHref);
parent.insertBefore(mark.firstChild, mark); value = value.replace(/^.*?:\/\/[^/]+\//, '');
value = value.split('#')[0];
value = value.replace(/^\/+/, '');
try {
value = decodeURIComponent(value);
} catch (error) {
} }
parent.removeChild(mark); return value;
}); }
function clearHighlights() {
storedHighlights = [];
} }
function setHighlights(items) { function setHighlights(items) {
unwrapHighlights(); storedHighlights = Array.isArray(items) ? items.slice() : [];
if (!Array.isArray(items)) { return; } }
items.forEach(function(item) { function setSearchPresentation(payload) {
if (!item || !item.rangeInfo) { return; } storedSearchPresentation = payload || null;
var range = rangeFromInfo(item.rangeInfo); }
if (!range || range.collapsed) { return; } 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;
}
return NodeFilter.FILTER_ACCEPT;
}
});
var nodes = [];
while (walker.nextNode()) {
nodes.push(walker.currentNode);
}
return nodes;
}
function documentContexts(rootDoc, baseOffsetX, baseOffsetY) {
var items = [];
if (!rootDoc) { return items; }
var offsetX = baseOffsetX || 0;
var offsetY = baseOffsetY || 0;
items.push({
doc: rootDoc,
href: normalizeHref(rootDoc.location && rootDoc.location.href),
offsetX: offsetX,
offsetY: offsetY
});
var frames = rootDoc.querySelectorAll ? rootDoc.querySelectorAll('iframe') : [];
frames.forEach(function(frame) {
try { try {
var mark = document.createElement('mark'); if (!frame.contentDocument) { return; }
var style = item.style || 'highlight'; var frameRect = frame.getBoundingClientRect();
mark.className = 'ss-reader-highlight ss-reader-highlight-' + style; documentContexts(
mark.style.color = 'inherit'; frame.contentDocument,
if (style === 'underline') { offsetX + frameRect.left,
mark.style.background = 'transparent'; offsetY + frameRect.top
mark.style.textDecorationLine = 'underline'; ).forEach(function(item) {
mark.style.textDecorationStyle = 'solid'; items.push(item);
mark.style.textDecorationThickness = '2px'; });
mark.style.textDecorationColor = item.color || '#F8E16C';
} else {
mark.style.background = item.color || '#F8E16C';
}
mark.dataset.highlightId = item.id || '';
var fragment = range.extractContents();
mark.appendChild(fragment);
range.insertNode(mark);
if (mark.parentNode && mark.parentNode.normalize) {
mark.parentNode.normalize();
}
} catch (error) { } catch (error) {
} }
}); });
return items;
}
function rectPayloadForRange(range, offsetX, offsetY) {
if (!range || range.collapsed) { return []; }
var rects = [];
Array.prototype.forEach.call(range.getClientRects(), function(rect) {
if (!rect || rect.width < 0.5 || rect.height < 0.5) { return; }
rects.push({
x: rect.left + (offsetX || 0),
y: rect.top + (offsetY || 0),
width: rect.width,
height: rect.height
});
});
return rects;
}
function collectHighlightDecorations() {
return storedHighlights.map(function(item, index) {
if (!item || !item.rangeInfo) { return null; }
var range = rangeFromInfo(item.rangeInfo);
var rects = rectPayloadForRange(range, 0, 0);
if (!rects.length) { return null; }
return {
key: item.id || ('highlight-' + index),
kind: item.style === 'underline' ? 'underline' : 'highlight',
color: item.color || '#F8E16C',
rangeInfo: item.rangeInfo,
rects: rects
};
}).filter(Boolean);
}
function collectSearchDecorations() {
if (!storedSearchPresentation || !storedSearchPresentation.keyword) {
return [];
}
var resources = new Map();
if (Array.isArray(storedSearchPresentation.resources)) {
storedSearchPresentation.resources.forEach(function(resource) {
resources.set(normalizeHref(resource.href), resource);
});
}
var keyword = String(storedSearchPresentation.keyword);
var lowerKeyword = keyword.toLowerCase();
var decorations = [];
documentContexts(document, 0, 0).forEach(function(context) {
var resourceState = resources.get(context.href);
if (!resourceState || resourceState.matchCount <= 0) {
return;
}
var activeIndex = typeof resourceState.activeLocalMatchIndex === 'number' ? resourceState.activeLocalMatchIndex : null;
var currentLocalIndex = 0;
var activeRect = null;
var contextStartIndex = decorations.length;
textNodes(context.doc).forEach(function(node) {
var source = node.nodeValue || '';
var lowerSource = source.toLowerCase();
var cursor = 0;
while (cursor < source.length) {
var foundIndex = lowerSource.indexOf(lowerKeyword, cursor);
if (foundIndex < 0) {
break;
}
var range = context.doc.createRange();
range.setStart(node, foundIndex);
range.setEnd(node, foundIndex + keyword.length);
var rects = rectPayloadForRange(range, context.offsetX, context.offsetY);
if (rects.length) {
var isActive = currentLocalIndex === activeIndex;
decorations.push({
key: context.href + ':' + currentLocalIndex,
kind: isActive ? 'activeSearch' : 'search',
rects: rects
});
if (isActive) {
activeRect = rects[0];
}
}
currentLocalIndex += 1;
cursor = foundIndex + keyword.length;
}
});
if (activeRect && !isFixedLayoutDocument()) {
var previousPageIndex = visiblePageIndex;
var activePageIndex = Math.max(0, Math.floor(Math.max(0, activeRect.x + currentPageOffset()) / effectivePageStride()));
setVisiblePageIndex(activePageIndex);
var deltaX = (activePageIndex - previousPageIndex) * effectivePageStride();
if (deltaX !== 0) {
for (var index = contextStartIndex; index < decorations.length; index += 1) {
decorations[index].rects = decorations[index].rects.map(function(rect) {
return {
x: rect.x - deltaX,
y: rect.y,
width: rect.width,
height: rect.height
};
});
}
}
}
});
return decorations;
}
function resolveDecorations() {
return {
highlights: collectHighlightDecorations(),
search: collectSearchDecorations()
};
} }
function selectionPayload() { function selectionPayload() {
var selection = window.getSelection(); var selection = window.getSelection();
@@ -290,7 +438,9 @@
this.scrollToPage(derivedPageIndex); this.scrollToPage(derivedPageIndex);
}, },
setHighlights: setHighlights, setHighlights: setHighlights,
clearHighlights: unwrapHighlights, clearHighlights: clearHighlights,
setSearchPresentation: setSearchPresentation,
resolveDecorations: resolveDecorations,
reportProgression: reportProgression, reportProgression: reportProgression,
selectionPayload: selectionPayload selectionPayload: selectionPayload
}; };
@@ -0,0 +1,56 @@
/* 适配搜狗百科 bar 里的 icon 被染色 */
.header-home, .header-home:before, .header-logo, .loginBox, .header-search, .header-search:before, .header-more:before {
background-color: transparent !important;
}
/* 适配搜狗百科 某些透明的 div 被染色导致挡住图片 */
.abstract-img-none {
background: none !important;
}
/* 适配百度搜索图片丢失 */
.c-touchable-feedback-no-default * {
background-color: transparent !important;
}
img, video {
z-index: 1 !important;
}
/*背景纯黑*/
*, *:before, *:after {
background-color: rgba(0, 0, 0, 1) !important;
}
.wr-business-container,
.wr-business-container *,
.wr-business-container *:before,
.wr-business-container *:after {
background-color: rgb(28, 28, 29) !important;
}
/*背景颜色和一般字体颜色*/
div, h1, h2, h3, h4, h5, h6, p, body, em, html, link, textarea, form, select, input, span, button, em, menu, aside, table, tr, td, nav, dl, dt, dd, amp-iframe, main, section {
color: rgba(180, 180, 182, 1) !important;
border-color: #555555 !important;
text-shadow: 0 0 0 #000;
}
/*超链接*/
a {
color: rgba(84, 127, 176, 1) !important;
}
blockquote {
color : rgb(218, 220, 224);
background-color : rgb(10, 14, 18) !important;
border-left: 1px solid rgba(116, 120, 124, 1);
}
img {
border: none !important;
}
ul li:before, ol li:before {
background-color: rgba(196, 200, 204, 1) !important;
}
@@ -0,0 +1,221 @@
/* this file is processed with xxd via a build rule and embedded in library */
/* these styles come from Safari */
/* note that comments are only permitted before selectors and before the styles */
/* DO NOT fiddle with this file if you want to have your own styles,
pass your own stylesheet via the option parameter to override these defaults */
head {
display:none;
}
title {
display:none;
}
style {
display:none;
}
link {
display: none;
}
meta {
display: none;
}
script {
display: none;
}
html {
display:block;
margin:0;
padding:0;
}
body {
display:block;
font-size:16px;
margin:0;
padding:0;
}
article,aside,footer,header,hgroup,nav,section {
display:block;
}
p {
display:block;
margin:0;
}
img {
margin:0;
}
ul,ol,menu,dir {
display:block;
margin:1em 0 1em 0;
padding-left:24px;
}
ul {
list-style-type:disc;
}
ol {
list-style-type:decimal;
}
li {
display:list-item;
}
ul ul, ol ul {
list-style-type: circle;
}
ol ol ul, ol ul ul, ul ol ul, ul ul ul {
list-style-type: square;
}
code {
font-family:Courier;
}
pre, xmp, plaintext, listing {
display: block;
font-family: monospace;
white-space: pre;
margin: 1em 0;
}
a {
color:#2262A3;
text-decoration:underline;
}
a:active {
color:#2262A3;
}
center {
text-align:center;
display:block;
}
strong,b {
font-weight:bold;
}
i,em {
font-style:italic;
}
u {
text-decoration:underline;
}
big {
font-size:bigger;
}
small {
font-size:smaller;
}
sub {
font-size:smaller;
vertical-align:sub;
}
sup {
font-size:smaller;
vertical-align:super;
}
s,strike,del {
text-decoration:line-through;
}
tt,code,kbd,samp {
font-family:monospace;
}
pre,xmp,plaintext,listing {
display:block;
font-family:monospace;
white-space:pre;
margin-top:1em;
margin-right:0;
margin-bottom:1em;
}
pre {
background-color:rgba(0,0,0,.05);
padding-top:1em;
padding-bottom:1em;
border-radius:0.3em;
}
h1 {
display:block;
font-size:1.5em;
font-weight: normal;
}
h2 {
display:block;
font-size:1.4em;
margin-top: 0.83em;
}
h3 {
display:block;
font-size:1.3em;
margin-top: 1em;
}
h4 {
display:block;
font-size:1.2em;
margin-top: 1.33em;
}
h5 {
display:block;
font-size:1.1em;
margin-top: 1.67em;
}
h6 {
display:block;
font-size:1em;
margin-top: 2.33em;
}
div {
display: block;
}
hr {
display: block;
margin:0.5em auto 0.5em auto;
border-style: inset;
border-width: 1px;
}
table {
display: table;
border-collapse: separate;
border-spacing: 2px;
border-color: gray;
}
blockquote {
display: block;
}
@@ -0,0 +1,113 @@
/* <pre>代码块,注意必须写font-weight使字体生效 */
pre {
font-family: "Menlo";
font-weight: normal;
line-height: 1.5em;
}
/*版权信息*/
.copyRightTitle {
color: black;
font-size: 1.5em;
font-family: "Source Han Serif CN";
font-weight: normal;
}
/*图片说明文字*/
.eepub-single-image-title {
font-size: 0.75em;
text-align: center;
line-height: 1.4em;
color: rgba(0, 0, 0, 0.9);
margin: 0.2em 0.4em 1em 0.4em;
font-weight: normal;
}
/*标题*/
.firstTitle, h1.firstTitle {
font-size: 1.5em;
font-weight: bold;
line-height: 1.25em;
}
.secondTitle, h2.secondTitle {
font-size: 1.4em;
font-weight: bold;
line-height: 1.35em;
}
.thirdTitle, h3.thirdTitle {
font-size: 1.3em;
font-weight: bold;
line-height: 1.5em;
}
.fourthTitle, h4.fourthTitle {
font-size: 1.2em;
font-weight: bold;
line-height: 1.65em;
}
.fifthTitle, h5.fifthTitle {
font-size: 1.1em;
font-weight: bold;
line-height: 1.85em;
}
.sixthTitle, h6.sixthTitle {
font-size: 1em;
font-weight: bold;
line-height: 2em;
}
/*首字加大*/
/*浮动元素有默认的margin,所以这里会修复一下*/
.ftext {
float: left;
margin: 0em;
font-size: 2.38em;
font-weight: normal;
}
/*引用内容*/
.conQuot {
font-weight: normal;
margin: 0em 0em 0.2em 0em;
}
/*标题下来可能会有一行subHead*/
.subHead{
text-indent: 2em;
}
pre, pre span, pre code {
font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size:.7em!important;
}
.bodyPic {
wr-vertical-center-style: 2;
}
.qrbodyPic {
page-break-inside: avoid;
wr-vertical-center-style: 2;
}
/* 注标图 */
.qqreader-footnote {
width: 1em;
}
/* 私有垂直居中类 */
.wr-vertical-center {
wr-vertical-center-style: 1 !important;
}
/* 翻译 */
.wr-translation {
line-height: 1.7em !important;
}
/* 章节结尾工具 */
.book-chapter-tool {
display: block;
height: 78px;
width: 100%;
margin-top: 24px;
}
@@ -0,0 +1,171 @@
/* <pre>代码块,注意必须写font-weight使字体生效 */
pre {
font-family: "Menlo";
font-weight: normal;
line-height: 1.5em;
}
/*版权信息*/
.copyRightTitle {
color: black;
font-size: 1.5em;
font-family: "Source Han Serif CN";
font-weight: normal;
}
/*图片说明文字*/
.eepub-single-image-title {
font-size: 0.75em;
text-align: center;
line-height: 1.4em;
color: rgba(0, 0, 0, 0.9);
margin: 0.2em 0.4em 1em 0.4em;
font-family: "FZFSJW--GB1-0";
font-weight: normal;
}
/*标题*/
.firstTitle, h1.firstTitle {
font-size: 1.5em;
font-family: "Source Han Serif CN";
font-weight: bold;
line-height: 1.25em;
}
.secondTitle, h2.secondTitle {
font-size: 1.4em;
font-family: "Source Han Serif CN";
font-weight: bold;
line-height: 1.35em;
}
.thirdTitle, h3.thirdTitle {
font-size: 1.3em;
font-family: "Source Han Serif CN";
font-weight: bold;
line-height: 1.5em;
}
.fourthTitle, h4.fourthTitle {
font-size: 1.2em;
font-family: "Source Han Serif CN";
font-weight: bold;
line-height: 1.65em;
}
.fifthTitle, h5.fifthTitle {
font-size: 1.1em;
font-family: "Source Han Serif CN";
font-weight: bold;
line-height: 1.85em;
}
.sixthTitle, h6.sixthTitle {
font-size: 1em;
font-family: "Source Han Serif CN";
font-weight: bold;
line-height: 2em;
}
/*首字加大*/
/*浮动元素有默认的margin,所以这里会修复一下*/
.ftext {
float: left;
margin: 0em;
font-size: 2.38em;
font-weight: normal;
}
/*引用内容*/
.conQuot {
font-family: "FZFSJW--GB1-0";
font-weight: normal;
margin: 0em 0em 0.2em 0em;
}
/*标题下来可能会有一行subHead*/
.subHead{
text-indent: 2em;
}
pre, pre span, pre code {
font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size:.7em!important;
}
.bodyPic {
wr-vertical-center-style: 2;
}
.qrbodyPic {
page-break-inside: avoid;
wr-vertical-center-style: 2;
}
/* 注标图 */
.qqreader-footnote {
width: 1em;
}
/* 翻译 */
.wr-translation {
line-height: 1.7em !important;
}
/*-------------------- 小文章相关 ----------------------*/
/*epub-custom-rule-6 如果 class="weread-page-relate" 在开头,需要把上一页末尾一行移到这一页*/
.weread-page-relate {
weread-page-relate:true;
}
/*epub-custom-rule-29 小文章打赏的样式*/
.chapter-reward {
display: block;
height: 120px;
width: 100%;
margin-top: 24px;
}
/*epub-custom-rule-7 小文章工具栏标签的样式*/
.chapter-tool {
display: block;
height: 140px;
width: 100%;
margin-top: 24px;
}
/* 章节结尾工具 */
.book-chapter-tool {
display: block;
height: 78px;
width: 100%;
margin-top: 24px;
}
/* 调试好样式后和安卓同步,交给后台随小文章 css 下发,并把下面的样式从replace.css 删除 */
/* 安卓的re_bookItem是通过原生view留出上下空间的,但是iOS是通过css来控制 */
.re_bookItem{
display: block;
margin: 18px 0 18px 0;
}
p {
text-align:justify;
}
img[data-image-size="large"] {
width: 100%;
}
/* 文集公众号文章不支持控件的样式 */
.unsupported_iframe {
border-radius: 4px;
border: 1px solid #ffcdc0;
background: #fee;
padding: 12px 0px;
color: #ff8d8d;
text-align: center;
font-size: 14px;
}
/* 私有垂直居中类 */
.wr-vertical-center {
wr-vertical-center-style: 1 !important;
}
@@ -1,7 +1,14 @@
import UIKit import UIKit
/// 章节数据访问层:为已分页章节提供便捷的查询接口。
///
/// 本类是 `RDEPUBTextChapter` 的轻量级封装,持有 `indexTable`(全局索引表),
/// 将分页数据、fragment 锚点、高亮、搜索结果等统一到同一套查询 API 中。
/// 由 `RDEPUBTextBook.chapterData(for:)` 或 `chapterData(atChapterIndex:)` 创建。
public final class RDEPUBChapterData { public final class RDEPUBChapterData {
/// 底层章节模型(只读)
public let chapter: RDEPUBTextChapter public let chapter: RDEPUBTextChapter
/// 全局索引表,用于 fileIndex/row/column 到绝对偏移量的映射
public let indexTable: RDEPUBTextIndexTable public let indexTable: RDEPUBTextIndexTable
public init(chapter: RDEPUBTextChapter, indexTable: RDEPUBTextIndexTable) { public init(chapter: RDEPUBTextChapter, indexTable: RDEPUBTextIndexTable) {
@@ -9,36 +16,61 @@ public final class RDEPUBChapterData {
self.indexTable = indexTable self.indexTable = indexTable
} }
// MARK: - 便捷属性(转发自 chapter)
/// 章节在全书中的序号
public var chapterIndex: Int { chapter.chapterIndex } public var chapterIndex: Int { chapter.chapterIndex }
/// spine 顺序索引
public var spineIndex: Int { chapter.spineIndex } public var spineIndex: Int { chapter.spineIndex }
/// 章节文件相对路径(如 "OEBPS/chapter1.xhtml")
public var href: String { chapter.href } public var href: String { chapter.href }
/// 章节标题
public var title: String { chapter.title } public var title: String { chapter.title }
/// 章节完整的富文本内容
public var attributedContent: NSAttributedString { chapter.attributedContent } public var attributedContent: NSAttributedString { chapter.attributedContent }
/// 分页后的页面列表
public var pages: [RDEPUBTextPage] { chapter.pages } public var pages: [RDEPUBTextPage] { chapter.pages }
/// fragment ID → 字符偏移量的映射表(用于锚点定位)
public var fragmentOffsets: [String: Int] { chapter.fragmentOffsets } public var fragmentOffsets: [String: Int] { chapter.fragmentOffsets }
// MARK: - 页面查询
/// 查找包含指定绝对字符偏移量的页面
/// - Parameter absoluteOffset: 全书绝对字符偏移量
/// - Returns: 包含该偏移量的页面,未找到返回 nil
public func page(containing absoluteOffset: Int) -> RDEPUBTextPage? { public func page(containing absoluteOffset: Int) -> RDEPUBTextPage? {
chapter.pages.first { NSLocationInRange(absoluteOffset, $0.contentRange) } chapter.pages.first { NSLocationInRange(absoluteOffset, $0.contentRange) }
} }
/// 获取指定绝对偏移量所在页的绝对页码(从 0 开始)
public func pageNumber(containing absoluteOffset: Int) -> Int? { public func pageNumber(containing absoluteOffset: Int) -> Int? {
page(containing: absoluteOffset)?.absolutePageIndex page(containing: absoluteOffset)?.absolutePageIndex
} }
/// 按绝对页码查找页面
public func page(atAbsolutePageIndex absolutePageIndex: Int) -> RDEPUBTextPage? { public func page(atAbsolutePageIndex absolutePageIndex: Int) -> RDEPUBTextPage? {
chapter.pages.first { $0.absolutePageIndex == absolutePageIndex } chapter.pages.first { $0.absolutePageIndex == absolutePageIndex }
} }
// MARK: - 锚点与位置映射
/// 将绝对字符索引转换为语义锚点(fileIndex/row/column 三元组)
public func anchor(forAbsoluteIndex index: Int) -> RDEPUBTextAnchor { public func anchor(forAbsoluteIndex index: Int) -> RDEPUBTextAnchor {
indexTable.anchor(forAbsoluteIndex: index, in: chapter) indexTable.anchor(forAbsoluteIndex: index, in: chapter)
} }
/// 将绝对字符范围转换为起止锚点对
public func rangeAnchor(for absoluteRange: NSRange) -> RDEPUBTextRangeAnchor { public func rangeAnchor(for absoluteRange: NSRange) -> RDEPUBTextRangeAnchor {
let start = anchor(forAbsoluteIndex: absoluteRange.location) let start = anchor(forAbsoluteIndex: absoluteRange.location)
let end = anchor(forAbsoluteIndex: absoluteRange.location + absoluteRange.length) let end = anchor(forAbsoluteIndex: absoluteRange.location + absoluteRange.length)
return RDEPUBTextRangeAnchor(start: start, end: end) return RDEPUBTextRangeAnchor(start: start, end: end)
} }
/// 从绝对字符范围构建选区对象(用于复制/高亮分享)
/// - Parameters:
/// - absoluteRange: 选区在全书中的字符范围
/// - bookIdentifier: 书籍标识符
/// - Returns: 选区对象,若起始偏移量不在任何页面内则返回 nil
public func selection(from absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBSelection? { public func selection(from absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBSelection? {
guard page(containing: absoluteRange.location) != nil else { return nil } guard page(containing: absoluteRange.location) != nil else { return nil }
let location = self.location(for: absoluteRange, bookIdentifier: bookIdentifier) let location = self.location(for: absoluteRange, bookIdentifier: bookIdentifier)
@@ -56,6 +88,7 @@ public final class RDEPUBChapterData {
) )
} }
/// 将绝对字符范围转换为持久化位置对象(RDEPUBLocation)
public func location(for absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBLocation { public func location(for absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBLocation {
indexTable.location( indexTable.location(
for: rangeAnchor(for: absoluteRange), for: rangeAnchor(for: absoluteRange),
@@ -64,10 +97,19 @@ public final class RDEPUBChapterData {
) )
} }
/// 将页面转换为对应的 RDEPUBLocation
public func location(forPage page: RDEPUBTextPage, bookIdentifier: String?) -> RDEPUBLocation { public func location(forPage page: RDEPUBTextPage, bookIdentifier: String?) -> RDEPUBLocation {
location(for: page.contentRange, bookIdentifier: bookIdentifier) location(for: page.contentRange, bookIdentifier: bookIdentifier)
} }
// MARK: - 位置反向解析(Location → 绝对偏移量)
/// 将持久化位置还原为章节内的绝对字符范围。
///
/// 解析优先级:
/// 1. rangeAnchor(锚点定位,最精确)
/// 2. fragment(片段 ID 定位)
/// 3. navigationProgression(进度百分比回退)
public func absoluteRange(for location: RDEPUBLocation) -> NSRange? { public func absoluteRange(for location: RDEPUBLocation) -> NSRange? {
if let rangeAnchor = location.rangeAnchor { if let rangeAnchor = location.rangeAnchor {
return indexTable.absoluteRange(for: rangeAnchor) return indexTable.absoluteRange(for: rangeAnchor)
@@ -83,6 +125,7 @@ public final class RDEPUBChapterData {
return NSRange(location: offset, length: 1) return NSRange(location: offset, length: 1)
} }
/// 从高亮的持久化位置还原绝对字符范围
public func absoluteRange(for highlight: RDEPUBHighlight) -> NSRange? { public func absoluteRange(for highlight: RDEPUBHighlight) -> NSRange? {
if let rangeAnchor = highlight.location.rangeAnchor { if let rangeAnchor = highlight.location.rangeAnchor {
return indexTable.absoluteRange(for: rangeAnchor) return indexTable.absoluteRange(for: rangeAnchor)
@@ -90,6 +133,7 @@ public final class RDEPUBChapterData {
return RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange return RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange
} }
/// 从搜索结果还原绝对字符范围
public func absoluteRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? { public func absoluteRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
if let rangeAnchor = searchMatch.rangeAnchor { if let rangeAnchor = searchMatch.rangeAnchor {
return indexTable.absoluteRange(for: rangeAnchor) return indexTable.absoluteRange(for: rangeAnchor)
@@ -100,6 +144,9 @@ public final class RDEPUBChapterData {
return nil return nil
} }
// MARK: - 高亮/搜索结果与页面的交叉查询
/// 获取指定页面上出现的所有高亮
public func highlights(on page: RDEPUBTextPage, from allHighlights: [RDEPUBHighlight]) -> [RDEPUBHighlight] { public func highlights(on page: RDEPUBTextPage, from allHighlights: [RDEPUBHighlight]) -> [RDEPUBHighlight] {
let pageRange = absoluteOffsetRange(for: page) let pageRange = absoluteOffsetRange(for: page)
return allHighlights.filter { highlight in return allHighlights.filter { highlight in
@@ -114,6 +161,7 @@ public final class RDEPUBChapterData {
} }
} }
/// 获取指定页面上出现的所有搜索匹配结果
public func searchMatches(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] { public func searchMatches(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] {
let pageRange = absoluteOffsetRange(for: page) let pageRange = absoluteOffsetRange(for: page)
return matches.filter { match in return matches.filter { match in
@@ -125,6 +173,7 @@ public final class RDEPUBChapterData {
} }
} }
/// 根据持久化位置获取对应页码(从 1 开始),用于阅读进度跳转
public func pageNumber(for location: RDEPUBLocation) -> Int? { public func pageNumber(for location: RDEPUBLocation) -> Int? {
if let range = absoluteRange(for: location) { if let range = absoluteRange(for: location) {
return pageNumber(containing: range.location).map { $0 + 1 } return pageNumber(containing: range.location).map { $0 + 1 }
@@ -132,12 +181,16 @@ public final class RDEPUBChapterData {
return nil return nil
} }
// MARK: - 私有工具方法
/// 将页面的起止偏移量转换为半开区间 [start, end+1)
private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range<Int> { private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range<Int> {
let lowerBound = page.pageStartOffset let lowerBound = page.pageStartOffset
let upperBound = page.pageEndOffset + 1 let upperBound = page.pageEndOffset + 1
return lowerBound..<max(upperBound, lowerBound) return lowerBound..<max(upperBound, lowerBound)
} }
/// 将语义锚点转换为绝对字符偏移量,若索引表无法解析则回退到 chapterOffset
private func absoluteOffset(for anchor: RDEPUBTextAnchor) -> Int { private func absoluteOffset(for anchor: RDEPUBTextAnchor) -> Int {
indexTable.absoluteIndex( indexTable.absoluteIndex(
fileIndex: anchor.fileIndex, fileIndex: anchor.fileIndex,
@@ -4,9 +4,18 @@ import UIKit
import DTCoreText import DTCoreText
#endif #endif
/// DTCoreText 渲染器实现:将 EPUB 章节 HTML 转换为富文本(NSAttributedString)。
///
/// 这是 `RDEPUBTextRenderer` 协议的默认实现,位于渲染链路的第二步:
/// 章节 HTML → DTCoreText 渲染 → NSAttributedString → 提取 fragment 偏移量 → 统一字体/行距
///
/// DTCoreText 库负责将 HTML 解析为带有排版属性的富文本,
/// 渲染过程中会通过 `willFlushCallback` 回调对每个 DOM 元素做布局规范化(图片尺寸等)。
/// 当 DTCoreText 不可用时(条件编译失败),回退到纯文本渲染。
public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer { public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
public init() {} public init() {}
/// DTCoreText 库是否在当前编译环境中可用
public static var isAvailable: Bool { public static var isAvailable: Bool {
#if canImport(DTCoreText) #if canImport(DTCoreText)
return true return true
@@ -15,6 +24,14 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
#endif #endif
} }
/// 渲染单个章节:HTML → NSAttributedString,同时提取 fragment 和语义标记。
///
/// 渲染流程:
/// 1. 将 HTML 字符串编码为 Data
/// 2. 通过 DTCoreText 解析为富文本(失败则回退到纯文本)
/// 3. 注入分页语义标记(${rd-sem-start/end} → 属性字典)
/// 4. 提取 fragment 偏移量映射表
/// 5. 规范化阅读属性(字体、行距、颜色统一)
public func renderChapter( public func renderChapter(
request: RDEPUBTextChapterRenderRequest request: RDEPUBTextChapterRenderRequest
) throws -> RDEPUBRenderedChapterContent { ) throws -> RDEPUBRenderedChapterContent {
@@ -42,6 +59,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
#endif #endif
} }
/// 便捷方法:直接传入 HTML 字符串进行渲染(不含上下文信息)
public func renderChapter( public func renderChapter(
html: String, html: String,
baseURL: URL?, baseURL: URL?,
@@ -58,6 +76,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
return try renderChapter(request: request) return try renderChapter(request: request)
} }
/// 回退渲染:当 DTCoreText 不可用时,将 HTML 源码当作纯文本处理
private func fallbackRenderedContent(request: RDEPUBTextChapterRenderRequest) -> RDEPUBRenderedChapterContent { private func fallbackRenderedContent(request: RDEPUBTextChapterRenderRequest) -> RDEPUBRenderedChapterContent {
let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style) let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style)
RDEPUBTextRendererSupport.applyPaginationSemantics(in: attributedString) RDEPUBTextRendererSupport.applyPaginationSemantics(in: attributedString)
@@ -71,6 +90,10 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
} }
#if canImport(DTCoreText) #if canImport(DTCoreText)
/// 使用 DTCoreText 将 HTML Data 解析为富文本。
///
/// 通过 `willFlushCallback` 回调,在每个 DOM 元素最终写入富文本前,
/// 对图片附件做尺寸规范化、判断是否为脚注/封面等特殊元素。
private func makeAttributedString(from data: Data, request: RDEPUBTextChapterRenderRequest) -> NSAttributedString? { private func makeAttributedString(from data: Data, request: RDEPUBTextChapterRenderRequest) -> NSAttributedString? {
let builder = DTHTMLAttributedStringBuilder( let builder = DTHTMLAttributedStringBuilder(
html: data, html: data,
@@ -87,6 +110,10 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
return builder?.generatedAttributedString() return builder?.generatedAttributedString()
} }
/// 构建 DTCoreText 的解析选项字典,包括字体、行高、图片尺寸限制等。
///
/// - 注意:行高倍率计算公式为 `(字体行高 + 行间距) / 字体行高`,
/// 确保最终行距与用户设置的 style.lineSpacing 一致。
private func dtOptions(request: RDEPUBTextChapterRenderRequest) -> [AnyHashable: Any] { private func dtOptions(request: RDEPUBTextChapterRenderRequest) -> [AnyHashable: Any] {
let style = request.style let style = request.style
let screenBounds = UIScreen.main.bounds.insetBy(dx: 20, dy: 28) let screenBounds = UIScreen.main.bounds.insetBy(dx: 20, dy: 28)
@@ -1,48 +1,80 @@
import UIKit import UIKit
// MARK: - 章节分页诊断数据
/// 单个章节的分页诊断信息,用于调试和质量检测。
/// 记录页数、分页原因、附件/块级元素统计等。
public struct RDEPUBTextChapterPaginationDiagnostic: Equatable { public struct RDEPUBTextChapterPaginationDiagnostic: Equatable {
public var href: String public var href: String
public var title: String public var title: String
public var pageCount: Int public var pageCount: Int
/// 每页的分页原因列表
public var breakReasons: [RDEPUBTextPageBreakReason] public var breakReasons: [RDEPUBTextPageBreakReason]
/// 包含附件的页数
public var attachmentPageCount: Int public var attachmentPageCount: Int
/// 因块级/附件边界调整而分页的页数
public var blockAdjustedPageCount: Int public var blockAdjustedPageCount: Int
public var blockKinds: [RDEPUBTextBlockKind] public var blockKinds: [RDEPUBTextBlockKind]
public var semanticHints: [RDEPUBTextSemanticHint] public var semanticHints: [RDEPUBTextSemanticHint]
public var attachmentPlacements: [RDEPUBTextAttachmentPlacement] public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
/// 采样诊断日志(最多 4 条)
public var sampleNotes: [String] public var sampleNotes: [String]
} }
// MARK: - 页面数据模型
/// 分页后的单页数据,包含全书绝对页码、所属章节、内容范围等。
///
/// 每个 `RDEPUBTextPage` 由 `RDEPUBTextLayoutFrame` 生成,
/// `contentRange` 标记了该页在章节富文本中的字符范围。
public struct RDEPUBTextPage: Equatable { public struct RDEPUBTextPage: Equatable {
/// 全书绝对页码(从 0 开始)
public var absolutePageIndex: Int public var absolutePageIndex: Int
public var chapterIndex: Int public var chapterIndex: Int
public var spineIndex: Int public var spineIndex: Int
public var href: String public var href: String
public var chapterTitle: String public var chapterTitle: String
/// 该页在所属章节中的相对页码(从 0 开始)
public var pageIndexInChapter: Int public var pageIndexInChapter: Int
public var totalPagesInChapter: Int public var totalPagesInChapter: Int
/// 所属章节的完整富文本内容(用于跨页查询)
public var chapterContent: NSAttributedString public var chapterContent: NSAttributedString
/// 该页的富文本片段
public var content: NSAttributedString public var content: NSAttributedString
/// 该页在章节富文本中的范围
public var contentRange: NSRange public var contentRange: NSRange
public var pageStartOffset: Int public var pageStartOffset: Int
public var pageEndOffset: Int public var pageEndOffset: Int
/// 页的元数据(分页原因、附件、语义标记等)
public var metadata: RDEPUBTextPageMetadata public var metadata: RDEPUBTextPageMetadata
} }
// MARK: - 章节数据模型
/// 渲染并分页后的单个章节,包含完整富文本和分页结果。
public struct RDEPUBTextChapter: Equatable { public struct RDEPUBTextChapter: Equatable {
public var chapterIndex: Int public var chapterIndex: Int
public var spineIndex: Int public var spineIndex: Int
public var href: String public var href: String
public var title: String public var title: String
/// 章节完整富文本内容
public var attributedContent: NSAttributedString public var attributedContent: NSAttributedString
/// fragment ID → 字符偏移量映射(用于锚点定位)
public var fragmentOffsets: [String: Int] public var fragmentOffsets: [String: Int]
public var pageBreakReasons: [RDEPUBTextPageBreakReason] public var pageBreakReasons: [RDEPUBTextPageBreakReason]
public var pages: [RDEPUBTextPage] public var pages: [RDEPUBTextPage]
} }
// MARK: - 分页书籍模型
/// 整本 EPUB 的分页书籍模型,包含所有章节、页面和全局索引表。
///
/// 这是 EPUBTextRendering 层的最终产物,由 `RDEPUBTextBookBuilder.build()` 生成。
/// 通过 `chapterData(for:)` 或 `chapterData(atChapterIndex:)` 获取 `RDEPUBChapterData` 进行查询。
public struct RDEPUBTextBook { public struct RDEPUBTextBook {
public var chapters: [RDEPUBTextChapter] public var chapters: [RDEPUBTextChapter]
public var pages: [RDEPUBTextPage] public var pages: [RDEPUBTextPage]
/// 全局索引表:fileIndex/row/column → 绝对字符偏移量
public let indexTable: RDEPUBTextIndexTable public let indexTable: RDEPUBTextIndexTable
public init(chapters: [RDEPUBTextChapter], pages: [RDEPUBTextPage]) { public init(chapters: [RDEPUBTextChapter], pages: [RDEPUBTextPage]) {
@@ -55,16 +87,19 @@ public struct RDEPUBTextBook {
lhs.chapters == rhs.chapters && lhs.pages == rhs.pages lhs.chapters == rhs.chapters && lhs.pages == rhs.pages
} }
/// 按 href 获取章节的数据访问层(包含索引表)
public func chapterData(for href: String) -> RDEPUBChapterData? { public func chapterData(for href: String) -> RDEPUBChapterData? {
guard let chapter = chapters.first(where: { $0.href == href }) else { return nil } guard let chapter = chapters.first(where: { $0.href == href }) else { return nil }
return RDEPUBChapterData(chapter: chapter, indexTable: indexTable) return RDEPUBChapterData(chapter: chapter, indexTable: indexTable)
} }
/// 按章节序号获取章节的数据访问层
public func chapterData(atChapterIndex index: Int) -> RDEPUBChapterData? { public func chapterData(atChapterIndex index: Int) -> RDEPUBChapterData? {
guard chapters.indices.contains(index) else { return nil } guard chapters.indices.contains(index) else { return nil }
return RDEPUBChapterData(chapter: chapters[index], indexTable: indexTable) return RDEPUBChapterData(chapter: chapters[index], indexTable: indexTable)
} }
/// 按页码(从 1 开始)获取对应页面
public func page(at pageNumber: Int) -> RDEPUBTextPage? { public func page(at pageNumber: Int) -> RDEPUBTextPage? {
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else { guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else {
return nil return nil
@@ -72,6 +107,12 @@ public struct RDEPUBTextBook {
return pages[pageNumber - 1] return pages[pageNumber - 1]
} }
/// 根据持久化位置计算对应页码(从 1 开始)。
///
/// 解析优先级:
/// 1. rangeAnchor 锚点定位
/// 2. fragment 片段 ID
/// 3. navigationProgression 进度百分比回退
public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? { public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? {
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier), guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier),
let chapter = chapters.first(where: { $0.href == normalizedLocation.href }) else { let chapter = chapters.first(where: { $0.href == normalizedLocation.href }) else {
@@ -98,6 +139,7 @@ public struct RDEPUBTextBook {
return chapter.pages.last.map { $0.absolutePageIndex + 1 } return chapter.pages.last.map { $0.absolutePageIndex + 1 }
} }
/// 根据页码生成持久化位置(RDEPUBLocation),包含起止锚点
public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? { public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
guard let page = page(at: pageNumber), guard let page = page(at: pageNumber),
let chapter = chapters.first(where: { $0.chapterIndex == page.chapterIndex }) else { let chapter = chapters.first(where: { $0.chapterIndex == page.chapterIndex }) else {
@@ -121,26 +163,49 @@ public struct RDEPUBTextBook {
} }
} }
// MARK: - 分页书籍构建器
/// 从 EPUB publication 构建分页书籍模型的核心构建器。
///
/// 渲染链路:遍历 spine → 渲染每章 HTML → 分页 → 合并/规范化尾页 → 构建 RDEPUBTextBook
///
/// 特性:
/// - 支持分页缓存(WXRead 模式:只缓存页范围,不缓存富文本)
/// - 性能采样(记录每章渲染/分页耗时)
/// - 尾页规范化(丢弃纯空白尾页、合并过短尾页)
/// - 封面章节特殊处理
public final class RDEPUBTextBookBuilder { public final class RDEPUBTextBookBuilder {
private let renderer: RDEPUBTextRenderer private let renderer: RDEPUBTextRenderer
private let cache: RDEPUBTextBookCache? private let cache: RDEPUBTextBookCache?
private let layoutConfig: RDEPUBTextLayoutConfig
private let sampler: RDEPUBTextPerformanceSampler private let sampler: RDEPUBTextPerformanceSampler
/// 最后一次构建的资源引用诊断(样式表、图片等)
public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = [] public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
/// 最后一次构建的分页诊断(每章一页的分页原因、附件统计等)
public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic] = [] public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic] = []
/// 最后一次构建的性能采样数据
public private(set) var lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample] = [] public private(set) var lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample] = []
/// 最后一次构建的缓存命中/未命中统计
public private(set) var lastBuildCacheStats: (hits: Int, misses: Int) = (0, 0) public private(set) var lastBuildCacheStats: (hits: Int, misses: Int) = (0, 0)
public init(renderer: RDEPUBTextRenderer, cache: RDEPUBTextBookCache? = nil) { public init(
renderer: RDEPUBTextRenderer,
cache: RDEPUBTextBookCache? = nil,
layoutConfig: RDEPUBTextLayoutConfig = .default
) {
self.renderer = renderer self.renderer = renderer
self.cache = cache self.cache = cache
self.layoutConfig = layoutConfig
self.sampler = RDEPUBTextPerformanceSampler() self.sampler = RDEPUBTextPerformanceSampler()
} }
/// 默认构造器,使用 DTCoreText 渲染器
public convenience init() { public convenience init() {
self.init(renderer: RDEPUBDTCoreTextRenderer()) self.init(renderer: RDEPUBDTCoreTextRenderer())
} }
/// 生成最近一次构建的语义摘要,用于 Phase 7 质量检测日志
public func phase7SemanticSummary(title: String? = nil) -> String? { public func phase7SemanticSummary(title: String? = nil) -> String? {
guard !lastBuildPaginationDiagnostics.isEmpty else { return nil } guard !lastBuildPaginationDiagnostics.isEmpty else { return nil }
@@ -164,6 +229,16 @@ public final class RDEPUBTextBookBuilder {
return parts.joined(separator: " · ") return parts.joined(separator: " · ")
} }
/// 核心构建方法:从 EPUB publication 构建分页书籍。
///
/// 流程:
/// 1. 生成缓存键,尝试加载分页缓存
/// 2. 遍历 spine 中的线性 HTML 章节
/// 3. 渲染每章 HTML → NSAttributedString
/// 4. 跳过空白的封面/扉页章节
/// 5. 分页:缓存命中则使用缓存的页范围,否则调用 CoreText 分页引擎
/// 6. 尾页规范化:丢弃纯空白尾页、合并过短尾页
/// 7. 构建 RDEPUBTextBook 并保存分页缓存
public func build( public func build(
parser: RDEPUBParser, parser: RDEPUBParser,
publication: RDEPUBPublication, publication: RDEPUBPublication,
@@ -180,7 +255,7 @@ public final class RDEPUBTextBookBuilder {
let buildStart = CFAbsoluteTimeGetCurrent() let buildStart = CFAbsoluteTimeGetCurrent()
// Cache lookup — WXRead style: load per-chapter page ranges // 缓存查询 — WXRead 模式:只加载每章的页范围,不缓存富文本
let bookID = publication.metadata.identifier ?? publication.metadata.title let bookID = publication.metadata.identifier ?? publication.metadata.title
let cacheKey = makeCacheKey(bookID: bookID, pageSize: pageSize, style: style) let cacheKey = makeCacheKey(bookID: bookID, pageSize: pageSize, style: style)
let cachedPagination = cacheKey.flatMap { cache?.load(key: $0) } let cachedPagination = cacheKey.flatMap { cache?.load(key: $0) }
@@ -191,6 +266,7 @@ public final class RDEPUBTextBookBuilder {
continue continue
} }
// 从目录表中解析章节标题
let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents) let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest( let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
href: item.href, href: item.href,
@@ -198,9 +274,11 @@ public final class RDEPUBTextBookBuilder {
rawHTML: rawHTML, rawHTML: rawHTML,
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(), baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
style: style, style: style,
resourceResolver: publication.resourceResolver resourceResolver: publication.resourceResolver,
contentLanguageCode: publication.metadata.language
) )
// 渲染 HTML → NSAttributedString
let renderStart = CFAbsoluteTimeGetCurrent() let renderStart = CFAbsoluteTimeGetCurrent()
let rendered = try renderer.renderChapter(request: request) let rendered = try renderer.renderChapter(request: request)
let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart
@@ -211,6 +289,7 @@ public final class RDEPUBTextBookBuilder {
if item.href.lowercased().contains("cover") { if item.href.lowercased().contains("cover") {
print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))") print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))")
} }
// 跳过空白的封面/扉页章节
if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) { if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
if item.href.lowercased().contains("cover") { if item.href.lowercased().contains("cover") {
print("[EPUB][Cover] skipped href=\(item.href)") print("[EPUB][Cover] skipped href=\(item.href)")
@@ -221,10 +300,12 @@ public final class RDEPUBTextBookBuilder {
let chapterIndex = chapters.count let chapterIndex = chapters.count
let content = NSMutableAttributedString(attributedString: rendered.attributedString) let content = NSMutableAttributedString(attributedString: rendered.attributedString)
// 分页:封面图片章节走特殊路径,缓存命中则跳过分页引擎
let paginateStart = CFAbsoluteTimeGetCurrent() let paginateStart = CFAbsoluteTimeGetCurrent()
let layoutFrames: [RDEPUBTextLayoutFrame] let layoutFrames: [RDEPUBTextLayoutFrame]
let isCacheHit: Bool let isCacheHit: Bool
if isAttachmentOnlyCoverChapter(item: item, content: content, plainText: plainText) { if isAttachmentOnlyCoverChapter(item: item, content: content, plainText: plainText) {
// 纯图片封面:整个内容作为一页
layoutFrames = [ layoutFrames = [
RDEPUBTextLayoutFrame( RDEPUBTextLayoutFrame(
contentRange: NSRange(location: 0, length: content.length), contentRange: NSRange(location: 0, length: content.length),
@@ -245,7 +326,7 @@ public final class RDEPUBTextBookBuilder {
] ]
isCacheHit = false isCacheHit = false
} else if let cached = cachedPagination?[item.href] { } else if let cached = cachedPagination?[item.href] {
// Cache hit: use cached page ranges, skip rd_paginatedFrames // 缓存命中:直接使用缓存的页范围,跳过 CoreText 分页
layoutFrames = cached.pageRanges.enumerated().map { idx, range in layoutFrames = cached.pageRanges.enumerated().map { idx, range in
let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit
return RDEPUBTextLayoutFrame( return RDEPUBTextLayoutFrame(
@@ -263,13 +344,15 @@ public final class RDEPUBTextBookBuilder {
} }
isCacheHit = true isCacheHit = true
} else { } else {
// 缓存未命中:调用 CoreText 分页引擎
layoutFrames = content.length > 0 layoutFrames = content.length > 0
? content.rd_paginatedFrames(size: pageSize, fragmentOffsets: rendered.fragmentOffsets) ? content.rd_paginatedFrames(size: pageSize, fragmentOffsets: rendered.fragmentOffsets, config: layoutConfig)
: [] : []
isCacheHit = false isCacheHit = false
} }
let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
// 尾页规范化:丢弃纯空白尾页、合并过短尾页
let normalizedFrames = normalizeTrailingFrames( let normalizedFrames = normalizeTrailingFrames(
layoutFrames, layoutFrames,
content: content, content: content,
@@ -298,6 +381,7 @@ public final class RDEPUBTextBookBuilder {
print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")") print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")")
} }
// 记录性能采样
sampler.record(RDEPUBTextPerformanceSample( sampler.record(RDEPUBTextPerformanceSample(
chapterHref: item.href, chapterHref: item.href,
renderDuration: renderDuration, renderDuration: renderDuration,
@@ -312,6 +396,7 @@ public final class RDEPUBTextBookBuilder {
lastBuildCacheStats.misses += 1 lastBuildCacheStats.misses += 1
} }
// 构建页面和章节模型
let chapterAttributedContent = content.copy() as! NSAttributedString let chapterAttributedContent = content.copy() as! NSAttributedString
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
let range = frame.contentRange let range = frame.contentRange
@@ -369,6 +454,7 @@ public final class RDEPUBTextBookBuilder {
sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart
// 保存分页缓存(只缓存页范围和分页原因,不缓存富文本)
if let cacheKey { if let cacheKey {
let paginationCache = chapters.map { chapter in let paginationCache = chapters.map { chapter in
let pageRanges = chapter.pages.map(\.contentRange) let pageRanges = chapter.pages.map(\.contentRange)
@@ -390,6 +476,9 @@ public final class RDEPUBTextBookBuilder {
return book return book
} }
// MARK: - 章节标题解析
/// 从目录表中查找章节标题,找不到则回退到 spine item 的 title 或 href
private func resolvedChapterTitle(for item: RDEPUBSpineItem, toc: [EPUBTableOfContentsItem]) -> String { private func resolvedChapterTitle(for item: RDEPUBSpineItem, toc: [EPUBTableOfContentsItem]) -> String {
if let title = flattenedTOCItems(from: toc).first(where: { tocItem in if let title = flattenedTOCItems(from: toc).first(where: { tocItem in
tocItem.href.components(separatedBy: "#").first == item.href tocItem.href.components(separatedBy: "#").first == item.href
@@ -400,12 +489,16 @@ public final class RDEPUBTextBookBuilder {
return trimmedTitle.isEmpty ? item.href : trimmedTitle return trimmedTitle.isEmpty ? item.href : trimmedTitle
} }
/// 递归展开嵌套目录为扁平列表
private func flattenedTOCItems(from items: [EPUBTableOfContentsItem]) -> [EPUBTableOfContentsItem] { private func flattenedTOCItems(from items: [EPUBTableOfContentsItem]) -> [EPUBTableOfContentsItem] {
items.flatMap { item in items.flatMap { item in
[item] + flattenedTOCItems(from: item.children) [item] + flattenedTOCItems(from: item.children)
} }
} }
// MARK: - 章节过滤
/// 判断是否应跳过该章节(空白的封面/扉页,无文本且无附件)
private func shouldSkipChapter(item: RDEPUBSpineItem, content: NSAttributedString, text: String) -> Bool { private func shouldSkipChapter(item: RDEPUBSpineItem, content: NSAttributedString, text: String) -> Bool {
let lowercasedHref = item.href.lowercased() let lowercasedHref = item.href.lowercased()
var hasAttachment = false var hasAttachment = false
@@ -422,6 +515,9 @@ public final class RDEPUBTextBookBuilder {
return false return false
} }
// MARK: - 附件统计
/// 统计富文本中的附件数量
private func attachmentCount(in content: NSAttributedString) -> Int { private func attachmentCount(in content: NSAttributedString) -> Int {
guard content.length > 0 else { return 0 } guard content.length > 0 else { return 0 }
var count = 0 var count = 0
@@ -433,6 +529,7 @@ public final class RDEPUBTextBookBuilder {
return count return count
} }
/// 获取富文本中所有附件的 NSRange 列表
private func attachmentRanges(in content: NSAttributedString) -> [NSRange] { private func attachmentRanges(in content: NSAttributedString) -> [NSRange] {
guard content.length > 0 else { return [] } guard content.length > 0 else { return [] }
var ranges: [NSRange] = [] var ranges: [NSRange] = []
@@ -444,6 +541,9 @@ public final class RDEPUBTextBookBuilder {
return ranges return ranges
} }
// MARK: - 封面章节检测
/// 判断是否为纯图片封面章节(href 包含 cover 且有附件但几乎无文本)
private func isAttachmentOnlyCoverChapter( private func isAttachmentOnlyCoverChapter(
item: RDEPUBSpineItem, item: RDEPUBSpineItem,
content: NSAttributedString, content: NSAttributedString,
@@ -455,6 +555,11 @@ public final class RDEPUBTextBookBuilder {
return attachmentCount(in: content) > 0 && trimmed.count <= 1 return attachmentCount(in: content) > 0 && trimmed.count <= 1
} }
// MARK: - 尾页规范化
/// 规范化分页结果的尾部页面:
/// 1. 丢弃纯空白的尾页
/// 2. 将过短的尾页(≤2 字符)合并到前一页
private func normalizeTrailingFrames( private func normalizeTrailingFrames(
_ frames: [RDEPUBTextLayoutFrame], _ frames: [RDEPUBTextLayoutFrame],
content: NSAttributedString, content: NSAttributedString,
@@ -464,6 +569,7 @@ public final class RDEPUBTextBookBuilder {
var normalized = frames var normalized = frames
// 丢弃纯空白的尾页
while let lastFrame = normalized.last, while let lastFrame = normalized.last,
shouldDropWhitespaceOnlyTrailingFrame(lastFrame, in: content) { shouldDropWhitespaceOnlyTrailingFrame(lastFrame, in: content) {
normalized.removeLast() normalized.removeLast()
@@ -476,6 +582,7 @@ public final class RDEPUBTextBookBuilder {
} }
} }
// 将过短的尾页合并到前一页
guard normalized.count > 1, guard normalized.count > 1,
let lastFrame = normalized.last, let lastFrame = normalized.last,
let previousFrame = normalized.dropLast().last, let previousFrame = normalized.dropLast().last,
@@ -489,6 +596,7 @@ public final class RDEPUBTextBookBuilder {
return normalized return normalized
} }
/// 判断尾页是否为纯空白(无可显示字符且无附件)
private func shouldDropWhitespaceOnlyTrailingFrame( private func shouldDropWhitespaceOnlyTrailingFrame(
_ frame: RDEPUBTextLayoutFrame, _ frame: RDEPUBTextLayoutFrame,
in content: NSAttributedString in content: NSAttributedString
@@ -500,6 +608,8 @@ public final class RDEPUBTextBookBuilder {
return visibleCharacterCount(in: content, range: frame.contentRange) == 0 return visibleCharacterCount(in: content, range: frame.contentRange) == 0
} }
/// 判断尾页是否过短需要合并到前一页。
/// 条件:尾页 ≤ 2 个可见字符,且前一页的字符数是尾页的 8 倍以上(至少 12 个字符)
private func shouldMergeShortTrailingFrame( private func shouldMergeShortTrailingFrame(
_ trailingFrame: RDEPUBTextLayoutFrame, _ trailingFrame: RDEPUBTextLayoutFrame,
previousFrame: RDEPUBTextLayoutFrame, previousFrame: RDEPUBTextLayoutFrame,
@@ -522,6 +632,7 @@ public final class RDEPUBTextBookBuilder {
return previousVisibleCount >= max(visibleCount * 8, 12) return previousVisibleCount >= max(visibleCount * 8, 12)
} }
/// 将尾页合并到前一页,保留尾页的分页原因
private func mergeTrailingFrame( private func mergeTrailingFrame(
_ previousFrame: RDEPUBTextLayoutFrame, _ previousFrame: RDEPUBTextLayoutFrame,
with trailingFrame: RDEPUBTextLayoutFrame with trailingFrame: RDEPUBTextLayoutFrame
@@ -547,6 +658,7 @@ public final class RDEPUBTextBookBuilder {
) )
} }
/// 统计指定范围内可见字符数(排除空白和控制字符)
private func visibleCharacterCount( private func visibleCharacterCount(
in content: NSAttributedString, in content: NSAttributedString,
range: NSRange range: NSRange
@@ -560,6 +672,9 @@ public final class RDEPUBTextBookBuilder {
return filteredScalars.count return filteredScalars.count
} }
// MARK: - 集合工具方法
/// 数组去重(保持顺序)
private func uniqueValues<T: Equatable>(from values: [T]) -> [T] { private func uniqueValues<T: Equatable>(from values: [T]) -> [T] {
values.reduce(into: [T]()) { result, value in values.reduce(into: [T]()) { result, value in
if !result.contains(value) { if !result.contains(value) {
@@ -568,6 +683,7 @@ public final class RDEPUBTextBookBuilder {
} }
} }
/// NSRange 数组去重(保持顺序)
private func uniqueRanges(from ranges: [NSRange]) -> [NSRange] { private func uniqueRanges(from ranges: [NSRange]) -> [NSRange] {
ranges.reduce(into: [NSRange]()) { result, value in ranges.reduce(into: [NSRange]()) { result, value in
if !result.contains(value) { if !result.contains(value) {
@@ -576,6 +692,7 @@ public final class RDEPUBTextBookBuilder {
} }
} }
/// 统计指定范围内附件数量
private func attachmentCount(in content: NSAttributedString, range: NSRange) -> Int { private func attachmentCount(in content: NSAttributedString, range: NSRange) -> Int {
guard content.length > 0, range.length > 0 else { return 0 } guard content.length > 0, range.length > 0 else { return 0 }
var count = 0 var count = 0
@@ -587,6 +704,9 @@ public final class RDEPUBTextBookBuilder {
return count return count
} }
// MARK: - 缓存键生成
/// 生成缓存键:基于书籍 ID、字号、行距、页面尺寸等参数的 SHA256 哈希
private func makeCacheKey( private func makeCacheKey(
bookID: String, bookID: String,
pageSize: CGSize, pageSize: CGSize,
@@ -598,7 +718,8 @@ public final class RDEPUBTextBookBuilder {
fontSize: style.font.pointSize, fontSize: style.font.pointSize,
lineHeightMultiple: style.lineSpacing, lineHeightMultiple: style.lineSpacing,
contentInsets: .zero, contentInsets: .zero,
pageSize: pageSize pageSize: pageSize,
layoutConfigSignature: layoutConfig.cacheSignature
) )
} }
} }
@@ -1,15 +1,18 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
// MARK: - Pagination Cache (WXRead-style: page ranges only, no attributedString) // MARK: - 分页缓存数据模型(WXRead 模式:只缓存页范围,不缓存富文本)
/// Per-chapter pagination metadata cached to disk. /// 单个章节的分页元数据缓存,用于磁盘持久化。
/// Mirrors WRChapterPageCount's caching: stores page NSRange + break reasons, ///
/// NOT the full attributed string. Builder re-renders HTML on cache hit but /// 对标 WXRead 的 WRChapterPageCount 缓存策略:只存储每页的 NSRange + 分页原因,
/// skips the rd_paginatedFrames pagination step. /// 不缓存完整的 NSAttributedString。缓存命中时重新渲染 HTML,但跳过 CoreText 分页步骤。
public struct RDEPUBTextChapterPaginationCache: Equatable { public struct RDEPUBTextChapterPaginationCache: Equatable {
/// 章节文件相对路径
public var href: String public var href: String
/// 每页在章节富文本中的字符范围
public var pageRanges: [NSRange] public var pageRanges: [NSRange]
/// 每页的分页原因(语义边界、帧限制等)
public var breakReasons: [RDEPUBTextPageBreakReason] public var breakReasons: [RDEPUBTextPageBreakReason]
public var semanticHints: [RDEPUBTextSemanticHint] public var semanticHints: [RDEPUBTextSemanticHint]
@@ -26,8 +29,10 @@ public struct RDEPUBTextChapterPaginationCache: Equatable {
} }
} }
// MARK: - NSCoding Archive (simple: strings + ints only) // MARK: - NSCoding 归档层(只使用字符串和整数类型)
/// 整本书的分页缓存归档,用于 NSKeyedArchiver 序列化。
/// 包含所有章节的分页归档数据。
final class PaginationCacheArchive: NSObject, NSSecureCoding { final class PaginationCacheArchive: NSObject, NSSecureCoding {
static var supportsSecureCoding: Bool { true } static var supportsSecureCoding: Bool { true }
@@ -47,14 +52,20 @@ final class PaginationCacheArchive: NSObject, NSSecureCoding {
} }
} }
/// 单个章节的分页归档,将 NSRange 拆分为 location/length 数组以便 NSSecureCoding 编码。
final class ChapterPaginationArchive: NSObject, NSSecureCoding { final class ChapterPaginationArchive: NSObject, NSSecureCoding {
static var supportsSecureCoding: Bool { true } static var supportsSecureCoding: Bool { true }
let href: String let href: String
/// 页范围的起始位置数组(与 rangeLengths 一一对应)
let rangeLocations: [NSNumber] let rangeLocations: [NSNumber]
/// 页范围的长度数组
let rangeLengths: [NSNumber] let rangeLengths: [NSNumber]
/// 分页原因的原始字符串数组
let breakReasons: [String] let breakReasons: [String]
/// 语义提示的原始字符串数组
let semanticHints: [String] let semanticHints: [String]
/// 从缓存模型构建归档对象
init(from cache: RDEPUBTextChapterPaginationCache) { init(from cache: RDEPUBTextChapterPaginationCache) {
self.href = cache.href self.href = cache.href
self.rangeLocations = cache.pageRanges.map { NSNumber(value: $0.location) } self.rangeLocations = cache.pageRanges.map { NSNumber(value: $0.location) }
@@ -86,6 +97,7 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding {
self.semanticHints = semanticHints self.semanticHints = semanticHints
} }
/// 将归档数据转换回缓存模型
func toCache() -> RDEPUBTextChapterPaginationCache { func toCache() -> RDEPUBTextChapterPaginationCache {
let pageRanges = zip(rangeLocations, rangeLengths).map { loc, len in let pageRanges = zip(rangeLocations, rangeLengths).map { loc, len in
NSRange(location: loc.intValue, length: len.intValue) NSRange(location: loc.intValue, length: len.intValue)
@@ -99,19 +111,27 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding {
} }
} }
// MARK: - RDEPUBTextBookCache // MARK: - 分页缓存管理器
/// Disk-persistent cache for per-chapter pagination metadata. /// 磁盘持久化的分页缓存层,对标 WXRead 的 WRChapterPageCount 缓存模式。
/// Follows WXRead's WRChapterPageCount caching pattern: ///
/// cache key = bookID + layout settings, cache value = page NSRange per chapter. /// 缓存策略:
/// Attributed strings are NOT cached — builder re-renders HTML on cache hit. /// - 缓存键 = SHA256(书籍ID + 字号 + 行距 + 内边距 + 页面尺寸 + schema版本)
/// - 缓存值 = 每章的页 NSRange 列表 + 分页原因(NSKeyedArchiver 序列化)
/// - 富文本不缓存:缓存命中时重新渲染 HTML,但跳过 CoreText 分页步骤
/// - 线程安全:所有读写操作通过 serial DispatchQueue 串行执行
public final class RDEPUBTextBookCache { public final class RDEPUBTextBookCache {
/// 缓存模式版本号,变更时旧缓存自动失效
public var schemaVersion: Int = 1 public var schemaVersion: Int = 1
/// 串行队列,保证缓存读写的线程安全
private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility) private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility)
/// 缓存文件目录
private let cacheDirectory: URL private let cacheDirectory: URL
/// 初始化缓存管理器,自动创建缓存目录
/// - Parameter subdirectory: Caches 目录下的子目录名
public init(subdirectory: String = "RDEPUBTextBookCache") { public init(subdirectory: String = "RDEPUBTextBookCache") {
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first? let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
.appendingPathComponent(subdirectory, isDirectory: true) .appendingPathComponent(subdirectory, isDirectory: true)
@@ -120,27 +140,31 @@ public final class RDEPUBTextBookCache {
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
} }
// MARK: - Cache Key // MARK: - 缓存键生成
// Mirrors WRChapterPageCount.currentCacheKeyWithBookId: // 对标 WRChapterPageCount.currentCacheKeyWithBookId:
// Encodes bookID + fontSize + lineHeightMultiple + contentInsets + pageSize // 编码 bookID + fontSize + lineHeightMultiple + contentInsets + pageSize
/// 生成缓存文件名(SHA256 哈希 + ".cache" 后缀)。
///
/// 任意布局参数变更都会导致缓存键不同,从而自动失效。
public func cacheKey( public func cacheKey(
bookID: String, bookID: String,
fontSize: CGFloat, fontSize: CGFloat,
lineHeightMultiple: CGFloat, lineHeightMultiple: CGFloat,
contentInsets: UIEdgeInsets, contentInsets: UIEdgeInsets,
pageSize: CGSize pageSize: CGSize,
layoutConfigSignature: String = RDEPUBTextLayoutConfig.default.cacheSignature
) -> String { ) -> String {
let raw = "\(bookID)_\(fontSize)_\(lineHeightMultiple)_\(contentInsets.top)_\(contentInsets.left)_\(contentInsets.bottom)_\(contentInsets.right)_\(pageSize.width)_\(pageSize.height)_v\(schemaVersion)" let raw = "\(bookID)_\(fontSize)_\(lineHeightMultiple)_\(contentInsets.top)_\(contentInsets.left)_\(contentInsets.bottom)_\(contentInsets.right)_\(pageSize.width)_\(pageSize.height)_\(layoutConfigSignature)_v\(schemaVersion)"
let digest = SHA256.hash(data: Data(raw.utf8)) let digest = SHA256.hash(data: Data(raw.utf8))
let hex = digest.map { String(format: "%02x", $0) }.joined() let hex = digest.map { String(format: "%02x", $0) }.joined()
return hex + ".cache" return hex + ".cache"
} }
// MARK: - Load / Save (pagination metadata only) // MARK: - 加载/保存(只缓存分页元数据)
/// Load cached pagination metadata for all chapters. /// 从磁盘加载缓存的分页元数据,返回以章节 href 为键的字典。
/// Returns dictionary keyed by chapter href. /// 缓存未命中或反序列化失败时返回 nil。
public func load(key: String) -> [String: RDEPUBTextChapterPaginationCache]? { public func load(key: String) -> [String: RDEPUBTextChapterPaginationCache]? {
queue.sync { queue.sync {
let fileURL = cacheDirectory.appendingPathComponent(key) let fileURL = cacheDirectory.appendingPathComponent(key)
@@ -170,7 +194,7 @@ public final class RDEPUBTextBookCache {
} }
} }
/// Save pagination metadata for all chapters. /// 将分页元数据保存到磁盘(原子写入,防止损坏)
public func save(_ chapters: [RDEPUBTextChapterPaginationCache], key: String) { public func save(_ chapters: [RDEPUBTextChapterPaginationCache], key: String) {
queue.sync { queue.sync {
let fileURL = cacheDirectory.appendingPathComponent(key) let fileURL = cacheDirectory.appendingPathComponent(key)
@@ -186,8 +210,9 @@ public final class RDEPUBTextBookCache {
} }
} }
// MARK: - Invalidate // MARK: - 缓存失效
/// 清除所有缓存文件
public func invalidateAll() { public func invalidateAll() {
queue.sync { queue.sync {
let fileManager = FileManager.default let fileManager = FileManager.default
@@ -1,8 +1,18 @@
// RDEPUBTextIndexTable.swift
// EPUB 文本索引表
// 构建全书的文本索引映射:章节起始偏移、href 与章节/spine 索引的对应关系、
// fragment 偏移映射、以及行列索引映射。支持锚点与位置之间的双向转换,
// 用于精确定位文本选择和搜索匹配。
import Foundation import Foundation
/// 行-列索引条目,表示文本中某一行的字符范围
public struct RDEPUBRowColumnIndex: Codable, Equatable { public struct RDEPUBRowColumnIndex: Codable, Equatable {
/// 行号
public let row: Int public let row: Int
/// 该行的起始字符偏移
public let startOffset: Int public let startOffset: Int
/// 该行的结束字符偏移
public let endOffset: Int public let endOffset: Int
public init(row: Int, startOffset: Int, endOffset: Int) { public init(row: Int, startOffset: Int, endOffset: Int) {
@@ -11,20 +21,30 @@ public struct RDEPUBRowColumnIndex: Codable, Equatable {
self.endOffset = endOffset self.endOffset = endOffset
} }
/// 判断给定偏移量是否在该行范围内
public func contains(_ offset: Int) -> Bool { public func contains(_ offset: Int) -> Bool {
offset >= startOffset && offset <= endOffset offset >= startOffset && offset <= endOffset
} }
} }
/// 文本索引表,存储全书的文本结构映射
public struct RDEPUBTextIndexTable { public struct RDEPUBTextIndexTable {
/// 各章节的起始绝对偏移量(累加长度)
public let chapterStartOffsets: [Int] public let chapterStartOffsets: [Int]
/// 各章节的文本长度
public let chapterLengths: [Int] public let chapterLengths: [Int]
/// href → 章节索引的映射
public let hrefToChapterIndex: [String: Int] public let hrefToChapterIndex: [String: Int]
/// href → spine 文件索引的映射
public let hrefToFileIndex: [String: Int] public let hrefToFileIndex: [String: Int]
/// href → (fragment ID → 章节内偏移) 的映射
public let fragmentOffsetsByHref: [String: [String: Int]] public let fragmentOffsetsByHref: [String: [String: Int]]
/// spine 文件索引 → href 的反向映射
public let fileIndexToHref: [Int: String] public let fileIndexToHref: [Int: String]
/// spine 文件索引 → 行列索引数组的映射
public let fileRowColumnMap: [Int: [RDEPUBRowColumnIndex]] public let fileRowColumnMap: [Int: [RDEPUBRowColumnIndex]]
/// 从文本章节列表构建索引表
public init(chapters: [RDEPUBTextChapter]) { public init(chapters: [RDEPUBTextChapter]) {
var offsets: [Int] = [] var offsets: [Int] = []
var lengths: [Int] = [] var lengths: [Int] = []
@@ -55,6 +75,7 @@ public struct RDEPUBTextIndexTable {
self.fileRowColumnMap = rowColumnMap self.fileRowColumnMap = rowColumnMap
} }
/// 根据绝对索引和章节构建文本锚点
public func anchor(forAbsoluteIndex index: Int, in chapter: RDEPUBTextChapter) -> RDEPUBTextAnchor { public func anchor(forAbsoluteIndex index: Int, in chapter: RDEPUBTextChapter) -> RDEPUBTextAnchor {
let normalizedIndex = clampedOffset(index, in: chapter) let normalizedIndex = clampedOffset(index, in: chapter)
let fragmentID = nearestFragmentID(beforeOrAt: normalizedIndex, in: chapter) let fragmentID = nearestFragmentID(beforeOrAt: normalizedIndex, in: chapter)
@@ -69,6 +90,7 @@ public struct RDEPUBTextIndexTable {
) )
} }
/// 根据阅读位置构建文本锚点(优先使用 rangeAnchor,其次根据 fragment 或 progression 计算)
public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? { public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? {
if let anchor = location.rangeAnchor?.start { if let anchor = location.rangeAnchor?.start {
return anchor return anchor
@@ -96,6 +118,7 @@ public struct RDEPUBTextIndexTable {
) )
} }
/// 根据锚点查找对应的绝对页码
public func pageNumber(for anchor: RDEPUBTextAnchor, in book: RDEPUBTextBook) -> Int? { public func pageNumber(for anchor: RDEPUBTextAnchor, in book: RDEPUBTextBook) -> Int? {
guard let chapter = book.chapters.first(where: { $0.spineIndex == anchor.fileIndex }) else { return nil } guard let chapter = book.chapters.first(where: { $0.spineIndex == anchor.fileIndex }) else { return nil }
let basePageIndex = chapter.pages.first?.absolutePageIndex ?? 0 let basePageIndex = chapter.pages.first?.absolutePageIndex ?? 0
@@ -105,14 +128,17 @@ public struct RDEPUBTextIndexTable {
}.map { $0 + basePageIndex } }.map { $0 + basePageIndex }
} }
/// 通过文件索引获取 href
public func href(for fileIndex: Int) -> String? { public func href(for fileIndex: Int) -> String? {
fileIndexToHref[fileIndex] fileIndexToHref[fileIndex]
} }
/// 通过 href 获取章节索引
public func chapterIndex(for href: String) -> Int? { public func chapterIndex(for href: String) -> Int? {
hrefToChapterIndex[href] hrefToChapterIndex[href]
} }
/// 将锚点转换为全书绝对字符索引
public func absoluteIndex(for anchor: RDEPUBTextAnchor) -> Int { public func absoluteIndex(for anchor: RDEPUBTextAnchor) -> Int {
absoluteIndex( absoluteIndex(
fileIndex: anchor.fileIndex, fileIndex: anchor.fileIndex,
@@ -121,12 +147,14 @@ public struct RDEPUBTextIndexTable {
) ?? anchor.chapterOffset ) ?? anchor.chapterOffset
} }
/// 将范围锚点转换为全书绝对 NSRange
public func absoluteRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange { public func absoluteRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
let start = absoluteIndex(for: rangeAnchor.start) let start = absoluteIndex(for: rangeAnchor.start)
let end = max(start, absoluteIndex(for: rangeAnchor.end)) let end = max(start, absoluteIndex(for: rangeAnchor.end))
return NSRange(location: start, length: max(end - start, 0)) return NSRange(location: start, length: max(end - start, 0))
} }
/// 将单个锚点转换为阅读位置(计算 progression)
public func location( public func location(
for anchor: RDEPUBTextAnchor, for anchor: RDEPUBTextAnchor,
in chapter: RDEPUBTextChapter, in chapter: RDEPUBTextChapter,
@@ -145,6 +173,7 @@ public struct RDEPUBTextIndexTable {
) )
} }
/// 将范围锚点转换为阅读位置(起止 progression)
public func location( public func location(
for rangeAnchor: RDEPUBTextRangeAnchor, for rangeAnchor: RDEPUBTextRangeAnchor,
in chapter: RDEPUBTextChapter, in chapter: RDEPUBTextChapter,
@@ -165,6 +194,7 @@ public struct RDEPUBTextIndexTable {
) )
} }
/// 根据绝对索引和文件索引查找所在行号
public func row(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int { public func row(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int {
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 } guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 }
if let rowIndex = rows.first(where: { $0.contains(index) })?.row { if let rowIndex = rows.first(where: { $0.contains(index) })?.row {
@@ -173,6 +203,7 @@ public struct RDEPUBTextIndexTable {
return rows.last?.row ?? 0 return rows.last?.row ?? 0
} }
/// 根据绝对索引和文件索引查找所在列号(行内偏移)
public func column(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int { public func column(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int {
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 } guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 }
if let rowEntry = rows.first(where: { $0.contains(index) }) { if let rowEntry = rows.first(where: { $0.contains(index) }) {
@@ -182,6 +213,7 @@ public struct RDEPUBTextIndexTable {
return max(index - lastRow.startOffset, 0) return max(index - lastRow.startOffset, 0)
} }
/// 根据文件索引、行号和列号计算全书绝对索引
public func absoluteIndex(fileIndex: Int, row: Int, column: Int) -> Int? { public func absoluteIndex(fileIndex: Int, row: Int, column: Int) -> Int? {
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return nil } guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return nil }
let normalizedRow = min(max(row, 0), rows.count - 1) let normalizedRow = min(max(row, 0), rows.count - 1)
@@ -190,11 +222,13 @@ public struct RDEPUBTextIndexTable {
return rowEntry.startOffset + min(max(column, 0), maxColumn) return rowEntry.startOffset + min(max(column, 0), maxColumn)
} }
/// 将偏移量限制在章节范围内
private func clampedOffset(_ index: Int, in chapter: RDEPUBTextChapter) -> Int { private func clampedOffset(_ index: Int, in chapter: RDEPUBTextChapter) -> Int {
let lastOffset = max(chapter.attributedContent.length - 1, 0) let lastOffset = max(chapter.attributedContent.length - 1, 0)
return min(max(index, 0), lastOffset) return min(max(index, 0), lastOffset)
} }
/// 查找距离给定偏移量最近(且在其之前)的 fragment ID
private func nearestFragmentID(beforeOrAt offset: Int, in chapter: RDEPUBTextChapter) -> String? { private func nearestFragmentID(beforeOrAt offset: Int, in chapter: RDEPUBTextChapter) -> String? {
var bestID: String? var bestID: String?
var bestOffset = -1 var bestOffset = -1
@@ -209,6 +243,7 @@ public struct RDEPUBTextIndexTable {
return bestID return bestID
} }
/// 从文本字符串构建行列索引数组(按行分割,记录每行的起止偏移)
private static func makeRowColumnIndices(for text: String) -> [RDEPUBRowColumnIndex] { private static func makeRowColumnIndices(for text: String) -> [RDEPUBRowColumnIndex] {
let nsText = text as NSString let nsText = text as NSString
let length = nsText.length let length = nsText.length
@@ -1,17 +1,33 @@
import Foundation import Foundation
/// CoreText 分页引擎产生的单帧(单页)布局数据。
///
/// 由 `RDEPUBTextLayouter` 在分页过程中生成,记录了该页的内容范围、分页原因、
/// 附件信息和语义标记。`RDEPUBTextBookBuilder` 会将 `RDEPUBTextLayoutFrame` 转换为
/// `RDEPUBTextPage`,并纳入最终的 `RDEPUBTextBook` 模型。
struct RDEPUBTextLayoutFrame: Equatable { struct RDEPUBTextLayoutFrame: Equatable {
/// 该帧在章节富文本中的字符范围
var contentRange: NSRange var contentRange: NSRange
/// 分页原因(语义边界、附件边界、帧限制等)
var breakReason: RDEPUBTextPageBreakReason var breakReason: RDEPUBTextPageBreakReason
/// 所属 HTML 块级元素的范围(用于调试)
var blockRange: NSRange? var blockRange: NSRange?
/// 帧内附件的字符范围列表
var attachmentRanges: [NSRange] var attachmentRanges: [NSRange]
/// 帧内附件类型列表(图片、通用附件等)
var attachmentKinds: [RDEPUBTextAttachmentKind] var attachmentKinds: [RDEPUBTextAttachmentKind]
/// 帧内包含的 HTML 块级元素类型列表(段落、列表、表格等)
var blockKinds: [RDEPUBTextBlockKind] var blockKinds: [RDEPUBTextBlockKind]
/// 帧内语义提示列表(避免分页、保持与下一段同页等)
var semanticHints: [RDEPUBTextSemanticHint] var semanticHints: [RDEPUBTextSemanticHint]
/// 帧内附件的布局方式(行内、基线、居中)
var attachmentPlacements: [RDEPUBTextAttachmentPlacement] var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
/// 帧尾部最近的 fragment ID(用于恢复阅读位置)
var trailingFragmentID: String? var trailingFragmentID: String?
/// 诊断日志列表(分页原因详情、范围信息等)
var diagnostics: [String] var diagnostics: [String]
/// 将布局帧数据转换为页元数据模型
var metadata: RDEPUBTextPageMetadata { var metadata: RDEPUBTextPageMetadata {
RDEPUBTextPageMetadata( RDEPUBTextPageMetadata(
breakReason: breakReason, breakReason: breakReason,
@@ -5,11 +5,31 @@ import UIKit
import DTCoreText import DTCoreText
#endif #endif
/// CoreText 分页引擎:将富文本按页面尺寸拆分为多帧(每帧对应一页)。
///
/// 分页策略优先级(从高到低):
/// 1. avoidPageBreakInside — 不在保护块内分页(对标 WXRead 的行级回退扫描)
/// 2. keepWithNext — 标题等元素需与下一段同页
/// 3. 语义边界 — pageBreakBefore/After 等显式分页标记
/// 4. pageRelate — 微信读书式的跨页关联元素
/// 5. 附件边界 — 块级附件应整体移到下一页
/// 6. 帧限制 — 默认按 CoreText 可视范围分页
///
/// 支持两条渲染路径:
/// - DTCoreText 路径(首选):DTCoreTextLayouter → DTCoreTextLayoutFrame
/// - CoreText 回退路径:CTFramesetterCreateFrame
struct RDEPUBTextLayouter { struct RDEPUBTextLayouter {
/// 待分页的富文本
private let attributedString: NSAttributedString private let attributedString: NSAttributedString
/// 页面尺寸(决定每帧能容纳多少内容)
private let pageSize: CGSize private let pageSize: CGSize
/// CoreText 帧设置器
private let framesetter: CTFramesetter private let framesetter: CTFramesetter
/// 页面矩形路径(用于 CTFrame 排版)
private let path: CGPath private let path: CGPath
/// DTCoreText 路径可直接消费的单矩形布局区域
private let dtLayoutRect: CGRect
/// 布局配置(avoidPageBreakInside、孤行控制等)
private let config: RDEPUBTextLayoutConfig private let config: RDEPUBTextLayoutConfig
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) { init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
@@ -17,9 +37,12 @@ struct RDEPUBTextLayouter {
self.pageSize = pageSize self.pageSize = pageSize
self.config = config self.config = config
self.framesetter = CTFramesetterCreateWithAttributedString(attributedString) self.framesetter = CTFramesetterCreateWithAttributedString(attributedString)
self.path = CGPath(rect: CGRect(origin: .zero, size: pageSize), transform: nil) self.dtLayoutRect = config.contentRect(fallback: pageSize)
self.path = Self.makeLayoutPath(pageSize: pageSize, config: config)
} }
/// 执行分页,返回布局帧列表(每帧对应一页)。
/// 根据编译环境选择 DTCoreText 或 CoreText 路径。
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] { func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else { guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
return [] return []
@@ -32,6 +55,11 @@ struct RDEPUBTextLayouter {
#endif #endif
} }
// MARK: - CoreText 分页路径(回退方案)
/// 使用原生 CoreText API 进行分页。
///
/// 流程:CTFramesetterCreateFrame → 获取可视范围 → 语义边界调整 → 记录帧
private func layoutFramesUsingCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] { private func layoutFramesUsingCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else { guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
return [] return []
@@ -49,7 +77,7 @@ struct RDEPUBTextLayouter {
let proposedRange = NSRange(location: location, length: visibleRange.length) let proposedRange = NSRange(location: location, length: visibleRange.length)
// Line-level avoidPageBreakInside (WXRead approach: scan CTFrame lines backward) // 行级 avoidPageBreakInside 处理(WXRead 方案:从最后一行向前扫描)
let avoidAdjusted = trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange) let avoidAdjusted = trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
let lineAdjusted = trimmedRangeForKeepWithNext(from: frame, proposed: avoidAdjusted) let lineAdjusted = trimmedRangeForKeepWithNext(from: frame, proposed: avoidAdjusted)
let lineRanges = lineRanges(from: frame) let lineRanges = lineRanges(from: frame)
@@ -89,8 +117,15 @@ struct RDEPUBTextLayouter {
return frames return frames
} }
// MARK: - DTCoreText 分页路径(首选方案)
#if canImport(DTCoreText) #if canImport(DTCoreText)
/// 使用 DTCoreTextLayouter 进行分页,提供更精确的行级语义处理。
private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] { private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
guard config.numberOfColumns == 1 else {
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
}
guard let layouter = DTCoreTextLayouter(attributedString: attributedString) else { guard let layouter = DTCoreTextLayouter(attributedString: attributedString) else {
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets) return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
} }
@@ -99,7 +134,7 @@ struct RDEPUBTextLayouter {
var frames: [RDEPUBTextLayoutFrame] = [] var frames: [RDEPUBTextLayoutFrame] = []
var location = 0 var location = 0
let pageRect = CGRect(origin: .zero, size: pageSize) let pageRect = dtLayoutRect
while location < attributedString.length { while location < attributedString.length {
guard let layoutFrame = layouter.layoutFrame(with: pageRect, range: NSRange(location: location, length: 0)) else { guard let layoutFrame = layouter.layoutFrame(with: pageRect, range: NSRange(location: location, length: 0)) else {
@@ -152,6 +187,31 @@ struct RDEPUBTextLayouter {
} }
#endif #endif
private static func makeLayoutPath(pageSize: CGSize, config: RDEPUBTextLayoutConfig) -> CGPath {
let columnRects = config.columnRects(fallback: pageSize)
guard columnRects.count > 1 else {
return CGPath(rect: columnRects.first ?? CGRect(origin: .zero, size: pageSize), transform: nil)
}
let path = CGMutablePath()
for rect in columnRects {
path.addRect(rect)
}
return path
}
// MARK: - 语义边界调整
/// 对 CoreText 提出的分页范围进行语义边界调整。
///
/// 调整优先级:
/// 1. 若已达章节末尾,直接返回 chapterEnd
/// 2. 优先在语义边界(pageBreakBefore/After)分页
/// 3. 其次在 pageRelate 跨页关联点分页
/// 4. 再次在附件边界分页(块级附件需整体移动)
/// 5. 以上都不满足时,使用原始帧限制分页
///
/// 最小分页长度约束:不低于原始范围的 55%,避免单页内容过少。
private func adjustedRange( private func adjustedRange(
from proposedRange: NSRange, from proposedRange: NSRange,
totalLength: Int, totalLength: Int,
@@ -193,6 +253,7 @@ struct RDEPUBTextLayouter {
) )
} }
// 最小分页长度:原始范围的 55%
let minLength = max(Int(Double(proposedRange.length) * 0.55), 1) let minLength = max(Int(Double(proposedRange.length) * 0.55), 1)
let minimumEnd = proposedRange.location + minLength let minimumEnd = proposedRange.location + minLength
@@ -203,6 +264,7 @@ struct RDEPUBTextLayouter {
let currentSemanticHints = proposedSemanticHints let currentSemanticHints = proposedSemanticHints
let currentAttachmentPlacements = proposedAttachmentPlacements let currentAttachmentPlacements = proposedAttachmentPlacements
// 1. 语义边界(pageBreakBefore/After)
if let semanticBoundary = preferredSemanticBoundary( if let semanticBoundary = preferredSemanticBoundary(
in: proposedRange, in: proposedRange,
minimumEnd: minimumEnd minimumEnd: minimumEnd
@@ -230,6 +292,7 @@ struct RDEPUBTextLayouter {
) )
} }
// 2. pageRelate 跨页关联边界
if let pageRelateBoundary = preferredPageRelateBoundary( if let pageRelateBoundary = preferredPageRelateBoundary(
after: proposedRange, after: proposedRange,
minimumEnd: minimumEnd, minimumEnd: minimumEnd,
@@ -258,6 +321,7 @@ struct RDEPUBTextLayouter {
) )
} }
// 3. 附件边界(块级附件整体移动)
if let attachmentBoundary = preferredAttachmentBoundary( if let attachmentBoundary = preferredAttachmentBoundary(
in: proposedRange, in: proposedRange,
minimumEnd: minimumEnd minimumEnd: minimumEnd
@@ -284,6 +348,7 @@ struct RDEPUBTextLayouter {
) )
} }
// 4. 帧限制(默认分页)
return ( return (
range: proposedRange, range: proposedRange,
breakReason: .frameLimit, breakReason: .frameLimit,
@@ -305,6 +370,11 @@ struct RDEPUBTextLayouter {
) )
} }
// MARK: - 语义边界查找
/// 在指定范围内查找最优的语义分页点(pageBreakBefore / pageBreakAfter)。
///
/// 只有当边界位置超过 minimumEnd(最小分页长度约束)时才有效。
private func preferredSemanticBoundary( private func preferredSemanticBoundary(
in range: NSRange, in range: NSRange,
minimumEnd: Int minimumEnd: Int
@@ -337,6 +407,8 @@ struct RDEPUBTextLayouter {
return boundary return boundary
} }
/// 查找附件边界:只有块级附件(.attachment 或 .centered)才触发分页,
/// 行内脚注图标等不应导致整段移动。
private func preferredAttachmentBoundary(in range: NSRange, minimumEnd: Int) -> Int? { private func preferredAttachmentBoundary(in range: NSRange, minimumEnd: Int) -> Int? {
var boundary: Int? var boundary: Int?
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, stop in attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, stop in
@@ -346,9 +418,8 @@ struct RDEPUBTextLayouter {
let placement = attachmentPlacement(at: location) let placement = attachmentPlacement(at: location)
let blockKind = blockKind(at: location) let blockKind = blockKind(at: location)
// Mirror WXRead more closely: only block-level attachments should // 对标 WXRead:只有块级附件才应将整个块推到下一页。
// push the entire block to the next page. Inline footnote icons and // 行内脚注图标等行内附件不应导致整段移动。
// other inline attachments must not cause a whole paragraph to move.
let isBlockLevelAttachment = blockKind == .attachment || placement == .centered let isBlockLevelAttachment = blockKind == .attachment || placement == .centered
guard isBlockLevelAttachment else { return } guard isBlockLevelAttachment else { return }
@@ -361,6 +432,8 @@ struct RDEPUBTextLayouter {
return boundary return boundary
} }
/// 查找 pageRelate 跨页关联边界:当下一页起始是一个 pageRelate 块且
/// 该块恰好是当前页最后一行时,将最后一行移到下一页。
private func preferredPageRelateBoundary( private func preferredPageRelateBoundary(
after range: NSRange, after range: NSRange,
minimumEnd: Int, minimumEnd: Int,
@@ -384,6 +457,9 @@ struct RDEPUBTextLayouter {
return lastLineStart return lastLineStart
} }
// MARK: - 属性查询工具
/// 获取指定位置的块级元素范围
private func blockRange(at location: Int) -> NSRange? { private func blockRange(at location: Int) -> NSRange? {
guard location >= 0, location < attributedString.length else { return nil } guard location >= 0, location < attributedString.length else { return nil }
let attributes = attributedString.attributes(at: location, effectiveRange: nil) let attributes = attributedString.attributes(at: location, effectiveRange: nil)
@@ -393,6 +469,7 @@ struct RDEPUBTextLayouter {
return nil return nil
} }
/// 获取指定位置的块级元素类型
private func blockKind(at location: Int) -> RDEPUBTextBlockKind? { private func blockKind(at location: Int) -> RDEPUBTextBlockKind? {
guard location >= 0, location < attributedString.length else { return nil } guard location >= 0, location < attributedString.length else { return nil }
let attributes = attributedString.attributes(at: location, effectiveRange: nil) let attributes = attributedString.attributes(at: location, effectiveRange: nil)
@@ -400,6 +477,7 @@ struct RDEPUBTextLayouter {
return RDEPUBTextBlockKind(rawValue: rawValue) return RDEPUBTextBlockKind(rawValue: rawValue)
} }
/// 获取指定位置的附件布局方式
private func attachmentPlacement(at location: Int) -> RDEPUBTextAttachmentPlacement? { private func attachmentPlacement(at location: Int) -> RDEPUBTextAttachmentPlacement? {
guard location >= 0, location < attributedString.length else { return nil } guard location >= 0, location < attributedString.length else { return nil }
let attributes = attributedString.attributes(at: location, effectiveRange: nil) let attributes = attributedString.attributes(at: location, effectiveRange: nil)
@@ -407,6 +485,7 @@ struct RDEPUBTextLayouter {
return RDEPUBTextAttachmentPlacement(rawValue: rawValue) return RDEPUBTextAttachmentPlacement(rawValue: rawValue)
} }
/// 获取包含指定位置的段落范围
private func paragraphRange(containing location: Int) -> NSRange { private func paragraphRange(containing location: Int) -> NSRange {
let source = attributedString.string as NSString let source = attributedString.string as NSString
guard source.length > 0 else { return NSRange(location: 0, length: 0) } guard source.length > 0 else { return NSRange(location: 0, length: 0) }
@@ -414,6 +493,7 @@ struct RDEPUBTextLayouter {
return source.paragraphRange(for: NSRange(location: safeLocation, length: 0)) return source.paragraphRange(for: NSRange(location: safeLocation, length: 0))
} }
/// 获取指定范围内的所有附件字符范围
private func attachmentRanges(in range: NSRange) -> [NSRange] { private func attachmentRanges(in range: NSRange) -> [NSRange] {
var results: [NSRange] = [] var results: [NSRange] = []
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, _ in attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, _ in
@@ -423,6 +503,7 @@ struct RDEPUBTextLayouter {
return results return results
} }
/// 获取指定位置的语义提示列表
private func semanticHints(at location: Int) -> [RDEPUBTextSemanticHint] { private func semanticHints(at location: Int) -> [RDEPUBTextSemanticHint] {
guard location >= 0, location < attributedString.length else { return [] } guard location >= 0, location < attributedString.length else { return [] }
let attributes = attributedString.attributes(at: location, effectiveRange: nil) let attributes = attributedString.attributes(at: location, effectiveRange: nil)
@@ -432,6 +513,7 @@ struct RDEPUBTextLayouter {
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) } .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
} }
/// 获取指定范围内的附件类型列表(去重)
private func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] { private func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
var kinds: [RDEPUBTextAttachmentKind] = [] var kinds: [RDEPUBTextAttachmentKind] = []
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, _, _ in attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, _, _ in
@@ -445,6 +527,7 @@ struct RDEPUBTextLayouter {
return kinds return kinds
} }
/// 获取指定范围内的块级元素类型列表(去重)
private func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] { private func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] {
var kinds: [RDEPUBTextBlockKind] = [] var kinds: [RDEPUBTextBlockKind] = []
attributedString.enumerateAttribute(.rdPageBlockKind, in: range) { value, _, _ in attributedString.enumerateAttribute(.rdPageBlockKind, in: range) { value, _, _ in
@@ -458,6 +541,7 @@ struct RDEPUBTextLayouter {
return kinds return kinds
} }
/// 获取指定范围内的语义提示列表(去重)
private func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] { private func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] {
var hints: [RDEPUBTextSemanticHint] = [] var hints: [RDEPUBTextSemanticHint] = []
attributedString.enumerateAttribute(.rdPageSemanticHints, in: range) { value, _, _ in attributedString.enumerateAttribute(.rdPageSemanticHints, in: range) { value, _, _ in
@@ -469,6 +553,7 @@ struct RDEPUBTextLayouter {
return hints return hints
} }
/// 获取指定范围内的附件布局方式列表(去重)
private func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] { private func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] {
var placements: [RDEPUBTextAttachmentPlacement] = [] var placements: [RDEPUBTextAttachmentPlacement] = []
attributedString.enumerateAttribute(.rdPageAttachmentPlacement, in: range) { value, _, _ in attributedString.enumerateAttribute(.rdPageAttachmentPlacement, in: range) { value, _, _ in
@@ -482,6 +567,7 @@ struct RDEPUBTextLayouter {
return placements return placements
} }
/// 查找指定位置之前最近的 fragment ID(用于阅读位置恢复)
private func nearestTrailingFragmentID( private func nearestTrailingFragmentID(
endingAt location: Int, endingAt location: Int,
fragmentOffsets: [String: Int] fragmentOffsets: [String: Int]
@@ -492,11 +578,11 @@ struct RDEPUBTextLayouter {
.key .key
} }
// MARK: - Line-level avoidPageBreakInside (WXRead approach) // MARK: - 行级 avoidPageBreakInside(WXRead 方案)
/// Scans CTFrame lines backward from the last line, removing trailing lines /// 从 CTFrame 最后一行向前扫描,移除落在 avoidPageBreakInside 保护块内的尾部行。
/// that fall inside an avoidPageBreakInside block. Mirrors WXRead's /// 对标 WXRead 的 WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded。
/// WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded. /// 最多移除 3 行(kMaxLinesToRemove),避免因保护块过大导致整页内容被清空。
private func trimmedRangeForAvoidPageBreakInside( private func trimmedRangeForAvoidPageBreakInside(
from frame: CTFrame, from frame: CTFrame,
proposed: NSRange proposed: NSRange
@@ -509,8 +595,8 @@ struct RDEPUBTextLayouter {
var origins = [CGPoint](repeating: .zero, count: lines.count) var origins = [CGPoint](repeating: .zero, count: lines.count)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins) CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
// Scan backward: count consecutive trailing lines inside protected blocks // 从最后一行向前扫描,统计落在保护块内的连续尾部行数
// kMaxLinesToRemove = 3 (same as WXRead) // kMaxLinesToRemove = 3(与 WXRead 一致)
let kMaxLinesToRemove = 3 let kMaxLinesToRemove = 3
var linesToRemove = 0 var linesToRemove = 0
@@ -521,8 +607,7 @@ struct RDEPUBTextLayouter {
if lineIsInAvoidPageBreakInsideBlock(lineNSRange) { if lineIsInAvoidPageBreakInsideBlock(lineNSRange) {
linesToRemove += 1 linesToRemove += 1
if linesToRemove >= kMaxLinesToRemove { if linesToRemove >= kMaxLinesToRemove {
// Don't remove more than kMaxLinesToRemove; stop here // 不超过 kMaxLinesToRemove 行,停在这里
// (line at index i stays, so linesToRemove stays at kMaxLinesToRemove)
linesToRemove = kMaxLinesToRemove linesToRemove = kMaxLinesToRemove
break break
} }
@@ -535,7 +620,7 @@ struct RDEPUBTextLayouter {
let validLineCount = lines.count - linesToRemove let validLineCount = lines.count - linesToRemove
guard validLineCount > 0 else { guard validLineCount > 0 else {
// All lines are in protected blocks; fall back to proposed // 所有行都在保护块内,回退到原始范围
return proposed return proposed
} }
@@ -549,6 +634,7 @@ struct RDEPUBTextLayouter {
return NSRange(location: proposed.location, length: adjustedLength) return NSRange(location: proposed.location, length: adjustedLength)
} }
/// CoreText 路径的 keepWithNext 处理:从最后行向前扫描
private func trimmedRangeForKeepWithNext( private func trimmedRangeForKeepWithNext(
from frame: CTFrame, from frame: CTFrame,
proposed: NSRange proposed: NSRange
@@ -561,7 +647,10 @@ struct RDEPUBTextLayouter {
return trimmedRangeForKeepWithNext(proposed: proposed, lineRanges: lineRanges) return trimmedRangeForKeepWithNext(proposed: proposed, lineRanges: lineRanges)
} }
// MARK: - DTCoreText 行级处理
#if canImport(DTCoreText) #if canImport(DTCoreText)
/// DTCoreText 路径的 avoidPageBreakInside 处理
private func trimmedRangeForAvoidPageBreakInside( private func trimmedRangeForAvoidPageBreakInside(
from layoutFrame: DTCoreTextLayoutFrame, from layoutFrame: DTCoreTextLayoutFrame,
proposed: NSRange proposed: NSRange
@@ -603,6 +692,7 @@ struct RDEPUBTextLayouter {
return NSRange(location: proposed.location, length: adjustedLength) return NSRange(location: proposed.location, length: adjustedLength)
} }
/// DTCoreText 路径的 keepWithNext 处理
private func trimmedRangeForKeepWithNext( private func trimmedRangeForKeepWithNext(
from layoutFrame: DTCoreTextLayoutFrame, from layoutFrame: DTCoreTextLayoutFrame,
proposed: NSRange proposed: NSRange
@@ -615,6 +705,8 @@ struct RDEPUBTextLayouter {
} }
#endif #endif
/// 从最后行向前扫描,移除落在 keepWithNext 保护块内的尾部行。
/// 最多移除 3 行。
private func trimmedRangeForKeepWithNext( private func trimmedRangeForKeepWithNext(
proposed: NSRange, proposed: NSRange,
lineRanges: [NSRange] lineRanges: [NSRange]
@@ -648,7 +740,7 @@ struct RDEPUBTextLayouter {
return NSRange(location: proposed.location, length: adjustedLength) return NSRange(location: proposed.location, length: adjustedLength)
} }
/// Checks if a line's string range intersects with an avoidPageBreakInside block. /// 检查某行是否落在 avoidPageBreakInside 保护块内
private func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool { private func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool {
var found = false var found = false
let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1)) let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1))
@@ -665,6 +757,7 @@ struct RDEPUBTextLayouter {
return found return found
} }
/// 检查某行是否落在 keepWithNext 保护块内
private func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool { private func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool {
var found = false var found = false
let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1)) let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1))
@@ -681,6 +774,7 @@ struct RDEPUBTextLayouter {
return found return found
} }
/// 获取 CTFrame 中所有行的字符范围
private func lineRanges(from frame: CTFrame) -> [NSRange] { private func lineRanges(from frame: CTFrame) -> [NSRange] {
let lines = CTFrameGetLines(frame) as! [CTLine] let lines = CTFrameGetLines(frame) as! [CTLine]
return lines.map { return lines.map {
@@ -690,6 +784,7 @@ struct RDEPUBTextLayouter {
} }
#if canImport(DTCoreText) #if canImport(DTCoreText)
/// 获取 DTCoreTextLayoutFrame 中所有行的字符范围
private func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] { private func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else { guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else {
return [] return []
@@ -698,6 +793,9 @@ struct RDEPUBTextLayouter {
} }
#endif #endif
// MARK: - 诊断日志
/// 生成分页诊断日志,记录分页原因、范围和语义信息
private func diagnostics( private func diagnostics(
reason: RDEPUBTextPageBreakReason, reason: RDEPUBTextPageBreakReason,
range: NSRange, range: NSRange,
@@ -1,7 +1,18 @@
import CoreText import CoreText
import UIKit import UIKit
// MARK: - NSAttributedString 分页扩展
/// 为 NSAttributedString 提供便捷的分页方法,是渲染链路中 CoreText 分页的入口点。
extension NSAttributedString { extension NSAttributedString {
/// 将富文本按指定页面尺寸分页,返回布局帧列表(每帧对应一页)。
///
/// 这是 `RDEPUBTextBookBuilder` 和 `RDPlainTextBookBuilder` 调用的核心分页方法。
/// - Parameters:
/// - size: 页面尺寸
/// - fragmentOffsets: fragment ID → 字符偏移量映射(用于阅读位置恢复)
/// - config: 布局配置(孤行控制、avoidPageBreakInside 等)
/// - Returns: 分页后的布局帧列表
func rd_paginatedFrames( func rd_paginatedFrames(
size: CGSize, size: CGSize,
fragmentOffsets: [String: Int] = [:], fragmentOffsets: [String: Int] = [:],
@@ -11,12 +22,17 @@ extension NSAttributedString {
.layoutFrames(fragmentOffsets: fragmentOffsets) .layoutFrames(fragmentOffsets: fragmentOffsets)
} }
/// 简化版分页:只返回每页的 NSRange 列表(不含语义元数据)
func ss_pageRanges(size: CGSize) -> [NSRange] { func ss_pageRanges(size: CGSize) -> [NSRange] {
rd_paginatedFrames(size: size).map(\.contentRange) rd_paginatedFrames(size: size).map(\.contentRange)
} }
} }
// MARK: - UIColor CSS 转换扩展
/// 为 UIColor 提供 CSS 颜色字符串转换,用于动态生成暗色模式 CSS。
extension UIColor { extension UIColor {
/// 将 UIColor 转换为 CSS rgba() 字符串格式
var ss_cssString: String { var ss_cssString: String {
var red: CGFloat = 0 var red: CGFloat = 0
var green: CGFloat = 0 var green: CGFloat = 0
@@ -1,13 +1,20 @@
import Foundation import Foundation
// MARK: - Performance Sample // MARK: - 性能采样数据模型
/// 单个章节的性能采样数据,记录渲染和分页的耗时。
public struct RDEPUBTextPerformanceSample: Equatable { public struct RDEPUBTextPerformanceSample: Equatable {
/// 章节文件路径
public var chapterHref: String public var chapterHref: String
/// HTML 渲染耗时(秒)
public var renderDuration: TimeInterval public var renderDuration: TimeInterval
/// CoreText 分页耗时(秒)
public var paginateDuration: TimeInterval public var paginateDuration: TimeInterval
/// 分页后的页数
public var pageCount: Int public var pageCount: Int
/// 富文本字符长度
public var attributedStringLength: Int public var attributedStringLength: Int
/// 是否命中分页缓存
public var cacheHit: Bool public var cacheHit: Bool
public init( public init(
@@ -27,19 +34,27 @@ public struct RDEPUBTextPerformanceSample: Equatable {
} }
} }
// MARK: - Performance Sampler // MARK: - 性能采样器
/// 书籍构建过程的性能采样器,用于监控每章的渲染和分页耗时。
///
/// 由 `RDEPUBTextBookBuilder` 在构建过程中使用,每章记录一个采样点,
/// 构建完成后输出汇总报告。
public final class RDEPUBTextPerformanceSampler { public final class RDEPUBTextPerformanceSampler {
/// 所有章节的采样数据列表
public private(set) var samples: [RDEPUBTextPerformanceSample] = [] public private(set) var samples: [RDEPUBTextPerformanceSample] = []
/// 整本书构建的总耗时(秒)
public var totalBuildDuration: TimeInterval = 0 public var totalBuildDuration: TimeInterval = 0
public init() {} public init() {}
/// 记录单个章节的性能采样,并输出日志
public func record(_ sample: RDEPUBTextPerformanceSample) { public func record(_ sample: RDEPUBTextPerformanceSample) {
samples.append(sample) samples.append(sample)
print("[PERF] \(sample.chapterHref): render=\(formatMS(sample.renderDuration)) paginate=\(formatMS(sample.paginateDuration)) pages=\(sample.pageCount) cache=\(sample.cacheHit ? "HIT" : "MISS")") print("[PERF] \(sample.chapterHref): render=\(formatMS(sample.renderDuration)) paginate=\(formatMS(sample.paginateDuration)) pages=\(sample.pageCount) cache=\(sample.cacheHit ? "HIT" : "MISS")")
} }
/// 生成性能汇总报告,包含总渲染/分页耗时和缓存命中率
public func summary() -> String { public func summary() -> String {
let totalRender = samples.reduce(0) { $0 + $1.renderDuration } let totalRender = samples.reduce(0) { $0 + $1.renderDuration }
let totalPaginate = samples.reduce(0) { $0 + $1.paginateDuration } let totalPaginate = samples.reduce(0) { $0 + $1.paginateDuration }
@@ -47,11 +62,13 @@ public final class RDEPUBTextPerformanceSampler {
return "[PERF] chapters=\(samples.count) render=\(formatMS(totalRender)) paginate=\(formatMS(totalPaginate)) total=\(formatMS(totalBuildDuration)) cacheHits=\(hitCount)/\(samples.count)" return "[PERF] chapters=\(samples.count) render=\(formatMS(totalRender)) paginate=\(formatMS(totalPaginate)) total=\(formatMS(totalBuildDuration)) cacheHits=\(hitCount)/\(samples.count)"
} }
/// 重置所有采样数据
public func reset() { public func reset() {
samples.removeAll() samples.removeAll()
totalBuildDuration = 0 totalBuildDuration = 0
} }
/// 将秒转换为毫秒格式字符串
private func formatMS(_ duration: TimeInterval) -> String { private func formatMS(_ duration: TimeInterval) -> String {
String(format: "%.0fms", duration * 1000) String(format: "%.0fms", duration * 1000)
} }
@@ -1,43 +1,71 @@
import UIKit import UIKit
// MARK: - 自定义富文本属性键
/// EPUBTextRendering 层使用的自定义 NSAttributedString 属性键。
/// 这些属性由 `RDEPUBTextRendererSupport` 在渲染阶段注入,
/// 供 `RDEPUBTextLayouter` 分页时读取语义信息。
public extension NSAttributedString.Key { public extension NSAttributedString.Key {
/// 块级元素在富文本中的字符范围(NSString 编码的 NSRange)
static let rdPageBlockRange = NSAttributedString.Key("com.rdreader.epub.pageBlockRange") static let rdPageBlockRange = NSAttributedString.Key("com.rdreader.epub.pageBlockRange")
/// 块级元素在章节中的序号
static let rdPageBlockIndex = NSAttributedString.Key("com.rdreader.epub.pageBlockIndex") static let rdPageBlockIndex = NSAttributedString.Key("com.rdreader.epub.pageBlockIndex")
/// fragment 锚点 ID
static let rdPageFragmentID = NSAttributedString.Key("com.rdreader.epub.pageFragmentID") static let rdPageFragmentID = NSAttributedString.Key("com.rdreader.epub.pageFragmentID")
/// 附件类型(图片/通用附件)
static let rdPageAttachmentKind = NSAttributedString.Key("com.rdreader.epub.pageAttachmentKind") static let rdPageAttachmentKind = NSAttributedString.Key("com.rdreader.epub.pageAttachmentKind")
/// 块级元素类型(段落/列表/表格/代码等)
static let rdPageBlockKind = NSAttributedString.Key("com.rdreader.epub.pageBlockKind") static let rdPageBlockKind = NSAttributedString.Key("com.rdreader.epub.pageBlockKind")
/// 语义提示标记(避免分页、保持与下一段同页等,逗号分隔)
static let rdPageSemanticHints = NSAttributedString.Key("com.rdreader.epub.pageSemanticHints") static let rdPageSemanticHints = NSAttributedString.Key("com.rdreader.epub.pageSemanticHints")
/// 附件布局方式(行内/基线/居中)
static let rdPageAttachmentPlacement = NSAttributedString.Key("com.rdreader.epub.pageAttachmentPlacement") static let rdPageAttachmentPlacement = NSAttributedString.Key("com.rdreader.epub.pageAttachmentPlacement")
} }
// MARK: - 块级元素类型枚举
/// HTML 块级元素的类型分类,用于分页时判断语义边界。
public enum RDEPUBTextBlockKind: String, Codable, Equatable, CaseIterable { public enum RDEPUBTextBlockKind: String, Codable, Equatable, CaseIterable {
case paragraph case paragraph // <p>
case list case list // <ul>, <ol>, <li>
case table case table // <table> 系列
case code case code // <pre>, <code>
case blockquote case blockquote // <blockquote>
case attachment case attachment // 附件块(图片、figure 等)
case generic case generic // 通用块(<div>, <h1>-<h6> 等)
} }
// MARK: - 语义提示枚举
/// 分页语义提示,指导分页引擎在何处/何处不进行分页。
public enum RDEPUBTextSemanticHint: String, Codable, Equatable, CaseIterable { public enum RDEPUBTextSemanticHint: String, Codable, Equatable, CaseIterable {
case avoidPageBreakInside case avoidPageBreakInside // 禁止在块内分页(如代码块、表格)
case keepWithNext case keepWithNext // 与下一段保持同页(如标题)
case pageBreakBefore case pageBreakBefore // 在此元素前强制分页
case pageBreakAfter case pageBreakAfter // 在此元素后强制分页
case pageRelate case pageRelate // 微信读书式跨页关联元素
} }
// MARK: - 附件布局方式枚举
/// 富文本附件(图片等)的垂直布局方式。
public enum RDEPUBTextAttachmentPlacement: String, Codable, Equatable { public enum RDEPUBTextAttachmentPlacement: String, Codable, Equatable {
case inline case inline // 行内布局
case baseline case baseline // 基线对齐
case centered case centered // 垂直居中(块级)
} }
// MARK: - 渲染样式
/// 阅读器渲染样式配置,控制字体、行距、颜色等。
public struct RDEPUBTextRenderStyle { public struct RDEPUBTextRenderStyle {
/// 基础字体(大小和字族将覆盖 EPUB 原始样式)
public var font: UIFont public var font: UIFont
/// 行间距(pt)
public var lineSpacing: CGFloat public var lineSpacing: CGFloat
/// 文本颜色(nil 则使用 EPUB 原始颜色)
public var textColor: UIColor? public var textColor: UIColor?
/// 背景颜色(nil 则透明,用于暗色模式判断)
public var backgroundColor: UIColor? public var backgroundColor: UIColor?
public init(font: UIFont, lineSpacing: CGFloat, textColor: UIColor? = nil, backgroundColor: UIColor? = nil) { public init(font: UIFont, lineSpacing: CGFloat, textColor: UIColor? = nil, backgroundColor: UIColor? = nil) {
@@ -48,35 +76,119 @@ public struct RDEPUBTextRenderStyle {
} }
} }
// MARK: - 布局配置
/// 分页引擎的布局控制参数。
public struct RDEPUBTextLayoutConfig: Equatable { public struct RDEPUBTextLayoutConfig: Equatable {
/// 页面帧宽度;传 0 时回退为分页入口传入的 pageSize.width
public var frameWidth: CGFloat
/// 页面帧高度;传 0 时回退为分页入口传入的 pageSize.height
public var frameHeight: CGFloat
/// 页面内容内边距,对标 WXRead 的 WRCoreTextLayoutConfig.edgeInsets
public var edgeInsets: UIEdgeInsets
/// 栏数,对标 WXRead 的 numberOfColumns
public var numberOfColumns: Int
/// 栏间距,对标 WXRead 的 columnGap
public var columnGap: CGFloat
/// 是否避免孤行(段落最后一行单独在下一页顶部)
public var avoidOrphans: Bool public var avoidOrphans: Bool
/// 是否避免寡行(段落第一行单独在上一页底部)
public var avoidWidows: Bool public var avoidWidows: Bool
/// 是否启用 avoidPageBreakInside 保护(对标 WXRead 的行级回退扫描)
public var avoidPageBreakInsideEnabled: Bool public var avoidPageBreakInsideEnabled: Bool
/// 是否启用连字符断字,对标 WXRead 的 hyphenation
public var hyphenation: Bool
/// 图片最大高度占页面高度的比例
public var imageMaxHeightRatio: CGFloat public var imageMaxHeightRatio: CGFloat
public init( public init(
frameWidth: CGFloat = 0,
frameHeight: CGFloat = 0,
edgeInsets: UIEdgeInsets = .zero,
numberOfColumns: Int = 1,
columnGap: CGFloat = 20,
avoidOrphans: Bool = true, avoidOrphans: Bool = true,
avoidWidows: Bool = true, avoidWidows: Bool = true,
avoidPageBreakInsideEnabled: Bool = true, avoidPageBreakInsideEnabled: Bool = true,
hyphenation: Bool = true,
imageMaxHeightRatio: CGFloat = 0.85 imageMaxHeightRatio: CGFloat = 0.85
) { ) {
self.frameWidth = frameWidth
self.frameHeight = frameHeight
self.edgeInsets = edgeInsets
self.numberOfColumns = max(1, numberOfColumns)
self.columnGap = max(0, columnGap)
self.avoidOrphans = avoidOrphans self.avoidOrphans = avoidOrphans
self.avoidWidows = avoidWidows self.avoidWidows = avoidWidows
self.avoidPageBreakInsideEnabled = avoidPageBreakInsideEnabled self.avoidPageBreakInsideEnabled = avoidPageBreakInsideEnabled
self.hyphenation = hyphenation
self.imageMaxHeightRatio = imageMaxHeightRatio self.imageMaxHeightRatio = imageMaxHeightRatio
} }
/// 默认配置
public static let `default` = RDEPUBTextLayoutConfig() public static let `default` = RDEPUBTextLayoutConfig()
/// 结合调用方 pageSize 解析后的实际页面尺寸。
public func resolvedFrameSize(fallback pageSize: CGSize) -> CGSize {
CGSize(
width: max(frameWidth > 0 ? frameWidth : pageSize.width, 1),
height: max(frameHeight > 0 ? frameHeight : pageSize.height, 1)
)
} }
/// 实际内容区域;对标 WXRead 的 frame + edgeInsets 组合。
public func contentRect(fallback pageSize: CGSize) -> CGRect {
let size = resolvedFrameSize(fallback: pageSize)
return CGRect(origin: .zero, size: size).inset(by: edgeInsets)
}
/// 多栏布局时的列矩形数组。
public func columnRects(fallback pageSize: CGSize) -> [CGRect] {
let rect = contentRect(fallback: pageSize)
let columns = max(1, numberOfColumns)
guard columns > 1 else { return [rect] }
let totalGap = CGFloat(columns - 1) * columnGap
let columnWidth = max((rect.width - totalGap) / CGFloat(columns), 1)
return (0..<columns).map { index in
let originX = rect.minX + CGFloat(index) * (columnWidth + columnGap)
return CGRect(x: originX, y: rect.minY, width: columnWidth, height: rect.height)
}
}
/// 持久化/缓存键使用的稳定签名。
public var cacheSignature: String {
[
String(format: "%.3f", frameWidth),
String(format: "%.3f", frameHeight),
String(format: "%.3f", edgeInsets.top),
String(format: "%.3f", edgeInsets.left),
String(format: "%.3f", edgeInsets.bottom),
String(format: "%.3f", edgeInsets.right),
String(numberOfColumns),
String(format: "%.3f", columnGap),
avoidOrphans ? "1" : "0",
avoidWidows ? "1" : "0",
avoidPageBreakInsideEnabled ? "1" : "0",
hyphenation ? "1" : "0",
String(format: "%.3f", imageMaxHeightRatio)
].joined(separator: "|")
}
}
// MARK: - CSS 样式表层级
/// 样式表层级类型,用于 CSS 层叠优先级管理。
public enum RDEPUBTextStyleSheetLayerKind: String, CaseIterable, Equatable { public enum RDEPUBTextStyleSheetLayerKind: String, CaseIterable, Equatable {
case `default` case `default` // 基础重置样式(margin、padding 等)
case replace case replace // 元素替换样式(图片居中、标题分页等)
case dark case dark // 暗色模式覆盖
case epub case epub // EPUB 原始样式表
case user case user // 用户自定义样式(字号、行距、颜色等)
} }
/// 单个 CSS 样式表层,包含层级类型和 CSS 内容。
public struct RDEPUBTextStyleSheetLayer: Equatable { public struct RDEPUBTextStyleSheetLayer: Equatable {
public var kind: RDEPUBTextStyleSheetLayerKind public var kind: RDEPUBTextStyleSheetLayerKind
public var css: String public var css: String
@@ -87,6 +199,9 @@ public struct RDEPUBTextStyleSheetLayer: Equatable {
} }
} }
/// CSS 样式表包,管理多层 CSS 的合并与注入顺序。
///
/// CSS 层叠顺序(从低到高):default → replace → dark → epub → user
public struct RDEPUBTextStyleSheetPackage: Equatable { public struct RDEPUBTextStyleSheetPackage: Equatable {
public var layers: [RDEPUBTextStyleSheetLayer] public var layers: [RDEPUBTextStyleSheetLayer]
@@ -94,6 +209,7 @@ public struct RDEPUBTextStyleSheetPackage: Equatable {
self.layers = layers self.layers = layers
} }
/// 合并所有非空层的 CSS,每层添加注释头标记
public var combinedCSS: String { public var combinedCSS: String {
layers layers
.filter { !$0.css.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } .filter { !$0.css.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
@@ -104,11 +220,15 @@ public struct RDEPUBTextStyleSheetPackage: Equatable {
} }
} }
// MARK: - 资源引用诊断
/// 资源引用类型(样式表或图片)
public enum RDEPUBTextResourceReferenceKind: String, Equatable { public enum RDEPUBTextResourceReferenceKind: String, Equatable {
case stylesheet case stylesheet
case image case image
} }
/// 单个资源引用的诊断信息,用于检测 EPUB 中的资源是否正确引用。
public struct RDEPUBTextResourceReferenceDiagnostic: Equatable { public struct RDEPUBTextResourceReferenceDiagnostic: Equatable {
public var kind: RDEPUBTextResourceReferenceKind public var kind: RDEPUBTextResourceReferenceKind
public var chapterHref: String public var chapterHref: String
@@ -134,6 +254,9 @@ public struct RDEPUBTextResourceReferenceDiagnostic: Equatable {
} }
} }
// MARK: - 章节渲染上下文与请求
/// 章节渲染上下文:包含 HTML 源码、样式表和资源诊断信息。
public struct RDEPUBTextChapterContext: Equatable { public struct RDEPUBTextChapterContext: Equatable {
public var href: String public var href: String
public var title: String public var title: String
@@ -159,6 +282,7 @@ public struct RDEPUBTextChapterContext: Equatable {
} }
} }
/// 章节渲染请求:打包上下文和渲染样式,传递给 `RDEPUBTextRenderer`。
public struct RDEPUBTextChapterRenderRequest { public struct RDEPUBTextChapterRenderRequest {
public var context: RDEPUBTextChapterContext public var context: RDEPUBTextChapterContext
public var style: RDEPUBTextRenderStyle public var style: RDEPUBTextRenderStyle
@@ -169,9 +293,15 @@ public struct RDEPUBTextChapterRenderRequest {
} }
} }
// MARK: - 渲染结果
/// 章节渲染的输出结果,包含富文本、fragment 偏移量和资源诊断。
public struct RDEPUBRenderedChapterContent { public struct RDEPUBRenderedChapterContent {
/// 渲染后的富文本
public var attributedString: NSAttributedString public var attributedString: NSAttributedString
/// fragment ID → 字符偏移量映射
public var fragmentOffsets: [String: Int] public var fragmentOffsets: [String: Int]
/// 资源引用诊断列表
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
public init( public init(
@@ -185,11 +315,18 @@ public struct RDEPUBRenderedChapterContent {
} }
} }
// MARK: - 渲染器协议
/// EPUB 文本渲染器协议,定义 HTML → NSAttributedString 的转换接口。
///
/// 默认实现:`RDEPUBDTCoreTextRenderer`(基于 DTCoreText 库)
public protocol RDEPUBTextRenderer { public protocol RDEPUBTextRenderer {
/// 渲染单个章节
func renderChapter( func renderChapter(
request: RDEPUBTextChapterRenderRequest request: RDEPUBTextChapterRenderRequest
) throws -> RDEPUBRenderedChapterContent ) throws -> RDEPUBRenderedChapterContent
/// 便捷方法:直接渲染 HTML 字符串
func renderChapter( func renderChapter(
html: String, html: String,
baseURL: URL?, baseURL: URL?,
@@ -197,6 +334,7 @@ public protocol RDEPUBTextRenderer {
) throws -> RDEPUBRenderedChapterContent ) throws -> RDEPUBRenderedChapterContent
} }
/// 协议默认实现:将便捷方法委托给完整方法
public extension RDEPUBTextRenderer { public extension RDEPUBTextRenderer {
func renderChapter( func renderChapter(
html: String, html: String,
@@ -215,9 +353,12 @@ public extension RDEPUBTextRenderer {
} }
} }
// MARK: - 渲染错误
/// EPUB 文本渲染过程中可能出现的错误类型。
public enum RDEPUBTextRenderingError: LocalizedError { public enum RDEPUBTextRenderingError: LocalizedError {
case htmlEncodingFailed case htmlEncodingFailed // HTML 字符串编码为 Data 失败
case htmlImportFailed case htmlImportFailed // HTML 富文本解析失败
public var errorDescription: String? { public var errorDescription: String? {
switch self { switch self {
@@ -4,13 +4,36 @@ import UIKit
import DTCoreText import DTCoreText
#endif #endif
/// 渲染器支持工具集:负责 HTML 预处理、fragment 标记注入/提取、CSS 组装和字体标准化。
///
/// 本枚举是渲染链路的预处理层,`RDEPUBTextBookBuilder` 在调用渲染器前通过
/// `makeChapterRenderRequest()` 完成 HTML 的全部预处理工作。
///
/// 核心职责:
/// - HTML 规范化:清理冗余字符、规范化附件标记
/// - Fragment 标记注入/提取:id 属性 → ${id=xxx} 标记 → 字符偏移量映射
/// - 语义标记注入:HTML 标签 → ${rd-sem-start/end} 标记 → NSAttributedString 属性
/// - CSS 组装:按优先级合并 default/replace/dark/epub/user 五层样式表
/// - 字体标准化:将 EPUB 原始字体映射到用户设置的字体
/// - 附件规范化:图片尺寸、脚注图标、封面图片等特殊处理
enum RDEPUBTextRendererSupport { enum RDEPUBTextRendererSupport {
/// 外部样式表链接正则
private static let stylesheetLinkPattern = #"<link\b[^>]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"# private static let stylesheetLinkPattern = #"<link\b[^>]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"#
/// 图片源地址正则
private static let imageSourcePattern = #"<img\b[^>]*src\s*=\s*["']([^"']+)["'][^>]*>"# private static let imageSourcePattern = #"<img\b[^>]*src\s*=\s*["']([^"']+)["'][^>]*>"#
/// 语义标记正则(${rd-sem-start:...} / ${rd-sem-end:...})
private static let semanticMarkerPattern = #"\$\{rd-sem-(start|end):([^}]+)\}"# private static let semanticMarkerPattern = #"\$\{rd-sem-(start|end):([^}]+)\}"#
/// 脚注附件日志已输出标记(避免重复日志)
private static var didLogFootnoteAttachment = false private static var didLogFootnoteAttachment = false
/// 封面附件日志已输出标记(避免重复日志)
private static var didLogCoverAttachment = false private static var didLogCoverAttachment = false
// MARK: - Fragment 标记注入/提取
/// 将 HTML 中的 id 属性元素注入 fragment 标记。
///
/// 将 `<tag id="xxx" ...>` 转换为 `${id=xxx}<tag id="xxx" ...>`,
/// 使 DTCoreText 渲染后能在富文本中定位到 fragment 偏移量。
static func injectFragmentMarkers(into html: String) -> String { static func injectFragmentMarkers(into html: String) -> String {
guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else { guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else {
return html return html
@@ -23,6 +46,10 @@ enum RDEPUBTextRendererSupport {
) )
} }
/// 从渲染后的富文本中提取 fragment 偏移量映射。
///
/// 扫描 `${id=xxx}` 标记,记录每个 fragment ID 的字符偏移量,
/// 然后删除标记文本,返回 [fragmentID: offset] 字典。
static func extractFragmentOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] { static func extractFragmentOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] {
let markerPattern = #"\$\{id=([^}]+)\}"# let markerPattern = #"\$\{id=([^}]+)\}"#
guard let regex = try? NSRegularExpression(pattern: markerPattern, options: []) else { guard let regex = try? NSRegularExpression(pattern: markerPattern, options: []) else {
@@ -51,6 +78,10 @@ enum RDEPUBTextRendererSupport {
return fragmentOffsets return fragmentOffsets
} }
/// 将 HTML 中注入的语义标记(${rd-sem-start/end})应用到富文本属性中。
///
/// 标记包含 blockKind、semanticHints、attachmentPlacement 等信息,
/// 解析后写入对应的 .rdPage* 属性键,供分页引擎读取。
static func applyPaginationSemantics(in attributedString: NSMutableAttributedString) { static func applyPaginationSemantics(in attributedString: NSMutableAttributedString) {
guard let regex = try? NSRegularExpression(pattern: semanticMarkerPattern, options: []) else { guard let regex = try? NSRegularExpression(pattern: semanticMarkerPattern, options: []) else {
return return
@@ -85,6 +116,14 @@ enum RDEPUBTextRendererSupport {
} }
} }
/// 规范化阅读属性:统一字体、行距、颜色,并注入分页语义属性。
///
/// 遍历富文本的所有属性区间:
/// 1. 将 EPUB 原始字体映射到用户设置字体(保留粗体/斜体特征)
/// 2. 统一行距和段间距
/// 3. 应用用户设置的文本颜色
/// 4. 规范化附件显示尺寸
/// 5. 注入块级元素范围、序号、类型等分页语义属性
static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, style: RDEPUBTextRenderStyle) { static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, style: RDEPUBTextRenderStyle) {
let fullRange = NSRange(location: 0, length: attributedString.length) let fullRange = NSRange(location: 0, length: attributedString.length)
var blockIndex = 0 var blockIndex = 0
@@ -128,6 +167,7 @@ enum RDEPUBTextRendererSupport {
} }
} }
/// 回退渲染:当 DTCoreText 不可用时,将 HTML 源码当作纯文本处理。
static func fallbackAttributedString(for html: String, style: RDEPUBTextRenderStyle) -> NSMutableAttributedString { static func fallbackAttributedString(for html: String, style: RDEPUBTextRenderStyle) -> NSMutableAttributedString {
let fallbackAttributes: [NSAttributedString.Key: Any] = [ let fallbackAttributes: [NSAttributedString.Key: Any] = [
.font: style.font, .font: style.font,
@@ -137,13 +177,24 @@ enum RDEPUBTextRendererSupport {
return NSMutableAttributedString(string: html, attributes: fallbackAttributes) return NSMutableAttributedString(string: html, attributes: fallbackAttributes)
} }
/// 核心预处理方法:将原始 HTML 转换为完整的章节渲染请求。
///
/// 预处理流程:
/// 1. normalizeHTML:清理冗余字符、规范化附件 HTML 标记
/// 2. injectPaginationSemanticMarkers:为 HTML 标签注入语义标记
/// 3. inlineLinkedStyleSheets:将外部样式表内联到 HTML 中
/// 4. 组装五层 CSS(default/replace/dark/epub/user)并注入 style 标签
/// 5. injectBaseHref:注入 base 标签以解析相对路径
/// 6. injectFragmentMarkers:注入 fragment 锚点标记
/// 7. collectImageDiagnostics:收集图片资源引用诊断
static func makeChapterRenderRequest( static func makeChapterRenderRequest(
href: String, href: String,
title: String, title: String,
rawHTML: String, rawHTML: String,
baseURL: URL?, baseURL: URL?,
style: RDEPUBTextRenderStyle, style: RDEPUBTextRenderStyle,
resourceResolver: RDEPUBResourceResolver? resourceResolver: RDEPUBResourceResolver?,
contentLanguageCode: String? = nil
) -> RDEPUBTextChapterRenderRequest { ) -> RDEPUBTextChapterRenderRequest {
let normalizedHTML = injectPaginationSemanticMarkers(into: normalizeHTML(rawHTML)) let normalizedHTML = injectPaginationSemanticMarkers(into: normalizeHTML(rawHTML))
let stylesheetHrefReplacements = inlineLinkedStyleSheets( let stylesheetHrefReplacements = inlineLinkedStyleSheets(
@@ -154,7 +205,9 @@ enum RDEPUBTextRendererSupport {
) )
let layers = makeStyleSheetLayers( let layers = makeStyleSheetLayers(
style: style, style: style,
epubCSS: stylesheetHrefReplacements.inlinedCSS epubCSS: stylesheetHrefReplacements.inlinedCSS,
contentLanguageCode: contentLanguageCode,
sourceHTML: rawHTML
) )
let htmlWithBase = injectBaseHref(into: stylesheetHrefReplacements.html, baseURL: baseURL) let htmlWithBase = injectBaseHref(into: stylesheetHrefReplacements.html, baseURL: baseURL)
let htmlWithDefaultLayers = injectStyleTag( let htmlWithDefaultLayers = injectStyleTag(
@@ -197,6 +250,7 @@ enum RDEPUBTextRendererSupport {
return RDEPUBTextChapterRenderRequest(context: context, style: style) return RDEPUBTextChapterRenderRequest(context: context, style: style)
} }
/// HTML 规范化:清理冗余字符(CR、多余换行),规范化附件 HTML 标记
static func normalizeHTML(_ html: String) -> String { static func normalizeHTML(_ html: String) -> String {
var cleanedHTML = html var cleanedHTML = html
let replacements: [(pattern: String, template: String)] = [ let replacements: [(pattern: String, template: String)] = [
@@ -242,11 +296,17 @@ enum RDEPUBTextRendererSupport {
private static func makeStyleSheetLayers( private static func makeStyleSheetLayers(
style: RDEPUBTextRenderStyle, style: RDEPUBTextRenderStyle,
epubCSS: String epubCSS: String,
contentLanguageCode: String?,
sourceHTML: String
) -> [RDEPUBTextStyleSheetLayer] { ) -> [RDEPUBTextStyleSheetLayer] {
let useLatinReplace = prefersLatinLanguageCSS(
languageCode: contentLanguageCode,
sourceHTML: sourceHTML
)
var layers: [RDEPUBTextStyleSheetLayer] = [ var layers: [RDEPUBTextStyleSheetLayer] = [
.init(kind: .default, css: defaultCSS()), .init(kind: .default, css: defaultCSS()),
.init(kind: .replace, css: replaceCSS()) .init(kind: .replace, css: replaceCSS(useLatinVariant: useLatinReplace))
] ]
if isDarkTheme(style: style) { if isDarkTheme(style: style) {
layers.append(.init(kind: .dark, css: darkCSS(style: style))) layers.append(.init(kind: .dark, css: darkCSS(style: style)))
@@ -259,65 +319,12 @@ enum RDEPUBTextRendererSupport {
} }
private static func defaultCSS() -> String { private static func defaultCSS() -> String {
""" RDEPUBAssetRepository.string(for: .wxReadDefaultCSS)
html, body {
margin: 0;
padding: 0;
background: transparent;
}
body {
word-break: break-word;
overflow-wrap: break-word;
-webkit-text-size-adjust: 100%;
}
p, div, li, blockquote {
margin-top: 0;
margin-bottom: 0.75em;
}
"""
} }
private static func replaceCSS() -> String { private static func replaceCSS(useLatinVariant: Bool) -> String {
""" let asset: RDEPUBAsset = useLatinVariant ? .wxReadLatinReplaceCSS : .wxReadReplaceCSS
img, svg, video, canvas { return RDEPUBAssetRepository.string(for: asset)
max-width: 100%;
height: auto;
}
.frontCover,
.bodyPic,
.qrbodyPic,
figure {
text-align: center;
text-indent: 0;
}
.frontCover img,
.rd-front-cover-image,
.bodyPic img,
.qrbodyPic img,
figure img {
display: block;
margin-left: auto;
margin-right: auto;
}
.qqreader-footnote,
.s-pic,
.h-pic,
.g-pic {
display: inline;
vertical-align: middle;
}
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
break-after: avoid;
}
pre, code {
white-space: pre-wrap;
word-break: break-word;
}
table {
max-width: 100%;
}
"""
} }
private static func normalizeAttachmentHTMLMarkers(in html: String) -> String { private static func normalizeAttachmentHTMLMarkers(in html: String) -> String {
@@ -466,7 +473,7 @@ enum RDEPUBTextRendererSupport {
private static func darkCSS(style: RDEPUBTextRenderStyle) -> String { private static func darkCSS(style: RDEPUBTextRenderStyle) -> String {
let background = style.backgroundColor?.ss_cssString ?? "rgba(0, 0, 0, 1.000)" let background = style.backgroundColor?.ss_cssString ?? "rgba(0, 0, 0, 1.000)"
let text = style.textColor?.ss_cssString ?? "rgba(255, 255, 255, 1.000)" let text = style.textColor?.ss_cssString ?? "rgba(255, 255, 255, 1.000)"
return """ return RDEPUBAssetRepository.string(for: .wxReadDarkCSS) + "\n\n" + """
html, body { html, body {
background: \(background) !important; background: \(background) !important;
color: \(text) !important; color: \(text) !important;
@@ -505,6 +512,118 @@ enum RDEPUBTextRendererSupport {
return luminance < 0.5 return luminance < 0.5
} }
private static func prefersLatinLanguageCSS(
languageCode: String?,
sourceHTML: String
) -> Bool {
let candidateCodes = inferredLanguageCodes(
explicitLanguageCode: languageCode,
sourceHTML: sourceHTML
)
if candidateCodes.contains(where: isExplicitLatinLanguageCode) {
return true
}
if candidateCodes.contains(where: isExplicitCJKLanguageCode) {
return false
}
let textSample = plainTextSample(from: sourceHTML)
guard !textSample.isEmpty else { return false }
var alphabeticCount = 0
var latinCount = 0
for scalar in textSample.unicodeScalars {
guard CharacterSet.letters.contains(scalar) else { continue }
alphabeticCount += 1
if isLatinScalar(scalar) {
latinCount += 1
}
}
guard alphabeticCount >= 80 else { return false }
return (Double(latinCount) / Double(alphabeticCount)) >= 0.6
}
private static func inferredLanguageCodes(
explicitLanguageCode: String?,
sourceHTML: String
) -> [String] {
var codes: [String] = []
if let explicitLanguageCode {
let normalized = explicitLanguageCode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if !normalized.isEmpty {
codes.append(normalized)
}
}
if let regex = try? NSRegularExpression(
pattern: #"\b(?:xml:lang|lang)\s*=\s*["']([^"']+)["']"#,
options: [.caseInsensitive]
) {
let nsHTML = sourceHTML as NSString
let range = NSRange(location: 0, length: min(nsHTML.length, 8_000))
for match in regex.matches(in: sourceHTML, options: [], range: range) {
guard match.numberOfRanges > 1 else { continue }
let code = nsHTML.substring(with: match.range(at: 1))
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if !code.isEmpty {
codes.append(code)
}
}
}
return Array(NSOrderedSet(array: codes)) as? [String] ?? codes
}
private static func isExplicitLatinLanguageCode(_ code: String) -> Bool {
let normalized = code.lowercased()
if normalized.contains("latn") {
return true
}
let prefix = normalized.split(separator: "-").first.map(String.init) ?? normalized
let latinPrefixes: Set<String> = [
"en", "fr", "de", "es", "it", "pt", "nl", "sv", "da", "no", "fi",
"is", "ga", "cy", "pl", "cs", "sk", "sl", "hr", "hu", "ro", "tr",
"vi", "id", "ms", "tl", "sw", "af", "sq", "et", "lv", "lt"
]
return latinPrefixes.contains(prefix)
}
private static func isExplicitCJKLanguageCode(_ code: String) -> Bool {
let prefix = code.lowercased().split(separator: "-").first.map(String.init) ?? code.lowercased()
return ["zh", "ja", "ko"].contains(prefix)
}
private static func plainTextSample(from html: String) -> String {
let maxLength = min(html.count, 20_000)
let sample = String(html.prefix(maxLength))
let withoutTags = sample.replacingOccurrences(
of: #"<[^>]+>"#,
with: " ",
options: .regularExpression
)
return withoutTags.replacingOccurrences(
of: #"&[A-Za-z0-9#]+;"#,
with: " ",
options: .regularExpression
)
}
private static func isLatinScalar(_ scalar: UnicodeScalar) -> Bool {
switch scalar.value {
case 0x0041...0x007A,
0x00C0...0x00FF,
0x0100...0x024F,
0x1E00...0x1EFF:
return true
default:
return false
}
}
private static func injectBaseHref(into html: String, baseURL: URL?) -> String { private static func injectBaseHref(into html: String, baseURL: URL?) -> String {
guard let baseURL else { guard let baseURL else {
return html return html
@@ -1,5 +1,10 @@
import Foundation import Foundation
/// EPUB 文本搜索引擎:在分页书籍中执行全文搜索。
///
/// 实现 `RDEPUBSearchEngine` 协议,遍历所有章节的富文本内容,
/// 使用大小写不敏感匹配查找关键词,返回搜索结果列表。
/// 每个搜索结果包含进度位置、预览文本和语义锚点。
final class RDEPUBTextSearchEngine: RDEPUBSearchEngine { final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
private let textBook: RDEPUBTextBook private let textBook: RDEPUBTextBook
private let publication: RDEPUBPublication private let publication: RDEPUBPublication
@@ -9,6 +14,14 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
self.publication = publication self.publication = publication
} }
/// 在全书中搜索关键词,返回所有匹配结果。
///
/// 搜索流程:
/// 1. 遍历所有章节
/// 2. 在每章的富文本字符串中执行大小写不敏感搜索
/// 3. 计算匹配位置的阅读进度(progression)
/// 4. 生成预览文本(匹配位置前后各 12 个字符)
/// 5. 生成语义锚点(用于跨设备/跨字体精确定位)
func search(keyword: String) -> [RDEPUBSearchMatch] { func search(keyword: String) -> [RDEPUBSearchMatch] {
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKeyword.isEmpty else { guard !normalizedKeyword.isEmpty else {
@@ -60,6 +73,7 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
return matches return matches
} }
/// 生成搜索结果的预览文本:匹配位置前后各取 12 个字符
private func previewText(in text: NSString, matchRange: NSRange) -> String { private func previewText(in text: NSString, matchRange: NSRange) -> String {
let previewRadius = 12 let previewRadius = 12
let start = max(matchRange.location - previewRadius, 0) let start = max(matchRange.location - previewRadius, 0)
@@ -1,13 +1,35 @@
import UIKit import UIKit
/// 将纯文本文件构建为 RDEPUBTextBook,复用 EPUB 文本渲染和分页管线 /// 纯文本书籍构建器:将 TXT 文件构建为 RDEPUBTextBook,复用 EPUB 文本渲染和分页管线。
///
/// 本类将纯文本文件(.txt)转换为与 EPUB 相同的分页书籍模型,
/// 使得纯文本阅读器可以复用 EPUBReader 的分页、导航、搜索等全部功能。
///
/// 处理流程:
/// 1. 解码文本文件(UTF-8 → GB18030 → GBK 兼容编码)
/// 2. 按中文章节正则拆分为多个章节
/// 3. 将纯文本包装为 HTML 段落
/// 4. 通过渲染器(DTCoreText)渲染为富文本
/// 5. CoreText 分页 → RDEPUBTextBook
public final class RDPlainTextBookBuilder { public final class RDPlainTextBookBuilder {
private let renderer: RDEPUBTextRenderer private let renderer: RDEPUBTextRenderer
private let layoutConfig: RDEPUBTextLayoutConfig
public init(renderer: RDEPUBTextRenderer = RDEPUBDTCoreTextRenderer()) { public init(
renderer: RDEPUBTextRenderer = RDEPUBDTCoreTextRenderer(),
layoutConfig: RDEPUBTextLayoutConfig = .default
) {
self.renderer = renderer self.renderer = renderer
self.layoutConfig = layoutConfig
} }
/// 从纯文本文件构建分页书籍。
///
/// - Parameters:
/// - textFileURL: 纯文本文件的本地 URL
/// - pageSize: 页面尺寸
/// - style: 渲染样式(字体、行距、颜色)
/// - Returns: 分页后的书籍模型
public func build( public func build(
textFileURL: URL, textFileURL: URL,
pageSize: CGSize, pageSize: CGSize,
@@ -24,7 +46,7 @@ public final class RDPlainTextBookBuilder {
let rendered = try renderer.renderChapter(html: html, baseURL: nil, style: style) let rendered = try renderer.renderChapter(html: html, baseURL: nil, style: style)
let content = NSMutableAttributedString(attributedString: rendered.attributedString) let content = NSMutableAttributedString(attributedString: rendered.attributedString)
let layoutFrames = content.length > 0 ? content.rd_paginatedFrames(size: pageSize) : [] let layoutFrames = content.length > 0 ? content.rd_paginatedFrames(size: pageSize, config: layoutConfig) : []
let effectiveFrames = layoutFrames.isEmpty && content.length > 0 let effectiveFrames = layoutFrames.isEmpty && content.length > 0
? [ ? [
RDEPUBTextLayoutFrame( RDEPUBTextLayoutFrame(
@@ -86,12 +108,14 @@ public final class RDPlainTextBookBuilder {
// MARK: - 章节拆分 // MARK: - 章节拆分
/// 章节规格:标题 + 内容
private struct ChapterSpec { private struct ChapterSpec {
let title: String? let title: String?
let content: String let content: String
} }
/// 按中文章节正则拆分文本。无匹配时整体作为一章。 /// 按中文章节正则拆分文本。匹配 "第X章/节/回/卷" 格式,
/// 无匹配时将整篇文本作为一章处理。
private func splitChapters(from text: String) -> [ChapterSpec] { private func splitChapters(from text: String) -> [ChapterSpec] {
let pattern = #"^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$"# let pattern = #"^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$"#
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else { guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else {
@@ -126,7 +150,7 @@ public final class RDPlainTextBookBuilder {
// MARK: - HTML 包装 // MARK: - HTML 包装
/// 将纯文本包装为 HTML 段落 /// 将纯文本包装为 HTML 段落:每行一个 `<p>` 标签
private func wrapTextAsHTML(_ text: String) -> String { private func wrapTextAsHTML(_ text: String) -> String {
let paragraphs = text.components(separatedBy: "\n").filter { !$0.isEmpty } let paragraphs = text.components(separatedBy: "\n").filter { !$0.isEmpty }
let body = paragraphs.map { "<p>\($0)</p>" }.joined(separator: "\n") let body = paragraphs.map { "<p>\($0)</p>" }.joined(separator: "\n")
@@ -135,6 +159,7 @@ public final class RDPlainTextBookBuilder {
// MARK: - 文本解码 // MARK: - 文本解码
/// 解码文本文件,按优先级尝试 UTF-8 → GB18030 → GBK 编码
private func rd_decodeTextFile(url: URL) -> String { private func rd_decodeTextFile(url: URL) -> String {
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String { if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
return content return content
@@ -4,6 +4,11 @@ import UIKit
import DTCoreText import DTCoreText
#endif #endif
// MARK: - 页面交互控制器
/// 页面交互控制器,负责文本层面的坐标映射与选择逻辑
/// 在 DTCoreText 排版结果和用户交互之间充当桥梁
/// 提供字符索引查找、选择范围计算、选择矩形计算等功能
final class RDEPUBPageInteractionController { final class RDEPUBPageInteractionController {
var snapshot: RDEPUBPageLayoutSnapshot? var snapshot: RDEPUBPageLayoutSnapshot?
@@ -4,29 +4,56 @@ import UIKit
import DTCoreText import DTCoreText
#endif #endif
// MARK: - 页面排版快照数据结构
/// 文本行信息
/// 描述 DTCoreText 排版后的一行文本的位置、范围和基线信息
struct RDEPUBPageLine { struct RDEPUBPageLine {
/// 行内文本在 chapterContent 中的绝对字符范围
let stringRange: NSRange let stringRange: NSRange
/// 行的框架(相对于排版区域)
let frame: CGRect let frame: CGRect
/// 行的基线原点
let baselineOrigin: CGPoint let baselineOrigin: CGPoint
/// 基线以上的高度(升部)
let ascent: CGFloat let ascent: CGFloat
/// 基线以下的高度(降部)
let descent: CGFloat let descent: CGFloat
/// 行间距
let leading: CGFloat let leading: CGFloat
} }
/// 文本 Run 信息
/// 描述一行内某个连续绘制单元(如普通文字或附件)
struct RDEPUBPageRun { struct RDEPUBPageRun {
/// Run 内文本的绝对字符范围
let stringRange: NSRange let stringRange: NSRange
/// Run 的绘制框架
let frame: CGRect let frame: CGRect
/// 是否为附件(图片等)
let isAttachment: Bool let isAttachment: Bool
} }
/// 附件信息
/// 描述嵌入在文本中的图片或其他附件
struct RDEPUBPageAttachment { struct RDEPUBPageAttachment {
/// 附件在文本中的绝对字符范围
let stringRange: NSRange let stringRange: NSRange
/// 附件的显示框架
let frame: CGRect let frame: CGRect
/// 附件的建议显示尺寸
let displaySize: CGSize let displaySize: CGSize
/// 附件的布局方式(行内/浮动等)
let placement: RDEPUBTextAttachmentPlacement? let placement: RDEPUBTextAttachmentPlacement?
/// 附件类型(封面图/普通图片等)
let kind: RDEPUBTextAttachmentKind? let kind: RDEPUBTextAttachmentKind?
} }
// MARK: - 页面排版快照
/// 页面排版快照
/// 封装 DTCoreText 的排版结果,提供高效的文本位置查询能力
/// 用于支持文本选择、高亮渲染和搜索结果定位
struct RDEPUBPageLayoutSnapshot { struct RDEPUBPageLayoutSnapshot {
let page: RDEPUBTextPage let page: RDEPUBTextPage
let lines: [RDEPUBPageLine] let lines: [RDEPUBPageLine]
@@ -1,12 +1,27 @@
import UIKit import UIKit
// MARK: - 底部工具栏
/// 阅读器底部工具栏
/// 提供目录、书签、高亮管理、新建标注和设置五个功能入口
/// 按钮可见性可根据 configuration 动态调整
final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView { final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
// MARK: 回调闭包
/// 点击目录按钮回调
var onShowTableOfContents: (() -> Void)? var onShowTableOfContents: (() -> Void)?
/// 点击书签列表按钮回调
var onShowBookmarks: (() -> Void)? var onShowBookmarks: (() -> Void)?
/// 点击高亮列表按钮回调
var onShowHighlights: (() -> Void)? var onShowHighlights: (() -> Void)?
/// 点击新建标注按钮回调
var onAddHighlight: (() -> Void)? var onAddHighlight: (() -> Void)?
/// 点击设置按钮回调
var onShowSettings: (() -> Void)? var onShowSettings: (() -> Void)?
// MARK: UI 组件
/// 水平等分布局的按钮容器
private let stackView: UIStackView = { private let stackView: UIStackView = {
let view = UIStackView() let view = UIStackView()
view.axis = .horizontal view.axis = .horizontal
@@ -16,10 +31,15 @@ final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
return view return view
}() }()
/// 目录按钮
private let chapterButton = RDEPUBReaderTintButton(type: .system) private let chapterButton = RDEPUBReaderTintButton(type: .system)
/// 书签列表按钮
private let bookmarksButton = RDEPUBReaderTintButton(type: .system) private let bookmarksButton = RDEPUBReaderTintButton(type: .system)
/// 高亮列表按钮
private let highlightsButton = RDEPUBReaderTintButton(type: .system) private let highlightsButton = RDEPUBReaderTintButton(type: .system)
/// 新建标注按钮
private let addHighlightButton = RDEPUBReaderTintButton(type: .system) private let addHighlightButton = RDEPUBReaderTintButton(type: .system)
/// 设置按钮
private let settingsButton = RDEPUBReaderTintButton(type: .system) private let settingsButton = RDEPUBReaderTintButton(type: .system)
override init(frame: CGRect) { override init(frame: CGRect) {
@@ -78,6 +98,9 @@ final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
} }
} }
// MARK: 按钮状态控制
/// 设置新建标注按钮是否可用(有选中文本时可用)
func setAddHighlightEnabled(_ isEnabled: Bool) { func setAddHighlightEnabled(_ isEnabled: Bool) {
addHighlightButton.isEnabled = isEnabled addHighlightButton.isEnabled = isEnabled
addHighlightButton.alpha = isEnabled ? 1 : 0.45 addHighlightButton.alpha = isEnabled ? 1 : 0.45
@@ -1,6 +1,11 @@
import UIKit import UIKit
// MARK: - 目录面板
/// EPUB 阅读器的目录面板控制器
/// 以列表形式展示书籍目录,支持多级缩进,当前所在章节高亮显示
final class RDEPUBReaderChapterListController: UITableViewController { final class RDEPUBReaderChapterListController: UITableViewController {
/// 用户选择目录项时的回调
var onSelectItem: ((RDEPUBReaderTableOfContentsItem) -> Void)? var onSelectItem: ((RDEPUBReaderTableOfContentsItem) -> Void)?
private let items: [RDEPUBReaderTableOfContentsItem] private let items: [RDEPUBReaderTableOfContentsItem]
@@ -1,24 +1,75 @@
import UIKit import UIKit
// MARK: - 文本渲染引擎
/// EPUB 文本渲染引擎枚举
/// 定义了当前支持的文本渲染方式,未来可扩展为多引擎选择
public enum RDEPUBTextRenderingEngine: Equatable { public enum RDEPUBTextRenderingEngine: Equatable {
/// 基于 DTCoreText 的渲染引擎,将 HTML/CSS 转换为 NSAttributedString
case dtCoreText case dtCoreText
} }
// MARK: - 阅读器配置
/// EPUB 阅读器的完整配置结构体
/// 作为 EPUBUI 层的入口配置,控制阅读器的外观、行为和功能开关
/// 调用方在创建 RDEPUBReaderController 前可自定义此配置
public struct RDEPUBReaderConfiguration: Equatable { public struct RDEPUBReaderConfiguration: Equatable {
// MARK: 阅读排版参数
/// 正文字号(单位:pt),默认 15
public var fontSize: CGFloat public var fontSize: CGFloat
/// 行距倍数,默认 1.6(即行距为字号的 1.6 倍)
public var lineHeightMultiple: CGFloat public var lineHeightMultiple: CGFloat
/// 翻页显示模式:.pageCurl(仿真翻页)或 .scroll(滚动阅读)
public var displayType: RDReaderView.DisplayType public var displayType: RDReaderView.DisplayType
/// 横屏时是否启用双页显示,默认开启
public var landscapeDualPageEnabled: Bool public var landscapeDualPageEnabled: Bool
// MARK: UI 功能开关
/// 是否显示目录入口,默认开启
public var showsTableOfContents: Bool public var showsTableOfContents: Bool
/// 是否允许文本高亮功能,默认开启
public var allowsHighlights: Bool public var allowsHighlights: Bool
/// 是否显示设置面板入口,默认开启
public var showsSettingsPanel: Bool public var showsSettingsPanel: Bool
// MARK: 内容内边距
/// 流式排版(reflowable)内容的内边距,上下 40pt、左右 16pt
public var reflowableContentInsets: UIEdgeInsets public var reflowableContentInsets: UIEdgeInsets
/// 固定布局(fixed layout)内容的内边距,默认为零
public var fixedContentInset: UIEdgeInsets public var fixedContentInset: UIEdgeInsets
// MARK: 主题与布局
/// 当前阅读主题(浅色/深色/护眼等),默认 .light
public var theme: RDEPUBReaderTheme public var theme: RDEPUBReaderTheme
/// 固定布局的适配模式:按页适配或按宽度适配
public var fixedLayoutFit: RDEPUBFixedLayoutFit public var fixedLayoutFit: RDEPUBFixedLayoutFit
/// 固定布局的跨页模式:自动/单页/双页
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode
/// 文本渲染引擎,默认使用 DTCoreText
public var textRenderingEngine: RDEPUBTextRenderingEngine public var textRenderingEngine: RDEPUBTextRenderingEngine
// MARK: 初始化
/// 创建阅读器配置,所有参数均提供合理的默认值
/// - Parameters:
/// - fontSize: 正文字号,默认 15pt
/// - lineHeightMultiple: 行距倍数,默认 1.6
/// - displayType: 翻页模式,默认 .pageCurl
/// - landscapeDualPageEnabled: 横屏双页,默认 true
/// - showsTableOfContents: 显示目录入口,默认 true
/// - allowsHighlights: 允许高亮,默认 true
/// - showsSettingsPanel: 显示设置面板,默认 true
/// - reflowableContentInsets: 流式排版内边距
/// - fixedContentInset: 固定布局内边距
/// - theme: 阅读主题,默认 .light
/// - fixedLayoutFit: 固定布局适配模式
/// - fixedLayoutSpreadMode: 固定布局跨页模式
/// - textRenderingEngine: 文本渲染引擎
public init( public init(
fontSize: CGFloat = 15, fontSize: CGFloat = 15,
lineHeightMultiple: CGFloat = 1.6, lineHeightMultiple: CGFloat = 1.6,
@@ -49,10 +100,15 @@ public struct RDEPUBReaderConfiguration: Equatable {
self.textRenderingEngine = textRenderingEngine self.textRenderingEngine = textRenderingEngine
} }
/// 默认配置实例,使用所有参数的默认值
public static let `default` = RDEPUBReaderConfiguration() public static let `default` = RDEPUBReaderConfiguration()
} }
// MARK: - 配置转换
extension RDEPUBReaderConfiguration { extension RDEPUBReaderConfiguration {
/// 将用户可见的配置转换为底层排版引擎所需的 RDEPUBPreferences
/// - Returns: 排版偏好设置实例,供 Core 层的渲染管道使用
func makePreferences() -> RDEPUBPreferences { func makePreferences() -> RDEPUBPreferences {
RDEPUBPreferences( RDEPUBPreferences(
fontSize: fontSize, fontSize: fontSize,
@@ -1,6 +1,29 @@
import UIKit import UIKit
// MARK: - 文件说明
/// EPUB 阅读器主控制器
/// EPUBUI 层的核心入口,提供开箱即用的完整阅读器体验
/// 调用方只需传入 EPUB 文件 URL 或已构建的 TextBook,即可呈现完整的阅读界面
///
/// **架构位置:** EPUBUI(最顶层)→ Core → Parser → Foundation
///
/// **核心职责:**
/// - EPUB 解析与分页(支持流式排版和固定布局)
/// - 阅读位置的持久化与恢复
/// - 高亮、书签的增删改查
/// - 工具栏、目录、设置等 UI 面板的协调管理
/// - 搜索功能(全文搜索、前后跳转)
///
/// **打开流程:** init(epubURL:) → viewDidLoad → RDEPUBParser.parse → paginatePublication → readerView.reloadData → restoreLocation
// MARK: - 阅读器主控制器
public final class RDEPUBReaderController: UIViewController { public final class RDEPUBReaderController: UIViewController {
// MARK: - 内部辅助类型
/// 视口签名结构体,用于检测视口尺寸或安全区是否发生显著变化
/// 当变化超过阈值时触发重新分页
private struct RDEPUBViewportSignature: Equatable { private struct RDEPUBViewportSignature: Equatable {
let width: CGFloat let width: CGFloat
let height: CGFloat let height: CGFloat
@@ -19,6 +42,7 @@ public final class RDEPUBReaderController: UIViewController {
} }
} }
/// 视口变化的原因枚举,用于决定延迟处理的策略
private enum RDEPUBViewportChangeReason { private enum RDEPUBViewportChangeReason {
case viewLayout case viewLayout
case orientationTransition case orientationTransition
@@ -27,8 +51,12 @@ public final class RDEPUBReaderController: UIViewController {
private typealias NativeTextSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo]) private typealias NativeTextSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
// MARK: - 公开属性
/// 阅读器委托,用于接收阅读状态变更通知
public weak var delegate: RDEPUBReaderDelegate? public weak var delegate: RDEPUBReaderDelegate?
/// 阅读器配置,修改后自动判断是否需要重新分页或刷新内容
public var configuration: RDEPUBReaderConfiguration { public var configuration: RDEPUBReaderConfiguration {
didSet { didSet {
persistReaderSettingsIfNeeded() persistReaderSettingsIfNeeded()
@@ -54,38 +82,49 @@ public final class RDEPUBReaderController: UIViewController {
} }
} }
/// 当前阅读位置
public var currentLocation: RDEPUBLocation? { public var currentLocation: RDEPUBLocation? {
currentVisibleLocation() currentVisibleLocation()
} }
/// 当前页码(从 1 开始),nil 表示尚未加载
public var currentPageNumber: Int? { public var currentPageNumber: Int? {
guard readerView.currentPage >= 0 else { return nil } guard readerView.currentPage >= 0 else { return nil }
return readerView.currentPage + 1 return readerView.currentPage + 1
} }
/// 当前用户选中的文本(只读),用于创建高亮/批注
public private(set) var currentSelection: RDEPUBSelection? public private(set) var currentSelection: RDEPUBSelection?
/// 所有高亮标注
public var highlights: [RDEPUBHighlight] { public var highlights: [RDEPUBHighlight] {
activeHighlights activeHighlights
} }
/// 所有书签
public var bookmarks: [RDEPUBBookmark] { public var bookmarks: [RDEPUBBookmark] {
activeBookmarks activeBookmarks
} }
/// 原始目录树结构
public var tableOfContents: [EPUBTableOfContentsItem] { public var tableOfContents: [EPUBTableOfContentsItem] {
publication?.tableOfContents ?? [] publication?.tableOfContents ?? []
} }
/// 展平后的目录列表(用于目录面板展示,每个条目附带页码)
public var flattenedTableOfContents: [RDEPUBReaderTableOfContentsItem] { public var flattenedTableOfContents: [RDEPUBReaderTableOfContentsItem] {
guard let publication else { return [] } guard let publication else { return [] }
return flattenedTableOfContentsItems(from: publication.tableOfContents) return flattenedTableOfContentsItems(from: publication.tableOfContents)
} }
/// 当前阅读位置所在的目录项
public var currentTableOfContentsItem: RDEPUBReaderTableOfContentsItem? { public var currentTableOfContentsItem: RDEPUBReaderTableOfContentsItem? {
resolvedCurrentTableOfContentsItem() resolvedCurrentTableOfContentsItem()
} }
// MARK: - 私有属性
/// EPUB 文件 URL
private let epubURL: URL private let epubURL: URL
fileprivate let persistence: RDEPUBReaderPersistence? fileprivate let persistence: RDEPUBReaderPersistence?
private let readerView = RDReaderView() private let readerView = RDReaderView()
@@ -131,6 +170,14 @@ public final class RDEPUBReaderController: UIViewController {
/// 外部 TextBook 的原始文件 URL(用于重建 TextBook) /// 外部 TextBook 的原始文件 URL(用于重建 TextBook)
private var textFileURL: URL? private var textFileURL: URL?
// MARK: - 初始化
/// 使用 EPUB 文件 URL 初始化阅读器
/// 会自动从持久化存储中恢复用户的阅读偏好设置
/// - Parameters:
/// - epubURL: EPUB 文件的本地 URL
/// - configuration: 阅读器配置,默认使用 .default
/// - persistence: 持久化策略,默认使用 UserDefaults
public init( public init(
epubURL: URL, epubURL: URL,
configuration: RDEPUBReaderConfiguration = .default, configuration: RDEPUBReaderConfiguration = .default,
@@ -149,7 +196,15 @@ public final class RDEPUBReaderController: UIViewController {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
/// 使用已构建的 TextBook 初始化,跳过 EPUB 解析流程(用于 TXT 等纯文本文件) /// 使用已构建的 TextBook 初始化,跳过 EPUB 解析流程
/// 适用于 TXT 等纯文本文件,调用方需自行构建 TextBook
/// - Parameters:
/// - textBook: 已分页的 TextBook 实例
/// - bookIdentifier: 书籍唯一标识符,用于持久化
/// - title: 书籍标题
/// - textFileURL: 原始文本文件 URL,用于重建 TextBook
/// - configuration: 阅读器配置
/// - persistence: 持久化策略
public convenience init( public convenience init(
textBook: RDEPUBTextBook, textBook: RDEPUBTextBook,
bookIdentifier: String, bookIdentifier: String,
@@ -244,6 +299,9 @@ public final class RDEPUBReaderController: UIViewController {
} }
} }
// MARK: - 公开 API
/// 重新加载书籍,重置所有状态并重新解析 EPUB
public func reloadBook() { public func reloadBook() {
didStartInitialLoad = false didStartInitialLoad = false
parser = nil parser = nil
@@ -263,11 +321,18 @@ public final class RDEPUBReaderController: UIViewController {
startInitialLoadIfNeeded() startInitialLoadIfNeeded()
} }
/// 跳转到指定阅读位置
/// - Parameter location: 目标阅读位置
public func go(to location: RDEPUBLocation) { public func go(to location: RDEPUBLocation) {
guard publication != nil else { return } guard publication != nil else { return }
restoreReadingLocation(location) restoreReadingLocation(location)
} }
/// 跳转到指定页码
/// - Parameters:
/// - pageNumber: 目标页码(从 1 开始)
/// - animated: 是否动画过渡
/// - Returns: 是否跳转成功
@discardableResult @discardableResult
public func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool { public func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
guard pageNumber > 0 else { return false } guard pageNumber > 0 else { return false }
@@ -289,6 +354,7 @@ public final class RDEPUBReaderController: UIViewController {
return true return true
} }
/// 清除当前文本选中状态
public func clearSelection() { public func clearSelection() {
updateCurrentSelection(nil) updateCurrentSelection(nil)
} }
@@ -321,6 +387,12 @@ public final class RDEPUBReaderController: UIViewController {
return parts.joined(separator: " · ") return parts.joined(separator: " · ")
} }
/// 添加高亮标注,从当前选中文本或指定选区创建
/// - Parameters:
/// - selection: 文本选区,默认使用 currentSelection
/// - color: 高亮颜色(CSS 格式),默认黄色
/// - note: 可选批注文字
/// - Returns: 创建的高亮对象,重复时返回 nil
@discardableResult @discardableResult
public func addHighlight( public func addHighlight(
from selection: RDEPUBSelection? = nil, from selection: RDEPUBSelection? = nil,
@@ -607,8 +679,9 @@ public final class RDEPUBReaderController: UIViewController {
if publication.readingProfile == .textReflowable { if publication.readingProfile == .textReflowable {
let renderer = resolvedTextRenderer() let renderer = resolvedTextRenderer()
let builder = RDEPUBTextBookBuilder(renderer: renderer, cache: textBookCache)
let pageSize = currentTextPageSize() let pageSize = currentTextPageSize()
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
let builder = RDEPUBTextBookBuilder(renderer: renderer, cache: textBookCache, layoutConfig: layoutConfig)
let renderStyle = currentTextRenderStyle() let renderStyle = currentTextRenderStyle()
DispatchQueue.global(qos: .userInitiated).async { [weak self] in DispatchQueue.global(qos: .userInitiated).async { [weak self] in
@@ -841,7 +914,7 @@ public final class RDEPUBReaderController: UIViewController {
let restoreLocation = currentVisibleLocation() ?? persistenceLocation() let restoreLocation = currentVisibleLocation() ?? persistenceLocation()
let pageSize = currentTextPageSize() let pageSize = currentTextPageSize()
let style = currentTextRenderStyle() let style = currentTextRenderStyle()
let builder = RDPlainTextBookBuilder() let builder = RDPlainTextBookBuilder(layoutConfig: currentTextLayoutConfig(pageSize: pageSize))
if let newBook = try? builder.build(textFileURL: textFileURL, pageSize: pageSize, style: style) { if let newBook = try? builder.build(textFileURL: textFileURL, pageSize: pageSize, style: style) {
applyTextBook(newBook, restoreLocation: restoreLocation) applyTextBook(newBook, restoreLocation: restoreLocation)
} }
@@ -1159,6 +1232,26 @@ public final class RDEPUBReaderController: UIViewController {
) )
} }
private func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
let viewportSize = currentLayoutContext().viewportSize
let derivedFrameSize = CGSize(
width: max(viewportSize.width, pageSize.width),
height: max(viewportSize.height, pageSize.height)
)
return RDEPUBTextLayoutConfig(
frameWidth: derivedFrameSize.width,
frameHeight: derivedFrameSize.height,
edgeInsets: .zero,
numberOfColumns: 1,
columnGap: 20,
avoidOrphans: true,
avoidWidows: true,
avoidPageBreakInsideEnabled: true,
hyphenation: true,
imageMaxHeightRatio: 0.85
)
}
private func resolvedTextRenderer() -> RDEPUBTextRenderer { private func resolvedTextRenderer() -> RDEPUBTextRenderer {
RDEPUBDTCoreTextRenderer() RDEPUBDTCoreTextRenderer()
} }
@@ -1285,6 +1378,8 @@ public final class RDEPUBReaderController: UIViewController {
} }
} }
// MARK: - 书签管理
extension RDEPUBReaderController { extension RDEPUBReaderController {
@discardableResult @discardableResult
public func addBookmark(note: String? = nil) -> RDEPUBBookmark? { public func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
@@ -1456,6 +1551,10 @@ extension RDEPUBReaderController {
return trimmed.isEmpty ? nil : trimmed return trimmed.isEmpty ? nil : trimmed
} }
// MARK: - 搜索功能
/// 执行全文搜索,自动跳转到第一个匹配项
/// - Parameter keyword: 搜索关键词,空字符串会清除搜索
public func search(keyword: String) { public func search(keyword: String) {
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKeyword.isEmpty else { guard !normalizedKeyword.isEmpty else {
@@ -1622,6 +1721,8 @@ extension RDEPUBReaderController {
} }
} }
// MARK: - RDReaderView 数据源与代理
extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate { extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
public func pageCountOfReaderView(readerView: RDReaderView) -> Int { public func pageCountOfReaderView(readerView: RDReaderView) -> Int {
textBook?.pages.count ?? activePages.count textBook?.pages.count ?? activePages.count
@@ -1724,6 +1825,8 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
} }
} }
// MARK: - Web 内容视图代理(EPUB 固定布局/Web 渲染路径)
extension RDEPUBReaderController: RDEPUBWebContentViewDelegate { extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) { func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) {
guard readerView.currentPage >= 0, guard readerView.currentPage >= 0,
@@ -1780,6 +1883,8 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
} }
} }
// MARK: - 文本内容视图代理(Native Text 渲染路径)
extension RDEPUBReaderController: RDEPUBTextContentViewDelegate { extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) { func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) {
guard let selection else { guard let selection else {
@@ -1902,6 +2007,8 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
} }
} }
// MARK: - 手势识别器代理(用于 NavigationController 返回手势)
extension RDEPUBReaderController: UIGestureRecognizerDelegate { extension RDEPUBReaderController: UIGestureRecognizerDelegate {
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
true true
@@ -1,20 +1,86 @@
import UIKit import UIKit
// MARK: - 阅读器委托协议
/// EPUB 阅读器的委托协议
/// 用于向调用方通知阅读器的各种状态变更事件
/// 所有方法均提供默认空实现,调用方可选择性实现感兴趣的方法
public protocol RDEPUBReaderDelegate: AnyObject { public protocol RDEPUBReaderDelegate: AnyObject {
/// 阅读器成功打开书籍时调用
/// - Parameters:
/// - reader: 阅读器控制器
/// - publication: 解析后的出版物对象
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication)
/// 阅读位置更新时调用(翻页、跳转等)
/// - Parameters:
/// - reader: 阅读器控制器
/// - location: 新的阅读位置
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation)
/// 阅读器翻到书籍末尾时调用
/// - Parameter reader: 阅读器控制器
func epubReaderDidReachEnd(_ reader: UIViewController) func epubReaderDidReachEnd(_ reader: UIViewController)
/// 用户选中文本发生变化时调用
/// - Parameters:
/// - reader: 阅读器控制器
/// - selection: 当前选中文本,nil 表示取消选中
func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?) func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?)
/// 高亮标注列表更新时调用
/// - Parameters:
/// - reader: 阅读器控制器
/// - highlights: 当前所有高亮
func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight]) func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight])
/// 书签列表更新时调用
/// - Parameters:
/// - reader: 阅读器控制器
/// - bookmarks: 当前所有书签
func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark]) func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark])
/// 搜索结果更新时调用
/// - Parameters:
/// - reader: 阅读器控制器
/// - result: 搜索结果摘要,nil 表示无搜索
func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?) func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?)
/// 当前搜索匹配项变化时调用
/// - Parameters:
/// - reader: 阅读器控制器
/// - match: 当前匹配项,nil 表示无匹配
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?) func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?)
/// 当前所在目录项更新时调用
/// - Parameters:
/// - reader: 阅读器控制器
/// - item: 当前目录项
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?) func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?)
/// 用户点击外部链接时调用
/// - Parameters:
/// - reader: 阅读器控制器
/// - url: 外部链接 URL
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL)
/// 阅读器发生错误时调用
/// - Parameters:
/// - reader: 阅读器控制器
/// - error: 错误对象
func epubReader(_ reader: UIViewController, didFailWithError error: Error) func epubReader(_ reader: UIViewController, didFailWithError error: Error)
/// 允许调用方自定义顶部工具栏
/// 在 viewDidLoad 时调用,可在工具栏上添加自定义按钮
/// - Parameters:
/// - reader: 阅读器控制器
/// - topToolView: 顶部工具栏视图
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView) func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView)
} }
// MARK: - 默认空实现
/// 所有委托方法的默认空实现,调用方可只实现需要的方法
public extension RDEPUBReaderDelegate { public extension RDEPUBReaderDelegate {
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {} func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {}
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {} func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {}
@@ -1,13 +1,24 @@
import UIKit import UIKit
// MARK: - 高亮管理面板
/// EPUB 阅读器的高亮标注管理控制器
/// 以列表形式展示所有高亮和批注,支持筛选(全部/批注/高亮)、跳转到位置、编辑批注、删除
final class RDEPUBReaderHighlightsViewController: UITableViewController { final class RDEPUBReaderHighlightsViewController: UITableViewController {
/// 用户选择跳转到某个高亮位置时的回调
var onSelectHighlight: ((RDEPUBHighlight) -> Void)? var onSelectHighlight: ((RDEPUBHighlight) -> Void)?
/// 用户编辑高亮批注后的回调
var onUpdateHighlight: ((RDEPUBHighlight) -> Void)? var onUpdateHighlight: ((RDEPUBHighlight) -> Void)?
/// 用户删除高亮时的回调
var onDeleteHighlight: ((RDEPUBHighlight) -> Void)? var onDeleteHighlight: ((RDEPUBHighlight) -> Void)?
/// 高亮列表(按创建时间降序排列)
private var highlights: [RDEPUBHighlight] private var highlights: [RDEPUBHighlight]
/// 当前主题配置
private let theme: RDEPUBReaderTheme private let theme: RDEPUBReaderTheme
/// 提供章节标题的回调,用于显示高亮所在的章节名
private let sectionTitleProvider: (RDEPUBHighlight) -> String? private let sectionTitleProvider: (RDEPUBHighlight) -> String?
/// 筛选控件:全部、批注、高亮
private let filterControl = UISegmentedControl(items: ["全部", "批注", "高亮"]) private let filterControl = UISegmentedControl(items: ["全部", "批注", "高亮"])
private var filteredHighlights: [RDEPUBHighlight] { private var filteredHighlights: [RDEPUBHighlight] {
@@ -205,8 +216,14 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
} }
} }
// MARK: - 书签管理面板
/// EPUB 阅读器的书签管理控制器
/// 以列表形式展示所有书签,支持跳转到书签位置和删除书签
final class RDEPUBReaderBookmarksViewController: UITableViewController { final class RDEPUBReaderBookmarksViewController: UITableViewController {
/// 用户选择跳转到某个书签位置时的回调
var onSelectBookmark: ((RDEPUBBookmark) -> Void)? var onSelectBookmark: ((RDEPUBBookmark) -> Void)?
/// 用户删除书签时的回调
var onDeleteBookmark: ((RDEPUBBookmark) -> Void)? var onDeleteBookmark: ((RDEPUBBookmark) -> Void)?
private var bookmarks: [RDEPUBBookmark] private var bookmarks: [RDEPUBBookmark]
@@ -1,16 +1,44 @@
import Foundation import Foundation
// MARK: - 持久化协议
/// 阅读器持久化协议
/// 定义了阅读位置、书签、高亮和用户设置的存储接口
/// 调用方可实现此协议以对接自定义存储(如数据库、云端同步等)
public protocol RDEPUBReaderPersistence: AnyObject { public protocol RDEPUBReaderPersistence: AnyObject {
/// 加载指定书籍的上次阅读位置
/// - Parameter bookIdentifier: 书籍唯一标识符
/// - Returns: 保存的阅读位置,nil 表示首次打开
func loadLocation(for bookIdentifier: String) -> RDEPUBLocation? func loadLocation(for bookIdentifier: String) -> RDEPUBLocation?
/// 保存指定书籍的当前阅读位置
/// - Parameters:
/// - location: 阅读位置
/// - bookIdentifier: 书籍唯一标识符
func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String) func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String)
/// 加载指定书籍的所有书签
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark]
/// 保存指定书籍的所有书签
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String)
/// 加载指定书籍的所有高亮标注
func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight] func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight]
/// 保存指定书籍的所有高亮标注
func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String) func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String)
/// 加载全局阅读器用户设置(字号、行距、主题等)
func loadReaderSettings() -> RDEPUBReaderSettings? func loadReaderSettings() -> RDEPUBReaderSettings?
/// 保存全局阅读器用户设置
func saveReaderSettings(_ settings: RDEPUBReaderSettings) func saveReaderSettings(_ settings: RDEPUBReaderSettings)
} }
// MARK: - 默认实现
/// 协议的默认空实现,书签/高亮/设置为可选功能
public extension RDEPUBReaderPersistence { public extension RDEPUBReaderPersistence {
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] { func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
_ = bookIdentifier _ = bookIdentifier
@@ -31,13 +59,30 @@ public extension RDEPUBReaderPersistence {
} }
} }
// MARK: - UserDefaults 持久化实现
/// 基于 UserDefaults 的持久化实现
/// 数据以 JSON 格式存储,按书籍标识符分隔存储位置
/// 适合轻量级场景,大量数据建议改用 SQLite 或文件系统
public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence { public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
/// UserDefaults 实例
private let defaults: UserDefaults private let defaults: UserDefaults
/// 阅读位置的 key 前缀
private let locationPrefix: String private let locationPrefix: String
/// 书签列表的 key 前缀
private let bookmarksPrefix: String private let bookmarksPrefix: String
/// 高亮列表的 key 前缀
private let highlightsPrefix: String private let highlightsPrefix: String
/// 用户设置的 key
private let settingsKey: String private let settingsKey: String
/// 初始化 UserDefaults 持久化策略
/// - Parameters:
/// - defaults: UserDefaults 实例,默认 .standard
/// - locationPrefix: 阅读位置 key 前缀
/// - bookmarksPrefix: 书签 key 前缀
/// - highlightsPrefix: 高亮 key 前缀
/// - settingsKey: 用户设置 key
public init( public init(
defaults: UserDefaults = .standard, defaults: UserDefaults = .standard,
locationPrefix: String = "ssreader.epub.location.", locationPrefix: String = "ssreader.epub.location.",
@@ -1,11 +1,20 @@
import UIKit import UIKit
// MARK: - 翻页显示模式
/// 阅读器翻页显示模式枚举
/// 用于持久化存储用户的翻页偏好设置,与 RDReaderView.DisplayType 相互转换
public enum RDEPUBReaderDisplayMode: String, Codable, Equatable { public enum RDEPUBReaderDisplayMode: String, Codable, Equatable {
/// 仿真翻页模式(纸张翻转效果)
case pageCurl case pageCurl
/// 水平滚动模式
case horizontalScroll case horizontalScroll
/// 垂直滚动模式
case verticalScroll case verticalScroll
/// 水平覆盖式滚动模式(类似电子杂志翻页效果)
case horizontalCoverScroll case horizontalCoverScroll
/// 从底层 DisplayType 转换为 UI 层的 DisplayMode
init(displayType: RDReaderView.DisplayType) { init(displayType: RDReaderView.DisplayType) {
switch displayType { switch displayType {
case .pageCurl: case .pageCurl:
@@ -17,6 +26,8 @@ public enum RDEPUBReaderDisplayMode: String, Codable, Equatable {
} }
} }
/// 转换为底层 RDReaderView 所需的 DisplayType
/// 注意:horizontalCoverScroll 会被映射为 horizontalScroll
var displayType: RDReaderView.DisplayType { var displayType: RDReaderView.DisplayType {
switch self { switch self {
case .pageCurl: case .pageCurl:
@@ -29,14 +40,25 @@ public enum RDEPUBReaderDisplayMode: String, Codable, Equatable {
} }
} }
// MARK: - 主题预设
/// 阅读器主题预设枚举
/// 将 RDEPUBReaderTheme 映射为可序列化的枚举值,用于持久化存储和设置面板展示
public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable { public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable {
/// 浅色主题
case light case light
/// 黄色护眼主题
case yellow case yellow
/// 绿色护眼主题
case green case green
/// 粉色主题
case pink case pink
/// 蓝色主题
case blue case blue
/// 深色/夜间主题
case dark case dark
/// 将预设枚举转换为主题配置实例
public var theme: RDEPUBReaderTheme { public var theme: RDEPUBReaderTheme {
switch self { switch self {
case .light: case .light:
@@ -54,6 +76,8 @@ public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable {
} }
} }
/// 从主题配置实例反向查找对应的预设枚举
/// 自定义主题(非内置预设)会返回 nil
public init?(theme: RDEPUBReaderTheme) { public init?(theme: RDEPUBReaderTheme) {
switch theme { switch theme {
case .light: case .light:
@@ -74,13 +98,26 @@ public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable {
} }
} }
// MARK: - 阅读器用户设置
/// 阅读器的可持久化用户设置
/// 用于保存用户在设置面板中调整的阅读偏好(字号、行距、翻页模式、主题等)
/// 所有属性均为可选类型,只覆盖用户实际修改过的配置项
public struct RDEPUBReaderSettings: Codable, Equatable { public struct RDEPUBReaderSettings: Codable, Equatable {
// MARK: 可调参数
/// 屏幕亮度(0.0 ~ 1.0),nil 表示使用系统亮度
public var brightness: CGFloat? public var brightness: CGFloat?
/// 用户选择的正文字号(pt),nil 表示使用默认值
public var fontSize: CGFloat? public var fontSize: CGFloat?
/// 用户选择的行距倍数,nil 表示使用默认值
public var lineHeightMultiple: CGFloat? public var lineHeightMultiple: CGFloat?
/// 用户选择的翻页模式,nil 表示使用默认值
public var displayMode: RDEPUBReaderDisplayMode? public var displayMode: RDEPUBReaderDisplayMode?
/// 用户选择的主题预设,nil 表示使用默认值
public var themePreset: RDEPUBReaderThemePreset? public var themePreset: RDEPUBReaderThemePreset?
/// 初始化用户设置,所有参数默认为 nil
public init( public init(
brightness: CGFloat? = nil, brightness: CGFloat? = nil,
fontSize: CGFloat? = nil, fontSize: CGFloat? = nil,
@@ -95,6 +132,10 @@ public struct RDEPUBReaderSettings: Codable, Equatable {
self.themePreset = themePreset self.themePreset = themePreset
} }
/// 将用户设置覆盖到基础配置上,得到最终生效的阅读器配置
/// nil 属性不会覆盖基础配置的对应值
/// - Parameter configuration: 基础配置(通常来自 RDEPUBReaderConfiguration.default)
/// - Returns: 合并后的完整配置
public func applying(to configuration: RDEPUBReaderConfiguration) -> RDEPUBReaderConfiguration { public func applying(to configuration: RDEPUBReaderConfiguration) -> RDEPUBReaderConfiguration {
var resolvedConfiguration = configuration var resolvedConfiguration = configuration
@@ -114,6 +155,12 @@ public struct RDEPUBReaderSettings: Codable, Equatable {
return resolvedConfiguration return resolvedConfiguration
} }
/// 从当前配置和亮度值捕获一份完整的用户设置快照
/// 亮度值会被限制在 0.0 ~ 1.0 范围内
/// - Parameters:
/// - configuration: 当前生效的阅读器配置
/// - brightness: 当前屏幕亮度
/// - Returns: 完整的用户设置实例
public static func capture( public static func capture(
configuration: RDEPUBReaderConfiguration, configuration: RDEPUBReaderConfiguration,
brightness: CGFloat brightness: CGFloat
@@ -1,12 +1,27 @@
import UIKit import UIKit
// MARK: - 设置面板
/// EPUB 阅读器的设置面板控制器
/// 提供亮度、字号、行距、翻页方式、主题的调节功能
/// 支持 iOS 13+ SF Symbols 和低版本文字回退
final class RDEPUBReaderSettingsViewController: UIViewController { final class RDEPUBReaderSettingsViewController: UIViewController {
// MARK: 回调闭包
/// 亮度变化回调(0.0 ~ 1.0)
var onBrightnessChange: ((CGFloat) -> Void)? var onBrightnessChange: ((CGFloat) -> Void)?
/// 字号变化回调(pt 值)
var onFontSizeChange: ((CGFloat) -> Void)? var onFontSizeChange: ((CGFloat) -> Void)?
/// 行距倍数变化回调
var onLineHeightChange: ((CGFloat) -> Void)? var onLineHeightChange: ((CGFloat) -> Void)?
/// 翻页模式变化回调
var onDisplayTypeChange: ((RDReaderView.DisplayType) -> Void)? var onDisplayTypeChange: ((RDReaderView.DisplayType) -> Void)?
/// 主题变化回调
var onThemeChange: ((RDEPUBReaderTheme) -> Void)? var onThemeChange: ((RDEPUBReaderTheme) -> Void)?
// MARK: 主题预设枚举
/// 设置面板中的主题预设枚举,用于按钮显示和主题切换
private enum ThemePreset: Int, CaseIterable { private enum ThemePreset: Int, CaseIterable {
case light case light
case yellow case yellow
@@ -60,7 +75,9 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
}() }()
private var themeButtons: [UIButton] = [] private var themeButtons: [UIButton] = []
/// 行距选项对应的倍数值(紧凑 1.3、标准 1.6、宽松 1.9)
private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9] private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9]
/// 当前配置的本地副本,修改后通过回调通知控制器
private var currentConfiguration: RDEPUBReaderConfiguration private var currentConfiguration: RDEPUBReaderConfiguration
init(configuration: RDEPUBReaderConfiguration, brightness: CGFloat) { init(configuration: RDEPUBReaderConfiguration, brightness: CGFloat) {
@@ -1,9 +1,17 @@
import Foundation import Foundation
// MARK: - 目录条目
/// 展平后的目录条目结构体
/// 用于目录面板展示,将树形目录展平为一维列表,通过 depth 字段保留层级关系
public struct RDEPUBReaderTableOfContentsItem: Equatable { public struct RDEPUBReaderTableOfContentsItem: Equatable {
/// 章节标题
public var title: String public var title: String
/// 章节的 href(相对于 EPUB 容器根目录)
public var href: String public var href: String
/// 目录层级深度(0 为顶级章节),用于列表缩进显示
public var depth: Int public var depth: Int
/// 该章节所在页码(从 1 开始),nil 表示无法确定
public var pageNumber: Int? public var pageNumber: Int?
public init(title: String, href: String, depth: Int, pageNumber: Int? = nil) { public init(title: String, href: String, depth: Int, pageNumber: Int? = nil) {
@@ -1,13 +1,37 @@
import UIKit import UIKit
// MARK: - 阅读器主题
/// EPUB 阅读器主题配置结构体
/// 定义阅读器内容区域和工具栏的颜色方案
/// 内置浅色、深色、黄色、绿色、粉色、蓝色六套预设主题
public struct RDEPUBReaderTheme: Equatable { public struct RDEPUBReaderTheme: Equatable {
// MARK: 内容区颜色
/// 内容区域的背景色
public var contentBackgroundColor: UIColor public var contentBackgroundColor: UIColor
/// 内容区域的正文文字颜色
public var contentTextColor: UIColor public var contentTextColor: UIColor
// MARK: 工具栏颜色
/// 工具栏背景色
public var toolBackgroundColor: UIColor public var toolBackgroundColor: UIColor
/// 工具栏控件文字/图标颜色
public var toolControlTextColor: UIColor public var toolControlTextColor: UIColor
/// 工具栏控件未选中状态的边框颜色
public var toolControlBorderUnselectColor: UIColor public var toolControlBorderUnselectColor: UIColor
/// 工具栏中分隔线的颜色
public var toolLineColor: UIColor public var toolLineColor: UIColor
/// 初始化主题配置
/// - Parameters:
/// - contentBackgroundColor: 内容区背景色
/// - contentTextColor: 内容区文字颜色
/// - toolBackgroundColor: 工具栏背景色
/// - toolControlTextColor: 工具栏控件颜色
/// - toolControlBorderUnselectColor: 控件未选中边框色
/// - toolLineColor: 分隔线颜色
public init( public init(
contentBackgroundColor: UIColor, contentBackgroundColor: UIColor,
contentTextColor: UIColor, contentTextColor: UIColor,
@@ -24,6 +48,9 @@ public struct RDEPUBReaderTheme: Equatable {
self.toolLineColor = toolLineColor self.toolLineColor = toolLineColor
} }
// MARK: 内置预设主题
/// 浅色主题:白底黑字,适合日间阅读
public static let light = RDEPUBReaderTheme( public static let light = RDEPUBReaderTheme(
contentBackgroundColor: .white, contentBackgroundColor: .white,
contentTextColor: .black, contentTextColor: .black,
@@ -33,6 +60,7 @@ public struct RDEPUBReaderTheme: Equatable {
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5) toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
) )
/// 深色主题:黑底白字,适合夜间阅读
public static let dark = RDEPUBReaderTheme( public static let dark = RDEPUBReaderTheme(
contentBackgroundColor: .black, contentBackgroundColor: .black,
contentTextColor: .white, contentTextColor: .white,
@@ -42,6 +70,7 @@ public struct RDEPUBReaderTheme: Equatable {
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5) toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
) )
/// 黄色护眼主题:暖色调背景,减少蓝光刺激
public static let yellow = RDEPUBReaderTheme( public static let yellow = RDEPUBReaderTheme(
contentBackgroundColor: UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1), contentBackgroundColor: UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1),
contentTextColor: .black, contentTextColor: .black,
@@ -51,6 +80,7 @@ public struct RDEPUBReaderTheme: Equatable {
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5) toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
) )
/// 绿色护眼主题:模拟纸质书的柔和绿色背景
public static let green = RDEPUBReaderTheme( public static let green = RDEPUBReaderTheme(
contentBackgroundColor: UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1), contentBackgroundColor: UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1),
contentTextColor: .black, contentTextColor: .black,
@@ -60,6 +90,7 @@ public struct RDEPUBReaderTheme: Equatable {
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5) toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
) )
/// 粉色主题:柔和粉色背景
public static let pink = RDEPUBReaderTheme( public static let pink = RDEPUBReaderTheme(
contentBackgroundColor: UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1), contentBackgroundColor: UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1),
contentTextColor: .black, contentTextColor: .black,
@@ -69,6 +100,7 @@ public struct RDEPUBReaderTheme: Equatable {
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5) toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
) )
/// 蓝色主题:冷色调蓝色背景
public static let blue = RDEPUBReaderTheme( public static let blue = RDEPUBReaderTheme(
contentBackgroundColor: UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1), contentBackgroundColor: UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1),
contentTextColor: .black, contentTextColor: .black,
@@ -79,11 +111,15 @@ public struct RDEPUBReaderTheme: Equatable {
) )
} }
// MARK: - CSS 颜色转换
extension RDEPUBReaderTheme { extension RDEPUBReaderTheme {
/// 将内容区背景色转换为 CSS 颜色字符串,注入 EPUB 的 HTML 中
var themeBackgroundColorCSS: String { var themeBackgroundColorCSS: String {
contentBackgroundColor.ss_cssString contentBackgroundColor.ss_cssString
} }
/// 将内容区文字颜色转换为 CSS 颜色字符串,注入 EPUB 的 HTML 中
var themeTextColorCSS: String { var themeTextColorCSS: String {
contentTextColor.ss_cssString contentTextColor.ss_cssString
} }
@@ -1,7 +1,13 @@
import UIKit import UIKit
// MARK: - 工具栏基类
/// 阅读器工具栏的基类
/// 提供分隔线和主题适配的通用逻辑,子类只需关注具体功能按钮
open class RDEPUBReaderToolView: UIView { open class RDEPUBReaderToolView: UIView {
/// 分隔线视图
private let lineView = UIView() private let lineView = UIView()
/// 分隔线高度
private let lineHeight: CGFloat = 0.5 private let lineHeight: CGFloat = 0.5
override init(frame: CGRect) { override init(frame: CGRect) {
@@ -19,15 +25,24 @@ open class RDEPUBReaderToolView: UIView {
lineView.frame = lineFrame(in: bounds) lineView.frame = lineFrame(in: bounds)
} }
/// 应用主题配色到工具栏
/// - Parameter theme: 阅读器主题配置
open func apply(theme: RDEPUBReaderTheme) { open func apply(theme: RDEPUBReaderTheme) {
backgroundColor = .white backgroundColor = .white
lineView.backgroundColor = UIColor.lightGray.withAlphaComponent(0.5) lineView.backgroundColor = UIColor.lightGray.withAlphaComponent(0.5)
} }
/// 计算分隔线的位置和尺寸,子类可重写以调整分隔线位置(如顶部或底部)
/// - Parameter bounds: 工具栏的 bounds
/// - Returns: 分隔线的 frame
open func lineFrame(in bounds: CGRect) -> CGRect { open func lineFrame(in bounds: CGRect) -> CGRect {
CGRect(x: 0, y: 0, width: bounds.width, height: lineHeight) CGRect(x: 0, y: 0, width: bounds.width, height: lineHeight)
} }
} }
// MARK: - 工具栏按钮
/// 阅读器工具栏专用按钮
/// 继承自 UIButton,用于工具栏中的功能按钮
final class RDEPUBReaderTintButton: UIButton { final class RDEPUBReaderTintButton: UIButton {
} }
@@ -1,7 +1,14 @@
import UIKit import UIKit
// MARK: - 顶部工具栏
/// 阅读器顶部工具栏
/// 显示书名、返回按钮和书签按钮
/// 位于阅读内容上方,支持 iOS 13+ SF Symbols 和低版本文字回退
public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView { public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
/// 返回按钮点击回调
var onBack: (() -> Void)? var onBack: (() -> Void)?
/// 书签按钮点击回调
var onToggleBookmark: (() -> Void)? var onToggleBookmark: (() -> Void)?
private let backButton = RDEPUBReaderTintButton(type: .system) private let backButton = RDEPUBReaderTintButton(type: .system)
@@ -1,21 +1,41 @@
import UIKit import UIKit
// MARK: - 覆盖层装饰类型
/// 覆盖层装饰的数据结构
/// 表示一个需要绘制在文本上方或下方的视觉装饰(高亮、搜索结果、选区等)
struct RDEPUBTextOverlayDecoration { struct RDEPUBTextOverlayDecoration {
/// 装饰类型枚举
enum Kind: String { enum Kind: String {
/// 文本选区(蓝色半透明)
case selection case selection
/// 用户高亮标注
case highlight case highlight
/// 用户划线标注
case underline case underline
/// 搜索匹配项(普通)
case search case search
/// 搜索匹配项(当前高亮)
case activeSearch case activeSearch
/// 定位指示(跳转到位置时的动画目标)
case locate case locate
} }
/// 装饰类型
var kind: Kind var kind: Kind
/// 装饰对应的绝对文本范围
var absoluteRange: NSRange var absoluteRange: NSRange
/// 装饰的绘制矩形数组(每行一个矩形)
var rects: [CGRect] var rects: [CGRect]
/// 装饰的颜色
var color: UIColor var color: UIColor
} }
// MARK: - 选择覆盖层视图
/// 文本选择和装饰的覆盖层绘制视图
/// 位于文本内容上方,负责绘制选区、高亮、搜索结果等视觉效果
/// 使用 Core Graphics 直接绘制,支持填充矩形和下划线两种绘制模式
final class RDEPUBSelectionOverlayView: UIView { final class RDEPUBSelectionOverlayView: UIView {
private(set) var page: RDEPUBTextPage? private(set) var page: RDEPUBTextPage?
private var snapshot: RDEPUBPageLayoutSnapshot? private var snapshot: RDEPUBPageLayoutSnapshot?
@@ -5,12 +5,22 @@ import Foundation
import DTCoreText import DTCoreText
#endif #endif
// MARK: - 文本内容视图代理
/// 文本内容视图的代理协议
/// 通知控制器文本选择变化和选择菜单操作
protocol RDEPUBTextContentViewDelegate: AnyObject { protocol RDEPUBTextContentViewDelegate: AnyObject {
/// 用户选中文本发生变化时调用
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?)
/// 用户从选择菜单中触发操作(拷贝/高亮/批注)
func textContentView(_ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) func textContentView(_ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
} }
// MARK: - 可选中文本视图
/// 自定义 UITextView,替换系统默认的 UIMenuItem 为自定义操作(拷贝、高亮、批注)
final class RDEPUBSelectableTextView: UITextView { final class RDEPUBSelectableTextView: UITextView {
/// 选择菜单操作回调
var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)? var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)?
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
@@ -37,6 +47,10 @@ final class RDEPUBSelectableTextView: UITextView {
} }
} }
// MARK: - DTCoreText 直接绘制视图
/// 基于 DTCoreText 的 Core Text 直接绘制视图
/// 将 DTCoreText 的排版结果直接绘制到 UIView 上,跳过 UITextView 的间接渲染
#if canImport(DTCoreText) #if canImport(DTCoreText)
final class RDEPUBDirectCoreTextPageView: UIView { final class RDEPUBDirectCoreTextPageView: UIView {
var layoutFrame: DTCoreTextLayoutFrame? { var layoutFrame: DTCoreTextLayoutFrame? {
@@ -73,6 +87,14 @@ final class RDEPUBDirectCoreTextPageView: UIView {
} }
#endif #endif
// MARK: - 文本内容视图
/// EPUB 流式排版的文本内容视图
/// 支持两种渲染路径:
/// 1. DTCoreText 路径:直接绘制到 CoreText 视图,支持精确的排版控制
/// 2. 回退路径:通过 UITextView 的 attributedText 渲染
///
/// 内置能力:高亮覆盖、搜索高亮、文本选择、长按菜单、封面图显示
final class RDEPUBTextContentView: UIView { final class RDEPUBTextContentView: UIView {
private var contentInsets: UIEdgeInsets = .zero private var contentInsets: UIEdgeInsets = .zero
private var currentPage: RDEPUBTextPage? private var currentPage: RDEPUBTextPage?
@@ -423,12 +445,12 @@ final class RDEPUBTextContentView: UIView {
case .highlight: case .highlight:
content.addAttribute( content.addAttribute(
.backgroundColor, .backgroundColor,
value: UIColor(hexString: highlight.color, alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45), value: UIColor(rdHexString: highlight.color, alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45),
range: relativeRange range: relativeRange
) )
case .underline: case .underline:
content.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: relativeRange) content.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: relativeRange)
if let color = UIColor(hexString: highlight.color, alpha: 1) { if let color = UIColor(rdHexString: highlight.color, alpha: 1) {
content.addAttribute(.underlineColor, value: color, range: relativeRange) content.addAttribute(.underlineColor, value: color, range: relativeRange)
} }
} }
@@ -523,7 +545,7 @@ final class RDEPUBTextContentView: UIView {
let rects = interactionController.selectionRects(for: absoluteRange) let rects = interactionController.selectionRects(for: absoluteRange)
guard !rects.isEmpty else { continue } guard !rects.isEmpty else { continue }
let color = UIColor(hexString: highlight.color, alpha: 0.45) let color = UIColor(rdHexString: highlight.color, alpha: 0.45)
?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45)
let decoration = RDEPUBTextOverlayDecoration( let decoration = RDEPUBTextOverlayDecoration(
kind: highlight.style == .underline ? .underline : .highlight, kind: highlight.style == .underline ? .underline : .highlight,
@@ -690,17 +712,3 @@ extension RDEPUBTextContentView: UITextViewDelegate {
delegate?.textContentView(self, didChangeSelection: selection) 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
)
}
}
@@ -1,18 +1,34 @@
import UIKit import UIKit
// MARK: - Web 内容视图代理
/// Web 内容视图的代理协议
/// 将 WebView 中的事件(位置更新、文本选择、链接点击等)转发给控制器
protocol RDEPUBWebContentViewDelegate: AnyObject { protocol RDEPUBWebContentViewDelegate: AnyObject {
/// WebView 中阅读位置更新时调用
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
/// WebView 中文本选择变化时调用
func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
/// 用户从选择菜单中触发操作
func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
/// 用户点击内部链接(章节跳转)
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
/// 用户点击外部链接
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL)
/// WebView 中 JavaScript 执行出错
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String)
} }
// MARK: - Web 内容视图
/// EPUB 固定布局和 Web 渲染路径的内容视图
/// 包装 RDEPUBWebView,提供页码标签和加载/释放资源的控制
/// 适用于固定布局 EPUB 和不支持 Native Text 的场景
final class RDEPUBWebContentView: UIView { final class RDEPUBWebContentView: UIView {
weak var delegate: RDEPUBWebContentViewDelegate? weak var delegate: RDEPUBWebContentViewDelegate?
private let epubWebView = RDEPUBWebView() private let epubWebView = RDEPUBWebView()
private let decorationOverlayView = RDEPUBWebDecorationOverlayView()
private let pageNumberLabel: UILabel = { private let pageNumberLabel: UILabel = {
let label = UILabel() let label = UILabel()
label.font = UIFont.systemFont(ofSize: 13) label.font = UIFont.systemFont(ofSize: 13)
@@ -25,8 +41,12 @@ final class RDEPUBWebContentView: UIView {
layer.masksToBounds = true layer.masksToBounds = true
addSubview(epubWebView) addSubview(epubWebView)
addSubview(decorationOverlayView)
addSubview(pageNumberLabel) addSubview(pageNumberLabel)
epubWebView.delegate = self epubWebView.delegate = self
epubWebView.onDecorationsResolved = { [weak self] decorations in
self?.decorationOverlayView.applyDecorations(decorations)
}
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
@@ -36,6 +56,7 @@ final class RDEPUBWebContentView: UIView {
override func layoutSubviews() { override func layoutSubviews() {
super.layoutSubviews() super.layoutSubviews()
epubWebView.frame = bounds epubWebView.frame = bounds
decorationOverlayView.frame = bounds
let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20)) let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
pageNumberLabel.frame = CGRect( pageNumberLabel.frame = CGRect(
@@ -56,13 +77,18 @@ final class RDEPUBWebContentView: UIView {
backgroundColor = theme.contentBackgroundColor backgroundColor = theme.contentBackgroundColor
pageNumberLabel.textColor = theme.contentTextColor pageNumberLabel.textColor = theme.contentTextColor
pageNumberLabel.text = "\(pageNumber) / \(totalPages)" pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
decorationOverlayView.applyDecorations([])
epubWebView.load(publication: publication, request: request) epubWebView.load(publication: publication, request: request)
setNeedsLayout() setNeedsLayout()
} }
func releaseResources() { func releaseResources() {
decorationOverlayView.applyDecorations([])
epubWebView.reset() epubWebView.reset()
epubWebView.delegate = self epubWebView.delegate = self
epubWebView.onDecorationsResolved = { [weak self] decorations in
self?.decorationOverlayView.applyDecorations(decorations)
}
} }
} }
@@ -90,6 +116,5 @@ extension RDEPUBWebContentView: RDEPUBWebViewDelegate {
func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String) { func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String) {
delegate?.epubWebContentView(self, didLogJavaScriptError: message) delegate?.epubWebContentView(self, didLogJavaScriptError: message)
} }
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView) {} func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView) {}
} }
@@ -0,0 +1,50 @@
import UIKit
/// WebView 路径的原生装饰覆盖层。
/// 对标 WXRead 的页面级装饰绘制思路:JS 只负责提供 rect,实际视觉绘制由原生 CGContext 完成。
final class RDEPUBWebDecorationOverlayView: UIView {
private var decorations: [RDEPUBTextOverlayDecoration] = []
private let verticalAdjustment: CGFloat = -1
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
isUserInteractionEnabled = false
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func applyDecorations(_ decorations: [RDEPUBTextOverlayDecoration]) {
self.decorations = decorations.filter { !$0.rects.isEmpty }
setNeedsDisplay()
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
for decoration in decorations {
switch decoration.kind {
case .underline:
context.setStrokeColor(decoration.color.cgColor)
context.setLineWidth(2)
for underlineRect in decoration.rects {
let y = underlineRect.maxY - 1
context.move(to: CGPoint(x: underlineRect.minX, y: y))
context.addLine(to: CGPoint(x: underlineRect.maxX, y: y))
context.strokePath()
}
default:
context.setFillColor(decoration.color.cgColor)
for selectionRect in decoration.rects {
let adjustedRect = selectionRect.offsetBy(dx: 0, dy: verticalAdjustment)
let path = UIBezierPath(roundedRect: adjustedRect.insetBy(dx: -1, dy: -1), cornerRadius: 4)
context.addPath(path.cgPath)
context.fillPath()
}
}
}
}
}
@@ -1,10 +1,41 @@
//
// RDURLReaderController.swift
// RDReaderDemo
//
// 文件职责:URL 阅读器入口控制器,根据文件类型自动选择阅读器。
// 该文件是 ReadViewSDK 的最简使用入口:
// - .epub 文件:直接创建 RDEPUBReaderController
// - 其他文本文件:通过 RDPlainTextBookBuilder 分页后创建 RDEPUBReaderController
// - 分页失败时回退到纯文本 UITextView 展示
//
// 架构位置:RDReaderView 四层架构中第四层(读者 UI)的入口控制器。
//
import UIKit import UIKit
/// URL 阅读器入口控制器
/// 接收一个书籍文件 URL,根据文件类型自动创建对应的阅读器控制器:
/// - EPUB 文件:直接使用 RDEPUBReaderController
/// - 纯文本文件:先通过 RDPlainTextBookBuilder 分页,再使用 RDEPUBReaderController
/// - 分页失败:回退到纯文本 UITextView 展示
///
/// 使用方式:
/// ```swift
/// let controller = RDURLReaderController(bookURL: fileURL)
/// navigationController?.pushViewController(controller, animated: true)
/// ```
public final class RDURLReaderController: UIViewController { public final class RDURLReaderController: UIViewController {
/// 书籍文件 URL
private let bookURL: URL private let bookURL: URL
/// EPUB 阅读器配置(字体、行距、主题等)
private let epubConfiguration: RDEPUBReaderConfiguration private let epubConfiguration: RDEPUBReaderConfiguration
/// 内嵌的阅读器控制器
private var embeddedController: UIViewController? private var embeddedController: UIViewController?
/// 初始化方法
/// - Parameters:
/// - bookURL: 书籍文件的本地 URL
/// - epubConfiguration: EPUB 阅读器配置,默认使用标准配置
public init( public init(
bookURL: URL, bookURL: URL,
epubConfiguration: RDEPUBReaderConfiguration = RDEPUBReaderConfiguration() epubConfiguration: RDEPUBReaderConfiguration = RDEPUBReaderConfiguration()
@@ -19,6 +50,7 @@ public final class RDURLReaderController: UIViewController {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
/// 视图加载完成后,设置背景色、标题并嵌入阅读器控制器
public override func viewDidLoad() { public override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = .systemBackground view.backgroundColor = .systemBackground
@@ -26,11 +58,18 @@ public final class RDURLReaderController: UIViewController {
embedReaderController() embedReaderController()
} }
/// 切换阅读器的翻页模式(Demo 用)
/// - Parameter displayType: 目标翻页模式
public func applyDemoDisplayType(_ displayType: RDReaderView.DisplayType) { public func applyDemoDisplayType(_ displayType: RDReaderView.DisplayType) {
readerController?.configuration.displayType = displayType readerController?.configuration.displayType = displayType
logDemoState(prefix: "display=\(displayType.demoArgumentValue)") logDemoState(prefix: "display=\(displayType.demoArgumentValue)")
} }
/// 跳转到指定页码(Demo 用)
/// - Parameters:
/// - pageNumber: 目标页码
/// - animated: 是否动画过渡
/// - Returns: 是否成功跳转
@discardableResult @discardableResult
public func goToDemoPage(_ pageNumber: Int, animated: Bool = false) -> Bool { public func goToDemoPage(_ pageNumber: Int, animated: Bool = false) -> Bool {
let moved = readerController?.go(toPageNumber: pageNumber, animated: animated) ?? false let moved = readerController?.go(toPageNumber: pageNumber, animated: animated) ?? false
@@ -40,6 +79,12 @@ public final class RDURLReaderController: UIViewController {
return moved return moved
} }
/// 执行翻页模式切换序列(Demo 用)
/// 按照指定顺序和延迟依次切换翻页模式
/// - Parameters:
/// - displayTypes: 翻页模式数组
/// - initialPageNumber: 可选的初始跳转页码
/// - stepDelay: 每步之间的延迟时间(秒)
public func runDemoDisplaySequence( public func runDemoDisplaySequence(
_ displayTypes: [RDReaderView.DisplayType], _ displayTypes: [RDReaderView.DisplayType],
initialPageNumber: Int? = nil, initialPageNumber: Int? = nil,
@@ -60,6 +105,8 @@ public final class RDURLReaderController: UIViewController {
} }
} }
/// 嵌入阅读器控制器到当前视图层级
/// 根据文件类型选择合适的阅读器控制器,并通过 Child View Controller 方式嵌入
private func embedReaderController() { private func embedReaderController() {
let controller: UIViewController let controller: UIViewController
if bookURL.pathExtension.lowercased() == "epub" { if bookURL.pathExtension.lowercased() == "epub" {
@@ -72,7 +119,20 @@ public final class RDURLReaderController: UIViewController {
let bookTitle = bookURL.deletingPathExtension().lastPathComponent let bookTitle = bookURL.deletingPathExtension().lastPathComponent
let pageSize = currentTextPageSize() let pageSize = currentTextPageSize()
let renderStyle = currentTextRenderStyle() let renderStyle = currentTextRenderStyle()
let builder = RDPlainTextBookBuilder() let builder = RDPlainTextBookBuilder(
layoutConfig: RDEPUBTextLayoutConfig(
frameWidth: pageSize.width,
frameHeight: pageSize.height,
edgeInsets: .zero,
numberOfColumns: 1,
columnGap: 20,
avoidOrphans: true,
avoidWidows: true,
avoidPageBreakInsideEnabled: true,
hyphenation: true,
imageMaxHeightRatio: 0.85
)
)
if let textBook = try? builder.build(textFileURL: bookURL, pageSize: pageSize, style: renderStyle) { if let textBook = try? builder.build(textFileURL: bookURL, pageSize: pageSize, style: renderStyle) {
controller = RDEPUBReaderController( controller = RDEPUBReaderController(
textBook: textBook, textBook: textBook,
@@ -104,10 +164,12 @@ public final class RDURLReaderController: UIViewController {
controller.didMove(toParent: self) controller.didMove(toParent: self)
} }
/// 获取内嵌的 EPUB 阅读器控制器(如果存在)
private var readerController: RDEPUBReaderController? { private var readerController: RDEPUBReaderController? {
embeddedController as? RDEPUBReaderController embeddedController as? RDEPUBReaderController
} }
/// 输出当前阅读器状态日志(Demo 调试用)
private func logDemoState(prefix: String) { private func logDemoState(prefix: String) {
guard let readerController else { return } guard let readerController else { return }
let location = readerController.currentLocation let location = readerController.currentLocation
@@ -117,6 +179,8 @@ public final class RDURLReaderController: UIViewController {
print("[ReadViewDemo] automation \(prefix) -> page \(page) href \(href) progression \(progression)") print("[ReadViewDemo] automation \(prefix) -> page \(page) href \(href) progression \(progression)")
} }
/// 计算文本分页的页面尺寸
/// 基于屏幕尺寸和配置的内边距
private func currentTextPageSize() -> CGSize { private func currentTextPageSize() -> CGSize {
let viewportSize = UIScreen.main.bounds.size let viewportSize = UIScreen.main.bounds.size
let insets = epubConfiguration.reflowableContentInsets let insets = epubConfiguration.reflowableContentInsets
@@ -126,6 +190,7 @@ public final class RDURLReaderController: UIViewController {
) )
} }
/// 根据当前配置生成文本渲染样式
private func currentTextRenderStyle() -> RDEPUBTextRenderStyle { private func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
let font = UIFont.systemFont(ofSize: epubConfiguration.fontSize) let font = UIFont.systemFont(ofSize: epubConfiguration.fontSize)
let lineSpacing = max(font.lineHeight * (epubConfiguration.lineHeightMultiple - 1), 4) let lineSpacing = max(font.lineHeight * (epubConfiguration.lineHeightMultiple - 1), 4)
@@ -137,6 +202,8 @@ public final class RDURLReaderController: UIViewController {
) )
} }
/// 解码文本文件内容
/// 尝试 UTF-8 编码,失败则尝试 GBK 和 GB2312 编码
private func rd_decodeTextFile(url: URL) -> String { private func rd_decodeTextFile(url: URL) -> String {
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String { if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
return content return content
@@ -151,7 +218,9 @@ public final class RDURLReaderController: UIViewController {
} }
} }
/// 翻页模式扩展:提供 Demo 命令行参数值
private extension RDReaderView.DisplayType { private extension RDReaderView.DisplayType {
/// 返回翻页模式对应的 Demo 命令行参数字符串
var demoArgumentValue: String { var demoArgumentValue: String {
switch self { switch self {
case .pageCurl: case .pageCurl:
@@ -0,0 +1,15 @@
import UIKit
extension UIColor {
convenience init?(rdHexString: String, alpha: CGFloat) {
var value = rdHexString.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
)
}
}
@@ -1,304 +0,0 @@
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)] = [
(#"<hr\s+lang="zh-CN">分页符</hr>"#, ""),
(#"\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
}
}
@@ -1,103 +0,0 @@
//
// 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)
}
}
@@ -1,198 +0,0 @@
//
// 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 ?? ""
}
}
@@ -1,102 +0,0 @@
//
// 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
}
}
@@ -1,93 +0,0 @@
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())
}
}
File diff suppressed because it is too large Load Diff
@@ -1,47 +0,0 @@
//
// 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")
}
}
@@ -1,127 +0,0 @@
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) {
}
}
@@ -1,499 +0,0 @@
//
// 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")
}
}
@@ -1,30 +0,0 @@
//
// 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")
}
}
@@ -1,325 +0,0 @@
//
// 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<CurrentType> 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<Observer>]()
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<T> {
class _Observer<T>: NSObject {
var observer: T
init(observer: T) {
self.observer = observer
super.init()
}
}
weak var observer: _Observer<T>?
private var _strongObserver: _Observer<T>?
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
}
}
@@ -1,75 +0,0 @@
//
// 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)
}

Some files were not shown because too many files have changed in this diff Show More