- 更新模块文件数(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 命名迁移为已完成
20 KiB
20 KiB
EPUBCore 功能实现逻辑
1. 范围与目标
- 代码范围:
Sources/RDReaderView/EPUBCore/(31 个 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)
- ZIP 解压到缓存目录(
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 个扩展文件(+Configuration / +Reflowable / +FixedLayout / +JavaScriptBridge / +Search) - 职责:
- 内部持有 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 搜索引擎 RDEPUBSearchEngine / RDEPUBHTMLSearchEngine
- 文件:
EPUBCore/RDEPUBSearchEngine.swift、EPUBCore/RDEPUBSearchModels.swift - 职责:
RDEPUBSearchEngine:搜索协议定义RDEPUBHTMLSearchEngine:WebView 路径实现,将 HTML 转为纯文本(NSAttributedString 或正则兜底),执行大小写不敏感的全文搜索- 搜索模型:
RDEPUBSearchMatch、RDEPUBSearchResult、RDEPUBSearchState、RDEPUBSearchPresentation
2.9 配置与样式
RDEPUBPreferences(EPUBCore/RDEPUBPreferences.swift):用户阅读偏好(字号、行高、内容边距、主题色、固定版式适配模式),构建RDEPUBPresentationStyle和RDEPUBRenderRequestRDEPUBNavigatorLayoutContext(EPUBCore/RDEPUBNavigatorLayoutContext.swift):容器布局上下文(容器尺寸、每屏页数、安全区域、设备类型)RDEPUBStyleSheetBuilder(EPUBCore/RDEPUBStyleSheetBuilder.swift):生成分页 CSS、渲染 CSS 和测量 JS 脚本RDEPUBFixedLayoutTemplate(EPUBCore/RDEPUBFixedLayoutTemplate.swift):生成固定版式 HTML 模板
2.10 文本锚点 RDEPUBTextAnchor / RDEPUBTextRangeAnchor
- 文件:
EPUBCore/RDEPUBTextAnchor.swift - 职责:
RDEPUBTextAnchor:精确定位到 EPUB 中的某个字符位置,包含 fileIndex(spine 索引)、row(行号)、column(列号)、chapterOffset(章节内字符偏移)、fragmentID(最近的 fragment)RDEPUBTextRangeAnchor:由起止锚点组成的文本区间,可转换为 NSRange- 支持 Codable 序列化,兼容
fileIndex和spineIndex两种 key - 用于文本 EPUB 的高亮精确锚定和跨会话恢复
2.11 渲染请求模型 RDEPUBRenderRequest
- 文件:
EPUBCore/RDEPUBRenderRequest.swift - 职责:
RDEPUBFixedLayoutFit:固定版式适配模式枚举(.auto/.page/.width)RDEPUBFixedLayoutSpreadMode:Spread 显示模式枚举(.automatic/.always/.never)RDEPUBPresentationStyle:WebView 和 Paginator 共用的视觉参数(viewportSize、contentInsets、fontSize、lineHeightMultiple、主题色)RDEPUBReflowableRenderRequest:可重排内容渲染请求(spineIndex、href、pageIndex、presentation、highlights、searchPresentation)RDEPUBFixedRenderRequest:固定版式渲染请求(spread、viewportSize、fit、searchPresentation)RDEPUBRenderRequest:统一枚举(.reflowable/.fixed),WebView 根据此类型选择渲染路径
2.12 调试工具 RDEPUBWebViewDebug
- 文件:
EPUBCore/RDEPUBWebViewDebug.swift - 职责:
- WebView 调试日志工具集,DEBUG 模式默认开启
- 支持导航事件、JS 执行、消息接收、URL Scheme 任务的日志记录
- 可通过 UserDefaults
"RDEPUBWebViewDebugEnabled"覆盖开关
2.13 资源加载器 RDEPUBAssetRepository
- 文件:
EPUBCore/RDEPUBAssetRepository.swift - 职责:
- 从资源包加载 JS 桥接脚本(
epub-bridge.js)和固定版式 HTML 模板(epub-fixed-layout.html) - 支持
{{token}}模板变量替换 - 自动定位
RDReaderViewAssets.bundle(优先已解析子 bundle,兜底宿主 bundle)
- 从资源包加载 JS 桥接脚本(
3. 主流程(代码级)
3.1 EPUB 解析全流程
- 入口:
RDEPUBParser.parse(epubURL:)。 - 调用
extractArchiveIfNeeded(epubURL:):- 缓存路径:
~/Library/Caches/ssreaderview-epub/<slug>-<fileSize>-<timestamp>/ - 若目录已存在则跳过解压
- 否则用 ZIPFoundation 解压 ZIP 到缓存目录
- 缓存路径:
- 解析
META-INF/container.xml:ContainerXMLParserDelegate(SAX)找到第一个<rootfile>的full-path属性
- 解析 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
- 构建 spine:
buildSpine(from:opfURL:)- 将 spine reference 匹配到 manifest item
- 标准化 href(相对于 OPF 目录)
- 确定每项的有效 layout(显式属性覆盖出版级设置)
- 返回
[RDEPUBSpineItem],空 spine 抛emptySpine错误
- 解析目录:
parseTOC(from:opfURL:)- 优先 NCX(
NCXParserDelegate),其次 Nav Document(NavDocumentParserDelegate) - 兜底使用 spine 标题
- href 标准化保留 fragment 标识符
- 优先 NCX(
- 创建 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 渲染
- 扫描 manifest 检查是否有
3.3 离屏分页测量
- 入口:
RDEPUBPaginator.calculate(parser:hostingView:presentation:completion:) - 初始化所有 spine 项页数为
[1, 1, ..., 1] - 固定版式直接返回(每 spread = 1 页)
- 顺序遍历可渲染的 HTML/XHTML spine 项
- 对每项:
webView.loadFileURL(...)加载文件didFinish后启动多次测量(scheduleMeasurementPass)- 3 次测量延迟 [0ms, 80ms, 180ms]
- 每次执行
measurementScript:注入分页 CSS → 测量scrollWidth→ 返回Math.max(1, Math.ceil(totalWidth / viewportWidth)) - 取多次测量的最大值
- 全部完成后回调
completion([Int])页数数组 activeSessionID(UUID)防止过期回调污染结果
3.4 WebView 可重排内容加载
- 入口:
RDEPUBWebView.loadPage(parser:spineIndex:pageIndex:...) - 计算
loadSignature(包含 publication key、spine index、href、viewport、字号、行高、主题色、目标位置、高亮等) - 若签名与
currentLoadSignature相同且已渲染/正在加载,跳过重复请求 - 若签名匹配但文档 URL 已加载,仅调用
applyPresentation()重新应用样式 - 否则
handleReflowableLoad(...)执行完整加载:- 读取 HTML 内容
- 调用
applyPresentationScript(for:)生成 JS - JS 执行:
RDReaderBridge.applyPagination(css)→setPageMetrics→ 清除并重新应用高亮 → 滚动到目标位置 → 报告进度 → 应用搜索装饰
3.5 固定版式内容加载
- 入口:
RDEPUBWebView.loadFixedSpread(parser:spread:...) RDEPUBFixedLayoutTemplate.html(for:publication:)从模板生成 HTML,包含 spread 中每个资源的<iframe>webView.loadHTMLString(html, baseURL: "ss-reader://book/")加载- 模板 JS 加载每个 iframe,检测页面尺寸,计算缩放(auto/page/width 适配模式),定位 iframe
- 所有 iframe 加载完成后 JS 发送
ssReaderFixedLayoutReady - Swift 收到消息后应用搜索装饰并调用
rendered() - 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)
├── rangeAnchor: RDEPUBTextRangeAnchor? (文本范围锚点,用于高亮精确定位)
└── 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 或 text-offset)
├── style: RDEPUBHighlightStyle (.highlight | .underline)
├── color: String, note: String
└── createdAt: Date
RDEPUBSelection
├── bookIdentifier
├── location: RDEPUBLocation
├── text, rangeInfo
└── createdAt: Date
RDEPUBBookmark
├── id, bookIdentifier
├── location: RDEPUBLocation
├── title: String?
├── note: String?
└── 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>pxhtml/body设置overflow: hidden- 页间导航通过
transform: translate3d(-N*stride, 0, 0)实现
6.2 重新布局触发条件
scheduleRelayout()(60ms 防抖)在以下事件触发:window.load、window.resizedocument.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)
- 检查链接 href 是否以