358 lines
17 KiB
Markdown
358 lines
17 KiB
Markdown
# 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`)
|
||
- 解析目录支持 NCX(EPUB 2)和 Nav Document(EPUB 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. 联调与排查建议
|
||
|
||
- 排查 1:EPUB 解析失败
|
||
- 检查 `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 分页样式是否正确注入
|
||
- 排查 5:JS 桥接消息未收到
|
||
- 确认 `epub-bridge.js` 已正确注入(检查 `WKUserContentController`)
|
||
- 检查 JS 控制台是否有错误
|
||
- 固定版式检查是否收到 `ssReaderFixedLayoutReady`,否则看 1 秒兜底定时器
|
||
- 排查 6:内部链接不跳转
|
||
- 检查链接 href 是否以 `ss-reader://` 开头
|
||
- 检查 `decidePolicyFor navigationAction` 中的链接类型判断
|
||
- 确认 `RDEPUBLocation` 构建是否正确(href + fragment)
|