- Rename source module from RDReaderView to RDEpubReaderView - Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/ - Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec - Update Podfile, demo project, and CocoaPods config for new pod name - Delete old RDReaderView pod support files from ReadViewDemo/Pods - Add new RDEpubReaderView pod support files - Update documentation (API ref, architecture, UML, conventions, etc.) - Add FixedLayoutRotationTests - Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
20 KiB
CFI 子系统文档
最后更新:2026-06-18
本文档详细描述 ReadViewSDK 中 EPUB CFI(Canonical Fragment Identifier)子系统的架构、数据模型、解析流程与容错机制。
1. CFI 规范简介
EPUB CFI 是 EPUB 3 规范定义的标准化片段标识符,用于精确定位 EPUB 内容中的任意位置。其语法形式为:
epubcfi(/6/4!ch01.xhtml/4/2/1:3)
├──────┘ ├────────┘ ├──────┘ └─┘
│ │ │ └─ 字符偏移(characterOffset)
│ │ └─ 内容路径(contentPath):定位 DOM 节点
│ └─ 包路径(packagePath):定位 OPF manifest 中的资源
└─ epubcfi() 包装器
关键规则:
- 步进(step):以
/分隔,偶数索引表示元素节点,奇数索引表示文本节点 - id 断言:
[id]形式,如/4[ch01.xhtml],用于增强定位鲁棒性 - 范围 CFI:
epubcfi(/parent,/start,/end)三段逗号分隔,表示起止范围 - 侧偏:
;s=b(before)或;s=a(after),指示锚点偏向 - 文本断言:
[prefix,exact,suffix],用于断言定位处的文本内容
2. 核心数据模型
2.1 RDEPUBCFI
顶层 CFI 模型,对应一个完整的 epubcfi(...) 字符串。
文件:Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFI.swift
public struct RDEPUBCFI: Codable, Equatable, Hashable {
public var rawValue: String // 原始 CFI 字符串
public var packagePath: RDEPUBCFIPath // 包路径(定位 XHTML 文件)
public var contentPath: RDEPUBCFIPath // 内容路径(定位 DOM 节点)
public var characterOffset: Int? // 文本节点内的字符偏移
public var sideBias: RDEPUBCFISideBias? // 侧偏方向
public var textAssertion: RDEPUBCFITextAssertion? // 文本断言
}
侧偏枚举:
public enum RDEPUBCFISideBias: String, Codable {
case before = "b" // 锚点偏向起始侧
case after = "a" // 锚点偏向结束侧
}
示例:解析 epubcfi(/6/4[ch01.xhtml]!/4/2/1:100;s=b) 后:
packagePath.steps=[Step(index: 6), Step(index: 4, idAssertion: "ch01.xhtml")]contentPath.steps=[Step(index: 4), Step(index: 2), Step(index: 1)]characterOffset= 100sideBias=.before
2.2 RDEPUBCFIPath
路径模型,由一组 RDEPUBCFIStep 组成,描述从根节点到目标节点的遍历序列。
文件:Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift
public struct RDEPUBCFIPath: Codable, Equatable, Hashable {
public var steps: [RDEPUBCFIStep]
// 计算两条路径的公共前缀
public func commonPrefix(with other: RDEPUBCFIPath) -> RDEPUBCFIPath
// 去除指定前缀后的剩余路径
public func droppingPrefix(_ prefix: RDEPUBCFIPath) -> RDEPUBCFIPath
}
RDEPUBCFIStep:
public struct RDEPUBCFIStep: Codable, Equatable, Hashable {
public var index: Int // 节点索引(偶数=元素,奇数=文本)
public var idAssertion: String? // 可选的 id 断言,如 "ch01.xhtml"
}
commonPrefix 方法在范围 CFI 序列化时用于提取起止点的公共父路径;droppingPrefix 用于生成相对于父级的路径片段。
2.3 RDEPUBCFIRange
范围模型,表示文档中的一个连续区域,由父级 CFI 和起止 CFI 组成。
文件:Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift
public struct RDEPUBCFIRange: Codable, Equatable, Hashable {
public var rawValue: String // 原始范围 CFI 字符串
public var parent: RDEPUBCFI? // 公共父级(起止共享的前缀路径)
public var start: RDEPUBCFI // 起始位置
public var end: RDEPUBCFI // 结束位置
}
序列化时输出格式:epubcfi(/parent_path,/start_terminal,/end_terminal),其中起止路径相对于父级路径输出。
3. 解析与序列化
3.1 RDEPUBCFIParser
将 CFI 字符串解析为结构化模型。
文件:Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift
public enum RDEPUBCFIParser {
// 解析单点 CFI
public static func parse(_ rawValue: String?) throws -> RDEPUBCFI
// 解析范围 CFI
public static func parseRange(_ rawValue: String?) throws -> RDEPUBCFIRange
}
解析流程(parse 方法):
- 去包装:剥离
epubcfi(...)外层,提取 body - 拆分包路径与内容路径:以第一个
!为分隔符 - 解析路径:按
/分割为 step,每个 step 可含[idAssertion] - 解析偏移与限定符:从内容路径中提取
:offset、;s=b/a、[textAssertion]
关键细节:
firstIndexOutsideBrackets方法确保:分隔符在方括号外才被识别,避免与文本断言中的内容混淆parseRange要求恰好 3 个逗号分隔部分(parent, start, end),否则抛出unsupportedRange错误
使用示例:
let cfi = try RDEPUBCFIParser.parse(
"epubcfi(/6/4[ch01.xhtml]!/4/2/1:50;s=a[前缀,目标文本,后缀])"
)
print(cfi.characterOffset) // Optional(50)
print(cfi.sideBias) // Optional(.after)
print(cfi.textAssertion?.exact) // Optional("目标文本")
3.2 RDEPUBCFISerializer
将 CFI 模型序列化为标准字符串。
文件:Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift
public enum RDEPUBCFISerializer {
// 序列化单点 CFI
public static func serialize(_ cfi: RDEPUBCFI) -> String
// 序列化范围 CFI
public static func serializeRange(_ range: RDEPUBCFIRange) -> String
// 序列化路径为 step 字符串
public static func serializePath(_ path: RDEPUBCFIPath) -> String
}
序列化逻辑:
- 若
rawValue非空,直接返回(避免重复序列化) - 范围序列化先通过
canonicalRangeComponents提取或推导公共父级,再分别序列化起止终端路径(相对于父级) - 终端路径使用
droppingPrefix去除与父级共享的部分
示例:
let cfi = RDEPUBCFIGenerator.makeOffsetCFI(
href: "chapter1.xhtml", fileIndex: 0, chapterOffset: 120
)
let serialized = RDEPUBCFISerializer.serialize(cfi)
// => "epubcfi(/6/2[chapter1.xhtml]!/4/2:120)"
4. DOM 解析与生成
4.1 RDEPUBCFIResolver
将 CFI 解析为可直接用于资源定位的结果。
文件:Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift
public struct RDEPUBCFIResolverResult: Equatable {
public var href: String? // XHTML 文件路径(来自 idAssertion)
public var fileIndex: Int? // 文件索引
public var chapterOffset: Int? // 章节内字符偏移
public var fragmentID: String? // 片段 ID(如 #section1)
}
public enum RDEPUBCFIResolver {
public static func resolve(_ cfi: RDEPUBCFI) -> RDEPUBCFIResolverResult
}
解析规则:
href:从packagePath中最后一个含idAssertion的 step 提取fileIndex:packagePath中倒数第二个 step 的(index / 2) - 1fragmentID:从contentPath中最后一个含idAssertion的 step 提取chapterOffset:直接取cfi.characterOffset
示例:
let cfi = try RDEPUBCFIParser.parse("epubcfi(/6/4[ch01.xhtml]!/4/2[chap1]:80)")
let result = RDEPUBCFIResolver.resolve(cfi)
print(result.href) // Optional("ch01.xhtml")
print(result.fileIndex) // Optional(0)
print(result.fragmentID) // Optional("chap1")
print(result.chapterOffset) // Optional(80)
4.2 RDEPUBCFIGenerator
从已知的章节信息生成 CFI。
文件:Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift
public enum RDEPUBCFIGenerator {
// 根据偏移量生成单点 CFI
public static func makeOffsetCFI(
href: String,
fileIndex: Int,
chapterOffset: Int,
fragmentID: String? = nil,
sideBias: RDEPUBCFISideBias? = nil,
textAssertion: RDEPUBCFITextAssertion? = nil
) -> RDEPUBCFI
// 根据自定义内容路径生成 CFI
public static func makeCFI(
href: String,
fileIndex: Int,
contentPath: RDEPUBCFIPath,
characterOffset: Int,
sideBias: RDEPUBCFISideBias? = nil,
textAssertion: RDEPUBCFITextAssertion? = nil
) -> RDEPUBCFI
// 生成范围 CFI(起止偏移量)
public static func makeOffsetRangeCFI(
href: String,
fileIndex: Int,
startOffset: Int,
endOffset: Int,
fragmentID: String? = nil,
startTextAssertion: RDEPUBCFITextAssertion? = nil,
endTextAssertion: RDEPUBCFITextAssertion? = nil
) -> RDEPUBCFIRange
}
路径构造规则:
packagePath固定为[Step(index: 6), Step(index: (fileIndex+1)*2, idAssertion: href)]contentPath默认为[Step(index: 4), Step(index: 2, idAssertion: fragmentID)]- 范围 CFI 的起始点自动附加
sideBias: .before,结束点附加sideBias: .after
4.3 RDEPUBCFIDOMPathBuilder
从 HTML 源码中提取带有 id 属性的元素路径映射。
文件:Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift
public enum RDEPUBCFIDOMPathBuilder {
// 从 HTML 中提取 fragmentID -> CFIPath 映射
public static func fragmentPaths(in html: String) -> [String: RDEPUBCFIPath]
}
实现细节:
- 使用正则表达式匹配 HTML 标签,维护一个模拟 DOM 栈
- 每遇到开标签,计算子节点索引(
childIndex * 2),生成RDEPUBCFIStep - 从标签属性中提取
id或xml:id作为idAssertion - 识别并跳过 void 元素(
br、img、input等)和可忽略标签(!doctype) - 闭标签时弹出栈顶,重置该深度的子节点计数
5. 容错恢复引擎(RDEPUBCFIRecoveryEngine)
当 CFI 精确定位失败时(如 DOM 结构变更),恢复引擎通过多级降级策略尝试找到最佳匹配位置。
文件:Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift
5.1 恢复置信度
public struct RDEPUBCFIRecoveryResult: Equatable {
public enum Confidence: Int, Codable {
case exactPath // 精确路径匹配
case assertionCalibrated // 路径匹配 + 文本断言校准
case siblingRecovered // 兄弟节点恢复
case tokenRecovered // 词元索引恢复
case fragmentFallback // 片段 ID 降级
case offsetFallback // 偏移量兜底
}
public var chapterOffset: Int
public var confidence: Confidence
}
5.2 恢复策略(按优先级)
| 优先级 | 策略 | 说明 |
|---|---|---|
| 1 | exactPath |
在 CFIMap 中查找精确匹配的 marker 或 pathRange |
| 2 | assertionCalibrated |
精确路径匹配后,使用文本断言在窗口内校准偏移 |
| 3 | siblingRecovered |
查找同父级的兄弟节点,通过 siblingScore 选择最近匹配 |
| 4 | tokenRecovered |
利用词元索引(token index)在章节文本中搜索匹配 |
| 5 | fragmentFallback |
回退到 fragment ID 对应的已知偏移量 |
| 6 | offsetFallback |
使用兜底偏移量 |
5.3 核心算法
校准机制(calibratedOffset):
- 在给定偏移量附近 ±512 字符窗口内搜索
textAssertion.exact文本 - 对每个候选位置计算
assertionScore:距离越近分数越低,prefix/suffix 匹配则大幅加分(不匹配 +10000) - 选取得分最低的候选位置
兄弟恢复(siblingRecovered):
- 查找与目标路径同父级、同深度的已知 marker
- 通过
siblingSignature(parent_path#childIndex)和索引距离计算相似度分数
词元恢复(tokenRecovered):
- 在预构建的 token index 中查找包含
textAssertion.exact的词元 - 对每个候选锚点执行校准,选择校准距离最小的结果
调用入口:
let result = RDEPUBCFIRecoveryEngine.recover(
cfi: someCFI,
cfiMap: chapterCFIMap,
chapterText: "章节纯文本...",
fragmentOffsets: ["section1": 150, "section2": 800],
fallbackOffset: 0,
lastOffset: 5000
)
// result?.confidence 反映恢复质量
6. 文本断言验证(RDEPUBCFITextAssertion)
文本断言用于在 CFI 定位后验证所指位置的文本内容是否符合预期,增强定位鲁棒性。
文件:Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift
public struct RDEPUBCFITextAssertion: Codable, Equatable, Hashable {
public var prefix: String? // 目标文本之前的上下文
public var exact: String? // 精确匹配的目标文本
public var suffix: String? // 目标文本之后的上下文
}
在 CFI 中的表示:[prefix,exact,suffix],位于偏移量和侧偏之后。
示例:
// CFI: epubcfi(/6/4[ch01.xhtml]!/4/2/1:100;s=a[这是一段,目标文本,后续内容])
let assertion = cfi.textAssertion
print(assertion?.prefix) // Optional("这是一段")
print(assertion?.exact) // Optional("目标文本")
print(assertion?.suffix) // Optional("后续内容")
容错引擎中的应用:
exact用于在窗口内搜索实际文本位置prefix和suffix用于对候选位置评分,优先选择上下文都匹配的位置
7. 兼容性处理(RDEPUBCFICompatibility)
提供宽松解析接口,兼容非标准 CFI 格式。
文件:Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFICompatibility.swift
public enum RDEPUBCFICompatibility {
// 宽松解析单点 CFI(解析失败返回 nil 而非抛异常)
public static func parseLossy(_ rawValue: String?) -> RDEPUBCFI?
// 宽松解析范围 CFI
public static func parseRangeLossy(_ rawValue: String?) -> RDEPUBCFIRange?
}
范围 CFI 兼容策略:
- 优先使用标准
parseRange(逗号三分段格式) - 若失败,尝试以
..或-作为分隔符拆分为两个独立 CFI 分别解析 - 支持
epubcfi(...)-epubcfi(...)或epubcfi(...)..epubcfi(...)格式
使用场景:处理第三方生成器或旧版本导出的非标准范围标记。
8. CFI 映射(RDEPUBCFIMap)
CFI 映射是章节级别的索引结构,将 CFI 路径映射到章节文本偏移量,是容错恢复引擎的核心数据源。
文件:Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift
8.1 RDEPUBCFIMap
public struct RDEPUBCFIMap: Codable, Equatable {
public var href: String // 章节文件路径
public var renderVersion: Int // 渲染版本号
public var domVersion: Int // DOM 版本号
public var markers: [RDEPUBCFIMarker] // CFI 路径到偏移量的标记列表
public var textAssertions: [String: RDEPUBCFITextAssertion] // 文本断言缓存
public var pathRanges: [RDEPUBCFIPathRange] // 路径范围列表
public var recoveryMetadata: RDEPUBCFIRecoveryMetadata // 恢复元数据
// 精确匹配 marker
public func marker(matching path: RDEPUBCFIPath) -> RDEPUBCFIMarker?
}
8.2 RDEPUBCFIMarker
每个 marker 记录一个 CFI 路径对应的章节信息:
public struct RDEPUBCFIMarker: Codable, Equatable {
public var cfiPath: RDEPUBCFIPath // CFI 路径
public var chapterOffset: Int? // 章节内字符偏移
public var fragmentID: String? // 片段 ID
public var textNodeLength: Int? // 文本节点长度
public var textNodeChecksum: UInt64? // 文本节点校验和(FNV-1a 64)
public var normalizedTextPreview: String? // 归一化文本预览(前 24 字符)
public var domSiblingSignature: String? // DOM 兄弟签名
}
8.3 RDEPUBCFIPathRange
描述一个 CFI 路径对应的文本偏移范围:
public struct RDEPUBCFIPathRange: Codable, Equatable {
public var cfiPath: RDEPUBCFIPath // CFI 路径
public var startOffset: Int // 范围起始偏移
public var endOffset: Int // 范围结束偏移
public var textNodeLength: Int // 文本节点长度
}
8.4 RDEPUBCFIRecoveryMetadata
恢复元数据,为容错引擎提供多维度恢复依据:
public struct RDEPUBCFIRecoveryMetadata: Codable, Equatable {
public var domFingerprint: String // DOM 指纹(SHA-256)
public var normalizedTextChecksum: String // 归一化文本校验和
public var tokenIndex: [RDEPUBCFITokenAnchor] // 词元锚点索引
public var fragmentPathMap: [String: RDEPUBCFIPath] // fragment -> 路径映射
}
8.5 RDEPUBCFITokenAnchor
词元锚点,用于基于内容的恢复:
public struct RDEPUBCFITokenAnchor: Codable, Equatable {
public var token: String // 采样词元(12 字符窗口,96 步长,最多 64 个)
public var occurrence: Int // 该词元的出现次数
public var chapterOffset: Int // 对应的章节偏移
public var cfiPath: RDEPUBCFIPath // 对应的 CFI 路径
}
8.6 映射构建流程
RDEPUBCFITextNodeMapBuilder.makeMap 方法负责构建完整映射:
- 归一化文本:解码 HTML 实体、合并连续空白、统一空白字符
- 提取 fragment 路径:通过
RDEPUBCFIDOMPathBuilder.fragmentPaths获取 id -> path 映射 - 构建 markers:逐标签遍历 HTML,维护 DOM 栈,为每个文本节点创建 marker
- 构建 pathRanges:将 markers 转换为偏移范围列表
- 构建恢复元数据:生成 DOM 指纹、文本校验和、词元锚点索引
9. 错误类型(RDEPUBCFIError)
文件:Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIError.swift
public enum RDEPUBCFIError: Error, Equatable {
case empty // 输入为空或空白
case invalidWrapper(String) // 缺少 epubcfi() 包装
case invalidPath(String) // 路径格式无效(不以 / 开头)
case unsupportedRange(String) // 范围格式不正确(非三段逗号分隔)
}
| 错误 | 触发条件 |
|---|---|
empty |
输入为 nil、空字符串或纯空白 |
invalidWrapper |
未以 epubcfi( 开头或未以 ) 结尾 |
invalidPath |
路径部分非空且不以 / 开头 |
unsupportedRange |
范围 CFI 的逗号分隔部分不等于 3 |
10. 使用场景与调用链
10.1 保存阅读位置
用户翻页
→ RDEPUBCFIGenerator.makeOffsetCFI(href, fileIndex, chapterOffset)
→ RDEPUBCFISerializer.serialize(cfi)
→ 存储 epubcfi(...) 字符串
10.2 恢复阅读位置
加载 epubcfi(...) 字符串
→ RDEPUBCFIParser.parse(rawValue)
→ RDEPUBCFIResolver.resolve(cfi)
→ 获取 href, fileIndex, chapterOffset
→ 若偏移量无效,进入恢复引擎
→ RDEPUBCFIRecoveryEngine.recover(cfi, cfiMap, chapterText, ...)
→ 依次尝试 exactPath → sibling → token → fragment → offset
10.3 高亮选中文本
用户选择文本范围
→ 获取 start/end DOM 位置
→ RDEPUBCFIGenerator.makeOffsetRangeCFI(href, fileIndex, startOffset, endOffset)
→ RDEPUBCFISerializer.serializeRange(range)
→ 存储范围 CFI
10.4 章节加载时构建索引
章节 HTML 加载完成
→ RDEPUBCFITextNodeMapBuilder.makeMap(href, rawHTML, chapterText, fragmentOffsets)
→ 生成 RDEPUBCFIMap(markers + pathRanges + recoveryMetadata)
→ 缓存供后续恢复使用
10.5 兼容性解析
外部导入非标准 CFI
→ RDEPUBCFICompatibility.parseLossy(rawValue) // 宽松解析
→ RDEPUBCFICompatibility.parseRangeLossy(rawValue) // 支持 .. / - 分隔符
文件清单
| 文件 | 职责 |
|---|---|
RDEPUBCFI.swift |
顶层 CFI 模型与侧偏枚举 |
RDEPUBCFIPath.swift |
路径模型与 step 定义 |
RDEPUBCFIRange.swift |
范围模型 |
RDEPUBCFIParser.swift |
字符串 → 模型解析 |
RDEPUBCFISerializer.swift |
模型 → 字符串序列化 |
RDEPUBCFIResolver.swift |
CFI → 资源定位结果 |
RDEPUBCFIGenerator.swift |
章节信息 → CFI 生成 |
RDEPUBCFIDOMPathBuilder.swift |
HTML → fragment 路径映射 + 文本节点映射构建 |
RDEPUBCFIRecoveryEngine.swift |
多级容错恢复引擎 |
RDEPUBCFITextAssertion.swift |
文本断言模型 |
RDEPUBCFICompatibility.swift |
非标准格式兼容解析 |
RDEPUBCFIMap.swift |
CFI 映射、marker、恢复元数据 |
RDEPUBCFIError.swift |
错误类型定义 |