Compare commits
3 Commits
5af90c1148
...
23182e8b5a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23182e8b5a | ||
|
|
c0aac56083 | ||
|
|
54798ba578 |
@ -4,8 +4,8 @@
|
||||
|
||||
RDReaderView 是一个 iOS 阅读器组件库(CocoaPods),提供开箱即用的 EPUB 阅读能力,并保留对纯文本翻页的支持。
|
||||
|
||||
- **最低 iOS 版本**:15.0(podspec 仍标注 9.0,实际 demo Podfile 要求 15.0)
|
||||
- **Swift 版本**:5.0+
|
||||
- **最低 iOS 版本**:15.0
|
||||
- **Swift 版本**:5.10+
|
||||
- **依赖**:ZIPFoundation(EPUB 解压)、DTCoreText(文本 EPUB 渲染)
|
||||
- **Demo 额外依赖**:SnapKit、SSAlertSwift
|
||||
|
||||
@ -21,10 +21,13 @@ RDReaderView 是一个 iOS 阅读器组件库(CocoaPods),提供开箱即
|
||||
│
|
||||
┌───────────────────────────▼─────────────────────────────┐
|
||||
│ EPUBUI 层(library 级读者 UI) │
|
||||
│ RDEPUBReaderController(开箱即用入口) │
|
||||
│ RDEPUBReaderConfiguration / Theme / Persistence │
|
||||
│ TopToolView / BottomToolView / ChapterList / Settings │
|
||||
│ RDEPUBReaderController(开箱即用入口,~1995 行) │
|
||||
│ RDEPUBReaderConfiguration / Theme / Settings / Persistence│
|
||||
│ TopToolView / BottomToolView / ToolView 基类 │
|
||||
│ ChapterList / Highlights / Bookmarks / Settings 面板 │
|
||||
│ RDEPUBTextContentView / RDEPUBWebContentView │
|
||||
│ RDEPUBPageInteractionController / SelectionOverlayView │
|
||||
│ RDEPUBPageLayoutSnapshot / RDURLReaderController │
|
||||
└───────────────────────────┬─────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────────▼─────────────────────────────┐
|
||||
@ -40,10 +43,13 @@ RDReaderView 是一个 iOS 阅读器组件库(CocoaPods),提供开箱即
|
||||
│ EPUBCore 层(EPUB 引擎) │
|
||||
│ │
|
||||
│ Publication 层 │
|
||||
│ RDEPUBParser(+Archive / +Package / +TOC / …) │
|
||||
│ RDEPUBParser(+Archive / +Package / +TOC / │
|
||||
│ +ReadingProfile / +Resources) │
|
||||
│ RDEPUBPublication(出版物聚合对象) │
|
||||
│ RDEPUBModels(metadata / manifest / spine 模型) │
|
||||
│ RDEPUBReadingModels(location / viewport / highlight) │
|
||||
│ RDEPUBTextAnchor / RDEPUBTextRangeAnchor(文本锚点) │
|
||||
│ RDEPUBRenderRequest(渲染请求模型) │
|
||||
│ │
|
||||
│ Services 层 │
|
||||
│ RDEPUBResourceResolver(资源 URL 统一入口) │
|
||||
@ -57,11 +63,12 @@ RDReaderView 是一个 iOS 阅读器组件库(CocoaPods),提供开箱即
|
||||
│ │
|
||||
│ Resource View 层 │
|
||||
│ RDEPUBWebView(+Configuration / +Reflowable / │
|
||||
│ +FixedLayout / +JavaScriptBridge) │
|
||||
│ +FixedLayout / +JavaScriptBridge / │
|
||||
│ +Search) │
|
||||
│ RDEPUBResourceURLSchemeHandler(ss-reader:// 协议) │
|
||||
│ RDEPUBStyleSheetBuilder / RDEPUBJavaScriptBridge │
|
||||
│ RDEPUBFixedLayoutTemplate / RDEPUBAssetRepository │
|
||||
│ RDEPUBRenderRequest │
|
||||
│ RDEPUBWebViewDebug(调试日志工具) │
|
||||
│ │
|
||||
│ Search 层 │
|
||||
│ RDEPUBSearchEngine(协议)/ RDEPUBHTMLSearchEngine │
|
||||
@ -75,15 +82,10 @@ RDReaderView 是一个 iOS 阅读器组件库(CocoaPods),提供开箱即
|
||||
│ RDEPUBTextRendererSupport / RDEPUBTextPaginationSupport │
|
||||
│ RDEPUBTextBookBuilder / RDPlainTextBookBuilder │
|
||||
│ RDEPUBTextLayouter / RDEPUBTextLayoutFrame │
|
||||
│ RDEPUBTextBookCache / RDEPUBChapterData │
|
||||
│ RDEPUBTextIndexTable / RDEPUBTextPerformanceSampler │
|
||||
│ RDEPUBTextSearchEngine │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ LegacyRDReaderController/(旧版阅读器,保留兼容) │
|
||||
│ RDReaderController(旧版)/ RDReaderManager │
|
||||
│ RDReaderContentView / RDReaderEPUBContentView │
|
||||
│ SSChapterListController / SSEventTrigger │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
@ -239,6 +241,7 @@ session.clearPendingNavigation() // 取消待执行跳转
|
||||
| `+Reflowable` | 注入分页 CSS、滚动到指定 progression、接收 JS 事件 |
|
||||
| `+FixedLayout` | fixed-layout HTML wrapper 生成和加载 |
|
||||
| `+JavaScriptBridge` | JS ↔ Swift 消息路由、选区、高亮、进度上报 |
|
||||
| `+Search` | 搜索高亮装饰 |
|
||||
|
||||
#### ss-reader:// 协议
|
||||
|
||||
@ -323,7 +326,10 @@ present(controller, animated: true)
|
||||
| `showsTableOfContents` | `true` | 是否显示目录入口 |
|
||||
| `allowsHighlights` | `true` | 是否显示高亮入口 |
|
||||
| `showsSettingsPanel` | `true` | 是否显示设置入口 |
|
||||
| `reflowableContentInsets` | (40,16,40,16) | 可重排内容内边距 |
|
||||
| `fixedContentInset` | .zero | 固定版式内容内边距 |
|
||||
| `theme` | `.light` | 主题 |
|
||||
| `fixedLayoutFit` | `.page` | 固定版式适配模式 |
|
||||
| `fixedLayoutSpreadMode` | `.automatic` | fixed-layout spread 模式 |
|
||||
| `textRenderingEngine` | `.dtCoreText` | 文本 EPUB 渲染引擎 |
|
||||
|
||||
@ -377,6 +383,7 @@ struct RDEPUBLocation: Codable, Equatable {
|
||||
var progression: Double // 视口起始位置 [0, 1]
|
||||
var lastProgression: Double? // 视口末尾位置 [0, 1]
|
||||
var fragment: String? // 锚点
|
||||
var rangeAnchor: RDEPUBTextRangeAnchor? // 文本范围锚点(用于文本 EPUB 高亮定位)
|
||||
}
|
||||
```
|
||||
|
||||
@ -393,8 +400,8 @@ struct RDEPUBLocation: Codable, Equatable {
|
||||
|
||||
| 问题 | 说明 |
|
||||
|------|------|
|
||||
| Demo 层 RDReaderController 过大 | 约 1900+ 行,仍混有 EPUB 加载、UI 管理、数据源逻辑,待进一步拆分 |
|
||||
| RDEPUBParser.swift 仍偏大 | 约 900 行,虽已拆出多个扩展文件,OPF metadata 细节仍集中 |
|
||||
| RDEPUBReaderController 过大 | 约 1995 行,仍混有 EPUB 加载、UI 管理、数据源逻辑,待进一步拆分 |
|
||||
| 部分 UI 文件仍然偏大 | RDEPUBTextContentView ~728 行、RDEPUBTextBookBuilder ~717 行,需继续拆分 |
|
||||
| 横竖屏切换已接入一阶段支持 | `RDEPUBReaderController.viewWillTransition()` 统一接管正文重分页,`RDReaderView` 保留容器级双页布局刷新;仍需补齐固定回归矩阵 |
|
||||
| 固定手势分区比例 | 三等分固定写死,不支持自定义 |
|
||||
| 自动化测试为首批接入状态 | 已有 `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 |
|
||||
| SnapKit | Demo 布局 | Demo 层 |
|
||||
| SSAlertSwift | Demo 弹窗 | Demo 层 |
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
# ReadSDK 代码规范
|
||||
# ReadViewSDK 代码规范
|
||||
|
||||
## 适用范围
|
||||
|
||||
本文档适用于 `ReadSDK` 新增代码与重构代码。
|
||||
本文档适用于 `ReadViewSDK` 新增代码与重构代码。
|
||||
|
||||
- 规范覆盖 `Sources/` 与 `RDReaderDemo/` 中的 Swift 代码。
|
||||
- 命名、分层与职责边界以 SDK 可维护性和可扩展性为优先。
|
||||
@ -225,15 +225,17 @@ RDEPUBWebView+Reflowable.swift
|
||||
- 命名先定前缀再落代码:类型一律 `RD` 开头。
|
||||
- 若需迁移历史 `SS` 前缀,按模块渐进替换,优先替换新增与重构触达文件。
|
||||
|
||||
## 旧 SS 命名迁移到 RD 的分阶段执行清单
|
||||
## 旧 SS 命名迁移到 RD 的执行状态
|
||||
|
||||
### 阶段 0:冻结新增 SS 命名(立即执行)
|
||||
**迁移已完成**:源码中已无 `SS` 前缀类型定义,全部使用 `RD`/`RDEPUB` 前缀。新增代码必须继续遵守此规则。
|
||||
|
||||
### 阶段 0:冻结新增 SS 命名(已完成)
|
||||
|
||||
- 目标:从当前时点开始,不再引入新的 `SS`/`RDEPUB` 类型名。
|
||||
- 动作:
|
||||
- 新增类型统一使用 `RD`/`RDEPUB` 前缀。
|
||||
- Code Review 增加命名检查项:发现新增 `SS` 命名必须驳回。
|
||||
- 在 PR 模板中加入“本次是否新增旧前缀命名”勾选项。
|
||||
- 在 PR 模板中加入”本次是否新增旧前缀命名”勾选项。
|
||||
- 验收:
|
||||
- 新提交代码中,新增类型 `SS` 前缀数量为 `0`。
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
## 1. 范围与目标
|
||||
|
||||
- 代码范围:`Sources/RDReaderView/EPUBCore/`(30 个 Swift 文件 + 2 个资源文件)
|
||||
- 代码范围:`Sources/RDReaderView/EPUBCore/`(31 个 Swift 文件 + 2 个资源文件)
|
||||
- 目标:说明 EPUBCore 如何完成 EPUB 解析、资源定位、阅读会话管理、离屏分页、JS 桥接渲染和全文搜索。
|
||||
- 主链路关键词:`epubURL -> RDEPUBParser.parse -> container.xml -> OPF -> spine/TOC -> RDEPUBPublication -> RDEPUBReadingSession -> RDEPUBWebView/Paginator -> 分页/渲染`。
|
||||
|
||||
@ -40,7 +40,7 @@
|
||||
|
||||
### 2.4 WebView 渲染层 `RDEPUBWebView`
|
||||
|
||||
- 文件:`EPUBCore/RDEPUBWebView.swift` + 5 个扩展文件
|
||||
- 文件:`EPUBCore/RDEPUBWebView.swift` + 5 个扩展文件(+Configuration / +Reflowable / +FixedLayout / +JavaScriptBridge / +Search)
|
||||
- 职责:
|
||||
- 内部持有 WKWebView,配置自定义 scheme handler 和 JS 桥接
|
||||
- 可重排内容加载:`loadPage(parser:spineIndex:pageIndex:...)`(`+Reflowable.swift`)
|
||||
@ -77,13 +77,13 @@
|
||||
- `ssReaderFixedLayoutReady`:固定版式渲染完成
|
||||
- JS 端 `window.RDReaderBridge` 暴露:`applyPagination`、`setPageMetrics`、`scrollToPage`、`scrollToLocation`、`setHighlights`、`clearHighlights`、`reportProgression`、`selectionPayload`
|
||||
|
||||
### 2.8 搜索引擎 `RDEPUBHTMLSearchEngine`
|
||||
### 2.8 搜索引擎 `RDEPUBSearchEngine` / `RDEPUBHTMLSearchEngine`
|
||||
|
||||
- 文件:`EPUBCore/RDEPUBSearchEngine.swift`、`EPUBCore/RDEPUBSearchModels.swift`
|
||||
- 职责:
|
||||
- 将 HTML 转为纯文本(NSAttributedString 或正则兜底)
|
||||
- 执行大小写不敏感的全文搜索
|
||||
- 返回 `RDEPUBSearchMatch` 数组(含 progression 偏移和预览文本)
|
||||
- `RDEPUBSearchEngine`:搜索协议定义
|
||||
- `RDEPUBHTMLSearchEngine`:WebView 路径实现,将 HTML 转为纯文本(NSAttributedString 或正则兜底),执行大小写不敏感的全文搜索
|
||||
- 搜索模型:`RDEPUBSearchMatch`、`RDEPUBSearchResult`、`RDEPUBSearchState`、`RDEPUBSearchPresentation`
|
||||
|
||||
### 2.9 配置与样式
|
||||
|
||||
@ -92,6 +92,42 @@
|
||||
- `RDEPUBStyleSheetBuilder`(`EPUBCore/RDEPUBStyleSheetBuilder.swift`):生成分页 CSS、渲染 CSS 和测量 JS 脚本
|
||||
- `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.1 EPUB 解析全流程
|
||||
@ -237,6 +273,7 @@ RDEPUBLocation
|
||||
├── progression: Double? (0..1,章节内起始位置)
|
||||
├── lastProgression: Double? (0..1,章节内结束位置)
|
||||
├── fragment: String? (#anchor)
|
||||
├── rangeAnchor: RDEPUBTextRangeAnchor? (文本范围锚点,用于高亮精确定位)
|
||||
└── navigationProgression (computed: midpoint of progression and lastProgression)
|
||||
```
|
||||
|
||||
@ -279,7 +316,8 @@ RDEPUBRenderRequest (enum)
|
||||
RDEPUBHighlight
|
||||
├── id, bookIdentifier
|
||||
├── location: RDEPUBLocation
|
||||
├── text, rangeInfo (JSON-serialized DOM range)
|
||||
├── text, rangeInfo (JSON-serialized DOM range 或 text-offset)
|
||||
├── style: RDEPUBHighlightStyle (.highlight | .underline)
|
||||
├── color: String, note: String
|
||||
└── createdAt: Date
|
||||
|
||||
@ -288,6 +326,13 @@ RDEPUBSelection
|
||||
├── location: RDEPUBLocation
|
||||
├── text, rangeInfo
|
||||
└── createdAt: Date
|
||||
|
||||
RDEPUBBookmark
|
||||
├── id, bookIdentifier
|
||||
├── location: RDEPUBLocation
|
||||
├── title: String?
|
||||
├── note: String?
|
||||
└── createdAt: Date
|
||||
```
|
||||
|
||||
### 5.6 搜索模型
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
## 1. 范围与目标
|
||||
|
||||
- 代码范围:`Sources/RDReaderView/EPUBTextRendering/`(9 个 Swift 文件)
|
||||
- 代码范围:`Sources/RDReaderView/EPUBTextRendering/`(13 个 Swift 文件)
|
||||
- 目标:说明文本渲染路径如何将 EPUB HTML 转换为 NSAttributedString、按字符范围分页、构建书籍模型,并支持全文搜索与 textReflowable 标注定位。
|
||||
- 主链路关键词:`RDEPUBParser HTML -> DTCoreText 渲染 -> 片段标记注入/提取 -> CoreText 分页 -> RDEPUBTextBook -> 页面查找/位置转换`。
|
||||
- 适用范围:仅用于 `textReflowable` 阅读配置文件(纯文本可重排 EPUB,如小说)。固定版式和交互式 EPUB 使用 WebView 渲染路径。
|
||||
@ -64,6 +64,14 @@
|
||||
- 在已渲染的 NSAttributedString 纯文本上执行线性搜索
|
||||
- 返回 `RDEPUBSearchMatch` 数组(含 href、progression、预览文本、匹配位置)
|
||||
|
||||
### 2.6.1 章节数据模型 `RDEPUBChapterData`
|
||||
|
||||
- 文件:`EPUBTextRendering/RDEPUBChapterData.swift`
|
||||
- 职责:
|
||||
- 每章节的数据聚合模型,管理高亮、搜索结果和页面查询
|
||||
- 支持按页码范围过滤当前页的高亮列表
|
||||
- 支持搜索结果在章节内的定位和匹配索引计算
|
||||
|
||||
### 2.7 CoreText 分页引擎 `RDEPUBTextLayouter` / `RDEPUBTextLayoutFrame`
|
||||
|
||||
- 文件:`EPUBTextRendering/RDEPUBTextLayouter.swift`、`EPUBTextRendering/RDEPUBTextLayoutFrame.swift`
|
||||
@ -80,6 +88,28 @@
|
||||
- 将纯文本按段落切分后渲染为 NSAttributedString
|
||||
- 按页面尺寸分页,输出与 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.1 完整渲染-分页流程
|
||||
@ -295,7 +325,8 @@ RDEPUBTextBook
|
||||
- **内存**:`RDEPUBTextBook` 同时持有 chapters(含完整 attributedContent)和 pages(含子串切片),存在一定程度的内存重复
|
||||
- **后台执行**:`build` 方法在 `DispatchQueue.global(qos: .userInitiated)` 执行,不阻塞主线程
|
||||
- **搜索性能**:线性扫描每章的 `attributedContent.string`,无索引,搜索时间与总文本量线性相关
|
||||
- **无分页缓存**:字号/行高变化时全书重新渲染和分页,无增量更新
|
||||
- **分页缓存**:`RDEPUBTextBookCache` 支持磁盘缓存分页结果,字号/行高变化时优先从缓存恢复
|
||||
- **性能采样**:`RDEPUBTextPerformanceSampler` 记录每章的渲染和分页耗时,支持缓存命中率统计
|
||||
|
||||
## 9. 联调与排查建议
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
## 1. 范围与目标
|
||||
|
||||
- 代码范围:`Sources/RDReaderView/EPUBUI/`(16 个 Swift 文件)
|
||||
- 代码范围:`Sources/RDReaderView/EPUBUI/`(19 个 Swift 文件)
|
||||
- 目标:说明 EPUBUI 如何作为开箱即用的阅读器 UI 层,协调 EPUBCore 解析、EPUBTextRendering 文本渲染、RDReaderView 分页容器,提供完整的阅读体验(工具栏、目录、高亮批注、设置面板、阅读位置持久化、搜索)。
|
||||
- 主链路关键词:`RDEPUBReaderController.init -> 解析 EPUB -> 分页 -> 渲染 -> 用户交互(翻页/工具栏/设置/高亮/搜索)-> 持久化`。
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
|
||||
### 2.1 主控制器 `RDEPUBReaderController`
|
||||
|
||||
- 文件:`EPUBUI/RDEPUBReaderController.swift`(~1255 行)
|
||||
- 文件:`EPUBUI/RDEPUBReaderController.swift`(~1995 行)
|
||||
- 入口方法:
|
||||
- `init(epubURL:configuration:persistence:)` — 标准 EPUB 阅读入口
|
||||
- `init(textBook:bookIdentifier:title:textFileURL:configuration:)` — 纯文本书籍阅读入口(由 `RDPlainTextBookBuilder` 构建 `RDEPUBTextBook` 后传入)
|
||||
@ -29,7 +29,7 @@
|
||||
- `fontSize`(默认 15)、`lineHeightMultiple`(默认 1.6)
|
||||
- `displayType`(默认 .pageCurl)、`landscapeDualPageEnabled`(默认 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)
|
||||
- `fixedLayoutFit`(默认 .page)、`fixedLayoutSpreadMode`(默认 .automatic)
|
||||
- `textRenderingEngine`(默认 .dtCoreText)
|
||||
@ -41,45 +41,47 @@
|
||||
|
||||
- 文件:`EPUBUI/RDEPUBReaderDelegate.swift`
|
||||
- 12 个可选方法(全部有默认空实现):
|
||||
- `didOpen`:成功打开出版物
|
||||
- `didUpdateLocation`:翻页或滚动时位置更新
|
||||
- `didReachEnd`:到达最后一页
|
||||
- `didChangeSelection`:文本选择变化或清除
|
||||
- `didUpdateHighlights`:高亮增删改
|
||||
- `didUpdateBookmarks`:书签增删改
|
||||
- `didUpdateSearchResult`:搜索状态变化
|
||||
- `didChangeCurrentSearchMatch`:当前搜索匹配项变化
|
||||
- `didUpdateCurrentTableOfContentsItem`:翻页时匹配的目录项
|
||||
- `didActivateExternalLink`:外部链接点击
|
||||
- `didFailWithError`:解析或分页错误
|
||||
- `configureTopToolView`:自定义顶部工具栏
|
||||
- `epubReader(_:didOpen:)`:成功打开出版物
|
||||
- `epubReader(_:didUpdateLocation:)`:翻页或滚动时位置更新
|
||||
- `epubReaderDidReachEnd(_:)`:到达最后一页
|
||||
- `epubReader(_:didChangeSelection:)`:文本选择变化或清除
|
||||
- `epubReader(_:didUpdateHighlights:)`:高亮增删改
|
||||
- `epubReader(_:didUpdateBookmarks:)`:书签增删改
|
||||
- `epubReader(_:didUpdateSearchResult:)`:搜索状态变化
|
||||
- `epubReader(_:didChangeCurrentSearchMatch:)`:当前搜索匹配项变化
|
||||
- `epubReader(_:didUpdateCurrentTableOfContentsItem:)`:翻页时匹配的目录项
|
||||
- `epubReader(_:didActivateExternalLink:)`:外部链接点击
|
||||
- `epubReader(_:didFailWithError:)`:解析或分页错误
|
||||
- `epubReader(_:configureTopToolView:)`:自定义顶部工具栏
|
||||
|
||||
### 2.4 持久化 `RDEPUBReaderPersistence`
|
||||
|
||||
- 文件:`EPUBUI/RDEPUBReaderPersistence.swift`
|
||||
- 协议定义 6 个方法:loadLocation / saveLocation / loadHighlights / saveHighlights / loadReaderSettings / saveReaderSettings
|
||||
- 协议定义 8 个方法:loadLocation / saveLocation / loadHighlights / saveHighlights / loadBookmarks / saveBookmarks / loadReaderSettings / saveReaderSettings
|
||||
- 默认实现 `RDEPUBUserDefaultsPersistence`:
|
||||
- 位置:`ssreader.epub.location.<bookIdentifier>`(JSON 编码)
|
||||
- 高亮:`ssreader.epub.highlights.<bookIdentifier>`(JSON 编码)
|
||||
- 书签:`ssreader.epub.bookmarks.<bookIdentifier>`(JSON 编码)
|
||||
- 设置:`ssreader.epub.settings`(全局,非按书)
|
||||
|
||||
### 2.5 主题 `RDEPUBReaderTheme`
|
||||
|
||||
- 文件:`EPUBUI/RDEPUBReaderTheme.swift`
|
||||
- 文件:`EPUBUI/RDEPUBReaderTheme.swift`(~125 行)
|
||||
- 6 个颜色属性:contentBackgroundColor、contentTextColor、toolBackgroundColor、toolControlTextColor、toolControlBorderUnselectColor、toolLineColor
|
||||
- 6 个内置预设:`.light`、`.dark`、`.yellow`、`.green`、`.pink`、`.blue`
|
||||
- `RDEPUBReaderThemePreset` 枚举将预设映射为可序列化值,用于持久化
|
||||
- 计算属性 `themeBackgroundColorCSS` / `themeTextColorCSS` 用于 Web 渲染路径
|
||||
|
||||
### 2.6 设置面板 `RDEPUBReaderSettingsViewController`
|
||||
|
||||
- 文件:`EPUBUI/RDEPUBReaderSettingsViewController.swift`
|
||||
- 文件:`EPUBUI/RDEPUBReaderSettingsViewController.swift`(~311 行)
|
||||
- 5 个控制项:
|
||||
- 亮度滑块(0-1)
|
||||
- 字号 A-/A+(范围 12-36,步长 1)
|
||||
- 行高分段(紧凑 1.3 / 标准 1.6 / 宽松 1.9)
|
||||
- 显示模式分段(仿真 / 横滑 / 竖滑 / 覆盖)
|
||||
- 主题选择(6 个圆形色块按钮)
|
||||
- 所有变更通过闭包实时回调
|
||||
- 所有变更通过闭包实时回调:onBrightnessChange / onFontSizeChange / onLineHeightChange / onDisplayTypeChange / onThemeChange
|
||||
|
||||
### 2.7 内容视图
|
||||
|
||||
@ -88,10 +90,17 @@
|
||||
|
||||
### 2.8 其他 UI 组件
|
||||
|
||||
- `RDEPUBReaderTopToolView`:顶部导航栏(返回按钮 + 标题)
|
||||
- `RDEPUBReaderBottomToolView`:底部工具栏(目录 / 批注 / 标注 / 设置 4 个按钮)
|
||||
- `RDEPUBReaderToolView`(`EPUBUI/RDEPUBReaderToolView.swift`):工具栏基类,提供分隔线和主题适配的通用逻辑
|
||||
- `RDEPUBReaderTopToolView`:顶部导航栏(返回按钮 + 书签切换 + 标题)
|
||||
- `RDEPUBReaderBottomToolView`:底部工具栏(目录 / 书签 / 标注 / 设置 4 个按钮)
|
||||
- `RDEPUBReaderChapterListController`:目录列表(modal UITableViewController,当前项高亮 systemBlue)
|
||||
- `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. 主流程(代码级)
|
||||
|
||||
@ -229,22 +238,26 @@
|
||||
| 书签列表 | `ssreader.epub.bookmarks.<bookIdentifier>` | JSON → `[RDEPUBBookmark]` |
|
||||
| 阅读设置 | `ssreader.epub.settings` | JSON → `RDEPUBReaderSettings` |
|
||||
|
||||
### 5.2 Book Identifier
|
||||
|
||||
- 优先使用 `parser.metadata.identifier`
|
||||
- 回退使用 `epubURL.lastPathComponent`
|
||||
|
||||
### 5.3 设置快照
|
||||
### 5.2 设置模型
|
||||
|
||||
```swift
|
||||
RDEPUBReaderSettings (Codable)
|
||||
├── brightness: CGFloat
|
||||
├── fontSize: CGFloat
|
||||
├── lineHeightMultiple: CGFloat
|
||||
├── displayMode: RDEPUBReaderDisplayMode
|
||||
├── brightness: CGFloat?
|
||||
├── fontSize: CGFloat?
|
||||
├── lineHeightMultiple: CGFloat?
|
||||
├── displayMode: RDEPUBReaderDisplayMode?
|
||||
└── 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 目录扁平化项
|
||||
|
||||
```swift
|
||||
|
||||
@ -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 字段 |
|
||||
| `RDEPUBModels.swift` | metadata / manifest / spine / TOC 基础模型 |
|
||||
| `RDEPUBReadingModels.swift` | RDEPUBLocation、RDEPUBViewport、RDEPUBHighlight、选区模型 |
|
||||
| `RDEPUBReadingModels.swift` | RDEPUBLocation、RDEPUBViewport、RDEPUBHighlight、RDEPUBBookmark、选区模型 |
|
||||
| `RDEPUBResourceResolver.swift` | href → fileURL / schemeURL 的统一入口,`normalizedHref` 标准化路径 |
|
||||
| `RDEPUBPaginator.swift` | 离屏 WKWebView 分页,计算每个 spine 资源被视口切成多少页 |
|
||||
| `RDEPUBReadingSession.swift` | 状态机 + 会话协调,管理 staged/active/pending 页模型 |
|
||||
| `RDEPUBNavigatorState.swift` | 状态枚举(initializing/loading/idle/jumping/moving/repaginating) |
|
||||
| `RDEPUBWebView.swift` + 扩展 | 承载 spine 资源的 WKWebView,分页 CSS 注入、JS bridge、fixed wrapper |
|
||||
| `RDEPUBWebView.swift` + 5 扩展 | 承载 spine 资源的 WKWebView,分页 CSS 注入、JS bridge、fixed wrapper、搜索装饰 |
|
||||
| `RDEPUBResourceURLSchemeHandler.swift` | `ss-reader://` 协议,从本地解压目录提供所有 EPUB 资源 |
|
||||
| `RDEPUBStyleSheetBuilder.swift` | 生成注入 WebView 的 CSS |
|
||||
| `RDEPUBJavaScriptBridge.swift` | JS ↔ Swift 消息定义和编解码 |
|
||||
| `RDEPUBSearchEngine.swift` | 搜索协议定义 + WebView 全文搜索引擎 |
|
||||
| `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 层
|
||||
|
||||
@ -39,17 +46,38 @@
|
||||
| `RDEPUBTextRendererSupport.swift` | fragment 标记注入、fragment offset 提取、属性标准化 |
|
||||
| `RDEPUBTextPaginationSupport.swift` | NSAttributedString 切页算法 |
|
||||
| `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 层
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `RDEPUBReaderController.swift` | 开箱即用读者控制器,整合 session / readerView / UI |
|
||||
| `RDEPUBReaderConfiguration.swift` | 外部配置项(字号、主题、翻页模式等) |
|
||||
| `RDEPUBReaderPersistence.swift` | 阅读位置和高亮的本地持久化 |
|
||||
| `RDEPUBReaderSettings.swift` | 运行时设置状态 |
|
||||
| `RDEPUBReaderController.swift` | 开箱即用读者控制器(~1995 行),整合 session / readerView / UI |
|
||||
| `RDEPUBReaderConfiguration.swift` | 外部配置项(13 个:字号、主题、翻页模式等) |
|
||||
| `RDEPUBReaderPersistence.swift` | 阅读位置、高亮、书签的本地持久化(8 个方法) |
|
||||
| `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 |
|
||||
| `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. 后续扩展建议
|
||||
|
||||
1. **拆分 demo 层 RDReaderController**:约 1900+ 行,EPUB 加载、UI、数据源职责仍混在一起,建议继续向 library 层迁移
|
||||
1. **拆分 RDEPUBReaderController**:约 1995 行,EPUB 加载、UI、数据源职责仍混在一起,建议继续向 library 层迁移
|
||||
2. **基线验证**:用四本样书跑一轮分页耗时、目录命中率、末页事件、设置恢复稳定性数据
|
||||
3. **横竖屏支持回归矩阵**:控制器级 `viewWillTransition()` 与 viewport 去重已接入,下一步重点是补齐 pageCurl / scroll、三条正文路径和 iPad 视口变化场景的固定回归
|
||||
4. **Pod 分层**:评估是否将 EPUBCore / EPUBTextRendering / EPUBUI 拆为独立 subspec
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
## 1. 范围与目标
|
||||
|
||||
- 代码范围:`Sources/RDReaderView/` 根目录(6 个 Swift 文件)
|
||||
- 代码范围:`Sources/RDReaderView/ReaderView/`(5 个 Swift 文件)
|
||||
- 目标:说明分页阅读器容器如何管理四种显示模式、DataSource/Delegate 协议、翻页交互、工具栏动画、双屏适配和 RTL 支持。
|
||||
- 主链路关键词:`RDReaderDataSource -> reloadData -> DisplayType 切换 -> 翻页/滚动 -> RDReaderDelegate.pageNum -> 工具栏显隐`。
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
|
||||
### 2.1 核心容器 `RDReaderView`
|
||||
|
||||
- 文件:`Sources/RDReaderView/RDReaderView.swift`(~717 行)
|
||||
- 文件:`Sources/RDReaderView/ReaderView/RDReaderView.swift`(~1219 行)
|
||||
- 入口方法:`reloadData()`
|
||||
- 职责:
|
||||
- 管理四种显示模式的视图层级切换
|
||||
@ -23,7 +23,7 @@
|
||||
|
||||
### 2.2 自定义布局 `RDReaderFlowLayout`
|
||||
|
||||
- 文件:`Sources/RDReaderView/RDReaderFlowLayout.swift`(~387 行)
|
||||
- 文件:`Sources/RDReaderView/ReaderView/RDReaderFlowLayout.swift`(~375 行)
|
||||
- 职责:
|
||||
- 继承 `UICollectionViewFlowLayout`,为三种滚动模式提供布局计算
|
||||
- 水平滚动:全屏宽 item,pagingEnabled
|
||||
@ -33,7 +33,7 @@
|
||||
|
||||
### 2.3 内容 Cell `RDReaderContentCell`
|
||||
|
||||
- 文件:`Sources/RDReaderView/RDReaderContentCell.swift`(~37 行)
|
||||
- 文件:`Sources/RDReaderView/ReaderView/RDReaderContentCell.swift`(~55 行)
|
||||
- 职责:
|
||||
- `UICollectionViewCell` 子类,作为内容视图的薄壳宿主
|
||||
- `containerView` 属性 setter 自动移除旧视图、添加新视图
|
||||
@ -41,7 +41,7 @@
|
||||
|
||||
### 2.4 页面子控制器 `RDReaderPageChildViewController`
|
||||
|
||||
- 文件:`Sources/RDReaderView/RDReaderPageChildViewController.swift`(~65 行)
|
||||
- 文件:`Sources/RDReaderView/ReaderView/RDReaderPageChildViewController.swift`(~89 行)
|
||||
- 职责:
|
||||
- 仅用于 pageCurl 模式,作为 `UIPageViewController` 的子控制器
|
||||
- 持有 `contentView: UIView?` 和 `pageNum: Int`
|
||||
@ -49,21 +49,11 @@
|
||||
|
||||
### 2.5 手势控制器 `RDReaderGestureController`
|
||||
|
||||
- 文件:`Sources/RDReaderView/RDReaderGestureController.swift`(~42 行)
|
||||
- 文件:`Sources/RDReaderView/ReaderView/RDReaderGestureController.swift`(~58 行)
|
||||
- 职责:
|
||||
- 当前为占位组件,存储 topToolView / bottomToolView 引用
|
||||
- 实际手势逻辑在 `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.1 协议定义
|
||||
|
||||
@ -12,10 +12,10 @@
|
||||
|
||||
| 文档 | 对应模块 | 说明 |
|
||||
|------|----------|------|
|
||||
| [EPUBCore_功能实现逻辑.md](EPUBCore_功能实现逻辑.md) | `Sources/RDReaderView/EPUBCore/`(30 文件) | 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 双向转换、全文搜索引擎、纯文本构建器 |
|
||||
| [RDReaderView_功能实现逻辑.md](RDReaderView_功能实现逻辑.md) | `Sources/RDReaderView/` 根目录(6 文件) | 四种显示模式(pageCurl/horizontalScroll/verticalScroll/horizontalCoverScroll)、DataSource/Delegate 协议、点击三区域翻页、工具栏动画、双页配对与哨兵页、横竖屏适配、RTL 支持 |
|
||||
| [EPUBUI_功能实现逻辑.md](EPUBUI_功能实现逻辑.md) | `Sources/RDReaderView/EPUBUI/`(16 文件) | RDEPUBReaderController 全生命周期、三条渲染路径分发、配置变更检测与响应、工具栏/目录/高亮/搜索/设置面板交互流、阅读位置持久化、主题管理 |
|
||||
| [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/`(13 文件) | DTCoreText HTML→NSAttributedString 渲染管线、片段标记注入/提取、CoreText 分页引擎(含语义边界调整)、文本索引表、Location↔PageNumber 双向转换、分页缓存、性能采样、全文搜索引擎、纯文本构建器 |
|
||||
| [RDReaderView_功能实现逻辑.md](RDReaderView_功能实现逻辑.md) | `Sources/RDReaderView/ReaderView/`(5 文件) | 四种显示模式(pageCurl/horizontalScroll/verticalScroll/horizontalCoverScroll)、DataSource/Delegate 协议、点击三区域翻页、工具栏动画、双页配对与哨兵页、横竖屏适配、RTL 支持 |
|
||||
| [EPUBUI_功能实现逻辑.md](EPUBUI_功能实现逻辑.md) | `Sources/RDReaderView/EPUBUI/`(19 文件) | RDEPUBReaderController 全生命周期、三条渲染路径分发、配置变更检测与响应、工具栏/目录/高亮/书签/搜索/设置面板交互流、阅读位置持久化、主题管理、CoreText 页面交互 |
|
||||
|
||||
## 方案讨论文档
|
||||
|
||||
|
||||
@ -40,9 +40,10 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
- 5 层 CSS 级联和 EPUB `<link>` 外部样式表内联都已经落地。
|
||||
- CoreText 页面 hit test、长按选区、菜单锚点、复制/高亮/批注主链路已经接入页面几何。
|
||||
- CoreText 路径的高亮、注释、搜索命中已经走页面装饰层,而不是继续改正文布局真值。
|
||||
- WebView 路径的高亮/批注展示链路已经补齐,不再因为 DOM 包裹失败而“有记录但不着色”。
|
||||
- WebView 路径的标注绘制、搜索高亮已经改成“JS 产出 rect,原生 overlay 负责 CGContext 绘制”,不再依赖 DOM 包裹着色。
|
||||
- 分页器已经补上 inline footnote attachment 不整段挪页、标题 `keepWithNext`、`weread-page-relate` 页首借行这几类 WXRead 风格规则。
|
||||
- 章节尾部“仅空白/段落分隔符”的尾页丢弃,以及极短尾页回并已经落地,`宝山辽墓材料与释读` 第 31 页空白问题已修复。
|
||||
- `RDEPUBTextLayoutConfig` 已补齐到 WXRead 同级配置面:`frameWidth/frameHeight/edgeInsets/numberOfColumns/columnGap/avoidOrphans/avoidWidows/hyphenation`,并已接入分页入口与缓存键。
|
||||
|
||||
### ⚠️ 有差异/有问题
|
||||
|
||||
@ -51,14 +52,14 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
- 位置模型虽然已经有 `rangeAnchor` 和 `fileIndex/row/column` 语义,但还不是完整的 WXRead 双向位置转换器。
|
||||
- `RDEPUBChapterData` 已承担页内高亮/搜索/页码查询职责,但还没完全吸收 controller/book 层的章节语义。
|
||||
- `RDReaderView` 已有 `pageCurl` fault patch、预加载、预测翻页和显示切换位置恢复,但还不是 WXRead 那种完整宿主层补丁集。
|
||||
- `default.css` / `replace.css` / `dark.css` 的效果已接近,但资源体系和 WXRead 私有 CSS 仍不是一比一镜像。
|
||||
- `default.css` / `replace.css` / `dark.css` / `replaceForLatinLanguageBook.css` 已切到内置资源镜像,并接入章节语言分支选择。
|
||||
|
||||
### ❌ 未实现
|
||||
|
||||
- 完整 `WRChapterData` 同构模型
|
||||
- 完整 `WREpubPositionConverter` 同构模型
|
||||
- 预加载、预测翻页、显示切换一致性的完整宿主层方案
|
||||
- 多栏、字体动态加载、繁简转换
|
||||
- 字体动态加载、繁简转换
|
||||
- TTS / DRM / Pencil
|
||||
|
||||
---
|
||||
@ -70,10 +71,10 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
| EPUB 文本渲染 | `WRPageView.drawRect:` → `CTFrameDraw` 到 CGContext | `RDEPUBTextContentView` → `RDEPUBDirectCoreTextPageView.draw(_:)` + `DTCoreTextLayoutFrame.draw(in:)` 页面级直绘 | ✅ 已实现 |
|
||||
| WebView 路径 | WKWebView(公众号/文集文章) | WKWebView(interactive/fixed-layout EPUB) | ✅ 已实现 |
|
||||
| 文本选区 | `CTLineGetStringIndexForPosition` 坐标级 hit test | `RDEPUBPageInteractionController` + `RDEPUBSelectionOverlayView` 已接入主链路 | ✅ 已实现 |
|
||||
| 标注绘制 | `CTFrame` 层叠加绘制(CGContext) | CoreText 路径已走页面装饰层;WebView 路径仍是 JS/DOM 高亮 | ⚠️ 有差异/有问题 |
|
||||
| 搜索高亮 | `WRCoreTextLayoutFrame.highlightSearchResults:` 直接绘制 | CoreText 路径已页面化;不同渲染路径仍不是同一套对象模型 | ⚠️ 有差异/有问题 |
|
||||
| 标注绘制 | `CTFrame` 层叠加绘制(CGContext) | CoreText / WebView 两条链路都已收口到原生页面装饰层绘制 | ✅ 已实现 |
|
||||
| 搜索高亮 | `WRCoreTextLayoutFrame.highlightSearchResults:` 直接绘制 | CoreText / WebView 两条链路都已统一为原生页面装饰对象绘制 | ✅ 已实现 |
|
||||
|
||||
**结论:** 文本页的“是否页面级 CoreText”已经不是问题,当前差距是页面装饰对象和双路径一致性。
|
||||
**结论:** 渲染层里“页面级绘制 + 页面装饰对象收口”这一块已经完成,当前剩余差距不再是标注/搜索高亮链路。
|
||||
|
||||
---
|
||||
|
||||
@ -88,7 +89,7 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
| 标题 keep-with-next | 标题不能孤悬页尾 | 已补 `keepWithNext` 语义与页尾回退 | ✅ 已实现 |
|
||||
| `weread-page-relate` | 页首关联块需要借上一页一行 | 已补页首 `pageRelate` 借行规则 | ✅ 已实现 |
|
||||
| 章节尾页收口 | 丢弃空白尾页、合并极短尾页 | 已补尾页空白丢弃与超短尾页回并 | ✅ 已实现 |
|
||||
| 分页配置 | `WRCoreTextLayoutConfig`(含多栏等) | 已有 `RDEPUBTextLayoutConfig`,但多栏等仍缺 | ⚠️ 有差异/有问题 |
|
||||
| 分页配置 | `WRCoreTextLayoutConfig`(含多栏等) | `RDEPUBTextLayoutConfig` 已补齐同级参数,并接入分页入口/缓存键/列路径构建 | ✅ 已实现 |
|
||||
| 缓存 | 按书籍 + 排版设置缓存 | 已有 `RDEPUBTextBookCache` 磁盘缓存 | ✅ 已实现 |
|
||||
|
||||
**结论:** 当前分页质量问题已经进入问题书细抠阶段,不再是“缺分页器”的阶段;章节尾部空白页这类系统性问题也已经开始按 WXRead 规则收口。
|
||||
@ -101,11 +102,11 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
|------|---------|-------------|------|
|
||||
| CSS 级联层级 | 5 级:`default < replace < dark < epub < user` | `RDEPUBTextStyleSheetPackage` 已实际生成并注入 5 层 | ✅ 已实现 |
|
||||
| EPUB `<link>` CSS | 解析前级联合并外部样式 | 已实现抽取、内联、URL 重写与诊断 | ✅ 已实现 |
|
||||
| 自定义 CSS 属性 | `wr-vertical-center-style`、`weread-page-relate`、断页相关私有属性 | 已支持 `pageRelate`、`avoidPageBreakInside`、attachment placement;仍非完整 WeRead 属性全集 | ⚠️ 有差异/有问题 |
|
||||
| WXRead 私有 CSS 文件 | 使用 WeRead 自带 `default/replace/dark/...` | 当前是等价策略,不是原文件原样镜像 | ⚠️ 有差异/有问题 |
|
||||
| `replaceForLatinLanguageBook.css` | 拉丁语言专项字体规则 | 无对应 | ❌ 未实现 |
|
||||
| 自定义 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/...` | 已把 `default/replace/dark` 作为 SDK 内置资源镜像注入 5 层级联 | ✅ 已实现 |
|
||||
| `replaceForLatinLanguageBook.css` | 拉丁语言专项字体规则 | 已按章节语言 / 正文拉丁字符占比自动切换 Latin replace 层 | ✅ 已实现 |
|
||||
|
||||
**结论:** CSS 主机制已对齐,剩下是规则细节和私有资源体系差异。
|
||||
**结论:** CSS 主机制、私有资源层和拉丁语言分支都已经按 WXRead 路径收口;这一节当前不再是剩余差距。
|
||||
|
||||
---
|
||||
|
||||
@ -156,22 +157,22 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
|
||||
| 文件 | 读书职责 | ReadViewSDK 状态 | 核查 |
|
||||
|------|-------------|-----------------|------|
|
||||
| `default.css` | 基础 HTML 标签样式 | 已有等价基础 CSS | ⚠️ 有差异/有问题 |
|
||||
| `replace.css` | 标题、图片、引用、分页控制 | 已有等价 replace 层 | ⚠️ 有差异/有问题 |
|
||||
| `dark.css` | 暗色主题 | 已有 dark 层 | ⚠️ 有差异/有问题 |
|
||||
| `replaceForLatinLanguageBook.css` | 拉丁语言字体 | 无 | ❌ 未实现 |
|
||||
| `default.css` | 基础 HTML 标签样式 | 已以内置资源镜像接入 | ✅ 已实现 |
|
||||
| `replace.css` | 标题、图片、引用、分页控制 | 已以内置资源镜像接入 | ✅ 已实现 |
|
||||
| `dark.css` | 暗色主题 | 已以内置资源镜像接入,并保留主题色覆盖层 | ✅ 已实现 |
|
||||
| `replaceForLatinLanguageBook.css` | 拉丁语言字体 | 已接入章节语言自动切换 | ✅ 已实现 |
|
||||
| `replaceForMPChapter.css` | 公众号文章专用 | EPUB 主链路不需要 | ❌ 未实现(暂不需要) |
|
||||
|
||||
### JS
|
||||
|
||||
| 文件 | 读书职责 | ReadViewSDK 状态 | 核查 |
|
||||
|------|-------------|-----------------|------|
|
||||
| `weread-highlighter.js` | Web 高亮引擎 | `epub-bridge.js` 已接住高亮/批注展示 | ⚠️ 有差异/有问题 |
|
||||
| `weread-highlighter.js` | Web 高亮引擎 | `epub-bridge.js` 现只负责 rect 解析与桥接,最终由原生 overlay 绘制 | ✅ 已实现 |
|
||||
| `rangy-*` | 选区/高亮库 | CoreText 路径不需要,Web 路径也未采用 | ⚠️ 有差异/有问题 |
|
||||
| `cssInjector.js` | 动态 CSS 注入 | 现为原生侧静态注入 | ⚠️ 有差异/有问题 |
|
||||
| `WeReadApi.js` | JS-Native 桥接 | `window.RDReaderBridge` | ⚠️ 有差异/有问题 |
|
||||
|
||||
**结论:** 不建议也不应该把 WXRead 私有 JS/CSS 资源整包原样搬用;当前应继续走“能力等价、实现自有”的路线。
|
||||
**结论:** CSS 资源层已经按 WXRead 的文件级职责收口;剩余差距主要在 JS 桥接策略,而不是 CSS 处理能力。
|
||||
|
||||
---
|
||||
|
||||
@ -186,7 +187,7 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
| 预加载 | ⚠️ 有差异/有问题 | P3 |
|
||||
| 预测翻页 | ⚠️ 有差异/有问题 | P3 |
|
||||
| 显示切换一致性 | ⚠️ 有差异/有问题 | P3 |
|
||||
| 多栏排版 | ❌ 未实现 | P4 |
|
||||
| 多栏排版 | ⚠️ 引擎配置与列路径已接入,阅读器侧未暴露切换 | P4 |
|
||||
| 字体动态加载 | ❌ 未实现 | P4 |
|
||||
| 繁简转换 | ❌ 未实现 | P4 |
|
||||
| TTS / DRM / Pencil | ❌ 未实现 | P4 |
|
||||
@ -221,12 +222,12 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
|
||||
### P4:外围能力
|
||||
|
||||
- 多栏
|
||||
- 多栏阅读器开关 / UI 暴露
|
||||
- 字体系统
|
||||
- 繁简转换
|
||||
- TTS / DRM / Pencil
|
||||
|
||||
当前状态:`❌ 未开始`
|
||||
当前状态:`⚠️ 进行中`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ Pod::Spec.new do |s|
|
||||
s.license = "MIT"
|
||||
s.source_files = 'Sources/RDReaderView/**/*.{swift}'
|
||||
s.resource_bundles = {
|
||||
'RDReaderViewAssets' => ['Sources/RDReaderView/Resources/**/*']
|
||||
'RDReaderViewAssets' => ['Sources/RDReaderView/EPUBCore/Resources/**/*']
|
||||
}
|
||||
s.dependency 'ZIPFoundation', '~> 0.9'
|
||||
s.dependency 'DTCoreText'
|
||||
|
||||
@ -43,7 +43,7 @@ EXTERNAL SOURCES:
|
||||
SPEC CHECKSUMS:
|
||||
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
||||
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
||||
RDReaderView: 438edba3372d14a7dde3c0334ebd4cc1945ca926
|
||||
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
|
||||
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
||||
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
|
||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
"source_files": "Sources/RDReaderView/**/*.{swift}",
|
||||
"resource_bundles": {
|
||||
"RDReaderViewAssets": [
|
||||
"Sources/RDReaderView/Resources/**/*"
|
||||
"Sources/RDReaderView/EPUBCore/Resources/**/*"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
2
ReadViewDemo/Pods/Manifest.lock
generated
2
ReadViewDemo/Pods/Manifest.lock
generated
@ -43,7 +43,7 @@ EXTERNAL SOURCES:
|
||||
SPEC CHECKSUMS:
|
||||
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
||||
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
||||
RDReaderView: 438edba3372d14a7dde3c0334ebd4cc1945ca926
|
||||
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
|
||||
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
||||
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
|
||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||
|
||||
2658
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
2658
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
File diff suppressed because it is too large
Load Diff
@ -8,78 +8,56 @@
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<key>DTCoreText.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
<key>DTFoundation.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
<key>orderHint</key>
|
||||
<integer>2</integer>
|
||||
</dict>
|
||||
<key>Pods-ReadViewDemo.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
<key>orderHint</key>
|
||||
<integer>3</integer>
|
||||
</dict>
|
||||
<key>RDReaderView-RDReaderViewAssets.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
<key>orderHint</key>
|
||||
<integer>5</integer>
|
||||
</dict>
|
||||
<key>RDReaderView.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
<key>orderHint</key>
|
||||
<integer>4</integer>
|
||||
</dict>
|
||||
<key>SSAlertSwift.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
<key>orderHint</key>
|
||||
<integer>8</integer>
|
||||
</dict>
|
||||
<key>SnapKit-SnapKit_Privacy.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
<key>orderHint</key>
|
||||
<integer>7</integer>
|
||||
</dict>
|
||||
<key>SnapKit.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
<key>orderHint</key>
|
||||
<integer>6</integer>
|
||||
</dict>
|
||||
<key>ZIPFoundation-ZIPFoundation_Privacy.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
<key>orderHint</key>
|
||||
<integer>10</integer>
|
||||
</dict>
|
||||
<key>ZIPFoundation.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
<key>orderHint</key>
|
||||
<integer>9</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
|
||||
@ -185,14 +185,10 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh\"\n";
|
||||
|
||||
Binary file not shown.
@ -1,20 +1,45 @@
|
||||
// RDEPUBAssetRepository.swift
|
||||
// EPUBCore 静态资源仓库
|
||||
// 负责从资源包中加载 EPUB 渲染所需的 JS 桥接脚本和固定版式 HTML 模板,
|
||||
// 支持模板变量替换({{token}} 占位符)。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// EPUB 内置资源枚举,定义桥接脚本和固定版式模板的文件名及扩展名
|
||||
enum RDEPUBAsset: String {
|
||||
/// JS 桥接脚本(epub-bridge.js),注入到 WebView 中实现原生-JS 通信
|
||||
case bridgeScript = "epub-bridge"
|
||||
/// 固定版式 HTML 模板(epub-fixed-layout.html),用于渲染 pre-paginated 类型的 EPUB
|
||||
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 {
|
||||
switch self {
|
||||
case .bridgeScript:
|
||||
return "js"
|
||||
case .fixedLayoutTemplate:
|
||||
return "html"
|
||||
case .wxReadDefaultCSS, .wxReadReplaceCSS, .wxReadDarkCSS, .wxReadLatinReplaceCSS:
|
||||
return "css"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 静态资源加载器,负责从资源包中读取模板文件并执行变量替换
|
||||
enum RDEPUBAssetRepository {
|
||||
/// 加载指定资源文件的内容,并将 {{key}} 占位符替换为对应的值
|
||||
/// - Parameters:
|
||||
/// - asset: 要加载的资源类型
|
||||
/// - replacements: 模板变量字典,key 为占位符名称,value 为替换值
|
||||
/// - Returns: 替换后的文件内容字符串
|
||||
static func string(for asset: RDEPUBAsset, replacements: [String: String] = [:]) -> String {
|
||||
guard let url = resourceBundle.url(forResource: asset.rawValue, withExtension: asset.fileExtension),
|
||||
var content = try? String(contentsOf: url, encoding: .utf8) else {
|
||||
@ -28,6 +53,7 @@ enum RDEPUBAssetRepository {
|
||||
return content
|
||||
}
|
||||
|
||||
/// 获取资源所在的 Bundle(优先使用已解析的子 bundle,兜底到宿主 bundle)
|
||||
private static var resourceBundle: Bundle {
|
||||
if let bundle = resolvedBundle {
|
||||
return bundle
|
||||
@ -35,6 +61,7 @@ enum RDEPUBAssetRepository {
|
||||
return Bundle(for: RDEPUBAssetBundleToken.self)
|
||||
}
|
||||
|
||||
/// 延迟解析 RDReaderViewAssets.bundle 的位置,在宿主 bundle 和所有 framework 中查找
|
||||
private static var resolvedBundle: Bundle? = {
|
||||
let hostBundles = [Bundle(for: RDEPUBAssetBundleToken.self), Bundle.main] + Bundle.allFrameworks + Bundle.allBundles
|
||||
for hostBundle in hostBundles {
|
||||
@ -47,4 +74,5 @@ enum RDEPUBAssetRepository {
|
||||
}()
|
||||
}
|
||||
|
||||
private final class RDEPUBAssetBundleToken {}
|
||||
/// 用于定位当前模块 Bundle 的标记类
|
||||
private final class RDEPUBAssetBundleToken {}
|
||||
|
||||
@ -1,12 +1,25 @@
|
||||
// RDEPUBFixedLayoutTemplate.swift
|
||||
// 固定版式(Fixed Layout)HTML 模板生成器
|
||||
// 根据渲染请求和 Publication 信息,将 iframe 拼装到 HTML 模板中,
|
||||
// 生成适配视口尺寸和内边距的固定版式页面 HTML。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 固定版式模板生成器,负责将 spine 资源渲染为 iframe 嵌套的 HTML 页面
|
||||
enum RDEPUBFixedLayoutTemplate {
|
||||
/// 根据渲染请求和 Publication 生成固定版式页面的完整 HTML
|
||||
/// - Parameters:
|
||||
/// - request: 固定版式渲染请求(含 spread、视口尺寸、内边距、适配模式等)
|
||||
/// - publication: EPUB 出版物,用于解析资源 URL
|
||||
/// - Returns: 完整的 HTML 字符串
|
||||
static func html(for request: RDEPUBFixedRenderRequest, publication: RDEPUBPublication) -> String {
|
||||
// 遍历 spread 中的每个资源,生成对应的 iframe pane HTML
|
||||
let panes = request.spread.resources.enumerated().compactMap { index, resource -> String? in
|
||||
guard let url = publication.resourceResolver.resourceURL(forRelativePath: resource.href)?.absoluteString else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 根据 spread 中的资源数量判断页面类型:单页/左页/右页/居中
|
||||
let pageType: String
|
||||
if request.spread.resources.count == 1 {
|
||||
pageType = "single"
|
||||
@ -23,6 +36,7 @@ enum RDEPUBFixedLayoutTemplate {
|
||||
"""
|
||||
}.joined()
|
||||
|
||||
// 计算视口区域(去除内边距后的可用区域)
|
||||
let background = request.backgroundColorCSS ?? "#FFFFFF"
|
||||
let viewportWidth = max(1, Int((request.viewportSize.width - request.contentInset.left - request.contentInset.right).rounded(.down)))
|
||||
let viewportHeight = max(1, Int((request.viewportSize.height - request.contentInset.top - request.contentInset.bottom).rounded(.down)))
|
||||
@ -30,6 +44,7 @@ enum RDEPUBFixedLayoutTemplate {
|
||||
let insetRight = Int(request.contentInset.right.rounded(.down))
|
||||
let insetBottom = Int(request.contentInset.bottom.rounded(.down))
|
||||
let insetLeft = Int(request.contentInset.left.rounded(.down))
|
||||
// 从资源仓库加载 HTML 模板并替换占位符
|
||||
return RDEPUBAssetRepository.string(
|
||||
for: .fixedLayoutTemplate,
|
||||
replacements: [
|
||||
|
||||
@ -1,19 +1,35 @@
|
||||
// RDEPUBJavaScriptBridge.swift
|
||||
// 原生与 WebView 之间的 JavaScript 桥接层
|
||||
// 定义所有 JS 消息类型(progression/selection/link/error 等),
|
||||
// 生成注入到 WebView 的用户脚本,以及分页渲染、搜索高亮等执行脚本。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// JS 桥接消息类型枚举,每种消息对应 WebView 向原生发送的一种事件
|
||||
enum RDEPUBJavaScriptBridgeMessage: String, CaseIterable {
|
||||
/// 阅读进度变化事件
|
||||
case progressionChanged = "ssReaderProgressionChanged"
|
||||
/// 文本选择变化事件
|
||||
case selectionChanged = "ssReaderSelectionChanged"
|
||||
/// 内部链接点击事件(ss-reader:// 协议)
|
||||
case internalLink = "ssReaderInternalLink"
|
||||
/// 外部链接点击事件(http/https/mailto/tel)
|
||||
case externalLink = "ssReaderExternalLink"
|
||||
/// JavaScript 运行时错误事件
|
||||
case javaScriptError = "ssReaderJSError"
|
||||
/// 固定版式渲染就绪事件
|
||||
case fixedLayoutReady = "ssReaderFixedLayoutReady"
|
||||
}
|
||||
|
||||
/// JavaScript 桥接工具集,负责生成注入脚本和执行脚本
|
||||
enum RDEPUBJavaScriptBridge {
|
||||
/// 所有需要注册的 JS 消息名称列表,用于 WKUserContentController 注册
|
||||
static var messageNames: [String] {
|
||||
RDEPUBJavaScriptBridgeMessage.allCases.map(\.rawValue)
|
||||
}
|
||||
|
||||
/// 获取注入到 WebView 的桥接用户脚本(在 document end 注入)
|
||||
/// 将消息类型占位符替换为实际值,使 JS 端能通过 window.webkit.messageHandlers 发送消息
|
||||
static var userScript: String {
|
||||
RDEPUBAssetRepository.string(
|
||||
for: .bridgeScript,
|
||||
@ -28,6 +44,10 @@ enum RDEPUBJavaScriptBridge {
|
||||
)
|
||||
}
|
||||
|
||||
/// 生成重排模式下的分页渲染脚本
|
||||
/// 注入 CSS 分页样式、设置页码参数、高亮和目标位置,最后触发进度汇报
|
||||
/// - Parameter request: 重排渲染请求,包含展示样式、高亮、目标位置等信息
|
||||
/// - Returns: 可直接通过 evaluateJavaScript 执行的脚本字符串
|
||||
static func applyPresentationScript(for request: RDEPUBReflowableRenderRequest) -> String {
|
||||
let style = escapedJavaScriptTemplateLiteral(RDEPUBStyleSheetBuilder.renderCSS(for: request.presentation))
|
||||
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 {
|
||||
let payload = jsonString(from: searchPayload(presentation), fallback: "null")
|
||||
return """
|
||||
(function() {
|
||||
var payload = \(payload);
|
||||
function normalizeHref(rawHref) {
|
||||
if (!rawHref) { return ''; }
|
||||
var value = String(rawHref);
|
||||
value = value.replace(/^.*?:\\/\\/[^/]+\\//, '');
|
||||
value = value.split('#')[0];
|
||||
value = value.replace(/^\\/+/, '');
|
||||
try {
|
||||
value = decodeURIComponent(value);
|
||||
} catch (error) {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function ensureStyle(doc) {
|
||||
if (!doc || !doc.head || doc.getElementById('ss-reader-search-style')) { return; }
|
||||
var style = doc.createElement('style');
|
||||
style.id = 'ss-reader-search-style';
|
||||
style.textContent = '.ss-reader-search-mark{background:rgba(248,225,108,0.55);color:inherit;border-radius:2px;padding:0;}.ss-reader-search-mark-active{background:rgba(255,159,67,0.75);color:inherit;border-radius:2px;padding:0;}';
|
||||
doc.head.appendChild(style);
|
||||
}
|
||||
|
||||
function clearMarks(doc) {
|
||||
if (!doc || !doc.body) { return; }
|
||||
var marks = doc.querySelectorAll('span.ss-reader-search-mark, span.ss-reader-search-mark-active');
|
||||
marks.forEach(function(mark) {
|
||||
var textNode = doc.createTextNode(mark.textContent || '');
|
||||
if (mark.parentNode) {
|
||||
mark.parentNode.replaceChild(textNode, mark);
|
||||
}
|
||||
});
|
||||
doc.body.normalize();
|
||||
}
|
||||
|
||||
function searchableDocuments(rootDoc) {
|
||||
var items = [];
|
||||
if (!rootDoc) { return items; }
|
||||
items.push({ doc: rootDoc, href: normalizeHref(rootDoc.location && rootDoc.location.href) });
|
||||
var frames = rootDoc.querySelectorAll ? rootDoc.querySelectorAll('iframe') : [];
|
||||
frames.forEach(function(frame) {
|
||||
try {
|
||||
if (frame.contentDocument) {
|
||||
items.push({
|
||||
doc: frame.contentDocument,
|
||||
href: normalizeHref(frame.contentWindow && frame.contentWindow.location && frame.contentWindow.location.href)
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
function textNodes(doc) {
|
||||
if (!doc || !doc.body) { return []; }
|
||||
var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: function(node) {
|
||||
if (!node || !node.nodeValue || !node.nodeValue.trim()) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
var parent = node.parentNode;
|
||||
if (!parent || !parent.nodeName) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
var name = parent.nodeName.toLowerCase();
|
||||
if (['script', 'style', 'noscript'].indexOf(name) >= 0) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
if (parent.classList && (parent.classList.contains('ss-reader-search-mark') || parent.classList.contains('ss-reader-search-mark-active'))) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
}
|
||||
});
|
||||
|
||||
var nodes = [];
|
||||
while (walker.nextNode()) {
|
||||
nodes.push(walker.currentNode);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function applyToDocument(doc, resourceState, keyword) {
|
||||
clearMarks(doc);
|
||||
ensureStyle(doc);
|
||||
if (!doc || !doc.body || !resourceState || !keyword || resourceState.matchCount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
var lowerKeyword = String(keyword).toLowerCase();
|
||||
var activeIndex = typeof resourceState.activeLocalMatchIndex === 'number' ? resourceState.activeLocalMatchIndex : null;
|
||||
var currentLocalIndex = 0;
|
||||
var activeElement = null;
|
||||
|
||||
textNodes(doc).forEach(function(node) {
|
||||
var source = node.nodeValue || '';
|
||||
var lowerSource = source.toLowerCase();
|
||||
var cursor = 0;
|
||||
var replaced = false;
|
||||
var fragment = doc.createDocumentFragment();
|
||||
|
||||
while (cursor < source.length) {
|
||||
var foundIndex = lowerSource.indexOf(lowerKeyword, cursor);
|
||||
if (foundIndex < 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
replaced = true;
|
||||
if (foundIndex > cursor) {
|
||||
fragment.appendChild(doc.createTextNode(source.slice(cursor, foundIndex)));
|
||||
}
|
||||
|
||||
var mark = doc.createElement('span');
|
||||
mark.className = currentLocalIndex === activeIndex ? 'ss-reader-search-mark-active' : 'ss-reader-search-mark';
|
||||
mark.textContent = source.slice(foundIndex, foundIndex + keyword.length);
|
||||
fragment.appendChild(mark);
|
||||
if (currentLocalIndex === activeIndex) {
|
||||
activeElement = mark;
|
||||
}
|
||||
|
||||
currentLocalIndex += 1;
|
||||
cursor = foundIndex + keyword.length;
|
||||
}
|
||||
|
||||
if (!replaced) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cursor < source.length) {
|
||||
fragment.appendChild(doc.createTextNode(source.slice(cursor)));
|
||||
}
|
||||
if (node.parentNode) {
|
||||
node.parentNode.replaceChild(fragment, node);
|
||||
}
|
||||
});
|
||||
|
||||
if (activeElement && activeElement.scrollIntoView) {
|
||||
activeElement.scrollIntoView({ block: 'center', inline: 'center', behavior: 'auto' });
|
||||
}
|
||||
}
|
||||
|
||||
var docs = searchableDocuments(document);
|
||||
var states = new Map();
|
||||
if (payload && Array.isArray(payload.resources)) {
|
||||
payload.resources.forEach(function(resource) {
|
||||
states.set(normalizeHref(resource.href), resource);
|
||||
});
|
||||
}
|
||||
|
||||
docs.forEach(function(item) {
|
||||
applyToDocument(item.doc, states.get(item.href), payload && payload.keyword ? payload.keyword : '');
|
||||
});
|
||||
if (!window.RDReaderBridge) { return false; }
|
||||
window.RDReaderBridge.setSearchPresentation(\(payload));
|
||||
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 {
|
||||
string
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "`", with: "\\`")
|
||||
}
|
||||
|
||||
/// 将高亮数组转换为 JS 可用的字典数组
|
||||
private static func highlightsPayload(_ highlights: [RDEPUBHighlight]) -> [[String: String]] {
|
||||
highlights.compactMap { highlight in
|
||||
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]? {
|
||||
guard let location else { return nil }
|
||||
return [
|
||||
@ -240,6 +127,7 @@ enum RDEPUBJavaScriptBridge {
|
||||
]
|
||||
}
|
||||
|
||||
/// 将搜索展示信息转换为 JS 可用的字典
|
||||
private static func searchPayload(_ presentation: RDEPUBSearchPresentation?) -> [String: Any]? {
|
||||
guard let presentation else { return nil }
|
||||
return [
|
||||
@ -254,6 +142,7 @@ enum RDEPUBJavaScriptBridge {
|
||||
]
|
||||
}
|
||||
|
||||
/// 将任意对象序列化为 JSON 字符串,失败时返回 fallback
|
||||
private static func jsonString(from object: Any?, fallback: String) -> String {
|
||||
guard let object else { return fallback }
|
||||
guard JSONSerialization.isValidJSONObject(object),
|
||||
|
||||
@ -1,36 +1,71 @@
|
||||
//
|
||||
// RDEPUBModels.swift
|
||||
// RDReaderView
|
||||
//
|
||||
// EPUBCore 层核心数据模型定义文件。
|
||||
// 包含 EPUB 解析过程中所需的全部基础枚举和数据结构:
|
||||
// 布局类型、阅读方向、页面分布、元数据、清单项、Spine 项、目录项及解析错误类型。
|
||||
// 这些模型由 RDEPUBParser 解析后填充,并由 RDEPUBPublication 聚合对外暴露。
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// EPUB 版面布局类型
|
||||
/// - reflowable: 可重排布局,内容根据屏幕尺寸和字号自动分页(小说类 EPUB)
|
||||
/// - fixed: 固定版式布局,页面尺寸固定不变(漫画、绘本类 EPUB)
|
||||
public enum RDEPUBLayout: String, Codable {
|
||||
case reflowable
|
||||
case fixed
|
||||
}
|
||||
|
||||
/// 阅读配置文件类型,决定使用哪种渲染路径
|
||||
/// - webInteractive: 可重排 + 含交互脚本,使用 WKWebView 渲染
|
||||
/// - webFixedLayout: 固定版式 EPUB(漫画、绘本),使用 WKWebView 渲染
|
||||
/// - textReflowable: 纯文本可重排 EPUB,使用 DTCoreText 渲染(性能更优)
|
||||
public enum RDEPUBReadingProfile: String, Codable {
|
||||
case webInteractive
|
||||
case webFixedLayout
|
||||
case textReflowable
|
||||
}
|
||||
|
||||
/// 阅读方向(文字排版方向)
|
||||
/// - ltr: 从左到右(英文、中文横排)
|
||||
/// - rtl: 从右到左(日文竖排、阿拉伯文)
|
||||
/// - auto: 根据语言自动判断
|
||||
public enum RDEPUBReadingProgression: String, Codable {
|
||||
case ltr
|
||||
case rtl
|
||||
case auto
|
||||
}
|
||||
|
||||
/// 固定版式页面在 spread(双页展开)中的位置
|
||||
/// - left: 左页
|
||||
/// - right: 右页
|
||||
/// - center: 居中(跨页或单页居中)
|
||||
public enum RDEPUBPageSpread: String, Codable {
|
||||
case left
|
||||
case right
|
||||
case center
|
||||
}
|
||||
|
||||
/// EPUB 出版物元数据,对应 OPF 文档中 <metadata> 节点的内容
|
||||
/// 包含书籍的基本信息:标识符、标题、作者、语言、版本、布局类型等
|
||||
public struct RDEPUBMetadata: Codable, Equatable {
|
||||
/// 书籍唯一标识符(对应 <dc:identifier>)
|
||||
public var identifier: String?
|
||||
/// 书名(对应 <dc:title>)
|
||||
public var title: String
|
||||
/// 作者(对应 <dc:creator>)
|
||||
public var author: String?
|
||||
/// 语言代码(对应 <dc:language>,如 "zh"、"en")
|
||||
public var language: String?
|
||||
/// EPUB 版本号(如 "2.0"、"3.0")
|
||||
public var version: String?
|
||||
/// 版面布局类型(reflowable 或 fixed)
|
||||
public var layout: RDEPUBLayout
|
||||
/// spread 模式设置(对应 <meta property="rendition:spread">)
|
||||
public var spread: String?
|
||||
/// 阅读方向(ltr / rtl / auto)
|
||||
public var readingProgression: RDEPUBReadingProgression
|
||||
|
||||
public init(
|
||||
@ -54,13 +89,22 @@ public struct RDEPUBMetadata: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// EPUB 清单项,对应 OPF 文档 <manifest> 中的每个 <item>
|
||||
/// 描述一个资源文件的 ID、路径、MIME 类型和附加属性
|
||||
public struct RDEPUBManifestItem: Codable, Equatable {
|
||||
/// 清单项的唯一标识符(对应 <item id="...">)
|
||||
public var id: String
|
||||
/// 资源文件路径,相对于 OPF 文件所在目录
|
||||
public var href: String
|
||||
/// MIME 类型(如 "application/xhtml+xml"、"image/jpeg"、"text/css")
|
||||
public var mediaType: String
|
||||
/// 属性列表(如 "nav" 表示导航文档、"scripted" 表示含脚本、"cover-image" 表示封面)
|
||||
public var properties: [String]
|
||||
/// 回退项 ID(对应 <item fallback="...">)
|
||||
public var fallback: String?
|
||||
/// 媒体叠加文件 ID(EPUB 3 有声书功能)
|
||||
public var mediaOverlay: String?
|
||||
/// 资源标题(用于搜索结果展示等辅助用途)
|
||||
public var title: String?
|
||||
|
||||
public init(
|
||||
@ -81,23 +125,37 @@ public struct RDEPUBManifestItem: Codable, Equatable {
|
||||
self.title = title
|
||||
}
|
||||
|
||||
/// 是否为 EPUB 3 导航文档(Nav Document)
|
||||
/// 通过检查 properties 中是否包含 "nav" 标记判断
|
||||
public var isNavigationDocument: Bool {
|
||||
properties.contains("nav")
|
||||
}
|
||||
|
||||
/// 是否为 NCX 目录文件(EPUB 2 格式)
|
||||
/// 通过 MIME 类型 "application/x-dtbncx+xml" 判断
|
||||
public var isNCX: Bool {
|
||||
mediaType == "application/x-dtbncx+xml"
|
||||
}
|
||||
}
|
||||
|
||||
/// EPUB Spine 项,对应 OPF 文档 <spine> 中的每个 <itemref>
|
||||
/// Spine 定义了阅读器中章节内容的线性阅读顺序
|
||||
public struct RDEPUBSpineItem: Codable, Equatable {
|
||||
/// 对应 manifest 中的 item id(用于关联资源)
|
||||
public var idref: String
|
||||
/// 标准化后的资源路径(相对于 OPF 文件所在目录)
|
||||
public var href: String
|
||||
/// MIME 类型
|
||||
public var mediaType: String
|
||||
/// 章节标题(从目录或 manifest 推断)
|
||||
public var title: String
|
||||
/// 是否为线性内容("no" 表示补充材料,不影响主线阅读进度)
|
||||
public var linear: Bool
|
||||
/// 属性列表(如 rendition:layout-* 用于单页布局覆盖)
|
||||
public var properties: [String]
|
||||
/// 页面在 spread 中的位置偏好(左页/右页/居中)
|
||||
public var pageSpread: RDEPUBPageSpread?
|
||||
/// 单页布局覆盖(显式属性可覆盖出版级别的 layout 设置)
|
||||
public var layout: RDEPUBLayout?
|
||||
|
||||
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 var title: String
|
||||
/// 目标资源路径(含可能的 fragment 锚点)
|
||||
public var href: String
|
||||
/// 子目录项列表(递归结构,支持多级目录)
|
||||
public var 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 {
|
||||
/// ZIP 文件无法打开(文件损坏或格式不支持)
|
||||
case archiveOpenFailed(URL)
|
||||
/// 缺少 META-INF/container.xml 文件(EPUB 格式不合规)
|
||||
case missingContainerXML
|
||||
/// container.xml 中未找到 <rootfile> 元素
|
||||
case missingRootFile
|
||||
/// OPF 根文件路径无效(文件不存在)
|
||||
case invalidRootFilePath(String)
|
||||
/// XML 解析失败(格式错误或编码问题)
|
||||
case invalidXML(URL)
|
||||
/// spine 中引用的 manifest 项不存在(idref 不匹配)
|
||||
case missingManifestItem(idref: String)
|
||||
/// 构建完成的 spine 为空(无可阅读的内容)
|
||||
case emptySpine
|
||||
|
||||
public var errorDescription: String? {
|
||||
|
||||
@ -1,10 +1,22 @@
|
||||
// RDEPUBNavigatorLayoutContext.swift
|
||||
// 导航器布局上下文
|
||||
// 封装阅读器容器的布局参数:容器尺寸、每屏页数、安全区域、设备类型、
|
||||
// 重排内容内边距等,供 Paginator 和渲染层使用。
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 导航器布局上下文,描述阅读器容器的布局参数
|
||||
/// 在 Paginator 计算分页和 WebView 渲染时作为核心输入
|
||||
public struct RDEPUBNavigatorLayoutContext: Equatable {
|
||||
/// 容器视图的总尺寸
|
||||
public var containerSize: CGSize
|
||||
/// 每屏显示的页数(横屏双页模式为 2)
|
||||
public var pagesPerScreen: Int
|
||||
/// 安全区域内边距(刘海屏、Home Indicator 等)
|
||||
public var safeAreaInsets: UIEdgeInsets
|
||||
/// 用户界面设备类型(phone/pad)
|
||||
public var userInterfaceIdiom: UIUserInterfaceIdiom
|
||||
/// 重排模式下的内容内边距(上下左右留白)
|
||||
public var reflowableContentInsets: UIEdgeInsets
|
||||
|
||||
public init(
|
||||
@ -21,6 +33,7 @@ public struct RDEPUBNavigatorLayoutContext: Equatable {
|
||||
self.reflowableContentInsets = reflowableContentInsets
|
||||
}
|
||||
|
||||
/// 计算单页视口尺寸(双页模式下宽度除以 2)
|
||||
public var viewportSize: CGSize {
|
||||
let width: CGFloat
|
||||
if pagesPerScreen > 1 {
|
||||
@ -31,6 +44,7 @@ public struct RDEPUBNavigatorLayoutContext: Equatable {
|
||||
return CGSize(width: width, height: containerSize.height)
|
||||
}
|
||||
|
||||
/// 固定版式模式下的内容内边距(仅手机端使用安全区域,Pad 端归零;左右取较大值对称)
|
||||
public var fixedContentInset: UIEdgeInsets {
|
||||
var insets = safeAreaInsets
|
||||
if userInterfaceIdiom != .phone {
|
||||
|
||||
@ -1,13 +1,33 @@
|
||||
//
|
||||
// RDEPUBNavigatorState.swift
|
||||
// RDReaderView
|
||||
//
|
||||
// EPUBCore 层导航状态机定义文件。
|
||||
// 定义了阅读器导航器的状态枚举,状态流转遵循:
|
||||
// initializing → loading → idle ↔ jumping/moving/repaginating
|
||||
// 由 RDEPUBReadingSession 管理状态跃迁,控制分页快照的应用时机。
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 导航状态枚举,描述阅读器的当前操作状态
|
||||
/// 状态机流转:initializing → loading → idle ↔ jumping/moving/repaginating
|
||||
public enum RDEPUBNavigatorState: String, Codable {
|
||||
/// 初始化状态,刚打开书籍,等待初始恢复位置
|
||||
case initializing
|
||||
/// 加载状态,正在生成首轮分页模型
|
||||
case loading
|
||||
/// 空闲状态,显示稳定,可接受用户操作和快照应用
|
||||
case idle
|
||||
/// 跳转状态,正在执行目录/内部链接跳转
|
||||
case jumping
|
||||
/// 翻页状态,用户正在翻页操作中
|
||||
case moving
|
||||
/// 重排状态,正在重新分页(字号/横竖屏变化触发)
|
||||
case repaginating
|
||||
|
||||
/// 是否处于可应用分页快照的稳定状态
|
||||
/// 只有 idle 状态才允许消费 staged snapshot
|
||||
public var isStableForSnapshotApplication: Bool {
|
||||
self == .idle
|
||||
}
|
||||
|
||||
@ -1,25 +1,45 @@
|
||||
// RDEPUBPaginator.swift
|
||||
// EPUB 离屏分页计算器
|
||||
// 使用隐藏的 WKWebView 加载每个 spine 项,通过 JS 测量 scrollWidth
|
||||
// 计算每章的页数。采用多轮延迟测量(0ms/80ms/180ms)确保布局稳定。
|
||||
// 固定版式(Fixed Layout)直接返回 1 页。
|
||||
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
/// EPUB 分页计算器,通过离屏 WebView 测量每个 spine 项的分页数
|
||||
public final class RDEPUBPaginator: NSObject {
|
||||
/// 当前解析器引用
|
||||
private var parser: RDEPUBParser?
|
||||
/// 承载 WebView 的父视图(弱引用)
|
||||
private weak var hostingView: UIView?
|
||||
/// 当前展示样式(视口尺寸、内边距、字号、行高)
|
||||
private var presentation = RDEPUBPresentationStyle(
|
||||
viewportSize: .zero,
|
||||
contentInsets: .zero,
|
||||
fontSize: 16,
|
||||
lineHeightMultiple: 1.5
|
||||
)
|
||||
/// 全量分页完成回调(返回每个 spine 项的页数数组)
|
||||
private var completion: (([Int]) -> Void)?
|
||||
/// 单个 spine 项分页完成回调
|
||||
private var singlePageCountCompletion: ((Int) -> Void)?
|
||||
/// 各 spine 项的页数结果
|
||||
private var pageCounts: [Int] = []
|
||||
/// 待测量的 spine 项索引列表
|
||||
private var measurementIndices: [Int] = []
|
||||
/// 当前测量偏移量(在 measurementIndices 中的位置)
|
||||
private var currentMeasurementOffset = 0
|
||||
/// 当前 spine 项的待定页数(多轮测量取最大值)
|
||||
private var pendingMeasurementValue = 1
|
||||
/// 当前测量轮次(0/1/2 对应延迟 0ms/80ms/180ms)
|
||||
private var measurementPass = 0
|
||||
/// 当前测量会话 ID,用于防并发和取消过期任务
|
||||
private var activeSessionID = UUID()
|
||||
/// 调试日志作用域标识
|
||||
private let debugScope = "PaginatorWebView"
|
||||
|
||||
/// 用于分页测量的隐藏 WebView(非持久化数据存储,禁止用户交互)
|
||||
private lazy var webView: WKWebView = {
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.websiteDataStore = .nonPersistent()
|
||||
@ -52,6 +72,12 @@ public final class RDEPUBPaginator: NSObject {
|
||||
webView.removeFromSuperview()
|
||||
}
|
||||
|
||||
/// 计算所有 spine 项的分页数
|
||||
/// - Parameters:
|
||||
/// - parser: EPUB 解析器
|
||||
/// - hostingView: 承载 WebView 的父视图
|
||||
/// - presentation: 展示样式(视口、内边距、字号、行高)
|
||||
/// - completion: 完成回调,返回每个 spine 项的页数数组
|
||||
public func calculate(
|
||||
parser: RDEPUBParser,
|
||||
hostingView: UIView,
|
||||
@ -82,6 +108,7 @@ public final class RDEPUBPaginator: NSObject {
|
||||
measureNextSpineItem()
|
||||
}
|
||||
|
||||
/// 便捷方法:使用独立参数计算所有 spine 项的分页数
|
||||
public func calculate(
|
||||
parser: RDEPUBParser,
|
||||
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(
|
||||
parser: RDEPUBParser,
|
||||
spineIndex: Int,
|
||||
@ -135,6 +169,7 @@ public final class RDEPUBPaginator: NSObject {
|
||||
measureNextSpineItem()
|
||||
}
|
||||
|
||||
/// 便捷方法:使用独立参数计算单个 spine 项的分页数
|
||||
public func calculateSingleSpinePageCount(
|
||||
parser: RDEPUBParser,
|
||||
spineIndex: Int,
|
||||
@ -159,6 +194,7 @@ public final class RDEPUBPaginator: NSObject {
|
||||
)
|
||||
}
|
||||
|
||||
/// 静态方法:将分页 CSS 注入到 HTML 字符串中(用于预渲染场景)
|
||||
public static func injectPaginationCSS(
|
||||
into html: String,
|
||||
viewportSize: CGSize,
|
||||
@ -181,6 +217,7 @@ public final class RDEPUBPaginator: NSObject {
|
||||
)
|
||||
}
|
||||
|
||||
/// 测量下一个 spine 项:加载文件到 WebView,跳过不可渲染或文件缺失的项
|
||||
private func measureNextSpineItem() {
|
||||
guard let parser else {
|
||||
finishIfNeeded()
|
||||
@ -218,10 +255,12 @@ public final class RDEPUBPaginator: NSObject {
|
||||
webView.loadFileURL(fileURL, allowingReadAccessTo: readAccessURL)
|
||||
}
|
||||
|
||||
/// 获取当前正在测量的 spine 项索引
|
||||
private func currentSpineIndexForMeasurement() -> Int? {
|
||||
measurementIndices[safe: currentMeasurementOffset]
|
||||
}
|
||||
|
||||
/// 检查是否所有 spine 项都已测量完毕,触发对应的完成回调
|
||||
private func finishIfNeeded() {
|
||||
if let singlePageCountCompletion {
|
||||
let measuredSpineIndex = measurementIndices.first ?? 0
|
||||
@ -236,6 +275,7 @@ public final class RDEPUBPaginator: NSObject {
|
||||
cleanupMeasurementState()
|
||||
}
|
||||
|
||||
/// 清理测量状态,释放 WebView 和所有引用
|
||||
private func cleanupMeasurementState() {
|
||||
parser = nil
|
||||
hostingView = nil
|
||||
@ -251,11 +291,13 @@ public final class RDEPUBPaginator: NSObject {
|
||||
webView.removeFromSuperview()
|
||||
}
|
||||
|
||||
/// 判断 spine 项是否可渲染(仅 HTML/XHTML/XML 类型可分页)
|
||||
private func isRenderablePage(item: RDEPUBSpineItem) -> Bool {
|
||||
let mediaType = item.mediaType.lowercased()
|
||||
return mediaType.contains("html") || mediaType.contains("xhtml") || mediaType.contains("xml")
|
||||
}
|
||||
|
||||
/// 调度多轮测量:0ms/80ms/180ms 三轮延迟,取最大值以应对布局抖动
|
||||
private func scheduleMeasurementPass() {
|
||||
let sessionID = activeSessionID
|
||||
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) {
|
||||
let script = RDEPUBStyleSheetBuilder.measurementScript(for: presentation)
|
||||
RDEPUBWebViewDebug.logJavaScript(debugScope, webView: webView, action: "measure", details: "session=\(sessionID) pass=\(measurementPass)")
|
||||
@ -332,6 +375,7 @@ extension RDEPUBPaginator: WKNavigationDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
/// Array 安全下标扩展,越界时返回 nil 而非崩溃
|
||||
private extension Array {
|
||||
subscript(safe index: Int) -> Element? {
|
||||
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 ZIPFoundation
|
||||
|
||||
extension RDEPUBParser {
|
||||
/// 解析 container.xml,提取 OPF(Package Document)的相对路径
|
||||
/// - Parameter containerURL: container.xml 的文件路径
|
||||
/// - Returns: OPF 文件的相对路径(如 "OEBPS/content.opf")
|
||||
func parseContainerRootFile(at containerURL: URL) throws -> String {
|
||||
guard let parser = XMLParser(contentsOf: containerURL) else {
|
||||
throw RDEPUBParserError.invalidXML(containerURL)
|
||||
@ -22,6 +30,10 @@ extension RDEPUBParser {
|
||||
return rootFilePath
|
||||
}
|
||||
|
||||
/// 解压 EPUB ZIP 文件到缓存目录(幂等:已存在则跳过)
|
||||
/// 目录名由 slug + 文件大小 + 修改时间戳组成,避免冲突
|
||||
/// - Parameter epubURL: EPUB 文件路径
|
||||
/// - Returns: 解压目标目录 URL
|
||||
func extractArchiveIfNeeded(epubURL: URL) throws -> URL {
|
||||
let fileManager = FileManager.default
|
||||
let extractionURL = temporaryExtractionDirectory(for: epubURL)
|
||||
@ -52,6 +64,8 @@ extension RDEPUBParser {
|
||||
return extractionURL
|
||||
}
|
||||
|
||||
/// 计算 EPUB 的临时解压目录路径
|
||||
/// 路径格式:~/Library/Caches/ssreaderview-epub/{slug}-{fileSize}-{modifiedTimestamp}/
|
||||
func temporaryExtractionDirectory(for epubURL: URL) -> URL {
|
||||
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
||||
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
|
||||
@ -65,12 +79,14 @@ extension RDEPUBParser {
|
||||
return baseURL.appendingPathComponent("\(slug)-\(fileSize)-\(signature)", isDirectory: true)
|
||||
}
|
||||
|
||||
/// 重置解析器状态(清除解压路径和出版物数据)
|
||||
func reset() {
|
||||
extractionRootURL = nil
|
||||
opfURL = nil
|
||||
resetPublicationState()
|
||||
}
|
||||
|
||||
/// 重置出版物相关状态(metadata、manifest、spine、目录)
|
||||
func resetPublicationState() {
|
||||
metadata = RDEPUBMetadata()
|
||||
manifest = [:]
|
||||
@ -79,7 +95,9 @@ extension RDEPUBParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// container.xml 的 SAX 解析代理,提取 <rootfile> 元素的 full-path 属性
|
||||
private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate {
|
||||
/// 解析得到的 OPF 文件相对路径
|
||||
private(set) var rootFilePath: String?
|
||||
|
||||
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
|
||||
|
||||
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] {
|
||||
let opfDirectoryURL = opfURL.deletingLastPathComponent()
|
||||
let publicationLayout = packageDocument.metadata.layout
|
||||
@ -50,22 +62,33 @@ extension RDEPUBParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// OPF 包文档的解析结果,包含 metadata、manifest、spine 引用及 NCX/nav 项
|
||||
struct OPFPackageDocument {
|
||||
var metadata: RDEPUBMetadata
|
||||
var manifest: [RDEPUBManifestItem]
|
||||
var manifestByID: [String: RDEPUBManifestItem]
|
||||
var spineReferences: [OPFSpineReference]
|
||||
/// NCX 目录文件的 manifest 项(EPUB 2)
|
||||
var ncxItem: RDEPUBManifestItem?
|
||||
/// Navigation Document 的 manifest 项(EPUB 3)
|
||||
var navigationItem: RDEPUBManifestItem?
|
||||
}
|
||||
|
||||
/// OPF 中 spine/itemref 元素的解析结果
|
||||
struct OPFSpineReference {
|
||||
/// 对应 manifest 中的 item id
|
||||
var idref: String
|
||||
/// 是否为线性阅读项("no" 表示辅助内容)
|
||||
var linear: Bool
|
||||
/// 属性列表(如 rendition:layout-pre-paginated、page-spread-left 等)
|
||||
var properties: [String]
|
||||
/// 页面展开方向(左/右/居中)
|
||||
var pageSpread: RDEPUBPageSpread?
|
||||
}
|
||||
|
||||
/// OPF 文档的 SAX 解析代理
|
||||
/// 解析 metadata(identifier/title/creator/language/rendition:layout 等)、
|
||||
/// manifest(item 元素)、spine(itemref 元素)三个主要区域
|
||||
final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
||||
private var metadata = RDEPUBMetadata()
|
||||
private var manifestItems: [RDEPUBManifestItem] = []
|
||||
@ -79,6 +102,7 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
||||
private var currentMetaRefinesID: String?
|
||||
private var ncxID: String?
|
||||
|
||||
/// 将解析中间结果组装为 OPFPackageDocument 结构体
|
||||
func packageDocument() -> OPFPackageDocument {
|
||||
if metadata.identifier == nil, let fallbackIdentifier = identifierByID.values.first {
|
||||
metadata.identifier = fallbackIdentifier
|
||||
@ -227,6 +251,7 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
||||
xmlContext.endElement()
|
||||
}
|
||||
|
||||
/// 当前解析所在的 OPF 区域(metadata/manifest/spine/other)
|
||||
private var currentSection: XMLSection {
|
||||
if xmlContext.containsElement(named: "manifest") {
|
||||
return .manifest
|
||||
@ -240,6 +265,7 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
||||
return .other
|
||||
}
|
||||
|
||||
/// 处理 <meta> 元素的文本内容,提取 rendition:layout、rendition:spread、identifier 等
|
||||
private func applyMetaValue(_ value: String) {
|
||||
let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
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? {
|
||||
if let properties = attributes["properties"]?.lowercased() {
|
||||
if properties.contains("page-spread-left") {
|
||||
@ -293,11 +320,13 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 将 rendition:layout 原始值转换为布局枚举
|
||||
private func layout(from rawValue: String) -> RDEPUBLayout {
|
||||
rawValue.lowercased().contains("pre-paginated") ? .fixed : .reflowable
|
||||
}
|
||||
}
|
||||
|
||||
/// OPF 文档的区域枚举,用于区分当前解析位置
|
||||
enum XMLSection {
|
||||
case metadata
|
||||
case manifest
|
||||
@ -305,11 +334,14 @@ enum XMLSection {
|
||||
case other
|
||||
}
|
||||
|
||||
/// XML 名称解析工具:提取本地名、分词、提取 refines ID
|
||||
enum XMLName {
|
||||
/// 从带命名空间前缀的名称中提取本地名(如 "dc:title" -> "title")
|
||||
static func localName(from rawName: String) -> String {
|
||||
rawName.split(separator: ":").last.map(String.init) ?? rawName
|
||||
}
|
||||
|
||||
/// 将空格分隔的属性值拆分为 token 数组
|
||||
static func tokenize(_ rawValue: String?) -> [String] {
|
||||
guard let rawValue else { return [] }
|
||||
return rawValue
|
||||
@ -317,6 +349,7 @@ enum XMLName {
|
||||
.map(String.init)
|
||||
}
|
||||
|
||||
/// 从 refines 属性值中提取 ID(去掉 "#" 前缀)
|
||||
static func refinedID(from rawValue: String) -> String? {
|
||||
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.hasPrefix("#") else {
|
||||
@ -326,10 +359,14 @@ enum XMLName {
|
||||
}
|
||||
}
|
||||
|
||||
/// XML 解析上下文,维护元素栈和当前文本内容
|
||||
final class XMLParserContext {
|
||||
/// 元素名称栈,用于追踪嵌套层级
|
||||
private var elementStack: [String] = []
|
||||
/// 当前元素累积的文本内容
|
||||
private var currentCharacters = ""
|
||||
|
||||
/// 去除空白后的当前文本
|
||||
var trimmedCharacters: String {
|
||||
currentCharacters.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
// RDEPUBParser+ReadingProfile.swift
|
||||
// EPUB 阅读配置文件判断
|
||||
// 根据 metadata 的 layout 属性和 manifest/spine 内容,
|
||||
// 将出版物分类为 webFixedLayout(固定版式)、webInteractive(含脚本/多媒体)或 textReflowable(纯文本重排)。
|
||||
|
||||
import Foundation
|
||||
|
||||
extension RDEPUBParser {
|
||||
/// 判断出版物的阅读配置文件
|
||||
/// 优先判断固定版式,其次检测交互内容(脚本/iframe/视频等),默认为文本重排
|
||||
public func readingProfile() -> RDEPUBReadingProfile {
|
||||
if metadata.layout == .fixed {
|
||||
return .webFixedLayout
|
||||
@ -8,6 +15,9 @@ extension RDEPUBParser {
|
||||
return hasInteractiveContent() ? .webInteractive : .textReflowable
|
||||
}
|
||||
|
||||
/// 检测出版物是否包含交互内容
|
||||
/// 通过 manifest 中的脚本媒体类型和 "scripted" 属性,
|
||||
/// 以及扫描 HTML 中的 script/iframe/video/audio/canvas/svg/form 等标签
|
||||
public func hasInteractiveContent() -> Bool {
|
||||
let interactiveMediaTypes = [
|
||||
"application/javascript",
|
||||
|
||||
@ -1,11 +1,18 @@
|
||||
// RDEPUBParser+Resources.swift
|
||||
// EPUB 资源查询与 URL 解析
|
||||
// 提供按 ID、href 查询 manifest 项,spine 索引与 href 互转,
|
||||
// 文件路径与 ss-reader:// 协议 URL 互转,封面图提取,HTML 内容读取等能力。
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
extension RDEPUBParser {
|
||||
/// 按 ID 查询 manifest 项
|
||||
public func manifestItem(for id: String) -> RDEPUBManifestItem? {
|
||||
manifest[id]
|
||||
}
|
||||
|
||||
/// 按 href 查询 manifest 项(忽略 fragment)
|
||||
public func manifestItem(forHref href: String) -> RDEPUBManifestItem? {
|
||||
let target = href.components(separatedBy: "#").first ?? href
|
||||
return manifest.values.first { item in
|
||||
@ -13,11 +20,13 @@ extension RDEPUBParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// 通过 spine 索引获取 href
|
||||
public func href(forSpineIndex index: Int) -> String? {
|
||||
guard spine.indices.contains(index) else { return nil }
|
||||
return spine[index].href
|
||||
}
|
||||
|
||||
/// 将相对路径转换为本地文件 URL,并校验是否在解压根目录内(防路径遍历攻击)
|
||||
public func fileURL(forRelativePath relativePath: String) -> URL? {
|
||||
guard let opfDirectoryURL else { return nil }
|
||||
let path = relativePath.components(separatedBy: "#").first ?? relativePath
|
||||
@ -32,6 +41,7 @@ extension RDEPUBParser {
|
||||
return resolvedURL
|
||||
}
|
||||
|
||||
/// 将相对路径转换为 ss-reader://book/ 协议的 URL(用于 WebView 加载资源)
|
||||
public func resourceURL(forRelativePath relativePath: String) -> URL? {
|
||||
let normalizedPath = relativePath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard !normalizedPath.isEmpty else { return nil }
|
||||
@ -45,6 +55,7 @@ extension RDEPUBParser {
|
||||
return components.url
|
||||
}
|
||||
|
||||
/// 将 ss-reader://book/ 协议的 URL 还原为本地文件 URL
|
||||
public func fileURL(forResourceURL resourceURL: URL) -> URL? {
|
||||
guard resourceURL.scheme == RDEPUBResourceURLSchemeHandler.scheme,
|
||||
resourceURL.host == RDEPUBResourceURLSchemeHandler.host else {
|
||||
@ -54,6 +65,7 @@ extension RDEPUBParser {
|
||||
return fileURL(forRelativePath: relativePath)
|
||||
}
|
||||
|
||||
/// 提取封面图片(优先查找含 "cover-image" 属性或 ID 含 "cover" 的 manifest 项)
|
||||
public func coverImage() -> UIImage? {
|
||||
let coverCandidates = manifest.values.filter { item in
|
||||
item.properties.contains("cover-image") || item.id.lowercased().contains("cover")
|
||||
@ -67,6 +79,7 @@ extension RDEPUBParser {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 通过 spine 索引读取 HTML 内容
|
||||
public func htmlString(forSpineIndex index: Int) -> String? {
|
||||
guard spine.indices.contains(index) else {
|
||||
return nil
|
||||
@ -74,6 +87,7 @@ extension RDEPUBParser {
|
||||
return htmlString(forRelativePath: spine[index].href)
|
||||
}
|
||||
|
||||
/// 通过相对路径读取 HTML 内容
|
||||
public func htmlString(forRelativePath relativePath: String) -> String? {
|
||||
guard let fileURL = fileURL(forRelativePath: relativePath) else {
|
||||
return nil
|
||||
@ -81,6 +95,7 @@ extension RDEPUBParser {
|
||||
return try? String(contentsOf: fileURL)
|
||||
}
|
||||
|
||||
/// 将 href 相对于目录 URL 解析为标准化的相对路径
|
||||
func normalize(href: String, relativeTo directoryURL: URL) -> String {
|
||||
guard let resolvedURL = URL(string: href, relativeTo: directoryURL)?.standardizedFileURL else {
|
||||
return href
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
// RDEPUBParser+TOC.swift
|
||||
// EPUB 目录解析
|
||||
// 支持两种目录格式:NCX(EPUB 2)和 Navigation Document(EPUB 3),
|
||||
// 优先使用 NCX,其次 Nav Document,最终回退到 spine 列表。
|
||||
// 内部使用 TOCTreeBuilder 构建树状目录结构。
|
||||
|
||||
import Foundation
|
||||
|
||||
extension RDEPUBParser {
|
||||
/// 从 OPF 解析结果中解析目录
|
||||
/// 优先尝试 NCX,再尝试 Nav Document,两者都失败则回退到 spine 列表
|
||||
func parseTOC(from packageDocument: OPFPackageDocument, opfURL: URL) -> [EPUBTableOfContentsItem] {
|
||||
let opfDirectoryURL = opfURL.deletingLastPathComponent()
|
||||
|
||||
@ -23,6 +31,7 @@ extension RDEPUBParser {
|
||||
return spine.map { EPUBTableOfContentsItem(title: $0.title, href: $0.href) }
|
||||
}
|
||||
|
||||
/// 解析 NCX(EPUB 2)目录文件
|
||||
func parseNCXDocumentItems(at ncxURL: URL) -> [EPUBTableOfContentsItem] {
|
||||
guard FileManager.default.fileExists(atPath: ncxURL.path),
|
||||
let parser = XMLParser(contentsOf: ncxURL) else {
|
||||
@ -40,6 +49,7 @@ extension RDEPUBParser {
|
||||
return delegate.items
|
||||
}
|
||||
|
||||
/// 解析 Navigation Document(EPUB 3)目录文件
|
||||
func parseNavDocumentItems(at navURL: URL, baseURL: URL) -> [EPUBTableOfContentsItem] {
|
||||
guard FileManager.default.fileExists(atPath: navURL.path),
|
||||
let parser = XMLParser(contentsOf: navURL) else {
|
||||
@ -57,6 +67,7 @@ extension RDEPUBParser {
|
||||
return delegate.items
|
||||
}
|
||||
|
||||
/// 标准化目录 href(拆分路径和 fragment,对路径部分做相对路径解析)
|
||||
func normalizeTOCHref(_ href: String, relativeTo baseURL: URL) -> String? {
|
||||
let components = href.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
let pathPart = components.first.map(String.init) ?? href
|
||||
@ -80,11 +91,13 @@ extension RDEPUBParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// 目录树节点,存储标题、href 和子节点,最终转换为 EPUBTableOfContentsItem
|
||||
private final class TOCNode {
|
||||
var title = ""
|
||||
var href: String?
|
||||
var children: [TOCNode] = []
|
||||
|
||||
/// 将节点及其子节点递归转换为目录项
|
||||
func asItem() -> EPUBTableOfContentsItem? {
|
||||
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let href, !href.isEmpty else {
|
||||
@ -98,10 +111,14 @@ private final class TOCNode {
|
||||
}
|
||||
}
|
||||
|
||||
/// 目录树构建器:通过 beginNode/endNode 操作维护节点栈,构建嵌套目录结构
|
||||
private final class TOCTreeBuilder {
|
||||
/// 当前嵌套路径上的节点栈
|
||||
private var nodeStack: [TOCNode] = []
|
||||
/// 顶层根节点列表
|
||||
private var rootNodes: [TOCNode] = []
|
||||
|
||||
/// 获取构建完成的目录项列表
|
||||
var items: [EPUBTableOfContentsItem] {
|
||||
rootNodes.compactMap { $0.asItem() }
|
||||
}
|
||||
@ -134,6 +151,7 @@ private final class TOCTreeBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
/// NCX 文档的 SAX 解析代理,解析 <navPoint>、<content>、<text> 元素
|
||||
private final class NCXParserDelegate: NSObject, XMLParserDelegate {
|
||||
private let baseURL: URL
|
||||
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 let baseURL: URL
|
||||
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 {
|
||||
if let epubType = attributes["epub:type"]?.lowercased(), epubType.contains("toc") {
|
||||
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
|
||||
|
||||
/// EPUB 解析器,执行从 epub 文件到出版物数据的完整解析流程
|
||||
/// 主链路:epubURL → extractArchiveIfNeeded → container.xml → OPF → metadata/manifest/spine/TOC
|
||||
public final class RDEPUBParser {
|
||||
/// 出版物元数据(标题、作者、语言、布局模式等)
|
||||
public internal(set) var metadata = RDEPUBMetadata()
|
||||
/// 资源清单字典(key 为 manifest item ID)
|
||||
public internal(set) var manifest: [String: RDEPUBManifestItem] = [:]
|
||||
/// 阅读顺序列表(按 spine 排列的资源项)
|
||||
public internal(set) var spine: [RDEPUBSpineItem] = []
|
||||
/// 目录树(从 NCX 或 Nav Document 解析得到)
|
||||
public internal(set) var tableOfContents: [EPUBTableOfContentsItem] = []
|
||||
/// EPUB 解压后的根目录路径
|
||||
public internal(set) var extractionRootURL: URL?
|
||||
/// OPF 文件的完整路径
|
||||
public internal(set) var opfURL: URL?
|
||||
|
||||
/// OPF 文件所在目录的 URL(用于解析相对路径)
|
||||
public var opfDirectoryURL: URL? {
|
||||
opfURL?.deletingLastPathComponent()
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
/// 基于当前解析结果创建 RDEPUBPublication(门面对象)
|
||||
public func makePublication() -> RDEPUBPublication {
|
||||
RDEPUBPublication(parser: self)
|
||||
}
|
||||
|
||||
/// 完整解析 EPUB 文件:解压 → container.xml → OPF
|
||||
/// - Parameter epubURL: EPUB 文件的本地路径
|
||||
public func parse(epubURL: URL) throws {
|
||||
reset()
|
||||
|
||||
@ -38,6 +56,8 @@ public final class RDEPUBParser {
|
||||
try parseOPF(at: packageURL)
|
||||
}
|
||||
|
||||
/// 解析 OPF 文件:metadata/manifest/spine/TOC
|
||||
/// - Parameter opfURL: OPF 文件路径
|
||||
public func parseOPF(at opfURL: URL) throws {
|
||||
resetPublicationState()
|
||||
|
||||
@ -61,10 +81,12 @@ public final class RDEPUBParser {
|
||||
self.tableOfContents = parseTOC(from: packageDocument, opfURL: opfURL)
|
||||
}
|
||||
|
||||
/// 获取已解析的目录
|
||||
public func parseTOC() -> [EPUBTableOfContentsItem] {
|
||||
tableOfContents
|
||||
}
|
||||
|
||||
/// 解析指定的 Navigation Document 文件
|
||||
public func parseNavDocument(_ navURL: URL, baseURL: URL? = nil) -> [EPUBTableOfContentsItem] {
|
||||
parseNavDocumentItems(at: navURL, baseURL: baseURL ?? navURL.deletingLastPathComponent())
|
||||
}
|
||||
|
||||
@ -1,14 +1,29 @@
|
||||
// RDEPUBPreferences.swift
|
||||
// EPUB 阅读偏好设置
|
||||
// 封装字号、行高、内容内边距、主题颜色、固定版式适配模式等展示参数,
|
||||
// 并提供从偏好设置生成渲染请求和展示样式的便捷方法。
|
||||
|
||||
import UIKit
|
||||
|
||||
/// EPUB 阅读偏好设置,包含所有影响渲染效果的展示参数
|
||||
public struct RDEPUBPreferences: Equatable {
|
||||
/// 字体大小(pt)
|
||||
public var fontSize: CGFloat
|
||||
/// 行高倍数(如 1.5 表示 1.5 倍行高)
|
||||
public var lineHeightMultiple: CGFloat
|
||||
/// 重排模式下的内容内边距
|
||||
public var reflowableContentInsets: UIEdgeInsets
|
||||
/// 固定版式下的内容内边距
|
||||
public var fixedContentInset: UIEdgeInsets
|
||||
/// 主题背景色(CSS 颜色值,如 "#FFFFFF")
|
||||
public var themeBackgroundColor: String?
|
||||
/// 主题文字色(CSS 颜色值)
|
||||
public var themeTextColor: String?
|
||||
/// 固定版式背景色
|
||||
public var fixedBackgroundColor: String?
|
||||
/// 固定版式适配模式(page/width/height/contain/cover)
|
||||
public var fixedLayoutFit: RDEPUBFixedLayoutFit
|
||||
/// 固定版式双页展开模式(never/always/automatic)
|
||||
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode
|
||||
|
||||
public init(
|
||||
@ -33,6 +48,7 @@ public struct RDEPUBPreferences: Equatable {
|
||||
self.fixedLayoutSpreadMode = fixedLayoutSpreadMode
|
||||
}
|
||||
|
||||
/// 从偏好设置生成重排模式的展示样式
|
||||
public func presentationStyle(viewportSize: CGSize) -> RDEPUBPresentationStyle {
|
||||
RDEPUBPresentationStyle(
|
||||
viewportSize: viewportSize,
|
||||
@ -44,6 +60,7 @@ public struct RDEPUBPreferences: Equatable {
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据页面和偏好设置生成对应的渲染请求(自动区分固定版式和重排)
|
||||
public func renderRequest(
|
||||
for page: EPUBPage,
|
||||
publication: RDEPUBPublication,
|
||||
|
||||
@ -1,7 +1,16 @@
|
||||
// RDEPUBPublication.swift
|
||||
// EPUB 出版物门面(Facade)
|
||||
// 对 RDEPUBParser 的高层封装,提供 metadata、spine、TOC 等只读访问,
|
||||
// 以及固定版式双页展开判断和 spread 生成等业务逻辑。
|
||||
// 是 EPUBCore Publication 层对外暴露的主要接口。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// EPUB 出版物门面对象,封装解析结果并提供高层访问接口
|
||||
public final class RDEPUBPublication {
|
||||
/// 底层解析器(持有解析数据)
|
||||
public let parser: RDEPUBParser
|
||||
/// 资源解析器(URL 转换和路径规范化)
|
||||
public let resourceResolver: RDEPUBResourceResolver
|
||||
|
||||
public init(parser: RDEPUBParser) {
|
||||
@ -9,38 +18,48 @@ public final class RDEPUBPublication {
|
||||
self.resourceResolver = RDEPUBResourceResolver(parser: parser)
|
||||
}
|
||||
|
||||
/// 元数据代理
|
||||
public var metadata: RDEPUBMetadata {
|
||||
parser.metadata
|
||||
}
|
||||
|
||||
/// 资源清单代理
|
||||
public var manifest: [String: RDEPUBManifestItem] {
|
||||
parser.manifest
|
||||
}
|
||||
|
||||
/// 阅读顺序代理
|
||||
public var spine: [RDEPUBSpineItem] {
|
||||
parser.spine
|
||||
}
|
||||
|
||||
/// 目录代理
|
||||
public var tableOfContents: [EPUBTableOfContentsItem] {
|
||||
parser.tableOfContents
|
||||
}
|
||||
|
||||
/// 布局模式(fixed/reflowable)
|
||||
public var layout: RDEPUBLayout {
|
||||
metadata.layout
|
||||
}
|
||||
|
||||
/// 阅读配置文件(webFixedLayout/webInteractive/textReflowable)
|
||||
public var readingProfile: RDEPUBReadingProfile {
|
||||
parser.readingProfile()
|
||||
}
|
||||
|
||||
/// 阅读方向(ltr/rtl/auto)
|
||||
public var readingProgression: RDEPUBReadingProgression {
|
||||
metadata.readingProgression
|
||||
}
|
||||
|
||||
/// 书籍唯一标识符
|
||||
public var bookIdentifier: String? {
|
||||
metadata.identifier
|
||||
}
|
||||
|
||||
/// 判断固定版式是否启用双页展开模式
|
||||
/// 综合考虑 metadata.spread、偏好设置和视口方向
|
||||
public func fixedLayoutSpreadEnabled(for preferences: RDEPUBPreferences, viewportSize: CGSize) -> Bool {
|
||||
guard layout == .fixed else {
|
||||
return false
|
||||
@ -59,6 +78,7 @@ public final class RDEPUBPublication {
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据偏好和视口生成固定版式的 spread 数组
|
||||
public func makeFixedSpreads(preferences: RDEPUBPreferences, viewportSize: CGSize) -> [EPUBFixedSpread] {
|
||||
EPUBFixedSpread.makeSpreads(
|
||||
spine: spine,
|
||||
|
||||
@ -1,11 +1,30 @@
|
||||
//
|
||||
// RDEPUBReadingModels.swift
|
||||
// RDReaderView
|
||||
//
|
||||
// EPUBCore 层阅读会话相关数据模型定义文件。
|
||||
// 包含阅读位置(Location)、视口(Viewport)、阅读上下文(ReadingContext)、
|
||||
// 文本选择(Selection)、高亮(Highlight)、书签(Bookmark)、
|
||||
// 章节信息(ChapterInfo)、页面模型(Page)、固定版式 Spread 模型等。
|
||||
// 这些模型由 RDEPUBReadingSession 管理,贯穿整个阅读生命周期。
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 阅读位置模型,EPUB 是可重排内容,使用 href + progression 而非页号来定位
|
||||
/// progression 取值 [0, 1],表示当前视口在章节中的相对位置
|
||||
public struct RDEPUBLocation: Codable, Equatable {
|
||||
/// 书籍唯一标识符,用于隔离不同书的阅读进度
|
||||
public var bookIdentifier: String?
|
||||
/// OPF 相对路径,对应 spine 中的资源文件
|
||||
public var href: String
|
||||
/// 视口起始位置在章节中的相对进度 [0, 1]
|
||||
public var progression: Double
|
||||
/// 视口末尾位置在章节中的相对进度 [0, 1](可选)
|
||||
public var lastProgression: Double?
|
||||
/// 锚点标识符(如 #section1),用于精确跳转
|
||||
public var fragment: String?
|
||||
/// 文本范围锚点(用于文本 EPUB 的高亮定位)
|
||||
public var rangeAnchor: RDEPUBTextRangeAnchor?
|
||||
|
||||
public init(
|
||||
@ -24,6 +43,8 @@ public struct RDEPUBLocation: Codable, Equatable {
|
||||
self.rangeAnchor = rangeAnchor
|
||||
}
|
||||
|
||||
/// 导航用的中间进度值,取 progression 和 lastProgression 的中点
|
||||
/// 用于在字号/横竖屏变化后估算最接近的页号
|
||||
public var navigationProgression: Double {
|
||||
let end = lastProgression ?? progression
|
||||
if end.isNaN || end.isInfinite {
|
||||
@ -32,17 +53,24 @@ public struct RDEPUBLocation: Codable, Equatable {
|
||||
return Self.clamp((progression + end) / 2.0)
|
||||
}
|
||||
|
||||
/// 将进度值限制在 [0, 1] 范围内,防止越界
|
||||
private static func clamp(_ value: Double) -> Double {
|
||||
guard value.isFinite else { return 0 }
|
||||
return min(1, max(0, value))
|
||||
}
|
||||
}
|
||||
|
||||
/// 视口中的单个资源信息,表示当前屏幕可见区域覆盖的某个 spine 资源
|
||||
public struct RDEPUBViewportResource: Codable, Equatable {
|
||||
/// 资源路径(相对于 OPF 目录)
|
||||
public var href: String
|
||||
/// 对应的 spine 索引
|
||||
public var spineIndex: Int
|
||||
/// 该资源在视口中的起始进度 [0, 1]
|
||||
public var progression: Double
|
||||
/// 该资源在视口中的结束进度 [0, 1]
|
||||
public var lastProgression: Double?
|
||||
/// 锚点标识符
|
||||
public var fragment: String?
|
||||
|
||||
public init(
|
||||
@ -60,10 +88,16 @@ public struct RDEPUBViewportResource: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 视口模型,描述当前屏幕可见区域的完整状态
|
||||
/// 可能跨越多个 spine 资源(如分栏布局时)
|
||||
public struct RDEPUBViewport: Codable, Equatable {
|
||||
/// 当前视口覆盖的资源列表(可能有多个,如分栏或跨页场景)
|
||||
public var resources: [RDEPUBViewportResource]
|
||||
/// 当前可见的页码
|
||||
public var visiblePageNumber: Int
|
||||
/// 当前章节索引
|
||||
public var chapterIndex: Int?
|
||||
/// 是否为固定版式内容
|
||||
public var isFixedLayout: Bool
|
||||
|
||||
public init(
|
||||
@ -79,10 +113,16 @@ public struct RDEPUBViewport: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 阅读上下文,聚合了位置、视口和页码等完整阅读状态
|
||||
/// 由 RDEPUBReadingSession 维护,是翻页容器层获取当前阅读状态的主要数据源
|
||||
public struct RDEPUBReadingContext: Codable, Equatable {
|
||||
/// 当前阅读位置(href + progression)
|
||||
public var location: RDEPUBLocation
|
||||
/// 当前视口状态
|
||||
public var viewport: RDEPUBViewport
|
||||
/// 当前扁平页码(跨章节累计)
|
||||
public var pageNumber: Int
|
||||
/// 当前章节索引
|
||||
public var chapterIndex: Int?
|
||||
|
||||
public init(
|
||||
@ -98,11 +138,17 @@ public struct RDEPUBReadingContext: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本选择模型,由 JS 桥接 ssReaderSelectionChanged 消息触发创建
|
||||
public struct RDEPUBSelection: Codable, Equatable {
|
||||
/// 书籍标识符
|
||||
public var bookIdentifier: String?
|
||||
/// 选择发生的位置
|
||||
public var location: RDEPUBLocation
|
||||
/// 选中的文本内容
|
||||
public var text: String
|
||||
/// DOM Range 信息(JSON 序列化字符串,用于精确重建选区)
|
||||
public var rangeInfo: String?
|
||||
/// 选择创建时间
|
||||
public var createdAt: Date
|
||||
|
||||
public init(
|
||||
@ -119,28 +165,44 @@ public struct RDEPUBSelection: Codable, Equatable {
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
/// 判断是否为空选择(无文本或无范围信息)
|
||||
public var isEmpty: Bool {
|
||||
text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || rangeInfo?.isEmpty != false
|
||||
}
|
||||
}
|
||||
|
||||
/// 高亮样式类型
|
||||
/// - highlight: 背景色高亮
|
||||
/// - underline: 下划线标记
|
||||
public enum RDEPUBHighlightStyle: String, Codable {
|
||||
case highlight
|
||||
case underline
|
||||
}
|
||||
|
||||
/// 标注菜单操作类型,用于用户长按选中文本后的操作选项
|
||||
public enum RDEPUBAnnotationMenuAction: Equatable {
|
||||
case copy
|
||||
case highlight
|
||||
case annotate
|
||||
}
|
||||
|
||||
/// 文本偏移范围信息,用于文本 EPUB 的高亮精确定位
|
||||
/// 使用字符偏移量而非 DOM Range,适用于 DTCoreText 渲染的纯文本内容
|
||||
public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
|
||||
/// 类型标识符,固定为 "text-offset"
|
||||
public var kind: String
|
||||
/// 资源路径
|
||||
public var href: String
|
||||
/// 起始字符偏移量
|
||||
public var start: Int
|
||||
/// 结束字符偏移量
|
||||
public var end: Int
|
||||
|
||||
/// 初始化文本偏移范围
|
||||
/// - Parameters:
|
||||
/// - href: 资源路径
|
||||
/// - start: 起始字符偏移量
|
||||
/// - end: 结束字符偏移量
|
||||
public init(href: String, start: Int, end: Int) {
|
||||
self.kind = "text-offset"
|
||||
self.href = href
|
||||
@ -148,16 +210,19 @@ public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
|
||||
self.end = end
|
||||
}
|
||||
|
||||
/// 转换为 NSRange(用于 NSAttributedString 操作)
|
||||
public var nsRange: NSRange? {
|
||||
guard end > start else { return nil }
|
||||
return NSRange(location: start, length: end - start)
|
||||
}
|
||||
|
||||
/// 序列化为 JSON 字符串
|
||||
public func jsonString() -> String? {
|
||||
guard let data = try? JSONEncoder().encode(self) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
/// 从 JSON 字符串反序列化,校验 kind 和范围有效性
|
||||
public static func decode(from string: String?) -> RDEPUBTextOffsetRangeInfo? {
|
||||
guard let string,
|
||||
let data = string.data(using: .utf8),
|
||||
@ -170,28 +235,48 @@ public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本分页截断原因,描述为什么在当前位置分页
|
||||
public enum RDEPUBTextPageBreakReason: String, Codable, Equatable {
|
||||
/// 章节自然结束
|
||||
case chapterEnd
|
||||
/// 达到帧容量限制
|
||||
case frameLimit
|
||||
/// 块级元素边界(如段落、标题)
|
||||
case blockBoundary
|
||||
/// 附件(图片等)边界
|
||||
case attachmentBoundary
|
||||
/// 语义边界(如列表、引用块)
|
||||
case semanticBoundary
|
||||
}
|
||||
|
||||
/// 文本附件类型
|
||||
public enum RDEPUBTextAttachmentKind: String, Codable, Equatable {
|
||||
/// 图片附件
|
||||
case image
|
||||
/// 通用附件
|
||||
case generic
|
||||
}
|
||||
|
||||
/// 文本页元数据,记录分页时的上下文信息
|
||||
/// 用于调试分页问题和优化分页质量
|
||||
public struct RDEPUBTextPageMetadata: Codable, Equatable {
|
||||
/// 分页截断原因
|
||||
public var breakReason: RDEPUBTextPageBreakReason
|
||||
/// 本页覆盖的块级元素范围
|
||||
public var blockRange: NSRange?
|
||||
/// 本页包含的附件范围列表
|
||||
public var attachmentRanges: [NSRange]
|
||||
/// 附件类型列表
|
||||
public var attachmentKinds: [RDEPUBTextAttachmentKind]
|
||||
/// 块级元素类型列表
|
||||
public var blockKinds: [RDEPUBTextBlockKind]
|
||||
/// 语义提示信息
|
||||
public var semanticHints: [RDEPUBTextSemanticHint]
|
||||
/// 附件布局位置信息
|
||||
public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
|
||||
/// 本页末尾的 fragment ID(用于恢复位置)
|
||||
public var trailingFragmentID: String?
|
||||
/// 诊断信息列表(调试用)
|
||||
public var diagnostics: [String]
|
||||
|
||||
public init(
|
||||
@ -217,15 +302,26 @@ public struct RDEPUBTextPageMetadata: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 高亮模型,表示用户在阅读中标记的文本片段
|
||||
/// 支持 Web EPUB(DOM Range 定位)和文本 EPUB(字符偏移定位)两种模式
|
||||
public struct RDEPUBHighlight: Codable, Equatable {
|
||||
/// 高亮唯一标识符
|
||||
public var id: String
|
||||
/// 书籍标识符
|
||||
public var bookIdentifier: String?
|
||||
/// 高亮发生的位置
|
||||
public var location: RDEPUBLocation
|
||||
/// 高亮的文本内容
|
||||
public var text: String
|
||||
/// 范围信息(Web EPUB 为 DOM Range JSON,文本 EPUB 为字符偏移 JSON)
|
||||
public var rangeInfo: String?
|
||||
/// 高亮样式(背景色高亮或下划线)
|
||||
public var style: RDEPUBHighlightStyle
|
||||
/// 高亮颜色(CSS 颜色值,如 "#F8E16C")
|
||||
public var color: String
|
||||
/// 用户备注
|
||||
public var note: String?
|
||||
/// 创建时间
|
||||
public var createdAt: Date
|
||||
|
||||
public init(
|
||||
@ -250,10 +346,12 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
/// 是否包含用户备注
|
||||
public var hasNote: Bool {
|
||||
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
}
|
||||
|
||||
/// 编码键,用于 Codable 序列化/反序列化
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case bookIdentifier
|
||||
@ -266,6 +364,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
case createdAt
|
||||
}
|
||||
|
||||
/// 自定义解码器,兼容旧版本数据格式(缺失字段使用默认值)
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
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()
|
||||
}
|
||||
|
||||
/// 自定义编码器
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
@ -298,12 +398,19 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 书签模型,记录用户标记的阅读位置
|
||||
public struct RDEPUBBookmark: Codable, Equatable {
|
||||
/// 书签唯一标识符
|
||||
public var id: String
|
||||
/// 书籍标识符
|
||||
public var bookIdentifier: String?
|
||||
/// 书签位置
|
||||
public var location: RDEPUBLocation
|
||||
/// 章节标题(用于书签列表展示)
|
||||
public var chapterTitle: String?
|
||||
/// 用户备注
|
||||
public var note: String?
|
||||
/// 创建时间
|
||||
public var createdAt: Date
|
||||
|
||||
public init(
|
||||
@ -322,14 +429,19 @@ public struct RDEPUBBookmark: Codable, Equatable {
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
/// 是否包含用户备注
|
||||
public var hasNote: Bool {
|
||||
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
}
|
||||
}
|
||||
|
||||
/// 章节信息模型,描述单个章节的元数据
|
||||
public struct EPUBChapterInfo: Codable, Equatable {
|
||||
/// 对应的 spine 索引
|
||||
public var spineIndex: Int
|
||||
/// 章节标题
|
||||
public var title: String
|
||||
/// 该章节的总页数
|
||||
public var 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 {
|
||||
/// 对应的 spine 索引
|
||||
public var spineIndex: Int
|
||||
/// 章节索引(从 0 开始的章节序号)
|
||||
public var chapterIndex: Int
|
||||
/// 页在章节中的索引(从 0 开始)
|
||||
public var pageIndexInChapter: Int
|
||||
/// 该章节的总页数
|
||||
public var totalPagesInChapter: Int
|
||||
/// 章节标题
|
||||
public var chapterTitle: String
|
||||
/// 固定版式 Spread 数据(仅 fixed layout 书籍有值)
|
||||
public var fixedSpread: EPUBFixedSpread?
|
||||
|
||||
public init(
|
||||
@ -364,10 +484,15 @@ public struct EPUBPage: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 固定版式 Spread 中的单个资源
|
||||
public struct EPUBFixedSpreadResource: Codable, Equatable {
|
||||
/// 对应的 spine 索引
|
||||
public var spineIndex: Int
|
||||
/// 资源路径
|
||||
public var href: String
|
||||
/// 资源标题
|
||||
public var title: String
|
||||
/// 在 spread 中的位置偏好
|
||||
public var pageSpread: RDEPUBPageSpread?
|
||||
|
||||
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 {
|
||||
/// 该 spread 包含的资源列表(通常 1-2 个)
|
||||
public var resources: [EPUBFixedSpreadResource]
|
||||
|
||||
public init(resources: [EPUBFixedSpreadResource]) {
|
||||
self.resources = resources
|
||||
}
|
||||
|
||||
/// 主资源(列表中的第一个),用于标识和定位
|
||||
public var primaryResource: EPUBFixedSpreadResource {
|
||||
resources.first ?? EPUBFixedSpreadResource(spineIndex: 0, href: "", title: "")
|
||||
}
|
||||
|
||||
/// 判断指定 href 的资源是否属于此 spread
|
||||
/// - Parameters:
|
||||
/// - normalizedHref: 标准化后的 href
|
||||
/// - normalizer: href 标准化函数
|
||||
/// - Returns: 是否包含该资源
|
||||
public func contains(normalizedHref: String, normalizer: (String) -> String?) -> Bool {
|
||||
resources.contains { resource in
|
||||
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] {
|
||||
let resources = spine.enumerated().map { index, item in
|
||||
EPUBFixedSpreadResource(
|
||||
@ -433,10 +573,13 @@ public struct EPUBFixedSpread: Codable, Equatable {
|
||||
return spreads
|
||||
}
|
||||
|
||||
/// 便捷方法:从 parser 构建 spread
|
||||
public static func makeSpreads(parser: RDEPUBParser, spreadEnabled: Bool) -> [EPUBFixedSpread] {
|
||||
makeSpreads(spine: parser.spine, spreadEnabled: spreadEnabled)
|
||||
}
|
||||
|
||||
/// 判断两个资源是否应该配对为一个 spread
|
||||
/// center 类型不参与配对,left+right 或 right+left 配对
|
||||
private static func shouldPair(current: EPUBFixedSpreadResource, next: EPUBFixedSpreadResource) -> Bool {
|
||||
if current.pageSpread == .center || next.pageSpread == .center {
|
||||
return false
|
||||
@ -453,7 +596,9 @@ public struct EPUBFixedSpread: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// String 扩展:空字符串转为 nil,用于 Codable 容错处理
|
||||
private extension String {
|
||||
/// 空白字符修剪后如果是空字符串则返回 nil
|
||||
var nilIfEmpty: String? {
|
||||
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
|
||||
@ -1,33 +1,56 @@
|
||||
// RDEPUBReadingSession.swift
|
||||
// EPUB 阅读会话(状态机)
|
||||
// 管理阅读器的运行时状态:导航状态(initializing/loading/idle/jumping/moving/repaginating)、
|
||||
// 活跃页面列表、待应用的分页快照、挂起的导航请求、当前视口和阅读上下文。
|
||||
// 提供分页快照生成、位置计算、导航队列、阅读上下文更新等核心业务方法。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// EPUB 阅读会话,管理导航状态、页面列表和阅读进度
|
||||
/// 状态机:initializing → loading → idle ↔ jumping/moving/repaginating
|
||||
public final class RDEPUBReadingSession {
|
||||
/// 分页快照类型别名(页面数组 + 章节信息数组)
|
||||
public typealias PaginationSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
|
||||
|
||||
/// 关联的出版物
|
||||
public let publication: RDEPUBPublication
|
||||
|
||||
/// 当前导航状态
|
||||
public private(set) var navigatorState: RDEPUBNavigatorState = .initializing
|
||||
/// 当前生效的页面列表
|
||||
public private(set) var activePages: [EPUBPage] = []
|
||||
/// 当前生效的章节信息列表
|
||||
public private(set) var activeChapters: [EPUBChapterInfo] = []
|
||||
/// 暂存的待应用页面列表(分页计算完成后暂存,等状态允许时才生效)
|
||||
public private(set) var stagedPages: [EPUBPage]?
|
||||
/// 暂存的待应用章节信息列表
|
||||
public private(set) var stagedChapters: [EPUBChapterInfo]?
|
||||
/// 暂存快照对应的恢复位置
|
||||
public private(set) var stagedRestoreLocation: RDEPUBLocation?
|
||||
/// 挂起的导航目标位置(用于 fragment 精确定位)
|
||||
public private(set) var pendingNavigationLocation: RDEPUBLocation?
|
||||
/// 挂起的导航目标页码
|
||||
public private(set) var pendingNavigationPageNum: Int?
|
||||
/// 当前视口信息
|
||||
public private(set) var currentViewport: RDEPUBViewport?
|
||||
/// 当前阅读上下文(位置 + 视口 + 页码 + 章节)
|
||||
public private(set) var currentReadingContext: RDEPUBReadingContext?
|
||||
|
||||
public init(publication: RDEPUBPublication) {
|
||||
self.publication = publication
|
||||
}
|
||||
|
||||
/// 资源解析器快捷访问
|
||||
public var resourceResolver: RDEPUBResourceResolver {
|
||||
publication.resourceResolver
|
||||
}
|
||||
|
||||
/// 状态机转换
|
||||
public func transition(to state: RDEPUBNavigatorState) {
|
||||
navigatorState = state
|
||||
}
|
||||
|
||||
/// 重置所有运行时状态(回到 initializing)
|
||||
public func resetRuntimeState() {
|
||||
navigatorState = .initializing
|
||||
activePages = []
|
||||
@ -38,17 +61,20 @@ public final class RDEPUBReadingSession {
|
||||
currentReadingContext = nil
|
||||
}
|
||||
|
||||
/// 设置当前生效的分页快照
|
||||
public func setActiveSnapshot(_ snapshot: PaginationSnapshot) {
|
||||
activePages = snapshot.pages
|
||||
activeChapters = snapshot.chapters
|
||||
}
|
||||
|
||||
/// 暂存分页快照(待状态允许时才生效)
|
||||
public func stageSnapshot(_ snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?) {
|
||||
stagedPages = snapshot.pages
|
||||
stagedChapters = snapshot.chapters
|
||||
stagedRestoreLocation = restoreLocation
|
||||
}
|
||||
|
||||
/// 获取暂存的分页快照
|
||||
public func stagedSnapshot() -> PaginationSnapshot? {
|
||||
guard let stagedPages, let stagedChapters else {
|
||||
return nil
|
||||
@ -56,6 +82,7 @@ public final class RDEPUBReadingSession {
|
||||
return (stagedPages, stagedChapters)
|
||||
}
|
||||
|
||||
/// 如果状态稳定且有暂存快照,则消费并返回(原子操作)
|
||||
public func consumeStagedSnapshotIfAllowed() -> (snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?)? {
|
||||
guard navigatorState.isStableForSnapshotApplication,
|
||||
let stagedPages,
|
||||
@ -67,17 +94,20 @@ public final class RDEPUBReadingSession {
|
||||
return ((stagedPages, stagedChapters), restoreLocation)
|
||||
}
|
||||
|
||||
/// 清除暂存快照
|
||||
public func clearStagedSnapshot() {
|
||||
stagedPages = nil
|
||||
stagedChapters = nil
|
||||
stagedRestoreLocation = nil
|
||||
}
|
||||
|
||||
/// 清除挂起的导航请求
|
||||
public func clearPendingNavigation() {
|
||||
pendingNavigationLocation = nil
|
||||
pendingNavigationPageNum = nil
|
||||
}
|
||||
|
||||
/// 检查指定页码和 spine 索引是否有挂起的导航位置
|
||||
public func pendingLocation(forPageNumber pageNumber: Int, spineIndex: Int?) -> RDEPUBLocation? {
|
||||
guard pendingNavigationPageNum == pageNumber,
|
||||
let pendingNavigationLocation else {
|
||||
@ -93,6 +123,7 @@ public final class RDEPUBReadingSession {
|
||||
return pendingNavigationLocation
|
||||
}
|
||||
|
||||
/// 判断指定页面是否包含给定的 spine 索引(固定版式需检查 spread 内所有资源)
|
||||
public func pageContains(spineIndex: Int, in page: EPUBPage) -> Bool {
|
||||
if let fixedSpread = page.fixedSpread {
|
||||
return fixedSpread.resources.contains(where: { $0.spineIndex == spineIndex })
|
||||
@ -100,6 +131,7 @@ public final class RDEPUBReadingSession {
|
||||
return page.spineIndex == spineIndex
|
||||
}
|
||||
|
||||
/// 根据页面信息生成回退位置(用于无精确位置时的兜底)
|
||||
public func fallbackLocation(for page: EPUBPage, bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
if let fixedSpread = page.fixedSpread {
|
||||
return RDEPUBLocation(
|
||||
@ -129,6 +161,7 @@ public final class RDEPUBReadingSession {
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取当前可见页面对应的位置
|
||||
public func currentVisibleLocation(currentPageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
guard currentPageNumber > 0,
|
||||
activePages.indices.contains(currentPageNumber - 1) else {
|
||||
@ -137,6 +170,7 @@ public final class RDEPUBReadingSession {
|
||||
return fallbackLocation(for: activePages[currentPageNumber - 1], bookIdentifier: bookIdentifier)
|
||||
}
|
||||
|
||||
/// 根据位置获取初始 spine 索引
|
||||
public func initialSpineIndex(for location: RDEPUBLocation?) -> Int {
|
||||
guard let location,
|
||||
let normalizedHref = resourceResolver.normalizedHref(location.href),
|
||||
@ -146,6 +180,7 @@ public final class RDEPUBReadingSession {
|
||||
return spineIndex
|
||||
}
|
||||
|
||||
/// 根据位置计算对应的页面索引(支持固定版式和重排模式)
|
||||
public func pageIndex(for location: RDEPUBLocation, bookIdentifier: String?) -> Int? {
|
||||
guard location.bookIdentifier == nil || location.bookIdentifier == bookIdentifier else {
|
||||
return nil
|
||||
@ -186,6 +221,7 @@ public final class RDEPUBReadingSession {
|
||||
return candidates[localIndex].offset
|
||||
}
|
||||
|
||||
/// 排队导航到指定位置:规范化位置、查找页面索引、设置挂起导航、切换到 jumping 状态
|
||||
public func queueNavigation(
|
||||
to location: RDEPUBLocation,
|
||||
relativeToSpineIndex spineIndex: Int? = nil,
|
||||
@ -205,6 +241,7 @@ public final class RDEPUBReadingSession {
|
||||
return pageIndex + 1
|
||||
}
|
||||
|
||||
/// 更新阅读上下文:规范化位置、设置视口和阅读上下文、清除挂起导航、回到 idle 状态
|
||||
public func updateReadingContext(
|
||||
pageNumber: Int,
|
||||
location: RDEPUBLocation,
|
||||
@ -253,10 +290,13 @@ public final class RDEPUBReadingSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前阅读位置(优先从阅读上下文获取,回退到可见位置)
|
||||
public func currentReadingLocation(bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
currentReadingContext?.location ?? currentVisibleLocation(currentPageNumber: currentViewport?.visiblePageNumber ?? 0, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
|
||||
/// 根据分页计数和偏好设置生成分页快照
|
||||
/// 固定版式按 spread 分页,重排模式按 spine 索引逐章分页
|
||||
public func makePaginationSnapshot(
|
||||
pageCounts: [Int],
|
||||
preferences: RDEPUBPreferences,
|
||||
|
||||
@ -1,23 +1,49 @@
|
||||
//
|
||||
// RDEPUBRenderRequest.swift
|
||||
// RDReaderView
|
||||
//
|
||||
// EPUBCore 层渲染请求模型定义文件。
|
||||
// 包含渲染所需的展示样式(PresentationStyle)、可重排/固定版式渲染请求(RenderRequest)、
|
||||
// 固定版式适配模式(Fit)和 Spread 模式(SpreadMode)。
|
||||
// 由 RDEPUBPreferences 构建,传递给 RDEPUBWebView 执行渲染。
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 固定版式页面适配模式
|
||||
/// - auto: 自动选择最佳适配策略
|
||||
/// - page: 适配整页(缩放至屏幕大小)
|
||||
/// - width: 适配宽度(按宽度等比缩放)
|
||||
public enum RDEPUBFixedLayoutFit: String, Codable {
|
||||
case auto
|
||||
case page
|
||||
case width
|
||||
}
|
||||
|
||||
/// 固定版式 Spread(双页)显示模式
|
||||
/// - automatic: 根据设备方向和屏幕尺寸自动判断
|
||||
/// - always: 始终启用双页显示
|
||||
/// - never: 始终单页显示
|
||||
public enum RDEPUBFixedLayoutSpreadMode: String, Codable {
|
||||
case automatic
|
||||
case always
|
||||
case never
|
||||
}
|
||||
|
||||
/// 渲染展示样式,WebView 和 Paginator 共用的视觉参数
|
||||
/// 由 RDEPUBPreferences 根据用户设置构建
|
||||
public struct RDEPUBPresentationStyle: Equatable {
|
||||
/// 视口尺寸(容器的可见区域大小)
|
||||
public var viewportSize: CGSize
|
||||
/// 内容内边距(上下左右的安全区域偏移)
|
||||
public var contentInsets: UIEdgeInsets
|
||||
/// 字号(pt)
|
||||
public var fontSize: CGFloat
|
||||
/// 行距倍数(1.0 为默认行距)
|
||||
public var lineHeightMultiple: CGFloat
|
||||
/// 主题背景色(CSS 颜色值,如 "#FFFFFF")
|
||||
public var themeBackgroundColor: String?
|
||||
/// 主题文字色(CSS 颜色值,如 "#000000")
|
||||
public var themeTextColor: String?
|
||||
|
||||
public init(
|
||||
@ -37,14 +63,23 @@ public struct RDEPUBPresentationStyle: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 可重排内容渲染请求,包含加载和渲染一个 reflowable spine 项所需的全部参数
|
||||
public struct RDEPUBReflowableRenderRequest: Equatable {
|
||||
/// spine 索引
|
||||
public var spineIndex: Int
|
||||
/// 资源路径
|
||||
public var href: String
|
||||
/// 目标页在章节中的索引
|
||||
public var pageIndex: Int
|
||||
/// 该章节的总页数
|
||||
public var totalPagesInChapter: Int
|
||||
/// 展示样式
|
||||
public var presentation: RDEPUBPresentationStyle
|
||||
/// 目标跳转位置(用于恢复阅读位置或锚点跳转)
|
||||
public var targetLocation: RDEPUBLocation?
|
||||
/// 高亮列表
|
||||
public var highlights: [RDEPUBHighlight]
|
||||
/// 搜索结果展示信息
|
||||
public var searchPresentation: RDEPUBSearchPresentation?
|
||||
|
||||
public init(
|
||||
@ -68,12 +103,19 @@ public struct RDEPUBReflowableRenderRequest: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 固定版式渲染请求,包含加载和渲染一个 fixed spread 所需的全部参数
|
||||
public struct RDEPUBFixedRenderRequest: Equatable {
|
||||
/// 要渲染的 spread(包含 1-2 个资源)
|
||||
public var spread: EPUBFixedSpread
|
||||
/// 视口尺寸
|
||||
public var viewportSize: CGSize
|
||||
/// 内容内边距
|
||||
public var contentInset: UIEdgeInsets
|
||||
/// 背景色 CSS 值
|
||||
public var backgroundColorCSS: String?
|
||||
/// 页面适配模式(auto/page/width)
|
||||
public var fit: RDEPUBFixedLayoutFit
|
||||
/// 搜索结果展示信息
|
||||
public var searchPresentation: RDEPUBSearchPresentation?
|
||||
|
||||
public init(
|
||||
@ -93,10 +135,15 @@ public struct RDEPUBFixedRenderRequest: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 渲染请求枚举,统一可重排和固定版式两种渲染模式
|
||||
/// RDEPUBWebView 根据此请求类型选择对应的加载和渲染路径
|
||||
public enum RDEPUBRenderRequest: Equatable {
|
||||
/// 可重排内容渲染请求
|
||||
case reflowable(RDEPUBReflowableRenderRequest)
|
||||
/// 固定版式渲染请求
|
||||
case fixed(RDEPUBFixedRenderRequest)
|
||||
|
||||
/// 是否为固定版式渲染
|
||||
public var isFixedLayout: Bool {
|
||||
if case .fixed = self {
|
||||
return true
|
||||
@ -104,6 +151,7 @@ public enum RDEPUBRenderRequest: Equatable {
|
||||
return false
|
||||
}
|
||||
|
||||
/// 主资源的 spine 索引
|
||||
public var primarySpineIndex: Int {
|
||||
switch self {
|
||||
case .reflowable(let request):
|
||||
@ -113,6 +161,7 @@ public enum RDEPUBRenderRequest: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 主资源的路径
|
||||
public var primaryHref: String {
|
||||
switch self {
|
||||
case .reflowable(let request):
|
||||
|
||||
@ -1,28 +1,41 @@
|
||||
// RDEPUBResourceResolver.swift
|
||||
// EPUB 资源解析器
|
||||
// 封装 RDEPUBParser 的资源查询能力,提供 href 规范化、
|
||||
// spine 索引查找、位置规范化等高层接口。是 Services 层的核心组件,
|
||||
// 被 RDEPUBPublication 和 RDEPUBReadingSession 广泛使用。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// EPUB 资源解析器,负责 URL 转换、href 规范化和位置解析
|
||||
public final class RDEPUBResourceResolver {
|
||||
/// 底层解析器引用
|
||||
private let parser: RDEPUBParser
|
||||
|
||||
public init(parser: RDEPUBParser) {
|
||||
self.parser = parser
|
||||
}
|
||||
|
||||
/// OPF 文件所在目录的 URL
|
||||
public var opfDirectoryURL: URL? {
|
||||
parser.opfDirectoryURL
|
||||
}
|
||||
|
||||
/// 将相对路径转换为本地文件 URL
|
||||
public func fileURL(forRelativePath relativePath: String) -> URL? {
|
||||
parser.fileURL(forRelativePath: relativePath)
|
||||
}
|
||||
|
||||
/// 将相对路径转换为 ss-reader://book/ 协议 URL
|
||||
public func resourceURL(forRelativePath relativePath: String) -> URL? {
|
||||
parser.resourceURL(forRelativePath: relativePath)
|
||||
}
|
||||
|
||||
/// 将 ss-reader://book/ 协议 URL 还原为本地文件 URL
|
||||
public func fileURL(forResourceURL resourceURL: URL) -> URL? {
|
||||
parser.fileURL(forResourceURL: resourceURL)
|
||||
}
|
||||
|
||||
/// 规范化 href:基于 OPF 目录和可选的 spine 索引,将相对路径解析为标准化的 OPF 内路径
|
||||
public func normalizedHref(_ href: String, relativeToSpineIndex spineIndex: Int? = nil) -> String? {
|
||||
guard let opfDirectoryURL else {
|
||||
return href.components(separatedBy: "#").first
|
||||
@ -57,6 +70,7 @@ public final class RDEPUBResourceResolver {
|
||||
return pathPart
|
||||
}
|
||||
|
||||
/// 规范化 href:基于另一个 href 作为基准路径
|
||||
public func normalizedHref(_ href: String, relativeToHref baseHref: String) -> String? {
|
||||
guard let opfDirectoryURL else {
|
||||
return href.components(separatedBy: "#").first
|
||||
@ -79,6 +93,7 @@ public final class RDEPUBResourceResolver {
|
||||
return pathPart
|
||||
}
|
||||
|
||||
/// 将相对于另一个 href 的引用转换为本地文件 URL
|
||||
public func fileURL(forReference href: String, relativeToHref baseHref: String) -> URL? {
|
||||
guard let normalizedHref = normalizedHref(href, relativeToHref: baseHref) else {
|
||||
return nil
|
||||
@ -86,6 +101,7 @@ public final class RDEPUBResourceResolver {
|
||||
return fileURL(forRelativePath: normalizedHref)
|
||||
}
|
||||
|
||||
/// 规范化位置对象:解析 href、保留 progression 和 fragment
|
||||
public func normalizedLocation(
|
||||
_ location: RDEPUBLocation,
|
||||
relativeToSpineIndex spineIndex: Int? = nil,
|
||||
@ -119,12 +135,14 @@ public final class RDEPUBResourceResolver {
|
||||
)
|
||||
}
|
||||
|
||||
/// 通过规范化 href 查找对应的 spine 索引
|
||||
public func spineIndex(forNormalizedHref normalizedHref: String) -> Int? {
|
||||
parser.spine.firstIndex { item in
|
||||
self.normalizedHref(item.href) == normalizedHref
|
||||
}
|
||||
}
|
||||
|
||||
/// 通过位置查找对应的 spine 索引
|
||||
public func spineIndex(for location: RDEPUBLocation) -> Int? {
|
||||
guard let normalizedHref = normalizedHref(location.href) else {
|
||||
return nil
|
||||
@ -132,6 +150,7 @@ public final class RDEPUBResourceResolver {
|
||||
return spineIndex(forNormalizedHref: normalizedHref)
|
||||
}
|
||||
|
||||
/// 通过 spine 索引获取 href
|
||||
public func href(forSpineIndex spineIndex: Int) -> String? {
|
||||
guard parser.spine.indices.contains(spineIndex) else {
|
||||
return nil
|
||||
@ -139,6 +158,7 @@ public final class RDEPUBResourceResolver {
|
||||
return parser.spine[spineIndex].href
|
||||
}
|
||||
|
||||
/// 通过 spine 索引获取标题
|
||||
public func title(forSpineIndex spineIndex: Int) -> String? {
|
||||
guard parser.spine.indices.contains(spineIndex) else {
|
||||
return nil
|
||||
@ -146,6 +166,7 @@ public final class RDEPUBResourceResolver {
|
||||
return parser.spine[spineIndex].title
|
||||
}
|
||||
|
||||
/// 通过 href 查询 manifest 项
|
||||
public func manifestItem(forHref href: String) -> RDEPUBManifestItem? {
|
||||
let targetHref = normalizedHref(href) ?? href.components(separatedBy: "#").first ?? href
|
||||
return parser.manifest.values.first {
|
||||
|
||||
@ -1,13 +1,25 @@
|
||||
// RDEPUBResourceURLSchemeHandler.swift
|
||||
// ss-reader:// 自定义 URL 协议处理器
|
||||
// 实现 WKURLSchemeHandler 协议,拦截 ss-reader://book/<path> 请求,
|
||||
// 将其映射到本地解压的 EPUB 资源文件。支持 MIME 类型推断和可选资源的空响应处理。
|
||||
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
/// 自定义 URL 协议处理器,将 ss-reader://book/ 请求映射到本地 EPUB 资源文件
|
||||
public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
/// 自定义协议名
|
||||
public static let scheme = "ss-reader"
|
||||
/// 自定义主机名
|
||||
public static let host = "book"
|
||||
|
||||
/// 底层解析器引用(弱引用,避免循环持有)
|
||||
private weak var parser: RDEPUBParser?
|
||||
/// 文件管理器实例
|
||||
private let fileManager = FileManager.default
|
||||
/// 串行队列,用于同步访问活跃任务字典
|
||||
private let syncQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler")
|
||||
/// 活跃的 URL Scheme 任务集合(用于任务取消检测)
|
||||
private var activeTasks: [ObjectIdentifier: Bool] = [:]
|
||||
|
||||
public init(parser: RDEPUBParser) {
|
||||
@ -15,6 +27,8 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// 处理 URL Scheme 请求:解析文件路径、读取数据、返回响应
|
||||
/// 对缺失的可选资源(字体/图片/CSS/JS)返回空响应,其他返回 404 错误
|
||||
public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
|
||||
let taskID = ObjectIdentifier(urlSchemeTask as AnyObject)
|
||||
syncQueue.sync {
|
||||
@ -72,23 +86,27 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
clearTask(taskID)
|
||||
}
|
||||
|
||||
/// 取消 URL Scheme 任务
|
||||
public func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) {
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: urlSchemeTask.request.url, event: "stop")
|
||||
clearTask(ObjectIdentifier(urlSchemeTask as AnyObject))
|
||||
}
|
||||
|
||||
/// 从活跃任务集合中移除已完成的任务
|
||||
private func clearTask(_ id: ObjectIdentifier) {
|
||||
syncQueue.sync {
|
||||
activeTasks[id] = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查任务是否仍然活跃(防止取消后继续发送数据)
|
||||
private func isTaskActive(_ id: ObjectIdentifier) -> Bool {
|
||||
syncQueue.sync {
|
||||
activeTasks[id] == true
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据文件扩展名推断 MIME 类型
|
||||
private static func mimeType(for pathExtension: String) -> String {
|
||||
switch pathExtension.lowercased() {
|
||||
case "html", "htm":
|
||||
@ -128,6 +146,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据文件扩展名推断文本编码(文本类文件返回 UTF-8)
|
||||
private static func textEncodingName(for pathExtension: String) -> String? {
|
||||
switch pathExtension.lowercased() {
|
||||
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 {
|
||||
switch url.pathExtension.lowercased() {
|
||||
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 UIKit
|
||||
|
||||
/// 搜索引擎协议,定义全文搜索接口
|
||||
protocol RDEPUBSearchEngine {
|
||||
func search(keyword: String) -> [RDEPUBSearchMatch]
|
||||
}
|
||||
|
||||
/// HTML 全文搜索引擎实现
|
||||
/// 通过解析 HTML 为纯文本,逐章执行关键词匹配
|
||||
final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
|
||||
/// EPUB 解析器(用于读取 HTML 内容)
|
||||
private let parser: RDEPUBParser
|
||||
/// EPUB 出版物(用于遍历 spine 和规范化 href)
|
||||
private let publication: RDEPUBPublication
|
||||
|
||||
init(parser: RDEPUBParser, publication: RDEPUBPublication) {
|
||||
@ -14,6 +24,7 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
|
||||
self.publication = publication
|
||||
}
|
||||
|
||||
/// 执行全文搜索:遍历所有线性 spine 项,提取纯文本后匹配关键词
|
||||
func search(keyword: String) -> [RDEPUBSearchMatch] {
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKeyword.isEmpty else {
|
||||
@ -35,6 +46,7 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
|
||||
return searchMatches
|
||||
}
|
||||
|
||||
/// 将 HTML 转换为纯文本(优先使用 NSAttributedString,失败时用正则兜底)
|
||||
private func plainText(fromHTML html: String, baseURL: URL?) -> String {
|
||||
guard let data = html.data(using: .utf8) else {
|
||||
return fallbackPlainText(fromHTML: html)
|
||||
@ -54,6 +66,7 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
|
||||
return fallbackPlainText(fromHTML: html)
|
||||
}
|
||||
|
||||
/// 兜底方案:用正则去除 HTML 标签并解码 HTML 实体
|
||||
private func fallbackPlainText(fromHTML html: String) -> String {
|
||||
let stripped = html.replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression)
|
||||
return stripped
|
||||
@ -65,6 +78,7 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
|
||||
.replacingOccurrences(of: """, with: "\"")
|
||||
}
|
||||
|
||||
/// 在纯文本中查找所有关键词匹配项(大小写不敏感)
|
||||
private func matches(in text: String, href: String, keyword: String) -> [RDEPUBSearchMatch] {
|
||||
let source = text as NSString
|
||||
let fullLength = source.length
|
||||
@ -106,6 +120,7 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
|
||||
return results
|
||||
}
|
||||
|
||||
/// 截取匹配位置前后的文本作为预览(前后各 12 个字符)
|
||||
private func previewText(in text: NSString, matchRange: NSRange) -> String {
|
||||
let previewRadius = 12
|
||||
let start = max(matchRange.location - previewRadius, 0)
|
||||
|
||||
@ -1,12 +1,25 @@
|
||||
// RDEPUBSearchModels.swift
|
||||
// EPUB 搜索相关数据模型
|
||||
// 定义搜索匹配项(RDEPUBSearchMatch)、搜索结果(RDEPUBSearchResult)、
|
||||
// 搜索状态(RDEPUBSearchState)和搜索展示(RDEPUBSearchPresentation)等数据结构。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 搜索匹配项,包含匹配位置、进度、预览文本和范围信息
|
||||
public struct RDEPUBSearchMatch: Codable, Equatable {
|
||||
/// 匹配所在资源的规范化 href
|
||||
public var href: String
|
||||
/// 匹配位置在文本中的进度比例(0.0 ~ 1.0)
|
||||
public var progression: Double
|
||||
/// 匹配位置前后的预览文本
|
||||
public var previewText: String
|
||||
/// 在当前资源内的本地匹配索引
|
||||
public var localMatchIndex: Int
|
||||
/// 匹配范围的起始位置(字符偏移量)
|
||||
public var rangeLocation: Int?
|
||||
/// 匹配范围的长度
|
||||
public var rangeLength: Int
|
||||
/// 文本范围锚点(用于精确定位)
|
||||
public var rangeAnchor: RDEPUBTextRangeAnchor?
|
||||
|
||||
public init(
|
||||
@ -28,10 +41,15 @@ public struct RDEPUBSearchMatch: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 搜索结果摘要,包含关键词、总匹配数和当前匹配项
|
||||
public struct RDEPUBSearchResult: Codable, Equatable {
|
||||
/// 搜索关键词
|
||||
public var keyword: String
|
||||
/// 总匹配数量
|
||||
public var totalMatchCount: Int
|
||||
/// 当前匹配项的索引(从 1 开始)
|
||||
public var currentMatchIndex: Int?
|
||||
/// 当前高亮的匹配项
|
||||
public var currentMatch: RDEPUBSearchMatch?
|
||||
|
||||
public init(
|
||||
@ -47,9 +65,13 @@ public struct RDEPUBSearchResult: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 搜索状态,管理关键词、匹配列表和当前索引
|
||||
public struct RDEPUBSearchState: Codable, Equatable {
|
||||
/// 搜索关键词
|
||||
public var keyword: String
|
||||
/// 所有匹配项列表
|
||||
public var matches: [RDEPUBSearchMatch]
|
||||
/// 当前匹配项索引(从 0 开始)
|
||||
public var currentMatchIndex: Int?
|
||||
|
||||
public init(keyword: String, matches: [RDEPUBSearchMatch], currentMatchIndex: Int? = nil) {
|
||||
@ -58,6 +80,7 @@ public struct RDEPUBSearchState: Codable, Equatable {
|
||||
self.currentMatchIndex = currentMatchIndex
|
||||
}
|
||||
|
||||
/// 获取当前匹配项
|
||||
public var currentMatch: RDEPUBSearchMatch? {
|
||||
guard let currentMatchIndex,
|
||||
matches.indices.contains(currentMatchIndex) else {
|
||||
@ -66,6 +89,7 @@ public struct RDEPUBSearchState: Codable, Equatable {
|
||||
return matches[currentMatchIndex]
|
||||
}
|
||||
|
||||
/// 转换为搜索结果摘要
|
||||
public var result: RDEPUBSearchResult {
|
||||
RDEPUBSearchResult(
|
||||
keyword: keyword,
|
||||
@ -76,9 +100,13 @@ public struct RDEPUBSearchState: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 搜索展示的单个资源信息
|
||||
public struct RDEPUBSearchPresentationResource: Codable, Equatable {
|
||||
/// 资源的规范化 href
|
||||
public var href: String
|
||||
/// 该资源内的匹配数量
|
||||
public var matchCount: Int
|
||||
/// 当前活跃的本地匹配索引
|
||||
public var activeLocalMatchIndex: Int?
|
||||
|
||||
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 var keyword: String
|
||||
/// 各资源的搜索展示信息列表
|
||||
public var resources: [RDEPUBSearchPresentationResource]
|
||||
|
||||
public init(keyword: String, resources: [RDEPUBSearchPresentationResource]) {
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
// RDEPUBStyleSheetBuilder.swift
|
||||
// EPUB CSS 样式构建器
|
||||
// 负责生成重排模式下的分页 CSS(多列布局)和测量 CSS,
|
||||
// 以及将分页样式注入到 HTML 中。还提供测量脚本(JS)用于计算页数。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// CSS 样式构建工具集,生成分页、渲染和测量所需的 CSS/JS
|
||||
public enum RDEPUBStyleSheetBuilder {
|
||||
/// 将分页 CSS 注入到 HTML 字符串中(插入 </head> 或 <body 之前)
|
||||
public static func injectPaginationCSS(
|
||||
into html: String,
|
||||
presentation: RDEPUBPresentationStyle
|
||||
@ -15,6 +22,8 @@ public enum RDEPUBStyleSheetBuilder {
|
||||
return styleTag + html
|
||||
}
|
||||
|
||||
/// 生成重排模式的渲染 CSS(视口、body、#ss-reader-viewport、#ss-reader-content 等)
|
||||
/// 包含多列布局、字号、行高、主题色、高亮样式等
|
||||
public static func renderCSS(for presentation: RDEPUBPresentationStyle) -> String {
|
||||
let values = cssValues(for: presentation)
|
||||
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 {
|
||||
let values = cssValues(for: presentation)
|
||||
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 {
|
||||
let style = measurementCSS(for: presentation)
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
@ -170,6 +181,7 @@ public enum RDEPUBStyleSheetBuilder {
|
||||
"""
|
||||
}
|
||||
|
||||
/// 从展示样式计算所有 CSS 变量值(视口尺寸、内边距、字号、行高等)
|
||||
private static func cssValues(for presentation: RDEPUBPresentationStyle) -> (
|
||||
viewportWidth: String,
|
||||
viewportHeight: String,
|
||||
|
||||
@ -1,12 +1,26 @@
|
||||
// RDEPUBTextAnchor.swift
|
||||
// EPUB 文本锚点和范围锚点
|
||||
// 定义精确的文本位置锚点(RDEPUBTextAnchor,含 fileIndex/row/column/chapterOffset/fragmentID),
|
||||
// 以及文本范围锚点(RDEPUBTextRangeAnchor,起止锚点对)。
|
||||
// 支持 Codable 序列化(兼容 spineIndex/fileIndex 两种 key)。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 文本锚点,精确定位到 EPUB 中的某个字符位置
|
||||
/// 包含文件索引、行号、列号、章节内偏移和可选的 fragment ID
|
||||
public struct RDEPUBTextAnchor: Codable, Equatable {
|
||||
/// 文件索引(等同于 spineIndex)
|
||||
public let fileIndex: Int
|
||||
/// 行号
|
||||
public let row: Int
|
||||
/// 列号(行内偏移)
|
||||
public let column: Int
|
||||
/// 章节内的绝对字符偏移量
|
||||
public let chapterOffset: Int
|
||||
/// 最近的 fragment ID(用于 URL 锚点定位)
|
||||
public let fragmentID: String?
|
||||
|
||||
/// spine 索引的别名(与 fileIndex 等价)
|
||||
public var spineIndex: Int { fileIndex }
|
||||
|
||||
public init(
|
||||
@ -53,8 +67,11 @@ public struct RDEPUBTextAnchor: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本范围锚点,由起止锚点组成的区间
|
||||
public struct RDEPUBTextRangeAnchor: Codable, Equatable {
|
||||
/// 起始锚点
|
||||
public let start: RDEPUBTextAnchor
|
||||
/// 结束锚点
|
||||
public let end: RDEPUBTextAnchor
|
||||
|
||||
public init(start: RDEPUBTextAnchor, end: RDEPUBTextAnchor) {
|
||||
@ -62,6 +79,7 @@ public struct RDEPUBTextRangeAnchor: Codable, Equatable {
|
||||
self.end = end
|
||||
}
|
||||
|
||||
/// 转换为 NSRange(基于 chapterOffset)
|
||||
public var nsRange: NSRange {
|
||||
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 WebKit
|
||||
|
||||
extension RDEPUBWebView {
|
||||
/// 按需配置 WKWebView(仅在 Publication 变更或 WebView 不存在时重建)
|
||||
func configureWebViewIfNeeded(publication: RDEPUBPublication) {
|
||||
let parser = publication.parser
|
||||
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)")
|
||||
}
|
||||
|
||||
/// 处理选择菜单动作(拷贝/高亮/批注),通知委托并清除选择
|
||||
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction) {
|
||||
delegate?.epubWebView(self, didRequestSelectionAction: action)
|
||||
clearWebSelection()
|
||||
}
|
||||
|
||||
/// 清除 WebView 中的文本选择
|
||||
func clearWebSelection() {
|
||||
webView?.evaluateJavaScript("window.getSelection && window.getSelection().removeAllRanges();")
|
||||
}
|
||||
|
||||
/// 销毁 WebView,移除所有消息处理器和引用
|
||||
func teardownWebView() {
|
||||
cancelFixedLayoutReadyFallback()
|
||||
if let webView {
|
||||
@ -96,6 +106,7 @@ extension RDEPUBWebView {
|
||||
}
|
||||
|
||||
extension UIColor {
|
||||
/// 将 UIColor 转换为 CSS 十六进制颜色字符串(如 "#FF0000")
|
||||
var ss_hexString: String {
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
// RDEPUBWebView+FixedLayout.swift
|
||||
// RDEPUBWebView 的固定版式渲染扩展
|
||||
// 处理固定版式(Fixed Layout)的加载流程:生成 HTML 模板、
|
||||
// 计算加载签名(去重)、设置固定版式就绪回退定时器等。
|
||||
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
extension RDEPUBWebView {
|
||||
/// 便捷方法:加载固定版式的 spread 页面
|
||||
public func loadFixedSpread(
|
||||
parser: RDEPUBParser,
|
||||
spread: EPUBFixedSpread,
|
||||
@ -22,6 +28,7 @@ extension RDEPUBWebView {
|
||||
load(publication: parser.makePublication(), request: request)
|
||||
}
|
||||
|
||||
/// 处理固定版式加载:设置状态、生成 HTML、加载到 WebView
|
||||
func handleFixedLayoutLoad(
|
||||
publication: RDEPUBPublication,
|
||||
request: RDEPUBFixedRenderRequest,
|
||||
@ -52,8 +59,19 @@ extension RDEPUBWebView {
|
||||
)
|
||||
}
|
||||
|
||||
/// 生成固定版式加载签名(用于去重相同请求)
|
||||
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,
|
||||
"fixed",
|
||||
request.spread.resources.map(\.href).joined(separator: "|"),
|
||||
@ -64,10 +82,12 @@ extension RDEPUBWebView {
|
||||
String(format: "%.2f", request.contentInset.bottom),
|
||||
String(format: "%.2f", request.contentInset.right),
|
||||
request.backgroundColorCSS ?? "",
|
||||
request.fit.rawValue
|
||||
request.fit.rawValue,
|
||||
searchSignature
|
||||
].joined(separator: "#")
|
||||
}
|
||||
|
||||
/// 调度固定版式就绪回退定时器(1 秒后如果未收到 ready 消息则手动触发渲染完成)
|
||||
func scheduleFixedLayoutReadyFallback() {
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
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)
|
||||
}
|
||||
|
||||
/// 取消固定版式就绪回退定时器
|
||||
func cancelFixedLayoutReadyFallback() {
|
||||
fixedLayoutReadyWorkItem?.cancel()
|
||||
fixedLayoutReadyWorkItem = nil
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
// RDEPUBWebView+JavaScriptBridge.swift
|
||||
// RDEPUBWebView 的 JS 桥接和导航委托扩展
|
||||
// 实现 WKNavigationDelegate 处理页面加载生命周期,
|
||||
// 实现 WKScriptMessageHandler 接收 JS 消息(进度/选择/链接/错误/固定版式就绪),
|
||||
// 并将内部链接和外部链接路由到委托方法。
|
||||
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
extension RDEPUBWebView {
|
||||
/// 从 URL 解析内部位置(ss-reader:// 协议的链接)
|
||||
func internalLocation(from url: URL) -> RDEPUBLocation? {
|
||||
let href = url.path.removingPercentEncoding?.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? ""
|
||||
let fragment = url.fragment
|
||||
@ -9,6 +16,7 @@ extension RDEPUBWebView {
|
||||
return RDEPUBLocation(href: href, progression: 0, fragment: fragment)
|
||||
}
|
||||
|
||||
/// 从 JS 消息体中构建当前位置对象
|
||||
func currentLocation(from body: [String: Any]) -> RDEPUBLocation {
|
||||
let progression = (body["progression"] as? NSNumber)?.doubleValue ?? 0
|
||||
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)
|
||||
}
|
||||
|
||||
/// 导航策略决策:区分内部链接(ss-reader://)、外部链接(http/https/mailto/tel)和其他导航
|
||||
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "decidePolicy", url: navigationAction.request.url)
|
||||
guard navigationAction.navigationType == .linkActivated,
|
||||
@ -69,6 +78,7 @@ extension RDEPUBWebView: WKNavigationDelegate {
|
||||
}
|
||||
|
||||
extension RDEPUBWebView: WKScriptMessageHandler {
|
||||
/// 接收 JS 桥接消息,根据消息类型路由到对应的委托方法
|
||||
public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
RDEPUBWebViewDebug.logMessage(debugScope, webView: message.webView, name: message.name, body: message.body)
|
||||
switch message.name {
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
// RDEPUBWebView+Reflowable.swift
|
||||
// RDEPUBWebView 的重排模式渲染扩展
|
||||
// 处理重排(Reflowable)模式的加载流程:构建渲染请求、
|
||||
// 生成加载签名(去重)、通过 JS 桥接应用分页样式和高亮、
|
||||
// 处理重复请求跳过和已有文档复用等优化逻辑。
|
||||
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
extension RDEPUBWebView {
|
||||
/// 便捷方法:加载重排模式的指定页面
|
||||
public func loadPage(
|
||||
parser: RDEPUBParser,
|
||||
spineIndex: Int,
|
||||
@ -39,6 +46,7 @@ extension RDEPUBWebView {
|
||||
load(publication: parser.makePublication(), request: request)
|
||||
}
|
||||
|
||||
/// 处理重排模式加载:设置状态、去重检查、加载资源或直接应用样式
|
||||
func handleReflowableLoad(
|
||||
publication: RDEPUBPublication,
|
||||
request: RDEPUBReflowableRenderRequest,
|
||||
@ -83,6 +91,7 @@ extension RDEPUBWebView {
|
||||
webView.load(URLRequest(url: requestURL))
|
||||
}
|
||||
|
||||
/// 生成重排模式加载签名(包含所有渲染参数的哈希,用于去重)
|
||||
func reflowableLoadSignature(publicationKey: String, request: RDEPUBReflowableRenderRequest) -> String {
|
||||
let targetSignature = [
|
||||
request.targetLocation?.href ?? "",
|
||||
@ -93,6 +102,16 @@ extension RDEPUBWebView {
|
||||
let highlightSignature = request.highlights
|
||||
.map { [$0.id, $0.rangeInfo ?? "", $0.color, $0.style.rawValue].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
|
||||
return [
|
||||
publicationKey,
|
||||
@ -112,10 +131,12 @@ extension RDEPUBWebView {
|
||||
presentation.themeBackgroundColor ?? "",
|
||||
presentation.themeTextColor ?? "",
|
||||
targetSignature,
|
||||
highlightSignature
|
||||
highlightSignature,
|
||||
searchSignature
|
||||
].joined(separator: "#")
|
||||
}
|
||||
|
||||
/// 应用分页展示样式:通过 JS 桥接注入 CSS、设置分页参数和高亮,然后触发渲染完成
|
||||
func applyPresentation() {
|
||||
guard let webView, let currentRenderRequest else { return }
|
||||
guard case .reflowable(let request) = currentRenderRequest else {
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
// RDEPUBWebView+Search.swift
|
||||
// RDEPUBWebView 的搜索高亮扩展
|
||||
// 从当前渲染请求中提取搜索展示信息,生成搜索高亮脚本并注入到 WebView 中执行。
|
||||
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
extension RDEPUBWebView {
|
||||
/// 如果当前渲染请求包含搜索信息,注入搜索高亮脚本到 WebView
|
||||
func applySearchDecorationsIfNeeded(completion: (() -> Void)? = nil) {
|
||||
guard let webView else {
|
||||
completion?()
|
||||
@ -26,7 +32,107 @@ extension RDEPUBWebView {
|
||||
if let error {
|
||||
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?()
|
||||
}
|
||||
}
|
||||
|
||||
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 WebKit
|
||||
|
||||
/// RDEPUBWebView 委托协议,定义 WebView 向上层通知的事件
|
||||
public protocol RDEPUBWebViewDelegate: AnyObject {
|
||||
/// 阅读位置更新事件
|
||||
func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
|
||||
/// 文本选择变化事件
|
||||
func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
|
||||
/// 选择菜单动作请求事件
|
||||
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
|
||||
/// 内部链接点击事件
|
||||
func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
|
||||
/// 外部链接点击事件
|
||||
func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL)
|
||||
/// JavaScript 错误事件
|
||||
func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String)
|
||||
/// 页面渲染完成事件
|
||||
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView)
|
||||
}
|
||||
|
||||
/// 委托协议的默认实现(选择菜单动作为可选)
|
||||
public extension RDEPUBWebViewDelegate {
|
||||
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {}
|
||||
}
|
||||
|
||||
/// 自定义 WKWebView 子类,支持拷贝/高亮/批注选择菜单
|
||||
final class RDEPUBAnnotationWebView: WKWebView {
|
||||
/// 选择菜单动作回调
|
||||
var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)?
|
||||
|
||||
override var canBecomeFirstResponder: Bool {
|
||||
@ -46,33 +64,63 @@ final class RDEPUBAnnotationWebView: WKWebView {
|
||||
}
|
||||
}
|
||||
|
||||
/// EPUB WebView 主类,封装 WKWebView 的 EPUB 渲染能力
|
||||
/// Resource View 层核心组件,统一管理固定版式和重排模式的渲染
|
||||
public final class RDEPUBWebView: UIView {
|
||||
/// 委托对象,接收渲染和交互事件
|
||||
public weak var delegate: RDEPUBWebViewDelegate?
|
||||
/// 渲染完成回调(与 delegate 并行使用)
|
||||
public var onRendered: (() -> Void)?
|
||||
/// 内部原生装饰解析回调
|
||||
var onDecorationsResolved: (([RDEPUBTextOverlayDecoration]) -> Void)?
|
||||
|
||||
/// 当前关联的出版物
|
||||
var publication: RDEPUBPublication?
|
||||
/// 当前渲染请求
|
||||
var currentRenderRequest: RDEPUBRenderRequest?
|
||||
/// 内部 WKWebView 实例
|
||||
var webView: WKWebView?
|
||||
/// URL Scheme 处理器
|
||||
var schemeHandler: RDEPUBResourceURLSchemeHandler?
|
||||
/// 已配置的出版物标识(用于判断是否需要重建 WebView)
|
||||
var configuredPublicationKey: String?
|
||||
/// 当前加载签名(用于去重相同请求)
|
||||
var currentLoadSignature: String?
|
||||
/// 当前 spine 索引
|
||||
var currentSpineIndex = 0
|
||||
/// 当前资源 href
|
||||
var currentHref = ""
|
||||
/// 当前页码(从 0 开始)
|
||||
var currentPageIndex = 0
|
||||
/// 当前章节总页数
|
||||
var currentTotalPagesInChapter = 1
|
||||
/// 当前视口尺寸
|
||||
var viewportSize: CGSize = .zero
|
||||
/// 当前内容内边距
|
||||
var currentPadding: UIEdgeInsets = .zero
|
||||
/// 当前字号
|
||||
var currentFontSize: CGFloat = 16
|
||||
/// 当前行高倍数
|
||||
var currentLineHeightMultiple: CGFloat = 1.5
|
||||
/// 当前主题背景色
|
||||
var currentThemeBackgroundColor: String?
|
||||
/// 当前主题文字色
|
||||
var currentThemeTextColor: String?
|
||||
/// 目标跳转位置
|
||||
var targetLocation: RDEPUBLocation?
|
||||
/// 待应用的高亮列表
|
||||
var pendingHighlights: [RDEPUBHighlight] = []
|
||||
/// 当前固定版式 spread(固定版式时非空)
|
||||
var fixedSpread: EPUBFixedSpread?
|
||||
/// 是否为固定版式模式
|
||||
var isFixedLayout = false
|
||||
/// 是否有待处理的进度汇报请求
|
||||
var pendingProgressionRequest = false
|
||||
/// 固定版式就绪回退定时器
|
||||
var fixedLayoutReadyWorkItem: DispatchWorkItem?
|
||||
/// 当前请求是否已完成渲染
|
||||
var didRenderCurrentRequest = false
|
||||
/// 调试日志作用域
|
||||
let debugScope = "ReaderWebView"
|
||||
|
||||
public override init(frame: CGRect) {
|
||||
@ -93,9 +141,11 @@ public final class RDEPUBWebView: UIView {
|
||||
webView?.frame = bounds
|
||||
}
|
||||
|
||||
/// 重置所有状态并销毁 WebView
|
||||
public func reset() {
|
||||
delegate = nil
|
||||
onRendered = nil
|
||||
onDecorationsResolved = nil
|
||||
publication = nil
|
||||
currentRenderRequest = nil
|
||||
currentSpineIndex = 0
|
||||
@ -117,6 +167,7 @@ public final class RDEPUBWebView: UIView {
|
||||
teardownWebView()
|
||||
}
|
||||
|
||||
/// 加载渲染请求:配置 WebView、生成签名、分发到固定版式或重排处理
|
||||
public func load(publication: RDEPUBPublication, request: RDEPUBRenderRequest) {
|
||||
configureWebViewIfNeeded(publication: publication)
|
||||
guard let webView else { return }
|
||||
@ -143,6 +194,7 @@ public final class RDEPUBWebView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成页面加载签名
|
||||
func pageLoadSignature(
|
||||
publicationKey: String,
|
||||
request: RDEPUBRenderRequest
|
||||
@ -155,6 +207,7 @@ public final class RDEPUBWebView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记渲染完成:通知委托和回调,延迟 100ms 确保布局稳定
|
||||
func rendered() {
|
||||
guard !didRenderCurrentRequest else { return }
|
||||
didRenderCurrentRequest = true
|
||||
|
||||
@ -1,7 +1,15 @@
|
||||
// RDEPUBWebViewDebug.swift
|
||||
// WebView 调试日志工具
|
||||
// 提供统一的调试日志输出接口,支持导航事件、JS 执行、
|
||||
// 消息接收、URL Scheme 任务等场景的日志记录。
|
||||
// DEBUG 模式默认开启,Release 模式默认关闭,可通过 UserDefaults 覆盖。
|
||||
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
/// WebView 调试日志工具集
|
||||
enum RDEPUBWebViewDebug {
|
||||
/// 调试日志是否启用(DEBUG 默认开启,可通过 UserDefaults "RDEPUBWebViewDebugEnabled" 覆盖)
|
||||
static var isEnabled: Bool = {
|
||||
if let configured = UserDefaults.standard.object(forKey: "RDEPUBWebViewDebugEnabled") as? Bool {
|
||||
return configured
|
||||
@ -13,16 +21,19 @@ enum RDEPUBWebViewDebug {
|
||||
#endif
|
||||
}()
|
||||
|
||||
/// 获取 WebView 的十六进制标识符(用于日志区分多个 WebView 实例)
|
||||
static func webViewID(_ webView: WKWebView?) -> String {
|
||||
guard let webView else { return "nil-webview" }
|
||||
return String(ObjectIdentifier(webView).hashValue, radix: 16)
|
||||
}
|
||||
|
||||
/// 输出通用日志
|
||||
static func log(_ scope: String, message: String) {
|
||||
guard isEnabled else { return }
|
||||
print("[RDReaderWK][\(scope)] \(message)")
|
||||
}
|
||||
|
||||
/// 输出导航事件日志(含 WebView ID、事件类型、URL、错误信息)
|
||||
static func logNavigationEvent(_ scope: String, webView: WKWebView?, event: String, url: URL? = nil, error: Error? = nil) {
|
||||
guard isEnabled else { return }
|
||||
let webViewToken = webViewID(webView)
|
||||
@ -34,16 +45,19 @@ enum RDEPUBWebViewDebug {
|
||||
}
|
||||
}
|
||||
|
||||
/// 输出 JavaScript 执行日志
|
||||
static func logJavaScript(_ scope: String, webView: WKWebView?, action: String, details: String) {
|
||||
guard isEnabled else { return }
|
||||
log(scope, message: "webView=\(webViewID(webView)) js=\(action) \(details)")
|
||||
}
|
||||
|
||||
/// 输出 JS 消息接收日志(含消息名称和消息体)
|
||||
static func logMessage(_ scope: String, webView: WKWebView?, name: String, body: Any) {
|
||||
guard isEnabled else { return }
|
||||
log(scope, message: "webView=\(webViewID(webView)) message=\(name) body=\(String(describing: body))")
|
||||
}
|
||||
|
||||
/// 输出 URL Scheme 任务日志(含请求 URL、文件 URL、事件类型、错误信息)
|
||||
static func logSchemeTask(_ scope: String, requestURL: URL?, fileURL: URL? = nil, event: String, error: Error? = nil) {
|
||||
guard isEnabled else { return }
|
||||
let requestText = summarizedURL(requestURL)
|
||||
@ -55,6 +69,7 @@ enum RDEPUBWebViewDebug {
|
||||
}
|
||||
}
|
||||
|
||||
/// 截取 URL 的摘要信息(ss-reader:// 协议显示完整 URL,其他显示最后路径组件)
|
||||
static func summarizedURL(_ url: URL?) -> String {
|
||||
guard let url else { return "nil" }
|
||||
if let scheme = url.scheme, scheme == RDEPUBResourceURLSchemeHandler.scheme {
|
||||
|
||||
@ -153,47 +153,195 @@
|
||||
var absoluteLeft = Math.max(0, rect.left + currentPageOffset());
|
||||
return Math.max(0, Math.floor(absoluteLeft / effectivePageStride()));
|
||||
}
|
||||
function unwrapHighlights() {
|
||||
document.querySelectorAll('mark.ss-reader-highlight').forEach(function(mark) {
|
||||
var parent = mark.parentNode;
|
||||
if (!parent) { return; }
|
||||
while (mark.firstChild) {
|
||||
parent.insertBefore(mark.firstChild, mark);
|
||||
}
|
||||
parent.removeChild(mark);
|
||||
});
|
||||
var storedHighlights = [];
|
||||
var storedSearchPresentation = null;
|
||||
function normalizeHref(rawHref) {
|
||||
if (!rawHref) { return ''; }
|
||||
var value = String(rawHref);
|
||||
value = value.replace(/^.*?:\/\/[^/]+\//, '');
|
||||
value = value.split('#')[0];
|
||||
value = value.replace(/^\/+/, '');
|
||||
try {
|
||||
value = decodeURIComponent(value);
|
||||
} catch (error) {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function clearHighlights() {
|
||||
storedHighlights = [];
|
||||
}
|
||||
function setHighlights(items) {
|
||||
unwrapHighlights();
|
||||
if (!Array.isArray(items)) { return; }
|
||||
items.forEach(function(item) {
|
||||
if (!item || !item.rangeInfo) { return; }
|
||||
var range = rangeFromInfo(item.rangeInfo);
|
||||
if (!range || range.collapsed) { return; }
|
||||
storedHighlights = Array.isArray(items) ? items.slice() : [];
|
||||
}
|
||||
function setSearchPresentation(payload) {
|
||||
storedSearchPresentation = payload || null;
|
||||
}
|
||||
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 {
|
||||
var mark = document.createElement('mark');
|
||||
var style = item.style || 'highlight';
|
||||
mark.className = 'ss-reader-highlight ss-reader-highlight-' + style;
|
||||
mark.style.color = 'inherit';
|
||||
if (style === 'underline') {
|
||||
mark.style.background = 'transparent';
|
||||
mark.style.textDecorationLine = 'underline';
|
||||
mark.style.textDecorationStyle = 'solid';
|
||||
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();
|
||||
}
|
||||
if (!frame.contentDocument) { return; }
|
||||
var frameRect = frame.getBoundingClientRect();
|
||||
documentContexts(
|
||||
frame.contentDocument,
|
||||
offsetX + frameRect.left,
|
||||
offsetY + frameRect.top
|
||||
).forEach(function(item) {
|
||||
items.push(item);
|
||||
});
|
||||
} 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() {
|
||||
var selection = window.getSelection();
|
||||
@ -290,7 +438,9 @@
|
||||
this.scrollToPage(derivedPageIndex);
|
||||
},
|
||||
setHighlights: setHighlights,
|
||||
clearHighlights: unwrapHighlights,
|
||||
clearHighlights: clearHighlights,
|
||||
setSearchPresentation: setSearchPresentation,
|
||||
resolveDecorations: resolveDecorations,
|
||||
reportProgression: reportProgression,
|
||||
selectionPayload: selectionPayload
|
||||
};
|
||||
56
Sources/RDReaderView/EPUBCore/Resources/wxread-dark.css
Normal file
56
Sources/RDReaderView/EPUBCore/Resources/wxread-dark.css
Normal file
@ -0,0 +1,56 @@
|
||||
/* 适配搜狗百科 bar 里的 icon 被染色 */
|
||||
.header-home, .header-home:before, .header-logo, .loginBox, .header-search, .header-search:before, .header-more:before {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* 适配搜狗百科 某些透明的 div 被染色导致挡住图片 */
|
||||
.abstract-img-none {
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
/* 适配百度搜索图片丢失 */
|
||||
.c-touchable-feedback-no-default * {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
img, video {
|
||||
z-index: 1 !important;
|
||||
}
|
||||
|
||||
/*背景纯黑*/
|
||||
*, *:before, *:after {
|
||||
background-color: rgba(0, 0, 0, 1) !important;
|
||||
}
|
||||
|
||||
.wr-business-container,
|
||||
.wr-business-container *,
|
||||
.wr-business-container *:before,
|
||||
.wr-business-container *:after {
|
||||
background-color: rgb(28, 28, 29) !important;
|
||||
}
|
||||
|
||||
/*背景颜色和一般字体颜色*/
|
||||
div, h1, h2, h3, h4, h5, h6, p, body, em, html, link, textarea, form, select, input, span, button, em, menu, aside, table, tr, td, nav, dl, dt, dd, amp-iframe, main, section {
|
||||
color: rgba(180, 180, 182, 1) !important;
|
||||
border-color: #555555 !important;
|
||||
text-shadow: 0 0 0 #000;
|
||||
}
|
||||
|
||||
/*超链接*/
|
||||
a {
|
||||
color: rgba(84, 127, 176, 1) !important;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
color : rgb(218, 220, 224);
|
||||
background-color : rgb(10, 14, 18) !important;
|
||||
border-left: 1px solid rgba(116, 120, 124, 1);
|
||||
}
|
||||
|
||||
img {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
ul li:before, ol li:before {
|
||||
background-color: rgba(196, 200, 204, 1) !important;
|
||||
}
|
||||
221
Sources/RDReaderView/EPUBCore/Resources/wxread-default.css
Normal file
221
Sources/RDReaderView/EPUBCore/Resources/wxread-default.css
Normal file
@ -0,0 +1,221 @@
|
||||
/* this file is processed with xxd via a build rule and embedded in library */
|
||||
|
||||
/* these styles come from Safari */
|
||||
|
||||
/* note that comments are only permitted before selectors and before the styles */
|
||||
|
||||
/* DO NOT fiddle with this file if you want to have your own styles,
|
||||
pass your own stylesheet via the option parameter to override these defaults */
|
||||
|
||||
head {
|
||||
display:none;
|
||||
}
|
||||
|
||||
title {
|
||||
display:none;
|
||||
}
|
||||
|
||||
style {
|
||||
display:none;
|
||||
}
|
||||
|
||||
link {
|
||||
display: none;
|
||||
}
|
||||
|
||||
meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
script {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html {
|
||||
display:block;
|
||||
margin:0;
|
||||
padding:0;
|
||||
}
|
||||
|
||||
body {
|
||||
display:block;
|
||||
font-size:16px;
|
||||
margin:0;
|
||||
padding:0;
|
||||
}
|
||||
|
||||
article,aside,footer,header,hgroup,nav,section {
|
||||
display:block;
|
||||
}
|
||||
|
||||
p {
|
||||
display:block;
|
||||
margin:0;
|
||||
}
|
||||
|
||||
img {
|
||||
margin:0;
|
||||
}
|
||||
|
||||
ul,ol,menu,dir {
|
||||
display:block;
|
||||
margin:1em 0 1em 0;
|
||||
padding-left:24px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type:disc;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type:decimal;
|
||||
}
|
||||
|
||||
li {
|
||||
display:list-item;
|
||||
}
|
||||
|
||||
ul ul, ol ul {
|
||||
list-style-type: circle;
|
||||
}
|
||||
|
||||
ol ol ul, ol ul ul, ul ol ul, ul ul ul {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family:Courier;
|
||||
}
|
||||
|
||||
pre, xmp, plaintext, listing {
|
||||
display: block;
|
||||
font-family: monospace;
|
||||
white-space: pre;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color:#2262A3;
|
||||
text-decoration:underline;
|
||||
}
|
||||
|
||||
a:active {
|
||||
color:#2262A3;
|
||||
}
|
||||
|
||||
center {
|
||||
text-align:center;
|
||||
display:block;
|
||||
}
|
||||
|
||||
strong,b {
|
||||
font-weight:bold;
|
||||
}
|
||||
|
||||
i,em {
|
||||
font-style:italic;
|
||||
}
|
||||
|
||||
u {
|
||||
text-decoration:underline;
|
||||
}
|
||||
|
||||
big {
|
||||
font-size:bigger;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size:smaller;
|
||||
}
|
||||
|
||||
sub {
|
||||
font-size:smaller;
|
||||
vertical-align:sub;
|
||||
}
|
||||
|
||||
sup {
|
||||
font-size:smaller;
|
||||
vertical-align:super;
|
||||
}
|
||||
|
||||
s,strike,del {
|
||||
text-decoration:line-through;
|
||||
}
|
||||
|
||||
tt,code,kbd,samp {
|
||||
font-family:monospace;
|
||||
}
|
||||
|
||||
pre,xmp,plaintext,listing {
|
||||
display:block;
|
||||
font-family:monospace;
|
||||
white-space:pre;
|
||||
margin-top:1em;
|
||||
margin-right:0;
|
||||
margin-bottom:1em;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color:rgba(0,0,0,.05);
|
||||
padding-top:1em;
|
||||
padding-bottom:1em;
|
||||
border-radius:0.3em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
display:block;
|
||||
font-size:1.5em;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
h2 {
|
||||
display:block;
|
||||
font-size:1.4em;
|
||||
margin-top: 0.83em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
display:block;
|
||||
font-size:1.3em;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
h4 {
|
||||
display:block;
|
||||
font-size:1.2em;
|
||||
margin-top: 1.33em;
|
||||
}
|
||||
|
||||
h5 {
|
||||
display:block;
|
||||
font-size:1.1em;
|
||||
margin-top: 1.67em;
|
||||
}
|
||||
|
||||
h6 {
|
||||
display:block;
|
||||
font-size:1em;
|
||||
margin-top: 2.33em;
|
||||
}
|
||||
|
||||
div {
|
||||
display: block;
|
||||
}
|
||||
|
||||
hr {
|
||||
display: block;
|
||||
margin:0.5em auto 0.5em auto;
|
||||
border-style: inset;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
table {
|
||||
display: table;
|
||||
border-collapse: separate;
|
||||
border-spacing: 2px;
|
||||
border-color: gray;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
display: block;
|
||||
}
|
||||
113
Sources/RDReaderView/EPUBCore/Resources/wxread-replace-latin.css
Normal file
113
Sources/RDReaderView/EPUBCore/Resources/wxread-replace-latin.css
Normal file
@ -0,0 +1,113 @@
|
||||
/* <pre>代码块,注意必须写font-weight使字体生效 */
|
||||
pre {
|
||||
font-family: "Menlo";
|
||||
font-weight: normal;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
/*版权信息*/
|
||||
.copyRightTitle {
|
||||
color: black;
|
||||
font-size: 1.5em;
|
||||
font-family: "Source Han Serif CN";
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/*图片说明文字*/
|
||||
.eepub-single-image-title {
|
||||
font-size: 0.75em;
|
||||
text-align: center;
|
||||
line-height: 1.4em;
|
||||
color: rgba(0, 0, 0, 0.9);
|
||||
margin: 0.2em 0.4em 1em 0.4em;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/*标题*/
|
||||
.firstTitle, h1.firstTitle {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
line-height: 1.25em;
|
||||
}
|
||||
.secondTitle, h2.secondTitle {
|
||||
font-size: 1.4em;
|
||||
font-weight: bold;
|
||||
line-height: 1.35em;
|
||||
}
|
||||
.thirdTitle, h3.thirdTitle {
|
||||
font-size: 1.3em;
|
||||
font-weight: bold;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
.fourthTitle, h4.fourthTitle {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
line-height: 1.65em;
|
||||
}
|
||||
.fifthTitle, h5.fifthTitle {
|
||||
font-size: 1.1em;
|
||||
font-weight: bold;
|
||||
line-height: 1.85em;
|
||||
}
|
||||
.sixthTitle, h6.sixthTitle {
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
line-height: 2em;
|
||||
}
|
||||
|
||||
/*首字加大*/
|
||||
/*浮动元素有默认的margin,所以这里会修复一下*/
|
||||
.ftext {
|
||||
float: left;
|
||||
margin: 0em;
|
||||
font-size: 2.38em;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/*引用内容*/
|
||||
.conQuot {
|
||||
font-weight: normal;
|
||||
margin: 0em 0em 0.2em 0em;
|
||||
}
|
||||
|
||||
/*标题下来可能会有一行subHead*/
|
||||
.subHead{
|
||||
text-indent: 2em;
|
||||
}
|
||||
|
||||
pre, pre span, pre code {
|
||||
font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
font-size:.7em!important;
|
||||
}
|
||||
|
||||
.bodyPic {
|
||||
wr-vertical-center-style: 2;
|
||||
}
|
||||
|
||||
.qrbodyPic {
|
||||
page-break-inside: avoid;
|
||||
wr-vertical-center-style: 2;
|
||||
}
|
||||
|
||||
/* 注标图 */
|
||||
.qqreader-footnote {
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
/* 私有垂直居中类 */
|
||||
.wr-vertical-center {
|
||||
wr-vertical-center-style: 1 !important;
|
||||
}
|
||||
|
||||
/* 翻译 */
|
||||
.wr-translation {
|
||||
line-height: 1.7em !important;
|
||||
}
|
||||
|
||||
/* 章节结尾工具 */
|
||||
.book-chapter-tool {
|
||||
display: block;
|
||||
height: 78px;
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
}
|
||||
171
Sources/RDReaderView/EPUBCore/Resources/wxread-replace.css
Normal file
171
Sources/RDReaderView/EPUBCore/Resources/wxread-replace.css
Normal file
@ -0,0 +1,171 @@
|
||||
/* <pre>代码块,注意必须写font-weight使字体生效 */
|
||||
pre {
|
||||
font-family: "Menlo";
|
||||
font-weight: normal;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
/*版权信息*/
|
||||
.copyRightTitle {
|
||||
color: black;
|
||||
font-size: 1.5em;
|
||||
font-family: "Source Han Serif CN";
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/*图片说明文字*/
|
||||
.eepub-single-image-title {
|
||||
font-size: 0.75em;
|
||||
text-align: center;
|
||||
line-height: 1.4em;
|
||||
color: rgba(0, 0, 0, 0.9);
|
||||
margin: 0.2em 0.4em 1em 0.4em;
|
||||
font-family: "FZFSJW--GB1-0";
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/*标题*/
|
||||
.firstTitle, h1.firstTitle {
|
||||
font-size: 1.5em;
|
||||
font-family: "Source Han Serif CN";
|
||||
font-weight: bold;
|
||||
line-height: 1.25em;
|
||||
}
|
||||
.secondTitle, h2.secondTitle {
|
||||
font-size: 1.4em;
|
||||
font-family: "Source Han Serif CN";
|
||||
font-weight: bold;
|
||||
line-height: 1.35em;
|
||||
}
|
||||
.thirdTitle, h3.thirdTitle {
|
||||
font-size: 1.3em;
|
||||
font-family: "Source Han Serif CN";
|
||||
font-weight: bold;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
.fourthTitle, h4.fourthTitle {
|
||||
font-size: 1.2em;
|
||||
font-family: "Source Han Serif CN";
|
||||
font-weight: bold;
|
||||
line-height: 1.65em;
|
||||
}
|
||||
.fifthTitle, h5.fifthTitle {
|
||||
font-size: 1.1em;
|
||||
font-family: "Source Han Serif CN";
|
||||
font-weight: bold;
|
||||
line-height: 1.85em;
|
||||
}
|
||||
.sixthTitle, h6.sixthTitle {
|
||||
font-size: 1em;
|
||||
font-family: "Source Han Serif CN";
|
||||
font-weight: bold;
|
||||
line-height: 2em;
|
||||
}
|
||||
|
||||
/*首字加大*/
|
||||
/*浮动元素有默认的margin,所以这里会修复一下*/
|
||||
.ftext {
|
||||
float: left;
|
||||
margin: 0em;
|
||||
font-size: 2.38em;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/*引用内容*/
|
||||
.conQuot {
|
||||
font-family: "FZFSJW--GB1-0";
|
||||
font-weight: normal;
|
||||
margin: 0em 0em 0.2em 0em;
|
||||
}
|
||||
|
||||
/*标题下来可能会有一行subHead*/
|
||||
.subHead{
|
||||
text-indent: 2em;
|
||||
}
|
||||
|
||||
pre, pre span, pre code {
|
||||
font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
font-size:.7em!important;
|
||||
}
|
||||
|
||||
.bodyPic {
|
||||
wr-vertical-center-style: 2;
|
||||
}
|
||||
|
||||
.qrbodyPic {
|
||||
page-break-inside: avoid;
|
||||
wr-vertical-center-style: 2;
|
||||
}
|
||||
|
||||
/* 注标图 */
|
||||
.qqreader-footnote {
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
/* 翻译 */
|
||||
.wr-translation {
|
||||
line-height: 1.7em !important;
|
||||
}
|
||||
|
||||
/*-------------------- 小文章相关 ----------------------*/
|
||||
|
||||
/*epub-custom-rule-6 如果 class="weread-page-relate" 在开头,需要把上一页末尾一行移到这一页*/
|
||||
.weread-page-relate {
|
||||
weread-page-relate:true;
|
||||
}
|
||||
|
||||
/*epub-custom-rule-29 小文章打赏的样式*/
|
||||
.chapter-reward {
|
||||
display: block;
|
||||
height: 120px;
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/*epub-custom-rule-7 小文章工具栏标签的样式*/
|
||||
.chapter-tool {
|
||||
display: block;
|
||||
height: 140px;
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* 章节结尾工具 */
|
||||
.book-chapter-tool {
|
||||
display: block;
|
||||
height: 78px;
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* 调试好样式后和安卓同步,交给后台随小文章 css 下发,并把下面的样式从replace.css 删除 */
|
||||
|
||||
/* 安卓的re_bookItem是通过原生view留出上下空间的,但是iOS是通过css来控制 */
|
||||
.re_bookItem{
|
||||
display: block;
|
||||
margin: 18px 0 18px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
text-align:justify;
|
||||
}
|
||||
|
||||
img[data-image-size="large"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 文集公众号文章不支持控件的样式 */
|
||||
.unsupported_iframe {
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ffcdc0;
|
||||
background: #fee;
|
||||
padding: 12px 0px;
|
||||
color: #ff8d8d;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 私有垂直居中类 */
|
||||
.wr-vertical-center {
|
||||
wr-vertical-center-style: 1 !important;
|
||||
}
|
||||
@ -1,7 +1,14 @@
|
||||
import UIKit
|
||||
|
||||
/// 章节数据访问层:为已分页章节提供便捷的查询接口。
|
||||
///
|
||||
/// 本类是 `RDEPUBTextChapter` 的轻量级封装,持有 `indexTable`(全局索引表),
|
||||
/// 将分页数据、fragment 锚点、高亮、搜索结果等统一到同一套查询 API 中。
|
||||
/// 由 `RDEPUBTextBook.chapterData(for:)` 或 `chapterData(atChapterIndex:)` 创建。
|
||||
public final class RDEPUBChapterData {
|
||||
/// 底层章节模型(只读)
|
||||
public let chapter: RDEPUBTextChapter
|
||||
/// 全局索引表,用于 fileIndex/row/column 到绝对偏移量的映射
|
||||
public let indexTable: RDEPUBTextIndexTable
|
||||
|
||||
public init(chapter: RDEPUBTextChapter, indexTable: RDEPUBTextIndexTable) {
|
||||
@ -9,36 +16,61 @@ public final class RDEPUBChapterData {
|
||||
self.indexTable = indexTable
|
||||
}
|
||||
|
||||
// MARK: - 便捷属性(转发自 chapter)
|
||||
|
||||
/// 章节在全书中的序号
|
||||
public var chapterIndex: Int { chapter.chapterIndex }
|
||||
/// spine 顺序索引
|
||||
public var spineIndex: Int { chapter.spineIndex }
|
||||
/// 章节文件相对路径(如 "OEBPS/chapter1.xhtml")
|
||||
public var href: String { chapter.href }
|
||||
/// 章节标题
|
||||
public var title: String { chapter.title }
|
||||
/// 章节完整的富文本内容
|
||||
public var attributedContent: NSAttributedString { chapter.attributedContent }
|
||||
/// 分页后的页面列表
|
||||
public var pages: [RDEPUBTextPage] { chapter.pages }
|
||||
/// fragment ID → 字符偏移量的映射表(用于锚点定位)
|
||||
public var fragmentOffsets: [String: Int] { chapter.fragmentOffsets }
|
||||
|
||||
// MARK: - 页面查询
|
||||
|
||||
/// 查找包含指定绝对字符偏移量的页面
|
||||
/// - Parameter absoluteOffset: 全书绝对字符偏移量
|
||||
/// - Returns: 包含该偏移量的页面,未找到返回 nil
|
||||
public func page(containing absoluteOffset: Int) -> RDEPUBTextPage? {
|
||||
chapter.pages.first { NSLocationInRange(absoluteOffset, $0.contentRange) }
|
||||
}
|
||||
|
||||
/// 获取指定绝对偏移量所在页的绝对页码(从 0 开始)
|
||||
public func pageNumber(containing absoluteOffset: Int) -> Int? {
|
||||
page(containing: absoluteOffset)?.absolutePageIndex
|
||||
}
|
||||
|
||||
/// 按绝对页码查找页面
|
||||
public func page(atAbsolutePageIndex absolutePageIndex: Int) -> RDEPUBTextPage? {
|
||||
chapter.pages.first { $0.absolutePageIndex == absolutePageIndex }
|
||||
}
|
||||
|
||||
// MARK: - 锚点与位置映射
|
||||
|
||||
/// 将绝对字符索引转换为语义锚点(fileIndex/row/column 三元组)
|
||||
public func anchor(forAbsoluteIndex index: Int) -> RDEPUBTextAnchor {
|
||||
indexTable.anchor(forAbsoluteIndex: index, in: chapter)
|
||||
}
|
||||
|
||||
/// 将绝对字符范围转换为起止锚点对
|
||||
public func rangeAnchor(for absoluteRange: NSRange) -> RDEPUBTextRangeAnchor {
|
||||
let start = anchor(forAbsoluteIndex: absoluteRange.location)
|
||||
let end = anchor(forAbsoluteIndex: absoluteRange.location + absoluteRange.length)
|
||||
return RDEPUBTextRangeAnchor(start: start, end: end)
|
||||
}
|
||||
|
||||
/// 从绝对字符范围构建选区对象(用于复制/高亮分享)
|
||||
/// - Parameters:
|
||||
/// - absoluteRange: 选区在全书中的字符范围
|
||||
/// - bookIdentifier: 书籍标识符
|
||||
/// - Returns: 选区对象,若起始偏移量不在任何页面内则返回 nil
|
||||
public func selection(from absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBSelection? {
|
||||
guard page(containing: absoluteRange.location) != nil else { return nil }
|
||||
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 {
|
||||
indexTable.location(
|
||||
for: rangeAnchor(for: absoluteRange),
|
||||
@ -64,10 +97,19 @@ public final class RDEPUBChapterData {
|
||||
)
|
||||
}
|
||||
|
||||
/// 将页面转换为对应的 RDEPUBLocation
|
||||
public func location(forPage page: RDEPUBTextPage, bookIdentifier: String?) -> RDEPUBLocation {
|
||||
location(for: page.contentRange, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
|
||||
// MARK: - 位置反向解析(Location → 绝对偏移量)
|
||||
|
||||
/// 将持久化位置还原为章节内的绝对字符范围。
|
||||
///
|
||||
/// 解析优先级:
|
||||
/// 1. rangeAnchor(锚点定位,最精确)
|
||||
/// 2. fragment(片段 ID 定位)
|
||||
/// 3. navigationProgression(进度百分比回退)
|
||||
public func absoluteRange(for location: RDEPUBLocation) -> NSRange? {
|
||||
if let rangeAnchor = location.rangeAnchor {
|
||||
return indexTable.absoluteRange(for: rangeAnchor)
|
||||
@ -83,6 +125,7 @@ public final class RDEPUBChapterData {
|
||||
return NSRange(location: offset, length: 1)
|
||||
}
|
||||
|
||||
/// 从高亮的持久化位置还原绝对字符范围
|
||||
public func absoluteRange(for highlight: RDEPUBHighlight) -> NSRange? {
|
||||
if let rangeAnchor = highlight.location.rangeAnchor {
|
||||
return indexTable.absoluteRange(for: rangeAnchor)
|
||||
@ -90,6 +133,7 @@ public final class RDEPUBChapterData {
|
||||
return RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange
|
||||
}
|
||||
|
||||
/// 从搜索结果还原绝对字符范围
|
||||
public func absoluteRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
|
||||
if let rangeAnchor = searchMatch.rangeAnchor {
|
||||
return indexTable.absoluteRange(for: rangeAnchor)
|
||||
@ -100,6 +144,9 @@ public final class RDEPUBChapterData {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - 高亮/搜索结果与页面的交叉查询
|
||||
|
||||
/// 获取指定页面上出现的所有高亮
|
||||
public func highlights(on page: RDEPUBTextPage, from allHighlights: [RDEPUBHighlight]) -> [RDEPUBHighlight] {
|
||||
let pageRange = absoluteOffsetRange(for: page)
|
||||
return allHighlights.filter { highlight in
|
||||
@ -114,6 +161,7 @@ public final class RDEPUBChapterData {
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取指定页面上出现的所有搜索匹配结果
|
||||
public func searchMatches(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] {
|
||||
let pageRange = absoluteOffsetRange(for: page)
|
||||
return matches.filter { match in
|
||||
@ -125,6 +173,7 @@ public final class RDEPUBChapterData {
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据持久化位置获取对应页码(从 1 开始),用于阅读进度跳转
|
||||
public func pageNumber(for location: RDEPUBLocation) -> Int? {
|
||||
if let range = absoluteRange(for: location) {
|
||||
return pageNumber(containing: range.location).map { $0 + 1 }
|
||||
@ -132,12 +181,16 @@ public final class RDEPUBChapterData {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - 私有工具方法
|
||||
|
||||
/// 将页面的起止偏移量转换为半开区间 [start, end+1)
|
||||
private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range<Int> {
|
||||
let lowerBound = page.pageStartOffset
|
||||
let upperBound = page.pageEndOffset + 1
|
||||
return lowerBound..<max(upperBound, lowerBound)
|
||||
}
|
||||
|
||||
/// 将语义锚点转换为绝对字符偏移量,若索引表无法解析则回退到 chapterOffset
|
||||
private func absoluteOffset(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
indexTable.absoluteIndex(
|
||||
fileIndex: anchor.fileIndex,
|
||||
|
||||
@ -4,9 +4,18 @@ import UIKit
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
/// DTCoreText 渲染器实现:将 EPUB 章节 HTML 转换为富文本(NSAttributedString)。
|
||||
///
|
||||
/// 这是 `RDEPUBTextRenderer` 协议的默认实现,位于渲染链路的第二步:
|
||||
/// 章节 HTML → DTCoreText 渲染 → NSAttributedString → 提取 fragment 偏移量 → 统一字体/行距
|
||||
///
|
||||
/// DTCoreText 库负责将 HTML 解析为带有排版属性的富文本,
|
||||
/// 渲染过程中会通过 `willFlushCallback` 回调对每个 DOM 元素做布局规范化(图片尺寸等)。
|
||||
/// 当 DTCoreText 不可用时(条件编译失败),回退到纯文本渲染。
|
||||
public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
public init() {}
|
||||
|
||||
/// DTCoreText 库是否在当前编译环境中可用
|
||||
public static var isAvailable: Bool {
|
||||
#if canImport(DTCoreText)
|
||||
return true
|
||||
@ -15,6 +24,14 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 渲染单个章节:HTML → NSAttributedString,同时提取 fragment 和语义标记。
|
||||
///
|
||||
/// 渲染流程:
|
||||
/// 1. 将 HTML 字符串编码为 Data
|
||||
/// 2. 通过 DTCoreText 解析为富文本(失败则回退到纯文本)
|
||||
/// 3. 注入分页语义标记(${rd-sem-start/end} → 属性字典)
|
||||
/// 4. 提取 fragment 偏移量映射表
|
||||
/// 5. 规范化阅读属性(字体、行距、颜色统一)
|
||||
public func renderChapter(
|
||||
request: RDEPUBTextChapterRenderRequest
|
||||
) throws -> RDEPUBRenderedChapterContent {
|
||||
@ -42,6 +59,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 便捷方法:直接传入 HTML 字符串进行渲染(不含上下文信息)
|
||||
public func renderChapter(
|
||||
html: String,
|
||||
baseURL: URL?,
|
||||
@ -58,6 +76,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
return try renderChapter(request: request)
|
||||
}
|
||||
|
||||
/// 回退渲染:当 DTCoreText 不可用时,将 HTML 源码当作纯文本处理
|
||||
private func fallbackRenderedContent(request: RDEPUBTextChapterRenderRequest) -> RDEPUBRenderedChapterContent {
|
||||
let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style)
|
||||
RDEPUBTextRendererSupport.applyPaginationSemantics(in: attributedString)
|
||||
@ -71,6 +90,10 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
/// 使用 DTCoreText 将 HTML Data 解析为富文本。
|
||||
///
|
||||
/// 通过 `willFlushCallback` 回调,在每个 DOM 元素最终写入富文本前,
|
||||
/// 对图片附件做尺寸规范化、判断是否为脚注/封面等特殊元素。
|
||||
private func makeAttributedString(from data: Data, request: RDEPUBTextChapterRenderRequest) -> NSAttributedString? {
|
||||
let builder = DTHTMLAttributedStringBuilder(
|
||||
html: data,
|
||||
@ -87,6 +110,10 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
return builder?.generatedAttributedString()
|
||||
}
|
||||
|
||||
/// 构建 DTCoreText 的解析选项字典,包括字体、行高、图片尺寸限制等。
|
||||
///
|
||||
/// - 注意:行高倍率计算公式为 `(字体行高 + 行间距) / 字体行高`,
|
||||
/// 确保最终行距与用户设置的 style.lineSpacing 一致。
|
||||
private func dtOptions(request: RDEPUBTextChapterRenderRequest) -> [AnyHashable: Any] {
|
||||
let style = request.style
|
||||
let screenBounds = UIScreen.main.bounds.insetBy(dx: 20, dy: 28)
|
||||
|
||||
@ -1,48 +1,80 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 章节分页诊断数据
|
||||
|
||||
/// 单个章节的分页诊断信息,用于调试和质量检测。
|
||||
/// 记录页数、分页原因、附件/块级元素统计等。
|
||||
public struct RDEPUBTextChapterPaginationDiagnostic: Equatable {
|
||||
public var href: String
|
||||
public var title: String
|
||||
public var pageCount: Int
|
||||
/// 每页的分页原因列表
|
||||
public var breakReasons: [RDEPUBTextPageBreakReason]
|
||||
/// 包含附件的页数
|
||||
public var attachmentPageCount: Int
|
||||
/// 因块级/附件边界调整而分页的页数
|
||||
public var blockAdjustedPageCount: Int
|
||||
public var blockKinds: [RDEPUBTextBlockKind]
|
||||
public var semanticHints: [RDEPUBTextSemanticHint]
|
||||
public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
|
||||
/// 采样诊断日志(最多 4 条)
|
||||
public var sampleNotes: [String]
|
||||
}
|
||||
|
||||
// MARK: - 页面数据模型
|
||||
|
||||
/// 分页后的单页数据,包含全书绝对页码、所属章节、内容范围等。
|
||||
///
|
||||
/// 每个 `RDEPUBTextPage` 由 `RDEPUBTextLayoutFrame` 生成,
|
||||
/// `contentRange` 标记了该页在章节富文本中的字符范围。
|
||||
public struct RDEPUBTextPage: Equatable {
|
||||
/// 全书绝对页码(从 0 开始)
|
||||
public var absolutePageIndex: Int
|
||||
public var chapterIndex: Int
|
||||
public var spineIndex: Int
|
||||
public var href: String
|
||||
public var chapterTitle: String
|
||||
/// 该页在所属章节中的相对页码(从 0 开始)
|
||||
public var pageIndexInChapter: Int
|
||||
public var totalPagesInChapter: Int
|
||||
/// 所属章节的完整富文本内容(用于跨页查询)
|
||||
public var chapterContent: NSAttributedString
|
||||
/// 该页的富文本片段
|
||||
public var content: NSAttributedString
|
||||
/// 该页在章节富文本中的范围
|
||||
public var contentRange: NSRange
|
||||
public var pageStartOffset: Int
|
||||
public var pageEndOffset: Int
|
||||
/// 页的元数据(分页原因、附件、语义标记等)
|
||||
public var metadata: RDEPUBTextPageMetadata
|
||||
}
|
||||
|
||||
// MARK: - 章节数据模型
|
||||
|
||||
/// 渲染并分页后的单个章节,包含完整富文本和分页结果。
|
||||
public struct RDEPUBTextChapter: Equatable {
|
||||
public var chapterIndex: Int
|
||||
public var spineIndex: Int
|
||||
public var href: String
|
||||
public var title: String
|
||||
/// 章节完整富文本内容
|
||||
public var attributedContent: NSAttributedString
|
||||
/// fragment ID → 字符偏移量映射(用于锚点定位)
|
||||
public var fragmentOffsets: [String: Int]
|
||||
public var pageBreakReasons: [RDEPUBTextPageBreakReason]
|
||||
public var pages: [RDEPUBTextPage]
|
||||
}
|
||||
|
||||
// MARK: - 分页书籍模型
|
||||
|
||||
/// 整本 EPUB 的分页书籍模型,包含所有章节、页面和全局索引表。
|
||||
///
|
||||
/// 这是 EPUBTextRendering 层的最终产物,由 `RDEPUBTextBookBuilder.build()` 生成。
|
||||
/// 通过 `chapterData(for:)` 或 `chapterData(atChapterIndex:)` 获取 `RDEPUBChapterData` 进行查询。
|
||||
public struct RDEPUBTextBook {
|
||||
public var chapters: [RDEPUBTextChapter]
|
||||
public var pages: [RDEPUBTextPage]
|
||||
/// 全局索引表:fileIndex/row/column → 绝对字符偏移量
|
||||
public let indexTable: RDEPUBTextIndexTable
|
||||
|
||||
public init(chapters: [RDEPUBTextChapter], pages: [RDEPUBTextPage]) {
|
||||
@ -55,16 +87,19 @@ public struct RDEPUBTextBook {
|
||||
lhs.chapters == rhs.chapters && lhs.pages == rhs.pages
|
||||
}
|
||||
|
||||
/// 按 href 获取章节的数据访问层(包含索引表)
|
||||
public func chapterData(for href: String) -> RDEPUBChapterData? {
|
||||
guard let chapter = chapters.first(where: { $0.href == href }) else { return nil }
|
||||
return RDEPUBChapterData(chapter: chapter, indexTable: indexTable)
|
||||
}
|
||||
|
||||
/// 按章节序号获取章节的数据访问层
|
||||
public func chapterData(atChapterIndex index: Int) -> RDEPUBChapterData? {
|
||||
guard chapters.indices.contains(index) else { return nil }
|
||||
return RDEPUBChapterData(chapter: chapters[index], indexTable: indexTable)
|
||||
}
|
||||
|
||||
/// 按页码(从 1 开始)获取对应页面
|
||||
public func page(at pageNumber: Int) -> RDEPUBTextPage? {
|
||||
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else {
|
||||
return nil
|
||||
@ -72,6 +107,12 @@ public struct RDEPUBTextBook {
|
||||
return pages[pageNumber - 1]
|
||||
}
|
||||
|
||||
/// 根据持久化位置计算对应页码(从 1 开始)。
|
||||
///
|
||||
/// 解析优先级:
|
||||
/// 1. rangeAnchor 锚点定位
|
||||
/// 2. fragment 片段 ID
|
||||
/// 3. navigationProgression 进度百分比回退
|
||||
public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? {
|
||||
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier),
|
||||
let chapter = chapters.first(where: { $0.href == normalizedLocation.href }) else {
|
||||
@ -98,6 +139,7 @@ public struct RDEPUBTextBook {
|
||||
return chapter.pages.last.map { $0.absolutePageIndex + 1 }
|
||||
}
|
||||
|
||||
/// 根据页码生成持久化位置(RDEPUBLocation),包含起止锚点
|
||||
public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
guard let page = page(at: pageNumber),
|
||||
let chapter = chapters.first(where: { $0.chapterIndex == page.chapterIndex }) else {
|
||||
@ -121,26 +163,49 @@ public struct RDEPUBTextBook {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 分页书籍构建器
|
||||
|
||||
/// 从 EPUB publication 构建分页书籍模型的核心构建器。
|
||||
///
|
||||
/// 渲染链路:遍历 spine → 渲染每章 HTML → 分页 → 合并/规范化尾页 → 构建 RDEPUBTextBook
|
||||
///
|
||||
/// 特性:
|
||||
/// - 支持分页缓存(WXRead 模式:只缓存页范围,不缓存富文本)
|
||||
/// - 性能采样(记录每章渲染/分页耗时)
|
||||
/// - 尾页规范化(丢弃纯空白尾页、合并过短尾页)
|
||||
/// - 封面章节特殊处理
|
||||
public final class RDEPUBTextBookBuilder {
|
||||
private let renderer: RDEPUBTextRenderer
|
||||
private let cache: RDEPUBTextBookCache?
|
||||
private let layoutConfig: RDEPUBTextLayoutConfig
|
||||
private let sampler: RDEPUBTextPerformanceSampler
|
||||
|
||||
/// 最后一次构建的资源引用诊断(样式表、图片等)
|
||||
public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
|
||||
/// 最后一次构建的分页诊断(每章一页的分页原因、附件统计等)
|
||||
public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic] = []
|
||||
/// 最后一次构建的性能采样数据
|
||||
public private(set) var lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample] = []
|
||||
/// 最后一次构建的缓存命中/未命中统计
|
||||
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.cache = cache
|
||||
self.layoutConfig = layoutConfig
|
||||
self.sampler = RDEPUBTextPerformanceSampler()
|
||||
}
|
||||
|
||||
/// 默认构造器,使用 DTCoreText 渲染器
|
||||
public convenience init() {
|
||||
self.init(renderer: RDEPUBDTCoreTextRenderer())
|
||||
}
|
||||
|
||||
/// 生成最近一次构建的语义摘要,用于 Phase 7 质量检测日志
|
||||
public func phase7SemanticSummary(title: String? = nil) -> String? {
|
||||
guard !lastBuildPaginationDiagnostics.isEmpty else { return nil }
|
||||
|
||||
@ -164,6 +229,16 @@ public final class RDEPUBTextBookBuilder {
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// 核心构建方法:从 EPUB publication 构建分页书籍。
|
||||
///
|
||||
/// 流程:
|
||||
/// 1. 生成缓存键,尝试加载分页缓存
|
||||
/// 2. 遍历 spine 中的线性 HTML 章节
|
||||
/// 3. 渲染每章 HTML → NSAttributedString
|
||||
/// 4. 跳过空白的封面/扉页章节
|
||||
/// 5. 分页:缓存命中则使用缓存的页范围,否则调用 CoreText 分页引擎
|
||||
/// 6. 尾页规范化:丢弃纯空白尾页、合并过短尾页
|
||||
/// 7. 构建 RDEPUBTextBook 并保存分页缓存
|
||||
public func build(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
@ -180,7 +255,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
|
||||
let buildStart = CFAbsoluteTimeGetCurrent()
|
||||
|
||||
// Cache lookup — WXRead style: load per-chapter page ranges
|
||||
// 缓存查询 — WXRead 模式:只加载每章的页范围,不缓存富文本
|
||||
let bookID = publication.metadata.identifier ?? publication.metadata.title
|
||||
let cacheKey = makeCacheKey(bookID: bookID, pageSize: pageSize, style: style)
|
||||
let cachedPagination = cacheKey.flatMap { cache?.load(key: $0) }
|
||||
@ -191,6 +266,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
continue
|
||||
}
|
||||
|
||||
// 从目录表中解析章节标题
|
||||
let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
|
||||
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
|
||||
href: item.href,
|
||||
@ -198,9 +274,11 @@ public final class RDEPUBTextBookBuilder {
|
||||
rawHTML: rawHTML,
|
||||
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
|
||||
style: style,
|
||||
resourceResolver: publication.resourceResolver
|
||||
resourceResolver: publication.resourceResolver,
|
||||
contentLanguageCode: publication.metadata.language
|
||||
)
|
||||
|
||||
// 渲染 HTML → NSAttributedString
|
||||
let renderStart = CFAbsoluteTimeGetCurrent()
|
||||
let rendered = try renderer.renderChapter(request: request)
|
||||
let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart
|
||||
@ -211,6 +289,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
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))")
|
||||
}
|
||||
// 跳过空白的封面/扉页章节
|
||||
if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
|
||||
if item.href.lowercased().contains("cover") {
|
||||
print("[EPUB][Cover] skipped href=\(item.href)")
|
||||
@ -221,10 +300,12 @@ public final class RDEPUBTextBookBuilder {
|
||||
let chapterIndex = chapters.count
|
||||
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
|
||||
|
||||
// 分页:封面图片章节走特殊路径,缓存命中则跳过分页引擎
|
||||
let paginateStart = CFAbsoluteTimeGetCurrent()
|
||||
let layoutFrames: [RDEPUBTextLayoutFrame]
|
||||
let isCacheHit: Bool
|
||||
if isAttachmentOnlyCoverChapter(item: item, content: content, plainText: plainText) {
|
||||
// 纯图片封面:整个内容作为一页
|
||||
layoutFrames = [
|
||||
RDEPUBTextLayoutFrame(
|
||||
contentRange: NSRange(location: 0, length: content.length),
|
||||
@ -245,7 +326,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
]
|
||||
isCacheHit = false
|
||||
} 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
|
||||
let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit
|
||||
return RDEPUBTextLayoutFrame(
|
||||
@ -263,13 +344,15 @@ public final class RDEPUBTextBookBuilder {
|
||||
}
|
||||
isCacheHit = true
|
||||
} else {
|
||||
// 缓存未命中:调用 CoreText 分页引擎
|
||||
layoutFrames = content.length > 0
|
||||
? content.rd_paginatedFrames(size: pageSize, fragmentOffsets: rendered.fragmentOffsets)
|
||||
? content.rd_paginatedFrames(size: pageSize, fragmentOffsets: rendered.fragmentOffsets, config: layoutConfig)
|
||||
: []
|
||||
isCacheHit = false
|
||||
}
|
||||
let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
|
||||
|
||||
// 尾页规范化:丢弃纯空白尾页、合并过短尾页
|
||||
let normalizedFrames = normalizeTrailingFrames(
|
||||
layoutFrames,
|
||||
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")")
|
||||
}
|
||||
|
||||
// 记录性能采样
|
||||
sampler.record(RDEPUBTextPerformanceSample(
|
||||
chapterHref: item.href,
|
||||
renderDuration: renderDuration,
|
||||
@ -312,6 +396,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
lastBuildCacheStats.misses += 1
|
||||
}
|
||||
|
||||
// 构建页面和章节模型
|
||||
let chapterAttributedContent = content.copy() as! NSAttributedString
|
||||
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
|
||||
let range = frame.contentRange
|
||||
@ -369,6 +454,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
|
||||
sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart
|
||||
|
||||
// 保存分页缓存(只缓存页范围和分页原因,不缓存富文本)
|
||||
if let cacheKey {
|
||||
let paginationCache = chapters.map { chapter in
|
||||
let pageRanges = chapter.pages.map(\.contentRange)
|
||||
@ -390,6 +476,9 @@ public final class RDEPUBTextBookBuilder {
|
||||
return book
|
||||
}
|
||||
|
||||
// MARK: - 章节标题解析
|
||||
|
||||
/// 从目录表中查找章节标题,找不到则回退到 spine item 的 title 或 href
|
||||
private func resolvedChapterTitle(for item: RDEPUBSpineItem, toc: [EPUBTableOfContentsItem]) -> String {
|
||||
if let title = flattenedTOCItems(from: toc).first(where: { tocItem in
|
||||
tocItem.href.components(separatedBy: "#").first == item.href
|
||||
@ -400,12 +489,16 @@ public final class RDEPUBTextBookBuilder {
|
||||
return trimmedTitle.isEmpty ? item.href : trimmedTitle
|
||||
}
|
||||
|
||||
/// 递归展开嵌套目录为扁平列表
|
||||
private func flattenedTOCItems(from items: [EPUBTableOfContentsItem]) -> [EPUBTableOfContentsItem] {
|
||||
items.flatMap { item in
|
||||
[item] + flattenedTOCItems(from: item.children)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 章节过滤
|
||||
|
||||
/// 判断是否应跳过该章节(空白的封面/扉页,无文本且无附件)
|
||||
private func shouldSkipChapter(item: RDEPUBSpineItem, content: NSAttributedString, text: String) -> Bool {
|
||||
let lowercasedHref = item.href.lowercased()
|
||||
var hasAttachment = false
|
||||
@ -422,6 +515,9 @@ public final class RDEPUBTextBookBuilder {
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - 附件统计
|
||||
|
||||
/// 统计富文本中的附件数量
|
||||
private func attachmentCount(in content: NSAttributedString) -> Int {
|
||||
guard content.length > 0 else { return 0 }
|
||||
var count = 0
|
||||
@ -433,6 +529,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
return count
|
||||
}
|
||||
|
||||
/// 获取富文本中所有附件的 NSRange 列表
|
||||
private func attachmentRanges(in content: NSAttributedString) -> [NSRange] {
|
||||
guard content.length > 0 else { return [] }
|
||||
var ranges: [NSRange] = []
|
||||
@ -444,6 +541,9 @@ public final class RDEPUBTextBookBuilder {
|
||||
return ranges
|
||||
}
|
||||
|
||||
// MARK: - 封面章节检测
|
||||
|
||||
/// 判断是否为纯图片封面章节(href 包含 cover 且有附件但几乎无文本)
|
||||
private func isAttachmentOnlyCoverChapter(
|
||||
item: RDEPUBSpineItem,
|
||||
content: NSAttributedString,
|
||||
@ -455,6 +555,11 @@ public final class RDEPUBTextBookBuilder {
|
||||
return attachmentCount(in: content) > 0 && trimmed.count <= 1
|
||||
}
|
||||
|
||||
// MARK: - 尾页规范化
|
||||
|
||||
/// 规范化分页结果的尾部页面:
|
||||
/// 1. 丢弃纯空白的尾页
|
||||
/// 2. 将过短的尾页(≤2 字符)合并到前一页
|
||||
private func normalizeTrailingFrames(
|
||||
_ frames: [RDEPUBTextLayoutFrame],
|
||||
content: NSAttributedString,
|
||||
@ -464,6 +569,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
|
||||
var normalized = frames
|
||||
|
||||
// 丢弃纯空白的尾页
|
||||
while let lastFrame = normalized.last,
|
||||
shouldDropWhitespaceOnlyTrailingFrame(lastFrame, in: content) {
|
||||
normalized.removeLast()
|
||||
@ -476,6 +582,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
// 将过短的尾页合并到前一页
|
||||
guard normalized.count > 1,
|
||||
let lastFrame = normalized.last,
|
||||
let previousFrame = normalized.dropLast().last,
|
||||
@ -489,6 +596,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
return normalized
|
||||
}
|
||||
|
||||
/// 判断尾页是否为纯空白(无可显示字符且无附件)
|
||||
private func shouldDropWhitespaceOnlyTrailingFrame(
|
||||
_ frame: RDEPUBTextLayoutFrame,
|
||||
in content: NSAttributedString
|
||||
@ -500,6 +608,8 @@ public final class RDEPUBTextBookBuilder {
|
||||
return visibleCharacterCount(in: content, range: frame.contentRange) == 0
|
||||
}
|
||||
|
||||
/// 判断尾页是否过短需要合并到前一页。
|
||||
/// 条件:尾页 ≤ 2 个可见字符,且前一页的字符数是尾页的 8 倍以上(至少 12 个字符)
|
||||
private func shouldMergeShortTrailingFrame(
|
||||
_ trailingFrame: RDEPUBTextLayoutFrame,
|
||||
previousFrame: RDEPUBTextLayoutFrame,
|
||||
@ -522,6 +632,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
return previousVisibleCount >= max(visibleCount * 8, 12)
|
||||
}
|
||||
|
||||
/// 将尾页合并到前一页,保留尾页的分页原因
|
||||
private func mergeTrailingFrame(
|
||||
_ previousFrame: RDEPUBTextLayoutFrame,
|
||||
with trailingFrame: RDEPUBTextLayoutFrame
|
||||
@ -547,6 +658,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
)
|
||||
}
|
||||
|
||||
/// 统计指定范围内可见字符数(排除空白和控制字符)
|
||||
private func visibleCharacterCount(
|
||||
in content: NSAttributedString,
|
||||
range: NSRange
|
||||
@ -560,6 +672,9 @@ public final class RDEPUBTextBookBuilder {
|
||||
return filteredScalars.count
|
||||
}
|
||||
|
||||
// MARK: - 集合工具方法
|
||||
|
||||
/// 数组去重(保持顺序)
|
||||
private func uniqueValues<T: Equatable>(from values: [T]) -> [T] {
|
||||
values.reduce(into: [T]()) { result, value in
|
||||
if !result.contains(value) {
|
||||
@ -568,6 +683,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
/// NSRange 数组去重(保持顺序)
|
||||
private func uniqueRanges(from ranges: [NSRange]) -> [NSRange] {
|
||||
ranges.reduce(into: [NSRange]()) { result, value in
|
||||
if !result.contains(value) {
|
||||
@ -576,6 +692,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
/// 统计指定范围内附件数量
|
||||
private func attachmentCount(in content: NSAttributedString, range: NSRange) -> Int {
|
||||
guard content.length > 0, range.length > 0 else { return 0 }
|
||||
var count = 0
|
||||
@ -587,6 +704,9 @@ public final class RDEPUBTextBookBuilder {
|
||||
return count
|
||||
}
|
||||
|
||||
// MARK: - 缓存键生成
|
||||
|
||||
/// 生成缓存键:基于书籍 ID、字号、行距、页面尺寸等参数的 SHA256 哈希
|
||||
private func makeCacheKey(
|
||||
bookID: String,
|
||||
pageSize: CGSize,
|
||||
@ -598,7 +718,8 @@ public final class RDEPUBTextBookBuilder {
|
||||
fontSize: style.font.pointSize,
|
||||
lineHeightMultiple: style.lineSpacing,
|
||||
contentInsets: .zero,
|
||||
pageSize: pageSize
|
||||
pageSize: pageSize,
|
||||
layoutConfigSignature: layoutConfig.cacheSignature
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
import Foundation
|
||||
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
|
||||
/// skips the rd_paginatedFrames pagination step.
|
||||
/// 单个章节的分页元数据缓存,用于磁盘持久化。
|
||||
///
|
||||
/// 对标 WXRead 的 WRChapterPageCount 缓存策略:只存储每页的 NSRange + 分页原因,
|
||||
/// 不缓存完整的 NSAttributedString。缓存命中时重新渲染 HTML,但跳过 CoreText 分页步骤。
|
||||
public struct RDEPUBTextChapterPaginationCache: Equatable {
|
||||
/// 章节文件相对路径
|
||||
public var href: String
|
||||
/// 每页在章节富文本中的字符范围
|
||||
public var pageRanges: [NSRange]
|
||||
/// 每页的分页原因(语义边界、帧限制等)
|
||||
public var breakReasons: [RDEPUBTextPageBreakReason]
|
||||
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 {
|
||||
static var supportsSecureCoding: Bool { true }
|
||||
|
||||
@ -47,14 +52,20 @@ final class PaginationCacheArchive: NSObject, NSSecureCoding {
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个章节的分页归档,将 NSRange 拆分为 location/length 数组以便 NSSecureCoding 编码。
|
||||
final class ChapterPaginationArchive: NSObject, NSSecureCoding {
|
||||
static var supportsSecureCoding: Bool { true }
|
||||
let href: String
|
||||
/// 页范围的起始位置数组(与 rangeLengths 一一对应)
|
||||
let rangeLocations: [NSNumber]
|
||||
/// 页范围的长度数组
|
||||
let rangeLengths: [NSNumber]
|
||||
/// 分页原因的原始字符串数组
|
||||
let breakReasons: [String]
|
||||
/// 语义提示的原始字符串数组
|
||||
let semanticHints: [String]
|
||||
|
||||
/// 从缓存模型构建归档对象
|
||||
init(from cache: RDEPUBTextChapterPaginationCache) {
|
||||
self.href = cache.href
|
||||
self.rangeLocations = cache.pageRanges.map { NSNumber(value: $0.location) }
|
||||
@ -86,6 +97,7 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding {
|
||||
self.semanticHints = semanticHints
|
||||
}
|
||||
|
||||
/// 将归档数据转换回缓存模型
|
||||
func toCache() -> RDEPUBTextChapterPaginationCache {
|
||||
let pageRanges = zip(rangeLocations, rangeLengths).map { loc, len in
|
||||
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.
|
||||
/// 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.
|
||||
/// 磁盘持久化的分页缓存层,对标 WXRead 的 WRChapterPageCount 缓存模式。
|
||||
///
|
||||
/// 缓存策略:
|
||||
/// - 缓存键 = SHA256(书籍ID + 字号 + 行距 + 内边距 + 页面尺寸 + schema版本)
|
||||
/// - 缓存值 = 每章的页 NSRange 列表 + 分页原因(NSKeyedArchiver 序列化)
|
||||
/// - 富文本不缓存:缓存命中时重新渲染 HTML,但跳过 CoreText 分页步骤
|
||||
/// - 线程安全:所有读写操作通过 serial DispatchQueue 串行执行
|
||||
public final class RDEPUBTextBookCache {
|
||||
|
||||
/// 缓存模式版本号,变更时旧缓存自动失效
|
||||
public var schemaVersion: Int = 1
|
||||
|
||||
/// 串行队列,保证缓存读写的线程安全
|
||||
private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility)
|
||||
/// 缓存文件目录
|
||||
private let cacheDirectory: URL
|
||||
|
||||
/// 初始化缓存管理器,自动创建缓存目录
|
||||
/// - Parameter subdirectory: Caches 目录下的子目录名
|
||||
public init(subdirectory: String = "RDEPUBTextBookCache") {
|
||||
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
||||
.appendingPathComponent(subdirectory, isDirectory: true)
|
||||
@ -120,27 +140,31 @@ public final class RDEPUBTextBookCache {
|
||||
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
// MARK: - Cache Key
|
||||
// Mirrors WRChapterPageCount.currentCacheKeyWithBookId:
|
||||
// Encodes bookID + fontSize + lineHeightMultiple + contentInsets + pageSize
|
||||
// MARK: - 缓存键生成
|
||||
// 对标 WRChapterPageCount.currentCacheKeyWithBookId:
|
||||
// 编码 bookID + fontSize + lineHeightMultiple + contentInsets + pageSize
|
||||
|
||||
/// 生成缓存文件名(SHA256 哈希 + ".cache" 后缀)。
|
||||
///
|
||||
/// 任意布局参数变更都会导致缓存键不同,从而自动失效。
|
||||
public func cacheKey(
|
||||
bookID: String,
|
||||
fontSize: CGFloat,
|
||||
lineHeightMultiple: CGFloat,
|
||||
contentInsets: UIEdgeInsets,
|
||||
pageSize: CGSize
|
||||
pageSize: CGSize,
|
||||
layoutConfigSignature: String = RDEPUBTextLayoutConfig.default.cacheSignature
|
||||
) -> 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 hex = digest.map { String(format: "%02x", $0) }.joined()
|
||||
return hex + ".cache"
|
||||
}
|
||||
|
||||
// MARK: - Load / Save (pagination metadata only)
|
||||
// MARK: - 加载/保存(只缓存分页元数据)
|
||||
|
||||
/// Load cached pagination metadata for all chapters.
|
||||
/// Returns dictionary keyed by chapter href.
|
||||
/// 从磁盘加载缓存的分页元数据,返回以章节 href 为键的字典。
|
||||
/// 缓存未命中或反序列化失败时返回 nil。
|
||||
public func load(key: String) -> [String: RDEPUBTextChapterPaginationCache]? {
|
||||
queue.sync {
|
||||
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) {
|
||||
queue.sync {
|
||||
let fileURL = cacheDirectory.appendingPathComponent(key)
|
||||
@ -186,8 +210,9 @@ public final class RDEPUBTextBookCache {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Invalidate
|
||||
// MARK: - 缓存失效
|
||||
|
||||
/// 清除所有缓存文件
|
||||
public func invalidateAll() {
|
||||
queue.sync {
|
||||
let fileManager = FileManager.default
|
||||
|
||||
@ -1,8 +1,18 @@
|
||||
// RDEPUBTextIndexTable.swift
|
||||
// EPUB 文本索引表
|
||||
// 构建全书的文本索引映射:章节起始偏移、href 与章节/spine 索引的对应关系、
|
||||
// fragment 偏移映射、以及行列索引映射。支持锚点与位置之间的双向转换,
|
||||
// 用于精确定位文本选择和搜索匹配。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 行-列索引条目,表示文本中某一行的字符范围
|
||||
public struct RDEPUBRowColumnIndex: Codable, Equatable {
|
||||
/// 行号
|
||||
public let row: Int
|
||||
/// 该行的起始字符偏移
|
||||
public let startOffset: Int
|
||||
/// 该行的结束字符偏移
|
||||
public let endOffset: Int
|
||||
|
||||
public init(row: Int, startOffset: Int, endOffset: Int) {
|
||||
@ -11,20 +21,30 @@ public struct RDEPUBRowColumnIndex: Codable, Equatable {
|
||||
self.endOffset = endOffset
|
||||
}
|
||||
|
||||
/// 判断给定偏移量是否在该行范围内
|
||||
public func contains(_ offset: Int) -> Bool {
|
||||
offset >= startOffset && offset <= endOffset
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本索引表,存储全书的文本结构映射
|
||||
public struct RDEPUBTextIndexTable {
|
||||
/// 各章节的起始绝对偏移量(累加长度)
|
||||
public let chapterStartOffsets: [Int]
|
||||
/// 各章节的文本长度
|
||||
public let chapterLengths: [Int]
|
||||
/// href → 章节索引的映射
|
||||
public let hrefToChapterIndex: [String: Int]
|
||||
/// href → spine 文件索引的映射
|
||||
public let hrefToFileIndex: [String: Int]
|
||||
/// href → (fragment ID → 章节内偏移) 的映射
|
||||
public let fragmentOffsetsByHref: [String: [String: Int]]
|
||||
/// spine 文件索引 → href 的反向映射
|
||||
public let fileIndexToHref: [Int: String]
|
||||
/// spine 文件索引 → 行列索引数组的映射
|
||||
public let fileRowColumnMap: [Int: [RDEPUBRowColumnIndex]]
|
||||
|
||||
/// 从文本章节列表构建索引表
|
||||
public init(chapters: [RDEPUBTextChapter]) {
|
||||
var offsets: [Int] = []
|
||||
var lengths: [Int] = []
|
||||
@ -55,6 +75,7 @@ public struct RDEPUBTextIndexTable {
|
||||
self.fileRowColumnMap = rowColumnMap
|
||||
}
|
||||
|
||||
/// 根据绝对索引和章节构建文本锚点
|
||||
public func anchor(forAbsoluteIndex index: Int, in chapter: RDEPUBTextChapter) -> RDEPUBTextAnchor {
|
||||
let normalizedIndex = clampedOffset(index, 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? {
|
||||
if let anchor = location.rangeAnchor?.start {
|
||||
return anchor
|
||||
@ -96,6 +118,7 @@ public struct RDEPUBTextIndexTable {
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据锚点查找对应的绝对页码
|
||||
public func pageNumber(for anchor: RDEPUBTextAnchor, in book: RDEPUBTextBook) -> Int? {
|
||||
guard let chapter = book.chapters.first(where: { $0.spineIndex == anchor.fileIndex }) else { return nil }
|
||||
let basePageIndex = chapter.pages.first?.absolutePageIndex ?? 0
|
||||
@ -105,14 +128,17 @@ public struct RDEPUBTextIndexTable {
|
||||
}.map { $0 + basePageIndex }
|
||||
}
|
||||
|
||||
/// 通过文件索引获取 href
|
||||
public func href(for fileIndex: Int) -> String? {
|
||||
fileIndexToHref[fileIndex]
|
||||
}
|
||||
|
||||
/// 通过 href 获取章节索引
|
||||
public func chapterIndex(for href: String) -> Int? {
|
||||
hrefToChapterIndex[href]
|
||||
}
|
||||
|
||||
/// 将锚点转换为全书绝对字符索引
|
||||
public func absoluteIndex(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
absoluteIndex(
|
||||
fileIndex: anchor.fileIndex,
|
||||
@ -121,12 +147,14 @@ public struct RDEPUBTextIndexTable {
|
||||
) ?? anchor.chapterOffset
|
||||
}
|
||||
|
||||
/// 将范围锚点转换为全书绝对 NSRange
|
||||
public func absoluteRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
||||
let start = absoluteIndex(for: rangeAnchor.start)
|
||||
let end = max(start, absoluteIndex(for: rangeAnchor.end))
|
||||
return NSRange(location: start, length: max(end - start, 0))
|
||||
}
|
||||
|
||||
/// 将单个锚点转换为阅读位置(计算 progression)
|
||||
public func location(
|
||||
for anchor: RDEPUBTextAnchor,
|
||||
in chapter: RDEPUBTextChapter,
|
||||
@ -145,6 +173,7 @@ public struct RDEPUBTextIndexTable {
|
||||
)
|
||||
}
|
||||
|
||||
/// 将范围锚点转换为阅读位置(起止 progression)
|
||||
public func location(
|
||||
for rangeAnchor: RDEPUBTextRangeAnchor,
|
||||
in chapter: RDEPUBTextChapter,
|
||||
@ -165,6 +194,7 @@ public struct RDEPUBTextIndexTable {
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据绝对索引和文件索引查找所在行号
|
||||
public func row(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int {
|
||||
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 }
|
||||
if let rowIndex = rows.first(where: { $0.contains(index) })?.row {
|
||||
@ -173,6 +203,7 @@ public struct RDEPUBTextIndexTable {
|
||||
return rows.last?.row ?? 0
|
||||
}
|
||||
|
||||
/// 根据绝对索引和文件索引查找所在列号(行内偏移)
|
||||
public func column(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int {
|
||||
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 }
|
||||
if let rowEntry = rows.first(where: { $0.contains(index) }) {
|
||||
@ -182,6 +213,7 @@ public struct RDEPUBTextIndexTable {
|
||||
return max(index - lastRow.startOffset, 0)
|
||||
}
|
||||
|
||||
/// 根据文件索引、行号和列号计算全书绝对索引
|
||||
public func absoluteIndex(fileIndex: Int, row: Int, column: Int) -> Int? {
|
||||
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return nil }
|
||||
let normalizedRow = min(max(row, 0), rows.count - 1)
|
||||
@ -190,11 +222,13 @@ public struct RDEPUBTextIndexTable {
|
||||
return rowEntry.startOffset + min(max(column, 0), maxColumn)
|
||||
}
|
||||
|
||||
/// 将偏移量限制在章节范围内
|
||||
private func clampedOffset(_ index: Int, in chapter: RDEPUBTextChapter) -> Int {
|
||||
let lastOffset = max(chapter.attributedContent.length - 1, 0)
|
||||
return min(max(index, 0), lastOffset)
|
||||
}
|
||||
|
||||
/// 查找距离给定偏移量最近(且在其之前)的 fragment ID
|
||||
private func nearestFragmentID(beforeOrAt offset: Int, in chapter: RDEPUBTextChapter) -> String? {
|
||||
var bestID: String?
|
||||
var bestOffset = -1
|
||||
@ -209,6 +243,7 @@ public struct RDEPUBTextIndexTable {
|
||||
return bestID
|
||||
}
|
||||
|
||||
/// 从文本字符串构建行列索引数组(按行分割,记录每行的起止偏移)
|
||||
private static func makeRowColumnIndices(for text: String) -> [RDEPUBRowColumnIndex] {
|
||||
let nsText = text as NSString
|
||||
let length = nsText.length
|
||||
@ -1,17 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
/// CoreText 分页引擎产生的单帧(单页)布局数据。
|
||||
///
|
||||
/// 由 `RDEPUBTextLayouter` 在分页过程中生成,记录了该页的内容范围、分页原因、
|
||||
/// 附件信息和语义标记。`RDEPUBTextBookBuilder` 会将 `RDEPUBTextLayoutFrame` 转换为
|
||||
/// `RDEPUBTextPage`,并纳入最终的 `RDEPUBTextBook` 模型。
|
||||
struct RDEPUBTextLayoutFrame: Equatable {
|
||||
/// 该帧在章节富文本中的字符范围
|
||||
var contentRange: NSRange
|
||||
/// 分页原因(语义边界、附件边界、帧限制等)
|
||||
var breakReason: RDEPUBTextPageBreakReason
|
||||
/// 所属 HTML 块级元素的范围(用于调试)
|
||||
var blockRange: NSRange?
|
||||
/// 帧内附件的字符范围列表
|
||||
var attachmentRanges: [NSRange]
|
||||
/// 帧内附件类型列表(图片、通用附件等)
|
||||
var attachmentKinds: [RDEPUBTextAttachmentKind]
|
||||
/// 帧内包含的 HTML 块级元素类型列表(段落、列表、表格等)
|
||||
var blockKinds: [RDEPUBTextBlockKind]
|
||||
/// 帧内语义提示列表(避免分页、保持与下一段同页等)
|
||||
var semanticHints: [RDEPUBTextSemanticHint]
|
||||
/// 帧内附件的布局方式(行内、基线、居中)
|
||||
var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
|
||||
/// 帧尾部最近的 fragment ID(用于恢复阅读位置)
|
||||
var trailingFragmentID: String?
|
||||
/// 诊断日志列表(分页原因详情、范围信息等)
|
||||
var diagnostics: [String]
|
||||
|
||||
/// 将布局帧数据转换为页元数据模型
|
||||
var metadata: RDEPUBTextPageMetadata {
|
||||
RDEPUBTextPageMetadata(
|
||||
breakReason: breakReason,
|
||||
|
||||
@ -5,11 +5,31 @@ import UIKit
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
/// CoreText 分页引擎:将富文本按页面尺寸拆分为多帧(每帧对应一页)。
|
||||
///
|
||||
/// 分页策略优先级(从高到低):
|
||||
/// 1. avoidPageBreakInside — 不在保护块内分页(对标 WXRead 的行级回退扫描)
|
||||
/// 2. keepWithNext — 标题等元素需与下一段同页
|
||||
/// 3. 语义边界 — pageBreakBefore/After 等显式分页标记
|
||||
/// 4. pageRelate — 微信读书式的跨页关联元素
|
||||
/// 5. 附件边界 — 块级附件应整体移到下一页
|
||||
/// 6. 帧限制 — 默认按 CoreText 可视范围分页
|
||||
///
|
||||
/// 支持两条渲染路径:
|
||||
/// - DTCoreText 路径(首选):DTCoreTextLayouter → DTCoreTextLayoutFrame
|
||||
/// - CoreText 回退路径:CTFramesetterCreateFrame
|
||||
struct RDEPUBTextLayouter {
|
||||
/// 待分页的富文本
|
||||
private let attributedString: NSAttributedString
|
||||
/// 页面尺寸(决定每帧能容纳多少内容)
|
||||
private let pageSize: CGSize
|
||||
/// CoreText 帧设置器
|
||||
private let framesetter: CTFramesetter
|
||||
/// 页面矩形路径(用于 CTFrame 排版)
|
||||
private let path: CGPath
|
||||
/// DTCoreText 路径可直接消费的单矩形布局区域
|
||||
private let dtLayoutRect: CGRect
|
||||
/// 布局配置(avoidPageBreakInside、孤行控制等)
|
||||
private let config: RDEPUBTextLayoutConfig
|
||||
|
||||
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
|
||||
@ -17,9 +37,12 @@ struct RDEPUBTextLayouter {
|
||||
self.pageSize = pageSize
|
||||
self.config = config
|
||||
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] {
|
||||
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
|
||||
return []
|
||||
@ -32,6 +55,11 @@ struct RDEPUBTextLayouter {
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - CoreText 分页路径(回退方案)
|
||||
|
||||
/// 使用原生 CoreText API 进行分页。
|
||||
///
|
||||
/// 流程:CTFramesetterCreateFrame → 获取可视范围 → 语义边界调整 → 记录帧
|
||||
private func layoutFramesUsingCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
|
||||
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
|
||||
return []
|
||||
@ -49,7 +77,7 @@ struct RDEPUBTextLayouter {
|
||||
|
||||
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 lineAdjusted = trimmedRangeForKeepWithNext(from: frame, proposed: avoidAdjusted)
|
||||
let lineRanges = lineRanges(from: frame)
|
||||
@ -89,8 +117,15 @@ struct RDEPUBTextLayouter {
|
||||
return frames
|
||||
}
|
||||
|
||||
// MARK: - DTCoreText 分页路径(首选方案)
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
/// 使用 DTCoreTextLayouter 进行分页,提供更精确的行级语义处理。
|
||||
private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
|
||||
guard config.numberOfColumns == 1 else {
|
||||
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
|
||||
}
|
||||
|
||||
guard let layouter = DTCoreTextLayouter(attributedString: attributedString) else {
|
||||
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
|
||||
}
|
||||
@ -99,7 +134,7 @@ struct RDEPUBTextLayouter {
|
||||
|
||||
var frames: [RDEPUBTextLayoutFrame] = []
|
||||
var location = 0
|
||||
let pageRect = CGRect(origin: .zero, size: pageSize)
|
||||
let pageRect = dtLayoutRect
|
||||
|
||||
while location < attributedString.length {
|
||||
guard let layoutFrame = layouter.layoutFrame(with: pageRect, range: NSRange(location: location, length: 0)) else {
|
||||
@ -152,6 +187,31 @@ struct RDEPUBTextLayouter {
|
||||
}
|
||||
#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(
|
||||
from proposedRange: NSRange,
|
||||
totalLength: Int,
|
||||
@ -193,6 +253,7 @@ struct RDEPUBTextLayouter {
|
||||
)
|
||||
}
|
||||
|
||||
// 最小分页长度:原始范围的 55%
|
||||
let minLength = max(Int(Double(proposedRange.length) * 0.55), 1)
|
||||
let minimumEnd = proposedRange.location + minLength
|
||||
|
||||
@ -203,6 +264,7 @@ struct RDEPUBTextLayouter {
|
||||
let currentSemanticHints = proposedSemanticHints
|
||||
let currentAttachmentPlacements = proposedAttachmentPlacements
|
||||
|
||||
// 1. 语义边界(pageBreakBefore/After)
|
||||
if let semanticBoundary = preferredSemanticBoundary(
|
||||
in: proposedRange,
|
||||
minimumEnd: minimumEnd
|
||||
@ -230,6 +292,7 @@ struct RDEPUBTextLayouter {
|
||||
)
|
||||
}
|
||||
|
||||
// 2. pageRelate 跨页关联边界
|
||||
if let pageRelateBoundary = preferredPageRelateBoundary(
|
||||
after: proposedRange,
|
||||
minimumEnd: minimumEnd,
|
||||
@ -258,6 +321,7 @@ struct RDEPUBTextLayouter {
|
||||
)
|
||||
}
|
||||
|
||||
// 3. 附件边界(块级附件整体移动)
|
||||
if let attachmentBoundary = preferredAttachmentBoundary(
|
||||
in: proposedRange,
|
||||
minimumEnd: minimumEnd
|
||||
@ -284,6 +348,7 @@ struct RDEPUBTextLayouter {
|
||||
)
|
||||
}
|
||||
|
||||
// 4. 帧限制(默认分页)
|
||||
return (
|
||||
range: proposedRange,
|
||||
breakReason: .frameLimit,
|
||||
@ -305,6 +370,11 @@ struct RDEPUBTextLayouter {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 语义边界查找
|
||||
|
||||
/// 在指定范围内查找最优的语义分页点(pageBreakBefore / pageBreakAfter)。
|
||||
///
|
||||
/// 只有当边界位置超过 minimumEnd(最小分页长度约束)时才有效。
|
||||
private func preferredSemanticBoundary(
|
||||
in range: NSRange,
|
||||
minimumEnd: Int
|
||||
@ -337,6 +407,8 @@ struct RDEPUBTextLayouter {
|
||||
return boundary
|
||||
}
|
||||
|
||||
/// 查找附件边界:只有块级附件(.attachment 或 .centered)才触发分页,
|
||||
/// 行内脚注图标等不应导致整段移动。
|
||||
private func preferredAttachmentBoundary(in range: NSRange, minimumEnd: Int) -> Int? {
|
||||
var boundary: Int?
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, stop in
|
||||
@ -346,9 +418,8 @@ struct RDEPUBTextLayouter {
|
||||
let placement = attachmentPlacement(at: location)
|
||||
let blockKind = blockKind(at: location)
|
||||
|
||||
// Mirror WXRead more closely: only block-level attachments should
|
||||
// push the entire block to the next page. Inline footnote icons and
|
||||
// other inline attachments must not cause a whole paragraph to move.
|
||||
// 对标 WXRead:只有块级附件才应将整个块推到下一页。
|
||||
// 行内脚注图标等行内附件不应导致整段移动。
|
||||
let isBlockLevelAttachment = blockKind == .attachment || placement == .centered
|
||||
guard isBlockLevelAttachment else { return }
|
||||
|
||||
@ -361,6 +432,8 @@ struct RDEPUBTextLayouter {
|
||||
return boundary
|
||||
}
|
||||
|
||||
/// 查找 pageRelate 跨页关联边界:当下一页起始是一个 pageRelate 块且
|
||||
/// 该块恰好是当前页最后一行时,将最后一行移到下一页。
|
||||
private func preferredPageRelateBoundary(
|
||||
after range: NSRange,
|
||||
minimumEnd: Int,
|
||||
@ -384,6 +457,9 @@ struct RDEPUBTextLayouter {
|
||||
return lastLineStart
|
||||
}
|
||||
|
||||
// MARK: - 属性查询工具
|
||||
|
||||
/// 获取指定位置的块级元素范围
|
||||
private func blockRange(at location: Int) -> NSRange? {
|
||||
guard location >= 0, location < attributedString.length else { return nil }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
@ -393,6 +469,7 @@ struct RDEPUBTextLayouter {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 获取指定位置的块级元素类型
|
||||
private func blockKind(at location: Int) -> RDEPUBTextBlockKind? {
|
||||
guard location >= 0, location < attributedString.length else { return nil }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
@ -400,6 +477,7 @@ struct RDEPUBTextLayouter {
|
||||
return RDEPUBTextBlockKind(rawValue: rawValue)
|
||||
}
|
||||
|
||||
/// 获取指定位置的附件布局方式
|
||||
private func attachmentPlacement(at location: Int) -> RDEPUBTextAttachmentPlacement? {
|
||||
guard location >= 0, location < attributedString.length else { return nil }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
@ -407,6 +485,7 @@ struct RDEPUBTextLayouter {
|
||||
return RDEPUBTextAttachmentPlacement(rawValue: rawValue)
|
||||
}
|
||||
|
||||
/// 获取包含指定位置的段落范围
|
||||
private func paragraphRange(containing location: Int) -> NSRange {
|
||||
let source = attributedString.string as NSString
|
||||
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))
|
||||
}
|
||||
|
||||
/// 获取指定范围内的所有附件字符范围
|
||||
private func attachmentRanges(in range: NSRange) -> [NSRange] {
|
||||
var results: [NSRange] = []
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, _ in
|
||||
@ -423,6 +503,7 @@ struct RDEPUBTextLayouter {
|
||||
return results
|
||||
}
|
||||
|
||||
/// 获取指定位置的语义提示列表
|
||||
private func semanticHints(at location: Int) -> [RDEPUBTextSemanticHint] {
|
||||
guard location >= 0, location < attributedString.length else { return [] }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
@ -432,6 +513,7 @@ struct RDEPUBTextLayouter {
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
}
|
||||
|
||||
/// 获取指定范围内的附件类型列表(去重)
|
||||
private func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
|
||||
var kinds: [RDEPUBTextAttachmentKind] = []
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, _, _ in
|
||||
@ -445,6 +527,7 @@ struct RDEPUBTextLayouter {
|
||||
return kinds
|
||||
}
|
||||
|
||||
/// 获取指定范围内的块级元素类型列表(去重)
|
||||
private func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] {
|
||||
var kinds: [RDEPUBTextBlockKind] = []
|
||||
attributedString.enumerateAttribute(.rdPageBlockKind, in: range) { value, _, _ in
|
||||
@ -458,6 +541,7 @@ struct RDEPUBTextLayouter {
|
||||
return kinds
|
||||
}
|
||||
|
||||
/// 获取指定范围内的语义提示列表(去重)
|
||||
private func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] {
|
||||
var hints: [RDEPUBTextSemanticHint] = []
|
||||
attributedString.enumerateAttribute(.rdPageSemanticHints, in: range) { value, _, _ in
|
||||
@ -469,6 +553,7 @@ struct RDEPUBTextLayouter {
|
||||
return hints
|
||||
}
|
||||
|
||||
/// 获取指定范围内的附件布局方式列表(去重)
|
||||
private func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] {
|
||||
var placements: [RDEPUBTextAttachmentPlacement] = []
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentPlacement, in: range) { value, _, _ in
|
||||
@ -482,6 +567,7 @@ struct RDEPUBTextLayouter {
|
||||
return placements
|
||||
}
|
||||
|
||||
/// 查找指定位置之前最近的 fragment ID(用于阅读位置恢复)
|
||||
private func nearestTrailingFragmentID(
|
||||
endingAt location: Int,
|
||||
fragmentOffsets: [String: Int]
|
||||
@ -492,11 +578,11 @@ struct RDEPUBTextLayouter {
|
||||
.key
|
||||
}
|
||||
|
||||
// MARK: - Line-level avoidPageBreakInside (WXRead approach)
|
||||
// MARK: - 行级 avoidPageBreakInside(WXRead 方案)
|
||||
|
||||
/// Scans CTFrame lines backward from the last line, removing trailing lines
|
||||
/// that fall inside an avoidPageBreakInside block. Mirrors WXRead's
|
||||
/// WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded.
|
||||
/// 从 CTFrame 最后一行向前扫描,移除落在 avoidPageBreakInside 保护块内的尾部行。
|
||||
/// 对标 WXRead 的 WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded。
|
||||
/// 最多移除 3 行(kMaxLinesToRemove),避免因保护块过大导致整页内容被清空。
|
||||
private func trimmedRangeForAvoidPageBreakInside(
|
||||
from frame: CTFrame,
|
||||
proposed: NSRange
|
||||
@ -509,8 +595,8 @@ struct RDEPUBTextLayouter {
|
||||
var origins = [CGPoint](repeating: .zero, count: lines.count)
|
||||
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
|
||||
var linesToRemove = 0
|
||||
|
||||
@ -521,8 +607,7 @@ struct RDEPUBTextLayouter {
|
||||
if lineIsInAvoidPageBreakInsideBlock(lineNSRange) {
|
||||
linesToRemove += 1
|
||||
if linesToRemove >= kMaxLinesToRemove {
|
||||
// Don't remove more than kMaxLinesToRemove; stop here
|
||||
// (line at index i stays, so linesToRemove stays at kMaxLinesToRemove)
|
||||
// 不超过 kMaxLinesToRemove 行,停在这里
|
||||
linesToRemove = kMaxLinesToRemove
|
||||
break
|
||||
}
|
||||
@ -535,7 +620,7 @@ struct RDEPUBTextLayouter {
|
||||
|
||||
let validLineCount = lines.count - linesToRemove
|
||||
guard validLineCount > 0 else {
|
||||
// All lines are in protected blocks; fall back to proposed
|
||||
// 所有行都在保护块内,回退到原始范围
|
||||
return proposed
|
||||
}
|
||||
|
||||
@ -549,6 +634,7 @@ struct RDEPUBTextLayouter {
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
/// CoreText 路径的 keepWithNext 处理:从最后行向前扫描
|
||||
private func trimmedRangeForKeepWithNext(
|
||||
from frame: CTFrame,
|
||||
proposed: NSRange
|
||||
@ -561,7 +647,10 @@ struct RDEPUBTextLayouter {
|
||||
return trimmedRangeForKeepWithNext(proposed: proposed, lineRanges: lineRanges)
|
||||
}
|
||||
|
||||
// MARK: - DTCoreText 行级处理
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
/// DTCoreText 路径的 avoidPageBreakInside 处理
|
||||
private func trimmedRangeForAvoidPageBreakInside(
|
||||
from layoutFrame: DTCoreTextLayoutFrame,
|
||||
proposed: NSRange
|
||||
@ -603,6 +692,7 @@ struct RDEPUBTextLayouter {
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
/// DTCoreText 路径的 keepWithNext 处理
|
||||
private func trimmedRangeForKeepWithNext(
|
||||
from layoutFrame: DTCoreTextLayoutFrame,
|
||||
proposed: NSRange
|
||||
@ -615,6 +705,8 @@ struct RDEPUBTextLayouter {
|
||||
}
|
||||
#endif
|
||||
|
||||
/// 从最后行向前扫描,移除落在 keepWithNext 保护块内的尾部行。
|
||||
/// 最多移除 3 行。
|
||||
private func trimmedRangeForKeepWithNext(
|
||||
proposed: NSRange,
|
||||
lineRanges: [NSRange]
|
||||
@ -648,7 +740,7 @@ struct RDEPUBTextLayouter {
|
||||
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 {
|
||||
var found = false
|
||||
let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1))
|
||||
@ -665,6 +757,7 @@ struct RDEPUBTextLayouter {
|
||||
return found
|
||||
}
|
||||
|
||||
/// 检查某行是否落在 keepWithNext 保护块内
|
||||
private func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool {
|
||||
var found = false
|
||||
let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1))
|
||||
@ -681,6 +774,7 @@ struct RDEPUBTextLayouter {
|
||||
return found
|
||||
}
|
||||
|
||||
/// 获取 CTFrame 中所有行的字符范围
|
||||
private func lineRanges(from frame: CTFrame) -> [NSRange] {
|
||||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||||
return lines.map {
|
||||
@ -690,6 +784,7 @@ struct RDEPUBTextLayouter {
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
/// 获取 DTCoreTextLayoutFrame 中所有行的字符范围
|
||||
private func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {
|
||||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else {
|
||||
return []
|
||||
@ -698,6 +793,9 @@ struct RDEPUBTextLayouter {
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - 诊断日志
|
||||
|
||||
/// 生成分页诊断日志,记录分页原因、范围和语义信息
|
||||
private func diagnostics(
|
||||
reason: RDEPUBTextPageBreakReason,
|
||||
range: NSRange,
|
||||
|
||||
@ -1,7 +1,18 @@
|
||||
import CoreText
|
||||
import UIKit
|
||||
|
||||
// MARK: - NSAttributedString 分页扩展
|
||||
|
||||
/// 为 NSAttributedString 提供便捷的分页方法,是渲染链路中 CoreText 分页的入口点。
|
||||
extension NSAttributedString {
|
||||
/// 将富文本按指定页面尺寸分页,返回布局帧列表(每帧对应一页)。
|
||||
///
|
||||
/// 这是 `RDEPUBTextBookBuilder` 和 `RDPlainTextBookBuilder` 调用的核心分页方法。
|
||||
/// - Parameters:
|
||||
/// - size: 页面尺寸
|
||||
/// - fragmentOffsets: fragment ID → 字符偏移量映射(用于阅读位置恢复)
|
||||
/// - config: 布局配置(孤行控制、avoidPageBreakInside 等)
|
||||
/// - Returns: 分页后的布局帧列表
|
||||
func rd_paginatedFrames(
|
||||
size: CGSize,
|
||||
fragmentOffsets: [String: Int] = [:],
|
||||
@ -11,12 +22,17 @@ extension NSAttributedString {
|
||||
.layoutFrames(fragmentOffsets: fragmentOffsets)
|
||||
}
|
||||
|
||||
/// 简化版分页:只返回每页的 NSRange 列表(不含语义元数据)
|
||||
func ss_pageRanges(size: CGSize) -> [NSRange] {
|
||||
rd_paginatedFrames(size: size).map(\.contentRange)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIColor CSS 转换扩展
|
||||
|
||||
/// 为 UIColor 提供 CSS 颜色字符串转换,用于动态生成暗色模式 CSS。
|
||||
extension UIColor {
|
||||
/// 将 UIColor 转换为 CSS rgba() 字符串格式
|
||||
var ss_cssString: String {
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
|
||||
@ -1,13 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Performance Sample
|
||||
// MARK: - 性能采样数据模型
|
||||
|
||||
/// 单个章节的性能采样数据,记录渲染和分页的耗时。
|
||||
public struct RDEPUBTextPerformanceSample: Equatable {
|
||||
/// 章节文件路径
|
||||
public var chapterHref: String
|
||||
/// HTML 渲染耗时(秒)
|
||||
public var renderDuration: TimeInterval
|
||||
/// CoreText 分页耗时(秒)
|
||||
public var paginateDuration: TimeInterval
|
||||
/// 分页后的页数
|
||||
public var pageCount: Int
|
||||
/// 富文本字符长度
|
||||
public var attributedStringLength: Int
|
||||
/// 是否命中分页缓存
|
||||
public var cacheHit: Bool
|
||||
|
||||
public init(
|
||||
@ -27,19 +34,27 @@ public struct RDEPUBTextPerformanceSample: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Performance Sampler
|
||||
// MARK: - 性能采样器
|
||||
|
||||
/// 书籍构建过程的性能采样器,用于监控每章的渲染和分页耗时。
|
||||
///
|
||||
/// 由 `RDEPUBTextBookBuilder` 在构建过程中使用,每章记录一个采样点,
|
||||
/// 构建完成后输出汇总报告。
|
||||
public final class RDEPUBTextPerformanceSampler {
|
||||
/// 所有章节的采样数据列表
|
||||
public private(set) var samples: [RDEPUBTextPerformanceSample] = []
|
||||
/// 整本书构建的总耗时(秒)
|
||||
public var totalBuildDuration: TimeInterval = 0
|
||||
|
||||
public init() {}
|
||||
|
||||
/// 记录单个章节的性能采样,并输出日志
|
||||
public func record(_ sample: RDEPUBTextPerformanceSample) {
|
||||
samples.append(sample)
|
||||
print("[PERF] \(sample.chapterHref): render=\(formatMS(sample.renderDuration)) paginate=\(formatMS(sample.paginateDuration)) pages=\(sample.pageCount) cache=\(sample.cacheHit ? "HIT" : "MISS")")
|
||||
}
|
||||
|
||||
/// 生成性能汇总报告,包含总渲染/分页耗时和缓存命中率
|
||||
public func summary() -> String {
|
||||
let totalRender = samples.reduce(0) { $0 + $1.renderDuration }
|
||||
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)"
|
||||
}
|
||||
|
||||
/// 重置所有采样数据
|
||||
public func reset() {
|
||||
samples.removeAll()
|
||||
totalBuildDuration = 0
|
||||
}
|
||||
|
||||
/// 将秒转换为毫秒格式字符串
|
||||
private func formatMS(_ duration: TimeInterval) -> String {
|
||||
String(format: "%.0fms", duration * 1000)
|
||||
}
|
||||
|
||||
@ -1,43 +1,71 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 自定义富文本属性键
|
||||
|
||||
/// EPUBTextRendering 层使用的自定义 NSAttributedString 属性键。
|
||||
/// 这些属性由 `RDEPUBTextRendererSupport` 在渲染阶段注入,
|
||||
/// 供 `RDEPUBTextLayouter` 分页时读取语义信息。
|
||||
public extension NSAttributedString.Key {
|
||||
/// 块级元素在富文本中的字符范围(NSString 编码的 NSRange)
|
||||
static let rdPageBlockRange = NSAttributedString.Key("com.rdreader.epub.pageBlockRange")
|
||||
/// 块级元素在章节中的序号
|
||||
static let rdPageBlockIndex = NSAttributedString.Key("com.rdreader.epub.pageBlockIndex")
|
||||
/// fragment 锚点 ID
|
||||
static let rdPageFragmentID = NSAttributedString.Key("com.rdreader.epub.pageFragmentID")
|
||||
/// 附件类型(图片/通用附件)
|
||||
static let rdPageAttachmentKind = NSAttributedString.Key("com.rdreader.epub.pageAttachmentKind")
|
||||
/// 块级元素类型(段落/列表/表格/代码等)
|
||||
static let rdPageBlockKind = NSAttributedString.Key("com.rdreader.epub.pageBlockKind")
|
||||
/// 语义提示标记(避免分页、保持与下一段同页等,逗号分隔)
|
||||
static let rdPageSemanticHints = NSAttributedString.Key("com.rdreader.epub.pageSemanticHints")
|
||||
/// 附件布局方式(行内/基线/居中)
|
||||
static let rdPageAttachmentPlacement = NSAttributedString.Key("com.rdreader.epub.pageAttachmentPlacement")
|
||||
}
|
||||
|
||||
// MARK: - 块级元素类型枚举
|
||||
|
||||
/// HTML 块级元素的类型分类,用于分页时判断语义边界。
|
||||
public enum RDEPUBTextBlockKind: String, Codable, Equatable, CaseIterable {
|
||||
case paragraph
|
||||
case list
|
||||
case table
|
||||
case code
|
||||
case blockquote
|
||||
case attachment
|
||||
case generic
|
||||
case paragraph // <p>
|
||||
case list // <ul>, <ol>, <li>
|
||||
case table // <table> 系列
|
||||
case code // <pre>, <code>
|
||||
case blockquote // <blockquote>
|
||||
case attachment // 附件块(图片、figure 等)
|
||||
case generic // 通用块(<div>, <h1>-<h6> 等)
|
||||
}
|
||||
|
||||
// MARK: - 语义提示枚举
|
||||
|
||||
/// 分页语义提示,指导分页引擎在何处/何处不进行分页。
|
||||
public enum RDEPUBTextSemanticHint: String, Codable, Equatable, CaseIterable {
|
||||
case avoidPageBreakInside
|
||||
case keepWithNext
|
||||
case pageBreakBefore
|
||||
case pageBreakAfter
|
||||
case pageRelate
|
||||
case avoidPageBreakInside // 禁止在块内分页(如代码块、表格)
|
||||
case keepWithNext // 与下一段保持同页(如标题)
|
||||
case pageBreakBefore // 在此元素前强制分页
|
||||
case pageBreakAfter // 在此元素后强制分页
|
||||
case pageRelate // 微信读书式跨页关联元素
|
||||
}
|
||||
|
||||
// MARK: - 附件布局方式枚举
|
||||
|
||||
/// 富文本附件(图片等)的垂直布局方式。
|
||||
public enum RDEPUBTextAttachmentPlacement: String, Codable, Equatable {
|
||||
case inline
|
||||
case baseline
|
||||
case centered
|
||||
case inline // 行内布局
|
||||
case baseline // 基线对齐
|
||||
case centered // 垂直居中(块级)
|
||||
}
|
||||
|
||||
// MARK: - 渲染样式
|
||||
|
||||
/// 阅读器渲染样式配置,控制字体、行距、颜色等。
|
||||
public struct RDEPUBTextRenderStyle {
|
||||
/// 基础字体(大小和字族将覆盖 EPUB 原始样式)
|
||||
public var font: UIFont
|
||||
/// 行间距(pt)
|
||||
public var lineSpacing: CGFloat
|
||||
/// 文本颜色(nil 则使用 EPUB 原始颜色)
|
||||
public var textColor: UIColor?
|
||||
/// 背景颜色(nil 则透明,用于暗色模式判断)
|
||||
public var backgroundColor: UIColor?
|
||||
|
||||
public init(font: UIFont, lineSpacing: CGFloat, textColor: UIColor? = nil, backgroundColor: UIColor? = nil) {
|
||||
@ -48,35 +76,119 @@ public struct RDEPUBTextRenderStyle {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 布局配置
|
||||
|
||||
/// 分页引擎的布局控制参数。
|
||||
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 avoidWidows: Bool
|
||||
/// 是否启用 avoidPageBreakInside 保护(对标 WXRead 的行级回退扫描)
|
||||
public var avoidPageBreakInsideEnabled: Bool
|
||||
/// 是否启用连字符断字,对标 WXRead 的 hyphenation
|
||||
public var hyphenation: Bool
|
||||
/// 图片最大高度占页面高度的比例
|
||||
public var imageMaxHeightRatio: CGFloat
|
||||
|
||||
public init(
|
||||
frameWidth: CGFloat = 0,
|
||||
frameHeight: CGFloat = 0,
|
||||
edgeInsets: UIEdgeInsets = .zero,
|
||||
numberOfColumns: Int = 1,
|
||||
columnGap: CGFloat = 20,
|
||||
avoidOrphans: Bool = true,
|
||||
avoidWidows: Bool = true,
|
||||
avoidPageBreakInsideEnabled: Bool = true,
|
||||
hyphenation: Bool = true,
|
||||
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.avoidWidows = avoidWidows
|
||||
self.avoidPageBreakInsideEnabled = avoidPageBreakInsideEnabled
|
||||
self.hyphenation = hyphenation
|
||||
self.imageMaxHeightRatio = imageMaxHeightRatio
|
||||
}
|
||||
|
||||
/// 默认配置
|
||||
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 {
|
||||
case `default`
|
||||
case replace
|
||||
case dark
|
||||
case epub
|
||||
case user
|
||||
case `default` // 基础重置样式(margin、padding 等)
|
||||
case replace // 元素替换样式(图片居中、标题分页等)
|
||||
case dark // 暗色模式覆盖
|
||||
case epub // EPUB 原始样式表
|
||||
case user // 用户自定义样式(字号、行距、颜色等)
|
||||
}
|
||||
|
||||
/// 单个 CSS 样式表层,包含层级类型和 CSS 内容。
|
||||
public struct RDEPUBTextStyleSheetLayer: Equatable {
|
||||
public var kind: RDEPUBTextStyleSheetLayerKind
|
||||
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 var layers: [RDEPUBTextStyleSheetLayer]
|
||||
|
||||
@ -94,6 +209,7 @@ public struct RDEPUBTextStyleSheetPackage: Equatable {
|
||||
self.layers = layers
|
||||
}
|
||||
|
||||
/// 合并所有非空层的 CSS,每层添加注释头标记
|
||||
public var combinedCSS: String {
|
||||
layers
|
||||
.filter { !$0.css.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
@ -104,11 +220,15 @@ public struct RDEPUBTextStyleSheetPackage: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 资源引用诊断
|
||||
|
||||
/// 资源引用类型(样式表或图片)
|
||||
public enum RDEPUBTextResourceReferenceKind: String, Equatable {
|
||||
case stylesheet
|
||||
case image
|
||||
}
|
||||
|
||||
/// 单个资源引用的诊断信息,用于检测 EPUB 中的资源是否正确引用。
|
||||
public struct RDEPUBTextResourceReferenceDiagnostic: Equatable {
|
||||
public var kind: RDEPUBTextResourceReferenceKind
|
||||
public var chapterHref: String
|
||||
@ -134,6 +254,9 @@ public struct RDEPUBTextResourceReferenceDiagnostic: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 章节渲染上下文与请求
|
||||
|
||||
/// 章节渲染上下文:包含 HTML 源码、样式表和资源诊断信息。
|
||||
public struct RDEPUBTextChapterContext: Equatable {
|
||||
public var href: String
|
||||
public var title: String
|
||||
@ -159,6 +282,7 @@ public struct RDEPUBTextChapterContext: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 章节渲染请求:打包上下文和渲染样式,传递给 `RDEPUBTextRenderer`。
|
||||
public struct RDEPUBTextChapterRenderRequest {
|
||||
public var context: RDEPUBTextChapterContext
|
||||
public var style: RDEPUBTextRenderStyle
|
||||
@ -169,9 +293,15 @@ public struct RDEPUBTextChapterRenderRequest {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 渲染结果
|
||||
|
||||
/// 章节渲染的输出结果,包含富文本、fragment 偏移量和资源诊断。
|
||||
public struct RDEPUBRenderedChapterContent {
|
||||
/// 渲染后的富文本
|
||||
public var attributedString: NSAttributedString
|
||||
/// fragment ID → 字符偏移量映射
|
||||
public var fragmentOffsets: [String: Int]
|
||||
/// 资源引用诊断列表
|
||||
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||||
|
||||
public init(
|
||||
@ -185,11 +315,18 @@ public struct RDEPUBRenderedChapterContent {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 渲染器协议
|
||||
|
||||
/// EPUB 文本渲染器协议,定义 HTML → NSAttributedString 的转换接口。
|
||||
///
|
||||
/// 默认实现:`RDEPUBDTCoreTextRenderer`(基于 DTCoreText 库)
|
||||
public protocol RDEPUBTextRenderer {
|
||||
/// 渲染单个章节
|
||||
func renderChapter(
|
||||
request: RDEPUBTextChapterRenderRequest
|
||||
) throws -> RDEPUBRenderedChapterContent
|
||||
|
||||
/// 便捷方法:直接渲染 HTML 字符串
|
||||
func renderChapter(
|
||||
html: String,
|
||||
baseURL: URL?,
|
||||
@ -197,6 +334,7 @@ public protocol RDEPUBTextRenderer {
|
||||
) throws -> RDEPUBRenderedChapterContent
|
||||
}
|
||||
|
||||
/// 协议默认实现:将便捷方法委托给完整方法
|
||||
public extension RDEPUBTextRenderer {
|
||||
func renderChapter(
|
||||
html: String,
|
||||
@ -215,9 +353,12 @@ public extension RDEPUBTextRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 渲染错误
|
||||
|
||||
/// EPUB 文本渲染过程中可能出现的错误类型。
|
||||
public enum RDEPUBTextRenderingError: LocalizedError {
|
||||
case htmlEncodingFailed
|
||||
case htmlImportFailed
|
||||
case htmlEncodingFailed // HTML 字符串编码为 Data 失败
|
||||
case htmlImportFailed // HTML 富文本解析失败
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
|
||||
@ -4,13 +4,36 @@ import UIKit
|
||||
import DTCoreText
|
||||
#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 {
|
||||
/// 外部样式表链接正则
|
||||
private static let stylesheetLinkPattern = #"<link\b[^>]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\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 var didLogFootnoteAttachment = 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 {
|
||||
guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else {
|
||||
return html
|
||||
@ -23,6 +46,10 @@ enum RDEPUBTextRendererSupport {
|
||||
)
|
||||
}
|
||||
|
||||
/// 从渲染后的富文本中提取 fragment 偏移量映射。
|
||||
///
|
||||
/// 扫描 `${id=xxx}` 标记,记录每个 fragment ID 的字符偏移量,
|
||||
/// 然后删除标记文本,返回 [fragmentID: offset] 字典。
|
||||
static func extractFragmentOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] {
|
||||
let markerPattern = #"\$\{id=([^}]+)\}"#
|
||||
guard let regex = try? NSRegularExpression(pattern: markerPattern, options: []) else {
|
||||
@ -51,6 +78,10 @@ enum RDEPUBTextRendererSupport {
|
||||
return fragmentOffsets
|
||||
}
|
||||
|
||||
/// 将 HTML 中注入的语义标记(${rd-sem-start/end})应用到富文本属性中。
|
||||
///
|
||||
/// 标记包含 blockKind、semanticHints、attachmentPlacement 等信息,
|
||||
/// 解析后写入对应的 .rdPage* 属性键,供分页引擎读取。
|
||||
static func applyPaginationSemantics(in attributedString: NSMutableAttributedString) {
|
||||
guard let regex = try? NSRegularExpression(pattern: semanticMarkerPattern, options: []) else {
|
||||
return
|
||||
@ -85,6 +116,14 @@ enum RDEPUBTextRendererSupport {
|
||||
}
|
||||
}
|
||||
|
||||
/// 规范化阅读属性:统一字体、行距、颜色,并注入分页语义属性。
|
||||
///
|
||||
/// 遍历富文本的所有属性区间:
|
||||
/// 1. 将 EPUB 原始字体映射到用户设置字体(保留粗体/斜体特征)
|
||||
/// 2. 统一行距和段间距
|
||||
/// 3. 应用用户设置的文本颜色
|
||||
/// 4. 规范化附件显示尺寸
|
||||
/// 5. 注入块级元素范围、序号、类型等分页语义属性
|
||||
static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, style: RDEPUBTextRenderStyle) {
|
||||
let fullRange = NSRange(location: 0, length: attributedString.length)
|
||||
var blockIndex = 0
|
||||
@ -128,6 +167,7 @@ enum RDEPUBTextRendererSupport {
|
||||
}
|
||||
}
|
||||
|
||||
/// 回退渲染:当 DTCoreText 不可用时,将 HTML 源码当作纯文本处理。
|
||||
static func fallbackAttributedString(for html: String, style: RDEPUBTextRenderStyle) -> NSMutableAttributedString {
|
||||
let fallbackAttributes: [NSAttributedString.Key: Any] = [
|
||||
.font: style.font,
|
||||
@ -137,13 +177,24 @@ enum RDEPUBTextRendererSupport {
|
||||
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(
|
||||
href: String,
|
||||
title: String,
|
||||
rawHTML: String,
|
||||
baseURL: URL?,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
resourceResolver: RDEPUBResourceResolver?
|
||||
resourceResolver: RDEPUBResourceResolver?,
|
||||
contentLanguageCode: String? = nil
|
||||
) -> RDEPUBTextChapterRenderRequest {
|
||||
let normalizedHTML = injectPaginationSemanticMarkers(into: normalizeHTML(rawHTML))
|
||||
let stylesheetHrefReplacements = inlineLinkedStyleSheets(
|
||||
@ -154,7 +205,9 @@ enum RDEPUBTextRendererSupport {
|
||||
)
|
||||
let layers = makeStyleSheetLayers(
|
||||
style: style,
|
||||
epubCSS: stylesheetHrefReplacements.inlinedCSS
|
||||
epubCSS: stylesheetHrefReplacements.inlinedCSS,
|
||||
contentLanguageCode: contentLanguageCode,
|
||||
sourceHTML: rawHTML
|
||||
)
|
||||
let htmlWithBase = injectBaseHref(into: stylesheetHrefReplacements.html, baseURL: baseURL)
|
||||
let htmlWithDefaultLayers = injectStyleTag(
|
||||
@ -197,6 +250,7 @@ enum RDEPUBTextRendererSupport {
|
||||
return RDEPUBTextChapterRenderRequest(context: context, style: style)
|
||||
}
|
||||
|
||||
/// HTML 规范化:清理冗余字符(CR、多余换行),规范化附件 HTML 标记
|
||||
static func normalizeHTML(_ html: String) -> String {
|
||||
var cleanedHTML = html
|
||||
let replacements: [(pattern: String, template: String)] = [
|
||||
@ -242,11 +296,17 @@ enum RDEPUBTextRendererSupport {
|
||||
|
||||
private static func makeStyleSheetLayers(
|
||||
style: RDEPUBTextRenderStyle,
|
||||
epubCSS: String
|
||||
epubCSS: String,
|
||||
contentLanguageCode: String?,
|
||||
sourceHTML: String
|
||||
) -> [RDEPUBTextStyleSheetLayer] {
|
||||
let useLatinReplace = prefersLatinLanguageCSS(
|
||||
languageCode: contentLanguageCode,
|
||||
sourceHTML: sourceHTML
|
||||
)
|
||||
var layers: [RDEPUBTextStyleSheetLayer] = [
|
||||
.init(kind: .default, css: defaultCSS()),
|
||||
.init(kind: .replace, css: replaceCSS())
|
||||
.init(kind: .replace, css: replaceCSS(useLatinVariant: useLatinReplace))
|
||||
]
|
||||
if isDarkTheme(style: style) {
|
||||
layers.append(.init(kind: .dark, css: darkCSS(style: style)))
|
||||
@ -259,65 +319,12 @@ enum RDEPUBTextRendererSupport {
|
||||
}
|
||||
|
||||
private static func defaultCSS() -> String {
|
||||
"""
|
||||
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;
|
||||
}
|
||||
"""
|
||||
RDEPUBAssetRepository.string(for: .wxReadDefaultCSS)
|
||||
}
|
||||
|
||||
private static func replaceCSS() -> String {
|
||||
"""
|
||||
img, svg, video, canvas {
|
||||
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 replaceCSS(useLatinVariant: Bool) -> String {
|
||||
let asset: RDEPUBAsset = useLatinVariant ? .wxReadLatinReplaceCSS : .wxReadReplaceCSS
|
||||
return RDEPUBAssetRepository.string(for: asset)
|
||||
}
|
||||
|
||||
private static func normalizeAttachmentHTMLMarkers(in html: String) -> String {
|
||||
@ -466,7 +473,7 @@ enum RDEPUBTextRendererSupport {
|
||||
private static func darkCSS(style: RDEPUBTextRenderStyle) -> String {
|
||||
let background = style.backgroundColor?.ss_cssString ?? "rgba(0, 0, 0, 1.000)"
|
||||
let text = style.textColor?.ss_cssString ?? "rgba(255, 255, 255, 1.000)"
|
||||
return """
|
||||
return RDEPUBAssetRepository.string(for: .wxReadDarkCSS) + "\n\n" + """
|
||||
html, body {
|
||||
background: \(background) !important;
|
||||
color: \(text) !important;
|
||||
@ -505,6 +512,118 @@ enum RDEPUBTextRendererSupport {
|
||||
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 {
|
||||
guard let baseURL else {
|
||||
return html
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
/// EPUB 文本搜索引擎:在分页书籍中执行全文搜索。
|
||||
///
|
||||
/// 实现 `RDEPUBSearchEngine` 协议,遍历所有章节的富文本内容,
|
||||
/// 使用大小写不敏感匹配查找关键词,返回搜索结果列表。
|
||||
/// 每个搜索结果包含进度位置、预览文本和语义锚点。
|
||||
final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||
private let textBook: RDEPUBTextBook
|
||||
private let publication: RDEPUBPublication
|
||||
@ -9,6 +14,14 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||
self.publication = publication
|
||||
}
|
||||
|
||||
/// 在全书中搜索关键词,返回所有匹配结果。
|
||||
///
|
||||
/// 搜索流程:
|
||||
/// 1. 遍历所有章节
|
||||
/// 2. 在每章的富文本字符串中执行大小写不敏感搜索
|
||||
/// 3. 计算匹配位置的阅读进度(progression)
|
||||
/// 4. 生成预览文本(匹配位置前后各 12 个字符)
|
||||
/// 5. 生成语义锚点(用于跨设备/跨字体精确定位)
|
||||
func search(keyword: String) -> [RDEPUBSearchMatch] {
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKeyword.isEmpty else {
|
||||
@ -60,6 +73,7 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||
return matches
|
||||
}
|
||||
|
||||
/// 生成搜索结果的预览文本:匹配位置前后各取 12 个字符
|
||||
private func previewText(in text: NSString, matchRange: NSRange) -> String {
|
||||
let previewRadius = 12
|
||||
let start = max(matchRange.location - previewRadius, 0)
|
||||
|
||||
@ -1,13 +1,35 @@
|
||||
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 {
|
||||
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.layoutConfig = layoutConfig
|
||||
}
|
||||
|
||||
/// 从纯文本文件构建分页书籍。
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - textFileURL: 纯文本文件的本地 URL
|
||||
/// - pageSize: 页面尺寸
|
||||
/// - style: 渲染样式(字体、行距、颜色)
|
||||
/// - Returns: 分页后的书籍模型
|
||||
public func build(
|
||||
textFileURL: URL,
|
||||
pageSize: CGSize,
|
||||
@ -24,7 +46,7 @@ public final class RDPlainTextBookBuilder {
|
||||
let rendered = try renderer.renderChapter(html: html, baseURL: nil, style: style)
|
||||
|
||||
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
|
||||
? [
|
||||
RDEPUBTextLayoutFrame(
|
||||
@ -86,12 +108,14 @@ public final class RDPlainTextBookBuilder {
|
||||
|
||||
// MARK: - 章节拆分
|
||||
|
||||
/// 章节规格:标题 + 内容
|
||||
private struct ChapterSpec {
|
||||
let title: String?
|
||||
let content: String
|
||||
}
|
||||
|
||||
/// 按中文章节正则拆分文本。无匹配时整体作为一章。
|
||||
/// 按中文章节正则拆分文本。匹配 "第X章/节/回/卷" 格式,
|
||||
/// 无匹配时将整篇文本作为一章处理。
|
||||
private func splitChapters(from text: String) -> [ChapterSpec] {
|
||||
let pattern = #"^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else {
|
||||
@ -126,7 +150,7 @@ public final class RDPlainTextBookBuilder {
|
||||
|
||||
// MARK: - HTML 包装
|
||||
|
||||
/// 将纯文本包装为 HTML 段落
|
||||
/// 将纯文本包装为 HTML 段落:每行一个 `<p>` 标签
|
||||
private func wrapTextAsHTML(_ text: String) -> String {
|
||||
let paragraphs = text.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||
let body = paragraphs.map { "<p>\($0)</p>" }.joined(separator: "\n")
|
||||
@ -135,6 +159,7 @@ public final class RDPlainTextBookBuilder {
|
||||
|
||||
// MARK: - 文本解码
|
||||
|
||||
/// 解码文本文件,按优先级尝试 UTF-8 → GB18030 → GBK 编码
|
||||
private func rd_decodeTextFile(url: URL) -> String {
|
||||
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
|
||||
return content
|
||||
|
||||
@ -4,6 +4,11 @@ import UIKit
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
// MARK: - 页面交互控制器
|
||||
|
||||
/// 页面交互控制器,负责文本层面的坐标映射与选择逻辑
|
||||
/// 在 DTCoreText 排版结果和用户交互之间充当桥梁
|
||||
/// 提供字符索引查找、选择范围计算、选择矩形计算等功能
|
||||
final class RDEPUBPageInteractionController {
|
||||
|
||||
var snapshot: RDEPUBPageLayoutSnapshot?
|
||||
|
||||
@ -4,29 +4,56 @@ import UIKit
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
// MARK: - 页面排版快照数据结构
|
||||
|
||||
/// 文本行信息
|
||||
/// 描述 DTCoreText 排版后的一行文本的位置、范围和基线信息
|
||||
struct RDEPUBPageLine {
|
||||
/// 行内文本在 chapterContent 中的绝对字符范围
|
||||
let stringRange: NSRange
|
||||
/// 行的框架(相对于排版区域)
|
||||
let frame: CGRect
|
||||
/// 行的基线原点
|
||||
let baselineOrigin: CGPoint
|
||||
/// 基线以上的高度(升部)
|
||||
let ascent: CGFloat
|
||||
/// 基线以下的高度(降部)
|
||||
let descent: CGFloat
|
||||
/// 行间距
|
||||
let leading: CGFloat
|
||||
}
|
||||
|
||||
/// 文本 Run 信息
|
||||
/// 描述一行内某个连续绘制单元(如普通文字或附件)
|
||||
struct RDEPUBPageRun {
|
||||
/// Run 内文本的绝对字符范围
|
||||
let stringRange: NSRange
|
||||
/// Run 的绘制框架
|
||||
let frame: CGRect
|
||||
/// 是否为附件(图片等)
|
||||
let isAttachment: Bool
|
||||
}
|
||||
|
||||
/// 附件信息
|
||||
/// 描述嵌入在文本中的图片或其他附件
|
||||
struct RDEPUBPageAttachment {
|
||||
/// 附件在文本中的绝对字符范围
|
||||
let stringRange: NSRange
|
||||
/// 附件的显示框架
|
||||
let frame: CGRect
|
||||
/// 附件的建议显示尺寸
|
||||
let displaySize: CGSize
|
||||
/// 附件的布局方式(行内/浮动等)
|
||||
let placement: RDEPUBTextAttachmentPlacement?
|
||||
/// 附件类型(封面图/普通图片等)
|
||||
let kind: RDEPUBTextAttachmentKind?
|
||||
}
|
||||
|
||||
// MARK: - 页面排版快照
|
||||
|
||||
/// 页面排版快照
|
||||
/// 封装 DTCoreText 的排版结果,提供高效的文本位置查询能力
|
||||
/// 用于支持文本选择、高亮渲染和搜索结果定位
|
||||
struct RDEPUBPageLayoutSnapshot {
|
||||
let page: RDEPUBTextPage
|
||||
let lines: [RDEPUBPageLine]
|
||||
|
||||
@ -1,12 +1,27 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 底部工具栏
|
||||
|
||||
/// 阅读器底部工具栏
|
||||
/// 提供目录、书签、高亮管理、新建标注和设置五个功能入口
|
||||
/// 按钮可见性可根据 configuration 动态调整
|
||||
final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
|
||||
// MARK: 回调闭包
|
||||
|
||||
/// 点击目录按钮回调
|
||||
var onShowTableOfContents: (() -> Void)?
|
||||
/// 点击书签列表按钮回调
|
||||
var onShowBookmarks: (() -> Void)?
|
||||
/// 点击高亮列表按钮回调
|
||||
var onShowHighlights: (() -> Void)?
|
||||
/// 点击新建标注按钮回调
|
||||
var onAddHighlight: (() -> Void)?
|
||||
/// 点击设置按钮回调
|
||||
var onShowSettings: (() -> Void)?
|
||||
|
||||
// MARK: UI 组件
|
||||
|
||||
/// 水平等分布局的按钮容器
|
||||
private let stackView: UIStackView = {
|
||||
let view = UIStackView()
|
||||
view.axis = .horizontal
|
||||
@ -16,10 +31,15 @@ final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
|
||||
return view
|
||||
}()
|
||||
|
||||
/// 目录按钮
|
||||
private let chapterButton = RDEPUBReaderTintButton(type: .system)
|
||||
/// 书签列表按钮
|
||||
private let bookmarksButton = RDEPUBReaderTintButton(type: .system)
|
||||
/// 高亮列表按钮
|
||||
private let highlightsButton = RDEPUBReaderTintButton(type: .system)
|
||||
/// 新建标注按钮
|
||||
private let addHighlightButton = RDEPUBReaderTintButton(type: .system)
|
||||
/// 设置按钮
|
||||
private let settingsButton = RDEPUBReaderTintButton(type: .system)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
@ -78,6 +98,9 @@ final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: 按钮状态控制
|
||||
|
||||
/// 设置新建标注按钮是否可用(有选中文本时可用)
|
||||
func setAddHighlightEnabled(_ isEnabled: Bool) {
|
||||
addHighlightButton.isEnabled = isEnabled
|
||||
addHighlightButton.alpha = isEnabled ? 1 : 0.45
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 目录面板
|
||||
|
||||
/// EPUB 阅读器的目录面板控制器
|
||||
/// 以列表形式展示书籍目录,支持多级缩进,当前所在章节高亮显示
|
||||
final class RDEPUBReaderChapterListController: UITableViewController {
|
||||
/// 用户选择目录项时的回调
|
||||
var onSelectItem: ((RDEPUBReaderTableOfContentsItem) -> Void)?
|
||||
|
||||
private let items: [RDEPUBReaderTableOfContentsItem]
|
||||
|
||||
@ -1,24 +1,75 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 文本渲染引擎
|
||||
|
||||
/// EPUB 文本渲染引擎枚举
|
||||
/// 定义了当前支持的文本渲染方式,未来可扩展为多引擎选择
|
||||
public enum RDEPUBTextRenderingEngine: Equatable {
|
||||
/// 基于 DTCoreText 的渲染引擎,将 HTML/CSS 转换为 NSAttributedString
|
||||
case dtCoreText
|
||||
}
|
||||
|
||||
// MARK: - 阅读器配置
|
||||
|
||||
/// EPUB 阅读器的完整配置结构体
|
||||
/// 作为 EPUBUI 层的入口配置,控制阅读器的外观、行为和功能开关
|
||||
/// 调用方在创建 RDEPUBReaderController 前可自定义此配置
|
||||
public struct RDEPUBReaderConfiguration: Equatable {
|
||||
// MARK: 阅读排版参数
|
||||
|
||||
/// 正文字号(单位:pt),默认 15
|
||||
public var fontSize: CGFloat
|
||||
/// 行距倍数,默认 1.6(即行距为字号的 1.6 倍)
|
||||
public var lineHeightMultiple: CGFloat
|
||||
/// 翻页显示模式:.pageCurl(仿真翻页)或 .scroll(滚动阅读)
|
||||
public var displayType: RDReaderView.DisplayType
|
||||
/// 横屏时是否启用双页显示,默认开启
|
||||
public var landscapeDualPageEnabled: Bool
|
||||
|
||||
// MARK: UI 功能开关
|
||||
|
||||
/// 是否显示目录入口,默认开启
|
||||
public var showsTableOfContents: Bool
|
||||
/// 是否允许文本高亮功能,默认开启
|
||||
public var allowsHighlights: Bool
|
||||
/// 是否显示设置面板入口,默认开启
|
||||
public var showsSettingsPanel: Bool
|
||||
|
||||
// MARK: 内容内边距
|
||||
|
||||
/// 流式排版(reflowable)内容的内边距,上下 40pt、左右 16pt
|
||||
public var reflowableContentInsets: UIEdgeInsets
|
||||
/// 固定布局(fixed layout)内容的内边距,默认为零
|
||||
public var fixedContentInset: UIEdgeInsets
|
||||
|
||||
// MARK: 主题与布局
|
||||
|
||||
/// 当前阅读主题(浅色/深色/护眼等),默认 .light
|
||||
public var theme: RDEPUBReaderTheme
|
||||
/// 固定布局的适配模式:按页适配或按宽度适配
|
||||
public var fixedLayoutFit: RDEPUBFixedLayoutFit
|
||||
/// 固定布局的跨页模式:自动/单页/双页
|
||||
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode
|
||||
/// 文本渲染引擎,默认使用 DTCoreText
|
||||
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(
|
||||
fontSize: CGFloat = 15,
|
||||
lineHeightMultiple: CGFloat = 1.6,
|
||||
@ -49,10 +100,15 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
self.textRenderingEngine = textRenderingEngine
|
||||
}
|
||||
|
||||
/// 默认配置实例,使用所有参数的默认值
|
||||
public static let `default` = RDEPUBReaderConfiguration()
|
||||
}
|
||||
|
||||
// MARK: - 配置转换
|
||||
|
||||
extension RDEPUBReaderConfiguration {
|
||||
/// 将用户可见的配置转换为底层排版引擎所需的 RDEPUBPreferences
|
||||
/// - Returns: 排版偏好设置实例,供 Core 层的渲染管道使用
|
||||
func makePreferences() -> RDEPUBPreferences {
|
||||
RDEPUBPreferences(
|
||||
fontSize: fontSize,
|
||||
|
||||
@ -1,6 +1,29 @@
|
||||
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 {
|
||||
// MARK: - 内部辅助类型
|
||||
|
||||
/// 视口签名结构体,用于检测视口尺寸或安全区是否发生显著变化
|
||||
/// 当变化超过阈值时触发重新分页
|
||||
private struct RDEPUBViewportSignature: Equatable {
|
||||
let width: CGFloat
|
||||
let height: CGFloat
|
||||
@ -19,6 +42,7 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 视口变化的原因枚举,用于决定延迟处理的策略
|
||||
private enum RDEPUBViewportChangeReason {
|
||||
case viewLayout
|
||||
case orientationTransition
|
||||
@ -27,8 +51,12 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
|
||||
private typealias NativeTextSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
|
||||
|
||||
// MARK: - 公开属性
|
||||
|
||||
/// 阅读器委托,用于接收阅读状态变更通知
|
||||
public weak var delegate: RDEPUBReaderDelegate?
|
||||
|
||||
/// 阅读器配置,修改后自动判断是否需要重新分页或刷新内容
|
||||
public var configuration: RDEPUBReaderConfiguration {
|
||||
didSet {
|
||||
persistReaderSettingsIfNeeded()
|
||||
@ -54,38 +82,49 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前阅读位置
|
||||
public var currentLocation: RDEPUBLocation? {
|
||||
currentVisibleLocation()
|
||||
}
|
||||
|
||||
/// 当前页码(从 1 开始),nil 表示尚未加载
|
||||
public var currentPageNumber: Int? {
|
||||
guard readerView.currentPage >= 0 else { return nil }
|
||||
return readerView.currentPage + 1
|
||||
}
|
||||
|
||||
/// 当前用户选中的文本(只读),用于创建高亮/批注
|
||||
public private(set) var currentSelection: RDEPUBSelection?
|
||||
|
||||
/// 所有高亮标注
|
||||
public var highlights: [RDEPUBHighlight] {
|
||||
activeHighlights
|
||||
}
|
||||
|
||||
/// 所有书签
|
||||
public var bookmarks: [RDEPUBBookmark] {
|
||||
activeBookmarks
|
||||
}
|
||||
|
||||
/// 原始目录树结构
|
||||
public var tableOfContents: [EPUBTableOfContentsItem] {
|
||||
publication?.tableOfContents ?? []
|
||||
}
|
||||
|
||||
/// 展平后的目录列表(用于目录面板展示,每个条目附带页码)
|
||||
public var flattenedTableOfContents: [RDEPUBReaderTableOfContentsItem] {
|
||||
guard let publication else { return [] }
|
||||
return flattenedTableOfContentsItems(from: publication.tableOfContents)
|
||||
}
|
||||
|
||||
/// 当前阅读位置所在的目录项
|
||||
public var currentTableOfContentsItem: RDEPUBReaderTableOfContentsItem? {
|
||||
resolvedCurrentTableOfContentsItem()
|
||||
}
|
||||
|
||||
// MARK: - 私有属性
|
||||
|
||||
/// EPUB 文件 URL
|
||||
private let epubURL: URL
|
||||
fileprivate let persistence: RDEPUBReaderPersistence?
|
||||
private let readerView = RDReaderView()
|
||||
@ -131,6 +170,14 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
/// 外部 TextBook 的原始文件 URL(用于重建 TextBook)
|
||||
private var textFileURL: URL?
|
||||
|
||||
// MARK: - 初始化
|
||||
|
||||
/// 使用 EPUB 文件 URL 初始化阅读器
|
||||
/// 会自动从持久化存储中恢复用户的阅读偏好设置
|
||||
/// - Parameters:
|
||||
/// - epubURL: EPUB 文件的本地 URL
|
||||
/// - configuration: 阅读器配置,默认使用 .default
|
||||
/// - persistence: 持久化策略,默认使用 UserDefaults
|
||||
public init(
|
||||
epubURL: URL,
|
||||
configuration: RDEPUBReaderConfiguration = .default,
|
||||
@ -149,7 +196,15 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
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(
|
||||
textBook: RDEPUBTextBook,
|
||||
bookIdentifier: String,
|
||||
@ -244,6 +299,9 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 公开 API
|
||||
|
||||
/// 重新加载书籍,重置所有状态并重新解析 EPUB
|
||||
public func reloadBook() {
|
||||
didStartInitialLoad = false
|
||||
parser = nil
|
||||
@ -263,11 +321,18 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
startInitialLoadIfNeeded()
|
||||
}
|
||||
|
||||
/// 跳转到指定阅读位置
|
||||
/// - Parameter location: 目标阅读位置
|
||||
public func go(to location: RDEPUBLocation) {
|
||||
guard publication != nil else { return }
|
||||
restoreReadingLocation(location)
|
||||
}
|
||||
|
||||
/// 跳转到指定页码
|
||||
/// - Parameters:
|
||||
/// - pageNumber: 目标页码(从 1 开始)
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 是否跳转成功
|
||||
@discardableResult
|
||||
public func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
|
||||
guard pageNumber > 0 else { return false }
|
||||
@ -289,6 +354,7 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
return true
|
||||
}
|
||||
|
||||
/// 清除当前文本选中状态
|
||||
public func clearSelection() {
|
||||
updateCurrentSelection(nil)
|
||||
}
|
||||
@ -321,6 +387,12 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// 添加高亮标注,从当前选中文本或指定选区创建
|
||||
/// - Parameters:
|
||||
/// - selection: 文本选区,默认使用 currentSelection
|
||||
/// - color: 高亮颜色(CSS 格式),默认黄色
|
||||
/// - note: 可选批注文字
|
||||
/// - Returns: 创建的高亮对象,重复时返回 nil
|
||||
@discardableResult
|
||||
public func addHighlight(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
@ -607,8 +679,9 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
|
||||
if publication.readingProfile == .textReflowable {
|
||||
let renderer = resolvedTextRenderer()
|
||||
let builder = RDEPUBTextBookBuilder(renderer: renderer, cache: textBookCache)
|
||||
let pageSize = currentTextPageSize()
|
||||
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
|
||||
let builder = RDEPUBTextBookBuilder(renderer: renderer, cache: textBookCache, layoutConfig: layoutConfig)
|
||||
let renderStyle = currentTextRenderStyle()
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
@ -841,7 +914,7 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
let restoreLocation = currentVisibleLocation() ?? persistenceLocation()
|
||||
let pageSize = currentTextPageSize()
|
||||
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) {
|
||||
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 {
|
||||
RDEPUBDTCoreTextRenderer()
|
||||
}
|
||||
@ -1285,6 +1378,8 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 书签管理
|
||||
|
||||
extension RDEPUBReaderController {
|
||||
@discardableResult
|
||||
public func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
@ -1456,6 +1551,10 @@ extension RDEPUBReaderController {
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
// MARK: - 搜索功能
|
||||
|
||||
/// 执行全文搜索,自动跳转到第一个匹配项
|
||||
/// - Parameter keyword: 搜索关键词,空字符串会清除搜索
|
||||
public func search(keyword: String) {
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKeyword.isEmpty else {
|
||||
@ -1622,6 +1721,8 @@ extension RDEPUBReaderController {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RDReaderView 数据源与代理
|
||||
|
||||
extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
public func pageCountOfReaderView(readerView: RDReaderView) -> Int {
|
||||
textBook?.pages.count ?? activePages.count
|
||||
@ -1724,6 +1825,8 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Web 内容视图代理(EPUB 固定布局/Web 渲染路径)
|
||||
|
||||
extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) {
|
||||
guard readerView.currentPage >= 0,
|
||||
@ -1780,6 +1883,8 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 文本内容视图代理(Native Text 渲染路径)
|
||||
|
||||
extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) {
|
||||
guard let selection else {
|
||||
@ -1902,6 +2007,8 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 手势识别器代理(用于 NavigationController 返回手势)
|
||||
|
||||
extension RDEPUBReaderController: UIGestureRecognizerDelegate {
|
||||
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
true
|
||||
|
||||
@ -1,20 +1,86 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 阅读器委托协议
|
||||
|
||||
/// EPUB 阅读器的委托协议
|
||||
/// 用于向调用方通知阅读器的各种状态变更事件
|
||||
/// 所有方法均提供默认空实现,调用方可选择性实现感兴趣的方法
|
||||
public protocol RDEPUBReaderDelegate: AnyObject {
|
||||
/// 阅读器成功打开书籍时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - publication: 解析后的出版物对象
|
||||
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication)
|
||||
|
||||
/// 阅读位置更新时调用(翻页、跳转等)
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - location: 新的阅读位置
|
||||
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation)
|
||||
|
||||
/// 阅读器翻到书籍末尾时调用
|
||||
/// - Parameter reader: 阅读器控制器
|
||||
func epubReaderDidReachEnd(_ reader: UIViewController)
|
||||
|
||||
/// 用户选中文本发生变化时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - selection: 当前选中文本,nil 表示取消选中
|
||||
func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?)
|
||||
|
||||
/// 高亮标注列表更新时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - highlights: 当前所有高亮
|
||||
func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight])
|
||||
|
||||
/// 书签列表更新时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - bookmarks: 当前所有书签
|
||||
func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark])
|
||||
|
||||
/// 搜索结果更新时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - result: 搜索结果摘要,nil 表示无搜索
|
||||
func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?)
|
||||
|
||||
/// 当前搜索匹配项变化时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - match: 当前匹配项,nil 表示无匹配
|
||||
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?)
|
||||
|
||||
/// 当前所在目录项更新时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - item: 当前目录项
|
||||
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?)
|
||||
|
||||
/// 用户点击外部链接时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - url: 外部链接 URL
|
||||
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL)
|
||||
|
||||
/// 阅读器发生错误时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - error: 错误对象
|
||||
func epubReader(_ reader: UIViewController, didFailWithError error: Error)
|
||||
|
||||
/// 允许调用方自定义顶部工具栏
|
||||
/// 在 viewDidLoad 时调用,可在工具栏上添加自定义按钮
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - topToolView: 顶部工具栏视图
|
||||
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView)
|
||||
}
|
||||
|
||||
// MARK: - 默认空实现
|
||||
|
||||
/// 所有委托方法的默认空实现,调用方可只实现需要的方法
|
||||
public extension RDEPUBReaderDelegate {
|
||||
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {}
|
||||
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {}
|
||||
|
||||
@ -1,13 +1,24 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 高亮管理面板
|
||||
|
||||
/// EPUB 阅读器的高亮标注管理控制器
|
||||
/// 以列表形式展示所有高亮和批注,支持筛选(全部/批注/高亮)、跳转到位置、编辑批注、删除
|
||||
final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
/// 用户选择跳转到某个高亮位置时的回调
|
||||
var onSelectHighlight: ((RDEPUBHighlight) -> Void)?
|
||||
/// 用户编辑高亮批注后的回调
|
||||
var onUpdateHighlight: ((RDEPUBHighlight) -> Void)?
|
||||
/// 用户删除高亮时的回调
|
||||
var onDeleteHighlight: ((RDEPUBHighlight) -> Void)?
|
||||
|
||||
/// 高亮列表(按创建时间降序排列)
|
||||
private var highlights: [RDEPUBHighlight]
|
||||
/// 当前主题配置
|
||||
private let theme: RDEPUBReaderTheme
|
||||
/// 提供章节标题的回调,用于显示高亮所在的章节名
|
||||
private let sectionTitleProvider: (RDEPUBHighlight) -> String?
|
||||
/// 筛选控件:全部、批注、高亮
|
||||
private let filterControl = UISegmentedControl(items: ["全部", "批注", "高亮"])
|
||||
|
||||
private var filteredHighlights: [RDEPUBHighlight] {
|
||||
@ -205,8 +216,14 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 书签管理面板
|
||||
|
||||
/// EPUB 阅读器的书签管理控制器
|
||||
/// 以列表形式展示所有书签,支持跳转到书签位置和删除书签
|
||||
final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
||||
/// 用户选择跳转到某个书签位置时的回调
|
||||
var onSelectBookmark: ((RDEPUBBookmark) -> Void)?
|
||||
/// 用户删除书签时的回调
|
||||
var onDeleteBookmark: ((RDEPUBBookmark) -> Void)?
|
||||
|
||||
private var bookmarks: [RDEPUBBookmark]
|
||||
|
||||
@ -1,16 +1,44 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - 持久化协议
|
||||
|
||||
/// 阅读器持久化协议
|
||||
/// 定义了阅读位置、书签、高亮和用户设置的存储接口
|
||||
/// 调用方可实现此协议以对接自定义存储(如数据库、云端同步等)
|
||||
public protocol RDEPUBReaderPersistence: AnyObject {
|
||||
/// 加载指定书籍的上次阅读位置
|
||||
/// - Parameter bookIdentifier: 书籍唯一标识符
|
||||
/// - Returns: 保存的阅读位置,nil 表示首次打开
|
||||
func loadLocation(for bookIdentifier: String) -> RDEPUBLocation?
|
||||
|
||||
/// 保存指定书籍的当前阅读位置
|
||||
/// - Parameters:
|
||||
/// - location: 阅读位置
|
||||
/// - bookIdentifier: 书籍唯一标识符
|
||||
func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String)
|
||||
|
||||
/// 加载指定书籍的所有书签
|
||||
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark]
|
||||
|
||||
/// 保存指定书籍的所有书签
|
||||
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String)
|
||||
|
||||
/// 加载指定书籍的所有高亮标注
|
||||
func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight]
|
||||
|
||||
/// 保存指定书籍的所有高亮标注
|
||||
func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String)
|
||||
|
||||
/// 加载全局阅读器用户设置(字号、行距、主题等)
|
||||
func loadReaderSettings() -> RDEPUBReaderSettings?
|
||||
|
||||
/// 保存全局阅读器用户设置
|
||||
func saveReaderSettings(_ settings: RDEPUBReaderSettings)
|
||||
}
|
||||
|
||||
// MARK: - 默认实现
|
||||
|
||||
/// 协议的默认空实现,书签/高亮/设置为可选功能
|
||||
public extension RDEPUBReaderPersistence {
|
||||
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
|
||||
_ = bookIdentifier
|
||||
@ -31,13 +59,30 @@ public extension RDEPUBReaderPersistence {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UserDefaults 持久化实现
|
||||
|
||||
/// 基于 UserDefaults 的持久化实现
|
||||
/// 数据以 JSON 格式存储,按书籍标识符分隔存储位置
|
||||
/// 适合轻量级场景,大量数据建议改用 SQLite 或文件系统
|
||||
public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
|
||||
/// UserDefaults 实例
|
||||
private let defaults: UserDefaults
|
||||
/// 阅读位置的 key 前缀
|
||||
private let locationPrefix: String
|
||||
/// 书签列表的 key 前缀
|
||||
private let bookmarksPrefix: String
|
||||
/// 高亮列表的 key 前缀
|
||||
private let highlightsPrefix: String
|
||||
/// 用户设置的 key
|
||||
private let settingsKey: String
|
||||
|
||||
/// 初始化 UserDefaults 持久化策略
|
||||
/// - Parameters:
|
||||
/// - defaults: UserDefaults 实例,默认 .standard
|
||||
/// - locationPrefix: 阅读位置 key 前缀
|
||||
/// - bookmarksPrefix: 书签 key 前缀
|
||||
/// - highlightsPrefix: 高亮 key 前缀
|
||||
/// - settingsKey: 用户设置 key
|
||||
public init(
|
||||
defaults: UserDefaults = .standard,
|
||||
locationPrefix: String = "ssreader.epub.location.",
|
||||
|
||||
@ -1,11 +1,20 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 翻页显示模式
|
||||
|
||||
/// 阅读器翻页显示模式枚举
|
||||
/// 用于持久化存储用户的翻页偏好设置,与 RDReaderView.DisplayType 相互转换
|
||||
public enum RDEPUBReaderDisplayMode: String, Codable, Equatable {
|
||||
/// 仿真翻页模式(纸张翻转效果)
|
||||
case pageCurl
|
||||
/// 水平滚动模式
|
||||
case horizontalScroll
|
||||
/// 垂直滚动模式
|
||||
case verticalScroll
|
||||
/// 水平覆盖式滚动模式(类似电子杂志翻页效果)
|
||||
case horizontalCoverScroll
|
||||
|
||||
/// 从底层 DisplayType 转换为 UI 层的 DisplayMode
|
||||
init(displayType: RDReaderView.DisplayType) {
|
||||
switch displayType {
|
||||
case .pageCurl:
|
||||
@ -17,6 +26,8 @@ public enum RDEPUBReaderDisplayMode: String, Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 转换为底层 RDReaderView 所需的 DisplayType
|
||||
/// 注意:horizontalCoverScroll 会被映射为 horizontalScroll
|
||||
var displayType: RDReaderView.DisplayType {
|
||||
switch self {
|
||||
case .pageCurl:
|
||||
@ -29,14 +40,25 @@ public enum RDEPUBReaderDisplayMode: String, Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 主题预设
|
||||
|
||||
/// 阅读器主题预设枚举
|
||||
/// 将 RDEPUBReaderTheme 映射为可序列化的枚举值,用于持久化存储和设置面板展示
|
||||
public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable {
|
||||
/// 浅色主题
|
||||
case light
|
||||
/// 黄色护眼主题
|
||||
case yellow
|
||||
/// 绿色护眼主题
|
||||
case green
|
||||
/// 粉色主题
|
||||
case pink
|
||||
/// 蓝色主题
|
||||
case blue
|
||||
/// 深色/夜间主题
|
||||
case dark
|
||||
|
||||
/// 将预设枚举转换为主题配置实例
|
||||
public var theme: RDEPUBReaderTheme {
|
||||
switch self {
|
||||
case .light:
|
||||
@ -54,6 +76,8 @@ public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从主题配置实例反向查找对应的预设枚举
|
||||
/// 自定义主题(非内置预设)会返回 nil
|
||||
public init?(theme: RDEPUBReaderTheme) {
|
||||
switch theme {
|
||||
case .light:
|
||||
@ -74,13 +98,26 @@ public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 阅读器用户设置
|
||||
|
||||
/// 阅读器的可持久化用户设置
|
||||
/// 用于保存用户在设置面板中调整的阅读偏好(字号、行距、翻页模式、主题等)
|
||||
/// 所有属性均为可选类型,只覆盖用户实际修改过的配置项
|
||||
public struct RDEPUBReaderSettings: Codable, Equatable {
|
||||
// MARK: 可调参数
|
||||
|
||||
/// 屏幕亮度(0.0 ~ 1.0),nil 表示使用系统亮度
|
||||
public var brightness: CGFloat?
|
||||
/// 用户选择的正文字号(pt),nil 表示使用默认值
|
||||
public var fontSize: CGFloat?
|
||||
/// 用户选择的行距倍数,nil 表示使用默认值
|
||||
public var lineHeightMultiple: CGFloat?
|
||||
/// 用户选择的翻页模式,nil 表示使用默认值
|
||||
public var displayMode: RDEPUBReaderDisplayMode?
|
||||
/// 用户选择的主题预设,nil 表示使用默认值
|
||||
public var themePreset: RDEPUBReaderThemePreset?
|
||||
|
||||
/// 初始化用户设置,所有参数默认为 nil
|
||||
public init(
|
||||
brightness: CGFloat? = nil,
|
||||
fontSize: CGFloat? = nil,
|
||||
@ -95,6 +132,10 @@ public struct RDEPUBReaderSettings: Codable, Equatable {
|
||||
self.themePreset = themePreset
|
||||
}
|
||||
|
||||
/// 将用户设置覆盖到基础配置上,得到最终生效的阅读器配置
|
||||
/// nil 属性不会覆盖基础配置的对应值
|
||||
/// - Parameter configuration: 基础配置(通常来自 RDEPUBReaderConfiguration.default)
|
||||
/// - Returns: 合并后的完整配置
|
||||
public func applying(to configuration: RDEPUBReaderConfiguration) -> RDEPUBReaderConfiguration {
|
||||
var resolvedConfiguration = configuration
|
||||
|
||||
@ -114,6 +155,12 @@ public struct RDEPUBReaderSettings: Codable, Equatable {
|
||||
return resolvedConfiguration
|
||||
}
|
||||
|
||||
/// 从当前配置和亮度值捕获一份完整的用户设置快照
|
||||
/// 亮度值会被限制在 0.0 ~ 1.0 范围内
|
||||
/// - Parameters:
|
||||
/// - configuration: 当前生效的阅读器配置
|
||||
/// - brightness: 当前屏幕亮度
|
||||
/// - Returns: 完整的用户设置实例
|
||||
public static func capture(
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
brightness: CGFloat
|
||||
|
||||
@ -1,12 +1,27 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 设置面板
|
||||
|
||||
/// EPUB 阅读器的设置面板控制器
|
||||
/// 提供亮度、字号、行距、翻页方式、主题的调节功能
|
||||
/// 支持 iOS 13+ SF Symbols 和低版本文字回退
|
||||
final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
// MARK: 回调闭包
|
||||
|
||||
/// 亮度变化回调(0.0 ~ 1.0)
|
||||
var onBrightnessChange: ((CGFloat) -> Void)?
|
||||
/// 字号变化回调(pt 值)
|
||||
var onFontSizeChange: ((CGFloat) -> Void)?
|
||||
/// 行距倍数变化回调
|
||||
var onLineHeightChange: ((CGFloat) -> Void)?
|
||||
/// 翻页模式变化回调
|
||||
var onDisplayTypeChange: ((RDReaderView.DisplayType) -> Void)?
|
||||
/// 主题变化回调
|
||||
var onThemeChange: ((RDEPUBReaderTheme) -> Void)?
|
||||
|
||||
// MARK: 主题预设枚举
|
||||
|
||||
/// 设置面板中的主题预设枚举,用于按钮显示和主题切换
|
||||
private enum ThemePreset: Int, CaseIterable {
|
||||
case light
|
||||
case yellow
|
||||
@ -60,7 +75,9 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
}()
|
||||
private var themeButtons: [UIButton] = []
|
||||
|
||||
/// 行距选项对应的倍数值(紧凑 1.3、标准 1.6、宽松 1.9)
|
||||
private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9]
|
||||
/// 当前配置的本地副本,修改后通过回调通知控制器
|
||||
private var currentConfiguration: RDEPUBReaderConfiguration
|
||||
|
||||
init(configuration: RDEPUBReaderConfiguration, brightness: CGFloat) {
|
||||
|
||||
@ -1,9 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - 目录条目
|
||||
|
||||
/// 展平后的目录条目结构体
|
||||
/// 用于目录面板展示,将树形目录展平为一维列表,通过 depth 字段保留层级关系
|
||||
public struct RDEPUBReaderTableOfContentsItem: Equatable {
|
||||
/// 章节标题
|
||||
public var title: String
|
||||
/// 章节的 href(相对于 EPUB 容器根目录)
|
||||
public var href: String
|
||||
/// 目录层级深度(0 为顶级章节),用于列表缩进显示
|
||||
public var depth: Int
|
||||
/// 该章节所在页码(从 1 开始),nil 表示无法确定
|
||||
public var pageNumber: Int?
|
||||
|
||||
public init(title: String, href: String, depth: Int, pageNumber: Int? = nil) {
|
||||
|
||||
@ -1,13 +1,37 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 阅读器主题
|
||||
|
||||
/// EPUB 阅读器主题配置结构体
|
||||
/// 定义阅读器内容区域和工具栏的颜色方案
|
||||
/// 内置浅色、深色、黄色、绿色、粉色、蓝色六套预设主题
|
||||
public struct RDEPUBReaderTheme: Equatable {
|
||||
// MARK: 内容区颜色
|
||||
|
||||
/// 内容区域的背景色
|
||||
public var contentBackgroundColor: UIColor
|
||||
/// 内容区域的正文文字颜色
|
||||
public var contentTextColor: UIColor
|
||||
|
||||
// MARK: 工具栏颜色
|
||||
|
||||
/// 工具栏背景色
|
||||
public var toolBackgroundColor: UIColor
|
||||
/// 工具栏控件文字/图标颜色
|
||||
public var toolControlTextColor: UIColor
|
||||
/// 工具栏控件未选中状态的边框颜色
|
||||
public var toolControlBorderUnselectColor: UIColor
|
||||
/// 工具栏中分隔线的颜色
|
||||
public var toolLineColor: UIColor
|
||||
|
||||
/// 初始化主题配置
|
||||
/// - Parameters:
|
||||
/// - contentBackgroundColor: 内容区背景色
|
||||
/// - contentTextColor: 内容区文字颜色
|
||||
/// - toolBackgroundColor: 工具栏背景色
|
||||
/// - toolControlTextColor: 工具栏控件颜色
|
||||
/// - toolControlBorderUnselectColor: 控件未选中边框色
|
||||
/// - toolLineColor: 分隔线颜色
|
||||
public init(
|
||||
contentBackgroundColor: UIColor,
|
||||
contentTextColor: UIColor,
|
||||
@ -24,6 +48,9 @@ public struct RDEPUBReaderTheme: Equatable {
|
||||
self.toolLineColor = toolLineColor
|
||||
}
|
||||
|
||||
// MARK: 内置预设主题
|
||||
|
||||
/// 浅色主题:白底黑字,适合日间阅读
|
||||
public static let light = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: .white,
|
||||
contentTextColor: .black,
|
||||
@ -33,6 +60,7 @@ public struct RDEPUBReaderTheme: Equatable {
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
/// 深色主题:黑底白字,适合夜间阅读
|
||||
public static let dark = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: .black,
|
||||
contentTextColor: .white,
|
||||
@ -42,6 +70,7 @@ public struct RDEPUBReaderTheme: Equatable {
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
/// 黄色护眼主题:暖色调背景,减少蓝光刺激
|
||||
public static let yellow = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1),
|
||||
contentTextColor: .black,
|
||||
@ -51,6 +80,7 @@ public struct RDEPUBReaderTheme: Equatable {
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
/// 绿色护眼主题:模拟纸质书的柔和绿色背景
|
||||
public static let green = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1),
|
||||
contentTextColor: .black,
|
||||
@ -60,6 +90,7 @@ public struct RDEPUBReaderTheme: Equatable {
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
/// 粉色主题:柔和粉色背景
|
||||
public static let pink = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1),
|
||||
contentTextColor: .black,
|
||||
@ -69,6 +100,7 @@ public struct RDEPUBReaderTheme: Equatable {
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
/// 蓝色主题:冷色调蓝色背景
|
||||
public static let blue = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1),
|
||||
contentTextColor: .black,
|
||||
@ -79,11 +111,15 @@ public struct RDEPUBReaderTheme: Equatable {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - CSS 颜色转换
|
||||
|
||||
extension RDEPUBReaderTheme {
|
||||
/// 将内容区背景色转换为 CSS 颜色字符串,注入 EPUB 的 HTML 中
|
||||
var themeBackgroundColorCSS: String {
|
||||
contentBackgroundColor.ss_cssString
|
||||
}
|
||||
|
||||
/// 将内容区文字颜色转换为 CSS 颜色字符串,注入 EPUB 的 HTML 中
|
||||
var themeTextColorCSS: String {
|
||||
contentTextColor.ss_cssString
|
||||
}
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 工具栏基类
|
||||
|
||||
/// 阅读器工具栏的基类
|
||||
/// 提供分隔线和主题适配的通用逻辑,子类只需关注具体功能按钮
|
||||
open class RDEPUBReaderToolView: UIView {
|
||||
/// 分隔线视图
|
||||
private let lineView = UIView()
|
||||
/// 分隔线高度
|
||||
private let lineHeight: CGFloat = 0.5
|
||||
|
||||
override init(frame: CGRect) {
|
||||
@ -19,15 +25,24 @@ open class RDEPUBReaderToolView: UIView {
|
||||
lineView.frame = lineFrame(in: bounds)
|
||||
}
|
||||
|
||||
/// 应用主题配色到工具栏
|
||||
/// - Parameter theme: 阅读器主题配置
|
||||
open func apply(theme: RDEPUBReaderTheme) {
|
||||
backgroundColor = .white
|
||||
lineView.backgroundColor = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
}
|
||||
|
||||
/// 计算分隔线的位置和尺寸,子类可重写以调整分隔线位置(如顶部或底部)
|
||||
/// - Parameter bounds: 工具栏的 bounds
|
||||
/// - Returns: 分隔线的 frame
|
||||
open func lineFrame(in bounds: CGRect) -> CGRect {
|
||||
CGRect(x: 0, y: 0, width: bounds.width, height: lineHeight)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 工具栏按钮
|
||||
|
||||
/// 阅读器工具栏专用按钮
|
||||
/// 继承自 UIButton,用于工具栏中的功能按钮
|
||||
final class RDEPUBReaderTintButton: UIButton {
|
||||
}
|
||||
@ -1,7 +1,14 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 顶部工具栏
|
||||
|
||||
/// 阅读器顶部工具栏
|
||||
/// 显示书名、返回按钮和书签按钮
|
||||
/// 位于阅读内容上方,支持 iOS 13+ SF Symbols 和低版本文字回退
|
||||
public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
|
||||
/// 返回按钮点击回调
|
||||
var onBack: (() -> Void)?
|
||||
/// 书签按钮点击回调
|
||||
var onToggleBookmark: (() -> Void)?
|
||||
|
||||
private let backButton = RDEPUBReaderTintButton(type: .system)
|
||||
|
||||
@ -1,21 +1,41 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 覆盖层装饰类型
|
||||
|
||||
/// 覆盖层装饰的数据结构
|
||||
/// 表示一个需要绘制在文本上方或下方的视觉装饰(高亮、搜索结果、选区等)
|
||||
struct RDEPUBTextOverlayDecoration {
|
||||
/// 装饰类型枚举
|
||||
enum Kind: String {
|
||||
/// 文本选区(蓝色半透明)
|
||||
case selection
|
||||
/// 用户高亮标注
|
||||
case highlight
|
||||
/// 用户划线标注
|
||||
case underline
|
||||
/// 搜索匹配项(普通)
|
||||
case search
|
||||
/// 搜索匹配项(当前高亮)
|
||||
case activeSearch
|
||||
/// 定位指示(跳转到位置时的动画目标)
|
||||
case locate
|
||||
}
|
||||
|
||||
/// 装饰类型
|
||||
var kind: Kind
|
||||
/// 装饰对应的绝对文本范围
|
||||
var absoluteRange: NSRange
|
||||
/// 装饰的绘制矩形数组(每行一个矩形)
|
||||
var rects: [CGRect]
|
||||
/// 装饰的颜色
|
||||
var color: UIColor
|
||||
}
|
||||
|
||||
// MARK: - 选择覆盖层视图
|
||||
|
||||
/// 文本选择和装饰的覆盖层绘制视图
|
||||
/// 位于文本内容上方,负责绘制选区、高亮、搜索结果等视觉效果
|
||||
/// 使用 Core Graphics 直接绘制,支持填充矩形和下划线两种绘制模式
|
||||
final class RDEPUBSelectionOverlayView: UIView {
|
||||
private(set) var page: RDEPUBTextPage?
|
||||
private var snapshot: RDEPUBPageLayoutSnapshot?
|
||||
|
||||
@ -5,12 +5,22 @@ import Foundation
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
// MARK: - 文本内容视图代理
|
||||
|
||||
/// 文本内容视图的代理协议
|
||||
/// 通知控制器文本选择变化和选择菜单操作
|
||||
protocol RDEPUBTextContentViewDelegate: AnyObject {
|
||||
/// 用户选中文本发生变化时调用
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?)
|
||||
/// 用户从选择菜单中触发操作(拷贝/高亮/批注)
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
|
||||
}
|
||||
|
||||
// MARK: - 可选中文本视图
|
||||
|
||||
/// 自定义 UITextView,替换系统默认的 UIMenuItem 为自定义操作(拷贝、高亮、批注)
|
||||
final class RDEPUBSelectableTextView: UITextView {
|
||||
/// 选择菜单操作回调
|
||||
var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)?
|
||||
|
||||
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)
|
||||
final class RDEPUBDirectCoreTextPageView: UIView {
|
||||
var layoutFrame: DTCoreTextLayoutFrame? {
|
||||
@ -73,6 +87,14 @@ final class RDEPUBDirectCoreTextPageView: UIView {
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - 文本内容视图
|
||||
|
||||
/// EPUB 流式排版的文本内容视图
|
||||
/// 支持两种渲染路径:
|
||||
/// 1. DTCoreText 路径:直接绘制到 CoreText 视图,支持精确的排版控制
|
||||
/// 2. 回退路径:通过 UITextView 的 attributedText 渲染
|
||||
///
|
||||
/// 内置能力:高亮覆盖、搜索高亮、文本选择、长按菜单、封面图显示
|
||||
final class RDEPUBTextContentView: UIView {
|
||||
private var contentInsets: UIEdgeInsets = .zero
|
||||
private var currentPage: RDEPUBTextPage?
|
||||
@ -423,12 +445,12 @@ final class RDEPUBTextContentView: UIView {
|
||||
case .highlight:
|
||||
content.addAttribute(
|
||||
.backgroundColor,
|
||||
value: UIColor(hexString: highlight.color, alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45),
|
||||
value: UIColor(rdHexString: highlight.color, alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45),
|
||||
range: relativeRange
|
||||
)
|
||||
case .underline:
|
||||
content.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: relativeRange)
|
||||
if let color = UIColor(hexString: highlight.color, alpha: 1) {
|
||||
if let color = UIColor(rdHexString: highlight.color, alpha: 1) {
|
||||
content.addAttribute(.underlineColor, value: color, range: relativeRange)
|
||||
}
|
||||
}
|
||||
@ -523,7 +545,7 @@ final class RDEPUBTextContentView: UIView {
|
||||
let rects = interactionController.selectionRects(for: absoluteRange)
|
||||
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)
|
||||
let decoration = RDEPUBTextOverlayDecoration(
|
||||
kind: highlight.style == .underline ? .underline : .highlight,
|
||||
@ -690,17 +712,3 @@ extension RDEPUBTextContentView: UITextViewDelegate {
|
||||
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
|
||||
|
||||
// MARK: - Web 内容视图代理
|
||||
|
||||
/// Web 内容视图的代理协议
|
||||
/// 将 WebView 中的事件(位置更新、文本选择、链接点击等)转发给控制器
|
||||
protocol RDEPUBWebContentViewDelegate: AnyObject {
|
||||
/// WebView 中阅读位置更新时调用
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
|
||||
/// WebView 中文本选择变化时调用
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
|
||||
/// 用户从选择菜单中触发操作
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
|
||||
/// 用户点击内部链接(章节跳转)
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
|
||||
/// 用户点击外部链接
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL)
|
||||
/// WebView 中 JavaScript 执行出错
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String)
|
||||
}
|
||||
|
||||
// MARK: - Web 内容视图
|
||||
|
||||
/// EPUB 固定布局和 Web 渲染路径的内容视图
|
||||
/// 包装 RDEPUBWebView,提供页码标签和加载/释放资源的控制
|
||||
/// 适用于固定布局 EPUB 和不支持 Native Text 的场景
|
||||
final class RDEPUBWebContentView: UIView {
|
||||
weak var delegate: RDEPUBWebContentViewDelegate?
|
||||
|
||||
private let epubWebView = RDEPUBWebView()
|
||||
private let decorationOverlayView = RDEPUBWebDecorationOverlayView()
|
||||
private let pageNumberLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.systemFont(ofSize: 13)
|
||||
@ -25,8 +41,12 @@ final class RDEPUBWebContentView: UIView {
|
||||
layer.masksToBounds = true
|
||||
|
||||
addSubview(epubWebView)
|
||||
addSubview(decorationOverlayView)
|
||||
addSubview(pageNumberLabel)
|
||||
epubWebView.delegate = self
|
||||
epubWebView.onDecorationsResolved = { [weak self] decorations in
|
||||
self?.decorationOverlayView.applyDecorations(decorations)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
@ -36,6 +56,7 @@ final class RDEPUBWebContentView: UIView {
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
epubWebView.frame = bounds
|
||||
decorationOverlayView.frame = bounds
|
||||
|
||||
let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
|
||||
pageNumberLabel.frame = CGRect(
|
||||
@ -56,13 +77,18 @@ final class RDEPUBWebContentView: UIView {
|
||||
backgroundColor = theme.contentBackgroundColor
|
||||
pageNumberLabel.textColor = theme.contentTextColor
|
||||
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
|
||||
decorationOverlayView.applyDecorations([])
|
||||
epubWebView.load(publication: publication, request: request)
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
func releaseResources() {
|
||||
decorationOverlayView.applyDecorations([])
|
||||
epubWebView.reset()
|
||||
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) {
|
||||
delegate?.epubWebContentView(self, didLogJavaScriptError: message)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
/// URL 阅读器入口控制器
|
||||
/// 接收一个书籍文件 URL,根据文件类型自动创建对应的阅读器控制器:
|
||||
/// - EPUB 文件:直接使用 RDEPUBReaderController
|
||||
/// - 纯文本文件:先通过 RDPlainTextBookBuilder 分页,再使用 RDEPUBReaderController
|
||||
/// - 分页失败:回退到纯文本 UITextView 展示
|
||||
///
|
||||
/// 使用方式:
|
||||
/// ```swift
|
||||
/// let controller = RDURLReaderController(bookURL: fileURL)
|
||||
/// navigationController?.pushViewController(controller, animated: true)
|
||||
/// ```
|
||||
public final class RDURLReaderController: UIViewController {
|
||||
/// 书籍文件 URL
|
||||
private let bookURL: URL
|
||||
/// EPUB 阅读器配置(字体、行距、主题等)
|
||||
private let epubConfiguration: RDEPUBReaderConfiguration
|
||||
/// 内嵌的阅读器控制器
|
||||
private var embeddedController: UIViewController?
|
||||
|
||||
/// 初始化方法
|
||||
/// - Parameters:
|
||||
/// - bookURL: 书籍文件的本地 URL
|
||||
/// - epubConfiguration: EPUB 阅读器配置,默认使用标准配置
|
||||
public init(
|
||||
bookURL: URL,
|
||||
epubConfiguration: RDEPUBReaderConfiguration = RDEPUBReaderConfiguration()
|
||||
@ -19,6 +50,7 @@ public final class RDURLReaderController: UIViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后,设置背景色、标题并嵌入阅读器控制器
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
@ -26,11 +58,18 @@ public final class RDURLReaderController: UIViewController {
|
||||
embedReaderController()
|
||||
}
|
||||
|
||||
/// 切换阅读器的翻页模式(Demo 用)
|
||||
/// - Parameter displayType: 目标翻页模式
|
||||
public func applyDemoDisplayType(_ displayType: RDReaderView.DisplayType) {
|
||||
readerController?.configuration.displayType = displayType
|
||||
logDemoState(prefix: "display=\(displayType.demoArgumentValue)")
|
||||
}
|
||||
|
||||
/// 跳转到指定页码(Demo 用)
|
||||
/// - Parameters:
|
||||
/// - pageNumber: 目标页码
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 是否成功跳转
|
||||
@discardableResult
|
||||
public func goToDemoPage(_ pageNumber: Int, animated: Bool = false) -> Bool {
|
||||
let moved = readerController?.go(toPageNumber: pageNumber, animated: animated) ?? false
|
||||
@ -40,6 +79,12 @@ public final class RDURLReaderController: UIViewController {
|
||||
return moved
|
||||
}
|
||||
|
||||
/// 执行翻页模式切换序列(Demo 用)
|
||||
/// 按照指定顺序和延迟依次切换翻页模式
|
||||
/// - Parameters:
|
||||
/// - displayTypes: 翻页模式数组
|
||||
/// - initialPageNumber: 可选的初始跳转页码
|
||||
/// - stepDelay: 每步之间的延迟时间(秒)
|
||||
public func runDemoDisplaySequence(
|
||||
_ displayTypes: [RDReaderView.DisplayType],
|
||||
initialPageNumber: Int? = nil,
|
||||
@ -60,6 +105,8 @@ public final class RDURLReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 嵌入阅读器控制器到当前视图层级
|
||||
/// 根据文件类型选择合适的阅读器控制器,并通过 Child View Controller 方式嵌入
|
||||
private func embedReaderController() {
|
||||
let controller: UIViewController
|
||||
if bookURL.pathExtension.lowercased() == "epub" {
|
||||
@ -72,7 +119,20 @@ public final class RDURLReaderController: UIViewController {
|
||||
let bookTitle = bookURL.deletingPathExtension().lastPathComponent
|
||||
let pageSize = currentTextPageSize()
|
||||
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) {
|
||||
controller = RDEPUBReaderController(
|
||||
textBook: textBook,
|
||||
@ -104,10 +164,12 @@ public final class RDURLReaderController: UIViewController {
|
||||
controller.didMove(toParent: self)
|
||||
}
|
||||
|
||||
/// 获取内嵌的 EPUB 阅读器控制器(如果存在)
|
||||
private var readerController: RDEPUBReaderController? {
|
||||
embeddedController as? RDEPUBReaderController
|
||||
}
|
||||
|
||||
/// 输出当前阅读器状态日志(Demo 调试用)
|
||||
private func logDemoState(prefix: String) {
|
||||
guard let readerController else { return }
|
||||
let location = readerController.currentLocation
|
||||
@ -117,6 +179,8 @@ public final class RDURLReaderController: UIViewController {
|
||||
print("[ReadViewDemo] automation \(prefix) -> page \(page) href \(href) progression \(progression)")
|
||||
}
|
||||
|
||||
/// 计算文本分页的页面尺寸
|
||||
/// 基于屏幕尺寸和配置的内边距
|
||||
private func currentTextPageSize() -> CGSize {
|
||||
let viewportSize = UIScreen.main.bounds.size
|
||||
let insets = epubConfiguration.reflowableContentInsets
|
||||
@ -126,6 +190,7 @@ public final class RDURLReaderController: UIViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据当前配置生成文本渲染样式
|
||||
private func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
|
||||
let font = UIFont.systemFont(ofSize: epubConfiguration.fontSize)
|
||||
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 {
|
||||
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
|
||||
return content
|
||||
@ -151,7 +218,9 @@ public final class RDURLReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 翻页模式扩展:提供 Demo 命令行参数值
|
||||
private extension RDReaderView.DisplayType {
|
||||
/// 返回翻页模式对应的 Demo 命令行参数字符串
|
||||
var demoArgumentValue: String {
|
||||
switch self {
|
||||
case .pageCurl:
|
||||
15
Sources/RDReaderView/EPUBUI/UIColor+RDEPUBHex.swift
Normal file
15
Sources/RDReaderView/EPUBUI/UIColor+RDEPUBHex.swift
Normal file
@ -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
Loading…
Reference in New Issue
Block a user