基于 Python 脚本实现通过 YAML frontmatter 指定 Pandoc 编译 Markdown 文件时的任意命令行参数

摘要:Pandoc 作为一款文档转换工具,由于灵活轻量、功能强大,衍生出了一套围绕 Pandoc 开展的文档写作方案,然而,作为广泛使用且广受好评的写作工具,Pandoc 在使用过程中遇到的部分难题却总述被人忽略,难以找到解决办法。本文将着重 Pandoc 在转换 Markdown 文件时,部分命令行运行参数无法在 YAML Header 中指定的一个问题展开分析,总结现有的解决方案,并提出基于 Python 脚本实现从 YAML Header 中提取需要指定的 Pandoc 运行参数并构建编译命令执行的解决方案。

背景与问题描述

基于 Pandoc 的写作

Pandoc 作为一款文档转换工具,支持不同文档格式之间的相互转换,也支持在转换前定义转换后生成文档的样式、效果等。由于灵活轻量、功能强大,衍生出了一套围绕 Pandoc 开展的文档写作方案。笔者在日常生活中,涉及到各种类型的写作和文章排版任务,就经常基于 Pandoc 提供的功能,首先使用 Markdown 轻量文本标记语言写好文章,再通过 Pandoc 转换为 \LaTeX 源码并调用 Xe\LaTeX 编译引擎一键导出为排版样式精美的 PDF 文档进行发布。这样一来,以往需要在 \LaTeX 中调整半天的格式,现在只需要提前在模板中定义好、在 Markdown 中轻松地敲好文章、挑选模板,再使用 Pandoc 一键导出就可以了。这种形式大大提升和改善了我们的写作和工作效率。

然而,作为广泛使用且广受好评的写作工具,Pandoc 在使用过程中遇到的部分难题却总述被人忽略。在互联网上反映这些问题和提供解决方案的博客文章几乎屈指可数。本文将着重 Pandoc 在转换 Markdown 文件时,部分命令行运行参数无法在 YAML Header 中指定的一个问题展开分析,总结现有的解决方案,并提出基于 Python 脚本实现从 YAML Header 中提取需要指定的 Pandoc 运行参数并构建编译命令执行的解决方案。

因此,笔者首先根据自身使用 Pandoc 的经验,总结一些 Pandoc 的使用规律,详述如下,以便解释后续遇到的问题。

通过 Pandoc 实现 Markdown 导出 PDF

编译命令及其调用

首先,使用 Pandoc 导出文档时,一般情况下我们会使用一个并不简单的长命令:

pandoc -f "<文件名>.md" -t "<文件名>.pdf"

我们并不会每次都手动敲打这个命令,而是将其定义在个人所使用的 Markdown 编辑器里面,通过编辑器命令或者界面按钮(比如 Visual Studio Code 里的 Code Runner 插件提供的右上角的编译按钮)调用。

Markdown 文件的 YAML 元数据

YAML frontmatter 是一种由 Jekyll 推广的创作约定 [1],它提供了一种向页面添加元数据的方法。本文提到 Markdown 文件的 YAML 元数据就是 Markdown 的 YAML frontmatter,它是一个键值内容块,位于每个 Markdown 文件的顶部,以三个短横线 --- 包裹。

而在基于 Pandoc 的写作中,我们一般会在编写的时候为 Markdown 文档加上 YAML 头部元数据,指定导出文档的标题 title、使用的文档类 documentclass、文档类参数 classoption 以及各种常见的文档元信息 authordatethanksabstract[2]。其中,导出的 PDF 文件的具体的排版规则,如标题字号、段落缩进、图表公式编号等,均定义在 \LaTeX 文档类 *.cls 文件当中。

# 文档类、文档类参数等
documentclass: customart
classoption: 14pt, a4paper

# 文章元数据
title: 文章标题
subtitle: 文章副标题
author: |
  作者的名字
  \thanks{作者的工作单位,联系方式E-mail:theauthor@example.com}
date: \zhtoday
abstract: | 
  本文是示例文本,展示了由 Markdown 通过 Pandoc 调用 LaTeX 

# 编译参数,包括启用文档类定义的缩进以及目录等
indent: true
partskip: true
toc: true

灵活调用 \LaTeX 文档类

以上述 YAML Header 为例,customart 是笔者自己写的一个 \LaTeX 文档类,其中按照笔者展示文章的习惯定义了各种排版规则。文档类可以“安装”在计算机上,从而在编译 \LaTeX 文档时,可以在计算机的任何位置从安装目录调用。

这一功能的实现依赖特定 \LaTeX 发行版提供的功能,比如笔者使用的 TeXLive 就提供了发行版安装目录下的 texmf-local 目录,支持将文档类安装到该位置的子目录下,并据此更新 \LaTeX 引擎在编译 \LaTeX 文档时搜索文档类所依赖的路径数据库,找到这份由用户自己定义的文档类,从而正确编译指定了自定义文档类的 \LaTeX 文档。这是这一 Pandoc 写作方案的便利性优势之一,但这部分内容不是本文的重点,故此不再赘述。

理想效果

在理想状态下,像上面这样编写 Markdown 文件开头的 YAML,直接导出 PDF 文件,就能看到排版好的文章,带有完善的摘要、作者信息、标题样式、排版效果。这样的设计的好处是方便灵活。具体地来说:

  1. 不需要每次都针对需要导出 PDF 的文档单独敲打一遍针对这一文档类的编译命令,只需要在每次编写的时候。
  2. 编译命令的很多参数可以和 Markdown 文件放在同一个文档文件里面,将 Markdown 文件单独分享给其他人的时候其他人也能快速导出。
  3. 我们写的文章也没有图片、是纯文本,那么我们也不需要为这篇文章单独开一个项目文档文件夹,即写即用。
  4. \LaTeX 源码可以直接插入 Markdown 中,与 Markdown 文件混排,在编译时即刻生效。

然而,这仅仅只是一种理想的情况。而在我们实际操作的时候,会遇到一系列意料之外的问题。

Pandoc 转换 Mardown 的难点

额外的命令行参数选项

尽管上述的事实看上去很美好,但实际上在通过 Pandoc 导出文件的时候,总会遇到额外的困难。这里列举一些常见的问题及其解决方案:

  1. 由于我们使用到的大多数现代化的文档类都会要求使用 Xe\LaTeX 作为编译器,如果使用 pdf\LaTeX 就会因为宏包的兼容性问题出现报错。这就需要我们在使用 Pandoc 的时候指定 --pdf-engine=xelatex
  2. 假如我们使用 report 或者 book 文档类,Pandoc 仍然会将 Markdown 中的一级标题映射到 \LaTeX 中的 section 而不是 chapter。这个问题需要通过指定额外的参数 --top-level-division=chapter 来解决。
  3. 有时我们并不总是在 YAML Header 中设置 title 参数作为文档大标题。像是 Github 上的 README.md 文件,我们希望直接将 Markdown 文件最开头的一级标题 # 作为整个文档的大标题。这种情况下,可以通过参数 --shift-heading-level-by=-1 来指定。

然而,问题在于,这几个参数都不能在 Markdown 的 YAML Header 中指定,只能在 Pandoc 的命令行参数中指定!换句话说,如果读者尝试在有待转换的 Markdown 文件的 YAML Header 中写入:

pdf_engine: xelatex
top_level_division: chapter

Pandoc 是不会正确读取的。

正如我们先前所述,由于我们的编译命令是定义在编辑器里面的。这样一来,假如我们在编写具有不同的标题级别的文档(reportarticle),那么在编辑器里面定义了 --top-level-division=chapter,就会导致编译 article 文档类的时候出错,因为 article 文档类没有 chapter 级别的标题。反之,如果我们不进行这一定义,那么在编译 report 的时候,一级标题就不会被编译成章标题 chapter 而是会作为节标题 section。这样,配置就无法根据我们的需要随机变更。

问题的现状

通过查阅互联网资料可以发现,在使用 Pandoc 的过程中遇到类似问题的人其实并不少,截止至本文成文的时候,在原仓库下面有 Issue # 1780 # 2925# 3060# 4627 都反馈了相似的问题。

根据官方给出的回答,尽管一直在收到反馈,且的确在考虑这种改变,但是出于许多技术上的原因(比如命令行的回传问题),Pandoc 本身如果需要将这个功能囊括在内,就需要极大拓展 Pandoc 的当前开发,作出许多变更。因此,在短期内,对于上述的这部分调用 Pandoc 时的命令运行参数,官方暂时不会支持在 Markdown 的 YAML Header 中指定的特性。

解决

现成的可行方案

R Studio 和 R Markdown

针对这一问题,网络上经常出现一类解决方案,提出可以在 Markdown 的 YAML 头部里加入如下的几行内容。有的时候 ChatGPT 一类的生成式模型也会提供像这样的解决方案,但是,读者往往在实际测试中才会发现,这样的方案并不生效。

output:
  pdf_document:
    latex_engine: xelatex
    top_level_division: chapter

实际上,这种机制是 R Studio 和 R Markdown 中特有的,只有在 R Studio 提供的 Knitr 功能中才生效,原生的 Pandoc 并不支持。

名义上,R Studio 虽然是用于编辑 R 语言的 IDE,但其实,R Studio 内部集成了一套强大的文学编程引擎,名为 Knitr,能够将代码和使用 Markdown 标记语法编写的论文混排,一键实现在运行代码的同时同步构建排版好的、可直接发表的论文成品文档。这个功能就是 Knitr 提供的。同时,R Studio 也提供了的 Markdown 实时渲染、可视化编辑功能,这在 R Studio 中称为“visual mode”。

在执行 Knitr 的时候,R Studio 会首先读取和处理 output 键值中的各项参数,生成一个具有针对性的 pandoc 命令,然后再运行这个命令,导出 PDF。

从这个角度上来说,R Markdown 和 R Studio 确实是一个可行的解决方案。然而,一方面我们不能指望每当读者需要使用 Markdown 写作时就必须打开 R Studio,另一方面 Knitr 作为一个 R 语言的包本身却没有提供用于与其他工具集成的命令行接口,更何况,Knitr 本身的功能是用于运行 R Markdown 中的 R 语言代码块并将运行结果集成到输出的 PDF 文件当中去。如果不使用这一功能,那么对于用户来说,相当于只使用了 R Studio 极少的功能。因此,这个解决方案仅适合部分用户。

第三方 Pandoc 自动化调用工具

对于上述问题,有不少开发者都选择开发了自己的第三方 Pandoc 自动化调用工具,其中 Github 上最出名的三套现成方案分别是 mb21 开发的 panrunmsprev 开发的 panzerhtdebeer 开发的 pandocomatic,这些项目足够优秀,但出于需求原因,本文作者并未实践考证过。建议读者自行前往项目仓库访问。

总的来讲,这三套方案能够解决问题,同时由各有利弊。

首先,panzer 和 pandocomatic 都是基于各自引入的样式(Style)或模板(Template)机制的,功能上更加强大、灵活,但同时也意味着用户需要付诸额外的学习成本,而且需要一些配置。不仅要面临与其他工具兼容的问题,而且也会遇到 Markdown 文档迁移的困难。

而 panrun 相对来说则更轻量简洁,其目的就是为了直接在 YAML 参数中指定 Pandoc 导出的格式及导出时的运行参数,而且作者有意兼容了 R Markdown 的 YAML 写法,大大降低了用户的学习成本。然而,panrun 是基于 Ruby 编写的,而出于某种原因,原作者竟然并未提供 panrun 的封装可执行文件(EXE文件)。因此,目前如果想要使用 panrun,则必须自行学习配置 Ruby 的运行环境。这对于很多用户来讲是不可接受的。

通过 Makefile 管理编译命令

Makefile 是 GNU Make 中一种定义了软件项目中文件依赖关系和构建规则的文本文件。通过 Makefile,我们可以使用 make 工具自动执行编译、链接等操作,从而简化软件项目的构建过程。针对当前的情况,读者可以考虑为每一条编译命令适用的情形设计一条编译规则,然后使用 make <编译规则名称> 的命令进行调用。

考虑采用这一方案,需要满足如下的几个条件:

  1. 用户熟悉 Makefile 的编写和使用,且完成了 GCC 的配置;
  2. 每当用户采用基于 Pandoc 的写作,总是为文章写作项目创建新的工作区文件夹(这是由于 Makefile 文件必须放置在 make 命令的执行目录下 )。

在编辑器中自行定义多个编译命令

如果读者使用的编辑器允许,那么读者也可以针对使用 Pandoc 编译 Markdown 的具体情形——比如,使用 chapter 映射 \LaTeX 一级标题的情形、使用最上方的一个一级标题作为全文 title 的情形等,总结若干个编译命令,固定在编辑器调用编译命令时的面板当中。

以常用于编写 Markdown 文档的 Obsidian 为例,读者可以选择不为 Obsidian 安装 Pandoc 插件,而是直接安装 Shell Commands 插件,手动编写几个命令,然后为每个命令设置快捷键,或者按下 Ctrl + p 从命令面板快捷调用。当然,这一方法的便利性和便捷程度取决于读者所使用的具体编辑器提供的功能。

基于 Python 自行编写脚本

脚本的逻辑

最后的解决方案也是笔者最推荐的解决方案,就是根据读者自身的实际需求,使用 Python 或任何读者熟悉使用的语言编写自动化脚本。这是由于每个人对于 Pandoc 转化 Markdown 文件时的命令行参数控制的要求各不相同;每个人使用的编辑器各不相同,因此兼容性也各不相同 [3]。同时,考虑到 Python 在处理 Markdown 文件和 YAML 数据时有独特的优势,使用也比较广泛,这就使得自动化脚本的编写变得容易[4]

实际上,由于脚本的功能简单,用户甚至不需要手动去编写这个脚本,目前有大量成熟的生成式 AI 如 ChatGPT 都能为读者代劳。读者只需要用语言描述清楚自己对于这个自动化脚本的应用场景、兼容问题、功能和特性,并将文本交由 ChatGPT,就能在短短三五分钟内获得可用的成品脚本。

脚本设计

我们可以借鉴其他 Pandoc 自动化项目的经验,编写一个 Python 脚本。脚本会在被调用时读取 Markdown 文件的 YAML Header 中的一个 pandoc_args 参数。脚本需要支持灵活识别 pandoc_args 的形式,可以是纯文本行的形式:

pandoc_args: "--pdf-engine=xelatex --top-level-division=chapter"

或者也可以是嵌套的 YAML 键值:

pandoc_args:
  output: pdf
  pdf_engine: xelatex
  top_level_division: chapter

脚本需要根据上述内容自动构建相应的 Pandoc 命令,再执行。其中,由于我们在编写 YAML 时的习惯,会使用下划线“_”,而不是连词符“-”命名键值,所以脚本在转化参数的时候需要能够自动替换键值的下划线为连接符。

为了兼容 Obsidian,我们还要允许脚本读取列表形式的 pandoc_args 参数,即:

pandoc_args:
  - output=pdf
  - pdf_engine=xelatex
  - top_level_division=chapter

为了增加脚本的可扩展性,我们还可以为脚本增加一个额外的 --pandoc-arguments 参数,让它不仅能接受来自 YAML Header 中的指定,也能接受来自命令行的指定。

成品

脚本的编写

在 ChatGPT 完成脚本 panargs.py 编写之后,经测试其工作效果良好,几乎不需要进行任何其他的修改。因此我只是我调整了参数名、完善 DocString 等。

脚本的核心逻辑是解析 Markdown 文件中的 pandoc_args,支持字典、列表以及简单字符串格式的参数定义,并将 YAML 中的选项与命令行传入的额外选项结合起来。

对于 YAML 中的参数,脚本会自动将 下划线 _ 转换为连字符 -,以符合 Pandoc 的命令行参数格式,同时对参数的优先级进行合理处理,保证 CLI 选项可以覆盖 YAML 中的配置。

最终,脚本以安全的方式执行生成的 Pandoc 命令,将 Markdown 文件转换为用户指定的输出格式(如 PDF、HTML 等)。

开源与下载

目前脚本已经基于 MIT 许可证 开源至 GitHub 平台,开源地址位于 GitHubonline1396529/panargs,其具体的使用方法请参见代码注释及相应的 README.md 文件说明。

脚本的灵活使用

对于该 Python 脚本,由于其灵活性,可以很容易与其他任何支持命令行操作的代码编辑器集成使用。

以 Visual Studio Code 为例,可以将脚本添加到任何系统环境变量目录下(比如在 Windows 平台上可以是某处专用于储存个人脚本工具的目录),然后在 Code Runner 的配置中写入:

"code-runner.executorMapByFileExtension": {
        /* 由扩展插件定义的各项编程语言源文件的运行命令 */
        // 此处省略其他编译命令...
        ".md": "python panargs.py $fileName"
    }

同样地,在 Obsidian 上也可以使用 Shell commands 插件定义名为“pandoc PDF via LaTeX”的新规则:

python panargs.py "{{file_path:absolute}}"

或者,以 Windows 上的 gVim 为例,可以在当前版本的 gVim 的安装目录下新建一个 ./python 文件夹,将脚本放入其中。然后在 .vimrc 中写入[5]

" Function to get name of current shell
function! CurrentShellName()
    return fnamemodify(&shell, ':t')
endfunction

" Function to Run Codes
function! Run()
    if expand("%:e") == "md"
        if CurrentShellName() == "bash.exe"
            ! python $VIMRUNTIME/python/panargs.py "%"
        elseif CurrentShellName() == "pwsh.exe"
            execute "!python " 
            \. expand("$VIMRUNTIME") 
            \. "/python/panargs.py " 
            \. expand("%")
        elseif CurrentShellName() == "powershell.exe"
            execute "!python " 
            \. expand("$VIMRUNTIME") 
            \. "/python/panargs.py " 
            \. expand("%")
        else
            execute "!python " 
            \. expand("$VIMRUNTIME") 
            \. "/python/panargs.py " 
            \. expand("%")
        endif
        redraw!
        echohl WarningMsg | echo " Generate PDF via LaTeX! :-)"
    elseif expand("%:e") == "..."
        " 这里省略其他编程语言 "
    else
        redraw!
        echo "Language not support! :-("
    endif
endfunction

总结

在我们使用 Pandoc 配置自动化写作方案时,仅使用 Pandoc 本身是不够灵活的。需要配合其他方案,如第三方工具或构建管理工具来使用。其中,本文提供的一份便捷的转换脚本能够帮助读者完成任务。通过这种方法,用户可以方便地在 Markdown 文档中集中管理格式化选项,同时保持命令行操作的灵活性。既能快速上手,又不失扩展性,适合轻量化的工作流需求。


  1. 另请参见网页资源 Front Matter - Jekyll↩︎

  2. 这种形式最初似乎是在 R Markdown 中实践并推广的。 ↩︎

  3. 以 Obsidian 为例:Obsidian 的 YAML 头部是扁平化的表格,不支持使用嵌套的键值结构。这就意味着 Obsidian 用户不能直接使用 panrun、panzer、pandocomatic 一类的自动化方案,而是需要手动扩展方案对于 YAML 头部数据的处理以支持列表项的形式。 ↩︎

  4. 在 Pandoc Repository 下的 Issue #4627 的讨论中,Pandoc 的原作者 John MacFarlane 回复 Jason T. Kiley 的消息亦有提及:If you're happy writing a script, then there's no problem as things stand. You can write a script to call pandoc with exactly the options you want. ↩︎

  5. 该配置仅在 Windows 平台下的 gVim 9.1 上使用过,在 NeoVim 或 GNU/Linux 下的 Vim 上没有实际测试过。 ↩︎

posted @ 2024-11-25 02:03  多玩我的世界盒子  阅读(24)  评论(0编辑  收藏  举报