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:
shen 2026-06-01 09:33:23 +08:00
parent 1efb9d172f
commit 948004eed1
56 changed files with 1691 additions and 792 deletions

View File

@ -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 │
│ RDURLReaderControllerURL 阅读入口) │
│ │
│ ReaderController/(协调器) │
│ RDEPUBReaderRuntime中央运行时协调器
│ RDEPUBReaderContext上下文状态容器
│ RDEPUBReaderDependencies依赖注入
│ RDEPUBReaderLoadCoordinatorEPUB 加载) │
│ RDEPUBReaderPaginationCoordinator分页协调
│ RDEPUBReaderLocationCoordinator位置持久化
│ RDEPUBReaderAnnotationCoordinator标注管理
│ RDEPUBReaderSearchCoordinator搜索
│ RDEPUBReaderChromeCoordinator工具栏
│ RDEPUBReaderAssemblyCoordinatorUI 组装) │
│ 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
│ │
│ RDReaderViewUIView统一翻页外壳
│ 4 种翻页模式pageCurl / horizontalScroll / │
│ verticalScroll / horizontalCoverScroll │
│ 3 种翻页模式pageCurl / horizontalScroll / │
│ verticalScroll │
│ RDReaderViewProtocolsDataSource / Delegate / DisplayType
│ +CollectionView / +ContentAccess / +PageCurl / +ToolView │
│ RDReaderFlowLayout / RDReaderContentCell │
│ RDReaderPageChildViewControllerpageCurl 页包装) │
│ RDReaderGestureController │
│ │
│ Paging/(翻页控制) │
│ RDReaderPagingController转场与排队
│ RDReaderPreloadController预加载与缓存
│ RDReaderSpreadResolver双页配对
│ RDReaderTapRegionHandler手势分区
└───────────────────────────┬─────────────────────────────┘
┌───────────────────────────▼─────────────────────────────┐
│ EPUBCore 层EPUB 引擎) │
│ │
│ Publication 层 │
解析与模型
│ RDEPUBParser+Archive / +Package / +TOC / │
│ +ReadingProfile / +Resources
│ RDEPUBPublication出版物聚合对象
│ RDEPUBModelsmetadata / manifest / spine 模型) │
│ RDEPUBReadingModelslocation / viewport / highlight
│ Models/ │
│ RDEPUBReadingLocationModelslocation 模型) │
│ RDEPUBPaginationModels分页模型
│ RDEPUBAnnotationModels标注模型
│ RDEPUBTextAnchor / RDEPUBTextRangeAnchor文本锚点
│ RDEPUBRenderRequest渲染请求模型
│ │
│ Services 层 │
服务层
│ RDEPUBResourceResolver资源 URL 统一入口) │
│ RDEPUBResourceURLSchemeHandlerss-reader:// 协议) │
│ RDEPUBPreferences展示参数聚合
│ RDEPUBPaginator离屏分页服务
│ RDEPUBStyleSheetBuilder / RDEPUBJavaScriptBridge │
│ RDEPUBFixedLayoutTemplate / RDEPUBAssetRepository │
│ │
│ Navigator 层 │
会话与导航
│ RDEPUBReadingSession状态机 + 会话协调) │
│ RDEPUBNavigatorState状态枚举
│ RDEPUBNavigatorLayoutContext │
│ │
Resource View 层
WebView 渲染
│ RDEPUBWebView+Configuration / +Reflowable / │
│ +FixedLayout / +JavaScriptBridge / │
│ +Search
│ RDEPUBResourceURLSchemeHandlerss-reader:// 协议) │
│ RDEPUBStyleSheetBuilder / RDEPUBJavaScriptBridge │
│ RDEPUBFixedLayoutTemplate / RDEPUBAssetRepository │
│ RDEPUBWebViewDebug调试日志工具
│ │
Search 层
│ 搜索 │
│ RDEPUBSearchEngine协议/ RDEPUBHTMLSearchEngine │
│ RDEPUBSearchModelsSearchMatch/Result/State/Presentation
└───────────────────────────┬─────────────────────────────┘
┌───────────────────────────▼─────────────────────────────┐
│ EPUBTextRendering 层(文本 EPUB 渲染) │
│ RDEPUBTextRenderer协议
│ RDEPUBDTCoreTextRendererDTCoreText 实现) │
│ RDEPUBTextRendererSupport / RDEPUBTextPaginationSupport │
│ RDEPUBTextBookBuilder / RDPlainTextBookBuilder │
│ RDEPUBTextLayouter / RDEPUBTextLayoutFrame │
│ RDEPUBTextBookCache / RDEPUBChapterData │
│ RDEPUBTextIndexTable / RDEPUBTextPerformanceSampler │
│ RDEPUBTextSearchEngine │
│ │
│ 渲染 │
│ RDEPUBTextRenderer协议
│ RDEPUBDTCoreTextRendererDTCoreText 实现) │
│ RDPlainTextBookBuilder纯文本书籍构建
│ RDEPUBTextPositionConverter位置转换器
│ RDEPUBTextSearchEngine文本搜索引擎
│ RDEPUBTextIndexTable / RDEPUBChapterData │
│ │
│ BuildPipeline/(构建管线) │
│ RDEPUBTextBookBuilder分页书籍构建器
│ RDEPUBTextBookCache / RDEPUBTextBookModels │
│ RDEPUBTextBuildPipelineInterfaces管线协议
│ RDEPUBPaginationCacheCoordinator缓存协调
│ RDEPUBChapterTailNormalizer章尾规范化
│ RDEPUBBuildDiagnosticsReporter诊断报告
│ RDEPUBTextPerformanceSampler性能采样
│ │
│ Pagination/(分页引擎) │
│ RDEPUBTextLayouter / RDEPUBTextLayoutFrame │
│ RDEPUBChapterPageCounter / RDEPUBCoreTextPageFrameFactory│
│ RDEPUBPageBreakPolicy断页策略
│ RDEPUBTextPaginationInterfaces分页协议
│ RDEPUBTextPaginationSupport分页支持
│ │
│ Typesetter/(排版管线) │
│ RDEPUBTypesettingPipeline排版管线编排
│ RDEPUBHTMLNormalizerHTML 规范化) │
│ RDEPUBStyleSheetComposerCSS 组合) │
│ RDEPUBFontNormalizer字体规范化
│ RDEPUBAttachmentNormalizer附件规范化
│ RDEPUBFragmentMarkerInjectorFragment 标记注入) │
│ 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 数据源协议

View File

@ -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` 相互转换。

View File

@ -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`,为种滚动模式提供布局计算
- 水平滚动:全屏宽 itempagingEnabled
- 垂直滚动:可变高度 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 翻页方向

View File

@ -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 │
└──────────────────────┴──────────────────────────┘
```
---
## 路径AEPUB 正文原生渲染 (核心路径)
这是 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:)
```
---
## 路径BWebView 渲染 (公众号/文集文章)
用于渲染**微信公众号文章、文集、书评**等富媒体内容。
```
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 存储 |

View File

@ -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 分层、类型器升级) |
## 测试与质量

View File

@ -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 自动化测试。下一步最值得投入的是分页回归基准、更多字体资源和书架/书库管理。

View File

@ -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 自动化测试。下一步最值得投入的是分页回归基准、更多字体资源和书架/书库管理。

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

View File

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

View File

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

View File

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

View File

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

View File

@ -181,13 +181,3 @@ public struct RDEPUBTextBook {
}
}
// MARK: -
/// EPUB publication
///
/// spine HTML / RDEPUBTextBook
///
///
/// - WXRead
/// - /
/// -

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import UIKit
import DTCoreText
#endif
///
struct RDEPUBAttachmentNormalizer {
///
private static var didLogFootnoteAttachment = false

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,13 +1,24 @@
import UIKit
/// HTMLCSS
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: HTMLCSS
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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,12 @@
import UIKit
/// EPUB
///
///
/// - highlightannotation
/// - 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 }

View File

@ -1,5 +1,11 @@
import UIKit
/// EPUB UI
///
///
/// - readerViewloadingIndicatorerrorLabel
/// -
/// -
final class RDEPUBReaderAssemblyCoordinator {
private unowned let context: RDEPUBReaderContext
@ -7,6 +13,7 @@ final class RDEPUBReaderAssemblyCoordinator {
self.context = context
}
/// readerViewloadingIndicatorerrorLabel
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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(_:)),

View File

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

View File

@ -1,4 +1,5 @@
import UIKit
///
///
/// RDEPUBSelectionOverlayView
final class RDEPUBTextPageDecorationView: RDEPUBSelectionOverlayView {}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,10 @@
//
// RDReaderView+CollectionView.swift
// ReadViewSDK
//
// UICollectionView
//
import UIKit
/// UICollectionView

View File

@ -1,3 +1,10 @@
//
// RDReaderView+ContentAccess.swift
// ReadViewSDK
//
// 访 UITableView register/dequeueReusable
//
import UIKit
///

View File

@ -1,3 +1,10 @@
//
// RDReaderView+PageCurl.swift
// ReadViewSDK
//
// UIPageViewController 仿
//
import UIKit
/// UIPageViewController

View File

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

View File

@ -6,7 +6,7 @@
//
// RDReaderView
// UIView RDReaderView
// 1. 仿
// 1. 仿
// 2. 1/31/31/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
/// - //

View File

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