diff --git a/Doc/ARCHITECTURE.md b/Doc/ARCHITECTURE.md index 39a093f..29e35d6 100644 --- a/Doc/ARCHITECTURE.md +++ b/Doc/ARCHITECTURE.md @@ -21,70 +21,143 @@ RDReaderView 是一个 iOS 阅读器组件库(CocoaPods),提供开箱即 │ ┌───────────────────────────▼─────────────────────────────┐ │ EPUBUI 层(library 级读者 UI) │ -│ RDEPUBReaderController(开箱即用入口,~1995 行) │ -│ RDEPUBReaderConfiguration / Theme / Settings / Persistence│ -│ TopToolView / BottomToolView / ToolView 基类 │ -│ ChapterList / Highlights / Bookmarks / Settings 面板 │ -│ RDEPUBTextContentView / RDEPUBWebContentView │ -│ RDEPUBPageInteractionController / SelectionOverlayView │ -│ RDEPUBPageLayoutSnapshot / RDURLReaderController │ +│ │ +│ 主控制器 │ +│ RDEPUBReaderController(开箱即用入口) │ +│ +ContentDelegates / +DataSource / +PublicAPI │ +│ +RenderSupport / +RuntimeBridge / +TableOfContents │ +│ RDURLReaderController(URL 阅读入口) │ +│ │ +│ ReaderController/(协调器) │ +│ RDEPUBReaderRuntime(中央运行时协调器) │ +│ RDEPUBReaderContext(上下文状态容器) │ +│ RDEPUBReaderDependencies(依赖注入) │ +│ RDEPUBReaderLoadCoordinator(EPUB 加载) │ +│ RDEPUBReaderPaginationCoordinator(分页协调) │ +│ RDEPUBReaderLocationCoordinator(位置持久化) │ +│ RDEPUBReaderAnnotationCoordinator(标注管理) │ +│ RDEPUBReaderSearchCoordinator(搜索) │ +│ RDEPUBReaderChromeCoordinator(工具栏) │ +│ RDEPUBReaderAssemblyCoordinator(UI 组装) │ +│ RDEPUBReaderViewportMonitor(视口变化监听) │ +│ │ +│ Settings/(配置与主题) │ +│ RDEPUBReaderConfiguration / RDEPUBReaderSettings │ +│ RDEPUBReaderSettingsViewController / RDEPUBReaderTheme │ +│ │ +│ TextPage/(文本页面交互) │ +│ RDEPUBTextContentView / RDEPUBTextPageRenderView │ +│ RDEPUBSelectableTextView / RDEPUBTextSelectionController│ +│ RDEPUBSelectionOverlayView / RDEPUBTextAnnotationOverlay│ +│ RDEPUBPageInteractionController / RDEPUBPageLayoutSnapshot│ +│ RDEPUBTextPageDecorationView │ +│ │ +│ 工具栏与面板 │ +│ RDEPUBReaderTopToolView / RDEPUBReaderBottomToolView │ +│ RDEPUBReaderToolView(基类) │ +│ RDEPUBReaderChapterListController(目录面板) │ +│ RDEPUBReaderHighlightsViewController(高亮管理) │ +│ RDEPUBReaderPersistence(位置持久化) │ +│ RDEPUBReaderDelegate / RDEPUBReaderTableOfContentsItem│ +│ RDEPUBWebContentView / RDEPUBWebDecorationOverlayView │ +│ RDEPUBViewportTypes / UIColor+RDEPUBHex │ └───────────────────────────┬─────────────────────────────┘ │ ┌───────────────────────────▼─────────────────────────────┐ │ 翻页容器层(RDReaderView) │ +│ │ │ RDReaderView(UIView,统一翻页外壳) │ -│ 4 种翻页模式:pageCurl / horizontalScroll / │ -│ verticalScroll / horizontalCoverScroll │ +│ 3 种翻页模式:pageCurl / horizontalScroll / │ +│ verticalScroll │ +│ RDReaderViewProtocols(DataSource / Delegate / DisplayType)│ +│ +CollectionView / +ContentAccess / +PageCurl / +ToolView │ │ RDReaderFlowLayout / RDReaderContentCell │ │ RDReaderPageChildViewController(pageCurl 页包装) │ +│ RDReaderGestureController │ +│ │ +│ Paging/(翻页控制) │ +│ RDReaderPagingController(转场与排队) │ +│ RDReaderPreloadController(预加载与缓存) │ +│ RDReaderSpreadResolver(双页配对) │ +│ RDReaderTapRegionHandler(手势分区) │ └───────────────────────────┬─────────────────────────────┘ │ ┌───────────────────────────▼─────────────────────────────┐ │ EPUBCore 层(EPUB 引擎) │ │ │ -│ Publication 层 │ +│ 解析与模型 │ │ RDEPUBParser(+Archive / +Package / +TOC / │ │ +ReadingProfile / +Resources) │ │ RDEPUBPublication(出版物聚合对象) │ │ RDEPUBModels(metadata / manifest / spine 模型) │ -│ RDEPUBReadingModels(location / viewport / highlight) │ +│ Models/ │ +│ RDEPUBReadingLocationModels(location 模型) │ +│ RDEPUBPaginationModels(分页模型) │ +│ RDEPUBAnnotationModels(标注模型) │ │ RDEPUBTextAnchor / RDEPUBTextRangeAnchor(文本锚点) │ │ RDEPUBRenderRequest(渲染请求模型) │ │ │ -│ Services 层 │ +│ 服务层 │ │ RDEPUBResourceResolver(资源 URL 统一入口) │ +│ RDEPUBResourceURLSchemeHandler(ss-reader:// 协议) │ │ RDEPUBPreferences(展示参数聚合) │ │ RDEPUBPaginator(离屏分页服务) │ +│ RDEPUBStyleSheetBuilder / RDEPUBJavaScriptBridge │ +│ RDEPUBFixedLayoutTemplate / RDEPUBAssetRepository │ │ │ -│ Navigator 层 │ +│ 会话与导航 │ │ RDEPUBReadingSession(状态机 + 会话协调) │ │ RDEPUBNavigatorState(状态枚举) │ │ RDEPUBNavigatorLayoutContext │ │ │ -│ Resource View 层 │ +│ WebView 渲染 │ │ RDEPUBWebView(+Configuration / +Reflowable / │ │ +FixedLayout / +JavaScriptBridge / │ │ +Search) │ -│ RDEPUBResourceURLSchemeHandler(ss-reader:// 协议) │ -│ RDEPUBStyleSheetBuilder / RDEPUBJavaScriptBridge │ -│ RDEPUBFixedLayoutTemplate / RDEPUBAssetRepository │ │ RDEPUBWebViewDebug(调试日志工具) │ │ │ -│ Search 层 │ +│ 搜索 │ │ RDEPUBSearchEngine(协议)/ RDEPUBHTMLSearchEngine │ │ RDEPUBSearchModels(SearchMatch/Result/State/Presentation)│ └───────────────────────────┬─────────────────────────────┘ - + │ ┌───────────────────────────▼─────────────────────────────┐ │ EPUBTextRendering 层(文本 EPUB 渲染) │ -│ RDEPUBTextRenderer(协议) │ -│ RDEPUBDTCoreTextRenderer(DTCoreText 实现) │ -│ RDEPUBTextRendererSupport / RDEPUBTextPaginationSupport │ -│ RDEPUBTextBookBuilder / RDPlainTextBookBuilder │ -│ RDEPUBTextLayouter / RDEPUBTextLayoutFrame │ -│ RDEPUBTextBookCache / RDEPUBChapterData │ -│ RDEPUBTextIndexTable / RDEPUBTextPerformanceSampler │ -│ RDEPUBTextSearchEngine │ +│ │ +│ 渲染 │ +│ RDEPUBTextRenderer(协议) │ +│ RDEPUBDTCoreTextRenderer(DTCoreText 实现) │ +│ RDPlainTextBookBuilder(纯文本书籍构建) │ +│ RDEPUBTextPositionConverter(位置转换器) │ +│ RDEPUBTextSearchEngine(文本搜索引擎) │ +│ RDEPUBTextIndexTable / RDEPUBChapterData │ +│ │ +│ BuildPipeline/(构建管线) │ +│ RDEPUBTextBookBuilder(分页书籍构建器) │ +│ RDEPUBTextBookCache / RDEPUBTextBookModels │ +│ RDEPUBTextBuildPipelineInterfaces(管线协议) │ +│ RDEPUBPaginationCacheCoordinator(缓存协调) │ +│ RDEPUBChapterTailNormalizer(章尾规范化) │ +│ RDEPUBBuildDiagnosticsReporter(诊断报告) │ +│ RDEPUBTextPerformanceSampler(性能采样) │ +│ │ +│ Pagination/(分页引擎) │ +│ RDEPUBTextLayouter / RDEPUBTextLayoutFrame │ +│ RDEPUBChapterPageCounter / RDEPUBCoreTextPageFrameFactory│ +│ RDEPUBPageBreakPolicy(断页策略) │ +│ RDEPUBTextPaginationInterfaces(分页协议) │ +│ RDEPUBTextPaginationSupport(分页支持) │ +│ │ +│ Typesetter/(排版管线) │ +│ RDEPUBTypesettingPipeline(排版管线编排) │ +│ RDEPUBHTMLNormalizer(HTML 规范化) │ +│ RDEPUBStyleSheetComposer(CSS 组合) │ +│ RDEPUBFontNormalizer(字体规范化) │ +│ RDEPUBAttachmentNormalizer(附件规范化) │ +│ RDEPUBFragmentMarkerInjector(Fragment 标记注入) │ +│ RDEPUBSemanticMarkerInjector(语义标记注入) │ +│ RDEPUBRenderDiagnosticsCollector(渲染诊断) │ +│ RDEPUBTextRendererSupport(渲染辅助工具) │ └──────────────────────────────────────────────────────────┘ ``` @@ -92,14 +165,13 @@ RDReaderView 是一个 iOS 阅读器组件库(CocoaPods),提供开箱即 ## 3. 翻页容器层(RDReaderView) -### 3.1 四种翻页模式 +### 3.1 三种翻页模式 | 模式 | 实现方式 | 特点 | |------|----------|------| | `pageCurl` | UIPageViewController | 原生翻书效果,手势由系统提供 | | `horizontalScroll` | UICollectionView + RDReaderFlowLayout | 每屏显示 2 项,水平分页滚动 | | `verticalScroll` | UICollectionView + RDReaderFlowLayout | 全宽项目,垂直连续滚动 | -| `horizontalCoverScroll` | UICollectionView + RDReaderFlowLayout | 覆盖滚动效果,Z 轴动画 | ### 3.2 数据源协议 diff --git a/Doc/EPUBUI_功能实现逻辑.md b/Doc/EPUBUI_功能实现逻辑.md index d304d06..525022b 100644 --- a/Doc/EPUBUI_功能实现逻辑.md +++ b/Doc/EPUBUI_功能实现逻辑.md @@ -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` 相互转换。 diff --git a/Doc/RDReaderView_功能实现逻辑.md b/Doc/RDReaderView_功能实现逻辑.md index fece1f8..30453e0 100644 --- a/Doc/RDReaderView_功能实现逻辑.md +++ b/Doc/RDReaderView_功能实现逻辑.md @@ -3,7 +3,7 @@ ## 1. 范围与目标 - 代码范围:`Sources/RDReaderView/ReaderView/`(5 个 Swift 文件) -- 目标:说明分页阅读器容器如何管理四种显示模式、DataSource/Delegate 协议、翻页交互、工具栏动画、双屏适配和 RTL 支持。 +- 目标:说明分页阅读器容器如何管理三种显示模式、DataSource/Delegate 协议、翻页交互、工具栏动画、双屏适配和 RTL 支持。 - 主链路关键词:`RDReaderDataSource -> reloadData -> DisplayType 切换 -> 翻页/滚动 -> RDReaderDelegate.pageNum -> 工具栏显隐`。 ## 2. 关键对象职责 @@ -13,7 +13,7 @@ - 文件:`Sources/RDReaderView/ReaderView/RDReaderView.swift`(~1219 行) - 入口方法:`reloadData()` - 职责: - - 管理四种显示模式的视图层级切换 + - 管理三种显示模式的视图层级切换 - 持有 `UIPageViewController`(pageCurl 模式)或 `UICollectionView`(滚动模式) - 处理点击手势(左/中/右三区域) - 管理工具栏(topToolView / bottomToolView)的显示/隐藏动画 @@ -25,10 +25,9 @@ - 文件:`Sources/RDReaderView/ReaderView/RDReaderFlowLayout.swift`(~375 行) - 职责: - - 继承 `UICollectionViewFlowLayout`,为三种滚动模式提供布局计算 + - 继承 `UICollectionViewFlowLayout`,为两种滚动模式提供布局计算 - 水平滚动:全屏宽 item,pagingEnabled - 垂直滚动:可变高度 item,累加计算 - - 水平覆盖滚动:zIndex 分层 + 阴影效果模拟深度 - 封面感知帧计算:封面页全屏宽,后续页面两两配对半屏宽 ### 2.3 内容 Cell `RDReaderContentCell` @@ -80,7 +79,7 @@ func pageNum(readerView: RDReaderView, pageNum: Int) 3. `reloadData` 内部调用 `switchReaderDisplayType(currentDisplayType)` 重建视图层级。 4. 同时从 `dataSource` 获取 `topToolView` 和 `bottomToolView` 并添加到视图层级。 -### 3.3 四种显示模式切换 +### 3.3 三种显示模式切换 **pageCurl 模式**: - 创建 `UIPageViewController`(transitionStyle: .pageCurl) @@ -99,13 +98,6 @@ func pageNum(readerView: RDReaderView, pageNum: Int) - 页面高度可变,通过 `RDReaderFlowLayoutDataSoure.heigtOfVerticalScrollPage` 查询 - collectionViewContentSize 为所有页面高度之和 -**horizontalCoverScroll 模式**: -- 水平分页,但带封面滑动动画 -- `layoutAttributesForElements` 中: - - 仅计算当前页附近的窄窗口内的 item attributes - - 当前页之前的 item zIndex = -1,当前及之后 zIndex = 1 - - 顶层页面边缘添加阴影效果 - ### 3.4 翻页交互 **点击手势**(`tapAction(tap:)`): @@ -170,8 +162,7 @@ func pageNum(readerView: RDReaderView, pageNum: Int) RDReaderView.DisplayType ├── .pageCurl // UIPageViewController 翻页效果 ├── .horizontalScroll // UICollectionView 水平滚动 - ├── .verticalScroll // UICollectionView 垂直滚动 - └── .horizontalCoverScroll // UICollectionView 水平覆盖动画 + └── .verticalScroll // UICollectionView 垂直滚动 ``` ### 5.2 翻页方向 diff --git a/Doc/WXRead/读书EPUB阅读器实现架构.md b/Doc/WXRead/读书EPUB阅读器实现架构.md deleted file mode 100644 index 444adfd..0000000 --- a/Doc/WXRead/读书EPUB阅读器实现架构.md +++ /dev/null @@ -1,560 +0,0 @@ -# 读书 EPUB 阅读器实现架构分析 - -> 分析日期: 2026-05-18 -> 基于读书 v10.0.3 - ---- - -## 核心结论:双渲染引擎架构 - -读书使用了 **两套渲染引擎**,根据内容类型选择不同的渲染路径: - -``` -┌─────────────────────────────────────────────────┐ -│ WRReaderViewController │ -│ (阅读器主控制器, 管理翻页和状态) │ -├─────────────────────────────────────────────────┤ -│ WRPageViewController │ -│ (基于 UIPageViewController) │ -│ 支持 UIPageCurl(仿真翻页) + Scroll(滑动) │ -├──────────────────────┬──────────────────────────┤ -│ 路径A: 原生渲染 │ 路径B: WebView 渲染 │ -│ (EPUB/书籍正文) │ (公众号/文集文章) │ -│ │ │ -│ WREpubTypesetter │ WKWebView + JS Bridge │ -│ DTCoreText │ weread-highlighter.js │ -│ CoreText 排版 │ rangy-*.js │ -│ NSAttributedString │ MediaPlatform.js/css │ -│ WRCoreTextLayouter │ Readability.js │ -│ WRPageView (draw) │ WRMPPageView │ -└──────────────────────┴──────────────────────────┘ -``` - ---- - -## 路径A:EPUB 正文原生渲染 (核心路径) - -这是 EPUB 阅读的**主要渲染方式**,完全用原生 CoreText 实现,不走 WebView。 - -### 1. EPUB 下载与解密流程 - -``` -服务器 ZIP 包 (加密) - │ - ▼ -WRBookNetwork.loadTarForEpubBookId:chapter:isPreload: - │ - ▼ -WRBookNetwork.handleUnzipWithBookId:zipPath:encryptKey:plainBookDirectory: - │ - 使用 encryptKey 解密 - │ - 解压到 plainBookDirectory - │ - 处理解压错误 handleUnzipErrorWithPath: - ▼ -WRBookNetwork.processEncryptedBookFileAtPath:encryptKey:book:chapterUid:isFromReview: - │ - ▼ -WREncryptedFileManager.decryptContentsOfFile:forBookId:isFileLost: - │ - keyForBookId: 获取每本书的密钥 - │ - 解密 EPUB 章节文件 - ▼ -WREncryptedFileManager.encryptFileForBookId:originalEncryptKey:atPath:toPath: - │ - 本地二次加密存储 (DRM 保护) - ▼ -本地缓存: epubImage 目录 + 解密后的 XHTML 文件 -``` - -**密钥管理**: -- `WRPreloadBookManager.saveEncryptKey:forPath:bookId:` - 预加载章节密钥 -- `WRPreloadBookManager.encryptKeyForPath:bookId:` - 读取密钥 -- `WREncryptedFileManager.keyForBookId:` - 每本书独立密钥 - -### 2. EPUB 解析与排版流程 - -``` -XHTML 章节文件 - │ - ▼ -WRBookNetwork.fileContentWithChapter:book:shouldRemoveHtmlTags:filterTranslateContent: - │ - 读取 XHTML 内容 - │ - 可选去除 HTML 标签 - │ - 过滤翻译内容 - ▼ -WREpubTypesetter.attributeStringWithFilePath:priority:insertArticleToolAttachment: - insertBookChapterToolAttachment:insertRecommendView:book:chapter: - pageFlippingStyle:renderErrorReason:isStyleFileNotFound:options: - │ - │ ┌─────────────────────────────────────────┐ - │ │ DTHTMLAttributedStringBuilder │ - │ │ (HTML -> NSAttributedString 转换器) │ - │ │ │ - │ │ 1. 解析 XHTML DOM 树 │ - │ │ 2. 读取 EPUB 内嵌 CSS (replace.css) │ - │ │ 3. 合并默认样式 (default.css) │ - │ │ 4. 应用用户主题样式 (dark.css) │ - │ │ 5. 转换为 NSAttributedString │ - │ │ - 保留字体、颜色、行高、对齐等属性 │ - │ │ - 处理图片 (NSTextAttachment) │ - │ │ - 处理超链接 │ - │ └─────────────────────────────────────────┘ - ▼ -NSAttributedString (富文本) - │ - ▼ -WRCoreTextLayouter (CoreText 排版引擎) - │ - │ ┌─────────────────────────────────────────┐ - │ │ DTCoreText 框架 (自定义修改版) │ - │ │ │ - │ │ DTCoreTextLayouter │ - │ │ └─ CTTypesetter │ - │ │ └─ CTFramesetter │ - │ │ └─ DTCoreTextLayoutFrame │ - │ │ └─ CTFrame (每页) │ - │ │ └─ DTCoreTextLayoutLine │ - │ │ └─ CTLine (每行) │ - │ │ └─ DTCoreTextGlyphRun │ - │ │ └─ CTLine (字形) │ - │ │ │ - │ │ 特殊处理: │ - │ │ - wr-vertical-center-style (图片居中) │ - │ │ - weread-page-relate (分页控制) │ - │ │ - avoidPageBreakInside (避免断页) │ - │ │ - 繁简转换 (convertHansToHant) │ - │ └─────────────────────────────────────────┘ - ▼ -WRChapterData (章节数据模型) - │ - │ - 包含排版后的 NSAttributedString - │ - 管理划线/高亮/书评等标注 - │ - addHighlightInRange:key:itemId:color: - │ - addUnderLineToAttributedString:range:itemId:style:color: - │ - addReviewUnderlineInRange:itemId:type: - │ - generateOutlineContents (生成目录) - │ - freeTrialChapterCutOffStringLocaion (免费试读截断) - ▼ -分页计算: WRChapterPageCount - │ - │ - rangeValueWithPageInfo: 计算每页的 NSRange - │ - rangeOfPage: 获取指定页的文本范围 - ▼ -WRPageView (页面视图, UIView 子类) - │ - │ - 继承 UIView - │ - drawRect: 中调用 CoreText 绘制 - │ - WRCoreTextLayoutFrame.drawInContext:image:size:inRect:position: - │ - 直接用 CGContext 绘制文字和图片 - │ - 不使用 UILabel/UITextView - ▼ -屏幕显示 (像素级精确渲染) -``` - -### 3. 翻页机制 - -``` -WRPageViewController - │ - │ 基于 UIPageViewController 封装 - │ - │ 初始化: initWithDelegate:withPageType:pageFlippingStyle: - │ - │ pageFlippingStyle 支持: - │ ┌────────────────────────────────────┐ - │ │ UIPageCurl - 仿真翻页 (纸张卷曲) │ - │ │ Scroll - 左右滑动翻页 │ - │ └────────────────────────────────────┘ - │ - │ 核心方法: - │ - pageViewController:viewControllerBeforeViewController: (上一页) - │ - pageViewController:viewControllerAfterViewController: (下一页) - │ - pageViewController:spineLocationForInterfaceOrientation: (书脊位置) - │ - weread_setViewControllers:withCurlOfType:fromLocation:direction: - │ animated:notifyDelegate:completion: (自定义设置方法) - │ - │ 故障修复: - │ - patchNavigationDirectionFault (导航方向修复) - │ - patchNoViewControllerManagingPageViewFault (页面管理修复) - │ - patchUIPageCurlFault (翻页动画修复) - │ - detectNavigationDirectionCrashWithPageViewController: (崩溃检测) - │ - ▼ -WRPageView (每个页面的渲染视图) - │ - │ - 通过 WRChapterData 获取排版结果 - │ - 通过 rangeOfPage: 获取当前页的文本范围 - │ - 使用 CoreText 直接绘制到 CGContext -``` - -### 4. 文本选择与标注 - -``` -用户触摸/长按 - │ - ▼ -WRPageView 手势识别 - │ - ▼ -文本位置计算 (CoreText hit test) - │ - CTLineGetStringIndexForPosition (坐标->字符索引) - │ - CTLineGetOffsetForStringIndex (字符索引->坐标) - ▼ -选择范围确定 - │ - ▼ -弹出操作菜单 (UIMenuController) - │ - 划线/高亮 - │ - 写想法/书评 - │ - 复制 - │ - 查询/翻译 - │ - 分享 - ▼ -WRChapterData 添加标注 - │ - addHighlightInRange:key:itemId:color: - │ - addUnderLineToAttributedString:range:itemId:style:color: - │ - addReviewUnderlineInRange:itemId:type: - ▼ -保存到服务器 - │ - WRBookNetwork.addReview:shareToWechat:... - │ - 同步书签: loadBookmarkListWithBookId:syncKey:callback: - ▼ -重新排版当前页 (recomposeCurrentPageViewWithSource:) -``` - ---- - -## 路径B:WebView 渲染 (公众号/文集文章) - -用于渲染**微信公众号文章、文集、书评**等富媒体内容。 - -``` -HTML 内容 (来自服务器) - │ - ▼ -WRMPReadingManager.composeMPReviewHTMLString:withReview: - │ - 组装 HTML 模板 - │ - 注入 CSS (MediaPlatform.css, MPExtra.css) - │ - 注入 JS (MediaPlatform.js, mpForArticle.js) - ▼ -WKWebView 加载 - │ - │ 注入脚本: - │ - │ - ▼ -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 存储 | diff --git a/Doc/index.md b/Doc/index.md index 6b72b69..dd81530 100644 --- a/Doc/index.md +++ b/Doc/index.md @@ -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 分层、类型器升级) | ## 测试与质量 diff --git a/Doc/阅读器功能开发计划.md b/Doc/阅读器功能开发计划.md index 87babd2..3009e04 100644 --- a/Doc/阅读器功能开发计划.md +++ b/Doc/阅读器功能开发计划.md @@ -1,7 +1,33 @@ # 阅读器功能开发计划 -基于 [阅读器规划.md](阅读器规划.md) 中的三方对比,本文档给出阅读器功能的开发计划与当前落地状态。 -功能完整度部分(书架、批注导出、阅读统计、TTS、全局搜索、云端同步、夜间定时)暂不展开。 +> 本文档整合了渲染质量三方对比(ReadViewSDK vs WXRead)与功能开发计划,作为阅读器能力演进的单一真值。 +> 基于读书 v10.0.3 逆向分析,与当前 ReadViewSDK 代码核查结果整合。 + +## 背景 + +渲染内核的架构对齐解决的是"代码可维护性"问题。本文档梳理从"能用的阅读器"到"能上架的商业阅读器"还需要补齐哪些能力,每项能力标注三方状态和具体实施计划。 + +### 三方对比总览(渲染质量) + +| 缺失项 | 严重度 | ReadViewSDK | WXRead | 差距说明 | +| --- | --- | --- | --- | --- | +| **竖排文字** | 高 | ❌ 未实现 | ❌ 未实现 | 双方均无 `writing-mode` 支持 | +| **Ruby 注音** | 高 | ❌ 未实现 | ❌ 未实现 | 双方均无 ``/`` 处理 | +| **数学公式** | 中 | ❌ 未实现 | ⚠️ 仅字体回退 | 非原生渲染范畴,需 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 自动化测试。下一步最值得投入的是分页回归基准、更多字体资源和书架/书库管理。 diff --git a/Doc/阅读器规划.md b/Doc/阅读器规划.md deleted file mode 100644 index 61edd64..0000000 --- a/Doc/阅读器规划.md +++ /dev/null @@ -1,162 +0,0 @@ -# 阅读器规划:从架构重构到商业版的差距 - -## 背景 - -渲染内核的架构对齐(WXRead 拆分类重构)解决的是"代码可维护性"问题。 -本文档梳理从"能用的阅读器"到"能上架的商业阅读器"还需要补齐哪些能力。 - -每项能力标注了三方状态:当前 ReadViewSDK 实现情况、WXRead(微信读书)反编译源码中的实现情况。 - -## 一、渲染质量(最直接影响用户体验) - -### 三方对比总览 - -| 缺失项 | 严重度 | ReadViewSDK | WXRead | 差距说明 | -| --- | --- | --- | --- | --- | -| **竖排文字** | 高 | ❌ 未实现 | ❌ 未实现 | 双方均无 `writing-mode` 支持。WXRead 同样不支持,不是对标项 | -| **Ruby 注音** | 高 | ❌ 未实现 | ❌ 未实现 | 双方均无 ``/`` 处理。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 自动化测试。下一步最值得投入的是分页回归基准、更多字体资源和书架/书库管理。 diff --git a/ReadViewDemo/ViewController.swift b/ReadViewDemo/ViewController.swift new file mode 100644 index 0000000..2483987 --- /dev/null +++ b/ReadViewDemo/ViewController.swift @@ -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? + 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(_ 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]) + } +} diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift b/Sources/RDReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift index 09b4a20..e8dc86a 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift +++ b/Sources/RDReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift @@ -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]") diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBPublication.swift b/Sources/RDReaderView/EPUBCore/RDEPUBPublication.swift index 8e657ea..df6248a 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBPublication.swift +++ b/Sources/RDReaderView/EPUBCore/RDEPUBPublication.swift @@ -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) diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBSearchEngine.swift b/Sources/RDReaderView/EPUBCore/RDEPUBSearchEngine.swift index 02981d7..b744f40 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBSearchEngine.swift +++ b/Sources/RDReaderView/EPUBCore/RDEPUBSearchEngine.swift @@ -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 diff --git a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift index 3b6be60..7b36b94 100644 --- a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift +++ b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift @@ -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, diff --git a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift index b3c9ed5..f5f5fdb 100644 --- a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift +++ b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift @@ -181,13 +181,3 @@ public struct RDEPUBTextBook { } } -// MARK: - 分页书籍构建器 - -/// 从 EPUB publication 构建分页书籍模型的核心构建器。 -/// -/// 渲染链路:遍历 spine → 渲染每章 HTML → 分页 → 合并/规范化尾页 → 构建 RDEPUBTextBook -/// -/// 特性: -/// - 支持分页缓存(WXRead 模式:只缓存页范围,不缓存富文本) -/// - 性能采样(记录每章渲染/分页耗时) -/// - 尾页规范化(丢弃纯空白尾页、合并过短尾页) diff --git a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBuildPipelineInterfaces.swift b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBuildPipelineInterfaces.swift index 285cb25..82d31ad 100644 --- a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBuildPipelineInterfaces.swift +++ b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBuildPipelineInterfaces.swift @@ -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, diff --git a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationInterfaces.swift b/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationInterfaces.swift index d33e3f0..2aab692 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationInterfaces.swift +++ b/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBTextPaginationInterfaces.swift @@ -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, diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPositionConverter.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPositionConverter.swift index 02e8c97..219560a 100644 --- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPositionConverter.swift +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPositionConverter.swift @@ -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? diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBAttachmentNormalizer.swift b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBAttachmentNormalizer.swift index 95aa829..96fa57d 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBAttachmentNormalizer.swift +++ b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBAttachmentNormalizer.swift @@ -4,6 +4,7 @@ import UIKit import DTCoreText #endif +/// 附件布局规范化器,负责缩放脚注、封面和通用图片的显示尺寸。 struct RDEPUBAttachmentNormalizer { /// 脚注附件日志已输出标记(避免重复日志) private static var didLogFootnoteAttachment = false diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBFontNormalizer.swift b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBFontNormalizer.swift index ec1023f..0b09cbd 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBFontNormalizer.swift +++ b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBFontNormalizer.swift @@ -1,6 +1,7 @@ import UIKit import CoreText +/// 字体规范化器,负责注册 EPUB 嵌入字体并映射到用户设置字体。 struct RDEPUBFontNormalizer { /// 已注册字体资源,避免重复调用 CTFontManager。 private static var registeredFontPaths = Set() @@ -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 } diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBFragmentMarkerInjector.swift b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBFragmentMarkerInjector.swift index febae4f..1382faf 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBFragmentMarkerInjector.swift +++ b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBFragmentMarkerInjector.swift @@ -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) } diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBHTMLNormalizer.swift b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBHTMLNormalizer.swift index 67d8b60..bd1c7f2 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBHTMLNormalizer.swift +++ b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBHTMLNormalizer.swift @@ -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) } diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBRenderDiagnosticsCollector.swift b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBRenderDiagnosticsCollector.swift index e268b5e..e3b37b2 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBRenderDiagnosticsCollector.swift +++ b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBRenderDiagnosticsCollector.swift @@ -1,11 +1,17 @@ import Foundation +/// 渲染诊断收集器,扫描 HTML 中的资源引用并生成诊断信息。 struct RDEPUBRenderDiagnosticsCollector { /// 外部样式表链接正则 private static let stylesheetLinkPattern = #"]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"# /// 图片源地址正则 private static let imageSourcePattern = #"]*src\s*=\s*["']([^"']+)["'][^>]*>"# + /// 收集 HTML 中图片资源的引用诊断。 + /// - Parameters: + /// - html: 待扫描的 HTML + /// - input: 排版输入上下文 + /// - Returns: 资源引用诊断列表 func collect( in html: String, input: RDEPUBTypesettingInput diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift index efd8c1d..b06a047 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift +++ b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift @@ -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? diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift index 52ba1e2..a173887 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift +++ b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift @@ -1,13 +1,24 @@ import UIKit +/// 样式表组合结果,包含合成后的 HTML、CSS 层列表及诊断信息。 struct RDEPUBStyleSheetComposition { + /// 合成后的 HTML 文本 var html: String + /// 五层 CSS 样式层(default/replace/dark/epub/user) var layers: [RDEPUBTextStyleSheetLayer] + /// 内联后的 CSS 内容 var inlinedCSS: String + /// 资源引用诊断列表 var diagnostics: [RDEPUBTextResourceReferenceDiagnostic] } +/// EPUB 样式表合成器,负责组装五层 CSS 并注入 HTML。 struct RDEPUBStyleSheetComposer { + /// 将 CSS 层注入 HTML,返回合成结果。 + /// - Parameters: + /// - html: 原始 HTML + /// - input: 排版输入上下文 + /// - Returns: 包含合成 HTML、CSS 层和诊断信息的结果 func compose(html: String, input: RDEPUBTypesettingInput) -> RDEPUBStyleSheetComposition { let stylesheetHrefReplacements = RDEPUBRenderDiagnosticsCollector.inlineLinkedStyleSheets( in: html, @@ -84,8 +95,11 @@ struct RDEPUBStyleSheetComposer { // MARK: - Style 注入 + /// `