在 Markdown 中优雅绘图

2023-06-22 ⏳7.1分钟(2.8千字) g

我一直用自研的博客系统1。因为是自研,只实现了核心功能。像矢量绘图功能就没有支持。技术文章在很多地方都需要添加示意图。现在只能先用外部软件画,再生成图片,最后上传到博客来引用。这种办法不但将绘图跟写作分割开来,更大的问题是后期改起来很麻烦。所以我尽量使用 ASCII 字符 + 代码块的方式画示意图,方便后续修改。到现在博客的基础功能已经稳定,为了让文章更加出彩,是时候实现矢量绘图功能了。今天跟大家分享基于 pandoc 的 lua-filter 实现在 Markdown 中直接绘制 SVG 矢量图。

功能要求

在介绍之前,先说一下我对绘图功能的几点要求:

功能要求明确之后,就可以做技术选型了。

技术选型

首先就不得排除 Mermaid。这是当前比较流行的绘图工具,支持绘制流程图、时序图等好多类型。甚至还能画甘特图和思维导图。但是它不支持在服务端使用。严格来说它只能在浏览器里使用。有个 Mermaid-CLI 项目,其实也是调用浏览器画完之后再提取图片。

如果在浏览器使用 JavaScript 绘制,那么需要加载将近 2M 的代码。读者每访问一次就得绘制一次。我个认感觉太浪费了。

另外,Mermaid 虽然支持多种示意图,但不支持程序化的绘图。也就是自己不能想怎么画就怎么画,只能使用它提供的几种功能。

基于上述几点考虑,只能放弃 Mermaid。但 Mermaid 用户确实很多,内置的功能还比较强大。现在支持不了真是个遗憾。

Graphviz

其实在 UNIX 世界,矢量绘图软件有很多。最简单却又很强大的要属 Graphviz。它跟 Mermaid 一样,是一种静态描述语言,主要用来绘制网状图,用于表还节点与节点之间的关系。

比如下图就是用 Graphviz 绘制的2,展示了 Linux 内核分层以及各系统的关系:

Graphviz 支持输出 SVG 格式,它的描述语言 DOT 也比较简单。Graphviz 在很多系统中都有应用,比如 Go 语言生成的代码依赖关系图用的就是它。所以 Graphviz 应该支持。

虽然 Graphviz 很强大,但它使用的 DOT 语言只是一种静态描述语言,只能表达图的结构以及各部分之间的关系,并不具备编程能力。

有时我们希望通程序动态输出图片,比如绘制某函数在特定区间的图象等。这就需要用到第二款绘图工具 Asymptote。

Asymptote

Asymptote 是一种专门用于矢量绘图的编程语言,语法跟 C++ 相似,底层使用 LaTeX 作为绘图引擎。Asymptote 能做的事 LaTeX 都能做。但就绘图而言,Asymptote 更专业,更强大、也更方便。

下图展示数列极限3:

数列极限示意图

后面我准备写一些数学方面的内容,Asymptote 应该必不可少。但无论 Asymptote 多么厉害,它只是 LaTeX 的一个壳。很多时候直接使用 LaTeX 会更灵活。

LaTeX

LaTeX 可以绘制各类图形,而且有大量宏包可用。最著名的要属 PGF/TikZ 了。下面是用 PGF/TikZ 绘制的饼状图:

使用 PGF/TikZ 绘制的饼状图

我们甚至还能用 TikZ 绘制动画。比如下图给出太阳、地球和月球的运行关系:

日-地-月系统

考虑到 Asymptote 仅限于数学领域,支持 LaTeX 原生绘图也必不可少。

综上所述,新的绘图系统应该同时支持 Graphviz/Asymptote/LaTeX 三种引擎。

技术方案

技术方案其实很简单。就是利用 lua-filter4 给每一个 CodeBlock 设置回调函数。在回调函数里提取描述代码,然后调用对应的处理程序生成 SVG 图片。

在 lua-filter 最后指定 CodeBlock 回调:

return {{
  CodeBlock = code_to_figure,
  --- ...
}}

整体流程

这里的 code_to_figure 就是我们要实现的 lua 函数。pandoc 调用时会把当前 block 对象传给它。我们可以通过block.classes[1]获取当前 CodeBlock 的代码类型。block.text 则保存了实际的代码内容。

比如说我们写如下代码块

'''tex
%%...
'''

那么它的block.classes[1]的值就是tex,block.text的值就是%%...。

然后就是提取绘图参数。 pandoc 支持在声明代码块时指定参数,比如:

'''{.tex width=200px}
%%...
'''

但这种写法不太自然。我参考了 pandoc 官方的 diagram 插件5,它可以从代码注释中提取配置。但官方的实现太过复杂,我做了简化。

上述配置可以简写为:

'''tex
%%| width: 200px
'''

注意,配置写在注释里。三种引擎注释可不相同。Graphviz 和 Asymptote 使用 // 前缀, LaTeX 使用 %% 前缀。后面坚跟|,再后面就是Key: Value这样的配置。

有了配置、代码和引擎,就可以执行对应的命令绘图了。

local ok, imgdata = pcall(engine.compile, engine, block.text, dgr_opt.opt)

最后将数所保存到 SVG 文件,然后生成对应的 Image 或者 Figure 对象,插入到生成的 HTML 文件。完整流程如下6:

local function code_to_figure (block)
  --- 提取描述代码类型
  local diagram_type = block.classes[1]
  if not diagram_type then
    return nil
  end
  --- 获取绘图引擎
  local engine = engines[diagram_type]
  if not engine then
    return nil
  end
  --- 提取绘图配置
  local dgr_opt = diagram_options(block, engine.line_comment_start)
  for optname, value in pairs(engine.opt or {}) do
    dgr_opt.opt[optname] = dgr_opt.opt[optname] or value
  end
  --- 生成 SVG 数据
  local ok, imgdata = pcall(engine.compile, engine, block.text, dgr_opt.opt)
  --- 保存 SVG 文件
  draws = draws + 1
  local basename, _ = pandoc.path.split_extension(PANDOC_STATE.input_files[1])
  local fname = basename .. '.' .. draws .. '.svg'
  write_file(fname, imgdata)
  --- 生成 Figure 或者 Image 对象
  --- 也就是生成对应 HTML 代码
  local image = pandoc.Image(dgr_opt.alt, "/"..fname, "", dgr_opt['image-attr'])
  return dgr_opt.caption and
    pandoc.Figure(
      pandoc.Plain{image},
      dgr_opt.caption,
      dgr_opt['fig-attr']
    ) or
    pandoc.Plain{image}
end

配置提取部分主要是字符串匹配与合并约定,在此不作赘述。现在说一下各引擎的实现。每个引擎最核心的就是实现自己的 compile() 函数。说白了就是构造各自的命令行参数。

Graphviz

Graphviz 最简单。给定 .dot 文件,运行 dot -Tsvg 就能从 stdin 读取 DOT 源码然后生成 SVG 代码,最终输出给 stdout。所以它的 Engine 代码最简单:

local graphviz = {
  line_comment_start = '//',
  compile = function (self, code)
    return pandoc.pipe('dot', {"-Tsvg"}, code)
  end,
}

这里用到 pandoc 提供的 pipe() 函数,来执行 dot 命令。pipe() 会返回程序的输出结果。

Asymptote

Asymptote 就没有那么容易了。它不支持直接将 SVG 输出到 stdout,只能保存到中间文件。这个时候就需要先创建一个临时目录,不然多个任务可能会相互覆盖。

pandoc 的 system 包提供了 with_temporary_directory() 函数,它的作用是创建临时目录,并调用另一个工作函数。工作函数返回后自动删除刚才创建的临时目录。system 包还提供了另一个函数 with_working_directory() 是让当前进程切换到新的工作目录。两者结合使用就可以为 asymptote 建立临时运行环境。

asymptote 底层使默认使用 pdflatex 生成 PDF。我样想支持汉字并生成 SVG,所以需要指定 -tex xelatex 和 -f svg 两个参数。另外还需要通过 -o draw 指定 SVG 的文件名。asymptote 可以通过特殊参数 - 表示从 stdin 读取 ASY 源码,比较方便。

生成 SVG 文件后还需要再读出来。完整代码如下:

local system = require 'pandoc.system'
local with_temporary_directory = system.with_temporary_directory
local with_working_directory = system.with_working_directory

local asymptote = {
  line_comment_start = '//',
  compile = function (self, code)
    return with_temporary_directory('asymptote', function(tmpdir)
      return with_working_directory(tmpdir, function ()
        local args = {'-tex', 'xelatex', '-f', 'svg', '-o', 'draw', '-'}
        pandoc.pipe('asy', args, code)
        return read_file('draw.svg')
      end)
    end)
  end,
}

Asymptote 内部使用 dvisvgm 生成 SVG 文件。所以需要一并安装 dvisvgm 工具。

LaTeX

LaTeX 的配置最为复杂。因为它不支持从 stdin 读取源码,必须生成 tex 源文件。另外 LaTeX 不支持直接生成 SVG 文件,跟 Asymptote 一样,需要 dvisvgm 转换,而且这一步需要手工执行。

又因为我们需要支持中文,所以这里使用 xelatex。使用 -no-pdf 指定不生成 PDF 而输出 XDV 文件。-halt-on-error 参数表示出错后立即退出,避免 LaTeX 进入交互式错误恢复状态。

最后的最后,我们们需要在 Tex 源码开头添加\documentclass[dvisvgm]{standalone} 导言。这部分的意思是为 dvisvgm 生成独立的描述信息,不再生成普通的页面结构描述。

剩下的部分跟 Asymptote 就没有两样了。

local xelatex = {
  line_comment_start = '%%',
  compile = function (self, code, user_opts)
    return with_temporary_directory('tex', function (tmpdir)
      return with_working_directory(tmpdir, function ()
        code = "\\documentclass[dvisvgm]{standalone}\n" .. code
        write_file('draw.tex', code)

        local args = {'-halt-on-error', '-no-pdf', 'draw.tex'}
        pandoc.pipe('xelatex', args, '')
        pandoc.pipe('dvisvgm', {'draw.xdv'}, '')

        return read_file('draw.svg')
      end)
    end)
  end
}

最终把它们组织到一起就可以了:

local engines = {
  ['asy.svg'] = asymptote,
  ['dot.svg'] = graphviz,
  ['tex.svg'] = xelatex,
}

杂项问题

如果你在网上搜过 TikZ + SVG,可能会看到有人说用 pdf2svg 这个程序。我试过,这个程序在 mac 平台可以通过 homebrew 安装。从名字上一看就知道,它可以把 PDF 转成 SVG。它的优点是兼容性好,只要有 PDF 就能产生 SVG。但缺点也很明显:

与 pdf2svg 相比,dvisvgm 更加先进。它生成的 SVG 可以保留文本信息。但是 SVG 目前没有提供 mac 平台的二进制版本,需要自己构建。而构建 dvisvgm 又需要 TexLive,比较麻烦。我目前只在 Ubuntu 服务器上构建。

另一个问题就是如何指定生成的 SVG 文件名。我是设置了一个全局变量 draws,每处理一个 CodeBlock 就加一,然后根据当前 Markdown 文件名生成对应的 .$draws.svg 文件。每次更新 Markdown 都会重新生成所有的 SVG 文件。慢是慢了点,但这样基本可以不考虑清理问题。官方的代码使用描述的 sha1 摘要做文件名,每次修改都会产生新 SVG,不可取。

最后说一下 SVG 文件体积问题。SVG 文件也可以内嵌字体描述数据。dvisvgm 生成 SVG 时,为了尽可能跟原来的 PDF 保持相同的显示效果,它会把用到的字体数据也打包到 SVG 文件中。但就我观察,如果只是做普通的示意图,完全可以不带字体数所,让流览器使用本机默认字体就行。可是 dvisvgm 并没有这样的参数。所以我就强行用 lua 删掉了 SVG 中的字体数据。这样可以显著减小某些示意图(特别是简单的图形)的体积。如果遇显示问题,则可以通过 opt-embed-font 参数关闭该特性。

完整示例

文章开头的饼状图完整代码如下:

%%| fig-cap: 使用 PGF/TikZ 绘制的饼状图
%% 默认会删除 SVG 中内嵌的字体描述信息,以减少体积
%% 如果展示有问题则可以指定内嵌字体
%%| opt-embed-font: true
\usepackage{xeCJK}
\usepackage{pgf-pie}

\begin{document}

\begin{tikzpicture}

\pie{22.97/洛杉矶湖人,
    22.97/Boston Celtics,
    8.11/Golden State Warriors,
    8.11/Chicago Bulls,
    6.76/San Antonio Spurs,
    31.07/Other Teams}

\end{tikzpicture}

\end{document}

注意这里使用了fig-cap和opt-embed-font两个参数。

总结

以上就是本文的全部内容,欢迎留言讨论。从此我的博客真正进入图文时代。以后要输出更多图文并茂的内容。