ReadViewSDK/Doc/EPUBCore_功能实现逻辑.md
2026-05-21 19:40:51 +08:00

358 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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