410 lines
13 KiB
Markdown
410 lines
13 KiB
Markdown
# Phase 1 Audit: Current Reflowable Engine, Boundaries, and Stable Contracts
|
||
|
||
**Phase:** 01-current-engine-boundaries
|
||
**Date:** 2026-05-21
|
||
**Purpose:** 固化当前 `.textReflowable` 旧引擎的真实调用链、模式分流边界、以及本次重构不能碰的外层契约。
|
||
|
||
## 1. Executive Conclusion
|
||
|
||
当前仓库并不是“所有 EPUB 都走 `WKWebView`”。真实结构是三分流:
|
||
|
||
1. `webFixedLayout`:Fixed Layout EPUB,继续走 `WKWebView`
|
||
2. `webInteractive`:带脚本/多媒体/iframe/表单等交互内容的 EPUB,继续走 `WKWebView`
|
||
3. `textReflowable`:普通 reflowable EPUB,走 **DTCoreText -> `NSAttributedString` -> CoreText 分页 -> 原生文本页视图**
|
||
|
||
因此,本次“旧引擎直接演进”的主战场已经存在,且就在 `EPUBTextRendering`。Phase 2/3 不需要新起一套阅读器,也不需要改 `RDReaderView`;要做的是升级当前 native reflowable 的 typesetter、属性体系和分页能力。
|
||
|
||
## 2. Entry Path: URL -> Reader Controller
|
||
|
||
入口在 `Sources/RDReaderView/RDURLReaderController.swift`。
|
||
|
||
### 2.1 `.epub` 入口
|
||
|
||
`RDURLReaderController.embedReaderController()` 对扩展名做分支:
|
||
|
||
- 扩展名是 `epub`
|
||
- 直接构造 `RDEPUBReaderController(epubURL:configuration:)`
|
||
- 把 reader controller 作为子控制器嵌入当前界面
|
||
|
||
这里没有直接决定 native 还是 web;它只负责把 `.epub` 交给 `RDEPUBReaderController`。
|
||
|
||
### 2.2 `.txt` 入口
|
||
|
||
TXT 则走 `RDPlainTextBookBuilder` 构造 `RDEPUBTextBook`,再以 external TextBook 模式交给 `RDEPUBReaderController`。这条链路与 EPUB 的原生文本展示在 UI 层会合,但不参与 EPUB reading profile 判定。
|
||
|
||
## 3. Reading Profile Split
|
||
|
||
模式分流在 `Sources/RDReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift`。
|
||
|
||
### 3.1 判定规则
|
||
|
||
`RDEPUBParser.readingProfile()` 的真实逻辑是:
|
||
|
||
- `metadata.layout == .fixed` -> `.webFixedLayout`
|
||
- 否则,如果 `hasInteractiveContent() == true` -> `.webInteractive`
|
||
- 否则 -> `.textReflowable`
|
||
|
||
### 3.2 `hasInteractiveContent()` 的判定来源
|
||
|
||
`hasInteractiveContent()` 会检查两类信号:
|
||
|
||
- manifest 中是否出现 JS 相关 media type 或 `scripted` property
|
||
- spine HTML 中是否出现交互模式特征
|
||
|
||
具体 regex 关注:
|
||
|
||
- `<script>`
|
||
- `<iframe>`
|
||
- `<video>`
|
||
- `<audio>`
|
||
- `<canvas>`
|
||
- `<svg>`
|
||
- `<form>`
|
||
- `onload` / `onclick` / `touchstart` 等内联事件
|
||
- `hype_generated_script`
|
||
- `swiper`
|
||
- `webview`
|
||
|
||
这意味着 fixed layout 与 interactive EPUB 的 WebView 边界不是概念约束,而是已经在 parser 层具备明确判定逻辑。
|
||
|
||
## 4. `.textReflowable` Real Call Chain
|
||
|
||
当前 native reflowable 的真实调用链如下:
|
||
|
||
```text
|
||
RDURLReaderController
|
||
-> RDEPUBReaderController(epubURL:)
|
||
-> RDEPUBParser
|
||
-> RDEPUBPublication
|
||
-> RDEPUBParser.readingProfile()
|
||
-> .textReflowable
|
||
-> RDEPUBReaderController.paginatePublication()
|
||
-> resolvedTextRenderer()
|
||
-> RDEPUBDTCoreTextRenderer
|
||
-> RDEPUBTextBookBuilder.build()
|
||
-> renderChapter(html:baseURL:style:)
|
||
-> RDEPUBRenderedChapterContent
|
||
-> ss_pageRanges(size:)
|
||
-> RDEPUBTextBook / RDEPUBTextChapter / RDEPUBTextPage
|
||
-> RDEPUBTextContentView
|
||
-> RDReaderView
|
||
```
|
||
|
||
### 4.1 Branch point in `RDEPUBReaderController`
|
||
|
||
`Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift` 的 `paginatePublication(restoreLocation:)` 是当前核心分支点:
|
||
|
||
- 如果 `publication.readingProfile == .textReflowable`
|
||
- 构造 renderer:`resolvedTextRenderer()`
|
||
- 当前默认返回 `RDEPUBDTCoreTextRenderer()`
|
||
- 构造 builder:`RDEPUBTextBookBuilder(renderer:)`
|
||
- 计算 `pageSize` 和 `RDEPUBTextRenderStyle`
|
||
- 在后台线程执行 `builder.build(...)`
|
||
- 完成后 `applyTextBook(...)`,最终由 `RDReaderView` 刷新内容
|
||
|
||
- 如果不是 `.textReflowable`
|
||
- fixed layout:直接创建 fixed snapshot
|
||
- reflowable-web / interactive:使用 `RDEPUBPaginator`
|
||
|
||
结论:native reflowable 与 WebView 路径已经在 reader controller 内部彻底分开。
|
||
|
||
## 5. Render Stage: `RDEPUBDTCoreTextRenderer`
|
||
|
||
`Sources/RDReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift` 是当前原生章节渲染入口。
|
||
|
||
### 5.1 输入
|
||
|
||
`renderChapter(html:baseURL:style:)` 接收:
|
||
|
||
- `html: String`
|
||
- `baseURL: URL?`
|
||
- `style: RDEPUBTextRenderStyle`
|
||
|
||
### 5.2 主要处理步骤
|
||
|
||
1. `RDEPUBTextRendererSupport.injectFragmentMarkers(into:)`
|
||
- 先往 HTML 注入 fragment marker
|
||
- 为后续目录/定位/fragment offset 映射保留锚点
|
||
|
||
2. 把 HTML 转为 UTF-8 `Data`
|
||
|
||
3. 调用 `DTHTMLAttributedStringBuilder`
|
||
- 通过 `makeAttributedString(from:baseURL:style:)`
|
||
- options 由 `dtOptions(baseURL:style:)` 提供
|
||
|
||
4. 如果 DTCoreText 构建失败
|
||
- 回退到 `fallbackRenderedContent(for:style:)`
|
||
|
||
5. 对生成的 attributed string 做后处理
|
||
- `extractFragmentOffsets(from:)`
|
||
- `normalizeReadingAttributes(in:style:)`
|
||
|
||
6. 返回 `RDEPUBRenderedChapterContent`
|
||
- `attributedString`
|
||
- `fragmentOffsets`
|
||
|
||
### 5.3 当前 `dtOptions` 的能力边界
|
||
|
||
当前 options 只有基础排版参数:
|
||
|
||
- `DTDefaultFontFamily`
|
||
- `DTDefaultFontName`
|
||
- `DTDefaultFontSize`
|
||
- `DTDefaultLineHeightMultiplier`
|
||
- `DTDefaultTextColor`
|
||
- `NSBaseURLDocumentOption`
|
||
- `DTUseiOS6Attributes`
|
||
|
||
这说明当前旧引擎的 renderer 仍然是“简单 DTCoreText builder + 少量 options”模型,还没有形成 WXRead 风格的多层 stylesheet / 自定义属性 / 页面级后处理体系。
|
||
|
||
## 6. Pagination Stage: `RDEPUBTextBookBuilder`
|
||
|
||
`Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift` 当前负责把章节渲染结果组织为 book/chapter/page 结构。
|
||
|
||
### 6.1 Chapter iteration
|
||
|
||
`build(parser:publication:pageSize:style:)` 会:
|
||
|
||
- 遍历 `publication.spine.enumerated()`
|
||
- 只处理 `linear == true`
|
||
- 只处理 `html` / `xhtml` 类型
|
||
- 从 parser 读取 `rawHTML`
|
||
- 计算章节标题
|
||
- `normalizeHTML(rawHTML)`
|
||
- 调用 renderer 生成 `RDEPUBRenderedChapterContent`
|
||
|
||
### 6.2 Base URL behavior
|
||
|
||
builder 把每个 spine item 的章节目录作为 `baseURL`:
|
||
|
||
- `parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent()`
|
||
|
||
这意味着当前图片/CSS/相对资源的解析锚点已经是“章节所在目录”,这是 Phase 2 引入更完整 CSS 层和资源解析时必须保留的基础约束。
|
||
|
||
### 6.3 Data model produced
|
||
|
||
builder 生成以下原生分页数据结构:
|
||
|
||
- `RDEPUBTextBook`
|
||
- `chapters: [RDEPUBTextChapter]`
|
||
- `pages: [RDEPUBTextPage]`
|
||
|
||
- `RDEPUBTextChapter`
|
||
- `chapterIndex`
|
||
- `spineIndex`
|
||
- `href`
|
||
- `title`
|
||
- `attributedContent`
|
||
- `fragmentOffsets`
|
||
- `pages`
|
||
|
||
- `RDEPUBTextPage`
|
||
- `absolutePageIndex`
|
||
- `chapterIndex`
|
||
- `spineIndex`
|
||
- `href`
|
||
- `chapterTitle`
|
||
- `pageIndexInChapter`
|
||
- `totalPagesInChapter`
|
||
- `content`
|
||
- `contentRange`
|
||
- `pageStartOffset`
|
||
- `pageEndOffset`
|
||
|
||
这些模型就是当前原生引擎与 UI/状态层的契约面。
|
||
|
||
## 7. Current Core Pagination Primitive
|
||
|
||
当前分页核心在 `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPaginationSupport.swift`:
|
||
|
||
- 扩展方法:`NSAttributedString.ss_pageRanges(size:)`
|
||
- 底层实现:
|
||
- `CTFramesetterCreateWithAttributedString`
|
||
- `CTFramesetterCreateFrame`
|
||
- `CTFrameGetVisibleStringRange`
|
||
|
||
处理循环逻辑是:
|
||
|
||
1. 从当前 `location` 创建 frame
|
||
2. 取 `visibleRange`
|
||
3. 把这个 range 记录为一页
|
||
4. `location += visibleRange.length`
|
||
5. 重复直到结束
|
||
|
||
### 7.1 Current limitation
|
||
|
||
这是一种典型的“可见字符串范围切页”模型,优点是简单、稳定、易于接回现有 `RDEPUBTextBook` 结构;缺点是:
|
||
|
||
- 没有块级避免断页策略
|
||
- 没有页面级语义结构
|
||
- 没有自定义 layout frame / layouter
|
||
- 很难表达复杂图片、表格、背景、分页约束
|
||
|
||
因此,`ss_pageRanges(size:)` 明确是后续需要被增强或替换的核心点之一。
|
||
|
||
## 8. Presentation Stage: `RDEPUBTextContentView`
|
||
|
||
`Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift` 是当前 native text page 的展示层。
|
||
|
||
### 8.1 Responsibilities
|
||
|
||
它负责:
|
||
|
||
- 接收单页 `RDEPUBTextPage`
|
||
- 将 `page.content` 赋给 `UITextView`
|
||
- 叠加高亮和搜索高亮
|
||
- 展示页码
|
||
- 输出 selection 和 annotation menu action
|
||
|
||
### 8.2 Why this matters
|
||
|
||
这说明当前 `.textReflowable` 路径已经和以下阅读器能力发生绑定:
|
||
|
||
- selection
|
||
- highlight
|
||
- annotate
|
||
- search highlight
|
||
- page number display
|
||
|
||
因此,如果后续升级分页器或页面语义,必须继续维护 `RDEPUBTextPage` 到 `RDEPUBTextContentView` 的兼容面,而不是绕开它另起视图层。
|
||
|
||
## 9. Location / Highlight / Search Compatibility Chain
|
||
|
||
当前原生文本路径不只是“能显示文本”,还承担已有 reader 能力链路:
|
||
|
||
### 9.1 Location mapping
|
||
|
||
`RDEPUBTextBook` 提供:
|
||
|
||
- `pageNumber(for:resolver:bookIdentifier:)`
|
||
- `location(forPageNumber:bookIdentifier:)`
|
||
|
||
它依赖:
|
||
|
||
- `fragmentOffsets`
|
||
- `pageStartOffset`
|
||
- `pageEndOffset`
|
||
- chapter-level `attributedContent.length`
|
||
|
||
### 9.2 Highlight and selection transport
|
||
|
||
`RDEPUBTextContentView` 会把选区转成:
|
||
|
||
- `RDEPUBSelection`
|
||
- `RDEPUBTextOffsetRangeInfo`
|
||
|
||
并通过 `RDEPUBReaderController` 的 text selection normalization / persistence 链路继续工作。
|
||
|
||
### 9.3 Search compatibility
|
||
|
||
`RDEPUBTextContentView.applySearchHighlights(...)` 也是基于页内 offset 重叠来计算高亮区域。
|
||
|
||
结论:`RDEPUBTextBook` 和 `RDEPUBTextPage` 不是单纯的 UI DTO,而是 reader state、annotation、search、location mapping 的核心兼容层。
|
||
|
||
## 10. What Still Uses `WKWebView`
|
||
|
||
下列链路必须继续视为 WebView 范围:
|
||
|
||
### 10.1 Fixed Layout
|
||
|
||
`Sources/RDReaderView/EPUBCore/RDEPUBWebView+FixedLayout.swift`
|
||
|
||
- `loadFixedSpread(...)`
|
||
- `handleFixedLayoutLoad(...)`
|
||
|
||
职责:
|
||
|
||
- 把 fixed spread 渲染成 HTML
|
||
- 通过 `WKWebView.loadHTMLString(...)` 加载
|
||
- 处理 fixed spread 资源与 ready fallback
|
||
|
||
### 10.2 Interactive / web pagination support
|
||
|
||
`Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift`
|
||
|
||
职责:
|
||
|
||
- 使用离屏、非持久化 `WKWebView`
|
||
- 按 spine item 加载 HTML
|
||
- 测量文档页数
|
||
- 为 WebView 路径生成分页信息
|
||
|
||
尽管它名字叫 paginator,但它不是当前 native `.textReflowable` 的分页核心;它是 WebView 渲染/测量体系的一部分。
|
||
|
||
### 10.3 Web reflowable presentation
|
||
|
||
`Sources/RDReaderView/EPUBCore/RDEPUBWebView+Reflowable.swift`
|
||
|
||
职责:
|
||
|
||
- 构造 `RDEPUBReflowableRenderRequest`
|
||
- 对当前 HTML 文档应用 presentation script
|
||
- 处理 page index / target location / highlights 的 WebView 表示
|
||
|
||
这条链路应该保留给 `.webInteractive` 以及任何必须留在 WebView 的内容,不应误认为是当前 native reflowable 主路径。
|
||
|
||
## 11. Stable Container Contract: `RDReaderView`
|
||
|
||
`Sources/RDReaderView/RDReaderView.swift` 是当前阅读容器的稳定外壳。
|
||
|
||
### 11.1 What it owns
|
||
|
||
它负责:
|
||
|
||
- `pageCurl`
|
||
- `horizontalScroll`
|
||
- `verticalScroll`
|
||
- RTL / LTR 翻页方向
|
||
- landscape dual-page
|
||
- cover page pairing
|
||
- orientation change handling
|
||
- page container reuse和 data source protocol
|
||
|
||
### 11.2 Why Phase 1 must keep it unchanged
|
||
|
||
roadmap 已经把 `RDReaderView` 排除在本次改造范围外,而源码也支持这一结论:
|
||
|
||
- `RDEPUBReaderController` 只把页面数据和内容视图交给 `RDReaderView`
|
||
- native text 和 web content 都通过相同的 container contract 接入
|
||
- 改它会把内核升级变成“内核 + 容器 + 手势 + 双页策略”的多变量回归
|
||
|
||
结论:**`RDReaderView` 不修改** 是正确的硬约束,不只是项目管理偏好,而是当前架构稳定性的必要条件。
|
||
|
||
## 12. Boundary and Exclusion Rules for Next Phases
|
||
|
||
后续 phase 应默认遵守以下边界:
|
||
|
||
1. `RDEPUBParser.readingProfile()` 三分流保持不变
|
||
2. `webFixedLayout` 继续走 `WKWebView`
|
||
3. `webInteractive` 继续走 `WKWebView`
|
||
4. `.textReflowable` 继续以 `EPUBTextRendering` 为主战场
|
||
5. `RDReaderView` 不修改
|
||
6. `RDEPUBTextBook` / `RDEPUBTextChapter` / `RDEPUBTextPage` 是兼容层,后续升级不能轻易破坏
|
||
7. `fragmentOffsets`、offset-based location mapping、高亮/搜索 range transport 必须继续可用
|
||
|
||
## 13. Direct Refactor Entry Points
|
||
|
||
基于当前代码,后续“旧引擎直接演进”的直接触点应当是:
|
||
|
||
- `RDEPUBDTCoreTextRenderer`
|
||
- 从简单 `dtOptions` 升级为更完整的 stylesheet / HTML preprocessing / attribute pipeline
|
||
|
||
- `RDEPUBTextBookBuilder`
|
||
- 继续保留 chapter -> rendered content -> page model 的骨架
|
||
- 后续承接更丰富页面语义、附件/块元素信息
|
||
|
||
- `RDEPUBTextPaginationSupport`
|
||
- 从单纯 `CTFrameGetVisibleStringRange` 切页,升级到更强分页策略
|
||
|
||
- `RDEPUBTextPage` / `RDEPUBTextChapter`
|
||
- 承接页面级元数据,而不是只保存纯 range
|
||
|
||
- `RDEPUBReaderController`
|
||
- 保持 branch point 与 reader integration,不把它变成新的排版实现层
|
||
|
||
## 14. Final Audit Result
|
||
|
||
Phase 1 的事实基线可以归纳为一句话:
|
||
|
||
> 当前旧引擎已经拥有一条完整的 native `.textReflowable` 路径,真正需要演进的是 `EPUBTextRendering` 内部的 renderer / page model / pagination semantics;Fixed Layout、交互式 EPUB 以及 `RDReaderView` 容器都应保持边界不变。
|