利用 Pandoc Lua Filter 将 Markdown 中的 Alerts Quote Block 渲染为 \LaTeX\ 环境 (浅试 Lua 过滤器脚本编写,附 Lua 脚本文件 `callout2latex.lua')
[!abstract]
本文从笔者现有的基于 pandoc 的 Markdown to PDF via \LaTeX\ 的写作方案出发,为实现在 Markdown 中指定某一段特定的文本为某个特定的 \LaTeX\ 环境,尝试了现有的解决方案,分析了现有的解决方案在编写 Markdown 文件时的跨平台语法支持问题。随后,浅析了 pandoc 的过滤器机制和 Alerts 语法,提出了新的解决方案并编写了 Lua 过滤器脚本。
问题与背景
起因
正如在我 2024 年 11 月 25 日写过的博客 中提及的,我给自己搞了一套方案,实现了从 Markdwon 文件经由 \LaTeX\ 引擎排版,到 PDF 文章的无缝衔接输出,方案的细节详见博客原文。
这种方案的好处在于,我所有的写作不仅可以在所有支持 Markdown 语法的平台之间迁移,也可以直接将文章上传到以 博客园 为代表的使用 Markdown 语言的在线博客平台,还可以直接导出排版好的 PDF 文件供人阅读;或者打印出来,作为纸质的教案或笔记使用。
然而,在后续的使用过程当中发现,此方案中一个美中不足的问题,是我在 \LaTeX\ 文档类中预先定义好的的各种定理类环境和 tcolorbox 样式环境无法使用,因为即使是经过 pandoc 扩展的 Markdown 的语法,也不支持指定环境类型的这一功能 [1],导出输出的文档质量达不到最优效果。于是,为了在使用 Markdown 时追求更加极致的文档排版效果,我开始寻找一种新的方案,能够允许我在 Markdown 中指定某一段文字为某一个特定的 \LaTeX\ 环境,并在编译为 PDF 时生效。
我们以定理类环境为例,在 \LaTeX\ 中,我们可以使用:
\begin{theorem}[自指定理]
这条行文字是一条定理。
\end{theorem}
来指定一个定理类环境。这一环境经过 \LaTeX\ 渲染之后,就会呈现为类似下的一行字样,具体的字样、样式可以在文档类开头导言区或文档类 (.cls) 中进行定义。
定理 1.1 (自指定理) 这条行文字是一条定理。
除了“定理”之外,用户也可以定义“引理”、“例”、“推导”之类的环境,可以指定让 \LaTeX\ 给它们自动编号,还可以指定编号的样式、形式等。关于 \LaTeX\ 中定理类环境的概念,如果读者们事先确实没有任何了解,可以参考如下的博客文章:
虽然我不是什么数学专业,使用 \LaTeX\ 中的定理类环境也不是那么频繁,却也不能完全避免使用。其中,用于表示“注”和“示例”的 note 和 example 环境的使用更是尤为频繁,因为它们可以有效地提醒读者们去阅读一些文本,大大提升我的写作质量。所以,为了解决这个问题,我就开始设计一种方案,完成如下的三个需求:
- 方案能够被 pandoc 支持,在 Markdown 中指定某一段文字为某一个特定的 \LaTeX\ 环境,并在编译为 PDF 时生效。
- 不引入其他新的语法,或引入的语法总是能被各大平台 / Markdown 编辑器支持,渲染为便于阅读的格式。
- 方案足够轻量,能够轻易地与其他 Markdown 写作工具相集成。
[!remark]
当下 Markdown 在使用时的一大问题是,由于缺乏一个足够强力的语法规则的约束,其文本在不同的平台上迁移时,往往会因为各个平台对扩展类语法的支持各不相同而遇到严重的兼容性问题,一旦迁移文件就会造成无法阅读。例如,在一些平台上,
~符号包裹的文本会被视作“划去”,而另一些平台却并不支持。因此,“不引入新的语法”就显得尤为关键。
[!important]
本文的内容涉及 pandoc 的高级用法,且要求读者具备一些基本的 \LaTeX\ 知识,因此本文并不适合 pandoc 新手用户阅读。
关于 Markdown Alerts
什么是 Markdown Alerts
Markdown Alerts 是 Markdown 扩展语法中的一种,有的时候也被称为 Admonition Block,或者 Callout 或者 Highlighted Block Quote,别名非常的多,而且在各个平台上语法均有不同程度的变化,但其基本形式被 Typora、GitHub、Microsoft Documention、Obsidian 等平台支持。在博客园上,用户可以通过 JavaScript 权限对文章中的这一 Markdown 语法进行动态渲染。你可能不知道它的名字,但是你一定见过它——这种语法的目的往往是将 Markdown 的块文本渲染为带有颜色的醒目样式框。
Alerts 块的语法
如果你现在在 Obsidian 或者 GitHub 的 Markdown 编辑页面使用如下语法:
> [!warning]
>
> 这是一个信息块,你可以将一些信息写入文本当中,这些信息就会被展示在框里。
就能得到如下所示的一个“警告”块,这也是各种平台上 Alerts 最常见的语法。
[!warning]
这是一个警告块,你可以将一些警告信息写入文本当中。由于文本用鲜艳的颜色高亮,读者能够警觉地意识到其存在。
我想读者们不难看出,这种最常见的 Alerts 块是基于 Markdown 中的引用格式改出来的。为方便后文叙述,在本文中,我们将被 [! 和 ] 框起来的文本标签称为 Alerts 的“类型名称 (type name)”,将随继的文本称为 Alerts 块的“内容文本 (content text)”。
Alerts 块在不同平台上的受支持情况
在 GitHub 和 Microsoft 的 Document 上,Alerts 的类型名称只有特定的五种,分别是:注意 (Note)、警告 (Warning)、提示 (Tip)、重要 (Important) 和危险 (Caution),对于不包含在特定种类的类型名称的指定,平台并不会给出正确的渲染。详细情况可以查看 GitHub 上的讨论 [Markdown] An option to highlight a "Note" and "Warning" using blockquote (Beta) 和 Microsoft 的官方指南 Learn Markdown reference。
在 Obsidian 上,不仅受支持的 Alerts 块的种类更多 (总共 14 种),而且即使在 Alerts 块的开头给出官方文档未列出的任意类型名称,Obsidian 也会默认地按照 note 的样式去渲染。不仅如此,Obsidian 还支持 自定义类型名称。

由于笔者从来没有用过 Typora,因此对 Typora 上的 Markdown Alerts 语法不甚了解,但是从查到的资料来看,Typora 不仅原生地支持形同 GitHub 和 Microsoft 的 Document 中的五种 Alerts 块类型,而且还可以通过社区插件,实现对 Obsidian 上的 Callout 块语法的支持。
关于 pandoc 过滤器
Pandoc 过滤器的概念
[!note]
我们下文会提及到 pandoc 过滤器的问题,考虑到本文的读者也可能包括一些刚刚开始尝试使用 pandoc 的新手,我在这里还是简单地给读者们普及一下 pandoc 中过滤器 (filter) 的概念。
Pandoc 中的过滤器,可以被简单地理解为 pandoc 的各种第三方的功能扩展插件。过滤器通常以一个可执行程序 (脚本) 的形式呈现,使用 JSON 数据格式与 pandoc 进行交互。在使用 pandoc 时,可以使用 --filter 参数指定过滤器。比如:
[!example] 使用 pandoc 过滤器
假设用户需要使用一个名为
pandoc-svg的过滤器将demo.md编译为demo.html,可以使用如下的命令:pandoc demo.md --filter=pandoc-svg --output=demo.html
在 Pandoc 的官网上可以找到一个 第三方过滤器列表,记录了所有被官方收录的 pandoc 过滤器及其功能,可以点击链接访问和查看。
[!tip]
大多数 pandoc 过滤器是用 Python 写的,安装依赖pip,但 pandoc 本体仍然可以是独立于 Python 环境的。换句话说,即使你不是使用pip安装了 pandoc,而是使用 pandoc 的独立安装包安装,你仍然可以使用pip命令安装过滤器,并使用--filter参数正常调用使用,无需再在 Python 中安装一次 pandoc。
Lua 过滤器
也许读者们已经知道 pandoc 的过滤器并不一定是 Python 编写的,但我仍想确保读者们知道 pandoc 的 Lua 过滤器并不是用 Lua 写成的 pandoc 过滤器。与一般的过滤器使用 --filter 参数指定不同,pandoc 的 Lua 过滤器使用 --lua-filter 参数指定。根据 pandoc 官网给出的解释:
“尽管传统过滤器非常灵活,但它们也有几个缺点。首先,将 JSON 写入 stdout 并从 stdin 读取(两次,过滤器的每一侧一次)会产生一些开销。其次,过滤器是否有效取决于用户环境的细节。过滤器可能需要某种编程语言的解释器,以及用于以 JSON 形式操作 pandoc AST 的库。不能简单地提供一个过滤器,让任何拥有特定版本的 pandoc 可执行文件的人都可以使用。”
这也就是说,由于 pandoc 自带 Lua 解释器,因此 Lua 过滤器无需外部解释器就可以直接嵌入 pandoc,并且与 Lua 5.3 一起运行,解析速度相较于一般的过滤器更快,且执行时无需外部程序,也无需使用 JSON 与格式传递数据,进一步加快了执行速度。
Pandoc 的 Lua 过滤器可以被安装到本地,从而在本地计算机的全局位置调用。如果需要这样做,你可以使用:
pandoc --version
在命令的输出结果中,通常会包含 User data directory: <path> 的字样,而 Lua 过滤器的安装目录就是在这个目录下的 <path>/filter 中,只需要将 Lua 过滤器复制黏贴到该目录下即可。
Pandoc 过滤器与 AST 机制
考虑到 pandoc 所使用的 AST 机制是 pandoc 及其过滤器的工作基础,与我们下文的 Lua 脚本有一点联系,我们暂且在这里向读者简单地阐述一些有关的概念。
[!note]
这段内容和这篇博客有一些关联,却不能算作是博客的主题。但是笔者发现相关的内容目前在平台上好像很少有其他博客写过,因此决定简单地写一写。所以如果你已经了解了相关的内容,或者单纯只是不想看这段内容的话,可以直接跳过,去看下面的内容。
根据 pandoc 官网的官方文档 中的解释:
“Pandoc 为用户提供了一个界面,用于编写作用于 pandoc AST (抽象语法树,Abstract Syntax Tree) 的程序 (称为过滤器)。Pandoc 由一组读取器和写入器组成。将文档从一种格式转换为另一种格式时,读取器会将文本解析为 pandoc 的文档中间表示形式——“抽象语法树”或者说 AST——然后写入器会将其转换为目标格式。pandoc AST 格式在 pandoc-types 包中的
Text.pandoc.Definition模块中定义。过滤器是一种在读取器和写入器之间修改 AST 的程序。”
这也就是说,pandoc 就像编程语言的编译器一样,使用抽象语法树的机制去理解一个文档的文本构成,对其进行解析,然后通过对解析的结果进行处理,完成文档格式的转换。
由于我也不是学计算机的,编译原理方面的东西我不能说一窍不通吧,只能说不知所云。但是我还是斗胆浅析一下 pandoc 的编译机制。我们可以先创建一个简单的 Markdown 文件 demo.md,只有短短两行:
# This is the title
This is a line of text.
接下来,我们使用如下命令,将 AST 的 native 输出重定向到文件 native.hs:
pandoc -t native demo.md > native.hs
其中,native 参数指定了输出结果为 pandoc 原生的数据格式。由于 pandoc 是基于 Haskell 编写的,输出的 native 数据实际上也就是 Haskell 的数据格式。
[ Header
1
( "this-is-the-title" , [] , [] )
[ Str "This"
, Space
, Str "is"
, Space
, Str "the"
, Space
, Str "title"
]
, Para
[ Str "This"
, Space
, Str "is"
, Space
, Str "a"
, Space
, Str "line"
, Space
, Str "of"
, Space
, Str "text."
]
]
由于笔者实际上没有学过 Haskell,因此对于上述数据的实际语法含义不甚了解,所以我让 ChatGPT 解释了一下这段数据的含义
Header和Para这些是 Pandoc AST 的数据构造器,类似于标签。1代表标题等级 (这里是#级标题)。("this-is-the-title", [], [])this-is-the-title是自动生成的 HTML 锚点 ID。- 第一个空的
[]表示没有类名 (class)。 - 第二个空的
[]表示没有键值属性 (key-value attributes)。
[Str "This", Space, Str "is", ...]Str是表示一个字符串。Space代表空格。
希望这些简单的解释能给读者一些有效的帮助。
解决方案
现有的解决方案
pandoc-filter-latex
现在,让我们言归正传。从我们最初的需求出发,单论“在 Markdown 中指定某一段文字为某一个特定的 \LaTeX\ 环境”的这件事情来讲,一个现成的方案之一是一个名为 pandoc-latex-environment 的过滤器,被收录在 pandoc 官方文档的第三方过滤器列表里面。
文档里面说该过滤器可以通过 pipx 进行安装,但我测试了一下发现直接使用 pip[2] 进行安装也是没有问题的。安装命令为:
pipx install pandoc-latex-environment
pip install pandoc-latex-environment
安装完成后,可以使用 --filter=pandoc-filter-latex 参数来指定过滤器。
该过滤器支持使用如下的语法指定一段文本为某个特定的 \LaTeX\ 环境:
---
pandoc-latex-environment:
test: [class1, class2]
---
::: {.class2 .class1}
content
:::
可以看到,在这个实现当中,首先需要在 Markdown 文本的开头使用 YAML Header 指定,将某个 \LaTeX\ 环境与“类名”关联起来,然后才能在 Markdown 的正文中使用“:::”这样的语法指定某段文字为某个 class,再由过滤器在编译时将这些 class 转换为 \LaTeX\ 环境。例如,如果我们将上面的这段 Markdown 经过 pandoc 编译,就能得到如下形式的 \LaTeX\ 代码:
\begin{test}
content
\end{test}
其中,“:::”是 pandoc 的原生语法形式,原有的功能是在转化成 HTML 文本时创建一个 Div 元素,同时将花括号 {} 内的内容传递给 <div> 的 class 参数。比如,如果将上述的代码直接编译为 HTML,就会得到下面的输出结果:
<div class="class2 class1">
<p>content</p>
</div>
详情请见 官方文档中关于 Div 和 Span 语法的说明。
pandoc-latex-admonition
目前的确存在一个名为 pandoc-latex-admonition 的过滤器,按照文档的解释,该过滤器可以将 pandoc 中的 admonition 转化为 PDF via \LaTeX\ 中预制好的格式。但是我去看了之后发现,这个过滤器的工作方式有点难以言喻,而且其中的 admonition 语法也并不是本文所述的这种形式,而是 pandoc Markdown 所扩展的语法。
具体来说:首先你需要在 Markdown 文件 YAML Header 中定义每个 class 及其样式,可选的样式详见 官方文档中的说明。
pandoc-latex-admonition:
# order is important
- color: firebrick
classes: [admonition, danger]
- color: gray
classes: [admonition]
紧接着,你可以在 Markdown 文件的正文当中指定某一段文字为某一个 admonition 样式。指定的方式有很多种,你可以使用 pandoc Markdown 原生的 fenced code 语法:
~~~admonition
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
In ac urna condimentum, posuere nibh sit amet, blandit
nisi. Vivamus nec elementum odio. Mauris non faucibus
nulla, eget tincidunt nisl. Cras dolor augue,
condimentum in quam at, tincidunt aliquet lorem. Donec
dolor augue, rhoncus ac mauris eget, tincidunt
facilisis quam. Quisque iaculis, nibh malesuada molestie
suscipit, ligula mauris lacinia nibh, eget mollis tellus
ante ut sapien. Mauris tristique tellus vitae vestibulum
eleifend. Aliquam metus nisl, hendrerit eu pellentesque
sed, bibendum ut diam. Aliquam mollis iaculis ipsum.
Nulla blandit urna suscipit eros ullamcorper rhoncus.
~~~
这段文字将会按照你在 YAML Header 中指定的样式进行渲染。
你也可以使用 pandoc Markdown 的 Div 语法:
::: {.admonition .danger} :::
Praesent[^praesent] non fringilla leo.
Praesent vestibulum eu purus vitae varius[^varius].
Nunc maximus libero urna, non pulvinar sapien volutpat sit amet.
Donec sit amet leo malesuada, euismod augue ac, fermentum sapien.
Donec vel nulla euismod, malesuada leo id, efficitur magna.
Praesent sed faucibus ipsum. Fusce vestibulum, odio in porta interdum,
nisi urna mollis nisl, id hendrerit velit metus eu libero.
Duis bibendum quam metus, eget ultricies lacus vulputate eget.
Donec interdum vulputate nisi quis dapibus. Donec molestie,
metus a eleifend eleifend, urna ipsum rutrum metus,
in vulputate nibh risus nec nibh. In blandit odio vel sem semper blandit.
Vestibulum quis placerat nisi, nec elementum nunc.
[^praesent]: Praesent non fringilla leo
[^varius]: Praesent vestibulum eu purus vitae varius
:::::::::::::::::::::::::::::
具体的详细用法,请自行对比 该过滤器 GitHub 仓库主页 提供的示例文件 pandoc-latex-admonition-sample.txt 及其 编译输出的 PDF[3]。
为什么选择 Alerts 语法
从编辑器和平台显示的角度来讲
如读者们所见,上述的两个现成的解决方案都存在一个问题,那就是 它们都使用 pandoc Markdwon 的 Div 块语法。尽管 pandoc Markdown 很强大,具有很多优势,其语法在各个平台上的支持却很不好。
如果我在 Obsidian 上编写 Markdown 文件,这些 Div 和 fenced code 就会被显示成满屏的 ::: 和 ~~~,非常的不优雅。Obsidian 的 AnyBlock 插件可以实现对 Div 块语法的支持,但是其本身的功能却与 Alerts 并没有什么关系,所以不会将其渲染为 Alerts 样式[4]。
而如果我想要将文章上传到博客园,仍不得不想办法让这些格式能够在博客园上正常显示。在这种情况下我有两种选择:
- 使用 JavaScript 权限,自己写一个能够动态渲染“
:::”或者“~~~”语法的实现——这种实现此前还没有人写过现成的解决方案,这意味着我需要自己想办法解决。 - 自己写一个 Lua filter 并在 pandoc 中进行从 Markdown 到 Markdown 的转换,将所有不被支持的的
Div和 fenced code 语法转换为受支持的、更常见的 Markdown 语法,比如将所有[!note]标签批量转化为被加粗的“注”字样。
但无论是哪一种,实际操作起来都是十分麻烦的。
综合来看,Alerts 和 pandoc Div 语法在不同平台上的支持情况如表所示。
| 语法 | 博客园 | GitHub | VS Code | Obsidian | R Studio |
|---|---|---|---|---|---|
| Alerts | 有方案 | 支持 | 支持 | 支持 | 不支持 |
| Div | 无方案 | 不支持 | 有插件 | 有插件 | 支持 |
: Alerts 和 pandoc Div 语法在不同平台上的支持情况
从表中可以看出,相较之下,Alerts 的语法的支持在各种可视化的编辑器上更为广泛,而且在博客园上此前也已经有人做过解决方案,我可以参考其他人的项目去实现 Alerts 块渲染的个性化实现,或者直接迁移现有的开源代码。
从 \LaTeX\ 和 PDF 的角度上来讲
Alerts 的语法和 \LaTeX\ 中的环境有一些可行的映射关系,它们都指定了一个类型名称、一个紧随其后的标题行,以及一段内容文本。因此,将 Markdown 中的 Alerts Quote Block 渲染为 \LaTeX\ 环境的实现并不是很困难。具体来说,我们考虑如下所示的一段 Markdown 代码:
> [!note] This is the note title
> This is a line of info.
如果我们利用 pandoc 的 Lua 过滤器,就不难将其转化为如下的 \LaTeX\ 环境块:
\begin{note}[This is the note title]
This is a line of info.
\end{note}
我的解决方案
Alerts 语法在 pandoc AST 中的呈现
我们将一个基本的 Alerts Quote Block 的语法形式 (前文提到的) 写入一个 Markdown 文件,再通过 pandoc 获取其 native 的 Haskell 数据格式,可以得到如下的内容:
[ BlockQuote
[ Para
[ Str "[!note]"
, Space
, Str "This"
, Space
, Str "is"
, Space
, Str "the"
, Space
, Str "note"
, Space
, Str "title"
]
, Para
[ Str "This"
, Space
, Str "is"
, Space
, Str "a"
, Space
, Str "line"
, Space
, Str "of"
, Space
, Str "info."
]
]
]
可以注意到,引用块中的所有内容都被 pandoc 分配到了一个名为 BlockQuote 的节点下,在此之后,每当该节点下出现两个连续的换行符,就会创建一个新的名为 Para 的节点。而经过我的测试,如果出现的是单个换行符,而不是连续的两个换行符,例如:
> [!note] This is the note title
> This is a line without dual-line break.
这里插入的就将会是 Para 下的一个 SoftBreak 节点。
-- 13 lines are omitted here.
, Str "title"
, SoftBreak
, Str "This"
-- 16 lines are omitted here.
除此之外,对于在 Quote 块中插入列表 (有序 / 无序列表) 的情形,例如:
> - This is a line of list.
> - This is another line of list.
>
> 1. This is a line of numbered list.
> 2. This is another line of numbered list.
则无序列表下的内容会被置于与 Para 同级的 BulletList 下,而有序列表下的内容则会被置于 OrderedList 下。
方案设计思路
在我们确定了 pandoc 的 AST 格式之后,我们的 pandoc Lua filter 的设计思路就很清晰了,其大致的逻辑是:
- 对于一个
BlockQuote,使用正则表达式匹配其第一个段落第一个Str节点中的 Alerts type label; - 如果成功匹配到,则提取 type name,作为 \LaTeX\ environment name;
- 继续遍历其余元素,判断区分子节点是
Para、BulletList还是OrderedList;- 如果是
Para,则正常遍历和传递元素; - 如果是
BulletList或者OrderedList,则创建有序 / 无序列表环境,并为每一行文本开头增加\item命令。
- 如果是
- 返回整合好的文本。
方案的实现
很好,现在我们已经有了方案的设计原理和思路,接下来只需要将脚本写出来就行了。那么问题是什么呢?问题是我根本就不会写 Lua。
好吧,虽然本文的笔者从来没有写过 Lua 语言,但是我们不用慌。一方面,考虑到我们的需求的体量不大、只是一个简单的 Lua 脚本,我们还是可以将上述的需求交给 AI 去完成;另一方面,结合官网给出的 十分详细的手册,我们就可以现取现用地对这个脚本进行调试和修改。
以下列举一些注意事项。
pandoc.utils 模块
参考 pandoc 官网上 pandoc.utils 模块的详解资料,该模块中包含了 pandoc 一些内部函数的外部接口,以及一些实用函数。我们可以使用 require 命令导入这一模块,或者从模块中导入一些变量赋值给过滤器脚本的局部变量:
local types = require 'pandoc.utils'.type
local stringify = require 'pandoc.utils'.stringify
Pandoc Lua 过滤器的语法
从 官网的手册 可以得知:pandoc 中的 Lua 过滤器是表结构,以元素名称作为键,值则是对这些元素进行操作的函数。按照笔者的猜测,这里所说的“表结构”,应该是指 Lua 中的 table 语法结构。
在官网的手册当中,我们可以找到这样的一个简单的例子:
[!example] 创建简易的 pandoc Lua 过滤器
在 pandoc 官网的说明中有这样的一个例子:假设创建一个包含如下函数的 Lua 过滤器,
function Strong(elem) return pandoc.SmallCaps(elem.content) end则其含义为:遍历 AST,一旦找到加粗的元素 (Strong elem),就使用小写元素 (SmallCaps element) 替换它们。
参考这一示例,由于我们是要针对性地处理 AST 的 BlockQuote 节点中的格式,因此我们首先应当创建一个与节点同名的 BlockQuote() 函数,然后把我们所有的处理逻辑都写在函数内。如果函数的返回值是:
nil:则意味着函数名称对应的对象保持不变。- 一个 pandoc 对象:这必须与输入类型相同,并且将替换原始对象。
- Pandoc 对象的列表:这些将替换原始对象;列表将与原始对象的邻居合并(插入到原始对象所属的列表中);返回空列表将删除对象。
AI 的使用
我们打开 DeepSeek 的网址。我们将原始的文档内容、输出的 native AST 格式和需求发给 DeepSeek,并配上尽可能详尽和具体的需求说明。随即 DeepSeek 就能给出一份基本的方案,但是该方案距离我们的实际使用还是有一定的距离,我们还需要对脚本进行人为的调试。
成果
经过一晚上的调试和修改,这个过滤器基本上已经能够正常使用了。目前我已经将这个脚本开源到了 GitHub 平台,开源地址在 GitHubonline1396529/callout2latex。
[!note]
尽管在本文中我们将这一语法称之为“Alerts”,这个脚本项目还是沿用了 Obsidian 中的“Callout”的称呼。这是因为笔者最初设计这个脚本的时候就是为了适配我的 Obsidian 编辑器,而且相较于 GitHub 只支持五种样式的 Alerts 方案,由于 Obsidian 支持任意的 Callouts 块名称,这也与本项目的方案一致。
尽管到本文的写作时间为止,过滤器还存在一些大大小小的问题,但是都可以在后续的更新中被修复的。这份脚本在本文中附上了一份,但是考虑到该脚本后续的更新,笔者还是建议各位读者们访问本项目的 GitHub 仓库。
总结
我原本只是想写一篇博客简单介绍一下这个脚本的基本功能的实现,没想到这篇文章在实际写作时的逻辑却比我想象的要复杂很多。我既想要向读者阐述这种设计的优势,又想要向读者解释为什么我不采用现有的解决方案,然后反过来又介绍我的方案相较于现有的方案的优势,还顺便提及了 pandoc 的过滤器和 AST 的机制,一来二去竟然写成了一篇两多字的大长文。但不管怎么说,还是希望本文能让读者们在解决类似问题时能多一些思路。
附 Lua 过滤器脚本原文
--- Pandoc Lua filter to convert Obsidian/Markdown callout blocks to LaTeX
-- environments.
-- @module callout2latex
-- @author DeepSeek, ChatGPT, Githubonline1396529
-- @release 0.0.2.1
-- @usage
-- Pandoc Lua filter to GitHub / Obsidian / Microsoft styled Markdown Callout
-- Blocks / Alert Blocks / Message Boxes / Admonitions callout blocks to LaTeX
-- environments.
--
-- For example, Convert File.md to File.tex.
-- +----------------------------------+--------------------------------------+
-- | Input | Output |
-- +----------------------------------+--------------------------------------+
-- | > [!note] This is the note title | \begin{note}[This is the note title] |
-- | > This is a line of info. | |
-- | | This is a line of info. |
-- | | |
-- | | \end{note} |
-- | | |
-- +----------------------------------+--------------------------------------+
-- | > [!note] | \begin{note}[] |
-- | > This is a line of info | |
-- | > It may contain multiple lines, | This is a line of info |
-- | > | |
-- | > Or even a new paragraph. | It may contain multiple lines, |
-- | | |
-- | | Or even a new paragraph. |
-- | | |
-- | | \end{note} |
-- +----------------------------------+--------------------------------------+
--
-- Here are a few things to note about this filter script:
--
-- Firstly, Every single line in the callout block will be converted into one
-- paragraph in LaTeX.
--
-- Secondly, DO NOT USE SPACE AFTER THE TYPE LABEL !
--
-- Leaving a space (or any other blank character) after the callout block type
-- label `[!TYPE]` may cause unwanted and unexpected LaTeX formatting. For
-- example:
--
-- ```markdown
-- > [!NOTE]
-- > Notice here is two spaces after the `[!NOTE]` label.
-- ```
--
-- You may can't see the spaces but they do exist. However this will be
-- converted to:
--
-- ```tex
-- \begin{note}[Notice the two spaces after the `[!NOTE]` label. ]
-- \end{note}
-- ```
--
-- This may result in unexpected formatting of the content.
--
-- @license MIT
--
-- MIT License
--
-- Copyright (c) 2025
--
-- Permission is hereby granted, free of charge, to any person obtaining a copy
-- of this software and associated documentation files (the "Software"), to
-- dealin the Software without restriction, including without limitation the
-- rightsto use, copy, modify, merge, publish, distribute, sublicense, and/or
-- sellcopies of the Software, and to permit persons to whom the Software
-- isfurnished to do so, subject to the following conditions:The above
-- copyright notice and this permission notice shall be included in allcopies
-- or substantial portions of the Software.
--
-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
-- ORIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
-- THEAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
-- OTHERLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
-- ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-- DEALINGS IN THESOFTWARE.
local types = require 'pandoc.utils'.type
local stringify = require 'pandoc.utils'.stringify
--- Handle list conversion (WIP)
-- @local
-- @param list pandoc.List List element to process
-- @return string LaTeX formatted list
local function handle_list(list)
local env = (list.t == "BulletList") and "itemize" or "enumerate"
local result = {"\\begin{" .. env .. "}"}
for _, item in ipairs(list.content) do
table.insert(
result, "\\item " .. pandoc.write(pandoc.Pandoc(item), "latex")
)
end
table.insert(result, "\\end{" .. env .. "}")
return table.concat(result, "\n")
end
--- Main processing function for BlockQuote elements.
-- @function BlockQuote
-- @param el pandoc.BlockQuote The blockquote element to process
-- @return pandoc.Blocks|nil Modified elements or original if not a callout
-- @usage
function BlockQuote(el)
-- Validate block structure: First element must be a paragraph.
if #el.content < 1 or el.content[1].t ~= 'Para' then
return el
end
-- Extract first paragraph's inline elements.
local first_para = el.content[1].content
if #first_para < 1 or first_para[1].t ~= 'Str' then
return el
end
-- Locate the [!type] marker in inline elements.
--
-- AST structure: Para contains sequence of Str/Space/SoftBreak elements
-- local marker_idx = nil .
for i, inline in ipairs(first_para) do
if inline.t == 'Str' and inline.text:match('^%[!%w+%]$') then
marker_idx = i
break
end
end
if not marker_idx then return el end
-- Extract callout type from marker (e.g., "note" from [!note])
local callout_type = first_para[marker_idx].text:match('%[!(%w+)%]')
if not callout_type then return el end
-- Find content starting point after first SoftBreak.
-- This separates title from content in the AST.
local content_start_idx = nil
for i = marker_idx + 1, #first_para do
if first_para[i].t == 'SoftBreak' then
content_start_idx = i + 1 -- Skip SoftBreak itself.
break
end
end
-- Extract title elements (between marker and first SoftBreak)
local title_elems = {}
for i = marker_idx + 1, (content_start_idx or #first_para + 1) - 1 do
table.insert(title_elems, first_para[i])
end
-- Process content blocks -------------------------------------------------
local content_blocks = pandoc.List()
-- Handle inlines after content_start_idx (actual callout content)
if content_start_idx then
local content_inlines = {}
for i = content_start_idx, #first_para do
local elem = first_para[i]
-- Split paragraphs at SoftBreak nodes (Markdown newlines)
if elem.t == 'SoftBreak' then
if #content_inlines > 0 then
content_blocks:insert(pandoc.Para(content_inlines))
content_inlines = {}
end
else
table.insert(content_inlines, elem)
end
end
-- Add remaining inlines as last paragraph
if #content_inlines > 0 then
content_blocks:insert(pandoc.Para(content_inlines))
end
end
-- Add subsequent blocks (other Markdown elements after first paragraph)
for i = 2, #el.content do
-- content_blocks:insert(el.content[i])
--
-- Try to deal with lists in quote block.
local block = el.content[i]
if block.t == "BulletList" or block.t == "OrderedList" then
content_blocks:insert(
pandoc.RawBlock("latex", handle_list(block))
)
else
content_blocks:insert(block)
end
end
-- Build LaTeX output -----------------------------------------------------
local env_name = callout_type:lower()
local title_str = #title_elems > 0 and stringify(title_elems) or nil
-- Clean title whitespace (collapse multiple spaces, trim edges)
if title_str then
title_str = title_str:gsub('%s+', ' '):gsub('^%s*(.-)%s*$', '%1')
end
-- LaTeX environment start with optional title
local output_blocks = pandoc.List()
local title_part = ''
if title_str ~= nil and not title_str:match('^%s*$') then
title_part = '[' .. title_str .. ']'
end
output_blocks:insert(
pandoc.RawBlock(
'latex',
'\\begin{' .. env_name .. '}' .. title_part
)
)
-- Insert processed content blocks
output_blocks:extend(content_blocks)
-- Close LaTeX environment
output_blocks:insert(pandoc.RawBlock('latex', '\\end{'..env_name..'}'))
return output_blocks
end
尽管读者们可能在此处制出,Pandoc 可以通过的过滤器 (Filter) 为 Markdown 文本提供额外的语法支持,但是本文的后文将会详细讨论这个问题:为什么我不接受现成的过滤器方案,以至于需要使用 Lua 自己单独再去写一个。 ↩︎
众所周知,
pip是 Python 的包管理器,这也就是说自动化地安装此脚本需要用户的计算机上配置过 Python 环境。这对 MacOS 和 Linux 用户来说很容易,因为 MacOS 和 Linux 预装了 Python,但在 Windows 上却需要花费额外的心力。 ↩︎如需自行编译这份示例文档
pandoc-latex-admonition-sample.txt,还需下载文档中的图片媒体文件Markdown-mark.svg.png并放置在正确的位置。 ↩︎如果我安装了这个插件,我在创建一个 Div 块的时候,即使指定该 Div 块对应的 \LaTeX\ 环境是
theorem,不仅“定理”字样不会被渲染出来、无法自定义渲染字样,而且:::语法会被隐藏。尽管插件在对应文本的位置创建了一个 Obsidian 的块对象,但是在编写的过程中却完全看不出来,就好像那只是一段普通的文本。这样的话,我在编写 Markdown 文件的时候,对生成的 PDF 样式没有预期,就会降低我的写作质量。 ↩︎

本文从笔者现有的基于 pandoc 的 Markdown to PDF via \LaTeX\ 的写作方案出发,为实现在 Markdown 中指定某一段特定的文本为某个特定的 \LaTeX\ 环境,尝试了现有的解决方案,分析了现有的解决方案在编写 Markdown 文件时的跨平台语法支持问题。随后,浅析了 pandoc 的过滤器机制和 Alarts 语法,提出了新的解决方案并编写了 Lua 过滤器脚本。
浙公网安备 33010602011771号