把 EPUB 里的弹窗脚注 / 跳转尾注,改写成紧跟正文的内嵌注释。 推送到 Kindle 之后,注释就印在引用它的那段正文下面 —— 不点、不跳、不弹窗。
用原生 Kindle 阅读器就能看,不需要越狱,不需要装 KOReader,不改推送方式。
转换前 转换后
───────────────── ─────────────────
正文段落……后面跟着角标[1]。 正文段落……后面跟着角标[1]。
(点一下 → 弹窗挡住半屏) [1] 注释正文注释正文……
(再点一下 → 跳到书末尾注) 正文继续往下走……
设备背景:在 Kindle Paperwhite 5 上开发并实测,书籍通过 Send to Kindle 推送 EPUB。 工具本身只是改 EPUB 文件,不依赖任何特定设备或越狱环境。
- 装好 Python 3(3.8 以上,装的时候勾 "Add Python to PATH")。
- 下载本仓库(右上角 Code → Download ZIP,或
git clone)。 - 把
.epub文件拖到拖到这里转换(内嵌注).bat上。
首次运行会自动建 .venv 并装 lxml,要联网,等几十秒。之后就是秒开。
输出文件在原书同一个文件夹,名字是 原文件名-tight.epub,原书不会被改动。
可以一次拖多本书上去。
pip install lxml
python epub_footnote_inline.py 书.epub --style tight --class-fallback也可以装成命令行工具,之后直接 kindle-footnote 调用:
pip install git+https://github.com/c30w/kindle-footnote.git
kindle-footnote 书.epub --style tight --class-fallback参数说明见下面「方法 B(命令行)」。
- 用 Send to Kindle 推 EPUB,受够了脚注弹窗挡住正文。
- 书里注释多(学术书、译著、古籍),跳来跳去根本读不下去。
- 想继续用原生 Kindle 阅读器,不想折腾 KOReader。
不适合:想要「严格意义上屏幕最底部、跟着翻页实时计算的页底注」的人 —— 那个只有 KOReader 的排版引擎做得到, 而它读不了推送的 KFX。详见下面第一节的原理说明。
Kindle 原生阅读器没有"脚注常驻页底"这个开关。脚注是弹窗、还是跳转、还是普通文字, 完全由书本身的标记决定:
<aside epub:type="footnote" id="fn1">…注释内容…</aside>只要书里是这种写法,阅读器就一定会做成点击弹窗。这个行为写在 Amazon 的渲染引擎(KFX 阅读器)里, KUAL 只能在系统里启动别的程序,改不了原生阅读器的渲染逻辑。
另外还有两点要清楚:
- Send to Kindle 推到 PW5 上的书,到手已经是 KFX 了(亚马逊服务器转的),不再是 EPUB。 所以"在设备上改 EPUB"这条路从一开始就不存在。
- KOReader 读不了 KFX(它只支持 EPUB / PDF / Mobi / FB2 等), 所以"装了 KOReader 也能读推送的书"是不成立的。
所以唯一可行的路就是:推送之前,在电脑上把书改好。
能做到什么程度:重排电子书没有"固定页",页面是 Kindle 打开时按当前字号实时算出来的, 所以在电脑上改书做不到"严格意义上的屏幕最底部"。 本工具做到的是注释紧跟在引用它的那段正文下面——翻页时它就在你眼前,不用点、不用跳、不弹窗, 实际阅读体验几乎等同于页底注。 (真正的动态页底注只有 KOReader 的排版引擎做得到,但它读不了推送的 KFX,本套工具不涉及这条路。)
原理:在电脑上把 <aside epub:type="footnote"> 里的注释文字搬到引用它的那段正文下面,
变成一段带小字号样式的普通 <div>,同时抹掉 epub:type 语义。
这样亚马逊转 KFX 时它就是普通段落,直接印在你读到的位置上 —— 不用点,不用跳,不会被弹窗挡住。
方法 A(推荐,零命令行):把 epub 文件拖到 bat 上
| 文件 | 效果 |
|---|---|
拖到这里转换(内嵌注).bat |
注释紧跟引用它的段落,纯缩进 1.2em、无竖线、行距 1 倍(tight 档,最省地方);同时兼容 class="fnote" 等老式脚注 |
- 可以一次拖多本书上去。
- 输出文件在原书同一个文件夹,名字是
原文件名-tight.epub,原书不会被改。 - 第一次运行会自动建一个本地 Python 环境并装 lxml(要联网,几十秒),之后就秒开了。
转换后长这样:
正文段落……后面跟着引用角标[1]。
[1] 注释正文注释正文注释正文……
- 角标
[1]留在正文里,变成不可点的上标(点它不再弹窗)。 - 注释块开头会自动补一个和角标一致的编号;如果注释正文自己已经带了同样的编号(比如「【3】…」),就不会重复加。
方法 B(命令行,可调参数)
.venv\Scripts\python.exe epub_footnote_inline.py 书.epub
:: 常用参数
--style tight|compact|loose 紧凑度三档(默认 compact;tight 最省地方)
-o 新文件名.epub 指定输出文件名
--suffix=-tight 输出原名 + 后缀(注意用等号,因为值以 - 开头)
--no-label 注释块前面不加编号
--keep-endnotes 文末的注释区也保留(注释会出现两次)
--keep-marker-link 角标保留可点击(默认改成不可点的上标)
--class-fallback 兼容老式 class="footnote" / class="fnote" 脚注(拖 bat 时已自动开启)
--no-drop-empty-file 保留被搬空后的"注释"页(默认自动删掉)
--trust-links 完全相信书里的链接(怀疑配错了才用,见下)很多转换出来的书(多看 / 微信读书导出、Calibre 转的)链接本身就是乱的,或者把脚注写成了老式 class="fnote" 段落,常见三种情况:
- 注释条目没写 id,回跳链接还是死的
href="#"—— 按链接找不到,注释就插不进去;现在会优先读取注释块内部回跳<a>的 id。 - 角标指到了别的注释上 —— 角标
[32]的href指向内容其实是[20]的那条。 照着链接走,就会把错误的注释插到正文里,比不处理还糟。 - 脚注定义和正文不在同一个 XHTML 文件 —— 现在支持按
partXXXX.html#id的跨文件链接反查,不再把它误判成未匹配。
脚本的做法是编号优先、链接兜底:先按 id / 回跳链接找,找到之后还要比对「角标上的编号」 和「注释正文开头的编号」是否一致;一致才信,不一致就改按编号重新配。 只在同一个 xhtml 文件内配对,不跨文件猜。
转换时会打印它做了什么:
识别脚注 541 条 / 命中引用 541 条 / 内嵌插入 541 块 / 未匹配 0 条
链接错位、按编号改配:12 条
注释没写链接、按编号补配:96 条
(实测一本 541 条注释的书:全部命中,其中约 20% 的注释链接在书里本来就是错的。)
如果你发现某本书配错了(注释内容和角标号对不上),加 --trust-links 退回「只信链接」的旧行为。
跑完会打印一行统计,例如:
识别脚注 38 条 / 命中引用 36 条 / 内嵌插入 36 块 / 未匹配 2 条
已移除搬空的注释页:OEBPS/notes.xhtml
- 未匹配 = 这条注释在正文里找不到引用它的角标,脚本就保持原样没动它。
- 如果"内嵌插入 0 块",命令行再加
--class-fallback试一次;拖 bat 本身已经自动开启这个兼容模式。
| 预设 | 字号 | 行距 | 上下留白 | 区分方式 | 适合 |
|---|---|---|---|---|---|
tight |
0.78em | 1 倍 | 0.1em / 0.22em | 纯缩进 1.2em,无竖线 | 注释多、想尽量不打断正文 |
compact |
0.82em | 1 倍 | 0.2em / 0.35em | 左侧 2px 细竖线 | 默认,注释和正文分得清 |
loose |
0.85em | 1.35 倍 | 0.6em / 0.9em | 左侧 2px 细竖线 | 注释长、需要看清换行 |
拖 bat 用的是 tight。想换档就用命令行 --style compact / --style loose。
同一段正文连着引了多条注释时,第二条及以后会自动加一个 kfx-note-adjacent 类,
把和上一条之间的空隙压到最小(tight 档是 0,compact 档是 0.05em),看起来是贴在一起的。
注释被搬进正文之前,脚本还会做两件清理,都是为了让注释块别被撑大:
-
拆掉无语义的包装标签。多看 / 微信读书导出的书长这样:
<aside epub:type="footnote"> <ol class="duokan-footnote-content"> <li class="duokan-footnote-item">[1] 注释正文……</li> </ol> </aside>
ol/li天生带缩进和项目符号,留着它每条注释前后都要多空一大截。 脚本会把它拍平成<div class="kfx-inline-note">[1] 注释正文……</div>。 判断标准是「只有一个 li」或「类名里带 footnote / note / duokan 等字样」—— 作者真的在注释里列了三条的那种列表会原样保留。 -
清掉注释里的回跳链接。注释开头那个
[1]通常是<a href="#noteref-xxx">, 点了会跳回正文。现在注释就在正文里,这个链接没用了,而且是死链(书本身链接错位时 还会跳错地方)。脚本按「站内跳转 + 链接文字只有一个编号」这个特征识别并删掉, 外链(http://)不动 —— 那种可能是注释里的引用出处。
打开 epub_footnote_inline.py,最上面有个 STYLE_PRESETS 字典,tight / compact / loose
三段的 CSS 都在里面,改完保存重新拖一次就行(用的是普通 CSS,单位用 em,会跟着你 Kindle
上的字号设置一起缩放):
.kfx-inline-note{font-size:0.78em;line-height:1;margin:0.1em 0 0.22em 0;padding:0 0 0 1.2em;text-align:justify;text-indent:0;}
.kfx-inline-note p,.kfx-inline-note div,.kfx-inline-note li{margin:0;line-height:1;text-indent:0;}
.kfx-inline-note ol,.kfx-inline-note ul{list-style:none;margin:0;padding:0;text-indent:0;}
.kfx-inline-note a{text-decoration:none;}
.kfx-note-marker{font-size:0.72em;vertical-align:super;line-height:1;}
.kfx-note-label{font-size:0.92em;margin-right:0.2em;}
.kfx-note-adjacent{margin-top:0;}几个常见改法:
- 想更紧凑:把
margin里的两个数继续调小(前者是距上一段,后者是距下一段);0 0 0.05em 0基本就是贴着了。 - 想加竖线:在
.kfx-inline-note里加border-left:2px solid #9a9a9a;。 - 想换线色:
#9a9a9a是灰,改#000(黑)或#c8c8c8(更浅)。 - 行距还想再压:
line-height:1已经是最小值了(= 单倍行距),再小字会叠在一起。 - 注释里的链接有下划线:已经有
.kfx-inline-note a{text-decoration:none;}去掉了。
只去掉角标上的 epub:type="noteref",Kindle 就不会弹窗了,变成点击跳转到文末注释
(文末那条注释里通常有返回链接)。还是要点一次,但至少不会盖住正文。
用 Calibre 的「编辑书籍」→「搜索替换(正则)」批量做:
查找:\s*epub:type="noteref"
替换:(空)
对所有 xhtml 文件执行一遍,保存,再推送。
Q:转换后 Kindle 上还是弹窗?
A:说明还有残留的 epub:type="footnote"。大概率是脚本没认出来(看"未匹配"数字),
试着加 --class-fallback;或者书里有第二套注释(比如译注)用了别的 class,
那就把那个 class 名加进 CLASS_DEF_RE 的正则里。
Q:注释编号丢了 / 重复了?
A:脚本会把正文角标的文字(如 [1])搬到注释块开头补上,所以编号不会丢。
如果注释正文自己开头就已经带了同样的编号(比如「【3】…」),脚本会跳过不补,
避免出现「[3]【3】…」这种重复。
判断只看注释正文开头那一个编号 —— 像「1848 年发生了很多事」这种以年份开头的不会被
误当成编号,「第一条:…」里的「一」也不会(它不是在最前面)。
万一还是重复,加 --no-label 全部不补,靠书里原有的编号。
如果重复出现在正文角标本身,而且原书结构是 <a><sup>(1)</sup></a>,旧版本可能会把外层和内层一起保留,显示成两个 (1);当前版本会自动把它展平成单层上标,不需要手工改 EPUB。
Q:注释内容跟角标号对不上?
A:先别急,这种情况脚本大概率已经自动纠正了(见上面「编号优先」那节)。
如果确实还有对不上的,加 --trust-links 对比一次,看哪种更准。
两个都不对的话,说明这本书的注释编号本身就不连续,那就 --no-label 去掉自动编号,
只保留注释原文里自带的编号。
Q:注释块之间空得还是太大? A:按这个顺序排查:
- 确认用的是
--style tight(拖 bat 默认就是)——行距已经是 1 倍、上下只剩 0.1em / 0.22em。 - 书如果是多看 / 微信读书导出的,确认注释块里没有残留
ol/li—— 脚本会自动拆平, 但要是它没认出来(包装标签上既没编号类 class、又有多个 li),就把那个 class 名加进LIST_WRAP_CLASS_RE的正则里。 - 还嫌大就按上面「样式不满意怎么改」直接调 CSS 里的
margin—— 前者是距上一段,后者是距下一段。
Q:推送后提示格式不支持?
A:先确认输出文件是 .epub 且能正常打开。Send to Kindle 单文件上限 200MB。
本工具不会加 DRM,也不会动封面、目录、字体。
Q:会损坏原书吗?
A:不会。原文件只读,结果写到 原文件名-tight.epub。
Q:还能调字体、字号、行距吗?
A:能,而且注释会跟着一起变。脚本注入的 CSS 只用 em 作为单位(0.78em = 正文字号的 78%),
不写死 px,也不设 position / height,所以 Kindle 上怎么调版式都不会把注释撑坏。
| 文件 | 用途 |
|---|---|
epub_footnote_inline.py |
主程序:识别弹窗脚注,搬到引用它的段落后面 |
拖到这里转换(内嵌注).bat |
一键转换(中文名),输出 原文件名-tight.epub |
convert-drag-and-drop.bat |
同上,英文文件名版,内容完全一致 |
requirements.txt |
依赖清单,只有一个 lxml |
_test/run_tests.py |
一键回归测试:6 样本 × 3 档 + 结构校验 + 配对校验 |
_test/ |
其余测试样本与校验脚本,删掉不影响使用 |
DESIGN.md |
设计说明与踩坑记录,给想改代码的人 |
.venv/ |
本地 Python 环境,首次运行自动生成,不上传 |
_test/ 里 6 个样本分别覆盖:标准 EPUB3 脚注、注释单独一个文件、不规范 HTML
(未闭合 <br>、只有 class="footnote")、同段多引用 + 编号去重、链接错位的书、
驼峰类名的书(class="noteContent",角标外面还套了一层 class="subScript")。
run_tests.py 一键跑完全流程;verify_outputs.py 校验成书结构(mimetype/zip/XML/OPF),
check_pairing.py 校验「每个角标后面跟的注释编号是否对得上」。
| 书的写法 | 例子 | 是否需要 --class-fallback |
|---|---|---|
| 标准 EPUB3 | <aside epub:type="footnote" id="fn1"> |
不需要 |
类名 footnote / fnote |
<p class="fnote"> |
需要(拖 bat 已默认开启) |
| 驼峰 / 连字符类名 | <p class="noteContent">、note-content |
需要(拖 bat 已默认开启) |
| 多看 / 微信读书 | duokan-footnote-item |
需要(拖 bat 已默认开启) |
| 注释和正文分属不同文件 | 角标 href="part0057.html#ch53" |
不需要,自动跨文件配对 |
id 写在注释内部的 <a> 上 |
<p class="fnote"><a id="ch53"> |
不需要,自动识别 |
角标类名里带 ref / marker / sub / sup 的(比如 noteref、noteRef、subScript)
会被当成正文角标而不是注释定义,不会误搬正文。
本工具只做格式重排:把书籍中已有的注释挪个位置,不解密、不移除任何 DRM。 请仅对你合法拥有的电子书使用,并遵守当地法律与书籍的授权条款。
输出的是一本新的 EPUB,原文件只读、不会被修改。
MIT License —— 随便用、随便改,注明出处即可。
回归测试覆盖以下结构(_test/ 里有对应样本,可自行复现):
| 书 | 注释数 | 结果 |
|---|---|---|
| 标准 EPUB3 脚注 | — | 全量通过 |
老式 class="fnote"(分属不同 XHTML) |
6492 | 识别 6492 / 命中 6479 / 未匹配 13 |
驼峰类名 class="noteContent" |
2421 | 识别 2421 / 命中 2421 / 未匹配 0 |
| 真书(注释含无链接条目) | 541 | 识别 541 / 命中 541 / 按编号补配 108 |
「未匹配」= 这条注释在正文里找不到引用它的角标,脚本保持原样不动,不会乱猜。
跑回归测试(6 个样本 × 3 档,含结构校验和配对校验):
pip install lxml
python _test/run_tests.py样本文件已随仓库提供,直接跑即可。想重新生成样本就 python _test/run_tests.py --regen。