PAYATHON 2026

pandoc API 的 writeEPUB3 重复脚注且缺少索引编号

排障赵师傅

结论

Markdown 脚注语法没有问题,writeEPUB3 也没有生成两份脚注。脚注正文仍保存在 EPUB 的 XHTML 中,阅读器会根据 epub:type="noteref" 和 epub:type="footnote" 将其显示为弹窗。如果相应样式没有生效,脚注正文还会出现在章节末尾,看上去像重复了一次。

pandoc CLI 生成 EPUB 时,会将输出作为独立文档处理并加载 EPUB 样式。直接调用 API 时,def 中的 writerStandalone 是 False,需要显式设为:

def { writerStandalone = True }

如果只想保留弹窗脚注,这通常可以解决 API 与 CLI 输出不一致的问题。显示带编号的脚注列表或将水平线换成标题,需要自定义 CSS。要让编号成为可点击的回链,还得修改生成的文档结构,单靠 CSS 无法实现。

修正后的 Haskell 代码

import qualified Data.ByteString.Lazy as BL
import qualified Data.Text as T
import Text.Pandoc

main :: IO ()
main = do
  mdfile <- readFile "c6note.md"

  let readerOpts =
        def
          { readerExtensions =
              enableExtension Ext_footnotes (readerExtensions def)
          }

      writerOpts =
        def
          { writerStandalone = True
          }

  result <- runIO $ do
    doc <- readMarkdown readerOpts (T.pack mdfile)
    writeEPUB3 writerOpts doc

  case result of
    Left err ->
      print err
    Right epub ->
      BL.writeFile "c6note.epub" epub

pandoc 的默认 Markdown 扩展集合通常已经启用了 Ext_footnotes,这里显式保留也没有问题。影响当前显示结果的是 writerStandalone = True。

为什么 CLI 没有显示章节末尾的脚注

执行以下命令时:

pandoc -o note.epub c6note.md

CLI 知道目标格式是 EPUB,因此会生成完整的独立文档,并为 XHTML 页面配置 EPUB 所需的模板和样式。

直接调用:

writeEPUB3 def doc

会将默认的 WriterOptions 原样传给 writer。def 与 CLI 为 .epub 输出组装的选项并不完全相同,其中就包括 writerStandalone。

生成的 EPUB3 脚注大致分为两部分。正文中有脚注引用:

<a epub:type="noteref" href="#fn1">1</a>

同时还有对应的脚注目标:

<aside id="fn1" epub:type="footnote">
  <p>the note!</p>
</aside>

阅读器通过前者打开后者。弹窗要正常工作,脚注目标就必须保留在 XHTML 中。默认 EPUB 样式会将它隐藏在普通页面流之外;样式失效后,aside 便会直接出现在章节末尾。

弹窗和末尾文字来自同一份脚注,只是阅读器用了两种方式呈现。

只保留弹窗脚注

先设置:

writerOpts = def { writerStandalone = True }

需要自定义样式时,可以使用下面的规则:

@namespace epub "http://www.idpf.org/2007/ops";

[epub|type~="footnote"] {
  display: none;
}

不同版本的 pandoc 可能使用 aside 或其他容器,因此属性选择器通常比限定元素名更可靠:

[epub|type~="footnote"] {
  display: none;
}

有些 EPUB 阅读器会自行处理脚注的显示方式,仍需在目标阅读器中实际检查。

同时显示弹窗和脚注列表

可以用 CSS 让脚注目标重新进入页面流:

@namespace epub "http://www.idpf.org/2007/ops";

section.footnotes {
  counter-reset: footnote;
}

[epub|type~="footnote"] {
  display: block;
  counter-increment: footnote;
  margin: 0.75em 0;
}

[epub|type~="footnote"]::before {
  content: counter(footnote) ". ";
  font-weight: bold;
}

这段样式会显示视觉编号,但 CSS 生成的编号只是文本,无法自动变成指向正文引用位置的链接。

pandoc 生成的正文脚注标记已经通过 href="#fn1" 指向脚注目标,所以从正文跳到脚注不依赖脚注正文前的编号。若想从列表编号返回正文,必须在 XHTML 中生成真实的 <a href="#fnref1">...</a>。CSS 的 content 无法创建带 href 的链接。

可以用以下方法生成回链:

  • 写入 EPUB 前,用 Haskell 遍历 Pandoc AST,另外生成一组带锚点的有序列表;
  • 使用 Pandoc filter 生成独立的尾注区;
  • 生成 EPUB 后处理其中的 XHTML,补充回链元素。

如果需要保留弹窗,不要删除 epub:type="noteref" 和 epub:type="footnote",否则部分阅读器将无法把脚注识别为弹窗。

Markdown 中的 [^3] 是脚注标识符,不保证最终显示为数字 3。pandoc 通常按脚注在正文中的出现顺序重新编号,因此这个最小示例显示为 1 是正常的。

将水平线替换为标题

如果只需要改变视觉效果,可以隐藏水平线,再通过伪元素显示标题:

section.footnotes > hr {
  display: none;
}

section.footnotes::before {
  content: "注释";
  display: block;
  margin: 1.5em 0 0.75em;
  font-size: 1.4em;
  font-weight: bold;
}

这种标题由 CSS 生成,并非 XHTML 中真正的标题元素,因此不会进入目录,对辅助技术的支持也有限。

需要语义正确的标题时,应在 AST 中插入 Header,或通过 filter 生成类似结构:

<section class="footnotes">
  <h2>注释</h2>
  ...
</section>

自定义 CSS 的加载方式

style.css、epub.css 和 style.html 这些文件名没有特殊含义。pandoc 不会因为文件位于当前目录就自动加载它。自定义样式需要明确传给 writer,也可以在输入文档的元数据中声明:

---
title: a single note
css:
  - notes.css
---

相对路径通常以程序运行时的工作目录或配置的资源搜索路径为基准。不同 pandoc 版本提供的样式和资源路径 API 可能不同,不能依靠文件名推测搜索位置。如果所用版本没有通过元数据将样式打包进 EPUB,应使用对应版本的 writerVariables 或资源路径 API 显式注册 CSS。

排查时,可以将 .epub 作为 ZIP 文件解压,检查 XHTML 中是否包含 <link rel="stylesheet">,并确认 CSS 文件已被打包。这样就能判断是选择器没有匹配,还是样式根本没有加载。

备注:内容仅供参考。