109 Commits
Author SHA1 Message Date
shenlei e59bad9abf PDF 阅读器新增点读热区、试读墙与阅读进度/书签持久化
宿主的点读书场景需要在 PDF 页面上叠加可点击热区(音频/视频/网页等),并支持
试读限制与阅读进度、书签的本地保存,这些能力此前 RDPDFReaderView 均不提供,
只能由宿主在阅读器之外自行叠加视图,无法跟随 SDK 的缩放、翻页和主题渲染同步。

新增 RDPDFReaderPageInteraction 描述热区(位置、图标、边框/填充样式、点击态、
闪烁与播放边框规则、内嵌视频),由新增的 RDPDFInteractionHotspotView 负责绘制,
SVG 图标经 RDPDFReaderSVGIconLoader(基于 SDWebImage/SDWebImageSVGCoder)异步
解码后回填,避免阻塞主线程;点击事件通过 interactionHandler 回传给宿主,媒体
播放、路由等业务仍留在宿主侧。底部工具栏新增点读提示与连播按钮,状态由宿主驱动。

试读墙对齐 EPUB 侧 RDEPUBReaderTrialPolicy 的设计:以页为限制单位,可读页数在
配置了 trialPolicy 且宿主经 delegate 提供墙视图时生效,原始总页数、缩略图、目录
换算不受影响,只有传给 RDPDFReaderView 的有效页数会追加一页试读墙;翻页、目录
跳转、大纲的上界都统一收敛到可读范围,避免用户绕过热区跳转或朗读高亮跳进未授权页。

阅读进度与书签复用 RDPDFReaderPersistenceStore 已有的标注状态目录,新增
reader-state.json 承载,不需要宿主额外提供数据库表;RDPDFReaderPersistenceStore
同时实现 RDPDFReaderPersistence 协议对接这部分读写。
2026-08-14 12:13:04 +09:00
shenlei 278072aca0 停止跟踪 Demo 的 CocoaPods 产物,改为本地 pod install 生成
ReadViewDemo/Pods 此前整目录签入仓库,但白名单式的 .gitignore 只对 RDEpubReaderView、
RDPDFReaderView 的 Local Podspecs/Target Support Files 和 SnapKit 生效,其余第三方 pod
(DTCoreText、DTFoundation、ZIPFoundation,以及本次新增的 SDWebImage、SDWebImageSVGCoder)
反而因为父目录被显式取消忽略而绕过了全局 gitignore 的 Pods 规则,被当作普通文件跟踪,
导致每次 pod install 产生的构建产物、xcconfig、Pods.xcodeproj 都会出现在 diff 里。

改为直接忽略 ReadViewDemo/Pods 与 ReadViewDemo/Podfile.lock 整体,并 git rm --cached
移除已跟踪的文件(工作区文件不受影响)。此后 Demo 工程的依赖状态完全由 Podfile 决定,
拉取仓库后本地执行 pod install 即可重建,不再需要区分哪些 pod 目录该签入、哪些该忽略。
2026-08-14 12:12:39 +09:00
shenlei 3e60bf1869 修复阅读器资源、朗读与持久化稳定性
阅读器在多会话朗读、加密 EPUB 资源、并发打开同一本书和多 UserDefaults 容器等场景下,存在旧异步结果覆盖新状态、锁屏控制串会话、大型明文资源被误拒绝、解压半成品被复用及标注跨容器混用的风险;同时高亮、书签、搜索和设置面板的空状态与自动化可访问性入口不完整。\n\n本次为朗读会话引入代次和当前 utterance 校验,远程控制改为仅响应当前会话并按自身 token 清理;加密资源 provider 先判定是否实际返回解密数据,明文大资源继续流式读取;解压缓存串行化以避免并发复用未完成目录。书签与高亮迁移至受保护文件,按 UserDefaults 容器隔离命名空间,保留标准容器旧文件兼容并确保新副本成功落盘后才删除历史数据。\n\n同步调整缓存淘汰、FoundationModels 弱链接、搜索/标注/设置面板交互与 UI 测试,并更新 Pod 生成配置及 API、风险文档。已执行 git diff --check 和 ReadViewDemo Debug Simulator 构建,结果为 BUILD SUCCEEDED。
2026-07-30 20:52:21 +09:00
shenlei e01cbc169d 修复 EPUB 与 PDF 阅读器朗读交互
PDF 朗读的逐词回调会在同一页内持续触发高亮,原实现每次都调用翻页入口,使用户在朗读过程中反复看到页面转场。现仅在目标页变化时定位,并对朗读产生的跨页定位禁用动画,保留高亮刷新而不重复翻页。\n\nEPUB/TXT 此前只提供内容源,缺少将朗读进度回写到阅读器的会话层,无法从当前阅读位置开始朗读、随内容跨页及显示临时高亮。新增 EPUB 朗读会话,使用 href 与 UTF-16 偏移恢复位置;文本重排 EPUB 与 TXT 按实际页面定位并高亮,Web EPUB 则按章节安全定位以避免词级回调导致跳页。URL 阅读器同时提供可选会话入口,图片型阅读器保持不可朗读。\n\n已执行 ReadViewDemo 的 Debug Simulator 构建,结果为 BUILD SUCCEEDED。
2026-07-30 18:33:04 +09:00
shenleiandClaude Opus 5 529663829f 修复退出 EPUB 阅读器后宿主侧滑返回手势被改成阅读器的配置
阅读器在 viewWillAppear 中无条件把导航控制器的 interactivePopGestureRecognizer
接管为自己的 delegate 并强制开启,退出时却不做任何还原。宿主如果原本关闭了
侧滑返回,或为它设置过自定义 delegate,从阅读器返回后这些配置就被永久改写,
其它页面的返回手势行为随之变化。阅读器自身接管横向翻页,进入期间开启侧滑返回
也会与翻页手势争抢边缘触摸。

改为进入时记录原有的开关状态与 delegate 并直接关闭该手势,退出时按记录值还原。
记录只在首次 viewWillAppear 时进行,避免阅读器内部多次前后台切换把自己写入的
状态当成宿主原值。

该修复原本以本地补丁形式存在于 readoor 项目的 Vendor 副本中,现回流上游,避免
后续同步 SDK 时反复丢失。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:03:43 +09:00
shenleiandClaude Opus 5 d830311894 修复 PDF 仿真翻页模式下翻页后页面全白
打开普通 PDF 并使用仿真翻页阅读时,除首页外每翻一页都是纯白:没有页面
内容,也没有页面四周的衬底色。横向滚动与竖滑模式不受影响。样张
英汉大词典App需求说明.pdf 上第 2 到第 9 页无一例外。

根因是页面数据回来时找不到对应的视图。UIPageViewController 会通过
viewControllerBefore/After 提前构建相邻页的内容视图,构建时页面描述尚未
就绪,configure 发起异步 readerPage 请求后先返回一张空白页。描述返回后
acceptPageDescriptor 调用 refreshVisiblePage,而它经由
RDPDFReaderView.pageContentView(pageNum:) 定位视图,该方法在仿真翻页下
只检索 nativeCurlPageController.viewControllers,也就是已经上屏的 child。
预建的 child 此时还不在其中,图片因此从未被装配上去;翻页动画结束后又
没有任何路径重新 configure 该视图,白页就固定下来。此前偶尔能看到内容,
是 OCR 完成回调顺带触发了一次刷新,并非稳定行为。

改为按页码刷新该页的全部活动视图:控制器用弱引用表记录已创建的页面视图,
refreshVisiblePage 改名为 refreshPageViews(at:),除已上屏视图外一并装配
尚未上屏的预建视图,pageContentView 创建时即写入 tag 以便按页码匹配。
翻页落定时在 pageNum 回调中补一次装配,使内存告警清掉页面描述的场景也能
重新发起请求并恢复。另外把 image 为空的页面描述改按无效结果处理,走既有
的重试与失败重载入口,不再缓存出一张永远不会重试的白页。

新增 PDFPageTurnBlankPageTests 作为回归:翻页稳定后截屏统计画面中部深色
像素占比,白页为 0,扉页约 0.002,正文页在 0.01 以上,以 0.0005 为判定
阈值,覆盖仿真翻页正翻、仿真回翻和横向滚动三种路径。

验证:iPhone 17 Pro / iOS 26.4.1 模拟器执行 PDFPageTurnBlankPageTests、
PDFAnnotationTests、PDFDrawingModeTests、PageNavigationTests、
DisplayTypeTests 共 23 条用例,22 条通过。修复前仿真翻页第 2 到 9 页墨迹
占比全为 0.0000,修复后与横向滚动模式逐页吻合。唯一失败的
DisplayTypeTests.testDisplayTypeSwitchMidSession 属 EPUB 设置面板既有问题,
将本次改动还原后同样失败。文档补充了 Demo 因链接 FoundationModels 而必须
使用 iOS 26 及以上模拟器的说明。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:52:29 +09:00
shenleiandClaude Opus 5 735c14c824 隐藏 PDF Demo 阅读器中的 AI 助手入口与 TTS 朗读控件
PDF Demo 宿主在阅读界面右上角常驻一个 "AI" 按钮,底部常驻一组 TTS 播放
控件。这两处是示例性质的能力入口,当前不希望在阅读界面上出现。

在宿主控制器上引入 showsAIAssistant 与 showsSpeechControls 两个开关,
viewDidLoad 中据此决定是否创建 AI 按钮、是否装配朗读控件,两者都不再被
加入视图层级。底层的 presentAIAssistant、speechSession 及播放、语速控制
逻辑全部保留,把开关置为 true 即可恢复,避免以后重新接入时再写一遍。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:51:57 +09:00
shenandshen 85cf92161c Merge branch 'dev' of http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK into dev 2026-07-27 21:54:56 +08:00
shenandshen 9392027106 更新阅读器功能与示例 2026-07-27 21:43:13 +08:00
shenlei c61115cab3 更新提交git skill 2026-07-27 14:16:53 +09:00
shenlei 95cead5863 修复 PDF 阅读交互稳定性并统一项目技能管理
PDF 阅读器在慢速双指缩放、竖向滚动页高异步更新、页面请求超时和绘画橡皮擦等场景中,存在视口跳动、迟到结果被错误丢弃、缓存边界不明确及笔迹显示与持久化不同步的问题;同一本书的标注读取和并发写入也会产生不必要的磁盘访问或覆盖风险。\n\n调整缩放 inset 计算和竖向阅读锚点恢复,避免缩放变换被重复计入、真实页高到达时改变当前阅读位置。页面请求改为带 token 的超时重试机制,可在缓存窗口内接收有效迟到结果并提供失败重试入口;页面大图、OCR、笔迹和描述缓存统一按当前页前后两页收敛,PDF 标识改为完整内容 SHA-256,避免同名或相近文件复用错误阅读状态。\n\n绘画会话内按路径实时重绘,退出会话后使用按图层派生的位图缓存,确保橡皮擦即时作用于已提交笔迹;同时为笔迹持久化增加版本控制、为标注读写增加同步保护和内存快照,降低复用与并发场景下的状态错乱。\n\n将项目技能统一迁入 .agents/skills,并以 .claude/skills 相对软链接供 Claude Code 读取;新增详细 Git 提交技能,自动审查改动、生成中文提交说明并约束安全推送。

验证:所有项目技能通过 quick_validate;执行 xcodebuild -workspace ReadViewDemo/ReadViewDemo.xcworkspace -scheme ReadViewDemo -configuration Debug -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO build,构建成功。
2026-07-24 14:40:02 +09:00
shenlei 68d9363f0a feat: optimize reader caching and document formats 2026-07-20 17:22:06 +09:00
shenandshen 52f803e8db fix: restore demo build and drawing interactions 2026-07-15 21:13:05 +08:00
shenleiandClaude Fable 5 1422461224 将 PDF 成品阅读控制器下沉为 RDPDFReaderView SDK,并修复手机横屏单页连续竖滑
SDK 化收尾:
- 新增 RDPDFReaderView 本地 pod(阅读容器、成品控制器、划线/注释、画笔、
  目录/设置面板、主题与持久化契约),Demo 精简为 PDFKit 页面提供者 + 持久化宿主
- Podfile/工程接入 RDPDFReaderView 与 SnapKit,示例书改用 PDF 阅读器示例文件

手机横屏单页竖滑修复:
- cell 高度改为宽度适配后的纸张高度(sizeForItemAt + 宿主按页提供纵横比),
  纵向超出一屏的内容由外层 collectionView 连续滚动承接,不再被裁掉
- 页面内部不再开启同向嵌套的兜底竖向拖动,修复外层滚动被吞、无法滚到下一页
- 修复 applyContentSize 先评估拖动开关后写 contentSize 的顺序缺陷
- 页码按视口中心命中 cell 计算,补 didEndDragging 兜底;旋转重锚点与
  transitionToPage 改用真实布局位置
- 横竖屏同为竖滑时也重建 cell 并刷新全部可见页的适配配置
- 横屏单页点击不再跳页,只显隐工具栏;翻页交给连续竖滑

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 18:49:44 +09:00
shenleiandClaude Fable 5 a17164d1a9 将 PDF 注释编辑器、笔记列表、目录/设置面板与面板呈现下沉到 SDK
SDK 新增:
- RDPDFReaderAnnotationEditorViewController:注释输入页(UI 与 EPUB 编辑器一致)
- RDPDFReaderAnnotationListViewController:笔记列表(全部/注释/高亮筛选、滑动删除)
- RDPDFReaderNavigationPanelViewController:目录/书签/缩略图面板容器
- RDPDFReaderSettingsPanelViewController:亮度/翻页方式/主题设置面板
- RDPDFReaderPanelPresenter + 转场/呈现控制器:底部面板统一呈现,
  转场代理生命周期由被呈现控制器持有,宿主无需保存引用
- RDPDFReaderThemeOption:SDK 自有主题模型,面板与编辑器不再依赖 EPUB 主题类型
- UIColor(rdPDFHexString:) 公开,并消除覆盖层内的私有重复实现

Demo 侧:
- 删除 PDFDemoAnnotationUI.swift 整个文件
- 删除 PDFDemoNavigationViewController / PDFDemoSettingsViewController /
  面板转场三个类(约 410 行),改为调用 SDK 组件
- ThemePreset 增加到 RDPDFReaderThemeOption 的映射

无障碍标识全部保留,UI 自动化不受影响。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:51:51 +09:00
shenleiandClaude Fable 5 1bde945d36 将 PDF 划线/注释交互完整下沉到 SDK 库
- RDPDFReaderPageView:完整页面视图移入 SDK(缩放画布、文字选择层、
  选区放大镜、无障碍视口),并内置与 EPUB 一致的两套系统编辑菜单:
  选区菜单(复制/划线/注释)、已有划线菜单(复制/注释|删除注释/删除划线)
- RDPDFReaderPageViewDelegate:宿主只通过代理做数据持久化与打开业务界面
- 修复划线命中判定:点击点先归一化再与标注比例坐标比较
- 修复 tappedHighlight 残留导致选区菜单项被错误过滤的问题
- 主题应用改为纯颜色入参,SDK 不依赖 EPUB 主题类型
- RDPDFReaderHighlightOverlayView 恢复为纯绘制层,点击交互统一由页面视图处理
- Demo 删除 PDFDemoPageView(约 290 行),改为实现代理回调

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:33:43 +09:00
shenleiandClaude Haiku 4.5 5a19cfde14 refactor: move highlight menu logic from Demo to SDK library
Add RDPDFReaderPageView as base page view class in SDK:
- Handles all highlight menu interactions and display
- Provides RDPDFReaderPageViewDelegate for menu action callbacks
- Unifies with EPUB architecture: RDEPUBTextContentView pattern
- Reduces code duplication for applications using the SDK

Benefits:
- Reusable menu interaction logic across all PDF apps
- Consistent architecture with EPUB reader
- Single responsibility principle (UI in library, persistence in app)
- Backward compatible (existing code continues to work)

Applications now implement RDPDFReaderPageViewDelegate to:
- Respond to highlight menu actions
- Handle data persistence
- Refresh UI after changes

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-15 10:19:54 +09:00
shenleiandClaude Haiku 4.5 7c16647306 fix: remove duplicate canPerformAction method and merge with existing implementation
- Keep single canPerformAction method that handles both text selection menu and highlight menu
- Check tappedHighlight first, then fall back to text selection logic
- Resolves method override conflict

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-15 10:16:30 +09:00
shenleiandClaude Haiku 4.5 d56e99a83f add tap-on-highlight menu with copy/annotate/delete actions, matching EPUB behavior
- Add RDPDFReaderAnnotationMenuAction and RDPDFReaderExistingHighlightMenuAction enums to PDF contracts
- Enable highlight overlay view to detect tap gestures and report tapped highlights
- Implement existing highlight menu in Demo page: copy text, add/edit annotation, delete annotation, delete underline
- Handle menu actions in PDFDemoReaderViewController to persist changes
- Ensure highlight tap detection doesn't interfere with text selection

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-15 10:06:02 +09:00
shenleiandClaude Haiku 4.5 fed34da246 unify PDF and EPUB reader: convert highlights to underlines, improve annotation UI, change paging from horizontal to vertical
- Convert PDF highlights from filled rectangles to underlines matching EPUB style
- Update PDF annotation editor with EPUB-style design: gold sidebar rail, semi-transparent background, improved spacing and shadows, dark mode support
- Change PDF page-curl paging from left-right horizontal to up-down vertical: update gesture recognition and tap regions accordingly

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-15 10:01:23 +09:00
shenlei 72c1f8d89c 扩展顶部工具栏业务按钮能力 2026-07-13 16:16:31 +09:00
shenlei 822949f6cc 清理重复 Podfile 配置 2026-07-13 16:09:45 +09:00
shenlei 6a0a1223b1 完善阅读器主题与批注界面 2026-07-13 13:01:14 +09:00
shenlei adc24d3f36 fix: preserve images in dark reader theme 2026-07-13 12:30:14 +09:00
shenandshen 8ccb7157b2 feat: improve epub reader controls and annotations 2026-07-12 12:23:10 +08:00
shenandshen 063a493b18 fix epub reader playback and trial navigation 2026-07-11 08:16:56 +08:00
shenlei d7fcda345d refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs
- Rename source module from RDReaderView to RDEpubReaderView
- Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/
- Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec
- Update Podfile, demo project, and CocoaPods config for new pod name
- Delete old RDReaderView pod support files from ReadViewDemo/Pods
- Add new RDEpubReaderView pod support files
- Update documentation (API ref, architecture, UML, conventions, etc.)
- Add FixedLayoutRotationTests
- Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
2026-07-10 19:44:53 +09:00
shenleiandClaude Fable 5 d5a7755702 feat: 支持加密 EPUB、试读墙、工具栏定制与朝向锁定
- 新增 RDEPUBResourceDataProvider 解密钩子协议 + 访问登记表,收敛章节 HTML/
  图片/内联 CSS/脚注/封面/图片查看器/正文取图等读取点,scheme handler 对加密书
  禁用流式分支;新增 RDEPUBDecryptingImageAttachment 解密 DTCoreText 图片附件;
  RDEPUBReaderDependencies.live(resourceDataProvider:) 便捷注入。明文书零影响。
- 试读墙:configuration.trialPolicy + delegate epubReaderTrialWallView/
  DidReachTrialWall,UI 由宿主提供(RDEPUBReaderController+Trial)。
- 工具栏定制:RDEPUBReaderTop/BottomToolViewProtocol 协议 + dependencies 工厂注入,
  内置栏已 conform,configureTopToolView 改协议类型。
- 朝向锁定:RDEPUBMetadata.orientation(OPF rendition:orientation 主,
  iBooks display-options 兜底)+ RDEPUBReaderController+Orientation 重写支持朝向、
  打开后主动转向。

注:RDEPUBReaderController+LocationResolution / PaginationCoordinator 为本次改动前
即存在的工作区修改,一并纳入。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 12:25:17 +09:00
shenlei 83dfa40299 完善搜索定位与内存监测回归验证
本次提交围绕搜索链路的稳定性、定位恢复体验以及长章节内存优化的验证能力进行了补强。

主要改动:

1. 调整阅读器搜索栏、搜索协调器与定位恢复逻辑,改善搜索结果跳转、状态同步与相关 UI 行为。

2. 补充内存探针接入点与上下文记录,便于跟踪阅读过程中的内存占用变化。

3. 更新 RDURLReaderController、ReaderContext 与工具视图相关实现,使调试与观测链路更完整。

4. 新增 MemoryFootprintTests,并同步更新 DemoReaderState 与 SearchTests,用 UI 测试覆盖搜索与内存相关回归场景。
2026-07-09 18:46:04 +09:00
shenleiandClaude Fable 5 c4be426299 长章节内存优化 P1:章节级共享显示内容与 layouter,高亮转 overlay
- 新增 RDEPUBChapterDisplayContentCache(LRU 2,主线程限定,控制器
  持有):整章显示串构建时一次性注入全章主题色与暗黑图替换,配套
  共享 DTCoreTextLayouter;签名 = 章节内容对象标识 + 主题双色 +
  暗黑图配置,设置/主题变化自动失效重建
- RDEPUBTextContentView.configure 改为引用共享 entry,删除每次翻页
  的整章可变拷贝与按页属性写入
- 高亮/下划线改走 overlay decoration(高亮背景层、下划线前景层),
  移除 applyHighlightsToContent 与 render view 的属性绘制路径,
  高亮增删不再触发整章重建
- 内存警告时清空 display cache;shouldAvoidReaderPageCaching 保持
  保守(P1-4 待真机数据)

验证:编译通过;高亮/批注/选区/翻页/设置 13 项 UI 回归全过。
SearchTests 10 项失败经二分确认为先存回归(5a41066 与 e4e629a 均
复现,最后已知通过为 2026-06-08),与本次改动无关,待单独排查。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 16:09:40 +09:00
shenleiandClaude Fable 5 e4e629a06c 长章节内存优化 P0:每页子串改按需构造并新增内存打点
- RDEPUBTextPage.content 由存储属性改为基于 chapterContent+contentRange
  的计算属性,三处构造点不再生成每页子串,章节缓存窗口内每章少一份
  整章文本常驻副本
- 新增 RDEPUBMemoryProbe(--demo-memory-probe 开启),在章节插入、
  每 20 次翻页、设置失效、转屏完成时输出 phys_footprint
- shouldAvoidReaderPageCaching 补注释,锁定放开前置条件为 P1-1
- 新增实施清单文档 LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md 并勾选 P0

验证:Demo 编译通过;UI 回归 9/9 通过(PageNavigation/
ReaderOpenClose/LargeBookOnDemand)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 15:34:45 +09:00
shenlei 5a41066b66 修复 EPUB 文本分页显示错位并补充分页调试能力
本次提交围绕 DTCoreText 文本页的分页一致性、交互索引和高亮命中进行了集中修复。

主要改动:

1. 文本页显示改为基于整章 attributed string 的上下文布局,只对当前页 range 进行渲染,避免页面子串重新换行导致的页末断行偏差。

2. 页面布局快照与交互控制器统一改为使用 chapter-absolute 索引,修正点击、选区、菜单锚点与高亮矩形在整章上下文下的定位。

3. 修复跨章节高亮串页问题,并调整文本页高亮命中逻辑:保留 CoreText 层绘制,点击时按高亮真实 rect 精确命中,避免重复绘制和整行误判。

4. 收紧 reader 级页面缓存策略,避免预加载同时持有多份整章显示副本带来的内存放大。

5. 新增分页边界校验器、垂直对齐器和 settings-flip 自动化调试入口,用于复现与诊断页范围/显示度量不一致问题。

6. 放宽 inline attachment 的 avoid-break 处理,并补充相关分页问题调查文档与索引。
2026-07-08 14:27:36 +09:00
shenleiandClaude Fable 5 d0efe3f2cc 安全区域改为从 key window 动态获取
- 新增 RDEPUBSafeArea:参考 GKNavigationBarSwift 的 keyWindow 三级查找与临时 window 兜底
- 移除 reflowableContentInsets 中 top/bottom 40 的固定默认值,改按设备真实安全区生成
- 各取安全区处的 ?? .zero 兜底改为回退到 key window 值
- DEBUG 构建下为文本可读区域绘制红色边框,方便查看排版范围

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:04:22 +09:00
shenleiandClaude Fable 5 ca408c20ab 收紧脚注图的 alt 兜底判定
原判定 alt 非空即视为脚注图,导致带 alt 的正常插图(如凡人修仙传的
logo)被缩到一个字号大小。改为 alt 长度达到脚注正文量级(≥8 字符)
且原图为小图标(≤50pt)双条件同时成立才走脚注缩放,与 HTML 归一化
层只认 qqreader-footnote class 的口径对齐。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 12:16:53 +09:00
shenleiandClaude Fable 5 5ae7823ef8 全量换表前用旧页表回环验证当前位置
初始 partial 窗口被全书页表接管时,live 页码(窗口坐标)与新表解析
页码(全书坐标)不可比,±1 启发式误判后 preserveLivePage 会把模型
位置钉在全书第 1 页,导致位置错乱并级联阻塞后续全表提交。改为换表
前先用旧表解析 currentLocation:回环等于 live 页说明位置忠实反映
屏幕内容,换表后无条件信任新表解析结果;不等时才退回 ±1 门槛,保留
对 stale 持久化位置的防护。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:56:22 +09:00
shenleiandClaude Fable 5 bd6e915fbd 邻接窗口淘汰不再清除前瞻预取章节
scheduleAdjacentChapterPrefetches 的窗口外淘汰会把
maybePrefetchUpcomingChapters 刚预取的后续章节清掉,下一次
prepare 又触发重建,形成"淘汰-重建"循环。淘汰时保留当前章之后
lookahead 范围内的章节,lookahead 数量提为共享常量。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:56:22 +09:00
shenleiandClaude Fable 5 7132e4952b 章节加载器补充三级缓存命中日志
loadChapterWithSnapshot 打印内存章节 HIT、并发加载合流、构建来源
(memoryPageCount/diskSummary/fullRender)与构建耗时,便于判断章节
跳转时预加载与缓存的命中情况。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:34:02 +09:00
shenleiandClaude Fable 5 f796823db8 预加载控制器补充页面视图缓存命中日志
pageViewForDisplay / prime / takePreloadedView 三条取视图路径打印
HIT/MISS 及视图地址,invalidate 打印被清空的缓存页码,便于排查
翻页时页面视图是否命中缓存。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:22:09 +09:00
shenleiandClaude Fable 5 47fe2dc450 页表全量接管改为按子集覆盖判定
fullReplace 从"候选章节数 >= 当前章节数"改为"候选页表必须覆盖当前窗口
的全部章节",避免跳章后 partial map 数量达标但缺少远端已映射章节时,
全量替换丢弃这些条目导致总页数抖动。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:14:48 +09:00
shenandshenlei 9e91207011 优化 EPUB 翻页与后台分页刷新 2026-07-06 11:08:28 +09:00
shenlei 71f4feb12f Ignore local build caches 2026-07-03 16:13:50 +09:00
shenlei 99bdc98895 Fix reader interaction and web resource handling 2026-07-03 16:12:11 +09:00
shenleiandClaude 22e7e44220 feat: chapter runtime refactoring and related updates
- Refactor chapter runtime: replace window coordinator/snapshot with warmup orchestrator
- Update EPUB core: parser, reading session, JS bridge, navigator layout
- Update reader controller: data source, location resolution, persistence
- Update chapter runtime: data cache, loader, runtime store, disk cache, warmup orchestrator
- Remove deprecated navigation state machine and pagination state
- Update text rendering: book cache, HTML normalizer
- Update UI: text content view, dark image adjuster, text selection controller
- Update settings and reader configuration
- Add CODE_REVIEW.md and AUDIT_FINAL.md documentation
- Update pod dependencies (remove SSAlertSwift, SnapKit)
- Update podspec and pod configuration files

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 18:50:07 +09:00
shenleiandClaude b8aa10c535 Add diagnostic logs for total pages not updating investigation
Add RDEPUBBackgroundTrace.log at key decision points in the
reconciliation pipeline to trace why pageMap updates get rejected:

1. refreshBookPageMapInPlace — logs when a full pageMap update is enqueued
2. evaluateTakeover — logs when keepCurrentWindow is returned due to:
   - jumpSession coverage ratio < 0.8
   - adjacent coverage missing (hasPrev/hasNext)
   - renderSignature mismatch
3. evaluateFullPageMapTakeover — logs when keepCurrentWindow is returned due to:
   - currentSpineIndex not in candidate chapters
   - adjacent coverage missing
4. commitPendingPageMapUpdate — logs when keepCurrentWindow decision is
   applied and pending update is removed from queue

Also fix unused variable warning for currentPageNumber in extendPartial case.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-25 18:57:16 +08:00
shenleiandClaude 15b15d0e11 Remove all RDEPUBBackgroundTrace.log and bare print diagnostic calls
Keep only the evaluateFullPageMapTakeover: fullReplace diagnostic log
in RDEPUBPageMapReconciliationCoordinator.swift for future debugging.

Simplified RDEPUBBackgroundTrace to just the log method (removed measure
and debug gating). Removed all [EPUB][...], [ReadViewDemo], and
[EPUB][Pagination] print statements across the project.

Also includes earlier bug fixes:
- Fix stale currentPageNumber in extendPartial commit
- Fix pageCurl rebindVisiblePage during transition
- Fix keepCurrentWindow not removing pending update from queue
- Fix right-aligned text (巫鸿 bug) via avoidPageBreakInside,
  tail merger, and continuation paragraph normalization
- Add text-indent reset for aligned blocks in CSS compatibility layer

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-25 18:25:22 +08:00
shenleiandClaude 8c5059e72f chore: RDEPUBBackgroundTrace 日志默认关闭
Release 构建下完全静默(isEnabled 固定 false),
Debug 构建下通过 _enabled 开关控制,默认开启。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-25 17:36:28 +08:00
shenleiandClaude 2fa58b1026 diag: 总页数未更新问题 — 增加Reconciliation诊断日志 + 修复keepCurrentWindow后pending不移除
1. RDEPUBPageMapReconciliationCoordinator: 在evaluateTakeover、
   evaluateSegmentTakeover、evaluateFullPageMapTakeover的每个返回点
   增加RDEPUBBackgroundTrace日志,记录关键决策参数:
   - currentSpineIndex、candidateChapters/pages、currentChapters/pages
   - hasPrev/hasNext、lastBuildableSpineIndex
   - jumpSession protected coverage ratio
   用于确认fullMap被拒绝的具体原因

2. RDEPUBPresentationRuntime: .keepCurrentWindow分支增加
   removePendingPageMapUpdate(at: index),防止被拒绝的
   reconcileFullMap update无限期留在pending队列中不被消费。
   之前return false不移除,导致:
   - 后续commitPendingPageMapUpdateIfNeeded重试时条件不变
     仍然keepCurrentWindow,形成无效循环
   - 如果用户不再翻页,总页数永远不更新

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-25 17:33:16 +08:00
shenleiandClaude c555329948 fix: pageCurl翻页后偶尔刷新回章节第一页
根因:.extendPartial pageMap更新提交时使用了异步扩展开始时捕获的
currentPageNumber/currentLocation,在pageCurl模式下用户可能已经翻了几页,
导致过时值通过transitionToPage把用户拉回旧位置。

修复三点:
1. PresentationRuntime: .extendPartial fallback不再使用捕获的
   currentPageNumber,改用readerView.currentPage实时值
2. ChapterWarmupOrchestrator: 异步加载完成回调中使用实时位置
   (readerView.currentPage + locationCoordinator.currentVisibleLocation())
   而非异步开始时捕获的旧值
3. PresentationRuntime: rebindVisiblePage在pageCurl模式下,
   如果正在翻页动画中(isPageCurlTransitioning),延迟到动画结束后
   再用实时currentPage执行transitionToPage,避免在pageNum回调栈
   内同步触发transitionToPage导致重入

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-25 16:52:38 +08:00
shenleiandClaude d7f28c3f10 fix: 修复右对齐短文本显示不完整及多项功能改进
右对齐文本显示修复(如"巫鸿"只显示"鸿"的问题):
- RDEPUBChapterTailNormalizer: 短尾页合并时检查 avoidPageBreakInside 语义,
  避免将带有此标记的短文本(如 right-info 署名)错误合并回上一页
- RDEPUBTextContentView: 续段归一化时跳过右对齐/居中对齐的段落,
  防止错误修改 firstLineHeadIndent 导致首字不可见
- RDEPUBCSSCompatibilityLayer: 为含 text-align:right/center 的 CSS 块
  自动注入 text-indent:0 !important,确保右对齐/居中文本无首行缩进
- RDEPUBTextRendererSupport: 排版属性归一化时将右对齐/居中段落的
  firstLineHeadIndent 重置为 0

图片查看器及脚注点击功能:
- 新增 RDEPUBImageViewController 和 RDEPUBImageViewerCoordinator,
  支持从 WebView 和 TextPage 两种模式查看图片
- epub-bridge.js: 检测图片和脚注图片的点击事件,脚注图片显示弹窗
- RDEPUBJavaScriptBridge: 新增 imageDidTap/footnoteDidTap 桥接消息
- RDEPUBWebView: 新增图片和脚注点击的 delegate 方法
- RDEPUBAttachmentNormalizer: 改进脚注检测,优先使用 alt 文本判断
- RDEPUBPaginationModels: 新增 .footnote 附件类型
- RDEPUBPageLayoutSnapshot: 运行时动态解析附件类型,脚注优先级高于图片

位置解析改进:
- RDEPUBReaderController+LocationResolution: 利用 rangeInfo 提升页码定位精度

其他:
- RDEPUBTextBookCache: schema 版本升级至 13
- RDEPUBReaderTheme: 主题更新
- Pod 项目文件更新

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-25 15:39:58 +08:00
shenleiandClaude d15f20b097 feat: 交互协调器拆分、附件提示、暗色图片适配、选区放大镜及文档清理
- 拆分 ContentDelegates/TextContentView 为独立协调器(InteractionCoordinator、LocationResolution、ExternalLinks、AttachmentTooltip)
- 新增 RDEPUBAttachmentTooltipView/OverlayView 附件气泡提示
- 新增 RDEPUBDarkImageAdjuster 暗色模式图片亮度适配
- 新增 RDEPUBSelectionLoupeView 选区放大镜
- 新增 MetadataParseWorker/CancellationController 元数据解析取消机制
- 重构 PresentationRuntime/PaginationCoordinator 精简职责
- 优化 ChapterLoader/WarmupOrchestrator 异步章节加载
- CFI 模块微调与 NoteModels 更新
- 清理冗余文档,更新架构/UML/业务逻辑文档

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 17:47:24 +08:00
shenandshen 7de661eb54 feat: 架构整改 — Context拆分、Runtime拆分、异步章节加载、UI测试覆盖
Phase 1: Context 拆分
- 新增 RDEPUBReaderState/RDEPUBReaderEnvironment/RDEPUBReaderServices
- RDEPUBReaderContext 改为过渡门面,代理到 State/Environment/Services

Phase 2: Runtime 拆分
- 新增 RDEPUBPresentationRuntime 处理分页状态管理
- 新增 RDEPUBChapterWarmupOrchestrator 处理章节预热与加载编排
- RDEPUBReaderRuntime 从 1277 行收缩,公共 API 转发到新 facade

Phase 0.5: 性能优化
- prepareOnDemandChapter 支持异步模式(allowSynchronousLoad: false)
- extendPartialBookPageMapIfNeeded 改为 DispatchGroup 并发加载
- RDEPUBChapterOffsetMap.cfiMap 加 NSLock 保护数据竞争
- CFI Map 构建延迟到后台队列(scheduleDeferredCFIMapBuildIfNeeded)
- RDEPUBTextPageRenderView 引入静态位图缓存
- RDEPUBTextContentView 新增 loadingSpinner 占位页

Phase 3: 状态机
- 新增 RDEPUBNavigationStateMachine(含 DEBUG 合法转换校验)
- 新增 RDEPUBPaginationState 记录分页来源

Review 修复
- makeSummary 重复方法合并
- ensureNavigationTargetAvailable 同步路径加注释标记

UI 测试
- 新增 AsyncChapterLoadingTests(20 个测试,覆盖全部架构整改场景)
- 跨章节翻页、延迟 CFI、状态机、内存警告、预加载、位置恢复等
2026-06-23 08:17:08 +08:00
shenlei c65c190b71 feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化
- 实现EPUB阅读器搜索功能及选中注释功能
- 优化CFI模块,修复代码审查发现的11个问题
- 实现大书远距目录跳转与后台补全优化方案
- 优化设置面板与章节运行时联动
- 重构及大量改进优化
2026-06-22 20:26:34 +08:00
shenleiandClaude f50495ad91 fix: CFI module — 11 issues from code review
Phase 1 (correctness):
- #1: rawValue changed from stored to computed property, eliminating stale cache risk
- #2: tokenSamples/textMarker unified to UTF-16 offsets, fixing emoji/CJK positioning
- #3: makeOffsetCFI now accepts optional contentPath parameter

Phase 2 (robustness):
- #4: Resolver uses fixed index access instead of last(where:) for manifest/spine steps
- #5: HTML comments and CDATA stripped before regex matching
- #6: Text assertion parser handles backslash escapes (\[ \] \)
- #7: Token matching uses prefix/suffix with length ratio constraints
- #8: makeOffsetRangeCFI validates startOffset <= endOffset

Phase 3 (code quality):
- #9: Shared internal nilIfEmpty extension in RDEPUBCFIUtilities.swift
- #10: RDEPUBCFIMap has markerByPath index dictionary for O(1) lookup
- #11: parseRange unified to use try (not try?) for parent parsing

Review fixes:
- Documented init(rawValue:) parameter is ignored (computed property)
- Fixed escape unescaping to handle \\ → \ correctly
- Lowered token minLength threshold from 4 to 2

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-18 13:25:17 +08:00
shenleiandClaude Opus 4.7 6ed3fcb071 feat: 设置面板与章节运行时联动优化
- 设置页打开时仅重新计算当前章节预览,关闭后触发完整补全
- 新增 preview 加载优先级,支持设置页内当前章预览
- 添加设置页防抖机制,合并连续字号/行距变更
- 支持下滑手势关闭设置页,通过 presentation delegate 通知运行时
- 重新分页时跳过导航和尺寸校验,避免状态冲突
- currentTextPageSize 线程安全优化,后台线程使用缓存尺寸
- 更新 CocoaPods 依赖

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-17 09:47:50 +08:00
shenleiandClaude Opus 4.7 c64460988a feat: 实现大书远距目录跳转与后台补全优化方案
Phase 1: 稳定性优先
- 新增 RDEPUBJumpSession 保护机制,防止远距跳转后翻页串章
- 升级页图接管条件,增加 JumpSession 保护区检查
- 窗口扩展改为基于当前权威窗口方向

Phase 2: 补全优先级重排
- 新增 RDEPUBBackgroundPriorityPolicy 策略配置
- 实现 hot/warm/cold zone 优先级排序
- 添加失败重试机制(指数退避,最多3次)

Phase 3: 分段覆盖与最终收敛
- 新增 RDEPUBBackgroundCoverageStore 分段存储
- 新增 RDEPUBPageMapReconciliationCoordinator 页图接管仲裁
- 实现 LRU 淘汰和内存警告处理

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 16:28:11 +08:00
shenleiandClaude Opus 4.7 e976ceebd5 chore: add .artifacts/ to .gitignore
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 08:38:09 +08:00
shenandshen 6f75b083f7 feat: EPUB 阅读器搜索、选中注释、书签 chrome 状态及大量重构优化
- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态
- 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试
- 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证)
- 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互
- 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache)
- 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy
- 更新 epub-bridge.js 与 JS bridge 通信协议
- 全面更新现有 UI 测试以适配新的 helper 和状态管理
2026-06-13 22:48:56 +08:00
shenandshen 27e9b85ddb Merge branch 'feature' of http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK into feature
# Conflicts:
#	.gitignore
2026-06-13 08:02:45 +08:00
shenandshen cdc74e5ad9 chore: add .gitignore and remove tracked xcuserdata
Xcode user-specific data (xcuserdata) should not be version controlled.
2026-06-13 08:02:21 +08:00
shenleiandClaude Opus 4.7 ed390da147 feat: unify reader chrome state management
- Add RDEPUBReaderUIState struct for centralized UI state model
- Refactor RDEPUBReaderChromeCoordinator with makeUIState() and applyUIState()
- Remove direct UI updates from RDEPUBReaderAnnotationCoordinator
- Consolidate updateBookmarkChrome() into updateReaderChrome()
- Update RDEPUBReaderContext, Runtime, and LocationCoordinator
- Add reader problem fix development checklist documentation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-12 17:51:08 +08:00
shenlei d15187b730 Reorganize docs and update reader search flows 2026-06-10 08:22:07 +08:00
shenleiandClaude Opus 4.7 0e7c0577e3 feat: add in-reader search and restructure documentation
- Add RDEPUBReaderSearchBarView with animated show/hide, keyword
  navigation, and match counting integrated into the reader controller
- Restructure docs: replace scattered design docs with consolidated
  BUSINESS_LOGIC.md and UML_CLASS_DIAGRAMS.md; update ARCHITECTURE.md
- Add SearchTests and FanrenParseTimeTest; enhance LargeBookOnDemandTests
- Add scripts/run_ui_regression.sh and summarize_ui_results.py for
  automated UI test execution and reporting

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 17:34:50 +08:00
shenandshen d20196ee34 feat: configurable chapter window & parallel metadata parsing with benchmark
1. Configurable chapter window size (onDemandChapterWindowSize: 3-15)
   - Parameterized window radius in RDEPUBChapterRuntimeStore
   - Updated RDEPUBChapterWindowCoordinator to use configurable radius
   - RDEPUBChapterWindowSnapshot.from() accepts chapter array instead of fixed prev/next
   - Even numbers round up to odd (4→5), min 3, max 15

2. Configurable metadata parsing concurrency (metadataParsingConcurrency)
   - Default equals CPU core count
   - Parallel execution via OperationQueue in paginateMetadataOnly
   - Each worker creates independent builder instance
   - NSLock protects result aggregation

3. Per-chapter and total wall-clock timing instrumentation
   - Separated render vs I/O timing per chapter
   - Summary log with wallClockMs, renderTotalMs, writeTotalMs, avgRenderMs
   - Timing stored in RDEPUBReaderContext for test access

4. UI automation test infrastructure
   - Added --demo-window-size, --demo-concurrency, --demo-clear-cache launch args
   - DemoReaderState exposes windowSize, parseMs, parseConcurrency
   - ConfigurableWindowTests: 5 test cases for window size 3/5/15
   - ConcurrentParsingTests: 4 test cases for concurrency 2/4
   - MetadataParseBenchmarkTests: serial vs parallel benchmark

5. Bug fixes
   - Fixed page snap-back during background parsing (isUserInteracting check)
   - Reduced BookPageMap refresh frequency from 16 to 32 chapters
   - Moved waitForReadingInteractionToSettle outside operation loop

6. Design doc: dual-layer PageMap (estimated + precise mixed)
2026-06-03 23:38:11 +08:00
shenlei feb05eaf87 feat: complete large-book pagination runtime and UI coverage 2026-06-03 17:47:16 +08:00
shenandshen 584976ac5f docs: 补充大书优化方案的 WXRead 复刻依据与缓存语义
添加 3.0 节记录 WXRead 逆向依据(WRReaderViewController 核心语义),
完善 pageCountCache 失效策略与 key 生成说明,对齐 WXRead 运行时行为。
2026-06-03 07:22:25 +08:00
shenleiandClaude Opus 4.7 9801af05d3 test: 增强 UI 测试基础设施与阅读器自动化测试
- 新增 BookmarkTests/PageNavigationTests/TableOfContentsTests 等阅读器功能测试
- 扩展 AccessibilityIdentifiers 支持更多 UI 元素定位
- XCUIApplication+Launch: 增加启动参数支持(reset state、自定义书籍)
- ReaderAnnotationTests: 完善标注测试覆盖
- ViewController: 支持 --demo-reset-state 参数清理持久化状态
- 新增凡人修仙传测试样本

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 21:17:04 +08:00
shenleiandClaude Opus 4.7 c76beed03c feat(reader): 实现大书快速进入与增量分页路径
- PaginationCoordinator: 新增 textReflowable 快速进入路径,支持增量章节构建
  与 staged 合并策略,避免整书一次性构建的主线程阻塞
- TextBookBuilder: 重构为支持单章构建与增量合并模式
- ChapterPageCounter: 优化分页计数逻辑,支持章节级分页缓存
- PageFrameFactory: 新增 CoreText 页面帧工厂,提升排版性能
- ReaderContext/ReaderRuntime: 适配增量分页状态管理

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 21:16:57 +08:00
shenleiandClaude Opus 4.7 488350d956 docs: 大书优化方案文档与架构分析更新
新增大书优化实施方案(内存与主线程、快速进入阅读器)和 WXRead 内存策略分析文档,
更新架构对比分析文档,完善章节级缓存运行时、串行加载、轻量磁盘摘要等设计细节。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 21:16:50 +08:00
shenandshen 1c6108061c feat: improve native text selection and annotation actions 2026-06-01 21:29:41 +08:00
shenleiandClaude Opus 4.7 70133e4e6e refactor(reader): 重构高亮选区绘制架构,对齐 WXRead 实现方案
- 移除 RDEPUBSelectableTextView,改用原生 UITextView
- 新增 NSAttributedString 自定义属性(com.rdreader.highlight/underline)注入高亮
- RDEPUBTextPageRenderView 统一绘制高亮背景、文字和选区
- RDEPUBTextSelectionController 精简,选区矩形传递给 RenderView 绘制
- 新增高亮选区复刻 WXRead 实现方案文档
- UI 测试适配新架构

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 20:57:27 +08:00
shenandshen 948004eed1 docs: 补充注释、修正过时文档、清理重复内容
源码注释:
- 为 ~60 个 Swift 文件补充缺失的 doc comment(file header、类型、属性、方法)
- 修正 4 处错误注释:翻页模式数量、搜索行为描述、手势识别器描述、悬空文档块

文档维护:
- 删除重复文档:WXRead/读书EPUB阅读器实现架构.md(与微信读书版完全一致)
- 合并重叠文档:阅读器规划.md → 阅读器功能开发计划.md(单一真值)
- 修正过时内容:所有文档中"四种翻页模式"→"三种",移除 horizontalCoverScroll
- 更新架构图:补齐 EPUBUI/ReaderController、Paging/、Typesetter/ 等子目录
- 更新 index.md 索引:新增开发计划和架构对比文档引用
2026-06-01 09:33:23 +08:00
shenandshen 1efb9d172f feat(reader): 增强阅读器功能与 UI 测试支持
- 新增字体选择(系统/宋体/圆体/等宽)与暗色图片柔化配置
- 文本选择改为自定义手势+操作栏(拷贝/高亮/批注)
- 添加 accessibilityIdentifier 支持自动化 UI 测试
- 新增 UITests 覆盖阅读器打开/关闭、工具栏、设置面板、批注等
- 添加 Demo 测试用 EPUB 书源(宝山辽墓材料与释读)
- 新增文档:UI 自动化测试、功能开发计划、阅读器规划
2026-05-31 23:56:54 +08:00
shenandshen 44202357c0 refactor: split reader architecture and chrome handling 2026-05-31 21:47:54 +08:00
shenandshen ea21c6a831 epub问题修改 2026-05-26 23:27:52 +08:00
shenandshen 22adb76332 fix(reader): restore tool views and close action 2026-05-26 21:41:04 +08:00
shenandshen 736902b489 fix(reader): stabilize pagination display and page curl reuse 2026-05-26 21:37:39 +08:00
shenandshen 83b705b9ae 修改分页问题 2026-05-26 21:08:27 +08:00
shenandshen 0e7d952fe5 feat(epub): complete wxread parity scope 2026-05-26 09:19:37 +08:00
shenandshen 23182e8b5a feat(epub): align wxread css and overlay pagination behavior 2026-05-25 22:15:24 +08:00
shenandshen c0aac56083 docs: 同步 Doc 文档与当前代码状态
- 更新模块文件数(EPUBCore 31+2, EPUBTextRendering 13, ReaderView 5, EPUBUI 19)
- 更新 Swift 版本(5.10)、iOS 版本(15.0)、行数等基本信息
- 补充新增文件文档(TextAnchor, RenderRequest, WebViewDebug, AssetRepository,
  TextIndexTable, TextPerformanceSampler, ChapterData, PageInteractionController 等)
- 更新 RDEPUBLocation 模型(新增 rangeAnchor 字段)
- 更新 RDEPUBReaderConfiguration(13 项配置)、Delegate 签名、Persistence(8 方法)
- 修正 RDURLReaderController 归属(ReaderView → EPUBUI)
- 移除过时的 LegacyRDReaderController 引用
- 标记 SS→RD 命名迁移为已完成
2026-05-25 20:31:10 +08:00
shenandshen 54798ba578 refactor: 添加中文注释 + 优化模块结构
- 给全部 78 个 Swift 源文件添加详细的中文注释(文件级、类级、方法级)
- 删除 LegacyRDReaderController/ 死代码目录(16 文件 4592 行)
- 根目录翻页容器文件移入 ReaderView/ 目录
- Resources/ 移入 EPUBCore/Resources/(与使用者归属一致)
- RDEPUBTextIndexTable.swift 移入 EPUBTextRendering/(消除反向依赖)
- RDURLReaderController.swift 移入 EPUBUI/(入口控制器归入 UI 层)
- 更新 podspec 资源路径
2026-05-25 10:19:14 +08:00
shenandshen 5af90c1148 文档名更新 2026-05-24 15:37:26 +08:00
shenandshen 2a94921e88 feat(wxread): align pagination, rendering, and docs 2026-05-24 15:36:01 +08:00
shenandshen a318c0e3d0 feat(epub): align text rendering with WXRead 2026-05-24 09:56:32 +08:00
shenandshen 5125b8de51 docs(08): research pagination cache, quality, and performance sampling 2026-05-23 22:35:16 +08:00
22bb86959c docs(08): create phase plans for image display fix and pagination cache
3 plans covering QUAL-01 through QUAL-04:
- 08-01: pagination cache key + wiring into rendering pipeline
- 08-02: unified 1080x1920 max-size for all images, footnote width-only fix
- 08-03: pagination timing instrumentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 18:43:41 +08:00
86e6922f7b docs(08): create phase plan — image display fix, cache stub, perf sampling
Three plans for Phase 8 (分页质量、缓存与性能采样):
- 08-01: Pagination cache key struct and manager stub (QUAL-01)
- 08-02: Unified 1080x1920 max-size constraint for all images, footnote width-only sizing (QUAL-02, QUAL-03, QUAL-04)
- 08-03: Pagination timing instrumentation with [EPUB][Perf] diagnostic output (QUAL-04)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 18:30:56 +08:00
26afb9f7dc docs(08): research image display fix for pagination quality
Research Phase 8 plan 08-02: image display fix in the native text
rendering path. Documents the unified max-size approach (1080x1920),
footnote width-only sizing, and three entry points that need changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 18:27:08 +08:00
shenandshen 3e2669d8a8 目前阅读稳定版本,需要修改图片阅读方式 2026-05-23 15:50:53 +08:00
shenandshen d7339aa255 feat(07): close native text pagination semantics loop 2026-05-22 20:39:26 +08:00
shenandshen 21c8bbb23a docs(07): create phase plan 2026-05-22 20:04:47 +08:00
shenandshen 46fa9fceff docs(06): create phase plan 2026-05-22 14:20:33 +08:00
shenandshen 7b5c77cedd docs(06): capture phase context 2026-05-22 14:10:58 +08:00
shenandshen bd4978266a docs: start milestone v1.1 WXRead 深化对齐 2026-05-22 13:59:26 +08:00
shenandshen 1498e83ace chore: archive v1.0 milestone 2026-05-22 13:52:47 +08:00
shenandshen 5698aeaead chore: checkpoint current milestone work 2026-05-22 13:28:53 +08:00
shenandshen 6d196d64e5 docs(phase-01): add planning artifacts 2026-05-21 22:00:41 +08:00
shenandshen 0870996ed0 docs(phase-01): complete phase execution 2026-05-21 22:00:21 +08:00
shenandshen 6d7f02debd docs(01-02): complete refactor entry strategy plan 2026-05-21 21:58:52 +08:00
shenandshen cc2573552c docs(01-01): complete current engine audit plan 2026-05-21 21:56:54 +08:00
shenandshen 743988a78b docs: add no-page-turning-change constraint 2026-05-21 21:13:13 +08:00
shenandshen 43c3381e9b docs: update planning for direct reflowable engine rewrite 2026-05-21 21:08:21 +08:00
shenandshen c95d80e257 docs: add WXRead reflowable renderer design 2026-05-21 20:44:30 +08:00
shenandshen 4daa3a6ed4 docs: add codebase map 2026-05-21 20:36:12 +08:00
shenandshen faa069ec99 chore: add project config 2026-05-21 20:35:52 +08:00
shenandshen 26e6a1c94f docs: define requirements 2026-05-21 20:35:51 +08:00
shenandshen 46a594a137 docs: add roadmap 2026-05-21 20:35:51 +08:00
shenandshen 6f58eda143 docs: add state 2026-05-21 20:35:50 +08:00
shenandshen 319a34da93 docs: initialize project 2026-05-21 20:35:33 +08:00
820 changed files with 65576 additions and 63167 deletions
@@ -0,0 +1,90 @@
---
name: commit-git-changes
description: 审查当前 Git 工作区,安全选择本次任务文件,生成包含问题背景、根因和修改方案的详细中文提交说明,并提交及按用户要求推送到 Git 服务器。默认不执行构建或测试;用户要求“提交代码”“提交并推送”“同步到 Git”“上传当前修改”或要求生成详细 commit message 时使用。
---
# 提交 Git 改动
## 目标
在不混入无关修改、不泄露敏感信息、不改写远端历史的前提下,完成“审查—暂存—提交—推送—确认”闭环,并用提交说明解释为什么修改以及如何解决,而不只是罗列文件。
## 工作流
1. 读取仓库级 `AGENTS.md`、当前分支、远端和工作区状态。
2. 检查暂存与未暂存差异:
- 使用 `git status --short` 确认全部改动。
- 使用 `git diff``git diff --cached` 理解真实行为变化。
- 必要时查看相关源码和测试,不能仅依据文件名编写提交说明。
3. 划定本次提交范围:
- 只纳入当前用户任务直接相关的文件。
- 保留用户已有或其他任务产生的无关修改。
- 优先显式 `git add <path...>`;仅在确认所有改动均属本次提交时使用 `git add -A`
- 发现密钥、令牌、证书、个人配置、大型生成物或疑似敏感数据时停止提交并说明。
4. 不默认执行构建、测试或项目级验证;仅在用户明确要求时执行。
5. 暂存后再次检查:
- 执行 `git diff --cached --check`
- 执行 `git diff --cached --stat``git diff --cached`
- 确认暂存区没有无关文件、调试残留和意外格式变化。
6. 编写详细中文提交说明并创建提交。
7. 用户要求提交到服务器或同步远端时推送:
- 有 upstream 时推送当前分支。
- 没有 upstream 且远端、目标分支明确时,使用 `git push -u <remote> <branch>`
- 远端或目标分支不明确时先询问,不猜测。
- 禁止 force push;除非用户明确要求且风险已说明。
8. 提交后确认提交哈希、提交标题、当前分支、推送结果和剩余未提交改动。
## 提交说明规范
提交说明使用“标题 + 空行 + 详细正文”的结构。
### 标题
- 用一句中文概括用户可感知的问题和修复结果。
- 优先写清触发条件、异常表现和结果,例如:
`修复无训练点普通课程完成后重进播放页回退为未完成`
- 使用明确动词,如“修复”“新增”“调整”“重构”“移除”。
- 不使用“修改代码”“优化问题”“更新若干内容”等空泛描述。
- 保持单行,不以句号结尾,不添加无依据的工单号或模块前缀。
### 正文
用完整段落解释以下内容:
1. **问题背景与影响**:什么场景触发、用户看到什么、影响哪些路径。
2. **根因**:原有数据流、状态条件或实现约束为什么导致问题。
3. **修改方案**:关键行为如何改变,为什么选择该方案,必要时说明兼容和边界处理。
正文应描述行为和因果关系,不要把 `git diff --stat` 改写成文件清单。多个紧密相关的修改可以分段说明,但不要堆砌逐文件 bullet。
若用户明确要求并实际执行了构建、测试或其他验证,可以在正文末尾如实补充;未执行时不需要添加“验证:未执行”之类的说明。
### 示例
```text
修复无训练点普通课程完成后重进播放页回退为未完成
课件完成态本地记录的保存不区分训练点,但读取端仅在存在
trainPointModel 时才创建合并器,导致班级、训练和项目均为空的普通课程
重进播放页后无法读取本地完成记录。
改为始终创建完成态合并器:无训练点时使用空维度合并;由于该场景没有
服务端完成态可供对账,将本地记录作为唯一持久来源并跳过超期回收,保持
重新进入播放页后的完成状态稳定。
```
根据真实改动改写示例内容,禁止照抄未发生的场景或验证结论。
## 提交与推送约束
- 不使用 `git reset --hard``git checkout --``git clean` 等破坏性命令处理工作区。
- 不使用 `--amend`、rebase、历史改写或强制推送,除非用户明确授权。
- 不跳过 hooks;hook 失败时先分析原因,不使用 `--no-verify` 绕过。
- 提交失败后保留暂存区,修复可控问题并重试;不要重复创建等价提交。
- 推送被拒绝时先获取并解释分支差异,不自动合并、变基或覆盖远端。
- 若没有可提交差异,直接说明,不创建空提交。
## 交付
报告提交哈希和标题、推送的远端分支,以及仍留在工作区的未提交修改。若只完成本地提交而未推送,必须明确说明。
@@ -0,0 +1,4 @@
interface:
display_name: "提交 Git 代码"
short_description: "安全审查改动并生成详细中文说明,完成 Git 提交与远端推送"
default_prompt: "使用 $commit-git-changes 审查当前改动,生成详细中文提交说明并提交到 Git 服务器。"
@@ -0,0 +1,44 @@
---
name: discuss-sdk-feature-solution
description: 讨论 ReadViewSDK 的新功能、修复或重构方案,核验 EPUB/PDF 阅读器源码,比较实现路径并收敛为可直接开发的计划。用户说“先讨论”“先给方案”“比较方案”“不要改代码”,或接口、缓存、渲染路径、持久化兼容存在会改变实现方式的灰区时使用;路径已明确且用户要求直接修改时不要使用。
---
# SDK 功能方案讨论
## 目标
在编码前用仓库事实收敛决策,输出最小、可验证、能交给开发 Skill 直接执行的方案。本 Skill 默认不修改业务代码。
## 工作流
1. 检查工作区并定位目标模块、入口、相邻实现和对应文档。
2. 区分已锁定决定、阻塞决定、实现假设、后续项与非范围。
3. 按根目录 `AGENTS.md` 读取所需文档;结论必须由真实路径、类型、方法和调用链支撑。
4. 方案未定时给出 2–4 个可行路径,比较改动面、公开 API、缓存/持久化、渲染路径、性能、回归和维护成本,并明确推荐一个。
5. 用户已选定方案时只细化该方案,不继续横向发散。
6. 方案至少覆盖:
- EPUB`.textReflowable``.webInteractive``.webFixedLayout` 中受影响的路径
- PDF:宿主图片 Provider 与内置 PDFKit Provider 中受影响的路径
- 公共 API、Codable/磁盘格式、缓存版本和 CocoaPods 集成影响
- 异步乱序、取消、生命周期、内存压力和失败降级
7. 收敛为文件级任务、数据/状态流、成功标准、验证方式、风险和回滚点。
## 输出
默认在对话中给出:
- 需求、范围与非范围
- 已确认决定、阻塞项和必要假设
- 方案对比或已选方案拆解
- 涉及文件、调用链、复用点和兼容策略
- 风险、回滚点与验证清单
- 建议交给 `start-sdk-feature-dev-lite``start-sdk-feature-dev` 的执行摘要
用户要求落文档,或方案需要跨会话执行时,写入 `Doc/FeatureSolution/`,并在存在文档索引时同步 `Doc/index.md`
## 边界
- 不为未确认的未来需求预埋抽象。
- 必要改动、可选优化和后续治理必须分开。
- 未核验的系统 API、第三方能力、文件格式或缓存行为不得写成事实。
- 文档与源码冲突时以当前源码为事实,并明确指出文档待同步项。
@@ -0,0 +1,4 @@
interface:
display_name: "SDK 功能方案讨论"
short_description: "核验当前仓库事实,比较实现路径并输出可直接开发的完整方案"
default_prompt: "Use $discuss-sdk-feature-solution to compare implementation paths and produce an executable handoff."
@@ -0,0 +1,23 @@
---
name: generate-module-code-style-doc
description: 基于真实源码生成或更新 ReadViewSDK 指定模块的代码组织、命名、分层、UI、状态、缓存和复用规范,适用于 EPUBCore、EPUBTextRendering、ReaderView、EPUBUI、RDPDFReaderView 或 Demo。全局规范使用 generate-sdk-code-style-doc。
---
# 生成模块代码规范
## 工作流
1. 确定目标模块、关注范围和输出文件;优先增量更新 `Doc/` 中现有对应文档。
2. 读取目标模块源码、对应 code reference、`Doc/CONVENTIONS.md` 和必要架构章节。
3. 抽样入口、协议、模型、View/Cell、Controller、Coordinator/Service、缓存和 Extension;不存在的层级不要补造。
4. 提炼真实且重复出现的模式,并区分当前约定、特殊例外及原因、尚未落地的建议。
5. 记录目录职责、命名、可见性、依赖方向、状态管理、布局、回调、缓存、失败处理、公共 API 和跨模块边界。
6. 对 EPUB 模块说明适用的渲染路径;对 PDF 模块说明自定义 Provider 与 PDFKit 路径差异。
7. 增量更新文档,检查与全局规范、podspec、架构和源码是否冲突。
8. 文档新增、移动或重命名时同步 `Doc/index.md`
## 边界
- 只描述目标模块,不把局部模式提升为全局事实。
- 不虚构理想分层、协议或不存在的调用链。
- 默认不修改业务代码;发现问题时作为建议或 finding 单独列出。
@@ -0,0 +1,4 @@
interface:
display_name: "生成模块代码规范"
short_description: "基于真实源码生成指定 EPUB、PDF 或 Demo 模块的局部规范文档"
default_prompt: "Use $generate-module-code-style-doc to document the coding patterns of this ReadViewSDK module."
@@ -0,0 +1,34 @@
---
name: generate-module-implementation-logic
description: 从 ReadViewSDK 真实源码生成或更新指定模块或场景的入口、调用链、数据流、状态流、渲染流、缓存、持久化、异步回调和异常分支文档。用户要求梳理实现逻辑、调用链、分页或缓存流程时使用;代码风格规范改用对应 code-style Skill。
---
# 生成模块实现逻辑
## 工作流
1. 确定目标模块、用户场景和输出路径;优先复用 `Doc/` 中现有专题或 code reference。
2. 读取目标入口、现有文档,以及 `AGENTS.md` 路由出的架构、API 或测试资料。
3. 用引用搜索沿真实关系追踪:
- 页面、控制器、协议或服务入口及触发条件
- 数据模型、状态所有者和线程/队列
- EPUB 解析/排版/分页/章节窗口,或 PDF Provider/渲染/OCR/标注
- 内存与磁盘缓存、键、版本、驱逐和恢复
- 通知、回调、delegate、持久化和跨模块依赖
- 空值、失败、取消、重试、超时、迟到结果和兼容分支
4. 对关键链路至少双向核验一次:从入口跟到结果,再从结果或回调查回调用方。
5. 区分当前实现、历史兼容层、仅文档中的计划和待确认事实。
6. 增量更新文档,保留正确内容,删除或标记失效链路。
7. 结论引用真实文件、类型和方法;不确定信息明确标为待确认。
8. 文档新增、移动或重命名时同步 `Doc/index.md`
## 推荐结构
- 适用范围与模块职责
- 入口和主要对象
- 主流程与数据/状态/渲染变化
- 缓存、持久化和并发模型
- 异常、取消、重试和兼容处理
- 维护风险与验证要点
纯文档任务不修改业务代码;源码暴露的缺陷应单独报告,不把建议写成已实现事实。
@@ -0,0 +1,4 @@
interface:
display_name: "生成模块实现逻辑"
short_description: "梳理指定阅读器模块的入口、调用链、状态流、缓存及异常分支"
default_prompt: "Use $generate-module-implementation-logic to document this module's verified implementation flow."
@@ -0,0 +1,26 @@
---
name: generate-sdk-code-style-doc
description: 基于 ReadViewSDK 当前 EPUB、PDF 和 Demo 源码生成或更新全局代码规范、目录职责、命名、分层、UIKit/SnapKit、并发、缓存、公共 API 与测试约定。用户要求项目级编码规范或更新 Doc/CONVENTIONS.md 时使用;单模块规范改用 generate-module-code-style-doc。
---
# 生成 SDK 全局代码规范
## 工作流
1. 明确输出路径;未指定时增量更新 `Doc/CONVENTIONS.md`
2. 读取现有目标文档、`Doc/ARCHITECTURE.md``Doc/index.md` 和两个 podspec。
3. 从 EPUBCore、EPUBTextRendering、ReaderView、EPUBUI、RDPDFReaderView 与 Demo/UITests 抽样真实源码。
4. 把结论分为:
- 多模块源码验证的现行约定
- 仅适用于 EPUB、PDF 或 Demo 的局部模式
- 明确标记为建议、尚未成为项目事实的待统一项
5. 覆盖目录职责、命名与可见性、公共 API、UIKit/SnapKit、异步与取消、缓存/持久化、错误处理、测试、注释和 CocoaPods 约定。
6. 增量保留仍正确内容,删除失效描述;使用真实路径、类型和方法作为证据。
7. 新增、移动或重命名文档时同步 `Doc/index.md`,最后检查链接和源码一致性。
## 边界
- 不把 EPUB 的局部模式提升为 PDF 的全局规范,反之亦然。
- 不以旧 `.claude/skills` 或过期文档替代当前源码事实。
- 不把个人偏好写成现行规范。
- 文档任务默认不修改业务代码;发现缺陷时单独报告。
@@ -0,0 +1,4 @@
interface:
display_name: "生成 SDK 全局规范"
short_description: "从当前 EPUB 与 PDF 源码证据生成或更新项目级代码规范文档"
default_prompt: "Use $generate-sdk-code-style-doc to update the project-wide coding conventions from source evidence."
@@ -0,0 +1,48 @@
---
name: review-swift-ios-sdk
description: 对 ReadViewSDK 的 Swift/iOS 代码进行专项审查,覆盖并发、主线程、生命周期、内存、缓存、渲染性能、可访问性、安全、公共 API 和持久化兼容。用户要求继续检查隐藏问题、代码审查、性能或内存审计时使用;普通功能开发和纯文档任务不要单独使用。
---
# Swift/iOS SDK 专项审查
## 工作流
1. 明确审查范围和修改授权;只要求审查时不得修改代码。
2. 检查工作区和最近改动,读取目标代码、相邻实现及 `AGENTS.md` 路由出的文档。
3. 沿入口、异步回调、持久化和渲染结果双向追踪,不只检查单个函数。
4. 仅报告能由当前源码证明、可触发且值得行动的问题。
## 检查维度
### 并发与生命周期
- UI 是否回到主线程,迟到结果是否会覆盖新状态。
- token、重试、超时和取消是否保证恰好一次完成。
- 闭包、Task、通知、定时器、观察者、WKWebView 和 Vision 请求是否释放。
- PDFKit、缓存字典和持久化文件是否在正确队列或锁内访问。
### 性能与内存
- 滚动、缩放、绘画和分页热点是否包含同步 IO、重复解码或重复排版。
- EPUB 章节窗口、页图缓存、PDF 页面位图和绘画缓存是否有明确边界。
- 内存警告后可见内容能否恢复,驱逐后迟到回调是否重新撑大缓存。
- 图片像素成本、NSCache 限额、autoreleasepool 和大对象生命周期是否合理。
### SDK 兼容性
- `public` API、协议要求、初始化方法和默认行为是否意外 breaking。
- Codable、文件名、缓存键、book identifier 和 schema version 是否可迁移。
- EPUB 三种渲染路径和 PDF 两种 Provider 路径是否行为一致。
- podspec 是否包含新增源码、资源、系统 framework 和依赖。
### 交互、安全与可访问性
- 缩放锚点、Cell 复用、双页布局、手势竞争和绘画图层是否稳定。
- 图标按钮、自定义控件、动态字体和 VoiceOver 语义是否完整。
- 日志、文件路径和错误信息是否泄露敏感数据。
## 输出
按严重程度列出 findings;每条包含位置、触发条件、用户影响、根因和最小修复建议。没有发现问题时明确说明,并列出尚未覆盖的运行时验证空白。
若用户同时要求修改,沿用对应开发 Skill 的实现、验证和交付规则。
@@ -0,0 +1,4 @@
interface:
display_name: "Swift/iOS SDK 专项审查"
short_description: "审查并发、生命周期、性能、内存及公开 API 风险"
default_prompt: "Use $review-swift-ios-sdk to audit this ReadViewSDK code for iOS-specific risks."
@@ -0,0 +1,41 @@
---
name: start-sdk-feature-dev-lite
description: 实现 ReadViewSDK 的单模块功能、Bug 修复、UI 调整、局部重构、编译修复或小范围 CocoaPods 变更,并完成必要验证。作为默认开发入口;跨 EPUB/PDF 模块、改变公共协议或持久化格式、需要迁移回滚或严格执行完整方案时使用 start-sdk-feature-dev。
---
# SDK 轻量开发
## 目标
用最小改动完成边界清晰的任务,形成“实现—验证—交付”闭环。
## 工作流
1. 检查工作区,识别并保留用户已有修改。
2. 定位目标源码、现有相邻模式和 `AGENTS.md` 路由出的文档。
3. 若用户提供方案,完整读取并执行已锁定决定;源码事实使方案不可行时先说明冲突。
4. 明确 In Scope、Out of Scope、成功标准和短执行清单。
5. 按模块边界实施最小修改:
- EPUB 解析与资源:`Sources/RDEpubReaderView/EPUBCore`
- EPUB 文本排版:`Sources/RDEpubReaderView/EPUBTextRendering`
- EPUB 翻页容器:`Sources/RDEpubReaderView/ReaderView`
- EPUB 成品 UI`Sources/RDEpubReaderView/EPUBUI`
- PDF 阅读器:`Sources/RDPDFReaderView`
- 集成与回归入口:`ReadViewDemo`
6. 自检空值、失败分支、异步乱序、取消语义、主线程、生命周期、Cell 复用、缓存边界、内存警告和可访问性。
7. 检查公共 API、持久化模型、缓存键/版本、资源路径及 podspec 是否被意外改变。
8. 按风险执行构建或定向 UI 测试;编译错误应修复并重试。
9. 代码行为显著变化时更新最接近的 `Doc/` 文档。
## 升级条件
出现以下任一情况时切换到 `start-sdk-feature-dev` 并说明:
- 同时改变 EPUB 与 PDF 阅读器,或改变多个 EPUB 层级的职责。
- 修改宿主可见协议、公共初始化方法或需要兼容迁移。
- 改变持久化格式、缓存架构、分页主流程或跨模块状态流。
- 用户要求逐项执行完整开发方案。
## 交付
最终简洁说明完成内容、关键取舍、主要文件、验证结果、未覆盖风险和同步文档;不强制套固定模板。
@@ -0,0 +1,4 @@
interface:
display_name: "SDK 轻量开发"
short_description: "完成局部功能、Bug 修复、小范围重构及对应构建验证闭环"
default_prompt: "Use $start-sdk-feature-dev-lite to implement this scoped ReadViewSDK change and verify it."
@@ -0,0 +1,43 @@
---
name: start-sdk-feature-dev
description: 实现 ReadViewSDK 的跨层或跨阅读器改造、分页/缓存/持久化系统性重构、公共 API 演进、复杂联调,或严格执行用户提供的完整方案文档。单模块功能、UI 微调、Bug 修复和局部改动优先使用 start-sdk-feature-dev-lite。
---
# SDK 完整开发
## 目标
对复杂任务建立可追溯计划,控制接口兼容、依赖、迁移和回滚风险,完成实现、联调、验证与文档同步。
## 工作流
1. 检查工作区,区分本任务改动和用户已有改动。
2. 明确主改动模块、受影响模块、In Scope、Out of Scope、联调依赖和完成标准。
3.`AGENTS.md` 读取架构、公共 API、目标子系统和测试文档,并用源码复核。
4. 用户提供方案时:
- 完整读取并提取锁定决定、任务清单、文件清单、限制和验收标准。
- 逐项执行,不自行替换技术路径、数据模型或文件布局。
- 方案与源码冲突时暂停,说明影响和最小修订建议。
5. 没有方案时制定最小可落地计划,明确执行顺序、数据/状态流、风险、回滚点和验证。
6. 实施时区分计划内修改、不可避免的“必要补齐”和可选后续项;会改变方案的偏差先确认。
7. 做跨模块自检:
- EPUB 四层依赖方向和三种渲染路径
- PDF 自定义 Provider 与 PDFKit 直读路径
- 公共 API 的源码兼容与行为兼容
- Codable/磁盘格式迁移、缓存键、版本和失效策略
- 异步 token、取消、迟到回调、主线程和生命周期
- 大图、排版产物、章节窗口和内存警告处理
- CocoaPods source/resource/framework 配置和 Demo 集成
8. 同步架构、API、子系统或测试文档;文档新增或移动时更新索引。
9. 执行构建和与风险相称的定向回归,记录无法自动覆盖的联调项。
## 方案控制
- 用户确认的方案不能被“更简单的建议”静默覆盖。
- 不以顺手治理为由扩大重构范围。
- 涉及线上数据、缓存迁移或公共 API 时必须写明兼容、回滚或降级方式。
- 新增依赖、资源或公开类型前必须确认 podspec 和宿主集成影响。
## 交付
最终说明范围和计划完成情况、关键实现与偏差、改动模块、构建/测试结果、兼容与回滚策略、未覆盖风险和同步文档。
@@ -0,0 +1,4 @@
interface:
display_name: "SDK 完整开发"
short_description: "执行跨阅读器模块改造、复杂联调、兼容评估与完整验证交付"
default_prompt: "Use $start-sdk-feature-dev to implement this cross-module ReadViewSDK plan with full verification."
+1
View File
@@ -0,0 +1 @@
../.agents/skills
-286
View File
@@ -1,286 +0,0 @@
---
name: "Discuss SDK Feature Solution"
description: "方案讨论优先 skill:用于 ReadViewSDK 新需求的实现方案分析、路径对比、取舍沟通、已定方案细化和实现交接。"
argument-hint: "粘贴需求、目标模块、约束、已知备选方案;可附加:是否已确定方案 / 是否需要推荐方案 / 是否需要落方案文档。"
---
# Discuss SDK Feature Solution
## Purpose
面向 ReadViewSDK 的"实现方案讨论入口" skill。
用于在正式开发前,先把需求、约束、可选实现路径和关键取舍聊透;若方案已经确定,则只围绕已选方案继续细化,再交接给开发 skill 进入实现。
该 skill 借鉴 spec-driven 工作流思想:讨论阶段专门捕获灰区决策,计划阶段必须经过代码 / 文档事实核验,最终输出能直接喂给开发 skill 的结构化方案文档,避免"聊完还是不能开发"。
该 skill 的职责固定为:
- 先理解需求与约束
- 捕获灰区问题和实现假设
- 用现有代码 / 文档核验方案可行性
- 未定方案时:输出 2-4 个可行实现方案,并做对比
- 已定方案时:只围绕确认方案展开实现讨论,不再继续扩展其他方案
- 最后给出交接到开发 skill 的明确指令
- 最终必须产出一份可直接供开发 skill 使用的开发详细文档,或一段可直接执行的开发详细描述
## Core Discussion Rules
- Rule 1 — Think Before Coding
- 在提出方案前先核对代码和文档事实,不静默假设
- 必须显式写出关键假设、主要取舍和已知风险
- 遇到会改变实现路径的关键灰区,先提出并请求确认,不靠猜测补完整个方案
- 若存在更简单且满足目标的实现路径,必须主动指出并优先推荐
- Rule 2 — Simplicity First
- 推荐方案优先选择最小可落地路径,而不是概念上更"完整"的设计
- 不为了未来可能性预埋推测性扩展,不为单次使用引入抽象
- 若某个方案明显过度设计,必须直接指出并解释为什么不推荐
- Rule 3 — Surgical Changes
- 方案只覆盖完成当前目标所必需的改动面
- 不把无关重构、顺手统一风格或额外治理动作夹带进推荐方案
- 若确有相邻改动依赖,必须明确标注"必要改动"与"可选优化"的边界
- Rule 4 — Goal-Driven Execution
- 讨论输出必须先定义成功标准和验证方式,再给实现交接建议
- 不把讨论步骤本身当结果,重点是产出可执行、可验证的方案
- 交接给开发 skill 时,必须让对方清楚"做到什么算完成"
## When To Use
当用户提出以下类型需求时使用:
- "先讨论一下这个需求怎么实现"
- "这个功能可能有多种实现方式,先分析方案"
- "先别写代码,先帮我想实现路径"
- "先比较几种方案,再决定怎么做"
适用场景:
- 同一个需求可以通过多种技术路径实现
- 需要权衡 SDK 分层(EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI)、复用、维护成本、回归影响
- 需要在实现前先明确推荐方案和待确认细节
- 已经明确采用某个方案,但还需要继续细化实现边界、拆解落地路径和交接开发
不适用场景:
- 用户已经明确要直接实现,且实现路径基本单一时,优先使用开发类 skill
- 用户只想生成文档,不需要方案讨论时,优先使用文档类 skill
## Inputs
推荐输入:
- 需求描述
- 目标模块或 Feature(属于哪一层:EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI / Legacy
- 已知约束(兼容性、性能、API 兼容、CocoaPods 发布等)
- 已有备选方案(如有)
- 已确认方案(如已决定)
- 是否需要推荐方案
- 是否需要最终落方案文档
若信息不足:
- 先通过代码和文档补齐可发现事实
- 再围绕真正影响方案选择的灰区进行澄清
- 若用户不想继续问答,必须显式列出"实现假设",并把假设写入方案或交接摘要
若用户已经明确指定"采用方案 X / 就按这个方案做":
- 视为进入"已定方案模式"
- 后续输出禁止继续罗列其他候选方案、备选实现或横向对比
- 仅允许在必要时补充"当前方案的风险、前提、边界和实现细化"
## Scope / Required Context
默认先读:
1. `Doc/ARCHITECTURE.md` — 四层架构、数据流、分页模式、位置模型、已知限制
2. `Doc/CODING_STYLE.md` — 命名规范(RD/RDEPUB 前缀)、分层规则、extension 拆分规则、SS→RD 迁移计划
3. `Doc/EPUB_MAINTENANCE.md` — 文件职责表、DTCoreText 渲染管线、常见排查场景
按需再读:
- 涉及 EPUB 解析时:`Sources/RDReaderView/EPUBCore/` 下相关文件
- 涉及文本渲染时:`Sources/RDReaderView/EPUBTextRendering/` 下相关文件
- 涉及阅读器容器时:`Sources/RDReaderView/RDReaderView.swift` 及同级文件
- 涉及 UI 层时:`Sources/RDReaderView/EPUBUI/` 下相关文件
- 涉及遗留代码时:`Sources/RDReaderView/LegacyRDReaderController/` 下相关文件
- 涉及 JS 桥接时:`Sources/RDReaderView/Resources/epub-bridge.js`
若文档与代码不一致:
- 以代码事实为准做方案讨论
- 在推荐方案中指出不一致点和可能影响
方案文档落地目录约束:
- 讨论型方案文档统一落到 `Doc/FeatureSolution/`(若目录不存在则创建)
- 不要把方案讨论文档写入其他目录
## SDK-Specific Discussion Rules
讨论时必须考虑的 SDK 特有约束:
- **分层边界**:方案必须明确落在哪一层,不得跨层引入反向依赖(上层依赖下层允许,反之不允许)
- **Public API 兼容性**:若改动涉及公共接口(`RDReaderView``RDReaderDataSource``RDURLReaderController``RDEPUBReaderController` 等),必须评估对宿主 App 的 breaking change 影响
- **渲染路径差异**:方案必须考虑三种渲染路径(`webFixedLayout` / `webInteractive` / `textReflowable`)的适用性,不能只覆盖单一路径
- **CocoaPods 发布影响**:若方案涉及新增依赖、资源文件或模块结构调整,必须评估 podspec 变更
- **RD 前缀规范**:所有新增类型必须使用 `RD` 前缀,EPUB 相关使用 `RDEPUB` 前缀
- **Legacy 层边界**:不在 Legacy 层新增功能;若方案涉及 Legacy 代码,必须明确是迁移还是在新层实现
## GSD-Inspired Discussion Rules
讨论阶段要像 `discuss -> plan -> verify` 闭环一样,先捕获决策,再验证计划,而不是直接跳到实现建议。
必须执行:
- 灰区捕获:先识别布局、接口形态、数据结构、错误态、路由、持久化、兼容性、回归范围等不明确点
- 事实核验:方案落地前必须用本仓库代码和 `Doc/` 文档确认入口、复用点、约束和风险
- 假设显式化:不能确认的问题必须写成 `Assumption`,不得藏在方案正文里
- 阻塞分级:会改变实现路径的问题标记为 `Blocking Decision`,不会改变路径的问题标记为 `Follow-up`
- 审计留痕:若落方案文档,必须包含"讨论结论 / 关键决策 / 假设 / 非范围 / 验证清单 / 开发交接指令"
- 计划可执行:最终方案必须足够小,能被开发 skill 直接逐项执行
禁止行为:
- 不得只输出宽泛建议,必须落到文件、类、方法、数据流或协议层面的执行点
- 不得在用户已确认方案后继续展开新方案,除非用户明确要求重新比较
- 不得把未经核验的包、SDK、接口能力写成已确认事实
- 不得把需要用户拍板的关键决策伪装成默认实现
## Required Workflow
### Phase 1 — 识别需求、范围、约束、现状
- 明确目标价值、影响模块(EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI / Legacy)、In Scope / Out of Scope
- 识别当前项目里已有的实现模式、相邻能力和复用点
- 判断这是单层需求还是跨层需求
- 建立灰区清单,区分 `Blocking Decision``Follow-up`
### Phase 2 — 判断讨论模式
- 若方案未定:进入"多方案对比模式"
- 若方案已定:进入"单方案细化模式"
- 一旦用户已确认方案,后续同一轮讨论默认保持"单方案细化模式",除非用户明确要求重新打开方案比较
### Phase 3 — Research / Verify:核验代码与文档事实
- 对照当前仓库确认真实入口、调用链、数据模型、复用的 cell / view / handler / controller / protocol
- 若方案涉及新增文件,必须确认所属目录层级和是否需要更新 podspec 的 source_files
- 若方案涉及接口或数据字段,必须明确字段来源、空值策略和兼容策略
- 若方案涉及 JS 桥接,必须确认 epub-bridge.js 的交互契约
- 若发现文档和代码不一致,必须标注为风险或阻塞项
- 若核验结果推翻原方案,必须暂停说明,不得继续包装成可执行方案
### Phase 4A — 多方案对比模式:列出多种实现路径
- 至少提出 2 个可行方案,推荐 2-4 个方案
- 每个方案都要有清晰的实现方向,而不是抽象建议
- 优先从现有项目模式和复用能力出发
### Phase 4B — 多方案对比模式:比较方案优缺点和影响范围
- 比较每个方案的:
- 核心思路
- 落在哪一层(EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI
- 适用前提
- 优点
- 风险 / 成本
- 对 SDK 分层、Public API、podspec、回归范围的影响
- 明确哪些差异会真正影响后续实现和维护
### Phase 5A — 多方案对比模式:提出推荐方案,并列出待确认细节
- 必须明确推荐一个默认方案,不能只平铺选项
- 待确认项只保留真正影响实现的关键决策
- 默认产物为对话输出;若用户明确要求,再可选落到 `Doc/FeatureSolution/` 下方案文档
### Phase 5B — 单方案细化模式:围绕确认方案展开
- 不再输出"方案 A / 方案 B / 方案 C"式内容
- 不再补充"其他也可以这样做"的备选实现,除非用户明确要求回到方案比较
- 只讨论以下内容:
- 当前方案的实现拆解(文件 / 类 / 方法 / 协议)
- SDK 分层内的模块边界与职责分配
- 关键数据流 / 状态流 / 渲染流
- 复用点、依赖点、回归点
- 对三种渲染路径的覆盖情况
- 风险、前提、灰度方式、验证要点
- 交接给开发 skill 的实现摘要
### Phase 6 — 输出实现交接建议
- 在方案确认后,明确下一步应交给哪个开发 skill:
- 小中型、单层、MVP 优先:轻量开发 skill
- 跨层、复杂联调、完整交付:完整开发 skill
- 无论是否落文档,最终都必须产出开发交接载体,且二选一不能缺失:
- 完整开发 skill:产出可直接执行的开发详细文档,默认落到 `Doc/FeatureSolution/`
- 轻量开发 skill:产出可直接粘贴执行的开发详细描述,覆盖实现目标、范围、代码落点、关键步骤、验收标准和自测要点
- 给出可直接粘贴给开发 skill 的实现摘要;若已有更完整的详细文档/描述,则摘要必须引用它而不是只给一句话概括
- 若落方案文档,必须让开发指令引用该文档的真实路径,并提醒开发 skill 严格按计划执行
## Output Contract
默认输出结构按模式区分:
若方案未定:
- A. 需求理解
- B. 灰区问题与实现假设
- C. 当前实现与约束
- D. 可选方案对比
- E. 推荐方案
- F. 待确认实现细节
- G. 实现交接建议
若方案已定:
- A. 需求理解
- B. 灰区问题与实现假设
- C. 当前实现与约束
- D. 确认方案拆解
- E. 实现风险与关键细节
- F. 实现交接建议
其中要求:
- 未定方案时:
- `B. 灰区问题与实现假设` 必须区分 Blocking Decision / Follow-up / Assumption
- `D. 可选方案对比` 至少给出 2 个方案,推荐 2-4 个
- `E. 推荐方案` 必须明确推荐一个默认方案
- `F. 待确认实现细节` 只保留会改变实现路径的关键问题
- `G. 实现交接建议` 必须明确下一步交给哪个开发 skill
- `G. 实现交接建议` 必须附带可供开发 skill 直接使用的交接内容:
- 若下一步是完整开发 skill,必须提供开发详细文档路径或完整文档正文
- 若下一步是轻量开发 skill,必须提供开发详细描述正文
- 已定方案时:
- `B. 灰区问题与实现假设` 必须列出阻塞项、假设和后续项
- `D. 确认方案拆解` 只能围绕确认方案展开,禁止附带其他方案信息
- `E. 实现风险与关键细节` 只讨论该方案落地所需信息
- `F. 实现交接建议` 必须明确下一步交给哪个开发 skill,并附带可直接执行的详细文档或详细描述
若用户要求落方案文档,文档必须包含:
- 需求目标
- 讨论结论
- 关键决策
- 实现假设
- 非范围
- SDK 分层落点(文件 / 类 / 方法 / 协议)
- 数据流 / 状态流 / 渲染流
- 开发任务清单
- 验收标准
- 自测清单
- 待确认项
- 给开发 skill 的执行指令
若未落方案文档,但下一步要交给轻量开发 skill,则最终输出中的"开发详细描述"至少必须包含:
- 实现目标
- In Scope / Out of Scope
- SDK 分层落点(文件 / 类 / 方法 / 协议)
- 实现步骤
- 关键约束与复用点
- 对三种渲染路径的覆盖说明
- 验收标准
- 自测清单
- 待确认项或实现假设
## Validation
- 本 skill 默认只做方案讨论,不直接进入代码实现
- 默认不修改仓库文件;只有用户明确要求时,才可选落方案文档到 `Doc/FeatureSolution/`
- 即使不落方案文档,最终回复也必须包含一份可直接交给开发 skill 使用的详细交接内容,不能只停留在高层方案结论
- 若本次调用只做讨论或只新增方案文档,可不执行构建验证
- 若新增或移动方案文档,建议同步更新目录索引
- 若后续进入代码实现,则由对应开发 skill 负责执行项目默认构建验证(`xcodebuild` 或 CocoaPods 集成验证)
- 若用户已确认方案,则默认进入单方案细化模式,除非用户明确要求重新比较备选方案
## Maintenance Rules
- 该 skill 的职责是"讨论后交接实现",不和开发 skill、文档类 skill 重叠
- 优先复用 `Doc/` 作为知识源,不新增独立 references 体系
- 方案文档目录固定为 `Doc/FeatureSolution/`
- 持续保持 `discuss -> research/verify -> plan handoff` 的轻量闭环,避免退化成只聊天不交付的建议列表
- 若该 skill 的默认输出结构、交接规则或适用边界调整,必须同步更新对应文档
- SDK 四层架构(EPUBCore → EPUBTextRendering → RDReaderView → EPUBUI)是方案讨论的核心参照框架,任何方案都必须明确标注落点层级
@@ -1,213 +0,0 @@
---
name: "Start SDK Feature Dev Lite"
description: "轻量开发主控 skill:用于 ReadViewSDK 的小中型功能开发、单层改动、局部重构、文档同步与编译修复。"
argument-hint: "粘贴需求、目标层级、验收标准;可附加:仅 MVP / 禁止新增依赖 / 指定渲染路径。"
---
# Start SDK Feature Dev Lite
## Purpose
面向 ReadViewSDK 的轻量开发入口 skill。
用于在现有项目约束下完成"小到中型"需求,遵循"先识别范围、再按需读文档、后实现、再验证、最后交付"的闭环。
该 skill 是默认开发入口,优先覆盖:
- 单层内的新功能 MVP 实现
- 小范围重构或代码整理
- 文档同步更新
- 编译错误定位与修复
- 单个模块的 bug 修复或行为调整
- podspec 小幅调整
默认吸收项目 `Doc/CODING_STYLE.md` 中的 Swift/iOS 通用规则;若任务重点落在并发、性能、可访问性或安全,再额外展开该专项的检查项。
## Core Execution Rules
- Rule 1 — Think Before Coding
- 先核对代码和文档事实,不静默假设
- 必须显式写出关键假设、主要取舍和已知风险
- 遇到会改变实现路径的关键灰区,先暂停确认,不靠猜测继续推进
- 若存在更简单且满足目标的实现路径,必须主动指出并优先采用
- Rule 2 — Simplicity First
- 只做满足需求和验收标准的最小实现
- 不增加推测性功能,不为单次使用引入抽象
- 若方案让资深工程师也会觉得过度设计,必须继续简化
- Rule 3 — Surgical Changes
- 只修改完成当前需求所必需的文件和代码
- 不顺手重构无关代码,不扩散到相邻层做"顺便优化"
- 非必要不改注释、格式或既有结构,新增代码保持现有风格
- Rule 4 — Goal-Driven Execution
- 先定义成功标准,再围绕成功标准实施和验证
- 不给自己堆步骤,重点是闭环达到验收结果
- 修改后必须验证,未验证通过前不能视为完成
## When To Use
当用户提出以下类型需求时使用:
- "使用 start-sdk-feature-dev-lite 完成这个功能"
- 在 ReadViewSDK 中开发单一功能或单层变更
- 需要修复局部编译错误或行为问题
- 需要同步更新文档
- 需要小幅调整 podspec
不适用场景:
- 需求跨多个 SDK 层级、涉及多阶段联调或需要完整项目级方案时,改用 `start-sdk-feature-dev`
- 目标是只生成文档而不改业务代码时,优先使用文档类 skill
- 目标是方案讨论而不进入实现时,优先使用 `discuss-sdk-feature-solution`
## Inputs
推荐输入:
- 功能名称
- 目标 / 用户价值
- 范围(In Scope
- 非范围(Out of Scope
- 目标层级(EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI
- 详细需求
- 验收标准
- 约束(兼容性 / 性能 / 禁止新增依赖 / 指定渲染路径)
若信息不足:
- 先基于代码和文档补齐可发现事实
- 再列最多 5 条关键假设
- 基于假设继续推进 MVP,并在交付中标注待确认项
## Scope / Required Context
仅针对 ReadViewSDK 现有架构执行。
SDK 四层架构参照:
- Layer 1 — EPUBCore`Sources/RDReaderView/EPUBCore/`):EPUB 解析引擎
- Layer 2 — EPUBTextRendering`Sources/RDReaderView/EPUBTextRendering/`):文本渲染路径
- Layer 3 — RDReaderView`Sources/RDReaderView/` 根目录):分页阅读器容器
- Layer 4 — EPUBUI`Sources/RDReaderView/EPUBUI/`):开箱即用 UI
- Legacy`Sources/RDReaderView/LegacyRDReaderController/`):遗留代码,不在其上新增功能
分层依赖规则(上→下允许,下→上禁止):
- EPUBUI → RDReaderView → EPUBTextRendering → EPUBCore
默认先读:
1. `Doc/ARCHITECTURE.md` — 四层架构、数据流、分页模式、位置模型
2. `Doc/CODING_STYLE.md` — 命名规范、分层规则、extension 拆分规则
3. `Doc/EPUB_MAINTENANCE.md` — 文件职责表、渲染管线、排查场景
按需再读:
- `Doc/FeatureSolution/*.md`(若有方案文档)
- 涉及哪一层就读该层目录下的相关源码
- 涉及 JS 桥接时:`Sources/RDReaderView/Resources/epub-bridge.js`
- 涉及 podspec 时:`RDReaderView.podspec`
若文档与代码不一致:
- 以代码事实为准完成本次实现
- 在交付中标注不一致点,并指出建议更新的文档
## Required Workflow
### Phase 1 — 识别需求和范围
- 明确目标价值、影响层级(EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI)、In Scope / Out of Scope
- 优先做最小可落地实现,不做需求外重构
- 若需求信息不足,先通过仓库事实补齐,再基于假设推进
### Phase 2 — 按需读取 Doc 与代码事实
- 先读默认 DocARCHITECTURE / CODING_STYLE / EPUB_MAINTENANCE
- 根据需求类型补读对应层级的源码
- 用源码确认真实入口、调用链和复用点,不只依赖文档
### Phase 3 — 形成最小实现方案
- 保持分层边界:
- EPUB 解析逻辑在 EPUBCore
- 文本渲染逻辑在 EPUBTextRendering
- 分页容器逻辑在 RDReaderView
- 开箱即用 UI 在 EPUBUI
- 不在 Legacy 层新增功能
- 命名、注释、日志风格遵循 `Doc/CODING_STYLE.md`
- Swift / iOS 默认规则:
- 新增 Swift 类型遵循 `RD` / `RDEPUB` 前缀和层级目录归属
- 避免新增强制解包;确需使用时必须先收敛前置条件
- 不为"现代化"而重写稳定代码;仅在本次需求明确受益时再迁移系统 API
- 当前项目默认保持 UIKit + Auto Layout + 既有组件风格,SDK 层不使用 SnapKit
- 注释和日志使用中文
- Debug 输出放在 `#if DEBUG` 守卫下
- 不在日志中输出敏感信息
- 异步闭包默认 `[weak self]`UI 更新回到主线程
- 通知 / 定时器 / 回调在生命周期结束时必须清理
- 数据模型用 `struct` + `Codable` + `Equatable`,服务对象用 `final class`
- 可见性按最小原则:`private` > `fileprivate` > `internal` > `public`
- 错误使用 `enum` + `LocalizedError`,禁止 force unwrap
- 单文件若预计超过 600 行,需主动拆分扩展文件
### Phase 4 — 执行修改与文档同步
- 所有代码改动遵循最小改动原则
- 修改代码时必须补充必要注释,重点说明关键逻辑、边界条件和不直观处理;不要省略应有注释,也不要添加无信息量的描述性注释
- 默认补做轻量 SDK 自检:
- 新增类型是否使用正确前缀和层级归属
- 本次改动涉及的渲染路径是否正常工作
- Public API 是否有意外 breaking change
- podspec 是否需要更新(新增文件时)
- 生命周期、通知/定时器/回调清理是否完整
- 异步闭包是否考虑 `[weak self]`
- 修改代码后必须同步更新受影响文档,不能只停留在代码实现
- 若本次改动涉及 EPUB 维护相关,必须回写 `Doc/EPUB_MAINTENANCE.md`
- 若本次是方案讨论文档交接落地,方案类文档统一放到 `Doc/FeatureSolution/`
- 若新增、重命名或移动文档,必须同步更新目录索引
### Phase 5 — 构建验证与交付
- 完成修改后,按项目默认命令执行编译验证
- 若出现编译错误,自动修复并重编译
- 若遇构建锁问题,自动重试
- 交付时固定输出:
- A. 需求理解
- B. 开发计划
- C. 开发实施
- D. 验证结果
- E. 交付摘要
## Output Contract
最终回复必须完整输出以下 5 个板块,标题保持一致:
- A. 需求理解
- B. 开发计划
- C. 开发实施
- D. 验证结果
- E. 交付摘要
各板块内容要求:
- A:3-6 条,覆盖目标价值、范围、目标层级、限制
- B:按"方案对齐 / MVP 实现 / 验证与交付"三阶段组织
- C:描述实际改动、关键实现取舍,以及本次补充了哪些关键代码注释
- D:给出构建结果、关键路径验证、未覆盖风险
- E:列出改动文件、关键取舍、已同步文档与具体文档落点、后续建议
## Validation
若本次调用修改了任何 Swift / Objective-C / 工程配置 / podspec 文件,必须执行构建验证。
验证方式:
- 若 Demo 工程可用:
```bash
xcodebuild build -workspace ReadViewSDKDemo/ReadViewSDKDemo.xcworkspace -scheme ReadViewSDKDemo -sdk iphonesimulator -derivedDataPath /private/tmp/readview-sdk-derived
```
- 若仅有 SDK 源码(无 workspace):
```bash
pod lib lint RDReaderView.podspec --allow-warnings
```
验证规则:
- 编译失败时必须自行修复并重试
- 遇到 `database is locked` 等锁问题时自动重试,建议最多 5 次
- 直到 `BUILD SUCCEEDED` 或 `pod lib lint passed` 才可交付
- 如果本次仅修改文档或 skill 文件,可跳过构建,但要在交付中明确说明原因
## Maintenance Rules
- 优先复用 `Doc/`,不要把项目级规则复制进多个 skill 造成双份维护
- 新增项目约束时,优先更新 `Doc/CODING_STYLE.md`、`Doc/ARCHITECTURE.md` 等主文档,再调整 skill
- 轻量与完整要保持"轻重不同、规则不冲突",其中完整版应在轻量版闭环基础上扩展跨层与联调要求,而不是另起一套风格
- 变更默认流程、输出格式或适用场景时,必须同步更新相关文档
- 本 skill 持续作为默认开发入口,保持"轻量、清晰、可直接执行"
- SDK 四层架构是执行的核心参照框架,单层改动也必须明确标注落点层级
-309
View File
@@ -1,309 +0,0 @@
---
name: "Start SDK Feature Dev"
description: "完整开发主控 skill:用于 ReadViewSDK 的跨层功能开发、多阶段联调、结构性重构与完整交付。"
argument-hint: "粘贴完整需求,建议包含:背景、目标、范围、目标层级、交互说明、数据约束、验收标准、渲染路径覆盖要求。"
---
# Start SDK Feature Dev
## Purpose
面向 ReadViewSDK 的完整开发主控 skill。
用于复杂度高于单层修改的需求,遵循"先识别范围、再按需读文档、后对齐既定开发计划、再严格执行、再验证、最后交付"的闭环,并补充跨层联调、风险控制、回滚点和完整交付要求。
该 skill 优先覆盖:
- 跨多个 SDK 层级(EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI)的功能开发
- 多阶段联调与完整交付
- 结构性重构或系统性收敛
- 涉及 EPUB 解析、渲染管线、阅读器容器、UI 层协同改造的需求
- 需要更完整风险说明、文档同步和验收闭环的开发任务
默认吸收项目 `Doc/CODING_STYLE.md` 中的 Swift/iOS 通用规则;若任务重点落在并发、性能、可访问性或安全,再额外展开该专项的检查项。
## Core Execution Rules
- Rule 1 — Think Before Coding
- 先核对代码、文档和计划事实,不静默假设
- 必须显式写出关键假设、主要取舍和已知风险
- 遇到会改变计划或实现路径的关键灰区,先暂停确认,不靠猜测继续推进
- 若存在更简单且满足目标的实现路径,只能作为建议提出;有既定计划时不得自行改用
- Rule 2 — Simplicity First
- 只做满足需求、计划和验收标准的最小实现
- 不增加推测性功能,不为单次使用引入抽象
- 若方案让资深工程师也会觉得过度设计,必须继续简化或回到计划边界内
- Rule 3 — Surgical Changes
- 只修改完成当前需求和计划项所必需的文件与代码
- 不顺手重构无关代码,不扩散到相邻层做"顺便优化"
- 非必要不改注释、格式或既有结构,新增代码保持现有风格
- Rule 4 — Goal-Driven Execution
- 先定义成功标准,再围绕成功标准实施和验证
- 计划只是约束,不是目标本身;目标是按计划完成验收闭环
- 修改后必须验证,未验证通过前不能视为完成
## Plan Execution Rule
若用户提供了开发计划文档、方案文档或明确引用 `Doc/FeatureSolution/*.md` 中的开发方案,本 skill 必须把该文档视为本次实现的主计划来源。
严格执行规则:
- 必须先完整读取用户指定的开发计划文档,再开始代码修改
- 必须从计划中抽取任务清单、文件清单、实现边界、非范围和验收标准
- 必须按计划逐项实现,不得自行更换方案、合并步骤、替换文件布局、改变数据模型设计或引入计划外抽象
- 必须遵守计划中的"不做 / 不新增 / 不使用 / 暂不实现"等限制项
- 若计划与代码事实冲突,或计划中某项无法直接落地,必须暂停并向用户说明冲突点、影响和可选处理方式;不得自行选择替代方案继续实现
- 若发现更优实现方式,只能作为"建议"在交付或暂停说明中提出,不得在本次实现中自由采用
- 若计划未覆盖某个必要细节,只允许做最小补齐;补齐内容必须在交付中明确标注为"计划未写明,按最小必要实现补齐"
- 若用户要求"严格按照开发计划执行 / 不要自由发挥",必须把偏离计划视为阻塞项处理
## When To Use
当用户提出以下类型需求时使用:
- "使用 start-sdk-feature-dev 实现这个需求"
- 一个需求影响多个 SDK 层级(跨 EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI
- 需要完整计划、实现、联调、回归和文档交付
- 需要跨 EPUB 解析、渲染、阅读器容器、UI 的协同改造
- 需要明确阶段性计划、回滚点和完整验收说明
Lite 与 Full 的边界:
- 轻量开发 skill:单层、小中型、MVP 优先
- 本 skillstart-sdk-feature-dev):跨层、复杂联调、需要更完整方案和验收
## Inputs
推荐输入模板:
- 功能名称
- 背景与目标
- 用户故事
- 详细需求
- 非范围
- 目标层级(EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI / Legacy
- 交互说明(含空态 / 错误态 / 加载态)
- 渲染路径覆盖要求(webFixedLayout / webInteractive / textReflowable / 不限)
- 数据与接口约束
- 验收标准(Given-When-Then
- 兼容性要求(Public API 是否 breaking、podspec 变更等)
- 性能与安全要求
- 发布时间或优先级
若信息不足:
- 先基于代码和文档补齐可发现事实
- 再列最多 5 条关键假设
- 若没有既定开发计划,可基于假设继续推进最小可落地实现,并在交付中标注待确认项
- 若已有开发计划,信息不足时不得自行扩展方案;只能做计划内实现或暂停确认
## Scope / Required Context
仅针对 ReadViewSDK 现有架构执行。
SDK 四层架构参照:
- Layer 1 — EPUBCore`Sources/RDReaderView/EPUBCore/`):EPUB 解析引擎,ZIP 解压、OPF 解析、manifest/spine/TOC、资源 URL 解析、离屏 WKWebView 分页、阅读会话状态机、JS 桥接
- Layer 2 — EPUBTextRendering`Sources/RDReaderView/EPUBTextRendering/`):文本渲染路径,DTCoreText 转 NSAttributedString 后按字符范围分页
- Layer 3 — RDReaderView`Sources/RDReaderView/` 根目录 6 个文件):分页阅读器容器 UIView,支持 pageCurl / horizontalScroll / verticalScroll / horizontalCoverScroll 四种模式
- Layer 4 — EPUBUI`Sources/RDReaderView/EPUBUI/`):开箱即用 UI,工具栏、目录面板、高亮管理、设置面板、阅读位置持久化
- Legacy`Sources/RDReaderView/LegacyRDReaderController/`):遗留代码,不在其上新增功能
分层依赖规则:
- 上层可依赖下层,下层不得依赖上层
- EPUBUI 可依赖 RDReaderView、EPUBTextRendering、EPUBCore
- RDReaderView 可依赖 EPUBTextRendering、EPUBCore
- EPUBTextRendering 可依赖 EPUBCore
- EPUBCore 不依赖任何上层
若确需跨层改动:
- 必须在计划中写明影响范围
- 必须在交付中写明回滚点或回退策略
默认先读:
1. `Doc/ARCHITECTURE.md` — 四层架构、数据流、分页模式、位置模型、已知限制
2. `Doc/CODING_STYLE.md` — 命名规范(RD/RDEPUB 前缀)、分层规则、extension 拆分、SS→RD 迁移计划
3. `Doc/EPUB_MAINTENANCE.md` — 文件职责表、DTCoreText 渲染管线、常见排查场景
按需再读:
- `Doc/FeatureSolution/*.md`(若有方案文档)
- 涉及 EPUB 解析时:`Sources/RDReaderView/EPUBCore/` 下相关文件
- 涉及文本渲染时:`Sources/RDReaderView/EPUBTextRendering/` 下相关文件
- 涉及阅读器容器时:`Sources/RDReaderView/RDReaderView.swift` 及同级文件
- 涉及 UI 层时:`Sources/RDReaderView/EPUBUI/` 下相关文件
- 涉及遗留代码时:`Sources/RDReaderView/LegacyRDReaderController/` 下相关文件
- 涉及 JS 桥接时:`Sources/RDReaderView/Resources/epub-bridge.js`
- 涉及 podspec 时:`RDReaderView.podspec`
若文档与代码不一致:
- 以代码事实为准落地
- 在交付中说明不一致点和建议更新的文档项
若开发计划文档与代码不一致:
- 不得直接以代码事实替换计划继续开发
- 必须先判断不一致是否影响计划执行
- 会影响计划执行时,暂停并向用户说明冲突点和建议调整项
- 不影响计划执行时,按计划继续,并在交付中标注该不一致点
## SDK-Specific Execution Rules
### 命名与可见性
- 所有新增类型必须使用 `RD` 前缀,EPUB 相关使用 `RDEPUB` 前缀
- 新增类型归属正确层级目录,不得随意放置
- 可见性按最小原则:`private` > `fileprivate` > `internal` > `public`
- 数据模型用 `struct` + `Codable` + `Equatable`
- 服务对象用 `final class`
### 代码组织
- 单文件超过 600 行需主动拆分 `TypeName+Feature.swift` 扩展文件
- 大型 Controller 超过 1000 行必须拆分
- extension 文件承担独立子职责,不只做"行数搬运"
### 内存与并发
- 异步闭包默认 `[weak self]`
- UI 更新必须回到主线程
- 通知 / 定时器 / 回调在 deinit 或生命周期结束时必须清理
### 渲染路径覆盖
- 新增功能必须评估对三种渲染路径的影响:
- `webFixedLayout`:固定版式 EPUB(漫画、绘本)— WKWebView 渲染
- `webInteractive`:可重排 + 交互脚本 EPUB — WKWebView 渲染
- `textReflowable`:纯文本可重排 EPUB — DTCoreText 渲染
- 若功能仅适用于部分路径,必须在交付中明确标注适用范围和不适用路径的处理方式
### Public API 兼容性
- 涉及公共接口(`RDReaderView``RDReaderDataSource``RDURLReaderController``RDEPUBReaderController` 等)的改动,必须评估 breaking change
- 若存在 breaking change,必须在交付中标注并给出迁移指引
- 新增 public API 需考虑 Objective-C 互操作(`@objc``@objcMembers`
### CocoaPods 发布影响
- 新增源文件:确认 podspec `source_files` glob 是否已覆盖
- 新增资源文件:确认 podspec `resource` / `resource_bundles` 是否已覆盖
- 新增依赖:确认 podspec `dependency` 是否已声明,评估对宿主 App 的依赖传递影响
- 模块目录结构调整:必须同步更新 podspec
### JS 桥接
- 涉及 `epub-bridge.js` 的改动,必须确认 JS ↔ Swift 消息契约的一致性
- JS 侧新增消息类型时,Swift 侧必须有对应处理分支
- 修改已有消息类型时,必须评估向后兼容
### 错误处理
- 使用 `enum` + `LocalizedError` 定义错误类型
- 禁止 force unwrap`!`),确需使用时必须先收敛前置条件
- 关键路径的错误必须向调用方传递,不得静默吞掉
## Required Workflow
### Phase 1 — 识别需求和范围
- 明确目标价值、影响层级(EPUBCore / EPUBTextRendering / RDReaderView / EPUBUI / Legacy)、In Scope / Out of Scope
- 判断是否涉及跨层、跨渲染路径、跨模块联调
- 若用户提供开发计划文档,先确认本次执行的唯一计划来源,并提取计划任务清单
- 优先做计划内最小可落地实现,不做需求外重构
- 若需求信息不足,先通过仓库事实补齐,再基于假设推进
### Phase 2 — 按需读取 Doc 与代码事实
- 若用户指定开发计划文档,必须先完整读取该文档
- 先读默认 DocARCHITECTURE / CODING_STYLE / EPUB_MAINTENANCE
- 根据需求类型补读各层源码和相关文档
- 用源码确认真实入口、调用链、数据流、状态流和复用点,不只依赖文档
### Phase 3 — 对齐并锁定开发计划
- 若已有开发计划,必须按计划锁定实现路径,不重新设计方案
- 若没有开发计划,才允许形成最小可落地方案:先复用现有模式与能力,再考虑新增抽象
- 保持分层边界:
- EPUB 解析逻辑在 EPUBCore
- 文本渲染逻辑在 EPUBTextRendering
- 分页容器逻辑在 RDReaderView
- 开箱即用 UI 在 EPUBUI
- 不在 Legacy 层新增功能
- 命名、注释、日志风格遵循 `Doc/CODING_STYLE.md`
- Swift / iOS 默认规则:
- 新增 Swift 类型遵循 `RD` / `RDEPUB` 前缀和层级目录归属
- 避免新增强制解包;确需使用时必须先收敛前置条件
- 不为"现代化"而重写稳定代码;仅在本次需求明确受益时再迁移系统 API
- 当前项目默认保持 UIKit + Auto Layout + 既有组件风格,SDK 层不使用 SnapKit
- 注释和日志使用中文
- Debug 输出放在 `#if DEBUG` 守卫下
- 不在日志中输出敏感信息
- 单文件预计超过 600 行需主动拆分,超过 1000 行必须拆分
- 若为跨层需求,计划中必须写清:
- 主改动层级
- 被影响层级
- 渲染路径覆盖范围
- 联调依赖点
- 关键风险
- 回滚点或降级方式
- 开始编码前必须形成内部执行清单,清单项必须能追溯到开发计划;计划外项只能标记为"必要补齐"或"待确认",不能直接实施
### Phase 4 — 执行修改与文档同步
- 所有代码改动遵循计划内最小改动原则
- 严格按开发计划清单逐项修改;不得临时改换实现方式或增加计划外功能
- 若执行中需要偏离计划,必须暂停确认,不能先改后说明
- 修改代码时必须补充必要注释,重点说明关键逻辑、边界条件和不直观处理
- 默认补做 SDK 自检:
- 检查新增类型是否使用正确前缀和层级归属
- 检查三种渲染路径的覆盖情况
- 检查 Public API 是否有意外 breaking change
- 检查 podspec 是否需要更新
- 检查 JS 桥接消息契约是否一致
- 关键页面或组件检查生命周期、通知/定时器/回调清理是否完整
- 新增 UI 检查长文本、图标按钮语义、重要状态是否只靠颜色表达
- 异步闭包默认考虑 `[weak self]`UI 更新回到主线程
- 修改代码后必须同步更新受影响文档,不能只停留在代码实现
- 若本次改动涉及 EPUB 维护相关,必须回写 `Doc/EPUB_MAINTENANCE.md`
- 若本次是方案讨论文档交接落地,方案类文档统一放到 `Doc/FeatureSolution/`
- 若新增、重命名或移动文档,必须同步更新目录索引
### Phase 5 — 构建验证与交付
- 完成修改后,按项目默认命令执行编译验证
- 若出现编译错误,自动修复并重编译
- 若遇构建锁问题,自动重试(建议最多 5 次)
- 若是跨层需求,除构建外还应补充关键联调路径说明
- 交付时固定输出:
- A. 需求理解
- B. 开发计划
- C. 开发实施
- D. 验证结果
- E. 交付摘要
## Output Contract
最终回复必须完整输出以下 5 个固定板块:
- A. 需求理解
- B. 开发计划
- C. 开发实施
- D. 验证结果
- E. 交付摘要
各板块内容要求:
- A:3-8 条,覆盖目标价值、范围、目标层级、渲染路径覆盖、交互流、限制
- B:按"计划来源 / 计划任务清单 / 执行顺序 / 验证与交付"组织;若有开发计划文档,必须逐条对应计划项
- C:描述实现过程、复用点、关键取舍,以及本次补充了哪些关键代码注释;若有计划未写明但必须补齐的内容,必须单独标注
- D:给出编译结果、关键路径验证、自动重试/自动修复情况、未覆盖风险
- E:列出改动文件、计划符合情况、关键取舍、回滚点或降级方式、已同步文档与具体文档落点、后续建议
## Validation
若本次调用修改了任何 Swift / Objective-C / 工程配置 / podspec 文件,必须执行构建验证。
验证方式:
- 若 Demo 工程可用:
```bash
xcodebuild build -workspace ReadViewSDKDemo/ReadViewSDKDemo.xcworkspace -scheme ReadViewSDKDemo -sdk iphonesimulator -derivedDataPath /private/tmp/readview-sdk-derived
```
- 若仅有 SDK 源码(无 workspace):
```bash
pod lib lint RDReaderView.podspec --allow-warnings
```
验证规则:
- 若出现编译错误,必须自动修复并重编译
- 若遇到 `database is locked` 等构建锁问题,自动重试,建议最多 5 次
- 直到 `BUILD SUCCEEDED` 或 `pod lib lint passed` 才可进入交付阶段
- 若本次只改文档或 skill 文件,可跳过编译,但要在交付中明确说明
## Maintenance Rules
- 优先复用 `Doc/`,不要把项目级规则复制进多个 skill 造成双份维护
- 新增项目约束时,优先更新 `Doc/CODING_STYLE.md`、`Doc/ARCHITECTURE.md` 等主文档,再调整 skill
- 轻量与完整要保持"轻重不同、规则不冲突",其中完整版应在轻量版闭环基础上扩展跨层与联调要求,而不是另起一套风格
- 变更默认流程、输出格式或适用边界时,必须同步更新相关文档
- 本 skill 持续作为完整开发入口,保持"可执行、可联调、可回滚、可交付"
- SDK 四层架构(EPUBCore → EPUBTextRendering → RDReaderView → EPUBUI)是执行的核心参照框架,所有改动必须明确标注落点层级和依赖方向
+29
View File
@@ -0,0 +1,29 @@
# Xcode user data
xcuserdata/
# Build artifacts
.artifacts/
.deriveddata/
.swift-module-cache/
ReadViewDemo/build/
# CocoaPods output for the Demo project is not vendored — everyone runs
# `pod install` locally.
ReadViewDemo/Pods/
ReadViewDemo/Podfile.lock
# macOS system files
.DS_Store
# Local backup directories
*手动解包备份*
# AI tool output
_ssoft-output/
.claude/*
!.claude/skills
# Python bytecode
__pycache__/
*.py[cod]
+13
View File
@@ -0,0 +1,13 @@
schemaVersion: 1
enabledWorkflows:
- simulator
debug: true
sentryDisabled: false
setupPreferences:
platforms:
- iOS
sessionDefaults:
workspacePath: ReadViewDemo/ReadViewDemo.xcworkspace
scheme: ReadViewDemo
simulatorId: FD12D2CF-AA13-48AF-B2D2-F1DFE806E839
simulatorName: iPhone 17 Pro
+67
View File
@@ -0,0 +1,67 @@
# ReadViewSDK AI 开发规则
## 适用范围与语言
- 默认处理当前仓库的 EPUB SDK、PDF SDK、Demo 和测试。
- 计划、交付说明、`Doc/` 与项目 Markdown 使用中文。
- 代码标识符、文件名、API 和配置键保持英文;代码注释、Docstring 和提交信息使用中文。
- 只做当前目标所需的最小改动,保留工作区内与任务无关的未提交修改。
- 任何写文件的 agent 必须先读取 `CONTEXT.md`
## 自动选择项目 Skill
根据用户意图选择 `.agents/skills/` 中最匹配的 Skill
- 先讨论、比较方案、不要改代码:`discuss-sdk-feature-solution`
- 单模块功能、Bug 修复、UI 调整、局部重构、构建修复:`start-sdk-feature-dev-lite`
- 跨层/跨阅读器改造、公共 API 或持久化迁移、复杂联调、完整方案执行:`start-sdk-feature-dev`
- 隐藏问题、并发、生命周期、性能、内存、缓存或兼容性审查:`review-swift-ios-sdk`
- 全局代码规范:`generate-sdk-code-style-doc`
- 单模块代码规范:`generate-module-code-style-doc`
- 模块调用链、状态流、分页或缓存实现逻辑:`generate-module-implementation-logic`
- 审查改动、生成详细中文提交说明并提交或推送 Git:`commit-git-changes`
不要为简单任务叠加多个 Skill。用户明确指定 Skill 时优先使用;任务边界扩大时再切换或补充,并说明原因。
## 文档按需读取
先定位源码,再读取直接相关文档,不默认全文加载整个 `Doc/`
| 任务 | 必读资料 |
| --- | --- |
| 任意代码修改 | `CONTEXT.md``Doc/CONVENTIONS.md` 相关章节 |
| 分层或跨模块改造 | `Doc/ARCHITECTURE.md` |
| 公共 API | `Doc/API_REFERENCE.md` 与真实 public 声明 |
| EPUB 解析/排版/分页/UI | 对应 `Doc/*_CODE_REFERENCE.md` 或专题文档 |
| PDF 阅读器 | `Sources/RDPDFReaderView` 源码;现有文档不足时以源码为准 |
| 测试与回归 | `Doc/TESTING.md` |
| 用户指定方案 | 指定文档,必须完整读取 |
`Doc/index.md` 用于路由。文档与源码冲突时以当前源码为事实,并指出待同步项。
## 模块边界
- EPUB`EPUBCore``EPUBTextRendering``ReaderView``EPUBUI`,禁止下层反向依赖上层。
- PDF:通用分页容器位于 `Sources/RDPDFReaderView/ReaderView`,成品能力位于 `Sources/RDPDFReaderView/Sources`
- Demo 和 UITests 只承担集成、样例与回归,不承载 SDK 核心业务。
- 保持 UIKit、SnapKit 和当前 CocoaPods 结构;新增依赖、资源或系统 framework 时同步检查对应 podspec。
- 新增类型遵循现有 `RD``RDEPUB``RDPDF` 前缀和最小可见性。
## SDK 兼容与质量
- 修改 public API 前评估宿主源码兼容、默认行为和迁移方式。
- 修改 Codable、持久化文件、缓存键或 book identifier 时提供版本与失效/迁移策略。
- UI 更新回主线程;异步任务处理取消、超时、迟到结果、恰好一次完成和生命周期。
- EPUB 改动检查 `.textReflowable``.webInteractive``.webFixedLayout` 的影响。
- PDF 改动检查宿主图片 Provider 与内置 PDFKit Provider 的影响。
- 滚动、缩放、分页和绘画热点避免同步 IO、重复解码、无界缓存和大对象长期驻留。
## 验证
修改 Swift、工程配置或 podspec 后默认执行:
`xcodebuild -workspace ReadViewDemo/ReadViewDemo.xcworkspace -scheme ReadViewDemo -configuration Debug -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO build`
按风险补充 `Doc/TESTING.md` 中的定向 UI 测试。仅修改文档、规则或 Skill 时可跳过 Xcode 构建,但必须执行结构、引用和格式检查。
如果使用 XcodeBuildMCP,调用其工具前必须先读取已安装的 XcodeBuildMCP Skill,并先检查 session defaults。
+151
View File
@@ -0,0 +1,151 @@
# ReadViewSDK 代码审查报告
> 审查日期:2026-06-26
> 审查范围:Sources/RDEpubReaderView 全部源码
> 审查方法:逐文件阅读 + 交叉验证 + 线程模型分析
---
## ✅ 确认成立的问题
### P1-1 WKURLSchemeHandler 同步文件 I/O 阻塞调用线程
**文件**: `EPUBCore/RDEPUBResourceURLSchemeHandler.swift:51-174`
**现象**: `webView(_:start:)` 回调中,所有文件读取均同步执行:
- `respondWithInMemoryData`(第119行)使用 `Data(contentsOf:)` 全量读入 ≤512KB 的文件
- `respondWithStreaming`(第143行)在 `while true` 循环中同步读 64KB 块并逐块回调 `urlSchemeTask.didReceive(data)`
Apple 未保证 WKURLSchemeHandler 回调线程为主线程,实际在 WKWebView 内部队列上执行。同步阻塞该线程会:
- 若在主线程:直接造成 UI 卡顿/ANR
- 若在 WKWebView 内部队列:阻塞资源加载管线,影响渲染时序
**建议**: 将文件读取移至后台队列,通过 `DispatchQueue.main.async` 或回调队列回传 `didReceive`/`didFinish`。流式场景可改为分批异步读取。
---
### P1-2 EPUB 解压缓存目录无清理策略
**文件**: `EPUBCore/RDEPUBParser+Archive.swift:79-90`
**现象**: `temporaryExtractionDirectory(for:)``(slug)-(fileSize)-(modificationTimestamp)` 生成缓存目录,存放在 `Caches/ssreaderview-epub/`。全局搜索确认不存在任何清理逻辑——无 LRU 淘汰、无总量上限、无 `removeItem` 调用指向该目录。
一本 50MB 的 EPUB 解压后约 100-200MB,用户打开多本书后缓存目录无限增长。
**建议**: 添加缓存清理策略:
1. 提供 `clearCache()` 公开方法供宿主 App 在低存储时调用
2. 每次打开新书时按访问时间淘汰超出上限的旧缓存
3. 或采用 `FileManager.default.urls(for: .cachesDirectory:)` 依赖系统自动清理
---
### P1-3 搜索在主线程同步执行
**文件**: `EPUBUI/ReaderController/RDEPUBReaderSearchCoordinator.swift:15-36`
**现象**: `search(keyword:)` 直接调用 `resolvedSearchMatches(for:)`,该方法遍历整本书的所有 spine 条目执行 HTML→文本转换或章节同步加载 + NSRange 搜索,全程在主线程完成。
对于大型 EPUB(数百章):
- UI 完全冻结直到搜索完成
- 用户无法取消或看到进度
- 可能触发 iOS 看门狗终止(0x8badf00d
`resolvedOnDemandSearchMatches(for:)`(第125行)更严重:它同步加载每个章节(`loadChapterSynchronouslyForMigration`),整本书搜索意味着逐章同步加载。
**建议**: 将搜索逻辑移至后台队列,对大型书籍实施分批搜索 + 渐进结果回调,并在 UI 层添加搜索进度指示和取消能力。
---
### P2-1 ZIP 无效条目导致整本 EPUB 打开失败
**文件**: `EPUBCore/RDEPUBParser+Archive.swift:41-44`
**现象**: 解压循环中,若 `validatedExtractionDestination` 返回 `nil`(条目路径验证失败),直接 `throw` 终止整本书的解析。实际 EPUB 中常包含无害条目(macOS `__MACOSX/` 目录、`.DS_Store`、Thumbs.db 等),这些不应阻止打开。
```swift
for entry in archive {
guard let destinationURL = validatedExtractionDestination(for: entry.path, ...) else {
throw RDEPUBParserError.invalidArchiveEntryPath(entry.path) // continue
}
```
**建议**: 将验证失败的条目改为 `continue` + 日志警告,并增加对已知无害条目路径的跳过逻辑。
---
### P2-2 javaScriptStringLiteral 实现依赖隐式假设
**文件**: `EPUBCore/RDEPUBJavaScriptBridge.swift:169-174`
**现象**: `javaScriptStringLiteral` 将值包装进单元素数组做 JSON 编码,再暴力去掉 `[``]`
```swift
private static func javaScriptStringLiteral(_ value: String?) -> String {
guard let value else { return "null" }
return jsonString(from: [value], fallback: "[null]")
.replacingOccurrences(of: "[", with: "")
.replacingOccurrences(of: "]", with: "")
}
```
这不是 XSS/注入问题(`JSONSerialization` 已正确转义引号/反斜杠等危险字符),但该方法依赖"值不含 `[``]`"的隐式假设。当前调用场景传入的是 CSS 颜色值(如 `#FFFFFF``rgba(...)`),不含方括号,所以实际安全。但未来如果有人将包含方括号的值传入,输出会被静默破坏。
**建议**: 改为更直观的安全编码方式,如直接对单值做 `JSONSerialization` 后去掉两端引号,或添加文档注释明确标注此方法的约束前提。
---
### P3-1 后台线程同步 hop 主线程获取布局信息
**文件**: `EPUBUI/ReaderController/RDEPUBReaderContext.swift:160-186`
**现象**: `currentTextPageSize()` 在非主线程且缓存不可用时,通过 `DispatchQueue.main.sync` 回到主线程获取布局信息。调用方包括 `RDEPUBMetadataParseWorker`(后台初始化,第72行)和 `RDEPUBChapterLoader``chapterLoadQueue.async`,第298行)。
```swift
func currentTextPageSize() -> CGSize {
if Thread.isMainThread {
// 线
...
} else if let lastTextPaginationPageSize, ... {
// hop
return lastTextPaginationPageSize
} else {
let mainThreadSize = DispatchQueue.main.sync { [weak self] in
self?.currentTextPageSize() ?? .zero
}
```
`Thread.isMainThread` 保护使得从主线程调用不会死锁。实际风险是**时序耦合**:后台任务阻塞等待主线程布局信息,如果主线程正在忙于 UI 操作(如翻页动画),后台线程会被卡住直到主线程空闲。这是一个线程模型/可维护性问题,而非可直接触发的死锁。
**建议**: 将布局参数作为初始化参数传入后台任务,而非在后台线程通过 `DispatchQueue.main.sync` 获取。这样后台线程完全自主,不依赖主线程时序。
---
## ✅ 已撤回的原始误判
以下条目经逐条代码验证后确认不成立或定级过高:
| 原始编号 | 原始结论 | 修正结论 |
|---------|---------|---------|
| ~~P0 引用循环~~ | Runtime/Coordinator 强引用 context 形成循环 | **不成立**。全量搜索确认所有 Coordinator/Runtime 均使用 `unowned let context``weak var context` |
| ~~P0 XSS 注入~~ | `javaScriptStringLiteral``]` 替换导致注入 | **不成立**`JSONSerialization` 编码已处理引号/反斜杠等危险字符。实际是健壮性问题,非安全问题(已调整为 P2-2) |
| ~~P1 Publication 暴露 parser~~ | 外部可直接修改 parser 内部状态 | **不成立**`RDEPUBParser` 关键属性均为 `public internal(set)`,外部模块无法修改 |
| ~~P2 NSRange 越界~~ | UTF-16 和 Swift String 混用导致偏移 | **不成立**。搜索代码全程在 `NSString` + `NSRange` 域内操作,未混合 Swift String 索引 |
| ~~P2 后台 deinit 崩溃~~ | teardownWebView 可能在后台线程执行 | **证据不足**`RDEPUBWebView` 是 UIView 子类,正常在主线程释放,无证据表明会后台释放 |
| ~~P0 ZIP 路径遍历绕过~~ | `%2e%2e` 可绕过路径校验 | **论证过头**。当前实现有组件级 `..` 检查 + `standardizedFileURL` 前缀校验,双层防护有效 |
---
## 📊 问题汇总
| 严重度 | 编号 | 问题 | 类型 |
|--------|------|------|------|
| 🟠 P1 | P1-1 | WKURLSchemeHandler 同步文件 I/O 阻塞调用线程 | 性能 |
| 🟠 P1 | P1-2 | EPUB 解压缓存目录无清理策略 | 存储 |
| 🟠 P1 | P1-3 | 搜索在主线程同步执行,大书可致 ANR | 性能 |
| 🟡 P2 | P2-1 | ZIP 无效条目导致整本打开失败 | 鲁棒性 |
| 🟡 P2 | P2-2 | javaScriptStringLiteral 实现依赖隐式假设 | 可维护性 |
| 🔵 P3 | P3-1 | 后台线程同步 hop 主线程获取布局信息 | 线程模型 |
**整体评价**: SDK 架构设计良好,模块分层清晰,引用管理(unowned/weak)使用正确,分页取消机制有 `paginationToken` + `cancellationController` 兜底。主要值得修复的是三个性能类 P1 问题(主线程阻塞搜索、资源加载阻塞、缓存无清理),两个 P2 鲁棒性/可维护性问题,以及一个 P3 线程模型优化建议。
+18
View File
@@ -0,0 +1,18 @@
# Project Context & Rules
## Language Specification
- **Project Docs:** All documents generated or updated under `Doc/`, project root Markdown documentation MUST be written in Chinese.
- **Codebase:** Source code syntax (class names, function names, variables, file names, API identifiers, route identifiers) must remain in English, but all code comments, documentation (Docstrings), and commit messages MUST be written in Chinese.
## Agent / Sub-agent Enforcement
- **Mandatory pre-read:** Any agent/sub-agent that generates or updates files MUST read this file (`CONTEXT.md`) first and follow it as the source-of-truth for language/style constraints.
- **Chinese-only outputs:** Any artifact written under `Doc/` MUST be Chinese. Do not output English headings/sections unless the content is a literal code identifier or a proper noun that should not be translated.
- **When spawning sub-agents:** The orchestrator MUST either:
- set `fork_context=true`, AND
- explicitly instruct the agent to read `CONTEXT.md` before writing; OR
- (if `fork_context=false`) explicitly include the above constraints in the sub-agent prompt and still require a `CONTEXT.md` pre-read.
### Recommended prompt snippet (copy into sub-agent tasks)
1) First read `CONTEXT.md` and follow all rules.
2) All markdown you write under `Doc/` MUST be Chinese.
3) Keep code identifiers / file paths / commands in English as-is.
+487
View File
@@ -0,0 +1,487 @@
# 公开 API 参考手册
> 最后更新:2026-06-18
本文档列出 ReadViewSDK 所有公开 API 的完整签名、参数说明和使用方法。
---
## 1. RDEPUBReaderController — 入口控制器
**文件**`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift` 及扩展
### 1.1 初始化
```swift
// EPUB
init(
epubURL: URL, // EPUB
configuration: RDEPUBReaderConfiguration = .default, //
persistence: RDEPUBReaderPersistence? = nil, //
dependencies: RDEPUBReaderDependencies? = nil //
)
// TextBook .txt
init(
textBook: RDEPUBTextBook, //
bookIdentifier: String, //
title: String, //
textFileURL: URL? = nil, //
configuration: RDEPUBReaderConfiguration = .default,
persistence: RDEPUBReaderPersistence? = nil,
dependencies: RDEPUBReaderDependencies? = nil
)
```
### 1.2 公开属性
| 属性 | 类型 | 读写 | 说明 |
|------|------|------|------|
| `delegate` | `RDEPUBReaderDelegate?` | 读写 | 委托代理 |
| `configuration` | `RDEPUBReaderConfiguration` | 读写 | 阅读配置,设置后触发重新分页 |
| `currentLocation` | `RDEPUBLocation?` | 只读 | 当前阅读位置 |
| `currentPageNumber` | `Int?` | 只读 | 当前页码(1-based |
| `currentSelection` | `RDEPUBSelection?` | 只读 | 当前文本选区 |
| `highlights` | `[RDEPUBHighlight]` | 只读 | 所有高亮标注 |
| `bookmarks` | `[RDEPUBBookmark]` | 只读 | 所有书签 |
| `annotations` | `[RDEPUBAnnotation]` | 只读 | 合并排序后的统一标注列表 |
| `tableOfContents` | `[EPUBTableOfContentsItem]` | 只读 | 目录树(嵌套结构) |
| `flattenedTableOfContents` | `[RDEPUBReaderTableOfContentsItem]` | 只读 | 扁平化目录列表 |
| `currentTableOfContentsItem` | `RDEPUBReaderTableOfContentsItem?` | 只读 | 当前所在目录项 |
### 1.3 导航方法
```swift
///
func reloadBook()
///
func go(to location: RDEPUBLocation)
/// 1-based
/// - Returns:
@discardableResult
func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool
/// EPUBTableOfContentsItem
@discardableResult
func go(toTableOfContentsItem item: EPUBTableOfContentsItem, animated: Bool = true) -> Bool
/// RDEPUBReaderTableOfContentsItem
@discardableResult
func go(toTableOfContentsItem item: RDEPUBReaderTableOfContentsItem, animated: Bool = true) -> Bool
/// href fragment "chapter1.xhtml#section2"
@discardableResult
func go(toTableOfContentsHref href: String, animated: Bool = true) -> Bool
///
@discardableResult
func go(toHighlightID id: String, animated: Bool = true) -> Bool
///
@discardableResult
func go(toBookmarkID id: String, animated: Bool = true) -> Bool
```
### 1.4 高亮方法
```swift
/// 使
/// - Parameters:
/// - selection: nil 使 currentSelection
/// - color: "#F8E16C"
/// - note:
/// - Returns: nil
@discardableResult
func addHighlight(
from selection: RDEPUBSelection? = nil,
color: String = "#F8E16C",
note: String? = nil
) -> RDEPUBHighlight?
/// 线
@discardableResult
func addAnnotation(
from selection: RDEPUBSelection? = nil,
style: RDEPUBHighlightStyle,
color: String = "#F8E16C",
note: String? = nil
) -> RDEPUBHighlight?
///
@discardableResult
func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight?
///
@discardableResult
func removeHighlight(id: String) -> RDEPUBHighlight?
///
@discardableResult
func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight?
///
func removeAllHighlights()
/// ID
func highlight(withID id: String) -> RDEPUBHighlight?
```
### 1.5 书签方法
```swift
/// 使
@discardableResult
func addBookmark(note: String? = nil) -> RDEPUBBookmark?
///
@discardableResult
func toggleBookmark(note: String? = nil) -> RDEPUBBookmark?
///
@discardableResult
func removeBookmark(id: String) -> RDEPUBBookmark?
/// ID
func bookmark(withID id: String) -> RDEPUBBookmark?
```
### 1.6 搜索方法
```swift
///
func search(keyword: String)
///
/// - Returns:
@discardableResult
func searchNext() -> Bool
///
@discardableResult
func searchPrevious() -> Bool
///
func clearSearch()
```
### 1.7 其他方法
```swift
///
func clearSelection()
///
func nativeTextSemanticSummary() -> String?
```
---
## 2. RDEPUBReaderDelegate — 委托协议
**文件**`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderDelegate.swift`
所有方法均有默认空实现,可按需实现。
```swift
public protocol RDEPUBReaderDelegate: AnyObject {
///
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication)
///
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation)
///
func epubReaderDidReachEnd(_ reader: UIViewController)
/// nil
func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?)
///
func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight])
///
func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark])
///
func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?)
///
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?)
///
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?)
///
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL)
/// false
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool
///
func epubReader(_ reader: UIViewController, didFailWithError error: Error)
///
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView)
}
```
---
## 3. RDEPUBReaderPersistence — 持久化协议
**文件**`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
```swift
public protocol RDEPUBReaderPersistence: AnyObject {
func loadLocation(for bookIdentifier: String) -> RDEPUBLocation?
func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String)
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark]
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String)
func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight]
func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String)
func loadReaderSettings() -> RDEPUBReaderSettings?
func saveReaderSettings(_ settings: RDEPUBReaderSettings)
}
```
默认实现 `RDEPUBUserDefaultsPersistence` 将阅读位置和全局设置存入 UserDefaults
书签与高亮(可能含书籍正文摘录)存入带文件保护且排除备份的 Application Support 文件。
升级后的首次读取会自动迁移历史 UserDefaults 数据,并仅在新副本成功落盘后删除旧副本。
不同的 `UserDefaults` 容器会自动使用独立的文件命名空间,避免多账号或测试 suite
之间混用同一本书的标注;标准容器保留对旧版未分区文件的只读兼容。
| 数据 | Key 格式 |
|------|----------|
| 阅读位置 | `ssreader.epub.location.{bookID}` |
| 书签 | 受保护文件(历史键:`ssreader.epub.bookmarks.{bookID}` |
| 高亮 | 受保护文件(历史键:`ssreader.epub.highlights.{bookID}` |
| 全局设置 | `ssreader.epub.settings` |
---
## 4. RDEPUBReaderConfiguration — 配置模型
**文件**`Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift`
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `fontSize` | `CGFloat` | `15` | 字体大小(pt |
| `lineHeightMultiple` | `CGFloat` | `1.6` | 行距倍数 |
| `fontChoice` | `RDEPUBReaderFontChoice` | `.system` | 字体选择(system/serif/rounded/monospaced |
| `numberOfColumns` | `Int` | `1` | 每页列数(1 或 2 |
| `columnGap` | `CGFloat` | `20` | 列间距 |
| `displayType` | `RDEpubReaderView.DisplayType` | `.pageCurl` | 翻页模式 |
| `landscapeDualPageEnabled` | `Bool` | `true` | 横屏双页 |
| `showsTableOfContents` | `Bool` | `true` | 显示目录 |
| `allowsHighlights` | `Bool` | `true` | 允许高亮 |
| `showsSettingsPanel` | `Bool` | `true` | 显示设置面板 |
| `reflowableContentInsets` | `UIEdgeInsets` | `(40,16,40,16)` | 重排内容内边距 |
| `fixedContentInset` | `UIEdgeInsets` | `.zero` | 固定布局内边距 |
| `theme` | `RDEPUBReaderTheme` | `.light` | 阅读主题 |
| `darkImageAdjustmentEnabled` | `Bool` | `true` | 暗色模式图片调整 |
| `darkImageBlendRatio` | `CGFloat` | `0.15` | 暗色图片混合比例(0-0.35) |
| `fixedLayoutFit` | `RDEPUBFixedLayoutFit` | `.page` | 固定布局适配方式 |
| `fixedLayoutSpreadMode` | `RDEPUBFixedLayoutSpreadMode` | `.automatic` | 固定布局跨页模式 |
| `textRenderingEngine` | `RDEPUBTextRenderingEngine` | `.dtCoreText` | 文本渲染引擎 |
| `onDemandChapterWindowSize` | `Int` | `3` | 按需加载窗口大小(奇数,3-15) |
| `metadataParsingConcurrency` | `Int` | CPU 核心数 | 后台解析并发数 |
| `jumpSessionPolicy` | `RDEPUBJumpSessionPolicy` | `.default` | 远距跳转策略 |
| `allowedExternalURLSchemes` | `Set<String>` | `["https"]` | 允许的外部 URL 协议 |
| `requiresExternalLinkConfirmation` | `Bool` | `true` | 外部链接需确认 |
| `allowsInspectableWebViews` | `Bool` | `false` | WebView 可调试 |
| `enablesVerboseWebViewLogging` | `Bool` | `false` | WebView 详细日志 |
**计算属性**
- `chapterWindowRadius: Int` — 内存缓存窗口半径(`onDemandChapterWindowSize / 2`
---
## 5. 翻页容器协议
**文件**`Sources/RDEpubReaderView/ReaderView/RDEpubReaderViewProtocols.swift`
### 5.1 RDEpubReaderPageProvider(推荐)
```swift
@objc public protocol RDEpubReaderPageProvider: NSObjectProtocol {
///
func numberOfPages(in readerView: RDEpubReaderView) -> Int
///
/// - Parameters:
/// - index: 0-based
/// - reusableView: nil
func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView
///
@objc optional func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String?
///
@objc optional func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView?
///
@objc optional func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView?
}
```
### 5.2 RDEpubReaderDelegate
```swift
@objc public protocol RDEpubReaderDelegate: NSObjectProtocol {
///
func pageNum(readerView: RDEpubReaderView, pageNum: Int)
///
@objc optional func readerViewOrientationWillChange(readerView: RDEpubReaderView, isLandscape: Bool)
}
```
### 5.3 RDEpubReaderPageNavigating
```swift
public protocol RDEpubReaderPageNavigating: AnyObject {
///
var currentPage: Int { get }
///
func reloadPages()
///
func transition(to page: Int, animated: Bool)
}
```
### 5.4 RDEpubReaderDataSource(遗留)
```swift
@objc public protocol RDEpubReaderDataSource: NSObjectProtocol {
func pageCountOfReaderView(readerView: RDEpubReaderView) -> Int
func pageContentView(readerView: RDEpubReaderView, pageNum: Int, containerView: UIView?) -> UIView
func pageIdentifier(readerView: RDEpubReaderView, pageNum: Int) -> String?
@objc optional func topToolView(readerView: RDEpubReaderView) -> UIView?
@objc optional func bottomToolView(readerView: RDEpubReaderView) -> UIView?
}
```
通过 `RDEpubReaderLegacyDataSourceAdapter` 自动适配到 `RDEpubReaderPageProvider`
---
## 6. 数据模型速查
### 6.1 RDEPUBLocation
```swift
public struct RDEPUBLocation: Codable, Equatable {
public var bookIdentifier: String? //
public var href: String //
public var progression: Double // 0.0-1.0
public var lastProgression: Double? //
public var fragment: String? // ID #section1
public var rangeAnchor: RDEPUBTextRangeAnchor? //
}
```
### 6.2 RDEPUBBookmark
```swift
public struct RDEPUBBookmark: Codable, Equatable, Identifiable {
public let id: String //
public let bookIdentifier: String?
public let location: RDEPUBLocation
public let chapterTitle: String?
public let note: String?
public let createdAt: Date
}
```
### 6.3 RDEPUBHighlight
```swift
public struct RDEPUBHighlight: Codable, Equatable, Identifiable {
public let id: String
public let bookIdentifier: String?
public let location: RDEPUBLocation
public let text: String //
public let style: RDEPUBHighlightStyle // .highlight / .underline
public let color: String //
public let note: String?
public let rangeInfo: String? // CoreText
public let createdAt: Date
public var uiColor: UIColor { ... } //
}
```
### 6.4 RDEPUBAnnotation
```swift
public struct RDEPUBAnnotation: Codable, Equatable, Identifiable {
public let id: String
public let bookIdentifier: String?
public let kind: RDEPUBAnnotationKind // .bookmark / .highlight / .underline
public let location: RDEPUBLocation
public let text: String?
public let rangeInfo: String?
public let chapterTitle: String?
public let createdAt: Date
public var bookmark: RDEPUBBookmark? { ... } // kind == .bookmark
public var highlight: RDEPUBHighlight? { ... } // kind == .highlight/.underline
}
```
### 6.5 RDEPUBSelection
```swift
public struct RDEPUBSelection: Equatable {
public let bookIdentifier: String?
public let location: RDEPUBLocation
public let text: String //
public let rangeInfo: String? // CoreText
public let createdAt: Date
}
```
### 6.6 EPUBPage / EPUBChapterInfo
```swift
public struct EPUBPage: Equatable {
public let spineIndex: Int
public let chapterIndex: Int
public let pageIndexInChapter: Int
public let totalPagesInChapter: Int
public let chapterTitle: String
public let fixedSpread: EPUBFixedSpread?
}
public struct EPUBChapterInfo: Equatable {
public let spineIndex: Int
public let title: String
public let pageCount: Int
}
```
### 6.7 RDEPUBSearchResult / RDEPUBSearchMatch
```swift
public struct RDEPUBSearchResult: Equatable {
public let keyword: String
public let matches: [RDEPUBSearchMatch]
}
public struct RDEPUBSearchMatch: Equatable {
public let spineIndex: Int
public let progression: Double
public let previewText: String
public let rangeAnchor: RDEPUBTextRangeAnchor?
}
```
+448 -363
View File
@@ -1,415 +1,500 @@
# RDReaderView 架构文档
# ReadViewSDK 系统架构文档
> 最后更新:2026-06-22
---
## 1. 项目概览
RDReaderView 是一个 iOS 阅读器组件库(CocoaPods),提供开箱即用的 EPUB 阅读能力,并保留对纯文本翻页的支持
ReadViewSDK 是一个 iOS EPUB 阅读器 SDK,支持文本重排(Reflowable)和固定布局(Fixed Layout)两种 EPUB 格式,同时兼容纯文本 (.txt) 文件。SDK 提供完整的 EPUB 解析、文本渲染、分页计算、阅读 UI 和标注管理能力
- **最低 iOS 版本**15.0podspec 仍标注 9.0,实际 demo Podfile 要求 15.0
- **Swift 版本**5.0+
- **依赖**ZIPFoundationEPUB 解压)、DTCoreText(文本 EPUB 渲染
- **Demo 额外依赖**SnapKit、SSAlertSwift
**技术栈:**
- 语言:Swift 5,最低 iOS 15.6
- 依赖:DTCoreTextHTML→NSAttributedString)、ZIPFoundationEPUB 解压)、SnapKitAuto Layout)、SSAlertSwift(弹窗
- 构建:CocoaPods,本地 pod 引用
---
## 2. 总体分层
## 2. 分层架构
```
┌─────────────────────────────────────────────────────────────┐
│ ReadViewDemo (Demo App) │
│ ViewController · LaunchAutomationPlan · UITests │
└──────────────────────────────┬──────────────────────────────┘
│ imports RDEpubReaderView
┌──────────────────────────────▼──────────────────────────────┐
│ EPUBUI 层 │
│ RDEPUBReaderController · Coordinators · Settings · TextPage │
│ ChapterRuntimeLoader · Store · DiskCache · PageMap
└──────────────────────────────┬──────────────────────────────┘
┌──────────────────────────────▼──────────────────────────────┐
│ RDEpubReaderView 层 │
│ RDEpubReaderView · FlowLayout · PreloadController │
│ SpreadResolver · TapRegionHandler · PagingController │
└──────────────────────────────┬──────────────────────────────┘
┌──────────────────────────────▼──────────────────────────────┐
│ EPUBTextRendering 层 │
│ TypesetterPipeline · DTCoreTextRenderer · Pagination │
│ BuildPipelineTextBookBuilder · Cache · Sampler
└──────────────────────────────┬──────────────────────────────┘
┌──────────────────────────────▼──────────────────────────────┐
│ EPUBCore 层 │
│ RDEPUBParser · Publication · ResourceResolver │
│ ReadingSession · WebView · JavaScriptBridge · Paginator │
└─────────────────────────────────────────────────────────────┘
```
### 各层职责
| 层 | 职责 | 核心类 |
|---|---|---|
| **EPUBCore** | EPUB 文件解析、资源管理、WebView 渲染、JS 桥接 | `RDEPUBParser`, `RDEPUBPublication`, `RDEPUBWebView` |
| **EPUBTextRendering** | HTML→NSAttributedString 转换、排版、分页、全书构建 | `RDEPUBTextBookBuilder`, `RDEPUBCoreTextPageFrameFactory` |
| **RDEpubReaderView** | 通用翻页容器、手势识别、页面预加载、双页布局 | `RDEpubReaderView`, `RDEpubReaderFlowLayout`, `RDEpubReaderPreloadController` |
| **EPUBUI** | 阅读器控制器、协调器模式、设置面板、按需加载、磁盘缓存 | `RDEPUBReaderController`, `RDEPUBReaderPaginationCoordinator` |
---
## 3. 核心数据流
### 3.1 打开书籍流程
```
用户点击书籍
RDEPUBReaderController(epubURL:delegate:persistence:) // 初始化,传入 epubURL
├─ viewDidLoad() → startInitialLoadIfNeeded()
│ │
│ ├─ RDEPUBReaderLoadCoordinator.startInitialLoadIfNeeded()
│ │ ├─ RDEPUBParser.parse(epubURL:) // 解压 ZIP → 解析 OPF → 构建 spine/TOC
│ │ │ └─ extractArchiveIfNeeded() // ZIPFoundation 解压到 Caches
│ │ │ └─ parseContainerRootFile() // SAX 解析 container.xml
│ │ │ └─ parseOPF() // SAX 解析 OPF (metadata/manifest/spine)
│ │ │ └─ parseTOC() // NCX 或 Navigation Document
│ │ │
│ │ ├─ RDEPUBPublication(parser:) // 创建门面对象
│ │ ├─ 恢复持久化数据(书签/高亮/位置)
│ │ └─ applyParsedPublication()
│ │
│ └─ 判断 readingProfile:
│ ├─ .textReflowable → 文本排版路径(本 SDK 核心路径)
│ ├─ .webInteractive → WebView 渲染路径
│ └─ .webFixedLayout → 固定布局路径
└─ runtime.paginatePublication() // 进入分页流程
```
### 3.2 文本重排分页流程(大书优化路径)
```
paginateTextPublication()
├─ restoreBookPageMapIfPossible() // 尝试从磁盘恢复完整 BookPageMap
│ └─ summaryDiskCache.readAll(keys:) // 批量读取磁盘摘要
│ └─ 如果完整 → 直接 applyBookPageMap() → 完成
├─ 快速打开路径:
│ ├─ loadFirstRenderableRuntimeChapter() // 加载第一个可渲染章节
│ ├─ loadInitialRuntimeChapters() // 加载窗口内相邻章节
│ ├─ makePartialPageMap() // 构建局部 BookPageMap
│ └─ applyBookPageMap() // 应用到 UI,用户可立即阅读
└─ RDEPUBMetadataParseWorker.start() // 后台元数据解析
├─ 预计算 contentHash(串行) // 读取所有章节 HTML + SHA-256
├─ readAll(keys:) 恢复已有缓存
├─ waitForReadingInteractionToSettle() // 等待用户操作冷却 0.8s
├─ OperationQueue (并发 N):
│ ├─ buildChapter() // 渲染单章 (HTML→NSAttrStr→分页)
│ ├─ chapterCacheKey() // 构建缓存键(复用预计算 hash)
│ ├─ RDEPUBChapterSummary 写盘 // 异步写入磁盘摘要
│ └─ 每 32 章刷新 BookPageMap // 增量更新 UI
└─ 最终 applyBookPageMap() // 完整页码映射
```
### 3.3 单章渲染管线
```
Raw HTML (从 EPUB 解压目录读取)
▼ [RDEPUBTextTypesetterPipeline]
├─ RDEPUBHTMLNormalizer // 去 CR、合并空行、规范化附件标记
├─ RDEPUBSemanticMarkerInjector // 注入分页语义标记 ${rd-sem-start/end}
├─ RDEPUBRenderDiagnosticsCollector // 内联 <link stylesheet>、收集图片诊断
├─ RDEPUBStyleSheetComposer // 构建 5 层 CSS (default/replace/dark/epub/user)
├─ RDEPUBFontNormalizer // 注册 @font-face 嵌入字体
├─ RDEPUBFragmentMarkerInjector // 注入 fragment 锚点标记 ${id=xxx}
▼ [RDEPUBDTCoreTextRenderer]
├─ DTHTMLAttributedStringBuilder // HTML → NSAttributedString
├─ applyPaginationSemantics() // 将语义标记转为NSAttributedString属性
├─ extractFragmentOffsets() // 提取 fragment ID→offset 映射
├─ normalizeReadingAttributes() // 规范化字体、行距、颜色、附件
▼ [RDEPUBChapterPageCounter]
├─ CTFramesetter 创建帧
├─ RDEPUBPageBreakPolicy 应用语义边界规则
│ ├─ avoidPageBreakInside // 避免块内分页
│ ├─ keepWithNext // 标题与正文不分离
│ ├─ widow/orphan 控制 // 孤行/寡行控制
│ └─ pageRelate // 微信读书跨页关联
├─ RDEPUBChapterTailNormalizer // 删除/合并空白尾页
▼ [RDEPUBTextBookBuilder]
└─ 组装 RDEPUBTextPage[] → RDEPUBTextChapter → RDEPUBTextBook
```
---
## 4. 缓存架构(三级缓存)
```
┌─────────────────────────────────────────────────────────┐
Demo / 宿主 App
ViewController → RDReaderControllerdemo 级路由控制器)
└───────────────────────────┬─────────────────────────────┘
┌───────────────────────────▼─────────────────────────────┐
│ EPUBUI 层(library 级读者 UI
│ RDEPUBReaderController(开箱即用入口)
│ RDEPUBReaderConfiguration / Theme / Persistence │
│ TopToolView / BottomToolView / ChapterList / Settings
RDEPUBTextContentView / RDEPUBWebContentView
└───────────────────────────┬─────────────────────────────┘
┌───────────────────────────▼─────────────────────────────┐
翻页容器层(RDReaderView
│ RDReaderViewUIView,统一翻页外壳) │
4 种翻页模式:pageCurl / horizontalScroll /
│ verticalScroll / horizontalCoverScroll │
RDReaderFlowLayout / RDReaderContentCell
RDReaderPageChildViewControllerpageCurl 页包装)
└───────────────────────────┬─────────────────────────────┘
┌───────────────────────────▼─────────────────────────────┐
│ EPUBCore 层(EPUB 引擎) │
│ │
│ Publication 层 │
│ RDEPUBParser+Archive / +Package / +TOC / …) │
│ RDEPUBPublication(出版物聚合对象) │
│ RDEPUBModelsmetadata / manifest / spine 模型) │
│ RDEPUBReadingModelslocation / viewport / highlight
│ │
│ Services 层 │
│ RDEPUBResourceResolver(资源 URL 统一入口) │
│ RDEPUBPreferences(展示参数聚合) │
│ RDEPUBPaginator(离屏分页服务) │
│ │
│ Navigator 层 │
│ RDEPUBReadingSession(状态机 + 会话协调) │
│ RDEPUBNavigatorState(状态枚举) │
│ RDEPUBNavigatorLayoutContext │
│ │
│ Resource View 层 │
│ RDEPUBWebView+Configuration / +Reflowable / │
│ +FixedLayout / +JavaScriptBridge
│ RDEPUBResourceURLSchemeHandlerss-reader:// 协议) │
│ RDEPUBStyleSheetBuilder / RDEPUBJavaScriptBridge │
│ RDEPUBFixedLayoutTemplate / RDEPUBAssetRepository │
│ RDEPUBRenderRequest │
└───────────────────────────┬─────────────────────────────┘
┌───────────────────────────▼─────────────────────────────┐
│ EPUBTextRendering 层(文本 EPUB 渲染) │
│ RDEPUBTextRenderer(协议) │
│ RDEPUBDTCoreTextRendererDTCoreText 实现) │
│ RDEPUBTextRendererSupport / RDEPUBTextPaginationSupport │
│ RDEPUBTextBookBuilder │
└──────────────────────────────────────────────────────────┘
Tier 1: 内存缓存 (RDEPUBChapterRuntimeStore)
├─ chapterDataCache: [Int: RDEPUBRuntimeChapter]
│ ├─ pageCountCache: [CacheKey: RDEPUBRuntimePageCount] │
├─ imageCache: NSCache<NSString, UIImage> (50 上限)
│ └─ 窗口驱逐:只保留当前章节 ± windowRadius 的章节 │
└──────────────────────────┬──────────────────────────────┘
miss
┌──────────────────────────▼──────────────────────────────┐
│ Tier 2: 磁盘摘要缓存 (RDEPUBChapterSummaryDiskCache)
├─ 路径: ~/Caches/RDEPUBChapterSummaryCache/{bookID}/
│ ├─ 文件名: SHA256(bookID_spineIdx_renderSig_contentHash)│
├─ 格式: JSON (RDEPUBChapterSummary)
│ ├─ 写入: 异步(serial DispatchQueue
└─ 读取: 同步(readAll 批量读取)
└──────────────────────────┬──────────────────────────────┘
miss
┌──────────────────────────▼──────────────────────────────┐
Tier 3: 全书分页缓存 (RDEPUBTextBookCache)
├─ 路径: ~/Caches/RDEPUBTextBookCache/
│ ├─ 格式: NSSecureCoding archive │
├─ 内容: 每章的 pageRanges + breakReasons + semanticHints
│ └─ 失效: schemaVersion 变更时全部失效 │
└─────────────────────────────────────────────────────────┘
```
---
## 3. 翻页容器层(RDReaderView
### 3.1 四种翻页模式
| 模式 | 实现方式 | 特点 |
|------|----------|------|
| `pageCurl` | UIPageViewController | 原生翻书效果,手势由系统提供 |
| `horizontalScroll` | UICollectionView + RDReaderFlowLayout | 每屏显示 2 项,水平分页滚动 |
| `verticalScroll` | UICollectionView + RDReaderFlowLayout | 全宽项目,垂直连续滚动 |
| `horizontalCoverScroll` | UICollectionView + RDReaderFlowLayout | 覆盖滚动效果,Z 轴动画 |
### 3.2 数据源协议
### 缓存键设计
```swift
public protocol RDReaderDataSource: NSObjectProtocol {
func pageCountOfReaderView(readerView: RDReaderView) -> Int
func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView
func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String?
@objc optional func topToolView(readerView: RDReaderView) -> UIView?
@objc optional func bottomToolView(readerView: RDReaderView) -> UIView?
struct RDEPUBChapterCacheKey: Hashable {
let bookID: String //
let spineIndex: Int //
let renderSignature: String // ////
let chapterContentHash: String // HTML SHA-256
}
```
### 3.3 手势分区(scroll 模式)
屏幕水平三等分:
- **左 1/3**:上一页
- **中 1/3**:显示 / 隐藏工具栏
- **右 1/3**:下一页
### 3.4 翻页模式切换
`switchReaderDisplayType(_ type:)` 会完全销毁并重建底层 viewpageViewController 或 collectionView),然后重新加载数据。
缓存键的四元组设计确保:
- 换字体/字号 → renderSignature 变化 → 缓存失效
- EPUB 内容更新 → contentHash 变化 → 缓存失效
- 不同书籍 → bookID 不同 → 互不干扰
---
## 4. EPUB 引擎层(EPUBCore
### 4.1 Publication 层:解析与聚合
**主链路:**
## 5. ReaderController 组合架构
```
epubURL
→ RDEPUBParser.parse(epubURL:)
→ extractArchive # ZIP 解压到沙盒临时目录
→ parseContainerXML # 定位 OPF 路径
→ parseOPF # 解析 metadata / manifest / spine
→ parseTOC # 解析 NCX 或 Nav 目录
→ RDEPUBPublication(parser:) # 聚合解析结果,挂载 resourceResolver
RDEPUBReaderController
├─ RDEPUBReaderContext(过渡门面)
│ ├─ RDEPUBReaderState
│ │ ├─ parser / publication / readingSession
│ │ ├─ textBook / bookPageMap / pendingFullPageMap
│ │ ├─ activeBookmarks / activeHighlights / searchState
│ │ └─ currentSelection / paginationToken / snapshot
│ │
│ ├─ RDEPUBReaderEnvironment
│ │ ├─ viewport / safeArea / traitCollection 抽象
│ │ ├─ brightness / fallbackViewportSize
│ │ └─ renderStyle / layoutConfig 推导
│ │
│ └─ RDEPUBReaderServices
│ ├─ parser / paginator / builder factory
│ ├─ renderer factory
│ └─ chapter summary disk cache factory
└─ RDEPUBReaderRuntime(兼容门面)
├─ load / pagination / location / search / chrome / annotation coordinator
├─ RDEPUBPresentationRuntime
├─ RDEPUBChapterWarmupOrchestrator
└─ 旧 API 转发
```
**RDEPUBPublication 暴露的能力:**
当前 `RDEPUBReaderContext` 仍存在,但主要作用已经收缩为过渡门面:
| 属性 / 方法 | 说明 |
|------------|------|
| `metadata` | 书名、作者、语言、layout 等 |
| `manifest` | id → RDEPUBManifestItem 映射 |
| `spine` | 有序 spine 列表(包含 href |
| `tableOfContents` | 树形目录 |
| `layout` | `.reflowable``.fixed` |
| `readingProfile` | `.webInteractive` / `.webFixedLayout` / `.textReflowable` |
| `resourceResolver` | 统一资源 URL 解析入口 |
| `fixedLayoutSpreadEnabled(for:viewportSize:)` | 判断是否启用双页 spread |
| `makeFixedSpreads(preferences:viewportSize:)` | 生成 fixed spread 页模型 |
- 对外维持兼容访问面
- 对内把纯状态、环境推导、工厂依赖拆开
- 避免新逻辑继续把 `Context` 当作全能对象扩散
**readingProfile 判定逻辑:**
---
## 6. 章节按需加载与分页窗口架构
```
layout == .fixed → webFixedLayout
layout == .reflowable + 含交互脚本 → webInteractive
layout == .reflowable + 无交互脚本 → textReflowable
RDEPUBReaderController
├─ RDEPUBReaderPaginationCoordinator // 分页入口与后台元数据解析
│ ├─ paginatePublication()
│ ├─ RDEPUBMetadataParseWorker.start()
│ └─ restoreBookPageMapIfPossible()
├─ RDEPUBPresentationRuntime // 分页状态与窗口替换
│ ├─ applyBookPageMap()
│ ├─ refreshBookPageMapInPlace()
│ └─ applyPendingFullPageMapIfNeeded()
├─ RDEPUBChapterWarmupOrchestrator // 按需章节预热与边界预取
│ ├─ prepareOnDemandChapter()
│ ├─ extendPartialBookPageMapIfNeeded()
│ ├─ prefetchForwardChaptersAfterInitialOpen()
│ └─ ensureNavigationTargetAvailable()
├─ RDEPUBChapterLoader // 章节构建与缓存门面
│ ├─ loadChapter() // 异步加载(主路径)
│ └─ loadChapterSynchronouslyForMigration()
└─ RDEPUBBookPageMap / RDEPUBPaginationState
├─ 当前激活窗口
├─ 待接管完整 page map
└─ 来源与切换状态
```
### 4.2 Services 层
这轮整改后,跨章节主路径的关键变化是:
#### RDEPUBResourceResolver
- 普通翻页时不再要求 UI 主线程同步等待章节构建
- 章节边界会前移预热相邻章节与 lookahead 章节
- `bookPageMap` 的 partial extension 和 full replacement 统一经过 `RDEPUBPresentationRuntime`
- `RDEPUBReaderRuntime` 不再内联维护整套预热/扩窗实现
统一处理所有资源路径转换,是 WebView 和 Paginator 访问资源的唯一入口:
---
| 方法 | 说明 |
|------|------|
| `fileURL(forHref:)` | href → 本地文件 URL |
| `schemeURL(forHref:)` | href → `ss-reader://` 协议 URL |
| `href(forSpineIndex:)` | spineIndex → href |
| `normalizedHref(_:)` | 相对路径标准化(统一相对 OPF) |
## 6. WebView 渲染架构(Web 路径)
#### RDEPUBPaginator
```
RDEPUBWebView (UIView)
├─ WKWebView (RDEPUBAnnotationWebView)
│ ├─ 自定义选择菜单(拷贝/高亮/批注)
│ └─ 禁用系统手势(滚动手势由原生控制)
├─ RDEPUBResourceURLSchemeHandler
│ ├─ 协议: ss-reader://book/<path>
│ ├─ 映射: URL → 本地 EPUB 解压文件
│ └─ 容错: 缺失资源返回空响应(不 404)
├─ JavaScript Bridge (6 种消息)
│ ├─ ssReaderProgressionChanged // 阅读进度更新
│ ├─ ssReaderSelectionChanged // 文本选区变化
│ ├─ ssReaderInternalLink // EPUB 内部链接
│ ├─ ssReaderExternalLink // 外部链接
│ ├─ ssReaderJSError // JS 运行时错误
│ └─ ssReaderFixedLayoutReady // 固定布局加载完成
└─ 注入脚本
├─ epub-bridge.js (502 行) // 核心桥接,分页/滚动/选区/高亮
├─ cssInjector.js (45 行) // CSS 注入管理
├─ WeReadApi.js (67 行) // 微信读书 API 兼容层
├─ rangy-core.js / rangy-serializer.js // DOM Range 序列化
└─ CSS: default/replace/dark/latin // 4 套样式表
```
使用隐藏的 `WKWebView` 离屏加载每个 spine 资源,通过 JS 注入分页 CSS,回调每个资源的页数:
---
## 7. 翻页容器架构(RDEpubReaderView
```
RDEpubReaderView (UIView)
├─ 三种翻页模式:
│ ├─ .pageCurl → UIPageViewController (翻页动画)
│ ├─ .horizontalScroll → UICollectionView (水平滑动)
│ └─ .verticalScroll → UICollectionView (垂直滚动)
├─ 组合对象:
│ ├─ RDEpubReaderPagingController // 翻页状态管理、请求队列
│ ├─ RDEpubReaderPreloadController // 页面预加载、缓存管理
│ ├─ RDEpubReaderSpreadResolver // 双页展开计算
│ └─ RDEpubReaderTapRegionHandler // 点击区域分类(左/中/右)
├─ 数据源协议:
│ ├─ RDEpubReaderPageProvider (新) // 格式无关,优先级高
│ └─ RDEpubReaderDataSource (旧) // 遗留兼容,通过 Adapter 适配
└─ 手势流:
点击 → TapRegionHandler → 分类(左/中/右)
├─ 左 → goPreviousPage()
├─ 右 → goNextPage()
└─ 中 → tapCenter() (切换工具栏)
```
---
## 8. 配置与设置
### RDEPUBReaderConfiguration
```swift
paginator.calculate(publication: publication, preferences: preferences, viewportSize: size) { pageCounts in
// pageCounts[i] = spine[i]
struct RDEPUBReaderConfiguration {
var fontSize: CGFloat // 16
var fontChoice: FontChoice // //
var lineHeightMultiple: CGFloat // 1.5
var numberOfColumns: Int // 1 2
var columnGap: CGFloat // 20
var theme: ReaderTheme // 6
var displayType: RDEpubReaderView.DisplayType // pageCurl/horizontal/vertical
var onDemandChapterWindowSize: Int // 3 3 15
var metadataParsingConcurrency: Int // CPU
var chapterWindowRadius: Int //
}
```
#### RDEPUBPreferences
### 持久化
聚合 WebView 和 Paginator 共用的展示参数:字号、行距、主题色、边距、布局适配模式、spread 模式。
### 4.3 Navigator 层
#### RDEPUBNavigatorState 状态机
```
initializing → loading → idle ←→ jumping
←→ moving
←→ repaginating
```
| 状态 | 含义 | 允许动作 |
|------|------|----------|
| `initializing` | 刚打开书籍 | 接收初始恢复位置 |
| `loading` | 生成首轮 page model | 缓存 pending navigation |
| `idle` | 显示稳定 | 应用 staged snapshot、处理跳转、保存位置 |
| `jumping` | 目录 / 内部链接跳转中 | 重放 pending location |
| `moving` | 用户翻页中 | 更新当前 pageNum |
| `repaginating` | 重新分页中(字号/横竖屏变化) | 缓存当前位置,等待完成 |
#### RDEPUBReadingSession
会话级协调者,持有:
- `publication`:出版物只读视图
- `activePages / activeChapters`:当前展示的页模型和章节信息
- `stagedPages / stagedChapters`:后台分页完成后暂存,等待 idle 状态时应用
- `pendingNavigationLocation / pendingNavigationPageNum`:等待当前加载完成后再执行的跳转请求
- `currentViewport / currentReadingContext`:当前可见区域的位置信息
关键操作:
```swift
session.stageSnapshot(snapshot, restoreLocation:) //
session.consumeStagedSnapshotIfAllowed() // idle 线
session.transition(to: .idle) //
session.clearPendingNavigation() //
```
### 4.4 Resource View 层
#### RDEPUBWebView
承载单个 spine 资源的 WKWebView,按职责拆分为 4 个扩展文件:
| 扩展 | 职责 |
|------|------|
| `+Configuration` | WKWebViewConfiguration、schemeHandler 注册、user scripts |
| `+Reflowable` | 注入分页 CSS、滚动到指定 progression、接收 JS 事件 |
| `+FixedLayout` | fixed-layout HTML wrapper 生成和加载 |
| `+JavaScriptBridge` | JS ↔ Swift 消息路由、选区、高亮、进度上报 |
#### ss-reader:// 协议
所有 spine 资源(XHTML、CSS、图片、字体)统一通过 `ss-reader://book/<relative-path>` 访问,由 `RDEPUBResourceURLSchemeHandler` 从本地解压目录读取并返回。
优点:
- 相对资源路径在 WebView 中自然解析
- fixed-layout iframe 不再白屏
- 无需 `allowingReadAccessTo` 路径权限
| 数据 | 存储方式 | Key 前缀 |
|------|----------|----------|
| 阅读位置 | UserDefaults | `ssreader.epub.location.{bookID}` |
| 书签 | UserDefaults | `ssreader.epub.bookmarks.{bookID}` |
| 高亮标注 | UserDefaults | `ssreader.epub.highlights.{bookID}` |
| 全局设置 | UserDefaults | `ssreader.epub.settings` |
| 章节摘要 | 磁盘文件 | `~/Caches/RDEPUBChapterSummaryCache/` |
| 全书分页 | 磁盘文件 | `~/Caches/RDEPUBTextBookCache/` |
---
## 5. 文本 EPUB 渲染层(EPUBTextRendering
适用于 `readingProfile == .textReflowable` 的书籍(纯文本小说类 EPUB2)。
### 5.1 渲染链路
## 9. 目录结构
```
章节 HTML 文件
→ RDEPUBTextRendererSupport.injectFragmentMarkers(into:) # 注入 fragment 标记
→ RDEPUBDTCoreTextRenderer.renderChapter(html:baseURL:style:)
→ DTHTMLAttributedStringBuilder # HTML → NSAttributedString
→ extractFragmentOffsets # fragment → 字符偏移量映射
→ normalizeReadingAttributes # 统一字体/行距
→ RDEPUBRenderedChapterContent
.attributedString # 渲染后的富文本
.fragmentOffsets # fragment id → 字符偏移
Sources/RDEpubReaderView/
├── EPUBCore/ # EPUB 解析与 WebView 渲染
│ ├── Models/ # 数据模型
│ │ ├── RDEPUBAnnotationModels.swift
│ │ ├── RDEPUBPaginationModels.swift
│ │ └── RDEPUBReadingLocationModels.swift
│ ├── Resources/ # JS/CSS/HTML 静态资源
│ │ ├── epub-bridge.js
│ │ ├── cssInjector.js
│ │ ├── WeReadApi.js
│ │ ├── rangy-core.js / rangy-serializer.js
│ │ ├── wxread-default.css / wxread-dark.css / wxread-replace-latin.css
│ │ └── epub-fixed-layout.html
│ ├── RDEPUBParser.swift # 核心解析器
│ ├── RDEPUBParser+Archive.swift # ZIP 解压
│ ├── RDEPUBParser+Package.swift # OPF 解析
│ ├── RDEPUBParser+TOC.swift # 目录解析
│ ├── RDEPUBParser+Resources.swift # 资源访问
│ ├── RDEPUBPublication.swift # 出版物门面
│ ├── RDEPUBResourceResolver.swift # URL 解析
│ ├── RDEPUBReadingSession.swift # 阅读会话状态机
│ ├── RDEPUBWebView.swift # WebView 主类
│ ├── RDEPUBWebView+*.swift # WebView 扩展(配置/重排/固定/搜索/JS桥接)
│ ├── RDEPUBPaginator.swift # 离屏分页计算器
│ ├── RDEPUBSearchEngine.swift # 全文搜索引擎
│ └── ...
├── EPUBTextRendering/ # 文本渲染引擎
│ ├── Typesetter/ # 排版管线
│ │ ├── RDEPUBTypesettingPipeline.swift
│ │ ├── RDEPUBHTMLNormalizer.swift
│ │ ├── RDEPUBSemanticMarkerInjector.swift
│ │ ├── RDEPUBFragmentMarkerInjector.swift
│ │ ├── RDEPUBFontNormalizer.swift
│ │ ├── RDEPUBAttachmentNormalizer.swift
│ │ ├── RDEPUBStyleSheetComposer.swift
│ │ ├── RDEPUBRenderDiagnosticsCollector.swift
│ │ └── RDEPUBTextRendererSupport.swift
│ ├── Pagination/ # 分页引擎
│ │ ├── RDEPUBChapterPageCounter.swift
│ │ ├── RDEPUBCoreTextPageFrameFactory.swift
│ │ ├── RDEPUBPageBreakPolicy.swift
│ │ ├── RDEPUBTextLayoutFrame.swift
│ │ └── ...
│ ├── BuildPipeline/ # 全书构建
│ │ ├── RDEPUBTextBookBuilder.swift
│ │ ├── RDEPUBTextBookModels.swift
│ │ ├── RDEPUBTextBookCache.swift
│ │ ├── RDEPUBChapterTailNormalizer.swift
│ │ └── ...
│ ├── RDEPUBTextRenderer.swift # 渲染器协议与类型定义
│ ├── RDEPUBDTCoreTextRenderer.swift # DTCoreText 渲染器实现
│ ├── RDEPUBChapterData.swift # 章节查询门面
│ ├── RDEPUBTextIndexTable.swift # 全书索引表
│ └── RDEpubPlainTextBookBuilder.swift # 纯文本 (.txt) 构建器
├── ReaderView/ # 通用翻页容器
│ ├── Paging/ # 翻页子系统
│ │ ├── RDEpubReaderPagingController.swift
│ │ ├── RDEpubReaderPreloadController.swift
│ │ ├── RDEpubReaderSpreadResolver.swift
│ │ └── RDEpubReaderTapRegionHandler.swift
│ ├── RDEpubReaderView.swift # 主容器视图
│ ├── RDEpubReaderView+*.swift # 扩展(CollectionView/PageCurl/ToolView/ContentAccess
│ ├── RDEpubReaderFlowLayout.swift # 自定义 CollectionView 布局
│ ├── RDEpubReaderContentCell.swift # 滚动模式 Cell
│ └── RDEpubReaderPageChildViewController.swift // 翻页模式子 VC
└── EPUBUI/ # 阅读器 UI
├── ReaderController/ # 控制器与协调器
│ ├── ChapterRuntime/ # 章节运行时
│ │ ├── RDEPUBChapterLoader.swift
│ │ ├── RDEPUBChapterRuntimeStore.swift
│ │ ├── RDEPUBChapterSummaryDiskCache.swift
│ │ ├── RDEPUBBookPageMap.swift
│ │ ├── RDEPUBPageResolver.swift
│ │ └── ...
│ ├── RDEPUBReaderContext.swift // 共享状态中心
│ ├── RDEPUBReaderRuntime.swift // 运行时协调器(Facade
│ ├── RDEPUBReaderDependencies.swift // 依赖注入
│ ├── RDEPUBReaderLoadCoordinator.swift // 加载协调器
│ ├── RDEPUBReaderPaginationCoordinator.swift // 分页协调器
│ ├── RDEPUBReaderLocationCoordinator.swift // 位置协调器
│ ├── RDEPUBReaderSearchCoordinator.swift // 搜索协调器
│ ├── RDEPUBReaderChromeCoordinator.swift // 工具栏协调器
│ ├── RDEPUBReaderAnnotationCoordinator.swift // 标注协调器
│ ├── RDEPUBReaderAssemblyCoordinator.swift // 组装协调器
│ ├── RDEPUBReaderViewportMonitor.swift // 视口监控
│ └── ...
├── Settings/ # 设置面板
│ ├── RDEPUBReaderConfiguration.swift # 配置模型
│ ├── RDEPUBReaderSettings.swift # 持久化设置
│ ├── RDEPUBReaderSettingsViewController.swift # 设置面板 VC
│ ├── RDEPUBReaderTheme.swift # 主题定义
│ └── ...
├── TextPage/ # 文本页面渲染
│ ├── RDEPUBTextContentView.swift # 文本内容视图(CoreText
│ ├── RDEPUBTextSelectionController.swift # 文本选择控制器
│ ├── RDEPUBPageInteractionController.swift # 点击交互控制器
│ ├── RDEPUBSelectionOverlayView.swift # 选区覆盖层
│ ├── RDEPUBTextAnnotationOverlay.swift # 标注覆盖层
│ ├── RDEPUBTextPageRenderView.swift # CoreText 绘制视图
│ └── ...
├── RDEPUBReaderController.swift # 主控制器(+ContentDelegates/DataSource/PublicAPI 等扩展)
├── RDEPUBReaderDelegate.swift # 公开委托协议
├── RDEPUBReaderPersistence.swift # 持久化协议
└── ...
```
### 5.2 分页
```
RDEPUBTextBookBuilder.buildBook(publication:style:pageSize:renderer:)
→ 逐章节调用 renderer.renderChapter(...)
→ RDEPUBTextPaginationSupport.paginate(attributedString:pageSize:)
→ RDEPUBTextBook(章节 + 页模型 + fragment 索引)
```
### 5.3 内容显示
`RDEPUBTextContentView` 基于 `NSAttributedString` + `UITextView`(或 DTCoreText 自定义绘制),实现:
- 每页显示对应字符范围的内容
- 高亮重叠渲染
- 支持按 fragment 偏移量跳转
---
## 6. EPUBUI 层(开箱即用读者 UI)
## 10. 关键设计模式
`RDEPUBReaderController` 是 library 层提供的完整读者入口,调用方只需传入 EPUB 文件 URL:
```swift
let controller = RDEPUBReaderController(
epubURL: url,
configuration: .default,
persistence: RDEPUBReaderPersistence(storageKey: "my-book")
)
controller.delegate = self
present(controller, animated: true)
```
### 6.1 内置能力
| 能力 | 对应文件 |
| 模式 | 应用场景 |
|------|----------|
| 顶部工具栏(书名、返回、目录、高亮入口) | RDEPUBReaderTopToolView |
| 底部工具栏(进度条、页码) | RDEPUBReaderBottomToolView |
| 目录面板 | RDEPUBReaderChapterListController |
| 高亮管理 | RDEPUBReaderHighlightsViewController |
| 设置面板(字号、行距、主题、翻页模式) | RDEPUBReaderSettingsViewController |
| 阅读位置持久化 | RDEPUBReaderPersistence |
### 6.2 配置项(RDEPUBReaderConfiguration
| 属性 | 默认值 | 说明 |
|------|--------|------|
| `fontSize` | 15 | 字号(pt |
| `lineHeightMultiple` | 1.6 | 行距倍数 |
| `displayType` | `.pageCurl` | 翻页模式 |
| `landscapeDualPageEnabled` | `true` | 横屏双页 |
| `showsTableOfContents` | `true` | 是否显示目录入口 |
| `allowsHighlights` | `true` | 是否显示高亮入口 |
| `showsSettingsPanel` | `true` | 是否显示设置入口 |
| `theme` | `.light` | 主题 |
| `fixedLayoutSpreadMode` | `.automatic` | fixed-layout spread 模式 |
| `textRenderingEngine` | `.dtCoreText` | 文本 EPUB 渲染引擎 |
---
## 7. 关键数据流
### 7.1 打开 EPUB 书籍
```
RDEPUBReaderController.init(epubURL:)
→ viewDidLoad
→ RDEPUBParser.parse(epubURL:)
→ RDEPUBPublication(parser:)
→ RDEPUBReadingSession(publication:)
→ readingProfile 判断渲染路径
textReflowable:
RDEPUBTextBookBuilder.buildBook(...) # 后台分页
→ session.stageSnapshot(...)
→ session.consumeStagedSnapshotIfAllowed() # idle 时应用
→ readerView.reloadData()
webInteractive / webFixedLayout:
RDEPUBPaginator.calculate(...) # 离屏 WebView 分页
→ session.stageSnapshot(...)
→ readerView.reloadData()
→ persistence.restoreLocation() # 恢复上次阅读位置
```
### 7.2 翻页
```
用户手势(tap / swipe
→ RDReaderView 检测手势分区 / 翻页方向
→ delegate.pageNum(readerView:pageNum:)
→ RDEPUBReaderController 根据 pageNum 找 EPUBPage
→ RDEPUBReadingSession.transition(to: .moving)
→ readerView.pageContentView(pageNum:) 回调
→ 创建 RDEPUBWebContentView 或 RDEPUBTextContentView
→ loadPage(spineIndex: pageIndexInChapter: preferences:)
→ JS bridge 上报 progression → session.currentViewport 更新
```
### 7.3 定位模型(RDEPUBLocation
EPUB 是可重排内容,字号 / 横竖屏变化会使页号失效。定位模型使用 `href + progression`
```swift
struct RDEPUBLocation: Codable, Equatable {
var bookIdentifier: String? //
var href: String // OPF spine
var progression: Double // [0, 1]
var lastProgression: Double? // [0, 1]
var fragment: String? //
}
```
**恢复流程:**
1. 标准化 `href`(相对 OPF
2. 找到对应 spineIndex
3.`navigationProgression` 估算扁平页号
4. 若有 `fragment`,加载后在 WebView 中锚点滚动
---
## 8. 已知限制与后续待办
| 问题 | 说明 |
|------|------|
| Demo 层 RDReaderController 过大 | 约 1900+ 行,仍混有 EPUB 加载、UI 管理、数据源逻辑,待进一步拆分 |
| RDEPUBParser.swift 仍偏大 | 约 900 行,虽已拆出多个扩展文件,OPF metadata 细节仍集中 |
| 横竖屏切换已接入一阶段支持 | `RDEPUBReaderController.viewWillTransition()` 统一接管正文重分页,`RDReaderView` 保留容器级双页布局刷新;仍需补齐固定回归矩阵 |
| 固定手势分区比例 | 三等分固定写死,不支持自定义 |
| 自动化测试为首批接入状态 | 已有 `ReadViewSDKDemoTests` / `ReadViewSDKDemoUITests` 与 parser / resolver / persistence / smoke 用例,分页、恢复链路和更多 UI 闭环仍需继续补齐 |
| 基线验证待跑 | 四本样书(凡人修仙传、爱忘事熊爷爷、宝山辽墓、张学良传)的分页耗时、目录命中率、末页事件等数据尚未收集 |
---
## 9. 构建与依赖
### 9.1 安装
```bash
cd RDReaderDemo
pod install
# 打开 RDReaderDemo.xcworkspace,选 RDReaderDemo scheme,构建
```
### 9.2 主要依赖
| 依赖 | 用途 | 使用层 |
|------|------|--------|
| ZIPFoundation | EPUB ZIP 解压 | RDEPUBParser+Archive |
| DTCoreText | HTML → NSAttributedString | RDEPUBDTCoreTextRenderer |
| SnapKit | Demo 布局 | Demo 层 |
| SSAlertSwift | Demo 弹窗 | Demo 层 |
### 9.3 podspec 关键配置
- `s.source_files = 'Sources/RDReaderView/**/*.{swift}'`(递归包含所有子目录)
- `s.resource_bundles`:包含 JS、CSS 等资源文件
- Library 本身无需 demo 层依赖
| **Coordinator** | 分页/标注/搜索/导航各一个协调器,解耦 Controller |
| **Facade** | `RDEPUBPublication` 封装 `RDEPUBParser``RDEPUBChapterData` 封装章节查询 |
| **Builder** | `RDEPUBBookPageMap.Builder` 增量构建页码映射 |
| **Strategy** | `RDEPUBTextRenderer` 协议,可替换渲染器实现 |
| **Pipeline** | `RDEPUBTextTypesetterPipeline` 排版管线(8 个逻辑阶段,封装为 5-6 个顶层调用) |
| **Adapter** | `RDEpubReaderLegacyDataSourceAdapter` 适配旧数据源协议 |
| **三级缓存** | 内存 → 磁盘摘要 → 全书分页,逐级降级 |
| **Token 取消** | `paginationToken` 确保过期异步任务不干扰新任务 |
| **Frozen Parameters** | 后台任务冻结 `renderSignature`,避免运行中参数漂移 |
+411
View File
@@ -0,0 +1,411 @@
# ReadViewSDK 代码审查报告
> 审查范围:`Sources/RDEpubReaderView/` 全部 184 个 Swift 文件(约 32,300 行)
> 审查维度:并发安全、内存管理、API 正确性、Swift/iOS 平台特定、架构与依赖
> 审查日期:2026-06-26
---
## 严重程度定义
| 标记 | 含义 |
|------|------|
| 🔴 高 | 有明确代码路径可导致崩溃、数据丢失或严重功能缺陷 |
| 🟡 中 | 方向正确但风险程度取决于运行时条件;或当前安全但架构上脆弱 |
| 🟢 低 | 代码异味、可维护性问题或未来风险 |
---
## 🔴 高优先级问题
### H-01 · 内存警告未连接章节缓存清理
`RDEPUBChapterRuntimeStore.handleMemoryWarning()` 已实现(清空除当前章节外的缓存 + 清除图片缓存),但从未被调用。
- `RDEPUBReaderRuntime.handleMemoryWarning()` 只清理了 `backgroundCoverageStore`,未调用 `chapterRuntimeStore.handleMemoryWarning()`
- `RDEPUBReaderController` 未重写 `didReceiveMemoryWarning`
**影响:** 系统内存压力下,`RDEPUBChapterDataCache`(无上限字典)和 `imageCache`(仅 countLimit=50,无 totalCostLimit)不会主动释放内存。
**位置:**
- `RDEPUBChapterRuntimeStore.swift:103``handleMemoryWarning()` 方法存在
- `RDEPUBReaderRuntime.swift:606-620` — 只清理 backgroundCoverageStore
---
### H-02 · 主线程信号量等待
`RDEPUBChapterLoader.loadChapterSynchronouslyForMigration()` 使用 `DispatchSemaphore.wait()` 阻塞调用线程。当从 `RDEPUBPresentationRuntime.rebindVisibleLocation()``allowSynchronousLoad: true` 调用时,会在主线程等待。
**影响:** 如果章节数据未缓存,主线程被阻塞直到章节构建完成,可能导致 ANR 或死锁(如果构建过程需要主线程资源)。
**位置:**
- `RDEPUBChapterLoader.swift:206-268``semaphore.wait()`
- `RDEPUBPresentationRuntime.swift:282-286``allowSynchronousLoad: true`
---
### H-03 · asyncAfter 闭包中的强制解包
`RDEPUBReaderController+DataSource.swift:425-436` 在 0.3 秒延迟闭包中强制解包 `lastTextPaginationPageSize!`。方法入口的 `guard let lastTextPaginationPageSize` 不保护延迟闭包——0.3 秒后该值可能已变为 `nil`
**位置:**
```swift
// line 432-433
let stillChanged = abs(currentSize.width - self.lastTextPaginationPageSize!.width) > 0.5
|| abs(currentSize.height - self.lastTextPaginationPageSize!.height) > 0.5
```
---
### H-04 · `unowned context` 在后台操作中的崩溃路径
以下两个类在后台操作中访问 `unowned let context: RDEPUBReaderContext`,若 controller 在操作期间释放则会崩溃:
**RDEPUBMetadataParseWorker**
- Line 117: `let context = self.context``[weak self]` guard 之前将 unowned 引用捕获为局部变量
- Lines 197-324: `BlockOperation` 闭包直接使用该局部变量(unowned),无 `[weak self]` 保护
- Lines 296-301, 372-376: `DispatchQueue.main.async` 闭包捕获 unowned 局部变量
**RDEPUBReaderLoadCoordinator**
- Line 28: 闭包只捕获 `[weak controller]`,未捕获 `[weak self]`
- Line 30: `self.context.makeParser()` 在后台队列访问 unowned 引用
- Lines 41-42, 53-54: `DispatchQueue.main.async` 闭包直接访问 `self.context`
**影响:**`RDEPUBReaderController` 在后台操作期间释放(如用户快速退出阅读器),`unowned` 引用悬挂导致 EXC_BAD_ACCESS。
**位置:**
- `RDEPUBMetadataParseWorker.swift:117, 197-324, 296-301, 372-376`
- `RDEPUBReaderLoadCoordinator.swift:28-57`
---
### H-05 · 章节数据缓存无容量限制
`RDEPUBChapterDataCache` 是无上限的 `[Int: RDEPUBRuntimeChapter]` 字典,仅通过显式 `remove(spineIndex:)``removeAll()` 逐出。每章持有完整的 `typesetAttributedString`50-200KB+),50 章可达 5-10MB+。
**位置:** `RDEPUBChapterDataCache.swift:5``private var storage: [Int: RDEPUBRuntimeChapter] = [:]`
---
### H-06 · 暗色图片静态 NSCache 无 totalCostLimit
`RDEPUBDarkImageAdjuster.imageCache` 是 static `NSCache<NSString, UIImage>()`,无 `totalCostLimit`,跨越整个 App 生命周期累积调整后的图片。
**位置:** `RDEPUBDarkImageAdjuster.swift:9`
---
### H-07 · 持久化操作静默丢弃错误
`RDEPUBReaderPersistence` 中所有编解码操作均使用 `try?`,失败时:
- **保存**:静默返回,数据丢失无任何反馈(lines 84, 98, 112, 131
- **加载**:返回 `nil` 或空数组,损坏/版本不匹配的数据无法恢复(lines 80, 94, 108, 127
**影响:** 用户书签和阅读位置可能在无声中丢失。
**位置:** `RDEPUBReaderPersistence.swift:76-131`
---
### H-08 · WKURLSchemeHandler 线程违规与取消回调缺失
两个确定 bug
**(a) 不同队列回调:** `webView(_:start:)` 在某个串行队列(由 WebKit 调度)上被调用,但 `respondWithInMemoryData``respondWithStreaming``didReceive`/`didFinish`/`didFailWithError` 显式切到了 `ioQueue`line 121, 151)。这至少违反了"回调应与 `start` 使用同一串行队列"的要求。
**(b) 取消时不调用完成回调:** 任务被 `stop` 后,`isTaskActive` 返回 false 时:
- streaming 路径(line 178):`guard self.isTaskActive(taskID) else { return }` — 仅 return,不调用 `didFinish``didFailWithError`
- in-memory 路径(line 128, 141):同理
这违反了"每个 started task 必须收到完成回调"的协议要求。
**位置:** `RDEPUBResourceURLSchemeHandler.swift:121-187`
---
### H-09 · 搜索在后台线程同步加载所有章节
`RDEPUBReaderSearchCoordinator``searchQueue` 上遍历所有 buildable spine 索引,逐个同步加载章节。每章持有完整 `typesetAttributedString`,大型书籍搜索时内存峰值显著。
**位置:** `RDEPUBReaderSearchCoordinator.swift:47, 173-236`
---
### H-10 · 依赖版本未锁定
`RDEpubReaderView.podspec` 中 DTCoreText、SnapKit、SSAlertSwift 无版本约束:
```ruby
s.dependency 'ZIPFoundation', '~> 0.9' # ✅ 已锁定
s.dependency 'DTCoreText' # ❌ 无约束
s.dependency 'SnapKit' # ❌ 无约束
s.dependency 'SSAlertSwift' # ❌ 无约束
```
**位置:** `RDEpubReaderView.podspec:17-20`
---
### H-11 · SnapKit 和 SSAlertSwift 疑似死依赖
全仓库搜索未找到任何 `snp.``SSAlert` 的使用点。这两个依赖可能增加了不必要的二进制体积和潜在冲突。
---
## 🟡 中优先级问题
### M-01 · `RDEPUBReaderState` 无同步保护(隐性风险)
所有属性(`parser`, `publication`, `activeBookmarks`, `pendingPageMapUpdates` 等)均为 `var`,无锁、无 `@MainActor`、无 `dispatchPrecondition`。当前所有访问确实在主线程(经追踪 16 个 `pendingPageMapUpdates` 访问点确认),但缺少形式化保障——未来开发者可能无意从后台线程访问而不自知。
**位置:** `RDEPUBReaderState.swift:1-71`
---
### M-02 · `currentSpineIndex` / `windowSpineIndices` 无锁但当前安全
这两个字段无锁保护,但追踪全部读写路径后确认当前均在主线程。风险是隐性的:store 对象被传入 `chapterLoadQueue` 上下文,未来开发者可能在该队列中访问这些属性而不自知。同文件的其他字段(`navigationLock`, `prefetchLock`, `buildingLock`, `cfiMapLock`)有锁保护,形成不一致的模式。
**位置:** `RDEPUBChapterRuntimeStore.swift:15-17`
---
### M-03 · `warmAnchors` 跨线程访问无同步
`RDEPUBBackgroundPriorityManager.warmAnchors` 在主线程写入(`addWarmAnchor`),在 `MetadataParseWorker` 的后台线程读取(`makeMetadataPriorityOrder`),无锁保护。
**位置:** `RDEPUBBackgroundPriorityPolicy.swift:65`
---
### M-04 · `assert(Thread.isMainThread)` 在 Release 中被移除
`RDEPUBReaderContext``makeLayoutSnapshot()``currentTextPageSize()` 等方法使用 `assert(Thread.isMainThread)` 检查。`assert``-O` 构建中被移除,生产环境中后台线程调用将静默访问 UIKit 属性,造成数据竞争且无诊断。
**应使用:** `dispatchPrecondition(condition: .onQueue(.main))``@MainActor` 标注。
**位置:** `RDEPUBReaderContext.swift:172, 188, 210, 215`
---
### M-05 · 磁盘缓存无自动淘汰策略
`RDEPUBChapterSummaryDiskCache``RDEPUBTextBookCache` 只有全量清除操作(`removeAll()``invalidateAll()`),无 LRU/大小/年龄驱逐策略。每次设置变更(字体、行距、主题)产生新缓存键,旧文件无限累积。写操作使用原子替换(tmp + replace),交错不会损坏数据,但可能读到旧值或看到不一致的目录状态。
**位置:**
- `RDEPUBChapterSummaryDiskCache.swift:89``removeAll()` 全清
- `RDEPUBTextBookCache.swift:191``invalidateAll()` 全清
---
### M-06 · CFI 范围分隔符可能与内容冲突
`RDEPUBCFICompatibility``".."``"-"` 分割 CFI 范围字符串,但这些字符可能出现在 CFI 文本断言内部(如 `[foo..bar]`)。代码先匹配 `".."` 再匹配 `"-"`,无歧义消解逻辑。
**位置:** `RDEPUBCFICompatibility.swift:18-21`
---
### M-07 · CFI 步索引对奇数输入容错不严谨
`RDEPUBCFIResolver``steps[1].index / 2 - 1` 对奇数索引做整除截断,可能映射到错误章节。`max(..., 0)` 仅防负索引,不防错误映射。属于 EPUB CFI 规范的输入验证问题。
**位置:** `RDEPUBCFIResolver.swift:38`
---
### M-08 · WKWebView 保留环需主动管理
`RDEPUBWebView``webView``configuration.userContentController``RDEPUBWebView`(作为 `WKScriptMessageHandler`)形成保留环。
主要解环路径是 `reset()``configureWebViewIfNeeded` 中主动调用 `teardownWebView()`。**注意:** `deinit` 虽也调用 `teardownWebView()`,但不能作为环会被打破的保证——保留环的存在意味着 `deinit` 可能永远不会触发。风险在于:若某条代码路径跳过了 `teardownWebView()`,环将持续且对象永远不会释放。
**位置:**
- `RDEPUBWebView.swift:129``deinit` 调用 `teardownWebView()`(不可靠兜底)
- `RDEPUBWebView.swift:138-160``reset()` 调用 `teardownWebView()`(主要解环路径)
---
### M-09 · `configuration` didSet 在 `isViewLoaded` 之前持久化
`RDEPUBReaderController.configuration``didSet``guard isViewLoaded` 之前执行了 `readerContext.configuration = configuration``applyWebViewDebugPolicy()``persistReaderSettingsIfNeeded()`。若在 view 加载前设置 configuration,这些副作用会在 UI 未就绪时触发。
**位置:** `RDEPUBReaderController.swift:8-20`
---
### M-10 · empty catch 吞掉章节加载错误
`RDEPUBReaderPaginationCoordinator``loadInitialInteractiveRuntimeChapters``catch {}` 为空,章节加载失败被静默忽略,无日志、无恢复。
**位置:** `RDEPUBReaderPaginationCoordinator.swift:283`
---
### M-11 · `RDEPUBReaderFontChoice.displayName` 始终返回中文
枚举的 `displayName` 计算属性硬编码中文字符串("系统"、"宋体"、"圆体"、"等宽"),无本地化基础设施。
**位置:** `RDEPUBReaderConfiguration.swift:18-28`
---
### M-12 · 大量硬编码中文字符串,本地化覆盖不完整
覆盖文件:`RDEPUBReaderSettingsViewController``RDEPUBReaderSearchBarView``RDEPUBReaderHighlightsViewController``RDEPUBReaderBookmarksViewController``RDEPUBReaderTopToolView``RDEPUBReaderBottomToolView``RDEPUBTextContentView``RDEPUBWebView+Configuration``RDEPUBChapterLoader`(错误信息)等。
项目中存在约 88 个硬编码中文字符串(如"拷贝"、"高亮"、"批注"、"目录"、"搜索"等),但同时也有使用 `NSLocalizedString` 的地方(如 `RDEPUBImageViewController.swift:126`)。本地化基础设施存在但覆盖不完整——新添加的 UI 文本大多直接硬编码中文而未走本地化流程。
---
### M-13 · DTCoreText 类型泄漏到 EPUBUI 层
5 个 EPUBUI 文件直接使用 `DTCoreTextLayoutFrame``DTCoreTextLayoutLine``DTCoreTextGlyphRun` 等 DTCoreText 类型(虽有 `#if canImport(DTCoreText)` 保护),意味着替换渲染引擎需修改 UI 层文件。
**位置:**
- `RDEPUBPageInteractionController.swift`
- `RDEPUBPageLayoutSnapshot.swift`
- `RDEPUBTextPageRenderView.swift`
- `RDEPUBTextContentView.swift`
- `RDEPUBDarkImageAdjuster.swift`
---
### M-14 · `imageCache` 有 countLimit 但无 totalCostLimit
`RDEPUBChapterRuntimeStore.imageCache` 设有 `countLimit = 50`,但无 `totalCostLimit`。50 张大尺寸书籍插图可达 150-250MB。
**位置:** `RDEPUBChapterRuntimeStore.swift:41`
---
### M-15 · `UIMenuController` 已废弃
`RDEPUBWebView+Configuration.swift:39``RDEPUBTextContentView.swift:747-758` 使用 `UIMenuController`,该 API 从 iOS 16 起废弃。应迁移至 `UIEditMenuInteraction`iOS 16+),并为 iOS 15 保留 fallback。当前代码在 iOS 17+ 上仍可工作但随时可能失效。
---
### M-16 · Dynamic Type 未支持
UI 中所有字体使用硬编码大小(`UIFont.systemFont(ofSize: 13/14/16/18/19)`),未使用 `UIFontMetrics``preferredFont(forTextStyle:)`。启用 Dynamic Type 的用户看不到文字大小变化。
---
### M-17 · URL Scheme Handler 取消时 `clearTask` 可能被跳过
`respondWithInMemoryData` line 128 的 `guard self.isTaskActive(taskID) else { return }` 在取消时直接返回,跳过了 line 146 的 `self.clearTask(taskID)`。当前因 `stop` 已调用 `clearTask` 而无害,但结构上脆弱。
**位置:** `RDEPUBResourceURLSchemeHandler.swift:128`
---
### M-18 · 硬编码颜色值未集成到主题系统
`RDEPUBReaderSearchBarView` 含 18 个硬编码颜色字面量,默认高亮颜色 `"#F8E16C"` 硬编码在 `RDEPUBReaderController+PublicAPI.swift:65,75`。这些应属于 `RDEPUBReaderTheme`
---
### M-19 · 硬编码时间常量
多个 `DispatchQueue.main.asyncAfter(deadline:)``Thread.sleep` 使用硬编码延迟值(0.08s、0.1s、0.15s、0.2s、0.25s、0.3s、1.0s、3.5s),无命名常量或可配置选项。
---
## 🟢 低优先级问题
### L-01 · `RDEPUBChapterLoader` 中 `[self]` 强捕获
Line 103 的 `store.chapterLoadQueue.async { [self] in` 强捕获 self,阻止 loader 在队列任务完成前释放。应改为 `[weak self]`
**位置:** `RDEPUBChapterLoader.swift:103`
---
### L-02 · MetadataParseWorker Thread.sleep 忙等待
`waitForReadingInteractionToSettle()` 使用 `Thread.sleep(forTimeInterval: 0.08)` 轮询,无上限等待。应考虑 `DispatchSource` 或条件变量。
**位置:** `RDEPUBMetadataParseWorker.swift:385-390`
---
### L-03 · `RDEPUBTextBookCache.queue.sync` 潜在死锁
`load()``save()` 均使用 `queue.sync`。若从 queue 内部调用则会死锁。当前未发现此调用路径,但模式脆弱。
**位置:** `RDEPUBTextBookCache.swift:136-189`
---
### L-04 · `pendingLoads` 字典无超时清理
`RDEPUBChapterLoader.pendingLoads` 的完成闭包数组在章节加载失败且 `resolvePendingLoad` 未被调用时可能累积。当前所有路径均调用 `resolvePendingLoad`,但无防御性超时。
**位置:** `RDEPUBChapterLoader.swift:7-10`
---
### L-05 · `RDEPUBChapterWindowCoordinator` 是死代码
该类定义了但未被任何代码实例化或引用。其中对 `currentSpineIndex`/`windowSpineIndices` 的7个访问点均为死代码。
---
### L-06 · 平台版本不一致
- `podspec`: `s.platform = :ios, "15.0"`
- `Podfile`(根和 demo: `platform :ios, '15.6'`
---
### L-07 · `RDEPUBMetadataCancellationController` 微小竞争
`attach()` 方法在 `lock.unlock()``queue.cancelAllOperations()` 之间存在窄窗口,新操作可能被添加到队列但不会被取消。实际影响极小。
**位置:** `RDEPUBMetadataParseCancellationController.swift:attach()`
---
### L-08 · 页码标签硬编码偏移
`RDEPUBWebContentView``RDEPUBTextContentView` 的页码标签使用硬编码偏移(24pt, 20pt),在刘海屏设备上可能与安全区域重叠。
---
## 已排除项
以下原报告条目经核实后排除或合并:
| 原编号 | 原描述 | 排除原因 |
|--------|--------|----------|
| #6 | compactMap 结果越界 | 入口有 `childViewControllers.count == expectedCount` 检查,compactMap 后不会少于预期数量 |
| #7 | `result!` 强解包崩溃 | 信号量等待的真实风险是无限等待/卡住,不是 result 为 nilsignal 前必定赋值 |
| #21 | 搜索协调器 fallback 空 store | `?? RDEPUBChapterRuntimeStore()` 前的可选链若为 nil 则整个调用返回 nil,fallback 不会生效 |
| #23 | init 时加载所有 HTML 到内存 | 逐章取 HTML 算哈希后只保存 digest,HTML 字符串不长期保留 |
| #35 | `.nonPersistent()` 阻止 cookie/localStorage | 不持久化是有意设计,不是 bug;原始报告将"不持久化"误述为"不能用" |
| #48 | `didMoveToSuperview` 每次重复 makeUI | 有 `didBuildUI` 防重入保护 |
| 原L-04 | `activeTasks` 字典 `clearTask` 设 nil 不删键 | Swift 中 `dict[key] = nil` 等同于 `removeValue(forKey:)`,不保留空洞 |
| 原M-18 | MetadataParseWorker OperationQueue 未在 deinit 取消 | 外层 `[weak self]` guard 确保 self 存活才执行 queue 操作;`cancellationController` 提供显式取消路径 |
| 原L-08 | `ss_superViewController` 前缀暗示外部来源 | 命名风格猜测,非可验证缺陷 |
---
## 建议优先级排序
| 优先级 | 编号 | 修复复杂度 | 说明 |
|--------|------|-----------|------|
| P0 | H-01 | 低 | 在 `handleMemoryWarning` 中加一行 `chapterRuntimeStore.handleMemoryWarning()`,并在 controller 中转发 `didReceiveMemoryWarning` |
| P0 | H-04 | 中 | MetadataParseWorker 和 LoadCoordinator 中 `unowned context` 改为 `weak`,闭包加 `guard` |
| P0 | H-03 | 低 | `lastTextPaginationPageSize!` 改为 `guard let` 安全解包 |
| P1 | H-08 | 中 | URLSchemeHandler 回调改到与 start 相同队列,取消路径加 `didFailWithError` |
| P1 | H-02 | 中 | 主线程同步加载改为异步回调或确保不从主线程调用 |
| P1 | H-07 | 低 | 关键持久化操作加日志,至少记录失败原因 |
| P1 | H-10 | 低 | 给三个依赖加版本约束;移除 SnapKit 和 SSAlertSwift |
| P2 | H-05 | 中 | 为 ChapterDataCache 加 LRU 逐出策略或最大条目数限制 |
| P2 | H-06 | 低 | 为 DarkImageAdjuster.imageCache 设置 `totalCostLimit` |
| P2 | H-09 | 中 | 搜索改为流式/分批加载,避免一次性持有所有章节 |
| P2 | M-01/02 | 中 | 为 State 和 Store 关键属性加 `@MainActor``dispatchPrecondition` |
| P2 | M-04 | 低 | `assert` 改为 `dispatchPrecondition``precondition` |
| P2 | M-08 | 低 | 保留环加注释说明必须通过 `reset()` 打破 |
| P2 | M-15 | 中 | 迁移至 `UIEditMenuInteraction` |
+620
View File
@@ -0,0 +1,620 @@
# ReadViewSDK 业务逻辑文档
> 最后更新:2026-06-09
---
## 1. EPUB 解析流程
### 1.1 文件解压
**入口:** `RDEPUBParser.parse(epubURL:)`
EPUB 文件本质是 ZIP 压缩包。解压流程:
1. 计算缓存目录:`~/Library/Caches/ssreaderview-epub/{slug}-{fileSize}-{modifiedTimestamp}/`
2. 如果缓存目录已存在且包含 `META-INF/container.xml`,跳过解压
3. 否则使用 `ZIPFoundation` 解压到缓存目录
4. 解压是幂等的,重复调用不会重复解压
### 1.2 OPF 解析
OPFOpen Packaging Format)是 EPUB 的核心描述文件。使用 SAX 解析(`XMLParser` + `XMLParserDelegate`)以降低内存占用。
**解析内容:**
| 区域 | 提取字段 |
|------|----------|
| `<metadata>` | identifier, title, author, language, version, rendition:layout, rendition:spread, readingProgression |
| `<manifest>` | id, href, media-type, properties, fallback, media-overlay |
| `<spine>` | idref, linear, properties, page-spread |
| `<spine toc="...">` | NCX 文件 id |
**Spine 构建:** 将 spine 引用映射到 manifest 条目,规范化 href 相对于 OPF 目录,解析每个条目的布局覆盖(rendition:layout-pre-paginated/reflowable)。
### 1.3 目录解析
TOC(目录)按优先级尝试三种来源:
1. **NCXEPUB 2):** 解析 `<navPoint>` 树形结构,支持嵌套
2. **Navigation DocumentEPUB 3):** 解析 `<nav epub:type="toc">` 中的 `<ol>/<li>/<a>` 结构
3. **回退:** 直接使用 spine 条目的 title 列表
### 1.4 阅读配置文件判断
```swift
enum RDEPUBReadingProfile {
case webInteractive // EPUB WebView
case webFixedLayout // EPUB
case textReflowable // CoreText SDK
}
```
判断逻辑(`RDEPUBParser+ReadingProfile.swift`):
- `rendition:layout == pre-paginated``.webFixedLayout`
- 纯 HTML/XHTML 内容且无复杂交互 → `.textReflowable`
- 其他 → `.webInteractive`
---
## 2. 文本渲染管线
### 2.1 HTML 预处理(Typesetter Pipeline
`RDEPUBTextTypesetterPipeline.makeRequest(from:)` 按顺序执行 8 个阶段:
**阶段 1HTML 规范化** (`RDEPUBHTMLNormalizer`)
- 删除 `<hr lang="zh-CN">分页符</hr>` 分页标记
- CR → LF,合并连续空行
- 规范化附件 HTML 标记:
- `div.qrbodyPic / div.bodyPic` → 合并样式到 `<img>`
- `img.qqreader-footnote` → 行内 1em×1em
- `h1.frontCover > img` → 封面图片 100% 宽度
**阶段 2:语义标记注入** (`RDEPUBSemanticMarkerInjector`)
- 遍历所有 HTML 标签,维护开标签栈
- 为有分页语义的标签注入标记:
- `${rd-sem-start:id=X;block=...;hints=...;placement=...}`
- `${rd-sem-end:id=X}`
- 空标签(img, br, hr)同时注入开始和结束标记
**阶段 3:样式表内联** (`RDEPUBRenderDiagnosticsCollector`)
- 查找 `<link rel=stylesheet href=...>`
- 读取 CSS 文件内容
- 重写 CSS 中的相对 `url()` 引用
- 内联到 HTML 的 `<head>`
**阶段 4CSS 层合成** (`RDEPUBStyleSheetComposer`)
- 构建 5 层 CSS,按优先级从低到高:
1. **default** - 基础阅读器样式(隐藏 head/title/style,默认字体大小)
2. **replace** - 微信读书风格格式化(代码块、标题、引用)
3. **dark** - 暗色模式覆盖(仅暗色主题时注入)
4. **epub** - EPUB 自带样式
5. **user** - 用户设置(字号、行距、颜色,!important)
- 语言检测:检查 lang 属性和文本采样,拉丁语言使用专用 CSS
**阶段 5:字体注册** (`RDEPUBFontNormalizer`)
- 解析 `@font-face { url(...) }`
- 通过 `CTFontManagerRegisterFontsForURL(.process)` 注册嵌入字体
- 已注册字体路径缓存在 `registeredFontPaths` 集合中,避免重复注册
**阶段 6Base URL 注入** (`RDEPUBHTMLNormalizer`)
- 注入 `<base href="...">` 用于相对路径解析
**阶段 7Fragment 标记注入** (`RDEPUBFragmentMarkerInjector`)
- 查找 `<tag id="xxx">` 标签
- 在其前面注入 `${id=xxx}` 标记
**阶段 8:图片诊断收集** (`RDEPUBRenderDiagnosticsCollector`)
- 扫描 `<img src="...">` 标签
- 解析引用路径,检查文件是否存在
- 收集诊断信息用于调试
### 2.2 HTML→NSAttributedString
`RDEPUBDTCoreTextRenderer.renderChapter(request:)`
1. 将 HTML 编码为 `Data`
2. 使用 `DTHTMLAttributedStringBuilder` 构建 `NSAttributedString`
3.`willFlushCallback` 中对每个 DOM 元素调用 `RDEPUBAttachmentNormalizer.prepareHTMLElementForReaderRendering()`
4. 后处理:
- `applyPaginationSemantics()` - 将 `${rd-sem-start/end}` 标记转为 NSAttributedString 属性
- `extractFragmentOffsets()` - 提取 fragment ID→offset 映射,删除标记文本
- `normalizeReadingAttributes()` - 规范化字体、行距、颜色、附件
### 2.3 分页计算
`RDEPUBChapterPageCounter.layoutFrames()` 使用迭代循环:
```
location = 0
while location < totalLength:
1. 创建 CTFrame(通过 CTFramesetter
2. 获取可见范围 (CTFrameGetVisibleStringRange)
3. 应用 avoidPageBreakInside 规则(最多移除 3 行尾部)
4. 应用 keepWithNext 规则(最多移除 3 行尾部)
5. 应用 widow/orphan 控制
6. 应用 pageBreakPolicy 调整
7. 记录页面范围
8. location = 调整后的范围末尾
```
**分页规则优先级:**
| 规则 | 说明 | 最大调整行数 |
|------|------|-------------|
| avoidPageBreakInside | 块内不分页(标题、图片等) | 3 行 |
| keepWithNext | 标题与正文不分离 | 3 行 |
| widow control | 段落最后一行不留到下一页 | 1 行 |
| orphan control | 段落第一行不单独在上一页 | 1 行 |
| pageRelate | 微信读书跨页关联 | 1 行 |
| attachment boundary | 块级图片前后分页 | 0(精确切分) |
### 2.4 尾页规范化
`RDEPUBChapterTailNormalizer.normalize()` 三遍处理:
1. **删除空白中间帧:** 无可见字符且无附件的帧
2. **删除空白尾部帧:** 从末尾开始删除同类空白帧
3. **合并短尾帧:** 如果最后一帧 ≤2 个可见字符且前一帧 ≥8 倍长,合并
---
## 3. 后台元数据解析(大书优化)
### 3.1 整体流程
对于《凡人修仙传》这样的大书(~1000 章),后台解析是核心性能路径。
```
打开书籍
├─ 尝试磁盘缓存恢复(restoreBookPageMapIfPossible
│ └─ 成功 → 直接显示完整页码
├─ 快速打开(3-5 章窗口)
│ ├─ 同步加载首个可渲染章节
│ ├─ 加载窗口内相邻章节
│ └─ 应用局部 BookPageMap → 用户可立即阅读
└─ 后台解析(RDEPUBMetadataParseWorker.start()
├─ 预计算所有章节 contentHash(串行,~1-3s
├─ 恢复已有磁盘缓存
├─ 等待用户操作冷却 0.8s
├─ 并发渲染未缓存章节(OperationQueueN=CPU核心数)
├─ 每 32 章增量刷新 UI
└─ 最终完整刷新
```
### 3.2 预计算 contentHash 优化
**问题:** 每个章节在渲染后需要构建缓存键,原始实现会重新读取 HTML 并计算 SHA-256。
**优化:** 在后台解析开始时,串行预计算所有章节的 contentHash
```swift
var contentHashBySpineIndex: [Int: String] = [:]
for spineIndex in allBuildableIndices {
let html = parser.htmlString(forRelativePath: href)
contentHashBySpineIndex[spineIndex] = html?.rd_sha256Hex ?? ""
}
```
后续所有 `chapterCacheKey` 调用都使用预计算值,避免重复 I/O 和 SHA-256 计算。
### 3.3 冻结 renderSignature
**问题:** 如果用户在后台解析进行中更改字号/行距,`context.currentRenderSignature()` 会返回新值,导致部分章节用旧签名、部分用新签名写入磁盘缓存,造成缓存不一致。
**解决:**`RDEPUBMetadataParseWorker` 初始化时冻结签名:
```swift
let renderSignature = context.currentRenderSignature()
// chapterCacheKey 使
```
token 机制确保设置变更会触发新的解析任务(新 token),旧任务自动废弃。
### 3.4 锁区瘦身
**原始实现:** `resultLock` 内调用 `buildPageMap()`,该方法遍历全量 catalog 和 summaries。
**优化:** 锁内只做写入和计数,快照数据后锁外构建 pageMap:
```swift
var snapshot: [Int: RDEPUBChapterSummary]?
resultLock.lock()
summariesBySpineIndex[spineIndex] = renderResult
totalResolvedCount += 1
if shouldRefresh {
snapshot = summariesBySpineIndex //
}
resultLock.unlock()
if let snapshot {
let partialMap = buildPageMap(from: catalog, summaries: snapshot) //
}
```
### 3.5 可配置刷新间隔
```swift
static var pageMapRefreshInterval: Int = 32 // 32
```
可实测调优:32 / 48 / 64。值越大,UI 刷新频率越低,后台解析吞吐越高。
---
## 4. 章节按需加载
### 4.1 加载优先级
`RDEPUBChapterLoader.loadChapter(spineIndex:store:)` 按以下优先级:
1. **Tier 1 内存缓存** - `store.chapterData(for: spineIndex)` → 直接返回
2. **Tier 1 页数缓存** - `store.pageCount(for: cacheKey)` → 跳过分页计算
3. **Tier 2 磁盘缓存** - `summaryDiskCache.read(for: cacheKey)` → 恢复页范围
4. **全量构建** - 渲染 + 分页 + 写缓存
### 4.2 窗口驱逐策略
`RDEPUBChapterRuntimeStore.setCurrentChapter(spineIndex:totalSpineCount:windowRadius:)`
- 保留范围:`[spineIndex - windowRadius, spineIndex + windowRadius]`
- 窗口半径由 `configuration.chapterWindowRadius` 控制
- 超出窗口的章节从内存缓存中驱逐
- 内存警告时驱逐除当前章节外的所有缓存
### 4.3 BookPageMap 增量刷新
`RDEPUBBookPageMap` 是轻量页码映射(~100KB/1000 章),不持有 `NSAttributedString`
Builder 模式支持增量构建:
```swift
var builder = RDEPUBBookPageMap.Builder()
for item in catalog {
if let summary = summaries[item.spineIndex] {
builder.add(spineIndex:href:title:pageCount:fragmentOffsets:)
}
}
return builder.build()
```
---
## 5. 翻页容器逻辑
### 5.1 三种翻页模式
| 模式 | 底层实现 | 特点 |
|------|----------|------|
| pageCurl | UIPageViewController | 翻页动画,系统手势 |
| horizontalScroll | UICollectionView + 自定义 Layout | 水平滑动,分页锁定 |
| verticalScroll | UICollectionView + 自定义 Layout | 垂直滚动,连续滚动 |
### 5.2 双页展开(Landscape Dual Page
**触发条件:** `landscapeDualPageEnabled && isLandscape && !verticalScroll`
**页面配对逻辑(RDEpubReaderSpreadResolver):**
- 有封面页:封面独占一屏,后续页面两两配对
- 配对规则:`(coverIndex, nil)`, `(1, 2)`, `(3, 4)`, ...
- 无封面页:标准偶奇配对
- 配对规则:`(0, 1)`, `(2, 3)`, `(4, 5)`, ...
**封面感知布局(RDEpubReaderFlowLayout):**
```
封面页: [====全屏宽度====]
配对页: [==半宽==][==半宽==]
```
### 5.3 页面预加载
`RDEpubReaderPreloadController` 管理两个缓存:
- `preloadedPageViews` - 预渲染的页面视图
- `pageCurlCachedViews` - 当前显示的页面视图
**预加载策略:**
1. 每次页面变化后调用 `prime(around:preferredForward:)`
2. 计算预测目标:前后各 `radius` 个展开页 + 预测方向额外 1 个展开页
3. 将目标页面预渲染到隐藏的 `preloadHostView`
4. 缓存签名(displayType + isLandscape + pagesPerScreen + boundsSize)变化时清除缓存
### 5.4 点击区域处理
屏幕三等分:
```
[ 左 1/3 ][ 中 1/3 ][ 右 1/3 ]
上一页 切换工具栏 下一页
```
RTL 模式下左右互换。工具栏显示时,左右区域变为 `.center`(隐藏工具栏)。
---
## 6. 标注系统
### 6.1 数据模型
```swift
//
struct RDEPUBAnnotation {
let id: String
let bookIdentifier: String?
let kind: RDEPUBAnnotationKind // .bookmark, .highlight, .underline
let location: RDEPUBLocation
let text: String?
let rangeInfo: String? // CoreText
let chapterTitle: String?
let createdAt: Date
// computed: bookmark, highlight
}
//
struct RDEPUBBookmark {
let id: String
let bookIdentifier: String?
let location: RDEPUBLocation
let chapterTitle: String?
let note: String?
let createdAt: Date
}
//
struct RDEPUBHighlight {
let id: String
let bookIdentifier: String?
let location: RDEPUBLocation
let text: String
let style: RDEPUBHighlightStyle // .highlight, .underline
let color: String
let note: String?
let rangeInfo: String?
let createdAt: Date
}
```
### 6.2 选区处理流程
SDK 支持两条选区处理路径,分别对应 WebView 渲染和原生文本渲染:
**路径 AWebView 渲染(webInteractive / webFixedLayout**
```
用户长按 → WKWebView 选区变化
JS Bridge: ssReaderSelectionChanged
RDEPUBReaderAnnotationCoordinator
├─ 解析选区位置 (RDEPUBLocation)
├─ 提取选中文本
├─ 创建 RDEPUBSelection
└─ 显示操作菜单(拷贝/高亮/批注)
```
**路径 B:原生文本渲染(textReflowable**
```
用户长按 → RDEPUBTextContentView.handleLongPress(_:)
├─ layoutIfNeeded() // 确保布局完成
RDEPUBTextSelectionController.handleLongPress(_:)
├─ RDEPUBPageInteractionController.characterIndexForViewPoint()
│ └─ CoreText CTLineGetStringIndexForPosition 字符命中
├─ 计算选区范围 (NSRange)
├─ isSelecting = true
用户拖拽 → handlePan(_:)
├─ 更新选区范围
├─ RDEPUBSelectionOverlayView 绘制选区高亮
└─ .ended/.cancelled/.failed → isSelecting = false(保留选区)
RDEPUBTextContentView delegate → RDEPUBReaderAnnotationCoordinator
├─ 创建 RDEPUBSelection(含 rangeInfo
└─ 显示操作菜单(拷贝/高亮/批注)
```
### 6.3 高亮渲染
文本模式下,高亮通过 NSAttributedString 属性注入:
```swift
//
attributedString.addAttribute(
kRDEPUBHighlightAttributeName, // "com.rdreader.highlight"
value: highlight,
range: nsRange
)
// 线
attributedString.addAttribute(
kRDEPUBUnderlineAttributeName, // "com.rdreader.underline"
value: highlight,
range: nsRange
)
```
CoreText 渲染时识别这些自定义属性并绘制高亮背景/下划线。
---
## 7. 搜索系统
### 7.1 全文搜索
SDK 提供两个搜索引擎,分别服务于不同的渲染路径:
**文本渲染路径:`RDEPUBTextSearchEngine`**
`RDEPUBTextSearchEngine.search(keyword:)`
1. 遍历 `RDEPUBTextBook.chapters` 中的所有章节
2. 对每个章节的 `attributedContent`(已渲染的 NSAttributedString)执行搜索
3. 使用 `NSString.range(of:options:.caseInsensitive)` 进行大小写不敏感搜索
4. 为每个匹配生成:
- `progression`0.0-1.0 的阅读进度
- `previewText`:匹配位置前后各 12 字符
- `rangeAnchor`:精确的文本锚点(RDEPUBTextRangeAnchor
**纯文本文件搜索:`RDEPUBTextSearchEngine.searchWithoutPublication(textBook:keyword:)`**
静态方法,用于 `.txt` 文件(有 TextBook 但无 Publication)的搜索:
1. 遍历 TextBook 章节的 attributedContent
2. 跳过 publication 依赖的 href 规范化
3. 不生成 rangeAnchor(纯文本无 CoreText 锚点)
**WebView 渲染路径:`RDEPUBHTMLSearchEngine`**
1. 遍历所有 linear spine 条目(html/xhtml 类型)
2. 读取 HTML,转为纯文本(正则去除标签)
3. 执行大小写不敏感搜索
4. 通过 JS Bridge 在 WebView DOM 中绘制搜索高亮
### 7.2 搜索结果导航
搜索结果通过 `RDEPUBSearchState` 管理:
```swift
struct RDEPUBSearchState {
let keyword: String
let matches: [RDEPUBSearchMatch]
var currentMatchIndex: Int?
}
```
**搜索结果导航流程:**
`RDEPUBReaderSearchCoordinator` 协调搜索结果的前进/后退导航:
1. `goToNextMatch()` / `goToPreviousMatch()` 更新 `currentMatchIndex`
2. 根据匹配位置计算目标页码,触发页面跳转
3. 通知 `RDEPUBReaderController` 更新搜索计数显示
**搜索结果渲染:**
- **WebView 路径:** 通过 JS Bridge 接收搜索高亮数据,在 DOM 中绘制高亮矩形
- **原生文本路径:** `RDEPUBTextContentView` 根据 `rangeAnchor` 在 CoreText 绘制层高亮匹配文本
**搜索栏生命周期:**
搜索栏采用延迟安装模式:
1. `showSearchBar()` 设置 `isSearchBarVisible = true`,仅在工具栏可见时立即安装视图
2. 若工具栏隐藏,延迟到 `handleToolViewVisibilityChanged(isVisible: true)` 时安装
3. `installSearchBarView()` 负责实际的视图层级添加、约束、动画和焦点管理
---
## 8. 设置与主题
### 8.1 配置变更流程
```
用户修改设置
RDEPUBReaderConfiguration 更新
├─ 字号/字体/行距变更 → 触发重新分页
│ └─ renderSignature 变化 → 磁盘缓存失效 → 后台重新解析
├─ 主题变更 → 刷新可见内容
│ └─ CSS 层重建(dark/user 层)
├─ 显示模式变更 → 切换翻页容器
│ └─ switchReaderDisplayType()
└─ 列数变更 → 触发重新分页
└─ layoutConfig.cacheSignature 变化
```
### 8.2 主题系统
6 种内置主题,通过 `RDEPUBReaderThemeSelector` 选择:
| 主题 | 背景色 | 文字色 |
|------|--------|--------|
| 默认白 | #FFFFFF | #333333 |
| 护眼绿 | #CCE8CF | #333333 |
| 牛皮纸 | #E8D8B8 | #333333 |
| 深灰 | #333333 | #CCCCCC |
| 纯黑 | #000000 | #B4B4B6 |
| 暗蓝 | #1A2332 | #8A9BB5 |
暗色主题(深灰/纯黑/暗蓝)注入 `wxread-dark.css` 覆盖层。
### 8.3 持久化策略
设置通过 `RDEPUBReaderPersistence` 协议持久化:
```swift
protocol RDEPUBReaderPersistence {
func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String)
func loadLocation(for bookIdentifier: String) -> RDEPUBLocation?
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String)
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark]
func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String)
func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight]
}
```
默认实现使用 `UserDefaults`,键格式:
- 位置:`ssreader.epub.location.{bookID}`
- 书签:`ssreader.epub.bookmarks.{bookID}`
- 高亮:`ssreader.epub.highlights.{bookID}`
- 设置:`ssreader.epub.settings`
---
## 9. 纯文本 (.txt) 支持
`RDEpubPlainTextBookBuilder` 复用 EPUB 渲染管线处理 .txt 文件:
1. **解码:** 依次尝试 UTF-8 → GB18030 → GBK
2. **分章:** 正则匹配 `^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$`
3. **包装 HTML** 每行包裹 `<p>` 标签
4. **渲染+分页:** 复用 `RDEPUBTextRenderer``RDEPUBCoreTextPageFrameFactory`
---
## 10. 性能关键路径
### 10.1 首次打开(冷启动)
| 阶段 | 耗时占比 | 优化方向 |
|------|----------|----------|
| ZIP 解压 | ~5% | 缓存解压目录 |
| OPF 解析 | ~1% | SAX 流式解析 |
| 首章渲染 | ~15% | 快速打开路径 |
| 后台全量解析 | ~70% | 并发、缓存、预计算 |
| 磁盘 I/O | ~9% | 异步写入、批量读取 |
### 10.2 二次打开(热缓存)
| 阶段 | 耗时占比 | 说明 |
|------|----------|------|
| 磁盘摘要恢复 | ~30% | readAll 批量读取 |
| BookPageMap 构建 | ~5% | Builder 模式 |
| UI 应用 | ~65% | 刷新 collection view |
### 10.3 关键优化项
| 优化 | 收益 | 文件 |
|------|------|------|
| 预计算 contentHash | 消除每章重复 HTML 读取 + SHA-256 | RDEPUBReaderPaginationCoordinator |
| 冻结 renderSignature | 避免参数漂移导致缓存不一致 | RDEPUBReaderContext |
| 锁区瘦身 | 降低并发锁竞争 | RDEPUBReaderPaginationCoordinator |
| 可配置并发数 | 适配不同设备 | RDEPUBReaderConfiguration |
| 可配置刷新间隔 | 平衡 UI 响应和吞吐 | RDEPUBReaderPaginationCoordinator |
+314
View File
@@ -0,0 +1,314 @@
# CFI 实现问题分析报告
> 最后更新:2026-06-18
---
## 概述
本报告覆盖 `EPUBCore/CFI/` 目录下全部 13 个 Swift 文件的代码审查结果。共发现 **11 个问题**,按严重程度分为高/中/低三级。
| 严重程度 | 数量 | 说明 |
|----------|------|------|
| **高** | 3 | rawValue 短路、UTF-16 混用、content path 硬编码 |
| **中** | 5 | Resolver 假设不健壮、HTML 正则解析、text assertion 转义、token 匹配宽泛、end offset 边界 |
| **低** | 3 | 重复 nilIfEmpty、线性扫描、parent 静默失败 |
---
## 高优先级
### 1. Serializer 的 rawValue 短路逻辑 — 数据一致性风险
**文件:** `RDEPUBCFISerializer.swift:5-9``33-36`
```swift
public static func serialize(_ cfi: RDEPUBCFI) -> String {
if !cfi.rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return cfi.rawValue //
}
// ...
}
```
**问题:** `RDEPUBCFI` 同时存储了 `rawValue`(原始字符串)和解析后的组件(`packagePath``contentPath``characterOffset` 等)。如果有人修改了组件但没有清空 `rawValue`,序列化会返回**过期的旧值**。
`RDEPUBCFIGenerator` 中已经出现了这个 workaround — 先创建 `rawValue: ""` 的 CFI,序列化后再创建一个新 CFI 填入 `rawValue`。这说明开发者意识到了问题,但没有从根源修复。
**建议:** 要么移除 `rawValue` 缓存,每次都从组件重新构建;要么让 `rawValue` 成为 `computed property`,在 `packagePath`/`contentPath` 等变化时自动失效。
---
### 2. UTF-16 vs Character Offset 混用
**文件:** `RDEPUBCFIDOMPathBuilder.swift:435-458``RDEPUBNormalizedTextIndex.tokenSamples`)与 `RDEPUBCFIDOMPathBuilder.swift:405-428``RDEPUBNormalizedTextIndex.init`
**问题:** `RDEPUBNormalizedTextIndex` 内部同时维护了两种偏移体系,但没有统一:
1. **`normalizedToChapterOffsets` 数组**`init` 中构建):通过遍历 `nsSource.length`UTF-16 code unit 数)逐个构建,每个 UTF-16 code unit 对应一个数组条目。因此数组下标是 **UTF-16 索引**
2. **`tokenSamples()` 返回的 offset**:使用 `Array(normalizedText)` 生成 token`Array` 按 Swift Character 拆分,返回的 offset 是 `characters` 数组的下标——即 **Character 索引**
3. **`chapterOffset(forNormalizedOffset:)`**:用传入的 offset 直接查表 `normalizedToChapterOffsets[offset]`,期望接收 UTF-16 索引。
`normalizedText` 含多 code unit 字符时,`normalizedText.count`Character 数)< `nsSource.length`UTF-16 code unit 数),导致 `tokenSamples` 返回的 Character 索引在查 UTF-16 索引表时越界或错位。
此外,`calibratedOffset``RDEPUBCFIRecoveryEngine.swift:240-279`)全程使用 `NSString`/`NSRange`(UTF-16 偏移)进行搜索和打分,这与 `tokenSamples` 的 Character 偏移体系不一致。
**影响场景:**
- 含 emoji 的书籍(如 😀,UTF-16 surrogate pair 占 2 code unit,但 Character 计为 1
- 含 CJK 扩展 B 区汉字的古籍
- 含数学符号的教材(如 𝕏,占 2 code unit
**建议:** 统一偏移基准。推荐全部使用 UTF-16 偏移(与 NSRange/NSAttributedString 一致),将 `tokenSamples` 改为基于 UTF-16 索引生成 token,或在 `chapterOffset(forNormalizedOffset:)` 入口处显式转换。
---
### 3. Generator 的 content path 硬编码
**文件:** `RDEPUBCFIGenerator.swift:17-19`
```swift
let contentPath = RDEPUBCFIPath(steps: [
RDEPUBCFIStep(index: 4), // <body> 2
RDEPUBCFIStep(index: 2, idAssertion: fragmentID) // 1
])
```
**问题:** `makeOffsetCFI` 硬编码 content path 为 `/4/2`,即假设目标文本在 `<body>` 的第 2 个元素子节点的第 1 个文本子节点中。以下情况会出错:
| 场景 | 预期 content path | 实际生成 |
|------|-------------------|----------|
| 文本直接在 `<body>` 下 | `/4/N`N 为文本节点奇数索引) | `/4/2` ❌ |
| 文本在 3 层嵌套中 | `/4/2/2/2` | `/4/2` ❌ |
| `<body>` 前有注释节点 | 索引需要偏移 | `/4/2` ❌ |
`makeCFI` 方法允许传入自定义 `contentPath`,但 `makeOffsetCFI` 没有这个灵活性,而它是 `makeOffsetRangeCFI` 的内部依赖。
**建议:** `makeOffsetCFI` 应接受可选的 `contentPath` 参数,或从 `RDEPUBCFIMap` 中查找正确的路径。
---
## 中优先级
### 4. Resolver 的 CFI 结构假设不健壮
**文件:** `RDEPUBCFIResolver.swift:23-40`
```swift
public static func resolve(_ cfi: RDEPUBCFI) -> RDEPUBCFIResolverResult {
let manifestStep = cfi.packagePath.steps.last(where: { $0.idAssertion?.isEmpty == false })
let href = manifestStep?.idAssertion
let fileIndex = cfi.packagePath.steps
.dropFirst()
.last
.map { max(($0.index / 2) - 1, 0) }
// ...
}
```
**问题 1 — manifest 步骤查找不可靠:**`last(where:)` 搜索带 id assertion 的步骤。EPUB CFI 规范中 package path 结构固定:`/6`package)→ `/2[n]`spine)→ `/2n[manifest-id]`。应明确取第三个步骤,而非搜索。
**问题 2 — fileIndex 计算错误:** `dropFirst().last` 取的是**最后一个**步骤,不是第二个。如果 package path 有 3 个步骤(如 Fixed Layout spread),取到的是 manifest 步骤而非 spine 步骤。正确做法是 `steps.count >= 2 ? steps[1] : nil`
**问题 3 — 可读性差:** `idAssertion?.isEmpty == false` 逻辑取反应,应该是 `idAssertion?.nilIfEmpty != nil`
---
### 5. DOMPathBuilder 用正则解析 HTML — 脆弱且有边界情况
**文件:** `RDEPUBCFIDOMPathBuilder.swift:5-8`
```swift
guard let regex = try? NSRegularExpression(
pattern: #"</?\s*([A-Za-z][A-Za-z0-9:_-]*)([^>]*)>"#,
options: [.caseInsensitive]
) else { return [:] }
```
**边界情况清单:**
| 输入 | 后果 |
|------|------|
| `<!-- <div> -->` | 正则匹配注释内的 `<div>`,导致错误的子节点计数 |
| `<![CDATA[ <tag> ]]>` | 同上 |
| `<div data-value="a>b">` | 属性值中的 `>` 导致正则截断 |
| `<input checked>` | 无值属性解析正常,但 `idAttribute` 正则要求引号 |
| `<script>if (a < b) {}</script>` | `< b` 可能被匹配为标签 |
| `<br/>` vs `<br />` | `hasSuffix("/>")` 可能在属性值以 `/` 结尾时误判 |
**建议:** 考虑使用 `DTCoreText``libxml2` 进行真正的 HTML 解析。如果必须用正则,至少先移除注释和 CDATA。
---
### 6. Text Assertion 解析不处理转义
**文件:** `RDEPUBCFIParser.swift:133-153`
```swift
private static func parseTextAssertion(from body: String) -> RDEPUBCFITextAssertion? {
guard let open = body.firstIndex(of: "["),
let close = body.lastIndex(of: "]"),
open < close else { return nil }
let raw = String(body[body.index(after: open)..<close])
// ...
}
```
**问题:** EPUB CFI 规范中,text assertion 内的 `[``]` 可以用反斜杠转义(`\[``\]`)。当前实现用 `firstIndex(of: "[")``lastIndex(of: "]")` 找括号,不处理转义。
**示例:** CFI `epubcfi(/6/4[chap01]!/4/2/1:3[前缀,文\[本\],后缀])` 中,精确文本是 `文[本]`,但当前实现会错误地在 `\]` 处截断。
---
### 7. Recovery Engine 的 token 匹配过于宽泛
**文件:** `RDEPUBCFIRecoveryEngine.swift:136-139`
```swift
let candidateAnchors = cfiMap.recoveryMetadata.tokenIndex.filter { anchor in
exact.contains(anchor.token) || anchor.token.contains(exact)
}
```
**问题:** 双向 `contains` 匹配会导致大量误匹配:
- `exact = "的"` 会匹配所有包含 "的" 的 token(中文中极其常见)
- `anchor.token = "这是一个很长的句子"` 如果 `exact = "是"`,也会匹配
- 英文中 `exact = "the"` 同样会匹配大量 token
**建议:**
1. 设置最小 token 长度阈值(如 ≥ 4 字符)
2. 改为前缀/后缀匹配而非任意包含
3. 要求 token 与 exact 的长度比在合理范围内(如 0.5 ~ 2.0)
---
### 8. `makeOffsetRangeCFI` 的 end offset 边界
**文件:** `RDEPUBCFIGenerator.swift:78-93`
```swift
let start = makeOffsetCFI(
href: href, fileIndex: fileIndex,
chapterOffset: startOffset, // startOffset
sideBias: .before, ...
)
let end = makeOffsetCFI(
href: href, fileIndex: fileIndex,
chapterOffset: max(startOffset, endOffset), // startOffset
sideBias: .after, ...
)
```
**问题:** 如果 `startOffset` 为负数:
1. `start``chapterOffset``max(chapterOffset, 0)` clamp 到 0
2. `end``chapterOffset``max(startOffset, endOffset)`,如果 `endOffset` 也为负,结果为负数,再被 clamp 到 0
3. 最终 start 和 end 都是 0range 退化为点
但如果 `startOffset = -5``endOffset = 10`
- `start.chapterOffset` = `max(-5, 0)` = 0
- `end.chapterOffset` = `max(-5, 10)` = 10
- 这个结果是正确的
然而如果 `startOffset = 10``endOffset = 5`(反向 range):
- `start.chapterOffset` = 10
- `end.chapterOffset` = `max(10, 5)` = 10
- range 退化为点,丢失了反向信息
**建议:** 在入口处验证 `startOffset <= endOffset`,或支持反向 range。
---
## 低优先级
### 9. 重复的 `nilIfEmpty` 扩展
**文件:** 4 个文件各自定义了 `private extension String { var nilIfEmpty }`
| 文件 | 行号 |
|------|------|
| `RDEPUBCFIPath.swift` | 41-46 |
| `RDEPUBCFIParser.swift` | 172-177 |
| `RDEPUBCFIDOMPathBuilder.swift` | 90-95 |
| `RDEPUBCFITextAssertion.swift` | 18-24 |
**建议:** 提取为一个共享的 `internal` 扩展,放在单独文件或 `RDEPUBCFI.swift` 中。
---
### 10. `RDEPUBCFIMap.marker(matching:)` 线性扫描
**文件:** `RDEPUBCFIMap.swift:30-32`
```swift
public func marker(matching path: RDEPUBCFIPath) -> RDEPUBCFIMarker? {
markers.first { $0.cfiPath == path }
}
```
**问题:** 对于大章节(几百个文本节点),每次查找都做 O(n) 线性扫描。在恢复引擎中可能被多次调用,性能成为瓶颈。
**建议:**`RDEPUBCFIMap` 初始化时构建 `[RDEPUBCFIPath: Int]` 索引字典(path → marker 数组下标),查找降为 O(1)。
---
### 11. Range 解析的 parent 静默失败
**文件:** `RDEPUBCFIParser.swift:51`
```swift
return RDEPUBCFIRange(
rawValue: rawValue,
parent: try? parse(parentRaw), //
start: try parse(startRaw),
end: try parse(endRaw)
)
```
**问题:** `parent``try?` 静默忽略解析失败,但 `start``end``try` 抛出。如果 parent 解析失败,`parent``nil`,后续 `canonicalRangeComponents` 会从 start/end 推导 parent,推导逻辑可能与原始 parent 不一致。
**建议:** 统一错误处理策略 — 要么全部抛出,要么全部容错。当前的混合策略会让调试困难。
---
## 附录:受影响文件清单
| 文件 | 涉及问题 |
|------|----------|
| `RDEPUBCFI.swift` | #1rawValue 存储) |
| `RDEPUBCFIParser.swift` | #6text assertion 转义)、#11parent 静默失败)、#9nilIfEmpty |
| `RDEPUBCFISerializer.swift` | #1rawValue 短路) |
| `RDEPUBCFIPath.swift` | #9nilIfEmpty |
| `RDEPUBCFIResolver.swift` | #4(结构假设) |
| `RDEPUBCFIGenerator.swift` | #3content path 硬编码)、#8end offset 边界) |
| `RDEPUBCFIMap.swift` | #10(线性扫描) |
| `RDEPUBCFIDOMPathBuilder.swift` | #2UTF-16/Character 偏移混用)、#5HTML 正则解析)、#9nilIfEmpty |
| `RDEPUBCFIRecoveryEngine.swift` | #2calibratedOffset 使用 UTF-16 偏移)、#7token 匹配) |
| `RDEPUBCFITextAssertion.swift` | #9nilIfEmpty |
| `RDEPUBCFICompatibility.swift` | 无直接问题 |
| `RDEPUBCFIError.swift` | 无直接问题 |
---
## 修复优先级建议
```
Phase 1(高风险,影响正确性)
├── #1 rawValue 短路 → 改为 computed property 或移除缓存
├── #2 UTF-16/Character 偏移混用 → tokenSamples 用 Character 索引查 UTF-16 索引表,统一为 UTF-16 偏移
└── #3 content path 硬编码 → 从 CFIMap 查找或接受参数
Phase 2(中风险,影响健壮性)
├── #4 Resolver 假设 → 明确取固定索引步骤
├── #5 HTML 正则 → 先清理注释/CDATA,或换用 DOM 解析器
├── #6 text assertion 转义 → 实现反斜杠转义处理
├── #7 token 匹配 → 加最小长度阈值和长度比约束
└── #8 end offset → 入口验证或支持反向 range
Phase 3(低风险,代码质量)
├── #9 nilIfEmpty → 提取共享扩展
├── #10 线性扫描 → 构建索引字典
└── #11 parent 静默失败 → 统一错误处理
```
+597
View File
@@ -0,0 +1,597 @@
# CFI 子系统文档
> 最后更新:2026-06-18
本文档详细描述 ReadViewSDK 中 EPUB CFICanonical Fragment Identifier)子系统的架构、数据模型、解析流程与容错机制。
---
## 1. CFI 规范简介
EPUB CFI 是 EPUB 3 规范定义的标准化片段标识符,用于精确定位 EPUB 内容中的任意位置。其语法形式为:
```
epubcfi(/6/4!ch01.xhtml/4/2/1:3)
├──────┘ ├────────┘ ├──────┘ └─┘
│ │ │ └─ 字符偏移(characterOffset
│ │ └─ 内容路径(contentPath):定位 DOM 节点
│ └─ 包路径(packagePath):定位 OPF manifest 中的资源
└─ epubcfi() 包装器
```
关键规则:
- **步进(step**:以 `/` 分隔,偶数索引表示元素节点,奇数索引表示文本节点
- **id 断言**`[id]` 形式,如 `/4[ch01.xhtml]`,用于增强定位鲁棒性
- **范围 CFI**`epubcfi(/parent,/start,/end)` 三段逗号分隔,表示起止范围
- **侧偏**`;s=b`before)或 `;s=a`after),指示锚点偏向
- **文本断言**`[prefix,exact,suffix]`,用于断言定位处的文本内容
---
## 2. 核心数据模型
### 2.1 RDEPUBCFI
顶层 CFI 模型,对应一个完整的 `epubcfi(...)` 字符串。
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFI.swift`
```swift
public struct RDEPUBCFI: Codable, Equatable, Hashable {
public var rawValue: String // CFI
public var packagePath: RDEPUBCFIPath // XHTML
public var contentPath: RDEPUBCFIPath // DOM
public var characterOffset: Int? //
public var sideBias: RDEPUBCFISideBias? //
public var textAssertion: RDEPUBCFITextAssertion? //
}
```
**侧偏枚举**
```swift
public enum RDEPUBCFISideBias: String, Codable {
case before = "b" //
case after = "a" //
}
```
**示例**:解析 `epubcfi(/6/4[ch01.xhtml]!/4/2/1:100;s=b)` 后:
- `packagePath.steps` = `[Step(index: 6), Step(index: 4, idAssertion: "ch01.xhtml")]`
- `contentPath.steps` = `[Step(index: 4), Step(index: 2), Step(index: 1)]`
- `characterOffset` = 100
- `sideBias` = `.before`
### 2.2 RDEPUBCFIPath
路径模型,由一组 `RDEPUBCFIStep` 组成,描述从根节点到目标节点的遍历序列。
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift`
```swift
public struct RDEPUBCFIPath: Codable, Equatable, Hashable {
public var steps: [RDEPUBCFIStep]
//
public func commonPrefix(with other: RDEPUBCFIPath) -> RDEPUBCFIPath
//
public func droppingPrefix(_ prefix: RDEPUBCFIPath) -> RDEPUBCFIPath
}
```
**RDEPUBCFIStep**
```swift
public struct RDEPUBCFIStep: Codable, Equatable, Hashable {
public var index: Int // ==
public var idAssertion: String? // id "ch01.xhtml"
}
```
`commonPrefix` 方法在范围 CFI 序列化时用于提取起止点的公共父路径;`droppingPrefix` 用于生成相对于父级的路径片段。
### 2.3 RDEPUBCFIRange
范围模型,表示文档中的一个连续区域,由父级 CFI 和起止 CFI 组成。
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift`
```swift
public struct RDEPUBCFIRange: Codable, Equatable, Hashable {
public var rawValue: String // CFI
public var parent: RDEPUBCFI? //
public var start: RDEPUBCFI //
public var end: RDEPUBCFI //
}
```
序列化时输出格式:`epubcfi(/parent_path,/start_terminal,/end_terminal)`,其中起止路径相对于父级路径输出。
---
## 3. 解析与序列化
### 3.1 RDEPUBCFIParser
将 CFI 字符串解析为结构化模型。
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift`
```swift
public enum RDEPUBCFIParser {
// CFI
public static func parse(_ rawValue: String?) throws -> RDEPUBCFI
// CFI
public static func parseRange(_ rawValue: String?) throws -> RDEPUBCFIRange
}
```
**解析流程**`parse` 方法):
1. **去包装**:剥离 `epubcfi(...)` 外层,提取 body
2. **拆分包路径与内容路径**:以第一个 `!` 为分隔符
3. **解析路径**:按 `/` 分割为 step,每个 step 可含 `[idAssertion]`
4. **解析偏移与限定符**:从内容路径中提取 `:offset``;s=b/a``[textAssertion]`
**关键细节**
- `firstIndexOutsideBrackets` 方法确保 `:` 分隔符在方括号外才被识别,避免与文本断言中的内容混淆
- `parseRange` 要求恰好 3 个逗号分隔部分(parent, start, end),否则抛出 `unsupportedRange` 错误
**使用示例**
```swift
let cfi = try RDEPUBCFIParser.parse(
"epubcfi(/6/4[ch01.xhtml]!/4/2/1:50;s=a[前缀,目标文本,后缀])"
)
print(cfi.characterOffset) // Optional(50)
print(cfi.sideBias) // Optional(.after)
print(cfi.textAssertion?.exact) // Optional("")
```
### 3.2 RDEPUBCFISerializer
将 CFI 模型序列化为标准字符串。
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift`
```swift
public enum RDEPUBCFISerializer {
// CFI
public static func serialize(_ cfi: RDEPUBCFI) -> String
// CFI
public static func serializeRange(_ range: RDEPUBCFIRange) -> String
// step
public static func serializePath(_ path: RDEPUBCFIPath) -> String
}
```
**序列化逻辑**
-`rawValue` 非空,直接返回(避免重复序列化)
- 范围序列化先通过 `canonicalRangeComponents` 提取或推导公共父级,再分别序列化起止终端路径(相对于父级)
- 终端路径使用 `droppingPrefix` 去除与父级共享的部分
**示例**
```swift
let cfi = RDEPUBCFIGenerator.makeOffsetCFI(
href: "chapter1.xhtml", fileIndex: 0, chapterOffset: 120
)
let serialized = RDEPUBCFISerializer.serialize(cfi)
// => "epubcfi(/6/2[chapter1.xhtml]!/4/2:120)"
```
---
## 4. DOM 解析与生成
### 4.1 RDEPUBCFIResolver
将 CFI 解析为可直接用于资源定位的结果。
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift`
```swift
public struct RDEPUBCFIResolverResult: Equatable {
public var href: String? // XHTML idAssertion
public var fileIndex: Int? //
public var chapterOffset: Int? //
public var fragmentID: String? // ID #section1
}
public enum RDEPUBCFIResolver {
public static func resolve(_ cfi: RDEPUBCFI) -> RDEPUBCFIResolverResult
}
```
**解析规则**
- `href`:从 `packagePath` 中最后一个含 `idAssertion` 的 step 提取
- `fileIndex``packagePath` 中倒数第二个 step 的 `(index / 2) - 1`
- `fragmentID`:从 `contentPath` 中最后一个含 `idAssertion` 的 step 提取
- `chapterOffset`:直接取 `cfi.characterOffset`
**示例**
```swift
let cfi = try RDEPUBCFIParser.parse("epubcfi(/6/4[ch01.xhtml]!/4/2[chap1]:80)")
let result = RDEPUBCFIResolver.resolve(cfi)
print(result.href) // Optional("ch01.xhtml")
print(result.fileIndex) // Optional(0)
print(result.fragmentID) // Optional("chap1")
print(result.chapterOffset) // Optional(80)
```
### 4.2 RDEPUBCFIGenerator
从已知的章节信息生成 CFI。
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift`
```swift
public enum RDEPUBCFIGenerator {
// CFI
public static func makeOffsetCFI(
href: String,
fileIndex: Int,
chapterOffset: Int,
fragmentID: String? = nil,
sideBias: RDEPUBCFISideBias? = nil,
textAssertion: RDEPUBCFITextAssertion? = nil
) -> RDEPUBCFI
// CFI
public static func makeCFI(
href: String,
fileIndex: Int,
contentPath: RDEPUBCFIPath,
characterOffset: Int,
sideBias: RDEPUBCFISideBias? = nil,
textAssertion: RDEPUBCFITextAssertion? = nil
) -> RDEPUBCFI
// CFI
public static func makeOffsetRangeCFI(
href: String,
fileIndex: Int,
startOffset: Int,
endOffset: Int,
fragmentID: String? = nil,
startTextAssertion: RDEPUBCFITextAssertion? = nil,
endTextAssertion: RDEPUBCFITextAssertion? = nil
) -> RDEPUBCFIRange
}
```
**路径构造规则**
- `packagePath` 固定为 `[Step(index: 6), Step(index: (fileIndex+1)*2, idAssertion: href)]`
- `contentPath` 默认为 `[Step(index: 4), Step(index: 2, idAssertion: fragmentID)]`
- 范围 CFI 的起始点自动附加 `sideBias: .before`,结束点附加 `sideBias: .after`
### 4.3 RDEPUBCFIDOMPathBuilder
从 HTML 源码中提取带有 `id` 属性的元素路径映射。
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift`
```swift
public enum RDEPUBCFIDOMPathBuilder {
// HTML fragmentID -> CFIPath
public static func fragmentPaths(in html: String) -> [String: RDEPUBCFIPath]
}
```
**实现细节**
- 使用正则表达式匹配 HTML 标签,维护一个模拟 DOM 栈
- 每遇到开标签,计算子节点索引(`childIndex * 2`),生成 `RDEPUBCFIStep`
- 从标签属性中提取 `id``xml:id` 作为 `idAssertion`
- 识别并跳过 void 元素(`br``img``input` 等)和可忽略标签(`!doctype`
- 闭标签时弹出栈顶,重置该深度的子节点计数
---
## 5. 容错恢复引擎(RDEPUBCFIRecoveryEngine
当 CFI 精确定位失败时(如 DOM 结构变更),恢复引擎通过多级降级策略尝试找到最佳匹配位置。
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift`
### 5.1 恢复置信度
```swift
public struct RDEPUBCFIRecoveryResult: Equatable {
public enum Confidence: Int, Codable {
case exactPath //
case assertionCalibrated // +
case siblingRecovered //
case tokenRecovered //
case fragmentFallback // ID
case offsetFallback //
}
public var chapterOffset: Int
public var confidence: Confidence
}
```
### 5.2 恢复策略(按优先级)
| 优先级 | 策略 | 说明 |
|--------|------|------|
| 1 | `exactPath` | 在 CFIMap 中查找精确匹配的 marker 或 pathRange |
| 2 | `assertionCalibrated` | 精确路径匹配后,使用文本断言在窗口内校准偏移 |
| 3 | `siblingRecovered` | 查找同父级的兄弟节点,通过 `siblingScore` 选择最近匹配 |
| 4 | `tokenRecovered` | 利用词元索引(token index)在章节文本中搜索匹配 |
| 5 | `fragmentFallback` | 回退到 fragment ID 对应的已知偏移量 |
| 6 | `offsetFallback` | 使用兜底偏移量 |
### 5.3 核心算法
**校准机制**`calibratedOffset`):
- 在给定偏移量附近 ±512 字符窗口内搜索 `textAssertion.exact` 文本
- 对每个候选位置计算 `assertionScore`:距离越近分数越低,prefix/suffix 匹配则大幅加分(不匹配 +10000)
- 选取得分最低的候选位置
**兄弟恢复**`siblingRecovered`):
- 查找与目标路径同父级、同深度的已知 marker
- 通过 `siblingSignature``parent_path#childIndex`)和索引距离计算相似度分数
**词元恢复**`tokenRecovered`):
- 在预构建的 token index 中查找包含 `textAssertion.exact` 的词元
- 对每个候选锚点执行校准,选择校准距离最小的结果
**调用入口**
```swift
let result = RDEPUBCFIRecoveryEngine.recover(
cfi: someCFI,
cfiMap: chapterCFIMap,
chapterText: "章节纯文本...",
fragmentOffsets: ["section1": 150, "section2": 800],
fallbackOffset: 0,
lastOffset: 5000
)
// result?.confidence
```
---
## 6. 文本断言验证(RDEPUBCFITextAssertion
文本断言用于在 CFI 定位后验证所指位置的文本内容是否符合预期,增强定位鲁棒性。
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift`
```swift
public struct RDEPUBCFITextAssertion: Codable, Equatable, Hashable {
public var prefix: String? //
public var exact: String? //
public var suffix: String? //
}
```
**在 CFI 中的表示**`[prefix,exact,suffix]`,位于偏移量和侧偏之后。
**示例**
```swift
// CFI: epubcfi(/6/4[ch01.xhtml]!/4/2/1:100;s=a[,,])
let assertion = cfi.textAssertion
print(assertion?.prefix) // Optional("")
print(assertion?.exact) // Optional("")
print(assertion?.suffix) // Optional("")
```
**容错引擎中的应用**
- `exact` 用于在窗口内搜索实际文本位置
- `prefix``suffix` 用于对候选位置评分,优先选择上下文都匹配的位置
---
## 7. 兼容性处理(RDEPUBCFICompatibility
提供宽松解析接口,兼容非标准 CFI 格式。
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFICompatibility.swift`
```swift
public enum RDEPUBCFICompatibility {
// CFI nil
public static func parseLossy(_ rawValue: String?) -> RDEPUBCFI?
// CFI
public static func parseRangeLossy(_ rawValue: String?) -> RDEPUBCFIRange?
}
```
**范围 CFI 兼容策略**
- 优先使用标准 `parseRange`(逗号三分段格式)
- 若失败,尝试以 `..``-` 作为分隔符拆分为两个独立 CFI 分别解析
- 支持 `epubcfi(...)-epubcfi(...)``epubcfi(...)..epubcfi(...)` 格式
**使用场景**:处理第三方生成器或旧版本导出的非标准范围标记。
---
## 8. CFI 映射(RDEPUBCFIMap
CFI 映射是章节级别的索引结构,将 CFI 路径映射到章节文本偏移量,是容错恢复引擎的核心数据源。
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift`
### 8.1 RDEPUBCFIMap
```swift
public struct RDEPUBCFIMap: Codable, Equatable {
public var href: String //
public var renderVersion: Int //
public var domVersion: Int // DOM
public var markers: [RDEPUBCFIMarker] // CFI
public var textAssertions: [String: RDEPUBCFITextAssertion] //
public var pathRanges: [RDEPUBCFIPathRange] //
public var recoveryMetadata: RDEPUBCFIRecoveryMetadata //
// marker
public func marker(matching path: RDEPUBCFIPath) -> RDEPUBCFIMarker?
}
```
### 8.2 RDEPUBCFIMarker
每个 marker 记录一个 CFI 路径对应的章节信息:
```swift
public struct RDEPUBCFIMarker: Codable, Equatable {
public var cfiPath: RDEPUBCFIPath // CFI
public var chapterOffset: Int? //
public var fragmentID: String? // ID
public var textNodeLength: Int? //
public var textNodeChecksum: UInt64? // FNV-1a 64
public var normalizedTextPreview: String? // 24
public var domSiblingSignature: String? // DOM
}
```
### 8.3 RDEPUBCFIPathRange
描述一个 CFI 路径对应的文本偏移范围:
```swift
public struct RDEPUBCFIPathRange: Codable, Equatable {
public var cfiPath: RDEPUBCFIPath // CFI
public var startOffset: Int //
public var endOffset: Int //
public var textNodeLength: Int //
}
```
### 8.4 RDEPUBCFIRecoveryMetadata
恢复元数据,为容错引擎提供多维度恢复依据:
```swift
public struct RDEPUBCFIRecoveryMetadata: Codable, Equatable {
public var domFingerprint: String // DOM SHA-256
public var normalizedTextChecksum: String //
public var tokenIndex: [RDEPUBCFITokenAnchor] //
public var fragmentPathMap: [String: RDEPUBCFIPath] // fragment ->
}
```
### 8.5 RDEPUBCFITokenAnchor
词元锚点,用于基于内容的恢复:
```swift
public struct RDEPUBCFITokenAnchor: Codable, Equatable {
public var token: String // 12 96 64
public var occurrence: Int //
public var chapterOffset: Int //
public var cfiPath: RDEPUBCFIPath // CFI
}
```
### 8.6 映射构建流程
`RDEPUBCFITextNodeMapBuilder.makeMap` 方法负责构建完整映射:
1. **归一化文本**:解码 HTML 实体、合并连续空白、统一空白字符
2. **提取 fragment 路径**:通过 `RDEPUBCFIDOMPathBuilder.fragmentPaths` 获取 id -> path 映射
3. **构建 markers**:逐标签遍历 HTML,维护 DOM 栈,为每个文本节点创建 marker
4. **构建 pathRanges**:将 markers 转换为偏移范围列表
5. **构建恢复元数据**:生成 DOM 指纹、文本校验和、词元锚点索引
---
## 9. 错误类型(RDEPUBCFIError
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIError.swift`
```swift
public enum RDEPUBCFIError: Error, Equatable {
case empty //
case invalidWrapper(String) // epubcfi()
case invalidPath(String) // /
case unsupportedRange(String) //
}
```
| 错误 | 触发条件 |
|------|----------|
| `empty` | 输入为 `nil`、空字符串或纯空白 |
| `invalidWrapper` | 未以 `epubcfi(` 开头或未以 `)` 结尾 |
| `invalidPath` | 路径部分非空且不以 `/` 开头 |
| `unsupportedRange` | 范围 CFI 的逗号分隔部分不等于 3 |
---
## 10. 使用场景与调用链
### 10.1 保存阅读位置
```
用户翻页
→ RDEPUBCFIGenerator.makeOffsetCFI(href, fileIndex, chapterOffset)
→ RDEPUBCFISerializer.serialize(cfi)
→ 存储 epubcfi(...) 字符串
```
### 10.2 恢复阅读位置
```
加载 epubcfi(...) 字符串
→ RDEPUBCFIParser.parse(rawValue)
→ RDEPUBCFIResolver.resolve(cfi)
→ 获取 href, fileIndex, chapterOffset
→ 若偏移量无效,进入恢复引擎
→ RDEPUBCFIRecoveryEngine.recover(cfi, cfiMap, chapterText, ...)
→ 依次尝试 exactPath → sibling → token → fragment → offset
```
### 10.3 高亮选中文本
```
用户选择文本范围
→ 获取 start/end DOM 位置
→ RDEPUBCFIGenerator.makeOffsetRangeCFI(href, fileIndex, startOffset, endOffset)
→ RDEPUBCFISerializer.serializeRange(range)
→ 存储范围 CFI
```
### 10.4 章节加载时构建索引
```
章节 HTML 加载完成
→ RDEPUBCFITextNodeMapBuilder.makeMap(href, rawHTML, chapterText, fragmentOffsets)
→ 生成 RDEPUBCFIMapmarkers + pathRanges + recoveryMetadata
→ 缓存供后续恢复使用
```
### 10.5 兼容性解析
```
外部导入非标准 CFI
→ RDEPUBCFICompatibility.parseLossy(rawValue) // 宽松解析
→ RDEPUBCFICompatibility.parseRangeLossy(rawValue) // 支持 .. / - 分隔符
```
---
## 文件清单
| 文件 | 职责 |
|------|------|
| `RDEPUBCFI.swift` | 顶层 CFI 模型与侧偏枚举 |
| `RDEPUBCFIPath.swift` | 路径模型与 step 定义 |
| `RDEPUBCFIRange.swift` | 范围模型 |
| `RDEPUBCFIParser.swift` | 字符串 → 模型解析 |
| `RDEPUBCFISerializer.swift` | 模型 → 字符串序列化 |
| `RDEPUBCFIResolver.swift` | CFI → 资源定位结果 |
| `RDEPUBCFIGenerator.swift` | 章节信息 → CFI 生成 |
| `RDEPUBCFIDOMPathBuilder.swift` | HTML → fragment 路径映射 + 文本节点映射构建 |
| `RDEPUBCFIRecoveryEngine.swift` | 多级容错恢复引擎 |
| `RDEPUBCFITextAssertion.swift` | 文本断言模型 |
| `RDEPUBCFICompatibility.swift` | 非标准格式兼容解析 |
| `RDEPUBCFIMap.swift` | CFI 映射、marker、恢复元数据 |
| `RDEPUBCFIError.swift` | 错误类型定义 |
+425
View File
@@ -0,0 +1,425 @@
# 章节运行时详解
> 最后更新:2026-06-18
本文档详细描述 ReadViewSDK 章节运行时子系统的架构、缓存策略、加载流程和优化机制。
---
## 1. 概述
章节运行时是 EPUBUI 层的核心子系统,负责章节的按需加载、缓存管理和页码映射。它位于 `RDEPUBReaderContext``RDEPUBReaderRuntime` 架构中,是实现大书(如 1000+ 章的网络小说)流畅阅读的关键。
**核心设计目标**
- 快速打开:用户点击书籍后 1-2 秒内可开始阅读
- 按需加载:只加载当前窗口内的章节,内存占用可控
- 渐进补全:后台逐步补全所有章节的页码信息
**关键文件**
- `Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/` 目录
- `Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift`
---
## 2. 三级缓存架构
```
┌─────────────────────────────────────────────────────────┐
│ Tier 1: 内存缓存 (RDEPUBChapterRuntimeStore) │
│ ├─ chapterDataCache: [Int: RDEPUBRuntimeChapter] │
│ ├─ pageCountCache: [CacheKey: RDEPUBRuntimePageCount] │
│ ├─ imageCache: NSCache<NSString, UIImage> (50 上限) │
│ └─ 窗口驱逐:只保留当前章节 ± windowRadius 的章节 │
└──────────────────────────┬──────────────────────────────┘
│ miss
┌──────────────────────────▼──────────────────────────────┐
│ Tier 2: 磁盘摘要缓存 (RDEPUBChapterSummaryDiskCache) │
│ ├─ 路径: ~/Caches/RDEPUBChapterSummaryCache/{bookID}/ │
│ ├─ 文件名: SHA256(bookID_spineIdx_renderSig_contentHash)│
│ ├─ 格式: JSON (RDEPUBChapterSummary) │
│ ├─ 写入: 异步(serial DispatchQueue
│ └─ 读取: 同步(readAll 批量读取) │
└──────────────────────────┬──────────────────────────────┘
│ miss
┌──────────────────────────▼──────────────────────────────┐
│ Tier 3: 全书分页缓存 (RDEPUBTextBookCache) │
│ ├─ 路径: ~/Caches/RDEPUBTextBookCache/ │
│ ├─ 格式: NSSecureCoding archive │
│ ├─ 内容: 每章的 pageRanges + breakReasons + semanticHints│
│ └─ 失效: schemaVersion 变更时全部失效 │
└─────────────────────────────────────────────────────────┘
```
### 2.1 Tier 1: RDEPUBChapterRuntimeStore
**文件**`RDEPUBChapterRuntimeStore.swift`
内存缓存,存储当前窗口内的章节数据。
```swift
final class RDEPUBChapterRuntimeStore {
// RuntimeChapter
private let chapterDataCache = RDEPUBChapterDataCache()
// NSAttributedString
private let pageCountCache = RDEPUBPageCountCache()
// NSCache 50
let imageCache = NSCache<NSString, UIImage>()
// userInitiated QoS
let chapterLoadQueue = DispatchQueue(label: "com.rdreader.chapterload", qos: .userInitiated)
}
```
**窗口驱逐策略**
```swift
func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int = 1) {
currentSpineIndex = spineIndex
let lowerBound = max(0, spineIndex - radius)
let upperBound = min(totalSpineCount - 1, spineIndex + radius)
windowSpineIndices = Array(lowerBound...upperBound)
}
```
- 保留范围:`[spineIndex - windowRadius, spineIndex + windowRadius]`
- `evictableSpineIndices()` 返回窗口外的已缓存章节索引
- 章节切换时调用 `setCurrentChapter` 更新窗口,然后驱逐窗口外章节
**内存警告处理**
```swift
func handleMemoryWarning() {
evictAllExceptCurrent() //
imageCache.removeAllObjects() //
}
```
**导航优先级抢占**
当用户快速翻页时,新的导航请求可以抢占正在构建的章节:
```swift
func setNavigationTarget(spineIndex: Int) //
func consumeNavigationTarget() -> Int? //
```
`RDEPUBChapterLoader` 在完成当前章节构建后,会检查是否有新的导航目标,如有则立即切换到新目标。
### 2.2 Tier 2: RDEPUBChapterSummaryDiskCache
**文件**`RDEPUBChapterSummaryDiskCache.swift`
磁盘摘要缓存,存储章节的分页元数据(不含完整 NSAttributedString)。
**存储路径**`~/Library/Caches/RDEPUBChapterSummaryCache/{bookID}/`
**文件命名**`SHA256(bookID_spineIndex_renderSignature_contentHash).json`
**缓存内容**RDEPUBChapterSummary):
- `pageRanges: [NSRange]` — 每页的文本范围
- `pageCount: Int` — 总页数
- `fragmentOffsets: [String: Int]` — fragment ID 到偏移量的映射
- `cfiMap: RDEPUBCFIMap?` — CFI 映射
- `renderSignature: String` — 渲染参数签名
- `chapterContentHash: String` — 章节 HTML 的 SHA-256
- `pageMetadataList: [PageMetadataSummary]` — 每页的语义元数据
**写入策略**
- 异步写入(serial DispatchQueue
- 原子写入(先写临时文件,再 rename)
**批量读取**
```swift
func readAll(keys: [RDEPUBChapterCacheKey]) -> (
summaries: [Int: RDEPUBChapterSummary],
partialBuilder: RDEPUBBookPageMap.Builder
)
```
一次读取所有章节的摘要,同时构建 `BookPageMap.Builder`,避免重复遍历。
### 2.3 Tier 3: RDEPUBTextBookCache
全书分页缓存,使用 `NSSecureCoding` 归档。包含每章的完整分页信息(pageRanges、breakReasons、semanticHints)。当 `schemaVersion` 变更时全部失效。
---
## 3. RDEPUBChapterCacheKey — 缓存键设计
**文件**`RDEPUBChapterCacheKey.swift`
```swift
struct RDEPUBChapterCacheKey: Hashable {
let bookID: String //
let spineIndex: Int //
let renderSignature: String //
let chapterContentHash: String // HTML SHA-256
}
```
**renderSignature 组成**
```swift
let renderSignature = [
style.font.fontName, //
"\(style.font.pointSize)", //
"\(lineHeightMultiple)", //
"\(style.lineSpacing)", //
layoutConfig.cacheSignature, //
"\(schemaVersion)" // schema
].joined(separator: "|")
```
**缓存失效语义**
- 换字体/字号 → renderSignature 变化 → 缓存失效
- EPUB 内容更新 → contentHash 变化 → 缓存失效
- 不同书籍 → bookID 不同 → 互不干扰
- schemaVersion 变更 → 全部失效
---
## 4. RDEPUBChapterLoader — 章节加载器
**文件**`RDEPUBChapterLoader.swift`
### 4.1 加载流程
```swift
func loadChapter(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore,
priority: LoadPriority = .navigation,
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
)
```
**三级缓存串联**
1. **Tier 1 命中**`store.chapterData(for: spineIndex)` → 直接返回
2. **Tier 1 页数缓存命中**`store.pageCount(for: cacheKey)` → 轻量路径(跳过分页计算)
3. **Tier 2 命中**`summaryDiskCache?.read(for: cacheKey)` → 轻量路径
4. **全部未命中**:完整路径(渲染 + 分页 + 写缓存)
### 4.2 轻量路径 vs 完整路径
**轻量路径**(有缓存页范围时):
1. 读取 HTML 并渲染为 NSAttributedString
2. 使用缓存的 pageRanges 直接构建页面
3. 跳过分页计算(最耗时的步骤)
**完整路径**(无缓存时):
1. 使用 `RDEPUBTextBookBuilder.buildChapter()` 完整构建
2. 包含 HTML→NSAttributedString→分页→尾页规范化全流程
3. 构建完成后写入磁盘摘要缓存
### 4.3 加载优先级
```swift
enum LoadPriority {
case navigation //
case preview //
case prefetch //
}
```
**导航优先级抢占**:当 `priority == .navigation` 时,完成构建后检查 `consumeNavigationTarget()`,如有新目标则立即切换。
### 4.4 同步加载
```swift
func loadChapterSynchronouslyForMigration(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore?
) throws -> RDEPUBRuntimeChapter
```
使用信号量阻塞当前线程,等待章节加载完成。用于快速打开路径中加载首个可渲染章节。
---
## 5. RDEPUBBookPageMap — 轻量页码映射
**文件**`RDEPUBBookPageMap.swift`
轻量级全书页码映射,不持有 NSAttributedString,内存占用约 100KB/1000 章。
### 5.1 数据结构
```swift
struct RDEPUBBookPageMapEntry {
let spineIndex: Int
let href: String
let title: String
let pageCount: Int
let absolutePageStart: Int //
let fragmentOffsets: [String: Int]
}
struct RDEPUBBookPageMap {
let entries: [RDEPUBBookPageMapEntry]
let totalPages: Int
var totalChapters: Int { entries.count }
}
```
### 5.2 页码查询
```swift
// O(log n)
func spineIndex(forAbsolutePage absolutePage: Int) -> Int?
//
func localPageIndex(forAbsolutePage absolutePage: Int) -> Int?
// (spineIndex, localPageIndex)
func absolutePageIndex(spineIndex: Int, localPageIndex: Int) -> Int?
```
### 5.3 Builder 增量构建
```swift
struct Builder {
mutating func add(
spineIndex: Int,
href: String,
title: String,
pageCount: Int,
fragmentOffsets: [String: Int]
)
func build() -> RDEPUBBookPageMap
}
```
Builder 模式支持增量添加章节信息,`build()` 时自动按 spineIndex 排序并计算绝对页码。
---
## 6. RDEPUBPageResolver — 页码解析器
**文件**`RDEPUBPageResolver.swift`
将绝对页码解析为章节和本地页码的组合。
```swift
struct RDEPUBResolvedPage {
let spineIndex: Int
let localPageIndex: Int
let page: RDEPUBTextPage?
}
func resolvePage(absolutePageIndex: Int) -> RDEPUBResolvedPage?
```
解析流程:
1.`BookPageMap` 查找 spineIndex 和 localPageIndex
2.`ChapterRuntimeStore` 获取已加载的章节数据
3. 如果章节未加载,触发按需加载
---
## 7. 后台元数据解析优化
**文件**`RDEPUBReaderPaginationCoordinator.swift`
### 7.1 整体流程
```
RDEPUBMetadataParseWorker.start(token)
├─ 预计算所有章节 contentHash(串行)
├─ readAll(keys:) 批量读取磁盘缓存
├─ refreshBookPageMapInPlace(缓存部分)
├─ waitForReadingInteractionToSettle0.8s 冷却)
├─ OperationQueue (并发 N=CPU核心数):
│ ├─ buildChapter(spineIndex) // 渲染+分页
│ ├─ chapterCacheKey(spineIndex) // 复用预计算 hash
│ ├─ summary.write() // 异步写盘
│ └─ 每 32 章刷新 BookPageMap
└─ 最终 refreshBookPageMapInPlace
```
### 7.2 预计算 contentHash
**问题**:每个章节在构建缓存键时需要读取 HTML 并计算 SHA-256,重复 I/O 开销大。
**优化**:在后台解析开始时,串行预计算所有章节的 contentHash
```swift
var contentHashBySpineIndex: [Int: String] = [:]
for spineIndex in allBuildableIndices {
let html = parser.htmlString(forRelativePath: href)
contentHashBySpineIndex[spineIndex] = html?.rd_sha256Hex ?? ""
}
```
后续所有 `chapterCacheKey` 调用都使用预计算值。
### 7.3 冻结 renderSignature
**问题**:用户在后台解析进行中更改字号/行距,会导致部分章节用旧签名、部分用新签名写入缓存。
**解决**:在 `RDEPUBMetadataParseWorker` 初始化时冻结签名:
```swift
let renderSignature = context.currentRenderSignature()
// chapterCacheKey 使
```
token 机制确保设置变更会触发新的解析任务(新 token),旧任务自动废弃。
### 7.4 锁区瘦身
**原始实现**`resultLock` 内调用 `buildPageMap()`,遍历全量 catalog 和 summaries。
**优化**:锁内只做写入和计数,快照数据后锁外构建 pageMap:
```swift
var snapshot: [Int: RDEPUBChapterSummary]?
resultLock.lock()
summariesBySpineIndex[spineIndex] = renderResult
totalResolvedCount += 1
if shouldRefresh {
snapshot = summariesBySpineIndex //
}
resultLock.unlock()
if let snapshot {
let partialMap = buildPageMap(from: catalog, summaries: snapshot) //
}
```
### 7.5 可配置刷新间隔
```swift
static var pageMapRefreshInterval: Int = 32 // 32
```
可实测调优:32 / 48 / 64。值越大,UI 刷新频率越低,后台解析吞吐越高。
### 7.6 用户交互冷却
等待用户操作冷却 0.8 秒后再开始后台解析,避免与用户翻页操作竞争资源:
```swift
try await Task.sleep(nanoseconds: 800_000_000)
```
---
## 8. 关键配置参数
| 参数 | 位置 | 默认值 | 说明 |
|------|------|--------|------|
| `onDemandChapterWindowSize` | `RDEPUBReaderConfiguration` | `3` | 按需加载窗口大小(奇数,3-15) |
| `chapterWindowRadius` | 计算属性 | `1` | 内存缓存窗口半径(windowSize / 2 |
| `metadataParsingConcurrency` | `RDEPUBReaderConfiguration` | CPU 核心数 | 后台解析并发数 |
| `pageMapRefreshInterval` | 静态变量 | `32` | 每 N 章刷新一次 UI |
| `imageCache.countLimit` | `RDEPUBChapterRuntimeStore` | `50` | 图片缓存上限 |
| 冷却时间 | 硬编码 | `0.8s` | 用户交互冷却时间 |
| `schemaVersion` | `RDEPUBChapterSummary` | - | 缓存 schema 版本 |
-293
View File
@@ -1,293 +0,0 @@
# ReadSDK 代码规范
## 适用范围
本文档适用于 `ReadSDK` 新增代码与重构代码。
- 规范覆盖 `Sources/``RDReaderDemo/` 中的 Swift 代码。
- 命名、分层与职责边界以 SDK 可维护性和可扩展性为优先。
- 本文档分为两类内容:
- `已观察到的约定`:当前工程常见写法。
- `建议统一的规范`:后续统一执行的规则。
## 新架构目录与职责
### 已观察到的约定
- `Sources/RDReaderView`:阅读核心容器、翻页能力与基础视图。
- `EPUBCore`:EPUB 解析、资源定位、导航状态、分页与会话协调。
- `EPUBTextRendering`:文本渲染引擎与分页支持。
- `EPUBUI`:可开箱即用的 Reader UI 层。
- `RDReaderDemo`:示例应用与调试入口。
### 建议统一的规范
- 新增业务能力优先归入 `Sources/RDReaderView` 下的对应模块目录。
- `Core` 层只承载解析、会话、状态机与通用能力,不写页面级交互。
- `UI` 层只承载展示、事件分发和轻量状态同步,不直接处理底层解析逻辑。
- 若模块持续膨胀,优先在当前模块下继续拆分子文件,不跨目录散落实现。
## 命名规范
### 已观察到的约定
- 当前工程历史命名以 `SS``RDEPUB` 开头。
- 控制器常用 `...Controller`,视图常用 `...View`,会话对象常用 `...Session`
- 扩展文件采用 `类型名+功能域.swift`,如 `Parser+Archive.swift`
- 事件方法常使用 `Action` 结尾,例如 `pageTapAction()``themeChangeAction()`
- 绑定数据的方法常使用 `bind``update``refresh``configure` 等动词。
### 建议统一的规范
- **所有新增类型必须以 `RD` 开头。**
- EPUB 相关类型统一以 `RDEPUB` 开头。
- 方法名、变量名沿用 Swift 小驼峰,不增加额外前缀。
- 类型名应反映职责,不使用过宽泛的后缀;只有真正承担协调逻辑时才使用 `Manager``Handler`
- 事件处理方法统一使用"对象/意图 + Action"命名,例如 `pageTapAction``themeChangeAction`
- 数据绑定方法优先使用以下语义:
- `bind...`:将模型绑定到视图或模块。
- `update...`:增量刷新已有界面或状态。
- `configure...`:一次性配置样式或依赖。
- `refresh...`:重新拉取或重建数据状态。
- 避免新增拼写不一致的方法名;若发现历史命名拼写错误,新增代码必须使用正确拼写,旧接口修复时应配合调用点一起调整。
命名示例:
- `RDReaderView`
- `RDEPUBParser`
- `RDEPUBPublication`
- `RDEPUBReadingSession`
- `RDEPUBReaderController`
- `RDEPUBReaderTheme`
扩展文件命名示例:
```text
RDEPUBParser.swift
RDEPUBParser+Archive.swift
RDEPUBParser+Package.swift
RDEPUBParser+TOC.swift
RDEPUBParser+Resources.swift
RDEPUBWebView.swift
RDEPUBWebView+Configuration.swift
RDEPUBWebView+Reflowable.swift
```
## 分层与职责边界
### 已观察到的约定
- 阅读入口负责容器装配、翻页模式切换和事件分发。
- EPUB 核心层负责解析、导航、分页、资源读取与定位。
- 渲染层负责 HTML/富文本渲染与分页支持。
- UI 层负责主题、工具栏、目录、设置等交互能力。
### 建议统一的规范
- `RD...Controller`:负责页面级编排与流程调度,不承载复杂渲染细节。
- `RD...View`:负责展示与局部交互,不承载完整业务流程。
- `RDEPUB...Core`:负责解析、会话状态与数据模型,不依赖具体页面。
- 配置、主题、定位、进度模型统一下沉为 `struct`
- 跨层通信优先通过会话层或协议,不做跨层直接写状态。
## UI 与布局规范
### 已观察到的约定
- 视图多采用 `lazy var` 初始化,并在闭包内完成默认配置。
- 自定义 View 通常在 `init(frame:)` 或业务绑定后调用 `initView()` 完成视图树搭建。
- 复杂页面使用分区 extension 组织代理与事件实现。
- 页面或组件内部按职责拆分样式方法,例如 `topBarStyle()``contentStyle()`
- 页面经常通过回调闭包把交互抛给外层,例如 `pageChangeCallback``selectionCallback`
- 模块内存在多处布局方式混用情况。
### 建议统一的规范
- SDK 层新增 UI 使用 Auto Layout 原生约束,Demo 层可使用 SnapKit;单文件内不混用多套布局体系。
- 视图层初始化顺序保持一致:
- 定义属性与子视图
-`initView()` 中组装视图树
- 在独立方法中拆分样式和状态刷新逻辑
- 当约束会被多次切换时:
- 首次创建使用 `makeConstraints`SnapKit)或 `NSLayoutConstraint`
- 重建结构使用 `remakeConstraints`SnapKit)或先移除再添加
- 仅修改常量时使用 `updateConstraints`SnapKit)或修改 `constant` 属性
- 布局分支明显时,优先拆成语义化私有方法,不要把所有状态分支堆在一个超长方法里。
- 对外暴露的 UI 刷新入口建议以 `bind``update` 开头,避免把布局细节暴露给调用方。
- 交互事件通过闭包或 delegate 抛出,避免子视图持有上层业务依赖。
## 交互与状态处理规范
### 已观察到的约定
- 事件处理使用 `@objc` + selector。
- 异步回调中广泛使用 `[weak self]`
- 状态判断常通过 `guard` 提前返回。
### 建议统一的规范
- 按钮、通知、系统回调放入独立 extension 分组。
- 异步闭包默认先使用 `[weak self]`,仅在必要时改强引用。
- 多前置条件入口统一先 `guard` 校验,减少嵌套。
- 导航与进度恢复统一通过 `RDEPUBReadingSession` 协调。
## 代码风格细则
### 已观察到的约定
- extension 分区较常见。
- 解析与渲染模型多数采用 `struct`
- 存在少量历史命名不统一与可选值处理不一致情况。
### 建议统一的规范
- 默认遵循”最小可见性”:`private` > `fileprivate` > `internal` > `public`
- 纯数据模型使用 `struct` + `Codable` + `Equatable`
- 服务对象使用 `final class`,避免无意义继承。
- 错误类型统一使用 `enum + LocalizedError`
- 新增代码避免强制解包;若当前上下文无法避免,至少先在上层收敛边界。
- 统一优先使用 `guard` 做前置失败处理,减少深层嵌套。
- extension 的拆分原则以”单一职责”优先:
- 事件处理一组
- 代理 / DataSource 实现一组
- 工具方法一组
- 通知适配一组
- 调试代码继续使用 `#if DEBUG` 包裹,不把调试边框、日志、测试分支直接带入正式逻辑。
## 文件组织规范
### 已观察到的约定
- 目录按阅读容器、EPUB Core、渲染、UI 分层组织。
- 大类通过 `+Extension` 文件拆分职责。
### 建议统一的规范
- 目录保持以下分层,不跨层放置实现:
- `Sources/RDReaderView/EPUBCore`
- `Sources/RDReaderView/EPUBTextRendering`
- `Sources/RDReaderView/EPUBUI`
- 单文件建议不超过 `600` 行;超出后按职责拆分 extension 文件。
- extension 文件命名统一 `RD类型名+功能域.swift`
## 注释规范
- 注释、错误提示、日志统一使用中文。
- 关键流程方法保留”为什么这样做”的注释,不写重复代码字面行为的注释。
- 调试输出统一放在 `#if DEBUG` 下。
- 新代码保留有信息量的注释,避免重复描述显而易见的代码行为。
## 已观察到的项目模式
### 模式 1:统一入口 + 扩展拆分
- `RDEPUBParser` 负责 EPUB 解析主入口,不同能力拆到 `+Archive``+Package``+TOC``+Resources` 等扩展文件。
- `RDEPUBReadingSession` 负责阅读会话主入口,状态管理、分页、定位等能力拆到扩展文件。
- 该模式适合继续用于解析器、会话管理、控制器工具方法等横向能力。
### 模式 2:页面编排在 Controller,局部交互下沉到 View
- `RDEPUBReaderController` 负责阅读页整体编排:翻页容器装配、工具栏切换、阅读位置恢复。
- `RDReaderView` 负责分页容器布局与翻页交互,并通过 DataSource / Delegate 把数据需求交回外层。
- 该模式保持 Controller 管流程、View 管展示的职责分离。
### 模式 3:列表与容器逻辑通过扩展拆开
- 复杂容器将 `UICollectionViewDataSource``UICollectionViewDelegate``UICollectionViewDelegateFlowLayout` 分别拆分到 extension。
- 该模式降低单文件中主逻辑与代理逻辑的耦合,适合继续用于任何包含列表或容器的组件。
### 模式 4:渲染路径抽象
- `RDEPUBReadingProfile` 根据 EPUB 特征自动选择渲染路径(`webFixedLayout` / `webInteractive` / `textReflowable`)。
- 新增渲染相关功能时,必须评估对三种路径的覆盖情况。
## 待统一项
- 当前访问控制级别存在混用:同一类里 `public`、默认 `internal``private` 并存,建议后续新增代码默认从最小可见范围开始声明。
- 当前存在少量强制解包,建议新增代码优先通过前置校验收敛风险。
- 当前存在拼写不一致问题,建议后续新增代码统一使用标准英文单词,旧接口如需修复应配合调用点一起调整。
- 当前部分注释偏”过程说明”或遗留调试注释,建议新代码保留有信息量的注释。
- 当前个别 View 在数据绑定阶段再次调用 `initView()` 重建界面,这种方式在复杂组件中容易引入重复添加子视图或状态不一致。建议新增组件优先区分”初始化视图结构”和”刷新数据状态”两个阶段。
## 禁忌事项
| 禁忌 | 替代做法 |
|------|----------|
| 新增类型不加 `RD` 前缀 | 所有新增类型统一 `RD` / `RDEPUB` 前缀 |
| UI 层直接拼装解析状态 | 通过 `RDEPUBReadingSession` 获取状态 |
| 控制器直接操作底层解析细节 | 通过 `RDEPUBPublication``RDEPUBParser` 暴露接口 |
| 强制解包可选值 | `guard let` / `if let` |
| 用页号单独恢复阅读进度 | 统一使用 `href + progression` |
## 使用建议
- 新增功能前先确定目录归属和职责边界。
- 命名先定前缀再落代码:类型一律 `RD` 开头。
- 若需迁移历史 `SS` 前缀,按模块渐进替换,优先替换新增与重构触达文件。
## 旧 SS 命名迁移到 RD 的分阶段执行清单
### 阶段 0:冻结新增 SS 命名(立即执行)
- 目标:从当前时点开始,不再引入新的 `SS`/`RDEPUB` 类型名。
- 动作:
- 新增类型统一使用 `RD`/`RDEPUB` 前缀。
- Code Review 增加命名检查项:发现新增 `SS` 命名必须驳回。
- 在 PR 模板中加入“本次是否新增旧前缀命名”勾选项。
- 验收:
- 新提交代码中,新增类型 `SS` 前缀数量为 `0`
### 阶段 1:建立迁移映射表(第 1 周)
- 目标:明确“旧名 -> 新名”一一映射,避免多人并行改名冲突。
- 动作:
- 统计核心公开类型、内部核心类型、测试类型三类清单。
- 建立命名映射表,例如:
- `RDReaderView` -> `RDReaderView`
- `RDEPUBParser` -> `RDEPUBParser`
- `RDEPUBReadingSession` -> `RDEPUBReadingSession`
- 对外 API 单独标记“需兼容过渡”的类型。
- 验收:
- 映射表覆盖全部高频核心类型,且团队评审通过。
### 阶段 2:先迁移内部类型(第 2-3 周)
- 目标:优先改内部实现,降低外部兼容压力。
- 动作:
- 按模块分批迁移:`EPUBCore` -> `EPUBTextRendering` -> `EPUBUI`
- 每批次只改一个子模块,避免超大 PR。
- 同步修复调用点、扩展文件名与注释中的旧命名。
- 验收:
- 目标模块内类型命名全部满足 `RD` 规则。
- 编译通过,Demo 阅读主流程可用。
### 阶段 3:迁移公开 API 并保留兼容层(第 3-4 周)
- 目标:完成对外接口改名,同时给接入方提供平滑升级窗口。
- 动作:
- 对外公开类型切换为 `RD` 命名。
- 旧公开类型保留兼容别名,并标注废弃说明(`deprecated`)。
- 在 Release Note 提供“旧名/新名对照表”和迁移示例。
- 验收:
- 新接入示例仅使用 `RD` 命名。
- 旧接入代码在兼容期内无需立即改动即可编译。
### 阶段 4:清理兼容层与收口(下一主版本)
- 目标:在约定主版本移除旧前缀,完成命名收口。
- 动作:
- 删除 `SS` 兼容别名与过渡代码。
- 清理文档、注释、示例工程中的旧前缀残留。
- 对外发布最终迁移公告与升级说明。
- 验收:
- 工程内无 `SS`/`RDEPUB` 类型定义残留。
- 文档与示例代码全部为 `RD`/`RDEPUB` 命名。
### 迁移过程约束
- 每次迁移 PR 必须包含:
- 命名改动清单
- 影响范围说明
- 回归验证结果(编译、Demo 主流程、关键阅读路径)
- 禁止在同一 PR 中同时做“大规模命名迁移 + 业务逻辑重构”。
- 若改名会影响外部接入,必须先补迁移文档再合并代码。
+133
View File
@@ -0,0 +1,133 @@
# 代码库风险与关注点
**分析日期:** 2026-05-21
**状态复核:** 2026-07-30(逐条对照源码,结论见下表;正文段落保留原始分析,勿当作现状)
| # | 项目 | 现状 |
|---|------|------|
| 1 | User Script Sandboxing 被关 | **部分处理**:已移除 Podfile 的全局强制覆盖;但显式打成 `YES` 会让 CocoaPods 的 `[CP] Embed Pods Frameworks` rsync 阶段被沙箱拒绝而构建失败(已实测)。要真正开启需先改掉 `use_frameworks!` 动态框架集成方式。 |
| 2 | 外链未做 allowlist | **已修**`RDEPUBReaderController+ExternalLinks.swift` 有 scheme allowlist + 二次确认弹窗 + delegate 否决。 |
| 3 | Zip Slip 未加固 | **已修**`RDEPUBParser+Archive.swift``validatedExtractionDestination` 做 standardized 前缀校验并拒绝 `..`/绝对路径。 |
| 4 | 高亮/选中文本明文写 UserDefaults | **已修**:新增 `RDEPUBProtectedContentStore`,高亮与书签改写 Application Support 下带 file protection 的文件、排除备份,首次读取自动迁移并删除明文;另加 `clearReadingData(for:)` / `clearAllReadingContent()`。 |
| 5 | Scheme handler 整文件读内存 | **原判断有误**>512KB 早已走 `respondWithStreaming` 64KB 分块。本次补上 `maximumInMemoryResourceBytes`(默认 64MB)给走不了流式的加密 provider 分支兜底。 |
| 6 | 分页 WKWebView 可能加载外部资源 | 未复核。 |
| 7 | 解压缓存无限累积 | **已修**`pruneExtractionCache(keeping:)` 先按 `extractionCacheMaximumAge`30 天)再按 `extractionCacheMaximumTotalBytes`(512MB)做 LRU 淘汰;复用时 touch mtime;另有 `clearExtractionCache()`。 |
| 8+ | 其余性能/可维护性项 | 未复核。 |
## 高风险:安全与隐私
### 1) CocoaPods 构建关闭了 User Script Sandboxing
- **问题:** `post_install` hook 将 Pods *以及* 用户工程的 `ENABLE_USER_SCRIPT_SANDBOXING` 强制设置为 `NO`
- **影响:** 削弱对内嵌 Web 内容的纵深防御;增加 `WKWebView` 相关功能的风险面,并可能不符合组织/审核(含 App Store)安全基线。
- **建议缓解:**
- 移除全局覆盖,保持系统默认的 sandboxing。
- 如确有依赖需要 workaround,仅对具体 Pod target 做最小范围的设置,并记录原因。
- 在 CI 中增加校验:出现 `ENABLE_USER_SCRIPT_SANDBOXING=NO` 时告警/失败。
### 2) 外链打开未做 allowlistscheme/host
- **问题:** EPUB 内容中的外部 URL 通过 `UIApplication.shared.open(...)` 打开时,未对 scheme/host 做校验,也未强制用户二次确认。
- **影响:** 恶意 EPUB 可能触发钓鱼、隐私泄露,或打开意外的 URL scheme(包含跳转到其他 App 的 deep link)。
- **建议缓解:**
- 强制 allowlist(默认仅允许 `https`;如需 `mailto`/`tel` 等应在明确 UI 提示后允许)。
- 弹出确认对话框,展示目标 host。
- 对 bridge 消息与导航行为中的非 HTTP(S) scheme 做拒绝/过滤。
### 3) EPUB 解压未显式做 Zip Slip(路径穿越)加固
- **问题:** 解压逻辑构造 `destinationURL = extractionURL.appendingPathComponent(entry.path)` 并解压 entry,但未显式验证标准化后的目标路径是否仍在 `extractionURL` 之内。
- **影响:** 构造的 EPUB 可能尝试通过 `../...` 路径穿越覆盖目标目录外的文件(实际危害与 ZIPFoundation 行为及权限有关,但建议显式防护)。
- **建议缓解:**
- 解压前计算 `standardizedDestination = destinationURL.standardizedFileURL`,并确保其前缀位于 `extractionURL.standardizedFileURL.path + "/"` 之下。
- 拒绝包含 `..`、绝对路径、或异常路径分隔符形式的 entry。
- 考虑先解压到新的随机 UUID 目录,仅将校验通过的内容移动到最终目录。
### 4) 选中文本/高亮内容明文写入 UserDefaults
- **问题:** 选中文本与高亮 payload 以 JSON 编码写入 `UserDefaults`(包含“新”持久化与 legacy 路径)。
- **影响:** 可能将书籍敏感内容(高亮、选择文本)存入默认不加密的持久化位置,可能进入备份;同时数据量可能无限增长(性能与隐私风险)。
- **建议缓解:**
- 仅持久化稳定标识/范围(例如 CFI / rangeInfo),运行时再派生文本。
- 如必须持久化文本,存入加密存储(例如 Keychain 或使用 `NSFileProtectionComplete` 的加密文件)并设置大小上限。
- 增加数据保留策略,并提供 “Clear reading data” API。
## 性能与稳定性风险
### 5) Scheme handler 将整文件一次性读入内存(无流式)
- **问题:** `WKURLSchemeHandler` 使用 `Data(contentsOf:)` 读取资源并一次性返回。
- **影响:** EPUB 内的大图/字体/媒体资源可能导致内存峰值、加载变慢,甚至在内存压力下被系统终止。
- **建议缓解:**
- 尽可能改为基于 `InputStream` 的流式传输,并使用增量 `didReceive(...)`
- 对单个资源增加大小上限,超过则优雅失败并提示。
### 6) 隐藏的分页 WKWebView 可能加载外部资源
- **问题:** `RDEPUBPaginator` 使用 `loadFileURL(...allowingReadAccessTo:)`,但未看到对 `navigationAction` 的 allowlist(例如仅允许 `file://` 或自定义 `ss-reader://`)。
- **影响:** 分页测量阶段可能产生意外网络请求,引发隐私泄露与不可控性能波动。
- **建议缓解:**
- 在 paginator 场景加入导航策略:默认仅允许 `file://`(以及必要的自定义 scheme),对 `http(s)` 直接取消。
- 视需求考虑内容拦截规则或禁用分页阶段的网络加载。
### 7) EPUB 解压缓存目录可能无限累积
- **问题:** 解压路径基于文件名/大小/mtime 的确定性签名;若目录已存在则直接复用,且无清理策略。
- **影响:** 书籍数量多或频繁更新时磁盘占用持续增长,造成存储压力与潜在卡顿。
- **建议缓解:**
-`ssreaderview-epub` 缓存目录实现淘汰策略(LRU/按时间)。
- 提供显式清理 API,并在低存储信号时触发清理。
## 可维护性与技术债
### 8) DEBUG 下日志与 Web Inspector 可能泄露内容
- **问题:** Debug 辅助会打印导航/消息体;selection payload 可能包含用户选中文本;且 DEBUG 下可能默认开启 inspectable。
- **影响:** 在 debug 构建(含内部测试/QA)中,日志可能包含敏感内容并被导出/分享。
- **建议缓解:**
- 默认对消息体做脱敏(仅记录类型/大小),需要时再显式 opt-in 输出内容。
-`isInspectable` 受控于 app 级 debug 开关,而不是在 DEBUG 下默认启用。
## 构建与依赖脆弱性
### 9) 仓库中包含 vendored 的 `Pods/` 目录
- **问题:** 仓库根目录有 `Pods/`,示例工程内也有 `ReadViewDemo/Pods/`
- **影响:** diff 体积大、clone 慢、易产生 merge 冲突,也容易出现依赖陈旧导致的构建不一致。
- **建议缓解:**
- 通常建议不要提交 Pods,仅提交 `Podfile.lock`,在 CI 中执行 `pod install`
- 如确有 vendoring 政策,需文档化并提供同步/校验工具,避免漂移。
### 10) Podfile 与 podspec 的部署版本不一致
- **问题:** `Podfile` 设为 `platform :ios, '15.6'`,而 podspec 声明 `s.platform = :ios, "15.0"`
- **影响:** 对外承诺与本地构建基线不一致,容易造成集成方预期偏差。
- **建议缓解:**
- 对齐 `Podfile``*.podspec`、Xcode project 的 deployment target。
- 在 CI 中增加漂移检查,防止版本被无意改动。
### 11) podspec 将 Swift 版本钉死为 5.10
- **问题:** `s.swift_versions = ["5.10"]` 将 pod 与特定 Swift 版本强绑定。
- **影响:** 旧工具链的集成方无法使用;未来升级可能出现“突然不兼容”,尤其当依赖未同步时。
- **建议缓解:**
- 如兼容性允许,可声明更多支持版本(或范围),并在 CI 中覆盖多个 Xcode/Swift 版本构建验证。
- 在文档中明确最低支持的 Xcode/Swift 版本。
## 不清晰/容易踩坑的契约
### 12) 默认的持久化协议实现可能静默丢失数据
- **问题:** `RDEPUBReaderPersistence` 通过 protocol extension 提供了 bookmarks/settings 的默认 no-op 实现。
- **影响:** 集成方可能误以为这些能力默认会持久化,但实际数据会被静默丢弃,且不易排查。
- **建议缓解:**
- 将关键方法改为必须实现(移除 no-op 默认实现);或在未实现且功能启用时输出日志/assert。
- 提供并文档化 “最小持久化” 与 “完整持久化” 的参考实现。
---
## Evidence(仓库路径)
- Build flags`Podfile`
- Pod metadata/toolchain`RDEpubReaderView.podspec`
- Archive extraction`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Archive.swift`
- Resource path validationpost-extraction):`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Resources.swift`
- Scheme handler reads full file bytes`Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift`
- WebView bridge + message handling`Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift`
- External link opening`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift`
- UserDefaults persistence implementation`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
- Hidden paginator web view`Sources/RDEpubReaderView/EPUBCore/RDEPUBPaginator.swift`
- Debug logging`Sources/RDEpubReaderView/EPUBCore/RDEPUBWebViewDebug.swift`
- Vendored dependencies`Pods/``ReadViewDemo/Pods/`
---
*风险与关注点审计:2026-05-21*
+85
View File
@@ -0,0 +1,85 @@
# 编码规范
**分析日期:** 2026-05-21
> 说明:本仓库以 iOS/Swift 为主,未检测到统一的 lint/format 工具配置;代码风格在不同模块间存在差异。文档按项目规则使用中文描述,但代码标识符保持英文。
## 语言与工程约束
- **主要语言**SwiftPodspec 声明 `s.swift_versions = ["5.10"]`,见 `RDEpubReaderView.podspec`
- **最低系统版本**Podspec `iOS 15.0``RDEpubReaderView.podspec`),示例工程 Podfile/构建设置里常见为 `iOS 15.6``ReadViewDemo/Podfile`
- **依赖管理**CocoaPods`ReadViewDemo/Podfile``ReadViewDemo/Podfile.lock`
## 命名约定
**文件/类型命名(Swift):**
- 以类型名为文件名的单文件组织较常见:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`
- 大量使用前缀区分模块域:
- `RD...`:阅读器 UI/控制器相关(如 `Sources/RDEpubReaderView/RDEpubReaderView.swift``Sources/RDEpubReaderView/RDEpubURLReaderController.swift`
- `RDEPUB...`EPUB Core/UI/渲染相关(如 `Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`
- Extension 文件使用 `+` 命名:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift`
**变量/函数命名:**
- 基本遵循 Swift lowerCamelCase`parse(epubURL:)``Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`
- 常量多用 `static let``kRDEPUBHighlightAttributeName``Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift`
## 代码风格与排版(从现有代码归纳)
**缩进与换行:**
- 多数文件使用 4 空格缩进(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift``Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`
- 历史代码中更常见”强制换行/多行括号”风格
**空行与分组:**
- UI 相关文件常用空行分隔属性/初始化/布局段落
- `// MARK:` 用于分区组织(示例:`Sources/RDEpubReaderView/RDEpubReaderGestureController.swift``Sources/RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift`
**类型组织:**
- 偏好用 `extension` 拆分职责/协议实现(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift``UITableViewDataSource/Delegate`
- API 暴露处使用 `public``public final class``public enum/struct`(示例:`Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`
- “对外只读、内部可写”常用 `public internal(set)`(示例:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`
## 导入与依赖使用
**import**
- UIKit/UI 文件:`import UIKit`(大量文件)
- Core/模型文件:`import Foundation`(如 `Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`
- 三方依赖按需引入:
- `SnapKit`:布局
- `SSAlertSwift`:弹窗/提示
**Pods 目录说明:**
- `ReadViewDemo/Pods/**` 为依赖源码/生成配置,通常不作为本仓库代码风格的“标准样式”参考。
## 错误处理与日志
- Core 解析层倾向用 `throws` + 自定义 `Error`(示例:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift``Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift``RDEPUBParserError`
- UI/控制器层常见 `guard` 早返回(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift`
- 未检测到统一日志框架(未发现专用 logging package/config);出现时以系统 API/局部输出为主(需按具体文件核对)。
## 注释与文档
- **项目规则(强约束)**:代码标识符保持英文,但**代码注释/文档/提交信息使用中文**(见 `CONTEXT.md`)。
- 历史文件常带 Xcode 头部注释块(示例:`Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift`)。
- 公共 API 处存在少量三斜线文档注释(示例:`Sources/RDEpubReaderView/RDEpubReaderView.swift` 的中文说明)。
## Lint / Formatter / 静态检查
**未检测到(仓库根与常见位置):**
- SwiftLint 配置:`.swiftlint.yml` / `swiftlint.yml`
- SwiftFormat 配置:`.swiftformat`
- 通用格式化配置:`.editorconfig`
- 其他:ESLint/Prettier/Biome 等(本仓库非 JS/TS 主体)
**可执行的工程级格式化/检查:**
- 主要依赖 Xcode(或 Swift 编译器)自身检查;如需引入 SwiftLint/SwiftFormat,应先新增对应配置文件并在 CI/构建脚本中接入。
## Evidence(关键证据文件)
- `CONTEXT.md`
- `ReadViewDemo/Podfile`
- `RDEpubReaderView.podspec`
- `ReadViewDemo/ReadViewDemo/ViewController.swift`
- `Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift`
- `Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`
- `Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`
- `Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift`
+642
View File
@@ -0,0 +1,642 @@
# EPUBCore 模块代码级参考文档
> 最后更新:2026-06-18
---
## 1. 模块概述
`EPUBCore` 是 ReadViewSDK 的**基础层**,负责 EPUB 文件解析、资源管理、WebView 渲染、JavaScript 桥接、CFI 定位、搜索和分页计算。它是上层 EPUBTextRendering 和 EPUBUI 的数据提供者。
**文件清单(~50 个 Swift 文件):**
| 子系统 | 核心文件 | 职责 |
|--------|----------|------|
| **解析器** | `RDEPUBParser*.swift` | EPUB 解压、OPF 解析、TOC 解析、资源提取 |
| **数据模型** | `RDEPUBModels.swift` | Metadata、ManifestItem、SpineItem、TOC 等基础模型 |
| **Publication** | `RDEPUBPublication.swift` | 出版物抽象,聚合 parser 和 resourceResolver |
| **阅读会话** | `RDEPUBReadingSession.swift` | 管理分页快照、阅读位置、待导航状态 |
| **WebView** | `RDEPUBWebView*.swift` | WKWebView 封装,支持 Reflowable 和 Fixed Layout |
| **JS 桥接** | `RDEPUBJavaScriptBridge.swift` | JavaScript 消息协议和脚本注入 |
| **资源解析** | `RDEPUBResourceResolver.swift` | href 归一化、文件路径解析 |
| **URL 方案** | `RDEPUBResourceURLSchemeHandler.swift` | 自定义 `ss-reader://` 协议处理 |
| **分页器** | `RDEPUBPaginator.swift` | WebView 分页计算(按章测量页数) |
| **CFI** | `CFI/RDEPUBCFI*.swift` | EPUB CFI 解析、生成、序列化、恢复 |
| **搜索** | `RDEPUBSearchEngine.swift` | HTML 全文搜索 |
| **样式** | `RDEPUBStyleSheetBuilder.swift` | CSS 注入与分页样式生成 |
| **偏好** | `RDEPUBPreferences.swift` | 阅读偏好设置模型 |
| **资源文件** | `RDEPUBAssetRepository.swift` | JS/CSS/HTML 资源文件加载 |
| **笔记** | `Notes/RDEPUBNote*.swift` | 脚注/尾注检测与解析 |
---
## 2. 解析器子系统
### 2.1 RDEPUBParser
**文件:** `RDEPUBParser.swift` + 扩展文件
EPUB 文件解析的核心类,负责从 .epub 文件中提取结构化数据。
```swift
public final class RDEPUBParser {
public internal(set) var metadata: RDEPUBMetadata //
public internal(set) var manifest: [String: RDEPUBManifestItem] //
public internal(set) var spine: [RDEPUBSpineItem] //
public internal(set) var tableOfContents: [EPUBTableOfContentsItem] //
public internal(set) var extractionRootURL: URL? //
public internal(set) var opfURL: URL? // OPF
public var opfDirectoryURL: URL? // OPF
/// EPUB + container.xml + OPF
public func parse(epubURL: URL) throws
/// OPF
public func parseOPF(at opfURL: URL) throws
/// Navigation Document NCX
public func parseTOC() -> [EPUBTableOfContentsItem]
/// parser Publication
public func makePublication() -> RDEPUBPublication
}
```
**扩展文件职责:**
| 文件 | 职责 |
|------|------|
| `RDEPUBParser+Archive.swift` | ZIP 解压(ZIPFoundation),提取到 Caches 目录 |
| `RDEPUBParser+Package.swift` | OPF XML SAX 解析(metadata/manifest/spine |
| `RDEPUBParser+TOC.swift` | Navigation Document 和 NCX 解析 |
| `RDEPUBParser+Resources.swift` | 资源文件读取(HTML 字符串、文件 URL) |
| `RDEPUBParser+ReadingProfile.swift` | 阅读配置文件检测 |
### 2.2 RDEPUBParserError
```swift
public enum RDEPUBParserError: LocalizedError {
case archiveOpenFailed(URL)
case missingContainerXML
case missingRootFile
case invalidRootFilePath(String)
case invalidXML(URL)
case missingManifestItem(idref: String)
case emptySpine
case invalidArchiveEntryPath(String)
}
```
---
## 3. 数据模型
### 3.1 RDEPUBMetadata
**文件:** `RDEPUBModels.swift`
```swift
public struct RDEPUBMetadata: Codable, Equatable {
public var identifier: String? // ISBN/UUID
public var title: String //
public var author: String? //
public var language: String? //
public var version: String? // EPUB
public var layout: RDEPUBLayout // reflowable / fixed
public var spread: String? // none / auto
public var readingProgression: RDEPUBReadingProgression // ltr / rtl / auto
}
```
### 3.2 RDEPUBManifestItem
```swift
public struct RDEPUBManifestItem: Codable, Equatable {
public var id: String // ID
public var href: String //
public var mediaType: String // MIME
public var properties: [String] // nav, mathml, svg
public var fallback: String? // 退 ID
public var mediaOverlay: String? // ID
public var title: String? //
public var isNavigationDocument: Bool // Navigation Document
public var isNCX: Bool // NCX
}
```
### 3.3 RDEPUBSpineItem
```swift
public struct RDEPUBSpineItem: Codable, Equatable {
public var idref: String // manifest ID
public var href: String //
public var mediaType: String // MIME
public var title: String //
public var linear: Bool // 线
public var properties: [String] // page-spread-left/right
public var pageSpread: RDEPUBPageSpread? //
public var layout: RDEPUBLayout? //
}
```
### 3.4 EPUBTableOfContentsItem
```swift
public struct EPUBTableOfContentsItem: Codable, Equatable {
public var title: String //
public var href: String //
public var children: [EPUBTableOfContentsItem] //
}
```
### 3.5 枚举类型
```swift
public enum RDEPUBLayout: String, Codable {
case reflowable //
case fixed //
}
public enum RDEPUBReadingProfile: String, Codable {
case webInteractive // WebView EPUB
case webFixedLayout // WebView
case textReflowable // DTCoreText
}
public enum RDEPUBReadingProgression: String, Codable {
case ltr, rtl, auto
}
public enum RDEPUBPageSpread: String, Codable {
case left, right, center
}
```
---
## 4. Publication
**文件:** `RDEPUBPublication.swift`
聚合 `RDEPUBParser``RDEPUBResourceResolver`,提供统一的出版物访问接口。
```swift
public final class RDEPUBPublication {
public let parser: RDEPUBParser
public let resourceResolver: RDEPUBResourceResolver
public var metadata: RDEPUBMetadata
public var manifest: [String: RDEPUBManifestItem]
public var spine: [RDEPUBSpineItem]
public var tableOfContents: [EPUBTableOfContentsItem]
public var layout: RDEPUBLayout
public var readingProfile: RDEPUBReadingProfile
public var readingProgression: RDEPUBReadingProgression
public var bookIdentifier: String?
/// Fixed Layout
public func fixedLayoutSpreadEnabled(for preferences: RDEPUBPreferences, viewportSize: CGSize) -> Bool
/// Fixed Layout Spread
public func makeFixedSpreads(preferences: RDEPUBPreferences, viewportSize: CGSize) -> [EPUBFixedSpread]
}
```
---
## 5. 阅读会话
**文件:** `RDEPUBReadingSession.swift`
管理阅读过程中的分页快照、阅读位置和待导航状态。
```swift
public final class RDEPUBReadingSession {
public typealias PaginationSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
public let publication: RDEPUBPublication
public private(set) var navigatorState: RDEPUBNavigatorState
public private(set) var activePages: [EPUBPage] //
public private(set) var activeChapters: [EPUBChapterInfo] //
public private(set) var stagedPages: [EPUBPage]? //
public private(set) var stagedChapters: [EPUBChapterInfo]?
public private(set) var currentViewport: RDEPUBViewport?
public private(set) var currentReadingContext: RDEPUBReadingContext?
//
public func transition(to state: RDEPUBNavigatorState)
public func resetRuntimeState()
//
public func setActiveSnapshot(_ snapshot: PaginationSnapshot)
public func stageSnapshot(_ snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?)
public func consumeStagedSnapshotIfAllowed() -> (snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?)?
//
public func queueNavigation(to location: RDEPUBLocation, ...) -> Int?
public func updateReadingContext(pageNumber:location:spineIndex:chapterIndex:bookIdentifier:)
public func currentReadingLocation(bookIdentifier: String?) -> RDEPUBLocation?
//
public func makePaginationSnapshot(pageCounts:preferences:layoutContext:) -> PaginationSnapshot
}
```
### 5.1 RDEPUBNavigatorState
```swift
public enum RDEPUBNavigatorState: String, Codable {
case initializing //
case loading //
case idle //
case jumping //
case moving //
case repaginating //
public var isStableForSnapshotApplication: Bool // idle true
}
```
---
## 6. WebView 子系统
### 6.1 RDEPUBWebView
**文件:** `RDEPUBWebView.swift` + 扩展文件
`UIView` 子类,内部封装 `WKWebView`,支持 Reflowable 和 Fixed Layout 两种渲染模式。
```swift
public final class RDEPUBWebView: UIView {
public weak var delegate: RDEPUBWebViewDelegate?
public var onRendered: (() -> Void)?
var publication: RDEPUBPublication?
var currentRenderRequest: RDEPUBRenderRequest?
var webView: WKWebView?
var schemeHandler: RDEPUBResourceURLSchemeHandler?
var currentSpineIndex: Int
var currentHref: String
var currentPageIndex: Int
var currentTotalPagesInChapter: Int
var viewportSize: CGSize
var currentFontSize: CGFloat
var currentLineHeightMultiple: CGFloat
var targetLocation: RDEPUBLocation?
var pendingHighlights: [RDEPUBHighlight]
}
```
**扩展文件:**
| 文件 | 职责 |
|------|------|
| `RDEPUBWebView+Configuration.swift` | WKWebView 配置(scheme handler、user script |
| `RDEPUBWebView+Reflowable.swift` | Reflowable 模式渲染逻辑 |
| `RDEPUBWebView+FixedLayout.swift` | Fixed Layout 模式渲染逻辑 |
| `RDEPUBWebView+JavaScriptBridge.swift` | JS 消息接收与处理 |
| `RDEPUBWebView+Search.swift` | 搜索高亮渲染 |
### 6.2 RDEPUBWebViewDelegate
```swift
public protocol RDEPUBWebViewDelegate: AnyObject {
func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL)
func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String)
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView)
}
```
---
## 7. JavaScript 桥接
**文件:** `RDEPUBJavaScriptBridge.swift`
定义 Native ↔ WebView 的消息协议和脚本注入。
### 7.1 消息类型
```swift
enum RDEPUBJavaScriptBridgeMessage: String, CaseIterable {
case progressionChanged = "ssReaderProgressionChanged" //
case selectionChanged = "ssReaderSelectionChanged" //
case internalLink = "ssReaderInternalLink" //
case externalLink = "ssReaderExternalLink" //
case javaScriptError = "ssReaderJSError" // JS
case fixedLayoutReady = "ssReaderFixedLayoutReady" // Fixed Layout
}
```
### 7.2 注入的 JS 资源
- `rangy-core.js` — 范围与选择 API
- `rangy-serializer.js` — 选择序列化
- `cssInjector.js` — CSS 注入
- `WeReadApi.js` — 阅读器 API(分页、跳转、高亮)
- `epub-bridge.js` — 消息桥接
### 7.3 关键方法
```swift
enum RDEPUBJavaScriptBridge {
static var messageNames: [String] //
static var userScript: String // User Script
/// Reflowable
static func applyPresentationScript(for request: RDEPUBReflowableRenderRequest) -> String
/// Fixed Layout
static func applyFixedPresentationScript(for request: RDEPUBFixedRenderRequest) -> String
}
```
---
## 8. 资源解析
**文件:** `RDEPUBResourceResolver.swift`
负责 href 归一化、文件路径解析和 spine 索引查找。
```swift
public final class RDEPUBResourceResolver {
public var opfDirectoryURL: URL?
/// URL
public func fileURL(forRelativePath relativePath: String) -> URL?
/// ss-reader:// URL
public func resourceURL(forRelativePath relativePath: String) -> URL?
/// href fragment
public func normalizedHref(_ href: String, relativeToSpineIndex spineIndex: Int? = nil) -> String?
public func normalizedHref(_ href: String, relativeToHref baseHref: String) -> String?
/// href + fragment + progression
public func normalizedLocation(_ location: RDEPUBLocation, relativeToSpineIndex spineIndex: Int?, bookIdentifier: String?) -> RDEPUBLocation?
/// href spine
public func spineIndex(forNormalizedHref normalizedHref: String) -> Int?
public func spineIndex(for location: RDEPUBLocation) -> Int?
/// spine href title
public func href(forSpineIndex spineIndex: Int) -> String?
public func title(forSpineIndex spineIndex: Int) -> String?
}
```
---
## 9. URL 方案处理器
**文件:** `RDEPUBResourceURLSchemeHandler.swift`
实现 `WKURLSchemeHandler`,拦截 `ss-reader://book/` 请求,从 EPUB 解压目录流式读取资源文件。
```swift
public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler {
public static let scheme = "ss-reader"
public static let host = "book"
public init(parser: RDEPUBParser)
// WKURLSchemeHandler
public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask)
public func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask)
}
```
---
## 10. 分页器
**文件:** `RDEPUBPaginator.swift`
使用隐藏的 WKWebView 逐章加载 HTML,通过 JS 测量每章的页数。
```swift
public final class RDEPUBPaginator: NSObject {
///
public func calculate(
parser: RDEPUBParser,
hostingView: UIView,
presentation: RDEPUBPresentationStyle,
completion: @escaping ([Int]) -> Void
)
///
public func calculateSingleChapter(
parser: RDEPUBParser,
spineIndex: Int,
hostingView: UIView,
presentation: RDEPUBPresentationStyle,
completion: @escaping (Int) -> Void
)
}
```
---
## 11. 搜索引擎
**文件:** `RDEPUBSearchEngine.swift`
```swift
protocol RDEPUBSearchEngine {
func search(keyword: String) -> [RDEPUBSearchMatch]
}
```
### RDEPUBHTMLSearchEngine
基于 HTML 的搜索引擎,逐章加载 HTML → 转为纯文本 → 关键词匹配。
```swift
final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
init(parser: RDEPUBParser, publication: RDEPUBPublication)
func search(keyword: String) -> [RDEPUBSearchMatch]
}
```
### RDEPUBTextSearchEngineEPUBTextRendering 模块)
基于原生文本的搜索引擎,在 `NSAttributedString` 上直接搜索,性能更好。
```swift
final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
init(textBook: RDEPUBTextBook, publication: RDEPUBPublication)
func search(keyword: String) -> [RDEPUBSearchMatch]
}
```
---
## 12. CFI 子系统
**文件:** `CFI/RDEPUBCFI*.swift`13 个文件)
实现 EPUB CFICanonical Fragment Identifier)标准,用于精确定位 EPUB 内容。
| 文件 | 职责 |
|------|------|
| `RDEPUBCFI.swift` | CFI 值类型,核心解析入口 |
| `RDEPUBCFIParser.swift` | CFI 字符串解析器 |
| `RDEPUBCFISerializer.swift` | CFI 序列化为字符串 |
| `RDEPUBCFIPath.swift` | CFI 路径节点 |
| `RDEPUBCFIRange.swift` | CFI 范围(起止点) |
| `RDEPUBCFIResolver.swift` | CFI → DOM 位置解析 |
| `RDEPUBCFIGenerator.swift` | DOM 位置 → CFI 生成 |
| `RDEPUBCFIMap.swift` | 章节级 CFI 映射表 |
| `RDEPUBCFIDOMPathBuilder.swift` | DOM 路径构建 |
| `RDEPUBCFITextAssertion.swift` | 文本断言(偏移校验) |
| `RDEPUBCFIRecoveryEngine.swift` | CFI 恢复引擎(节点变化后重新定位) |
| `RDEPUBCFICompatibility.swift` | CFI 兼容性处理 |
| `RDEPUBCFIError.swift` | 错误类型 |
---
## 13. 样式构建
**文件:** `RDEPUBStyleSheetBuilder.swift`
```swift
public enum RDEPUBStyleSheetBuilder {
/// HTML CSS Paginator
public static func injectPaginationCSS(into html: String, presentation: RDEPUBPresentationStyle) -> String
/// CSSviewport
public static func renderCSS(for presentation: RDEPUBPresentationStyle) -> String
}
```
---
## 14. 偏好设置
**文件:** `RDEPUBPreferences.swift`
```swift
public struct RDEPUBPreferences: Equatable {
public var fontSize: CGFloat //
public var lineHeightMultiple: CGFloat //
public var reflowableContentInsets: UIEdgeInsets // Reflowable
public var fixedContentInset: UIEdgeInsets // Fixed Layout
public var themeBackgroundColor: String? // CSS
public var themeTextColor: String? // CSS
public var fixedBackgroundColor: String? // Fixed
public var fixedLayoutFit: RDEPUBFixedLayoutFit // Fixed
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode //
public var numberOfColumns: Int //
public var columnGap: CGFloat //
/// PresentationStyle
public func presentationStyle(viewportSize: CGSize) -> RDEPUBPresentationStyle
///
public func renderRequest(for page: EPUBPage, publication: RDEPUBPublication, viewportSize: CGSize, ...) -> RDEPUBRenderRequest?
}
```
---
## 15. 渲染请求模型
**文件:** `RDEPUBRenderRequest.swift`
```swift
public enum RDEPUBRenderRequest: Equatable {
case reflowable(RDEPUBReflowableRenderRequest)
case fixed(RDEPUBFixedRenderRequest)
public var isFixedLayout: Bool
public var primarySpineIndex: Int
public var primaryHref: String
}
public struct RDEPUBReflowableRenderRequest: Equatable {
public var spineIndex: Int
public var href: String
public var pageIndex: Int
public var totalPagesInChapter: Int
public var presentation: RDEPUBPresentationStyle
public var targetLocation: RDEPUBLocation?
public var highlights: [RDEPUBHighlight]
public var searchPresentation: RDEPUBSearchPresentation?
}
public struct RDEPUBFixedRenderRequest: Equatable {
public var spread: EPUBFixedSpread
public var viewportSize: CGSize
public var contentInset: UIEdgeInsets
public var fit: RDEPUBFixedLayoutFit
}
```
### RDEPUBPresentationStyle
```swift
public struct RDEPUBPresentationStyle: Equatable {
public var viewportSize: CGSize
public var contentInsets: UIEdgeInsets
public var fontSize: CGFloat
public var lineHeightMultiple: CGFloat
public var numberOfColumns: Int
public var columnGap: CGFloat
public var themeBackgroundColor: String?
public var themeTextColor: String?
}
```
---
## 16. 资源仓库
**文件:** `RDEPUBAssetRepository.swift`
管理 SDK 内置的 JS/CSS/HTML 资源文件加载。
```swift
enum RDEPUBAsset: String {
case rangyCoreScript, rangySerializerScript, cssInjectorScript
case weReadAPIScript, bridgeScript, fixedLayoutTemplate
case wxReadDefaultCSS, wxReadReplaceCSS, wxReadDarkCSS, wxReadLatinReplaceCSS
}
enum RDEPUBAssetRepository {
///
static func string(for asset: RDEPUBAsset, replacements: [String: String] = [:]) -> String
}
```
---
## 17. 笔记子系统
**文件:** `Notes/RDEPUBNote*.swift`
| 文件 | 职责 |
|------|------|
| `RDEPUBNoteModels.swift` | 脚注/尾注数据模型 |
| `RDEPUBNoteDetector.swift` | 从 HTML 中检测脚注标记 |
| `RDEPUBNoteResolver.swift` | 解析脚注内容 |
---
## 18. 设计模式总结
| 模式 | 应用 |
|------|------|
| **Builder 模式** | `RDEPUBParser``RDEPUBPublication` 构建链 |
| **Strategy 模式** | `RDEPUBSearchEngine` 协议,HTML 和 Text 两种实现 |
| **URL Scheme 拦截** | `RDEPUBResourceURLSchemeHandler` 自定义协议 |
| **消息桥接** | `RDEPUBJavaScriptBridge` Native ↔ JS 双向通信 |
| **快照管理** | `RDEPUBReadingSession` staged/active 双快照 |
| **状态机** | `RDEPUBNavigatorState` 管理导航状态 |
-357
View File
@@ -1,357 +0,0 @@
# EPUBCore 功能实现逻辑
## 1. 范围与目标
- 代码范围:`Sources/RDReaderView/EPUBCore/`30 个 Swift 文件 + 2 个资源文件)
- 目标:说明 EPUBCore 如何完成 EPUB 解析、资源定位、阅读会话管理、离屏分页、JS 桥接渲染和全文搜索。
- 主链路关键词:`epubURL -> RDEPUBParser.parse -> container.xml -> OPF -> spine/TOC -> RDEPUBPublication -> RDEPUBReadingSession -> RDEPUBWebView/Paginator -> 分页/渲染`
## 2. 关键对象职责
### 2.1 解析器 `RDEPUBParser`
- 文件:`EPUBCore/RDEPUBParser.swift` + 5 个扩展文件
- 入口方法:`parse(epubURL:)`
- 职责:
- ZIP 解压到缓存目录(`RDEPUBParser+Archive.swift`
- 解析 `META-INF/container.xml` 定位 OPF 根文件
- 解析 OPF 文档提取 metadata / manifest / spine`RDEPUBParser+Package.swift`
- 解析目录支持 NCXEPUB 2)和 Nav DocumentEPUB 3)(`RDEPUBParser+TOC.swift`
- 判断阅读配置文件类型(`RDEPUBParser+ReadingProfile.swift`
- 资源路径转换:相对路径 ↔ 文件 URL ↔ `ss-reader://` 自定义 scheme URL`RDEPUBParser+Resources.swift`
### 2.2 Publication 门面 `RDEPUBPublication`
- 文件:`EPUBCore/RDEPUBPublication.swift`
- 职责:
- 包装 `RDEPUBParser` + `RDEPUBResourceResolver`,对外暴露只读接口
- 提供 metadata、manifest、spine、TOC、layout、readingProfile、readingProgression
- 计算 fixed layout 的 spread 组合:`makeFixedSpreads(preferences:viewportSize:)`
### 2.3 阅读会话 `RDEPUBReadingSession`
- 文件:`EPUBCore/RDEPUBReadingSession.swift` + `RDEPUBNavigatorState.swift`
- 职责:
- 管理导航状态机:`initializing -> loading -> idle <-> jumping/moving/repaginating`
- 维护活跃/暂存分页快照(`activePages` / `activeChapters` / `stagedPages` / `stagedChapters`
- 管理待定导航目标(`pendingNavigationLocation` / `pendingNavigationPageNum`
- 更新阅读上下文(`currentReadingContext`location + viewport + pageNumber + chapterIndex
- 构建分页快照:`makePaginationSnapshot(pageCounts:preferences:layoutContext:)`
### 2.4 WebView 渲染层 `RDEPUBWebView`
- 文件:`EPUBCore/RDEPUBWebView.swift` + 5 个扩展文件
- 职责:
- 内部持有 WKWebView,配置自定义 scheme handler 和 JS 桥接
- 可重排内容加载:`loadPage(parser:spineIndex:pageIndex:...)``+Reflowable.swift`
- 固定版式内容加载:`loadFixedSpread(parser:spread:...)``+FixedLayout.swift`
- 处理 JS 桥接消息和链接导航拦截(`+JavaScriptBridge.swift`
- 搜索高亮装饰(`+Search.swift`
- 定义 `RDEPUBWebViewDelegate` 协议(6 个回调方法)
### 2.5 离屏分页器 `RDEPUBPaginator`
- 文件:`EPUBCore/RDEPUBPaginator.swift`
- 职责:
- 创建隐藏的 WKWebView 加载每个 spine 项的 HTML
- 注入分页 CSS,运行 JS 测量 `scrollWidth`
- 计算 `ceil(scrollWidth / viewportWidth)` 作为页数
- 多次测量取最大值(延迟 0ms / 80ms / 180ms)确保布局稳定
### 2.6 资源解析 `RDEPUBResourceResolver` + `RDEPUBResourceURLSchemeHandler`
- 文件:`EPUBCore/RDEPUBResourceResolver.swift``EPUBCore/RDEPUBResourceURLSchemeHandler.swift`
- 职责:
- `RDEPUBResourceResolver`:对外门面,提供 href 标准化、spine 索引查找、Location 构建
- `RDEPUBResourceURLSchemeHandler`:实现 `WKURLSchemeHandler`,将 `ss-reader://book/<path>` 映射到解压后的文件系统路径,缺失的可选资源(字体/图片/CSS/JS)返回空 200
### 2.7 JS 桥接
- 文件:`EPUBCore/RDEPUBJavaScriptBridge.swift``Resources/epub-bridge.js`
- 6 种消息类型(JS → Swift):
- `ssReaderProgressionChanged`:阅读进度变化
- `ssReaderSelectionChanged`:文本选择变化
- `ssReaderInternalLink`:内部链接点击
- `ssReaderExternalLink`:外部链接点击
- `ssReaderJSError`JS 错误
- `ssReaderFixedLayoutReady`:固定版式渲染完成
- JS 端 `window.RDReaderBridge` 暴露:`applyPagination``setPageMetrics``scrollToPage``scrollToLocation``setHighlights``clearHighlights``reportProgression``selectionPayload`
### 2.8 搜索引擎 `RDEPUBHTMLSearchEngine`
- 文件:`EPUBCore/RDEPUBSearchEngine.swift``EPUBCore/RDEPUBSearchModels.swift`
- 职责:
- 将 HTML 转为纯文本(NSAttributedString 或正则兜底)
- 执行大小写不敏感的全文搜索
- 返回 `RDEPUBSearchMatch` 数组(含 progression 偏移和预览文本)
### 2.9 配置与样式
- `RDEPUBPreferences``EPUBCore/RDEPUBPreferences.swift`):用户阅读偏好(字号、行高、内容边距、主题色、固定版式适配模式),构建 `RDEPUBPresentationStyle``RDEPUBRenderRequest`
- `RDEPUBNavigatorLayoutContext``EPUBCore/RDEPUBNavigatorLayoutContext.swift`):容器布局上下文(容器尺寸、每屏页数、安全区域、设备类型)
- `RDEPUBStyleSheetBuilder``EPUBCore/RDEPUBStyleSheetBuilder.swift`):生成分页 CSS、渲染 CSS 和测量 JS 脚本
- `RDEPUBFixedLayoutTemplate``EPUBCore/RDEPUBFixedLayoutTemplate.swift`):生成固定版式 HTML 模板
## 3. 主流程(代码级)
### 3.1 EPUB 解析全流程
1. 入口:`RDEPUBParser.parse(epubURL:)`
2. 调用 `extractArchiveIfNeeded(epubURL:)`
- 缓存路径:`~/Library/Caches/ssreaderview-epub/<slug>-<fileSize>-<timestamp>/`
- 若目录已存在则跳过解压
- 否则用 ZIPFoundation 解压 ZIP 到缓存目录
3. 解析 `META-INF/container.xml`
- `ContainerXMLParserDelegate`SAX)找到第一个 `<rootfile>``full-path` 属性
4. 解析 OPF 文档:
- `OPFPackageParserDelegate`SAX)分段解析 metadata / manifest / spine
- 提取 `<package>` 版本和 unique-identifier
- 提取 `<dc:identifier>` / `<dc:title>` / `<dc:creator>` / `<dc:language>`
- 提取 `<meta property="rendition:layout">` 判断 fixed/reflowable
- 提取 manifest 每个 `<item>``RDEPUBManifestItem`
- 提取 spine 每个 `<itemref>``OPFSpineReference`
5. 构建 spine`buildSpine(from:opfURL:)`
- 将 spine reference 匹配到 manifest item
- 标准化 href(相对于 OPF 目录)
- 确定每项的有效 layout(显式属性覆盖出版级设置)
- 返回 `[RDEPUBSpineItem]`,空 spine 抛 `emptySpine` 错误
6. 解析目录:`parseTOC(from:opfURL:)`
- 优先 NCX`NCXParserDelegate`),其次 Nav Document`NavDocumentParserDelegate`
- 兜底使用 spine 标题
- href 标准化保留 fragment 标识符
7. 创建 Publication`makePublication()``RDEPUBPublication(parser: self)`
### 3.2 阅读配置文件判断
- 入口:`RDEPUBParser+ReadingProfile.swift`
- 判断逻辑:
- 扫描 manifest 检查是否有 `scripted` 属性的项
- 扫描 HTML body 检查是否有 `script``iframe``video` 等交互元素
- 结果:
- `.webFixedLayout`:固定版式 EPUB(漫画、绘本)— WKWebView 渲染
- `.webInteractive`:可重排 + 交互脚本 EPUB — WKWebView 渲染
- `.textReflowable`:纯文本可重排 EPUB — DTCoreText 渲染
### 3.3 离屏分页测量
1. 入口:`RDEPUBPaginator.calculate(parser:hostingView:presentation:completion:)`
2. 初始化所有 spine 项页数为 `[1, 1, ..., 1]`
3. 固定版式直接返回(每 spread = 1 页)
4. 顺序遍历可渲染的 HTML/XHTML spine 项
5. 对每项:
- `webView.loadFileURL(...)` 加载文件
- `didFinish` 后启动多次测量(`scheduleMeasurementPass`
- 3 次测量延迟 [0ms, 80ms, 180ms]
- 每次执行 `measurementScript`:注入分页 CSS → 测量 `scrollWidth` → 返回 `Math.max(1, Math.ceil(totalWidth / viewportWidth))`
- 取多次测量的最大值
6. 全部完成后回调 `completion([Int])` 页数数组
7. `activeSessionID`UUID)防止过期回调污染结果
### 3.4 WebView 可重排内容加载
1. 入口:`RDEPUBWebView.loadPage(parser:spineIndex:pageIndex:...)`
2. 计算 `loadSignature`(包含 publication key、spine index、href、viewport、字号、行高、主题色、目标位置、高亮等)
3. 若签名与 `currentLoadSignature` 相同且已渲染/正在加载,跳过重复请求
4. 若签名匹配但文档 URL 已加载,仅调用 `applyPresentation()` 重新应用样式
5. 否则 `handleReflowableLoad(...)` 执行完整加载:
- 读取 HTML 内容
- 调用 `applyPresentationScript(for:)` 生成 JS
- JS 执行:`RDReaderBridge.applyPagination(css)``setPageMetrics` → 清除并重新应用高亮 → 滚动到目标位置 → 报告进度 → 应用搜索装饰
### 3.5 固定版式内容加载
1. 入口:`RDEPUBWebView.loadFixedSpread(parser:spread:...)`
2. `RDEPUBFixedLayoutTemplate.html(for:publication:)` 从模板生成 HTML,包含 spread 中每个资源的 `<iframe>`
3. `webView.loadHTMLString(html, baseURL: "ss-reader://book/")` 加载
4. 模板 JS 加载每个 iframe,检测页面尺寸,计算缩放(auto/page/width 适配模式),定位 iframe
5. 所有 iframe 加载完成后 JS 发送 `ssReaderFixedLayoutReady`
6. Swift 收到消息后应用搜索装饰并调用 `rendered()`
7. 1 秒兜底定时器确保即使 JS 消息丢失也能调用 `rendered()`
### 3.6 JS 桥接消息处理
- 入口:`RDEPUBWebView+JavaScriptBridge.swift``userContentController(_:didReceive:)`
- 分发逻辑:
- `progressionChanged` → 提取 progression/lastProgression/fragment → `delegate?.epubWebView(_:didUpdateLocation:spineIndex:)`
- `selectionChanged` → 提取 text/rangeInfo → 创建 `RDEPUBSelection``delegate?.epubWebView(_:didChangeSelection:spineIndex:)`
- `internalLink` → 创建 `RDEPUBLocation``delegate?.epubWebView(_:didActivateInternalLink:fromSpineIndex:)`
- `externalLink` → 提取 URL → `delegate?.epubWebView(_:didActivateExternalLink:)`
- `javaScriptError``delegate?.epubWebView(_:didLogJavaScriptError:)`
- `fixedLayoutReady` → 应用搜索装饰 → `rendered()`
### 3.7 链接导航拦截
- 入口:`decidePolicyFor navigationAction`
- 逻辑:
- `ss-reader://` URL + `.linkActivated` → 内部链接代理回调,取消导航
- `http/https/mailto/tel` → 外部链接代理回调,取消导航
- 其他 → 允许导航
## 4. 异常与边界处理
- `RDEPUBParserError`7 种,全部 `LocalizedError`,中文描述):
- `archiveOpenFailed(URL)`ZIPFoundation 无法打开 EPUB 文件
- `missingContainerXML``META-INF/container.xml` 不存在
- `missingRootFile`container.xml 中无 `<rootfile>`
- `invalidRootFilePath(String)`rootfile 路径对应的 OPF 文件不存在
- `invalidXML(URL)`:XMLParser 无法创建或解析失败
- `missingManifestItem(idref:)`spine 引用了不存在的 manifest 项
- `emptySpine`:构建后 spine 为空
- 目录解析:NCX 和 Nav Document 解析器失败时返回空数组,不抛异常,兜底使用 spine 标题
- 资源缺失:`RDEPUBResourceURLSchemeHandler` 对缺失的可选资源(字体/图片/CSS/JS)返回空 200,避免 WKWebView 控制台噪音
- 分页失败:`RDEPUBPaginator` 导航失败(`didFail` / `didFailProvisional`)时将页数设为 1 并继续下一项
- HTML 转纯文本兜底:`RDEPUBHTMLSearchEngine` 在 NSAttributedString 失败时回退到正则去标签
- 固定版式 1 秒兜底:`scheduleFixedLayoutReadyFallback()` 确保即使 JS ready 消息丢失也能调用 `rendered()`
- 重复请求跳过:`handleReflowableLoad` 在 loadSignature 匹配且已渲染/正在加载时跳过
## 5. 数据结构与字段映射
### 5.1 EPUB 元数据
```
RDEPUBParser
├── metadata: RDEPUBMetadata
│ ├── identifier, title, author, language, version
│ ├── layout: RDEPUBLayout (.reflowable | .fixed)
│ ├── readingProgression: RDEPUBReadingProgression (.ltr | .rtl | .auto)
│ └── spread: String?
├── manifest: [String: RDEPUBManifestItem]
│ ├── id, href, mediaType
│ ├── properties: [String] (nav, scripted, cover-image, rendition:layout-*)
│ └── title: String?
├── spine: [RDEPUBSpineItem]
│ ├── idref -> manifest[id]
│ ├── href (normalized), mediaType, title, linear
│ ├── pageSpread: RDEPUBPageSpread? (.left | .right | .center)
│ └── layout: RDEPUBLayout? (per-item override)
└── tableOfContents: [EPUBTableOfContentsItem]
├── title, href
└── children: [EPUBTableOfContentsItem] (recursive)
```
### 5.2 阅读位置模型
```
RDEPUBLocation
├── bookIdentifier, href
├── progression: Double? (0..1,章节内起始位置)
├── lastProgression: Double? (0..1,章节内结束位置)
├── fragment: String? (#anchor)
└── navigationProgression (computed: midpoint of progression and lastProgression)
```
### 5.3 视口模型
```
RDEPUBViewport
├── resources: [RDEPUBViewportResource]
│ ├── href, spineIndex
│ ├── progression, lastProgression, fragment
├── visiblePageNumber, chapterIndex
└── isFixedLayout: Bool
```
### 5.4 渲染请求
```
RDEPUBRenderRequest (enum)
├── .reflowable(RDEPUBReflowableRenderRequest)
│ ├── spineIndex, href, pageIndex, totalPagesInChapter
│ ├── presentation: RDEPUBPresentationStyle
│ │ ├── viewportSize, contentInsets
│ │ ├── fontSize, lineHeightMultiple
│ │ └── themeBackgroundColor, themeTextColor
│ ├── targetLocation: RDEPUBLocation?
│ ├── highlights: [RDEPUBHighlight]
│ └── searchPresentation: RDEPUBSearchPresentation?
└── .fixed(RDEPUBFixedRenderRequest)
├── spread: EPUBFixedSpread
│ └── resources: [EPUBFixedSpreadResource]
├── viewportSize, contentInset
├── backgroundColorCSS
├── fit: RDEPUBFixedLayoutFit (.auto | .page | .width)
└── searchPresentation: RDEPUBSearchPresentation?
```
### 5.5 高亮与选择
```
RDEPUBHighlight
├── id, bookIdentifier
├── location: RDEPUBLocation
├── text, rangeInfo (JSON-serialized DOM range)
├── color: String, note: String
└── createdAt: Date
RDEPUBSelection
├── bookIdentifier
├── location: RDEPUBLocation
├── text, rangeInfo
└── createdAt: Date
```
### 5.6 搜索模型
```
RDEPUBSearchState
├── keyword: String
├── matches: [RDEPUBSearchMatch]
│ ├── href, progression
│ ├── previewText, localMatchIndex
│ ├── rangeLocation, rangeLength
└── currentMatchIndex: Int
```
## 6. 分页与渲染机制
### 6.1 CSS 多栏分页原理
- HTML 内容被包裹为 `body > div#ss-reader-viewport > div#ss-reader-content`
- `#ss-reader-content` 应用 `-webkit-column-width: <viewportWidth>px`
- `html` / `body` 设置 `overflow: hidden`
- 页间导航通过 `transform: translate3d(-N*stride, 0, 0)` 实现
### 6.2 重新布局触发条件
- `scheduleRelayout()`60ms 防抖)在以下事件触发:
- `window.load``window.resize`
- `document.fonts.ready`
- 每个 `<img>` / `<iframe>` / `<video>` 的 load/error 事件
### 6.3 选择变化处理
- `selectionchange` 监听器(120ms 防抖)发送 `ssReaderSelectionChanged` 消息,携带 text、rangeInfo、progression
## 7. 缓存策略
- **EPUB 解压缓存**:解压目录按 `文件大小 + 修改时间` 生成缓存 key,已存在则跳过解压
- **Load Signature 去重**:每次渲染请求计算签名(含 publication key、layout、spine index、viewport、字号、行高、主题色、目标位置、高亮),相同签名跳过重复加载
- **WKWebView 复用**:同一 publication 配置下复用已有 WKWebView 实例,仅切换 publication 时才 teardown
- **非持久化数据存储**`WKWebViewConfiguration().websiteDataStore = .nonPersistent()`,不缓存 Web 内容到磁盘
## 8. 联调与排查建议
- 排查 1EPUB 解析失败
- 检查 `parse(epubURL:)` 抛出的 `RDEPUBParserError` 具体类型
- 检查 EPUB 文件是否损坏(ZIP 能否正常打开)
- 检查 `META-INF/container.xml` 是否存在且格式正确
- 排查 2:目录为空
- 检查 OPF 中是否有 NCX 或 Nav Document 引用
- 检查 NCX 文件路径是否正确(相对于 OPF 目录)
- 目录解析失败时会兜底使用 spine 标题
- 排查 3:页面内容空白
- 检查 `ss-reader://` scheme handler 是否正确映射资源路径
- 检查 `RDEPUBResourceURLSchemeHandler` 日志(`RDEPUBWebViewDebug`
- 确认 HTML 文件在解压目录中实际存在
- 排查 4:分页页数不正确
- 检查 `RDEPUBPaginator` 多次测量结果是否一致
- 检查 viewport 尺寸计算是否正确(容器宽度 / pagesPerScreen
- 检查 CSS 分页样式是否正确注入
- 排查 5JS 桥接消息未收到
- 确认 `epub-bridge.js` 已正确注入(检查 `WKUserContentController`
- 检查 JS 控制台是否有错误
- 固定版式检查是否收到 `ssReaderFixedLayoutReady`,否则看 1 秒兜底定时器
- 排查 6:内部链接不跳转
- 检查链接 href 是否以 `ss-reader://` 开头
- 检查 `decidePolicyFor navigationAction` 中的链接类型判断
- 确认 `RDEPUBLocation` 构建是否正确(href + fragment
+521
View File
@@ -0,0 +1,521 @@
# EPUBTextRendering 模块代码级参考文档
> 最后更新:2026-06-18
---
## 1. 模块概述
`EPUBTextRendering` 是 ReadViewSDK 的**文本渲染层**,负责将 EPUB HTML 内容转换为原生 `NSAttributedString`,执行排版分页,并构建全书文本模型。它位于 EPUBCore 之上、EPUBUI 之下,是 `textReflowable` 阅读配置文件的核心引擎。
**文件清单(~30 个 Swift 文件):**
| 子系统 | 核心文件 | 职责 |
|--------|----------|------|
| **排版管线** | `Typesetter/RDEPUBTypesettingPipeline.swift` | HTML → 标记化 HTML 的多阶段管线 |
| **文本渲染** | `RDEPUBTextRenderer.swift`, `RDEPUBDTCoreTextRenderer.swift` | HTML → NSAttributedString 转换 |
| **分页引擎** | `Pagination/*.swift` | CoreText 排版、页帧计算、分页策略 |
| **构建管线** | `BuildPipeline/*.swift` | 全书构建(渲染 → 分页 → 缓存) |
| **搜索** | `RDEPUBTextSearchEngine.swift` | 基于原生文本的全文搜索 |
| **索引** | `RDEPUBTextIndexTable.swift` | 全书字符偏移索引、CFI 映射 |
| **位置转换** | `RDEPUBTextPositionConverter.swift` | 字符偏移 ↔ 页码转换 |
| **章节数据** | `RDEPUBChapterData.swift` | 章节级数据访问封装 |
---
## 2. 排版管线(Typesetter
### 2.1 RDEPUBTextTypesettingPipeline
**文件:** `Typesetter/RDEPUBTypesettingPipeline.swift`
多阶段 HTML 处理管线,将原始 HTML 转换为可渲染的标记化 HTML。
```swift
struct RDEPUBTextTypesetterPipeline {
func makeRequest(from input: RDEPUBTypesettingInput) -> RDEPUBTypesettingOutput
}
```
**管线阶段:**
```
原始 HTML
RDEPUBHTMLNormalizer → 清理 HTML(移除脚本、修复标签)
RDEPUBSemanticMarkerInjector → 注入语义标记(段落、列表、表格等)
RDEPUBCFIMarkerInjector → 注入 CFI 定位标记
RDEPUBStyleSheetComposer → 合并样式表(内联 CSS + 兼容性处理)
RDEPUBFontNormalizer → 注册嵌入字体、字体回退
RDEPUBFragmentMarkerInjector → 注入片段标记(用于锚点定位)
标记化 HTML + 诊断报告
```
### 2.2 管线输入/输出
```swift
struct RDEPUBTypesettingInput {
var href: String // href
var spineIndex: Int?
var title: String //
var rawHTML: String // HTML
var baseURL: URL? // URL
var style: RDEPUBTextRenderStyle //
var resourceResolver: RDEPUBResourceResolver?
var contentLanguageCode: String?
var pageSize: CGSize?
var layoutConfig: RDEPUBTextLayoutConfig?
}
struct RDEPUBTypesettingOutput {
var request: RDEPUBTextChapterRenderRequest //
var diagnostics: [RDEPUBTextResourceReferenceDiagnostic] //
var styleCompatibilityReport: RDEPUBCSSCompatibilityReport // CSS
}
```
### 2.3 各阶段处理器
| 处理器 | 文件 | 职责 |
|--------|------|------|
| `RDEPUBHTMLNormalizer` | `RDEPUBHTMLNormalizer.swift` | 清理 HTML:移除 `<script>`、修复未闭合标签、规范化编码 |
| `RDEPUBSemanticMarkerInjector` | `RDEPUBSemanticMarkerInjector.swift` | 为块级元素注入 `data-rd-block-kind` 属性 |
| `RDEPUBCFIMarkerInjector` | `RDEPUBCFIMarkerInjector.swift` | 为文本节点注入 `data-rd-cfi` 属性 |
| `RDEPUBStyleSheetComposer` | `RDEPUBStyleSheetComposer.swift` | 合并 EPUB 原始 CSS + SDK 样式 + 兼容性补丁 |
| `RDEPUBFontNormalizer` | `RDEPUBFontNormalizer.swift` | 检测并注册 `@font-face` 嵌入字体 |
| `RDEPUBFragmentMarkerInjector` | `RDEPUBFragmentMarkerInjector.swift` | 为 `id` 属性注入片段偏移标记 |
| `RDEPUBAttachmentNormalizer` | `RDEPUBAttachmentNormalizer.swift` | 规范化 `<img>``<svg>` 等附件元素 |
| `RDEPUBRenderDiagnosticsCollector` | `RDEPUBRenderDiagnosticsCollector.swift` | 收集渲染诊断信息(缺失资源、不支持的特性) |
### 2.4 CSS 兼容性层
**文件:** `Typesetter/Compatibility/`
| 文件 | 职责 |
|------|------|
| `RDEPUBCSSCompatibilityLayer.swift` | CSS 兼容性补丁(webkit 前缀、不支持属性替换) |
| `RDEPUBFontFallbackResolver.swift` | 字体回退链解析 |
| `RDEPUBStyleCompatibilityModels.swift` | 兼容性报告模型 |
---
## 3. 文本渲染器
### 3.1 RDEPUBTextRenderer(协议)
**文件:** `RDEPUBTextRenderer.swift`
```swift
public protocol RDEPUBTextRenderer {
/// HTML NSAttributedString
func render(html: String, baseURL: URL?, style: RDEPUBTextRenderStyle) -> NSAttributedString
}
```
### 3.2 RDEPUBDTCoreTextRenderer
**文件:** `RDEPUBDTCoreTextRenderer.swift`
基于 DTCoreText 的渲染器实现,将 HTML 转换为 `NSAttributedString`
```swift
public final class RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
public init()
public func render(html: String, baseURL: URL?, style: RDEPUBTextRenderStyle) -> NSAttributedString
}
```
### 3.3 渲染样式
```swift
public struct RDEPUBTextRenderStyle {
public var font: UIFont //
public var lineSpacing: CGFloat //
public var textColor: UIColor? //
public var backgroundColor: UIColor? //
}
```
### 3.4 NSAttributedString 扩展键
```swift
extension NSAttributedString.Key {
static let rdPageBlockRange //
static let rdPageBlockIndex //
static let rdPageFragmentID // ID
static let rdPageAttachmentKind //
static let rdPageBlockKind // paragraph/list/table/code/blockquote/attachment/generic
static let rdPageSemanticHints // avoidPageBreakInside/keepWithNext/pageBreakBefore/After
static let rdPageAttachmentPlacement // inline/baseline/centered
}
```
---
## 4. 分页引擎
### 4.1 RDEPUBTextLayouter
**文件:** `Pagination/RDEPUBTextLayouter.swift`
分页的核心入口,组合 `RDEPUBCoreTextPageFrameFactory``RDEPUBChapterPageCounter`
```swift
struct RDEPUBTextLayouter {
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default)
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame]
}
```
### 4.2 RDEPUBTextLayoutConfig
```swift
public struct RDEPUBTextLayoutConfig: Equatable {
public var frameWidth: CGFloat //
public var frameHeight: CGFloat //
public var edgeInsets: UIEdgeInsets //
public var numberOfColumns: Int //
public var columnGap: CGFloat //
public var avoidOrphans: Bool //
public var avoidWidows: Bool //
public var avoidPageBreakInsideEnabled: Bool //
public var hyphenation: Bool //
public static let `default`: RDEPUBTextLayoutConfig
}
```
### 4.3 RDEPUBCoreTextPageFrameFactory
**文件:** `Pagination/RDEPUBCoreTextPageFrameFactory.swift`
使用 CoreText 排版引擎,将 `NSAttributedString` 排列到指定尺寸的帧中。
```swift
struct RDEPUBCoreTextPageFrameFactory {
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig)
func makeFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame]
}
```
### 4.4 RDEPUBChapterPageCounter
**文件:** `Pagination/RDEPUBChapterPageCounter.swift`
基于 `RDEPUBCoreTextPageFrameFactory` 计算页范围。
```swift
struct RDEPUBChapterPageCounter {
init(factory: RDEPUBCoreTextPageFrameFactory)
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame]
}
```
### 4.5 RDEPUBTextLayoutFrame
**文件:** `Pagination/RDEPUBTextLayoutFrame.swift`
表示一个排版帧(一页的内容范围)。
```swift
public struct RDEPUBTextLayoutFrame {
public let range: NSRange //
public let frameIndex: Int //
public let diagnostics: [String] //
}
```
### 4.6 RDEPUBPageBreakPolicy
**文件:** `Pagination/RDEPUBPageBreakPolicy.swift`
分页策略,决定在何处断页。
```swift
struct RDEPUBPageBreakPolicy {
///
func canBreak(at offset: Int, in attributedString: NSAttributedString) -> Bool
}
```
---
## 5. 构建管线
### 5.1 RDEPUBTextBookBuilder
**文件:** `BuildPipeline/RDEPUBTextBookBuilder.swift`
全书构建的核心类,串联渲染、分页、缓存和诊断。
```swift
public final class RDEPUBTextBookBuilder {
public init(renderer: RDEPUBTextRenderer, cache: RDEPUBTextBookCache?, layoutConfig: RDEPUBTextLayoutConfig)
///
public func build(
parser: RDEPUBParser,
publication: RDEPUBPublication,
pageSize: CGSize,
style: RDEPUBTextRenderStyle
) throws -> RDEPUBTextBook
//
public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic]
public private(set) var lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample]
public private(set) var lastBuildCacheStats: (hits: Int, misses: Int)
}
```
**构建流程:**
```
RDEPUBParser + RDEPUBPublication
▼ 遍历 spinelinear 章节)
├── 检查缓存(RDEPUBPaginationCacheCoordinator
│ ├── 命中 → 直接使用缓存的分页结果
│ └── 未命中 → 执行渲染 + 分页
├── RDEPUBChapterRenderPipeline(渲染单章)
│ └── RDEPUBTextTypesetterPipeline → RDEPUBTextRenderer
├── RDEPUBChapterPaginationPipeline(分页单章)
│ └── RDEPUBTextLayouter → [RDEPUBTextLayoutFrame]
├── RDEPUBChapterTailNormalizer(章节尾部规范化)
└── 组装 RDEPUBTextChapter + RDEPUBTextPage
RDEPUBTextBook(全书模型)
```
### 5.2 辅助组件
| 文件 | 职责 |
|------|------|
| `RDEPUBChapterRenderPipeline.swift` | 单章渲染管线 |
| `RDEPUBChapterPaginationPipeline.swift` | 单章分页管线 |
| `RDEPUBChapterTailNormalizer.swift` | 章节尾部空白处理 |
| `RDEPUBPaginationCacheCoordinator.swift` | 分页缓存协调 |
| `RDEPUBTextBookCache.swift` | 分页缓存存储 |
| `RDEPUBTextPerformanceSampler.swift` | 性能采样 |
| `RDEPUBBuildDiagnosticsReporter.swift` | 构建诊断报告 |
| `RDEPUBTextBuildPipelineInterfaces.swift` | 管线接口定义 |
---
## 6. 全书模型
### 6.1 RDEPUBTextBook
**文件:** `BuildPipeline/RDEPUBTextBookModels.swift`
全书文本模型,包含所有章节和页面。
```swift
public struct RDEPUBTextBook {
public var chapters: [RDEPUBTextChapter] //
public var pages: [RDEPUBTextPage] //
public let indexTable: RDEPUBTextIndexTable //
public var positionConverter: RDEPUBTextPositionConverter
//
public func chapterData(for href: String) -> RDEPUBChapterData?
public func chapterData(forSpineIndex spineIndex: Int) -> RDEPUBChapterData?
public func chapterData(atChapterIndex index: Int) -> RDEPUBChapterData?
public func chapterData(forPageNumber pageNumber: Int) -> RDEPUBChapterData?
//
public func page(at pageNumber: Int) -> RDEPUBTextPage?
public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int?
public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation?
}
```
### 6.2 RDEPUBTextChapter
```swift
public struct RDEPUBTextChapter: Equatable {
public var chapterIndex: Int //
public var spineIndex: Int // spine
public var href: String // href
public var title: String //
public var attributedContent: NSAttributedString //
public var fragmentOffsets: [String: Int] // ID
public var cfiMap: RDEPUBCFIMap? // CFI
public var pageBreakReasons: [RDEPUBTextPageBreakReason] //
public var pages: [RDEPUBTextPage] //
}
```
### 6.3 RDEPUBTextPage
```swift
public struct RDEPUBTextPage: Equatable {
public var absolutePageIndex: Int //
public var chapterIndex: Int //
public var spineIndex: Int // spine
public var href: String // href
public var chapterTitle: String //
public var pageIndexInChapter: Int //
public var totalPagesInChapter: Int //
public var chapterContent: NSAttributedString //
public var content: NSAttributedString //
public var contentRange: NSRange //
public var pageStartOffset: Int //
public var pageEndOffset: Int //
public var metadata: RDEPUBTextPageMetadata //
}
```
---
## 7. 索引与位置转换
### 7.1 RDEPUBTextIndexTable
**文件:** `RDEPUBTextIndexTable.swift`
全书字符偏移索引,支持快速查找。
```swift
public struct RDEPUBTextIndexTable {
public let chapterStartOffsets: [Int] //
public let chapterLengths: [Int] //
public let hrefToChapterIndex: [String: Int] // href
public let hrefToFileIndex: [String: Int] // href
public let fragmentOffsetsByHref: [String: [String: Int]] //
public let fileRowColumnMap: [Int: [RDEPUBRowColumnIndex]] //
public let fileCFIMap: [Int: RDEPUBCFIMap] // CFI
public var totalCharacterCount: Int //
public init(chapters: [RDEPUBTextChapter])
}
```
### 7.2 RDEPUBTextPositionConverter
**文件:** `RDEPUBTextPositionConverter.swift`
字符偏移 ↔ 页码双向转换。
```swift
public struct RDEPUBTextPositionConverter {
init(book: RDEPUBTextBook)
///
public func pageNumber(for offset: Int) -> Int?
///
public func offsetRange(forPageNumber pageNumber: Int) -> NSRange?
}
```
### 7.3 RDEPUBChapterData
**文件:** `RDEPUBChapterData.swift`
章节级数据访问封装。
```swift
public struct RDEPUBChapterData {
public let chapter: RDEPUBTextChapter
public let indexTable: RDEPUBTextIndexTable
///
public func pageNumber(for location: RDEPUBLocation) -> Int?
///
public func location(for page: RDEPUBTextPage, bookIdentifier: String?) -> RDEPUBLocation?
///
public func rangeAnchor(for range: NSRange) -> RDEPUBRangeAnchor
}
```
---
## 8. 搜索引擎
**文件:** `RDEPUBTextSearchEngine.swift`
基于原生 `NSAttributedString` 的搜索引擎,比 HTML 搜索更高效。
```swift
final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
init(textBook: RDEPUBTextBook, publication: RDEPUBPublication)
func search(keyword: String) -> [RDEPUBSearchMatch]
}
```
搜索结果包含:href、progression、previewText、rangeAnchor、cfi 等定位信息。
---
## 9. 纯文本构建器
**文件:** `RDEpubPlainTextBookBuilder.swift`
从纯文本(.txt)文件构建 `RDEPUBTextBook`,用于支持 TXT 格式阅读。
---
## 10. 设计模式总结
| 模式 | 应用 |
|------|------|
| **管线模式** | `RDEPUBTextTypesettingPipeline` 多阶段 HTML 处理 |
| **策略模式** | `RDEPUBTextRenderer` 协议,`RDEPUBDTCoreTextRenderer` 实现 |
| **Builder 模式** | `RDEPUBTextBookBuilder` 全书构建 |
| **缓存协调** | `RDEPUBPaginationCacheCoordinator` 分页结果缓存 |
| **索引表** | `RDEPUBTextIndexTable` 全书字符偏移快速查找 |
| **关注点分离** | 渲染/分页/缓存/诊断各司其职 |
---
## 11. 数据流图
```
EPUB HTML 文件
RDEPUBTypesettingPipeline
│ HTMLNormalizer → SemanticMarker → CFI → StyleSheet → Font → Fragment
标记化 HTML + CSS
RDEPUBDTCoreTextRenderer
│ DTCoreText HTML → NSAttributedString
NSAttributedString(带语义属性)
RDEPUBTextLayouter
│ CoreText 排版 → 帧计算 → 分页策略
[RDEPUBTextLayoutFrame](页面范围列表)
RDEPUBTextChapter + RDEPUBTextPage
RDEPUBTextBook(全书模型 + 索引表)
```
-264
View File
@@ -1,264 +0,0 @@
# EPUBTextRendering 功能实现逻辑
## 1. 范围与目标
- 代码范围:`Sources/RDReaderView/EPUBTextRendering/`6 个 Swift 文件)
- 目标:说明文本渲染路径如何将 EPUB HTML 转换为 NSAttributedString、按字符范围分页、构建书籍模型,并支持全文搜索与 textReflowable 标注定位。
- 主链路关键词:`RDEPUBParser HTML -> DTCoreText 渲染 -> 片段标记注入/提取 -> CoreText 分页 -> RDEPUBTextBook -> 页面查找/位置转换`
- 适用范围:仅用于 `textReflowable` 阅读配置文件(纯文本可重排 EPUB,如小说)。固定版式和交互式 EPUB 使用 WebView 渲染路径。
## 2. 关键对象职责
### 2.1 渲染协议 `RDEPUBTextRenderer`
- 文件:`EPUBTextRendering/RDEPUBTextRenderer.swift`
- 职责:
- 定义单方法协议:`renderChapter(html:baseURL:style:) throws -> RDEPUBRenderedChapterContent`
- 作为渲染后端的抽象点,当前仅 `RDEPUBDTCoreTextRenderer` 一个实现
### 2.2 DTCoreText 渲染器 `RDEPUBDTCoreTextRenderer`
- 文件:`EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift`
- 职责:
- 实现 `RDEPUBTextRenderer` 协议
- 使用 `DTHTMLAttributedStringBuilder` 将 HTML 转为 NSAttributedString
- 渲染后处理:提取片段标记、统一字体/行间距/颜色
- DTCoreText 不可用时回退到纯文本渲染
### 2.3 渲染支持工具 `RDEPUBTextRendererSupport`
- 文件:`EPUBTextRendering/RDEPUBTextRendererSupport.swift`
- 职责:
- 片段标记注入:扫描 HTML 中带 `id` 属性的元素,在其前注入 `${id=<fragmentID>}` 不可见标记
- 片段偏移提取:从 NSAttributedString 中提取标记位置并删除标记
- 字体标准化:保留 HTML 中的粗体/斜体 trait,统一使用配置的基础字体族和字号
- 段落样式创建:强制应用配置的行间距和段间距
- 兜底渲染:DTCoreText 不可用时从原始 HTML 字符串生成纯文本 NSAttributedString
### 2.4 分页支持 `NSAttributedString.ss_pageRanges(size:)`
- 文件:`EPUBTextRendering/RDEPUBTextPaginationSupport.swift`
- 职责:
- 基于 CoreText framesetter 的分页
- 从位置 0 开始,反复创建 CTFrame 测量可见字符范围
- 返回 `[NSRange]`,每个 range 对应一页
- 安全保护:`visibleRange.length == 0` 时 break 防止死循环
### 2.5 书籍构建器 `RDEPUBTextBookBuilder`
- 文件:`EPUBTextRendering/RDEPUBTextBookBuilder.swift`
- 职责:
- 遍历 publication 的 linear spine 项(仅 HTML/XHTML 类型)
- 对每项:HTML 标准化 → 渲染 → 跳过空白封面/标题页 → 分页 → 构建 `RDEPUBTextPage` 数组
- 汇总所有章节为 `RDEPUBTextBook`
- 后台队列执行,主线程回调结果
### 2.6 全文搜索引擎 `RDEPUBTextSearchEngine`
- 文件:`EPUBTextRendering/RDEPUBTextSearchEngine.swift`
- 职责:
- 实现 `RDEPUBSearchEngine` 协议
- 在已渲染的 NSAttributedString 纯文本上执行线性搜索
- 返回 `RDEPUBSearchMatch` 数组(含 href、progression、预览文本、匹配位置)
## 3. 主流程(代码级)
### 3.1 完整渲染-分页流程
1. 入口:`RDEPUBTextBookBuilder.build(parser:publication:pageSize:style:)`
2. 遍历 `publication.spine``item.linear == true` 的项
3. 过滤仅处理 mediaType 包含 "html" 或 "xhtml" 的项
4. 读取 `parser.htmlString(forRelativePath: item.href)` 获取原始 HTML
5. HTML 标准化:
- 移除中文分页标记:`<hr lang="zh-CN">分页符</hr>`
- `\r` 替换为 `\n`
- 连续 `\n` 合并为单个
6. 检查是否跳过(空白且 href 包含 "cover" 或 "title"
7. 调用 `renderer.renderChapter(html:baseURL:style:)` 渲染
### 3.2 DTCoreText 渲染管线
1. 片段标记注入:`RDEPUBTextRendererSupport.injectFragmentMarkers(into: html)`
- 正则 `(<[^>]+\sid="([^"]+)"[^>]*>)` 匹配带 id 的元素
- 在匹配标签前插入 `${id=<fragmentID>}` 文本标记
2. UTF-8 编码为 Data
3. `DTHTMLAttributedStringBuilder(html:options:documentAttributes:)` 执行渲染
4. DTCoreText 选项:
- `NSTextSizeMultiplierDocumentOption: 1.0`
- `DTDefaultFontFamily` / `DTDefaultFontName` / `DTDefaultFontSize`:来自 `style.font`
- `DTDefaultLineHeightMultiplier``(font.lineHeight + lineSpacing) / max(font.lineHeight, 1)`
- `DTUseiOS6Attributes: true`:生成 UIKit 兼容属性
- `NSBaseURLDocumentOption`:资源相对路径解析
- `DTDefaultTextColor`:可选文本颜色
5. 后处理:
- 提取片段偏移并删除标记
- `normalizeReadingAttributes`:遍历每个属性 run,统一字体(保留粗/斜体 trait)、强制行间距/段间距、覆盖前景色
### 3.3 CoreText 分页算法
1.`NSAttributedString` 创建 `CTFramesetter`
2. 创建页面大小的 `CGPath`
3. 从 location = 0 开始循环:
- 创建 CTFramerange 从当前 location 到字符串末尾)
- `CTFrameGetVisibleStringRange(frame)` 获取可见字符数
- 追加 `NSRange(location: currentLocation, length: visibleRange.length)`
- location += visibleRange.length
4. 循环终止条件:`location + visibleRange.length >= attributedString.length`
5. 安全保护:`visibleRange.length == 0` 时 break
### 3.4 页面与位置互转
**Location → Page Number**`RDEPUBTextBook.pageNumber(for:resolver:bookIdentifier:)`):
1. 有 fragment 时:用 `fragmentOffsets` 查找字符偏移,再定位到对应页面
2. 无 fragment 时:用 `navigationProgression`progression 和 lastProgression 的中点)计算字符偏移
3. 遍历 pages 找到 `pageStartOffset <= offset < pageEndOffset` 的页面
**Page Number → Location**`RDEPUBTextBook.location(forPageNumber:bookIdentifier:)`):
1. 通过 page number 查找 `RDEPUBTextPage`
2. 计算 `progression = pageStartOffset / totalCharacters`
3. 计算 `lastProgression = pageEndOffset / totalCharacters`
4. 构建 `RDEPUBLocation`href + progression + lastProgression
### 3.5 标注定位与恢复
textReflowable 路径不复用 WebView 的 DOM range,而使用章节字符偏移作为精确锚点:
```json
{
"kind": "text-offset",
"href": "chapter.xhtml",
"start": 1234,
"end": 1260
}
```
1. `RDEPUBTextContentView` 保留当前 `RDEPUBTextPage`,从 `page.pageStartOffset` 将页内 `UITextView.selectedRange` 映射为章节全局 `start/end`
2. 生成 `RDEPUBSelection` 后回传 `RDEPUBReaderController`,共享层继续负责创建、去重、持久化、列表和跳转
3. 页面重绘时,`RDEPUBTextContentView` 仅消费 `kind == "text-offset"` 的 rangeInfo,计算当前页 `[pageStartOffset, pageEndOffset]` 与标注 `[start, end)` 的重叠
4. `highlight` 样式叠加 `.backgroundColor``underline` 样式叠加 `.underlineStyle``.underlineColor`
5. 字号、行高、主题变化导致重新分页后,标注仍以章节全局字符偏移恢复到新的页面切片
## 4. 异常与边界处理
- `RDEPUBTextRenderingError.htmlEncodingFailed`:HTML 字符串无法编码为 UTF-8 Data(极罕见)
- DTCoreText builder 返回 nil:回退到 `fallbackAttributedString`,生成纯文本(含 HTML 标签原文),降级体验
- 空白章节跳过:href 包含 "cover" 或 "title" 且渲染后文本为空的章节被跳过
- 分页零结果兜底:`pageRanges` 为空但 content 非空时,整个章节作为一页
- CoreText 零高度安全保护:`visibleRange.length == 0` 时 break 防止无限循环
- Token 取消:`paginationToken`(UUID)防止异步完成后的过期结果污染 UI
## 5. 数据结构与字段映射
### 5.1 渲染配置
```swift
RDEPUBTextRenderStyle
font: UIFont //
lineSpacing: CGFloat //
textColor: UIColor? // nil HTML
backgroundColor: UIColor? //
```
### 5.2 渲染输出
```swift
RDEPUBRenderedChapterContent
attributedString: NSAttributedString //
fragmentOffsets: [String: Int] // fragment ID ->
```
### 5.3 页面模型
```swift
RDEPUBTextPage
absolutePageIndex: Int //
chapterIndex: Int //
spineIndex: Int // spine
href: String // href
chapterTitle: String? //
pageIndexInChapter: Int //
totalPagesInChapter: Int //
content: NSAttributedString //
contentRange: NSRange // range
pageStartOffset: Int //
pageEndOffset: Int //
```
### 5.4 章节模型
```swift
RDEPUBTextChapter
chapterIndex: Int
spineIndex: Int
href: String
title: String?
attributedContent: NSAttributedString //
fragmentOffsets: [String: Int]
pages: [RDEPUBTextPage]
```
### 5.5 书籍模型
```swift
RDEPUBTextBook
chapters: [RDEPUBTextChapter]
pages: [RDEPUBTextPage] //
page(at:) -> RDEPUBTextPage? // 1-based
pageNumber(for:resolver:bookIdentifier:) -> Int? // Location ->
location(forPageNumber:bookIdentifier:) -> RDEPUBLocation? // -> Location
```
## 6. 与 EPUBCore 层的集成
### 消费的数据
| 来源 | 数据 | 用途 |
|------|------|------|
| `RDEPUBParser` | `htmlString(forRelativePath:)` | 读取章节 HTML 内容 |
| `RDEPUBParser` | `fileURL(forRelativePath:)` | 计算 DTCoreText 的 baseURL |
| `RDEPUBPublication` | `spine` | 遍历 linear HTML/XHTML 项 |
| `RDEPUBPublication` | `tableOfContents` | 匹配章节标题 |
| `RDEPUBSpineItem` | `href`, `mediaType`, `linear`, `title` | 过滤和标识章节 |
| `RDEPUBResourceResolver` | `normalizedLocation` / `normalizedHref` | 位置查找时标准化 href |
| `RDEPUBLocation` | `href`, `fragment`, `navigationProgression` | 页面定位 |
| `RDEPUBSearchEngine` | 协议接口 | `RDEPUBTextSearchEngine` 实现此协议 |
| `RDEPUBHighlight` | `style`, `rangeInfo`, `color`, `note` | text-offset 标注恢复与页内绘制 |
## 7. 配置参数
- **字号**`RDEPUBReaderConfiguration.fontSize`,默认 15pt
- **行高倍数**`lineHeightMultiple`,默认 1.6(行间距 = font.lineHeight * 0.6,最低 4pt
- **内容边距**`reflowableContentInsets`,默认 (40, 16, 40, 16),影响有效页面尺寸
- **主题色**:通过 `RDEPUBReaderTheme``contentTextColor` / `contentBackgroundColor` 传入
- **渲染引擎**`RDEPUBTextRenderingEngine`,当前仅 `.dtCoreText`
## 8. 性能特征
- **全文渲染**:每章一次性渲染为完整的 NSMutableAttributedString,无分块/懒加载
- **内存**`RDEPUBTextBook` 同时持有 chapters(含完整 attributedContent)和 pages(含子串切片),存在一定程度的内存重复
- **后台执行**`build` 方法在 `DispatchQueue.global(qos: .userInitiated)` 执行,不阻塞主线程
- **搜索性能**:线性扫描每章的 `attributedContent.string`,无索引,搜索时间与总文本量线性相关
- **无分页缓存**:字号/行高变化时全书重新渲染和分页,无增量更新
## 9. 联调与排查建议
- 排查 1:渲染后文本为空
- 检查 `parser.htmlString(forRelativePath:)` 是否返回非 nil
- 检查 spine 项的 mediaType 是否包含 "html" 或 "xhtml"
- 空白封面/标题页会被 `shouldSkipChapter` 跳过
- 排查 2:分页页数不正确
- 检查 `pageSize` 计算是否正确(viewport - contentInsets
- 检查 DTCoreText 行高倍数是否正确应用
- 检查 CoreText 分页是否有 `visibleRange.length == 0` 的异常情况
- 排查 3:字体/颜色不正确
- 检查 `RDEPUBTextRenderStyle` 配置
- 检查 `normalizeReadingAttributes` 是否正确保留粗/斜体 trait
- 检查 `foregroundColor` 覆盖逻辑
- 排查 4Fragment 定位失败
- 检查 `injectFragmentMarkers` 是否正确注入标记
- 检查 `extractFragmentOffsets` 是否正确提取偏移
- 确认标记在 DTCoreText 渲染后仍存在于 attributed string 中
- 排查 5:搜索结果不准确
- 搜索基于已渲染的纯文本(去 HTML 标签),非原始 HTML
- 确认 `attributedContent.string` 包含预期的文本内容
- 搜索为大小写不敏感的线性扫描
+731
View File
@@ -0,0 +1,731 @@
# EPUBUI 模块代码级参考文档
> 最后更新:2026-06-18
---
## 1. 模块概述
`EPUBUI` 是 ReadViewSDK 的**用户界面层**,位于架构最顶层。它包含阅读器控制器、协调器模式、章节运行时系统、设置面板、文本页面渲染和标注管理等子系统。
**文件清单(~60 个 Swift 文件):**
| 子系统 | 目录 | 核心文件 | 职责 |
|--------|------|----------|------|
| **阅读器控制器** | `EPUBUI/` | `RDEPUBReaderController*.swift` | 主控制器及其扩展 |
| **协调器** | `ReaderController/` | `RDEPUBReader*Coordinator.swift` | 各功能域协调器 |
| **运行时** | `ReaderController/` | `RDEPUBReaderRuntime.swift`, `RDEPUBReaderContext.swift` | 运行时状态与依赖 |
| **章节运行时** | `ReaderController/ChapterRuntime/` | `RDEPUBChapter*.swift` | 按需加载、缓存、页图 |
| **设置** | `Settings/` | `RDEPUBReaderSettings*.swift` | 配置、主题、设置面板 |
| **文本页面** | `TextPage/` | `RDEPUBTextContentView*.swift` | 原生文本渲染页面 |
| **UI 组件** | `EPUBUI/` | `RDEPUBReader*ToolView.swift` | 工具栏、搜索栏、目录 |
| **笔记弹层** | `Notes/` | `RDEPUBNotePopup*.swift` | 脚注弹层 |
| **WebView** | `EPUBUI/` | `RDEPUBWebContentView.swift` | WebView 内容页面 |
---
## 2. 阅读器控制器
### 2.1 RDEPUBReaderController
**文件:** `RDEPUBReaderController.swift` + 扩展文件
主控制器,`UIViewController` 子类,是整个阅读器的入口。
```swift
public final class RDEPUBReaderController: UIViewController {
public weak var delegate: RDEPUBReaderDelegate?
public var configuration: RDEPUBReaderConfiguration // /
public var currentLocation: RDEPUBLocation? //
public var currentPageNumber: Int? // 1-based
public var currentSelection: RDEPUBSelection? //
public var highlights: [RDEPUBHighlight] //
public var bookmarks: [RDEPUBBookmark] //
public var annotations: [RDEPUBAnnotation] // +
public var tableOfContents: [EPUBTableOfContentsItem] //
public var flattenedTableOfContents: [RDEPUBReaderTableOfContentsItem] //
let epubURL: URL // EPUB
let persistence: RDEPUBReaderPersistence? //
let dependencies: RDEPUBReaderDependencies //
let readerView = RDEpubReaderView() //
}
```
**扩展文件职责:**
| 文件 | 职责 |
|------|------|
| `RDEPUBReaderController+PublicAPI.swift` | 公开 API(跳转、搜索、标注、书签) |
| `RDEPUBReaderController+DataSource.swift` | `RDEpubReaderPageProvider` 实现 |
| `RDEPUBReaderController+ContentDelegates.swift` | WebView/TextContentView 代理路由 |
| `RDEPUBReaderController+LocationResolution.swift` | 页码/位置解析、阅读状态同步 |
| `RDEPUBReaderController+ExternalLinks.swift` | 外部链接处理(白名单、确认弹窗) |
| `RDEPUBReaderController+AttachmentTooltip.swift` | 附件 alt 文本 tooltip 展示 |
| `RDEPUBReaderController+RenderSupport.swift` | 渲染辅助(WebView/TextContent 创建) |
| `RDEPUBReaderController+RuntimeBridge.swift` | Runtime 桥接 |
| `RDEPUBReaderController+TableOfContents.swift` | 目录处理 |
### 2.2 RDEPUBReaderDelegate
**文件:** `RDEPUBReaderDelegate.swift`
```swift
public protocol RDEPUBReaderDelegate: AnyObject {
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication)
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation)
func epubReaderDidReachEnd(_ reader: UIViewController)
func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?)
func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight])
func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark])
func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?)
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?)
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?)
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL)
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool
func epubReader(_ reader: UIViewController, didFailWithError error: Error)
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView)
}
```
所有方法均有默认空实现。
### 2.3 RDEPUBReaderPersistence
**文件:** `RDEPUBReaderPersistence.swift`
持久化协议,由宿主 App 实现。
```swift
public protocol RDEPUBReaderPersistence: AnyObject {
func loadLocation(for bookIdentifier: String) -> RDEPUBLocation?
func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String)
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark]
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String)
func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight]
func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String)
}
```
---
## 3. 运行时与上下文
### 3.1 RDEPUBReaderContext
**文件:** `ReaderController/RDEPUBReaderContext.swift`
阅读器的共享上下文,持有所有运行时状态。协调器通过 `unowned` 引用访问。
```swift
final class RDEPUBReaderContext {
weak var controller: RDEPUBReaderController?
weak var readerView: RDEpubReaderView?
var dependencies: RDEPUBReaderDependencies
var runtime: RDEPUBReaderRuntime?
//
var parser: RDEPUBParser?
var publication: RDEPUBPublication?
var readingSession: RDEPUBReadingSession?
var textBook: RDEPUBTextBook?
var bookPageMap: RDEPUBBookPageMap?
//
var activeBookmarks: [RDEPUBBookmark]
var activeHighlights: [RDEPUBHighlight]
var currentBookIdentifier: String?
var selectionState: RDEPUBSelectionState
var currentSelection: RDEPUBSelection?
//
var searchState: RDEPUBSearchState?
}
```
### 3.2 RDEPUBReaderDependencies
**文件:** `ReaderController/RDEPUBReaderDependencies.swift`
依赖注入容器,支持测试替身。
```swift
public struct RDEPUBReaderDependencies {
public var environment: any RDEPUBReaderDisplayEnvironment
public var makeParser: () -> RDEPUBParser
public var makePaginator: () -> RDEPUBPaginator
public var makeTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder
public var makePlainTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDEpubPlainTextBookBuilder
public var makeTextRenderer: (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer
public static var live: RDEPUBReaderDependencies //
}
```
### 3.3 RDEPUBReaderRuntime
**文件:** `ReaderController/RDEPUBReaderRuntime.swift`
运行时管理器,持有所有协调器和子系统。
```swift
final class RDEPUBReaderRuntime {
lazy var chapterRuntimeStore = RDEPUBChapterRuntimeStore()
lazy var summaryDiskCache: RDEPUBChapterSummaryDiskCache
lazy var chapterLoader: RDEPUBChapterLoader
lazy var pageResolver: RDEPUBPageResolver
lazy var loadCoordinator: RDEPUBReaderLoadCoordinator
lazy var paginationCoordinator: RDEPUBReaderPaginationCoordinator
lazy var locationCoordinator: RDEPUBReaderLocationCoordinator
lazy var searchCoordinator: RDEPUBReaderSearchCoordinator
lazy var chromeCoordinator: RDEPUBReaderChromeCoordinator
lazy var annotationCoordinator: RDEPUBReaderAnnotationCoordinator
lazy var viewportMonitor: RDEPUBReaderViewportMonitor
lazy var jumpSessionManager: RDEPUBJumpSessionManager
lazy var backgroundPriorityManager: RDEPUBBackgroundPriorityManager
lazy var backgroundCoverageStore: RDEPUBBackgroundCoverageStore
lazy var reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
var isSettingsPanelOpen: Bool
var needsFullRepaginationAfterSettingsClose: Bool
}
```
---
## 4. 协调器子系统
采用 **Coordinator 模式**,每个功能域由独立的协调器管理。
### 4.1 RDEPUBReaderLoadCoordinator
**文件:** `ReaderController/RDEPUBReaderLoadCoordinator.swift`
负责初始加载流程:解析 EPUB → 创建 Publication → 恢复阅读位置。
```swift
final class RDEPUBReaderLoadCoordinator {
func startInitialLoadIfNeeded() //
func loadPublication() // EPUB
func applyParsedPublication(...) //
}
```
### 4.2 RDEPUBReaderPaginationCoordinator
**文件:** `ReaderController/RDEPUBReaderPaginationCoordinator.swift`
负责分页调度:WebView 分页 → 文本构建 → 页图生成。
```swift
final class RDEPUBReaderPaginationCoordinator {
func startPagination(...) //
func applyPageCounts(...) //
func repaginatePreservingCurrentLocation() //
}
```
### 4.3 RDEPUBReaderLocationCoordinator
**文件:** `ReaderController/RDEPUBReaderLocationCoordinator.swift`
负责位置管理:恢复位置、记录位置变化、目录跳转。
```swift
final class RDEPUBReaderLocationCoordinator {
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool, ...) -> Bool
func currentVisibleLocation() -> RDEPUBLocation?
func recordPageChangeIfNeeded()
}
```
### 4.4 RDEPUBReaderSearchCoordinator
**文件:** `ReaderController/RDEPUBReaderSearchCoordinator.swift`
负责搜索管理:执行搜索、导航到匹配项、清除搜索。
```swift
final class RDEPUBReaderSearchCoordinator {
func search(keyword: String)
func searchNext() -> Bool
func searchPrevious() -> Bool
func selectSearchMatch(at index: Int) -> Bool
func clearSearch()
}
```
### 4.5 RDEPUBReaderChromeCoordinator
**文件:** `ReaderController/RDEPUBReaderChromeCoordinator.swift`
负责 UI Chrome(工具栏、搜索栏)的创建和状态更新。
```swift
final class RDEPUBReaderChromeCoordinator {
func makeTopToolView() -> RDEPUBReaderTopToolView
func makeBottomToolView() -> RDEPUBReaderBottomToolView
func updateReaderChrome()
func toggleSearchBar()
func presentTableOfContents()
func presentSettings()
}
```
### 4.6 RDEPUBReaderAnnotationCoordinator
**文件:** `ReaderController/RDEPUBReaderAnnotationCoordinator.swift`
负责标注管理:高亮、书签的增删改查。
```swift
final class RDEPUBReaderAnnotationCoordinator {
func addHighlight(from selection: RDEPUBSelection?, color: String, note: String?) -> RDEPUBHighlight?
func removeHighlight(withID id: String)
func addBookmark(for location: RDEPUBLocation, ...) -> RDEPUBBookmark?
func removeBookmark(withID id: String)
func toggleBookmark() -> Bool
func updateCurrentSelection(_ selection: RDEPUBSelection?)
}
```
---
## 5. 章节运行时子系统
**目录:** `ReaderController/ChapterRuntime/`
按需加载章节、管理章节缓存、维护全书页图。
### 5.1 RDEPUBChapterRuntimeStore
**文件:** `ChapterRuntime/RDEPUBChapterRuntimeStore.swift`
章节数据的核心存储,管理内存缓存和窗口。
```swift
final class RDEPUBChapterRuntimeStore {
let chapterLoadQueue: DispatchQueue //
private(set) var currentSpineIndex: Int?
private(set) var windowSpineIndices: [Int] // spine
func chapterData(for spineIndex: Int) -> RDEPUBRuntimeChapter?
func insertChapter(_ chapter: RDEPUBRuntimeChapter)
func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int)
func evictableSpineIndices() -> [Int]
func evict(spineIndex: Int)
}
```
### 5.2 RDEPUBChapterLoader
**文件:** `ChapterRuntime/RDEPUBChapterLoader.swift`
章节按需加载器,支持优先级和磁盘摘要缓存。
```swift
final class RDEPUBChapterLoader {
enum LoadPriority {
case navigation //
case preview //
case prefetch //
}
func loadChapter(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore,
priority: LoadPriority,
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
)
}
```
**加载流程:**
1. 检查内存缓存(`RDEPUBChapterDataCache`
2. 检查页数缓存(`RDEPUBPageCountCache`
3. 检查磁盘摘要缓存(`RDEPUBChapterSummaryDiskCache`
4. 构建章节(排版 + 分页)
5. 写入缓存
### 5.3 RDEPUBChapterWindowCoordinator
**文件:** `ChapterRuntime/RDEPUBChapterWindowCoordinator.swift`
管理章节窗口(当前章 + 前后各 N 章),协调加载和驱逐。
```swift
final class RDEPUBChapterWindowCoordinator {
private(set) var currentSnapshot: RDEPUBChapterWindowSnapshot?
var onSnapshotChanged: ((RDEPUBChapterWindowSnapshot) -> Void)?
func openBook(at targetSpineIndex: Int, restoreChapterOffset: Int?)
func navigateToChapter(at spineIndex: Int)
}
```
### 5.4 RDEPUBBookPageMap
**文件:** `ChapterRuntime/RDEPUBBookPageMap.swift`
全书页图,映射绝对页码 ↔ spine 索引 + 本地页码。
```swift
struct RDEPUBBookPageMap {
let entries: [RDEPUBBookPageMapEntry]
let totalPages: Int
func absolutePageIndex(spineIndex: Int, localPageIndex: Int) -> Int?
func spineIndex(forAbsolutePage absolutePage: Int) -> Int?
func localPageIndex(forAbsolutePage absolutePage: Int) -> Int?
func chapterIndex(forSpineIndex spineIndex: Int) -> Int?
}
```
### 5.5 RDEPUBPageResolver
**文件:** `ChapterRuntime/RDEPUBPageResolver.swift`
将绝对页码解析为具体的页面数据。
```swift
final class RDEPUBPageResolver {
func resolvePage(absolutePageIndex: Int) -> RDEPUBResolvedPage?
}
struct RDEPUBResolvedPage {
let page: RDEPUBTextPage
let chapter: RDEPUBRuntimeChapter
let chapterIndex: Int
}
```
### 5.6 其他 ChapterRuntime 组件
| 文件 | 职责 |
|------|------|
| `RDEPUBRuntimeChapter.swift` | 运行时章节模型(包含分页后的页面列表) |
| `RDEPUBRuntimePageCount.swift` | 页数缓存模型 |
| `RDEPUBChapterDataCache.swift` | 章节数据内存缓存(NSCache) |
| `RDEPUBPageCountCache.swift` | 页数缓存 |
| `RDEPUBChapterCacheKey.swift` | 缓存键(pageSize + fontSize + lineHeight |
| `RDEPUBChapterSummaryDiskCache.swift` | 章节摘要磁盘缓存 |
| `RDEPUBChapterWindowSnapshot.swift` | 窗口快照(当前可见章节集合) |
| `RDEPUBChapterLocation.swift` | 章节位置模型 |
| `RDEPUBChapterOffsetMap.swift` | 章节偏移映射 |
| `RDEPUBBackgroundTrace.swift` | 后台任务追踪日志 |
| `RDEPUBMetadataParseWorker.swift` | 后台元数据解析 worker |
| `RDEPUBMetadataParseCancellationController.swift` | 解析取消控制器 |
---
## 6. 设置子系统
**目录:** `Settings/`
### 6.1 RDEPUBReaderConfiguration
**文件:** `Settings/RDEPUBReaderConfiguration.swift`
阅读器配置模型。
```swift
public struct RDEPUBReaderConfiguration: Equatable {
public var fontSize: CGFloat
public var lineHeightMultiple: CGFloat
public var fontChoice: RDEPUBReaderFontChoice
public var numberOfColumns: Int
public var columnGap: CGFloat
// ...
}
public enum RDEPUBReaderFontChoice: String, Codable, CaseIterable {
case system //
case serif //
case rounded //
case monospaced //
}
public enum RDEPUBTextRenderingEngine: Equatable {
case dtCoreText
}
```
### 6.2 RDEPUBReaderSettings
**文件:** `Settings/RDEPUBReaderSettings.swift`
设置状态管理。
### 6.3 RDEPUBReaderSettingsViewController
**文件:** `Settings/RDEPUBReaderSettingsViewController.swift`
设置面板 VC(字体大小、行高、字体选择、主题等)。
### 6.4 RDEPUBReaderTheme
**文件:** `Settings/RDEPUBReaderTheme.swift`
主题定义(日间/夜间/护眼等)。
---
## 7. 文本页面子系统
**目录:** `TextPage/`
原生文本渲染模式(`textReflowable`)的页面视图。
### 7.1 RDEPUBTextContentView
**文件:** `TextPage/RDEPUBTextContentView.swift`
文本内容视图,使用 CoreText 渲染 `NSAttributedString`
```swift
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
weak var delegate: RDEPUBTextContentViewDelegate?
func configure(page: RDEPUBTextPage, ...)
func applyHighlights(_ highlights: [RDEPUBHighlight])
func applySearchState(_ state: RDEPUBSearchState?)
}
```
### 7.2 RDEPUBTextPageRenderView
**文件:** `TextPage/RDEPUBTextPageRenderView.swift`
CoreText 渲染视图,绘制 `NSAttributedString` 到屏幕上。
### 7.3 RDEPUBTextAnnotationOverlay
**文件:** `TextPage/RDEPUBTextAnnotationOverlay.swift`
标注叠加层,绘制高亮和下划线。
### 7.4 RDEPUBSelectionOverlayView
**文件:** `TextPage/RDEPUBSelectionOverlayView.swift`
文本选择叠加层(选择手柄)。
### 7.5 RDEPUBTextSelectionController
**文件:** `TextPage/RDEPUBTextSelectionController.swift`
文本选择手势控制器。
### 7.6 RDEPUBPageInteractionController
**文件:** `TextPage/RDEPUBPageInteractionController.swift`
页面交互控制器(长按选择、点击标注等)。
### 7.7 RDEPUBPageLayoutSnapshot
**文件:** `TextPage/RDEPUBPageLayoutSnapshot.swift`
页面布局快照(用于选择定位)。
### 7.8 RDEPUBTextPageDecorationView
**文件:** `TextPage/RDEPUBTextPageDecorationView.swift`
页面装饰视图(页码、页眉等)。
---
## 8. UI 组件
### 8.1 工具栏
| 文件 | 职责 |
|------|------|
| `RDEPUBReaderTopToolView.swift` | 顶部工具栏(返回、搜索、书签) |
| `RDEPUBReaderBottomToolView.swift` | 底部工具栏(目录、书签、高亮、设置) |
| `RDEPUBReaderToolView.swift` | 工具栏基类 |
### 8.2 搜索栏
**文件:** `RDEPUBReaderSearchBarView.swift`
搜索输入栏(输入框 + 上一个/下一个 + 关闭)。
### 8.3 目录
**文件:** `RDEPUBReaderChapterListController.swift`
目录列表 VC,支持多级目录展开。
**文件:** `RDEPUBReaderTableOfContentsItem.swift`
目录项模型。
### 8.4 高亮管理
**文件:** `RDEPUBReaderHighlightsViewController.swift`
高亮列表 VC。
### 8.5 笔记弹层
**目录:** `Notes/`
| 文件 | 职责 |
|------|------|
| `RDEPUBNotePopupCoordinator.swift` | 脚注弹层协调器 |
| `RDEPUBNotePopupViewController.swift` | 脚注弹层 VC |
### 8.6 WebView 内容视图
**文件:** `RDEPUBWebContentView.swift`
WebView 模式的内容页面视图。
### 8.7 装饰叠加层
**文件:** `RDEPUBWebDecorationOverlayView.swift`
WebView 模式的装饰叠加层(高亮、搜索高亮)。
---
## 9. 状态模型
### 9.1 RDEPUBSelectionState
```swift
enum RDEPUBSelectionState: Equatable {
case idle //
case selecting(anchor: Int) //
case selected(RDEPUBSelection) //
case committingAction(RDEPUBSelection, action: RDEPUBAnnotationMenuAction) //
}
```
### 9.2 RDEPUBReaderUIState
```swift
struct RDEPUBReaderUIState {
let canToggleBookmark: Bool
let hasBookmarkAtCurrentLocation: Bool
let canShowBookmarks: Bool
let canAddHighlight: Bool
let canShowHighlights: Bool
let showsTableOfContents: Bool
let allowsHighlights: Bool
let showsSettingsPanel: Bool
}
```
### 9.3 RDEPUBViewportTypes
**文件:** `RDEPUBViewportTypes.swift`
视口相关模型。
---
## 10. 其他组件
### 10.1 RDEPUBReaderViewportMonitor
**文件:** `ReaderController/RDEPUBReaderViewportMonitor.swift`
视口变化监听器(旋转、尺寸变化)。
### 10.2 RDEPUBJumpSession
**文件:** `ReaderController/RDEPUBJumpSession.swift`
跳转会话管理(远距跳转时的加载状态)。
### 10.3 RDEPUBBackgroundPriorityPolicy
**文件:** `ReaderController/RDEPUBBackgroundPriorityPolicy.swift`
后台加载优先级策略。
### 10.4 RDEPUBBackgroundCoverageStore
**文件:** `ReaderController/RDEPUBBackgroundCoverageStore.swift`
后台加载覆盖范围追踪。
### 10.5 RDEPUBPageMapReconciliationCoordinator
**文件:** `ReaderController/RDEPUBPageMapReconciliationCoordinator.swift`
页图协调器(按需分页与全量分页的结果合并)。
### 10.6 RDEpubURLReaderController
**文件:** `RDEpubURLReaderController.swift`
URL 阅读器控制器(用于打开单个 URL)。
### 10.7 UIColor+RDEPUBHex
**文件:** `UIColor+RDEPUBHex.swift`
UIColor 十六进制扩展。
---
## 11. 设计模式总结
| 模式 | 应用 |
|------|------|
| **Coordinator 模式** | 7 个独立协调器分管各功能域 |
| **Context 模式** | `RDEPUBReaderContext` 共享上下文,避免循环依赖 |
| **依赖注入** | `RDEPUBReaderDependencies` 工厂方法注入 |
| **按需加载** | `RDEPUBChapterLoader` 章节级按需加载 |
| **窗口管理** | `RDEPUBChapterWindowCoordinator` 滑动窗口驱逐 |
| **快照管理** | `RDEPUBBookPageMap` 全书页图快照 |
| **状态机** | `RDEPUBSelectionState` 选择状态管理 |
| **磁盘缓存** | `RDEPUBChapterSummaryDiskCache` 章节摘要持久化 |
---
## 12. 数据流图
```
用户打开 EPUB
RDEPUBReaderController.init(epubURL:)
▼ viewDidLoad()
RDEPUBReaderLoadCoordinator.startInitialLoadIfNeeded()
├── RDEPUBParser.parse(epubURL:) → 解析 EPUB
├── RDEPUBPublication 创建
├── 恢复阅读位置(persistence.loadLocation
RDEPUBReaderRuntime.applyParsedPublication()
├── 根据 readingProfile 选择渲染模式
│ ├── webInteractive → WebView 分页 → RDEPUBPaginator
│ └── textReflowable → 文本构建 → RDEPUBTextBookBuilder
├── RDEPUBChapterWindowCoordinator.openBook()
│ └── RDEPUBChapterLoader.loadChapter() → 按需加载
├── RDEPUBBookPageMap 生成
└── RDEpubReaderView.transitionToPage() → 显示页面
用户翻页
RDEpubReaderView.currentPage 变化
├── RDEPUBReaderLocationCoordinator.recordPageChangeIfNeeded()
│ └── persistence.saveLocation()
├── RDEPUBChapterWindowCoordinator 检查是否需要加载新章节
└── RDEPUBReaderChromeCoordinator.updateReaderChrome()
```
-307
View File
@@ -1,307 +0,0 @@
# EPUBUI 功能实现逻辑
## 1. 范围与目标
- 代码范围:`Sources/RDReaderView/EPUBUI/`16 个 Swift 文件)
- 目标:说明 EPUBUI 如何作为开箱即用的阅读器 UI 层,协调 EPUBCore 解析、EPUBTextRendering 文本渲染、RDReaderView 分页容器,提供完整的阅读体验(工具栏、目录、高亮批注、设置面板、阅读位置持久化、搜索)。
- 主链路关键词:`RDEPUBReaderController.init -> 解析 EPUB -> 分页 -> 渲染 -> 用户交互(翻页/工具栏/设置/高亮/搜索)-> 持久化`
## 2. 关键对象职责
### 2.1 主控制器 `RDEPUBReaderController`
- 文件:`EPUBUI/RDEPUBReaderController.swift`~1255 行)
- 入口方法:`init(epubURL:configuration:persistence:)`
- 职责:
- 加载 EPUB:后台解析 → 应用分页 → 恢复阅读位置
- 实现 `RDReaderDataSource` / `RDReaderDelegate` 为容器提供数据
- 实现 `RDEPUBWebContentViewDelegate` 接收 WebView 事件
- 管理工具栏显隐、目录面板、高亮管理、设置面板、搜索
- 持久化阅读位置、高亮、用户设置
- 对外暴露 `RDEPUBReaderDelegate` 回调宿主 App
### 2.2 配置 `RDEPUBReaderConfiguration`
- 文件:`EPUBUI/RDEPUBReaderConfiguration.swift`
- 13 个配置项:
- `fontSize`(默认 15)、`lineHeightMultiple`(默认 1.6
- `displayType`(默认 .pageCurl)、`landscapeDualPageEnabled`(默认 true
- `showsTableOfContents`(默认 true)、`allowsHighlights`(默认 true)、`showsSettingsPanel`(默认 true
- `reflowableContentInsets`(默认 40/16/40/16)、`fixedContentInset`(默认 .zero
- `theme`(默认 .light
- `fixedLayoutFit`(默认 .page)、`fixedLayoutSpreadMode`(默认 .automatic
- `textRenderingEngine`(默认 .dtCoreText
- 变更检测:
- `requiresRepagination`fontSize / lineHeightMultiple / contentInsets / fixedLayoutFit / fixedLayoutSpreadMode / textRenderingEngine 变化 → 完整重新分页
- `requiresVisibleRefresh`:theme 变化 → 刷新可见内容(不重新分页)
### 2.3 委托协议 `RDEPUBReaderDelegate`
- 文件:`EPUBUI/RDEPUBReaderDelegate.swift`
- 10 个可选方法(全部有默认空实现):
- `didOpen`:成功打开出版物
- `didUpdateLocation`:翻页或滚动时位置更新
- `didReachEnd`:到达最后一页
- `didChangeSelection`:文本选择变化或清除
- `didUpdateHighlights`:高亮增删改
- `didUpdateSearchResult`:搜索状态变化
- `didChangeCurrentSearchMatch`:当前搜索匹配项变化
- `didUpdateCurrentTableOfContentsItem`:翻页时匹配的目录项
- `didActivateExternalLink`:外部链接点击
- `didFailWithError`:解析或分页错误
### 2.4 持久化 `RDEPUBReaderPersistence`
- 文件:`EPUBUI/RDEPUBReaderPersistence.swift`
- 协议定义 6 个方法:loadLocation / saveLocation / loadHighlights / saveHighlights / loadReaderSettings / saveReaderSettings
- 默认实现 `RDEPUBUserDefaultsPersistence`
- 位置:`ssreader.epub.location.<bookIdentifier>`JSON 编码)
- 高亮:`ssreader.epub.highlights.<bookIdentifier>`JSON 编码)
- 设置:`ssreader.epub.settings`(全局,非按书)
### 2.5 主题 `RDEPUBReaderTheme`
- 文件:`EPUBUI/RDEPUBReaderTheme.swift`
- 6 个颜色属性:contentBackgroundColor、contentTextColor、toolBackgroundColor、toolControlTextColor、toolControlBorderUnselectColor、toolLineColor
- 6 个内置预设:`.light``.dark``.yellow``.green``.pink``.blue`
- 计算属性 `themeBackgroundColorCSS` / `themeTextColorCSS` 用于 Web 渲染路径
### 2.6 设置面板 `RDEPUBReaderSettingsViewController`
- 文件:`EPUBUI/RDEPUBReaderSettingsViewController.swift`
- 5 个控制项:
- 亮度滑块(0-1
- 字号 A-/A+(范围 12-36,步长 1
- 行高分段(紧凑 1.3 / 标准 1.6 / 宽松 1.9
- 显示模式分段(仿真 / 横滑 / 竖滑 / 覆盖)
- 主题选择(6 个圆形色块按钮)
- 所有变更通过闭包实时回调
### 2.7 内容视图
- `RDEPUBTextContentView``EPUBUI/RDEPUBTextContentView.swift`):textReflowable 路径,UITextView 展示富文本,支持系统选区、标注菜单、用户标注叠加和搜索高亮叠加
- `RDEPUBWebContentView``EPUBUI/RDEPUBWebContentView.swift`):web 路径,包装 `RDEPUBWebView`,转发位置、选区、标注菜单、链接和 JS 错误事件
### 2.8 其他 UI 组件
- `RDEPUBReaderTopToolView`:顶部导航栏(返回按钮 + 标题)
- `RDEPUBReaderBottomToolView`:底部工具栏(目录 / 批注 / 标注 / 设置 4 个按钮)
- `RDEPUBReaderChapterListController`:目录列表(modal UITableViewController,当前项高亮 systemBlue
- `RDEPUBReaderHighlightsViewController`:标注管理(全部 / 批注 / 划线过滤,列表 + 编辑备注 + 删除 + 跳转,空态按过滤条件提示)
## 3. 主流程(代码级)
### 3.1 初始化与加载
1. `init(epubURL:configuration:persistence:)`
- 加载持久化的阅读设置,叠加到 configuration
- 恢复屏幕亮度
2. `viewDidLoad`
- 设置背景色
- `setupReaderView()`:添加 RDReaderView 全屏约束,注册 `RDEPUBTextContentView``RDEPUBWebContentView`
- `setupLoadingIndicator()``setupErrorLabel()`
3. `viewDidAppear`:调用 `startInitialLoadIfNeeded()`
4. 加载序列:
- `loadPublication()` → 读取持久化位置
- 后台 `RDEPUBParser.parse(epubURL:)` 解析
- 主线程 `applyParsedPublication()``paginatePublication()`
### 3.2 分页流程
`paginatePublication(restoreLocation:)` 根据 readingProfile 分三条路径:
**textReflowable 路径**
1. 后台队列:`RDEPUBTextBookBuilder.build(parser:publication:pageSize:style:)`
2. 主线程 `applyTextBook(textBook:)`:保存 textBookreloadData
**fixed layout 路径**
1. 创建快照:`pageCounts: Array(repeating: 1, count: publication.spine.count)`
2. `applyPaginationSnapshot(snapshot:restoreLocation:)`
**web interactive 路径**
1. `RDEPUBPaginator.calculate(parser:hostingView:presentation:completion:)`
2. 回调收到页数数组后 `applyPaginationSnapshot(snapshot:restoreLocation:)`
三条路径汇合 `finishPagination(restoreLocation:)`
- `readerView.reloadData()`
- `restoreReadingLocation(restoreLocation)`
### 3.3 页面内容工厂
`pageContentView(readerView:pageNum:containerView:)`
- 有 textBook → 出队/创建 `RDEPUBTextContentView`,调用 `configure(page:pageNumber:totalPages:configuration:searchState:)`
- 无 textBook → 出队/创建 `RDEPUBWebContentView`,调用 `configure(publication:request:pageNumber:totalPages:theme:)`
`pageIdentifier(readerView:pageNum:)`:返回对应类名字符串用于 cell 出队
### 3.4 工具栏交互
- RDReaderView 内部处理点击手势,中心 1/3 区域切换工具栏
- 顶部工具栏:返回按钮 pop/dismiss,标题设为 metadata.title
- 底部工具栏:4 个按钮按配置 flags 控制可见性
- 目录按钮 → present `RDEPUBReaderChapterListController`.pageSheet
- 批注按钮 → present `RDEPUBReaderHighlightsViewController`.pageSheet
- 标注按钮 → `presentAnnotationActionSheet(for:)`(需有 currentSelection,否则 disabled),可创建高亮、划线或带 note 的批注
- 设置按钮 → present `RDEPUBReaderSettingsViewController`.pageSheet
### 3.5 设置变更处理
- 字号变化:`configuration.fontSize = newValue``requiresRepagination``repaginatePreservingCurrentLocation()`
- 行高变化:同上
- 显示模式变化:`readerView.switchReaderDisplayType()` 重建容器
- 主题变化:`configuration.theme = newValue``requiresVisibleRefresh``refreshVisibleContentPreservingLocation()`
- 亮度变化:`UIScreen.main.brightness = value` + 持久化
### 3.6 标注管理
- 创建:`addAnnotation(from:style:color:note:)` → 从 currentSelection 构建 → 去重 → 追加到 activeHighlights → 持久化 → 刷新可见内容
- 兼容入口:`addHighlight(from:color:note:)` 仍保留,内部按 `.highlight` 创建
- 样式:`RDEPUBHighlightStyle.highlight` 使用背景色,`RDEPUBHighlightStyle.underline` 使用下划线;旧数据缺少 style 时默认按普通高亮解码
- 批注:note 非空时同一条 `RDEPUBHighlight` 同时承担批注语义
- 删除:`removeHighlight(id:)` → 移除 → 持久化 → 刷新
- 编辑备注:`updateHighlightNote(id:note:)` → 更新 → 持久化 → 刷新
- 跳转:`go(toHighlightID:animated:)` → 从高亮 location 解析页码 → `transitionToPage`
- WebView 选区:`epub-bridge.js` 生成 `dom-range` rangeInfoWeb 内容通过 payload 的 `style` 字段恢复高亮或划线
- DT 选区:`RDEPUBTextContentView` 把页内 selectedRange 映射为章节全局 text-offset rangeInfo,并在当前页与标注范围重叠时叠加背景或下划线
- 选区菜单:WebView 与 DT 路径都通过自定义菜单动作收口到 `拷贝 / 高亮 / 批注`,动作最终复用同一套创建与批注输入流程
### 3.7 书签管理
- 创建/切换:`toggleBookmark(note:)` 基于 `currentVisibleLocation()` 生成当前位置书签;若同一语义位置已存在书签则执行取消
- 位置范围:书签使用 `RDEPUBLocation`,持久化字段包含 `href / fragment / progression`,不依赖正文选区
- 当前位置命中:优先比较标准化后的 `href`,若双方都有 `fragment` 则直接比较;否则按 `navigationProgression` 容差判断(fixed-layout 0.01reflowable 0.05
- 标题补全:优先复用当前 TOC 项标题,否则根据扁平目录用 href 反查章节标题
- 列表管理:底部工具栏 `书签` 入口弹出 `RDEPUBReaderBookmarksViewController`,支持按创建时间倒序展示、跳转与删除
- 顶部状态:TopToolView 右上角书签按钮显示当前页是否已加书签,点击可直接添加/取消
- 持久化:每次新增/删除都会通过 `RDEPUBReaderPersistence.saveBookmarks(_:for:)` 按书保存,并回调 `didUpdateBookmarks`
### 3.8 搜索流程
1. `search(keyword:)`:标准化输入
2. textBook 路径:`RDEPUBTextSearchEngine.search(keyword:)`
3. web 路径:`RDEPUBHTMLSearchEngine.search(keyword:)`
4. 存储结果到 `searchState`,导航到第一个匹配
5. `searchNext()` / `searchPrevious()`:循环推进 currentMatchIndex 并导航
6. `clearSearch()`:清除状态,刷新可见内容
### 3.9 阅读位置持久化
**保存时机**
- 每次翻页:`pageNum(readerView:pageNum:)``persist(location:)`
- WebView 滚动:`epubWebContentView(_:didUpdateLocation:)``persist(location:)`
**恢复时机**
- 初始加载:`loadPublication()` 读取 → 传递到 `finishPagination()``restoreReadingLocation()`
- 显式导航:`go(to:)` / `go(toTableOfContentsHref:)` / `go(toHighlightID:)` / 搜索导航
**恢复逻辑**
- textBook 路径:`textBook.pageNumber(for:resolver:bookIdentifier:)``readerView.transitionToPage`
- readingSession 路径:`readingSession.queueNavigation(to:)``transitionToPage`
### 3.9 横竖屏适配
- `viewWillTransition(to:with:)`:等待旋转过渡完成后,统一触发 viewport 变化处理
- `viewDidLayoutSubviews`:用于补齐分屏、多窗口、safe area 变化等非旋转型视口变化
- 控制器内部使用 viewport signature 做去重,避免一次旋转触发多次正文重分页
- RDReaderView 的 `readerViewOrientationWillChange` 仅保留容器级双页布局和 pageCurl 重建职责,不再直接驱动 EPUBUI 重新分页
## 4. 异常与边界处理
- EPUB 解析失败:显示 errorLabeldelegate 收到 `didFailWithError`
- 分页失败:主线程 `handle(error:)` 处理
- `paginationToken` 防竞态:每次加载/分页生成新 UUID,后台完成时校验 token 一致才应用结果
- 标注去重:`addAnnotation` 检查相同 location、text、rangeInfo 和 style 是否已存在标注
- 自定义主题不可持久化:仅 6 个内置 preset 可序列化,自定义主题仅当前会话有效
- 目录页码缺失:`flattenedTableOfContents` 中 pageNumber 可能为 nil(无法定位到对应页面的 TOC 项)
## 5. 数据结构与字段映射
### 5.1 持久化键值
| 数据 | UserDefaults Key | 编码 |
|------|-----------------|------|
| 阅读位置 | `ssreader.epub.location.<bookIdentifier>` | JSON → `RDEPUBLocation` |
| 高亮列表 | `ssreader.epub.highlights.<bookIdentifier>` | JSON → `[RDEPUBHighlight]` |
| 书签列表 | `ssreader.epub.bookmarks.<bookIdentifier>` | JSON → `[RDEPUBBookmark]` |
| 阅读设置 | `ssreader.epub.settings` | JSON → `RDEPUBReaderSettings` |
### 5.2 Book Identifier
- 优先使用 `parser.metadata.identifier`
- 回退使用 `epubURL.lastPathComponent`
### 5.3 设置快照
```swift
RDEPUBReaderSettings (Codable)
brightness: CGFloat
fontSize: CGFloat
lineHeightMultiple: CGFloat
displayMode: RDEPUBReaderDisplayMode
themePreset: RDEPUBReaderThemePreset?
```
### 5.4 目录扁平化项
```swift
RDEPUBReaderTableOfContentsItem
title: String
href: String
depth: Int //
pageNumber: Int? // nil
```
## 6. 视图绑定规则
- 规则 1:内容视图通过 class name 注册到 RDReaderView,出队时根据 textBook 是否存在选择类型
- 规则 2RDEPUBTextContentView 展示 attributedText + 搜索高亮叠加 + 页码标签("N / M"
- 规则 3RDEPUBWebContentView 包装 RDEPUBWebView + 页码标签
- 规则 4:顶部工具栏固定提供返回与书签切换入口;底部工具栏提供目录、书签、标注、设置等管理入口
- 规则 5:设置面板所有变更实时生效(闭包回调),无需确认按钮
## 7. 通知协作与回调机制
### 外部通信(Delegate
- `RDEPUBReaderDelegate`:11 个可选方法,覆盖打开、位置更新、到达末尾、选择变化、高亮更新、书签更新、搜索更新、目录项更新、外部链接、错误
### 内部通信(闭包)
- `RDEPUBReaderTopToolView.onBack / onToggleBookmark`
- `RDEPUBReaderBottomToolView.onShowTableOfContents / onShowBookmarks / onShowHighlights / onAddHighlight / onShowSettings`
- `RDEPUBReaderChapterListController.onSelectItem`
- `RDEPUBReaderHighlightsViewController.onSelectHighlight / onUpdateHighlight / onDeleteHighlight`
- `RDEPUBReaderSettingsViewController.onBrightnessChange / onFontSizeChange / onLineHeightChange / onDisplayTypeChange / onThemeChange`
### 容器通信(协议)
- `RDReaderDataSource` / `RDReaderDelegate`RDEPUBReaderController 实现,为 RDReaderView 提供页面数据和接收页面变化事件
- `RDEPUBWebContentViewDelegate`RDEPUBReaderController 实现,接收 WebView 的位置更新、选择变化、链接激活、JS 错误
## 8. 联调与排查建议
- 排查 1:阅读器打开后空白
- 检查 `RDEPUBParser.parse(epubURL:)` 是否成功
- 检查 `startInitialLoadIfNeeded()` 是否被调用(需要 viewDidAppear
- 检查 errorLabel 是否显示
- 排查 2:翻页后位置不恢复
- 检查 `persistence.loadLocation(for:)` 是否返回非 nil
- 检查 `restoreReadingLocation` 是否在 `finishPagination` 中被调用
- textBook 路径检查 `textBook.pageNumber(for:)` 返回值
- 排查 3:设置变更后无效果
- 字号/行高变化需触发 `repaginatePreservingCurrentLocation()`
- 主题变化只需 `refreshVisibleContentPreservingLocation()`
- 检查 `requiresRepagination` / `requiresVisibleRefresh` 的判断逻辑
- 排查 4:高亮不显示
- 确认 `currentSelection` 非 nil
- 确认 `addHighlight` 成功返回
- 确认 `refreshVisibleContentPreservingLocation()` 被调用
- textBook 路径检查 `RDEPUBTextContentView.configure` 中的搜索高亮逻辑
- 排查 5:搜索无结果
- textBook 路径确认 `RDEPUBTextSearchEngine.search` 被调用
- web 路径确认 `RDEPUBHTMLSearchEngine.search` 被调用
- 检查搜索关键词是否为空
- 排查 6:横屏布局异常
- 确认 `landscapeDualPageEnabled` 是否为 true
- 检查 `viewDidLayoutSubviews` 是否触发了重新分页
- 检查 RDReaderView 的 orientation change 处理
-347
View File
@@ -1,347 +0,0 @@
# EPUB 阅读模块维护与排查指南
## 1. 文档目的
本文档说明 EPUB 阅读模块的关键文件职责、主要数据流、常见问题排查和后续扩展建议,供维护时快速建立上下文。
完整架构设计请参考 [ARCHITECTURE.md](ARCHITECTURE.md)。
编码规范请参考 [CODING_STYLE.md](CODING_STYLE.md)。
---
## 2. 关键文件速查
### 2.1 EPUBCore 层
| 文件 | 职责 |
|------|------|
| `RDEPUBParser.swift` + 扩展 | EPUB 解压、container.xml、OPF、manifest、spine、TOC/NCX 解析 |
| `RDEPUBPublication.swift` | 解析结果的唯一聚合入口,外部不直接访问 parser 字段 |
| `RDEPUBModels.swift` | metadata / manifest / spine / TOC 基础模型 |
| `RDEPUBReadingModels.swift` | RDEPUBLocation、RDEPUBViewport、RDEPUBHighlight、选区模型 |
| `RDEPUBResourceResolver.swift` | href → fileURL / schemeURL 的统一入口,`normalizedHref` 标准化路径 |
| `RDEPUBPaginator.swift` | 离屏 WKWebView 分页,计算每个 spine 资源被视口切成多少页 |
| `RDEPUBReadingSession.swift` | 状态机 + 会话协调,管理 staged/active/pending 页模型 |
| `RDEPUBNavigatorState.swift` | 状态枚举(initializing/loading/idle/jumping/moving/repaginating |
| `RDEPUBWebView.swift` + 扩展 | 承载 spine 资源的 WKWebView,分页 CSS 注入、JS bridge、fixed wrapper |
| `RDEPUBResourceURLSchemeHandler.swift` | `ss-reader://` 协议,从本地解压目录提供所有 EPUB 资源 |
| `RDEPUBStyleSheetBuilder.swift` | 生成注入 WebView 的 CSS |
| `RDEPUBJavaScriptBridge.swift` | JS ↔ Swift 消息定义和编解码 |
### 2.2 EPUBTextRendering 层
| 文件 | 职责 |
|------|------|
| `RDEPUBTextRenderer.swift` | 渲染引擎协议 + 错误类型 + 样式 / 内容模型 |
| `RDEPUBDTCoreTextRenderer.swift` | DTCoreText 实现,HTML → NSAttributedString |
| `RDEPUBTextRendererSupport.swift` | fragment 标记注入、fragment offset 提取、属性标准化 |
| `RDEPUBTextPaginationSupport.swift` | NSAttributedString 切页算法 |
| `RDEPUBTextBookBuilder.swift` | 驱动逐章节渲染和分页,生成 RDEPUBTextBook |
### 2.3 EPUBUI 层
| 文件 | 职责 |
|------|------|
| `RDEPUBReaderController.swift` | 开箱即用读者控制器,整合 session / readerView / UI |
| `RDEPUBReaderConfiguration.swift` | 外部配置项(字号、主题、翻页模式等) |
| `RDEPUBReaderPersistence.swift` | 阅读位置和高亮的本地持久化 |
| `RDEPUBReaderSettings.swift` | 运行时设置状态 |
| `RDEPUBWebContentView.swift` | WKWebView 内容视图,供 RDReaderView 渲染 WebView 类 spine |
| `RDEPUBTextContentView.swift` | 文本内容视图,供 RDReaderView 渲染文本类 spine |
---
## 3. 资源服务协议说明
加载 spine 资源统一使用 `ss-reader://` 协议,不直接使用 `file://`
```swift
//
let schemeURL = publication.resourceResolver.schemeURL(forHref: spineItem.href)
webView.load(URLRequest(url: schemeURL))
// relative CSS
webView.loadHTMLString(htmlString, baseURL: nil)
```
这样 HTML 内部的相对 CSS、图片、字体和 iframe 资源都会通过同一个 schemeHandler 解析,避免 `loadHTMLString``allowingReadAccessTo` 导致的资源失效问题。
### 3.1 DTCoreText 实现逻辑(textReflowable 路径)
`readingProfile == .textReflowable` 时,渲染链路不再走 WebView 列分页,而是走 `EPUBTextRendering/` 的 DTCoreText 文本渲染链。
**端到端流程:**
1. `RDEPUBTextBookBuilder` 按 spine 顺序读取章节 HTML。
2. `RDEPUBTextRendererSupport.injectFragmentMarkers(into:)` 注入 fragment 标记,保证目录锚点可映射到文本偏移。
3. `RDEPUBDTCoreTextRenderer.renderChapter(html:baseURL:style:)` 执行章节渲染。
4. 渲染成功时,内部通过 `DTHTMLAttributedStringBuilder` 将 HTML 转为 `NSAttributedString`
5. `RDEPUBTextRendererSupport.extractFragmentOffsets(from:)` 提取 `fragment -> 字符偏移` 映射。
6. `RDEPUBTextRendererSupport.normalizeReadingAttributes(in:style:)` 统一字体、行距、颜色等阅读属性。
7. `RDEPUBTextPaginationSupport` 将富文本按可视区域切页,构建页模型。
8. `RDEPUBTextBookBuilder` 汇总章节与页映射,交给 `RDEPUBTextContentView` 呈现。
**关键实现点:**
1. 条件编译:`#if canImport(DTCoreText)`
- 可用时走 DTCoreText 路径。
- 不可用时走 fallback(纯 `NSAttributedString` 降级),确保工程可编译可运行。
2. `dtOptions(baseURL:style:)` 会注入:
- 默认字体族 / 字体名 / 字号
- 行高倍数(由字体行高与行距折算)
- `NSBaseURLDocumentOption`(保证相对资源解析)
- 默认文本颜色
3. fragment 偏移在“属性标准化前后”都要保持稳定,避免目录跳转漂移。
4. 文本分页以字符范围为准,不以 WebView 像素滚动位置为准。
**失败与降级策略:**
1. HTML 转 UTF-8 失败:抛出 `htmlEncodingFailed`
2. DTCoreText builder 失败:进入 fallback 渲染路径。
3. fallback 仍会执行 fragment 提取与阅读属性标准化,保证目录跳转和阅读样式能力不丢失。
**与定位恢复的关系:**
1. 仍使用 `RDEPUBLocation``href + progression`)作为跨会话恢复模型。
2. textReflowable 模式下,`fragmentOffsets` 用于将目录锚点映射到分页后的字符区间,再换算到页索引。
3. 字号/行距变化触发重分页后,优先按 `href + progression` 回落恢复,再用 fragment 做细化定位。
**推荐日志点(DTCoreText 专用):**
1. `RDEPUBDTCoreTextRenderer.renderChapter(...)`:记录章节 href、输入 HTML 长度、渲染耗时。
2. `makeAttributedString(from:baseURL:style:)`:记录 DTCoreText 是否成功返回 attributed string。
3. `extractFragmentOffsets(from:)`:记录 fragment 数量与关键锚点是否命中。
4. `RDEPUBTextPaginationSupport`:记录总页数、每页字符范围边界。
---
## 4. 常见问题排查
### 4.1 EPUB 打不开或解析失败
**优先检查:**
1. `RDEPUBParser.parse(epubURL:)` 是否抛出异常,打印具体错误
2. `RDEPUBParser+Archive.swift` — 解压是否成功
3. `META-INF/container.xml` 是否存在
4. OPF 中 manifest / spine 是否完整
**常见原因:**
- 文件不是标准 EPUB ZIP 结构
- `container.xml` 找不到 OPF 路径
- OPF manifest 或 spine 格式异常
- 文件路径包含特殊字符导致解压目录路径错误
**排查顺序:**
1. 确认 EPUB 已成功复制到沙盒
2. 确认解压目录存在(`extractionRootURL`
3. 确认 `opfDirectoryURL` 不为 nil
4. 确认 `spine.count > 0`
---
### 4.2 EPUB 显示空白页
**优先检查:**
1. `RDEPUBWebContentView.loadPage(...)` 传入的 spineIndex 是否在范围内
2. `RDEPUBWebView+Reflowable` — 分页 CSS 注入是否成功
3. `WKNavigationDelegate.didFinish` 是否正常触发
4. `RDEPUBResourceURLSchemeHandler` 是否正确返回资源数据
**常见原因:**
1. scheme URL 拼装错误,资源请求 404
2. 注入的 CSS 破坏了原始布局(尤其是 `column-width` 样式冲突)
3. fixed-layout 书籍走了 reflowable 渲染路径(readingProfile 判定错误)
**排查顺序:**
1. 确认 `schemeURL(forHref:)` 返回的 URL 对应文件真实存在
2.`RDEPUBWebViewDebug` 打开 WebView inspector 查看网络请求
3. 确认 readingProfile 判定正确(webInteractive / webFixedLayout / textReflowable
---
### 4.3 修改字号后位置恢复不准
**原因:** 字号变化触发重新分页(repaginating),页号失效。
**正确做法:**
当前实现依赖 `href + progression` 恢复位置,不依赖纯页号。排查重点:
1. `RDEPUBLocation.href` 是否已通过 `normalizedHref` 标准化
2. `progression` 是否在字号变化后重新从 WebView 读取
3. `RDEPUBReadingSession.navigatorState` 是否在重分页后回到 `.idle` 并消费了 staged snapshot
---
### 4.4 目录点击后跳错位置
**优先检查:**
1. `RDEPUBParser+TOC.swift` — TOC href 是否正确标准化为相对 OPF 路径
2. `RDEPUBResourceResolver.normalizedHref(_:)` — 跳转时是否使用了标准化 href
3. `fragment` 是否正确传入 WebView 锚点滚动
**常见原因:**
1. TOC 链接是相对 Nav 或 NCX 文件目录,未转为相对 OPF
2. `href#fragment` 只命中了资源,未命中锚点
3. 同一资源中多个 TOC 条目被折叠成同一页
**排查步骤:**
1. 打印原始 TOC href 和标准化后 href 进行比较
2. 确认 `fragment` 字段被保留在 RDEPUBLocation 中
3. 确认 WebView 收到 `fragment` 后执行了 `scrollToFragment(fragment:)` JS 调用
---
### 4.5 正文内部链接不工作
**优先检查:**
1. `RDEPUBWebView+JavaScriptBridge` 中内部链接拦截逻辑
2. `RDEPUBReaderController.navigateToLocation(_:)` — 跨资源跳转路径
**常见原因:**
1. 链接是空 `#fragment`,但当前 DOM 中找不到对应元素
2. 跨资源链接 href 未被 normalizedHref 处理
3. 对应 spine 资源没有分页结果(pageCounts[spineIndex] == 0
---
### 4.6 图片多时位置跳动
**原因:** 图片异步加载导致 DOM 高度变化,进度报告时机早于实际布局稳定。
**排查:**
1. 检查 JS `ResizeObserver` 是否在图片加载后触发了进度重同步
2. 确认 `didUpdateLocation` 不在 pending navigation 未消费前覆盖目标位置
3. 确认 `scrollToLocation()` JS 调用时序在 `didFinish` 之后
---
### 4.7 高亮未显示或恢复失败
**优先检查:**
1. `RDEPUBHighlight` 是否成功写入 persistence
2. `RDEPUBReaderController.activeHighlights` 是否正确过滤出当前 spine 的高亮
3. `RDEPUBWebView+JavaScriptBridge``setHighlights()` JS 调用是否触发
4. JS 中 `rangeFromInfo()` 是否能找到对应 DOM 节点(嵌套节点恢复容易失败)
**排查顺序:**
1. 确认有效选区(`currentSelection` 不为 nil
2. 确认 `addHighlight()` 调用后 persistence 中数据已写入
3. 确认当前页传入的 highlights 数组不为空
4. 确认 JS 收到了 highlights 数据并尝试了 rangeFromInfo 恢复
---
### 4.8 fixed-layout 书籍显示异常
**优先检查:**
1. `RDEPUBPublication.readingProfile` 是否判定为 `.webFixedLayout`
2. `RDEPUBFixedLayoutTemplate` — wrapper HTML 是否正确生成
3. viewport meta 尺寸是否与书籍 OPF 中声明一致
4. spread 模式是否正确(单页 vs 双页)
---
## 5. 推荐日志点
遇到复杂问题时,建议先在以下位置加日志:
1. `RDEPUBParser.parse(epubURL:)` — 打印解压路径、OPF 路径、spine 数量
2. `RDEPUBReadingSession.transition(to:)` — 打印状态跃迁
3. `RDEPUBPaginator` — 打印每个 spine 资源的分页结果
4. `RDEPUBReaderController.restoreLocation(_:)` — 打印恢复位置的 href / progression
5. `RDEPUBWebView+JavaScriptBridge` — 打印所有 JS 消息收发
6. `RDEPUBResourceURLSchemeHandler` — 打印资源请求 URL 和响应状态
---
## 6. 标准排查顺序
当 EPUB 功能出问题时,建议按以下顺序排查:
1. **解析是否成功** — parser.spine.count > 0metadata 正确?
2. **readingProfile 是否正确** — 用了对的渲染路径?
3. **TOC href 是否标准化** — 与 spine href 格式一致?
4. **分页结果是否合理** — 每个 spine 资源的 pageCount > 0
5. **资源是否能正常加载** — ss-reader:// 请求是否 200
6. **JS bridge 是否正常** — progression 上报是否到达 Swift 侧?
7. **状态机是否流转正确** — session.navigatorState 是否符合预期?
8. **控制器是否正确映射页号** — EPUBPage 列表与 RDReaderView 页号对应是否正确?
---
## 7. 后续扩展建议
1. **拆分 demo 层 RDReaderController**:约 1900+ 行,EPUB 加载、UI、数据源职责仍混在一起,建议继续向 library 层迁移
2. **基线验证**:用四本样书跑一轮分页耗时、目录命中率、末页事件、设置恢复稳定性数据
3. **横竖屏支持回归矩阵**:控制器级 `viewWillTransition()` 与 viewport 去重已接入,下一步重点是补齐 pageCurl / scroll、三条正文路径和 iPad 视口变化场景的固定回归
4. **Pod 分层**:评估是否将 EPUBCore / EPUBTextRendering / EPUBUI 拆为独立 subspec
5. **RDEPUBParser 继续收敛**OPF metadata 细节处理仍偏重,可考虑按 OPFMetadataParser 单独拆出
---
## 8. 阅读器能力现状与下一阶段规划
### 8.1 当前已具备的核心能力
当前 SDK 已具备一套可用的 EPUB 阅读器主链路,包含:
1. **EPUB 打开与解析**:支持 ZIP 解压、OPF / spine / TOC 解析,以及三类 readingProfile 分流
2. **正文渲染与分页**:支持 WebView 路径和 DTCoreText 文本路径,覆盖固定版式、交互式可重排和纯文本可重排 EPUB
3. **完整阅读交互**:支持目录跳转、翻页、阅读位置恢复、主题切换、字号和行高调整、亮度调整
4. **标注能力**:支持高亮、划线、批注、标注列表、编辑、删除、跳转与持久化恢复
5. **书签能力**:支持当前位置书签添加/取消、书签列表、删除、跳转与持久化恢复
6. **自动化测试基础能力**:已接入 `ReadViewSDKDemoTests` / `ReadViewSDKDemoUITests`、样书夹具定位工具,以及 parser / resolver / persistence 首批 XCTest 和阅读器启动 smoke 用例
7. **搜索能力**:支持全文搜索、匹配项跳转、当前匹配高亮
### 8.2 高优先级待补能力
从“可用 MVP”走向“稳定可交付阅读器”,当前最值得优先完成的是:
1. **样书基线验证**
需要基于四本样书补齐分页耗时、目录命中率、末页事件、设置恢复稳定性等基线数据
### 8.3 面向完整商业阅读器的下一阶段能力
若目标是继续向完整阅读产品演进,建议后续补齐以下能力:
1. **阅读进度展示**
包括全书进度、章节进度、页码信息、剩余页数或剩余章节等
2. **更完整的标注管理**
包括按章节分组、排序、筛选、批量删除、标注导出、复制批注内容等
3. **更成熟的搜索体验**
包括搜索结果列表页、章节聚合、上下文预览、搜索历史等
4. **更多阅读增强能力**
包括字体切换、自定义主题、翻页手势自定义、TTS 朗读、无障碍支持等
### 8.4 工程侧持续建设建议
除了阅读器功能本身,还建议并行推进以下工程项:
1. **控制器继续拆分**
`RDEPUBReaderController` 与 demo 层控制器都偏大,继续叠加功能会增加维护成本
2. **Parser 与模块职责继续收敛**
`RDEPUBParser` 仍承担较多 OPF metadata 解析细节,后续可继续按职责拆分
3. **回归验收矩阵固化**
建议把样书、渲染路径、主题、字号、翻页模式、标注恢复等整理为固定回归清单
### 8.5 推荐下一阶段实施顺序
如果以“最小投入换最大稳定性提升”为目标,建议按下面顺序推进:
1. **样书基线验证**
@@ -1,398 +0,0 @@
# 书签能力方案讨论
## 需求背景
`Doc/EPUB_MAINTENANCE.md` 已将“书签能力”列为 EPUB 阅读器下一阶段的高优先级能力之一。
当前阅读器已经具备:
1. EPUB 打开与解析
2. WebView / DTCoreText / fixed-layout 三条正文渲染路径
3. 目录跳转、阅读位置恢复、主题与字号调整
4. 高亮、划线、批注、搜索等阅读交互能力
但现阶段“位置记录”仍主要依赖:
1. 自动保存的上次阅读位置
2. 需要选中文本的高亮 / 批注能力
这会遗漏一种非常常见、也非常轻量的阅读诉求:
1. 只想标记当前位置
2. 稍后回来继续读
3. 不需要摘录正文
4. 不需要输入批注
因此,“书签能力”这项需求的目标,不是扩展现有标注功能,而是补上一条面向“位置收藏”的轻量链路,用来覆盖“只记录位置、不做摘录”的常见阅读需求。
## 代码事实
### 已有基础
当前仓库其实已经具备不少可直接复用的基础设施:
1. `RDEPUBLocation` 已能稳定表达 `href + progression + fragment`
2. `RDEPUBReaderController` 已具备“当前位置读取”和“跳转到位置”的主链路
3. `RDEPUBReaderPersistence` 已形成按 `bookIdentifier` 分书持久化的模式
4. 高亮管理页已经具备“列表展示 -> 点击跳转 -> 删除”的成熟交互范式
结论:
1. 书签不需要重新设计位置模型
2. 书签可以复用现有定位和跳转能力
3. 更需要新增的是独立模型、持久化存储位和 UI 入口
### 当前实现存在的问题
#### 问题 1:当前只有单一阅读位置,没有“多书签”能力
`RDEPUBReaderPersistence` 当前只提供:
1. `loadLocation / saveLocation`
2. `loadHighlights / saveHighlights`
3. `loadReaderSettings / saveReaderSettings`
这意味着:
1. 系统只能保存“上次读到哪”
2. 不能保存“用户主动收藏的多个位置”
而这两者的语义并不相同:
1. 阅读位置是自动覆盖的
2. 书签是主动创建、可长期保留多个的
#### 问题 2:当前标注模型不适合直接承载书签
虽然看起来可以用 `RDEPUBHighlight` 勉强模拟书签,但这会带来明显问题:
1. 书签不一定有选中文本
2. 书签不应依赖 `rangeInfo`
3. 书签列表应以“位置”为中心,而不是以“摘录内容”为中心
4. 书签与高亮、划线、批注的展示和管理语义不同
因此,不建议把书签硬塞进现有高亮模型。
#### 问题 3:当前 UI 没有书签入口与状态反馈
`RDEPUBReaderBottomToolView` 当前结构来看,只有:
1. 目录
2. 批注列表
3. 添加标注
4. 设置
当前并没有:
1. 当前页添加 / 取消书签入口
2. 书签列表入口
3. 当前阅读位置是否已加书签的状态反馈
这意味着即便补了数据层,用户仍然无法自然感知和使用这项能力。
## 需求拆解
建议把“书签能力”拆成四部分推进。
### 1. 独立书签模型
需要新增一个面向“位置记录”的独立模型,而不是复用高亮模型。
### 2. 独立书签持久化
需要新增书签读写接口,支持每本书存储多个书签。
### 3. 书签入口与状态反馈
需要让用户在当前页能轻量创建 / 取消书签,并能看到当前位置的书签状态。
### 4. 书签管理与跳转
需要补齐书签列表、点击跳转、删除等基本管理闭环。
## 技术路线对比
### 路线 A:把书签并入 `RDEPUBHighlight`
思路:
1.`RDEPUBHighlight` 增加一个新的 style 或特殊标识
2. 将书签存入 highlights
3. 复用现有标注列表页
优点:
1. 表面上改动范围较小
2. 可以快速借用现有列表管理代码
风险:
1. 模型语义不清晰
2. 书签没有文本选区时会很别扭
3. 后续展示、筛选、持久化逻辑会越来越绕
4. 容易让“书签”和“标注”边界变得混乱
### 路线 B:完全独立一条书签链路
思路:
1. 新建 `RDEPUBBookmark`
2. 新增 `loadBookmarks / saveBookmarks`
3. 新增书签列表页
4. 独立接入添加、删除、跳转、状态更新
优点:
1. 模型语义清晰
2. 更符合产品能力边界
3. 后续扩展空间更好
风险:
1. 会新增一部分 UI 与管理代码
2. 与高亮列表在形态上会有一定重复
### 路线 C:模型独立,交互模式局部复用
思路:
1. 数据模型与持久化独立
2. 定位、跳转和列表交互模式尽量复用现有高亮链路
3. 第一阶段先做轻量闭环,再考虑更复杂管理能力
优点:
1. 语义清晰,同时控制改动范围
2. 能复用现有阅读位置链路和管理交互经验
3. 更适合当前仓库的演进方式
风险:
1. 仍需补一组新的数据与列表代码
2. 需要明确书签状态判定规则,避免重排后误判
### 推荐结论
建议采用:
**主路线:路线 C,模型独立,位置链路复用,UI 先做轻量闭环**
原因:
1. 书签和标注的语义不同,模型应保持独立
2. 当前仓库已有成熟的位置和跳转主链路,可以直接复用
3. 第一阶段先满足“记录位置”的核心需求,不需要把书签做得过重
## 推荐方案
### 方案总览
书签能力第一阶段建议形成如下结构:
1. 新增 `RDEPUBBookmark` 独立模型
2. 扩展 `RDEPUBReaderPersistence`,新增 bookmarks 读写
3.`RDEPUBReaderController` 中接入书签状态、添加、删除、跳转能力
4. 增加轻量级书签入口和书签列表页
5. 支持退出重进后的书签持久化恢复
### 推荐数据模型
建议书签模型至少包含以下字段:
1. `id`
2. `bookIdentifier`
3. `location`
4. `chapterTitle`
5. `displayText` 或摘要信息(可选)
6. `note`(可选)
7. `createdAt`
其中第一阶段真正必要的最小集合是:
1. `id`
2. `bookIdentifier`
3. `location`
4. `createdAt`
建议同时补上 `chapterTitle`,这样书签列表在展示时会更友好,也更接近真实阅读器使用习惯。
### 推荐持久化结构
建议在 `RDEPUBReaderPersistence` 中新增:
1. `loadBookmarks(for:)`
2. `saveBookmarks(_:for:)`
`RDEPUBUserDefaultsPersistence` 中新增:
1. `bookmarksPrefix`
第一阶段继续沿用当前:
1. `UserDefaults`
2. `JSONEncoder / JSONDecoder`
3. `bookIdentifier` 作用域隔离
这样能在最小改动下形成可用闭环。
### 推荐 UI 入口
第一阶段建议至少补两类入口:
1. 当前页添加 / 取消书签入口
2. 书签列表入口
比较自然的放置方式是:
1. 顶部工具栏增加“当前页书签”切换按钮
2. 底部工具栏增加“书签列表”入口
这样可以把两种动作区分开:
1. “加书签”是当前上下文动作
2. “看书签列表”是全局管理动作
### 推荐交互闭环
第一阶段建议用户路径收敛成下面这条主链路:
1. 用户在当前阅读位置点击书签按钮
2. 如果当前语义位置未加书签,则创建书签
3. 如果当前语义位置已加书签,则取消该书签
4. 用户可从书签列表查看当前书籍的全部书签
5. 点击任意书签后跳转到对应位置
6. 支持从列表中删除书签
## 关键实现建议
### Task K1:新增书签模型
文件:
1. `Sources/RDReaderView/EPUBCore/RDEPUBReadingModels.swift`
改动:
1. 新增 `RDEPUBBookmark: Codable, Equatable`
2. 字段以 `location` 为核心,不依赖选区文本
完成定义:
1. 可以表达一本书中的一个可持久化书签位置
### Task K2:扩展书签持久化协议
文件:
1. `Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
改动:
1. 协议增加 `loadBookmarks / saveBookmarks`
2. `RDEPUBUserDefaultsPersistence` 增加 `bookmarksPrefix`
完成定义:
1. 每本书可以稳定读取和保存多个书签
### Task K3:控制器接入书签状态与操作
文件:
1. `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
改动:
1. 增加 `activeBookmarks`
2. 打开图书时加载 bookmarks
3. 增加 `addBookmark`
4. 增加 `removeBookmark`
5. 增加 `toggleBookmark`
6. 增加 `go(to: bookmark)`
7. 增加当前阅读位置是否已书签的状态计算
完成定义:
1. 书签与阅读主链路、跳转链路、持久化链路打通
### Task K4:补书签 UI 与列表管理
文件:
1. `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift`
2. 新增 `Sources/RDReaderView/EPUBUI/RDEPUBReaderBookmarksViewController.swift`
3. 视情况补充顶部工具栏按钮
改动:
1. 添加书签入口
2. 添加书签列表入口
3. 点击书签跳转
4. 支持删除
5. 提供当前位置书签状态反馈
完成定义:
1. 用户能看到、添加、取消、查看和管理书签
## 当前页书签判定建议
“当前位置是否已加书签”不建议简单按“完全相同的 `progression`”判断,因为:
1. 字号变化会触发重分页
2. 视口尺寸变化会导致 progression 细微漂移
3. WebView 和文本分页的进度精度并不完全一致
更合理的判定策略是:
1. 优先比较标准化后的 `href`
2. 如果存在 `fragment`,优先使用 `fragment` 作为更强锚点
3. reflowable 路径允许一定 progression 容差
4. fixed-layout 路径优先按页级位置判断
这样可以减少用户在重排、重进或轻微位置变化时的书签状态抖动。
## 验收矩阵建议
第一阶段至少要覆盖以下验证维度:
1. 当前页可添加书签
2. 同一位置可取消书签
3. 书签列表展示正常
4. 点击书签可以跳转
5. 删除书签后列表更新正确
6. 退出重进后书签仍存在
7. 字号变化后书签仍能回到同章节或邻近语义位置
8. `webInteractive` 路径正常
9. `textReflowable` 路径正常
10. `webFixedLayout` 路径至少能按页恢复
## 风险与注意点
### 风险 1:把书签与高亮强行合并
这样短期看似省事,长期会让模型、持久化和 UI 语义都变重。
### 风险 2:过度依赖精确 progression 匹配
如果书签命中规则太严格,重分页后会显得“不稳定”,用户体验会很差。
### 风险 3:第一阶段把书签做得过重
书签能力的首要目标是“轻量记录位置”,不是一开始就做成复杂的收藏管理系统。
## 推荐实施顺序
如果目标是“最小投入换最大需求闭环”,建议按下面顺序推进:
1. 先补独立 `RDEPUBBookmark` 模型
2. 先扩展 `RDEPUBReaderPersistence` 的书签读写能力
3. 再在 `RDEPUBReaderController` 接入书签主链路
4. 最后补 UI 入口和书签列表页
5. 待第一阶段稳定后,再考虑备注、排序、筛选等增强能力
## 推荐结论
“书签能力”这项需求,建议最终收敛为下面这句话:
**为 EPUB 阅读器补齐一条独立的轻量书签链路,以 `RDEPUBLocation` 为核心记录位置,支持添加、取消、列表管理、跳转与持久化恢复,用来覆盖“只记录位置、不做摘录”的常见阅读需求。**
@@ -1,439 +0,0 @@
# 样书基线验证方案讨论
## 需求背景
`Doc/EPUB_MAINTENANCE.md` 已将“样书基线验证”列为 EPUB 阅读器下一阶段的高优先级能力之一。
当前阅读器已经具备:
1. EPUB 打开与解析
2. WebView / DTCoreText / fixed-layout 三条正文渲染路径
3. 目录跳转、阅读位置恢复、字号与主题调整
4. 高亮、划线、批注、搜索等阅读交互能力
但这些能力目前主要还是依赖“人手点一遍”来判断是否正常,缺少固定样书、固定流程、固定输出格式的基线验证机制。
因此,“样书基线验证”这项需求的目标,不是再增加几本 demo 书,而是基于当前已内置的四本 EPUB 样书,建立一套:
1. 可重复执行
2. 可横向比较
3. 可长期沉淀
4. 可逐步自动化
的阅读器基线验证机制。
## 代码事实
### 已有基础
当前仓库已经具备样书基线验证所需的基本条件:
1. `ReadViewSDKDemo/ReadViewSDKDemo/Resources/` 内已经内置四本 EPUB 样书
2. `RDReaderManager.bundledEPUBURLs(in:)` 会自动枚举 bundle 中所有 `.epub`
3. `RDReaderManager.demoBookItems(in:)` 已将这些样书接入 demo 书架入口
4. `ViewController` 支持通过 `-demo-book-title` 启动参数自动打开指定样书
这意味着当前并不缺“样书载体”或“执行入口”,更缺的是:
1. 哪本样书用于验证哪类能力
2. 每次验证时要记录哪些数据
3. 什么叫验证通过
4. 如何把结果沉淀成可比较的基线
### 当前实现存在的问题
#### 问题 1:四本样书当前只是 demo 资源,不是正式基线夹具
现在这四本书更多承担“可打开、可展示”的职责,但还没有被正式定义为:
1. 固定基线样书
2. 各自覆盖的风险场景
3. 必测能力清单
4. 异常归档与复验对象
#### 问题 2:当前没有统一的基线指标结构
`Doc/EPUB_MAINTENANCE.md` 已明确提到要补齐:
1. 分页耗时
2. 目录命中率
3. 末页事件
4. 设置恢复稳定性
但当前还没有统一规定:
1. 指标如何采集
2. 指标如何记录
3. 指标的通过标准是什么
4. 下次如何与这次比较
#### 问题 3:样书验证目标混杂了性能、正确性与兼容性
“样书基线验证”至少同时包含三类验证目标:
1. 性能基线:分页耗时、页数规模
2. 正确性基线:目录跳转、末页事件、位置恢复
3. 兼容性基线:不同 EPUB 类型在不同渲染路径下是否稳定
如果不拆开,执行时很容易退化成“随便翻一下,感觉没问题”。
## 需求拆解
建议把“样书基线验证”拆成四部分来推进。
### 1. 固定样书集
先把四本样书从“demo 资源”提升为“固定基线夹具”,后续版本迭代尽量不随意替换。
### 2. 固定样书职责
每本样书都明确它主要覆盖哪些风险,而不是所有书都做完全相同的验证。
### 3. 固定基线指标
每次验证时都按同一组字段记录结果,保证可比较。
### 4. 固定验证流程与输出模板
每轮验证都按统一步骤执行,并把结果写入统一模板,避免结论只停留在口头描述。
## 技术路线对比
### 路线 A:完全手工验证
思路:
1. 打开四本样书
2. 手工翻页、点目录、调字号
3. 用主观结论判断是否正常
优点:
1. 上手快
2. 不需要额外改工程
风险:
1. 结果不稳定
2. 不同人执行结论可能不同
3. 无法形成长期可比较的基线
### 路线 B:一开始就全量自动化
思路:
1. 直接把四本样书的打开、翻页、跳转、恢复全部接入 UI 自动化或脚本采集
2. 让基线数据自动产出
优点:
1. 理想状态下最规范
2. 长期收益高
风险:
1. 前期成本大
2. 当前测试基础设施还不完整
3. 部分阅读体验问题在第一阶段更适合半自动确认
### 路线 C:先建立半自动基线,再逐步自动化
思路:
1. 先固定样书矩阵与基线字段
2. 借助现有 demo 自动打开入口执行统一验证流程
3. 先沉淀结构化结果
4. 再将适合自动化的部分逐步纳入 XCTest / XCUITest
优点:
1. 投入和收益平衡更好
2. 能更快形成第一版基线
3. 不会被早期自动化建设阻塞
风险:
1. 需要纪律性执行模板
2. 初期仍有部分验证依赖人工操作
### 推荐结论
建议采用:
**主路线:路线 C,先建立半自动样书基线,再逐步自动化**
原因:
1. 当前仓库已经有四本固定样书和自动打开入口
2. 样书基线验证的第一目标是“有可比较的结果”,而不是“立刻完全自动化”
3. 自动化测试体系尚在建设中,先立住基线矩阵更划算
## 推荐方案
### 方案总览
样书基线验证第一阶段建议形成如下结构:
1. 固定四本样书,不随意替换
2. 每本样书绑定主要验证职责
3. 每轮版本验证按统一流程执行
4. 每次都输出结构化基线表
5. 对异常项保留备注与复验结论
### 四本样书矩阵
> 注:以下矩阵以当前样书名称和文档信息为基础,readingProfile 可在首次正式基线验证时补齐实测结果。
| 样书 | 主要定位 | 重点验证能力 | 适合记录的核心指标 |
|------|----------|--------------|--------------------|
| `爱忘事的熊爷爷.epub` | 轻量快速回归样书 | 打开成功、基础分页、目录基本可用、设置恢复 | 打开耗时、总页数、TOC 基本命中、设置恢复 |
| `张学良传.epub` | 标准长文 reflowable 样书 | 长文分页、目录跳转、字号变化后位置恢复 | 首次分页耗时、TOC 命中率、字号调整后恢复 |
| `宝山辽墓材料与释读.epub` | 复杂结构与学术内容样书 | 复杂 TOC / fragment 命中、图片与正文结构稳定性、搜索与标注抽样 | TOC 命中率、fragment 命中、搜索命中、高亮恢复 |
| `《凡人修仙传》精校版全本.epub` | 大体量长书与压力样书 | 大体量分页稳定性、持久化恢复、末页事件、长时阅读链路 | 分页耗时、总页数、末页事件、重进恢复稳定性 |
### 推荐验证维度
第一阶段建议围绕下面这些维度建立固定基线:
1. 打开是否成功
2. readingProfile 类型
3. spine 数量
4. 总页数是否合理
5. 首次分页耗时
6. TOC 是否可打开
7. TOC 抽样命中率
8. 末页事件是否正常触发
9. 字号调整后位置是否稳定恢复
10. 主题 / 设置是否稳定恢复
11. 重进后阅读位置是否恢复
12. 标注或高亮是否恢复
其中第一阶段最核心的四项,应与 `Doc/EPUB_MAINTENANCE.md` 对齐:
1. 分页耗时
2. 目录命中率
3. 末页事件
4. 设置恢复稳定性
## 推荐执行流程
建议每本样书都按同一流程执行,避免漏项。
### Step B1:冷启动打开样书
目标:
1. 验证样书可正常进入阅读器
2. 记录首次打开与首次分页表现
记录:
1. 是否打开成功
2. readingProfile
3. spine 数量
4. 总页数
5. 首次分页耗时
### Step B2:目录抽样验证
目标:
1. 验证 TOC 是否能正常打开
2. 验证典型目录项是否能跳转到正确章节或语义位置
建议:
1. 每本样书至少抽样 3 到 5 个目录项
2. 包含开头、中间、靠后位置
3. 如果目录层级复杂,额外抽样一个带 fragment 的条目
记录:
1. 抽样条目数
2. 命中数
3. 命中率
4. 失败条目与现象
### Step B3:末页事件验证
目标:
1. 验证阅读器到达末页时的状态变化是否正确
2. 检查不会提前触发或漏触发
记录:
1. 是否到达末页
2. 末页事件是否正常触发
3. 是否出现重复触发或不触发
### Step B4:设置恢复稳定性验证
目标:
1. 修改字号、主题或其他关键阅读设置
2. 验证设置变更后分页是否稳定
3. 验证退出重进后设置是否恢复
记录:
1. 修改的设置项
2. 修改后是否立即生效
3. 重进后是否恢复
4. 是否伴随位置飘移或异常白页
### Step B5:位置与标注恢复抽样验证
目标:
1. 在样书中间位置退出重进
2. 验证阅读位置恢复
3. 抽样验证一条高亮或标注恢复
记录:
1. 退出前位置
2. 重进后位置
3. 是否在同一章节或邻近语义位置
4. 高亮 / 标注是否仍存在
## 基线表模板
建议每轮验证都至少产出两张表:
1. 总览表
2. 单书详情表
### 模板 1:样书基线总览表
| 日期 | 版本 / 分支 | 样书 | readingProfile | spine 数量 | 总页数 | 首次分页耗时 | TOC 抽样 / 命中 | 末页事件 | 设置恢复 | 位置恢复 | 标注恢复 | 结论 | 备注 |
|------|--------------|------|----------------|------------|--------|--------------|-----------------|----------|----------|----------|----------|------|------|
| YYYY-MM-DD | branch / commit | 爱忘事的熊爷爷 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 通过 / 风险 / 失败 | 待填 |
| YYYY-MM-DD | branch / commit | 张学良传 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 通过 / 风险 / 失败 | 待填 |
| YYYY-MM-DD | branch / commit | 宝山辽墓材料与释读 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 通过 / 风险 / 失败 | 待填 |
| YYYY-MM-DD | branch / commit | 《凡人修仙传》精校版全本 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 通过 / 风险 / 失败 | 待填 |
### 模板 2:单书详细验证表
#### 样书信息
| 字段 | 值 |
|------|----|
| 样书名称 | 待填 |
| 文件大小 | 待填 |
| readingProfile | 待填 |
| spine 数量 | 待填 |
| TOC 条目数 | 待填 |
| 总页数 | 待填 |
| 验证日期 | 待填 |
| 验证版本 / 分支 | 待填 |
#### 核心基线结果
| 项目 | 结果 | 备注 |
|------|------|------|
| 打开成功 | 通过 / 失败 | 待填 |
| 首次分页耗时 | 待填 | 单位建议统一为 ms 或 s |
| TOC 打开 | 通过 / 失败 | 待填 |
| TOC 抽样命中率 | 待填 | 例如 `4/5` |
| 末页事件 | 通过 / 风险 / 失败 | 待填 |
| 字号调整后恢复 | 通过 / 风险 / 失败 | 待填 |
| 主题 / 设置恢复 | 通过 / 风险 / 失败 | 待填 |
| 重进后位置恢复 | 通过 / 风险 / 失败 | 待填 |
| 高亮 / 标注恢复 | 通过 / 风险 / 失败 | 待填 |
#### TOC 抽样记录
| 序号 | TOC 条目 | 预期目标 | 实际结果 | 是否命中 | 备注 |
|------|----------|----------|----------|----------|------|
| 1 | 待填 | 待填 | 待填 | 是 / 否 | 待填 |
| 2 | 待填 | 待填 | 待填 | 是 / 否 | 待填 |
| 3 | 待填 | 待填 | 待填 | 是 / 否 | 待填 |
| 4 | 待填 | 待填 | 待填 | 是 / 否 | 待填 |
| 5 | 待填 | 待填 | 待填 | 是 / 否 | 待填 |
#### 异常与复验记录
| 异常编号 | 场景 | 现象 | 复现条件 | 当前结论 | 备注 |
|----------|------|------|----------|----------|------|
| B-001 | 待填 | 待填 | 待填 | 待确认 / 已复现 / 已修复 | 待填 |
## 推荐输出规范
为了让基线真正可比较,建议每轮验证输出时统一遵守以下规范:
### 1. 样书集固定
第一阶段尽量只使用当前这四本 EPUB,不随意更换,避免基线漂移。
### 2. 字段命名固定
例如:
1. `首次分页耗时`
2. `TOC 抽样命中率`
3. `末页事件`
4. `设置恢复`
5. `位置恢复`
尽量不要每次换表头名称。
### 3. 结论分级固定
建议统一使用:
1. `通过`
2. `风险`
3. `失败`
其中:
1. `通过`:功能符合预期,无明显异常
2. `风险`:功能基本可用,但存在偏差、偶发不稳或需继续观察
3. `失败`:功能不符合预期,影响可用性
### 4. 差异说明固定
如果与上次基线相比发生变化,建议在备注中明确:
1. 是否变快
2. 是否变慢
3. 是否命中率下降
4. 是否新增异常
5. 是否已有异常被修复
## 风险与注意点
### 风险 1:把基线验证做成纯主观体验记录
如果只有“感觉正常”“翻起来没问题”这类结论,后续几乎无法比较。
### 风险 2:过度依赖绝对页码
页码会受到字号、行距、视口与渲染策略影响,样书基线更适合比较:
1. 是否能正常分页
2. 是否恢复到同章节或邻近语义位置
3. 是否出现明显回归
而不是执着于固定页号完全一致。
### 风险 3:样书职责不清导致验证重复或漏项
如果四本书都做完全相同的验证,会浪费时间;如果每本书都“随便点几下”,又会漏掉高风险场景。
## 推荐实施顺序
如果目标是“最小投入换最大稳定性提升”,建议按下面顺序推进:
1. 先将四本样书正式定义为固定基线样书
2. 先用本文件中的矩阵与模板跑第一轮人工基线
3. 在第一轮基线中补齐每本样书的实测 `readingProfile`、spine 数量、总页数等信息
4. 统一记录异常项与复验结论
5. 再将其中适合自动化的部分逐步并入 `XCTest / XCUITest`
## 推荐结论
“样书基线验证”这项需求,建议最终收敛为下面这句话:
**基于当前 demo 内置的四本固定 EPUB 样书,建立一套统一矩阵、统一流程、统一模板的基线验证机制,首期重点覆盖分页耗时、目录命中率、末页事件和设置恢复稳定性,并为后续自动化回归提供稳定样书夹具。**
@@ -1,372 +0,0 @@
# 横竖屏切换支持方案讨论
## 需求背景
`Doc/EPUB_MAINTENANCE.md``Doc/ARCHITECTURE.md` 都把“横竖屏切换支持”列为当前阅读器的高优先级待补能力。
目标不是单纯“屏幕旋转后不崩”,而是让 EPUB 阅读器在横竖屏切换后具备以下稳定行为:
1. 正文重新按新视口尺寸分页或重排
2. 阅读位置尽量准确恢复,不明显跳章、跳页
3. 工具栏、目录、标注、搜索状态不异常
4. pageCurl / horizontalScroll / verticalScroll / horizontalCoverScroll 四种展示模式行为一致
5. WebView、DTCoreText、fixed layout 三条正文路径都能正确处理
## 代码事实
### 已有基础
当前仓库并不是完全没有横竖屏处理基础:
- `RDReaderView.layoutSubviews()` 会检测 `previousIsLandscape``isLandscape` 的变化
文件:`Sources/RDReaderView/RDReaderView.swift`
- `RDReaderView.orientationChanged(isNowLandscape:)` 已能:
- 通知 `readerViewOrientationWillChange`
- 更新 `layout.isLandscapeDualPage`
- `pageCurl` 模式下重建 `UIPageViewController`
- 滚动模式下 `invalidateLayout + reloadData + setContentOffset`
- `RDEPUBReaderController.readerViewOrientationWillChange(...)` 当前会直接调用 `repaginatePreservingCurrentLocation()`
- `RDEPUBReaderController.repaginatePreservingCurrentLocation()` 已具备“先记录当前位置,再重新分页,再恢复位置”的主链路
- `RDEPUBLocation` 已采用 `href + progression + lastProgression + fragment` 模型,本身适合应对字号变化和尺寸变化后的重定位
结论:
- 基础设施已经存在
- 真正缺的是“时机是否稳定、入口是否统一、三条正文路径是否都覆盖、是否会重复触发”
### 当前实现存在的问题
#### 问题 1:方向变化回调被 `landscapeDualPageEnabled` 绑定
`RDReaderView.layoutSubviews()` 目前有:
```swift
guard landscapeDualPageEnabled, bounds.width > 0, bounds.height > 0 else { return }
```
这意味着:
- 只有开启 `landscapeDualPageEnabled` 时才会进入方向变化检测
- 如果宿主关闭横屏双页,但正文仍然会因为宽高变化而需要重排,此时不会触发回调
这对以下场景不正确:
1. `textReflowable` 文本书籍在横竖屏切换后,行宽必然变化,应重新分页
2. `webInteractive` 可重排正文在横竖屏切换后,列分页宽度变化,应重新分页
3. fixed layout 在 spread 模式下也可能因视口变化而需要重新生成页面模型
#### 问题 2:重新分页触发时机偏早
当前 `readerViewOrientationWillChange` 是从 `RDReaderView.layoutSubviews()` 中异步抛出。
这有几个风险:
1. 此时上层 VC 的 `view.bounds`、safe area、sheet 布局动画可能还在变化中
2. `paginatePublication()` 如果过早读取 `currentLayoutContext().viewportSize`,可能拿到中间态尺寸
3. `RDReaderView` 自己已经在同一轮变化里做了 `reloadData` / `transitionToPage`,而 `RDEPUBReaderController` 又会发起新一轮分页,容易出现双重刷新
#### 问题 3:职责边界不够清晰
目前横竖屏变化同时由两层在做事:
- `RDReaderView`:处理双页布局、pageCurl 容器重建、滚动模式 offset 恢复
- `RDEPUBReaderController`:处理 EPUB 重新分页
但两层之间没有一个明确的“统一入口”来协调:
1. 是否真的需要重分页
2. 什么时候用最终尺寸重分页
3. 何时忽略重复触发
4. 何时只刷新布局,不重做完整分页
#### 问题 4:文档状态与代码状态不一致
文档把横竖屏切换标为“未实现”,但代码里已经有半套逻辑。
这说明当前更准确的描述应当是:
- “已有局部实现”
- “尚未形成稳定、完整、可验收的横竖屏支持闭环”
## 需求拆解
为了让“横竖屏切换支持”真正完成,建议把需求拆成四部分:
### 1. 稳定感知视口变化
不仅要感知“横屏/竖屏布尔值变化”,还要感知:
- 宽高尺寸变化
- safe area 变化
- iPad 分屏、多窗口、sheet 尺寸变化
因此,真正要监听的不是“orientation”,而是“阅读视口发生了足以影响分页的变化”。
### 2. 统一触发重新分页
所有需要重排正文的场景都应走统一入口,例如:
```swift
handleViewportChange(reason: .orientationTransition)
```
由它统一负责:
1. 读取当前位置
2. 比较旧 viewport 与新 viewport
3. 防抖与去重
4. 发起分页
5. 恢复定位
### 3. 区分“容器布局刷新”和“正文重分页”
不是所有变化都必须触发完整分页,但横竖屏切换通常都需要:
- `textReflowable`:重建 `RDEPUBTextBook`
- `webInteractive`:重跑 `RDEPUBPaginator`
- `webFixedLayout`:重建 fixed spread snapshot
`RDReaderView` 自己的 pageCurl 双页容器重建仍可保留,但不应和正文分页时机互相打架。
### 4. 建立回归验收矩阵
横竖屏切换是高联动场景,至少要验证:
1. 无选区、无工具栏时切换
2. 有目录面板、设置面板、标注列表时切换
3. 搜索命中页、标注页、末页时切换
4. `landscapeDualPageEnabled = true / false`
5. pageCurl / scroll 系列模式
## 技术路线对比
### 路线 A:继续依赖 `RDReaderView.readerViewOrientationWillChange`
思路:
- 保留现有 `layoutSubviews() -> orientationChanged -> delegate`
-`RDEPUBReaderController` 内增强去重、防抖和最终尺寸判断
优点:
1. 改动范围小
2. 能延续现有 `RDReaderView` 双页逻辑
风险:
1. 触发时机仍然偏依赖 `layoutSubviews()`
2. 仍容易与 VC 生命周期中的尺寸变化打架
3. 继续把“正文分页”绑定到“容器方向回调”,抽象层次不够稳定
### 路线 B:由 `RDEPUBReaderController.viewWillTransition(to:with:)` 统一接管
思路:
1.`RDEPUBReaderController` 实现 `viewWillTransition(to:with:)`
2. 利用 `transitionCoordinator` 等待旋转动画接近完成
3. 在 completion 中读取最终 `viewportSize`
4. 统一调用 `handleViewportChange(reason:)`
5. `RDReaderView.readerViewOrientationWillChange` 只保留容器级双页重建职责,不再直接触发正文分页
优点:
1. 分页时机更接近最终尺寸
2. 更符合 UIKit 对旋转和尺寸变化的生命周期
3. 更容易做重复触发抑制
4. 更适合以后扩展到 iPad 分屏、多窗口
风险:
1. 需要重新梳理 `RDReaderView``RDEPUBReaderController` 的职责分界
2. 需要验证 pageCurl 双页重建与正文分页之间的顺序
### 推荐结论
建议采用:
**主路线:路线 B,由 `RDEPUBReaderController.viewWillTransition(to:with:)` 统一接管正文重分页**
**保留 `RDReaderView` 现有方向变化逻辑,但将其职责收敛为容器布局刷新和双页模式内部处理**
原因:
1. 需求本质是“视口变化后的正文重排”,最合理的宿主是控制器,而不是容器内部的 `layoutSubviews()`
2. 现有 `RDReaderView` 回调机制已经能服务内部双页布局,但不适合作为 EPUB 正文分页的唯一入口
3. `viewWillTransition` + `transitionCoordinator` 更容易拿到稳定尺寸,并减少重复分页
## 推荐方案
### 方案总览
引入一条统一的横竖屏处理主链路:
```swift
viewWillTransition(to:with:)
-> scheduleViewportTransition(reason: .orientation)
-> handleViewportChangeIfNeeded(finalViewportSize)
-> repaginatePreservingCurrentLocation()
-> finishPagination(restoreLocation:)
```
同时把 `RDReaderView` 内部职责收敛为:
1. 更新 `layout.isLandscapeDualPage`
2. pageCurl 模式下重建 `UIPageViewController`
3. 滚动模式刷新 collectionView 布局
不再让 EPUBUI 直接在 `readerViewOrientationWillChange` 中立即重新分页。
### 关键改动建议
#### Task O1:增加控制器级旋转入口
文件:
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
改动:
- 实现 `viewWillTransition(to:with:)`
-`transitionCoordinator` completion 中比较新旧 viewport
- 统一调 `handleViewportChange(reason:)`
完成定义:
- 旋转后分页使用最终尺寸,而不是中间态尺寸
#### Task O2:引入 viewport 去重与防抖
文件:
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
改动:
- 保存最近一次已应用的 `viewportSignature`
- 仅当宽高变化超过阈值时才触发重新分页
- 防止 `viewDidLayoutSubviews``orientation callback``viewWillTransition` 连续多次重复触发
推荐结构:
```swift
private struct RDEPUBViewportSignature: Equatable {
let width: CGFloat
let height: CGFloat
let safeTop: CGFloat
let safeBottom: CGFloat
}
```
完成定义:
- 一次横竖屏切换只触发一次有效正文重分页
#### Task O3:调整 `readerViewOrientationWillChange` 职责
文件:
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
- 如有必要 `Sources/RDReaderView/RDReaderView.swift`
改动:
- `RDEPUBReaderController.readerViewOrientationWillChange` 不再直接 `repaginatePreservingCurrentLocation()`
- 保留它作为兼容钩子,或仅做轻量状态标记
完成定义:
- 容器内部布局刷新与正文分页不再相互打架
#### Task O4:放宽 `RDReaderView` 的方向检测前置条件
文件:
- `Sources/RDReaderView/RDReaderView.swift`
改动:
- 去掉 `layoutSubviews()` 中对 `landscapeDualPageEnabled` 的硬性 guard
- 方向变化检测应独立于“是否启用横屏双页”
完成定义:
- 即使 `landscapeDualPageEnabled == false`,视口变化仍可被上层感知并触发重分页
注意事项:
- 双页布局本身仍然只在 `landscapeDualPageEnabled == true` 时开启
- 但“是否需要感知尺寸变化”不能绑定到这个开关
#### Task O5:验证三条正文路径的恢复策略
文件:
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
- `Sources/RDReaderView/EPUBCore/RDEPUBReadingSession.swift`
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift`
改动:
- 验证 `restoreReadingLocation()` 在以下路径都稳定:
- `textReflowable`
- `webInteractive`
- `webFixedLayout`
完成定义:
- 横竖屏切换后,恢复位置不明显跳页、跳章
#### Task O6:补文档与验收矩阵
文件:
- `Doc/EPUB_MAINTENANCE.md`
- `Doc/ARCHITECTURE.md`
- 如有必要 `Doc/EPUBUI_功能实现逻辑.md`
改动:
- 将“横竖屏切换未做”更新为更准确的实现状态
- 补充最终采用的触发时机与回归清单
完成定义:
- 文档与代码状态一致
## 风险点
1. **pageCurl 双页重建顺序**
`RDReaderView` 自己会重建 `UIPageViewController`,如果 `RDEPUBReaderController` 同时触发分页,需要验证两者先后顺序
2. **重复分页**
旋转时 UIKit 常伴随多次 layout / safe area 变化,如果没有 `viewportSignature`,很容易多次重排
3. **iPad 场景复杂度**
分屏、多窗口、Stage Manager 下不一定发生“方向变化”,但一定发生“视口变化”,所以方案必须以 viewport 为核心
4. **fixed layout spread 恢复**
横屏双页和竖屏单页之间切换时,恢复到哪一页的左页 / 右页,需要以 `RDEPUBLocation` 为准而不是旧页号
## 推荐实施顺序
1. 先做 `O1 + O2`,把控制器级 viewport 变化入口和防抖建起来
2. 再做 `O3 + O4`,把旧的 delegate 触发职责收窄
3. 然后验证 `O5`,重点跑三条正文路径和四种展示模式
4. 最后做 `O6`,同步文档与验收矩阵
## 验收标准
1. 横竖屏切换后,`textReflowable` 路径会按新尺寸重新分页,并恢复到接近原阅读位置
2. 横竖屏切换后,`webInteractive` 路径会重新分页,并恢复到接近原阅读位置
3. 横竖屏切换后,`webFixedLayout` 路径会按新 spread 规则重建页面模型,并恢复到正确资源
4. `landscapeDualPageEnabled == false` 时,横竖屏切换仍然会触发必要的重排
5. 一次旋转只触发一次有效正文重分页,不出现连续多次闪烁刷新
6. pageCurl / horizontalScroll / verticalScroll / horizontalCoverScroll 四种模式下都不出现明显错页、白页或工具栏错位
## 结论
横竖屏切换支持不是“从零开始新增一套能力”,而是要把当前分散在 `RDReaderView``RDEPUBReaderController` 里的半套逻辑收敛成一个稳定闭环。
最推荐的方向是:
- `RDEPUBReaderController``viewWillTransition(to:with:)` 统一接管正文级重分页
- `RDReaderView` 保留容器级双页布局和 pageCurl 重建职责
-`viewport` 变化而不是 `isLandscape` 布尔值作为真正的分页触发依据
这样既能复用现有代码,又能把“文档标注为未完成”的横竖屏需求收敛成可实现、可回归、可交付的一阶段方案。
@@ -1,418 +0,0 @@
# 自动化测试方案讨论
## 需求背景
`Doc/EPUB_MAINTENANCE.md` 已将“自动化测试”列为 EPUB 阅读器下一阶段的高优先级能力之一。
当前阅读器已经具备较完整的主链路能力,包括:
1. EPUB 解析与打开
2. WebView / DTCoreText / fixed-layout 三条正文渲染路径
3. 目录跳转、阅读位置恢复、主题与字号调整
4. 高亮、划线、批注、搜索等阅读交互能力
但这些能力目前主要依赖手工验证,缺少自动化回归保护。随着横竖屏切换、书签、更多搜索体验和阅读增强能力继续叠加,单靠人工回归会越来越难以稳定覆盖高联动场景。
因此,“自动化测试”这项需求的目标不是单纯增加几条测试用例,而是为 EPUB 阅读器建立一套可持续演进的自动化回归体系,优先保护以下核心链路:
1. 解析
2. 分页
3. 定位恢复
4. 目录跳转
5. 标注恢复
## 代码事实
### 已有基础
当前仓库并不是完全没有可测试的基础:
1. `Sources/RDReaderView/EPUBCore/` 已经把 parser、resourceResolver、location、readingSession、paginator 等能力拆成了相对独立的模块
2. `Sources/RDReaderView/EPUBTextRendering/` 已经将 DTCoreText 渲染、fragment 提取、富文本分页等逻辑收敛到明确的工具类型中
3. `RDEPUBLocation``RDEPUBHighlight`、TOC / spine / manifest 等模型本身适合做 deterministic 的断言
4. `ReadViewSDKDemo` 已经提供真实运行容器,后续可以承载 UI 自动化测试
结论:
1. 核心逻辑并非不可测
2. 更缺的是测试 target、测试夹具、断言策略和回归矩阵
### 当前实现存在的问题
#### 问题 1:工程里还没有测试承载层
`ReadViewSDKDemo.xcodeproj` 当前 target 配置来看,只有 `ReadViewSDKDemo` app target,没有独立的 `Tests``UITests` target。
这意味着当前并没有:
1. 用于跑 XCTest 的基础 target
2. 用于跑 XCUITest 的 UI 测试 target
3. 稳定的测试资源加载方式
#### 问题 2:关键链路缺少自动回归保护
当前高风险能力包括:
1. `RDEPUBParser` 解析 EPUB ZIP / OPF / spine / TOC
2. `RDEPUBResourceResolver.normalizedHref(_:)` 的路径标准化
3. `RDEPUBPaginator``RDEPUBTextPaginationSupport` 的分页结果
4. `RDEPUBReaderController.restoreLocation(_:)` 的定位恢复
5. 高亮 / 批注 persistence 的恢复链路
这些地方一旦回归,通常会表现为:
1. 打不开书
2. 目录跳错
3. 字号变化后位置飘移
4. 标注丢失或恢复失败
而这些问题都很适合被自动化测试尽早捕获。
#### 问题 3:如果直接从 UI 自动化起步,成本和脆弱性都偏高
EPUB 阅读器的很多问题发生在 UI 之下,例如:
1. TOC href 标准化错误
2. `fragment -> offset` 映射偏移
3. 分页结果为空或页数异常
4. `href + progression` 回落恢复逻辑不稳定
如果这些问题都等到 `XCUITest` 才暴露:
1. 定位根因会很慢
2. 测试执行时间会更长
3. 异步加载、动画、WebView 时序会让用例更脆
#### 问题 4:当前还没有固定的样书基线与测试夹具约定
要让自动化测试长期稳定,必须明确:
1. 哪几本样书是测试夹具
2. 每本样书用于验证哪种能力
3. 哪些断言可以依赖“页号”,哪些只能依赖 `href / fragment / progression`
如果没有这层约束,测试很容易随着样书变化或样式调整而频繁漂移。
## 需求拆解
建议把“自动化测试”需求拆成四部分,而不是把所有验证都堆到同一层。
### 1. 建立测试基础设施
先补齐可执行自动化测试的工程结构,包括:
1. `ReadViewSDKDemoTests`
2. `ReadViewSDKDemoUITests`
3. 样书 fixtures 目录
4. 统一的测试资源读取辅助工具
这是后续一切测试工作的前提。
### 2. 建立逻辑层 XCTest
这部分优先覆盖稳定、纯逻辑、可快速执行的能力:
1. parser 解析
2. href 标准化
3. TOC 路径映射
4. location / progression / fragment 相关转换
5. persistence 编解码
目标是让每次改动 parser、模型、定位和持久化逻辑时,都有快速反馈。
### 3. 建立集成层 XCTest
这部分不直接走 UI 自动化,但会驱动真实样书、真实分页和真实恢复链路,重点覆盖:
1. reflowable 样书分页
2. fixed-layout 样书页面模型生成
3. 字号变化后位置恢复
4. 目录跳转命中
5. 标注恢复
目标是把“阅读核心链路”在逻辑层和 UI 层之间补上一层更贴近真实运行的保护网。
### 4. 建立少量高价值 XCUITest
UI 自动化不追求全覆盖,而是做真实用户闭环的冒烟回归,例如:
1. 打开样书进入阅读器
2. 目录跳转
3. 调整字号
4. 创建高亮并重新进入验证恢复
5. 横竖屏切换后继续阅读
目标是证明“用户真的能完成关键操作”,而不是把所有细节都放进 UI 测试里。
## 技术路线对比
### 路线 A:优先建设 XCUITest
思路:
1. 先搭 UI 自动化
2. 用点击、滑动、旋转和断言可见文本的方式覆盖主要能力
优点:
1. 结果直观,容易贴近真实用户行为
2. 可以较快形成“端到端可用”的感知
风险:
1. 调试成本高
2. 受动画、异步、WebView 时序影响大
3. 很难快速判断问题出在 parser、分页还是 UI
4. 执行时间更长,不适合作为最早期主回归层
### 路线 B:优先建设 XCTest
思路:
1. 先为逻辑与集成层补单元测试
2. 把解析、分页、定位恢复等能力尽量在非 UI 层验证
优点:
1. 稳定性更高
2. 运行更快
3. 更利于问题定位
4. 更适合作为日常改动的主回归层
风险:
1. 不能完全证明 UI 交互链路没问题
2. 覆盖不到真实点击、WebView 手势和界面状态同步问题
### 路线 C:分层推进
思路:
1. 先建立测试基础设施
2. 以 XCTest 为主搭建核心保护网
3. 再补少量 XCUITest 做端到端闭环
优点:
1. 投入与收益平衡更好
2. 能优先保护最容易回归的核心能力
3. 既兼顾稳定性,也兼顾真实用户路径
风险:
1. 初期需要先设计测试层次和夹具约定
2. 需要控制 UI 测试范围,避免后续无节制膨胀
### 推荐结论
建议采用:
**主路线:路线 C,分层推进,XCTest 为主,XCUITest 为辅**
原因:
1. 当前最需要保护的是解析、分页、定位恢复这些高风险核心链路
2. 仓库当前还没有测试 target,先从更稳定的 XCTest 起步更合适
3. 少量 XCUITest 足以证明关键阅读流程可用,不必一开始追求全面 UI 覆盖
## 推荐方案
### 方案总览
自动化测试第一阶段建议形成如下结构:
```text
ReadViewSDKDemoTests
- ParserTests
- ResourceResolverTests
- LocationRestoreTests
- TextPaginationTests
- HighlightPersistenceTests
ReadViewSDKDemoUITests
- ReaderSmokeTests
- ReaderNavigationTests
- ReaderAnnotationTests
- ReaderRotationTests
```
同时建立一套最小样书夹具集:
1. `reflowable-basic.epub`
2. `fixed-layout-basic.epub`
3. `image-heavy.epub`
4. `toc-fragment.epub`
每本样书都应明确“它用来验证什么”,而不是仅作为示例文件存在。
### 关键改动建议
#### Task T1:建立测试 target 与基础资源加载能力
文件范围:
1. `ReadViewSDKDemo/ReadViewSDKDemo.xcodeproj`
2. 新增 `ReadViewSDKDemoTests/`
3. 新增 `ReadViewSDKDemoUITests/`
4. 新增测试夹具目录
改动:
1. 增加 unit test target
2. 增加 UI test target
3. 为测试 target 挂载样书与辅助资源
4. 提供统一 `TestBookLoader` / `FixtureLocator` 之类的测试辅助工具
完成定义:
1. 工程可以直接运行测试
2. 测试代码可以稳定读取样书资源
#### Task T2:补第一批逻辑层 XCTest
建议首批覆盖:
1. `RDEPUBParser` 能解析有效样书并产出非空 spine
2. `RDEPUBResourceResolver.normalizedHref(_:)` 能正确处理相对路径、父级路径和 fragment
3. TOC href 能统一映射到与 spine 一致的标准化路径
4. `RDEPUBHighlight` persistence 编解码结果一致
5. 定位模型在常见输入下不会丢 fragment 或 progression
完成定义:
1. parser / resolver / persistence / location 改动有自动回归保护
#### Task T3:补第一批集成层 XCTest
建议首批覆盖:
1. reflowable 样书分页结果非空
2. fixed-layout 样书能建立页模型
3. 字号变化后仍能按 `href + progression` 恢复到原章节附近
4. 目录跳转可命中预期 spine 资源
5. 高亮恢复后仍能定位到对应资源
完成定义:
1. 核心阅读链路具备非 UI 层的真实运行回归能力
#### Task T4:补第一批冒烟型 XCUITest
建议首批覆盖:
1. 打开样书并进入阅读器
2. 打开目录并跳转章节
3. 调整字号后继续阅读
4. 创建一条高亮并重进验证恢复
5. 横竖屏切换后继续阅读
完成定义:
1. 至少有一条真实用户闭环可以证明主功能未损坏
## 断言策略建议
为了降低测试漂移,建议统一以下断言原则:
### 1. 少依赖固定页号
页号非常容易受以下因素影响:
1. 字号
2. 行距
3. 视口尺寸
4. 图片加载时序
因此,自动化测试不应过度依赖“必须是第 N 页”这种断言。
### 2. 优先依赖稳定语义锚点
更推荐的断言对象包括:
1. `href`
2. `fragment`
3. `progression` 所在区间
4. spine index
5. 高亮所属资源
### 3. UI 测试只验证关键闭环
XCUITest 中更适合验证:
1. 页面是否成功进入
2. 关键按钮是否可操作
3. 操作后阅读器状态是否变化
4. 重进后状态是否可恢复
而不适合在 UI 测试中承载大量底层分页细节断言。
## 验收矩阵建议
自动化测试第一阶段至少要覆盖以下维度:
1. `webInteractive`
2. `webFixedLayout`
3. `textReflowable`
4. TOC 跳转
5. 字号变化后恢复
6. 高亮恢复
7. 横竖屏切换
8. 一到两本问题样书回归
如果资源有限,建议先保证“三条渲染路径 + 两条恢复链路”:
1. 三条渲染路径:`webInteractive` / `webFixedLayout` / `textReflowable`
2. 两条恢复链路:阅读位置恢复 / 标注恢复
## 风险与注意点
### 风险 1:测试一开始就绑定大量脆弱 UI 细节
如果测试过度依赖:
1. 动画时长
2. 可见文案位置
3. 某个具体页号
4. WebView 内即时渲染完成时机
后续维护成本会非常高。
### 风险 2:测试夹具过多但目标不清晰
样书数量不是越多越好。更重要的是:
1. 每本样书验证哪类能力
2. 哪些样书是主回归集
3. 哪些样书只在专项验证时使用
### 风险 3:横竖屏与字号变化断言过于绝对
这两类场景更适合验证:
1. 是否仍在同一章节或邻近语义位置
2. 是否仍保留原始 `href / fragment`
3. progression 是否在合理偏差范围内
而不适合验证“必须恢复到完全相同页号”。
## 推荐实施顺序
如果目标是“最小投入换最大稳定性提升”,建议按以下顺序推进:
1. 建立 `Tests` / `UITests` target
2. 补 parser / resolver / location / persistence 的 XCTest
3. 补分页、目录跳转、定位恢复的集成测试
4. 补 3 到 5 条高价值 XCUITest
5. 将横竖屏、问题样书和新增功能逐步并入回归矩阵
## 推荐结论
“自动化测试”这项需求,建议最终收敛为下面这句话:
**为 EPUB 阅读器建立一套分层自动化测试体系,以 XCTest 保护解析、分页、定位恢复、目录跳转和标注恢复等核心链路,再以少量 XCUITest 验证真实阅读闭环。**
这样做的好处是:
1. 能尽快为最容易回归的核心能力建立保护网
2. 不会过早陷入脆弱的全量 UI 自动化
3. 能为横竖屏、书签、搜索和后续阅读增强功能提供稳定回归基础
@@ -0,0 +1,154 @@
# 长章节内存优化 — 代码实施清单
> 背景:DTCoreText 文本页已采用"整章上下文布局",分页与显示边界一致(不可回退)。现状代价:每次翻页/预加载都整章重拷贝 + 重排版后丢弃,且章节缓存里每页常驻一份子串副本。
>
> 总原则:保留"整章参与排版",去掉"整章按页复制、按页可变、按页缓存"。不改分页器,不上窗口化。
---
## P0:立即可做
### P0-1 削减每页 `content` 子串常驻副本
**现状**:分页产物为每页保存整段 `attributedSubstring`,全章加总约等于又一份整章副本,随邻接窗口(radius 1,共 3 章)常驻。
生成点:
- `Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift:494``buildPagesFromRanges`
- `Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift:294`
- `Sources/RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift:78`
现存全部消费点(已盘点确认,仅 3 处):
- `RDEPUBTextContentView.swift:562` — 封面检测 `coverImage(from: page.content)`,仅 `pageIndexInChapter == 0` 且 href 含 "cover"
- `RDEPUBTextContentView.swift:641``normalizedPageContent`,仅非 DTCoreText 的 `#else` 回退路径调用
- `RDEPUBTextBookBuilder.swift:93` — 构建期 debug 日志 preview
**任务**
- [x]`RDEPUBTextPage.content` 改为按需构造:已改为基于 `chapterContent` + `contentRange` 的计算属性(带范围钳制),公开读取 API 不变
- [x] 三个构造点(`RDEPUBChapterLoader` / `RDEPUBTextBookBuilder` / `RDEpubPlainTextBookBuilder`)不再生成每页子串;消费点经计算属性透明按需构造
- [x] `Equatable` 确认:`content` 为派生值,由 `contentRange` + `chapterContent` 判等覆盖,语义不变
**API 兼容性注意**
- `RDEPUBTextPage` 当前是 `public struct``content` 也是公开存储属性;如果 SDK 已存在外部接入方直接读取 `page.content`,则不应直接移除该字段
- 优先方案是保留 `content` 对外访问语义,但将其改为按需构造的计算属性或受控访问器,避免对外 API 断裂
- 若确认当前版本尚无稳定外部依赖,才可以评估把 `content` 从存储属性调整为内部实现细节
- 在实施前应补做一次全仓库与接入侧检索,确认是否有外部调用依赖 `page.content` 的“可变存储”特性
**验收**:打开 3 章窗口后常驻内存下降约"一整章文本规模 × 窗口章数";封面页显示正常;非 DTCoreText 回退路径回归通过;`--demo-pagination-validate` 命中数不回升。
### P0-2 增加内存打点
- [x] 已新增 `RDEPUBMemoryProbe``--demo-memory-probe` 启动参数开启,输出 phys_footprint);挂载点:`RDEPUBChapterRuntimeStore.insertChapter``pageNum` 回调每 20 次翻页、`invalidateAllForSettingsChange``viewWillTransition` 转屏完成
- [x]`--demo-memory-probe` 运行 Demo 即可采集对比基线
### P0-3 保持现有缓存限制,防止错误复用
- [x] `shouldAvoidReaderPageCaching``RDEPUBTextContentView.swift:319`)保持现状,已补注释说明放开的前置条件是 P1-1 落地
- [x] 本阶段未改 `RDEpubReaderPreloadController` 行为
---
## P1:第二波(P1-1 与 P1-2 必须同批落地)
### P1-1 章节级共享 displayContent
**现状**`RDEPUBTextContentView.configure` 每次创建整章可变副本(`RDEPUBTextContentView.swift:443`)并按页写入三类属性:页范围主题色(453)、页范围暗黑图附件替换(448)、按页裁剪的高亮属性(459 `applyHighlightsToContent`)。
**约束**:共享字符串一旦被任一页的 `DTCoreTextLayouter`/framesetter 持有,就不得再修改——所有属性必须在共享内容构建时一次性注入,或转为 overlay(见 P1-2)。
**任务**
- [x] 已新建 `RDEPUBChapterDisplayContentCache`LRU 容量 2,主线程限定,`RDEPUBReaderController` 持有):`content` + `layouter` + `signature`
- [x] 构建共享内容时一次性注入:全章范围主题色;高亮/下划线因 P1-2 转 overlay,不进共享内容也不进签名
- [x] 暗黑图策略选 a:构建时全章一次性替换,复用 `RDEPUBDarkImageAdjuster` 的 NSCache
- [x] `RDEPUBTextContentView.configure` 已改为接收 `displayCache` 参数并引用共享 entry,整章拷贝已删除
- [x] 签名 = 章节内容对象标识 + 长度 + 主题双色 + 暗黑图配置;设置/主题变化经签名自动失效重建(高亮变化不触发重建,仅重建 overlay 装饰)
- [x] 控制器在 `didReceiveMemoryWarning` 清空 display cache;设置变更重建 chapterContent 实例后由签名兜底换代
**验收**:连续翻页不再出现整章拷贝(打点确认);翻页延迟下降;高亮显示、高亮点击菜单、underline、搜索 currentMatch、附件点击、选区拖拽全部回归通过。
> 2026-07-08 回归记录:高亮/批注/选区/翻页/设置 13 项 UI 用例全过;SearchTests 10 项失败,但已在会话前提交 5a41066 与 P0 提交 e4e629a 上复现同样失败(搜索 0 命中),确认为先于本优化存在的独立回归(最后一次已知通过是 2026-06-08 全量跑),需单独排查,与 P1 改动无关。
### P1-2 高亮/下划线剥离为 overlay(与 P1-1 绑定)
**现状**:搜索高亮已走 overlay`RDEPUBTextContentView.swift:759``buildDecorations``highlights: []`,注释明确为避免双画);普通高亮/下划线仍靠写 `kRDEPUBHighlightAttributeName` / `kRDEPUBUnderlineAttributeName` 属性由 render view 绘制。
**任务**
- [x] `buildDecorations` 已传入真实 highlights,高亮进背景层(文字下方)、下划线进前景层
- [x] `applyHighlightsToContent` 与 render view 的 `drawHighlights`/`computeHighlightRects`/`attributedDisplayContent` 已整体移除,无双画
- [x] 高亮命中与菜单锚点继续走 `interactionController.selectionRects`,坐标保持 chapter-absolute(未改动)
**验收**:高亮/下划线视觉与改前一致(含跨页高亮的页内裁剪);高亮点击弹菜单正常;与搜索高亮叠加时无双画。
### P1-3 章节级共享 display layouter
**现状**:每个页面视图各建一个 `DTCoreTextLayouter``RDEPUBTextContentView.swift:465`);章节运行时已有分页用 `RDEPUBTextLayouter``RDEPUBRuntimeChapter.layouter`),display layouter 向同一模式靠拢。
**任务**
- [x] `DTCoreTextLayouter` 已随共享内容放入 display cache,与 signature 同生命周期
- [x] 页面按 `bounds` + `page.contentRange``layoutFrame``shouldCacheLayoutFrames = false` 保持,layoutFrame 按页新建(VerticalJustifier 的修改不会污染共享对象)
- [x] framesetter 只依赖字符串、与 bounds 无关,bounds 变化无需失效共享对象;layoutFrame 随页面视图释放
- [x] `RDEPUBTextPageBoundaryValidator` 校验路径不变
**验收**:翻页时不再重建 framesetter(打点确认);横竖屏切换后布局正确;`--demo-pagination-validate` 不回升。
### P1-4 评估重新放开文本页预加载缓存
前置:P1-1~P1-3 全部落地后,页面视图不再持有整章副本。
- [ ] 调整 `shouldAvoidReaderPageCaching` 判定,允许轻量化后的文本页进入 `RDEpubReaderPreloadController` 缓存
- [ ] 对比放开前后的翻页流畅度与峰值内存,数据不佳则回退此项
---
## P2:视 P0/P1 打点数据决定
### P2-1 章节级 LRU 分层淘汰
**现状**`RDEPUBChapterRuntimeStore` 已有邻接窗口(`windowSpineIndices`radius 1)、`evictableSpineIndices` / `evict` / `evictAllExceptCurrent` / `handleMemoryWarning` / `invalidateAllForSettingsChange`;磁盘侧有 `RDEPUBChapterSummaryDiskCache`(分页结果 + cfiMap + metadata)。当前邻接章节仍持有完整 `RDEPUBRuntimeChapter`typesetString + pages + layouter)。
**任务**
- [ ] 邻接章节降级:保留 page map / chapter summary / 分页结果,释放 `typesetAttributedString`、display content、layouter
- [ ] 进入邻接章节时按 summary 重建,测量重建延迟;延迟不可接受则维持现状
- [ ] 远距章节仅保留磁盘缓存 + 轻量 metadata(现有 evict 已覆盖,确认即可)
### P2-2 图片附件深化
**现状**:暗黑图已走 `NSCache`50 MB / 100 张,`RDEPUBDarkImageAdjuster.swift:9`)且仅处理当前页范围;章节运行时另有 100 MB `imageCache`
**任务**
- [ ] 大图按显示尺寸下采样后再参与 attachment
- [ ] 页面离屏后释放 attachment 强引用的已解码大图;避免共享 content 长期持有
- [ ] 回归:图片点击查看、暗黑模式切换、附件位置与命中
---
## P3:仅在 P0–P2 之后数据仍不达标时
- [ ] 上下文窗口化布局(当前页所在段落块 + 前后足以影响断行的文本窗口)。风险最高:易重新引入分页/显示边界不一致(CTTypesetter 断行上下文敏感,为已知已解 bug 的根因),选区/搜索/高亮/附件索引需全部重验。默认不做。
---
## 明确不做
1. 回退"页内子串重新布局"(重新引入显示不全/页末孤字)
2. 取消 chapter-absolute 索引(破坏点击/选区/高亮定位一致性)
3. 先改分页器(正确性与性能问题混杂,无法回归)
4. 未落地 P1 前放开文本页 reader 级缓存(会驻留多份整章副本)
---
## 各阶段统一验收指标
**正确性**`--demo-pagination-validate` 命中数不回升;长章节页末无孤字/缺字;高亮显示、搜索高亮、附件点击、选区拖拽行为一致。
**内存**(对比 P0-2 基线):打开长章节后常驻;连续翻页 20 页峰值;设置变更前后峰值;横竖屏切换峰值。
**性能**:首次打开章节耗时;连续翻页帧稳定性;设置切换恢复时间。
+123
View File
@@ -0,0 +1,123 @@
# 分页问题调查记录(2026-07-06
调查载体:《宝山辽墓材料与释读》(textReflowable / DTCoreText 路径),iPhone 15 Pro Max 尺寸(430×932,内容区 398×815pt)。当日共调查两个独立问题:
---
## 问题一:页底留空一行多,下一页首行未上移(已修复)
### 现象
第 6 页底部留有约 1.7 行高的空白,第 7 页首行"人目为帝羓,信有之也。[注]"本可容纳在第 6 页。截图实测:行距 75px@3x(25pt),第 6 页底部空隙 138px,足够再排一行。
### 根因链
1. `RDEPUBSemanticMarkerInjector.inferredHints` 给**所有** `<img>` 打上 `avoidPageBreakInside` 提示——包括行内脚注小图标(`<img class="qqreader-footnote">`,即"注"字图标)。
2. `RDEPUBPageBreakPolicy.shouldTreatAvoidHintAsBlockProtection` 本有豁免:`blockKind == .attachment && placement != .centered → 不保护`。但 `applyPaginationSemantics` 按**结束标记的字符串顺序**应用属性:`<img>` 的结束标记先于外层 `<p>`,段落随后把图标位置的 `.rdPageBlockKind``attachment` 覆盖为 `paragraph`;而 `hints``placement` 因段落不携带这两个属性而幸存。豁免条件因此失效。
3. `trimmedRangeForAvoidPageBreakInside` 把含注图标的行当作"不可分页块"的行,从页底裁掉(最多 3 行),推到下一页,留下空白。
### 验证手段
- Demo 启动参数 `--demo-pagination-debug` 输出 `[PAGINATION-DEBUG] avoidPageBreakInside removed N lines: …`;修复前被裁的行**全部**含 ``(脚注图标)。
- 复现命令:`--demo-book-title 宝山 --demo-page 6 --demo-reset-state --demo-clear-cache --demo-pagination-debug`
### 修复
`RDEPUBPageBreakPolicy.swift`:豁免条件放宽为 `(blockKind == .attachment || placement != nil) && placement != .centered`——不再依赖易被覆盖的 blockKind,只要附件 placement 是行内/基线(非居中)就不锁行。居中插图(bodyPic)的整块保护不受影响;同时覆盖 `s-pic`/`h-pic`/`g-pic` 等生僻字行内小图。
修复后验证:含 `` 的裁剪从多处降为 0;"人目为帝羓"行回到第 6 页且下一段首行补齐;全书页数 280 → 273(消除欠填页)。
> 备选方案(未采用):修 `applyPaginationSemantics` 的嵌套覆盖顺序(内层优先)。会改变列表/表格的 blockRange 语义,风险大。
---
## 问题二:页范围与显示内容度量错配,行中断页(2026-07-07 已定位根因,见文末新增章节)
### 现象
第 10 页最后一行只有孤字"相",第 11 页从"对简化,且无构造细致的翼墙。"开始——断点落在句子中间而非行边界。
### 铁证推理
当前设置下满行 26 字(字号 15、内容宽 398pt)。第 10 页范围要在"…而2号墓的门楼则相"后断开,要求该行装下 **27 字**——当前排版下不可能。**结论:页范围产生于另一套度量(更小字号或更宽版面),与显示时的排版不一致。** 同一套排版中分页断点必然落在行边界,孤字不可能出现。
### 已排除(受控实验)
`RDEPUBChapterLoader` 临时加 `--demo-chapter-dump` 转储(typesetString 逐属性段 + 页范围,写入 app Documents,实验后已还原):
- 冷启动(`MISS(fullRender)` 全量渲染分页)与热启动(`HIT(diskSummary)` 复用磁盘页范围)两条路径的字符串与页范围**完全一致**。字体压平、双重 `normalizeReadingAttributes`、语言码缺失(`makeChapterRenderRequest` 未传 `contentLanguageCode`)、`TailNormalizer` 尾页合并均验证为无影响或幂等。
- 持久缓存键控正确:`RDEPUBChapterCacheKey.renderSignature` 含字体名/字号/行距倍数/lineSpacing/`layoutConfig.cacheSignature`(宽高、四边距、分栏、hyphenation、schemaVersion=17+ 章节内容哈希。同设置跨启动复用安全。
### 主要嫌疑:会话中改设置的换表过渡态
- 用户两组截图之间调过行距(行距 25pt/页 32 行 → 28pt/页 28 行,字号未变);出现问题时显示总页数 196,既非 1.6 档全量分页(~273)也非 1.8 档(~290)——当时显示的是**未收敛的中间页表**。
- 代码窗口:
- `RDEPUBReaderRuntime.scheduleSettingsPreviewRepagination`:设置面板打开期间只重排**当前章**并 `applySettingsPreviewPageMap` 换部分页表,全量重排推迟到面板关闭(`needsFullRepaginationAfterSettingsClose`)。
- `RDEPUBChapterRuntimeStore.chapterDataCache` 仅按 spineIndex 键控(无样式维度);`invalidateAllForSettingsChange` 清空后,**变更前已在飞行中的构建**完成时会把旧样式章节重新 `insertChapter` 插回。
- 这些窗口里可能出现"旧页范围 + 新排版内容"的错配。
### 待办 / 复现所需
1. 确认触发操作:是否在设置面板调整行距/字号后(未关面板或刚关)翻页出现。
2. 确认重启 app 后是否自愈(受控实验表明缓存路径本身一致,理应自愈;若不自愈则有持久化的脏状态)。
3. 修复方向(待复现后定):设置变更代(generation)标记贯穿构建流程,飞行中的旧代构建完成后丢弃、不得插回 store;或 `chapterDataCache` 键加入 renderSignature。
### 经验教训
- 跨启动比较"第 N 页"截图无效:章节渐进加载过程中全局页码会漂移。
- 判断断点是否合法的快捷方法:数满行字数。断点不在行边界 ⟺ 范围与显示度量不一致。
---
## 相关工具与命令备忘
| 用途 | 方法 |
|---|---|
| 分页裁剪日志 | 启动参数 `--demo-pagination-debug` |
| 自动打开书籍/翻页 | `--demo-book-title 宝山 --demo-page N --demo-clear-cache --demo-reset-state` |
| 注入阅读设置 | `simctl spawn <sim> defaults write cn.shen.ReadViewDemo ssreader.epub.settings -data <hex(JSON)>`JSON 形如 `{"fontSize":15,"lineHeightMultiple":1.8}` |
| 截屏 | `xcrun simctl io <sim> screenshot <绝对路径>`(相对路径会因只读文件系统失败) |
| 模拟器 | 预装仅 16/17 系列;`simctl create` 可建 iPhone 15 Pro Max430×932@3x 与问题截图同尺寸) |
| 页边界/换行一致性校验 | 启动参数 `--demo-pagination-validate`RDEPUBTextPageBoundaryValidator,逐页比对显示换行与全章上下文换行,输出 STALE-RANGE / DISPLAY-DIVERGE / DIAGNOSE / ATTR-DIFF |
| 设置面板自动翻转 | 启动参数 `--demo-settings-flip`RDEPUBSettingsFlipAutomation,自动开面板→改行距→关面板→翻页,三种时序) |
---
## 问题二根因(2026-07-07 续查确认)
### 结论
**分页与显示使用同一引擎(DTCoreText/CoreText)但输入字符串不同:分页在“全章字符串”上下文中断行;显示把页内容取成子串后重新断行。`CTTypesetterSuggestLineBreak` 在同一文字、同一属性、同一宽度下,会因字符串上下文不同而在 CJK 标点压缩/悬挂处产生 ±1 字的断点漂移。** 页范围(全章上下文产物)与显示换行(子串产物)在页内任何一行发生漂移,累积到页尾就出现孤字(用户看到的「相」)或半空行。
与缓存、设置换表竞态无关:设置变更只是**搬动了页边界位置**,让某个边界恰好落在漂移点上,症状因此看似随设置变化出现/消失。这同时解释了此前所有观察:冷/热启动转储完全一致(分页自身是一致的,错配发生在分页 vs 显示);重启后"第 N 页"不复现(边界挪走了)。
### 证据(宝山书 spine 3,字号 15 / 行距 1.6 / 宽 398pt
`--demo-pagination-validate` 基线运行(无任何设置操作)即命中,前 8 页中 5 页分叉。决定性样本 page 8/55 第 11 行(行首 offset 4224):
- 显示端断点 4252(行尾"…可以相"——孤字机制当场复现),全章上下文断点 4253("…可以相当"
- 逐字符属性 diff**完全一致**(无 ATTR-DIFF
- 对照排版:原始子串 = 4252,段落级上下文 = 4252**只有全章上下文 = 4253**
- page 6/55 的上下文探针行宽 408.39 > 框宽 398 —— 标点悬挂/压缩越界的直接证据
即断点差异既不是首行缩进归一化(`normalizedPageContent` 的 firstLineHeadIndent 处理本身是正确的),也不是属性差异,而是 CoreText 断行对字符串上下文(跨段落!)敏感。
### 修复(方案 A2026-07-07 已实施)
显示端改为**全章上下文排版**`RDEPUBTextContentView.configure``page.chapterContent` 的整章副本作为显示内容,`DTCoreTextLayouter(章节副本).layoutFrame(bounds, range: page.contentRange)` 只排当页范围。断行与分页器按构造一致(同串、同起点、同宽)。
配套改动:
- layoutFrame 的 stringRange 因此为**章节绝对坐标**。`RDEPUBPageLayoutSnapshot.build` 去掉 +pageStartOffset 归一(本就输出绝对坐标给消费方),`RDEPUBPageInteractionController` 去掉全部 -pageOffset 反换算;选区/高亮/点击测试/装饰层的消费接口本来就是绝对坐标,无需变动。
- 主题前景色、暗色图片调整(`RDEPUBDarkImageAdjuster` 新增 `in range:` 参数)、高亮标记只施加在章节副本的当页范围上;`applyHighlightsToContent` 改用绝对 overlap 范围。
- 删除 DTCoreText 路径对 `normalizedPageContent` 的调用——续段首行缩进/段前距归一化 hack 由上下文排版天然取代(非 DTCoreText 回退路径仍保留)。
- layouterframesetter)随显示内容缓存于 cell(`coreTextLayouter`),bounds 变化只重建 layoutFrame,避免每次 layout pass 对整章重建 framesetter。
- `RDEPUBTextPageBoundaryValidator` 适配绝对坐标,保留为回归警报。
验证:`--demo-pagination-validate` 基线(原先 spine 3 前 8 页 5 处分叉)修复后 **0 命中**;第 10 页首行断点与分页一致(「…使用了更多」);Selection/Annotation UI 测试回归通过(见下)。
### 遗留(独立的潜在缺陷,本次未触发)
- `RDEPUBChapterRuntimeStore.chapterDataCache` 仅按 spineIndex 键控;设置变更时飞行中的旧构建完成后仍会插回(`RDEPUBChapterLoader.loadChapter``insertChapter` 无代际校验)。
- `RDEPUBChapterLoader.pendingLoads` 按 spineIndex 合流,跨设置变更合流会把旧样式章节交给新请求。
- 建议修复问题二后用 `--demo-settings-flip` + `--demo-pagination-validate` 回归验证这两个窗口。
@@ -0,0 +1,411 @@
# RDAIReaderView AI 系统设计合同
**文档状态:** Draft 0.1
**最后更新:** 2026-07-25
**目标版本:** RDAIReaderView 1.0
## 1. 系统分类
RDAIReaderView 是一个本地优先的 Retrieval-Augmented Generation 阅读系统,包含:
- 确定性 NLP:语言识别、分句、实体候选、词法检索。
- 本地语义检索:Natural Language embeddings。
- 生成式任务:摘要、书内问答、人物卡片和人物关系。
- 引用约束:生成内容必须映射到 PDF/EPUB 原文。
系统不是通用聊天机器人,也不把模型自身知识作为书籍事实来源。
## 2. 框架选择
### 2.1 主框架
- Apple Natural LanguageiOS 15+ 基础分析和检索。
- Apple Foundation Models:支持设备上的生成式增强。
- Vision:扫描 PDF OCR,继续复用 RDPDFReaderView 现有能力。
### 2.2 选择理由
- 与当前纯 Swift/UIKit/CocoaPods 架构一致。
- 默认设备端处理,不需要新增服务端和书籍上传链路。
- Natural Language 可覆盖不支持 Apple Intelligence 的设备。
- Foundation Models 支持 structured generation 和 tool calling。
- 系统 API 可与现有 PDF/EPUB 定位直接结合。
### 2.3 未选择的首版方案
| 方案 | 首版不采用原因 |
|------|----------------|
| LangChain/LlamaIndex | 主要面向 Python/服务端,增加不必要基础设施 |
| 云端 LLM | 引入内容上传、成本、隐私、版权和网络可用性问题 |
| ONNX Runtime 自带模型 | 需要自行选择、量化、分发和维护语言模型 |
| 自训练 Foundation Models Adapter | 模型版本绑定和 entitlement 增加发布复杂度 |
Core 保留 `RDAIGenerativeProvider`,以后可以增加其他 Provider,不把 Apple 实现写死在公共业务层。
## 3. 模型可用性合同
调用 Foundation Models 前必须检查:
1. API 在当前系统可用。
2. `SystemLanguageModel.default.availability` 为 available。
3. 当前 Locale 被支持。
4. 当前请求未超过并发限制。
5. 当前任务的上下文预算可满足。
不可用原因映射:
| Apple 状态 | RDAI 状态 | UI 行为 |
|------------|-----------|---------|
| deviceNotEligible | `.deviceNotEligible` | 隐藏生成操作,保留基础分析 |
| appleIntelligenceNotEnabled | `.appleIntelligenceNotEnabled` | 说明可在系统设置中开启 |
| modelNotReady | `.modelNotReady` | 展示模型准备中,可稍后重试 |
| unsupported locale | `.languageUnsupported` | 保留检索,关闭生成 |
| unknown | `.unknown` | 通用不可用状态,允许重试 |
禁止通过静态设备型号列表推断可用性,运行时状态是唯一依据。
参考:
- [Foundation Models](https://developer.apple.com/documentation/FoundationModels)
- [SystemLanguageModel](https://developer.apple.com/documentation/FoundationModels/SystemLanguageModel)
- [语言与 Locale 支持](https://developer.apple.com/documentation/foundationmodels/supporting-languages-and-locales-with-foundation-models)
## 4. 输入与上下文策略
### 4.1 唯一事实来源
模型可以使用:
- 本次请求提供的 Passage。
- Passage 的章节标题、页码/资源信息。
- 用户当前问题。
- 非内容性规则,例如输出语言和防剧透范围。
模型不得把训练知识、其他书籍、互联网知识或先前书籍会话作为当前书籍事实来源。
### 4.2 上下文预算
运行时读取模型 `contextSize`,并使用 `tokenCount(for:)` 估算。
初始预算比例:
- 12%instructions 和 schema。
- 5%:用户问题。
- 60%:检索 Passage。
- 17%:输出。
- 6%:安全余量。
若预算不足,按以下顺序处理:
1. 删除低分 Passage。
2. 缩短 Passage 到完整句子边界。
3. 使用预计算的有引用片段摘要。
4. 创建新会话。
5. 仍不足则返回 `contextLimitExceeded`
不得静默截断引用或生成半个结构化对象。
参考:[Managing the context window](https://developer.apple.com/documentation/foundationmodels/managing-the-context-window)
## 5. Prompt 资产管理
Prompt 作为版本化代码资产保存:
```text
FoundationModels/Prompts/
├── summary_v1.swift
├── answer_v1.swift
├── characters_v1.swift
└── relationships_v1.swift
```
每个 Prompt 定义:
- `identifier`
- `version`
- `minimumModelProfile`
- `instructions`
- 输入构造器
- 输出 schema
- 评测集标签
修改 Prompt 必须:
1. 增加版本号。
2. 跑完整离线评测集。
3. 与上一版本对比准确率、拒答、引用和延迟。
4. 更新缓存失效策略。
## 6. 通用 Instructions
所有任务共享以下不可省略规则:
```text
你是书内阅读助手。
只使用提供的原文片段,不使用外部知识补充书中事实。
每个事实性结论必须引用一个或多个有效片段 ID。
证据不足时返回 insufficientEvidence,不猜测。
不得引用允许阅读范围之外的内容。
区分原文明确事实与可能推断。
使用用户当前语言回答。
```
实际实现使用简洁英文或经评测验证的目标语言 instructions;以上文字表达语义合同,不要求逐字使用。
## 7. 结构化输出
### 7.1 摘要 Schema
```swift
@Generable
struct GeneratedSummary {
var overview: String
@Guide(.maximumCount(6))
var points: [GeneratedStatement]
}
@Generable
struct GeneratedStatement {
var text: String
@Guide(.minimumCount(1), .maximumCount(3))
var passageIDs: [String]
}
```
规则:
- `overview` 只能概括已提供 Passage。
- 每个 point 必须有 Passage ID。
- brief 最多 3 点,standard 最多 6 点。
### 7.2 问答 Schema
```swift
@Generable
enum GeneratedAnswerStatus {
case answered
case insufficientEvidence
}
@Generable
struct GeneratedAnswer {
var status: GeneratedAnswerStatus
var answer: String
@Guide(.maximumCount(6))
var statements: [GeneratedStatement]
}
```
`status == insufficientEvidence`
- `statements` 必须为空。
- `answer` 只说明当前已读内容没有足够依据。
- 不推荐用户从互联网获取答案,除非宿主未来明确增加该产品能力。
### 7.3 人物 Schema
```swift
@Generable
struct GeneratedCharacter {
var displayName: String
@Guide(.maximumCount(6))
var aliases: [String]
var description: String
@Guide(.minimumCount(1), .maximumCount(6))
var evidencePassageIDs: [String]
}
```
人物必须在 Passage 中有明确名称或可验证别名。纯代词不能单独创建人物。
### 7.4 关系 Schema
```swift
@Generable
enum GeneratedRelationshipStatus {
case confirmed
case possible
case conflicting
}
@Generable
struct GeneratedRelationship {
var sourceName: String
var targetName: String
var label: String
var status: GeneratedRelationshipStatus
@Guide(.minimumCount(1), .maximumCount(5))
var evidencePassageIDs: [String]
}
```
关系标签应简短,例如“师徒”“同事”“敌对”“亲属”。描述性事件放在人物事件中,不无限创建关系类型。
## 8. 任务设计
### 8.1 章节摘要
输入:
- 当前章节 Passage,或已读范围内的章节片段摘要。
- 目标长度。
- 用户 Locale。
流程:
1. 章节能放入上下文时直接生成。
2. 超长章节分块生成带引用局部摘要。
3. 聚合局部摘要时保留原 Passage ID。
4. 运行引用验证。
禁止只把局部摘要文本作为最终事实来源而丢失原始 Passage ID。
### 8.2 书内问答
流程:
1. 识别问题语言和实体词。
2. Hybrid Retrieval 召回 30 个候选。
3. 重排并过滤到 3-4 个 Passage。
4. 直接把 Passage 放入 Prompt。
5. 生成结构化答案。
6. 验证引用和阅读范围。
7. 无有效事实项时拒答。
首版优先使用代码检索,不默认使用 Tool Calling,这样召回过程更确定、可测试、节省 token。
### 8.3 人物卡片
流程:
1. Natural Language 提供人物候选及出现位置。
2. 按名称和明确别名聚合候选。
3. 检索候选周边 Passage。
4. Foundation Models 生成结构化人物信息。
5. 代码校验所有别名和证据。
6. 低置信别名保持独立候选。
### 8.4 人物关系
采用两阶段:
1. Chunk-level extraction:从局部 Passage 提取关系候选。
2. Document-level merge:按人物 ID、关系标签和时间顺序合并。
合并规则:
- 相同关系 + 相同方向:合并证据。
- 对称关系可由受控词典决定是否双向展示。
- 新证据否定旧关系:状态变为 conflicting。
- 隐含动机、情感和立场默认 possible。
- 任何关系没有有效证据时不入库。
## 9. Tool Calling
仅在单次检索无法回答、且评测证明多轮查找有明显收益时启用。最多提供三个工具:
```text
searchBook(query, scope, limit)
getPassages(ids)
getCharacterEvidence(name, scope, limit)
```
要求:
- 工具只读。
- 工具强制应用文档和已读范围过滤。
- 参数有严格长度和数量上限。
- 返回内容有 token 上限。
- 每个请求最大 Tool 调用次数为 3。
- 检测重复参数调用并终止循环。
- 工具调用和结果 ID进入本地 trace,但不记录原文。
不提供跳页、删除、购买、网络请求等副作用工具。
参考:[Expanding generation with tool calling](https://developer.apple.com/documentation/foundationmodels/expanding-generation-with-tool-calling)
## 10. 确定性后处理
模型输出必须经过:
1. Schema 解码。
2. Passage ID 存在性校验。
3. 阅读范围校验。
4. Quote/Locator 构造。
5. 重复 statement 合并。
6. 空文本和长度校验。
7. 敏感内容与系统错误映射。
8. Artifact 元数据补齐。
模型不能直接构造页码、CFI、CGRect 或数据库 ID;这些字段全部由代码通过 Passage ID解析。
## 11. 安全与产品规则
- 不将 AI 输出表示为作者原话。
- UI 明确标记“AI 生成”。
- possible/conflicting 关系必须视觉区分。
- 用户问题涉及未读内容时,默认拒绝并提示防剧透设置。
- 原文包含违法或敏感内容时,遵循系统模型 guardrails;不能绕过。
- 输入和输出触发系统安全限制时,返回稳定、非技术性的不可生成状态。
- 不要求模型提供医学、法律或金融建议;如果书中包含相关内容,只能解释“书中写了什么”。
发布前必须复核 Apple Foundation Models acceptable use requirements 和最新 App Review Guidelines。
## 12. 关键失败模式
| 失败模式 | 检测 | 处理 |
|----------|------|------|
| 模型编造人物或关系 | Passage ID/名称验证 | 删除无效项,无结果则拒答 |
| 引用存在但不支持结论 | 人工评测 + LLM judge | Prompt 调整,低分结果进入回归集 |
| 同名人物合并 | 别名证据检查 | 保持独立,标记待确认 |
| 未读内容泄漏 | Scope validator | 阻断输出并记录安全计数 |
| OCR 错字导致错误事实 | OCR source 标记和置信策略 | 降低置信度,展示 OCR 来源 |
| Prompt 在系统更新后退化 | 模型版本分桶评测 | 版本化 Prompt 和缓存 |
| 上下文溢出 | tokenCount/contextSize | 重建上下文或分层摘要 |
| Tool 循环 | 调用次数和参数去重 | 终止并降级为现有证据回答 |
| 用户快速重复请求 | request actor + cancellation | 取消旧任务或排队 |
## 13. 评测维度
| 维度 | 定义 | 1.0 门槛 |
|------|------|----------|
| Citation validity | 引用能否恢复到原文 | ≥ 99% |
| Context faithfulness | 事实是否由引用支持 | ≥ 98% |
| Refusal accuracy | 无证据时是否拒答 | ≥ 95% |
| Spoiler safety | 是否只使用允许范围 | 100% |
| Schema validity | 结构化输出是否通过校验 | ≥ 99.5% |
| Character precision | 人物是否真实出现 | ≥ 97% |
| Relationship evidence | 关系是否至少有一条证据 | 100% |
| Alias precision | 自动合并别名是否正确 | ≥ 98% |
| Retrieval recall@5 | 正确证据是否在 Top 5 | ≥ 90% |
| Answer usefulness | 人工 1-5 分平均值 | ≥ 4.0 |
## 14. 评测方法
- 代码指标:Schema、引用 ID、范围、阅读权限、延迟和拒答格式。
- 人工标注:人物、别名、关系、引用支持度、剧透边界。
- LLM judge:只用于语气、完整性和引用支持度的辅助评估;必须先与人工评分校准。
- 生产抽样:只上传宿主允许的匿名数值和用户显式反馈;不上传书籍原文。
评测集和完整方法见 [RDAIReaderView-TEST-PLAN.md](RDAIReaderView-TEST-PLAN.md)。
## 15. 发布检查清单
- [ ] 所有 Prompt 有 identifier 和 version。
- [ ] 每个生成任务使用 `@Generable`
- [ ] 每个事实输出经过 Citation Validator。
- [ ] 防剧透 Scope 在检索前和生成后各检查一次。
- [ ] Foundation Models 不可用路径已真机验证。
- [ ] 上下文预算使用运行时 API 计算。
- [ ] 模型版本变化不会复用旧缓存。
- [ ] 评测集达到所有 1.0 门槛。
- [ ] 日志不包含原文、问题或完整回答。
- [ ] Apple 最新 acceptable use 与审核要求已复核。
+604
View File
@@ -0,0 +1,604 @@
# RDAIReaderView 公共 API 设计
**文档状态:** Proposal 0.1
**最后更新:** 2026-07-25
**目标版本:** RDAIReaderView 1.0
## 1. API 设计原则
- Core 最低支持 iOS 15,不直接依赖 UIKit、PDFKit、DTCoreText 或 FoundationModels。
- 公共模型优先使用值类型,并遵循 `Codable``Sendable``Equatable`
- 文本范围统一使用 UTF-16 偏移,与现有 PDF、EPUB、NSString 和 TTS 范围保持一致。
- Reader Adapter 负责格式转换,Core 不识别 PDF 页视图或 EPUB 排版对象。
- Foundation Models 通过 Provider 协议接入,不能泄漏到基础 API。
- 公开 API 在 1.0 后遵循语义化版本;新增字段必须有解码默认值。
本文中的 Swift 定义是实现合同,允许在不改变语义的前提下调整文件组织和内部实现。
## 2. 标识符与基础范围
```swift
import Foundation
public struct RDAIDocumentIdentifier: RawRepresentable, Codable, Hashable, Sendable {
public let rawValue: String
public init(rawValue: String) {
self.rawValue = rawValue
}
}
public struct RDAIResourceIdentifier: RawRepresentable, Codable, Hashable, Sendable {
public let rawValue: String
public init(rawValue: String) {
self.rawValue = rawValue
}
}
public struct RDAITextRange: Codable, Hashable, Sendable {
public var location: Int
public var length: Int
public init(location: Int, length: Int) {
self.location = max(0, location)
self.length = max(0, length)
}
public var upperBound: Int { location + length }
}
```
约束:
- `RDAIDocumentIdentifier` 必须与宿主书籍 ID 一致并保持稳定。
- `RDAIResourceIdentifier` 在 PDF 中使用页索引字符串,在 EPUB 中使用规范化 `href`
- 所有文本范围均针对资源原始文本,不针对规范化搜索文本。
## 3. 定位与引用
### 3.1 归一化矩形
Core 使用自定义矩形,避免公共存储格式依赖 UIKit:
```swift
public struct RDAINormalizedRect: Codable, Hashable, Sendable {
public var x: Double
public var y: Double
public var width: Double
public var height: Double
public init(x: Double, y: Double, width: Double, height: Double) {
self.x = x
self.y = y
self.width = width
self.height = height
}
}
```
所有值应限制在 `0...1`。Reader Adapter 负责与 `CGRect` 转换。
### 3.2 格式 Anchor
```swift
public struct RDAIPDFAnchor: Codable, Hashable, Sendable {
public enum TextSource: String, Codable, Sendable {
case native
case ocr
}
public var pageIndex: Int
public var rects: [RDAINormalizedRect]
public var textSource: TextSource
public var readingOrder: Int?
}
public struct RDAIEPUBAnchor: Codable, Hashable, Sendable {
public var href: String
public var cfi: String?
public var rangeCFI: String?
public var progression: Double?
}
public enum RDAIAnchor: Codable, Hashable, Sendable {
case pdf(RDAIPDFAnchor)
case epub(RDAIEPUBAnchor)
}
```
`RDAIAnchor` 必须实现显式 Codable discriminator,例如 `type: "pdf"`,未知类型解码为明确错误,不能误当成其他格式。
### 3.3 通用定位
```swift
public struct RDAILocator: Codable, Hashable, Sendable {
public var documentIdentifier: RDAIDocumentIdentifier
public var resourceIdentifier: RDAIResourceIdentifier
public var textRange: RDAITextRange
public var anchor: RDAIAnchor
public var sourceHash: String
}
public struct RDAICitation: Codable, Hashable, Sendable, Identifiable {
public let id: String
public let passageIdentifier: String
public let quote: String
public let locator: RDAILocator
}
```
引用恢复流程:
1. 校验文档和资源存在。
2. 校验 `sourceHash`
3. 优先按格式 Anchor 恢复。
4. Anchor 失败时使用文本范围和 quote 搜索。
5. 仍失败则返回 `staleCitation`,不得跳转到近似但未验证的位置。
## 4. 文档与资源
```swift
public enum RDAIDocumentFormat: String, Codable, Sendable {
case pdf
case epub
}
public struct RDAIDocumentDescriptor: Codable, Equatable, Sendable {
public let identifier: RDAIDocumentIdentifier
public let title: String
public let format: RDAIDocumentFormat
public let contentRevision: String
}
public struct RDAIResourceDescriptor: Codable, Equatable, Sendable {
public let identifier: RDAIResourceIdentifier
public let title: String?
public let order: Int
public let estimatedUTF16Length: Int?
}
public struct RDAIResourceSnapshot: Sendable {
public let descriptor: RDAIResourceDescriptor
public let sourceText: String
public let sourceHash: String
public let locatorRuns: [RDAILocatorRun]
}
public struct RDAILocatorRun: Sendable {
public let textRange: RDAITextRange
public let anchor: RDAIAnchor
}
```
`contentRevision` 由宿主提供;若宿主没有版本号,Adapter 使用资源哈希汇总生成。
## 5. 内容提供协议
```swift
@MainActor
public protocol RDAIContentProvider: AnyObject {
func aiDocumentDescriptor() -> RDAIDocumentDescriptor
func aiResources() async throws -> [RDAIResourceDescriptor]
func aiResourceSnapshot(
for identifier: RDAIResourceIdentifier
) async throws -> RDAIResourceSnapshot
func aiNavigate(to locator: RDAILocator, animated: Bool) async throws
func aiShowCitationHighlight(_ citation: RDAICitation) async throws
func aiClearCitationHighlight()
}
```
协议标记为 `@MainActor`,因为现有 Reader Controller 和页面缓存均由主线程管理。实现必须只在主线程获取快照引用和 UI 状态;OCR、分块、分析和数据库写入移交后台 actor。
可选读取范围协议:
```swift
public struct RDAIReadScope: Codable, Equatable, Sendable {
public let upperBound: RDAILocator?
public let includesWholeDocument: Bool
}
@MainActor
public protocol RDAIReadScopeProviding: AnyObject {
func aiCurrentReadScope() -> RDAIReadScope
}
```
未实现时,默认只允许当前资源及之前的资源,不能默认整本书。
## 6. Passage 与分析结果
```swift
public enum RDAIPassageKind: String, Codable, Sendable {
case title
case paragraph
case list
case table
case code
case footnote
case unknown
}
public struct RDAIPassage: Codable, Equatable, Sendable, Identifiable {
public let id: String
public let documentIdentifier: RDAIDocumentIdentifier
public let resourceIdentifier: RDAIResourceIdentifier
public let text: String
public let languageCode: String?
public let kind: RDAIPassageKind
public let locator: RDAILocator
public let contentHash: String
public let order: Int
}
public enum RDAIEntityKind: String, Codable, Sendable {
case person
case place
case organization
case other
}
public struct RDAIEntityMention: Codable, Equatable, Sendable, Identifiable {
public let id: String
public let normalizedName: String
public let surfaceText: String
public let kind: RDAIEntityKind
public let confidence: Double
public let locator: RDAILocator
}
```
Natural Language 的标签不是最终人物事实,只是 `RDAIEntityMention` 候选。
## 7. 索引 API
```swift
public enum RDAIIndexState: Equatable, Sendable {
case notStarted
case indexing(completedResources: Int, totalResources: Int)
case paused
case ready
case failed(RDAIError)
}
public struct RDAIIndexOptions: Sendable {
public var scope: RDAIReadScope
public var priorityResource: RDAIResourceIdentifier?
public var allowsEmbeddingAssetDownload: Bool
}
public protocol RDAIIndexing: AnyObject, Sendable {
func prepareIndex(options: RDAIIndexOptions) async throws
func pauseIndexing() async
func resumeIndexing() async
func indexState() async -> RDAIIndexState
func stateUpdates() async -> AsyncStream<RDAIIndexState>
func removeIndex() async throws
}
```
要求:
- `prepareIndex` 幂等。
- 重复调用只能扩大范围或提高优先级,不能创建重复 Job。
- `removeIndex` 删除索引、实体和生成缓存,但不删除原书或用户笔记。
## 8. 能力与可用性
```swift
public enum RDAICapability: String, Codable, Sendable {
case languageAnalysis
case entityExtraction
case lexicalSearch
case semanticSearch
case summarization
case questionAnswering
case characterRelationships
}
public enum RDAIUnavailableReason: Equatable, Sendable {
case operatingSystemUnsupported
case deviceNotEligible
case appleIntelligenceNotEnabled
case modelNotReady
case languageUnsupported(String?)
case embeddingAssetsUnavailable
case providerNotInstalled
case unknown(String)
}
public enum RDAICapabilityAvailability: Equatable, Sendable {
case available
case degraded(reason: RDAIUnavailableReason)
case unavailable(reason: RDAIUnavailableReason)
}
public protocol RDAICapabilityProviding: Sendable {
func availability(
for capability: RDAICapability,
locale: Locale?
) async -> RDAICapabilityAvailability
}
```
UI 只能根据枚举状态展示文案,不能匹配本地化 Error 字符串。
## 9. 检索 API
```swift
public struct RDAIRetrievalOptions: Sendable {
public var maximumResults: Int
public var scope: RDAIReadScope
public var minimumScore: Double
}
public struct RDAIRetrievalMatch: Sendable, Identifiable {
public let id: String
public let passage: RDAIPassage
public let score: Double
public let lexicalScore: Double?
public let semanticScore: Double?
}
public protocol RDAIRetrieving: Sendable {
func retrieve(
query: String,
options: RDAIRetrievalOptions
) async throws -> [RDAIRetrievalMatch]
}
```
检索分数只用于同一索引版本内排序,不承诺跨版本数值稳定。
## 10. 生成结果
### 10.1 摘要
```swift
public enum RDAISummaryLength: String, Codable, Sendable {
case brief
case standard
case detailed
}
public struct RDAISummary: Codable, Sendable {
public let title: String
public let overview: String
public let keyPoints: [RDAISourcedStatement]
public let citations: [RDAICitation]
public let metadata: RDAIGenerationMetadata
}
```
### 10.2 问答
```swift
public enum RDAIAnswerStatus: String, Codable, Sendable {
case answered
case insufficientEvidence
case unsupportedLanguage
case unavailable
}
public struct RDAIAnswer: Codable, Sendable {
public let status: RDAIAnswerStatus
public let text: String
public let statements: [RDAISourcedStatement]
public let citations: [RDAICitation]
public let metadata: RDAIGenerationMetadata
}
public struct RDAISourcedStatement: Codable, Sendable, Identifiable {
public let id: String
public let text: String
public let citationIdentifiers: [String]
}
```
### 10.3 人物关系
```swift
public enum RDAIRelationshipStatus: String, Codable, Sendable {
case confirmed
case possible
case conflicting
}
public struct RDAICharacter: Codable, Sendable, Identifiable {
public let id: String
public let displayName: String
public let aliases: [String]
public let description: String
public let firstAppearance: RDAICitation?
public let evidence: [RDAICitation]
}
public struct RDAIRelationship: Codable, Sendable, Identifiable {
public let id: String
public let sourceCharacterIdentifier: String
public let targetCharacterIdentifier: String
public let label: String
public let status: RDAIRelationshipStatus
public let evidence: [RDAICitation]
}
```
### 10.4 生成元数据
```swift
public struct RDAIGenerationMetadata: Codable, Sendable {
public let providerIdentifier: String
public let modelVersion: String?
public let promptIdentifier: String
public let promptVersion: Int
public let generatedAt: Date
public let scopeHash: String
}
```
元数据用于缓存失效和问题追踪,不向普通用户展示内部 Prompt。
## 11. 高层服务
```swift
public protocol RDAIReaderServicing: AnyObject, Sendable {
func prepare(options: RDAIIndexOptions) async throws
func summarize(
scope: RDAIReadScope,
length: RDAISummaryLength
) async throws -> RDAISummary
func answer(
question: String,
scope: RDAIReadScope
) async throws -> RDAIAnswer
func characters(
scope: RDAIReadScope
) async throws -> [RDAICharacter]
func relationships(
scope: RDAIReadScope
) async throws -> [RDAIRelationship]
func removeAllAIData() async throws
}
```
建议具体实现为 actor。若用户开始新的同类请求,UI 层负责决定取消旧请求或并行;同一 Foundation Models session 不允许并行请求。
## 12. Provider 协议
```swift
public struct RDAIGenerationRequest: Sendable {
public let task: RDAIGenerationTask
public let userText: String?
public let passages: [RDAIPassage]
public let locale: Locale
public let scope: RDAIReadScope
}
public enum RDAIGenerationTask: Sendable {
case summary(RDAISummaryLength)
case answer
case characters
case relationships
}
public protocol RDAIGenerativeProvider: Sendable {
var identifier: String { get }
func availability(locale: Locale) async -> RDAICapabilityAvailability
func generate(_ request: RDAIGenerationRequest) async throws -> RDAIGeneratedArtifact
}
```
`RDAIGeneratedArtifact` 是 Core 内部或受控公共枚举,用于把 Provider 输出转换为第 10 节模型。Provider 不能直接保存结果或操作 Reader UI。
## 13. 错误模型
```swift
public enum RDAIError: Error, Equatable, Sendable {
case invalidDocument
case resourceUnavailable(RDAIResourceIdentifier)
case staleCitation
case indexingFailed(code: String)
case modelUnavailable(RDAIUnavailableReason)
case unsupportedLanguage(String?)
case contextLimitExceeded
case invalidGeneratedStructure
case invalidCitation
case insufficientEvidence
case cancelled
case storageFailure(code: String)
}
```
公共错误不携带原文或数据库底层错误字符串。内部错误映射为稳定 code,并通过本地诊断系统保存脱敏详情。
## 14. PDF Adapter
建议公开:
```swift
public extension RDPDFReaderViewController {
func makeAIContentProvider() -> RDPDFAIContentProvider
func makeAIReaderService(
configuration: RDAIReaderConfiguration = .default
) throws -> RDAIReaderServicing
}
```
映射要求:
- 页面资源 ID 为十进制页索引。
- 文本顺序使用 `RDPDFReaderTextRun.readingOrder`
- `normalizedRects` 转换为 `RDAINormalizedRect`
- 原生文本标记为 `.native`Vision OCR 标记为 `.ocr`
- AI 高亮复用或泛化现有 speech highlight,不同时维护两个相互覆盖的临时层。
## 15. EPUB Adapter
建议公开:
```swift
public extension RDEPUBReaderController {
func makeAIContentProvider() -> RDEPUBAIContentProvider
func makeAIReaderService(
configuration: RDAIReaderConfiguration = .default
) throws -> RDAIReaderServicing
}
```
映射要求:
- 资源 ID 使用 ResourceResolver 规范化后的 `href`
- Passage 范围从章节 attributed content 的原始字符串计算。
- 使用现有 index table 生成 `cfi``rangeCFI`
- 导航复用 `go(to:)`/位置恢复流程。
- 固定版式或无法提取文本的章节返回明确 unavailable,不制造空 Passage。
## 16. TTS 集成
AI 层不依赖 RDSpeechReaderView。宿主可以把摘要或回答转换为临时 `RDSpeechContentProvider`
后续可增加桥接 Pod
```ruby
pod 'RDSpeechReaderView/AI'
```
桥接只负责朗读 AI 结果;Natural Language 的分句与语言识别实现应抽取为共享内部组件,避免同一文本产生不同范围。
## 17. 配置
```swift
public struct RDAIReaderConfiguration: Sendable {
public var spoilerPolicy: RDAISpoilerPolicy
public var maximumRetrievedPassages: Int
public var allowsEmbeddingAssetDownload: Bool
public var storesGeneratedArtifacts: Bool
public var diagnosticsLevel: RDAIDiagnosticsLevel
public static let `default`: RDAIReaderConfiguration
}
```
默认值:
- `spoilerPolicy = .readContentOnly`
- `maximumRetrievedPassages = 4`
- `allowsEmbeddingAssetDownload = false`
- `storesGeneratedArtifacts = true`
- `diagnosticsLevel = .metadataOnly`
## 18. API 演进规则
- 1.0 前可以调整命名,但每次调整同步更新五份设计文档。
- 1.0 后删除或改变语义需要主版本升级。
- Codable 枚举新增 case 时必须实现向后兼容策略。
- 数据库 Schema 版本与 SDK 版本独立。
- Prompt 版本与 SDK 版本独立。
- Reader Adapter 可以增加格式能力,但不能改变 Core Locator 的 UTF-16 语义。
@@ -0,0 +1,431 @@
# RDAIReaderView 架构设计
**文档状态:** Draft 0.1
**最后更新:** 2026-07-25
**目标版本:** RDAIReaderView 1.0
## 1. 架构目标
RDAIReaderView 必须满足四个架构目标:
1. 不改变 RDPDFReaderView、RDEpubReaderView 和 RDSpeechReaderView 的核心职责。
2. iOS 15 用户继续获得稳定阅读、基础 NLP 和 TTSFoundation Models 仅作为可选增强。
3. 所有生成结果都能追溯到稳定原文位置。
4. AI Provider、索引实现和 UI 可替换,公共数据模型保持稳定。
## 2. 总体分层
```text
┌─────────────────────────────────────────────────────────────┐
│ Host App / RDAIReaderViewUI │
│ AI 面板、摘要、问答、人物卡片、关系图、引用跳转 │
├─────────────────────────────────────────────────────────────┤
│ RDAIReaderView │
│ Query Service / Summary Service / Character Service │
├───────────────────────┬─────────────────────────────────────┤
│ NaturalLanguage │ FoundationModels │
│ 分句/语言/实体/检索 │ 结构化生成/工具调用/拒答 │
├───────────────────────┴─────────────────────────────────────┤
│ Index & Storage │
│ Passage / EntityMention / Citation / Artifact / Job │
├───────────────────────┬─────────────────────────────────────┤
│ RDPDFReaderView/AI │ RDEpubReaderView/AI │
│ 页码/范围/矩形/OCR │ href/CFI/范围/章节 │
├───────────────────────┴─────────────────────────────────────┤
│ RDPDFReaderView / RDEpubReaderView / RDSpeechReaderView │
└─────────────────────────────────────────────────────────────┘
```
依赖方向只能从上到下。阅读器核心不能反向依赖 RDAIReaderView。
## 3. CocoaPods 模块设计
建议新增:
```ruby
pod 'RDAIReaderView/Core'
pod 'RDAIReaderView/NaturalLanguage'
pod 'RDAIReaderView/FoundationModels'
pod 'RDAIReaderView/UI'
pod 'RDPDFReaderView/AI'
pod 'RDEpubReaderView/AI'
```
### 3.1 Core
- 最低 iOS 15。
- 只依赖 Foundation、SQLite3/CryptoKit 等系统能力。
- 包含公共协议、数据模型、索引调度、存储和查询编排。
- 不导入 UIKit、PDFKit、DTCoreText 或 FoundationModels。
### 3.2 NaturalLanguage
- 最低 iOS 15。
- 依赖 Core 和 NaturalLanguage。
- 提供语言识别、分句、实体候选、关键词和语义评分。
- iOS 17+ 可选使用 `NLContextualEmbedding`;资源不可用时降级。
### 3.3 FoundationModels
- 源码使用 `@available(iOS 26.0, *)` 隔离。
- 依赖 Core 和系统 FoundationModels。
- 需要支持 Foundation Models 的 Xcode 工具链。
- 不被基础 Pod 默认引入,避免旧工具链客户无法编译。
### 3.4 UI
- 最低 iOS 15。
- 依赖 Core,可选识别 FoundationModels 可用性。
- UI 不直接构造 Prompt,也不直接访问数据库。
### 3.5 Reader Adapters
- `RDPDFReaderView/AI` 依赖 RDAIReaderView/Core。
- `RDEpubReaderView/AI` 依赖 RDAIReaderView/Core。
- Adapter 只负责内容快照、定位转换、跳转和高亮。
## 4. 建议目录
```text
Sources/RDAIReaderView/
├── RDAIReaderView.podspec
├── Core/
│ ├── Contracts/
│ ├── Models/
│ ├── Indexing/
│ ├── Retrieval/
│ ├── Storage/
│ └── Services/
├── NaturalLanguage/
│ ├── Analysis/
│ ├── Embeddings/
│ └── Retrieval/
├── FoundationModels/
│ ├── Availability/
│ ├── Generation/
│ ├── Prompts/
│ ├── Schemas/
│ └── Tools/
├── UI/
│ ├── Assistant/
│ ├── Citations/
│ └── Characters/
└── Tests/
Sources/RDPDFReaderView/AI/
Sources/RDEpubReaderView/AI/
```
## 5. 核心数据流
### 5.1 建立索引
```text
ContentProvider
↓ 读取资源快照
Text Snapshot + Stable Locator
Language Detection
Paragraph/Sentence Chunking
Entity Mentions + Keywords + Embeddings
Transactional Storage
Index Checkpoint
```
每次数据库事务只提交一个资源或一组有界片段。应用退出时,最多重做当前事务,不重做整本书。
### 5.2 问答
```text
User Question
Language / Intent / Spoiler Scope
Hybrid Retrieval
Access + Read-Progress Filter
Context Budget Builder
Foundation Models Structured Generation
Citation Validator
Answer or Evidence-Insufficient Refusal
```
检索由代码执行。首版不让模型自由遍历整本数据库,只有需要多轮查找时才使用受限 Tool Calling。
### 5.3 人物关系
```text
Entity Mentions
Alias Candidate Grouping
Chunk-level Structured Extraction
Evidence Validation
Relationship Merge
confirmed / possible / conflicting
```
代码只能自动合并完全相同的标准化名称和明确别名。代词消解、同名人物合并和隐含关系必须保持低置信度,等待更多证据或用户确认。
## 6. 稳定定位模型
### 6.1 通用定位
`RDAILocator` 包含:
- `documentIdentifier`
- `resourceIdentifier`
- `utf16Range`
- `sourceHash`
- 格式专属 Anchor
文本范围统一使用 UTF-16,与现有 RDSpeechReaderView、NSString 和 EPUB 搜索范围保持一致。
### 6.2 PDF Anchor
```text
pageIndex
normalizedRects
textSource: native / ocr
readingOrder
```
PDF Adapter 从现有 `RDPDFReaderTextRun` 构建连续页文本和 UTF-16 范围。每个 Passage 必须保留与 run 的映射,不能在 AI 层重新拼接后丢失矩形。
扫描 PDF 使用现有 `speechTextRuns(at:)`/OCR 能力,但商用实现应增加独立的 AI OCR 调度入口,避免页面导航取消请求时同时取消后台索引。
### 6.3 EPUB Anchor
```text
normalizedHref
cfi
rangeCFI
rangeAnchor
progressionFallback
```
优先级:
1. `rangeCFI`
2. `cfi + UTF-16 range`
3. `rangeAnchor`
4. `href + progression`
屏幕页码只用于展示,不能作为持久化引用主键。
## 7. 文本快照与分块
### 7.1 不修改原文
索引保存两份文本信息:
- `sourceText`:原始文本,用于引用与范围映射。
- `searchText`:规范化副本,用于检索。
禁止使用规范化文本范围直接驱动阅读器高亮。
### 7.2 分块策略
- 先按资源和章节边界划分。
- 再按段落划分。
- 超长段落使用 `NLTokenizer(unit: .sentence)`
- 中文目标 600-900 字;英文目标 300-600 词。
- 片段之间保留 1-2 句重叠。
- 表格、代码、脚注和标题保留语义类型,避免与正文无差别拼接。
### 7.3 内容哈希
建议使用 SHA-256
```text
documentHash = hash(ordered resource identifiers + resource hashes)
resourceHash = hash(source text + format-specific stable metadata)
passageHash = hash(resource hash + UTF-16 range + source text)
```
书籍更新时按资源哈希增量失效。
## 8. 检索架构
首版采用 Hybrid Retrieval
```text
finalScore =
0.45 * lexicalScore +
0.40 * semanticScore +
0.10 * proximityScore +
0.05 * headingBoost
```
权重是初始值,必须通过评测集调优,不作为永久常量。
检索步骤:
1. 规范化查询并识别语言。
2. 关键词倒排召回 Top 30。
3. 语义相似度重排。
4. 合并高度重叠 Passage。
5. 按已读范围、文档授权和最大上下文过滤。
6. 返回 Top 3-4,并保留评分解释。
若语义模型资源不可用,使用纯词法检索,不阻塞问答入口;UI 可提示结果质量可能降低。
## 9. Foundation Models 编排
### 9.1 Provider 抽象
Core 只依赖 `RDAIGenerativeProvider`。Apple 实现位于 FoundationModels 子模块,未来可增加 Core ML、MLX 或经用户授权的云端实现。
### 9.2 会话策略
- 摘要、问答、人物关系使用不同 instructions 和独立会话。
- 同一会话只处理一个并发请求。
- 用户切换书籍、变更阅读范围或取消时终止任务。
- 达到上下文阈值前主动新建会话,不等待系统抛错。
- 记录 Prompt 版本、模型可用性分类、耗时和 token 数,不记录原文。
### 9.3 上下文预算
默认预算建议:
| 项目 | Token 预算 |
|------|------------|
| Instructions + Schema | 500 |
| 用户问题 | 200 |
| 检索上下文 | 2400 |
| 模型输出 | 700 |
| 安全余量 | 296 |
实际使用 `contextSize``tokenCount(for:)` 动态计算,不写死为 4096。
### 9.4 引用验证
生成后执行确定性校验:
1. Citation ID 必须存在于本次上下文。
2. Citation 必须属于当前文档和允许阅读范围。
3. 引用文本必须能在 Passage 原文中匹配。
4. 每个事实项至少有一个有效引用。
5. 删除无效项后答案为空,则返回 evidence insufficient。
## 10. 存储设计
建议使用 SQLiteSchema 初稿:
```text
documents
resources
passages
passage_fts
embeddings
entity_mentions
entities
entity_aliases
relationships
relationship_evidence
artifacts
index_jobs
schema_metadata
```
关键原则:
- FTS 和关系表通过 Passage ID 关联原文。
- 向量数据按模型标识符、revision 和语言分区。
- 生成结果不覆盖人工编辑内容。
- 每个 Artifact 保存 Prompt/模型/输入哈希。
- 索引表支持按文档级联删除。
## 11. 并发与生命周期
建议采用 Swift Concurrency
- `RDAIIndexCoordinator`actor,管理索引队列和 checkpoint。
- `RDAIStore`actor,串行化数据库写入。
- `RDAIRetriever`:Sendable 服务,可并发读取快照。
- Reader Adapter`@MainActor`,仅提取 UI/阅读器状态和执行跳转。
- Foundation Models Session:由单请求 actor 或服务隔离。
优先级:
1. 用户当前问答所需 Passage。
2. 当前章节。
3. 相邻章节。
4. 已读范围。
5. 其余获准内容。
发生内存警告时:
- 取消低优先级 embedding 任务。
- 卸载 contextual embedding。
- 清理内存 Passage/向量缓存。
- 保留已提交数据库和当前用户请求。
## 12. 可用性与降级
```text
Foundation Models available
├─ 是 → 完整生成能力
└─ 否
├─ Natural Language available → 索引、实体、基础检索
└─ 语言/资源不支持 → 关键词检索与基础分段
```
降级状态是公共 API 的一部分,UI 不根据 Error 字符串猜测原因。
## 13. 安全与隐私
- 默认 Provider 为设备端 Provider。
- Core 不包含网络代码。
- 云端 Provider 若未来增加,必须是单独 Pod,并要求宿主显式配置。
- 日志只记录文档匿名哈希、阶段、耗时、错误类别和计数。
- 禁止记录 Prompt、Passage、用户问题和模型回答全文。
- 导出诊断包前再次脱敏。
- 删除书籍时由宿主调用 `removeDocument`,同时删除索引、关系和生成缓存。
## 14. 可观测性
本地指标:
- 索引耗时、资源数、片段数、失败类别。
- 检索 P50/P95、召回数量和降级模式。
- 生成耗时、取消率、错误类别和引用校验失败率。
- Foundation Models availability 分布。
- 缓存命中率和数据库大小。
商用版本默认只汇总数值。任何远程遥测必须由宿主决定,并遵守其隐私政策。
## 15. 架构决策记录
实施时至少补充以下 ADR
- ADR-001:独立 RDAIReaderView 而非嵌入阅读器核心。
- ADR-002UTF-16 作为跨模块文本范围。
- ADR-003:本地 SQLite 和增量资源哈希。
- ADR-004:检索先行、生成后置、引用强校验。
- ADR-005Foundation Models 可选依赖和运行时降级。
- ADR-006:已读范围作为默认安全边界。
## 16. 已知技术风险
| 风险 | 影响 | 缓解 |
|------|------|------|
| 中文小说实体识别不足 | 人物漏识别或误合并 | 规则候选 + 结构化模型提取 + 证据与置信度 |
| OCR 阅读顺序错误 | 摘要和引用错误 | 保留 readingOrder、双栏测试、允许宿主提供文本 |
| PDF OCR 请求被页面导航取消 | 后台索引不完整 | AI 使用独立调度队列与缓存 |
| EPUB 重排后范围变化 | 引用跳转漂移 | CFI/rangeCFI 优先,文本哈希校验 |
| 系统模型更新 | Prompt 质量回归 | Prompt 版本化、模型版本分层评测 |
| 上下文不足 | 回答遗漏 | 检索压缩、分层摘要、新会话 |
| 同名人物 | 错误关系合并 | 不自动合并低置信候选,保留冲突 |
@@ -0,0 +1,36 @@
# RDAIReaderView Release Checklist
This checklist records the external validation required after the local build
passes. It intentionally contains no book text, prompts, answers or user data.
> Status on 2026-07-25: Apple Intelligence eligible hardware, App Store privacy
> submission and TestFlight rollout access are temporarily unavailable. The
> unchecked physical-device and release gates below are deferred, not passed.
## Local Gates
- [x] Core, NaturalLanguage, FoundationModels and UI compile for iPhoneOS.
- [x] PDF and EPUB adapters compile in the ReadViewDemo workspace.
- [x] PDF/EPUB citations use stable locators and transient highlights.
- [x] SQLite data, FTS and generated artifacts are deleted with a book.
- [ ] Add fixture-backed XCTest coverage for locators, SQLite migration, scope,
citation validation, pause/resume and artifact invalidation.
- [ ] Add UI automation for index, summary, answer, citation jump, clear data
and VoiceOver labels.
## Physical Device Gates
- [ ] On an eligible iOS 26 device, verify each `SystemLanguageModel`
availability state and its UI fallback.
- [ ] Run structured summary and question-answering evaluation fixtures; record
citation validity, faithfulness and spoiler-safety without uploading text.
- [ ] Verify Vision OCR quality and cancellation/retry on scanned PDF samples.
- [ ] Measure index latency, memory, battery and citation jump P95 targets.
## Release Gates
- [ ] Create a feature flag/remote configuration policy owned by the host app.
- [ ] Complete App Store privacy labels, Foundation Models acceptable-use review
and network audit.
- [ ] Run TestFlight rollout 5%, 25%, then 100% with P0/P1 stop conditions.
- [ ] Confirm crash-free and AI quality metrics meet the published thresholds.
+287
View File
@@ -0,0 +1,287 @@
# RDAIReaderView 产品与开发规格
**文档状态:** Draft 0.1
**最后更新:** 2026-07-25
**目标版本:** RDAIReaderView 1.0
**适用工程:** ReadViewSDK
## 1. 文档目的
本文档锁定 RDAIReaderView 首个商用版本的产品范围、系统兼容策略、功能要求、非功能要求和发布门槛。架构、公共 API、生成式 AI 约束和测试方法分别见同目录其他文档。
## 2. 背景与现状
ReadViewSDK 已具备以下基础:
- RDPDFReaderViewPDF 页面、原生文本、Vision OCR、页内文本矩形、跳页与临时朗读高亮。
- RDEpubReaderViewEPUB 章节、`href`、CFI、UTF-16 文本范围、搜索与位置恢复。
- RDSpeechReaderView:基于 Natural Language 的语言识别和分句、系统 TTS、断点续听、后台播放与锁屏控制。
- 最低部署版本为 iOS 15Demo 使用 iOS 15.6。
当前缺少统一的文档语义索引、可追溯引用、书内问答、章节摘要和人物关系能力。
## 3. 产品目标
RDAIReaderView 1.0 的目标是提供一个默认本地运行、可选启用 Apple Foundation Models、可被 PDF 和 EPUB 阅读器复用的智能阅读能力层。
首版必须实现:
1. 对书籍内容建立增量、可恢复的本地索引。
2. 识别语言、句子、人物、地点、组织和关键词。
3. 生成当前章节或已读范围摘要。
4. 回答书内问题,并为事实性陈述提供可跳转的原文引用。
5. 生成人物卡片和带证据的人物关系。
6. Foundation Models 不可用时安全降级,不影响阅读、搜索和 TTS。
7. 所有 AI 结果默认限制在用户已读内容,避免剧透。
## 4. 非目标
RDAIReaderView 1.0 不包含:
- 互联网百科、新闻或通用知识问答。
- 无来源的文学评价、复杂逻辑推理或事实推断。
- 自动改写、续写或批量导出版权书籍内容。
- 云端上传书籍全文。
- 自训练 Foundation Models Adapter。
- 自动替用户发布内容、发送消息或执行购买行为。
- 对 CBZ、漫画图片内容进行视觉剧情理解。
- 对 DRM 内容绕过访问控制或持久化超出宿主授权范围的文本。
上述能力必须在后续版本单独评审产品价值、版权、隐私和审核风险。
## 5. 用户价值与首版场景
### 5.1 阅读回顾
用户重新打开书籍时,可请求:
- 上次阅读内容的 3 句回顾。
- 当前章节摘要。
- 已读部分的关键人物变化。
输入范围默认从最近一个自然章节边界到当前阅读位置,不得包含未读段落。
### 5.2 书内问答
用户可以询问当前书籍,例如:
- “这一章发生了什么?”
- “张三为什么离开?”
- “这里提到的组织是什么?”
系统先检索相关段落,再基于检索结果生成答案。答案中的事实性结论必须关联一个或多个 `RDAICitation`。没有充分证据时必须明确拒答,而不是依赖模型常识补全。
### 5.3 人物与关系
人物卡片包含:
- 标准显示名和已发现的别名。
- 首次出现位置。
- 仅基于已读内容的简短介绍。
- 关键行为及证据。
- 与其他人物的关系边及证据。
关系状态分为:
- `confirmed`:原文明确表达。
- `possible`:模型推断但证据不充分,UI 必须显示“可能”。
- `conflicting`:不同段落给出冲突信息,UI 展示冲突而非自动覆盖。
### 5.4 与阅读器联动
- 点击引用跳转到 PDF 页面或 EPUB CFI。
- 跳转后高亮对应原文范围。
- 摘要、答案和人物卡片可交给 RDSpeechReaderView 朗读。
- 用户调整字体、页面尺寸或翻页方式后,EPUB 引用仍应通过 CFI/文本范围恢复。
## 6. 功能要求
### FR-001 文档导入
- AI 层只能通过 `RDAIContentProvider` 读取宿主已授权的文本快照。
- 不直接读取宿主数据库或下载接口。
- 支持取消导入、增量恢复和内容变更检测。
### FR-002 稳定定位
- PDF:使用文档 ID、页索引、页内 UTF-16 范围和归一化矩形。
- EPUB:使用文档 ID、规范化 `href`、UTF-16 范围和 CFICFI 不可用时才使用 progression 兜底。
- 所有引用必须保存源文本哈希,恢复时验证引用是否仍指向相同内容。
### FR-003 文本分析
- 使用 `NLLanguageRecognizer` 识别资源或段落语言。
- 使用 `NLTokenizer` 切分句子,保持原始 UTF-16 偏移。
- 使用 `NLTagger` 生成人名、地点、组织候选。
- 实体候选必须保留每次出现的原文位置,不能只保存名称。
- Natural Language 不支持或质量不足的语言,降级为字符/标点分段和关键词检索。
### FR-004 分块与索引
- 中文片段建议 600-900 个字符;拉丁文字建议 300-600 词。
- 优先在章节、段落和句子边界切分,不截断组合字符。
- 相邻片段保留 1-2 句重叠,便于跨边界检索。
- 每个片段保存内容哈希、语言、顺序、定位和索引版本。
- 索引任务在后台执行,并按当前章节、相邻章节、其余内容的优先级处理。
### FR-005 检索
- 首版采用关键词/BM25 风格评分与 Natural Language 语义相似度融合。
- 结果必须经过文档 ID、已读范围和访问范围过滤。
- 默认返回 3-4 个片段,最大不超过 5 个。
- 检索结果必须包含分数、匹配原因和稳定定位。
### FR-006 Foundation Models 可用性
- 编译期使用可选子模块,不提高 Core 的 iOS 15 最低版本。
- 运行时检查系统版本、设备资格、Apple Intelligence 开关、模型准备状态和语言支持。
- UI 必须区分 `deviceNotEligible``appleIntelligenceNotEnabled``modelNotReady`、不支持语言和未知错误。
- 不可用时隐藏生成入口或提供明确说明,Natural Language 索引、搜索和 TTS 保持可用。
### FR-007 结构化生成
- 摘要、答案、实体和关系均使用 `@Generable` 结构化输出。
- 输出不得依赖字符串正则解析。
- 每个事实项必须携带检索片段 ID;生成后由代码验证 ID 是否真实存在。
- 无效引用、越权引用或未读范围引用必须删除;删除后答案无证据则转为拒答。
### FR-008 防剧透
- 默认分析范围为“当前位置及之前”。
- 用户主动切换到整本书模式时必须进行一次明确确认。
- 缓存键包含阅读范围;已读摘要不得复用整本书摘要。
- 人物关系图默认只展示已读范围内已出现的人物和关系。
### FR-009 缓存与恢复
- 索引、实体、摘要和问答缓存均存储在应用沙盒。
- 缓存键包含文档内容哈希、索引版本、Prompt 版本、模型版本和阅读范围。
- 内容哈希变化时,失效受影响资源,不强制删除整本书其他有效索引。
- 提供按书删除、删除全部 AI 数据和存储空间统计接口。
### FR-010 用户控制
- 所有长任务支持取消。
- UI 展示索引或生成状态,不伪造确定进度。
- 用户可关闭 AI、清除 AI 缓存、选择“仅本地处理”。
- 生成失败不得阻塞翻页、搜索、标注或 TTS。
## 7. 系统兼容矩阵
| 环境 | 必须提供的能力 |
|------|----------------|
| iOS 15+ | 语言识别、分句、实体候选、关键词索引、基础检索 |
| iOS 17+ | 可选 contextual embedding;资源不存在时允许下载或降级 |
| iOS 26+ 且模型可用 | 摘要、问答、人物关系、结构化笔记 |
| 不支持 Apple Intelligence | Natural Language 能力完整可用,生成式入口降级 |
| 离线 | 已下载模型与本地索引可用;不得要求联网 |
| 扫描 PDF | 使用现有 Vision OCR;OCR 失败的页面明确标记不可分析 |
## 8. 非功能要求
### 8.1 性能
- 索引不得在主线程执行文本分析、向量计算或数据库批量写入。
- 当前章节索引优先完成,目标 P95 不超过 2 秒;具体阈值以目标真机基线校准。
- 10 万中文字的基础索引目标 P95 不超过 30 秒,允许后台增量完成。
- 索引期间阅读页面滚动/翻页帧率不得出现持续性下降。
- 单次 Foundation Models 响应首个可展示结果目标 P95 不超过 5 秒。
- AI 模块空闲 30 秒后应释放 contextual embedding 和不必要的内存缓存。
### 8.2 稳定性
- AI 相关 crash-free session 不低于 99.9%。
- Foundation Models 不可用或生成失败时降级成功率为 100%。
- 强制退出后索引可从最后一个已提交资源恢复。
- 数据库迁移失败时保留原数据库备份,并允许重建索引。
### 8.3 准确性
- 引用定位有效率不低于 99%。
- 事实性陈述有原文支持的比例不低于 98%。
- 无答案问题正确拒答率不低于 95%。
- 人物关系证据覆盖率为 100%。
- 结构化输出通过本地校验的比例不低于 99.5%。
### 8.4 隐私与安全
- 默认不上传书籍文本、查询、摘要、人物关系和阅读历史。
- 日志不得包含原文、用户问题全文或模型完整输出。
- 调试日志必须经过显式编译配置才能包含脱敏片段。
- AI 数据遵循宿主账户登出、删书和清除缓存生命周期。
- 文件保护等级、备份策略和共享容器由宿主配置,SDK 提供明确接口和文档。
### 8.5 可访问性
- 所有 AI 控件支持 VoiceOver、Dynamic Type 和 Reduce Motion。
- 状态变化使用可访问性公告,但不得连续播报索引细节。
- “AI 生成”“可能关系”“无原文证据”等状态不能只靠颜色表达。
## 9. 商用验收门槛
以下条件全部满足后才可发布 1.0
- Core、NaturalLanguage、FoundationModels、PDF Adapter、EPUB Adapter 均有单元测试。
- 关键用户流有 UI 自动化测试。
- 完成至少 100 条人工标注的产品评测集。
- 所有准确性指标达到第 8.3 节门槛。
- 在最低支持系统、主流支持设备和至少两代 Apple Intelligence 设备上完成真机验证。
- 完成模型不可用、未下载、语言不支持、上下文溢出和生成取消测试。
- 完成隐私清单、App Store 隐私申报和 AI 功能说明审核。
- TestFlight 灰度无 P0/P1 缺陷,AI 相关 crash-free session 达标。
- 现有 PDF、EPUB、搜索、标注和 TTS 回归全部通过。
## 10. 实施路线
以下排期按 2 名 iOS 工程师、1 名测试工程师、产品/内容评测兼职参与估算,总周期 12-14 周。单人开发建议按 18-22 周估算。
| 周期 | 阶段 | 主要交付 | 退出条件 |
|------|------|----------|----------|
| 第 1 周 | 合同与工程骨架 | 五份开发文档、Podspec、目录、CI Scheme、ADR | 文档评审通过,空库支持 iOS 15 编译 |
| 第 2-3 周 | Core 与存储 | 公共模型、SQLite Schema、迁移、Job/Checkpoint、删除接口 | 崩溃恢复和增量失效单测通过 |
| 第 4-5 周 | Natural Language | 分句、语言、实体候选、词法/语义检索 | 基础检索评测达标,TTS 范围无漂移 |
| 第 6 周 | PDF Adapter | 原生文本/OCR 快照、Locator、引用跳转和高亮 | PDF 引用恢复率达到门槛 |
| 第 7 周 | EPUB Adapter | href/CFI/rangeCFI 快照、Locator、跳转和高亮 | 重排后引用恢复率达到门槛 |
| 第 8-9 周 | Foundation Models | 可用性、摘要、问答、结构化输出、引用校验 | 不可用降级与 AI 评测通过 |
| 第 10 周 | 人物关系 | 人物、别名、关系、冲突和证据合并 | 人物/关系指标达到门槛 |
| 第 11 周 | 商用 UI | AI 面板、状态、取消、引用、防剧透、无障碍 | 核心 UI 自动化通过 |
| 第 12 周 | 性能与隐私 | 内存、耗电、数据库、日志、清除数据、隐私说明 | 无 P0/P1,性能与隐私门禁通过 |
| 第 13-14 周 | TestFlight 灰度 | 5%→25%→100% 分阶段发布 | Crash-free 和质量反馈持续达标 |
每阶段要求:
- 功能代码、单元测试和文档同一阶段完成。
- 公共 API 变更必须先更新 API 文档。
- Prompt 变更必须增加版本并跑 AI 回归集。
- 阶段退出条件未满足时不得把未验证能力带入下一阶段默认开启。
## 11. 版本范围
### 1.0
- 本地索引。
- 章节摘要。
- 带引用的书内问答。
- 人物卡片和基础人物关系。
- PDF/EPUB 引用跳转。
- 防剧透和完整降级。
### 1.1 候选
- 关系时间线与冲突关系展示。
- 用户划线/笔记参与问答。
- 多本书对照,仅限用户主动选择的本地书籍。
- 可选 Core ML/MLX 或云端 Provider。
### 2.0 候选
- 多模态图片理解。
- 漫画/图文书内容理解。
- 经过独立法律和产品评审的云端增强能力。
## 12. 依赖文档
- [RDAIReaderView-ARCHITECTURE.md](RDAIReaderView-ARCHITECTURE.md)
- [RDAIReaderView-API.md](RDAIReaderView-API.md)
- [RDAIReaderView-AI-SPEC.md](RDAIReaderView-AI-SPEC.md)
- [RDAIReaderView-TEST-PLAN.md](RDAIReaderView-TEST-PLAN.md)
@@ -0,0 +1,473 @@
# RDAIReaderView 商用测试与发布计划
**文档状态:** Draft 0.1
**最后更新:** 2026-07-25
**目标版本:** RDAIReaderView 1.0
## 1. 目标
测试计划验证以下结论:
1. AI 模块不会破坏现有 PDF、EPUB、搜索、标注和 TTS。
2. 索引和引用在书籍更新、分页变化、OCR 和应用中断后仍然可靠。
3. Foundation Models 的输出有证据、可拒答、不剧透并可安全降级。
4. 性能、内存、耗电、隐私和可访问性达到商用要求。
5. Prompt 或系统模型更新后,可以通过可重复评测发现质量回归。
## 2. 测试层级
```text
人工与 TestFlight 验收
UI / 真机系统测试
PDF / EPUB / TTS 集成测试
AI 产品评测与 Prompt 回归
Core / Storage / NLP 单元测试
```
确定性校验优先放在单元测试;非确定性生成质量使用固定数据集、多次运行和人工评审。
## 3. 测试目标与 Target
建议新增:
```text
RDAIReaderViewCoreTests
RDAIReaderViewNaturalLanguageTests
RDAIReaderViewFoundationModelsTests
RDPDFReaderViewAITests
RDEpubReaderViewAITests
ReadViewDemoAIUITests
```
Foundation Models 真机测试与普通 CI 分离,避免不支持模型的 Runner 造成假失败。
## 4. 测试数据
### 4.1 数据来源
只使用:
- 公版书籍。
- 项目拥有测试授权的书籍。
- 团队自行编写的合成文本。
- 经过脱敏、明确允许进入测试仓库的样本。
不得把商业书籍全文或用户内容提交到测试仓库。
### 4.2 数据集组成
首版至少包含:
| 类型 | 最低数量 | 重点 |
|------|----------|------|
| 中文小说章节 | 20 | 多人物、别名、代词、倒叙、否定关系 |
| 英文小说章节 | 10 | 名称大小写、代词、长句 |
| 中文技术/非虚构 | 10 | 术语、组织、事实问答 |
| 英文技术/非虚构 | 10 | 代码块、列表、表格 |
| 原生文本 PDF | 5 本/50 页 | 单栏、双栏、页眉页脚 |
| 扫描 PDF | 5 本/50 页 | OCR 错字、旋转、低清晰度 |
| EPUB | 5 本/30 章 | CFI、脚注、长章节、重排 |
| 无答案问题 | 20 | 拒答 |
| 剧透边界问题 | 20 | 已读/未读范围隔离 |
| 同名或别名关系 | 20 | 实体合并准确性 |
首版产品评测集不少于 100 个 Case。每个 Case 保存输入、允许范围、期望证据、可接受答案要点和禁止行为。
### 4.3 Case 格式
建议使用 JSONL
```json
{
"id": "qa-zh-001",
"task": "questionAnswering",
"documentFixture": "novel-zh-01",
"readScope": {
"resourceOrderUpperBound": 3,
"utf16UpperBound": 8200
},
"input": "林川为什么离开村庄?",
"expectedPassageIDs": ["p-3-18", "p-3-19"],
"requiredFacts": ["受到追捕"],
"forbiddenFacts": ["第四章之后的身份揭示"],
"expectedStatus": "answered"
}
```
测试结果不得把 fixture 原文写入可上传日志。
## 5. Core 单元测试
### 5.1 文本范围
- 空文本、空白文本和超长文本。
- Emoji、组合字符、代理对、中文标点和换行。
- UTF-16 范围与 Swift String Index 双向转换。
- 搜索规范化不能改变 source range。
- Passage 重叠不能产生错误 Locator。
### 5.2 哈希与失效
- 相同内容产生相同哈希。
- 只改变一个章节时只失效该资源。
- Prompt 版本变化只失效相关 Artifact。
- embedding 模型 revision 变化只重建对应向量。
- 阅读范围扩大不复用越界缓存。
### 5.3 存储与迁移
- 首次建库、重复打开和并发读取。
- 每个 Schema 版本向下一版本迁移。
- 迁移中断后恢复或回滚。
- 数据库损坏时保留诊断并可安全重建。
- 按书删除和删除全部数据。
- 登出/删书回调后无孤立 Passage、向量或 Artifact。
### 5.4 索引调度
- 当前章节优先。
- 暂停、恢复、取消和重复 prepare。
- 应用强制退出后从 checkpoint 恢复。
- 内存警告取消低优先级任务。
- 内容 Provider 释放后任务安全失败,不野指针或永久等待。
## 6. Natural Language 测试
### 6.1 语言与分句
- 中文、英文、中英混排。
- 无标点长段落。
- 缩写、小数、网址和引号。
- 句子范围与 RDSpeechReaderView 结果一致。
- 不支持语言的降级分块。
### 6.2 实体
按任务统计 Precision、Recall、F1
```text
precision = 正确识别实体 / 所有识别实体
recall = 正确识别实体 / 所有标注实体
F1 = 2 * precision * recall / (precision + recall)
```
首版门槛:
- 人物候选 Precision ≥ 0.95。
- 人物候选 Recall ≥ 0.85。
- 地点/组织作为辅助信息,F1 ≥ 0.80。
人物候选宁可少召回,也不能大量创建虚假人物。
### 6.3 检索
- 关键词命中、同义表达、别名和错别字。
- Top 5 包含正确证据的 Recall@5 ≥ 0.90。
- 无 embedding 资源时词法检索仍可返回结果。
- 已读范围外 Passage 召回数必须为 0。
- 相同输入和索引版本的词法结果顺序稳定。
## 7. PDF Adapter 测试
- 原生文本 run 按 `readingOrder` 正确拼接。
- 双栏页面不会按几何坐标错误穿插。
- run 间换行计入 UTF-16 范围。
- Passage 范围映射回正确 normalized rects。
- characterRects 存在/缺失均可高亮。
- OCR 与原生文本来源正确标记。
- OCR 缓存命中、失败、取消和重试。
- 页面缓存裁剪后 Citation 仍可重新加载。
- 跳转后目标页和高亮正确。
- 旋转、横屏、竖滑、双页模式下高亮位置正确。
- 页面导航取消 OCR 时,AI 索引任务不应永久丢失。
PDF 引用定位有效率必须 ≥ 99%。
## 8. EPUB Adapter 测试
- `href` 规范化一致。
- UTF-16 range、rangeAnchor、CFI 和 rangeCFI 互相映射。
- 改字号、行距、边距、字体和横竖屏后 Citation 可恢复。
- 长章节按需加载时可提取目标资源。
- 脚注、列表、图片替代文本和代码块类型正确。
- EPUB 更新导致 source hash 变化时旧 Citation 标记 stale。
- CFI 缺失时 progression 只作兜底。
- 引用跳转复用标准位置恢复流程。
- 固定版式无文本章节返回明确不可分析状态。
EPUB 重排后引用定位有效率必须 ≥ 99%。
## 9. Foundation Models 产品评测
### 9.1 运行方式
- 每个 Case 至少运行 3 次,避免偶然结果掩盖问题。
- 按系统模型版本、系统语言和设备分桶。
- Prompt 新版本同时运行旧版和新版,生成差异报告。
- 代码校验先执行,再进入人工/模型评分。
### 9.2 摘要 Rubric
| 分数 | 标准 |
|------|------|
| 5 | 覆盖关键事件,全部有证据,无未读信息,表述简洁 |
| 3 | 基本正确但遗漏一项重要内容,或引用不够精确 |
| 1 | 包含无证据事实、重大误解或剧透 |
发布门槛:
- 平均分 ≥ 4.0。
- 任一剧透 Case 失败即阻断发布。
- 无证据事实比例 ≤ 2%。
### 9.3 问答 Rubric
检查:
- 是否回答用户问题。
- 每个事实是否被引用支持。
- 是否遗漏关键反证。
- 证据不足时是否拒答。
- 是否泄漏未读内容。
发布门槛:
- Context faithfulness ≥ 98%。
- 无答案正确拒答率 ≥ 95%。
- Citation validity ≥ 99%。
- Spoiler safety = 100%。
### 9.4 人物与关系 Rubric
检查:
- 人物真实出现。
- 别名有明确证据。
- 同名人物未误合并。
- 关系方向正确。
- `confirmed``possible` 分类合理。
- 冲突关系没有被静默覆盖。
发布门槛:
- Character precision ≥ 97%。
- Alias merge precision ≥ 98%。
- 关系证据覆盖率 = 100%。
- 无证据关系数 = 0。
### 9.5 LLM Judge
LLM Judge 只作为辅助:
- 使用固定 Rubric,不使用泛化“是否有帮助”问题。
- 先对至少 30 个 Case 与人工评分校准。
- Spearman/Pearson 相关性低于 0.7 时不得作为发布门禁。
- Judge 分歧、低分和边界 Case 必须人工复核。
- 若使用云端 Judge,测试原文必须为可上传的公版或合成数据。
## 10. 可用性与错误测试
覆盖:
- iOS 15/17Foundation Models 模块不参与运行。
- iOS 26+ 不支持设备。
- Apple Intelligence 未开启。
- 模型正在下载或未准备。
- 当前语言不支持。
- context limit exceeded。
- guardrail 拒绝输入或输出。
- 生成中取消、切书、关闭页面、进入后台。
- 同一 session 并发请求。
- 低存储空间和数据库写入失败。
每种情况必须产生稳定枚举状态,并保持阅读器可操作。
## 11. UI 与可访问性
### 11.1 UI 自动化
- 打开/关闭 AI 面板。
- 当前章节索引状态。
- 发起问题、取消和重试。
- 点击引用并跳转高亮。
- 仅已读范围默认开启。
- 整本书模式确认。
- Foundation Models 不可用状态。
- 清除本书和全部 AI 数据。
- AI 结果交给 TTS 朗读。
### 11.2 可访问性
- VoiceOver 顺序和标签。
- Dynamic Type 最大辅助字号。
- Reduce Motion。
- 深色模式和高对比度。
- `possible`/`conflicting` 不只依赖颜色。
- 加载、取消和失败状态有可访问性公告。
## 12. 性能与资源测试
目标设备至少覆盖:
- 最低性能 iOS 15 支持设备。
- iOS 17 中档设备。
- 一台首代支持 Apple Intelligence 的设备。
- 一台当前系统的高性能设备。
基准:
| 指标 | 1.0 目标 |
|------|----------|
| 当前章节基础索引 P95 | ≤ 2 秒 |
| 10 万中文字基础索引 P95 | ≤ 30 秒 |
| 本地检索 P95 | ≤ 300 ms |
| 生成首个可展示结果 P95 | ≤ 5 秒 |
| 引用跳转 P95 | ≤ 500 ms,不含未缓存页面加载 |
| 索引峰值额外内存 | 目标 ≤ 150 MB,按真机基线确认 |
| 空闲内存释放 | 30 秒内释放 embedding 和大文本缓存 |
| AI crash-free session | ≥ 99.9% |
测试同时记录:
- CPU time。
- 主线程卡顿。
- thermal state。
- 电量变化。
- 数据库大小/万字。
- embedding 资源加载耗时。
- 缓存命中率。
性能基线变化超过 15% 时 CI 或发布报告必须提示。
## 13. 隐私与安全测试
- 网络抓包确认默认实现不上传书籍或问题。
- 搜索日志、控制台日志和 crash breadcrumbs 不含原文。
- 清除 AI 数据后数据库、缓存和临时文件均删除。
- 删除书籍和退出账户触发相同清除路径。
- Scope 过滤在检索前和生成后均执行。
- 构造伪造 Passage ID,确认 Citation Validator 拒绝。
- 构造路径、超长查询和大量 Tool 参数,确认边界限制。
- Tool Calling 最大次数和重复调用检测生效。
- 数据库迁移和诊断包不泄漏原文。
## 14. 回归测试
每次合入必须运行:
- Core 单元测试。
- Natural Language 确定性测试。
- PDF/EPUB Adapter fixture 测试。
- 现有 PDF、EPUB 和 TTS 编译。
- `git diff --check` 和公共 API 兼容检查。
每日或候选发布运行:
- UI smoke。
- 完整 AI 评测集。
- 大书索引性能。
- Foundation Models 真机矩阵。
- 现有 `ReadViewDemoUITests` 回归。
## 15. CI 建议
```text
PR:
lint/diff-check
Core unit tests
NLP unit tests
Adapter tests
build iOS 15 target
build iOS 26 FoundationModels target
Nightly:
Full reader UI regression
AI deterministic evals
Performance fixtures
Release candidate:
Foundation Models physical-device eval
Human review sample
Privacy/network audit
Migration matrix
```
设备模型评测结果应保存以下元数据:
- OS 版本。
- 模型版本或可识别 profile。
- Prompt identifier/version。
- fixture version。
- SDK commit。
- 各指标与失败 Case ID。
不得保存生产用户原文。
## 16. 缺陷分级
### P0
- 泄漏书籍内容或阅读历史。
- 绕过已读范围造成剧透。
- 删除用户原书、标注或笔记。
- 大面积崩溃或数据库不可恢复损坏。
### P1
- 无引用或错误引用的事实作为确定答案展示。
- 人物关系大面积误合并。
- Foundation Models 不可用导致阅读器不可用。
- 引用跳转到错误章节/页面。
### P2
- 摘要遗漏、检索质量下降、局部 UI 或性能问题。
- 可重试且不影响阅读主流程的生成失败。
发布时 P0/P1 必须为 0;P2 必须有明确接受记录和后续版本计划。
## 17. TestFlight 灰度
### 阶段 A:内部
- 团队和测试设备。
- 至少 7 天。
- 完整日志仅限脱敏元数据。
### 阶段 B5%
- 只开启摘要和问答。
- 观察 crash-free、取消率、拒答率、引用点击成功率。
- 人物关系仍受远程/本地 feature flag 控制。
### 阶段 C25%
- 开启人物卡片。
- 关系图只对达到索引完整度的书籍开放。
- 至少稳定 7 天。
### 阶段 D100%
- 所有发布门禁持续达标。
- 保留快速关闭 Foundation Models 功能的配置,但关闭后基础阅读和 NLP 正常。
## 18. 最终发布门禁
- [ ] 五份开发文档与实现一致。
- [ ] 公共 API 兼容检查通过。
- [ ] Core/NLP/Adapter 单元和集成测试通过。
- [ ] 现有 Reader 与 TTS 回归通过。
- [ ] 100+ AI Case 全量运行并达到指标。
- [ ] Foundation Models 支持与不支持路径均完成真机测试。
- [ ] PDF/EPUB Citation validity ≥ 99%。
- [ ] Context faithfulness ≥ 98%。
- [ ] Spoiler safety = 100%。
- [ ] AI crash-free session ≥ 99.9%。
- [ ] 隐私和网络审计通过。
- [ ] 无 P0/P1 缺陷。
- [ ] TestFlight 灰度指标稳定。
-251
View File
@@ -1,251 +0,0 @@
# RDReaderView 功能实现逻辑
## 1. 范围与目标
- 代码范围:`Sources/RDReaderView/` 根目录(6 个 Swift 文件)
- 目标:说明分页阅读器容器如何管理四种显示模式、DataSource/Delegate 协议、翻页交互、工具栏动画、双屏适配和 RTL 支持。
- 主链路关键词:`RDReaderDataSource -> reloadData -> DisplayType 切换 -> 翻页/滚动 -> RDReaderDelegate.pageNum -> 工具栏显隐`
## 2. 关键对象职责
### 2.1 核心容器 `RDReaderView`
- 文件:`Sources/RDReaderView/RDReaderView.swift`~717 行)
- 入口方法:`reloadData()`
- 职责:
- 管理四种显示模式的视图层级切换
- 持有 `UIPageViewController`pageCurl 模式)或 `UICollectionView`(滚动模式)
- 处理点击手势(左/中/右三区域)
- 管理工具栏(topToolView / bottomToolView)的显示/隐藏动画
- 检测横竖屏变化并触发重新布局
- 管理双屏配对逻辑(含封面页处理)
- RTL 语言方向支持
### 2.2 自定义布局 `RDReaderFlowLayout`
- 文件:`Sources/RDReaderView/RDReaderFlowLayout.swift`~387 行)
- 职责:
- 继承 `UICollectionViewFlowLayout`,为三种滚动模式提供布局计算
- 水平滚动:全屏宽 itempagingEnabled
- 垂直滚动:可变高度 item,累加计算
- 水平覆盖滚动:zIndex 分层 + 阴影效果模拟深度
- 封面感知帧计算:封面页全屏宽,后续页面两两配对半屏宽
### 2.3 内容 Cell `RDReaderContentCell`
- 文件:`Sources/RDReaderView/RDReaderContentCell.swift`~37 行)
- 职责:
- `UICollectionViewCell` 子类,作为内容视图的薄壳宿主
- `containerView` 属性 setter 自动移除旧视图、添加新视图
- `layoutSubviews` 将 containerView 填满 contentView.bounds
### 2.4 页面子控制器 `RDReaderPageChildViewController`
- 文件:`Sources/RDReaderView/RDReaderPageChildViewController.swift`~65 行)
- 职责:
- 仅用于 pageCurl 模式,作为 `UIPageViewController` 的子控制器
- 持有 `contentView: UIView?``pageNum: Int`
- `contentView` didSet 在 view 已加载时自动调用 `installContentView()`
### 2.5 手势控制器 `RDReaderGestureController`
- 文件:`Sources/RDReaderView/RDReaderGestureController.swift`~42 行)
- 职责:
- 当前为占位组件,存储 topToolView / bottomToolView 引用
- 实际手势逻辑在 `RDReaderView.tapCenter()` 中实现
### 2.6 URL 入口 `RDURLReaderController`
- 文件:`Sources/RDReaderView/RDURLReaderController.swift`~105 行)
- 职责:
- 最简入口:传入 URL 即可打开书籍
- `.epub` 扩展名 → 创建 `RDEPUBReaderController`
- 其他 → 创建 `RDPlainTextReaderController`UITextView 只读展示,尝试 UTF-8 → GBK → GB2312 解码)
- 导航栏标题设为文件名(去掉扩展名)
## 3. 主流程(代码级)
### 3.1 协议定义
**RDReaderDataSource**(数据供给):
```swift
func pageCountOfReaderView(readerView: RDReaderView) -> Int
func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView
func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String?
@objc optional func topToolView(readerView: RDReaderView) -> UIView?
@objc optional func bottomToolView(readerView: RDReaderView) -> UIView?
```
**RDReaderDelegate**(事件回调):
```swift
func pageNum(readerView: RDReaderView, pageNum: Int)
@objc optional func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool)
```
### 3.2 初始化与数据加载
1. 调用方创建 `RDReaderView`,设置 `dataSource``delegate`
2. 调用 `reloadData()`
3. `reloadData` 内部调用 `switchReaderDisplayType(currentDisplayType)` 重建视图层级。
4. 同时从 `dataSource` 获取 `topToolView``bottomToolView` 并添加到视图层级。
### 3.3 四种显示模式切换
**pageCurl 模式**
- 创建 `UIPageViewController`transitionStyle: .pageCurl
- 通过 `attachPageViewControllerIfNeeded()` 添加为父 VC 的 child VC
- 横屏双页时重建 `UIPageViewController`spineLocation: .mid, isDoubleSided: true
- RTL 时翻转导航方向
**horizontalScroll 模式**
- 移除 pageViewController,插入 `UICollectionView`
- `RDReaderFlowLayout` 设置 scrollDirection = .horizontal, isPagingEnabled = true
- itemSize 宽度 = 容器宽度 / pagesPerScreen
- RTL 时对 collectionView 做 `scaleX: -1` 翻转,cell contentView 再翻转回来
**verticalScroll 模式**
- 同一 collectionViewscrollDirection = .vertical, isPagingEnabled = false
- 页面高度可变,通过 `RDReaderFlowLayoutDataSoure.heigtOfVerticalScrollPage` 查询
- collectionViewContentSize 为所有页面高度之和
**horizontalCoverScroll 模式**
- 水平分页,但带封面滑动动画
- `layoutAttributesForElements` 中:
- 仅计算当前页附近的窄窗口内的 item attributes
- 当前页之前的 item zIndex = -1,当前及之后 zIndex = 1
- 顶层页面边缘添加阴影效果
### 3.4 翻页交互
**点击手势**`tapAction(tap:)`):
- 屏幕分为左 1/3、中 1/3、右 1/3 三个区域
- 左区域:工具栏隐藏时翻上一页(RTL 时翻下一页);工具栏显示时触发 `tapCenter()`
- 中区域:始终触发 `tapCenter()`
- 右区域:工具栏隐藏时翻下一页(RTL 时翻上一页);工具栏显示时触发 `tapCenter()`
**goNextPage() / goPreviousPage()**
- 双页模式使用 `adjacentDualPage(from:forward:)` 计算目标页
- 单页模式直接 +1 / -1
- 调用 `transitionToPage(pageNum:animated:true)` 执行跳转
**工具栏切换**`tapCenter()`):
- 显示时:topToolView 从上方滑入,bottomToolView 从下方滑入(CGAffineTransform translationY
- 隐藏时:反向动画
- 工具栏显示期间禁用 collectionView 和 pageViewController 的用户交互
### 3.5 双页配对逻辑
**`dualPagePair(for pageNum:)`** 返回 `(left: Int, right: Int?)`
- 有封面页:封面页 → (coverIndex, nil);封面后的页面两两配对(coverIndex+1 与 coverIndex+2coverIndex+3 与 coverIndex+4...
- 无封面页:标准偶奇配对(0+1, 2+3, 4+5...
**空白哨兵页**
- `blankPageNum = Int.max``blankEndPageNum = Int.max - 1`
- 仅用于 pageCurl 双页模式,填充封面页独占或总页数为奇数时的右侧空白
### 3.6 横竖屏变化处理
1. `layoutSubviews()` 检测 `previousIsLandscape` 与当前 `isLandscape` 的变化
2. 异步调用 `orientationChanged(isNowLandscape:)` 避免嵌套布局
3. 通知 delegate `readerViewOrientationWillChange`
4. 更新 `layout.isLandscapeDualPage``layout.coverPageIndex`
5. pageCurl 模式:重建 UIPageViewControllerspineLocation 不可变)并跳转到保存的页面
6. 滚动模式:invalidate layoutreload data,强制布局,无动画滚动到保存的 offset
### 3.7 CollectionView DataSource / Layout Delegate
**cellForItemAt**
-`dataSource.pageIdentifier` 获取重用标识符
- 出队 `RDReaderContentCell`
- 调用 `dataSource.pageContentView(pageNum:containerView:)` 传入 cell 已有的 containerView 以便复用
- RTL 水平模式对 cell.contentView 做 `scaleX: -1` 翻转
**pageNum(flowLayout:pageIndex:)**
- 布局检测到滚动中页面变化时更新 `currentPage`
## 4. 异常与边界处理
- `currentPage` 初始值为 -1,首次设置时 delegate 会收到回调
- 空白哨兵页(Int.max / Int.max-1)在 delegate 回调中被过滤,不会通知到外部
- pageCurl 模式下 collectionView 的 DataSource 方法存在但不被调用(collectionView 已从视图层级移除)
- 横竖屏变化时异步处理避免嵌套 layoutSubviews
- `UIView.ss_superViewController` 通过响应链查找最近的父 VC,用于正确挂载 pageViewController
## 5. 数据结构与字段映射
### 5.1 显示模式
```swift
RDReaderView.DisplayType
.pageCurl // UIPageViewController
.horizontalScroll // UICollectionView
.verticalScroll // UICollectionView
.horizontalCoverScroll // UICollectionView
```
### 5.2 翻页方向
```swift
RDReaderView.PageDirection
.leftToRight // /
.rightToLeft //
```
### 5.3 关键公开属性
| 属性 | 类型 | 默认值 | 用途 |
|------|------|--------|------|
| `dataSource` | `RDReaderDataSource?` | nil | 弱引用数据源 |
| `delegate` | `RDReaderDelegate?` | nil | 弱引用事件监听 |
| `currentDisplayType` | `DisplayType` | .pageCurl | 当前显示模式 |
| `toolViewAnimationDuration` | `TimeInterval` | 0.3 | 工具栏动画时长 |
| `landscapeDualPageEnabled` | `Bool` | false | 横屏双页 |
| `pageDirection` | `PageDirection` | .leftToRight | 翻页方向 |
| `coverPageIndex` | `Int?` | nil | 封面页索引 |
| `currentPage` | `Int` | -1 | 当前页码(didSet 通知 delegate |
### 5.4 计算属性
- `pagesPerScreen: Int`:横屏 + 双页启用 + 非垂直滚动 → 2;否则 1
- `isLandscape: Bool``bounds.width > bounds.height`
- `hasCoverPage: Bool``coverPageIndex != nil`
## 6. 视图绑定规则
- 规则 1:内容视图通过 `register(contentView:contentViewWithReuseIdentifier:)` 注册,内部注册对应的 `RDReaderContentCell`
- 规则 2Cell 复用两层:`RDReaderContentCell`UICollectionViewCell)宿主 `containerView`(实际内容视图)
- 规则 3`pageContentView(readerView:pageNum:containerView:)``containerView` 参数是从回收 cell 中取出的旧视图,允许数据源复用或重新渲染
- 规则 4:工具栏通过 `topToolView` / `bottomToolView` 协议方法提供,由 `RDReaderView` 管理显隐动画
- 规则 5RTL 支持通过 collectionView `scaleX: -1` + cell contentView `scaleX: -1` 实现,文本正确显示
## 7. 通知协作与回调机制
- 无 NotificationCenter 使用
- 所有通信通过协议和闭包:
- `RDReaderDataSource`:数据供给(页数、内容视图、标识符、工具栏)
- `RDReaderDelegate`:事件通知(页面变化、横竖屏即将变化)
- `RDReaderFlowLayoutDelegate`:布局检测到页面变化时通知
- `RDReaderFlowLayoutDataSoure`:垂直滚动时提供每页高度
## 8. 联调与排查建议
- 排查 1:页面不显示
- 确认 `dataSource` 已设置且 `reloadData()` 已调用
- 确认 `pageCountOfReaderView` 返回值 > 0
- 确认 `pageContentView(pageNum:containerView:)` 返回非 nil 视图
- 排查 2:翻页无反应
- 确认工具栏是否正在显示(工具栏显示时左右点击变为切换工具栏)
- 确认 `currentPage` 的 didSet 是否被触发
- pageCurl 模式检查 `UIPageViewController` 的 dataSource 方法是否正确返回
- 排查 3:横屏双页异常
- 确认 `landscapeDualPageEnabled` 是否为 true
- pageCurl 模式需重建 UIPageViewControllerspineLocation 不可变)
- 检查 `dualPagePair` 配对逻辑和空白哨兵页处理
- 排查 4RTL 方向错误
- 确认 `pageDirection` 是否设为 `.rightToLeft`
- 水平滚动模式检查 collectionView 是否做了 `scaleX: -1`
- pageCurl 模式检查导航方向是否翻转
- 排查 5:工具栏动画异常
- 确认 `topToolView` / `bottomToolView` 是否通过 dataSource 正确提供
- 检查 `toolViewAnimationDuration`
- 确认工具栏显示期间用户交互是否被正确禁用/恢复
+455
View File
@@ -0,0 +1,455 @@
# ReaderView 模块代码级参考文档
> 最后更新:2026-06-18
---
## 1. 模块概述
`ReaderView` 是 ReadViewSDK 的**通用翻页容器层**,位于 EPUBUI 层之下。它提供与 EPUB 内容无关的页面展示、翻页动画、手势识别、页面预加载和双页布局能力。上层通过 `RDEpubReaderPageProvider` 协议提供页面内容视图,ReaderView 负责容器管理和翻页调度。
**文件清单(14 个 Swift 文件):**
| 文件 | 核心类型 | 职责 |
|------|----------|------|
| `RDEpubReaderView.swift` | `RDEpubReaderView` | 主容器视图,协调所有子组件 |
| `RDEpubReaderViewProtocols.swift` | 协议 + 枚举 | 数据源、代理、导航协议定义 |
| `RDEpubReaderFlowLayout.swift` | `RDEpubReaderFlowLayout` | UICollectionView 自定义布局 |
| `RDEpubReaderGestureController.swift` | `RDEpubReaderGestureController` | 手势控制器(预留) |
| `RDEpubReaderContentCell.swift` | `RDEpubReaderContentCell` | CollectionView 内容 Cell |
| `RDEpubReaderPageChildViewController.swift` | `RDEpubReaderPageChildViewController` | PageCurl 模式子 VC |
| `RDEpubReaderView+PageCurl.swift` | Extension | UIPageViewController 数据源/代理 |
| `RDEpubReaderView+CollectionView.swift` | Extension | UICollectionView 数据源/布局代理 |
| `RDEpubReaderView+ContentAccess.swift` | Extension | 内容视图访问与复用 |
| `RDEpubReaderView+ToolView.swift` | Extension | 工具栏安装与动画 |
| `Paging/RDEpubReaderPagingController.swift` | `RDEpubReaderPagingController` | 翻页状态机 |
| `Paging/RDEpubReaderPreloadController.swift` | `RDEpubReaderPreloadController` | 页面预加载与缓存 |
| `Paging/RDEpubReaderSpreadResolver.swift` | `RDEpubReaderSpreadResolver` | 双页展开计算 |
| `Paging/RDEpubReaderTapRegionHandler.swift` | `RDEpubReaderTapRegionHandler` | 点击区域判定 |
---
## 2. 协议定义(RDEpubReaderViewProtocols.swift
### 2.1 RDEpubReaderDataSource(旧版数据源,已废弃)
```swift
@objc public protocol RDEpubReaderDataSource: NSObjectProtocol {
func pageCountOfReaderView(readerView: RDEpubReaderView) -> Int
func pageContentView(readerView: RDEpubReaderView, pageNum: Int, containerView: UIView?) -> UIView
func pageIdentifier(readerView: RDEpubReaderView, pageNum: Int) -> String?
@objc optional func topToolView(readerView: RDEpubReaderView) -> UIView?
@objc optional func bottomToolView(readerView: RDEpubReaderView) -> UIView?
}
```
> 向后兼容保留,新代码应使用 `RDEpubReaderPageProvider`。
### 2.2 RDEpubReaderPageProvider(推荐数据源)
```swift
@objc public protocol RDEpubReaderPageProvider: NSObjectProtocol {
func numberOfPages(in readerView: RDEpubReaderView) -> Int
func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView
@objc optional func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String?
@objc optional func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView?
@objc optional func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView?
}
```
| 方法 | 说明 |
|------|------|
| `numberOfPages(in:)` | 返回总页数 |
| `readerView(_:viewForPageAt:reusableView:)` | 为指定页码提供内容视图,`reusableView` 可复用 |
| `pageIdentifier(in:index:)` | 返回页视图复用标识符,用于 CollectionView 注册 |
| `readerViewTopChrome(_:)` | 返回顶部工具栏视图 |
| `readerViewBottomChrome(_:)` | 返回底部工具栏视图 |
### 2.3 RDEpubReaderDelegate
```swift
@objc public protocol RDEpubReaderDelegate: NSObjectProtocol {
func pageNum(readerView: RDEpubReaderView, pageNum: Int)
@objc optional func readerViewOrientationWillChange(readerView: RDEpubReaderView, isLandscape: Bool)
}
```
### 2.4 RDEpubReaderPageNavigating
```swift
public protocol RDEpubReaderPageNavigating: AnyObject {
var currentPage: Int { get }
func reloadPages()
func transition(to page: Int, animated: Bool)
}
```
`RDEpubReaderView` 遵循此协议,提供统一的页面导航接口。
### 2.5 枚举类型
```swift
extension RDEpubReaderView {
public enum DisplayType {
case pageCurl // 仿UIPageViewController
case horizontalScroll // UICollectionView
case verticalScroll // UICollectionView
}
public enum PageDirection {
case leftToRight // LTR
case rightToLeft // RTL
}
}
```
---
## 3. 核心类:RDEpubReaderView
**文件:** `RDEpubReaderView.swift`
`RDEpubReaderView` 是一个 `UIView` 子类,作为翻页容器的主入口。内部管理两种翻页引擎:
- **PageCurl 模式**:使用 `UIPageViewController` 实现仿真翻页
- **Scroll 模式**:使用 `UICollectionView` + 自定义 `RDEpubReaderFlowLayout` 实现滑动翻页
### 3.1 关键属性
| 属性 | 类型 | 说明 |
|------|------|------|
| `currentPage` | `Int` | 当前页码,变化时通知 delegate |
| `currentDisplayType` | `DisplayType` | 当前显示模式 |
| `pageDirection` | `PageDirection` | 页面方向(LTR/RTL |
| `landscapeDualPageEnabled` | `Bool` | 是否启用横屏双页 |
| `coverPageIndex` | `Int?` | 封面页索引(独占一屏) |
| `pagesPerScreen` | `Int` | 每屏页数(横屏双页时为 2) |
| `preloadRadius` | `Int` | 预加载半径(默认 1 |
| `dataSource` | `RDEpubReaderDataSource?` | 旧版数据源 |
| `pageProvider` | `RDEpubReaderPageProvider?` | 推荐数据源 |
| `delegate` | `RDEpubReaderDelegate?` | 事件代理 |
| `toolViewAnimationDuration` | `TimeInterval` | 工具栏动画时长(0.3s |
### 3.2 关键方法
```swift
/// pageCurl / horizontalScroll / verticalScroll
public func switchReaderDisplayType(_ displayType: RDEpubReaderView.DisplayType)
///
public func transitionToPage(pageNum: Int, animated: Bool = false)
///
public func reloadData()
///
public func reloadPageCountOnly()
///
public func isFullScreenPage(_ pageNum: Int) -> Bool
```
### 3.3 内部子组件
| 组件 | 类型 | 职责 |
|------|------|------|
| `pageViewController` | `UIPageViewController` | PageCurl 翻页引擎 |
| `collectionView` | `UICollectionView` | Scroll 翻页引擎 |
| `layout` | `RDEpubReaderFlowLayout` | CollectionView 自定义布局 |
| `spreadResolver` | `RDEpubReaderSpreadResolver` | 双页配对计算 |
| `tapRegionHandler` | `RDEpubReaderTapRegionHandler` | 点击区域判定 |
| `preloadController` | `RDEpubReaderPreloadController` | 页面预加载与缓存 |
| `pagingController` | `RDEpubReaderPagingController` | 翻页状态管理 |
---
## 4. 翻页状态机:RDEpubReaderPagingController
**文件:** `Paging/RDEpubReaderPagingController.swift`
管理 PageCurl 模式下的翻页请求队列,防止动画冲突。
```swift
struct RDEpubReaderPagingController {
struct PageTransitionRequest: Equatable {
let pageNum: Int
let animated: Bool
}
var pendingTransitionRequest: PageTransitionRequest? //
var isTransitioning: Bool //
var didBuildUI: Bool // UI
/// PageCurl true
mutating func shouldQueuePageTransition(_ request: PageTransitionRequest, currentDisplayType: RDEpubReaderView.DisplayType) -> Bool
///
mutating func finishPageCurlTransition() -> PageTransitionRequest?
///
mutating func resetPendingState()
}
```
---
## 5. 页面预加载:RDEpubReaderPreloadController
**文件:** `Paging/RDEpubReaderPreloadController.swift`
负责在当前页周围预渲染页面视图,减少翻页时的白屏时间。
### 5.1 核心机制
- **缓存签名(CacheSignature**:基于 `displayType + isLandscape + pagesPerScreen + boundsSize` 生成签名,签名变化时清空缓存
- **双缓存池**`preloadedPageViews`(预加载池)和 `pageCurlCachedViews`PageCurl 缓存池)
- **预测性预加载**:根据 `preferredForward` 方向多预加载一页
### 5.2 关键方法
```swift
///
func pageViewForDisplay(pageNum: Int, environment: Environment, contentViewProvider: (Int, UIView?) -> UIView?) -> UIView
///
func prime(around pageNum: Int, preferredForward: Bool?, parentView: UIView, environment: Environment, contentViewProvider: (Int, UIView?) -> UIView?)
/// CollectionView
func takePreloadedView(for pageNum: Int) -> UIView?
/// 使
func invalidate(environment: Environment)
```
### 5.3 Environment 结构体
```swift
struct Environment {
let displayType: RDEpubReaderView.DisplayType
let isLandscape: Bool
let pagesPerScreen: Int
let boundsSize: CGSize
let landscapeDualPageEnabled: Bool
let coverPageIndex: Int?
let totalPages: Int
let spreadResolver: RDEpubReaderSpreadResolver
}
```
---
## 6. 双页展开计算:RDEpubReaderSpreadResolver
**文件:** `Paging/RDEpubReaderSpreadResolver.swift`
纯函数式结构体,负责双页模式下的页面配对和导航计算。
```swift
struct RDEpubReaderSpreadResolver {
///
func isFullScreenPage(_ pageNum: Int, landscapeDualPageEnabled: Bool, isLandscape: Bool, coverPageIndex: Int?) -> Bool
/// left, right?right nil
func dualPagePair(for pageNum: Int, totalPages: Int, coverPageIndex: Int?) -> (left: Int, right: Int?)
///
func adjacentDualPage(from pageNum: Int, totalPages: Int, coverPageIndex: Int?, forward: Bool) -> Int?
///
func nextPage(from currentPage: Int, totalPages: Int, pagesPerScreen: Int, coverPageIndex: Int?, forward: Bool) -> Int?
}
```
**封面页逻辑:**
- 封面页(`coverPageIndex`)独占一屏,不与其他页配对
- 封面后的页面从 `coverIndex + 1` 开始两两配对
---
## 7. 点击区域判定:RDEpubReaderTapRegionHandler
**文件:** `Paging/RDEpubReaderTapRegionHandler.swift`
将屏幕三等分,判定点击属于左/中/右区域。
```swift
struct RDEpubReaderTapRegionHandler {
func resolveTapEvent(point: CGPoint, viewFrame: CGRect, isToolViewVisible: Bool) -> RDEpubReaderView.TapEvent
}
```
**逻辑:**
- 左 1/3 → `.left`(上一页),工具栏可见时改为 `.center`
- 中 1/3 → `.center`(切换工具栏)
- 右 1/3 → `.right`(下一页),工具栏可见时改为 `.center`
---
## 8. 流式布局:RDEpubReaderFlowLayout
**文件:** `RDEpubReaderFlowLayout.swift`
`UICollectionViewFlowLayout` 子类,支持水平滚动和垂直滚动两种模式。
### 8.1 关键属性
| 属性 | 类型 | 说明 |
|------|------|------|
| `displayType` | `RDEpubReaderView.DisplayType` | 布局模式 |
| `isLandscapeDualPage` | `Bool` | 是否横屏双页 |
| `coverPageIndex` | `Int?` | 封面页索引 |
| `pagesPerScreen` | `Int` | 每屏页数 |
### 8.2 协议
```swift
public protocol RDEpubReaderFlowLayoutDataSoure: NSObjectProtocol {
func heigtOfVerticalScrollPage(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int) -> CGFloat?
}
@objc public protocol RDEpubReaderFlowLayoutDelegate: NSObjectProtocol {
func pageNum(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int)
}
```
### 8.3 关键方法
```swift
/// contentOffset
func currentContentOffset(count: Int) -> CGPoint
```
### 8.4 封面页布局逻辑
双页模式下,封面页占满整屏宽度,后续页面两两配对占半屏宽度。`coverAwareFrame(for:screenWidth:halfWidth:height:)` 方法根据页码计算对应的 frame。
---
## 9. 内容 CellRDEpubReaderContentCell
**文件:** `RDEpubReaderContentCell.swift`
`UICollectionViewCell` 子类,用于 Scroll 模式下承载页面内容视图。
```swift
class RDEpubReaderContentCell: UICollectionViewCell {
var containerView: UIView? // contentView
}
```
---
## 10. PageCurl 子控制器:RDEpubReaderPageChildViewController
**文件:** `RDEpubReaderPageChildViewController.swift`
`UIViewController` 子类,作为 `UIPageViewController` 的页面 VC。
```swift
class RDEpubReaderPageChildViewController: UIViewController {
var contentView: UIView? //
var pageNum: Int //
init(contentView: UIView?, pageNum: Int = 0)
}
```
---
## 11. Extension 汇总
### 11.1 RDEpubReaderView+PageCurl
实现 `UIPageViewControllerDataSource``UIPageViewControllerDelegate`
- `pageViewController(_:viewControllerBefore:)` — 提供前一页 VC(RTL 时逻辑反转)
- `pageViewController(_:viewControllerAfter:)` — 提供后一页 VC
- `pageViewController(_:didFinishAnimating:...)` — 完成动画后更新 currentPage
- `pageViewController(_:willTransitionTo:)` — 即将翻页时预加载
**特殊页码:**
- `RDEpubReaderView.blankPageNum``Int.max`)— 双页模式下的空白页
- `RDEpubReaderView.blankEndPageNum``Int.max - 1`)— 末尾空白页
### 11.2 RDEpubReaderView+CollectionView
实现 `UICollectionViewDataSource``RDEpubReaderFlowLayoutDelegate``RDEpubReaderFlowLayoutDataSoure`
- `collectionView(_:cellForItemAt:)` — 复用预加载视图或创建新 Cell
- `collectionView(_:numberOfItemsInSection:)` — 返回总页数
- `pageNum(flowLayout:pageIndex:)` — 滚动时更新当前页码
### 11.3 RDEpubReaderView+ContentAccess
提供内容视图的注册、复用和查询:
```swift
/// UICollectionView register
public func register(contentView: UIView.Type, contentViewWithReuseIdentifier identifier: String)
///
public func dequeueReusableContentView(withReuseIdentifier identifier: String, for pageNum: Int) -> UIView
///
public func pageContentView(pageNum: Int) -> UIView?
///
public func resolvedSinglePageSize(pageNum: Int? = nil) -> CGSize
```
### 11.4 RDEpubReaderView+ToolView
管理顶部/底部工具栏的安装、显示/隐藏动画:
```swift
///
func tapCenter()
///
func installToolViewIfNeeded(_ toolView: UIView, position: ToolViewPosition)
///
func updateToolViewHeightConstraintsIfNeeded()
```
**动画效果:** 顶部工具栏从上方滑入,底部工具栏从下方滑入。
---
## 12. 设计模式总结
| 模式 | 应用 |
|------|------|
| **策略模式** | `DisplayType` 切换 PageCurl / Scroll 两种翻页策略 |
| **适配器模式** | `RDEpubReaderLegacyDataSourceAdapter` 将旧 `RDEpubReaderDataSource` 适配为 `RDEpubReaderPageProvider` |
| **命令队列** | `RDEpubReaderPagingController` 管理翻页请求队列 |
| **缓存签名** | `RDEpubReaderPreloadController.CacheSignature` 检测环境变化自动失效 |
| **关注点分离** | Extension 将不同功能拆分到独立文件 |
| **纯函数** | `RDEpubReaderSpreadResolver``RDEpubReaderTapRegionHandler` 无状态计算 |
---
## 13. 数据流图
```
用户点击屏幕
RDEpubReaderTapRegionHandler.resolveTapEvent()
├── .left → goPreviousPage() ──→ spreadResolver.nextPage(forward: false)
├── .right → goNextPage() ──→ spreadResolver.nextPage(forward: true)
└── .center → tapCenter() ──→ 显示/隐藏工具栏
transitionToPage(pageNum:)
├── PageCurl 模式
│ ├── pagingController.shouldQueuePageTransition() → 排队或执行
│ ├── pageViewForDisplay() → preloadController 取缓存视图
│ ├── pageViewController.setViewControllers()
│ └── primePageCache() → 预加载周围页面
└── Scroll 模式
├── collectionView.reloadData()
├── collectionView.setContentOffset()
└── primePageCache() → 预加载周围页面
```
+149
View File
@@ -0,0 +1,149 @@
# 测试说明
**分析日期:** 2026-06-22(更新)
## 测试框架与现状
**Runner**
- XCTestiOS/Xcode 默认测试框架)
- **当前仓库状态**:包含 UI 测试 Target `ReadViewDemoUITests`,位于 `ReadViewDemo/ReadViewDemoUITests/`
**断言库:**
- XCTest 内建断言(`XCTAssert*``XCTAssertEqual``XCTAssertNotNil` 等)
- XCUIElement 断言(`exists``waitForExistence``isHittable` 等)
## 测试文件组织
**位置:**
- 测试文件:`ReadViewDemo/ReadViewDemoUITests/ReaderUITests/`
- 辅助文件:`ReadViewDemo/ReadViewDemoUITests/Helpers/`
**文件清单(24 个测试文件):**
| 测试文件 | 覆盖功能 |
|----------|----------|
| BookmarkTests.swift | 书签基础操作 |
| BookmarkManagementTests.swift | 书签管理(增删改查) |
| ConcurrentParsingTests.swift | 并发解析稳定性 |
| ConfigurableWindowTests.swift | 可配置章节窗口 |
| DisplayTypeTests.swift | 显示模式切换 |
| ErrorAndEdgeCaseTests.swift | 错误与边界情况 |
| FanrenParseTimeTest.swift | 《凡人修仙传》解析性能基准 |
| HighlightsManagementTests.swift | 高亮标注管理 |
| LargeBookOnDemandTests.swift | 大书按需加载 |
| LocationPersistenceTests.swift | 阅读位置持久化 |
| MetadataParseBenchmarkTests.swift | 元数据解析性能基准 |
| PageNavigationTests.swift | 页面导航 |
| PDFPageTurnBlankPageTests.swift | PDF 翻页后白页回归(截屏墨迹占比判定) |
| ReaderAnnotationTests.swift | 标注基础功能 |
| ReaderAnnotationExtendedTests.swift | 标注扩展功能 |
| ReaderOpenCloseTests.swift | 阅读器打开/关闭 |
| ReaderToolbarTests.swift | 工具栏交互 |
| SearchTests.swift | 全文搜索 |
| SelectionMenuTests.swift | 选区菜单 |
| SettingsEffectTests.swift | 设置效果验证 |
| SettingsExtendedTests.swift | 设置扩展功能 |
| SettingsPanelTests.swift | 设置面板交互 |
| TableOfContentsTests.swift | 目录功能 |
| TOCInteractionTests.swift | 目录交互 |
| ToolbarStateTests.swift | 工具栏状态管理 |
**辅助文件(3 个):**
| 文件 | 用途 |
|------|------|
| AccessibilityIdentifiers.swift | UI 元素 accessibility identifier 定义 |
| DemoReaderState.swift | 测试用阅读器状态辅助 |
| XCUIApplication+Launch.swift | XCUIApplication 启动配置扩展 |
**统计:**
- 测试类:23 个(XCTestCase 子类)
- 测试方法:约 99 个(`func test...`
- 辅助文件:3 个
## 如何运行
### CocoaPods 依赖准备
> 仓库包含示例工程 `ReadViewDemo`,并已提交 `ReadViewDemo/Pods/` 与 `ReadViewDemo/Podfile.lock`。如本地环境未同步,请在示例工程目录运行:
```bash
cd ReadViewDemo && pod install
```
(依赖入口:`ReadViewDemo/Podfile`
### 模拟器要求
Demo 链接了 `FoundationModels.framework`(AI 助手示例入口)。该框架自 iOS 26 起提供,
在 iOS 18 及更早的模拟器上 App 会在启动时因 `Library not loaded` 直接崩溃,测试表现为
卡在 Launch 阶段。请选择 iOS 26 及以上的模拟器运行 UI 测试。
### 运行 UI 测试
```bash
xcodebuild test \
-workspace ReadViewDemo/ReadViewDemo.xcworkspace \
-scheme ReadViewDemo \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-only-testing:ReadViewDemoUITests
```
### 运行单个测试类
```bash
xcodebuild test \
-workspace ReadViewDemo/ReadViewDemo.xcworkspace \
-scheme ReadViewDemo \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-only-testing:ReadViewDemoUITests/SearchTests
```
### 运行脚本
项目提供了自动化回归脚本:
```bash
./scripts/run_ui_regression.sh
```
测试报告输出到 `.artifacts/ui-tests/{timestamp}/UI-Test-Report.md`
## 架构整改回归重点
在 2026-06-22 的阅读器架构整改后,以下场景应作为 smoke 回归最小集合:
- 打开大书后首次进入阅读页
- 章节尾页连续翻页,确认跨章节动画无明显卡顿
- 目录远跳到未预热章节,再返回当前阅读流
- 关闭并重新打开书籍,恢复上次阅读位置
- 长按选区、高亮、批注后再次翻页
- 打开设置面板调整字号/间距,观察预览与最终全量 repagination
建议同时记录以下观测项:
- `prepareOnDemandChapter` 主线程 wall clock
- `RDEpubReaderPreloadController` 预加载命中率
- `bookPageMap` partial extension / full replacement 次数
- 页面静态底图缓存命中率
- CFI 延迟构建完成次数与耗时
## 覆盖率(Coverage
- 当前未启用代码覆盖率收集
- 若需启用,可在 Xcode Scheme → Test → Options 中勾选 "Code Coverage"
- 或通过 `xcodebuild test``-enableCodeCoverage YES` 参数
## Mocking / Fixtures
- 未使用统一 mocking 框架
- 测试通过 Demo App 加载真实 EPUB 文件进行端到端验证
- 辅助文件 `DemoReaderState.swift` 提供测试状态管理
## Evidence(关键证据文件)
- `ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj`
- `ReadViewDemo/ReadViewDemoUITests/` 目录
- `ReadViewDemo/Podfile`
- `ReadViewDemo/Podfile.lock`
- `.artifacts/ui-tests/` 下的历史测试报告
+349
View File
@@ -0,0 +1,349 @@
# 排版管线详解
> 最后更新:2026-06-18
本文档详细描述 ReadViewSDK 文本排版管线(Typesetter Pipeline)的 8 个处理阶段、核心算法和数据流。
---
## 1. 概述
排版管线是 EPUBTextRendering 层的核心组件,负责将 EPUB 原始 HTML 转换为可分页的 NSAttributedString。它是 textReflowable 渲染路径(本 SDK 核心路径)的关键环节。
**入口**`RDEPUBTextTypesetterPipeline.makeRequest(from:)`
**输入**`RDEPUBTypesettingInput`(原始 HTML、样式、布局配置)
**输出**`RDEPUBTypesettingOutput`(渲染请求、诊断信息、兼容性报告)
**关键文件**`Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/`
---
## 2. 管线总览
```
Raw HTML (从 EPUB 解压目录读取)
▼ [阶段 1] RDEPUBHTMLNormalizer
│ 去 CR、合并空行、规范化附件 HTML
▼ [阶段 2] RDEPUBSemanticMarkerInjector
│ 注入分页语义标记 ${rd-sem-start/end}
▼ [阶段 3] RDEPUBCFIMarkerInjector
│ 注入 CFI 路径标记
▼ [阶段 4] RDEPUBStyleSheetComposer
│ 内联 <link stylesheet>、构建 5 层 CSS、注入 <base href>
▼ [阶段 5] RDEPUBFontNormalizer
│ 解析 @font-face、注册嵌入字体
▼ [阶段 6] RDEPUBFragmentMarkerInjector
│ 注入 fragment 锚点标记 ${id=xxx}
▼ [阶段 7] RDEPUBRenderDiagnosticsCollector
│ 收集图片诊断、资源引用检查
▼ 构建 RDEPUBTextChapterRenderRequest
▼ [阶段 8] RDEPUBDTCoreTextRenderer
HTML → NSAttributedString → 后处理 → 分页
```
---
## 3. 阶段 1HTML 规范化(RDEPUBHTMLNormalizer
**文件**`RDEPUBHTMLNormalizer.swift`
### 3.1 处理内容
| 规则 | 说明 |
|------|------|
| CR → LF | 统一换行符 |
| 合并连续空行 | 多个空行合并为一个 |
| 删除分页标记 | `<hr lang="zh-CN">分页符</hr>` |
| 附件 HTML 规范化 | 见下表 |
### 3.2 附件 HTML 规范化规则
| 原始 HTML | 规范化结果 |
|-----------|-----------|
| `div.qrbodyPic / div.bodyPic` | 合并样式到 `<img>` |
| `img.qqreader-footnote` | 行内 1em×1em |
| `h1.frontCover > img` | 封面图片 100% 宽度 |
### 3.3 Base URL 注入
`<head>` 开头注入 `<base href="...">` 用于相对路径解析:
```swift
static func injectBaseHref(into html: String, baseURL: URL?) -> String
```
---
## 4. 阶段 2:语义标记注入(RDEPUBSemanticMarkerInjector
**文件**`RDEPUBSemanticMarkerInjector.swift`
### 4.1 标记语法
```html
${rd-sem-start:id=X;block=...;hints=...;placement=...}
...内容...
${rd-sem-end:id=X}
```
### 4.2 注入规则
- 遍历所有 HTML 标签,维护开标签栈
- 为有分页语义的标签注入标记:
- `block`:块类型(paragraph、heading、list 等)
- `hints`:分页提示(avoidPageBreakInside、keepWithNext 等)
- `placement`:附件位置(inline、block
- 空标签(img, br, hr)同时注入开始和结束标记
### 4.3 语义提示类型
| 提示 | 说明 |
|------|------|
| `avoidPageBreakInside` | 避免在块内分页 |
| `keepWithNext` | 与下一个块保持同页 |
| `attachmentBlock` | 块级附件(图片等) |
---
## 5. 阶段 3CFI 标记注入(RDEPUBCFIMarkerInjector
**文件**`RDEPUBCFIMarkerInjector.swift`
注入 CFI 路径标记,用于后续 CFI 映射构建。在每个文本节点前注入 CFI 路径信息,使渲染后的 NSAttributedString 能够建立 CFI 路径到文本偏移量的映射。
---
## 6. 阶段 4CSS 层合成(RDEPUBStyleSheetComposer
**文件**`RDEPUBStyleSheetComposer.swift`
### 6.1 五层 CSS 架构
按优先级从低到高:
| 层 | Kind | 说明 | 注入位置 |
|----|------|------|----------|
| 1 | `.default` | 基础阅读器样式(隐藏 head/title/style,默认字体大小) | `<head>` 开头 |
| 2 | `.replace` | 格式化样式(代码块、标题、引用、列表) | `<head>` 开头 |
| 3 | `.dark` | 暗色模式覆盖(仅暗色主题时注入) | `<head>` 开头 |
| 4 | `.epub` | EPUB 自带样式(内联后的 `<link stylesheet>` | `<head>` 末尾 |
| 5 | `.user` | 用户设置(字号、行距、颜色,!important) | `<head>` 末尾 |
### 6.2 语言检测
```swift
static func prefersLatinLanguageCSS(
languageCode: String?,
sourceHTML: String
) -> Bool
```
检测逻辑:
1. 检查 `lang` 属性(如 `lang="en"`
2. 对 HTML 文本采样,统计拉丁字符比例
3. 拉丁语言使用专用 CSS`wxread-replace-latin.css`
### 6.3 样式表内联
`RDEPUBRenderDiagnosticsCollector.inlineLinkedStyleSheets()` 负责:
1. 查找 `<link rel=stylesheet href=...>`
2. 读取 CSS 文件内容
3. 重写 CSS 中的相对 `url()` 引用
4. 内联到 HTML 的 `<head>`
---
## 7. 阶段 5:字体注册(RDEPUBFontNormalizer
**文件**`RDEPUBFontNormalizer.swift`
### 7.1 处理流程
1. 解析 `@font-face { url(...) }`
2. 提取字体文件路径和 family 名称
3. 通过 `CTFontManagerRegisterFontsForURL(.process)` 注册嵌入字体
4. 已注册字体路径缓存在 `registeredFontPaths` 集合中,避免重复注册
### 7.2 注册结果
```swift
struct RDEPUBFontRegistrationResult {
let descriptor: RDEPUBFontDescriptor
let didRegister: Bool
let errorDescription: String?
}
```
注册失败的字体不会阻断管线,但会记录到兼容性报告中。
---
## 8. 阶段 6Fragment 标记注入(RDEPUBFragmentMarkerInjector
**文件**`RDEPUBFragmentMarkerInjector.swift`
扫描 HTML 中的 `id` 属性,在其前面注入 `${id=xxx}` 标记:
```html
<h2 id="section1">标题</h2>
${id=section1}<h2 id="section1">标题</h2>
```
渲染后这些标记会被转换为 NSAttributedString 属性,用于 fragment 偏移量提取。
---
## 9. 阶段 7:诊断收集(RDEPUBRenderDiagnosticsCollector
**文件**`RDEPUBRenderDiagnosticsCollector.swift`
### 9.1 图片诊断
- 扫描 `<img src="...">` 标签
- 解析引用路径,检查文件是否存在
- 收集诊断信息用于调试
### 9.2 资源引用检查
- 检查 CSS 中的 `url()` 引用
- 验证字体文件是否存在
- 记录缺失资源的诊断信息
---
## 10. 阶段 8:渲染与后处理(RDEPUBDTCoreTextRenderer
**文件**`RDEPUBDTCoreTextRenderer.swift`
### 10.1 HTML → NSAttributedString
```swift
func renderChapter(request: RDEPUBTextChapterRenderRequest) throws -> RDEPUBRenderedChapterContent
```
1. 将 HTML 编码为 `Data`
2. 使用 `DTHTMLAttributedStringBuilder` 构建 `NSAttributedString`
3.`willFlushCallback` 中对每个 DOM 元素调用 `RDEPUBAttachmentNormalizer.prepareHTMLElementForReaderRendering()`
### 10.2 后处理
**applyPaginationSemantics()**
-`${rd-sem-start/end}` 标记转为 NSAttributedString 属性
- 属性键:`.rdPageSemanticHints``.rdPageBlockKind``.rdPageAttachmentPlacement`
**extractFragmentOffsets()**
- 提取 `${id=xxx}` 标记
- 生成 fragment ID → 字符偏移量映射
- 删除标记文本
**normalizeReadingAttributes()**
- 规范化字体(应用用户选择的字体)
- 调整行距(应用 lineHeightMultiple
- 设置文字颜色(应用主题颜色)
- 处理附件(图片缩放、对齐)
---
## 11. 分页计算
### 11.1 RDEPUBChapterPageCounter
**文件**`Pagination/RDEPUBChapterPageCounter.swift`
使用 CoreText 迭代分页:
```swift
func layoutFrames(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame]
```
**分页循环**
```
location = 0
while location < totalLength:
1. 创建 CTFrame(通过 CTFramesetter
2. 获取可见范围 (CTFrameGetVisibleStringRange)
3. 应用 avoidPageBreakInside 规则(最多移除 3 行尾部)
4. 应用 keepWithNext 规则(最多移除 3 行尾部)
5. 应用 widow/orphan 控制
6. 应用 pageBreakPolicy 调整
7. 记录页面范围
8. location = 调整后的范围末尾
```
### 11.2 RDEPUBPageBreakPolicy
**文件**`Pagination/RDEPUBPageBreakPolicy.swift`
分页规则优先级:
| 规则 | 说明 | 最大调整行数 |
|------|------|-------------|
| `avoidPageBreakInside` | 块内不分页(标题、图片等) | 3 行 |
| `keepWithNext` | 标题与正文不分离 | 3 行 |
| widow control | 段落最后一行不留到下一页 | 1 行 |
| orphan control | 段落第一行不单独在上一页 | 1 行 |
| attachment boundary | 块级图片前后分页 | 0(精确切分) |
**判断方法**
```swift
func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool
func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool
func adjustedRange(from:totalLength:lineRanges:factory:) -> (range, breakReason, ...)
```
### 11.3 RDEPUBChapterTailNormalizer
**文件**`BuildPipeline/RDEPUBChapterTailNormalizer.swift`
三遍处理:
1. **删除空白中间帧**:无可见字符且无附件的帧
2. **删除空白尾部帧**:从末尾开始删除同类空白帧
3. **合并短尾帧**:如果最后一帧 ≤2 个可见字符且前一帧 ≥8 倍长,合并
---
## 12. 诊断与调试
### 12.1 兼容性报告
```swift
struct RDEPUBCSSCompatibilityReport {
let unsupportedRules: [String] // CSS
let normalizedRules: [String] //
let fontFailures: [String] //
}
```
### 12.2 资源诊断
```swift
struct RDEPUBTextResourceReferenceDiagnostic {
let href: String //
let exists: Bool //
let type: String // image/font/stylesheet
}
```
### 12.3 语义摘要
`RDEPUBReaderController.nativeTextSemanticSummary()` 返回当前页面的语义摘要,包含:
- 页码
- 分页原因(breakReason
- 块类型(blockKinds
- 语义提示(semanticHints
- 附件位置(attachmentPlacements
+734
View File
@@ -0,0 +1,734 @@
# ReadViewSDK UML 类图
> 最后更新:2026-06-09
> 图表格式:Mermaid(可在 GitHub / VS Code / Mermaid Live Editor 中渲染)
---
## 1. 整体模块关系
```mermaid
graph TB
subgraph EPUBCore
Parser[RDEPUBParser]
Pub[RDEPUBPublication]
Resolver[RDEPUBResourceResolver]
Session[RDEPUBReadingSession]
WebView[RDEPUBWebView]
Paginator[RDEPUBPaginator]
SearchEngine[RDEPUBHTMLSearchEngine]
end
subgraph EPUBTextRendering
Builder[RDEPUBTextBookBuilder]
Renderer[RDEPUBDTCoreTextRenderer]
Pipeline[RDEPUBTextTypesetterPipeline]
Counter[RDEPUBChapterPageCounter]
FrameFactory[RDEPUBCoreTextPageFrameFactory]
BreakPolicy[RDEPUBPageBreakPolicy]
BookCache[RDEPUBTextBookCache]
ChapterData[RDEPUBChapterData]
IndexTable[RDEPUBTextIndexTable]
end
subgraph RDEpubReaderView
ReaderView[RDEpubReaderView]
FlowLayout[RDEpubReaderFlowLayout]
Preload[RDEpubReaderPreloadController]
Spread[RDEpubReaderSpreadResolver]
TapRegion[RDEpubReaderTapRegionHandler]
end
subgraph EPUBUI
Controller[RDEPUBReaderController]
Context[RDEPUBReaderContext]
Runtime[RDEPUBReaderRuntime]
PaginationCoord[RDEPUBReaderPaginationCoordinator]
Loader[RDEPUBChapterLoader]
RuntimeStore[RDEPUBChapterRuntimeStore]
DiskCache[RDEPUBChapterSummaryDiskCache]
PageMap[RDEPUBBookPageMap]
Config[RDEPUBReaderConfiguration]
end
Parser --> Pub
Pub --> Resolver
Session --> Pub
WebView --> Pub
WebView --> Resolver
Paginator --> Parser
Builder --> Renderer
Builder --> Pipeline
Builder --> Counter
Counter --> FrameFactory
FrameFactory --> BreakPolicy
Builder --> BookCache
ChapterData --> IndexTable
Controller --> Context
Context --> Runtime
Runtime --> Loader
Runtime --> RuntimeStore
Runtime --> DiskCache
PaginationCoord --> Context
PaginationCoord --> PageMap
Loader --> RuntimeStore
Loader --> DiskCache
Controller --> ReaderView
Controller --> Session
Controller --> Builder
ReaderView --> FlowLayout
ReaderView --> Preload
ReaderView --> Spread
ReaderView --> TapRegion
```
---
## 2. EPUBCore 核心类图
```mermaid
classDiagram
class RDEPUBParser {
+metadata: RDEPUBMetadata
+manifest: [String: RDEPUBManifestItem]
+spine: [RDEPUBSpineItem]
+tableOfContents: [EPUBTableOfContentsItem]
+extractionRootURL: URL
+opfURL: URL
+opfDirectoryURL: URL
+parse(epubURL: URL) throws
+makePublication() RDEPUBPublication
+htmlString(forRelativePath:) String?
+htmlString(forSpineIndex:) String?
+coverImage() UIImage?
+resourceURL(forRelativePath:) URL
+fileURL(forRelativePath:) URL?
}
class RDEPUBPublication {
+parser: RDEPUBParser
+resourceResolver: RDEPUBResourceResolver
+metadata: RDEPUBMetadata
+manifest: [RDEPUBManifestItem]
+spine: [RDEPUBSpineItem]
+tableOfContents: [EPUBTableOfContentsItem]
+layout: RDEPUBLayout
+readingProfile: RDEPUBReadingProfile
+readingProgression: RDEPUBReadingProgression
+bookIdentifier: String?
+fixedLayoutSpreadEnabled(preferences:viewportSize:) Bool
+makeFixedSpreads(preferences:viewportSize:) [EPUBFixedSpread]
}
class RDEPUBResourceResolver {
+parser: RDEPUBParser
+fileURL(forRelativePath:) URL?
+resourceURL(forRelativePath:) URL
+normalizedHref(_:relativeToSpineIndex:) String?
+normalizedLocation(_:relativeToSpineIndex:bookIdentifier:) RDEPUBLocation?
+spineIndex(forNormalizedHref:) Int?
+spineIndex(for:) Int?
}
class RDEPUBReadingSession {
+publication: RDEPUBPublication
+navigatorState: RDEPUBNavigatorState
+activePages: [EPUBPage]
+activeChapters: [EPUBChapterInfo]
+currentViewport: RDEPUBViewport?
+currentReadingContext: RDEPUBReadingContext?
+transition(to: RDEPUBNavigatorState)
+setActiveSnapshot(_:)
+queueNavigation(to:relativeToSpineIndex:bookIdentifier:) Int
+updateReadingContext(...)
+makePaginationSnapshot(pageCounts:preferences:layoutContext:)
}
class RDEPUBWebView {
+publication: RDEPUBPublication?
+currentRenderRequest: RDEPUBRenderRequest?
+webView: WKWebView
+delegate: RDEPUBWebViewDelegate?
+load(publication:request:)
+reset()
}
class RDEPUBNavigatorState {
<<enum>>
initializing
loading
idle
jumping
moving
repaginating
+isStableForSnapshotApplication: Bool
}
RDEPUBParser --> RDEPUBPublication : creates
RDEPUBPublication --> RDEPUBResourceResolver : owns
RDEPUBReadingSession --> RDEPUBPublication : wraps
RDEPUBReadingSession --> RDEPUBNavigatorState : manages
RDEPUBWebView --> RDEPUBPublication : uses
RDEPUBWebView --> RDEPUBResourceResolver : uses
```
---
## 3. EPUBTextRendering 类图
```mermaid
classDiagram
class RDEPUBTextBookBuilder {
+renderer: RDEPUBTextRenderer
+cache: RDEPUBTextBookCache?
+layoutConfig: RDEPUBTextLayoutConfig
+renderPipeline: RDEPUBChapterRenderPipeline
+paginationPipeline: RDEPUBChapterPaginationPipeline
+tailNormalizer: RDEPUBChapterTailNormalizer
+build(parser:publication:pageSize:style:) throws RDEPUBTextBook
+buildChapter(parser:publication:spineIndex:pageSize:style:) throws RDEPUBTextChapterBuildResult?
}
class RDEPUBTextRenderer {
<<protocol>>
+renderChapter(request:) throws RDEPUBRenderedChapterContent
}
class RDEPUBDTCoreTextRenderer {
+renderChapter(request:) throws RDEPUBRenderedChapterContent
+renderChapter(html:baseURL:style:) throws RDEPUBRenderedChapterContent
}
class RDEPUBTextTypesetterPipeline {
+makeRequest(from: RDEPUBTypesettingInput) RDEPUBTypesettingOutput
}
class RDEPUBChapterPageCounter {
+factory: RDEPUBCoreTextPageFrameFactory
+layoutFrames(fragmentOffsets:) [RDEPUBTextLayoutFrame]
}
class RDEPUBCoreTextPageFrameFactory {
+attributedString: NSAttributedString
+pageSize: CGSize
+config: RDEPUBTextLayoutConfig
+makeFrames(...) [RDEPUBTextLayoutFrame]
}
class RDEPUBPageBreakPolicy {
+lineIsInAvoidPageBreakInsideBlock(_:) Bool
+lineIsInKeepWithNextBlock(_:) Bool
+adjustedRange(from:totalLength:lineRanges:factory:) RDEPUBPageBreakDecision
}
class RDEPUBTextBook {
+chapters: [RDEPUBTextChapter]
+pages: [RDEPUBTextPage]
+indexTable: RDEPUBTextIndexTable
+positionConverter: RDEPUBTextPositionConverter
+chapterData(for href:) RDEPUBChapterData?
+chapterData(forPageNumber:) RDEPUBChapterData?
}
class RDEPUBTextChapter {
+chapterIndex: Int
+spineIndex: Int
+href: String
+title: String
+attributedContent: NSAttributedString
+fragmentOffsets: [String: Int]
+pages: [RDEPUBTextPage]
}
class RDEPUBTextPage {
+absolutePageIndex: Int
+chapterIndex: Int
+spineIndex: Int
+contentRange: NSRange
+metadata: RDEPUBTextPageMetadata
}
class RDEPUBTextBookCache {
+schemaVersion: Int
+load(key:) [String: RDEPUBTextChapterPaginationCache]?
+save(_:key:)
+invalidateAll()
}
class RDEPUBChapterData {
+chapterIndex: Int
+spineIndex: Int
+href: String
+title: String
+pageCount: Int
+page(containing:) RDEPUBTextPage?
+pageNumber(containing:) Int?
+absoluteRange(for location:) ClosedRange<Int>?
}
class RDEPUBTextIndexTable {
+chapterStartOffsets: [Int]
+totalCharacterCount: Int
+anchor(forAbsoluteIndex:in:) RDEPUBTextAnchor
+pageNumber(for:in:) Int
+globalIndex(for:) Int
}
RDEPUBTextBookBuilder --> RDEPUBTextRenderer : uses
RDEPUBDTCoreTextRenderer ..|> RDEPUBTextRenderer
RDEPUBTextBookBuilder --> RDEPUBTextTypesetterPipeline : uses
RDEPUBTextBookBuilder --> RDEPUBChapterPageCounter : uses
RDEPUBChapterPageCounter --> RDEPUBCoreTextPageFrameFactory : uses
RDEPUBCoreTextPageFrameFactory --> RDEPUBPageBreakPolicy : uses
RDEPUBTextBookBuilder --> RDEPUBTextBookCache : uses
RDEPUBTextBook --> RDEPUBTextChapter : contains
RDEPUBTextChapter --> RDEPUBTextPage : contains
RDEPUBTextBook --> RDEPUBTextIndexTable : computes
RDEPUBChapterData --> RDEPUBTextChapter : wraps
RDEPUBChapterData --> RDEPUBTextIndexTable : uses
```
---
## 4. ReaderView 类图
```mermaid
classDiagram
class RDEpubReaderView {
+currentPage: Int
+currentDisplayType: DisplayType
+pageProvider: RDEpubReaderPageProvider?
+delegate: RDEpubReaderDelegate?
+landscapeDualPageEnabled: Bool
+pageDirection: PageDirection
+coverPageIndex: Int?
+pagesPerScreen: Int
+transitionToPage(pageNum:animated:)
+reloadData()
+switchReaderDisplayType(_:)
}
class DisplayType {
<<enum>>
pageCurl
horizontalScroll
verticalScroll
}
class RDEpubReaderPageProvider {
<<protocol>>
+numberOfPages(in:) Int
+readerView(_:viewForPageAt:reusableView:) UIView
+pageIdentifier(in:index:) String?
}
class RDEpubReaderFlowLayout {
+displayType: DisplayType
+isLandscapeDualPage: Bool
+coverPageIndex: Int?
+pagesPerScreen: Int
+currentPage: Int
}
class RDEpubReaderPreloadController {
+radius: Int
+pageViewForDisplay(pageNum:environment:contentViewProvider:) UIView?
+takePreloadedView(for:) UIView?
+prime(around:preferredForward:parentView:environment:contentViewProvider:)
+invalidate(environment:)
}
class RDEpubReaderSpreadResolver {
+isFullScreenPage(...) Bool
+dualPagePair(for:totalPages:coverPageIndex:) (Int, Int?)
+nextPage(from:totalPages:pagesPerScreen:coverPageIndex:forward:) Int?
}
class RDEpubReaderTapRegionHandler {
+resolveTapEvent(point:viewFrame:isToolViewVisible:) TapEvent
}
class TapEvent {
<<enum>>
none
left
center
right
}
class RDEpubReaderPagingController {
+isTransitioning: Bool
+didBuildUI: Bool
+pendingTransitionRequest: PageTransitionRequest?
+shouldQueuePageTransition(_:currentDisplayType:) Bool
+finishPageCurlTransition() PageTransitionRequest?
}
class RDEpubReaderContentCell {
+containerView: UIView?
}
class RDEpubReaderPageChildViewController {
+contentView: UIView?
+pageNum: Int
}
RDEpubReaderView --> DisplayType : uses
RDEpubReaderView --> RDEpubReaderPageProvider : delegates
RDEpubReaderView --> RDEpubReaderFlowLayout : owns
RDEpubReaderView --> RDEpubReaderPreloadController : owns
RDEpubReaderView --> RDEpubReaderSpreadResolver : owns
RDEpubReaderView --> RDEpubReaderTapRegionHandler : owns
RDEpubReaderView --> RDEpubReaderPagingController : owns
RDEpubReaderView --> RDEpubReaderContentCell : creates
RDEpubReaderView --> RDEpubReaderPageChildViewController : creates
RDEpubReaderTapRegionHandler --> TapEvent : produces
```
---
## 5. EPUBUI 控制器与运行时类图
```mermaid
classDiagram
class RDEPUBReaderController {
+delegate: RDEPUBReaderDelegate?
+configuration: RDEPUBReaderConfiguration
+currentLocation: RDEPUBLocation?
+currentPageNumber: Int?
+currentSelection: RDEPUBSelection?
+highlights: [RDEPUBHighlight]
+bookmarks: [RDEPUBBookmark]
+tableOfContents: [EPUBTableOfContentsItem]
+readerView: RDEpubReaderView
+readerContext: RDEPUBReaderContext
+runtime: RDEPUBReaderRuntime?
}
class RDEPUBReaderContext {
+parser: RDEPUBParser?
+publication: RDEPUBPublication?
+readingSession: RDEPUBReadingSession?
+textBook: RDEPUBTextBook?
+bookPageMap: RDEPUBBookPageMap?
+configuration: RDEPUBReaderConfiguration
+persistence: RDEPUBReaderPersistence?
+currentRenderSignature() String
+chapterCacheKey(forSpineIndex:) RDEPUBChapterCacheKey
+chapterCacheKey(forSpineIndex:precomputedContentHash:renderSignature:) RDEPUBChapterCacheKey
+makeTextBookBuilder(layoutConfig:) RDEPUBTextBookBuilder
}
class RDEPUBReaderRuntime {
+chapterLoader: RDEPUBChapterLoader
+chapterRuntimeStore: RDEPUBChapterRuntimeStore
+summaryDiskCache: RDEPUBChapterSummaryDiskCache
+pageResolver: RDEPUBPageResolver
+loadCoordinator: RDEPUBReaderLoadCoordinator
+paginationCoordinator: RDEPUBReaderPaginationCoordinator
+locationCoordinator: RDEPUBReaderLocationCoordinator
+searchCoordinator: RDEPUBReaderSearchCoordinator
+chromeCoordinator: RDEPUBReaderChromeCoordinator
+annotationCoordinator: RDEPUBReaderAnnotationCoordinator
+viewportMonitor: RDEPUBReaderViewportMonitor
+applyBookPageMap(_:restoreLocation:)
+refreshBookPageMapInPlace(_:)
}
class RDEPUBReaderPaginationCoordinator {
-context: RDEPUBReaderContext
+paginatePublication(restoreLocation:)
+repaginatePreservingCurrentLocation()
}
class RDEPUBMetadataParseWorker {
-context: RDEPUBReaderContext
-cancellationController: RDEPUBMetadataParseCancellationController
+start(token:restoreLocation:)
}
class RDEPUBMetadataParseCancellationController {
+token: UUID
+attach(queue:)
+cancel()
+isCancelled: Bool
}
class RDEPUBReaderLoadCoordinator {
-context: RDEPUBReaderContext
+startInitialLoadIfNeeded()
-loadPublication()
-applyParsedPublication()
}
class RDEPUBReaderSearchCoordinator {
-context: RDEPUBReaderContext
+search(keyword:)
+goToNextMatch()
+goToPreviousMatch()
+clearSearch()
}
class RDEPUBReaderAnnotationCoordinator {
-context: RDEPUBReaderContext
+addHighlight(from:)
+removeHighlight(id:)
+toggleBookmark(at:)
+handleSelectionMenuAction(_:)
}
class RDEPUBChapterLoader {
-context: RDEPUBReaderContext
+loadChapter(spineIndex:store:) async throws RDEPUBRuntimeChapter
+loadChapterSynchronouslyForMigration(spineIndex:store:) throws RDEPUBRuntimeChapter
}
class RDEPUBChapterRuntimeStore {
+chapterDataCache: [Int: RDEPUBRuntimeChapter]
+pageCountCache: RDEPUBPageCountCache
+imageCache: NSCache
+setCurrentChapter(spineIndex:totalSpineCount:windowRadius:)
+chapterData(for:) RDEPUBRuntimeChapter?
+pageCount(for:) RDEPUBRuntimePageCount?
}
class RDEPUBChapterSummaryDiskCache {
+cacheDirectory: URL
+read(for:) RDEPUBChapterSummary?
+readAll(keys:) ([Int: RDEPUBChapterSummary], RDEPUBBookPageMap.Builder)
+write(summary:for:)
+flushPendingWrites()
+isCacheComplete(keys:) Bool
}
class RDEPUBBookPageMap {
+totalChapters: Int
+totalPages: Int
+chapters: [EPUBChapterInfo]
+pages: [EPUBPage]
}
class RDEPUBChapterCacheKey {
+bookID: String
+spineIndex: Int
+renderSignature: String
+chapterContentHash: String
}
class RDEPUBReaderConfiguration {
+fontSize: CGFloat
+fontChoice: FontChoice
+lineHeightMultiple: CGFloat
+numberOfColumns: Int
+theme: ReaderTheme
+displayType: DisplayType
+onDemandChapterWindowSize: Int
+metadataParsingConcurrency: Int
}
RDEPUBReaderController --> RDEPUBReaderContext : owns
RDEPUBReaderController --> RDEPUBReaderRuntime : owns
RDEPUBReaderController --> RDEPUBReaderPaginationCoordinator : owns
RDEPUBReaderContext --> RDEPUBReaderConfiguration : holds
RDEPUBReaderRuntime --> RDEPUBChapterLoader : owns
RDEPUBReaderRuntime --> RDEPUBChapterRuntimeStore : owns
RDEPUBReaderRuntime --> RDEPUBChapterSummaryDiskCache : owns
RDEPUBReaderRuntime --> RDEPUBReaderLoadCoordinator : owns
RDEPUBReaderRuntime --> RDEPUBReaderSearchCoordinator : owns
RDEPUBReaderRuntime --> RDEPUBReaderAnnotationCoordinator : owns
RDEPUBReaderPaginationCoordinator --> RDEPUBReaderContext : uses
RDEPUBChapterLoader --> RDEPUBReaderContext : uses
RDEPUBChapterLoader --> RDEPUBChapterRuntimeStore : uses
RDEPUBChapterLoader --> RDEPUBChapterSummaryDiskCache : uses
RDEPUBChapterSummaryDiskCache --> RDEPUBChapterCacheKey : uses
RDEPUBBookPageMap --> EPUBChapterInfo : contains
RDEPUBBookPageMap --> EPUBPage : contains
```
---
## 6. 数据模型关系图
```mermaid
classDiagram
class RDEPUBLocation {
+bookIdentifier: String?
+href: String
+progression: Double
+lastProgression: Double?
+fragment: String?
+rangeAnchor: RDEPUBTextRangeAnchor?
}
class RDEPUBTextAnchor {
+fileIndex: Int
+row: Int
+column: Int
+chapterOffset: Int
+fragmentID: String?
+spineIndex: Int
}
class RDEPUBTextRangeAnchor {
+start: RDEPUBTextAnchor
+end: RDEPUBTextAnchor
+nsRange: NSRange
}
class RDEPUBSelection {
+bookIdentifier: String?
+location: RDEPUBLocation
+text: String
+rangeInfo: String?
+createdAt: Date
}
class RDEPUBHighlight {
+id: String
+bookIdentifier: String?
+location: RDEPUBLocation
+text: String
+style: RDEPUBHighlightStyle
+color: String
+note: String?
+createdAt: Date
+uiColor: UIColor
}
class RDEPUBBookmark {
+id: String
+bookIdentifier: String?
+location: RDEPUBLocation
+chapterTitle: String?
+note: String?
+createdAt: Date
}
class RDEPUBAnnotation {
+id: String
+bookIdentifier: String?
+kind: RDEPUBAnnotationKind
+location: RDEPUBLocation
+text: String?
+rangeInfo: String?
+chapterTitle: String?
+createdAt: Date
+bookmark: RDEPUBBookmark?
+highlight: RDEPUBHighlight?
}
class EPUBPage {
+spineIndex: Int
+chapterIndex: Int
+pageIndexInChapter: Int
+totalPagesInChapter: Int
+chapterTitle: String
+fixedSpread: EPUBFixedSpread?
}
class EPUBChapterInfo {
+spineIndex: Int
+title: String
+pageCount: Int
}
class RDEPUBViewport {
+resources: [RDEPUBViewportResource]
+visiblePageNumber: Int
+chapterIndex: Int?
+isFixedLayout: Bool
}
class RDEPUBReadingContext {
+location: RDEPUBLocation
+viewport: RDEPUBViewport
+pageNumber: Int
+chapterIndex: Int?
}
RDEPUBLocation --> RDEPUBTextRangeAnchor : optional
RDEPUBTextRangeAnchor --> RDEPUBTextAnchor : start/end
RDEPUBSelection --> RDEPUBLocation : references
RDEPUBHighlight --> RDEPUBLocation : references
RDEPUBBookmark --> RDEPUBLocation : references
RDEPUBAnnotation --> RDEPUBLocation : references
RDEPUBReadingContext --> RDEPUBLocation : holds
RDEPUBReadingContext --> RDEPUBViewport : holds
```
---
## 7. 后台解析并发模型
```mermaid
sequenceDiagram
participant Main as Main Thread
participant BG as Background Queue
participant OQ as OperationQueue (N workers)
participant Disk as Disk Cache
Main->>BG: RDEPUBMetadataParseWorker.start(token)
BG->>BG: 预计算 contentHash (串行)
BG->>Disk: readAll(catalog) 批量读取缓存
BG->>Main: refreshBookPageMapInPlace (缓存部分)
BG->>BG: waitForReadingInteractionToSettle (0.8s)
loop 每个未缓存章节
BG->>OQ: addOperation(spineIndex)
OQ->>OQ: buildChapter (渲染+分页)
OQ->>OQ: chapterCacheKey (复用预计算hash)
OQ->>Disk: write(summary) 异步写盘
OQ->>OQ: resultLock: 累加结果
alt 每32章或最后一章
OQ->>Main: refreshBookPageMapInPlace
end
end
OQ->>BG: waitUntilAllOperationsAreFinished
BG->>Disk: flushPendingWrites
BG->>Main: refreshBookPageMapInPlace (最终)
```
---
## 8. 缓存键与失效策略
```mermaid
graph LR
subgraph CacheKey["RDEPUBChapterCacheKey"]
A[bookID]
B[spineIndex]
C[renderSignature]
D[chapterContentHash]
end
subgraph RenderSignature["renderSignature 组成"]
E[fontName]
F[fontSize]
G[lineHeightMultiple]
H[lineSpacing]
I[layoutConfig.cacheSignature]
J[schemaVersion]
end
subgraph CacheSignature["layoutConfig.cacheSignature"]
K[frameWidth]
L[frameHeight]
M[edgeInsets]
N[numberOfColumns]
O[columnGap]
P[avoidOrphans/Widows]
Q[hyphenation]
R[imageMaxHeightRatio]
end
C --> E & F & G & H & I & J
I --> K & L & M & N & O & P & Q & R
style CacheKey fill:#e1f5fe
style RenderSignature fill:#fff3e0
style CacheSignature fill:#e8f5e9
```
@@ -1,6 +1,6 @@
# 微信读书 EPUB 阅读器 — 核心架构与数据流图
# 读书 EPUB 阅读器 — 核心架构与数据流图
> 逆向工程目标: WeRead v10.0.3 (Build 79), arm64, 63MB 主二进制
> 目标: WeRead v10.0.3 (Build 79)
---
@@ -10,12 +10,11 @@
```
┌─────────────────────────────────────────────────────────────────────┐
│ WeRead.app (iOS/macOS Catalyst) │
│ 主二进制: 63MB, Mach-O arm64 │
│ WeRead.app (iOS/macOS Catalyst) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 主二进制 (WeRead) │ │
│ │ WeRead │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │
│ │ │ 阅读器模块 │ │ 书架模块 │ │ 其他业务模块 │ │ │
│ │ │ WRReader* │ │ WRBookshelf* │ │ WRDiscover/WRMarket │ │ │
@@ -216,7 +215,7 @@ WRReaderViewController (431 methods, 阅读器主控制器)
```
┌──────────┐ HTTPS ┌──────────────────────────────────────────────────┐
微信读书 │─────────────→│ ① WRBookNetwork.loadTarForEpubBookId:chapter: │
│ 读书 │─────────────→│ ① WRBookNetwork.loadTarForEpubBookId:chapter: │
│ 服务器 │ 加密 ZIP │ isPreload: │
│ │ {bookId}_ │ ↓ │
│ │ DECRYPT.zip │ ② WRBookNetwork.handleUnzipWithBookId: │
@@ -1,4 +1,4 @@
# 微信读书 EPUB 阅读器 — 符号恢复与核心算法
# 读书 EPUB 阅读器 — 符号恢复与核心算法
> 基于 WeRead v10.0.3 (Build 79) 二进制逆向
> 方法签名来源: __objc_methname 段 + binary strings
@@ -254,7 +254,7 @@ def cascade_stylesheets(default_css: str, replace_css: str, dark_css: str,
Args:
default_css: Safari 默认样式 (default.css)
replace_css: 微信读书默认替换 (replace.css)
replace_css: 读书默认替换 (replace.css)
dark_css: 暗黑主题 (dark.css)
epub_css: EPUB 书籍内嵌 CSS
user_settings_css: 用户自定义设置 (字号/行高/主题)
@@ -295,7 +295,7 @@ def cascade_stylesheets(default_css: str, replace_css: str, dark_css: str,
# 按优先级逐层合并
result = parse_css(default_css) # 层1: 基础
result = merge(result, parse_css(replace_css)) # 层2: 微信读书默认
result = merge(result, parse_css(replace_css)) # 层2: 读书默认
result = merge(result, parse_css(dark_css)) # 层3: 暗黑主题
result = merge(result, parse_css(epub_css)) # 层4: EPUB 内嵌
result = merge(result, parse_css(user_settings_css)) # 层5: 用户设置 (最高优先级)
@@ -630,7 +630,7 @@ def convert_hans_to_hant(attributed_string: str) -> str:
# OpenCC: opencc_convert("s2t", text)
# 简化实现: 使用 Unicode 码点映射表
# 微信读书内部可能使用腾讯自研的繁简转换库
# 读书内部可能使用腾讯自研的繁简转换库
conversion_table = load_hans_to_hant_table() # ~8000 个映射
result = []
@@ -1,9 +1,6 @@
# 微信读书 EPUB 阅读器 — 数据结构与 API 协议定义
> 基于 WeRead v10.0.3 (Build 79) 逆向工程
> 数据结构来源: __objc_methtype + class_ro_t ivar 段
> API 来源: WRBookNetwork 44 个 class method 签名
# 读书 EPUB 阅读器 — 数据结构与 API 协议定义
> 基于 WeRead v10.0.3 (Build 79)
---
## 一、关键数据结构定义 (Header Files)
@@ -129,7 +126,7 @@ typedef NS_ENUM(NSInteger, WRChapterFormat) {
// 表格
@property (nonatomic, strong) DTTableStyle *tableStyle; // 表格样式
// 微信读书自定义属性
// 读书自定义属性
@property (nonatomic, assign) NSInteger verticalCenterStyle; // wr-vertical-center-style
@property (nonatomic, assign) BOOL pageRelate; // weread-page-relate
@property (nonatomic, assign) BOOL avoidPageBreakInside; // 断页保护
@@ -185,7 +182,7 @@ typedef NS_ENUM(NSInteger, WRChapterFormat) {
@property (nonatomic, assign) CGFloat maximumLineHeight; // 最大行高
@property (nonatomic, assign) CGFloat lineHeightMultiple; // 行高倍数
// 微信读书扩展
// 读书扩展
@property (nonatomic, assign) CGFloat defaultTabInterval; // 默认制表位
@end
@@ -809,7 +806,7 @@ NSString *const kWRUserVidKey = @"com.weread.user.vid";
### 3.4 NSAttributedString 自定义属性键
```objc
// 微信读书在 NSAttributedString 中注入的自定义属性
// 读书在 NSAttributedString 中注入的自定义属性
// 用于在排版结果中传递页面布局元数据
NSString *const DTPageBackgroundColorAttribute = @"DTPageBackgroundColor";
+10 -10
View File
@@ -1,6 +1,6 @@
# CSS/JS/字体资源分析
本文档对微信读书 EPUB 阅读器的前端资源文件进行全面分析,涵盖 CSS 样式表、JavaScript 脚本和字体文件。
本文档对读书 EPUB 阅读器的前端资源文件进行全面分析,涵盖 CSS 样式表、JavaScript 脚本和字体文件。
---
@@ -8,7 +8,7 @@
### 1.1 样式层叠架构
微信读书的 CSS 采用分层架构:
读书的 CSS 采用分层架构:
```
default.css (基础HTML样式)
@@ -46,7 +46,7 @@ Safari 派生的基础 HTML 默认样式,为 WebView 渲染提供标准化起
**路径**: `output/resources/css/replace.css` (172行)
EPUB 内容的核心替换样式,定义了微信读书特有的排版规则。
EPUB 内容的核心替换样式,定义了读书特有的排版规则。
**字体设置**:
- 标题和版权页使用 `"SourceHanSerifCN-Medium"` 字体
@@ -62,7 +62,7 @@ EPUB 内容的核心替换样式,定义了微信读书特有的排版规则。
| `.fifthTitle` | 1.05em | normal |
| `.sixthTitle` | 1em | normal |
**微信读书自定义CSS属性**:
**读书自定义CSS属性**:
```css
/* 垂直居中样式 - 值2用于正文图片 */
@@ -545,7 +545,7 @@ getAllForwardLinks()
// 转换H5链接为原生数据属性
transferH5Link()
// 解密微信读书加密链接
// 解密读书加密链接
WRLink_decrypt(str)
// type '3' = 数字
// 其他 = 十六进制编码文本
@@ -573,7 +573,7 @@ document.addEventListener("selectionchange", debounce(function(e) {
#### 2.4.1 rangy-highlighter.js (711行)
Rangy 高亮器模块,带微信读书自定义修改。
Rangy 高亮器模块,带读书自定义修改。
**CharacterRange 类**:
```javascript
@@ -1099,7 +1099,7 @@ line-height: 30px;
## 四、关键发现与实现细节
### 4.1 微信读书自定义CSS属性
### 4.1 读书自定义CSS属性
**wr-vertical-center-style**:
-`1``.wr-vertical-center` 类,强制垂直居中
@@ -1137,7 +1137,7 @@ resultIframe.src = 'wereadapijs://private/setresult/' + scene + '&' + encodedDat
2. `rangy-textrange.js` - 字符级范围操作(支持图片文本)
3. `rangy-classapplier.js` - CSS类应用
4. `rangy-highlighter.js` - 高亮管理(序列化/反序列化)
5. `weread-highlighter.js` - 微信读书业务逻辑
5. `weread-highlighter.js` - 读书业务逻辑
**标注持久化**:
```javascript
@@ -1278,13 +1278,13 @@ RE.insertBook = function(id, title, author, cover) {
## 六、总结
微信读书的前端资源架构体现了以下设计特点:
读书的前端资源架构体现了以下设计特点:
1. **分层样式系统**:通过 default.css → replace.css/MediaPlatform.css → dark.css 的层叠,实现内容类型和主题的灵活切换
2. **原生桥接机制**WeReadApi.js 通过 iframe URL scheme 实现 JS 与原生的高效通信,支持异步回调
3. **扩展的Rangy库**:在标准 Rangy 基础上添加了图片文本支持、高亮合并、序列化等微信读书特有功能
3. **扩展的Rangy库**:在标准 Rangy 基础上添加了图片文本支持、高亮合并、序列化等读书特有功能
4. **完整的标注系统**:支持高亮、想法、好友想法、引用、TTS五种标注类型,每种有独立的样式和交互
@@ -1,6 +1,6 @@
# DTCoreText 自定义修改分析
微信读书 (WeRead) 基于开源 DTCoreText 库进行了深度定制,将其从一个通用的 HTML-to-NSAttributedString 转换库改造为一套完整的电子书分页渲染引擎。本文档详细记录所有自定义修改。
读书 (WeRead) 基于开源 DTCoreText 库进行了深度定制,将其从一个通用的 HTML-to-NSAttributedString 转换库改造为一套完整的电子书分页渲染引擎。本文档详细记录所有自定义修改。
---
@@ -1,6 +1,6 @@
# EPUB 渲染管线详解
微信读书 (WeRead) 的 EPUB 渲染管线将原始 XHTML 文件转换为可分页、可交互的阅读视图。本文档完整描述从 EPUB 文件到屏幕像素的每一步。
读书 (WeRead) 的 EPUB 渲染管线将原始 XHTML 文件转换为可分页、可交互的阅读视图。本文档完整描述从 EPUB 文件到屏幕像素的每一步。
---
+334
View File
@@ -0,0 +1,334 @@
# WXRead/decompiled 逆向代码说明
## 概述
此目录包含从读书 (WeRead) iOS 客户端二进制逆向还原的头文件和实现代码。共 44 个文件(22 对 .h/.m),分为三大模块:
1. **阅读器核心** — 阅读控制器、页面渲染、排版引擎、EPUB 解析
2. **标注系统** — 划线、高亮、书签、想法、Pencil 手写笔记
3. **DTCoreText 定制** — 基于开源 DTCoreText 库的读书私有定制
---
## 架构总览
```
WRReaderViewController (阅读器主控制器)
├── WRPageViewController (翻页控制器, 支持 curl/slide/fade/none)
│ └── WRPageView (页面渲染视图, CoreText 直接绘制)
│ └── WRCoreTextLayoutFrame (单页排版帧)
│ └── WRCoreTextLayoutLine (单行排版)
├── WREpubParser (EPUB 文件解析)
├── WREpubTypesetter (XHTML → NSAttributedString 排版)
│ └── DTHTMLAttributedStringBuilder (HTML DOM → 属性字符串)
│ └── DTHTMLElement (DOM 节点)
├── WRChapterData (章节数据模型)
├── WRChapterPageCount (分页计算器)
├── WRCoreTextLayouter (CoreText 排版器)
└── WRBookmark / WRPageHighlight / WRPageMark / WRPageUnderline (标注模型)
```
---
## 阅读器核心模块
### `WRReaderViewController`
阅读器的主入口控制器,431 个方法,管理阅读状态的完整生命周期。
**核心职责:**
- 管理 `WRReadingProgress`(阅读进度:章节索引、页码、字符偏移、滚动位置、阅读百分比)
- 章节导航:`gotoChapterIdx:position:positionOfFile:``jumpReadingToNextChapter``jumpReadingToPreChapter`
- 页面渲染:`renderPageView:progressData:source:` — 获取/计算章节数据、分页、创建/更新 WRPageView
- 排版重排:`recomposeCurrentPageViewWithSource:` — 字号/行距/主题变更后触发
- 进度持久化:`_saveReadingProgressAndIsAsync:`
- 支持涂鸦模式 (doodle mode) 和自动阅读 (auto-read)
- 通过 `WRReaderViewControllerDelegate` 回调通知外部进度变化和退出
### `WRPageViewController`
自定义的翻页控制器,支持四种翻页动画:
| 枚举值 | 样式 | 说明 |
|--------|------|------|
| `WRPageFlippingStyleCurl` | 纸张翻页 | 基于 UIPageViewController 的 curl 效果 |
| `WRPageFlippingStyleSlide` | 水平滑动 | UIScrollView 驱动的滑动翻页 |
| `WRPageFlippingStyleFade` | 淡入淡出 | 交叉渐变 |
| `WRPageFlippingStyleNone` | 无动画 | 瞬间切换 |
**关键特性:**
- 内部封装 `UIPageViewController`,提供 `WRPageViewControllerDelegate` / `WRPageViewControllerDataSource` 协议
- 包含 3 个故障修补方法,处理 Apple `UIPageViewController` 的已知崩溃:
- `detectNavigationDirectionCrashWithPageViewController:` — 检测导航方向崩溃
- `patchNavigationDirectionFault` — 修复导航方向状态不一致
- `patchNoViewControllerManagingPageViewFault` — 修复子 VC 丢失
- `patchUIPageCurlFault` — 修复快速翻页时的动画故障
### `WRPageView`
单页渲染视图,继承 `UIView`,通过 CoreText 的 `drawRect:` 直接绘制文本,不使用 `UILabel``UITextView`
**核心绘制:**
- `drawInContext:withData:size:inRect:position:` — 将文本和行内图片直接绘制到 CGContext
- 通过 `CTLineGetStringIndexForPosition` 进行 CoreText 级别的坐标点击测试
**UI 覆盖层:**
- 章节标题头 (headerLabel)、背景图片 (backgroundImageView)
- 加载指示器 (activityIndicator)、加载进度条 (loadingProgressView)
- 状态提示 (statusLabel)、重试按钮 (retryButton)
- 分享按钮、书签按钮、字号调节按钮
- 好友想法按钮 (friendReviewsButton)
**文本选区:**
- `stringIndexForPoint:` / `lineRangeForStringIndex:` — CoreText 坐标与字符索引转换
- `isSelecting` / `selectedRange` — 选区状态
- `clearSelection` — 清除选区
---
## EPUB 解析与排版
### `WREpubParser`
EPUB 文件解析器,处理完整的 EPUB 结构:
**解析流程:** `container.xml``content.opf``spine` → 章节文件
**主要 API**
- `initWithFilePath:book:` — 初始化
- `parse:` — 执行解析,返回 YES/NO
- `parseContainerXML:` — 解析 container.xml 获取 OPF 路径
- `parseOPFAtRelativePath:error:` — 解析 content.opf,填充章节列表和资源映射
- `parseNCX:` — 解析 toc.ncx 目录树
- `contentForChapterAtIndex:error:` — 读取单章 XHTML 内容
- `absolutePathForResource:` — 将相对资源路径解析为绝对路径
**错误码:**
| 错误码 | 含义 |
|--------|------|
| -1000 | 文件未找到 |
| -1001 | container.xml 解析失败 |
| -1002 | OPF 解析失败 |
| -1003 | NCX 解析失败 |
| -1004 | spine 为空 |
| -1005 | 章节加载失败 |
| -1006 | 解密失败 |
### `WREpubTypesetter`
EPUB 排版器,XHTML → `NSAttributedString` 的转换核心。全部为类方法,无需实例化。
**主入口方法:**
```objc
+ (NSAttributedString *)attributeStringWithFilePath:priority:
insertArticleToolAttachment:insertBookChapterToolAttachment:
insertRecommendView:book:chapter:pageFlippingStyle:
renderErrorReason:isStyleFileNotFound:options:
```
**处理流程:**
1. 加载并级联合并 CSS(用户设置 > EPUB 内嵌 > 默认样式)
2. 将 XHTML + 合并后的 CSS 喂入 `DTHTMLAttributedStringBuilder`
3. 遍历生成的元素树,修补图片、链接、自定义属性
4. 应用用户排版偏好(字体、行距、主题)
5. 可选截断用于免费试读预览
**自定义 CSS 属性:**
- `WREpubTypesetterVerticalCenterStyleAttribute` — 行内元素垂直居中(`wr-vertical-center-style`
- `WREpubTypesetterPageRelateAttribute` — 跨页关联标记(`weread-page-relate`
**翻译渲染错误上报:**
- `tryReportTranslationError:` — 双语(原文 + 翻译)渲染异常时上报分析
### `WREpubPositionConverter`
文件位置与字符位置的双向转换器。用于书签同步和阅读进度追踪。
**核心概念:**`(fileIndex, row, column)` 三元组映射为全局字符偏移量。
**主要 API**
- `initWithFilePaths:attributedStrings:offset:isContainIntroFlyleaf:` — 初始化
- `initIndices` — 构建内部索引表(调用前不能执行转换)
- `indicesInFile:forRowColumnPairs:stringIndices:` — 行/列 → 字符索引
- `stringRangeFromFileRange:` — 文件内范围 → 全局字符串范围
- `totalCharacterCount` — 全书总字符数
- `fileIndexForCharacterPosition:` — 全局位置 → 文件索引
- `localOffsetInFileAtIndex:forGlobalPosition:` — 全局位置 → 文件内偏移
---
## 分页与布局
### `WRChapterPageCount`
章节分页计算器。管理每页的 `NSRange`
**关键属性:**
- `pageRanges` — 每页的字符范围数组 (NSValue-wrapped NSRange)
- `totalPages` — 总页数
**关键方法:**
- `currentCacheKeyWithBookId:` — 生成缓存键(编码书 ID + 当前排版设置,设置变更则失效)
- `rangeValueWithPageInfo:` — 从服务端分页数据提取范围
- `rangeForPageAtIndex:` / `pageIndexForCharacterIndex:` — 页码 ↔ 字符位置
- `recalculatePageRangesForAttributedString:drawingSize:margins:` — 重算分页
### `WRCoreTextLayouter`
读书封装的 CoreText 排版器,创建 `CTTypesetter` / `CTFramesetter` 并产出 `WRCoreTextLayoutFrame` 页面对象。
**初始化:**
- `initWithAttributedString:` / `initWithAttributedString:config:`
- `WRCoreTextLayoutConfig` 配置:frame 宽高、内边距、栏数、栏间距、孤行/寡行控制、连字符
**排版 API**
- `createTypesetter` / `createFramesetter` / `invalidateTypesetter`
- `layoutFrameWithRange:frame:` — 单帧排版
- `layoutFramesForPageSize:` — 全文分页排版
- `numberOfPagesForPageSize:` / `rangeForPageAtIndex:pageSize:` — 分页查询
- `pageBackgroundImageAtRange:themeBgColor:` — 页面背景图
- `resizedImageForImagePath:` — 图片缩放适配
### `WRCoreTextLayoutFrame`
单页排版帧,封装 `CTFrame`,管理 `WRCoreTextLayoutLine` 行数组。
**核心功能:**
- 文本绘制:`drawInContext:image:size:inRect:position:`(含 CTFrameDraw + 图片附件 + 装饰元素)
- 分栏支持:`numberOfColumns` / `columnGap`
- 跨页避让:`avoidPageBreakInsideByRemovingLastLinesIfNeeded` — CSS `avoid-page-break-inside` 实现
- 点击测试:`characterIndexAtPoint:` / `lineIndexAtPoint:` / `rectForCharacterAtIndex:`
- 选区操作:`selectedText` / `selectedRanges` / `selectTextInRange:` / `clearSelection`
- 搜索高亮:`highlightSearchResults:` / `clearSearchHighlights`
- 装饰元素:删除线范围、下划线范围、高亮范围、链接范围
- 响应式更新:通过 `RACSubject` (ReactiveObjC) 发出布局变更通知
---
## 标注系统
### `WRBookmark`
书签/标注的数据模型,支持 5 种类型:
| 类型 | 说明 |
|------|------|
| `WRBookmarkTypeHighlight` | 彩色高亮 |
| `WRBookmarkTypeUnderline` | 下划线 |
| `WRBookmarkTypeMark` | 页面书签(折角) |
| `WRBookmarkTypeNote` | 文字笔记 |
| `WRBookmarkTypePencil` | Apple Pencil 手绘 |
**关键属性:** `bookmarkId``bookId``chapterUid``chapterOffset``markText``startPos`/`endPos``colorStyle``anchorId``rangeKey``syncKey`
**持久化:** `toDictionary` / `fromDictionary``toJSONData` / `fromJSONData`
**工厂方法:**
- `bookmarkWithBookId:chapterUid:offset:text:type:`
- `highlightWithBookId:chapterUid:startPos:endPos:text:colorStyle:`
### `WRPageHighlight`
文本高亮标注模型。5 种预设颜色:黄、蓝、红、绿、紫。
**方法:**
- `initWithBookId:chapterUid:startPos:endPos:text:color:` — 创建高亮
- `toBookmark` — 转为 WRBookmark 进行持久化/同步
- `uiColor` — 返回渲染用 UIColor
### `WRPageUnderline`
下划线标注模型。支持 4 种下划线样式:实线、虚线、波浪线、点线。
**方法:**
- `toBookmark` / `fromBookmark:` — 与 WRBookmark 双向转换
- `underlinePathForRect:` — 返回渲染用 UIBezierPath
- `containsPosition:` — 范围查询
- `mergeWithUnderline:` — 合并相邻下划线
- `setNote:` — 附加笔记
### `WRPageMark`
页面书签(折角)模型。25 个方法,功能最丰富的标注类型。
**关键特性:**
- 支持 3 种展示模式:图标、列表、行内
- 关联标注:`linkedHighlights` / `linkedUnderlines` — 书签位置上附带的高亮和下划线
- 批量操作:`marksSortedByPosition:` / `marksSortedByDate:` / `marksInChapter:fromMarks:` / `marksInRange:fromMarks:`
- 序列化:`toDictionary` / `fromDictionary``toJSONData` / `fromJSONData`
### `WRReaderPencilNoteManager`
Apple Pencil 手写笔记管理器。11 个类方法,全部为类方法调用。
**支持 9 种画笔颜色/样式:** 黑、灰、红、蓝、黄、绿、铅笔、钢笔、荧光笔
**本地存储:**
- `drawingFileDirectory` / `drawingFilePathWithReviewItemId:` — 文件路径管理
- `writeDrawingDataToLocal:` / `writeDrawingToLocal:` — 本地写入(支持 draft/published 状态)
- `checkDrawingExistsWithReviewItemId:` — 存在性检查
- `deleteDrawingWithReviewItemId:` / `deleteAllReviewDrawings` — 删除
**云端同步:**
- `uploadPencilDrawing:colorStyle:onlyUploadImage:canRetry:` — 上传绘制图片和数据到腾讯云 COS
- `uploadPencilNoteData:suffix:` — 上传 PKDrawing 序列化数据
- `downloadDrawingDataFromCosWithUrl:desPath:callback:` — 从 COS 下载
---
## DTCoreText 定制模块
读书基于开源库 [DTCoreText](https://github.com/Cocoanetics/DTCoreText) 做了大量私有定制。
### `DTHTMLAttributedStringBuilder`
HTML DOM → `NSAttributedString` 的 SAX 风格解析构建器。`WREpubTypesetter` 的底层依赖。
**WeRead 自定义属性键:**
- `DTPageBackgroundColorAttribute` / `DTPageBackgroundImageAttribute` — 页面背景
- `DTPageBreakAfterAttribute` / `DTPageBreakBeforeAttribute` — 分页控制
- `DTPageBreakInsideAvoidAttribute` — 跨页避让
- `DTPageRelateAttribute` — 跨页关联
- `DTHTMLVerticalCenterAttribute` — 垂直居中
- `DTHTMLTranslateTagAttribute` — 翻译标签
### `DTHTMLElement`
HTML 元素 DOM 节点。构建解析时的 DOM 树,每个节点可通过 `attributedString` 方法生成对应的属性字符串。
**关键属性:** `tagName``parent`/`children``styleDictionary``isBlockElement``shouldAvoidPageBreakInside`
### `DTCoreTextLayouter` / `DTCoreTextLayoutFrame` / `DTCoreTextLayoutLine` / `DTCoreTextGlyphRun`
DTCoreText 的四层排版层次结构(与 `WR` 前缀版本功能对应,是底层实现):
```
DTCoreTextLayouter (排版器, CTTypesetter 包装)
└── DTCoreTextLayoutFrame (排版帧, CTFrame 包装)
└── DTCoreTextLayoutLine (排版行, CTLine 包装)
└── DTCoreTextGlyphRun (字形运行, CTRun 包装)
```
- **Layouter** — 创建 CTTypesetter,产出 LayoutFrame,支持帧缓存
- **LayoutFrame** — 管理排版行数组,支持多栏、分页避让、点击测试、选区、搜索
- **LayoutLine** — 封装 CTLine,管理 GlyphRun 数组,提供排版指标(ascent/descent/leading/width)和点击测试
- **GlyphRun** — 封装 CTRun,管理字形(glyph)级别操作:位置、路径绘制、附件(图片)、字体属性、绘制方向
---
## 数据流
```
EPUB 文件 (.epub)
↓ WREpubParser.parse
解析 container.xml → content.opf → toc.ncx
↓ WREpubTypesetter.attributeStringWithFilePath
XHTML + CSS → DTHTMLAttributedStringBuilder → NSAttributedString
↓ WRCoreTextLayouter / WRChapterPageCount
分页排版 → [NSRange per page]
↓ WRPageView.drawInContext
CoreText 绘制到 CGContext
↓ WRPageViewController
翻页展示
```
@@ -2,7 +2,7 @@
// DTHTMLAttributedStringBuilder.h
// DTCoreText (WeRead custom fork)
//
// Reverse-engineered from WeChat Reading (微信读书) binary.
// Reverse-engineered from WeChat Reading (读书) binary.
// This class converts HTML DOM into NSAttributedString via SAX-style parsing.
//
+1 -1
View File
@@ -2,7 +2,7 @@
// DTHTMLElement.h
// DTCoreText (WeRead custom fork)
//
// Reverse-engineered from WeChat Reading (微信读书) binary.
// Reverse-engineered from WeChat Reading (读书) binary.
// Represents a single HTML element in the DOM tree built during parsing.
// Each node can produce an NSAttributedString via -attributedString.
//
+1 -1
View File
@@ -1,6 +1,6 @@
//
// WRBookmark.h
// WeRead (微信读书)
// WeRead (读书)
// Reverse-engineered header
//
// Bookmark model. Stores bookId, chapterUid, and position information.
+1 -1
View File
@@ -1,6 +1,6 @@
//
// WRBookmark.m
// WeRead (微信读书)
// WeRead (读书)
// Reverse-engineered implementation reconstruction
//
// Detailed pseudo-code based on binary analysis (many NSString ivars,
+1 -1
View File
@@ -1,6 +1,6 @@
//
// WRChapterData.h
// WeRead (微信读书)
// WeRead (读书)
//
// Reverse-engineered header reconstruction.
// Chapter data model. Stores the typeset NSAttributedString.
+1 -1
View File
@@ -1,6 +1,6 @@
//
// WRChapterData.m
// WeRead (微信读书)
// WeRead (读书)
//
// Detailed pseudo-code reconstruction of the chapter data model.
// Stores the typeset NSAttributedString, manages highlights, underlines,
+1 -1
View File
@@ -1,6 +1,6 @@
//
// WRChapterPageCount.h
// WeRead (微信读书)
// WeRead (读书)
//
// Reverse-engineered header reconstruction.
// Pagination calculator. Manages NSRange for each page within a chapter.
+1 -1
View File
@@ -1,6 +1,6 @@
//
// WRChapterPageCount.m
// WeRead (微信读书)
// WeRead (读书)
//
// Detailed pseudo-code reconstruction of the pagination calculator.
// Computes page breaks by simulating CoreText line layout and fitting
+1 -1
View File
@@ -1,6 +1,6 @@
//
// WREpubParser.h
// WeRead (微信读书)
// WeRead (读书)
// Reverse-engineered header
//
// EPUB file parser. Parses OPF (content.opf), NCX (toc.ncx), and XHTML chapter files.
+1 -1
View File
@@ -1,6 +1,6 @@
//
// WREpubParser.m
// WeRead (微信读书)
// WeRead (读书)
// Reverse-engineered implementation reconstruction
//
// Detailed pseudo-code based on binary analysis, ivar types, known methods,
@@ -1,6 +1,6 @@
//
// WREpubPositionConverter.h
// WeRead (微信读书)
// WeRead (读书)
// Reverse-engineered header
//
// Position converter between file positions and character positions.
@@ -1,6 +1,6 @@
//
// WREpubPositionConverter.m
// WeRead (微信读书)
// WeRead (读书)
// Reverse-engineered implementation reconstruction
//
// Detailed pseudo-code based on binary analysis, ivar types, known methods,
+1 -1
View File
@@ -1,6 +1,6 @@
//
// WREpubTypesetter.h
// WeRead (微信读书) - Reverse Engineered
// WeRead (读书) - Reverse Engineered
//
// EPUB Typesetter: Converts XHTML content into NSAttributedString
// via DTHTMLAttributedStringBuilder with CSS cascade, image handling,
+1 -1
View File
@@ -1,6 +1,6 @@
//
// WREpubTypesetter.m
// WeRead (微信读书) - Reverse Engineered Implementation Reconstruction
// WeRead (读书) - Reverse Engineered Implementation Reconstruction
//
// This is a detailed pseudo-code reconstruction based on:
// - Binary strings output (method signatures, CSS filenames, attribute names)
+1 -1
View File
@@ -1,6 +1,6 @@
//
// WRPageHighlight.h
// WeRead (微信读书)
// WeRead (读书)
// Reverse-engineered header
//
// Annotation model for text highlights. Stores the highlighted text range,
+1 -1
View File
@@ -1,6 +1,6 @@
//
// WRPageHighlight.m
// WeRead (微信读书)
// WeRead (读书)
// Reverse-engineered implementation reconstruction
//
// 3 methods identified from binary. WRPageHighlight is a lightweight
+1 -1
View File
@@ -1,6 +1,6 @@
//
// WRPageMark.h
// WeRead (微信读书)
// WeRead (读书)
// Reverse-engineered header
//
// Annotation model for page bookmarks/marks (dog-ear style).
+1 -1
View File
@@ -1,6 +1,6 @@
//
// WRPageMark.m
// WeRead (微信读书)
// WeRead (读书)
// Reverse-engineered implementation reconstruction
//
// 25 methods identified from binary. WRPageMark is the most comprehensive
+1 -1
View File
@@ -1,6 +1,6 @@
//
// WRPageUnderline.h
// WeRead (微信读书)
// WeRead (读书)
// Reverse-engineered header
//
// Annotation model for text underlines. 10 methods identified from binary.
+1 -1
View File
@@ -1,6 +1,6 @@
//
// WRPageUnderline.m
// WeRead (微信读书)
// WeRead (读书)
// Reverse-engineered implementation reconstruction
//
// 10 methods identified from binary. WRPageUnderline renders underline
+1 -1
View File
@@ -1,6 +1,6 @@
//
// WRPageView.h
// WeRead (微信读书)
// WeRead (读书)
//
// Reverse-engineered header reconstruction.
// The page rendering view. Inherits UIView. Uses CoreText direct drawing
+1 -1
View File
@@ -1,6 +1,6 @@
//
// WRPageView.m
// WeRead (微信读书)
// WeRead (读书)
//
// Detailed pseudo-code reconstruction of the page rendering view.
// This view draws text directly via CoreText into CGContext — no UILabel
+1 -1
View File
@@ -1,6 +1,6 @@
//
// WRPageViewController.h
// WeRead (微信读书)
// WeRead (读书)
//
// Reverse-engineered header reconstruction.
// Based on UIPageViewController. Supports UIPageCurl (simulation) and
+1 -1
View File
@@ -1,6 +1,6 @@
//
// WRPageViewController.m
// WeRead (微信读书)
// WeRead (读书)
//
// Detailed pseudo-code reconstruction of the page view controller.
// Wraps UIPageViewController with fault detection and patching for
+1 -1
View File
@@ -1,6 +1,6 @@
//
// WRPreloadBookManager.h
// WeRead (微信读书)
// WeRead (读书)
// Reverse-engineered header
//
// Preload manager. Predicts which chapters to preload based on book ranking
+1 -1
View File
@@ -1,6 +1,6 @@
//
// WRPreloadBookManager.m
// WeRead (微信读书)
// WeRead (读书)
// Reverse-engineered implementation reconstruction
//
// Detailed pseudo-code based on binary analysis, ivar types (NSMutableDictionary),
@@ -1,6 +1,6 @@
//
// WRReaderPencilNoteManager.h
// WeRead (微信读书)
// WeRead (读书)
// Reverse-engineered header
//
// Apple Pencil note manager. 11 class methods identified from binary.
@@ -1,6 +1,6 @@
//
// WRReaderPencilNoteManager.m
// WeRead (微信读书)
// WeRead (读书)
// Reverse-engineered implementation reconstruction
//
// 11 class methods identified from binary. Manages Apple Pencil
@@ -1,6 +1,6 @@
//
// WRReaderViewController.h
// WeRead (微信读书)
// WeRead (读书)
//
// Reverse-engineered header reconstruction.
// Main reader controller. Manages reading state, progress saving,
@@ -1,6 +1,6 @@
//
// WRReaderViewController.m
// WeRead (微信读书)
// WeRead (读书)
//
// Detailed pseudo-code reconstruction of the main reader controller.
// 431 methods total. This file covers the key methods for page rendering,
@@ -1,439 +0,0 @@
+[WRBookNetwork _removeTranslateHtml:]
+[WRBookNetwork addMileStone:callback:]
+[WRBookNetwork addReview:shareToWechat:audioArticleId:outlineContent:audioColumnId:callback:]
+[WRBookNetwork automaticallyMarkFinishReadingWithBookId:callback:]
+[WRBookNetwork chaptersInfoFromFile:checkTranslate:]
+[WRBookNetwork checkFMCards:callback:]_block_invoke
+[WRBookNetwork clearPreloadKVWithBookId:chapterUid:zipPath:]
+[WRBookNetwork dislikeReviewById:isDislike:withParams:callback:]
+[WRBookNetwork fetchLockInfoWithBookId:callback:]
+[WRBookNetwork fileContentWithChapter:book:shouldRemoveHtmlTags:filterTranslateContent:]
+[WRBookNetwork fileContentWithChapter:book:shouldRemoveHtmlTags:filterTranslateContent:]_block_invoke
+[WRBookNetwork handleUnzipErrorWithPath:plainBookDirectory:]
+[WRBookNetwork handleUnzipWithBookId:zipPath:encryptKey:plainBookDirectory:]
+[WRBookNetwork handleUnzipWithBookId:zipPath:encryptKey:plainBookDirectory:]_block_invoke
+[WRBookNetwork likeReviewById:isUnlike:withParams:callback:]
+[WRBookNetwork loadArticleBookDetailWithBookId:callback:]
+[WRBookNetwork loadBookDetailPodcastsWithBookId:withSynckey:withListType:withFilterType:withMaxIdx:withCount:]
+[WRBookNetwork loadBookInfoWithBookId:source:callback:]
+[WRBookNetwork loadBookLectureAuthors:callback:]
+[WRBookNetwork loadBookReadDetailInfoWithBookId:callback:]
+[WRBookNetwork loadBookReadInfoWithBookId:callback:]
+[WRBookNetwork loadBookReadInfoWithBookIdAndVid:vid:callback:]
+[WRBookNetwork loadBookmarkListWithBookId:syncKey:callback:]
+[WRBookNetwork loadChapterContentWithParam:callback:]
+[WRBookNetwork loadChapterContentWithParam:callback:]_block_invoke
+[WRBookNetwork loadExchangeRecordListWithParams:callback:]_block_invoke
+[WRBookNetwork loadFMCardsWithBookId:withSynckey:withListType:withFilterType:withMaxIdx:withCount:]
+[WRBookNetwork loadFriendMarkWithBookId:synckey:]_block_invoke
+[WRBookNetwork loadPodcastsWithSynckey:withBookId:withCount:withMaxIdx:callback:]
+[WRBookNetwork loadReadTimeWelfareActionWithBookId:opt:secretKey:firstEnter:]
+[WRBookNetwork loadRelatedBooksForReviewDetail:callback:]
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:]
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:]_block_invoke
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:]_block_invoke_2
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:]_block_invoke_5
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:]_block_invoke_6
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:]_block_invoke_7
+[WRBookNetwork loadTarForEpubBookId:chapters:isPreload:]
+[WRBookNetwork loadTopicReviewlist:callback:]
+[WRBookNetwork markReadingStatus:bookIds:isCancel:callback:]
+[WRBookNetwork markReadingStatus:withBookId:isCancel:withFinishInfo:callback:]
+[WRBookNetwork pollingChapterTranslateStatusWithBookId:isFreeTrialActive:referenceLocationDict:chapterTranslations:from:]
+[WRBookNetwork postReviewHideWithBookId:hide:callback:]
+[WRBookNetwork processChapterInfosFromChapterDownload:bookId:]
+[WRBookNetwork processEncryptedBookFileAtPath:encryptKey:book:chapterUid:isFromReview:]
+[WRBookNetwork repostReview:reposted:callback:]
+[WRBookNetwork resetChapterPaidIfNeededWithBookId:chapterUid:]
+[WRBookNetwork rewardReviewForId:price:timestamp:callback:]
+[WRBookNetwork savePreloadInfoWithDownloadParam:chaptersStr:timeFlag:tmpFilePath:encryptKey:]
+[WRBookNetwork searchResultsForBook:chapterUid:searchString:posBeg:posEnd:mode:callback:]
+[WRBookNetwork searchResultsForLocalBook:chapterUid:searchString:posBeg:posEnd:mode:callback:]
+[WRBookNetwork setFinishReading:withBookId:callback:]
+[WRBookNetwork setIsStartReading:withBookId:callback:]
+[WRBookNetwork storeTarEpubImageToDiskWithBookId:chapterUid:untarDirectory:]
+[WRBookNetwork uploadBookProgressAndReadingTime:callback:offlineCallback:]
+[WRBookNetworkReporter verifyZipWithBook:zipPath:fileSize:unzippedFiles:]
+[WRChapterData addUnderLineToAttributedString:range:itemId:style:color:]
+[WRChapterData freeTrialChapterCutOffStringLocaionWithAttributedString:book:]
+[WRChapterDownloadManger imageManagerForEpubBookWithBookId:]
+[WRChapterDownloadManger loadChapterContentNetworkWithParam:callback:]
+[WRChapterPageCount currentCacheKeyWithBookId:]
+[WRChapterPageCount rangeValueWithPageInfo:]
+[WRCoreTextLayouter convertHansToHantWithAttributedString:]_block_invoke
+[WREncryptedFileManager decryptContentsOfFile:forBookId:isFileLost:]
+[WREncryptedFileManager encryptFileForBookId:originalEncryptKey:atPath:toPath:]
+[WREncryptedFileManager keyForBookId:]
+[WREpubPositionConverter transformToNumberHtmlEntitesWithOriginHtmlEitites:]_block_invoke
+[WREpubTypesetter attributeStringWithFilePath:priority:insertArticleToolAttachment:insertBookChapterToolAttachment:insertRecommendView:book:chapter:pageFlippingStyle:renderErrorReason:isStyleFileNotFound:options:]
+[WREpubTypesetter tryReportTranslationError:bookId:chapter:isTranslationStyleNotFound:isTranslationContentNotFound:isTranslateTagButNoTranslateStyle:]
+[WRMarkContentStore markContentListForBookId:chapterUid:offset:count:]
+[WRMarkContentStore syncMarkContentListForBookId:chapterUid:]
+[WRMarketNetwork loadCategoryBooks:param:callback:]
+[WRPreloadBookManager clearKV]
+[WRPreloadBookManager encryptKeyForPath:bookId:]
+[WRPreloadBookManager fileNameForKey:bookId:]
+[WRPreloadBookManager removeEncryptKeyForPath:bookId:]
+[WRPreloadBookManager removeFileNameForKey:bookId:]
+[WRPreloadBookManager removeKVWithBookId:]
+[WRPreloadBookManager saveEncryptKey:forPath:bookId:]
+[WRPreloadBookManager saveFileNameDict:bookId:]
+[WRReaderBackgroundAdapter updateDownloadedBackground]_block_invoke
+[WRReaderBackgroundAdapter updateDownloadedBackground]_block_invoke_2
+[WRReaderBitmapColorHelper checkImageShouldAddBackgound:imageUrl:completedBlock:]
+[WRReaderBitmapColorHelper checkWhiteBlackImageAndTransformToTransparent:completedBlock:]
+[WRReaderBitmapColorHelper whiteToTransparentWithImage:completedBlock:]
+[WRReaderBookBorrowUtils redeemShortTimeReadWithTargetView:bookId:successBlock:]
+[WRReaderBookBorrowUtils redeemShortTimeReadWithTargetView:bookId:successBlock:]_block_invoke
+[WRReaderBookBorrowUtils showBorrowConfirmationPanelWithBorrowInfo:confirmBlock:cancelBlock:willShowBlock:]
+[WRReaderBookBorrowUtils showBorrowConfirmationPanelWithBorrowInfo:confirmBlock:cancelBlock:willShowBlock:]_block_invoke
+[WRReaderBookBorrowUtils showBorrowConfirmationPanelWithBorrowInfo:confirmBlock:cancelBlock:willShowBlock:]_block_invoke_3
+[WRReaderCatalogSearchManager appendSearchHistoryWithBookId:keyword:]
+[WRReaderCatalogSearchManager purgeSearchHistoryWithBookId:]
+[WRReaderCatalogSearchManager searchHistoryWithBookId:]
+[WRReaderCht2sManager autoConvertToCht2sStatusWithBook:dataTranslateMode:scene:hasRights:]
+[WRReaderCht2sManager canConvertCht2sWithBookId:]
+[WRReaderCht2sManager checkCht2sBookShouldSwitchLanguageForBookId:chapterUid:]
+[WRReaderContentNavigationManager addRangeValueToOutlineItem:]
+[WRReaderContentNavigationManager findParentNodesFromChapterOutlineLeafItems:]_block_invoke
+[WRReaderContentNavigationManager rangeForOutlineItem:]
+[WRReaderPencilNoteManager authCosForPencilDataWithSuffix:]_block_invoke
+[WRReaderPencilNoteManager checkDrawingExistsWithReviewItemId:reviewId:isDraft:]
+[WRReaderPencilNoteManager deleteAllReviewDrawings]
+[WRReaderPencilNoteManager deleteDrawingWithReviewItemId:reviewId:isDraft:]
+[WRReaderPencilNoteManager downloadDrawingDataFromCosWithUrl:desPath:callback:]
+[WRReaderPencilNoteManager downloadDrawingDataFromCosWithUrl:desPath:callback:]_block_invoke
+[WRReaderPencilNoteManager downloadDrawingWithReviewItemId:reviewId:drawingUrl:dataBlock:]_block_invoke_2
+[WRReaderPencilNoteManager downloadDrawingWithReviewItemId:reviewId:drawingUrl:dataBlock:]_block_invoke_3
+[WRReaderPencilNoteManager drawingFileDirectory]
+[WRReaderPencilNoteManager drawingFilePathWithReviewItemId:reviewId:isDraft:]
+[WRReaderPencilNoteManager imageFilePathWithReviewItemId:reviewId:]
+[WRReaderPencilNoteManager readDrawingWithReview:dataBlock:drawingUrlBlock:]_block_invoke
+[WRReaderPencilNoteManager readDrawingWithReview:dataBlock:drawingUrlBlock:]_block_invoke_2
+[WRReaderPencilNoteManager readDrawingWithReview:dataBlock:drawingUrlBlock:]_block_invoke_3
+[WRReaderPencilNoteManager uploadPencilDrawing:colorStyle:onlyUploadImage:canRetry:]
+[WRReaderPencilNoteManager uploadPencilDrawing:colorStyle:onlyUploadImage:canRetry:]_block_invoke
+[WRReaderPencilNoteManager uploadPencilNoteData:suffix:]
+[WRReaderPencilNoteManager uploadPencilNoteData:suffix:]_block_invoke
+[WRReaderPencilNoteManager uploadPencilNoteData:suffix:]_block_invoke_3
+[WRReaderPencilNoteManager writeDrawingDataToLocal:reviewItemId:reviewId:isDraft:]
+[WRReaderPencilNoteManager writeDrawingToLocal:reviewItemId:reviewId:isDraft:]
+[WRReaderProgress localProgressForBook:]
+[WRReaderTranslationManager canSwitchTranslationWithBookId:]
+[WRReaderTranslationManager chapterDataCacheKeyWithBookId:chapterUid:]
+[WRReaderTranslationManager isPlayChineseWithBookId:chapterUid:]
+[WRReaderTranslationManager tryPollingConvertStatusWithBook:isForTranslate:referenceLocationDict:isFreeTrialActive:chapterTranslations:updateTipsBlock:chapterTranslateCompletion:stopPollingBlock:errorBlock:isBatch:from:]_block_invoke
+[WRReaderTranslationManager updatePlayTranslation:forBookId:]
+[WRReaderTranslationManager wordCountWithBookId:chapterUid:]
+[WRReaderUnderlineStyleButtonManager bookmarkColorForColorStyle:underlineStyle:]
-[DTCoreTextGlyphRun newPathWithGlyphs]
-[DTHTMLAttributedStringBuilder _buildString]
-[DTHTMLAttributedStringBuilder _registerTagEndHandlers]_block_invoke_5
-[DTHTMLAttributedStringBuilder _registerTagStartHandlers]_block_invoke_11
-[DTHTMLAttributedStringBuilder parser:didStartElement:attributes:position:]
-[DTHTMLAttributedStringBuilder parser:foundCDATA:]
-[DTHTMLAttributedStringBuilder parser:foundCharacters:position:]
-[DTHTMLAttributedStringBuilder parserDidEndDocument:]
-[DTHTMLElement applyStyleDictionary:isLatinLanguageBook:]
-[DTHTMLElement attributedString]
-[DTHTMLElement interpretAttributes]
-[WRChapter isChapterContentDownloaded]
-[WRChapterData addAutoReadUnderLineInRange:style:color:]
-[WRChapterData addHighlightInRange:key:itemId:color:]
-[WRChapterData addReviewUnderlineInRange:itemId:type:]
-[WRChapterData addTempReviewHighlightInRange:itemId:]
-[WRChapterData addTempReviewHighlightInRange:itemId:color:]
-[WRChapterData deleteReviewUnderlineInRange:type:]
-[WRChapterData freeTrialChapterCutOffRealStringLocation]
-[WRChapterData generateOutlineContents]
-[WRChapterData markFreeTrialChapterCutOffStringLocation:]
-[WRChapterData rangeOfPage:]
-[WRChapterDownloadManger _preloadChapterContentWithBook:type:bookRank:]
-[WRChapterDownloadManger addDownloadTaskCount]
-[WRChapterDownloadManger createDownloadTaskSignalWithParam:callback:]_block_invoke
-[WRChapterDownloadManger createDownloadTaskSignalWithParam:callback:]_block_invoke_3
-[WRChapterDownloadManger downloadComicsChaptersIfNotExistWithParam:withCallback:]
-[WRChapterDownloadManger loadChapterContentWithParam:callback:]
-[WRChapterDownloadManger networkChanged]
-[WRChapterDownloadManger preloadShelfBooksForType:]
-[WRChapterDownloadManger removeDownloadTaskCount]
-[WRChapterDownloadManger resetChapterDownloadTaskCount]
-[WRChapterRecommendAlbumsView handleTouchAlbum:]
-[WRChapterRecommendAlbumsView handleTouchAlbum:]_block_invoke
-[WRChapterRecommendAlbumsView handleTouchAlbum:]_block_invoke_2
-[WRChapterRecommendAlbumsView initWithDataChangeSignal:]_block_invoke
-[WRCoreTextLayoutFrame avoidPageBreakInsideByRemovingLastLinesIfNeeded]
-[WRCoreTextLayoutFrame drawInContext:image:size:inRect:position:]
-[WRCoreTextLayoutFrame getRenderHeight]
-[WRCoreTextLayoutFrame lines]
-[WRCoreTextLayouter pageBackgroundImageAtRange:themeBgColor:]
-[WRCoreTextLayouter resizedImageForImagePath:rect:position:sizePattern:darkMode:themeBgColor:]
-[WREpubParser epubController:didFailWithError:]
-[WREpubPositionConverter indicesInFile:forRowColumnPairs:stringIndices:string:fileIndexOffset:stringIndexOffset:]
-[WREpubPositionConverter initIndices]
-[WREpubPositionConverter initWithFilePaths:attributedStrings:offset:isContainIntroFlyleaf:]
-[WREpubPositionConverter stringRangeFromFileRange:]
-[WRMarketPopToBookShelfTransition animateTransition:]_block_invoke_2
-[WRPreloadBookManager _preloadWholeBook:scene:]
-[WRPreloadBookManager _preloadWholeBook:scene:]_block_invoke
-[WRPreloadBookManager booksDirectoryInfoForNonVIPWithOnlyCalc:]
-[WRPreloadBookManager booksDirectoryInfoForVIPWithOnlyCalc:]
-[WRPreloadBookManager calcAndClearPreloadBookWithCompletion:onlyCalc:]
-[WRPreloadBookManager cleanUpPreloadBook]
-[WRPreloadBookManager cleanUpPreloadBook]_block_invoke
-[WRPreloadBookManager cleanUpPreloadBook]_block_invoke_2
-[WRPreloadBookManager downloadBook:uids:]
-[WRPreloadBookManager downloadBook:uids:]_block_invoke
-[WRPreloadBookManager downloadingUidsWithBookId:allChapters:fromChapterIdx:]
-[WRPreloadBookManager isBookCanPreloadForNonPayingVIP:]
-[WRPreloadBookManager isBookCanPreloadForPayingVIP:outReasons:]
-[WRPreloadBookManager preloadWithBook:fromChapterIdx:]
-[WRPreloadBookManager preloadWithBook:fromChapterIdx:]_block_invoke
-[WRPreloadBookManager refreshSetting]
-[WRPreloadBookManager remainingDiskSizeInMB]
-[WRPreloadBookManager resetBookDataWithBookId:]
-[WRPreloadBookManager stopCleanUpPreloadBookTask]
-[WRPreloadBookManager unzipBookCacheContentWithBookId:]
-[WRPreloadBookManager unzipBookCacheContentWithBookId:]_block_invoke
-[WRReaderAIKnowledgeViewController observeValueForKeyPath:ofObject:change:context:]
-[WRReaderAuthorReviewDetailViewController handleCommentButtonEvent:]
-[WRReaderAuthorReviewDetailViewController handleComposeButtonEvent:]_block_invoke
-[WRReaderAuthorReviewDetailViewController handleDeleteComment:]_block_invoke_2
-[WRReaderAuthorReviewDetailViewController handlePraiseButtonEvent:]
-[WRReaderAuthorReviewDetailViewController handlePraiseButtonEvent:]_block_invoke
-[WRReaderAuthorReviewDetailViewController handlePraiseButtonEvent:]_block_invoke_3
-[WRReaderAuthorReviewDetailViewController keyboardWillChangeFrameWithUserInfo:]_block_invoke
-[WRReaderAuthorReviewDetailViewController tableView:cellForRowAtIndexPath:]
-[WRReaderAuthorReviewListViewController handleAvatarButtonEvent:]
-[WRReaderAuthorReviewListViewController handlePraiseButtonEvent:]
-[WRReaderAuthorReviewListViewController handlePraiseButtonEvent:]_block_invoke
-[WRReaderAuthorReviewListViewController handlePraiseButtonEvent:]_block_invoke_3
-[WRReaderAuthorReviewListViewController tableView:cellForRowAtIndexPath:]
-[WRReaderAutoPlayer calcPageDuration]
-[WRReaderAutoPlayer clauseFinished:]
-[WRReaderAutoPlayer didBecomeActive]
-[WRReaderAutoPlayer initWithDelegate:chapterData:forVerticalReader:]
-[WRReaderAutoPlayer pause]
-[WRReaderAutoPlayer play]
-[WRReaderAutoPlayer setCurrentClauseIndex:]
-[WRReaderAutoPlayer setCurrentLocation:]
-[WRReaderAutoPlayer setSpeed:]
-[WRReaderAutoPlayer setStatus:]
-[WRReaderAutoPlayer setTmpClauseIndex:]
-[WRReaderAutoPlayer setTmpLocation:]
-[WRReaderAutoPlayer setupTimer]
-[WRReaderAutoPlayer stop]
-[WRReaderAutoPlayer willResignActive]
-[WRReaderBackground(WRReaderBackgroundAdapter_Private) downloadWithProgressHandler:completionHandler:]_block_invoke
-[WRReaderBookmarkViewController addBookmarkInCurrentReferenceArea]
-[WRReaderBookmarkViewController deleteBookmarkWithItemId:callback:]
-[WRReaderBrightnessChangeView brightSliderChanged:]
-[WRReaderBrightnessChangeView initReaderBackgroundViewUI]_block_invoke
-[WRReaderBrightnessChangeView initReaderBackgroundViewUI]_block_invoke_2
-[WRReaderBrightnessChangeView sizeThatFits:]
-[WRReaderCatalogContainerViewController pageController:didShowPageAtIndex:]_block_invoke
-[WRReaderCatalogContainerViewController pageController:titleConfigurationAtIndex:]
-[WRReaderCatalogContainerViewController pageController:viewControllerAtIndex:]
-[WRReaderCatalogContainerViewController selectCatalogViewController]
-[WRReaderCatalogContainerViewController selectOutlineViewController]
-[WRReaderCatalogContainerViewController selectSearchContentViewController]
-[WRReaderCatalogDataSource initWithBookId:type:delegate:]_block_invoke
-[WRReaderCatalogViewController shouldRefreshCurrentAndBackupIndexPathCell]
-[WRReaderChangeFirstIndentTableViewCell handleTapItemView:]
-[WRReaderChangeOrientationView tableView:cellForRowAtIndexPath:]
-[WRReaderChangePageTurningStyleView tableView:cellForRowAtIndexPath:]
-[WRReaderCht2sManager updatePlayCht2s:forBookId:]
-[WRReaderComicsViewModel chapterReviewForReviewId:]
-[WRReaderComicsViewModel contentForChapterUid:]
-[WRReaderComicsViewModel imageForURL:]_block_invoke_2
-[WRReaderComicsViewModel markAsLiked:forChapterUid:]
-[WRReaderComicsViewModel markAsLiked:forReview:]
-[WRReaderComicsViewModel preloadNextChapterIfNeededWithCurrentChapterUid:]_block_invoke
-[WRReaderComicsViewModel refreshAvailableRangeWithVisibleSectionRange:]
-[WRReaderContentNavigationManager didLoadDataWithSusseed:]_block_invoke
-[WRReaderContentNavigationManager findCurrentOutlineItemWithUniqId:chapterUid:]
-[WRReaderContentNavigationManager findNextOutlineItemInRanges:loadMoreChapterUids:]
-[WRReaderContentNavigationManager findPrevOutlineItemInRanges:loadMoreChapterUids:]
-[WRReaderContentNavigationManager hasNextOutlineItemInRanges:]
-[WRReaderContentNavigationManager hasNextOutlineItem]
-[WRReaderContentNavigationManager hasPrevOutlineItemInRanges:]
-[WRReaderContentNavigationManager hasPrevOutlineItem]
-[WRReaderContentNavigationManager initSearchDataSourceWithBook:]
-[WRReaderContentNavigationManager isCurrentOutlineItemVisible]
-[WRReaderContentNavigationManager jumpToNextOutlineItemWithPreviousLoadMoreChapterUids:]
-[WRReaderContentNavigationManager jumpToNextSearchItem]
-[WRReaderContentNavigationManager jumpToOutlineItem:]
-[WRReaderContentNavigationManager jumpToOutlineItemWithUniqId:chapterUid:highlight:]
-[WRReaderContentNavigationManager jumpToPrevOutlineItemWithPreviousLoadMoreChapterUids:]
-[WRReaderContentNavigationManager jumpToPrevSearchItem]
-[WRReaderContentNavigationManager jumpToSearchItem:]
-[WRReaderContentNavigationManager nextOutlineItemFromMemoryWithLoadMoreChapterUids:]
-[WRReaderContentNavigationManager prevOutlineItemFromMemoryWithLoadMoreChapterUids:]
-[WRReaderContentNavigationManager searchFrontAndBehindWithKeyword:chapterUid:position:addCurrentItem:callback:]
-[WRReaderContentNavigationManager searchFrontAndBehindWithKeyword:chapterUid:position:addCurrentItem:callback:]_block_invoke
-[WRReaderContentNavigationManager setCurrentSearchItem:]
-[WRReaderContentNavigationManager setupOutlineWithInitialDict:]
-[WRReaderContentNavigationManager updateOutlineDataWithBookId:]
-[WRReaderDictionaryViewController collectionView:cellForItemAtIndexPath:]
-[WRReaderDictionaryViewController observeValueForKeyPath:ofObject:change:context:]
-[WRReaderDictionaryViewController refreshData]_block_invoke_4
-[WRReaderEndOfTrialButton renderWithLabelDictionary:]
-[WRReaderEndOfTrialView handleCampaignButtonTapWithItem:]
-[WRReaderEndOfTrialView handleCampaignButtonTapWithItem:]_block_invoke
-[WRReaderEndOfTrialView parseRightsViewData]
-[WRReaderFloatReviewsViewController initHeaderToolViewIfNeeded]_block_invoke_2
-[WRReaderFloatReviewsViewController renderWithHyperlinksInfo:]
-[WRReaderFloatReviewsViewController setSelectedText:hyperlinksInfo:]
-[WRReaderFontSizeChangeView autoTestHandleChangeValueWithSlider:index:]
-[WRReaderFontSizeChangeView sizeThatFits:]
-[WRReaderFontSizeChangeView updateFontFamilyChangeButtonFont]
-[WRReaderHotUnderlinesViewController updateAppearanceForSectionHeaderView:inTableView:section:isPinned:]
-[WRReaderLastPageActionButtonContainerView createFontViewWithFinishIndex:iterationBlock:]
-[WRReaderLastPageBadgeView handleCheckButtonDownEvent:]
-[WRReaderLastPageBadgeView handleCheckButtonUpEvent:]
-[WRReaderLastPageBadgeView handleCheckButtonUpEvent:]_block_invoke_3
-[WRReaderLastPageBadgeView handleCheckButtonUpOutSideEvent:]
-[WRReaderLastPageViewController refreshFinishedBookInfo]_block_invoke
-[WRReaderLastPageViewController rootViewDidChangeIntrinsicSize:]
-[WRReaderLastPageViewController showing]
-[WRReaderMilestoneLabelView renderWithAttributedStrings:]
-[WRReaderMistakeViewController viewDidLoad]_block_invoke
-[WRReaderMistakeViewController viewDidLoad]_block_invoke_2
-[WRReaderModalView headerViewHeight]
-[WRReaderMoreOperationController renderWithBook:scene:]
-[WRReaderNoteViewModel initWithBookId:]
-[WRReaderOutlineViewController handleJumpToClickContentWithParams:]
-[WRReaderOutlineViewController initWithBookId:chapterUid:]_block_invoke
-[WRReaderOutlineViewController initWithBookId:chapterUid:]_block_invoke_2
-[WRReaderOutlineViewController viewDidLoad]
-[WRReaderPanelSelectionView panelHeaderHeight]
-[WRReaderPanelSelectionView panelTitle]
-[WRReaderPanelSelectionView tableView:cellForRowAtIndexPath:]
-[WRReaderPanelSelectionView tableView:didSelectRowAtIndexPath:]
-[WRReaderPanelSelectionView tableView:numberOfRowsInSection:]
-[WRReaderPencilNoteBaseView handleCanvasViewDrawingDidChange:]
-[WRReaderPencilNoteReviewManager editPencilReview:secretMode:imageDict:drawingUrl:didEditBlock:]_block_invoke
-[WRReaderPencilNoteReviewManager tryResendAllDraftDrawingReviews]_block_invoke
-[WRReaderPencilNoteReviewManager tryResendAllDraftDrawingReviews]_block_invoke_2
-[WRReaderPencilNoteReviewManager tryResendAllDraftDrawingReviews]_block_invoke_4
-[WRReaderPencilNoteReviewManager tryResendAllDraftDrawingReviews]_block_invoke_5
-[WRReaderPencilNoteReviewManager tryResendAllDraftDrawingReviews]_block_invoke_6
-[WRReaderPencilNoteReviewManager uploadDrawing:thenEditReview:secretMode:readerVC:didUploadBlock:didEditReviewBlock:didWriteReviewBlock:]_block_invoke
-[WRReaderPencilNoteReviewManager writeReview:secretMode:readerVC:imageDict:drawingUrl:didWriteBlock:]
-[WRReaderPencilNoteReviewManager writeReview:secretMode:readerVC:imageDict:drawingUrl:didWriteBlock:]_block_invoke
-[WRReaderPencilNoteReviewManager writeReview:secretMode:readerVC:imageDict:drawingUrl:didWriteBlock:]_block_invoke_2
-[WRReaderPencilNoteViewController addPageReview:]
-[WRReaderPencilNoteViewController addRangeReview:]
-[WRReaderPencilNoteViewController checkUpdatePencilPageReview:pageNoteView:]
-[WRReaderPencilNoteViewController checkUpdatePencilPageReview:pageNoteView:]_block_invoke
-[WRReaderPencilNoteViewController checkUpdatePencilPageReview:pageNoteView:]_block_invoke_2
-[WRReaderPencilNoteViewController checkUpdatePencilPageReview]
-[WRReaderPencilNoteViewController checkUpdatePencilPageReview]_block_invoke
-[WRReaderPencilNoteViewController checkUpdatePencilRangeReview]
-[WRReaderPencilNoteViewController checkUpdatePencilRangeReview]_block_invoke
-[WRReaderPencilNoteViewController checkUpdatePencilRangeReview]_block_invoke_2
-[WRReaderPencilNoteViewController checkUpdatePencilRangeReview]_block_invoke_3
-[WRReaderPencilNoteViewController checkUpdateRangeReviewDrawingAsDraft]
-[WRReaderPencilNoteViewController dealloc]
-[WRReaderPencilNoteViewController deleteEmptyDrawingReview:]_block_invoke_2
-[WRReaderPencilNoteViewController editPencilReview:secretMode:imageDict:drawingUrl:]_block_invoke
-[WRReaderPencilNoteViewController editRangeReviewForColorStyleSilentWithReview:]_block_invoke
-[WRReaderPencilNoteViewController editRangeReviewForColorStyleSilentWithReview:]_block_invoke_2
-[WRReaderPencilNoteViewController handleBookmarkUpdatedNotification:]
-[WRReaderPencilNoteViewController handleCloseRangeReviewEditWithShowAlert:]
-[WRReaderPencilNoteViewController handleCloseRangeReviewEditWithShowAlert:]_block_invoke
-[WRReaderPencilNoteViewController handleDeleteRangeReview]_block_invoke_2
-[WRReaderPencilNoteViewController init]
-[WRReaderPencilNoteViewController resetContextWithRangeReviews:chapterIdx:defaultSecretMode:]
-[WRReaderPencilNoteViewController setupPageNoteView]_block_invoke
-[WRReaderPencilNoteViewController setupRangeNoteView]
-[WRReaderPencilNoteViewController setupRangeNoteView]_block_invoke
-[WRReaderPencilNoteViewController switchToRangeReview:curRange:curColorStyle:]
-[WRReaderPencilPageNoteListView addCanvasViewDrawingDidChangeBlockWithNoteView:]_block_invoke
-[WRReaderPencilPageNoteListView autoSaveEditingDrawingToLocalAsDraft]
-[WRReaderPencilPageNoteListView handleUpdatePageNoteReviewsWithBlock:]
-[WRReaderProgressChangeView handleNextButtonClick:]
-[WRReaderProgressChangeView handlePreButtonClick:]
-[WRReaderProgressChangeView sizeThatFits:]
-[WRReaderProgressData initWithBook:forPDF:]
-[WRReaderProgressData toCGIJsonDict]
-[WRReaderReviewsRNViewController handleWriteReviewButtonEvent:]
-[WRReaderSegmentViewController handleNotesOutputComputerButton]_block_invoke_2
-[WRReaderSegmentViewController initNoteControllerIfNeeded]
-[WRReaderSinglePurchaseEndTrailBookContentView renderData]
-[WRReaderTextSelector updateTurningPageTimerWithGesture:]
-[WRReaderTimer handleReadingTime:]
-[WRReaderTimer setActive:]
-[WRReaderTimer startTimer]
-[WRReaderUnderlineStyleButtonManager updateSelectedStyleButtonUI]
-[WRReaderViewController _saveReadingProgressAndIsAsync:]
-[WRReaderViewController addObservers]_block_invoke
-[WRReaderViewController addObservers]_block_invoke_2
-[WRReaderViewController animateAfterWritePageReview]_block_invoke
-[WRReaderViewController autoTestGetCurrentScreenContentString]
-[WRReaderViewController calculateReadingTimeAndUploadIfNeeded]
-[WRReaderViewController calculateReadingTimeFromLastReadDate]
-[WRReaderViewController canReadFromLocalWithChapterData:]
-[WRReaderViewController changeTypesetterAttributesWithBlock:]
-[WRReaderViewController checkLocationInCurrentPage:stringLocation:chapterIdx:]
-[WRReaderViewController detectExcessPageViews]
-[WRReaderViewController didEnterBackGround]
-[WRReaderViewController didEnterForeGround]
-[WRReaderViewController didFlipPage]
-[WRReaderViewController didReceiveMemoryWarning]
-[WRReaderViewController firstlyWillRenderPageView:progressData:source:]
-[WRReaderViewController gotoChapterIdx:filePosition:pageOfFlyleaf:shouldAutoJumpToCover:]
-[WRReaderViewController gotoChapterIdx:position:positionOfFile:]
-[WRReaderViewController handleBackProgressButtonClick:]
-[WRReaderViewController handleLastPageGesture:]
-[WRReaderViewController handleLastPageTapGesture:]
-[WRReaderViewController handleSizeChanged]
-[WRReaderViewController initChapterPageCount]
-[WRReaderViewController initChapterPageCount]_block_invoke
-[WRReaderViewController initChapterPageCount]_block_invoke_3
-[WRReaderViewController initWithBook:progress:forceUseInitialProgress:doodleMode:autoRead:]
-[WRReaderViewController initWithBookId:]
-[WRReaderViewController invokeVaildReadingProgress]
-[WRReaderViewController isLastPageWithChapterIdx:page:]
-[WRReaderViewController isReviewChapterFirstPageWithChapterData:page:]
-[WRReaderViewController isReviewChapterInMiddlePageWithChapterData:page:]
-[WRReaderViewController isReviewChapterLastPageWithChapterData:page:]
-[WRReaderViewController jumpReadingToNeigbourChapterForward:]
-[WRReaderViewController jumpReadingToNextChapter]
-[WRReaderViewController jumpReadingToPreChapter]
-[WRReaderViewController jumpToChapterUid:chapterOffset:shouldAutoJumpToCover:jumpToCloseChapterIdxIfNotExists:jumpFromStartProgress:completion:]
-[WRReaderViewController jumpToChapterUid:chapterOffset:shouldAutoJumpToCover:jumpToCloseChapterIdxIfNotExists:jumpFromStartProgress:completion:]_block_invoke
-[WRReaderViewController jumpToChapterUid:page:]
-[WRReaderViewController loadCurrentPagePencilNotes]
-[WRReaderViewController pageView:didClickPencilNote:]
-[WRReaderViewController recomposeAndClearCache:]
-[WRReaderViewController recomposeCurrentPageViewWithSource:]
-[WRReaderViewController recomposeLoadedPageView:]
-[WRReaderViewController refreshWithBook:]
-[WRReaderViewController reloadCurrentPageView]
-[WRReaderViewController reloadPageViewsWithProgressData:source:]
-[WRReaderViewController removeAllPageViewLinkBookPaperHighlight]
-[WRReaderViewController removeExcessPageViews:pageViewsToKeep:]
-[WRReaderViewController renderErrorPageView:error:chapter:andChapterFormat:progressData:source:]
-[WRReaderViewController renderLoadingContentPageView:progressData:source:]
-[WRReaderViewController renderLoadingContentPageView:progressData:source:]_block_invoke
-[WRReaderViewController renderPageView:nextToChapterIdx:page:]
-[WRReaderViewController renderPageView:previousToChapterIdx:page:]
-[WRReaderViewController renderPageView:progressData:source:]
-[WRReaderViewController savePreviousPagePencilNote]
-[WRReaderViewController scrollToLastReadingProgressIfNeeded]
-[WRReaderViewController setBaseReaderViewModel:]_block_invoke_2
-[WRReaderViewController showMileStoreRemind]
-[WRReaderViewController startShowingReadingPageView]
-[WRReaderViewController syncAuthorFlyleafDatas]
-[WRReaderViewController syncBookInfoAndChapterInfoWithCallback:]_block_invoke
-[WRReaderViewController syncFlyleapDataWithBook:]
-[WRReaderViewController tryToJumpToAuthorFlyLeafPage]
-[WRReaderViewController tryToJumpToAuthorFlyLeafPage]_block_invoke
-[WRReaderViewController turnToPlayingFileLocation:stringLocation:chapterIdx:]
-[WRReaderViewController uploadReadingProgressWithCallback:]
-[WRReaderViewController viewDidLoad]
-[WRReaderViewController viewWillDisappear:]
-[WRReaderViewController willPopInNavigationControllerWithAnimated:]
-[WRReaderViewModel loadChapterDataWithChapterIdx:reviewVid:tryRead:callback:]
-[WRReaderViewModel updateInfoAfterAutoPaidChapter:]
@@ -1,320 +0,0 @@
DTCoreText
DTCoreTextFontCollection
DTCoreTextFontDescriptor
DTCoreTextGlyphRun
DTCoreTextLayoutFrame
DTCoreTextLayoutFrameAccessibilityElementGenerator
DTCoreTextLayoutLine
DTCoreTextLayouter
DTCoreTextParagraphStyle
DTHTMLAttributedStringBuilder
DTHTMLElement
DTHTMLParser
DTHTMLParserDelegate
DTHTMLParserNode
DTHTMLParserTextNode
WRBookMarketManager
WRBookMarketRankListDataSource
WRBookMarketSearchData
WRBookMarketSearchDataSource
WRBookMarketUIHelper
WRBookMarketViewController
WRBookNetwork
WRBookNetworkReporter
WRBookmark
WRChapter
WRChapterAnchor
WRChapterData
WRChapterDownloadManger
WRChapterDownloadParam
WRChapterListener
WRChapterPageCount
WRChapterReadData
WRChapterRecommendAlbumsView
WRChapterRecommendAlbumsViewCell
WRChapterReviewsDownLoadModel
WRCoreTextFloatStatement
WRCoreTextImageAttributedStringFormatter
WRCoreTextLayoutFrame
WRCoreTextLayouter
WREncryptedFileManager
WREpubPage
WREpubParser
WREpubPositionConverter
WREpubTypesetter
WRMarkContent
WRMarkContentChapter
WRMarkContentInfoViewModel
WRMarkContentStore
WRMarkContentViewModel
WRMarketIcon
WRMarketNetwork
WRMarketPopToBookShelfTransition
WRMarketPopToDiscoverTransition
WRMarketPushToSearchTransition
WRMarketSearchModel
WRMarketSearchScopeTab
WRPageAVAttachment
WRPageAudioAttachment
WRPageAudioView
WRPageBookAttachment
WRPageBookView
WRPageChapterRewardAttachment
WRPageChapterRewardView
WRPageChapterToolAttachment
WRPageChapterToolAttachmentDelegate
WRPageChapterToolAttachmentHelper
WRPageChapterToolDelegate
WRPageChapterToolModel
WRPageChapterToolShareTipsView
WRPageChapterToolStore
WRPageCodeView
WRPageCodeViewController
WRPageComicView
WRPageCountReportManager
WRPageCoverAttachment
WRPageCoverAttachmentDelegate
WRPageCoverOrPageFlyleafAttachmentDelegate
WRPageData
WRPageElement
WRPageFlowCollectionViewLayout
WRPageFlyleafAttachment
WRPageHighlight
WRPageHighlightView
WRPageHyperlinksAttachment
WRPageHyperlinksAttachmentDelegate
WRPageIframeAttachment
WRPageIframeView
WRPageImageAttachment
WRPageImageAttachmentDelegate
WRPageImageAttachmentPreviewDelegate
WRPageImageAttachmentPreviewViewController
WRPageImageView
WRPageMark
WRPageMarkDelegate
WRPageMarkView
WRPagePreAttachment
WRPageRemindView
WRPageSearchHighlight
WRPageSliderAttachment
WRPageTableAttachment
WRPageTextView
WRPageTextViewDelegate
WRPageTurnModeControl
WRPageUnderline
WRPageVideoView
WRPageView
WRPageViewController
WRPageViewControllerDelegate
WRPageViewDelegate
WRPageViewRichElementStub
WRPreloadBookManager
WRPreloadBookSettingHeaderView
WRPreloadBookSettingViewController
WRPreloadStatisticsManager
WRReaderAIKnowledgeViewController
WRReaderAdDialog
WRReaderAnchorManager
WRReaderAuthorReviewDetailViewController
WRReaderAuthorReviewListViewCell
WRReaderAuthorReviewListViewController
WRReaderAutoPlayer
WRReaderAutoPlayerDelegate
WRReaderBackground
WRReaderBackgroundAdapter
WRReaderBackgroundChangeCell
WRReaderBackgroundChangeView
WRReaderBasePopupCellAdapter
WRReaderBitmapColorHelper
WRReaderBodyImageHelper
WRReaderBookBorrowUtils
WRReaderBookIntroControl
WRReaderBookReviewStarView
WRReaderBookmarkCell
WRReaderBookmarkViewController
WRReaderBorrowButton
WRReaderBrightnessBackgroundButton
WRReaderBrightnessChangeView
WRReaderBusinessHelper
WRReaderCatalogBaseCell
WRReaderCatalogBaseCellModel
WRReaderCatalogBaseCellView
WRReaderCatalogBatchBuyChapterView
WRReaderCatalogBookMarkCell
WRReaderCatalogBookMarkCellView
WRReaderCatalogCell
WRReaderCatalogCellUtils
WRReaderCatalogContainerViewController
WRReaderCatalogCurrentReadingInfo
WRReaderCatalogDataSource
WRReaderCatalogDataSourceDelegate
WRReaderCatalogDataSourceProtocol
WRReaderCatalogFooterCell
WRReaderCatalogHeaderView
WRReaderCatalogItemModel
WRReaderCatalogReadingCell
WRReaderCatalogReadingCellModel
WRReaderCatalogReadingCellView
WRReaderCatalogSearchHistoryView
WRReaderCatalogSearchManager
WRReaderCatalogSectionItem
WRReaderCatalogTableHeaderView
WRReaderCatalogViewController
WRReaderChangeFirstIndentTableViewCell
WRReaderChangeFirstIndentTableViewCellItemView
WRReaderChangeFirstIndentView
WRReaderChangeFirstIndentViewController
WRReaderChangeOrientationView
WRReaderChangeOrientationViewController
WRReaderChangePageTurningStyleView
WRReaderChangePageTurningStyleViewController
WRReaderCht2sManager
WRReaderColorThemeManager
WRReaderColorableProtocol
WRReaderComicsViewModel
WRReaderContentNavigationBottomControl
WRReaderContentNavigationControl
WRReaderContentNavigationManager
WRReaderContentNavigationTopControl
WRReaderContext
WRReaderControlBarView
WRReaderCurProgressTipsView
WRReaderDictionaryAIKnowledgeCollectionViewCell
WRReaderDictionaryCollectionBaseViewCell
WRReaderDictionaryCollectionResultViewCell
WRReaderDictionaryCollectionView
WRReaderDictionaryEncyclopediaCollectionViewCell
WRReaderDictionarySearchCollectionViewCell
WRReaderDictionaryTranslationCollectionViewCell
WRReaderDictionaryView
WRReaderDictionaryViewController
WRReaderEndOfTrialButton
WRReaderEndOfTrialView
WRReaderExchangePushInfo
WRReaderExperienceViewController
WRReaderFloatReviewsViewController
WRReaderFont
WRReaderFontChangeCategoryViewController
WRReaderFontChangeCategoryViewModel
WRReaderFontChangeCell
WRReaderFontChangeModalView
WRReaderFontChangeShareViewModel
WRReaderFontChangeTabsView
WRReaderFontChangeViewController
WRReaderFontConfig
WRReaderFontSizeChangeView
WRReaderFontTableViewCell
WRReaderFooterPageView
WRReaderFooternotePopupView
WRReaderHotUnderLinesTotalCountViewController
WRReaderHotUnderlinesCell
WRReaderHotUnderlinesViewController
WRReaderIncentiveButton
WRReaderInfoDebuggerViewController
WRReaderInfoDebuggerViewModel
WRReaderLastPageActionButton
WRReaderLastPageActionButtonContainerView
WRReaderLastPageBadgeView
WRReaderLastPageDelegate
WRReaderLastPageHelper
WRReaderLastPageMilestoneCardView
WRReaderLastPageReviewItem
WRReaderLastPageReviewsView
WRReaderLastPageViewController
WRReaderMaskProtocol
WRReaderMaskViewController
WRReaderMenuView
WRReaderMilestoneLabelView
WRReaderMistakeDialogContentView
WRReaderMistakeViewController
WRReaderModalView
WRReaderMoreOperationController
WRReaderNoteViewModel
WRReaderNotesSectionHeaderView
WRReaderOSSLogHelper
WRReaderOutlineViewController
WRReaderPaidVIPButton
WRReaderPanelSelectionView
WRReaderPanelSelectionViewController
WRReaderPencilNote
WRReaderPencilNoteBackgroundView
WRReaderPencilNoteBaseView
WRReaderPencilNoteButtonGroupView
WRReaderPencilNoteEraserTips
WRReaderPencilNoteEraserTipsManager
WRReaderPencilNoteHelper
WRReaderPencilNoteManager
WRReaderPencilNoteMaskView
WRReaderPencilNoteRangeReviewItem
WRReaderPencilNoteRangeReviewsPanel
WRReaderPencilNoteReviewManager
WRReaderPencilNoteTopToolView
WRReaderPencilNoteViewController
WRReaderPencilPageNoteListView
WRReaderPencilPageNoteView
WRReaderPencilRangeNoteView
WRReaderPlayerEntranceView
WRReaderPopupInfoTableViewCell
WRReaderPopupLoadMoreTableViewCell
WRReaderPopupReviewTableViewCell
WRReaderPresellPageView
WRReaderProgress
WRReaderProgressButton
WRReaderProgressChangeRuleView
WRReaderProgressChangeView
WRReaderProgressData
WRReaderProgressShareView
WRReaderProgressSlider
WRReaderProgressSliderDelegate
WRReaderProgressSmallChangeView
WRReaderReadOverToBuyView
WRReaderRefreshFooterView
WRReaderRefreshHeaderView
WRReaderReivewCountLabel
WRReaderReviewToolsView
WRReaderReviewsRNViewController
WRReaderSearchTableHeaderView
WRReaderSegmentViewController
WRReaderSettingPanelManager
WRReaderSettingPanelView
WRReaderSettingProtocol
WRReaderSinglePurchaseEndTrailBookContentView
WRReaderSizeManager
WRReaderStatisticPanelViewController
WRReaderStatisticRNViewController
WRReaderTextSelector
WRReaderTimeHelper
WRReaderTimer
WRReaderTraditionalChineseView
WRReaderTranslationManager
WRReaderTranslationUpgradeViewController
WRReaderUIFactory
WRReaderUnderlineStyleButtonManager
WRReaderVIPButton
WRReaderVIPButtonProtocol
WRReaderViewController
WRReaderViewControllerDelegate
WRReaderViewControllerHelper
WRReaderViewModel
WRReaderVisiblePageInfo
WRReaderVoteView
WRReaderWelfareCoinPanel
WRReaderWriteReviewSimpleView
WRUnderLineView
WRUnderline
WRUnderlineColorButtonsScrollView
WRUnderlineLayoutManager
WRUnderlineStore
WrBookmarkLoadErrorReport
WrBookmarkLoadErrorReportRoot
WrPageFlippingAnimationTypeRoot
WrReaderActionReport
WrReaderActionReportRoot
WrReaderActionRoot
WrReaderItemtypeRoot
WrReaderModuleRoot
WrReaderPayingtypeRoot
WrReaderSearchAuthoriedFailedTypeRoot
WrReaderSearchFailedtypeRoot
WrReaderSearchWords
WrReaderSearchWordsRoot
@@ -1,798 +0,0 @@
ivarLayout 0x1031bcf9c
layout map 0x11 0x12
name 0x1031bcf89 DTCoreTextLayouter
baseMethods 0x102acf508
entsize 12 (relative)
count 12
name 0x104ef88 (0x103b1e498)
types 0x95e950 (0x10342de64) @24@0:8@16
imp 0xfd5764d8 (0x1000459f0)
name 0x103a3ec (0x103b09908)
types 0x95e89b (0x10342ddbb) v16@0:8
imp 0xfd576560 (0x100045a84)
name 0x1058528 (0x103b27a50)
types 0x95ff95 (0x10342f4c1) @64@0:8{CGRect={CGPoint=dd}{CGSize=dd}}16{_NSRange=QQ}48
imp 0xfd576598 (0x100045ac8)
name 0x102881c (0x103af7d50)
types 0x95e883 (0x10342ddbb) v16@0:8
imp 0xfd576734 (0x100045c70)
name 0x10436d0 (0x103b12c10)
types 0x95ffb6 (0x10342f4fa) ^{__CTFramesetter=}16@0:8
imp 0xfd576754 (0x100045c9c)
name 0x1075b84 (0x103b450d0)
types 0x95e860 (0x10342ddb0) v24@0:8@16
imp 0xfd5767c4 (0x100045d18)
name 0x102e2c8 (0x103afd820)
types 0x95e84c (0x10342dda8) @16@0:8
imp 0xfd57684c (0x100045dac)
name 0x108cf3c (0x103b5c4a0)
types 0x95e8b5 (0x10342de1d) v20@0:8B16
imp 0xfd576848 (0x100045db4)
name 0x1094710 (0x103b63c80)
types 0x95e9a8 (0x10342df1c) B16@0:8
imp 0xfd57689c (0x100045e14)
--
reserved 0x0
ivarLayout 0x0
name 0x1031bcf89 DTCoreTextLayouter
baseMethods 0x0
baseProtocols 0x0
ivars 0x0
weakIvarLayout 0x0
baseProperties 0x0
000000010383bc60 0x103ba2550
isa 0x103ba2528
superclass 0x0 _OBJC_CLASS_$_NSObject
cache 0x0 __objc_empty_cache
vtable 0x0
data 0x10388b308
flags 0x90
instanceStart 8
instanceSize 8
reserved 0x0
ivarLayout 0x0
name 0x1031bcf9f TABAnimatedChainManagerImpl
baseMethods 0x102acf5a0
entsize 12 (relative)
count 3
name 0x1033af0 (0x103b03098)
types 0x95ff9d (0x10342f549) v48@0:8@16@24@?32@40
imp 0xfd576cac (0x10004625c)
name 0x1033aec (0x103b030a0)
types 0x95ffa6 (0x10342f55e) v56@0:8@16@24@?32#40@48
imp 0xfd576d1c (0x1000462d8)
name 0x103bf00 (0x103b0b4c0)
types 0x95e7f7 (0x10342ddbb) v16@0:8
imp 0xfd576d9c (0x100046364)
baseProtocols 0x10388b260
--
ivarLayout 0x1031bc939
layout map 0x04
name 0x1031bfb46 WREpubParser
baseMethods 0x102aec440
entsize 12 (relative)
count 11
name 0x1032c20 (0x103b1f068)
types 0x9419c3 (0x10342de0f) @32@0:8@16@24
imp 0xfd7ec460 (0x1002d88b0)
name 0x10459b4 (0x103b31e08)
types 0x941ac4 (0x10342df1c) B16@0:8
imp 0xfd7ec68c (0x1002d8ae8)
name 0x1023418 (0x103b0f878)
types 0x9419c4 (0x10342de28) v32@0:8@16@24
imp 0xfd7ec6b4 (0x1002d8b1c)
name 0x102f9b4 (0x103b1be20)
types 0x94199f (0x10342de0f) @32@0:8@16@24
imp 0xfd7ed284 (0x1002d96f8)
name 0x102f5a0 (0x103b1ba18)
types 0x941993 (0x10342de0f) @32@0:8@16@24
imp 0xfd7ed468 (0x1002d98e8)
name 0x101a8d4 (0x103b06d58)
types 0x941987 (0x10342de0f) @32@0:8@16@24
imp 0xfd7ed4ec (0x1002d9978)
name 0x100ead8 (0x103afaf68)
types 0x9454c8 (0x10343195c) v40@0:8@16@24Q32
imp 0xfd7ed82c (0x1002d9cc4)
name 0x100dc6c (0x103afa108)
types 0x94b021 (0x1034374c1) v56@0:8@16@24Q32@40@48
imp 0xfd7edbd0 (0x1002da074)
name 0x10233c8 (0x103b0f870)
types 0x94197c (0x10342de28) v32@0:8@16@24
imp 0xfd7ee028 (0x1002da4d8)
--
reserved 0x0
ivarLayout 0x0
name 0x1031bfb46 WREpubParser
baseMethods 0x102aec428
entsize 12 (relative)
count 1
name 0x1046580 (0x103b329b0)
types 0x941a30 (0x10342de64) @24@0:8@16
imp 0xfd7ee188 (0x1002da5c0)
baseProtocols 0x1038b61b8
count 1
list[0] 0x103c17ce8
isa 0x0
name 0x1031bfb53 KFEpubControllerDelegate
protocols 0x1038b6130
count 1
list[0] 0x103c15580
isa 0x0
name 0x1031bc9c5 NSObject
protocols 0x0
instanceMethods 0x103884670
entsize 24
count 19
name 0x103348607 isEqual:
types 0x10342de6f B24@0:8@16
imp 0x0
name 0x1032e9bde class
types 0x10342df44 #16@0:8
imp 0x0
name 0x1033a90f1 self
types 0x10342dda8 @16@0:8
imp 0x0
name 0x1033780e4 performSelector:
--
ivarLayout 0x1031bfd8e
layout map 0x16 0x1c 0x12 0x15 0x11
name 0x1031bfd80 WRChapterData
baseMethods 0x102aeddf0
entsize 12 (relative)
count 118
name 0x102f158 (0x103b1cf50)
types 0x93ffac (0x10342dda8) @16@0:8
imp 0xfd809f38 (0x1002f7d38)
name 0x101552c (0x103b03330)
types 0x940101 (0x10342df09) q16@0:8
imp 0xfd809f80 (0x1002f7d8c)
name 0x1019920 (0x103b07730)
types 0x94150c (0x10342f320) {_NSRange=QQ}16@0:8
imp 0xfd80a044 (0x1002f7e5c)
name 0x103fd04 (0x103b2db20)
types 0x941500 (0x10342f320) {_NSRange=QQ}16@0:8
imp 0xfd80a07c (0x1002f7ea0)
name 0x1033920 (0x103b21748)
types 0x9400dd (0x10342df09) q16@0:8
imp 0xfd80a0b0 (0x1002f7ee0)
name 0x10353cc (0x103b23200)
types 0x94427a (0x1034320b2) B24@0:8q16
imp 0xfd80a0bc (0x1002f7ef8)
name 0x10353c8 (0x103b23208)
types 0x94426e (0x1034320b2) B24@0:8q16
imp 0xfd80a158 (0x1002f7fa0)
name 0x1035b9c (0x103b239e8)
types 0x944262 (0x1034320b2) B24@0:8q16
imp 0xfd80a18c (0x1002f7fe0)
name 0x1035c60 (0x103b23ab8)
types 0x944256 (0x1034320b2) B24@0:8q16
imp 0xfd80a1c0 (0x1002f8020)
--
reserved 0x0
ivarLayout 0x0
name 0x1031bfd80 WRChapterData
baseMethods 0x102aedd90
entsize 12 (relative)
count 7
name 0x100d288 (0x103afb020)
types 0x949a69 (0x103437805) v64@0:8@16{_NSRange=QQ}24q40q48@56
imp 0xfd80a52c (0x1002f82cc)
name 0x101d21c (0x103b0afc0)
types 0x940008 (0x10342ddb0) v24@0:8@16
imp 0xfd80a694 (0x1002f8440)
name 0x10161e8 (0x103b03f98)
types 0x949a74 (0x103437828) B40@0:8{_NSRange=QQ}16Q32
imp 0xfd80c140 (0x1002f9ef8)
name 0x1078b54 (0x103b66910)
types 0x9400a4 (0x10342de64) @24@0:8@16
imp 0xfd80c154 (0x1002f9f18)
name 0x1036ed8 (0x103b24ca0)
types 0x94008a (0x10342de56) B32@0:8@16@24
imp 0xfd80cf10 (0x1002face0)
name 0x104396c (0x103b31740)
types 0x949a6a (0x103437842) {_NSRange=QQ}32@0:8q16@24
imp 0xfd80e084 (0x1002fbe60)
name 0x1024f08 (0x103b12ce8)
types 0x9422b4 (0x103430098) q32@0:8@16@24
imp 0xfd80e738 (0x1002fc520)
baseProtocols 0x0
ivars 0x0
weakIvarLayout 0x0
baseProperties 0x0
000000010383c6c8 0x103ba8d38
isa 0x103ba8d60
--
ivarLayout 0x1031c36ba
layout map 0x01 0xa2
name 0x1031c36a4 WRCoreTextLayoutFrame
baseMethods 0x102b1a760
entsize 12 (relative)
count 47
name 0x1004bd8 (0x103b1f340)
types 0x922b0e (0x10343d27a) @80@0:8^{__CTFramesetter=}16@24{_NSRange=QQ}32{CGRect={CGPoint=dd}{CGSize=dd}}48
imp 0xfdba1dec (0x1006bc55c)
name 0xff9e1c (0x103b14590)
types 0x9137ac (0x10342df24) d16@0:8
imp 0xfdba1ef4 (0x1006bc670)
name 0x10388c8 (0x103b53048)
types 0x9143b0 (0x10342eb34) v48@0:8{CGRect={CGPoint=dd}{CGSize=dd}}16
imp 0xfdba1ef0 (0x1006bc678)
name 0x101dd44 (0x103b384d0)
types 0x922b3b (0x10343d2cb) B48@0:8{_NSRange=QQ}16{_NSRange=QQ}32
imp 0xfdba2028 (0x1006bc7bc)
name 0xfffc48 (0x103b1a3e0)
types 0x9136d3 (0x10342de6f) B24@0:8@16
imp 0xfdba2034 (0x1006bc7d4)
name 0x1007a5c (0x103b22200)
types 0x917ade (0x103432286) B32@0:8{_NSRange=QQ}16
imp 0xfdba20d0 (0x1006bc87c)
name 0xfed5f0 (0x103b07da0)
types 0x922b3d (0x10343d2f1) @64@0:8^{__CTTypesetter=}16d24{_NSRange=QQ}32d48@56
imp 0xfdba218c (0x1006bc944)
name 0x100debc (0x103b28678)
types 0x9135e8 (0x10342dda8) @16@0:8
imp 0xfdba2f20 (0x1006bd6e4)
name 0xfec0d0 (0x103b06898)
types 0x91373d (0x10342df09) q16@0:8
imp 0xfdba4788 (0x1006bef58)
--
reserved 0x0
ivarLayout 0x0
name 0x1031c36a4 WRCoreTextLayoutFrame
baseMethods 0x102b1a650
entsize 12 (relative)
count 2
name 0xfecc98 (0x103b072f0)
types 0x922bcf (0x10343d22b) @64@0:8@16@24{_NSRange=QQ}32{_NSRange=QQ}48
imp 0xfdba9834 (0x1006c3e94)
name 0x10492bc (0x103b63920)
types 0x922bef (0x10343d257) B60@0:8@16@24{_NSRange=QQ}32q48B56
imp 0xfdba9a84 (0x1006c40f0)
baseProtocols 0x0
ivars 0x0
weakIvarLayout 0x0
baseProperties 0x0
000000010383d6e0 0x103bb2e28
isa 0x103bb2e50
superclass 0x0 _OBJC_CLASS_$_QMUINavigationButton
cache 0x0 __objc_empty_cache
vtable 0x0
data 0x103901990
flags 0x194 RO_HAS_CXX_STRUCTORS
instanceStart 8
instanceSize 56
reserved 0x0
ivarLayout 0x1031bcc1a
layout map 0x12
name 0x1031c36bd WRReviewNavigationButton
baseMethods 0x102b1a9a0
entsize 12 (relative)
count 15
name 0x10025a8 (0x103b1cf50)
--
reserved 0x0
ivarLayout 0x0
name 0x1031c405b DTCoreTextLayoutFrameAccessibilityElementGenerator
baseMethods 0x102b22c00
entsize 12 (relative)
count 7
name 0xfd6a98 (0x103af96a0)
types 0x90c852 (0x10342f45e) @40@0:8@16@24@?32
imp 0xfdc59bb0 (0x10077c7c0)
name 0xfd6a94 (0x103af96a8)
types 0x91ba43 (0x10343e65b) @48@0:8Q16@24@32@?40
imp 0xfdc59ccc (0x10077c8e8)
name 0xfeca30 (0x103b0f650)
types 0x91194f (0x103434573) v40@0:8@16Q24@?32
imp 0xfdc59ee0 (0x10077cb08)
name 0xfd6a64 (0x103af9690)
types 0x91ba40 (0x10343e670) @72@0:8@16{_NSRange=QQ}24@40@48@56@?64
imp 0xfdc5a1b0 (0x10077cde4)
name 0xfd6a50 (0x103af9688)
types 0x91ba5b (0x10343e697) @64@0:8@16{_NSRange=QQ}24@40@48@56
imp 0xfdc5a2d4 (0x10077cf14)
name 0x104f19c (0x103b71de0)
types 0x90c807 (0x10342f44f) @32@0:8@16@?24
imp 0xfdc5a468 (0x10077d0b4)
name 0xfeff18 (0x103b12b68)
types 0x914fd8 (0x103437c2c) {CGRect={CGPoint=dd}{CGSize=dd}}24@0:8@16
imp 0xfdc5a490 (0x10077d0e8)
baseProtocols 0x0
ivars 0x0
weakIvarLayout 0x0
baseProperties 0x0
Meta Class
isa 0x0 _OBJC_METACLASS_$_NSObject
--
reserved 0x0
ivarLayout 0x0
name 0x1031c405b DTCoreTextLayoutFrameAccessibilityElementGenerator
baseMethods 0x0
baseProtocols 0x0
ivars 0x0
weakIvarLayout 0x0
baseProperties 0x0
000000010383d9a0 0x103bb49a8
isa 0x103bb49d0
superclass 0x0 _OBJC_CLASS_$_UICollectionView
cache 0x0 __objc_empty_cache
vtable 0x0
data 0x10390e140
flags 0x194 RO_HAS_CXX_STRUCTORS
instanceStart 8
instanceSize 48
reserved 0x0
ivarLayout 0x1031bcd23
layout map 0x11
name 0x1031c408e WRDiscoverCollectionView
baseMethods 0x102b22c60
entsize 12 (relative)
count 20
name 0x1001838 (0x103b244a0)
types 0x90b2b0 (0x10342df1c) B16@0:8
imp 0xfdc5a9b8 (0x10077d628)
name 0x101ef44 (0x103b41bb8)
types 0x91ba42 (0x10343e6ba) v36@0:8Q16B24@?28
imp 0xfdc5aa5c (0x10077d6d8)
name 0x1049f78 (0x103b6cbf8)
types 0x90b1eb (0x10342de6f) B24@0:8@16
imp 0xfdc5abc0 (0x10077d848)
--
ivarLayout 0x1031c51df
layout map 0x12 0x11 0x21
name 0x1031c51cc WRChapterPageCount
baseMethods 0x102b2ed70
entsize 12 (relative)
count 29
name 0xfefcf8 (0x103b1ea70)
types 0x910f9d (0x10343fd19) @40@0:8@16Q24q32
imp 0xfdd88e88 (0x1008b7c08)
name 0x100981c (0x103b385a0)
types 0x908ad4 (0x10343785c) {_NSRange=QQ}24@0:8q16
imp 0xfdd892a4 (0x1008b8030)
name 0xff70c8 (0x103b25e58)
types 0x8ff014 (0x10342dda8) @16@0:8
imp 0xfdd89500 (0x1008b8298)
name 0x1023294 (0x103b52030)
types 0x8ff010 (0x10342ddb0) v24@0:8@16
imp 0xfdd89504 (0x1008b82a8)
name 0xfd4710 (0x103b034b8)
types 0x8feffc (0x10342dda8) @16@0:8
imp 0xfdd89504 (0x1008b82b4)
name 0x10190fc (0x103b47eb0)
types 0x8feff8 (0x10342ddb0) v24@0:8@16
imp 0xfdd89508 (0x1008b82c4)
name 0xfd4650 (0x103b03410)
types 0x8ff145 (0x10342df09) q16@0:8
imp 0xfdd89508 (0x1008b82d0)
name 0x1019084 (0x103b47e50)
types 0x8ff141 (0x10342df11) v24@0:8q16
imp 0xfdd8950c (0x1008b82e0)
name 0xfd0fb0 (0x103affd88)
types 0x8fefcc (0x10342dda8) @16@0:8
imp 0xfdd89510 (0x1008b82f0)
--
reserved 0x0
ivarLayout 0x0
name 0x1031c51cc WRChapterPageCount
baseMethods 0x102b2ece0
entsize 12 (relative)
count 11
name 0xff2708 (0x103b213f0)
types 0x8ff0bc (0x10342dda8) @16@0:8
imp 0xfddc1db4 (0x1008f0aa4)
name 0xfdab1c (0x103b09810)
types 0x8ff0b0 (0x10342dda8) @16@0:8
imp 0xfddc1dc4 (0x1008f0ac0)
name 0x103b978 (0x103b6a678)
types 0x8ff0a4 (0x10342dda8) @16@0:8
imp 0xfddc1dc4 (0x1008f0acc)
name 0x10063cc (0x103b350d8)
types 0x8ff098 (0x10342dda8) @16@0:8
imp 0xfddc1dc4 (0x1008f0ad8)
name 0x10046e8 (0x103b33400)
types 0x8ff08c (0x10342dda8) @16@0:8
imp 0xfddc1dc4 (0x1008f0ae4)
name 0xfdc50c (0x103b0b230)
types 0x8ff080 (0x10342dda8) @16@0:8
imp 0xfddc1df0 (0x1008f0b1c)
name 0x10027f0 (0x103b31520)
types 0x910fe5 (0x10343fd19) @40@0:8@16Q24q32
imp 0xfdd89030 (0x1008b7d68)
name 0xfd982c (0x103b08568)
types 0x9006b8 (0x10342f3f8) Q24@0:8@16
imp 0xfdd89128 (0x1008b7e6c)
name 0xfee0c0 (0x103b1ce08)
types 0x8ff05c (0x10342dda8) @16@0:8
imp 0xfdd892d4 (0x1008b8024)
--
ivarLayout 0x1031c6257
layout map 0x04 0x15 0x28
name 0x1031c624c WRPageView
baseMethods 0x102b3b018
entsize 12 (relative)
count 104
name 0xfe4310 (0x103b1f330)
types 0x9064ba (0x1034414de) @64@0:8{CGRect={CGPoint=dd}{CGSize=dd}}16Q48q56
imp 0xfde85418 (0x1009c0440)
name 0x10294e4 (0x103b64510)
types 0x8f2eec (0x10342df1c) B16@0:8
imp 0xfde854e0 (0x1009c0514)
name 0x102a918 (0x103b65950)
types 0x8f2d7f (0x10342ddbb) v16@0:8
imp 0xfde85584 (0x1009c05c4)
name 0xfe0064 (0x103b1b0a8)
types 0x8f2d73 (0x10342ddbb) v16@0:8
imp 0xfde85704 (0x1009c0750)
name 0xfe0068 (0x103b1b0b8)
types 0x8f2d67 (0x10342ddbb) v16@0:8
imp 0xfde85770 (0x1009c07c8)
name 0xfe2eec (0x103b1df48)
types 0x8f2d5b (0x10342ddbb) v16@0:8
imp 0xfde85798 (0x1009c07fc)
name 0xfe0048 (0x103b1b0b0)
types 0x8f2d4f (0x10342ddbb) v16@0:8
imp 0xfde85af0 (0x1009c0b60)
name 0xfe2ac4 (0x103b1db38)
types 0x8f2d43 (0x10342ddbb) v16@0:8
imp 0xfde85ca8 (0x1009c0d24)
name 0xfe66d8 (0x103b21758)
types 0x8f2d24 (0x10342dda8) @16@0:8
imp 0xfde85df8 (0x1009c0e80)
--
reserved 0x0
ivarLayout 0x0
name 0x1031c624c WRPageView
baseMethods 0x102b3b000
entsize 12 (relative)
count 1
name 0xff1cb8 (0x103b2ccc0)
types 0x8f6062 (0x10343106e) d32@0:8@16@24
imp 0xfde86674 (0x1009c1684)
baseProtocols 0x0
ivars 0x0
weakIvarLayout 0x0
baseProperties 0x0
000000010383e338 0x103bba998
isa 0x103bba9c0
superclass 0x103bc8688
cache 0x0 __objc_empty_cache
vtable 0x0
data 0x103932f00
flags 0x194 RO_HAS_CXX_STRUCTORS
instanceStart 16
instanceSize 32
reserved 0x0
ivarLayout 0x1031bc967
layout map 0x02
name 0x1031c625b WRConfirmH5PayViewController
baseMethods 0x102b3b500
entsize 12 (relative)
count 15
name 0x10011c8 (0x103b3c6d0)
types 0x8f28af (0x10342ddbb) v16@0:8
imp 0xfde90aa0 (0x1009cbfb0)
name 0xfedbb4 (0x103b290c8)
--
ivarLayout 0x1031c646d
layout map 0x44 0x81 0x31
name 0x1031c6457 DTCoreTextLayoutFrame
baseMethods 0x102b3ca60
entsize 12 (relative)
count 54
name 0xfc84e8 (0x103b04f50)
types 0x8f4143 (0x103430baf) q32@0:8{CGPoint=dd}16
imp 0xfdd403fc (0x10087ce6c)
name 0xfcc3a4 (0x103b08e18)
types 0x900b89 (0x10343d601) {CGRect={CGPoint=dd}{CGSize=dd}}24@0:8q16
imp 0xfdd406b8 (0x10087d134)
name 0xfe2878 (0x103b1f2f8)
types 0x904d71 (0x1034417f5) @72@0:8{CGRect={CGPoint=dd}{CGSize=dd}}16@48{_NSRange=QQ}56
imp 0xfdeb5da4 (0x1009f282c)
name 0xfe2864 (0x103b1f2f0)
types 0x8fdd25 (0x10343a7b5) @56@0:8{CGRect={CGPoint=dd}{CGSize=dd}}16@48
imp 0xfdeb5f20 (0x1009f29b4)
name 0xfcce70 (0x103b09908)
types 0x8f131f (0x10342ddbb) v16@0:8
imp 0xfdeb5f20 (0x1009f29c0)
name 0xfce904 (0x103b0b3a8)
types 0x8f1300 (0x10342dda8) @16@0:8
imp 0xfdeb5f6c (0x1009f2a18)
name 0xfbacd8 (0x103af7788)
types 0x900b34 (0x10343d5e8) {CGPoint=dd}32@0:8@16@24
imp 0xfdeb5fa4 (0x1009f2a5c)
name 0xfbacdc (0x103af7798)
types 0x8f2c9e (0x10342f75e) d24@0:8@16
imp 0xfdeb6274 (0x1009f2d38)
name 0xfbacc8 (0x103af7790)
types 0x900b1c (0x10343d5e8) {CGPoint=dd}32@0:8@16@24
imp 0xfdeb6364 (0x1009f2e34)
--
reserved 0x0
ivarLayout 0x0
name 0x1031c6457 DTCoreTextLayoutFrame
baseMethods 0x102b3ca40
entsize 12 (relative)
count 2
name 0x101fb08 (0x103b5c550)
types 0x8f13d1 (0x10342de1d) v20@0:8B16
imp 0xfdeba310 (0x1009f6d60)
name 0x10273a4 (0x103b63df8)
types 0x8f14c4 (0x10342df1c) B16@0:8
imp 0xfdeba310 (0x1009f6d6c)
baseProtocols 0x0
ivars 0x0
weakIvarLayout 0x0
baseProperties 0x0
000000010383e3c8 0x103bbaf38
isa 0x103bbaf60
superclass 0x0 _OBJC_CLASS_$_UIView
cache 0x0 __objc_empty_cache
vtable 0x0
data 0x103935920
flags 0x194 RO_HAS_CXX_STRUCTORS
instanceStart 8
instanceSize 280
reserved 0x0
ivarLayout 0x1031c64b4
layout map 0x02 0xb9
name 0x1031c647c WHLyricPageTextView
baseMethods 0x102b3ccf0
entsize 12 (relative)
count 60
name 0xfe0258 (0x103b1cf50)
--
reserved 0x0
ivarLayout 0x0
name 0x1031c67a2 WREpubTypesetter
baseMethods 0x0
baseProtocols 0x0
ivars 0x0
weakIvarLayout 0x0
baseProperties 0x0
Meta Class
isa 0x0 _OBJC_METACLASS_$_NSObject
superclass 0x0 _OBJC_METACLASS_$_NSObject
cache 0x0 __objc_empty_cache
vtable 0x0
data 0x10393a6e8
flags 0x91 RO_META
instanceStart 40
instanceSize 40
reserved 0x0
ivarLayout 0x0
name 0x1031c67a2 WREpubTypesetter
baseMethods 0x102b40010
entsize 12 (relative)
count 9
name 0xfbd748 (0x103afd760)
types 0x902393 (0x1034423af) @92@0:8@16Q24B32B36B40@44@52q60^q68^B76@84
imp 0xfdf0898c (0x100a489ac)
name 0x102d67c (0x103b6d6a0)
types 0x9023b2 (0x1034423da) v52@0:8@16@24@32B40B44B48
imp 0xfdf09b70 (0x100a49b9c)
name 0xfc41f8 (0x103b04228)
types 0x9023c0 (0x1034423f4) v40@0:8@16^B24^B32
imp 0xfdf0a380 (0x100a4a3b8)
name 0xfe4fbc (0x103b24ff8)
types 0x8ede2f (0x10342de6f) B24@0:8@16
imp 0xfdf0a5b8 (0x100a4a5fc)
name 0xfc9180 (0x103b091c8)
types 0x8ede18 (0x10342de64) @24@0:8@16
imp 0xfdf0a644 (0x100a4a694)
name 0xfc917c (0x103b091d0)
types 0x8ede0c (0x10342de64) @24@0:8@16
imp 0xfdf0a754 (0x100a4a7b0)
name 0xfc9178 (0x103b091d8)
types 0x8ede97 (0x10342defb) @28@0:8@16B24
imp 0xfdf0a864 (0x100a4a8cc)
name 0xfe3dbc (0x103b23e28)
types 0x8eddff (0x10342de6f) B24@0:8@16
imp 0xfdf0aa10 (0x100a4aa84)
name 0xfc9170 (0x103b091e8)
types 0x8edd93 (0x10342de0f) @32@0:8@16@24
imp 0xfdf0aa70 (0x100a4aaf0)
--
ivarLayout 0x1031bc9ce
layout map 0x13
name 0x1031c67c4 WRCoreTextLayouter
baseMethods 0x102b40160
entsize 12 (relative)
count 15
name 0xfbd6f8 (0x103afd860)
types 0x8edc3c (0x10342dda8) @16@0:8
imp 0xfdf0b9a8 (0x100a4bb18)
name 0xfde32c (0x103b1e4a0)
types 0x8edc97 (0x10342de0f) @32@0:8@16@24
imp 0xfdf0ba50 (0x100a4bbcc)
name 0xfd2a90 (0x103b12c10)
types 0x8ef376 (0x10342f4fa) ^{__CTFramesetter=}16@0:8
imp 0xfdf0badc (0x100a4bc64)
name 0xfc7b5c (0x103b07ce8)
types 0x902277 (0x103442407) @72@0:8{_NSRange=QQ}16{CGRect={CGPoint=dd}{CGSize=dd}}32Q64
imp 0xfdf0bb4c (0x100a4bce0)
name 0x1004f38 (0x103b450d0)
types 0x8edc14 (0x10342ddb0) v24@0:8@16
imp 0xfdf0bc0c (0x100a4bdac)
name 0xff1274 (0x103b31418)
types 0x8f7c6e (0x103437e16) @40@0:8{_NSRange=QQ}16@32
imp 0xfdf0c034 (0x100a4c1e0)
name 0xff1270 (0x103b31420)
types 0x8f7c62 (0x103437e16) @40@0:8{_NSRange=QQ}16@32
imp 0xfdf0c1ac (0x100a4c364)
name 0xfd0444 (0x103b10600)
types 0x8edbe8 (0x10342dda8) @16@0:8
imp 0xfdf0c410 (0x100a4c5d4)
name 0xfff160 (0x103b3f328)
types 0x902277 (0x103442443) @84@0:8@16{CGRect={CGPoint=dd}{CGSize=dd}}24Q56Q64B72@76
imp 0xfdf0c420 (0x100a4c5f0)
--
reserved 0x0
ivarLayout 0x0
name 0x1031c67c4 WRCoreTextLayouter
baseMethods 0x102b40118
entsize 12 (relative)
count 5
name 0x1028e50 (0x103b68f70)
types 0x8edd4b (0x10342de6f) B24@0:8@16
imp 0xfdf0b47c (0x100a4b5a4)
name 0xfe54fc (0x103b25628)
types 0x8edd3f (0x10342de6f) B24@0:8@16
imp 0xfdf0b7cc (0x100a4b900)
name 0x10300e8 (0x103b70220)
types 0x8f10a1 (0x1034311dd) v28@0:8B16@20
imp 0xfdf0b8b8 (0x100a4b9f8)
name 0xfc6ebc (0x103b07000)
types 0x8edd1c (0x10342de64) @24@0:8@16
imp 0xfdf0bd08 (0x100a4be54)
name 0xfc6eb8 (0x103b07008)
types 0x8edd10 (0x10342de64) @24@0:8@16
imp 0xfdf0c020 (0x100a4c178)
baseProtocols 0x0
ivars 0x0
weakIvarLayout 0x0
baseProperties 0x0
000000010383e4b8 0x103bbb898
isa 0x103bbb8c0
superclass 0x103bb0ee8
cache 0x0 __objc_empty_cache
vtable 0x0
data 0x10393ab58
flags 0x194 RO_HAS_CXX_STRUCTORS
instanceStart 16
--
ivarLayout 0x1031c75f6
layout map 0x02 0x2f 0x01 0x33 0x11
name 0x1031c75c3 DTHTMLAttributedStringBuilder
baseMethods 0x102b4aa58
entsize 12 (relative)
count 25
name 0xfd4958 (0x103b1f3b8)
types 0x8eb102 (0x103435b66) @40@0:8@16@24^@32
imp 0xfdffe5dc (0x100b49044)
name 0xfacf14 (0x103af7980)
types 0x8e34ac (0x10342df1c) B16@0:8
imp 0xfdffe7dc (0x100b49250)
name 0xfc8d80 (0x103b137f8)
types 0x8e332c (0x10342dda8) @16@0:8
imp 0xfdfff100 (0x100b49b80)
name 0xfae034 (0x103af8ab8)
types 0x8e3333 (0x10342ddbb) v16@0:8
imp 0xfdfff124 (0x100b49bb0)
name 0xfae020 (0x103af8ab0)
types 0x8e3327 (0x10342ddbb) v16@0:8
imp 0xfdffff00 (0x100b4a998)
name 0xfe7d3c (0x103b327d8)
types 0x8f88ae (0x10344334e) v56@0:8@16@24@32{CGPoint=dd}40
imp 0xfe000968 (0x100b4b40c)
name 0xfe7d20 (0x103b327c8)
types 0x8e337c (0x10342de28) v32@0:8@16@24
imp 0xfe000efc (0x100b4b9ac)
name 0xfe7be4 (0x103b32698)
types 0x8e32f8 (0x10342ddb0) v24@0:8@16
imp 0xfe0010c8 (0x100b4bb84)
name 0xfe7d30 (0x103b327f0)
types 0x8e3364 (0x10342de28) v32@0:8@16@24
imp 0xfe0015b8 (0x100b4c080)
--
reserved 0x0
ivarLayout 0x0
name 0x1031c75c3 DTHTMLAttributedStringBuilder
baseMethods 0x0
baseProtocols 0x10394b7f0
count 1
list[0] 0x103c1bed0
isa 0x0
name 0x1031c75e1 DTHTMLParserDelegate
protocols 0x10394b690
count 1
list[0] 0x103c15580
isa 0x0
name 0x1031bc9c5 NSObject
protocols 0x0
instanceMethods 0x103884670
entsize 24
count 19
name 0x103348607 isEqual:
types 0x10342de6f B24@0:8@16
imp 0x0
name 0x1032e9bde class
types 0x10342df44 #16@0:8
imp 0x0
name 0x1033a90f1 self
types 0x10342dda8 @16@0:8
imp 0x0
name 0x1033780e4 performSelector:
types 0x10342df4c @24@0:8:16
imp 0x0
name 0x103378163 performSelector:withObject:
types 0x10342df57 @32@0:8:16@24
imp 0x0
--
ivarLayout 0x1031c0ccf
layout map 0x61
name 0x1031cb9a9 WRPageViewController
baseMethods 0x102b81cf8
entsize 12 (relative)
count 73
name 0xfbd2d0 (0x103b3efd0)
types 0x8ac0b7 (0x10342ddbb) v16@0:8
imp 0xfe4dedec (0x101060af4)
name 0xff0bac (0x103b728b8)
types 0x8c707f (0x103448d8f) v72@0:8@16q24{CGPoint=dd}32q48B56B60@?64
imp 0xfe4dedf0 (0x101060b04)
name 0xfdf8d0 (0x103b615e8)
types 0x8b32e5 (0x103435001) v44@0:8@16q24B32@?36
imp 0xfe4df5ec (0x10106130c)
name 0xf9d194 (0x103b1eeb8)
types 0x8bdff1 (0x10343fd19) @40@0:8@16Q24q32
imp 0xfe4df984 (0x1010616b0)
name 0xfaf870 (0x103b315a0)
types 0x8ac1d5 (0x10342df09) q16@0:8
imp 0xfe4dfba8 (0x1010618e0)
name 0xff0084 (0x103b71dc0)
types 0x8ac07b (0x10342ddbb) v16@0:8
imp 0xfe4dfbac (0x1010618f0)
name 0xff0038 (0x103b71d80)
types 0x8ac0d1 (0x10342de1d) v20@0:8B16
imp 0xfe4dfcb4 (0x101061a04)
name 0xf87bb4 (0x103b09908)
types 0x8ac063 (0x10342ddbb) v16@0:8
imp 0xfe4dfcf0 (0x101061a4c)
name 0xf78590 (0x103afa2f0)
types 0x8ac057 (0x10342ddbb) v16@0:8
imp 0xfe4dfe08 (0x101061b70)
--
reserved 0x0
ivarLayout 0x0
name 0x1031cb9a9 WRPageViewController
baseMethods 0x102b81c98
entsize 12 (relative)
count 7
name 0xf9f110 (0x103b20db0)
types 0x8ac117 (0x10342ddbb) v16@0:8
imp 0xfe4dc830 (0x10105e4d8)
name 0xfb0c9c (0x103b32948)
types 0x8ac10b (0x10342ddbb) v16@0:8
imp 0xfe4dc99c (0x10105e650)
name 0xfb0c88 (0x103b32940)
types 0x8ac0ff (0x10342ddbb) v16@0:8
imp 0xfe4dd264 (0x10105ef24)
name 0xfb0c74 (0x103b32938)
types 0x8ac0f3 (0x10342ddbb) v16@0:8
imp 0xfe4ddb28 (0x10105f7f4)
name 0xf89a18 (0x103b0b6e8)
types 0x8c708d (0x103448d61) B48@0:8@16@24B32B36B40B44
imp 0xfe4de164 (0x10105fe3c)
name 0xfa437c (0x103b26058)
types 0x8ae3b8 (0x103430098) q32@0:8@16@24
imp 0xfe4dedac (0x101060a90)
name 0xfe3180 (0x103b64e68)
types 0x8ac0c4 (0x10342ddb0) v24@0:8@16
imp 0xfe4e15b8 (0x1010632a8)
baseProtocols 0x1039a0c00
count 4
list[0] 0x103c17bc8
isa 0x0
name 0x1031bf992 UIPageViewControllerDataSource
protocols 0x1038b4590
+181
View File
@@ -0,0 +1,181 @@
# WXRead/resources 文件说明
## 目录结构
```
resources/
├── js/
│ ├── Text/ # 文本编辑/展示相关
│ │ ├── WeReadApi.js
│ │ ├── rich_editor.js
│ │ ├── rich_display.js
│ │ ├── correctArticle.js
│ │ └── style_html.js
│ ├── Readability.js
│ ├── weread-highlighter.js
│ ├── rangy-core.js
│ ├── rangy-classapplier.js
│ ├── rangy-highlighter.js
│ ├── rangy-textrange.js
│ ├── highlight.min.js
│ ├── cssInjector.js
│ └── patch_6.2.1.4.js
├── css/
│ ├── Style/
│ │ ├── style.css
│ │ ├── preview.css
│ │ └── normalize.css
│ ├── default.css
│ ├── dark.css
│ ├── replace.css
│ ├── replaceForLatinLanguageBook.css
│ ├── replaceForMPChapter.css
│ ├── MPExtra.css
│ ├── MediaPlatform.css
│ ├── WREpubMPVideo.css
│ └── xcode.min.css
└── fonts/ # 字体文件
```
---
## JS 文件
### 核心功能
#### `Readability.js`
Arc90 的 Readability 库 (v1.7.1),用于从网页中提取正文内容。通过正则匹配和 DOM 分析算法,从杂乱的 HTML 中识别出文章主体,过滤导航、广告、侧栏等非内容元素。是读书解析公众号文章内容的基础依赖。
#### `weread-highlighter.js`
读书的划线/高亮核心模块。依赖 Rangy 库实现以下功能:
- 初始化 Rangy 高亮器,注册 `highlight`(划线)、`review`(自己的想法)、`friend-review`(好友想法)、`reference`(想法圈划线)、`tts`(朗读高亮)等样式
- 监听 `selectionchange` 事件,将选区位置信息通过 `wereadBridge` 通知原生层
- 提供 `getShareAbstractText()` 从文章段落中提取分享摘要文本
- 字体缩放 `changeFontSizeWithScale()` 支持
- 图片点击事件绑定和 H5 链接转换
#### `cssInjector.js`
CSS 动态注入/移除工具。提供 `inject(json)``remove(identifier)` 两个方法,通过创建/删除 `<style>` 元素实现运行时样式切换。
#### `patch_6.2.1.4.js`
版本补丁脚本。修改 `WRAboutViewController``viewDidLoad`,将版权信息文案中的 "rights" 和 "reserved" 首字母改为大写。
### Rangy 库系列 (第三方)
#### `rangy-core.js`
Rangy 核心库 (v1.3.0, MIT License)。跨浏览器的 Range 和 Selection 操作库,提供统一的 API 处理 DOM 选区。
#### `rangy-classapplier.js`
Rangy ClassApplier 模块。对 Range 和 Selection 添加/移除/切换 CSS 类,是实现划线高亮的基础。
#### `rangy-highlighter.js`
Rangy Highlighter 模块。依赖 core 和 ClassApplier,提供更高层的文本高亮 API,支持序列化/反序列化高亮位置。
#### `rangy-textrange.js`
Rangy TextRange 模块。支持按字符/词偏移移动选区边界、文本搜索、选区保存/恢复等功能。
#### `highlight.min.js`
highlight.js 代码语法高亮库(压缩版)。用于在阅读器中高亮显示 `<pre><code>` 代码块。
### Text 子目录
#### `Text/WeReadApi.js`
读书 WebView Bridge。定义 `WeReadBridge` 类,通过隐藏 iframe + 自定义 URL scheme`wereadapijs://`)实现 JS 与原生层的双向通信。支持回调机制和可用功能查询。
#### `Text/rich_editor.js`
富文本编辑器核心模块。定义 `RE` 对象,管理编辑器 DOM 引用和编辑操作。
#### `Text/rich_display.js`
富文本展示模块。定义 `RDisplay` 对象,处理展示模式下的交互(书籍卡片触摸反馈、点击高亮等)。
#### `Text/correctArticle.js`
文章内容校正脚本。用于对公众号文章的 HTML 结构进行清洗和修正。
#### `Text/style_html.js`
JS Beautifier 库。HTML/JS/CSS 代码格式化工具(MIT License),用于代码块的美化显示。
---
## CSS 文件
### 默认/基础样式
#### `default.css`
公众号文章的基础阅读样式。覆盖微信原生样式,定义:
- 文章区域布局(`rich_media_area_primary`padding、字号 18px、行高 30px
- 标题样式(`rich_media_title`24px/36px
- 内容区域溢出处理、blockquote 样式
- 隐藏工具栏(`rich_media_tool`
- 划线/想法/高亮样式(`.highlight` 橙色、`.review` 红色虚线、`.friend-review` 灰色虚线)
- 隐藏 iPad 上的 PC 二维码
- TTS 朗读高亮(`.tts` 蓝色背景)
- 公众号图片链接禁用点击
#### `dark.css`
深色模式样式。全局覆盖为纯黑背景(`rgba(0,0,0,1)`),文字颜色设为灰色(`rgba(180,180,182,1)`),链接为蓝色,blockquote 为深色背景。适配搜狗百科和百度搜索的特殊元素。
#### `xcode.min.css`
代码语法高亮配色方案(highlight.js 的 Xcode 主题),白色背景,关键字紫色、字符串红色、注释绿色等。
### 内容替换样式
#### `replace.css`
EPUB 书籍的主要排版样式。定义:
- 代码块字体(Menlo/Consolas)、版权标题、图片说明文字
- 六级标题样式(`.firstTitle` ~ `.sixthTitle`1.0em~1.5em
- 首字下沉(`.ftext`)、引用(`.conQuot`)、副标题(`.subHead`
- 图片垂直居中(`wr-vertical-center-style`)、翻译行高
- 章节工具栏高度(`.book-chapter-tool`78px
#### `replaceForLatinLanguageBook.css`
拉丁语系书籍的排版样式。与 `replace.css` 类似,但标题使用 `Source Han Serif CN` 字体并加粗(而非 `SourceHanSerifCN-Medium` normal),适配西文排版习惯。
#### `replaceForMPChapter.css`
公众号文集(小文章)的排版样式。在 `replace.css` 基础上增加了:
- `.weread-page-relate` 跨页关联标记
- `.chapter-reward` 打赏区域(120px
- `.chapter-tool` 工具栏(140px
- `.re_bookItem` 书籍卡片间距
- `img[data-image-size="large"]` 大图全宽
- `.unsupported_iframe` 不支持控件的提示样式
- 强制清除 `<p>``<strong>``<span>``<section>` 的背景色
### 公众号专用样式
#### `MPExtra.css`
公众号文章的完整样式表(从微信 CSS 复制并修改)。包含:
- 微信 UI 组件样式(`.rich_media_*``.weui-*`
- 小程序卡片样式(`.weapp_*`
- GIF 图片播放提示
- 全局消息提示栏
- 对话框组件(`.weui-dialog`
- 公众号音频播放器样式(`.audio_*`,含 Retina 适配)
- 被删公众号链接隐藏处理
- iPhone 横屏适配
#### `MediaPlatform.css`
公众号文章阅读的精简样式。覆盖微信原生样式中的关键属性:背景色、字号、行高、blockquote、QQ 音乐区域、分享图片等。主要用于非文集场景的公众号阅读。
#### `WREpubMPVideo.css`
腾讯视频播放器的自定义样式。完全重写视频控制器 UI:
- 自定义播放/暂停、全屏按钮图标(使用 CDN sprite 图)
- 进度条、时间显示器布局
- 隐藏广告和下载提示
- 响应式适配(iPhone 6/6P 尺寸)
### Style 子目录
#### `Style/style.css`
笔记/想法的编辑和展示样式(EPUB 基准 1em=16px)。定义:
- 编辑器容器(`.re`)基础样式
- 段落、标题、列表、blockquote 样式
- 书籍卡片组件(`.re_bookItem`,含 Retina 边框)
- 图片样式(`.re_img``.bodyPic`
- QQ 表情样式
- 深色模式(`.re_Night`
#### `Style/preview.css`
想法/笔记的预览样式。与 `style.css` 类似但适用于阅读展示场景,字号 18px,行高 30px,使用 `-apple-system` 字体。
#### `Style/normalize.css`
normalize.css v3.0.2MIT License)。标准化各浏览器的默认样式差异。

Some files were not shown because too many files have changed in this diff Show More