docs: 补充注释、修正过时文档、清理重复内容
源码注释: - 为 ~60 个 Swift 文件补充缺失的 doc comment(file header、类型、属性、方法) - 修正 4 处错误注释:翻页模式数量、搜索行为描述、手势识别器描述、悬空文档块 文档维护: - 删除重复文档:WXRead/读书EPUB阅读器实现架构.md(与微信读书版完全一致) - 合并重叠文档:阅读器规划.md → 阅读器功能开发计划.md(单一真值) - 修正过时内容:所有文档中"四种翻页模式"→"三种",移除 horizontalCoverScroll - 更新架构图:补齐 EPUBUI/ReaderController、Paging/、Typesetter/ 等子目录 - 更新 index.md 索引:新增开发计划和架构对比文档引用
This commit is contained in:
parent
1efb9d172f
commit
948004eed1
@ -21,70 +21,143 @@ RDReaderView 是一个 iOS 阅读器组件库(CocoaPods),提供开箱即
|
||||
│
|
||||
┌───────────────────────────▼─────────────────────────────┐
|
||||
│ EPUBUI 层(library 级读者 UI) │
|
||||
│ RDEPUBReaderController(开箱即用入口,~1995 行) │
|
||||
│ RDEPUBReaderConfiguration / Theme / Settings / Persistence│
|
||||
│ TopToolView / BottomToolView / ToolView 基类 │
|
||||
│ ChapterList / Highlights / Bookmarks / Settings 面板 │
|
||||
│ RDEPUBTextContentView / RDEPUBWebContentView │
|
||||
│ RDEPUBPageInteractionController / SelectionOverlayView │
|
||||
│ RDEPUBPageLayoutSnapshot / RDURLReaderController │
|
||||
│ │
|
||||
│ 主控制器 │
|
||||
│ RDEPUBReaderController(开箱即用入口) │
|
||||
│ +ContentDelegates / +DataSource / +PublicAPI │
|
||||
│ +RenderSupport / +RuntimeBridge / +TableOfContents │
|
||||
│ RDURLReaderController(URL 阅读入口) │
|
||||
│ │
|
||||
│ ReaderController/(协调器) │
|
||||
│ RDEPUBReaderRuntime(中央运行时协调器) │
|
||||
│ RDEPUBReaderContext(上下文状态容器) │
|
||||
│ RDEPUBReaderDependencies(依赖注入) │
|
||||
│ RDEPUBReaderLoadCoordinator(EPUB 加载) │
|
||||
│ RDEPUBReaderPaginationCoordinator(分页协调) │
|
||||
│ RDEPUBReaderLocationCoordinator(位置持久化) │
|
||||
│ RDEPUBReaderAnnotationCoordinator(标注管理) │
|
||||
│ RDEPUBReaderSearchCoordinator(搜索) │
|
||||
│ RDEPUBReaderChromeCoordinator(工具栏) │
|
||||
│ RDEPUBReaderAssemblyCoordinator(UI 组装) │
|
||||
│ RDEPUBReaderViewportMonitor(视口变化监听) │
|
||||
│ │
|
||||
│ Settings/(配置与主题) │
|
||||
│ RDEPUBReaderConfiguration / RDEPUBReaderSettings │
|
||||
│ RDEPUBReaderSettingsViewController / RDEPUBReaderTheme │
|
||||
│ │
|
||||
│ TextPage/(文本页面交互) │
|
||||
│ RDEPUBTextContentView / RDEPUBTextPageRenderView │
|
||||
│ RDEPUBSelectableTextView / RDEPUBTextSelectionController│
|
||||
│ RDEPUBSelectionOverlayView / RDEPUBTextAnnotationOverlay│
|
||||
│ RDEPUBPageInteractionController / RDEPUBPageLayoutSnapshot│
|
||||
│ RDEPUBTextPageDecorationView │
|
||||
│ │
|
||||
│ 工具栏与面板 │
|
||||
│ RDEPUBReaderTopToolView / RDEPUBReaderBottomToolView │
|
||||
│ RDEPUBReaderToolView(基类) │
|
||||
│ RDEPUBReaderChapterListController(目录面板) │
|
||||
│ RDEPUBReaderHighlightsViewController(高亮管理) │
|
||||
│ RDEPUBReaderPersistence(位置持久化) │
|
||||
│ RDEPUBReaderDelegate / RDEPUBReaderTableOfContentsItem│
|
||||
│ RDEPUBWebContentView / RDEPUBWebDecorationOverlayView │
|
||||
│ RDEPUBViewportTypes / UIColor+RDEPUBHex │
|
||||
└───────────────────────────┬─────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────────▼─────────────────────────────┐
|
||||
│ 翻页容器层(RDReaderView) │
|
||||
│ │
|
||||
│ RDReaderView(UIView,统一翻页外壳) │
|
||||
│ 4 种翻页模式:pageCurl / horizontalScroll / │
|
||||
│ verticalScroll / horizontalCoverScroll │
|
||||
│ 3 种翻页模式:pageCurl / horizontalScroll / │
|
||||
│ verticalScroll │
|
||||
│ RDReaderViewProtocols(DataSource / Delegate / DisplayType)│
|
||||
│ +CollectionView / +ContentAccess / +PageCurl / +ToolView │
|
||||
│ RDReaderFlowLayout / RDReaderContentCell │
|
||||
│ RDReaderPageChildViewController(pageCurl 页包装) │
|
||||
│ RDReaderGestureController │
|
||||
│ │
|
||||
│ Paging/(翻页控制) │
|
||||
│ RDReaderPagingController(转场与排队) │
|
||||
│ RDReaderPreloadController(预加载与缓存) │
|
||||
│ RDReaderSpreadResolver(双页配对) │
|
||||
│ RDReaderTapRegionHandler(手势分区) │
|
||||
└───────────────────────────┬─────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────────▼─────────────────────────────┐
|
||||
│ EPUBCore 层(EPUB 引擎) │
|
||||
│ │
|
||||
│ Publication 层 │
|
||||
│ 解析与模型 │
|
||||
│ RDEPUBParser(+Archive / +Package / +TOC / │
|
||||
│ +ReadingProfile / +Resources) │
|
||||
│ RDEPUBPublication(出版物聚合对象) │
|
||||
│ RDEPUBModels(metadata / manifest / spine 模型) │
|
||||
│ RDEPUBReadingModels(location / viewport / highlight) │
|
||||
│ Models/ │
|
||||
│ RDEPUBReadingLocationModels(location 模型) │
|
||||
│ RDEPUBPaginationModels(分页模型) │
|
||||
│ RDEPUBAnnotationModels(标注模型) │
|
||||
│ RDEPUBTextAnchor / RDEPUBTextRangeAnchor(文本锚点) │
|
||||
│ RDEPUBRenderRequest(渲染请求模型) │
|
||||
│ │
|
||||
│ Services 层 │
|
||||
│ 服务层 │
|
||||
│ RDEPUBResourceResolver(资源 URL 统一入口) │
|
||||
│ RDEPUBResourceURLSchemeHandler(ss-reader:// 协议) │
|
||||
│ RDEPUBPreferences(展示参数聚合) │
|
||||
│ RDEPUBPaginator(离屏分页服务) │
|
||||
│ RDEPUBStyleSheetBuilder / RDEPUBJavaScriptBridge │
|
||||
│ RDEPUBFixedLayoutTemplate / RDEPUBAssetRepository │
|
||||
│ │
|
||||
│ Navigator 层 │
|
||||
│ 会话与导航 │
|
||||
│ RDEPUBReadingSession(状态机 + 会话协调) │
|
||||
│ RDEPUBNavigatorState(状态枚举) │
|
||||
│ RDEPUBNavigatorLayoutContext │
|
||||
│ │
|
||||
│ Resource View 层 │
|
||||
│ WebView 渲染 │
|
||||
│ RDEPUBWebView(+Configuration / +Reflowable / │
|
||||
│ +FixedLayout / +JavaScriptBridge / │
|
||||
│ +Search) │
|
||||
│ RDEPUBResourceURLSchemeHandler(ss-reader:// 协议) │
|
||||
│ RDEPUBStyleSheetBuilder / RDEPUBJavaScriptBridge │
|
||||
│ RDEPUBFixedLayoutTemplate / RDEPUBAssetRepository │
|
||||
│ RDEPUBWebViewDebug(调试日志工具) │
|
||||
│ │
|
||||
│ Search 层 │
|
||||
│ 搜索 │
|
||||
│ RDEPUBSearchEngine(协议)/ RDEPUBHTMLSearchEngine │
|
||||
│ RDEPUBSearchModels(SearchMatch/Result/State/Presentation)│
|
||||
└───────────────────────────┬─────────────────────────────┘
|
||||
|
||||
│
|
||||
┌───────────────────────────▼─────────────────────────────┐
|
||||
│ EPUBTextRendering 层(文本 EPUB 渲染) │
|
||||
│ │
|
||||
│ 渲染 │
|
||||
│ RDEPUBTextRenderer(协议) │
|
||||
│ RDEPUBDTCoreTextRenderer(DTCoreText 实现) │
|
||||
│ RDEPUBTextRendererSupport / RDEPUBTextPaginationSupport │
|
||||
│ RDEPUBTextBookBuilder / RDPlainTextBookBuilder │
|
||||
│ RDPlainTextBookBuilder(纯文本书籍构建) │
|
||||
│ RDEPUBTextPositionConverter(位置转换器) │
|
||||
│ RDEPUBTextSearchEngine(文本搜索引擎) │
|
||||
│ RDEPUBTextIndexTable / RDEPUBChapterData │
|
||||
│ │
|
||||
│ BuildPipeline/(构建管线) │
|
||||
│ RDEPUBTextBookBuilder(分页书籍构建器) │
|
||||
│ RDEPUBTextBookCache / RDEPUBTextBookModels │
|
||||
│ RDEPUBTextBuildPipelineInterfaces(管线协议) │
|
||||
│ RDEPUBPaginationCacheCoordinator(缓存协调) │
|
||||
│ RDEPUBChapterTailNormalizer(章尾规范化) │
|
||||
│ RDEPUBBuildDiagnosticsReporter(诊断报告) │
|
||||
│ RDEPUBTextPerformanceSampler(性能采样) │
|
||||
│ │
|
||||
│ Pagination/(分页引擎) │
|
||||
│ RDEPUBTextLayouter / RDEPUBTextLayoutFrame │
|
||||
│ RDEPUBTextBookCache / RDEPUBChapterData │
|
||||
│ RDEPUBTextIndexTable / RDEPUBTextPerformanceSampler │
|
||||
│ RDEPUBTextSearchEngine │
|
||||
│ RDEPUBChapterPageCounter / RDEPUBCoreTextPageFrameFactory│
|
||||
│ RDEPUBPageBreakPolicy(断页策略) │
|
||||
│ RDEPUBTextPaginationInterfaces(分页协议) │
|
||||
│ RDEPUBTextPaginationSupport(分页支持) │
|
||||
│ │
|
||||
│ Typesetter/(排版管线) │
|
||||
│ RDEPUBTypesettingPipeline(排版管线编排) │
|
||||
│ RDEPUBHTMLNormalizer(HTML 规范化) │
|
||||
│ RDEPUBStyleSheetComposer(CSS 组合) │
|
||||
│ RDEPUBFontNormalizer(字体规范化) │
|
||||
│ RDEPUBAttachmentNormalizer(附件规范化) │
|
||||
│ RDEPUBFragmentMarkerInjector(Fragment 标记注入) │
|
||||
│ RDEPUBSemanticMarkerInjector(语义标记注入) │
|
||||
│ RDEPUBRenderDiagnosticsCollector(渲染诊断) │
|
||||
│ RDEPUBTextRendererSupport(渲染辅助工具) │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@ -92,14 +165,13 @@ RDReaderView 是一个 iOS 阅读器组件库(CocoaPods),提供开箱即
|
||||
|
||||
## 3. 翻页容器层(RDReaderView)
|
||||
|
||||
### 3.1 四种翻页模式
|
||||
### 3.1 三种翻页模式
|
||||
|
||||
| 模式 | 实现方式 | 特点 |
|
||||
|------|----------|------|
|
||||
| `pageCurl` | UIPageViewController | 原生翻书效果,手势由系统提供 |
|
||||
| `horizontalScroll` | UICollectionView + RDReaderFlowLayout | 每屏显示 2 项,水平分页滚动 |
|
||||
| `verticalScroll` | UICollectionView + RDReaderFlowLayout | 全宽项目,垂直连续滚动 |
|
||||
| `horizontalCoverScroll` | UICollectionView + RDReaderFlowLayout | 覆盖滚动效果,Z 轴动画 |
|
||||
|
||||
### 3.2 数据源协议
|
||||
|
||||
|
||||
@ -249,7 +249,7 @@ RDEPUBReaderSettings (Codable)
|
||||
└── themePreset: RDEPUBReaderThemePreset?
|
||||
```
|
||||
|
||||
`RDEPUBReaderDisplayMode`:可序列化的翻页模式枚举(pageCurl / horizontalScroll / verticalScroll / horizontalCoverScroll),与 `RDReaderView.DisplayType` 相互转换。
|
||||
`RDEPUBReaderDisplayMode`:可序列化的翻页模式枚举(pageCurl / horizontalScroll / verticalScroll),与 `RDReaderView.DisplayType` 相互转换。注意:历史版本遗留的 `horizontalCoverScroll` 会自动映射为 `horizontalScroll`。
|
||||
|
||||
`RDEPUBReaderThemePreset`:可序列化的主题预设枚举(light / yellow / green / pink / blue / dark),与 `RDEPUBReaderTheme` 相互转换。
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
## 1. 范围与目标
|
||||
|
||||
- 代码范围:`Sources/RDReaderView/ReaderView/`(5 个 Swift 文件)
|
||||
- 目标:说明分页阅读器容器如何管理四种显示模式、DataSource/Delegate 协议、翻页交互、工具栏动画、双屏适配和 RTL 支持。
|
||||
- 目标:说明分页阅读器容器如何管理三种显示模式、DataSource/Delegate 协议、翻页交互、工具栏动画、双屏适配和 RTL 支持。
|
||||
- 主链路关键词:`RDReaderDataSource -> reloadData -> DisplayType 切换 -> 翻页/滚动 -> RDReaderDelegate.pageNum -> 工具栏显隐`。
|
||||
|
||||
## 2. 关键对象职责
|
||||
@ -13,7 +13,7 @@
|
||||
- 文件:`Sources/RDReaderView/ReaderView/RDReaderView.swift`(~1219 行)
|
||||
- 入口方法:`reloadData()`
|
||||
- 职责:
|
||||
- 管理四种显示模式的视图层级切换
|
||||
- 管理三种显示模式的视图层级切换
|
||||
- 持有 `UIPageViewController`(pageCurl 模式)或 `UICollectionView`(滚动模式)
|
||||
- 处理点击手势(左/中/右三区域)
|
||||
- 管理工具栏(topToolView / bottomToolView)的显示/隐藏动画
|
||||
@ -25,10 +25,9 @@
|
||||
|
||||
- 文件:`Sources/RDReaderView/ReaderView/RDReaderFlowLayout.swift`(~375 行)
|
||||
- 职责:
|
||||
- 继承 `UICollectionViewFlowLayout`,为三种滚动模式提供布局计算
|
||||
- 继承 `UICollectionViewFlowLayout`,为两种滚动模式提供布局计算
|
||||
- 水平滚动:全屏宽 item,pagingEnabled
|
||||
- 垂直滚动:可变高度 item,累加计算
|
||||
- 水平覆盖滚动:zIndex 分层 + 阴影效果模拟深度
|
||||
- 封面感知帧计算:封面页全屏宽,后续页面两两配对半屏宽
|
||||
|
||||
### 2.3 内容 Cell `RDReaderContentCell`
|
||||
@ -80,7 +79,7 @@ func pageNum(readerView: RDReaderView, pageNum: Int)
|
||||
3. `reloadData` 内部调用 `switchReaderDisplayType(currentDisplayType)` 重建视图层级。
|
||||
4. 同时从 `dataSource` 获取 `topToolView` 和 `bottomToolView` 并添加到视图层级。
|
||||
|
||||
### 3.3 四种显示模式切换
|
||||
### 3.3 三种显示模式切换
|
||||
|
||||
**pageCurl 模式**:
|
||||
- 创建 `UIPageViewController`(transitionStyle: .pageCurl)
|
||||
@ -99,13 +98,6 @@ func pageNum(readerView: RDReaderView, pageNum: Int)
|
||||
- 页面高度可变,通过 `RDReaderFlowLayoutDataSoure.heigtOfVerticalScrollPage` 查询
|
||||
- collectionViewContentSize 为所有页面高度之和
|
||||
|
||||
**horizontalCoverScroll 模式**:
|
||||
- 水平分页,但带封面滑动动画
|
||||
- `layoutAttributesForElements` 中:
|
||||
- 仅计算当前页附近的窄窗口内的 item attributes
|
||||
- 当前页之前的 item zIndex = -1,当前及之后 zIndex = 1
|
||||
- 顶层页面边缘添加阴影效果
|
||||
|
||||
### 3.4 翻页交互
|
||||
|
||||
**点击手势**(`tapAction(tap:)`):
|
||||
@ -170,8 +162,7 @@ func pageNum(readerView: RDReaderView, pageNum: Int)
|
||||
RDReaderView.DisplayType
|
||||
├── .pageCurl // UIPageViewController 翻页效果
|
||||
├── .horizontalScroll // UICollectionView 水平滚动
|
||||
├── .verticalScroll // UICollectionView 垂直滚动
|
||||
└── .horizontalCoverScroll // UICollectionView 水平覆盖动画
|
||||
└── .verticalScroll // UICollectionView 垂直滚动
|
||||
```
|
||||
|
||||
### 5.2 翻页方向
|
||||
|
||||
@ -1,560 +0,0 @@
|
||||
# 读书 EPUB 阅读器实现架构分析
|
||||
|
||||
> 分析日期: 2026-05-18
|
||||
> 基于读书 v10.0.3
|
||||
|
||||
---
|
||||
|
||||
## 核心结论:双渲染引擎架构
|
||||
|
||||
读书使用了 **两套渲染引擎**,根据内容类型选择不同的渲染路径:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ WRReaderViewController │
|
||||
│ (阅读器主控制器, 管理翻页和状态) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ WRPageViewController │
|
||||
│ (基于 UIPageViewController) │
|
||||
│ 支持 UIPageCurl(仿真翻页) + Scroll(滑动) │
|
||||
├──────────────────────┬──────────────────────────┤
|
||||
│ 路径A: 原生渲染 │ 路径B: WebView 渲染 │
|
||||
│ (EPUB/书籍正文) │ (公众号/文集文章) │
|
||||
│ │ │
|
||||
│ WREpubTypesetter │ WKWebView + JS Bridge │
|
||||
│ DTCoreText │ weread-highlighter.js │
|
||||
│ CoreText 排版 │ rangy-*.js │
|
||||
│ NSAttributedString │ MediaPlatform.js/css │
|
||||
│ WRCoreTextLayouter │ Readability.js │
|
||||
│ WRPageView (draw) │ WRMPPageView │
|
||||
└──────────────────────┴──────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 路径A:EPUB 正文原生渲染 (核心路径)
|
||||
|
||||
这是 EPUB 阅读的**主要渲染方式**,完全用原生 CoreText 实现,不走 WebView。
|
||||
|
||||
### 1. EPUB 下载与解密流程
|
||||
|
||||
```
|
||||
服务器 ZIP 包 (加密)
|
||||
│
|
||||
▼
|
||||
WRBookNetwork.loadTarForEpubBookId:chapter:isPreload:
|
||||
│
|
||||
▼
|
||||
WRBookNetwork.handleUnzipWithBookId:zipPath:encryptKey:plainBookDirectory:
|
||||
│ - 使用 encryptKey 解密
|
||||
│ - 解压到 plainBookDirectory
|
||||
│ - 处理解压错误 handleUnzipErrorWithPath:
|
||||
▼
|
||||
WRBookNetwork.processEncryptedBookFileAtPath:encryptKey:book:chapterUid:isFromReview:
|
||||
│
|
||||
▼
|
||||
WREncryptedFileManager.decryptContentsOfFile:forBookId:isFileLost:
|
||||
│ - keyForBookId: 获取每本书的密钥
|
||||
│ - 解密 EPUB 章节文件
|
||||
▼
|
||||
WREncryptedFileManager.encryptFileForBookId:originalEncryptKey:atPath:toPath:
|
||||
│ - 本地二次加密存储 (DRM 保护)
|
||||
▼
|
||||
本地缓存: epubImage 目录 + 解密后的 XHTML 文件
|
||||
```
|
||||
|
||||
**密钥管理**:
|
||||
- `WRPreloadBookManager.saveEncryptKey:forPath:bookId:` - 预加载章节密钥
|
||||
- `WRPreloadBookManager.encryptKeyForPath:bookId:` - 读取密钥
|
||||
- `WREncryptedFileManager.keyForBookId:` - 每本书独立密钥
|
||||
|
||||
### 2. EPUB 解析与排版流程
|
||||
|
||||
```
|
||||
XHTML 章节文件
|
||||
│
|
||||
▼
|
||||
WRBookNetwork.fileContentWithChapter:book:shouldRemoveHtmlTags:filterTranslateContent:
|
||||
│ - 读取 XHTML 内容
|
||||
│ - 可选去除 HTML 标签
|
||||
│ - 过滤翻译内容
|
||||
▼
|
||||
WREpubTypesetter.attributeStringWithFilePath:priority:insertArticleToolAttachment:
|
||||
insertBookChapterToolAttachment:insertRecommendView:book:chapter:
|
||||
pageFlippingStyle:renderErrorReason:isStyleFileNotFound:options:
|
||||
│
|
||||
│ ┌─────────────────────────────────────────┐
|
||||
│ │ DTHTMLAttributedStringBuilder │
|
||||
│ │ (HTML -> NSAttributedString 转换器) │
|
||||
│ │ │
|
||||
│ │ 1. 解析 XHTML DOM 树 │
|
||||
│ │ 2. 读取 EPUB 内嵌 CSS (replace.css) │
|
||||
│ │ 3. 合并默认样式 (default.css) │
|
||||
│ │ 4. 应用用户主题样式 (dark.css) │
|
||||
│ │ 5. 转换为 NSAttributedString │
|
||||
│ │ - 保留字体、颜色、行高、对齐等属性 │
|
||||
│ │ - 处理图片 (NSTextAttachment) │
|
||||
│ │ - 处理超链接 │
|
||||
│ └─────────────────────────────────────────┘
|
||||
▼
|
||||
NSAttributedString (富文本)
|
||||
│
|
||||
▼
|
||||
WRCoreTextLayouter (CoreText 排版引擎)
|
||||
│
|
||||
│ ┌─────────────────────────────────────────┐
|
||||
│ │ DTCoreText 框架 (自定义修改版) │
|
||||
│ │ │
|
||||
│ │ DTCoreTextLayouter │
|
||||
│ │ └─ CTTypesetter │
|
||||
│ │ └─ CTFramesetter │
|
||||
│ │ └─ DTCoreTextLayoutFrame │
|
||||
│ │ └─ CTFrame (每页) │
|
||||
│ │ └─ DTCoreTextLayoutLine │
|
||||
│ │ └─ CTLine (每行) │
|
||||
│ │ └─ DTCoreTextGlyphRun │
|
||||
│ │ └─ CTLine (字形) │
|
||||
│ │ │
|
||||
│ │ 特殊处理: │
|
||||
│ │ - wr-vertical-center-style (图片居中) │
|
||||
│ │ - weread-page-relate (分页控制) │
|
||||
│ │ - avoidPageBreakInside (避免断页) │
|
||||
│ │ - 繁简转换 (convertHansToHant) │
|
||||
│ └─────────────────────────────────────────┘
|
||||
▼
|
||||
WRChapterData (章节数据模型)
|
||||
│
|
||||
│ - 包含排版后的 NSAttributedString
|
||||
│ - 管理划线/高亮/书评等标注
|
||||
│ - addHighlightInRange:key:itemId:color:
|
||||
│ - addUnderLineToAttributedString:range:itemId:style:color:
|
||||
│ - addReviewUnderlineInRange:itemId:type:
|
||||
│ - generateOutlineContents (生成目录)
|
||||
│ - freeTrialChapterCutOffStringLocaion (免费试读截断)
|
||||
▼
|
||||
分页计算: WRChapterPageCount
|
||||
│
|
||||
│ - rangeValueWithPageInfo: 计算每页的 NSRange
|
||||
│ - rangeOfPage: 获取指定页的文本范围
|
||||
▼
|
||||
WRPageView (页面视图, UIView 子类)
|
||||
│
|
||||
│ - 继承 UIView
|
||||
│ - drawRect: 中调用 CoreText 绘制
|
||||
│ - WRCoreTextLayoutFrame.drawInContext:image:size:inRect:position:
|
||||
│ - 直接用 CGContext 绘制文字和图片
|
||||
│ - 不使用 UILabel/UITextView
|
||||
▼
|
||||
屏幕显示 (像素级精确渲染)
|
||||
```
|
||||
|
||||
### 3. 翻页机制
|
||||
|
||||
```
|
||||
WRPageViewController
|
||||
│
|
||||
│ 基于 UIPageViewController 封装
|
||||
│
|
||||
│ 初始化: initWithDelegate:withPageType:pageFlippingStyle:
|
||||
│
|
||||
│ pageFlippingStyle 支持:
|
||||
│ ┌────────────────────────────────────┐
|
||||
│ │ UIPageCurl - 仿真翻页 (纸张卷曲) │
|
||||
│ │ Scroll - 左右滑动翻页 │
|
||||
│ └────────────────────────────────────┘
|
||||
│
|
||||
│ 核心方法:
|
||||
│ - pageViewController:viewControllerBeforeViewController: (上一页)
|
||||
│ - pageViewController:viewControllerAfterViewController: (下一页)
|
||||
│ - pageViewController:spineLocationForInterfaceOrientation: (书脊位置)
|
||||
│ - weread_setViewControllers:withCurlOfType:fromLocation:direction:
|
||||
│ animated:notifyDelegate:completion: (自定义设置方法)
|
||||
│
|
||||
│ 故障修复:
|
||||
│ - patchNavigationDirectionFault (导航方向修复)
|
||||
│ - patchNoViewControllerManagingPageViewFault (页面管理修复)
|
||||
│ - patchUIPageCurlFault (翻页动画修复)
|
||||
│ - detectNavigationDirectionCrashWithPageViewController: (崩溃检测)
|
||||
│
|
||||
▼
|
||||
WRPageView (每个页面的渲染视图)
|
||||
│
|
||||
│ - 通过 WRChapterData 获取排版结果
|
||||
│ - 通过 rangeOfPage: 获取当前页的文本范围
|
||||
│ - 使用 CoreText 直接绘制到 CGContext
|
||||
```
|
||||
|
||||
### 4. 文本选择与标注
|
||||
|
||||
```
|
||||
用户触摸/长按
|
||||
│
|
||||
▼
|
||||
WRPageView 手势识别
|
||||
│
|
||||
▼
|
||||
文本位置计算 (CoreText hit test)
|
||||
│ - CTLineGetStringIndexForPosition (坐标->字符索引)
|
||||
│ - CTLineGetOffsetForStringIndex (字符索引->坐标)
|
||||
▼
|
||||
选择范围确定
|
||||
│
|
||||
▼
|
||||
弹出操作菜单 (UIMenuController)
|
||||
│ - 划线/高亮
|
||||
│ - 写想法/书评
|
||||
│ - 复制
|
||||
│ - 查询/翻译
|
||||
│ - 分享
|
||||
▼
|
||||
WRChapterData 添加标注
|
||||
│ - addHighlightInRange:key:itemId:color:
|
||||
│ - addUnderLineToAttributedString:range:itemId:style:color:
|
||||
│ - addReviewUnderlineInRange:itemId:type:
|
||||
▼
|
||||
保存到服务器
|
||||
│ - WRBookNetwork.addReview:shareToWechat:...
|
||||
│ - 同步书签: loadBookmarkListWithBookId:syncKey:callback:
|
||||
▼
|
||||
重新排版当前页 (recomposeCurrentPageViewWithSource:)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 路径B:WebView 渲染 (公众号/文集文章)
|
||||
|
||||
用于渲染**微信公众号文章、文集、书评**等富媒体内容。
|
||||
|
||||
```
|
||||
HTML 内容 (来自服务器)
|
||||
│
|
||||
▼
|
||||
WRMPReadingManager.composeMPReviewHTMLString:withReview:
|
||||
│ - 组装 HTML 模板
|
||||
│ - 注入 CSS (MediaPlatform.css, MPExtra.css)
|
||||
│ - 注入 JS (MediaPlatform.js, mpForArticle.js)
|
||||
▼
|
||||
WKWebView 加载
|
||||
│
|
||||
│ 注入脚本:
|
||||
│ <script src="WeReadApi.js"></script>
|
||||
│ <script src="rich_display.js"></script>
|
||||
▼
|
||||
weread-highlighter.js 初始化
|
||||
│
|
||||
│ 1. rangy.init() - 初始化 Rangy 选择库
|
||||
│ 2. 创建 Highlighter (TextRange 模式)
|
||||
│ 3. 注册 ClassApplier:
|
||||
│ - "highlight" (高亮)
|
||||
│ - "review" / "friend-review" (书评)
|
||||
│ - "reference" (引用)
|
||||
│ - "tts" (语音朗读标记)
|
||||
│ 4. 监听 selectionchange 事件
|
||||
│ 5. 通过 wereadBridge.execMPReaderMethod 通知原生
|
||||
▼
|
||||
JS Bridge 双向通信
|
||||
│
|
||||
│ 原生 -> JS:
|
||||
│ - evaluateJavaScript: 调用 JS 方法
|
||||
│ - WKUserScript 注入脚本
|
||||
│
|
||||
│ JS -> 原生:
|
||||
│ - window.webkit.messageHandlers.XXX.postMessage()
|
||||
│ - wereadBridge.execMPReaderMethod('MPReader', data)
|
||||
▼
|
||||
WRMPReadingViewModel (ViewModel 层)
|
||||
│
|
||||
│ - addHighlightWithStart:withEnd:withContent:callback:
|
||||
│ - addReviewWithRange:content:reference:secretMode:withCallback:
|
||||
│ - genJSInfosWithHighlights:refrencedHighlight:
|
||||
│ - genJSInfosWithReviews:refrencedReview:
|
||||
│ - readReviewsWithLoadCount:maxObj:
|
||||
│ - setupTTSAudioList
|
||||
▼
|
||||
WRMPPageView (WebView 包装视图)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键源码路径 (从二进制中提取)
|
||||
|
||||
```
|
||||
WeRead/Src/Modules/EpubParser/
|
||||
├── WREpubTypesetter.m # EPUB 排版器
|
||||
├── WREpubPositionConverter.m # 位置转换器
|
||||
└── Utils/DTCoreTextFunctions.m # CoreText 工具函数
|
||||
|
||||
WeRead/Src/Modules/TypeSetter/
|
||||
├── WRCoreTextLayouter.m # CoreText 排版器
|
||||
├── WRCoreTextLayoutFrame.m # 排版帧 (管理页面)
|
||||
└── DTCoreTextGlyphRun.m # 字形渲染
|
||||
|
||||
WeRead/Src/Modules/Reading/
|
||||
├── Controller/
|
||||
│ ├── WRReaderViewController.m # 阅读器主控制器
|
||||
│ └── WRPageViewController.m # 翻页控制器
|
||||
├── Model/
|
||||
│ ├── WRChapterData.m # 章节数据模型
|
||||
│ ├── WRChapterDownloadManger.m # 章节下载管理
|
||||
│ ├── WRReaderViewModel.m # 阅读器 ViewModel
|
||||
│ └── WRMPReadingManager.m # 公众号阅读管理
|
||||
└── View/
|
||||
└── WRPageView.m # 页面渲染视图
|
||||
|
||||
WeRead/Src/Modules/MediaPlatform/
|
||||
├── Controller/
|
||||
│ ├── WRMPListViewController.m
|
||||
│ └── WRMPSubscribeViewController.m
|
||||
└── Model/
|
||||
├── WRMPCoverManager.m
|
||||
├── WRMPCoverPainter.m
|
||||
├── WRMPStore.m
|
||||
└── WRMPViewModel.m
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## EPUB CSS 样式系统
|
||||
|
||||
```
|
||||
加载优先级 (从低到高):
|
||||
1. default.css - 基础 HTML 标签样式 (Safari 默认)
|
||||
2. replace.css - 读书默认替换样式
|
||||
├── 标题样式 (h1-h6, 使用 Source Han Serif CN 字体)
|
||||
├── 代码块 (pre, 使用 Menlo 字体)
|
||||
├── 图片 (.bodyPic, wr-vertical-center-style: 2)
|
||||
├── 引用 (.conQuot)
|
||||
├── 翻译 (.wr-translation)
|
||||
├── 章节工具 (.book-chapter-tool, .chapter-tool)
|
||||
└── 分页控制 (.weread-page-relate)
|
||||
3. dark.css - 暗黑主题样式
|
||||
4. EPUB 内嵌 CSS - 书籍自带样式
|
||||
5. 用户设置 - 字号、行高、主题覆盖
|
||||
```
|
||||
|
||||
**自定义 CSS 属性** (读书私有):
|
||||
- `wr-vertical-center-style: 1|2` - 图片垂直居中方式
|
||||
- `weread-page-relate: true` - 控制分页时的内容关联
|
||||
|
||||
---
|
||||
|
||||
## 核心类职责表
|
||||
|
||||
| 类名 | 职责 | 渲染路径 |
|
||||
|---|---|---|
|
||||
| `WRReaderViewController` | 阅读器主控制器,管理阅读状态、进度保存、章节跳转 | 共用 |
|
||||
| `WRPageViewController` | 翻页控制器,基于 UIPageViewController 封装 | 共用 |
|
||||
| `WRPageView` | 页面渲染视图,CoreText 直接绘制 | 路径A |
|
||||
| `WRMPPageView` | 公众号页面视图,WKWebView 包装 | 路径B |
|
||||
| `WREpubTypesetter` | EPUB 排版器,HTML->NSAttributedString | 路径A |
|
||||
| `WRCoreTextLayouter` | CoreText 排版引擎,管理 CTFrame/CTLine | 路径A |
|
||||
| `WRCoreTextLayoutFrame` | 排版帧,管理单页的绘制 | 路径A |
|
||||
| `WRChapterData` | 章节数据模型,存储排版结果和标注 | 路径A |
|
||||
| `WRChapterPageCount` | 分页计算,管理每页的 NSRange | 路径A |
|
||||
| `WREpubPositionConverter` | EPUB 位置转换器 (文件位置<->字符位置) | 路径A |
|
||||
| `WRChapterDownloadManger` | 章节下载管理器 | 共用 |
|
||||
| `WREncryptedFileManager` | 加密文件管理 (DRM) | 共用 |
|
||||
| `WRBookNetwork` | 书籍网络请求 (下载/解密/解压) | 共用 |
|
||||
| `WRMPReadingManager` | 公众号阅读管理器 | 路径B |
|
||||
| `WRMPReadingViewModel` | 公众号阅读 ViewModel (JS Bridge 交互) | 路径B |
|
||||
| `WRReaderViewModel` | 阅读器 ViewModel | 共用 |
|
||||
| `DTCoreTextLayouter` | DTCoreText 排版器 (第三方库修改版) | 路径A |
|
||||
| `DTHTMLAttributedStringBuilder` | HTML->NSAttributedString 构建器 | 路径A |
|
||||
| `WRReaderPencilNoteManager` | Apple Pencil 手写笔记管理 | 共用 |
|
||||
| `WRReaderTranslationManager` | 翻译管理 (繁简转换/中英翻译) | 共用 |
|
||||
| `WRReaderCht2sManager` | 繁体转简体管理 | 共用 |
|
||||
|
||||
---
|
||||
|
||||
## JavaScript 文件职责
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `weread-highlighter.js` | 核心高亮引擎,初始化 Rangy,管理选择和高亮 |
|
||||
| `rangy-core.js` | Rangy 核心库,跨浏览器 Range/Selection 封装 |
|
||||
| `rangy-highlighter.js` | Rangy 高亮模块,管理高亮的创建/删除/序列化 |
|
||||
| `rangy-classapplier.js` | Rangy ClassApplier 模块,CSS 类应用器 |
|
||||
| `rangy-textrange.js` | Rangy TextRange 模块,文本范围操作 |
|
||||
| `Readability.js` | Arc90 Readability 库,提取文章正文 |
|
||||
| `MediaPlatform.js` | 公众号平台 JS,原生-JS 桥接 |
|
||||
| `mpForArticle.js` | 文章相关 JS 逻辑 |
|
||||
| `MPExtra.css` | 公众号额外样式 |
|
||||
| `MediaPlatform.css` | 公众号基础样式 |
|
||||
| `WeReadApi.js` | 读书 JS API (供 WebView 调用原生功能) |
|
||||
| `rich_display.js` | 富文本显示逻辑 |
|
||||
| `cssInjector.js` | CSS 注入器 |
|
||||
| `highlight.min.js` | 代码语法高亮 (highlight.js) |
|
||||
|
||||
---
|
||||
|
||||
## DRM 与安全机制
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ DRM 保护链 │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. 传输层: HTTPS + 加密 ZIP │
|
||||
│ - 服务器下发加密的 .zip 文件 │
|
||||
│ - 文件名格式: {bookId}_DECRYPT.zip │
|
||||
│ │
|
||||
│ 2. 解密层: 逐章解密 │
|
||||
│ - WREncryptedFileManager │
|
||||
│ - keyForBookId: (每本书独立密钥) │
|
||||
│ - decryptContentsOfFile:forBookId: │
|
||||
│ │
|
||||
│ 3. 存储层: 本地二次加密 │
|
||||
│ - encryptFileForBookId:atPath:toPath: │
|
||||
│ - 解密后立即重新加密存储 │
|
||||
│ - 防止直接拷贝文件读取 │
|
||||
│ │
|
||||
│ 4. 密钥管理: │
|
||||
│ - WRPreloadBookManager 管理预加载密钥 │
|
||||
│ - 密钥与设备绑定 │
|
||||
│ - 通过 Keychain 安全存储 │
|
||||
│ │
|
||||
│ 5. 免费试读控制: │
|
||||
│ - freeTrialChapterCutOffStringLocaion │
|
||||
│ - 服务端控制试读范围 │
|
||||
│ - 客户端截断显示 │
|
||||
│ │
|
||||
│ 6. 章节付费: │
|
||||
│ - isChapterAvailableForBookId:chapterUid │
|
||||
│ - getCouponBuyChapterWithBookId: │
|
||||
│ - resetChapterPaidIfNeeded │
|
||||
│ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 字体系统
|
||||
|
||||
```
|
||||
内嵌字体:
|
||||
├── SourceHanSerifCN-Medium.ttf # 思源宋体 (正文默认)
|
||||
├── FZJuZhenXinFang.ttf # 方正聚珍新仿
|
||||
├── Lora-Regular.ttf / Italic.ttf # Lora 衬线体
|
||||
├── PlayfairDisplay-Regular.ttf # Playfair Display
|
||||
├── WeReadLS-Regular/Medium/Bold # 读书 LS 系列
|
||||
├── WeReadRN-Regular.ttf # RN 专用字体
|
||||
├── WeRead-Icon.ttf # 图标字体
|
||||
├── WeRead-Rating-Icon.ttf # 评分图标
|
||||
├── OpenDyslexic-Regular.otf # 阅读障碍友好字体
|
||||
├── WeChatNumber.ttf # 微信数字字体
|
||||
├── SharpGroteskTRIAL*.ttf # Sharp Grotesk 系列
|
||||
└── icon_font.ttf # 通用图标字体
|
||||
|
||||
动态字体:
|
||||
├── CDN 下载: cdn.weread.qq.com/app/assets/font/
|
||||
│ ├── FZLTHProGBK_B/DB/SB.zip # 方正兰亭黑系列
|
||||
│ ├── FZQingKBYSJF-M.zip # 方正清刻本悦宋
|
||||
│ ├── FZSKBXKK.zip # 方正书宋
|
||||
│ ├── FZYBKSK.zip # 方正中楷
|
||||
│ └── SourceHanSansCN-Heavy.zip # 思源黑体
|
||||
│
|
||||
└── WRFontsManager 管理:
|
||||
- unZipFileAndRegisterFont:completionHandler:filePath:lateOverWrite:
|
||||
- 动态下载 + 解压 + 注册
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 预加载与缓存策略
|
||||
|
||||
```
|
||||
预加载策略:
|
||||
├── WRForecastUtils.shouldPreloadChapterUidsForBook:type:bookRank:archiveRank:
|
||||
│ - 根据书籍排名和用户行为预测需要预加载的章节
|
||||
│
|
||||
├── WRChapterDownloadManger._preloadChapterContentWithBook:type:bookRank:
|
||||
│ - 后台预下载相邻章节
|
||||
│
|
||||
├── WRPreloadBookManager
|
||||
│ - saveEncryptKey:forPath:bookId: (缓存密钥)
|
||||
│ - saveFileNameDict:bookId: (缓存文件名映射)
|
||||
│ - clearKV (清理缓存)
|
||||
│
|
||||
└── SDWebImage 缓存:
|
||||
- epubImage 目录缓存书籍图片
|
||||
- com.hackemist.SDWebImageCache.epubImage
|
||||
|
||||
缓存目录结构:
|
||||
├── Documents/
|
||||
│ └── {bookId}/
|
||||
│ ├── plainBookDirectory/ (解密后的 EPUB 文件)
|
||||
│ ├── epubImage/ (书籍图片缓存)
|
||||
│ └── _DECRYPT.zip (下载的加密 ZIP)
|
||||
└── Library/
|
||||
└── {cachePath}/
|
||||
├── epubImage/ (SDWebImage 缓存)
|
||||
└── com.hackemist.SDWebImageCache.epubImage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 朗读 (TTS) 集成
|
||||
|
||||
```
|
||||
朗读流程:
|
||||
├── WRReadAloudAudio.parseCGIInfos: (解析音频信息)
|
||||
├── TTS 引擎: wxtts (微信语音合成)
|
||||
│ ├── 离线资源: wxtts_offline_resource5.zip
|
||||
│ ├── 在线资源: getWxttsSeginfo / getWxttsVoice
|
||||
│ └── VITS/VALL-E 模型 (高保真语音)
|
||||
│
|
||||
├── 文本分段:
|
||||
│ - weread-highlighter.js 中的 "tts" ClassApplier
|
||||
│ - 标记当前朗读位置
|
||||
│
|
||||
└── 进度同步:
|
||||
- lastListenedChapterOffset / lastListenedChapterUid
|
||||
- MPReading/lastListenedReviewId (公众号朗读进度)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Apple Pencil 手写笔记
|
||||
|
||||
```
|
||||
WRReaderPencilNoteManager:
|
||||
├── 数据存储:
|
||||
│ - writeDrawingDataToLocal:reviewItemId:reviewId:isDraft:
|
||||
│ - drawingFilePathWithReviewItemId:reviewId:isDraft:
|
||||
│ - 本地草稿 + 云端同步
|
||||
│
|
||||
├── 数据上传:
|
||||
│ - uploadPencilDrawing:colorStyle:onlyUploadImage:canRetry:
|
||||
│ - uploadPencilNoteData:suffix:
|
||||
│ - 使用腾讯云 COS 存储
|
||||
│ - authCosForPencilDataWithSuffix: (COS 认证)
|
||||
│
|
||||
├── 数据下载:
|
||||
│ - downloadDrawingDataFromCosWithUrl:desPath:callback:
|
||||
│ - downloadDrawingWithReviewItemId:reviewId:drawingUrl:dataBlock:
|
||||
│
|
||||
└── 图片导出:
|
||||
- imageFilePathWithReviewItemId:
|
||||
- 手绘笔记可导出为图片
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
| 特性 | 实现方式 |
|
||||
|---|---|
|
||||
| **EPUB 解析** | 自研 EPUB Parser,解析 OPF/NCX/XHTML |
|
||||
| **文字排版** | DTCoreText (CoreText 封装) + 自定义 WRCoreTextLayouter |
|
||||
| **页面渲染** | CGContext 直接绘制,不用 UILabel/UITextView |
|
||||
| **翻页效果** | UIPageViewController (UIPageCurl + Scroll) |
|
||||
| **文本选择** | CoreText hit test + Rangy.js (WebView 场景) |
|
||||
| **标注系统** | NSAttributedString 属性注入 + 服务器同步 |
|
||||
| **图片处理** | NSTextAttachment + CDN 尺寸优化 + 白底透明化 |
|
||||
| **DRM** | 逐章加密 + 逐书密钥 + 本地二次加密 |
|
||||
| **公众号文章** | WKWebView + JS Bridge + Rangy 高亮 |
|
||||
| **繁简转换** | CoreText 层面的 convertHansToHant |
|
||||
| **字体** | 内嵌 Source Han Serif CN + 动态下载字体 |
|
||||
| **预加载** | 后台预下载相邻章节 ZIP + 解密缓存 |
|
||||
| **TTS 朗读** | wxtts 引擎 + VITS/VALL-E 高保真模型 |
|
||||
| **手写笔记** | PencilKit + 腾讯云 COS 存储 |
|
||||
@ -14,13 +14,15 @@
|
||||
|------|----------|------|
|
||||
| [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 支持 |
|
||||
| [RDReaderView_功能实现逻辑.md](RDReaderView_功能实现逻辑.md) | `Sources/RDReaderView/ReaderView/`(5 文件) | 三种显示模式(pageCurl/horizontalScroll/verticalScroll)、DataSource/Delegate 协议、点击三区域翻页、工具栏动画、双页配对与哨兵页、横竖屏适配、RTL 支持 |
|
||||
| [EPUBUI_功能实现逻辑.md](EPUBUI_功能实现逻辑.md) | `Sources/RDReaderView/EPUBUI/`(19 文件) | RDEPUBReaderController 全生命周期、三条渲染路径分发、配置变更检测与响应、工具栏/目录/高亮/书签/搜索/设置面板交互流、阅读位置持久化、主题管理、CoreText 页面交互 |
|
||||
|
||||
## 方案讨论文档
|
||||
## 方案讨论与规划文档
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [阅读器功能开发计划.md](阅读器功能开发计划.md) | 渲染质量三方对比(ReadViewSDK vs WXRead)、功能开发计划与落地状态、优先级路线图 |
|
||||
| [架构对比分析_WXRead_vs_ReadViewSDK.md](架构对比分析_WXRead_vs_ReadViewSDK.md) | ReadViewSDK 与 WXRead 的逐项架构核查,确认主链路复刻完成度 |
|
||||
| [ReflowableEPUB_WXReadRenderer_Design.md](FeatureSolution/ReflowableEPUB_WXReadRenderer_Design.md) | 基于读书的 CoreText 渲染架构,设计 Reflowable EPUB 的增强文本渲染方案(CSS 分层、类型器升级) |
|
||||
|
||||
## 测试与质量
|
||||
|
||||
@ -1,7 +1,33 @@
|
||||
# 阅读器功能开发计划
|
||||
|
||||
基于 [阅读器规划.md](阅读器规划.md) 中的三方对比,本文档给出阅读器功能的开发计划与当前落地状态。
|
||||
功能完整度部分(书架、批注导出、阅读统计、TTS、全局搜索、云端同步、夜间定时)暂不展开。
|
||||
> 本文档整合了渲染质量三方对比(ReadViewSDK vs WXRead)与功能开发计划,作为阅读器能力演进的单一真值。
|
||||
> 基于读书 v10.0.3 逆向分析,与当前 ReadViewSDK 代码核查结果整合。
|
||||
|
||||
## 背景
|
||||
|
||||
渲染内核的架构对齐解决的是"代码可维护性"问题。本文档梳理从"能用的阅读器"到"能上架的商业阅读器"还需要补齐哪些能力,每项能力标注三方状态和具体实施计划。
|
||||
|
||||
### 三方对比总览(渲染质量)
|
||||
|
||||
| 缺失项 | 严重度 | ReadViewSDK | WXRead | 差距说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **竖排文字** | 高 | ❌ 未实现 | ❌ 未实现 | 双方均无 `writing-mode` 支持 |
|
||||
| **Ruby 注音** | 高 | ❌ 未实现 | ❌ 未实现 | 双方均无 `<ruby>`/`<rt>` 处理 |
|
||||
| **数学公式** | 中 | ❌ 未实现 | ⚠️ 仅字体回退 | 非原生渲染范畴,需 WebView 回退 |
|
||||
| **复杂图文分页质量** | 高 | ✅ 已实现 | ✅ 已实现 | 双方均已实现 |
|
||||
| **字体选择器** | 中 | ✅ 已实现 | ⚠️ 管线完备,UI 未见 | 已支持四档字体选择 |
|
||||
| **连字/断字** | 中 | ⚠️ 仅配置标记 | ⚠️ 仅属性声明 | 双方均未实际实现 |
|
||||
| **多语言排版回退** | 中 | ⚠️ 语言检测有,简繁转换无 | ✅ 已实现 | WXRead 额外有简繁转换 |
|
||||
|
||||
### ReadViewSDK 领先 WXRead 的项
|
||||
|
||||
| 项 | 说明 |
|
||||
| --- | --- |
|
||||
| **`keepWithNext`** | 已实现 `trimmedRangeForKeepWithNext`,WXRead 反编译代码中未找到对应实现 |
|
||||
|
||||
### 不需要对标 WXRead 的项
|
||||
|
||||
以下项 WXRead 自身也未实现,不属于必须补齐的能力:竖排文字、Ruby 注音、数学公式、Hyphenation 断字。
|
||||
|
||||
---
|
||||
|
||||
@ -536,3 +562,56 @@ ReadViewDemoUITests 全量 8 个 UI 测试: TEST SUCCEEDED
|
||||
- SDK 提供清晰的 DRM 接入协议
|
||||
- 不引入 DRM 依赖,保持 SDK 轻量
|
||||
- 调用方能通过协议接入自己的 DRM 方案
|
||||
|
||||
---
|
||||
|
||||
## 四、功能完整度(暂未展开)
|
||||
|
||||
以下功能需求已识别但暂未进入详细开发计划:
|
||||
|
||||
| 缺失项 | 严重度 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| **书架/书库管理** | 高 | SDK 只能打开单本书,无书架 UI、阅读历史、分类管理 |
|
||||
| **批注导出/分享** | 高 | 高亮/笔记只有本地存储,无导出、分享、复制到剪贴板 |
|
||||
| **阅读统计** | 中 | 无阅读时长追踪、阅读速度、连续阅读天数 |
|
||||
| **TTS 朗读** | 中 | 微信读书核心功能之一,当前无任何语音相关代码 |
|
||||
| **全局搜索** | 中 | 当前搜索只在单本书内,无跨书搜索 |
|
||||
| **离线/云端同步** | 高 | 无 iCloud/自建同步,阅读进度和笔记只在本地 |
|
||||
| **夜间模式定时切换** | 低 | 有暗色主题但不能跟随系统或定时切换 |
|
||||
|
||||
---
|
||||
|
||||
## 五、按优先级排序的建议路线
|
||||
|
||||
### P0 — 影响商业发布
|
||||
|
||||
1. **分页回归基准** — UI 自动化测试已启动,下一步需要把分页结果、首屏时间、截图差异纳入回归基准
|
||||
2. **字体选择器增强** — 基础字体选择器已实现,后续需补:更多内置字体包、字体预览、字体资源加载失败兜底
|
||||
3. **书架/书库管理** — 商业阅读器的入口
|
||||
4. **暗色模式图片处理增强** — 基础处理已实现,后续可补:按图片亮度自适应混合比例
|
||||
|
||||
### P1 — 影响用户留存
|
||||
|
||||
5. **批注导出/分享** — 深度阅读用户的核心需求
|
||||
6. **阅读统计/时长追踪** — 用户粘性和产品数据的基础
|
||||
7. **性能基线与大书优化** — 大书卡顿是用户流失的主要原因
|
||||
8. **简繁转换** — 面向港澳台用户需要
|
||||
|
||||
### P2 — 提升竞争力
|
||||
|
||||
9. **TTS 朗读** — 通勤场景、无障碍场景刚需
|
||||
10. **云端同步** — 多设备用户的基本需求
|
||||
11. **VoiceOver 完善** — 合规和品牌形象
|
||||
12. **全局搜索** — 藏书量大时的效率工具
|
||||
|
||||
---
|
||||
|
||||
## 六、总结
|
||||
|
||||
经过三方对比,渲染质量层面的真实差距比最初评估要小:
|
||||
|
||||
- **图文分页质量**:双方基本对齐,我们甚至在 `keepWithNext` 上领先
|
||||
- **真正的差距**:字体包数量、分页回归基准、简繁转换
|
||||
- **WXRead 也没做的**:竖排、ruby、公式、hyphenation——这些不是必须对齐的
|
||||
|
||||
如果目标是"能上架的商业阅读器",当前已补齐字体选择器、暗色模式图片处理和基础 UI 自动化测试。下一步最值得投入的是分页回归基准、更多字体资源和书架/书库管理。
|
||||
|
||||
162
Doc/阅读器规划.md
162
Doc/阅读器规划.md
@ -1,162 +0,0 @@
|
||||
# 阅读器规划:从架构重构到商业版的差距
|
||||
|
||||
## 背景
|
||||
|
||||
渲染内核的架构对齐(WXRead 拆分类重构)解决的是"代码可维护性"问题。
|
||||
本文档梳理从"能用的阅读器"到"能上架的商业阅读器"还需要补齐哪些能力。
|
||||
|
||||
每项能力标注了三方状态:当前 ReadViewSDK 实现情况、WXRead(微信读书)反编译源码中的实现情况。
|
||||
|
||||
## 一、渲染质量(最直接影响用户体验)
|
||||
|
||||
### 三方对比总览
|
||||
|
||||
| 缺失项 | 严重度 | ReadViewSDK | WXRead | 差距说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **竖排文字** | 高 | ❌ 未实现 | ❌ 未实现 | 双方均无 `writing-mode` 支持。WXRead 同样不支持,不是对标项 |
|
||||
| **Ruby 注音** | 高 | ❌ 未实现 | ❌ 未实现 | 双方均无 `<ruby>`/`<rt>` 处理。DTCoreText 限制,需 WebView 回退 |
|
||||
| **数学公式** | 中 | ❌ 未实现 | ⚠️ 仅字体回退 | 我们无任何支持。WXRead 打包 mathFonts.bundle 做数学符号字体回退,但无 MathML 解析 |
|
||||
| **复杂图文分页质量** | 高 | ✅ 已实现 | ✅ 已实现 | 双方均已实现,见下方详情对比 |
|
||||
| **字体选择器** | 中 | ✅ 已实现 | ⚠️ 管线完备,UI 未见 | 我们已支持系统/宋体/圆体/等宽四档字体选择、持久化、重新分页和缓存隔离。WXRead 有 15+ 内置字体,后续差距主要是字体包数量 |
|
||||
| **连字/断字** | 中 | ⚠️ 仅配置标记 | ⚠️ 仅属性声明 | 双方均未实际实现断字逻辑 |
|
||||
| **多语言排版回退** | 中 | ⚠️ 语言检测有,简繁转换无 | ✅ 已实现 | 我们有拉丁/CJK 分轨 CSS。WXRead 额外有简繁转换和更完善的字体回退 |
|
||||
|
||||
### 逐项详情
|
||||
|
||||
#### 1. 复杂图文分页质量
|
||||
|
||||
双方均已实现,核心能力对齐情况:
|
||||
|
||||
| 子项 | ReadViewSDK | WXRead |
|
||||
| --- | --- | --- |
|
||||
| `avoidPageBreakInside` | ✅ 反向扫描行,`kMaxLinesToRemove=3`,保护 table/code/list/blockquote | ✅ 同算法,`kMaxLinesToRemove=3`,保护同类元素 |
|
||||
| `pageBreakBefore` / `pageBreakAfter` | ✅ 语义标记注入 + 分页引擎消费 | ✅ U+2028 LINE SEPARATOR + DTPageBreakBefore/AfterAttribute |
|
||||
| `pageRelate` 跨页关联 | ✅ `weread-page-relate` 语义注入 | ✅ `weread-page-relate:true` CSS 属性 |
|
||||
| `keepWithNext` | ✅ `trimmedRangeForKeepWithNext` 实现 | ❌ 未实现(我们反而领先) |
|
||||
| 图片垂直居中 | ✅ `wr-vertical-center-style:2` + bodyPic 包装 | ✅ 同机制 |
|
||||
| 图片缩放 | ✅ 动态 `imageMaxHeightRatio=0.85` | ✅ 四档 full/half/third/quarter + 1080x1920 上限 |
|
||||
| 暗色模式图片处理 | ✅ 已实现 | ✅ 图片与主题背景色 85% 混合 |
|
||||
| 孤行/寡行控制 | ⚠️ 配置标记有,分页逻辑未引用 | ⚠️ `avoidOrphans`/`avoidWidows` 声明,实际机制未完全还原 |
|
||||
|
||||
**结论**:图文分页质量双方基本对齐,我们甚至在 `keepWithNext` 上领先。暗色模式图片处理已补齐,后续主要是继续扩充分页回归基准和更多真实书籍样本。
|
||||
|
||||
#### 2. 字体选择器
|
||||
|
||||
| 子项 | ReadViewSDK | WXRead |
|
||||
| --- | --- | --- |
|
||||
| 字号调节 | ✅ A-/A+ 按钮 | ✅ A-/A+ 按钮,12-36pt |
|
||||
| `fontFamily` 持久化 | ✅ `RDEPUBReaderFontChoice` 持久化 | ✅ NSUserDefaults `WRTypesetterFontFamily` |
|
||||
| CSS / 排版动态生成 | ✅ 渲染样式、分页缓存签名和重新分页均接入当前字体 | ✅ `_WRBuildUserSettingsCSS` 动态生成 `font-family` CSS |
|
||||
| 内置字体 | ⚠️ 系统/宋体/圆体/等宽四档 | ✅ 15+(思源宋体、方正兰亭黑、OpenDyslexic 等) |
|
||||
| 字体选择面板 UI | ✅ 设置面板分段控件 | ⚠️ 反编译代码中未见,可能在未包含的模块 |
|
||||
|
||||
**当前实现状态**:字体选择器已可用,切换字体会触发重新分页,并纳入分页缓存 key,避免不同字体复用旧分页。后续如果继续对标 WXRead,重点是引入更多内置字体包和字体预览样式。
|
||||
|
||||
#### 3. 多语言排版
|
||||
|
||||
| 子项 | ReadViewSDK | WXRead |
|
||||
| --- | --- | --- |
|
||||
| 拉丁/CJK 语言检测 | ✅ `prefersLatinLanguageCSS` | ✅ `isLatinLanguageBook` |
|
||||
| 分轨 CSS | ✅ `wxread-replace.css` / `wxread-replace-latin.css` | ✅ `replace.css` / `replaceForLatinLanguageBook.css` |
|
||||
| 简繁转换 | ❌ 无 | ✅ `CFStringTransform` Hans→Latin→Hant |
|
||||
| `lang` 属性处理 | ✅ 提取语言代码 | ✅ `DTHTMLElement.lang` |
|
||||
| `direction` (ltr/rtl) | ✅ `RDEPUBReadingProgression` | ✅ `DTHTMLElement.direction` |
|
||||
|
||||
#### 4. Hyphenation 断字
|
||||
|
||||
| 子项 | ReadViewSDK | WXRead |
|
||||
| --- | --- | --- |
|
||||
| 配置标记 | ✅ `hyphenation: Bool = true` | ✅ `BOOL hyphenation = YES` |
|
||||
| 实际断字逻辑 | ❌ 未应用 `NSParagraphStyle.hyphenationFactor` | ❌ 未使用,行分割仍用 `CTTypesetterSuggestLineBreak` |
|
||||
|
||||
**结论**:双方状态一致,都是声明了但未实现。
|
||||
|
||||
#### 5. 未实现且 WXRead 也未实现的项
|
||||
|
||||
| 项 | ReadViewSDK | WXRead | 建议 |
|
||||
| --- | --- | --- | --- |
|
||||
| **竖排文字** | ❌ | ❌ | 非核心需求,双方均未做 |
|
||||
| **Ruby 注音** | ❌ | ❌ | DTCoreText 限制,需 WebView 回退方案 |
|
||||
| **数学公式** | ❌ | ⚠️ 仅字体 | 非原生渲染范畴,需 WebView 回退 |
|
||||
|
||||
## 二、功能完整度
|
||||
|
||||
| 缺失项 | 严重度 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| **书架/书库管理** | 高 | SDK 只能打开单本书,无书架 UI、阅读历史、分类管理 |
|
||||
| **批注导出/分享** | 高 | 高亮/笔记只有本地存储,无导出、分享、复制到剪贴板 |
|
||||
| **阅读统计** | 中 | 无阅读时长追踪、阅读速度、连续阅读天数 |
|
||||
| **TTS 朗读** | 中 | 微信读书核心功能之一,当前无任何语音相关代码 |
|
||||
| **全局搜索** | 中 | 当前搜索只在单本书内,无跨书搜索 |
|
||||
| **离线/云端同步** | 高 | 无 iCloud/自建同步,阅读进度和笔记只在本地 |
|
||||
| **夜间模式定时切换** | 低 | 有暗色主题但不能跟随系统或定时切换 |
|
||||
|
||||
## 三、工程成熟度
|
||||
|
||||
| 缺失项 | 严重度 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| **自动化测试** | 高 | ✅ UI 自动化测试已启动并接入 Demo 流程,覆盖打开书籍、阅读器基础交互、工具栏、设置面板、字体选择、暗色主题切换等。仍缺单元测试和分页回归基准 |
|
||||
| **性能基线** | 高 | 有 `RDEPUBTextPerformanceSampler` 但无持续监控。大书(100MB+)首屏时间、内存峰值无基准 |
|
||||
| **崩溃防护** | 中 | 有 pageCurl 崩溃检测和异步恢复,但无全局异常捕获和上报 |
|
||||
| **内存管理** | 中 | 无显式内存预算,大书场景下无章节级内存释放策略 |
|
||||
| **增量构建** | 中 | 全书一次性分页,无章节级增量重建能力 |
|
||||
| **缓存管理** | 中 | UserDefaults 存储有限,无磁盘缓存大小控制和淘汰策略 |
|
||||
|
||||
## 四、可访问性与合规
|
||||
|
||||
| 缺失项 | 严重度 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| **VoiceOver** | 高 | 有 accessibility identifier 但无阅读内容的 VoiceOver 流程 |
|
||||
| **Dynamic Type** | 中 | 不响应系统字体大小设置 |
|
||||
| **高对比度** | 低 | 主题固定,不跟随系统 trait |
|
||||
| **DRM** | 看业务 | 需求文档已声明不在此范围,但商业分发通常需要 |
|
||||
|
||||
## 五、按优先级排序的建议路线
|
||||
|
||||
### P0 — 影响商业发布
|
||||
|
||||
1. **分页回归基准**(Phase 9 后续)— UI 自动化测试已启动,下一步需要把分页结果、首屏时间、截图差异纳入回归基准
|
||||
2. **字体选择器增强** — 基础字体选择器已实现。后续需补:更多内置字体包、字体预览、字体资源加载失败兜底
|
||||
3. **书架/书库管理** — 商业阅读器的入口,没有书架就没有产品形态
|
||||
4. **暗色模式图片处理增强** — 基础处理已实现。后续可补:按图片亮度自适应混合比例、真实书籍样本回归
|
||||
|
||||
### P1 — 影响用户留存
|
||||
|
||||
5. **批注导出/分享** — 深度阅读用户的核心需求
|
||||
6. **阅读统计/时长追踪** — 用户粘性和产品数据的基础
|
||||
7. **性能基线与大书优化** — 大书卡顿是用户流失的主要原因
|
||||
8. **简繁转换** — WXRead 有 `CFStringTransform` 简繁转换,面向港澳台用户需要
|
||||
|
||||
### P2 — 提升竞争力
|
||||
|
||||
9. **TTS 朗读** — 通勤场景、无障碍场景刚需
|
||||
10. **云端同步** — 多设备用户的基本需求
|
||||
11. **VoiceOver 完善** — 合规和品牌形象
|
||||
12. **全局搜索** — 藏书量大时的效率工具
|
||||
|
||||
### 不需要对标 WXRead 的项
|
||||
|
||||
以下项 WXRead 自身也未实现,不属于必须补齐的能力:
|
||||
|
||||
| 项 | ReadViewSDK | WXRead | 建议 |
|
||||
| --- | --- | --- | --- |
|
||||
| **竖排文字** | ❌ | ❌ | 非核心需求,可延后 |
|
||||
| **Ruby 注音** | ❌ | ❌ | DTCoreText 限制,需 WebView 回退方案 |
|
||||
| **数学公式** | ❌ | ⚠️ 仅字体 | 非原生渲染范畴,需 WebView 回退 |
|
||||
| **Hyphenation 断字** | ⚠️ 仅标记 | ⚠️ 仅标记 | 双方均未实现,中文场景影响小 |
|
||||
|
||||
### ReadViewSDK 领先 WXRead 的项
|
||||
|
||||
| 项 | 说明 |
|
||||
| --- | --- |
|
||||
| **`keepWithNext`** | 我们实现了 `trimmedRangeForKeepWithNext`,WXRead 反编译代码中未找到对应实现 |
|
||||
|
||||
## 六、总结
|
||||
|
||||
经过三方对比,渲染质量层面的真实差距比最初评估要小:
|
||||
|
||||
- **图文分页质量**:双方基本对齐,我们甚至在 `keepWithNext` 上领先
|
||||
- **真正的差距**:字体包数量、分页回归基准、简繁转换
|
||||
- **WXRead 也没做的**:竖排、ruby、公式、hyphenation——这些不是必须对齐的
|
||||
|
||||
如果目标是"能上架的商业阅读器",当前已补齐字体选择器、暗色模式图片处理和基础 UI 自动化测试。下一步最值得投入的是分页回归基准、更多字体资源和书架/书库管理。
|
||||
753
ReadViewDemo/ViewController.swift
Normal file
753
ReadViewDemo/ViewController.swift
Normal file
@ -0,0 +1,753 @@
|
||||
import UIKit
|
||||
import RDReaderView
|
||||
|
||||
final class ViewController: UIViewController {
|
||||
private struct DemoBook: Equatable {
|
||||
let title: String
|
||||
let detail: String
|
||||
let fileURL: URL
|
||||
}
|
||||
|
||||
private struct LaunchAutomationPlan {
|
||||
let bookTitleQuery: String
|
||||
let displayType: RDReaderView.DisplayType?
|
||||
let pageNumber: Int?
|
||||
let displaySequence: [RDReaderView.DisplayType]
|
||||
let stepDelay: TimeInterval
|
||||
|
||||
nonisolated private static func parseDisplayType(_ rawValue: String) -> RDReaderView.DisplayType? {
|
||||
switch rawValue.lowercased() {
|
||||
case "pagecurl", "curl":
|
||||
return .pageCurl
|
||||
case "horizontalscroll", "horizontal", "scroll":
|
||||
return .horizontalScroll
|
||||
case "verticalscroll", "vertical":
|
||||
return .verticalScroll
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func parse(arguments: [String]) -> LaunchAutomationPlan? {
|
||||
func value(after flag: String) -> String? {
|
||||
guard let index = arguments.firstIndex(of: flag), arguments.indices.contains(index + 1) else {
|
||||
return nil
|
||||
}
|
||||
return arguments[index + 1]
|
||||
}
|
||||
|
||||
guard let bookTitleQuery = value(after: "--demo-book-title"),
|
||||
!bookTitleQuery.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let displayType = value(after: "--demo-display-type").flatMap(parseDisplayType)
|
||||
let pageNumber = value(after: "--demo-page").flatMap(Int.init)
|
||||
let displaySequence = value(after: "--demo-display-sequence")
|
||||
.map { raw in
|
||||
raw.split(separator: ",").compactMap { parseDisplayType(String($0)) }
|
||||
} ?? []
|
||||
let stepDelay = value(after: "--demo-step-delay").flatMap(TimeInterval.init) ?? 1.0
|
||||
|
||||
return LaunchAutomationPlan(
|
||||
bookTitleQuery: bookTitleQuery,
|
||||
displayType: displayType,
|
||||
pageNumber: pageNumber,
|
||||
displaySequence: displaySequence,
|
||||
stepDelay: stepDelay
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private enum ValidationCategory: String, CaseIterable {
|
||||
case textNovel = "TXT/小说"
|
||||
case textRich = "复杂图文"
|
||||
case webFixed = "Fixed/互动"
|
||||
case plainText = "TXT"
|
||||
|
||||
var sortOrder: Int {
|
||||
switch self {
|
||||
case .textNovel: return 0
|
||||
case .textRich: return 1
|
||||
case .webFixed: return 2
|
||||
case .plainText: return 3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct BookValidationReport {
|
||||
let title: String
|
||||
let category: ValidationCategory
|
||||
let profile: String
|
||||
let passed: Bool
|
||||
let notes: [String]
|
||||
}
|
||||
|
||||
private struct ResourceValidationSummary {
|
||||
let checkedCount: Int
|
||||
let passedCount: Int
|
||||
let failedBooks: [String]
|
||||
let matrixLines: [String]
|
||||
let diagnosticLines: [String]
|
||||
let rerunHint: String
|
||||
|
||||
var statusText: String {
|
||||
var lines: [String] = []
|
||||
if checkedCount == 0 {
|
||||
lines.append("样本验证:未发现可验证样本")
|
||||
} else if failedBooks.isEmpty {
|
||||
lines.append("样本验证:\(passedCount)/\(checkedCount) 通过")
|
||||
} else {
|
||||
let titles = failedBooks.prefix(2).joined(separator: "、")
|
||||
lines.append("样本验证:\(passedCount)/\(checkedCount) 通过,失败样本:\(titles)")
|
||||
}
|
||||
lines.append(contentsOf: matrixLines.prefix(3))
|
||||
lines.append(contentsOf: prioritizedDiagnosticLines())
|
||||
if !rerunHint.isEmpty {
|
||||
lines.append(rerunHint)
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private func prioritizedDiagnosticLines() -> [String] {
|
||||
var selected: [String] = []
|
||||
if let semanticLine = diagnosticLines.first(where: { $0.contains("属性闭环诊断") }) {
|
||||
selected.append(semanticLine)
|
||||
}
|
||||
if let paginationLine = diagnosticLines.first(where: { $0.contains("分页诊断") && !selected.contains($0) }) {
|
||||
selected.append(paginationLine)
|
||||
}
|
||||
if selected.count < 2 {
|
||||
for line in diagnosticLines where !selected.contains(line) {
|
||||
selected.append(line)
|
||||
if selected.count == 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
}
|
||||
|
||||
private let statusLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.numberOfLines = 0
|
||||
label.textAlignment = .left
|
||||
label.textColor = .secondaryLabel
|
||||
label.font = .systemFont(ofSize: 15)
|
||||
label.text = "ReadViewSDK Demo"
|
||||
label.accessibilityIdentifier = "demo.status"
|
||||
return label
|
||||
}()
|
||||
|
||||
private let tableView: UITableView = {
|
||||
let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
||||
tableView.accessibilityIdentifier = "demo.books.table"
|
||||
tableView.rowHeight = 72
|
||||
tableView.tableFooterView = UIView()
|
||||
return tableView
|
||||
}()
|
||||
|
||||
private lazy var emptyStateLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.numberOfLines = 0
|
||||
label.textAlignment = .center
|
||||
label.textColor = .secondaryLabel
|
||||
label.font = .systemFont(ofSize: 15)
|
||||
label.text = "book 目录下暂时没有可阅读的 txt 或 epub 文件"
|
||||
label.accessibilityIdentifier = "demo.books.empty"
|
||||
return label
|
||||
}()
|
||||
|
||||
private var books: [DemoBook] = []
|
||||
private var validationSummary: ResourceValidationSummary?
|
||||
private var validationTask: Task<Void, Never>?
|
||||
private let launchAutomationPlan = LaunchAutomationPlan.parse(arguments: ProcessInfo.processInfo.arguments)
|
||||
private var didRunLaunchAutomation = false
|
||||
|
||||
deinit {
|
||||
validationTask?.cancel()
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
view.accessibilityIdentifier = "demo.root"
|
||||
title = "书库"
|
||||
setupViews()
|
||||
loadBooks()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
runLaunchAutomationIfNeeded()
|
||||
}
|
||||
|
||||
private func setupViews() {
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
|
||||
view.addSubview(statusLabel)
|
||||
view.addSubview(tableView)
|
||||
|
||||
statusLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
statusLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
|
||||
statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||||
statusLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
|
||||
|
||||
tableView.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 12),
|
||||
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
private func loadBooks() {
|
||||
books = discoverBooks()
|
||||
tableView.reloadData()
|
||||
updateChrome()
|
||||
validateSampleBooks()
|
||||
}
|
||||
|
||||
private func updateChrome() {
|
||||
if books.isEmpty {
|
||||
statusLabel.text = "ReadViewSDK Demo\nbook 目录下未找到 txt 或 epub 文件"
|
||||
tableView.backgroundView = emptyStateLabel
|
||||
} else {
|
||||
var lines = [
|
||||
"ReadViewSDK Demo",
|
||||
"已发现 \(books.count) 本本地图书,点击即可进入阅读器"
|
||||
]
|
||||
if let validationSummary {
|
||||
lines.append(validationSummary.statusText)
|
||||
}
|
||||
statusLabel.text = lines.joined(separator: "\n")
|
||||
tableView.backgroundView = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func discoverBooks() -> [DemoBook] {
|
||||
let fileManager = FileManager.default
|
||||
let bookDirectoryURLs = [
|
||||
Bundle.main.resourceURL?.appendingPathComponent("book", isDirectory: true),
|
||||
Bundle.main.resourceURL
|
||||
].compactMap { $0 }
|
||||
|
||||
let fileURLs = bookDirectoryURLs.flatMap { directoryURL in
|
||||
(try? fileManager.contentsOfDirectory(
|
||||
at: directoryURL,
|
||||
includingPropertiesForKeys: [.isRegularFileKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
)) ?? []
|
||||
}
|
||||
|
||||
return fileURLs
|
||||
.filter { url in
|
||||
let ext = url.pathExtension.lowercased()
|
||||
return ext == "epub" || ext == "txt"
|
||||
}
|
||||
.reduce(into: [URL]()) { result, url in
|
||||
if !result.contains(url) {
|
||||
result.append(url)
|
||||
}
|
||||
}
|
||||
.sorted { lhs, rhs in
|
||||
lhs.lastPathComponent.localizedStandardCompare(rhs.lastPathComponent) == .orderedAscending
|
||||
}
|
||||
.map { fileURL in
|
||||
let ext = fileURL.pathExtension.uppercased()
|
||||
let title = fileURL.deletingPathExtension().lastPathComponent
|
||||
return DemoBook(
|
||||
title: title,
|
||||
detail: ext.isEmpty ? fileURL.lastPathComponent : "\(ext) · \(fileURL.lastPathComponent)",
|
||||
fileURL: fileURL
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func validateSampleBooks() {
|
||||
validationTask?.cancel()
|
||||
guard !books.isEmpty else {
|
||||
validationSummary = nil
|
||||
updateChrome()
|
||||
return
|
||||
}
|
||||
|
||||
let pageSize = currentReaderPageSize()
|
||||
let style = currentReaderTextStyle()
|
||||
validationTask = Task.detached(priority: .utility) { [books] in
|
||||
let summary = Self.validateBooks(books, pageSize: pageSize, style: style)
|
||||
await MainActor.run {
|
||||
print("[ReadViewDemo] \(summary.statusText)")
|
||||
self.validationSummary = summary
|
||||
self.updateChrome()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func validateBooks(
|
||||
_ books: [DemoBook],
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) -> ResourceValidationSummary {
|
||||
var checkedCount = 0
|
||||
var passedCount = 0
|
||||
var failedBooks: [String] = []
|
||||
var reports: [BookValidationReport] = []
|
||||
var diagnosticLines: [String] = []
|
||||
|
||||
for book in books {
|
||||
let result = validateBook(book, pageSize: pageSize, style: style)
|
||||
checkedCount += 1
|
||||
if result.report.passed {
|
||||
passedCount += 1
|
||||
} else {
|
||||
failedBooks.append(book.title)
|
||||
}
|
||||
reports.append(result.report)
|
||||
diagnosticLines.append(contentsOf: result.diagnostics)
|
||||
}
|
||||
|
||||
return ResourceValidationSummary(
|
||||
checkedCount: checkedCount,
|
||||
passedCount: passedCount,
|
||||
failedBooks: failedBooks,
|
||||
matrixLines: makeMatrixLines(from: reports),
|
||||
diagnosticLines: diagnosticLines,
|
||||
rerunHint: "复现入口:启动 Demo 查看摘要,打开样本书后再验证搜索 / 高亮 / 主题字号切换"
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated private static func validateBook(
|
||||
_ book: DemoBook,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) -> (report: BookValidationReport, diagnostics: [String]) {
|
||||
let fileExtension = book.fileURL.pathExtension.lowercased()
|
||||
if fileExtension == "txt" {
|
||||
let builder = RDPlainTextBookBuilder()
|
||||
do {
|
||||
let textBook = try builder.build(textFileURL: book.fileURL, pageSize: pageSize, style: style)
|
||||
let passed = !textBook.pages.isEmpty && !textBook.chapters.isEmpty
|
||||
let report = BookValidationReport(
|
||||
title: book.title,
|
||||
category: .plainText,
|
||||
profile: "txt",
|
||||
passed: passed,
|
||||
notes: [
|
||||
"章节 \(textBook.chapters.count)",
|
||||
"页数 \(textBook.pages.count)"
|
||||
]
|
||||
)
|
||||
let diagnostics = [
|
||||
"TXT 验证:\(book.title) · chapters \(textBook.chapters.count) · pages \(textBook.pages.count)"
|
||||
]
|
||||
return (report, diagnostics)
|
||||
} catch {
|
||||
let report = BookValidationReport(
|
||||
title: book.title,
|
||||
category: .plainText,
|
||||
profile: "txt",
|
||||
passed: false,
|
||||
notes: [error.localizedDescription]
|
||||
)
|
||||
return (report, ["TXT 验证失败:\(book.title) · \(error.localizedDescription)"])
|
||||
}
|
||||
}
|
||||
|
||||
let parser = RDEPUBParser()
|
||||
do {
|
||||
try parser.parse(epubURL: book.fileURL)
|
||||
let publication = parser.makePublication()
|
||||
switch publication.readingProfile {
|
||||
case .textReflowable:
|
||||
return validateReflowableBook(
|
||||
book,
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
style: style
|
||||
)
|
||||
case .webFixedLayout, .webInteractive:
|
||||
return validateWebBook(
|
||||
book,
|
||||
publication: publication,
|
||||
viewportSize: pageSize
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
let report = BookValidationReport(
|
||||
title: book.title,
|
||||
category: .textRich,
|
||||
profile: "parse-failed",
|
||||
passed: false,
|
||||
notes: [error.localizedDescription]
|
||||
)
|
||||
return (report, ["EPUB 验证失败:\(book.title) · \(error.localizedDescription)"])
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func validateReflowableBook(
|
||||
_ book: DemoBook,
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) -> (report: BookValidationReport, diagnostics: [String]) {
|
||||
let builder = RDEPUBTextBookBuilder()
|
||||
do {
|
||||
let textBook = try builder.build(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
style: style
|
||||
)
|
||||
let missingResources = builder.lastBuildResourceDiagnostics.filter { !$0.existsOnDisk }
|
||||
let diagnostics = builder.lastBuildPaginationDiagnostics
|
||||
let attachmentPages = diagnostics.reduce(0) { $0 + $1.attachmentPageCount }
|
||||
let category: ValidationCategory = attachmentPages > 0 ? .textRich : .textNovel
|
||||
let passed = !textBook.pages.isEmpty && missingResources.isEmpty
|
||||
|
||||
var reportNotes = [
|
||||
"章节 \(textBook.chapters.count)",
|
||||
"页数 \(textBook.pages.count)"
|
||||
]
|
||||
if attachmentPages > 0 {
|
||||
reportNotes.append("attachment 页 \(attachmentPages)")
|
||||
}
|
||||
|
||||
var summaryLines: [String] = []
|
||||
if let paginationSummary = makePaginationSummary(diagnostics, title: book.title) {
|
||||
summaryLines.append("分页诊断:\(paginationSummary)")
|
||||
}
|
||||
if let semanticSummary = builder.phase7SemanticSummary(title: book.title) {
|
||||
summaryLines.append("属性闭环诊断:\(semanticSummary)")
|
||||
}
|
||||
if let restoreSummary = makeRestoreSummary(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
textBook: textBook,
|
||||
pageSize: pageSize,
|
||||
style: style,
|
||||
title: book.title
|
||||
) {
|
||||
summaryLines.append("恢复诊断:\(restoreSummary)")
|
||||
}
|
||||
|
||||
let report = BookValidationReport(
|
||||
title: book.title,
|
||||
category: category,
|
||||
profile: publication.readingProfile.rawValue,
|
||||
passed: passed,
|
||||
notes: reportNotes
|
||||
)
|
||||
return (report, summaryLines)
|
||||
} catch {
|
||||
let category: ValidationCategory = book.title.contains("凡人") ? .textNovel : .textRich
|
||||
let report = BookValidationReport(
|
||||
title: book.title,
|
||||
category: category,
|
||||
profile: publication.readingProfile.rawValue,
|
||||
passed: false,
|
||||
notes: [error.localizedDescription]
|
||||
)
|
||||
return (report, ["EPUB 验证失败:\(book.title) · \(error.localizedDescription)"])
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func validateWebBook(
|
||||
_ book: DemoBook,
|
||||
publication: RDEPUBPublication,
|
||||
viewportSize: CGSize
|
||||
) -> (report: BookValidationReport, diagnostics: [String]) {
|
||||
let linearItems = publication.spine.filter(\.linear)
|
||||
let missingFiles = linearItems.filter {
|
||||
guard let normalizedHref = publication.resourceResolver.normalizedHref($0.href) else {
|
||||
return true
|
||||
}
|
||||
return publication.resourceResolver.fileURL(forRelativePath: normalizedHref) == nil
|
||||
}
|
||||
let defaultConfiguration = RDEPUBReaderConfiguration.default
|
||||
let preferences = RDEPUBPreferences(
|
||||
fontSize: defaultConfiguration.fontSize,
|
||||
lineHeightMultiple: defaultConfiguration.lineHeightMultiple,
|
||||
reflowableContentInsets: defaultConfiguration.reflowableContentInsets,
|
||||
fixedContentInset: defaultConfiguration.fixedContentInset,
|
||||
fixedLayoutFit: defaultConfiguration.fixedLayoutFit,
|
||||
fixedLayoutSpreadMode: defaultConfiguration.fixedLayoutSpreadMode
|
||||
)
|
||||
let spreadCount = publication.layout == .fixed
|
||||
? publication.makeFixedSpreads(
|
||||
preferences: preferences,
|
||||
viewportSize: viewportSize
|
||||
).count
|
||||
: 0
|
||||
let passed = !linearItems.isEmpty && missingFiles.isEmpty && (publication.layout != .fixed || spreadCount > 0)
|
||||
let category: ValidationCategory = .webFixed
|
||||
let report = BookValidationReport(
|
||||
title: book.title,
|
||||
category: category,
|
||||
profile: publication.readingProfile.rawValue,
|
||||
passed: passed,
|
||||
notes: [
|
||||
"spine \(linearItems.count)",
|
||||
publication.layout == .fixed ? "spread \(spreadCount)" : "interactive"
|
||||
]
|
||||
)
|
||||
let diagnostic = "Web 路径验证:\(book.title) · profile \(publication.readingProfile.rawValue) · spine \(linearItems.count) · missing \(missingFiles.count)"
|
||||
return (report, [diagnostic])
|
||||
}
|
||||
|
||||
nonisolated private static func makeMatrixLines(from reports: [BookValidationReport]) -> [String] {
|
||||
let grouped = Dictionary(grouping: reports, by: \.category)
|
||||
return ValidationCategory.allCases.compactMap { category in
|
||||
guard let items = grouped[category], !items.isEmpty else { return nil }
|
||||
let passed = items.filter(\.passed).count
|
||||
let notes = items.prefix(2).map { "\($0.title)(\($0.profile))" }.joined(separator: "、")
|
||||
return "矩阵[\(category.rawValue)] \(passed)/\(items.count) · \(notes)"
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func makePaginationSummary(
|
||||
_ diagnostics: [RDEPUBTextChapterPaginationDiagnostic],
|
||||
title: String
|
||||
) -> String? {
|
||||
guard !diagnostics.isEmpty else { return nil }
|
||||
let attachmentPages = diagnostics.reduce(0) { $0 + $1.attachmentPageCount }
|
||||
let semanticBreakPages = diagnostics.reduce(0) { $0 + $1.blockAdjustedPageCount }
|
||||
let blockKinds = uniqueValues(diagnostics.flatMap(\.blockKinds))
|
||||
let semanticHints = uniqueValues(diagnostics.flatMap(\.semanticHints))
|
||||
let attachmentPlacements = uniqueValues(diagnostics.flatMap(\.attachmentPlacements))
|
||||
let breakReasonCounts = diagnostics
|
||||
.flatMap(\.breakReasons)
|
||||
.reduce(into: [RDEPUBTextPageBreakReason: Int]()) { counts, reason in
|
||||
counts[reason, default: 0] += 1
|
||||
}
|
||||
let orderedReasons = breakReasonCounts
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.value == rhs.value {
|
||||
return lhs.key.rawValue < rhs.key.rawValue
|
||||
}
|
||||
return lhs.value > rhs.value
|
||||
}
|
||||
.map { "\($0.key.rawValue):\($0.value)" }
|
||||
.joined(separator: ", ")
|
||||
let note = diagnostics
|
||||
.flatMap(\.sampleNotes)
|
||||
.first(where: { $0.contains("attachment") || $0.contains("block") || $0.contains("page break") })
|
||||
var parts = [
|
||||
"\(title)",
|
||||
"章节 \(diagnostics.count)",
|
||||
"attachment 页 \(attachmentPages)",
|
||||
"semantic break 页 \(semanticBreakPages)",
|
||||
blockKinds.isEmpty ? nil : "block kinds [\(blockKinds.map(\.rawValue).joined(separator: ","))]",
|
||||
semanticHints.isEmpty ? nil : "hints [\(semanticHints.map(\.rawValue).joined(separator: ","))]",
|
||||
attachmentPlacements.isEmpty ? nil : "placements [\(attachmentPlacements.map(\.rawValue).joined(separator: ","))]",
|
||||
orderedReasons.isEmpty ? nil : "reasons [\(orderedReasons)]"
|
||||
].compactMap { $0 }
|
||||
if let note {
|
||||
parts.append(note)
|
||||
}
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
nonisolated private static func makeRestoreSummary(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
textBook: RDEPUBTextBook,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
title: String
|
||||
) -> String? {
|
||||
guard textBook.pages.count > 1 else { return nil }
|
||||
let targetPageNumber = min(max(textBook.pages.count / 2, 1), textBook.pages.count)
|
||||
guard let restoreLocation = textBook.location(forPageNumber: targetPageNumber, bookIdentifier: publication.metadata.identifier),
|
||||
let baseResolvedPage = textBook.pageNumber(
|
||||
for: restoreLocation,
|
||||
resolver: publication.resourceResolver,
|
||||
bookIdentifier: publication.metadata.identifier
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let alternateFont = UIFont.systemFont(ofSize: style.font.pointSize + 2)
|
||||
let alternateLineSpacing = style.lineSpacing + max(alternateFont.lineHeight * 0.15, 2)
|
||||
let alternateStyle = RDEPUBTextRenderStyle(
|
||||
font: alternateFont,
|
||||
lineSpacing: alternateLineSpacing,
|
||||
textColor: style.textColor,
|
||||
backgroundColor: style.backgroundColor
|
||||
)
|
||||
let themeStyle = RDEPUBTextRenderStyle(
|
||||
font: style.font,
|
||||
lineSpacing: style.lineSpacing,
|
||||
textColor: .white,
|
||||
backgroundColor: .black
|
||||
)
|
||||
|
||||
let builder = RDEPUBTextBookBuilder()
|
||||
guard let alternateBook = try? builder.build(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
style: alternateStyle
|
||||
), let alternatePageNumber = alternateBook.pageNumber(
|
||||
for: restoreLocation,
|
||||
resolver: publication.resourceResolver,
|
||||
bookIdentifier: publication.metadata.identifier
|
||||
), let alternateResolvedLocation = alternateBook.location(
|
||||
forPageNumber: alternatePageNumber,
|
||||
bookIdentifier: publication.metadata.identifier
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let themeBuilder = RDEPUBTextBookBuilder()
|
||||
let themeBook = try? themeBuilder.build(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
style: themeStyle
|
||||
)
|
||||
let themePageNumber = themeBook?.pageNumber(
|
||||
for: restoreLocation,
|
||||
resolver: publication.resourceResolver,
|
||||
bookIdentifier: publication.metadata.identifier
|
||||
)
|
||||
|
||||
let hrefStable = (publication.resourceResolver.normalizedHref(alternateResolvedLocation.href) ?? alternateResolvedLocation.href) ==
|
||||
(publication.resourceResolver.normalizedHref(restoreLocation.href) ?? restoreLocation.href)
|
||||
let progressionDelta = abs(alternateResolvedLocation.navigationProgression - restoreLocation.navigationProgression)
|
||||
let themeStable = themePageNumber == baseResolvedPage
|
||||
|
||||
return [
|
||||
title,
|
||||
"base \(baseResolvedPage)",
|
||||
"font-shift \(alternatePageNumber)",
|
||||
"theme-stable \(themeStable ? "yes" : "no")",
|
||||
"href-stable \(hrefStable ? "yes" : "no")",
|
||||
String(format: "progression-delta %.3f", progressionDelta)
|
||||
].joined(separator: " · ")
|
||||
}
|
||||
|
||||
nonisolated private static func uniqueValues<T: Equatable>(_ values: [T]) -> [T] {
|
||||
values.reduce(into: [T]()) { result, value in
|
||||
if !result.contains(value) {
|
||||
result.append(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func openBook(
|
||||
_ book: DemoBook,
|
||||
configuration: RDEPUBReaderConfiguration = .default,
|
||||
automationPlan: LaunchAutomationPlan? = nil
|
||||
) -> RDURLReaderController {
|
||||
let controller = RDURLReaderController(bookURL: book.fileURL, epubConfiguration: configuration)
|
||||
controller.title = book.title
|
||||
|
||||
if let navigationController {
|
||||
navigationController.pushViewController(controller, animated: true)
|
||||
} else {
|
||||
let hostController = UINavigationController(rootViewController: controller)
|
||||
hostController.modalPresentationStyle = .fullScreen
|
||||
hostController.view.accessibilityIdentifier = "demo.reader.host"
|
||||
present(hostController, animated: true)
|
||||
}
|
||||
|
||||
if let automationPlan {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak controller] in
|
||||
if let displayType = automationPlan.displayType {
|
||||
controller?.applyDemoDisplayType(displayType)
|
||||
}
|
||||
if let pageNumber = automationPlan.pageNumber {
|
||||
_ = controller?.goToDemoPage(pageNumber)
|
||||
}
|
||||
if !automationPlan.displaySequence.isEmpty {
|
||||
controller?.runDemoDisplaySequence(
|
||||
automationPlan.displaySequence,
|
||||
initialPageNumber: nil,
|
||||
stepDelay: automationPlan.stepDelay
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
private func currentReaderPageSize() -> CGSize {
|
||||
let viewportSize = UIScreen.main.bounds.size
|
||||
let insets = RDEPUBReaderConfiguration.default.reflowableContentInsets
|
||||
return CGSize(
|
||||
width: max(viewportSize.width - insets.left - insets.right, 1),
|
||||
height: max(viewportSize.height - insets.top - insets.bottom, 1)
|
||||
)
|
||||
}
|
||||
|
||||
private func currentReaderTextStyle() -> RDEPUBTextRenderStyle {
|
||||
let configuration = RDEPUBReaderConfiguration.default
|
||||
let font = configuration.fontChoice.font(ofSize: configuration.fontSize)
|
||||
let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4)
|
||||
return RDEPUBTextRenderStyle(
|
||||
font: font,
|
||||
lineSpacing: lineSpacing,
|
||||
textColor: configuration.theme.contentTextColor,
|
||||
backgroundColor: configuration.theme.contentBackgroundColor
|
||||
)
|
||||
}
|
||||
|
||||
private func runLaunchAutomationIfNeeded() {
|
||||
guard !didRunLaunchAutomation,
|
||||
let launchAutomationPlan,
|
||||
!books.isEmpty else {
|
||||
return
|
||||
}
|
||||
didRunLaunchAutomation = true
|
||||
|
||||
guard let book = books.first(where: { $0.title.localizedCaseInsensitiveContains(launchAutomationPlan.bookTitleQuery) }) else {
|
||||
print("[ReadViewDemo] automation skipped: missing book matching \(launchAutomationPlan.bookTitleQuery)")
|
||||
return
|
||||
}
|
||||
|
||||
var configuration = RDEPUBReaderConfiguration.default
|
||||
if let displayType = launchAutomationPlan.displayType {
|
||||
configuration.displayType = displayType
|
||||
}
|
||||
_ = openBook(book, configuration: configuration, automationPlan: launchAutomationPlan)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ViewController: UITableViewDataSource {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
books.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cellIdentifier = "demo.book.cell"
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ??
|
||||
UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
|
||||
let book = books[indexPath.row]
|
||||
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
cell.textLabel?.text = book.title
|
||||
cell.textLabel?.numberOfLines = 2
|
||||
cell.detailTextLabel?.text = book.detail
|
||||
cell.detailTextLabel?.textColor = .secondaryLabel
|
||||
cell.accessibilityIdentifier = "demo.book.\(indexPath.row)"
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
extension ViewController: UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard books.indices.contains(indexPath.row) else { return }
|
||||
openBook(books[indexPath.row])
|
||||
}
|
||||
}
|
||||
@ -83,6 +83,10 @@ enum RDEPUBJavaScriptBridge {
|
||||
"""
|
||||
}
|
||||
|
||||
/// 生成固定版式模式下的主题应用脚本
|
||||
/// 仅应用共享主题(背景色),不涉及分页逻辑(固定版式由 HTML 自身布局决定)
|
||||
/// - Parameter request: 固定版式渲染请求,包含背景色等信息
|
||||
/// - Returns: 可直接通过 evaluateJavaScript 执行的脚本字符串
|
||||
static func applyFixedPresentationScript(for request: RDEPUBFixedRenderRequest) -> String {
|
||||
"""
|
||||
(function() {
|
||||
@ -113,6 +117,9 @@ enum RDEPUBJavaScriptBridge {
|
||||
"""
|
||||
}
|
||||
|
||||
/// 生成获取当前装饰数据的脚本
|
||||
/// 从 WebView 中读取高亮和搜索高亮的 DOM 装饰信息,用于同步原生标注状态
|
||||
/// - Returns: 可直接通过 evaluateJavaScript 执行的脚本字符串,返回包含 highlights 和 search 的对象
|
||||
static func resolveDecorationsScript() -> String {
|
||||
"""
|
||||
(function() {
|
||||
@ -178,6 +185,8 @@ enum RDEPUBJavaScriptBridge {
|
||||
return string
|
||||
}
|
||||
|
||||
/// 将 Swift 字符串转换为 JavaScript 字符串字面量
|
||||
/// nil 值转为 "null",非空值序列化为 JSON 字符串后去除外层方括号
|
||||
private static func javaScriptStringLiteral(_ value: String?) -> String {
|
||||
guard let value else { return "null" }
|
||||
return jsonString(from: [value], fallback: "[null]")
|
||||
|
||||
@ -13,6 +13,8 @@ public final class RDEPUBPublication {
|
||||
/// 资源解析器(URL 转换和路径规范化)
|
||||
public let resourceResolver: RDEPUBResourceResolver
|
||||
|
||||
/// 基于解析器创建出版物门面对象
|
||||
/// - Parameter parser: 已完成解析的 EPUB 解析器
|
||||
public init(parser: RDEPUBParser) {
|
||||
self.parser = parser
|
||||
self.resourceResolver = RDEPUBResourceResolver(parser: parser)
|
||||
|
||||
@ -8,6 +8,9 @@ import UIKit
|
||||
|
||||
/// 搜索引擎协议,定义全文搜索接口
|
||||
protocol RDEPUBSearchEngine {
|
||||
/// 执行全文搜索
|
||||
/// - Parameter keyword: 搜索关键词
|
||||
/// - Returns: 匹配结果列表
|
||||
func search(keyword: String) -> [RDEPUBSearchMatch]
|
||||
}
|
||||
|
||||
@ -19,6 +22,10 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
|
||||
/// EPUB 出版物(用于遍历 spine 和规范化 href)
|
||||
private let publication: RDEPUBPublication
|
||||
|
||||
/// 创建 HTML 全文搜索引擎
|
||||
/// - Parameters:
|
||||
/// - parser: EPUB 解析器(用于读取 HTML 内容)
|
||||
/// - publication: EPUB 出版物(用于遍历 spine 和规范化 href)
|
||||
init(parser: RDEPUBParser, publication: RDEPUBPublication) {
|
||||
self.parser = parser
|
||||
self.publication = publication
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import UIKit
|
||||
|
||||
/// EPUB 文本书籍构建器,负责将 EPUB publication 渲染、分页并组装为 `RDEPUBTextBook`。
|
||||
public final class RDEPUBTextBookBuilder {
|
||||
private let renderer: RDEPUBTextRenderer
|
||||
private let cache: RDEPUBTextBookCache?
|
||||
@ -19,6 +21,11 @@ public final class RDEPUBTextBookBuilder {
|
||||
/// 最后一次构建的缓存命中/未命中统计
|
||||
public private(set) var lastBuildCacheStats: (hits: Int, misses: Int) = (0, 0)
|
||||
|
||||
/// 创建书籍构建器。
|
||||
/// - Parameters:
|
||||
/// - renderer: 文本渲染器
|
||||
/// - cache: 分页缓存(可选)
|
||||
/// - layoutConfig: 页面布局配置
|
||||
public init(
|
||||
renderer: RDEPUBTextRenderer,
|
||||
cache: RDEPUBTextBookCache? = nil,
|
||||
|
||||
@ -181,13 +181,3 @@ public struct RDEPUBTextBook {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 分页书籍构建器
|
||||
|
||||
/// 从 EPUB publication 构建分页书籍模型的核心构建器。
|
||||
///
|
||||
/// 渲染链路:遍历 spine → 渲染每章 HTML → 分页 → 合并/规范化尾页 → 构建 RDEPUBTextBook
|
||||
///
|
||||
/// 特性:
|
||||
/// - 支持分页缓存(WXRead 模式:只缓存页范围,不缓存富文本)
|
||||
/// - 性能采样(记录每章渲染/分页耗时)
|
||||
/// - 尾页规范化(丢弃纯空白尾页、合并过短尾页)
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
// RDEPUBTextBuildPipelineInterfaces.swift
|
||||
// EPUB 文本构建管线接口定义,包括全书构建、章节渲染与分页管线。
|
||||
|
||||
import UIKit
|
||||
|
||||
/// EPUB 全书文本构建接口,将 EPUB 出版物解析为可分页的文本书籍。
|
||||
protocol RDEPUBTextBookBuilding {
|
||||
func build(
|
||||
parser: RDEPUBParser,
|
||||
@ -9,25 +13,41 @@ protocol RDEPUBTextBookBuilding {
|
||||
) throws -> RDEPUBTextBook
|
||||
}
|
||||
|
||||
/// 章节渲染管线,将章节渲染请求委托给底层文本渲染器。
|
||||
struct RDEPUBChapterRenderPipeline {
|
||||
private let renderer: RDEPUBTextRenderer
|
||||
|
||||
/// 初始化渲染管线。
|
||||
/// - Parameter renderer: 文本渲染器实例
|
||||
init(renderer: RDEPUBTextRenderer) {
|
||||
self.renderer = renderer
|
||||
}
|
||||
|
||||
/// 渲染单个章节,返回渲染后的富文本内容。
|
||||
/// - Parameter request: 章节渲染请求
|
||||
/// - Returns: 渲染完成的章节内容
|
||||
func render(_ request: RDEPUBTextChapterRenderRequest) throws -> RDEPUBRenderedChapterContent {
|
||||
try renderer.renderChapter(request: request)
|
||||
}
|
||||
}
|
||||
|
||||
/// 章节分页管线,将渲染后的富文本按页面尺寸拆分为文本帧数组。
|
||||
struct RDEPUBChapterPaginationPipeline {
|
||||
private let frameFactory: RDEPUBPageFrameBuilding
|
||||
|
||||
/// 初始化分页管线。
|
||||
/// - Parameter frameFactory: 页面帧工厂,默认使用 CoreText 实现
|
||||
init(frameFactory: RDEPUBPageFrameBuilding = RDEPUBCoreTextPageFrameFactory()) {
|
||||
self.frameFactory = frameFactory
|
||||
}
|
||||
|
||||
/// 将富文本内容分页,返回页面帧数组。
|
||||
/// - Parameters:
|
||||
/// - content: 待分页的富文本
|
||||
/// - pageSize: 页面尺寸
|
||||
/// - config: 排版配置
|
||||
/// - fragmentOffsets: fragment 锚点偏移映射
|
||||
/// - Returns: 页面帧数组
|
||||
func frames(
|
||||
for content: NSAttributedString,
|
||||
pageSize: CGSize,
|
||||
|
||||
@ -1,15 +1,30 @@
|
||||
// RDEPUBTextPaginationInterfaces.swift
|
||||
// EPUB 分页接口定义,包括断页决策、章节数页和页面帧构建协议。
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
// MARK: - 分页接口定义
|
||||
|
||||
/// 单次断页决策,记录断页位置、原因及诊断信息。
|
||||
struct RDEPUBPageBreakDecision {
|
||||
/// 本页在富文本中的字符范围
|
||||
var range: NSRange
|
||||
/// 断页原因
|
||||
var reason: RDEPUBTextPageBreakReason
|
||||
/// 分页过程中的诊断日志
|
||||
var diagnostics: [String]
|
||||
}
|
||||
|
||||
/// 章节页数计算接口,将富文本按页面尺寸拆分为断页决策序列。
|
||||
protocol RDEPUBChapterPageCounting {
|
||||
/// 计算章节中每页的断页位置。
|
||||
/// - Parameters:
|
||||
/// - attributedString: 章节富文本
|
||||
/// - pageSize: 页面尺寸
|
||||
/// - config: 排版配置
|
||||
/// - fragmentOffsets: fragment 锚点偏移映射
|
||||
/// - Returns: 断页决策数组,每个元素对应一页
|
||||
func pageRanges(
|
||||
for attributedString: NSAttributedString,
|
||||
pageSize: CGSize,
|
||||
@ -18,7 +33,15 @@ protocol RDEPUBChapterPageCounting {
|
||||
) -> [RDEPUBPageBreakDecision]
|
||||
}
|
||||
|
||||
/// 页面帧构建接口,将富文本转换为可渲染的页面帧数组。
|
||||
protocol RDEPUBPageFrameBuilding {
|
||||
/// 将富文本构建为页面帧数组。
|
||||
/// - Parameters:
|
||||
/// - attributedString: 待分页的富文本
|
||||
/// - pageSize: 页面尺寸
|
||||
/// - config: 排版配置
|
||||
/// - fragmentOffsets: fragment 锚点偏移映射
|
||||
/// - Returns: 页面帧数组
|
||||
func makeFrames(
|
||||
attributedString: NSAttributedString,
|
||||
pageSize: CGSize,
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
// RDEPUBTextPositionConverter.swift
|
||||
// EPUB 全书文本位置转换器,在多种坐标体系之间进行双向映射。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 对标 WXRead `WREpubPositionConverter` 的全书位置转换器。
|
||||
@ -8,44 +11,74 @@ import Foundation
|
||||
/// - 全书字符偏移
|
||||
/// - 页码 / `RDEPUBLocation`
|
||||
public struct RDEPUBTextPositionConverter {
|
||||
/// 当前转换器所关联的 EPUB 文本对象。
|
||||
public let book: RDEPUBTextBook
|
||||
|
||||
/// 创建指定 EPUB 文本对象的位置转换器。
|
||||
/// - Parameter book: 要关联的 EPUB 文本对象。
|
||||
public init(book: RDEPUBTextBook) {
|
||||
self.book = book
|
||||
}
|
||||
|
||||
/// 全书字符总数(跨所有文件累计)。
|
||||
public var totalCharacterCount: Int {
|
||||
book.indexTable.totalCharacterCount
|
||||
}
|
||||
|
||||
/// 将语义锚点转换为全书字符偏移。
|
||||
/// - Parameter anchor: 由文件索引、行号、列号组成的语义锚点。
|
||||
/// - Returns: 对应的全书字符偏移量。
|
||||
public func globalIndex(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
book.indexTable.globalIndex(for: anchor)
|
||||
}
|
||||
|
||||
/// 将语义范围锚点转换为全书字符范围。
|
||||
/// - Parameter rangeAnchor: 由起止锚点组成的语义范围。
|
||||
/// - Returns: 对应的全书字符范围(`NSRange`)。
|
||||
public func globalRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
||||
book.indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
/// 根据全书字符偏移查找所属文件的索引。
|
||||
/// - Parameter index: 全书字符偏移量。
|
||||
/// - Returns: 对应的文件索引,超出范围时返回 `nil`。
|
||||
public func fileIndex(forCharacterPosition index: Int) -> Int? {
|
||||
book.indexTable.fileIndex(forCharacterPosition: index)
|
||||
}
|
||||
|
||||
/// 将全书字符偏移转换为指定文件内的本地偏移。
|
||||
/// - Parameters:
|
||||
/// - fileIndex: 目标文件索引。
|
||||
/// - index: 全书字符偏移量。
|
||||
/// - Returns: 文件内的本地字符偏移,越界时返回 `nil`。
|
||||
public func localOffsetInFile(at fileIndex: Int, forGlobalPosition index: Int) -> Int? {
|
||||
book.indexTable.localOffsetInFile(at: fileIndex, forGlobalPosition: index)
|
||||
}
|
||||
|
||||
/// 将全书字符偏移转换为语义锚点。
|
||||
/// - Parameter index: 全书字符偏移量。
|
||||
/// - Returns: 对应的语义锚点,无效位置时返回 `nil`。
|
||||
public func anchor(forCharacterPosition index: Int) -> RDEPUBTextAnchor? {
|
||||
book.indexTable.anchor(forGlobalIndex: index)
|
||||
}
|
||||
|
||||
/// 将 `RDEPUBLocation` 转换为语义锚点。
|
||||
/// - Parameter location: EPUB 位置对象。
|
||||
/// - Returns: 对应的语义锚点,无法映射时返回 `nil`。
|
||||
public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? {
|
||||
book.indexTable.anchor(for: location)
|
||||
}
|
||||
|
||||
/// 根据语义锚点获取页码(从 1 开始)。
|
||||
/// - Parameter anchor: 由文件索引、行号、列号组成的语义锚点。
|
||||
/// - Returns: 对应的页码,无法定位时返回 `nil`。
|
||||
public func pageNumber(for anchor: RDEPUBTextAnchor) -> Int? {
|
||||
book.indexTable.pageNumber(for: anchor, in: book).map { $0 + 1 }
|
||||
}
|
||||
|
||||
/// 根据全书字符偏移获取页码(从 1 开始)。
|
||||
/// - Parameter index: 全书字符偏移量。
|
||||
/// - Returns: 对应的页码,无效位置时返回 `nil`。
|
||||
public func pageNumber(forCharacterPosition index: Int) -> Int? {
|
||||
guard let anchor = anchor(forCharacterPosition: index) else {
|
||||
return nil
|
||||
@ -53,6 +86,11 @@ public struct RDEPUBTextPositionConverter {
|
||||
return pageNumber(for: anchor)
|
||||
}
|
||||
|
||||
/// 将语义锚点转换为可序列化的 `RDEPUBLocation`。
|
||||
/// - Parameters:
|
||||
/// - anchor: 由文件索引、行号、列号组成的语义锚点。
|
||||
/// - bookIdentifier: 书籍标识符,会写入 location 的 `bookId` 字段。
|
||||
/// - Returns: 对应的 `RDEPUBLocation`,无法映射时返回 `nil`。
|
||||
public func location(
|
||||
for anchor: RDEPUBTextAnchor,
|
||||
bookIdentifier: String?
|
||||
@ -63,6 +101,11 @@ public struct RDEPUBTextPositionConverter {
|
||||
return book.indexTable.location(for: anchor, in: chapter, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
|
||||
/// 将语义范围锚点转换为可序列化的 `RDEPUBLocation`。
|
||||
/// - Parameters:
|
||||
/// - rangeAnchor: 由起止锚点组成的语义范围。
|
||||
/// - bookIdentifier: 书籍标识符,会写入 location 的 `bookId` 字段。
|
||||
/// - Returns: 对应的 `RDEPUBLocation`,无法映射时返回 `nil`。
|
||||
public func location(
|
||||
for rangeAnchor: RDEPUBTextRangeAnchor,
|
||||
bookIdentifier: String?
|
||||
|
||||
@ -4,6 +4,7 @@ import UIKit
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
/// 附件布局规范化器,负责缩放脚注、封面和通用图片的显示尺寸。
|
||||
struct RDEPUBAttachmentNormalizer {
|
||||
/// 脚注附件日志已输出标记(避免重复日志)
|
||||
private static var didLogFootnoteAttachment = false
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import UIKit
|
||||
import CoreText
|
||||
|
||||
/// 字体规范化器,负责注册 EPUB 嵌入字体并映射到用户设置字体。
|
||||
struct RDEPUBFontNormalizer {
|
||||
/// 已注册字体资源,避免重复调用 CTFontManager。
|
||||
private static var registeredFontPaths = Set<String>()
|
||||
@ -52,6 +53,8 @@ struct RDEPUBFontNormalizer {
|
||||
}
|
||||
}
|
||||
|
||||
/// 若字体尚未注册,则通过 CTFontManager 注册。
|
||||
/// - Parameter fileURL: 字体文件 URL
|
||||
static func registerFontIfNeeded(at fileURL: URL) {
|
||||
let standardizedPath = fileURL.standardizedFileURL.path
|
||||
guard !registeredFontPaths.contains(standardizedPath) else { return }
|
||||
|
||||
@ -1,10 +1,18 @@
|
||||
/// RDEPUBFragmentMarkerInjector - Fragment 标记注入与偏移量提取
|
||||
import Foundation
|
||||
|
||||
/// Fragment 标记注入器,负责在 HTML 中注入 fragment 锚点标记并从渲染结果中提取偏移量。
|
||||
///
|
||||
/// 工作流程:
|
||||
/// 1. `process` 阶段:将 HTML 中的 `id` 属性元素转为 `${id=xxx}` 文本标记
|
||||
/// 2. `extractOffsets` 阶段:从 NSAttributedString 中扫描标记,记录偏移量后删除标记
|
||||
struct RDEPUBFragmentMarkerInjector: RDEPUBTypesettingStage {
|
||||
/// 将 HTML 中的 id 元素注入 fragment 文本标记,供后续偏移量提取使用。
|
||||
func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
|
||||
Self.injectFragmentMarkers(into: html)
|
||||
}
|
||||
|
||||
/// 从渲染后的富文本中扫描 `${id=xxx}` 标记,提取 fragment ID 到字符偏移量的映射。
|
||||
func extractOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] {
|
||||
Self.extractFragmentOffsets(from: attributedString)
|
||||
}
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
/// HTML 规范化器,清理冗余字符并规范化附件 HTML 标记。
|
||||
struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
|
||||
/// 执行 HTML 规范化处理。
|
||||
/// - Parameters:
|
||||
/// - html: 原始 HTML
|
||||
/// - context: 排版上下文
|
||||
/// - Returns: 规范化后的 HTML
|
||||
func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
|
||||
Self.normalizeHTML(html)
|
||||
}
|
||||
|
||||
@ -1,11 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
/// 渲染诊断收集器,扫描 HTML 中的资源引用并生成诊断信息。
|
||||
struct RDEPUBRenderDiagnosticsCollector {
|
||||
/// 外部样式表链接正则
|
||||
private static let stylesheetLinkPattern = #"<link\b[^>]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"#
|
||||
/// 图片源地址正则
|
||||
private static let imageSourcePattern = #"<img\b[^>]*src\s*=\s*["']([^"']+)["'][^>]*>"#
|
||||
|
||||
/// 收集 HTML 中图片资源的引用诊断。
|
||||
/// - Parameters:
|
||||
/// - html: 待扫描的 HTML
|
||||
/// - input: 排版输入上下文
|
||||
/// - Returns: 资源引用诊断列表
|
||||
func collect(
|
||||
in html: String,
|
||||
input: RDEPUBTypesettingInput
|
||||
|
||||
@ -1,14 +1,22 @@
|
||||
/// RDEPUBSemanticMarkerInjector - 分页语义标记注入与应用
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// 语义标记注入器,为 HTML 标签注入分页语义信息(块级类型、分页提示、附件放置方式等)。
|
||||
///
|
||||
/// 分两阶段工作:
|
||||
/// 1. HTML 阶段(`process`):解析标签并注入 `${rd-sem-start/end}` 语义标记
|
||||
/// 2. 渲染后阶段(`apply`):将标记解析为 NSAttributedString 属性
|
||||
struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
|
||||
/// 语义标记正则(${rd-sem-start:...} / ${rd-sem-end:...})
|
||||
private static let semanticMarkerPattern = #"\$\{rd-sem-(start|end):([^}]+)\}"#
|
||||
|
||||
/// 为 HTML 标签注入 `${rd-sem-start/end}` 语义标记,标记块级元素类型和分页提示。
|
||||
func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
|
||||
Self.injectPaginationSemanticMarkers(into: html)
|
||||
}
|
||||
|
||||
/// 解析渲染后富文本中的语义标记,将其转为 NSAttributedString 属性并删除标记文本。
|
||||
func apply(to attributedString: NSMutableAttributedString) {
|
||||
Self.applyPaginationSemantics(in: attributedString)
|
||||
}
|
||||
@ -323,6 +331,7 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
|
||||
|
||||
// MARK: - 内部类型
|
||||
|
||||
/// 单个 HTML 元素的分页语义信息,由标签名和属性推断而来。
|
||||
struct RDPaginationSemantics {
|
||||
var id: String
|
||||
var blockKind: RDEPUBTextBlockKind?
|
||||
|
||||
@ -1,13 +1,24 @@
|
||||
import UIKit
|
||||
|
||||
/// 样式表组合结果,包含合成后的 HTML、CSS 层列表及诊断信息。
|
||||
struct RDEPUBStyleSheetComposition {
|
||||
/// 合成后的 HTML 文本
|
||||
var html: String
|
||||
/// 五层 CSS 样式层(default/replace/dark/epub/user)
|
||||
var layers: [RDEPUBTextStyleSheetLayer]
|
||||
/// 内联后的 CSS 内容
|
||||
var inlinedCSS: String
|
||||
/// 资源引用诊断列表
|
||||
var diagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||||
}
|
||||
|
||||
/// EPUB 样式表合成器,负责组装五层 CSS 并注入 HTML。
|
||||
struct RDEPUBStyleSheetComposer {
|
||||
/// 将 CSS 层注入 HTML,返回合成结果。
|
||||
/// - Parameters:
|
||||
/// - html: 原始 HTML
|
||||
/// - input: 排版输入上下文
|
||||
/// - Returns: 包含合成 HTML、CSS 层和诊断信息的结果
|
||||
func compose(html: String, input: RDEPUBTypesettingInput) -> RDEPUBStyleSheetComposition {
|
||||
let stylesheetHrefReplacements = RDEPUBRenderDiagnosticsCollector.inlineLinkedStyleSheets(
|
||||
in: html,
|
||||
@ -84,8 +95,11 @@ struct RDEPUBStyleSheetComposer {
|
||||
|
||||
// MARK: - Style 注入
|
||||
|
||||
/// `<style>` 标签的注入位置。
|
||||
enum StyleInjectionPosition {
|
||||
/// 注入到 `<head>` 标签之后
|
||||
case headStart
|
||||
/// 注入到 `</head>` 标签之前
|
||||
case headEnd
|
||||
}
|
||||
|
||||
|
||||
@ -1,27 +1,50 @@
|
||||
import UIKit
|
||||
|
||||
/// 排版输入参数,包含章节 HTML、样式和布局配置。
|
||||
struct RDEPUBTypesettingInput {
|
||||
/// 章节相对路径
|
||||
var href: String
|
||||
/// 章节标题
|
||||
var title: String
|
||||
/// 原始 HTML 内容
|
||||
var rawHTML: String
|
||||
/// HTML 基础 URL,用于解析相对路径
|
||||
var baseURL: URL?
|
||||
/// 渲染样式
|
||||
var style: RDEPUBTextRenderStyle
|
||||
/// 资源解析器
|
||||
var resourceResolver: RDEPUBResourceResolver?
|
||||
/// 内容语言代码(如 zh-CN)
|
||||
var contentLanguageCode: String?
|
||||
/// 页面尺寸
|
||||
var pageSize: CGSize?
|
||||
/// 页面布局配置
|
||||
var layoutConfig: RDEPUBTextLayoutConfig?
|
||||
}
|
||||
|
||||
/// 排版输出结果,包含渲染请求和诊断信息。
|
||||
struct RDEPUBTypesettingOutput {
|
||||
/// 章节渲染请求
|
||||
var request: RDEPUBTextChapterRenderRequest
|
||||
/// 资源引用诊断列表
|
||||
var diagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||||
}
|
||||
|
||||
/// 排版阶段协议,定义 HTML 处理管线中的单个步骤。
|
||||
protocol RDEPUBTypesettingStage {
|
||||
/// 处理 HTML 并返回转换后的结果。
|
||||
/// - Parameters:
|
||||
/// - html: 输入 HTML
|
||||
/// - context: 排版上下文
|
||||
/// - Returns: 处理后的 HTML
|
||||
func process(_ html: String, context: RDEPUBTypesettingInput) -> String
|
||||
}
|
||||
|
||||
/// 排版管线,依次执行 HTML 规范化、语义标记、样式合成、字体注册和诊断收集。
|
||||
struct RDEPUBTextTypesetterPipeline {
|
||||
/// 从排版输入构建渲染请求。
|
||||
/// - Parameter input: 排版输入参数
|
||||
/// - Returns: 包含渲染请求和诊断信息的输出
|
||||
func makeRequest(from input: RDEPUBTypesettingInput) -> RDEPUBTypesettingOutput {
|
||||
let htmlNormalizer = RDEPUBHTMLNormalizer()
|
||||
let semanticMarkerInjector = RDEPUBSemanticMarkerInjector()
|
||||
|
||||
@ -1,8 +1,19 @@
|
||||
// RDEPUBReaderController+ContentDelegates.swift
|
||||
// EPUB 阅读器内容视图代理实现
|
||||
// 处理 Web 渲染路径(RDEPUBWebContentViewDelegate)和 Native Text 渲染路径
|
||||
// (RDEPUBTextContentViewDelegate)的位置更新、选区变化、链接跳转、错误日志等事件回调。
|
||||
|
||||
import UIKit
|
||||
|
||||
/// RDEPUBReaderController 内容代理扩展
|
||||
///
|
||||
/// 本文件实现 EPUB 阅读器的内容视图代理,处理 Web 渲染路径和 Native Text 渲染路径的
|
||||
/// 位置更新、选区变化、链接跳转、错误日志等事件回调。
|
||||
|
||||
// MARK: - Web 内容视图代理(EPUB 固定布局/Web 渲染路径)
|
||||
|
||||
extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
|
||||
/// Web 内容视图位置更新回调,同步阅读上下文与持久化位置
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) {
|
||||
guard readerView.currentPage >= 0,
|
||||
activePages.indices.contains(readerView.currentPage) else {
|
||||
@ -24,6 +35,7 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
|
||||
)
|
||||
}
|
||||
|
||||
/// Web 内容视图选区变化回调,转换为统一选区模型
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) {
|
||||
if let selection {
|
||||
updateCurrentSelection(scopedSelection(selection, relativeToSpineIndex: spineIndex))
|
||||
@ -32,10 +44,12 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
/// Web 内容视图选区菜单操作回调
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {
|
||||
handleSelectionMenuAction(action, selection: currentSelection)
|
||||
}
|
||||
|
||||
/// Web 内容视图内部链接点击回调,执行页内跳转
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) {
|
||||
guard let readingSession,
|
||||
let pageNumber = readingSession.queueNavigation(
|
||||
@ -48,11 +62,13 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
|
||||
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: true)
|
||||
}
|
||||
|
||||
/// Web 内容视图外部链接点击回调,使用系统浏览器打开
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) {
|
||||
delegate?.epubReader(self, didActivateExternalLink: url)
|
||||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||||
}
|
||||
|
||||
/// Web 内容视图 JavaScript 错误日志回调
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) {
|
||||
print("EPUB JS Error: \(message)")
|
||||
}
|
||||
@ -61,6 +77,7 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
|
||||
// MARK: - 文本内容视图代理(Native Text 渲染路径)
|
||||
|
||||
extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
/// 文本内容视图选区变化回调,标准化后更新当前选区
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) {
|
||||
guard let selection else {
|
||||
updateCurrentSelection(nil)
|
||||
@ -69,6 +86,7 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
updateCurrentSelection(normalizedTextSelection(selection))
|
||||
}
|
||||
|
||||
/// 文本内容视图选区菜单操作回调
|
||||
func textContentView(
|
||||
_ contentView: RDEPUBTextContentView,
|
||||
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
|
||||
@ -79,6 +97,7 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
contentView.clearSelection()
|
||||
}
|
||||
|
||||
/// 标准化文本选区的偏移范围,确保在有效内容长度内
|
||||
private func normalizedTextSelection(_ selection: RDEPUBSelection) -> RDEPUBSelection? {
|
||||
guard let textBook,
|
||||
let chapterData = textBook.chapterData(for: selection.location.href) else {
|
||||
@ -103,6 +122,7 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据 EPUB 位置计算对应的页码
|
||||
func pageNumber(for location: RDEPUBLocation) -> Int? {
|
||||
if let textBook, let publication {
|
||||
if let anchor = location.rangeAnchor?.start {
|
||||
@ -130,6 +150,7 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据页码解析对应的文本位置
|
||||
func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? {
|
||||
guard let textBook,
|
||||
let publication,
|
||||
@ -148,6 +169,7 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
) ?? location
|
||||
}
|
||||
|
||||
/// 同步文本阅读状态到阅读会话(页码、位置、spine、章节等)
|
||||
func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) {
|
||||
guard let textBook,
|
||||
let page = textBook.page(at: pageNumber) else {
|
||||
@ -164,6 +186,7 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
)
|
||||
}
|
||||
|
||||
/// 从文本书籍生成原生文本快照(页面列表 + 章节信息)
|
||||
func nativeTextSnapshot(from textBook: RDEPUBTextBook) -> RDEPUBNativeTextSnapshot {
|
||||
let chapters = textBook.chapterInfos
|
||||
let pages = textBook.pages.map {
|
||||
|
||||
@ -1,12 +1,24 @@
|
||||
// RDEPUBReaderController+DataSource.swift
|
||||
// EPUB 阅读器数据源与代理实现
|
||||
// 实现 RDReaderDataSource 和 RDReaderDelegate 协议,为阅读器视图提供页面数量、
|
||||
// 页面内容视图、页码变化通知、屏幕方向变化处理等核心数据与事件支持。
|
||||
|
||||
import UIKit
|
||||
|
||||
/// RDEPUBReaderController 数据源与代理扩展
|
||||
///
|
||||
/// 本文件实现 RDReaderDataSource 和 RDReaderDelegate 协议,为阅读器视图提供页面数量、
|
||||
/// 页面内容视图、页码变化通知、屏幕方向变化处理等核心数据与事件支持。
|
||||
|
||||
// MARK: - RDReaderView 数据源与代理
|
||||
|
||||
extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
/// 返回阅读器总页数
|
||||
public func pageCountOfReaderView(readerView: RDReaderView) -> Int {
|
||||
textBook?.pages.count ?? activePages.count
|
||||
}
|
||||
|
||||
/// 为指定页码创建或复用内容视图(优先文本渲染,回退 Web 渲染)
|
||||
public func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView {
|
||||
if let textBook, let page = textBook.page(at: pageNum + 1) {
|
||||
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
|
||||
@ -39,12 +51,14 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
return contentView
|
||||
}
|
||||
|
||||
/// 返回页面内容视图的重用标识符(区分文本与 Web 渲染)
|
||||
public func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? {
|
||||
textBook == nil
|
||||
? NSStringFromClass(RDEPUBWebContentView.self)
|
||||
: NSStringFromClass(RDEPUBTextContentView.self)
|
||||
}
|
||||
|
||||
/// 获取指定文本页面上的高亮标注列表
|
||||
private func textHighlights(for page: RDEPUBTextPage) -> [RDEPUBHighlight] {
|
||||
if let textBook,
|
||||
let chapterData = textBook.chapterData(for: page.href) {
|
||||
@ -60,18 +74,22 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据规范化 href 获取文本章节数据
|
||||
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
|
||||
readerContext.textChapterData(forNormalizedHref: href)
|
||||
}
|
||||
|
||||
/// 返回阅读器顶部工具视图
|
||||
public func topToolView(readerView: RDReaderView) -> UIView? {
|
||||
topToolView
|
||||
}
|
||||
|
||||
/// 返回阅读器底部工具视图
|
||||
public func bottomToolView(readerView: RDReaderView) -> UIView? {
|
||||
bottomToolView
|
||||
}
|
||||
|
||||
/// 页码变化回调:清除选区、同步阅读状态、检测到达末尾
|
||||
public func pageNum(readerView: RDReaderView, pageNum: Int) {
|
||||
updateCurrentSelection(nil)
|
||||
reconcileTextPaginationSizeIfNeeded(for: pageNum)
|
||||
@ -96,11 +114,13 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
/// 屏幕方向即将变化回调,捕获待恢复的展示位置
|
||||
public func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool) {
|
||||
_ = isLandscape
|
||||
runtime.viewportMonitor.capturePendingPresentationRestoreLocation()
|
||||
}
|
||||
|
||||
/// 检测页面尺寸变化并触发重新分页(避免布局错乱)
|
||||
private func reconcileTextPaginationSizeIfNeeded(for pageNum: Int) {
|
||||
guard textBook != nil,
|
||||
!isRepaginating,
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
// RDEPUBReaderController+PublicAPI.swift
|
||||
// RDEPUBReaderController 的公开 API 扩展,提供书签、高亮、目录跳转、搜索等操作
|
||||
|
||||
import UIKit
|
||||
|
||||
// MARK: - Public Reader Commands
|
||||
@ -30,14 +33,22 @@ extension RDEPUBReaderController {
|
||||
runtime.clearSelection()
|
||||
}
|
||||
|
||||
/// 根据唯一标识获取书签
|
||||
/// - Parameter id: 书签 ID
|
||||
/// - Returns: 匹配的书签对象,不存在时返回 nil
|
||||
public func bookmark(withID id: String) -> RDEPUBBookmark? {
|
||||
runtime.bookmark(withID: id)
|
||||
}
|
||||
|
||||
/// 根据唯一标识获取高亮标注
|
||||
/// - Parameter id: 高亮 ID
|
||||
/// - Returns: 匹配的高亮对象,不存在时返回 nil
|
||||
public func highlight(withID id: String) -> RDEPUBHighlight? {
|
||||
runtime.highlight(withID: id)
|
||||
}
|
||||
|
||||
/// 获取当前页的原生文本语义摘要,用于调试和可访问性
|
||||
/// - Returns: 包含页码、断行原因、块类型等信息的摘要字符串,无内容时返回 nil
|
||||
public func nativeTextSemanticSummary() -> String? {
|
||||
guard let textBook,
|
||||
let page = textBook.page(at: max(readerView.currentPage + 1, 1)) ?? textBook.pages.first else {
|
||||
@ -73,6 +84,13 @@ extension RDEPUBReaderController {
|
||||
runtime.addHighlight(from: selection, color: color, note: note)
|
||||
}
|
||||
|
||||
/// 添加指定样式的标注,从当前选中文本或指定选区创建
|
||||
/// - Parameters:
|
||||
/// - selection: 文本选区,默认使用 currentSelection
|
||||
/// - style: 标注样式(下划线、高亮等)
|
||||
/// - color: 标注颜色(CSS 格式),默认黄色
|
||||
/// - note: 可选批注文字
|
||||
/// - Returns: 创建的高亮对象,重复时返回 nil
|
||||
@discardableResult
|
||||
public func addAnnotation(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
@ -83,40 +101,72 @@ extension RDEPUBReaderController {
|
||||
runtime.addAnnotation(from: selection, style: style, color: color, note: note)
|
||||
}
|
||||
|
||||
/// 插入或更新高亮标注(存在则更新,不存在则插入)
|
||||
/// - Parameter highlight: 要写入的高亮对象
|
||||
/// - Returns: 写入后的高亮对象
|
||||
@discardableResult
|
||||
public func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
|
||||
runtime.upsertHighlight(highlight)
|
||||
}
|
||||
|
||||
/// 移除指定高亮标注
|
||||
/// - Parameter id: 高亮 ID
|
||||
/// - Returns: 被移除的高亮对象,不存在时返回 nil
|
||||
@discardableResult
|
||||
public func removeHighlight(id: String) -> RDEPUBHighlight? {
|
||||
runtime.removeHighlight(id: id)
|
||||
}
|
||||
|
||||
/// 更新高亮标注的批注内容
|
||||
/// - Parameters:
|
||||
/// - id: 高亮 ID
|
||||
/// - note: 新的批注文字,传 nil 清除批注
|
||||
/// - Returns: 更新后的高亮对象,不存在时返回 nil
|
||||
@discardableResult
|
||||
public func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
|
||||
runtime.updateHighlightNote(id: id, note: note)
|
||||
}
|
||||
|
||||
/// 跳转到指定高亮标注所在位置
|
||||
/// - Parameters:
|
||||
/// - id: 高亮 ID
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 是否跳转成功
|
||||
@discardableResult
|
||||
public func go(toHighlightID id: String, animated: Bool = true) -> Bool {
|
||||
runtime.go(toHighlightID: id, animated: animated)
|
||||
}
|
||||
|
||||
/// 移除当前书籍的所有高亮标注
|
||||
public func removeAllHighlights() {
|
||||
runtime.removeAllHighlights()
|
||||
}
|
||||
|
||||
/// 跳转到目录项对应的阅读位置
|
||||
/// - Parameters:
|
||||
/// - item: EPUB 原生目录项
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 是否跳转成功
|
||||
@discardableResult
|
||||
public func go(toTableOfContentsItem item: EPUBTableOfContentsItem, animated: Bool = true) -> Bool {
|
||||
go(toTableOfContentsHref: item.href, animated: animated)
|
||||
}
|
||||
|
||||
/// 跳转到目录项对应的阅读位置
|
||||
/// - Parameters:
|
||||
/// - item: RDReader 目录项
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 是否跳转成功
|
||||
@discardableResult
|
||||
public func go(toTableOfContentsItem item: RDEPUBReaderTableOfContentsItem, animated: Bool = true) -> Bool {
|
||||
go(toTableOfContentsHref: item.href, animated: animated)
|
||||
}
|
||||
|
||||
/// 通过目录 href 跳转到对应阅读位置,支持带锚点的 href
|
||||
/// - Parameters:
|
||||
/// - href: 目录资源路径(可含 #fragment)
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 是否跳转成功
|
||||
@discardableResult
|
||||
public func go(toTableOfContentsHref href: String, animated: Bool = true) -> Bool {
|
||||
guard publication != nil else { return false }
|
||||
@ -133,42 +183,61 @@ extension RDEPUBReaderController {
|
||||
return restoreReadingLocation(location, animated: animated)
|
||||
}
|
||||
|
||||
/// 在当前位置添加书签
|
||||
/// - Parameter note: 可选批注文字
|
||||
/// - Returns: 创建的书签对象,位置已存在书签时返回 nil
|
||||
@discardableResult
|
||||
public func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
runtime.addBookmark(note: note)
|
||||
}
|
||||
|
||||
/// 切换当前位置的书签状态(有则移除,无则添加)
|
||||
/// - Parameter note: 添加时可附带的批注文字
|
||||
/// - Returns: 添加时返回新书签,移除时返回被移除的书签
|
||||
@discardableResult
|
||||
public func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
runtime.toggleBookmark(note: note)
|
||||
}
|
||||
|
||||
/// 移除指定书签
|
||||
/// - Parameter id: 书签 ID
|
||||
/// - Returns: 被移除的书签对象,不存在时返回 nil
|
||||
@discardableResult
|
||||
public func removeBookmark(id: String) -> RDEPUBBookmark? {
|
||||
runtime.removeBookmark(id: id)
|
||||
}
|
||||
|
||||
/// 跳转到指定书签所在位置
|
||||
/// - Parameters:
|
||||
/// - id: 书签 ID
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 是否跳转成功
|
||||
@discardableResult
|
||||
public func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
|
||||
runtime.go(toBookmarkID: id, animated: animated)
|
||||
}
|
||||
|
||||
/// 执行全文搜索,自动跳转到第一个匹配项
|
||||
/// 执行全文搜索,匹配结果非空时自动跳转到第一个匹配项,无匹配时清除搜索状态
|
||||
/// - Parameter keyword: 搜索关键词,空字符串会清除搜索
|
||||
public func search(keyword: String) {
|
||||
runtime.search(keyword: keyword)
|
||||
}
|
||||
|
||||
/// 跳转到下一个搜索匹配项
|
||||
/// - Returns: 是否存在下一个匹配项并跳转成功
|
||||
@discardableResult
|
||||
public func searchNext() -> Bool {
|
||||
runtime.searchNext()
|
||||
}
|
||||
|
||||
/// 跳转到上一个搜索匹配项
|
||||
/// - Returns: 是否存在上一个匹配项并跳转成功
|
||||
@discardableResult
|
||||
public func searchPrevious() -> Bool {
|
||||
runtime.searchPrevious()
|
||||
}
|
||||
|
||||
/// 清除搜索状态和高亮
|
||||
public func clearSearch() {
|
||||
runtime.clearSearch()
|
||||
}
|
||||
|
||||
@ -1,30 +1,47 @@
|
||||
// RDEPUBReaderController+RenderSupport.swift
|
||||
// EPUB 阅读器渲染支持辅助方法
|
||||
// 提供获取当前布局上下文、偏好设置、页面尺寸、渲染样式、文本布局配置,
|
||||
// 以及构建渲染请求、管理分页宿主视图和回退位置查询等功能。
|
||||
|
||||
import UIKit
|
||||
|
||||
/// RDEPUBReaderController 渲染支持扩展
|
||||
///
|
||||
/// 本文件提供阅读器渲染所需的辅助方法,包括获取当前布局上下文、偏好设置、页面尺寸、
|
||||
/// 渲染样式、文本布局配置,以及构建渲染请求和回退位置查询等功能。
|
||||
|
||||
extension RDEPUBReaderController {
|
||||
/// 获取当前导航器布局上下文(视口尺寸、方向等)
|
||||
func currentLayoutContext() -> RDEPUBNavigatorLayoutContext {
|
||||
readerContext.currentLayoutContext()
|
||||
}
|
||||
|
||||
/// 获取当前阅读偏好设置
|
||||
func currentPreferences() -> RDEPUBPreferences {
|
||||
readerContext.currentPreferences()
|
||||
}
|
||||
|
||||
/// 获取当前文本页面尺寸
|
||||
func currentTextPageSize() -> CGSize {
|
||||
readerContext.currentTextPageSize()
|
||||
}
|
||||
|
||||
/// 获取当前文本渲染样式(字体、行高等)
|
||||
func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
|
||||
readerContext.currentTextRenderStyle()
|
||||
}
|
||||
|
||||
/// 获取指定页面尺寸下的文本布局配置
|
||||
func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
|
||||
readerContext.currentTextLayoutConfig(pageSize: pageSize)
|
||||
}
|
||||
|
||||
/// 获取已解析的文本渲染器实例
|
||||
func resolvedTextRenderer() -> RDEPUBTextRenderer {
|
||||
readerContext.resolvedTextRenderer()
|
||||
}
|
||||
|
||||
/// 确保分页宿主视图已添加到视图层级(位于屏幕外用于预计算分页)
|
||||
func ensurePaginationHostView() -> UIView {
|
||||
let viewportSize = currentLayoutContext().viewportSize
|
||||
let hostFrame = CGRect(x: -viewportSize.width - 32, y: 0, width: viewportSize.width, height: viewportSize.height)
|
||||
@ -36,6 +53,7 @@ extension RDEPUBReaderController {
|
||||
return paginationHostView
|
||||
}
|
||||
|
||||
/// 为指定页面索引构建渲染请求(含位置、高亮、搜索状态)
|
||||
func request(for pageIndex: Int) -> RDEPUBRenderRequest? {
|
||||
guard let publication, activePages.indices.contains(pageIndex) else {
|
||||
return nil
|
||||
@ -52,11 +70,13 @@ extension RDEPUBReaderController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取指定页面索引的回退位置(当无法确定精确位置时使用)
|
||||
func fallbackLocation(for pageIndex: Int) -> RDEPUBLocation? {
|
||||
guard activePages.indices.contains(pageIndex) else { return nil }
|
||||
return readingSession?.fallbackLocation(for: activePages[pageIndex], bookIdentifier: currentBookIdentifier)
|
||||
}
|
||||
|
||||
/// 获取指定固定布局页面上的高亮标注列表
|
||||
private func highlights(for page: EPUBPage) -> [RDEPUBHighlight] {
|
||||
guard let publication else { return [] }
|
||||
if let spread = page.fixedSpread {
|
||||
|
||||
@ -1,6 +1,17 @@
|
||||
// RDEPUBReaderController+RuntimeBridge.swift
|
||||
// EPUB 阅读器运行时桥接层
|
||||
// 将阅读器控制器的公开行为委托给 runtime 对象执行,涵盖配置应用、
|
||||
// 出版物加载与分页、阅读位置恢复、加载状态管理、错误处理等生命周期操作。
|
||||
|
||||
import UIKit
|
||||
|
||||
/// RDEPUBReaderController 运行时桥接扩展
|
||||
///
|
||||
/// 本文件将阅读器控制器的公开行为委托给 runtime 对象执行,涵盖配置应用、
|
||||
/// 出版物加载与分页、阅读位置恢复、加载状态管理、错误处理等生命周期操作。
|
||||
|
||||
extension RDEPUBReaderController {
|
||||
/// 应用阅读器视图配置,处理显示类型/翻页方向/横屏双页等变更并恢复位置
|
||||
func applyReaderViewConfiguration() {
|
||||
let resolvedDirection = resolvedPageDirection()
|
||||
let presentationDidChange = readerView.currentDisplayType != configuration.displayType
|
||||
@ -21,14 +32,17 @@ extension RDEPUBReaderController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动初始加载流程(如果尚未加载)
|
||||
func startInitialLoadIfNeeded() {
|
||||
runtime.startInitialLoadIfNeeded()
|
||||
}
|
||||
|
||||
/// 加载 EPUB 出版物
|
||||
func loadPublication() {
|
||||
runtime.loadPublication()
|
||||
}
|
||||
|
||||
/// 应用已解析的出版物数据(解析器、出版物模型、书签、高亮等)
|
||||
func applyParsedPublication(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
@ -47,14 +61,17 @@ extension RDEPUBReaderController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 对出版物执行分页计算
|
||||
func paginatePublication(restoreLocation: RDEPUBLocation?) {
|
||||
runtime.paginatePublication(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 应用文本书籍数据并恢复阅读位置
|
||||
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
||||
runtime.applyTextBook(textBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 应用分页快照(页面列表与章节信息)
|
||||
func applyPaginationSnapshot(
|
||||
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
|
||||
restoreLocation: RDEPUBLocation?
|
||||
@ -62,35 +79,43 @@ extension RDEPUBReaderController {
|
||||
runtime.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 完成分页流程并恢复阅读位置
|
||||
func finishPagination(restoreLocation: RDEPUBLocation?) {
|
||||
runtime.finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 保留当前位置重新执行分页
|
||||
func repaginatePreservingCurrentLocation() {
|
||||
runtime.repaginatePreservingCurrentLocation()
|
||||
}
|
||||
|
||||
/// 恢复到指定阅读位置,返回是否成功
|
||||
@discardableResult
|
||||
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||||
runtime.restoreReadingLocation(location, animated: animated)
|
||||
}
|
||||
|
||||
/// 获取当前可见页面的位置
|
||||
func currentVisibleLocation() -> RDEPUBLocation? {
|
||||
runtime.currentVisibleLocation()
|
||||
}
|
||||
|
||||
/// 获取持久化存储的阅读位置
|
||||
func persistenceLocation() -> RDEPUBLocation? {
|
||||
readerContext.persistenceLocation()
|
||||
}
|
||||
|
||||
/// 持久化当前阅读位置
|
||||
func persist(location: RDEPUBLocation) {
|
||||
readerContext.persist(location: location)
|
||||
}
|
||||
|
||||
/// 更新当前文本选区状态
|
||||
func updateCurrentSelection(_ selection: RDEPUBSelection?) {
|
||||
runtime.annotationCoordinator.updateCurrentSelection(selection)
|
||||
}
|
||||
|
||||
/// 将选区限定到指定 spine 范围内
|
||||
func scopedSelection(
|
||||
_ selection: RDEPUBSelection,
|
||||
relativeToSpineIndex spineIndex: Int?
|
||||
@ -98,53 +123,65 @@ extension RDEPUBReaderController {
|
||||
runtime.annotationCoordinator.scopedSelection(selection, relativeToSpineIndex: spineIndex)
|
||||
}
|
||||
|
||||
/// 刷新可见内容并保留当前位置
|
||||
func refreshVisibleContentPreservingLocation() {
|
||||
runtime.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
/// 重建外部文本书籍
|
||||
func rebuildExternalTextBook() {
|
||||
runtime.rebuildExternalTextBook()
|
||||
}
|
||||
|
||||
/// 更新阅读器 UI 装饰层(导航栏、工具栏等)
|
||||
func updateReaderChrome() {
|
||||
runtime.updateReaderChrome()
|
||||
}
|
||||
|
||||
/// 更新书签按钮状态
|
||||
func updateBookmarkChrome() {
|
||||
runtime.updateBookmarkChrome()
|
||||
}
|
||||
|
||||
/// 展示书签管理界面
|
||||
func presentBookmarksManager() {
|
||||
runtime.presentBookmarksManager()
|
||||
}
|
||||
|
||||
/// 展示高亮标注管理界面
|
||||
func presentHighlightsManager() {
|
||||
runtime.presentHighlightsManager()
|
||||
}
|
||||
|
||||
/// 展示标注创建界面
|
||||
func presentAnnotationCreation() {
|
||||
runtime.presentAnnotationCreation()
|
||||
}
|
||||
|
||||
/// 处理选区菜单操作(复制、标注、分享等)
|
||||
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
|
||||
runtime.handleSelectionMenuAction(action, selection: selection)
|
||||
}
|
||||
|
||||
/// 展示阅读设置界面
|
||||
func presentSettings() {
|
||||
runtime.presentSettings()
|
||||
}
|
||||
|
||||
/// 使用闭包更新阅读器配置
|
||||
func updateConfiguration(_ update: (inout RDEPUBReaderConfiguration) -> Void) {
|
||||
var nextConfiguration = configuration
|
||||
update(&nextConfiguration)
|
||||
configuration = nextConfiguration
|
||||
}
|
||||
|
||||
/// 设置屏幕亮度并持久化
|
||||
func setScreenBrightness(_ brightness: CGFloat) {
|
||||
currentBrightness = max(0, min(1, brightness))
|
||||
persistReaderSettingsIfNeeded()
|
||||
}
|
||||
|
||||
/// 持久化阅读器设置(亮度、配置等)
|
||||
func persistReaderSettingsIfNeeded() {
|
||||
let settings = RDEPUBReaderSettings.capture(
|
||||
configuration: configuration,
|
||||
@ -153,6 +190,7 @@ extension RDEPUBReaderController {
|
||||
persistence?.saveReaderSettings(settings)
|
||||
}
|
||||
|
||||
/// 判断配置变更是否需要重新分页
|
||||
func requiresRepagination(
|
||||
from oldConfiguration: RDEPUBReaderConfiguration,
|
||||
to newConfiguration: RDEPUBReaderConfiguration
|
||||
@ -169,6 +207,7 @@ extension RDEPUBReaderController {
|
||||
oldConfiguration.textRenderingEngine != newConfiguration.textRenderingEngine
|
||||
}
|
||||
|
||||
/// 判断配置变更是否需要刷新可见内容
|
||||
func requiresVisibleRefresh(
|
||||
from oldConfiguration: RDEPUBReaderConfiguration,
|
||||
to newConfiguration: RDEPUBReaderConfiguration
|
||||
@ -178,14 +217,17 @@ extension RDEPUBReaderController {
|
||||
oldConfiguration.darkImageBlendRatio != newConfiguration.darkImageBlendRatio
|
||||
}
|
||||
|
||||
/// 展示目录界面
|
||||
func presentTableOfContents() {
|
||||
runtime.presentTableOfContents()
|
||||
}
|
||||
|
||||
/// 处理返回操作
|
||||
func handleBackAction() {
|
||||
runtime.handleBackAction()
|
||||
}
|
||||
|
||||
/// 处理错误:停止分页、隐藏加载指示器、显示错误信息并通知代理
|
||||
func handle(error: Error) {
|
||||
isRepaginating = false
|
||||
hideLoading()
|
||||
@ -197,19 +239,23 @@ extension RDEPUBReaderController {
|
||||
delegate?.epubReader(self, didFailWithError: error)
|
||||
}
|
||||
|
||||
/// 显示加载指示器
|
||||
func showLoading() {
|
||||
errorLabel.isHidden = true
|
||||
loadingIndicator.startAnimating()
|
||||
}
|
||||
|
||||
/// 隐藏加载指示器
|
||||
func hideLoading() {
|
||||
loadingIndicator.stopAnimating()
|
||||
}
|
||||
|
||||
/// 获取当前视口签名(用于检测视口变化)
|
||||
func currentViewportSignature() -> RDEPUBViewportSignature? {
|
||||
runtime.currentViewportSignature()
|
||||
}
|
||||
|
||||
/// 检测并处理视口变化(旋转、尺寸变化等)
|
||||
func handleViewportChangeIfNeeded(
|
||||
reason: RDEPUBViewportChangeReason,
|
||||
viewportSignature: RDEPUBViewportSignature? = nil
|
||||
@ -217,18 +263,21 @@ extension RDEPUBReaderController {
|
||||
runtime.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature)
|
||||
}
|
||||
|
||||
/// 获取指定页面的搜索结果展示状态
|
||||
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
|
||||
runtime.searchPresentation(for: page)
|
||||
}
|
||||
|
||||
/// 根据出版物阅读方向解析页面翻页方向
|
||||
private func resolvedPageDirection() -> RDReaderView.PageDirection {
|
||||
publication?.readingProgression == .rtl ? .rightToLeft : .leftToRight
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 手势识别器代理(用于 NavigationController 返回手势)
|
||||
// MARK: - 手势识别器代理
|
||||
|
||||
extension RDEPUBReaderController: UIGestureRecognizerDelegate {
|
||||
/// 手势识别器代理,始终返回 true 允许手势触发
|
||||
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
@ -1,6 +1,17 @@
|
||||
// RDEPUBReaderController+TableOfContents.swift
|
||||
// EPUB 阅读器目录功能
|
||||
// 提供目录(Table of Contents)相关功能,包括解析当前页面对应的目录项
|
||||
// 以及将嵌套目录树结构扁平化为线性列表。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// RDEPUBReaderController 目录扩展
|
||||
///
|
||||
/// 本文件提供目录(Table of Contents)相关功能,包括解析当前页面对应的目录项
|
||||
/// 以及将嵌套目录树结构扁平化为线性列表。
|
||||
|
||||
extension RDEPUBReaderController {
|
||||
/// 解析当前阅读位置对应的目录项,按页码或 href 匹配
|
||||
func resolvedCurrentTableOfContentsItem() -> RDEPUBReaderTableOfContentsItem? {
|
||||
let items = flattenedTableOfContents
|
||||
guard !items.isEmpty else { return nil }
|
||||
@ -46,6 +57,7 @@ extension RDEPUBReaderController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将嵌套目录树递归扁平化为线性列表,并计算每项目标页码
|
||||
func flattenedTableOfContentsItems(
|
||||
from items: [EPUBTableOfContentsItem],
|
||||
depth: Int = 0
|
||||
|
||||
@ -3,11 +3,17 @@ import UIKit
|
||||
/// 视口签名结构体,用于检测视口尺寸或安全区是否发生显著变化
|
||||
/// 当变化超过阈值时触发重新分页
|
||||
struct RDEPUBViewportSignature: Equatable {
|
||||
/// 视口宽度(pt)
|
||||
let width: CGFloat
|
||||
/// 视口高度(pt)
|
||||
let height: CGFloat
|
||||
/// 顶部安全区域高度
|
||||
let safeTop: CGFloat
|
||||
/// 左侧安全区域宽度
|
||||
let safeLeft: CGFloat
|
||||
/// 底部安全区域高度
|
||||
let safeBottom: CGFloat
|
||||
/// 右侧安全区域宽度
|
||||
let safeRight: CGFloat
|
||||
|
||||
func differsSignificantly(from other: RDEPUBViewportSignature, threshold: CGFloat = 1) -> Bool {
|
||||
@ -22,8 +28,11 @@ struct RDEPUBViewportSignature: Equatable {
|
||||
|
||||
/// 视口变化的原因枚举,用于决定延迟处理的策略
|
||||
enum RDEPUBViewportChangeReason {
|
||||
/// 视图布局变化(如 Safe Area 更新、分屏调整等)
|
||||
case viewLayout
|
||||
/// 屏幕方向旋转过渡
|
||||
case orientationTransition
|
||||
}
|
||||
|
||||
/// 原生文本渲染路径的分页快照,包含所有页面和章节信息
|
||||
typealias RDEPUBNativeTextSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import UIKit
|
||||
|
||||
/// WebView 路径的原生装饰覆盖层。
|
||||
/// EPUB WebView 路径的原生装饰覆盖层。
|
||||
/// 用于在 WebView 上方叠加高亮、下划线等视觉装饰。
|
||||
/// 对标 WXRead 的页面级装饰绘制思路:JS 只负责提供 rect,实际视觉绘制由原生 CGContext 完成。
|
||||
final class RDEPUBWebDecorationOverlayView: UIView {
|
||||
private var decorations: [RDEPUBTextOverlayDecoration] = []
|
||||
@ -17,6 +18,8 @@ final class RDEPUBWebDecorationOverlayView: UIView {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 应用装饰数据并触发重绘
|
||||
/// - Parameter decorations: 装饰项数组(高亮、下划线等)
|
||||
func applyDecorations(_ decorations: [RDEPUBTextOverlayDecoration]) {
|
||||
self.decorations = decorations.filter { !$0.rects.isEmpty }
|
||||
setNeedsDisplay()
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
import UIKit
|
||||
|
||||
/// EPUB 阅读器标注协调器:负责高亮、批注和书签的增删改查操作。
|
||||
///
|
||||
/// 职责:
|
||||
/// - 管理高亮(highlight)与批注(annotation)的创建、更新和删除
|
||||
/// - 管理书签(bookmark)的添加、切换和删除
|
||||
/// - 处理文本选中后的菜单操作(复制、高亮、批注)
|
||||
/// - 弹出高亮管理器和书签管理器界面
|
||||
final class RDEPUBReaderAnnotationCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
@ -11,16 +18,19 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
context.controller
|
||||
}
|
||||
|
||||
/// 根据 ID 查找书签。
|
||||
func bookmark(withID id: String) -> RDEPUBBookmark? {
|
||||
guard let controller else { return nil }
|
||||
return controller.activeBookmarks.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// 根据 ID 查找高亮。
|
||||
func highlight(withID id: String) -> RDEPUBHighlight? {
|
||||
guard let controller else { return nil }
|
||||
return controller.activeHighlights.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// 更新当前文本选区状态,并同步刷新底部工具栏的高亮按钮可用性。
|
||||
func updateCurrentSelection(_ selection: RDEPUBSelection?) {
|
||||
guard let controller else { return }
|
||||
controller.currentSelection = selection?.isEmpty == false ? selection : nil
|
||||
@ -34,6 +44,7 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
controller.delegate?.epubReader(controller, didChangeSelection: controller.currentSelection)
|
||||
}
|
||||
|
||||
/// 基于当前选区添加高亮标记,自动去重并持久化。
|
||||
@discardableResult
|
||||
func addHighlight(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
@ -43,6 +54,7 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
addAnnotation(from: selection, style: .highlight, color: color, note: note)
|
||||
}
|
||||
|
||||
/// 基于选区创建标注(高亮/划线/批注),支持指定样式、颜色和备注。
|
||||
@discardableResult
|
||||
func addAnnotation(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
@ -85,6 +97,7 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return newHighlight
|
||||
}
|
||||
|
||||
/// 插入或更新高亮(upsert),按 ID 匹配已有记录。
|
||||
@discardableResult
|
||||
func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
|
||||
guard let controller else { return nil }
|
||||
@ -101,6 +114,7 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return scopedHighlight
|
||||
}
|
||||
|
||||
/// 根据 ID 删除高亮并持久化。
|
||||
@discardableResult
|
||||
func removeHighlight(id: String) -> RDEPUBHighlight? {
|
||||
guard let controller else { return nil }
|
||||
@ -112,6 +126,7 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return removed
|
||||
}
|
||||
|
||||
/// 更新指定高亮的批注备注内容。
|
||||
@discardableResult
|
||||
func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
|
||||
guard let controller else { return nil }
|
||||
@ -123,6 +138,7 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return controller.activeHighlights[index]
|
||||
}
|
||||
|
||||
/// 跳转到指定高亮所在位置。
|
||||
@discardableResult
|
||||
func go(toHighlightID id: String, animated: Bool = true) -> Bool {
|
||||
guard let controller else { return false }
|
||||
@ -132,6 +148,7 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return controller.restoreReadingLocation(highlight.location, animated: animated)
|
||||
}
|
||||
|
||||
/// 清除所有高亮标记。
|
||||
func removeAllHighlights() {
|
||||
guard let controller else { return }
|
||||
guard !controller.activeHighlights.isEmpty else { return }
|
||||
@ -139,6 +156,7 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
persistHighlightsAndRefreshContent()
|
||||
}
|
||||
|
||||
/// 将选区位置相对于指定 spine 索引进行规范化。
|
||||
func scopedSelection(
|
||||
_ selection: RDEPUBSelection,
|
||||
relativeToSpineIndex spineIndex: Int?
|
||||
@ -166,6 +184,7 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
)
|
||||
}
|
||||
|
||||
/// 弹出高亮管理器,支持查看、编辑备注和删除高亮。
|
||||
func presentHighlightsManager() {
|
||||
guard let controller else { return }
|
||||
guard controller.configuration.allowsHighlights else { return }
|
||||
@ -196,6 +215,7 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
controller.present(navigationController, animated: true)
|
||||
}
|
||||
|
||||
/// 弹出标注创建面板(高亮/划线/批注选择)。
|
||||
func presentAnnotationCreation() {
|
||||
guard let controller else { return }
|
||||
guard controller.configuration.allowsHighlights,
|
||||
@ -205,6 +225,7 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
presentAnnotationActionSheet(for: currentSelection)
|
||||
}
|
||||
|
||||
/// 处理文本选中后的菜单操作:复制、高亮、批注。
|
||||
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
|
||||
guard let selection else { return }
|
||||
switch action {
|
||||
@ -218,6 +239,7 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 在当前位置添加书签,自动去重。
|
||||
@discardableResult
|
||||
func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
guard let controller else { return nil }
|
||||
@ -239,6 +261,7 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return newBookmark
|
||||
}
|
||||
|
||||
/// 切换当前位置的书签状态:已存在则移除,不存在则添加。
|
||||
@discardableResult
|
||||
func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
guard let controller else { return nil }
|
||||
@ -254,6 +277,7 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return addBookmark(note: note)
|
||||
}
|
||||
|
||||
/// 根据 ID 删除书签并持久化。
|
||||
@discardableResult
|
||||
func removeBookmark(id: String) -> RDEPUBBookmark? {
|
||||
guard let controller else { return nil }
|
||||
@ -265,6 +289,7 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return removed
|
||||
}
|
||||
|
||||
/// 跳转到指定书签所在位置。
|
||||
@discardableResult
|
||||
func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
|
||||
guard let controller else { return false }
|
||||
@ -274,12 +299,14 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return controller.restoreReadingLocation(bookmark.location, animated: animated)
|
||||
}
|
||||
|
||||
/// 同步顶部书签按钮和底部书签列表按钮的 UI 状态。
|
||||
func updateBookmarkChrome() {
|
||||
guard let controller else { return }
|
||||
controller.topToolView.setBookmarkSelected(currentBookmark() != nil)
|
||||
controller.bottomToolView.setBookmarksEnabled(!controller.activeBookmarks.isEmpty)
|
||||
}
|
||||
|
||||
/// 弹出书签管理器,支持查看、跳转和删除书签。
|
||||
func presentBookmarksManager() {
|
||||
guard let controller else { return }
|
||||
guard !controller.activeBookmarks.isEmpty else { return }
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
import UIKit
|
||||
|
||||
/// EPUB 阅读器界面组装协调器:负责阅读器初次启动时的 UI 搭建。
|
||||
///
|
||||
/// 职责:
|
||||
/// - 组装阅读器视图层次结构(readerView、loadingIndicator、errorLabel)
|
||||
/// - 注册内容视图类型
|
||||
/// - 处理外部纯文本图书的启动收尾逻辑
|
||||
final class RDEPUBReaderAssemblyCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
@ -7,6 +13,7 @@ final class RDEPUBReaderAssemblyCoordinator {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// 组装阅读器界面:添加 readerView、loadingIndicator、errorLabel 到控制器视图,并配置顶部工具栏。
|
||||
func assembleInterface() {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView else { return }
|
||||
@ -18,6 +25,7 @@ final class RDEPUBReaderAssemblyCoordinator {
|
||||
controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView)
|
||||
}
|
||||
|
||||
/// 外部纯文本图书启动时,加载已保存的书签、高亮和阅读位置,完成分页收尾。
|
||||
func finishExternalTextBookLaunchIfNeeded() {
|
||||
guard let runtime = context.runtime,
|
||||
context.isExternalTextBook else {
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
import UIKit
|
||||
|
||||
/// EPUB 阅读器 Chrome 协调器:负责顶部/底部工具栏的创建、更新和交互处理。
|
||||
///
|
||||
/// 职责:
|
||||
/// - 创建并配置顶部工具栏(返回、书签按钮)
|
||||
/// - 创建并配置底部工具栏(目录、书签、高亮、设置按钮)
|
||||
/// - 同步工具栏的主题和状态
|
||||
/// - 弹出设置面板和目录面板
|
||||
/// - 处理返回按钮的关闭逻辑
|
||||
final class RDEPUBReaderChromeCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
@ -11,6 +19,7 @@ final class RDEPUBReaderChromeCoordinator {
|
||||
context.controller
|
||||
}
|
||||
|
||||
/// 创建顶部工具栏视图,绑定返回和书签切换回调。
|
||||
func makeTopToolView() -> RDEPUBReaderTopToolView {
|
||||
let toolView = RDEPUBReaderTopToolView()
|
||||
toolView.onBack = { [weak self] in
|
||||
@ -22,6 +31,7 @@ final class RDEPUBReaderChromeCoordinator {
|
||||
return toolView
|
||||
}
|
||||
|
||||
/// 创建底部工具栏视图,绑定目录、书签、高亮、设置等回调。
|
||||
func makeBottomToolView() -> RDEPUBReaderBottomToolView {
|
||||
let toolView = RDEPUBReaderBottomToolView()
|
||||
toolView.onShowTableOfContents = { [weak self] in
|
||||
@ -42,6 +52,7 @@ final class RDEPUBReaderChromeCoordinator {
|
||||
return toolView
|
||||
}
|
||||
|
||||
/// 同步更新顶部和底部工具栏的主题、标题、按钮可用性等状态。
|
||||
func updateReaderChrome() {
|
||||
guard let controller else { return }
|
||||
controller.topToolView.apply(theme: controller.configuration.theme)
|
||||
@ -67,6 +78,7 @@ final class RDEPUBReaderChromeCoordinator {
|
||||
controller.updateBookmarkChrome()
|
||||
}
|
||||
|
||||
/// 弹出阅读设置面板(字号、字体、行距、分栏、主题、亮度等)。
|
||||
func presentSettings() {
|
||||
guard let controller else { return }
|
||||
guard controller.configuration.showsSettingsPanel else { return }
|
||||
@ -101,6 +113,7 @@ final class RDEPUBReaderChromeCoordinator {
|
||||
controller.present(navigationController, animated: true)
|
||||
}
|
||||
|
||||
/// 弹出目录列表面板,支持点击跳转到指定章节。
|
||||
func presentTableOfContents() {
|
||||
guard let controller else { return }
|
||||
guard controller.configuration.showsTableOfContents else { return }
|
||||
@ -124,6 +137,7 @@ final class RDEPUBReaderChromeCoordinator {
|
||||
controller.present(navigationController, animated: true)
|
||||
}
|
||||
|
||||
/// 处理返回按钮点击,自动判断 pop 或 dismiss 方式关闭阅读器。
|
||||
func handleBackAction() {
|
||||
guard let controller else { return }
|
||||
close(controller)
|
||||
|
||||
@ -11,37 +11,61 @@ import UIKit
|
||||
final class RDEPUBReaderContext {
|
||||
// MARK: - 引用
|
||||
|
||||
/// 弱引用阅读器控制器,用于 UIKit 呈现操作。
|
||||
weak var controller: RDEPUBReaderController?
|
||||
/// 弱引用阅读器视图,用于布局和页面状态查询。
|
||||
weak var readerView: RDReaderView?
|
||||
/// 依赖注入容器,提供解析器、分页器等工厂方法。
|
||||
var dependencies: RDEPUBReaderDependencies = .live
|
||||
/// 便捷访问当前控制器的运行时协调器集合。
|
||||
var runtime: RDEPUBReaderRuntime? {
|
||||
controller?.runtime
|
||||
}
|
||||
|
||||
// MARK: - 业务状态
|
||||
|
||||
/// EPUB 解析器实例。
|
||||
var parser: RDEPUBParser?
|
||||
/// 解析后的出版物模型。
|
||||
var publication: RDEPUBPublication?
|
||||
/// 当前阅读会话,管理页面和章节状态。
|
||||
var readingSession: RDEPUBReadingSession?
|
||||
/// 原生文本排版生成的图书模型(仅文本重排模式)。
|
||||
var textBook: RDEPUBTextBook?
|
||||
/// 当前书籍的所有书签。
|
||||
var activeBookmarks: [RDEPUBBookmark] = []
|
||||
/// 当前书籍的所有高亮标注。
|
||||
var activeHighlights: [RDEPUBHighlight] = []
|
||||
/// 当前打开书籍的唯一标识。
|
||||
var currentBookIdentifier: String?
|
||||
/// 分页操作令牌,用于取消过期的异步分页任务。
|
||||
var paginationToken = UUID()
|
||||
/// Web 内容分页计算器。
|
||||
var paginator: RDEPUBPaginator?
|
||||
/// 全文搜索状态。
|
||||
var searchState: RDEPUBSearchState?
|
||||
/// 上次文本分页时的页面尺寸,用于检测是否需要重新分页。
|
||||
var lastTextPaginationPageSize: CGSize?
|
||||
/// 当前用户文本选区。
|
||||
var currentSelection: RDEPUBSelection?
|
||||
|
||||
// MARK: - 控制器状态(从 controller 下沉)
|
||||
|
||||
/// 阅读器配置(字号、字体、主题等)。
|
||||
var configuration: RDEPUBReaderConfiguration = .default
|
||||
/// 持久化策略,负责书签、高亮、阅读位置的存取。
|
||||
var persistence: RDEPUBReaderPersistence?
|
||||
/// 当前打开的 EPUB 文件 URL。
|
||||
var epubURL: URL = URL(string: "about:blank")!
|
||||
/// 是否正在重新分页。
|
||||
var isRepaginating: Bool = false
|
||||
/// 是否已完成首次加载。
|
||||
var didStartInitialLoad: Bool = false
|
||||
/// 是否为外部传入的纯文本图书。
|
||||
var isExternalTextBook: Bool = false
|
||||
/// 外部纯文本文件的 URL。
|
||||
var textFileURL: URL?
|
||||
/// 文本图书缓存,避免重复排版。
|
||||
var textBookCache = RDEPUBTextBookCache()
|
||||
|
||||
// MARK: - 初始化
|
||||
@ -53,6 +77,7 @@ final class RDEPUBReaderContext {
|
||||
|
||||
// MARK: - 便捷方法
|
||||
|
||||
/// 根据当前 readerView 和控制器尺寸构建布局上下文。
|
||||
func currentLayoutContext() -> RDEPUBNavigatorLayoutContext {
|
||||
let containerSize = readerView?.bounds.size ?? .zero
|
||||
let viewSize = controller?.view.bounds.size ?? containerSize
|
||||
@ -66,10 +91,12 @@ final class RDEPUBReaderContext {
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据当前配置生成阅读偏好设置。
|
||||
func currentPreferences() -> RDEPUBPreferences {
|
||||
configuration.makePreferences()
|
||||
}
|
||||
|
||||
/// 获取当前文本排版的单页尺寸,优先从 readerView 解析,兜底用布局上下文。
|
||||
func currentTextPageSize() -> CGSize {
|
||||
let pageNum = (readerView?.currentPage ?? -1) >= 0 ? readerView?.currentPage : nil
|
||||
if let readerView, let pageNum {
|
||||
@ -81,6 +108,7 @@ final class RDEPUBReaderContext {
|
||||
return currentLayoutContext().viewportSize
|
||||
}
|
||||
|
||||
/// 根据当前配置生成文本渲染样式(字体、行距、颜色)。
|
||||
func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
|
||||
let font = configuration.fontChoice.font(ofSize: configuration.fontSize)
|
||||
let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4)
|
||||
@ -92,6 +120,7 @@ final class RDEPUBReaderContext {
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据页面尺寸和配置生成文本排版参数。
|
||||
func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
|
||||
return RDEPUBTextLayoutConfig(
|
||||
frameWidth: max(pageSize.width, 1),
|
||||
@ -108,61 +137,75 @@ final class RDEPUBReaderContext {
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取当前配置对应的文本渲染器实例。
|
||||
func resolvedTextRenderer() -> RDEPUBTextRenderer {
|
||||
dependencies.makeTextRenderer(configuration.textRenderingEngine)
|
||||
}
|
||||
|
||||
/// 当前活跃的页面列表。
|
||||
var activePages: [EPUBPage] {
|
||||
readingSession?.activePages ?? []
|
||||
}
|
||||
|
||||
/// 当前活跃的章节信息列表。
|
||||
var activeChapters: [EPUBChapterInfo] {
|
||||
readingSession?.activeChapters ?? []
|
||||
}
|
||||
|
||||
/// 屏幕亮度代理属性,读写均转发给系统环境。
|
||||
var currentBrightness: CGFloat {
|
||||
get { dependencies.environment.currentBrightness }
|
||||
set { dependencies.environment.currentBrightness = newValue }
|
||||
}
|
||||
|
||||
/// 用新快照替换当前活跃的分页快照。
|
||||
func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) {
|
||||
readingSession?.setActiveSnapshot(snapshot)
|
||||
}
|
||||
|
||||
/// 清除当前活跃快照,重置运行时状态。
|
||||
func clearActiveSnapshot() {
|
||||
readingSession?.resetRuntimeState()
|
||||
}
|
||||
|
||||
/// 工厂方法:创建 EPUB 解析器。
|
||||
func makeParser() -> RDEPUBParser {
|
||||
dependencies.makeParser()
|
||||
}
|
||||
|
||||
/// 工厂方法:创建 Web 内容分页计算器。
|
||||
func makePaginator() -> RDEPUBPaginator {
|
||||
dependencies.makePaginator()
|
||||
}
|
||||
|
||||
/// 工厂方法:创建 EPUB 文本图书构建器。
|
||||
func makeTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder {
|
||||
dependencies.makeTextBookBuilder(resolvedTextRenderer(), textBookCache, layoutConfig)
|
||||
}
|
||||
|
||||
/// 工厂方法:创建纯文本图书构建器。
|
||||
func makePlainTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder {
|
||||
dependencies.makePlainTextBookBuilder(resolvedTextRenderer(), layoutConfig)
|
||||
}
|
||||
|
||||
/// 获取当前可见页面的阅读位置。
|
||||
func currentVisibleLocation() -> RDEPUBLocation? {
|
||||
controller?.currentVisibleLocation()
|
||||
}
|
||||
|
||||
/// 从持久化存储加载上次保存的阅读位置。
|
||||
func persistenceLocation() -> RDEPUBLocation? {
|
||||
guard let currentBookIdentifier else { return nil }
|
||||
return persistence?.loadLocation(for: currentBookIdentifier)
|
||||
}
|
||||
|
||||
/// 将阅读位置持久化到存储。
|
||||
func persist(location: RDEPUBLocation) {
|
||||
guard let currentBookIdentifier else { return }
|
||||
persistence?.saveLocation(location, for: currentBookIdentifier)
|
||||
}
|
||||
|
||||
/// 根据规范化 href 获取文本章节数据。
|
||||
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
|
||||
guard let textBook, let publication else { return nil }
|
||||
let normalizedHref = publication.resourceResolver.normalizedHref(href) ?? href
|
||||
@ -171,38 +214,47 @@ final class RDEPUBReaderContext {
|
||||
.flatMap { textBook.chapterData(for: $0.href) }
|
||||
}
|
||||
|
||||
/// 显示加载指示器。
|
||||
func showLoading() {
|
||||
controller?.showLoading()
|
||||
}
|
||||
|
||||
/// 隐藏加载指示器。
|
||||
func hideLoading() {
|
||||
controller?.hideLoading()
|
||||
}
|
||||
|
||||
/// 将错误转发给控制器处理。
|
||||
func handle(error: Error) {
|
||||
controller?.handle(error: error)
|
||||
}
|
||||
|
||||
/// 触发工具栏状态更新。
|
||||
func updateReaderChrome() {
|
||||
controller?.updateReaderChrome()
|
||||
}
|
||||
|
||||
/// 刷新可见内容并保持当前阅读位置不变。
|
||||
func refreshVisibleContentPreservingLocation() {
|
||||
controller?.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
/// 恢复到指定阅读位置。
|
||||
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||||
controller?.restoreReadingLocation(location, animated: animated) ?? false
|
||||
}
|
||||
|
||||
/// 重新分页并保持当前阅读位置。
|
||||
func repaginatePreservingCurrentLocation() {
|
||||
controller?.repaginatePreservingCurrentLocation()
|
||||
}
|
||||
|
||||
/// 将当前配置应用到阅读器视图。
|
||||
func applyReaderViewConfiguration() {
|
||||
controller?.applyReaderViewConfiguration()
|
||||
}
|
||||
|
||||
/// 同步书签按钮的 UI 状态。
|
||||
func updateBookmarkChrome() {
|
||||
controller?.updateBookmarkChrome()
|
||||
}
|
||||
|
||||
@ -1,10 +1,17 @@
|
||||
// RDEPUBReaderDependencies.swift
|
||||
// EPUB 阅读器依赖注入配置,定义显示环境协议与核心组件工厂
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 阅读器显示环境协议,抽象屏幕亮度和视口尺寸以支持测试和多平台适配
|
||||
public protocol RDEPUBReaderDisplayEnvironment: AnyObject {
|
||||
/// 当前屏幕亮度,取值范围 0.0 ~ 1.0
|
||||
var currentBrightness: CGFloat { get set }
|
||||
/// 后备视口尺寸,用于无法获取实际视图尺寸时的布局计算
|
||||
var fallbackViewportSize: CGSize { get }
|
||||
}
|
||||
|
||||
/// 基于 UIScreen 的默认显示环境实现,直接读写系统屏幕亮度
|
||||
public final class RDEPUBUIScreenEnvironment: RDEPUBReaderDisplayEnvironment {
|
||||
public init() {}
|
||||
|
||||
@ -18,14 +25,29 @@ public final class RDEPUBUIScreenEnvironment: RDEPUBReaderDisplayEnvironment {
|
||||
}
|
||||
}
|
||||
|
||||
/// EPUB 阅读器核心依赖容器,通过工厂闭包注入各组件以便替换和测试
|
||||
public struct RDEPUBReaderDependencies {
|
||||
/// 显示环境实例
|
||||
public var environment: any RDEPUBReaderDisplayEnvironment
|
||||
/// EPUB 解析器工厂
|
||||
public var makeParser: () -> RDEPUBParser
|
||||
/// 分页器工厂
|
||||
public var makePaginator: () -> RDEPUBPaginator
|
||||
/// 富文本书籍构建器工厂
|
||||
public var makeTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder
|
||||
/// 纯文本书籍构建器工厂
|
||||
public var makePlainTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder
|
||||
/// 文本渲染器工厂
|
||||
public var makeTextRenderer: (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer
|
||||
|
||||
/// 初始化依赖容器
|
||||
/// - Parameters:
|
||||
/// - environment: 显示环境实例
|
||||
/// - makeParser: EPUB 解析器工厂闭包
|
||||
/// - makePaginator: 分页器工厂闭包
|
||||
/// - makeTextBookBuilder: 富文本书籍构建器工厂闭包
|
||||
/// - makePlainTextBookBuilder: 纯文本书籍构建器工厂闭包
|
||||
/// - makeTextRenderer: 文本渲染器工厂闭包
|
||||
public init(
|
||||
environment: any RDEPUBReaderDisplayEnvironment,
|
||||
makeParser: @escaping () -> RDEPUBParser,
|
||||
@ -42,6 +64,7 @@ public struct RDEPUBReaderDependencies {
|
||||
self.makeTextRenderer = makeTextRenderer
|
||||
}
|
||||
|
||||
/// 默认生产环境依赖,使用系统屏幕环境和标准组件实现
|
||||
public static var live: RDEPUBReaderDependencies {
|
||||
RDEPUBReaderDependencies(
|
||||
environment: RDEPUBUIScreenEnvironment(),
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
/// EPUB 阅读器加载协调器:负责 EPUB 文件的解析和出版物初始化。
|
||||
///
|
||||
/// 职责:
|
||||
/// - 判断是否需要执行首次加载
|
||||
/// - 后台解析 EPUB 文件并构建 Publication 模型
|
||||
/// - 将解析结果应用到阅读器上下文并触发分页
|
||||
final class RDEPUBReaderLoadCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
@ -7,6 +13,7 @@ final class RDEPUBReaderLoadCoordinator {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// 检查条件后启动首次加载,确保只执行一次且视图已布局。
|
||||
func startInitialLoadIfNeeded() {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView,
|
||||
@ -19,6 +26,7 @@ final class RDEPUBReaderLoadCoordinator {
|
||||
loadPublication()
|
||||
}
|
||||
|
||||
/// 后台解析 EPUB 文件,加载书签、高亮和阅读位置,完成后回调主线程。
|
||||
func loadPublication() {
|
||||
guard let controller = context.controller else { return }
|
||||
context.showLoading()
|
||||
@ -57,6 +65,7 @@ final class RDEPUBReaderLoadCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将解析完成的出版物应用到上下文,设置书签/高亮/会话,并触发分页。
|
||||
func applyParsedPublication(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
/// EPUB 阅读器位置协调器:负责阅读位置的恢复、查询和持久化。
|
||||
///
|
||||
/// 职责:
|
||||
/// - 根据保存的位置恢复阅读进度
|
||||
/// - 获取当前可见页面的阅读位置
|
||||
/// - 从持久化存储加载已保存位置
|
||||
/// - 持久化当前位置并通知委托
|
||||
final class RDEPUBReaderLocationCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
@ -7,6 +14,7 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// 恢复到指定阅读位置,返回是否成功跳转。
|
||||
@discardableResult
|
||||
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||||
guard let controller = context.controller,
|
||||
@ -30,6 +38,7 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
return true
|
||||
}
|
||||
|
||||
/// 获取当前可见页面对应的阅读位置。
|
||||
func currentVisibleLocation() -> RDEPUBLocation? {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView else {
|
||||
@ -41,6 +50,7 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
return context.readingSession?.currentReadingLocation(bookIdentifier: context.currentBookIdentifier)
|
||||
}
|
||||
|
||||
/// 从持久化存储加载上次保存的阅读位置。
|
||||
func persistenceLocation() -> RDEPUBLocation? {
|
||||
guard let controller = context.controller,
|
||||
let currentBookIdentifier = context.currentBookIdentifier else {
|
||||
@ -49,6 +59,7 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
return controller.persistence?.loadLocation(for: currentBookIdentifier)
|
||||
}
|
||||
|
||||
/// 持久化阅读位置,并通知委托更新目录项和书签状态。
|
||||
func persist(location: RDEPUBLocation) {
|
||||
guard let controller = context.controller,
|
||||
let currentBookIdentifier = context.currentBookIdentifier else { return }
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
/// EPUB 阅读器分页协调器:负责出版物的分页计算和页面数据更新。
|
||||
///
|
||||
/// 职责:
|
||||
/// - 根据出版物类型(文本重排/Fixed Layout/Web 内容)选择分页策略
|
||||
/// - 后台构建文本图书模型并应用分页快照
|
||||
/// - 重新分页时保持当前阅读位置
|
||||
/// - 刷新可见内容并保持位置
|
||||
/// - 重建外部纯文本图书
|
||||
final class RDEPUBReaderPaginationCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
@ -7,6 +15,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// 对出版物执行分页:文本重排走 TextBookBuilder,Fixed Layout 直接生成快照,Web 内容走 Paginator。
|
||||
func paginatePublication(restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller,
|
||||
let parser = context.parser,
|
||||
@ -79,6 +88,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用文本图书模型:生成分页快照并完成分页流程。
|
||||
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller else { return }
|
||||
context.textBook = textBook
|
||||
@ -93,6 +103,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 应用分页快照(Fixed Layout 或 Web 内容),并完成分页流程。
|
||||
func applyPaginationSnapshot(
|
||||
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
|
||||
restoreLocation: RDEPUBLocation?
|
||||
@ -109,6 +120,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 分页完成后的收尾:刷新视图、恢复阅读位置、处理待定视口变更。
|
||||
func finishPagination(restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView else { return }
|
||||
@ -125,6 +137,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
context.runtime?.viewportMonitor.processPendingChangeAfterPagination()
|
||||
}
|
||||
|
||||
/// 重新分页并保持当前阅读位置(优先使用待恢复位置,其次当前位置,最后持久化位置)。
|
||||
func repaginatePreservingCurrentLocation() {
|
||||
guard context.publication != nil else { return }
|
||||
let restoreLocation = context.runtime?.viewportMonitor.consumePendingPresentationRestoreLocation()
|
||||
@ -133,6 +146,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
paginatePublication(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 刷新可见内容并保持当前阅读位置不变。
|
||||
func refreshVisibleContentPreservingLocation() {
|
||||
guard let readerView = context.readerView else { return }
|
||||
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
|
||||
@ -142,6 +156,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 重建外部纯文本图书(布局变更后重新排版)。
|
||||
func rebuildExternalTextBook() {
|
||||
guard let controller = context.controller,
|
||||
let textFileURL = controller.textFileURL else { return }
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import UIKit
|
||||
|
||||
/// EPUB 阅读器运行时总协调器。
|
||||
/// 统一持有并分发给加载、分页、定位、搜索、工具栏、批注、视口监测等子协调器,
|
||||
/// 作为阅读器控制器的门面(Facade),简化外部调用。
|
||||
final class RDEPUBReaderRuntime {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
@ -15,18 +18,22 @@ final class RDEPUBReaderRuntime {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// 创建顶部工具栏视图
|
||||
func makeTopToolView() -> RDEPUBReaderTopToolView {
|
||||
chromeCoordinator.makeTopToolView()
|
||||
}
|
||||
|
||||
/// 创建底部工具栏视图
|
||||
func makeBottomToolView() -> RDEPUBReaderBottomToolView {
|
||||
chromeCoordinator.makeBottomToolView()
|
||||
}
|
||||
|
||||
/// 若尚未加载则启动首次加载流程
|
||||
func startInitialLoadIfNeeded() {
|
||||
loadCoordinator.startInitialLoadIfNeeded()
|
||||
}
|
||||
|
||||
/// 重新加载当前书籍,清空解析器、分页、批注等状态后从头初始化
|
||||
func reloadBook() {
|
||||
guard let readerView = context.readerView else { return }
|
||||
context.didStartInitialLoad = false
|
||||
@ -44,10 +51,20 @@ final class RDEPUBReaderRuntime {
|
||||
startInitialLoadIfNeeded()
|
||||
}
|
||||
|
||||
/// 跳转到指定阅读位置
|
||||
/// - Parameters:
|
||||
/// - location: 目标位置
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 跳转是否成功
|
||||
func go(to location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||||
locationCoordinator.restoreReadingLocation(location, animated: animated)
|
||||
}
|
||||
|
||||
/// 跳转到指定页码
|
||||
/// - Parameters:
|
||||
/// - pageNumber: 目标页码(从 1 开始)
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 跳转是否成功
|
||||
@discardableResult
|
||||
func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
|
||||
guard let controller = context.controller,
|
||||
@ -73,6 +90,7 @@ final class RDEPUBReaderRuntime {
|
||||
return true
|
||||
}
|
||||
|
||||
/// 清除当前选区
|
||||
func clearSelection() {
|
||||
annotationCoordinator.updateCurrentSelection(nil)
|
||||
}
|
||||
@ -168,20 +186,24 @@ final class RDEPUBReaderRuntime {
|
||||
annotationCoordinator.handleSelectionMenuAction(action, selection: selection)
|
||||
}
|
||||
|
||||
/// 按关键词搜索全文
|
||||
func search(keyword: String) {
|
||||
searchCoordinator.search(keyword: keyword)
|
||||
}
|
||||
|
||||
/// 跳转到下一个搜索匹配项
|
||||
@discardableResult
|
||||
func searchNext() -> Bool {
|
||||
searchCoordinator.searchNext()
|
||||
}
|
||||
|
||||
/// 跳转到上一个搜索匹配项
|
||||
@discardableResult
|
||||
func searchPrevious() -> Bool {
|
||||
searchCoordinator.searchPrevious()
|
||||
}
|
||||
|
||||
/// 清除搜索状态
|
||||
func clearSearch() {
|
||||
searchCoordinator.clearSearch()
|
||||
}
|
||||
@ -190,26 +212,32 @@ final class RDEPUBReaderRuntime {
|
||||
searchCoordinator.searchPresentation(for: page)
|
||||
}
|
||||
|
||||
/// 更新阅读器工具栏显示状态
|
||||
func updateReaderChrome() {
|
||||
chromeCoordinator.updateReaderChrome()
|
||||
}
|
||||
|
||||
/// 弹出阅读设置面板
|
||||
func presentSettings() {
|
||||
chromeCoordinator.presentSettings()
|
||||
}
|
||||
|
||||
/// 弹出目录面板
|
||||
func presentTableOfContents() {
|
||||
chromeCoordinator.presentTableOfContents()
|
||||
}
|
||||
|
||||
/// 处理返回操作
|
||||
func handleBackAction() {
|
||||
chromeCoordinator.handleBackAction()
|
||||
}
|
||||
|
||||
/// 启动 Publication 加载流程
|
||||
func loadPublication() {
|
||||
loadCoordinator.loadPublication()
|
||||
}
|
||||
|
||||
/// 将已解析的 Publication 应用到控制器
|
||||
func applyParsedPublication(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
@ -228,14 +256,17 @@ final class RDEPUBReaderRuntime {
|
||||
)
|
||||
}
|
||||
|
||||
/// 对 Publication 执行分页计算
|
||||
func paginatePublication(restoreLocation: RDEPUBLocation?) {
|
||||
paginationCoordinator.paginatePublication(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 应用外部 TextBook 并恢复阅读位置
|
||||
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
||||
paginationCoordinator.applyTextBook(textBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 应用分页快照并恢复阅读位置
|
||||
func applyPaginationSnapshot(
|
||||
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
|
||||
restoreLocation: RDEPUBLocation?
|
||||
@ -243,35 +274,43 @@ final class RDEPUBReaderRuntime {
|
||||
paginationCoordinator.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 完成分页流程并恢复阅读位置
|
||||
func finishPagination(restoreLocation: RDEPUBLocation?) {
|
||||
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 重新分页并保持当前阅读位置不变
|
||||
func repaginatePreservingCurrentLocation() {
|
||||
paginationCoordinator.repaginatePreservingCurrentLocation()
|
||||
}
|
||||
|
||||
/// 刷新当前可见内容,保持阅读位置不变
|
||||
func refreshVisibleContentPreservingLocation() {
|
||||
paginationCoordinator.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
/// 重建外部 TextBook 数据
|
||||
func rebuildExternalTextBook() {
|
||||
paginationCoordinator.rebuildExternalTextBook()
|
||||
}
|
||||
|
||||
/// 恢复到指定阅读位置
|
||||
@discardableResult
|
||||
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||||
locationCoordinator.restoreReadingLocation(location, animated: animated)
|
||||
}
|
||||
|
||||
/// 获取当前可见页面的阅读位置
|
||||
func currentVisibleLocation() -> RDEPUBLocation? {
|
||||
locationCoordinator.currentVisibleLocation()
|
||||
}
|
||||
|
||||
/// 获取当前视口签名快照
|
||||
func currentViewportSignature() -> RDEPUBViewportSignature? {
|
||||
viewportMonitor.currentViewportSignature()
|
||||
}
|
||||
|
||||
/// 视口变化时检查是否需要重新分页
|
||||
func handleViewportChangeIfNeeded(
|
||||
reason: RDEPUBViewportChangeReason,
|
||||
viewportSignature: RDEPUBViewportSignature? = nil
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
/// 搜索协调器,负责管理全文搜索的执行、结果导航和搜索状态通知。
|
||||
final class RDEPUBReaderSearchCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
@ -11,6 +12,8 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
context.controller
|
||||
}
|
||||
|
||||
/// 按关键词执行全文搜索,匹配结果自动导航到首个命中位置
|
||||
/// - Parameter keyword: 搜索关键词
|
||||
func search(keyword: String) {
|
||||
guard let controller else { return }
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@ -34,16 +37,21 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 跳转到下一个搜索匹配项
|
||||
/// - Returns: 是否成功跳转
|
||||
@discardableResult
|
||||
func searchNext() -> Bool {
|
||||
advanceSearch(by: 1)
|
||||
}
|
||||
|
||||
/// 跳转到上一个搜索匹配项
|
||||
/// - Returns: 是否成功跳转
|
||||
@discardableResult
|
||||
func searchPrevious() -> Bool {
|
||||
advanceSearch(by: -1)
|
||||
}
|
||||
|
||||
/// 清除搜索状态并刷新当前可见内容
|
||||
func clearSearch() {
|
||||
guard let controller else { return }
|
||||
controller.searchState = nil
|
||||
@ -51,6 +59,9 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
controller.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
/// 构建指定页面的搜索结果展示信息,用于高亮渲染
|
||||
/// - Parameter page: 目标页面
|
||||
/// - Returns: 搜索展示数据,若无搜索状态则返回 nil
|
||||
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
|
||||
guard let controller else { return nil }
|
||||
guard let searchState = controller.searchState,
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import UIKit
|
||||
|
||||
/// 视口监测器,监听视图布局和屏幕旋转等视口变化事件,
|
||||
/// 检测变化是否显著,必要时触发重新分页或内容重建。
|
||||
final class RDEPUBReaderViewportMonitor {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
@ -16,6 +18,7 @@ final class RDEPUBReaderViewportMonitor {
|
||||
context.controller
|
||||
}
|
||||
|
||||
/// 视图布局完成后检查视口是否发生变化,首次布局时触发初始加载
|
||||
func viewDidLayoutSubviews() {
|
||||
guard let controller else { return }
|
||||
guard let viewportSignature = currentViewportSignature() else { return }
|
||||
@ -38,6 +41,8 @@ final class RDEPUBReaderViewportMonitor {
|
||||
handleViewportChangeIfNeeded(reason: .viewLayout, viewportSignature: viewportSignature)
|
||||
}
|
||||
|
||||
/// 屏幕旋转前捕获当前阅读位置,旋转完成后检测视口变化并处理
|
||||
/// - Parameter coordinator: 转场协调器
|
||||
func viewWillTransition(with coordinator: UIViewControllerTransitionCoordinator) {
|
||||
guard let controller else { return }
|
||||
guard controller.didStartInitialLoad else { return }
|
||||
@ -52,6 +57,7 @@ final class RDEPUBReaderViewportMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置所有视口状态,用于重新加载书籍
|
||||
func resetForReload() {
|
||||
lastAppliedViewportSignature = currentViewportSignature()
|
||||
pendingViewportChangeReason = nil
|
||||
@ -59,16 +65,19 @@ final class RDEPUBReaderViewportMonitor {
|
||||
isWaitingForViewportTransitionCompletion = false
|
||||
}
|
||||
|
||||
/// 消费并返回待恢复的阅读位置(一次性读取后清空)
|
||||
func consumePendingPresentationRestoreLocation() -> RDEPUBLocation? {
|
||||
defer { pendingPresentationRestoreLocation = nil }
|
||||
return pendingPresentationRestoreLocation
|
||||
}
|
||||
|
||||
/// 主动捕获当前阅读位置到待恢复队列,供后续视口变化后恢复使用
|
||||
func capturePendingPresentationRestoreLocation() {
|
||||
guard let controller else { return }
|
||||
pendingPresentationRestoreLocation = controller.currentVisibleLocation() ?? controller.persistenceLocation()
|
||||
}
|
||||
|
||||
/// 分页完成后处理挂起的视口变化(如有),避免分页过程中重复触发
|
||||
func processPendingChangeAfterPagination() {
|
||||
guard let pendingReason = pendingViewportChangeReason else { return }
|
||||
pendingViewportChangeReason = nil
|
||||
@ -77,6 +86,7 @@ final class RDEPUBReaderViewportMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前视口签名,包含容器尺寸和安全区域信息
|
||||
func currentViewportSignature() -> RDEPUBViewportSignature? {
|
||||
guard let controller else { return nil }
|
||||
let containerSize = controller.readerView.bounds.size == .zero ? controller.view.bounds.size : controller.readerView.bounds.size
|
||||
@ -92,6 +102,7 @@ final class RDEPUBReaderViewportMonitor {
|
||||
)
|
||||
}
|
||||
|
||||
/// 检测视口签名是否发生显著变化,若变化则触发重新分页或重建外部 TextBook
|
||||
func handleViewportChangeIfNeeded(
|
||||
reason: RDEPUBViewportChangeReason,
|
||||
viewportSignature: RDEPUBViewportSignature? = nil
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import UIKit
|
||||
|
||||
/// 自定义 UITextView,替换系统默认的 UIMenuItem 为自定义操作(拷贝、高亮、批注)
|
||||
/// 可选文本视图,继承自 UITextView。
|
||||
/// 替换系统默认的 UIMenuItem 为自定义操作(拷贝、高亮、批注),通过 `onSelectionAction` 回调通知外部。
|
||||
final class RDEPUBSelectableTextView: UITextView {
|
||||
/// 选择菜单操作回调
|
||||
/// 选择菜单操作回调,当用户选择拷贝、高亮或批注时触发
|
||||
var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)?
|
||||
|
||||
/// 判断指定菜单项是否可执行,仅允许自定义的拷贝、高亮、批注操作
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
switch action {
|
||||
case #selector(rd_copy(_:)),
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
import UIKit
|
||||
|
||||
/// 前景覆盖层,负责绘制高亮、搜索命中和当前选区。
|
||||
/// 原生文本渲染路径的批注覆盖层,负责绘制用户高亮、搜索命中高亮和当前选区装饰。
|
||||
final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
||||
/// 将用户高亮批注以富文本属性形式应用到页面内容上
|
||||
/// - Parameters:
|
||||
/// - highlights: 高亮批注数组
|
||||
/// - content: 待修改的富文本
|
||||
/// - page: 目标文本页
|
||||
/// - contentBaseOffset: 内容在全局偏移中的起始位置
|
||||
func applyHighlights(
|
||||
_ highlights: [RDEPUBHighlight],
|
||||
to content: NSMutableAttributedString,
|
||||
@ -38,6 +44,12 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将搜索命中高亮以富文本背景色形式应用到页面内容上
|
||||
/// - Parameters:
|
||||
/// - content: 待修改的富文本
|
||||
/// - page: 目标文本页
|
||||
/// - searchState: 当前搜索状态
|
||||
/// - contentBaseOffset: 内容在全局偏移中的起始位置
|
||||
func applySearchHighlights(
|
||||
to content: NSMutableAttributedString,
|
||||
page: RDEPUBTextPage,
|
||||
@ -65,6 +77,13 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建页面的背景和前景装饰数组(搜索高亮为背景,下划线批注为前景)
|
||||
/// - Parameters:
|
||||
/// - page: 目标文本页
|
||||
/// - highlights: 高亮批注数组
|
||||
/// - searchState: 当前搜索状态
|
||||
/// - interactionController: 用于计算选区矩形的交互控制器
|
||||
/// - Returns: 分离的背景和前景装饰数组
|
||||
func buildDecorations(
|
||||
page: RDEPUBTextPage,
|
||||
highlights: [RDEPUBHighlight],
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import UIKit
|
||||
|
||||
/// 背景覆盖层,负责绘制页内装饰和位于正文下方的提示层。
|
||||
/// 原生文本渲染路径的页级背景覆盖层。
|
||||
/// 继承自 RDEPUBSelectionOverlayView,负责绘制位于正文下方的页内装饰(如搜索高亮背景、批注背景等)。
|
||||
final class RDEPUBTextPageDecorationView: RDEPUBSelectionOverlayView {}
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import UIKit
|
||||
|
||||
/// UIColor 十六进制颜色扩展,提供从 HEX 字符串创建颜色的便捷方法。
|
||||
extension UIColor {
|
||||
/// 从十六进制字符串创建颜色
|
||||
/// - Parameters:
|
||||
/// - rdHexString: 颜色 HEX 字符串,支持带或不带 "#" 前缀(如 "#FF5733" 或 "FF5733"),必须为 6 位
|
||||
/// - alpha: 透明度(0.0 ~ 1.0)
|
||||
convenience init?(rdHexString: String, alpha: CGFloat) {
|
||||
var value = rdHexString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
value = value.replacingOccurrences(of: "#", with: "")
|
||||
|
||||
@ -1,6 +1,17 @@
|
||||
//
|
||||
// RDReaderPreloadController.swift
|
||||
// ReadViewSDK
|
||||
//
|
||||
// 文件职责:页面预加载控制器,负责提前渲染即将显示的页面视图以减少翻页卡顿。
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 页面预加载控制器
|
||||
/// 负责管理页面视图的预加载和缓存,在当前页面基础上提前渲染前后若干页,
|
||||
/// 以减少翻页时的等待时间。支持仿真翻页和滚动模式下的不同复用策略。
|
||||
final class RDReaderPreloadController {
|
||||
/// 预加载半径,表示在当前页前后各预加载的页数,默认为 1
|
||||
var radius: Int = 1
|
||||
|
||||
private let preloadHostView = UIView()
|
||||
@ -8,6 +19,7 @@ final class RDReaderPreloadController {
|
||||
private var pageCurlCachedViews: [Int: UIView] = [:]
|
||||
private var cacheSignature: CacheSignature?
|
||||
|
||||
/// 预加载环境参数,封装翻页模式、屏幕方向、页面尺寸等上下文信息
|
||||
struct Environment {
|
||||
let displayType: RDReaderView.DisplayType
|
||||
let isLandscape: Bool
|
||||
@ -26,10 +38,12 @@ final class RDReaderPreloadController {
|
||||
let boundsSize: CGSize
|
||||
}
|
||||
|
||||
/// 设置预加载宿主视图的 frame
|
||||
func setHostFrame(_ frame: CGRect) {
|
||||
preloadHostView.frame = frame
|
||||
}
|
||||
|
||||
/// 将预加载宿主视图添加到父视图中(如果尚未添加),置于最底层且不可见
|
||||
func ensureHostView(in parentView: UIView) {
|
||||
guard preloadHostView.superview == nil else { return }
|
||||
preloadHostView.isHidden = true
|
||||
@ -40,10 +54,12 @@ final class RDReaderPreloadController {
|
||||
parentView.insertSubview(preloadHostView, at: 0)
|
||||
}
|
||||
|
||||
/// 根据当前环境初始化缓存签名,用于后续检测环境是否发生变化
|
||||
func initializeSignature(_ environment: Environment) {
|
||||
cacheSignature = currentCacheSignature(environment)
|
||||
}
|
||||
|
||||
/// 清除所有已缓存的页面视图并更新缓存签名
|
||||
func invalidate(environment: Environment) {
|
||||
pageCurlCachedViews.values.forEach { $0.removeFromSuperview() }
|
||||
preloadedPageViews.values.forEach { $0.removeFromSuperview() }
|
||||
@ -52,6 +68,7 @@ final class RDReaderPreloadController {
|
||||
cacheSignature = currentCacheSignature(environment)
|
||||
}
|
||||
|
||||
/// 获取指定页码的页面视图用于显示,优先复用已缓存的视图
|
||||
func pageViewForDisplay(
|
||||
pageNum: Int,
|
||||
environment: Environment,
|
||||
@ -63,12 +80,14 @@ final class RDReaderPreloadController {
|
||||
return view
|
||||
}
|
||||
|
||||
/// 取出指定页码的预加载视图并从缓存中移除,供外部复用
|
||||
func takePreloadedView(for pageNum: Int) -> UIView? {
|
||||
let preloaded = preloadedPageViews.removeValue(forKey: pageNum)
|
||||
preloaded?.removeFromSuperview()
|
||||
return preloaded
|
||||
}
|
||||
|
||||
/// 预加载当前页周围指定半径内的页面,清除不再需要的缓存视图
|
||||
func prime(
|
||||
around pageNum: Int,
|
||||
preferredForward: Bool? = nil,
|
||||
|
||||
@ -1,6 +1,17 @@
|
||||
//
|
||||
// RDReaderSpreadResolver.swift
|
||||
// ReadViewSDK
|
||||
//
|
||||
// 文件职责:页面展开(spread)解析器,负责计算双页模式下的页面配对和翻页目标。
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 页面展开解析器
|
||||
/// 处理横屏双页模式下的页码配对逻辑,包括封面页独占、双页对齐、
|
||||
/// 以及相邻页面跳转等计算。
|
||||
struct RDReaderSpreadResolver {
|
||||
/// 判断指定页码是否为全屏独占页面(如封面页在双页模式下独占一屏)
|
||||
func isFullScreenPage(
|
||||
_ pageNum: Int,
|
||||
landscapeDualPageEnabled: Bool,
|
||||
@ -11,6 +22,8 @@ struct RDReaderSpreadResolver {
|
||||
return pageNum == coverIndex
|
||||
}
|
||||
|
||||
/// 计算指定页码在双页模式下的左右页配对,返回 (左页, 右页?)。
|
||||
/// 封面页独占时单独处理,后续页面按两页一组配对。
|
||||
func dualPagePair(
|
||||
for pageNum: Int,
|
||||
totalPages: Int,
|
||||
@ -32,6 +45,7 @@ struct RDReaderSpreadResolver {
|
||||
return (left, right)
|
||||
}
|
||||
|
||||
/// 计算从指定页码出发的下一个(或上一个)双页展开的起始页码
|
||||
func adjacentDualPage(
|
||||
from pageNum: Int,
|
||||
totalPages: Int,
|
||||
@ -49,6 +63,7 @@ struct RDReaderSpreadResolver {
|
||||
return dualPagePair(for: prevEnd, totalPages: totalPages, coverPageIndex: coverPageIndex).left
|
||||
}
|
||||
|
||||
/// 计算下一页(或上一页)的页码,自动根据单页/双页模式选择合适的算法
|
||||
func nextPage(
|
||||
from currentPage: Int,
|
||||
totalPages: Int,
|
||||
|
||||
@ -1,6 +1,20 @@
|
||||
//
|
||||
// RDReaderTapRegionHandler.swift
|
||||
// ReadViewSDK
|
||||
//
|
||||
// 文件职责:点击区域判定处理器,将屏幕点击坐标映射为翻页或工具栏操作事件。
|
||||
//
|
||||
|
||||
import CoreGraphics
|
||||
|
||||
/// 点击区域判定处理器
|
||||
/// 将屏幕三等分为左、中、右三个区域,根据点击位置和工具栏可见状态
|
||||
/// 决定触发上一页、下一页还是切换工具栏。
|
||||
struct RDReaderTapRegionHandler {
|
||||
/// 根据点击坐标和视图尺寸,解析点击事件类型
|
||||
/// - 左1/3区域:上一页(工具栏可见时转为 center)
|
||||
/// - 中1/3区域:切换工具栏
|
||||
/// - 右1/3区域:下一页(工具栏可见时转为 center)
|
||||
func resolveTapEvent(
|
||||
point: CGPoint,
|
||||
viewFrame: CGRect,
|
||||
|
||||
@ -1,3 +1,10 @@
|
||||
//
|
||||
// RDReaderView+CollectionView.swift
|
||||
// ReadViewSDK
|
||||
//
|
||||
// 文件职责:UICollectionView 数据源和自定义布局代理实现,处理水平滚动和垂直滚动两种翻页模式。
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// UICollectionView 数据源和自定义布局代理实现
|
||||
|
||||
@ -1,3 +1,10 @@
|
||||
//
|
||||
// RDReaderView+ContentAccess.swift
|
||||
// ReadViewSDK
|
||||
//
|
||||
// 文件职责:内容视图注册、复用和页面内容访问,提供类似 UITableView 的 register/dequeueReusable 机制。
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 内容视图注册和复用扩展
|
||||
|
||||
@ -1,3 +1,10 @@
|
||||
//
|
||||
// RDReaderView+PageCurl.swift
|
||||
// ReadViewSDK
|
||||
//
|
||||
// 文件职责:UIPageViewController 数据源和代理实现,处理仿真翻页模式下的页面数据供给和翻页事件。
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// UIPageViewController 数据源和代理实现
|
||||
|
||||
@ -1,9 +1,16 @@
|
||||
//
|
||||
// RDReaderView+ToolView.swift
|
||||
// ReadViewSDK
|
||||
//
|
||||
// 文件职责:工具栏管理扩展,处理工具栏的显示/隐藏动画、安装和布局约束。
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 工具栏管理扩展:处理工具栏的显示/隐藏动画、安装、布局
|
||||
extension RDReaderView {
|
||||
|
||||
/// 点击屏幕中央区域,切换工具栏的显示/隐藏
|
||||
/// 点击屏幕中央区域,切换工具栏的显示/隐藏,带动画效果
|
||||
func tapCenter() {
|
||||
refreshToolViewsFromProviderIfNeeded()
|
||||
isShowToolView = !isShowToolView
|
||||
@ -50,7 +57,7 @@ extension RDReaderView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断点击命中的视图是否在指定工具栏内
|
||||
/// 判断点击命中的视图是否在指定工具栏内,用于决定是否拦截点击事件
|
||||
func isHitView(_ hitView: UIView?, inside toolView: UIView, point: CGPoint) -> Bool {
|
||||
if toolView.frame.contains(point) {
|
||||
return true
|
||||
@ -60,11 +67,15 @@ extension RDReaderView {
|
||||
return hitView === toolView || hitView.isDescendant(of: toolView)
|
||||
}
|
||||
|
||||
/// 工具栏位置枚举
|
||||
enum ToolViewPosition {
|
||||
/// 顶部工具栏(如标题栏、导航栏)
|
||||
case top
|
||||
/// 底部工具栏(如进度条、操作按钮)
|
||||
case bottom
|
||||
}
|
||||
|
||||
/// 将工具栏安装到指定位置并设置布局约束,已安装则跳过
|
||||
func installToolViewIfNeeded(_ toolView: UIView, position: ToolViewPosition) {
|
||||
guard toolView.superview !== self else { return }
|
||||
|
||||
@ -97,6 +108,7 @@ extension RDReaderView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新顶部和底部工具栏的高度约束,通常在 safeAreaInsets 变化时调用
|
||||
func updateToolViewHeightConstraintsIfNeeded() {
|
||||
topToolViewHeightConstraint?.constant = resolvedToolViewHeight(for: .top)
|
||||
bottomToolViewHeightConstraint?.constant = resolvedToolViewHeight(for: .bottom)
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
// 文件职责:核心翻页容器视图,RDReaderView 模块的入口和中枢。
|
||||
// 该文件定义了阅读器的核心 UIView 子类 RDReaderView,负责:
|
||||
// 1. 管理四种翻页模式(仿真翻页、水平滚动、垂直滚动、水平覆盖滚动)
|
||||
// 1. 管理三种翻页模式(仿真翻页、水平滚动、垂直滚动)
|
||||
// 2. 实现手势分区逻辑(左1/3上一页、中1/3工具栏、右1/3下一页)
|
||||
// 3. 支持横屏双页显示(含封面页独占和页码配对算法)
|
||||
// 4. 支持从左往右(LTR)和从右往左(RTL)两种翻页方向
|
||||
@ -21,7 +21,7 @@ import UIKit
|
||||
|
||||
|
||||
/// 核心翻页容器视图
|
||||
/// RDReaderView 模块的核心类,负责管理四种翻页模式的切换、手势识别、
|
||||
/// RDReaderView 模块的核心类,负责管理三种翻页模式的切换、手势识别、
|
||||
/// 横屏双页显示、页面缓存预加载等核心功能。
|
||||
///
|
||||
/// 使用方式:
|
||||
@ -31,7 +31,7 @@ import UIKit
|
||||
/// 4. 调用 ``reloadData()`` 刷新数据
|
||||
///
|
||||
/// 支持的功能:
|
||||
/// - 四种翻页模式:pageCurl / horizontalScroll / verticalScroll
|
||||
/// - 三种翻页模式:pageCurl / horizontalScroll / verticalScroll
|
||||
/// - 横屏双页显示(含封面页独占逻辑)
|
||||
/// - RTL 翻页方向支持
|
||||
/// - 手势分区(左翻/工具栏/右翻)
|
||||
|
||||
@ -1,3 +1,10 @@
|
||||
//
|
||||
// RDReaderViewProtocols.swift
|
||||
// ReadViewSDK
|
||||
//
|
||||
// 文件职责:阅读器核心协议定义,包括数据源、代理、页面提供者、页面导航等接口。
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 阅读器数据源协议
|
||||
@ -19,10 +26,15 @@ import UIKit
|
||||
/// 与内容格式无关的统一分页提供者协议。
|
||||
/// 逐步替代面向 EPUB 命名的 ``RDReaderDataSource``,方便后续复用到 PDF 等其他阅读场景。
|
||||
@objc public protocol RDReaderPageProvider: NSObjectProtocol {
|
||||
/// 返回阅读器的总页数
|
||||
func numberOfPages(in readerView: RDReaderView) -> Int
|
||||
/// 返回指定页码的内容视图,支持通过 reusableView 复用已有视图
|
||||
func readerView(_ readerView: RDReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView
|
||||
/// 返回指定页码的唯一标识符,用于 UICollectionViewCell 复用
|
||||
@objc optional func pageIdentifier(in readerView: RDReaderView, index: Int) -> String?
|
||||
/// 返回顶部工具栏视图(可选)
|
||||
@objc optional func readerViewTopChrome(_ readerView: RDReaderView) -> UIView?
|
||||
/// 返回底部工具栏视图(可选)
|
||||
@objc optional func readerViewBottomChrome(_ readerView: RDReaderView) -> UIView?
|
||||
}
|
||||
|
||||
@ -35,9 +47,14 @@ import UIKit
|
||||
@objc optional func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool)
|
||||
}
|
||||
|
||||
/// 阅读器页面导航协议
|
||||
/// 提供与具体实现无关的页面导航接口,外部可通过此协议控制翻页和刷新。
|
||||
public protocol RDReaderPageNavigating: AnyObject {
|
||||
/// 当前显示的页码
|
||||
var currentPage: Int { get }
|
||||
/// 重新加载所有页面数据
|
||||
func reloadPages()
|
||||
/// 跳转到指定页码,可选是否带动画
|
||||
func transition(to page: Int, animated: Bool)
|
||||
}
|
||||
|
||||
@ -65,7 +82,10 @@ extension RDReaderView {
|
||||
|
||||
// MARK: - Legacy 适配器
|
||||
|
||||
/// 旧版数据源适配器
|
||||
/// 将 ``RDReaderDataSource`` 协议适配为 ``RDReaderPageProvider``,实现渐进式迁移。
|
||||
final class RDReaderLegacyDataSourceAdapter: NSObject, RDReaderPageProvider {
|
||||
/// 被适配的旧版数据源
|
||||
weak var dataSource: RDReaderDataSource?
|
||||
|
||||
func numberOfPages(in readerView: RDReaderView) -> Int {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user