ReadViewSDK/Doc/EPUBCore_功能实现逻辑.md
shen 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

20 KiB
Raw Blame History

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 / spineRDEPUBParser+Package.swift
    • 解析目录支持 NCXEPUB 2和 Nav DocumentEPUB 3RDEPUBParser+TOC.swift
    • 判断阅读配置文件类型(RDEPUBParser+ReadingProfile.swift
    • 资源路径转换:相对路径 ↔ 文件 URL ↔ ss-reader:// 自定义 scheme URLRDEPUBParser+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
    • 更新阅读上下文(currentReadingContextlocation + 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.swiftEPUBCore/RDEPUBResourceURLSchemeHandler.swift
  • 职责:
    • RDEPUBResourceResolver:对外门面,提供 href 标准化、spine 索引查找、Location 构建
    • RDEPUBResourceURLSchemeHandler:实现 WKURLSchemeHandler,将 ss-reader://book/<path> 映射到解压后的文件系统路径,缺失的可选资源(字体/图片/CSS/JS返回空 200

2.7 JS 桥接

  • 文件:EPUBCore/RDEPUBJavaScriptBridge.swiftResources/epub-bridge.js
  • 6 种消息类型JS → Swift
    • ssReaderProgressionChanged:阅读进度变化
    • ssReaderSelectionChanged:文本选择变化
    • ssReaderInternalLink:内部链接点击
    • ssReaderExternalLink:外部链接点击
    • ssReaderJSErrorJS 错误
    • ssReaderFixedLayoutReady:固定版式渲染完成
  • JS 端 window.RDReaderBridge 暴露:applyPaginationsetPageMetricsscrollToPagescrollToLocationsetHighlightsclearHighlightsreportProgressionselectionPayload

2.8 搜索引擎 RDEPUBSearchEngine / RDEPUBHTMLSearchEngine

  • 文件:EPUBCore/RDEPUBSearchEngine.swiftEPUBCore/RDEPUBSearchModels.swift
  • 职责:
    • RDEPUBSearchEngine:搜索协议定义
    • RDEPUBHTMLSearchEngineWebView 路径实现,将 HTML 转为纯文本NSAttributedString 或正则兜底),执行大小写不敏感的全文搜索
    • 搜索模型:RDEPUBSearchMatchRDEPUBSearchResultRDEPUBSearchStateRDEPUBSearchPresentation

2.9 配置与样式

  • RDEPUBPreferencesEPUBCore/RDEPUBPreferences.swift):用户阅读偏好(字号、行高、内容边距、主题色、固定版式适配模式),构建 RDEPUBPresentationStyleRDEPUBRenderRequest
  • RDEPUBNavigatorLayoutContextEPUBCore/RDEPUBNavigatorLayoutContext.swift):容器布局上下文(容器尺寸、每屏页数、安全区域、设备类型)
  • RDEPUBStyleSheetBuilderEPUBCore/RDEPUBStyleSheetBuilder.swift):生成分页 CSS、渲染 CSS 和测量 JS 脚本
  • RDEPUBFixedLayoutTemplateEPUBCore/RDEPUBFixedLayoutTemplate.swift):生成固定版式 HTML 模板

2.10 文本锚点 RDEPUBTextAnchor / RDEPUBTextRangeAnchor

  • 文件:EPUBCore/RDEPUBTextAnchor.swift
  • 职责:
    • RDEPUBTextAnchor:精确定位到 EPUB 中的某个字符位置,包含 fileIndexspine 索引、row行号、column列号、chapterOffset章节内字符偏移、fragmentID最近的 fragment
    • RDEPUBTextRangeAnchor:由起止锚点组成的文本区间,可转换为 NSRange
    • 支持 Codable 序列化,兼容 fileIndexspineIndex 两种 key
    • 用于文本 EPUB 的高亮精确锚定和跨会话恢复

2.11 渲染请求模型 RDEPUBRenderRequest

  • 文件:EPUBCore/RDEPUBRenderRequest.swift
  • 职责:
    • RDEPUBFixedLayoutFit:固定版式适配模式枚举(.auto / .page / .width
    • RDEPUBFixedLayoutSpreadModeSpread 显示模式枚举(.automatic / .always / .never
    • RDEPUBPresentationStyleWebView 和 Paginator 共用的视觉参数viewportSize、contentInsets、fontSize、lineHeightMultiple、主题色
    • RDEPUBReflowableRenderRequest可重排内容渲染请求spineIndex、href、pageIndex、presentation、highlights、searchPresentation
    • RDEPUBFixedRenderRequest固定版式渲染请求spread、viewportSize、fit、searchPresentation
    • RDEPUBRenderRequest:统一枚举(.reflowable / .fixedWebView 根据此类型选择渲染路径

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

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
    • ContainerXMLParserDelegateSAX找到第一个 <rootfile>full-path 属性
  4. 解析 OPF 文档:
    • OPFPackageParserDelegateSAX分段解析 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. 构建 spinebuildSpine(from:opfURL:)
    • 将 spine reference 匹配到 manifest item
    • 标准化 href相对于 OPF 目录)
    • 确定每项的有效 layout显式属性覆盖出版级设置
    • 返回 [RDEPUBSpineItem],空 spine 抛 emptySpine 错误
  6. 解析目录:parseTOC(from:opfURL:)
    • 优先 NCXNCXParserDelegate),其次 Nav DocumentNavDocumentParserDelegate
    • 兜底使用 spine 标题
    • href 标准化保留 fragment 标识符
  7. 创建 PublicationmakePublication()RDEPUBPublication(parser: self)

3.2 阅读配置文件判断

  • 入口:RDEPUBParser+ReadingProfile.swift
  • 判断逻辑:
    • 扫描 manifest 检查是否有 scripted 属性的项
    • 扫描 HTML body 检查是否有 scriptiframevideo 等交互元素
    • 结果:
      • .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. activeSessionIDUUID防止过期回调污染结果

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.swiftuserContentController(_:didReceive:)
  • 分发逻辑:
    • progressionChanged → 提取 progression/lastProgression/fragment → delegate?.epubWebView(_:didUpdateLocation:spineIndex:)
    • selectionChanged → 提取 text/rangeInfo → 创建 RDEPUBSelectiondelegate?.epubWebView(_:didChangeSelection:spineIndex:)
    • internalLink → 创建 RDEPUBLocationdelegate?.epubWebView(_:didActivateInternalLink:fromSpineIndex:)
    • externalLink → 提取 URL → delegate?.epubWebView(_:didActivateExternalLink:)
    • javaScriptErrordelegate?.epubWebView(_:didLogJavaScriptError:)
    • fixedLayoutReady → 应用搜索装饰 → rendered()

3.7 链接导航拦截

  • 入口:decidePolicyFor navigationAction
  • 逻辑:
    • ss-reader:// URL + .linkActivated → 内部链接代理回调,取消导航
    • http/https/mailto/tel → 外部链接代理回调,取消导航
    • 其他 → 允许导航

4. 异常与边界处理

  • RDEPUBParserError7 种,全部 LocalizedError,中文描述):
    • archiveOpenFailed(URL)ZIPFoundation 无法打开 EPUB 文件
    • missingContainerXMLMETA-INF/container.xml 不存在
    • missingRootFilecontainer.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>px
  • html / body 设置 overflow: hidden
  • 页间导航通过 transform: translate3d(-N*stride, 0, 0) 实现

6.2 重新布局触发条件

  • scheduleRelayout()60ms 防抖)在以下事件触发:
    • window.loadwindow.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