在 Markdown 中优雅绘图
涛叔我一直用自研的博客系统1。因为是自研,只实现了核心功能。像矢量绘图功能就没有支持。技术文章在很多地方都需要添加示意图。现在只能先用外部软件画,再生成图片,最后上传到博客来引用。这种办法不但将绘图跟写作分割开来,更大的问题是后期改起来很麻烦。所以我尽量使用 ASCII 字符 + 代码块的方式画示意图,方便后续修改。到现在博客的基础功能已经稳定,为了让文章更加出彩,是时候实现矢量绘图功能了。今天跟大家分享基于 pandoc 的 lua-filter 实现在 Markdown 中直接绘制 SVG 矢量图。
功能要求
在介绍之前,先说一下我对绘图功能的几点要求:
- 必须输出矢量图。示意图本质上是数据的可视化,应该使用矢量图展示。
- 必须在服务端一次生成。接上条,数据不变,图片也就不变。一次生成就好。
- 必须在 Markdown 源码中直接编辑,其他转换工作全部由工具完成。
- 内置功能尽可能丰富,还须支持通过自动化程序动态生成图片。
功能要求明确之后,就可以做技术选型了。
技术选型
首先就不得排除 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 绘制的饼状图:
我们甚至还能用 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'
(fname, imgdata)
write_file--- 生成 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
('draw.tex', code)
write_file
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
两个参数。
总结
以上就是本文的全部内容,欢迎留言讨论。从此我的博客真正进入图文时代。以后要输出更多图文并茂的内容。
https://graphviz.org/Gallery/directed/Linux_kernel_diagram.html↩︎
如果读者不熟悉 pandoc 的 lua-filter 可以先参考的另一文章 ./blog-internal.html。↩︎
完整代码参见 https://github.com/taoso/led/blob/master/cmd/mkd/desc.lua↩︎