Compare commits

...

3 Commits

Author SHA1 Message Date
shen
23182e8b5a feat(epub): align wxread css and overlay pagination behavior 2026-05-25 22:15:24 +08:00
shen
c0aac56083 docs: 同步 Doc 文档与当前代码状态
- 更新模块文件数(EPUBCore 31+2, EPUBTextRendering 13, ReaderView 5, EPUBUI 19)
- 更新 Swift 版本(5.10)、iOS 版本(15.0)、行数等基本信息
- 补充新增文件文档(TextAnchor, RenderRequest, WebViewDebug, AssetRepository,
  TextIndexTable, TextPerformanceSampler, ChapterData, PageInteractionController 等)
- 更新 RDEPUBLocation 模型(新增 rangeAnchor 字段)
- 更新 RDEPUBReaderConfiguration(13 项配置)、Delegate 签名、Persistence(8 方法)
- 修正 RDURLReaderController 归属(ReaderView → EPUBUI)
- 移除过时的 LegacyRDReaderController 引用
- 标记 SS→RD 命名迁移为已完成
2026-05-25 20:31:10 +08:00
shen
54798ba578 refactor: 添加中文注释 + 优化模块结构
- 给全部 78 个 Swift 源文件添加详细的中文注释(文件级、类级、方法级)
- 删除 LegacyRDReaderController/ 死代码目录(16 文件 4592 行)
- 根目录翻页容器文件移入 ReaderView/ 目录
- Resources/ 移入 EPUBCore/Resources/(与使用者归属一致)
- RDEPUBTextIndexTable.swift 移入 EPUBTextRendering/(消除反向依赖)
- RDURLReaderController.swift 移入 EPUBUI/(入口控制器归入 UI 层)
- 更新 podspec 资源路径
2026-05-25 10:19:14 +08:00
110 changed files with 5285 additions and 6252 deletions

View File

@ -4,8 +4,8 @@
RDReaderView 是一个 iOS 阅读器组件库CocoaPods提供开箱即用的 EPUB 阅读能力,并保留对纯文本翻页的支持。
- **最低 iOS 版本**15.0podspec 仍标注 9.0,实际 demo Podfile 要求 15.0
- **Swift 版本**5.0+
- **最低 iOS 版本**15.0
- **Swift 版本**5.10+
- **依赖**ZIPFoundationEPUB 解压、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出版物聚合对象
│ RDEPUBModelsmetadata / manifest / spine 模型) │
│ RDEPUBReadingModelslocation / 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
│ RDEPUBResourceURLSchemeHandlerss-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 层 |

View File

@ -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`

View File

@ -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 中的某个字符位置,包含 fileIndexspine 索引、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 搜索模型

View File

@ -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. 联调与排查建议

View File

@ -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

View File

@ -15,20 +15,27 @@
| 文件 | 职责 |
|------|------|
| `RDEPUBParser.swift` + 扩展 | EPUB 解压、container.xml、OPF、manifest、spine、TOC/NCX 解析 |
| `RDEPUBParser.swift` + 5 扩展 | EPUB 解压、container.xml、OPF、manifest、spine、TOC/NCX 解析、阅读配置文件判断 |
| `RDEPUBPublication.swift` | 解析结果的唯一聚合入口,外部不直接访问 parser 字段 |
| `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**:约 1995EPUB 加载、UI、数据源职责仍混在一起建议继续向 library 层迁移
2. **基线验证**:用四本样书跑一轮分页耗时、目录命中率、末页事件、设置恢复稳定性数据
3. **横竖屏支持回归矩阵**:控制器级 `viewWillTransition()` 与 viewport 去重已接入,下一步重点是补齐 pageCurl / scroll、三条正文路径和 iPad 视口变化场景的固定回归
4. **Pod 分层**:评估是否将 EPUBCore / EPUBTextRendering / EPUBUI 拆为独立 subspec

View File

@ -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`,为三种滚动模式提供布局计算
- 水平滚动:全屏宽 itempagingEnabled
@ -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 协议定义

View File

@ -12,10 +12,10 @@
| 文档 | 对应模块 | 说明 |
|------|----------|------|
| [EPUBCore_功能实现逻辑.md](EPUBCore_功能实现逻辑.md) | `Sources/RDReaderView/EPUBCore/`30 文件 | EPUB 解析全流程ZIP→container.xml→OPF→spine/TOC、阅读会话状态机、离屏分页测量、`ss-reader://` 资源协议、JS 桥接6 种消息、WebView 渲染管线、缓存策略 |
| [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 页面交互 |
## 方案讨论文档

View File

@ -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公众号/文集文章) | WKWebViewinteractive/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
当前状态:`❌ 未开始`
当前状态:`⚠️ 进行中`
---

View File

@ -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'

View File

@ -43,7 +43,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
RDReaderView: 438edba3372d14a7dde3c0334ebd4cc1945ca926
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351

View File

@ -20,7 +20,7 @@
"source_files": "Sources/RDReaderView/**/*.{swift}",
"resource_bundles": {
"RDReaderViewAssets": [
"Sources/RDReaderView/Resources/**/*"
"Sources/RDReaderView/EPUBCore/Resources/**/*"
]
},
"dependencies": {

View File

@ -43,7 +43,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
RDReaderView: 438edba3372d14a7dde3c0334ebd4cc1945ca926
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351

File diff suppressed because it is too large Load Diff

View File

@ -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>

View File

@ -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";

View File

@ -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 {}

View File

@ -1,12 +1,25 @@
// RDEPUBFixedLayoutTemplate.swift
// Fixed LayoutHTML
// 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: [

View File

@ -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 progressionfragment
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),

View File

@ -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>
/// IDMIME
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?
/// IDEPUB 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 DocumentEPUB 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? {

View File

@ -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 {

View File

@ -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
}

View File

@ -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

View File

@ -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 OPFPackage 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()
}
/// metadatamanifestspine
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(

View File

@ -1,6 +1,18 @@
// RDEPUBParser+Package.swift
// OPFPackage Document Spine
// OPFPackageParserDelegateSAX metadata/manifest/spine
// RDEPUBSpineItem rendition:layoutpage-spread
// XMLNameXMLSectionXMLParserContext XML
import Foundation
extension RDEPUBParser {
/// OPF spine
/// manifest hreffixed/reflowablepage-spread
/// - Parameters:
/// - packageDocument: OPF metadatamanifestspineReferences
/// - 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 metadatamanifestspine 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-paginatedpage-spread-left
var properties: [String]
/// //
var pageSpread: RDEPUBPageSpread?
}
/// OPF SAX
/// metadataidentifier/title/creator/language/rendition:layout
/// manifestitem spineitemref
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:layoutrendition:spreadidentifier
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)
}

View File

@ -1,6 +1,13 @@
// RDEPUBParser+ReadingProfile.swift
// EPUB
// metadata layout manifest/spine
// webFixedLayoutwebInteractive/ 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",

View File

@ -1,11 +1,18 @@
// RDEPUBParser+Resources.swift
// EPUB URL
// IDhref 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

View File

@ -1,6 +1,14 @@
// RDEPUBParser+TOC.swift
// EPUB
// NCXEPUB 2 Navigation DocumentEPUB 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) }
}
/// NCXEPUB 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 DocumentEPUB 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:typetyperole
private func isTOCNav(attributes: [String: String]) -> Bool {
if let epubType = attributes["epub:type"]?.lowercased(), epubType.contains("toc") {
return true

View File

@ -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())
}

View File

@ -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,

View File

@ -1,7 +1,16 @@
// RDEPUBPublication.swift
// EPUB Facade
// RDEPUBParser metadataspineTOC 访
// 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,

View File

@ -1,11 +1,30 @@
//
// RDEPUBReadingModels.swift
// RDReaderView
//
// EPUBCore
// LocationViewportReadingContext
// SelectionHighlightBookmark
// ChapterInfoPage 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 EPUBDOM 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

View File

@ -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,

View File

@ -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):

View File

@ -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 {

View File

@ -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":

View File

@ -1,12 +1,22 @@
// RDEPUBSearchEngine.swift
// EPUB
// spine 线 HTML/XHTML
// hrefprogressionpreviewText
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: "&quot;", 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)

View File

@ -1,12 +1,25 @@
// RDEPUBSearchModels.swift
// EPUB
// RDEPUBSearchMatchRDEPUBSearchResult
// RDEPUBSearchStateRDEPUBSearchPresentation
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]) {

View File

@ -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
}
/// CSSbody#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,

View File

@ -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))
}

View File

@ -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

View File

@ -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

View File

@ -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 {

View File

@ -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 {

View File

@ -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
)
}
}

View File

@ -1,21 +1,39 @@
// RDEPUBWebView.swift
// EPUB WebView
// WKWebView EPUB Resource View
// spineIndexpageIndex
//
// +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

View File

@ -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 IDURL
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 {

View File

@ -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
};

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View File

@ -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,

View File

@ -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)

View File

@ -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
)
}
}

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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: - avoidPageBreakInsideWXRead
/// 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,

View File

@ -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

View File

@ -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)
}

View File

@ -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` // marginpadding
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 {

View File

@ -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}
///
/// blockKindsemanticHintsattachmentPlacement
/// .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. CSSdefault/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

View File

@ -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)

View File

@ -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

View File

@ -4,6 +4,11 @@ import UIKit
import DTCoreText
#endif
// MARK: -
///
/// DTCoreText
///
final class RDEPUBPageInteractionController {
var snapshot: RDEPUBPageLayoutSnapshot?

View File

@ -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]

View File

@ -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

View File

@ -1,6 +1,11 @@
import UIKit
// MARK: -
/// EPUB
///
final class RDEPUBReaderChapterListController: UITableViewController {
///
var onSelectItem: ((RDEPUBReaderTableOfContentsItem) -> Void)?
private let items: [RDEPUBReaderTableOfContentsItem]

View File

@ -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,

View File

@ -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

View File

@ -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) {}

View File

@ -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]

View File

@ -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.",

View File

@ -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.0nil 使
public var brightness: CGFloat?
/// ptnil 使
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

View File

@ -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) {

View File

@ -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) {

View File

@ -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
}

View File

@ -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 {
}

View File

@ -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)

View File

@ -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?

View File

@ -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
)
}
}

View File

@ -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) {}
}

View File

@ -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()
}
}
}
}
}

View File

@ -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:

View 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
)
}
}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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 ?? ""
}
}

View File

@ -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
}
}

View File

@ -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())
}
}

View File

@ -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")
}
}

View File

@ -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) {
}
}

View File

@ -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")
}
}

View File

@ -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")
}
}

View File

@ -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
}
}

View File

@ -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