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 生成器要自己干:
- 扫所有章节 HTML,找
data-en-id属性 → 建一个{en_id: 章节文件名}的映射表 - 提取定义,从
## 注释章节的data-endef-iddiv 里拿 - 把引用转成跨文件链接:
<sup class="endnote-ref"> <a id="enref-42" href="endnotes.xhtml#endef-42">[3]</a> </sup> - 生成
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> - 加到 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 强,而是性价比差太多:
| 方案 | 每本书成本 | 确定性 | 延迟 |
|---|---|---|---|
| 纯正则 | ¥0 | 100% | < 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 阶段流水线把原来需要人工校对的活变成了全自动。几个核心原则:
- 早检测,统计定性:逐页让 LLM 顺手检测 + 全书统计投票,零额外成本。
- 分开处理:不同类型的注释走不同路径,别指望一套逻辑通吃。
- 正则先上,AI 兜底:能确定性解决的别花钱调 API。
- data 属性做桥梁:一个简单的 HTML 属性协议,把 Markdown 处理和 EPUB 多文件结构连起来。
- 对账兜底比预防更实际:AI 合并丢信息是常态,与其试图完全防住,不如建好恢复机制,拿原始 OCR 当 ground truth。
最终效果:读者点上标跳注释、点返回回正文,跟翻纸书一样自然——但比纸书方便。
线上跑得怎么样?
这套流水线已经在线上跑了一段时间,我们一直在实时监测转换效果。从内部测试和真实用户的转换结果来看,大多数书处理得还不错——尤其是格式比较规范的学术著作和译著,注释格式遵循常见惯例的那种。
但也不是说完美无缺。确实还有一些 case 会翻车:比较奇葩的注释排版导致分类器判断失误、OCR 识别错误影响匹配、正文和注释章节里的章节名差太多导致模糊匹配也救不回来。还有一些边缘情况——注释嵌在表格里、多层嵌套注释、一本书里前半部分用脚注后半部分突然换成尾注——这些也偶尔会出问题。
这些失败的 case 我们都有在从日志和用户反馈里收集整理,分门别类,持续修复。每一轮修复基本上又会暴露出新的一类边缘情况,但整体趋势是越来越好的。
想试试效果?在 PDF2EPUB.ai 转换你的 PDF,脚注尾注自动处理。