ReadViewSDK/Doc/CFI_SUBSYSTEM.md
shenlei c65c190b71 feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化
- 实现EPUB阅读器搜索功能及选中注释功能
- 优化CFI模块,修复代码审查发现的11个问题
- 实现大书远距目录跳转与后台补全优化方案
- 优化设置面板与章节运行时联动
- 重构及大量改进优化
2026-06-22 20:26:34 +08:00

20 KiB
Raw Blame History

CFI 子系统文档

最后更新2026-06-18

本文档详细描述 ReadViewSDK 中 EPUB CFICanonical 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],用于增强定位鲁棒性
  • 范围 CFIepubcfi(/parent,/start,/end) 三段逗号分隔,表示起止范围
  • 侧偏;s=bbefore;s=aafter指示锚点偏向
  • 文本断言[prefix,exact,suffix],用于断言定位处的文本内容

2. 核心数据模型

2.1 RDEPUBCFI

顶层 CFI 模型,对应一个完整的 epubcfi(...) 字符串。

文件Sources/RDReaderView/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 = 100
  • sideBias = .before

2.2 RDEPUBCFIPath

路径模型,由一组 RDEPUBCFIStep 组成,描述从根节点到目标节点的遍历序列。

文件Sources/RDReaderView/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/RDReaderView/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/RDReaderView/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 方法):

  1. 去包装:剥离 epubcfi(...) 外层,提取 body
  2. 拆分包路径与内容路径:以第一个 ! 为分隔符
  3. 解析路径:按 / 分割为 step每个 step 可含 [idAssertion]
  4. 解析偏移与限定符:从内容路径中提取 :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/RDReaderView/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/RDReaderView/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 提取
  • fileIndexpackagePath 中倒数第二个 step 的 (index / 2) - 1
  • fragmentID:从 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/RDReaderView/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/RDReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift

public enum RDEPUBCFIDOMPathBuilder {
    // 从 HTML 中提取 fragmentID -> CFIPath 映射
    public static func fragmentPaths(in html: String) -> [String: RDEPUBCFIPath]
}

实现细节

  • 使用正则表达式匹配 HTML 标签,维护一个模拟 DOM 栈
  • 每遇到开标签,计算子节点索引(childIndex * 2),生成 RDEPUBCFIStep
  • 从标签属性中提取 idxml:id 作为 idAssertion
  • 识别并跳过 void 元素(brimginput 等)和可忽略标签(!doctype
  • 闭标签时弹出栈顶,重置该深度的子节点计数

5. 容错恢复引擎RDEPUBCFIRecoveryEngine

当 CFI 精确定位失败时(如 DOM 结构变更),恢复引擎通过多级降级策略尝试找到最佳匹配位置。

文件Sources/RDReaderView/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
  • 通过 siblingSignatureparent_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/RDReaderView/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 用于在窗口内搜索实际文本位置
  • prefixsuffix 用于对候选位置评分,优先选择上下文都匹配的位置

7. 兼容性处理RDEPUBCFICompatibility

提供宽松解析接口,兼容非标准 CFI 格式。

文件Sources/RDReaderView/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/RDReaderView/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 方法负责构建完整映射:

  1. 归一化文本:解码 HTML 实体、合并连续空白、统一空白字符
  2. 提取 fragment 路径:通过 RDEPUBCFIDOMPathBuilder.fragmentPaths 获取 id -> path 映射
  3. 构建 markers:逐标签遍历 HTML维护 DOM 栈,为每个文本节点创建 marker
  4. 构建 pathRanges:将 markers 转换为偏移范围列表
  5. 构建恢复元数据:生成 DOM 指纹、文本校验和、词元锚点索引

9. 错误类型RDEPUBCFIError

文件Sources/RDReaderView/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)
    → 生成 RDEPUBCFIMapmarkers + 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 错误类型定义