Pandoc 过滤器

概述

Pandoc 提供了一个接口,让用户能够编写程序(称为过滤器),这些程序可以在 Pandoc 的抽象语法树(AST)上进行操作。

Pandoc 包含了一系列的读取器和写入器。当将文档从一种格式转换到另一种格式时,文本首先由读取器解析成 Pandoc 的中间表示形式——即一个 “抽象语法树” 或简称 AST —— 然后由写入器将其转换为目标格式。Pandoc 的 AST 格式定义在模块 Text.Pandoc.Definition 中,该模块位于 pandoc-types 包 内。

“过滤器” 是在读取器和写入器之间修改 AST 的程序。

INPUT --reader--> AST --filter--> AST --writer--> OUTPUT

Pandoc 支持两种类型的过滤器:

  • Lua 过滤器 使用 Lua 语言定义对 Pandoc AST 的转换。它们在 单独的文档 中描述。
  • JSON 过滤器,在这里描述,是管道程序,它们从标准输入读取并写入标准输出,消费和产生 Pandoc AST 的 JSON 表示形式:
         源格式
           ↓
        (pandoc)
           ↓
     JSON 格式的 AST
           ↓
      (JSON 过滤器)
           ↓
     JSON 格式的 AST
           ↓
        (pandoc)
           ↓
        目标格式

Lua 过滤器有几个优点。它们使用嵌入在 Pandoc 内部的 Lua 解释器,因此您无需安装任何外部软件。而且它们通常比 JSON 过滤器更快。但是如果您希望用 Lua 以外的语言编写您的过滤器,您可能会倾向于使用 JSON 过滤器。JSON 过滤器可以用任何编程语言编写。

您可以直接在管道中使用 JSON 过滤器:

pandoc -s input.txt -t json | \
 pandoc-citeproc | \
 pandoc -s -f json -o output.html

但使用 --filter 选项更为方便,它可以自动处理管道连接:

pandoc -s input.txt --filter pandoc-citeproc -o output.html

要了解如何编写自己的过滤器,请继续阅读本指南。此外,在 wiki 页面 上还有一个第三方过滤器的列表。

一个简单的例子

假设你想要将 Markdown 文档中所有的二级及以上的标题替换为普通的段落,并且其中的文字用斜体表示。你会如何实现这个需求呢?

首先想到的方法可能是使用正则表达式。比如这样做:

perl -pe 's/^##+ (.*)$/*\1*/' source.txt

这在大多数情况下应该是可行的。但是别忘了 ATX 风格的标题可以以不是标题文本一部分的 # 符号序列结尾:

## My heading

如果文档中含有 HTML 注释或限定代码块中以 ## 开头的行又会怎样呢?

<!--
## 这仅仅是一个注释
-->

A third level heading in standard markdown


我们不希望这些行被修改。另外,对于 Setext 风格的二级标题又该如何处理?

## A heading

我们也需要处理这种情况。最后,我们能确定在字符串两侧添加星号就能使其变为斜体吗?如果字符串本身已经包含了星号怎么办?这样可能会导致文字变为粗体,这不是我们想要的结果。如果它包含了一个普通的未转义的星号又会怎样?

你将如何修改你的正则表达式来处理这些情况?这将会非常复杂。

一个更好的方法是让 Pandoc 处理解析工作,然后在文档被写入之前修改抽象语法树(AST)。为此我们可以使用过滤器。

为了查看当 Pandoc 解析我们的文本时会产生什么样的 AST,我们可以使用 Pandoc 的 native 输出格式:

% cat test.txt
## my heading

text with *italics*
% pandoc -s -t native test.txt
Pandoc (Meta {unMeta = fromList []})
[Header 2 ("my-heading",[],[]) [Str "My",Space,Str "heading"]
, Para [Str "text",Space,Str "with",Space,Emph [Str "italics"]] ]

一个 Pandoc 文档由一个 Meta 块(包含诸如标题、作者和日期等元数据)和一系列 Block 元素组成。在这个例子中,我们有两个 Block,一个是 Header,另一个是 Para。每个 Block 的内容都是一系列 Inline 元素。关于 Pandoc 的 AST 更多细节,请参阅 Hackage 上 Text.Pandoc.Definition 的文档

我们可以使用 Haskell 创建一个 JSON 过滤器来转换这个 AST,将每个等级大于等于 2Header 块替换为一个其内容被包裹在 Emph 内联元素中的 Para

#!/usr/bin/env runhaskell
-- behead.hs
import Text.Pandoc.JSON

main :: IO ()
main = toJSONFilter behead

behead :: Block -> Block
behead (Header n _ xs) | n >= 2 = Para [Emph xs]
behead x = x

toJSONFilter 函数做了两件事。首先,它将 behead 函数(该函数映射 Block -> Block)提升为对整个 Pandoc AST 的转换,遍历 AST 并转换每一个块。其次,它将这个 Pandoc -> Pandoc 的转换包装上必要的 JSON 序列化和反序列化,产生一个从标准输入消费 JSON 并向标准输出产生 JSON 的可执行程序。

要使用这个过滤器,先让它具有可执行权限:

chmod +x behead.hs

然后运行:

pandoc -f SOURCEFORMAT -t TARGETFORMAT --filter ./behead.hs

(还需要安装 pandoc-types 在本地包仓库中。使用 cabal-install 可以通过命令 cabal v2-update && cabal v2-install --lib pandoc-types --package-env . 完成。)

或者我们可以编译这个过滤器:

ghc -package-env=default --make behead.hs
pandoc -f SOURCEFORMAT -t TARGETFORMAT --filter ./behead

需要注意的是,如果过滤器位于系统路径(PATH)中,则不需要初始的 ./。同样要注意的是命令行可以包含多个 --filter 实例:这些过滤器将按顺序应用。

针对 WordPress 的 LaTeX

另一个简单的例子。WordPress 博客要求 LaTeX 数学公式使用特殊的格式。与直接使用 $e=mc^2$ 不同,你需要使用 $LaTeX e=mc^2$。我们如何相应地转换一个 Markdown 文档呢?

再次,使用正则表达式很难可靠地完成这项工作。一个 $ 可能是一个常规的货币指示符,也可能出现在注释、代码块或内联代码范围内。我们只想找到那些开始 LaTeX 数学公式的 $。如果我们有一个解析器就好了……

我们确实有。Pandoc 已经能够提取 LaTeX 数学公式,因此我们可以这样操作:

#!/usr/bin/env runhaskell
-- wordpressify.hs
import Text.Pandoc.JSON

main = toJSONFilter wordpressify
  where wordpressify (Math x y) = Math x ("LaTeX " ++ y)
        wordpressify x = x

任务完成。(这里省略了类型签名,只是为了展示它可以做到这一点。)

但是我不想学习 Haskell!

虽然使用 Haskell 编写 Pandoc 过滤器最为方便,但使用 Python 和 pandocfilters 包也相当容易。该包已在 PyPI 中,可以通过 pip install pandocfilterseasy_install pandocfilters 安装。

以下是用 Python 编写的 “去头” 过滤器:

#!/usr/bin/env python

"""
Pandoc 过滤器用于将所有二级及以上的标题转换为带有强调文本的段落。
"""

from pandocfilters import toJSONFilter, Emph, Para

def behead(key, value, format, meta):
  if key == 'Header' and value[0] >= 2:
    return Para([Emph(value[2])])

if __name__ == "__main__":
  toJSONFilter(behead)

toJSONFilter(behead) 遍历 AST 并将 behead 动作应用于每个元素。如果 behead 返回空值,节点保持不变;如果返回对象,则替换节点;如果返回列表,则将新列表插入。

请注意,尽管在这个示例中没有使用这些参数,format 提供了访问目标格式的途径,而 meta 提供了访问文档元数据的途径。

pandocfilters 存储库 中有许多 Python 过滤器的例子。

对于更符合 Python 风格的替代方案,可以考虑使用 panflute 库。

不喜欢 Python?还有其他语言版本的 pandocfilters

从 Pandoc 2.0 开始,Pandoc 内置了使用 Lua 编写过滤器的支持。Lua 解释器已内置到 Pandoc 中,因此 Lua 过滤器无需额外软件即可运行。请参阅 Lua 过滤器文档

包含文件

目前为止,我们所做的转换都没有涉及到输入输出(IO)。那么,有没有一种脚本可以读取一个 Markdown 文档,找出所有具有 include 属性的内联代码块,并用指定文件的实际内容替换它们的内容呢?

#!/usr/bin/env runhaskell
-- includes.hs
import Text.Pandoc.JSON
import qualified Data.Text.IO as TIO
import qualified Data.Text as T

doInclude :: Block -> IO Block
doInclude cb@(CodeBlock (id, classes, namevals) contents) =
  case lookup (T.pack "include") namevals of
       Just f     -> CodeBlock (id, classes, namevals) <$>
                      TIO.readFile (T.unpack f)
       Nothing    -> return cb
doInclude x = return x

main :: IO ()
main = toJSONFilter doInclude

在以下操作中尝试此操作:

Here's the pandoc README:

~~~~ {include="README"}
this will be replaced by contents of README
~~~~

移除链接

如果我们想要从文档中移除每一个链接,但保留链接的文本内容,应该怎么做呢?

#!/usr/bin/env runhaskell
-- delink.hs
import Text.Pandoc.JSON

main = toJSONFilter delink

delink :: Inline -> [Inline]
delink (Link _ txt _) = txt
delink x              = [x]

请注意,delink 不能是一个类型为 Inline -> Inline 的函数,因为我们想要用来替换链接的不是一个单一的 Inline 元素,而是一个这样的元素列表。因此我们将 delink 设计为从一个 Inline 元素到一个 Inline 元素列表的函数。toJSONFilter 仍然可以将这个函数提升为类型为 Pandoc -> Pandoc 的转换。

这段话的意思是,在处理 Pandoc 的抽象语法树时,如果我们要将链接(Link)替换为其文本内容,由于链接的文本内容可能包含多个 Inline 元素(例如单词间可能有空格或其他标记),我们需要返回一个 Inline 元素的列表而不是单个 Inline 元素。即使这样,toJSONFilter 依然可以将这样的函数提升为在整个文档级别进行转换的功能。

以下是去除 HTML 标签并翻译成中文的内容:

一个用于 ruby 文本的过滤器

最后,这里有一个很好的实际应用例子,是在 pandoc-discuss 列表中开发的。Qubyte 写道:

我对使用 pandoc 将我的日语 Markdown 笔记转换成美观的 HTML 和(Xe)LaTeX 格式很感兴趣。随着 HTML5 的出现,ruby(通常用于给汉字注音,通过在汉字上方或旁边放置文本)成为标准,并且浏览器的支持正在逐渐增强(基于 WebKit 的浏览器似乎已经完全支持它)。对于那些尚未支持它的浏览器(特别是 Firefox),该特性会以一种优雅的方式退化,即在每个汉字旁边用括号包含注音,这对于其他输出格式也很适用。(Xe)LaTeX 中的 ruby 不是问题。

目前,我在转换成 HTML 时使用内联 HTML 来实现这个效果,但这看起来很丑陋并且需要很多按键操作,例如:

<ruby>ご<rt>ご</rt>飯<rp>(</rp><rt>はん</rt><rp>)</rp></ruby>

这样设置 “ご飯”(gohan),其中 “はん” 被注音在第二个字符之上,或者如果浏览器不支持 ruby,则显示在字符旁边的括号内。我希望有一种更简洁的方式来表示:

r[はん](飯)

或者任何节省按键的操作都会受到欢迎。

我们提出了以下脚本,它使用了这样的约定:Markdown 链接中 URL 以破折号开头的被视为 ruby:

[はん](-飯)
{-# LANGUAGE OverloadedStrings #-}
-- handleruby.hs
import Text.Pandoc.JSON
import System.Environment (getArgs)
import qualified Data.Text as T

handleRuby :: Maybe Format -> Inline -> Inline
handleRuby (Just format) x@(Link attr [Str ruby] (src,_)) =
  case T.uncons src of
    Just ('-',kanji)
      | format == Format "html" -> RawInline format $
        "<ruby>" <> kanji <> "<rp>(</rp><rt>" <> ruby <>
        "</rt><rp>)</rp></ruby>"
      | format == Format "latex" -> RawInline format $
        "\\ruby{" <> kanji <> "}{" <> ruby <> "}"
      | otherwise -> Str ruby
    _ -> x
handleRuby _ x = x

main :: IO ()
main = toJSONFilter handleRuby

注意,当使用 --filter 调用脚本时,pandoc 会传递目标格式作为第一个参数。当函数的第一个参数类型为 Maybe Format 时,toJSONFilter 会自动为其分配 Just 目标格式或 Nothing

我们编译我们的脚本:

# 首先确保安装了pandoc-types:
cabal install --lib pandoc-types --package-env .
ghc --make handleRuby

然后运行它:

% pandoc -F ./handleRuby -t html
[はん](-飯)
^D
<p><ruby>飯<rp>(</rp><rt>はん</rt><rp>)</rp></ruby></p>
% pandoc -F ./handleRuby -t latex
[はん](-飯)
^D
\ruby{飯}{はん}

注意:要通过 LaTeX 生成 PDF,你需要使用 --pdf-engine=xelatex,指定一个包含日文字符的主字体(例如 “Noto Sans CJK JP” ),并在模板或 header-includes 中添加 \usepackage{ruby}

练习

  1. 将 Markdown 文档中的所有常规文本转换为全大写(不改变 URL 或链接标题中的文本)。
  2. 从文档中移除所有的水平分割线。
  3. 将所有编号列表重新编号为罗马数字。
  4. 将所有带有 dot 类别的分隔代码块替换为由 dot -Tpng(来自 graphviz)在代码块内容上运行生成的图像。
  5. 找出所有带有 python 类别的代码块,并使用 Python 解释器运行它们,将结果打印到控制台。

JSON 过滤器的技术细节

JSON 过滤器是指任何能够消费和产生有效的 Pandoc JSON 文档表示的程序。本节描述了与调用过滤器相关的技术细节。

参数

程序总是会被调用,并且目标格式作为唯一的参数。例如,一个像这样的 Pandoc 命令:

pandoc --filter demo --to=html

将会导致 Pandoc 调用程序 demo 并传入参数 html

环境变量

Pandoc 在调用过滤器之前会设置额外的环境变量。

  • PANDOC_VERSION: 使用来处理文档的 Pandoc 版本号。例如:2.11.1

  • PANDOC_READER_OPTIONS: 传递给输入解析器选项的 JSON 对象表示。

    对象字段包括:

    • abbreviations: 已知缩写的集合(字符串数组)。
    • columns: 终端中的列数;一个整数。
    • default-image-extension: 图像的默认扩展名;一个字符串。
    • extensions: 语法扩展位字段的整数表示。
    • indented-code-classes: 缩进代码块的默认类别;字符串数组。
    • standalone: 输入是否为包含头部的独立文档;布尔值 truefalse
    • strip-comments: HTML 注释被剥离而不是作为原始 HTML 解析;布尔值 truefalse
    • tab-stop: 制表符的宽度(即等效空格数);一个整数。
    • track-changes: docx 中跟踪更改的设置;可选值为 "accept-changes""reject-changes""all-changes"

支持的解释器

通过 --filter/-F 参数传递的文件预期是可执行的。但是,如果未设置可执行权限,那么 Pandoc 会尝试根据文件扩展名猜测合适的解释器。

文件扩展名 解释器
.py python
.hs runhaskell
.pl perl
.rb ruby
.php php
.js node
.r Rscript
在本文档中