PDF 转 EPUB 最难的部分没人提:脚注和尾注的完整处理方案

大多数 PDF 转 EPUB 工具会悄悄丢失脚注和尾注。我们构建了一套 6 阶段流水线,实现注释的自动检测、分类、提取和关联,匹配准确率超过 90%——本文是完整的技术拆解。

|PDF2EPUB Team

PDF 转 EPUB 最难的部分没人提:脚注和尾注


0. 注释都去哪了?

随便找一本学术书、历史专著或者译注本,15%–30% 的有效内容在脚注和尾注里。一本正经的非虚构书通常有 200–500 条注释,学术味儿重的甚至上千条。

用 Calibre 或者随便哪个在线工具把 PDF 转成 EPUB,看看注释变成了什么样?

要么直接没了,要么更惨——变成散落在电子书各处的文本碎片,上标标记和注释内容之间的关联断了,读者根本没法跳转。

这不是什么小概率事件,几乎所有 PDF 转 EPUB 工具都是这样。

原因很简单:PDF 按页面组织,EPUB 按流式排版。PDF 里脚注在第 47 页底部,EPUB 里根本没有「第 47 页」这个概念——内容跟着屏幕大小和字号动态重排。正文里的上标 "¹" 和页底的注释内容之间那个空间关系,在 EPUB 的 DOM 里压根不存在。

搞定这个问题光靠提文字不行,得真正理解注释是什么、全书怎么组织的,然后在一个完全不同的格式里把它重建出来。

我们为此做了一套 6 阶段的注释处理流水线。下面是完整的技术拆解。


1. 先搞清楚:注释有很多种

动手处理之前,得先搞明白你面对的是哪种注释。我们分析了几千本真实 PDF,总结出两个独立的分类维度:

1.1 注释放在哪(5 种)

类型什么意思常见于
footnote 脚注注释就在引用的同一页底部人文社科教材
endnote_book 书末尾注全部注释集中在书末尾一个专门章节学术专著、译著
endnote_chapter 章末尾注注释放在每章末尾部分社科著作
mixed 混合脚注和尾注混着用有译者注又有作者尾注的书
none 无注释没注释小说、童书

1.2 引用标记长什么样(4 种)

格式长这样Unicode 范围
superscript_number 上标数字¹ ² ³ ⁴ ⁵U+00B9, U+00B2, U+00B3, U+2074–U+2079
circled_number 圈码① ② ③ ④ ⑤U+2460–U+2473
bracket 方括号[1] [2] [3]ASCII 方括号
symbol 符号* † ‡ §剑号/节号

为什么要分这么细? 因为不同组合的处理策略完全不一样。footnote + circled_number 得从同页恢复定义;endnote_book + superscript_number 得先把书末注释章节整个提出来,再做跨章节链接。一刀切必挂。


2. 阶段一:OCR 时顺手检测(零额外开销)

第一步是在每一页上检测注释相关的信息。传统 OCR 输出的就是纯文本,它分不清「这是脚注引用」还是「这是正文」。

我们的做法:搭便车。反正每页图片都要发给多模态 LLM(Gemini Flash)做 OCR,在 prompt 里多加一句话,让它在输出末尾附带一个结构化的元数据块:

<!--NOTES_META
has_note_refs: true/false
has_note_defs: true/false
ref_format: superscript_number/circled_number/bracket/symbol/none
def_count: 3
is_notes_section: false
-->

五个字段,每个抓一个信号:

  • has_note_refs:正文里有没有注释引用标记(上标、圈码、方括号、符号)
  • has_note_defs:这一页有没有注释定义(就是那些解释性文字)
  • ref_format:引用标记是哪种格式
  • def_count:这页有几条注释定义
  • is_notes_section:整页是不是都在一个专门的注释章节里

为什么行得通

LLM 本来就「看到」了整页图片,理解页面的语义结构。让它多判断一下注释有没有,只是 prompt 里加几行字的事——不用多打一次 API,不用多传一张图,多输出的 token 也就 ~50 个(OCR 正文一般要 ~2,000 token)。边际成本约等于

元数据用 HTML 注释包着,下游的 Markdown 处理完全看不见它,解析就一行正则:

_META_PATTERN = re.compile(r'<!--NOTES_META\s*\n(.*?)-->', re.DOTALL)

解析完就把这块从 OCR 文本里删掉,后面的流程拿到的是干净内容。


3. 阶段二:统计聚合,投票定类型

单页的元数据有噪声——LLM 偶尔会判错。所以需要一个聚合层,用统计方法从全书维度做出稳健的判断。

3.1 分类逻辑

本质上就是一棵决策树,阈值都是跑真实数据调出来的:

输入:page_metas(每页元数据),total_pages

1. ref_pages  = has_note_refs = true 的页
2. def_pages  = has_note_defs = true 的页
3. notes_section_pages = is_notes_section = true 的页

4. 没有 ref_pages → "none",直接结束

5. 如果有 notes_section_pages:
   a. threshold = total_pages × 0.7
   b. end_pages = 在 threshold 之后的 notes_section_pages
   c. |end_pages| ≥ |notes_section_pages| × 0.7 → "endnote_book"
   d. 否则 → "endnote_chapter"

6. 没有 def_pages → "unknown"(有引用没定义)

7. co_occur = ref_page_nums ∩ def_page_nums
   |co_occur| ≥ |ref_page_nums| × 0.5 → "footnote"

8. 以上都不满足 → "mixed"

3.2 几个阈值为什么这么选

70% 位置阈值(步骤 5a):300 页的书,注释章节得在第 210 页之后才算「书末」。不然中间出现的术语表、参考文献会被误判。

70% 集中度(步骤 5c):检测出来的注释章节页面,至少 70% 要落在书末区域。允许少量页面被 OCR 误标——多数票说了算。

50% 共现(步骤 7):超过一半有注释引用的页面同时也有注释定义 → 脚注(定义就在页底)。对个别页面的漏检容忍度够高。

3.3 引用格式怎么定

简单粗暴,数票:

ref_formats = [m.ref_format for m in ref_pages if m.ref_format != 'none']
format_counter = Counter(ref_formats)
primary_ref_format = format_counter.most_common(1)[0][0]

出现最多的格式赢。


4. 阶段三:尾注预提取(正则打底,AI 兜底)

如果判定是 endnote_book,注释章节必须在合并正文之前单独提出来——一方面把这些页面从合并输入里剔除(避免重复),另一方面提前拿到结构化的定义数据,后面对账要用。

4.1 策略:正则先上,AI 兜底

正则解析(快、确定性高、免费)
  ↓ 不行?
AI 解析(Gemini Flash 输出结构化 JSON)

为什么不直接用 AI?因为正则免费、确定性 100%、跑得快。格式规范的注释章节(大多数书都是)正则就能搞定,没必要花钱调 API。

4.2 正则认识 4 种章节标题

注释章节里通常有子标题把注释按章分组,正则识别四种写法:

sub_heading_re = re.compile(
    r'(?:^#{1,4}\s+(.+?)$)'                    # ### 章节名
    r'|(?:^\*\*(.+?)\*\*\s*$)'                  # **章节名**
    r'|(?:^(第[一二三四五六七八九十百千\d]+[章节篇部]'
    r'(?:\s+.+?)?)$)'                            # 第X章 标题
    r'|(?:^((?:Chapter|Part)\s+\d+.*)$)',        # Chapter N...
    re.MULTILINE | re.IGNORECASE
)

注释条目也认 4 种编号格式:

note_pattern = re.compile(
    r'(?:^|\n)\s*(\d+)\s*[.、))\s]\s*(.+?)(?=\n\s*\d+\s*[.、))\s]|\n\n|\Z)',
    re.DOTALL
)

能匹配 1. 内容1、内容1)内容1 内容

4.3 可靠性兜底:提不到 3 条就换 AI

正则提取出来的注释少于 3 条?直接判定不可靠,切 AI。这是为了防止正文里的编号列表被误认成注释。

4.4 AI 提取:让 LLM 输出 JSON

正则搞不定的时候,把拼好的注释页 OCR 文本扔给 Gemini Flash,让它直接输出结构化 JSON:

{
  "chapters": {
    "第一章": {
      "1": "注释内容1",
      "2": "注释内容2"
    },
    "第二章": {
      "1": "注释内容1"
    }
  }
}

prompt 里会把全书目录也带上,方便 LLM 把注释子标题和真实章节名对上。文本限制 10 万字符,防止撑爆上下文。

4.5 章节名匹配:三级模糊查找

注释里写的章节名和正文标题经常对不上(比如正文是「导论:方法与路径」,注释里就写个「导论」)。所以 EndnoteDatabase 做了三级模糊匹配:

1. 精确匹配:"第一章 起源" == "第一章 起源"
2. 规范化匹配:去空格和标点再比
3. 子串匹配:"第一章" ⊂ "第一章 起源"(匹配长度 ≥ 2 才算)

5. 阶段四:合并时注入注释处理指令

Markdown 合并阶段(逐页 OCR 整合成连贯文档),系统根据前面判定的 note_style,往 AI 合并 prompt 里动态注入不同的指令。

5.1 关键设计:双标记体系

用两种不同的标记把脚注和尾注分开,这是最核心的决策:

注释类型标记格式为什么
脚注(同页有定义)[^N] + [^N]: 定义标准 Markdown 脚注,定义跟着引用走
尾注(定义在别处)<sup>N</sup>先存成原始 HTML,后面再对账

5.2 按类型注入不同 prompt

endnote_book / endnote_chapter

上标数字(¹ ² ³)→ 写成 <sup>N</sup>转成 [^N] 圈码(①②③)且同页底部有定义 → 用 [^N],定义放批次末尾 别试图给尾注引用编造注释内容

footnote

引用标记转 [^N] 定义放批次末尾:[^N]: 内容 编号全文唯一连续

mixed

页底有定义 → [^N] 页底没定义 → <sup>N</sup>

这样做的目的是防一个很要命的问题:AI 给尾注引用「编」一个脚注定义——当前页面根本没有这个注释内容,但 AI 觉得应该有,就自己生成一个。双标记体系从格式层面堵死了这条路。


6. 阶段五:合并后对账

合并出完整 Markdown 之后,跑两轮对账把注释补回来、关联上。

6.1 脚注恢复

AI 合并的时候有时会把脚注定义([^N]: 内容)搞丢,但引用([^N])留着了。对账逻辑:

1. 扫 markdown 里的 [^N] 引用 → referenced_ids
2. 扫 markdown 里的 [^N]: 定义 → defined_ids
3. orphaned = referenced_ids - defined_ids(丢了的)
4. 每个丢了的 ID:
   a. 回原始 OCR 文本里找 [^N]: 定义
   b. 也找圈码格式的定义(①②③)
   c. 找到了就追加到 markdown 末尾

关键点:原始 OCR 文本是 ground truth。AI 可能重新组织或丢掉了定义,但 OCR 原文里还好好留着。

6.2 尾注对账:跨章节链接

尾注比脚注麻烦得多。引用在第二章(<sup>3</sup>),定义在书末注释章节(第 3 条),它们在文档里隔着好远。EPUB 拆章之后更是在不同的 XHTML 文件里。标准 Markdown 脚注要求引用和定义在同一个块里,没法跨章节。

怎么办?我们设计了一套 HTML data 属性协议来做跨章节链接:

步骤 1: 扫描正文中的 <sup>N</sup>,去 EndnoteDatabase 里
        按「章节名 + 编号」查定义

步骤 2: 替换成带 data 属性的标签:
        <sup data-en-id="42" data-en-num="3">3</sup>

步骤 3: 所有定义收到一个 ## 注释 章节里:
        <div data-endef-id="42">3. 定义文本</div>

步骤 4: 全局自增 ID(en_counter),防止不同章节
        用了相同的注释编号导致 ID 冲突

为什么用 data 属性?因为 markdown.markdown() 处理时会保留原始 HTML 不动,这些标签能安全穿过 Markdown 渲染,到 EPUB 生成阶段再转成真正的跳转链接。

6.3 两层匹配策略

第一层:按章节匹配
  - 从 markdown 里提取章节结构
  - 每个章节去 EndnoteDatabase 里找对应的注释
  - 章节名模糊匹配(精确 → 规范化 → 子串)

第二层:全局兜底
  - 第一层没匹配上的 <sup>N</sup>
  - 把 EndnoteDatabase 全部注释扁平化
  - 只按编号匹配,不管属于哪章

第二层是安全网,处理 OCR 出来的章节名跟注释里的对不上的情况。

6.4 匹配率监控

每次对账都记日志:

pre_sup_count = len(re.findall(r'<sup>(\d+)</sup>', markdown))
# ... 对账 ...
post_sup_count = len(re.findall(r'<sup>(\d+)</sup>', result))
match_rate = (pre_sup_count - post_sup_count) / pre_sup_count * 100
print(f"匹配率: {match_rate:.1f}%")

线上跑下来,格式规范的书 85%–95% 匹配率。没匹配上的主要原因:OCR 把注释编号认错了,或者章节名实在对不上。

6.5 清理残留的注释章节文本

尾注页面虽然在合并前排除了,但 AI 合并有时还是会把一些注释内容带进来。对账之后需要清一下。这里有个安全阀:

# 注释标题在文档前 30%(endnote_book)或 50%(其他类型)出现的,不删
position_threshold = 0.3 if notes_meta.note_style == 'endnote_book' else 0.5
if heading_match.start() < len(markdown) * position_threshold:
    return markdown  # 别删,这八成是个正经章节

防的是什么?有些社科书前面就有一章叫「方法论笔记」或者「研究札记」,标题里带「注」字,但它不是尾注。

6.6 处理标题里的脚注

还有一个容易踩的坑:章节标题里有脚注引用。比如 ## 导论[^1],EPUB 拆章之后这个脚注就跨章节了,链接必断。

处理方式——把标题里的脚注转成行内斜体:

转换前:## 导论[^1]
         [^1]: 写于 1985 年

转换后:## 导论
        *写于 1985 年*

简单粗暴,但有效。


7. 阶段六:EPUB 生成,做出能点的链接

最后一步,把处理好的 Markdown 转成 EPUB 里真正能交互的脚注和尾注。

7.1 脚注走 Python-Markdown 扩展

标准脚注([^N] + [^N]: 定义)直接交给 Python-Markdown 的 footnotes 扩展,它会生成 EPUB 兼容的 HTML:

<!-- 正文里的引用 -->
<sup class="footnote-ref"><a href="#fn-1">1</a></sup>

<!-- 章节末尾的定义 -->
<div class="footnote">
  <ol>
    <li id="fn-1"><p>定义文本 <a class="footnote-backref" href="#fnref-1"></a></p></li>
  </ol>
</div>

点上标跳到注释,点 ↩ 跳回来,双向导航。

7.2 尾注自己生成 endnotes.xhtml

尾注没法用现成扩展,因为引用和定义不在同一个章节文件里。EPUB 生成器要自己干:

  1. 扫所有章节 HTML,找 data-en-id 属性 → 建一个 {en_id: 章节文件名} 的映射表
  2. 提取定义,从 ## 注释 章节的 data-endef-id div 里拿
  3. 把引用转成跨文件链接
    <sup class="endnote-ref">
      <a id="enref-42" href="endnotes.xhtml#endef-42">[3]</a>
    </sup>
    
  4. 生成 endnotes.xhtml,每条定义带返回链接:
    <div class="endnote-item" id="endef-42">
      <p class="endnote-text">
        <span class="endnote-num">[3]</span> 定义文本
        <a class="endnote-backref" href="chapter_02.xhtml#enref-42"></a>
      </p>
    </div>
    
  5. 加到 EPUB 书脊和目录里

7.3 CSS 样式 + 暗色模式

脚注和尾注都有专门的样式,在电子阅读器上效果不错:

/* 脚注引用——小巧不碍事 */
.footnote-ref {
    font-size: 0.75em;
    vertical-align: super;
    line-height: 0;
}

/* 脚注区域——和正文隔开 */
div.footnote {
    margin-top: 2em;
    padding-top: 1em;
    border-top: 1px solid #ccc;
    font-size: 0.9em;
}

/* 尾注引用链接 */
.endnote-ref a {
    color: #0066cc;
    font-size: 0.75em;
    vertical-align: super;
}

暗色模式也考虑了:

@media (prefers-color-scheme: dark) {
    div.footnote { border-top-color: #555; }
    .endnote-ref a,
    .endnotes-section .endnote-num,
    a.endnote-backref { color: #6db3f2; }
}

8. 踩过的坑和工程经验

8.1 正则优先 + AI 兜底,比纯 AI 靠谱

尾注提取我们选择正则打主力、AI 兜底。不是说正则比 AI 强,而是性价比差太多:

方案每本书成本确定性延迟
纯正则¥0100%< 10ms
纯 AI~¥0.15看心情~3 秒
正则 → AI 兜底¥0–0.15能确定就确定10ms–3s

实际跑下来正则能搞定 ~70% 的书。多数情况下又快又稳,没必要花钱。

8.2 位置阈值防误删

删注释章节这事得小心。300 页的书,第 15 页出现一个标题叫「注释」,那大概率是正经章节;第 280 页出现的才是尾注。所以加了个位置阈值(文档长度的 30%–50%),前面的不动。

8.3 修 AI 生成的烂 HTML

AI 合并偶尔吐出残缺的 HTML,开标签截了半截:

</sup>-id="44" data-en-num="7">7</sup>

用一个精准正则清理,只匹配这种结构性损坏,不误伤正常标签:

pattern = r'</(\w+)>-?[\w-]+="[^"]*"(?:\s+[\w-]+="[^"]*")*>[^<]*</\1>'

8.4 断点续传

整个流水线支持 checkpoint。注释元数据和尾注数据库提取完就存到云端。500 页的书合并到一半挂了?从最后一个成功的批次接着来,不用重跑 OCR。


9. 整体架构一图看完

┌─────────────────────────────────────────────────────────────┐
│  阶段一:OCR + 检测                                           │
│  页面图像 → Gemini Flash → Markdown + NOTES_META              │
│  额外成本:$0(搭 OCR 的便车)                                  │
└──────────────────────────┬──────────────────────────────────┘
                           ▼
┌─────────────────────────────────────────────────────────────┐
│  阶段二:聚合分类                                              │
│  逐页元数据 → 统计投票 → NotesMeta                             │
│  输出:note_style, primary_ref_format, section_pages          │
└──────────────────────────┬──────────────────────────────────┘
                           ▼
┌─────────────────────────────────────────────────────────────┐
│  阶段三:尾注预提取(endnote_book 才走这步)                     │
│  注释页 → 正则解析 → AI 兜底 → EndnoteDatabase                │
│  门槛:提不到 3 条就切 AI                                      │
└──────────────────────────┬──────────────────────────────────┘
                           ▼
┌─────────────────────────────────────────────────────────────┐
│  阶段四:合并注入指令                                          │
│  按 note_style 给 AI 合并 prompt 加注释处理规则                 │
│  脚注 → [^N],尾注 → <sup>N</sup>                             │
└──────────────────────────┬──────────────────────────────────┘
                           ▼
┌─────────────────────────────────────────────────────────────┐
│  阶段五:合并后对账                                            │
│  脚注恢复 + 尾注匹配 + 标题脚注处理                              │
│  用 data-en-id / data-endef-id 做跨章节链接                    │
└──────────────────────────┬──────────────────────────────────┘
                           ▼
┌─────────────────────────────────────────────────────────────┐
│  阶段六:EPUB 生成                                            │
│  脚注 → Python-Markdown footnotes 扩展                       │
│  尾注 → 自己生成 endnotes.xhtml + 双向跳转                     │
│  CSS 带暗色模式                                               │
└─────────────────────────────────────────────────────────────┘

10. 总结

脚注和尾注是 PDF 转 EPUB 里最容易翻车、也最少有人认真做的部分。问题跨了格式检测、结构解析、跨章节链接、最终渲染四个环节,每个环节都有自己的一堆细节。

我们这套 6 阶段流水线把原来需要人工校对的活变成了全自动。几个核心原则:

  1. 早检测,统计定性:逐页让 LLM 顺手检测 + 全书统计投票,零额外成本。
  2. 分开处理:不同类型的注释走不同路径,别指望一套逻辑通吃。
  3. 正则先上,AI 兜底:能确定性解决的别花钱调 API。
  4. data 属性做桥梁:一个简单的 HTML 属性协议,把 Markdown 处理和 EPUB 多文件结构连起来。
  5. 对账兜底比预防更实际:AI 合并丢信息是常态,与其试图完全防住,不如建好恢复机制,拿原始 OCR 当 ground truth。

最终效果:读者点上标跳注释、点返回回正文,跟翻纸书一样自然——但比纸书方便。

线上跑得怎么样?

这套流水线已经在线上跑了一段时间,我们一直在实时监测转换效果。从内部测试和真实用户的转换结果来看,大多数书处理得还不错——尤其是格式比较规范的学术著作和译著,注释格式遵循常见惯例的那种。

但也不是说完美无缺。确实还有一些 case 会翻车:比较奇葩的注释排版导致分类器判断失误、OCR 识别错误影响匹配、正文和注释章节里的章节名差太多导致模糊匹配也救不回来。还有一些边缘情况——注释嵌在表格里、多层嵌套注释、一本书里前半部分用脚注后半部分突然换成尾注——这些也偶尔会出问题。

这些失败的 case 我们都有在从日志和用户反馈里收集整理,分门别类,持续修复。每一轮修复基本上又会暴露出新的一类边缘情况,但整体趋势是越来越好的。


想试试效果?在 PDF2EPUB.ai 转换你的 PDF,脚注尾注自动处理。

准备好转换您的 PDF 了吗?

免费试用 PDF2EPUB.ai - AI 驱动的 PDF 转 EPUB 转换,支持 OCR、公式保留和精美排版。

免费试用 PDF2EPUB

相关文章