City Background
Article cover

聊聊自动化 PDF 渲染方案

自动化 PDF 是一个我从上大学开始就一直在关注的领域。考研的时候有背单词的需求。为了能根据背诵情况生成小测试卷,我在那时曾经研究过很多方案。作为一名「开源圣战士」(我曾经是能捏着鼻子坚持用 GIMP 和 Open Office 的猛男),因为这事竟然屈辱吞下了用闭源解决方案的结果,可见当年的解决方案有多糟糕。

不过时至今日,typst 和各种开源工具链的出现极大降低了整件事情的开发难度。因为这些年来一直在关注这个领域的发展,所以写篇文章讲讲目前我所发现的各种解决方案,以及它们的优缺点。

考虑到「报告自动生成」是一个在 OA 系统开发当中广为存在的一个需求,而时至今日依旧没有一个「银弹」可以解决大多数方案,所以写一篇文章记录一下我这些年对整个话题的探索,本文亦会记录我最后找到适合自己的「标准答案」。

已有方案

做 PDF 自动生成,有几个比较主要的思路,ˡₐₜₑˣ、Web Stack、typst。这几个方案我之前都试过。因为每个项目对技术栈的要求都不一样,所以在这里我们可以来展开看看每个方案具体的使用场景。

ᴸAₜEˣ

对于 PDF 生成来讲,ₗᵃTᴱₓ 是表现力最好的一种方案了。如果开发者和设计师都懂纸媒设计的基本原则,合作起来会非常愉快。同时,如果你对于「字体排印」非常考究,想要精确实现各种排版效果、把控每一个排印细节,ˡₐᵗₑˣ 可以说是你唯一的选择了。

但是这套方案本身最大的问题是混乱的发行版,ᶫAᵗEₓ 的那一百万个发行版,哪种发行版能做什么,不能做什么。字体怎么导入、表格怎么画之类的话题在各个发行版当中都有不同的答案。各种各样的问题盘根错节,宛若一副绵延的历史绘卷,为它陡峭的入门门槛奠定了扎实的基础。

LₐTᵉX 的复杂度还体现在它「不太具备可维护性」的语法表达上。考虑到会写 ᶫₐᵗᴱX 的开发者本来就不是很多,想把「颇具个人色彩」的模板交接给后面的开发者来「延续工作」可能会有点难度。

最后,如果你希望 PDF 是从客户端生成的,那么 ˡAₜEX 可能也不是一个好的选择。因为大多数的发行版体积都颇为庞大,如果想要做客户端分发,你很有可能需要自己剪裁和维护一个发行版。因此除非你就职于一个不差钱大公司,且「从客户端」生成「印刷质量的 PDF」真的是一个重要的需求,否则我都不会推荐你轻易尝试在客户端嵌入 ᶫᵃᵀᵉˣ 引擎。

不过在服务端跑其实就无所谓了,只要能找到冤大头画模板就行,spawn 模板注意别搞出安全漏洞就好。之前知乎就闹出 K8S 集群上跑的 ₗAᵀEₓ 注入的问题,导致其他客户端看到的渲染结果被篡改。如果不同报表的渲染出现了注入和串扰的问题,后果可能会比较可怕。

Web Stack

如果你对于印刷品排印样式非常考究,那么 Web 可能也不是一个很好的选择。虽然有类似赫蹏 能够有限地实现一些排版特性,但如果真较真起来前端会变得非常痛苦。目前 W3 上已经有针对中文排版的需求文档,但需求变成标准还需要很长的路要走。

设计层面讲完,我们再来看看技术面。

Web 生成 PDF 这边的情况比较复杂一些。古早年代的确是有 Phantom.js 用 QTWebkit 搞出了非常便利的 PDF 生成流程。但是这个项目已经不维护了,而且 QT 也抛弃了 Webkit 引擎,改用 Chromium 内核,所以这个方向在事实层面上已经没办法走通。

如果你所在的公司富甲一方,并且你也不想给自己找太多麻烦,我个人会比较推荐用闭源的商业方案 Prince,虽然不自由,但是整个开发体验的确是很不错,没有那些乱七八糟的毛病。

最后,如果你向往自由,希望用开源方案生成 PDF,还希望钉死在 Web 方案上,那么恭喜!你可能选择了一个难度最高的游戏。

最滑稽的开头是「我电脑里面那 1001 个 Chromium 到底在哪个目录里?」几乎每次开发者换开发设备的时候,这个目录地址都会变,所以每次配环境的时候你都得重新折腾一遍这种很琐碎的事情,浏览器升级的时候二进制的目录也有可能发生变化。JS 生态里面的确有些包能够「再额外在你的电脑里下载一个 Chromium」,但是限于众所周知的网络条件原因,这个过程可能会充满心酸。

接下来就是真正的问题了,尽管 Web 最一开始就是用来排印文档的,但对于打印样式的规范一直都不是很明确。有些 CSS 样式写了也会让人怀疑「加进去的这堆魔法真的有用吗」。对纸张尺寸限制、页眉页脚、页边距、页码样式、换页等问题的处理,你可能需要花很多时间才能整理出一个具备「跨引擎一致性」的答案。

如果你开发的程序跑在服务器上,而不是客户端上,一些无头 Chromium 框架可以在一定程度上解决样式设置上的问题,「打印样式」这个话题在 Web 平台上并不像其他 API 一样散发着「充满兼容性」的气息。框架(Playwright、Puppeteer)和浏览器引擎偶尔会搞出一些 Breaking Change。以我个人过往的使用经验来看,每年维护简历的时候编译出来的样式一定会飘掉,可以说让人觉得非常头痛。

最后,性能也是一个很重要的考量。通过 Chromium 的 Headless 渲染 PDF 需要的时间并不短,而且因为是开一个不可见的浏览器 Tab,因此对内存的要求不低,也很难做并行渲染加速。因此配一个渲染队列和妥善的权限管理、资源回收机制也是额外需要做的工作。

不过尽管有这些麻烦的地方,Web Stack 也有独特的优点。比如:

  • 极佳的调试体验,打开 Inspector,打开打印渲染的模式,就可以迅速调试打印样式了。浏览器窗口大小调一调就可以检查不同纸张宽度对应的渲染效果是什么样的。
  • 平滑的难度曲线,通常来讲智力正常且十指能动就能写前端,相对于难度偏高的 Web App 开发,排版一个没有任何 JavaScript 介入的静态文档可以说是入门当中的入门难度了。
  • 非常能打的渲染器,如果你要渲染的东西有很多「花活」,像是图表、插图之类的,那么使用 Web Stack 渲染通常不会出什么兼容性上的毛病,只要你细心点调 CSS,输出的 PDF 文件在什么软件上打开都不会有太大问题。

事实上对于略微复杂的 PDF(比如你要嵌入 SVG 图片,或者带渐变色,对于 PDF 来讲,这就已经是有复杂度的事情了),各种阅读器的渲染效果都变得很随机,最后打印出来的效果可能也会跟设计稿不一致。所以我并不推荐开发者在小众方案上动心思,这类方案除了能满足一些诡异的优越感、提升开发者在公司内的不可替代性之外,几乎不会有什么实质的好处。

typst

相对于前两者,typst 是一个很新的方案,在 GitHub 上能找到最早的 Commit 是 2020 年 7 月。所以在设计上开发者能够玩的花活最少,生成 PDF 的兼容性也没有那么好。因为方案本身很新,所以想画一些复杂的图表、思维导图,几乎是没有可能,而且市面上有的高质量模板和最佳实践也很少,因此选择这个方案意味着你需要「徒手」做的事情会变多。

但 typst 本身用 Rust 开发,所以渲染性能在今天讲到的三个方案里面是最好的。相对于 LᵃtₑX 那刑具一般的语法设计,typst 写起来更像是「带脚本的 Markdown」(但是它的语法和 Markdown 不完全兼容)。此外,它布局和样式管理和 CSS/SVG 很像,前端开发者对着文档就能很快理解其核心设计思路,也能够用相对容易的方式实现一些基础的排版效果。

此外,它的渲染性能在今天介绍的所有方案里面是最快的,几乎是回车键按下的一瞬间 PDF 就「从地里冒出来了」,对于性能敏感的项目,这是一个很重要的优势。

尽管在调试样式的时候,整体体验不如 Web 那样自由和方便,但得益于 tinymist 随着你不停输入,编译结果自动刷新,在编译结果里面点一下就能跳到对应代码等「基本款」的开发体验还是没问题的。

使用 Rust 开发这件事情是非常值得拿出来单独聊聊的。如果说 Python 是一个胶水语言,用什么语言开发的库都可以很便利地接进来,那么 Rust 就像是某种「贴贴语言」,使用 Rust 开发的工程可以很方便地接入其他语言的开发工作流。

无论你是在开发一个服务端产品还是客户端产品,都可以利用 Rust 的这个特性做系统集成。这里面有几个很关键的组件:

  • typst-as-lib 裸 typst 是不能拿来直接当成库用的,更别提做系统集成。但是 typst-as-lib 做了一层封装,暴露出来了一些非常符合直觉的 API。你可以准备好模板,把数据抠成空,在 Rust 这边封装个函数把数据传到模板里,整个过程用 GPT-4o 及以上复杂度的模型就能搞定,你只需要搞清楚自己的需求就行。
  • include_dir 直接分发 typst 模板的原始文件在各种意义上都可能会很有问题,你可以用这个库把所有的模板和资源文件(字体、图片)全都打包到输出的二进制文件里,这样输出的工程就会变得很「干净」,算是满足「洁癖」开发者的一种手段。

使用 typst 二次开发渲染工具

具体 API 怎么调用这个官方的 example 已经说得很清楚了,资源加载的整合部分流程只需遵循这样的流程就好:

首先我们用 macro 把所有要用到的文件打进二进制内:

static PROJECT_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../resources");

这样我们就可以用这样的方式读取入口模板:

let entry = match PROJECT_DIR.get_file("main.typ") {
    Some(file) => file,
    None => {
        error!("main.typ file not found!");
        return Err("main.typ file not found!".into());
    }
};

如果你想要给 typst 引擎提供资源文件(像是字体、图片、其他 typst 子模板):

type Fonts = Vec<Font>;
type StaticFiles = HashMap<String, Vec<u8>>;
type StaticSources = HashMap<String, String>;

pub fn load_template_resources(
) -> Result<(Fonts, StaticFiles, StaticSources), Box<dyn std::error::Error>> {
    let mut fonts = Vec::<Font>::new();
    let mut static_files = HashMap::new();
    let mut static_sources = HashMap::new();

    let font_glob = "**/*.ttf";
    let image_globs = ["**/*.png", "**/*.jpg", "**/*.jpeg", "**/*.svg"];
    let typ_glob = "**/*.typ";

    // Scan for fonts
    info!("Scanning for fonts...");
    for entry in PROJECT_DIR.find(font_glob).unwrap() {
        if let Some(file) = PROJECT_DIR.get_file(entry.path()) {
            let file_bytes = file.contents();
            let font_bytes = Bytes::from(file_bytes.to_vec());
            match Font::new(font_bytes, 0) {
                Some(font) => {
                    fonts.push(font);
                    info!("Loaded font: {}", entry.path().display());
                }
                None => {
                    error!("Could not parse font {}", entry.path().display());
                }
            }
        } else {
            error!("Font file not found: {}", entry.path().display());
        }
    }

    // Scan for images
    info!("Scanning for images...");
    for image_glob in image_globs {
        for entry in PROJECT_DIR.find(image_glob).unwrap() {
            if let Some(file) = PROJECT_DIR.get_file(entry.path()) {
                static_files.insert(
                    entry.path().to_str().unwrap().to_string(),
                    file.contents().to_vec(),
                );
                info!("Loaded image: {}", entry.path().display());
            } else {
                error!("Image file not found: {}", entry.path().display());
            }
        }
    }

    // Scan for typ files
    info!("Scanning for typ files...");
    for entry in PROJECT_DIR.find(typ_glob).unwrap() {
        if let Some(file) = PROJECT_DIR.get_file(entry.path()) {
            match file.contents_utf8() {
                Some(contents) => {
                    static_sources.insert(
                        entry.path().to_str().unwrap().to_string(),
                        contents.to_string(),
                    );
                    info!("Loaded typ file: {}", entry.path().display());
                }
                None => {
                    error!("Could not read typ file: {}", entry.path().display());
                }
            }
        } else {
            error!("Typ file not found: {}", entry.path().display());
        }
    }

    Ok((fonts, static_files, static_sources))
}

有了这个函数,我们就能很容易地加载资源并且创建模板了:

let (fonts, static_files, static_sources) = load_template_resources()?;

// Read in fonts and the main source file.
info!("Creating typst template...");
let static_sources_ref: HashMap<&str, &str> = static_sources
    .iter()
    .map(|(k, v)| (k.as_str(), v.as_str()))
    .collect();
let static_files_ref: HashMap<&str, &[u8]> = static_files
    .iter()
    .map(|(k, v)| (k.as_str(), v.as_slice()))
    .collect();

let template = TypstTemplate::new(fonts, entry.contents_utf8().unwrap())
    .with_static_source_file_resolver(static_sources_ref)
    .with_static_file_resolver(static_files_ref);

令人欣喜的是,从「单纯的 typst 工程」到「嵌入到二进制文件里的 typst 工程」的转换过程中,目录结构和字体的解析并没有任何变化,所以你可以放心大胆地做。唯一需要注意的是如果你使用了线上的包,注意把它们下载下来嵌入到工程里,不要直接用自带的包管理器来解决问题,否则编译过程会变得不可预期。

美好的事情聊完了,接下来我们来聊聊这套方案所产生的「坑」有哪些。

typst 自己实现了一套 PDF 渲染机制,且这个机制本身并不完备,渲染某些特殊格式图像和有渐变色的内容时,tinymist 里面的预览结果和实际的 PDF 输出就会有出入。这个时候你就得对整个渲染管线做一些改造才能达到预期。

让我们来分析一下整个问题,之所以 tinymist 里的预览结果没有问题,是因为它把 typst 源码编译成了 SVG 图片,而浏览器渲染 SVG 格式本来就是「分内之事」,自然不可能会有什么问题。

如果你尝试用浏览器「打印」这个 SVG,就会发现它的渲染输出没有任何问题。这也就是我之前说的,Chromium 的 PDF 渲染准确度位列第一梯度水准。

因此如果你遇到了特殊样式,且不介意自己的渲染管道不可避免地牵涉到浏览器,可以让 typst 输出 SVG 格式的文档,再用 Chromium 把 SVG 转换成单页 PDF,最后用 lopdf 来合并所有的单页 PDF。

我之前实现过一份基于 Chromium 的方案,如果你能接受这件事的话读到这里就好。如果你和我一样厌恶电子垃圾的话,可以继续看另外一个更加「低碳」的方案:rsvg

librsvg 是一个免费的 SVG 渲染库,由 GNOME 项目开发,旨在提供轻量、高效且便携的 SVG 渲染体验。该库主要使用 C 和 Rust 语言编写,并采用 LGPLv2.1 许可证。它依赖 libxml 解析 SVG 文件,并使用 cairo 渲染图像。

因为 cairo 有一个自己的 Rust Binding 和 PDF Surface,所以通过 librsvg 来做这部分转换是更加理想的选择。此外,绕开浏览器之后,我们还可以用 rayon 来做多线程渲染加速,这可以显著提升 PDF 后处理的速度。

rsvg 看起来很美好,但是它带来了三个问题:

第一个问题是 typst 带来的,无论你的图片格式如何,typst 都会以 base64 的形式对整个图片做编码,再以 image 标签的嵌入到 SVG 中,而 rsvg 在处理所有经过 base64 编码的图像时,会以极低的分辨率过一遍栅格化,因此所有矢量图都是糊的。

因此在渲染 SVG 之前,我们需要对 PDF 做一次预处理,把所有的 SVG 图片展开成 inline 的图形。这部分工作我已经提前做完了,你可以直接复制粘贴拿走用。

这里有一个特殊情况我没有处理,原理上任何 PDF 渲染器在渲染带有 filter 的 PDF 的时候都会对图像做栅格化,所以预处理时最好将滤镜全部剥离。但是我目前手头的案子没这个情况,所以我也没特别做。

第二个问题是 rsvg 的问题,尽管 rsvg 本身用 rust 实现,但是它依赖了 GTK 大量的外部库,像是 pango 和 cairo,在 rust 当中,这些库是很难被静态编译到 binary 里的,所以在部署的时候,你必须得小心处理好依赖(没装依赖是不会报错的,程序会直接闪退)。

另外一方面,对于 Windows 版本,我只推荐你使用 mingw 工具链编译。vcpkg 那边对 rust 库的支持迟迟都没有跟上,而且 vcpkg 仓库里面的 rsvg 库本身也是在 mingw 版本上打补丁,而且没打干净。输出产物不光依赖 vcpkg 的编译结果,也会依赖 mingw 的编译结果。You have a life,不要在这件事情上伤害你自己。

此外,在 Windows 下编译完毕后,你得记得把 MSYS2 目录里面有关的 DLL 文件全部拷贝到项目根目录中,否则程序不会启动。

这意味着,在 Windows 下,做一些跨语言整合会遇到很麻烦的问题,比如用 rinf 开发 Flutter 程序时,它会绑死 Visual Studio 的编译工具链,如果你想让程序跑起来,必须在 Flutter 编译完 Rust 部分之后切到 MSYS2 里面把 Rust 的 DLL 编译好手动覆盖原来破损的版本。

我本来想修改一下 rinf 的编译流程的,但奈何 cargokit 的架构设计过于迷惑,我实在是改不动,于是作罢。结果是,如果你用 Flutter 为这个渲染器开发前端界面,那么整个工程在 Windows 下面是不可调试的(因为 Flutter 的编译过不去)。好在我平时都用 NixOS,而且 Flutter 也不会出什么平台特异的 bug,所以这块对我来讲目前还算是说得过去。

不过这些都建立在贵司的设计需求真的太过复杂,一般情况下其实不会遇到太多麻烦。

最后,上述的后编译流程其实你都不需要自己从头写,我这边已经做好了整套工具如果你真的足够倒霉,摊上了这坨事情至少还有现成的方案可以用。

当然现在我开源的这套代码假设你会将它拆出来独立使用,如果跟 typst_as_lib 渲染管线接在一起的话,可以直接调用 SVG 的渲染 API,把输出的 binary 直接喂给 rsvg 而不需要从硬盘上绕一圈,性能应该会有痕量的提升。

具体而言,渲染的部分可以这样做:

let pdf_pages: Vec<_> = doc
    .pages
    .par_iter()
    .enumerate()
    .map(|(i, page)| {
        info!("Creating page {}...", i);
        let svg = typst_svg::svg(&page.frame);

        render_svg_to_pdf(&svg).unwrap()
    })
    .collect();

最后,通过这种方法渲染的 PDF 虽然能够以「矢量」的方式打印,但是里面的文本已经变得不可选择。不过对于有内容保护需求的企业,这可能会带来额外的好处。所以这是不是「问题」就比较见仁见智了。

至此,一个小众精品 PDF 渲染器就完成了,鼓掌!

总结

我们详细探讨了PDF自动生成领域的几种主要方案,包括 LᴬTₑX、Web Stack和typst,并分析了它们各自的优缺点及适用场景。

  • ₗᴬₜₑˣ:ᶫₐᵗₑX 以强大的排版能力和精细的字体控制著称,适合对印刷质量要求极高的项目。然而,其复杂的发行版、陡峭的学习曲线和较差的可维护性使其在实际应用中面临诸多挑战。尤其在客户端生成PDF时,ₗₐₜₑˣ 的庞大体积和复杂配置更是令人望而却步。

  • Web Stack:Web Stack方案利用现代浏览器的渲染能力生成 PDF,具有极佳的调试体验和较低的入门难度,尤其适合需要复杂图表和插图的文档。然而,Web Stack在处理打印样式时存在兼容性问题,且性能较差,难以实现高效的并行渲染。此外,Web Stack方案依赖于浏览器的稳定性,可能会受到浏览器版本更新的影响。

  • typst:作为一个新兴方案,typst在设计和性能上都有显著优势。它由Rust开发,渲染速度极快,且语法设计更接近 Markdown,易于学习和使用。然而,typst的生态系统尚不完善,缺乏高质量的模板和最佳实践,且在渲染复杂图表和思维导图时存在一定局限性。尽管如此,typst通过与Rust的良好集成,可以实现高效的系统集成。

尽管没有一种方案可以完美解决所有PDF生成需求,但通过合理选择和组合不同的技术栈,可以在不同场景下实现高效的PDF生成。希望本文能够为开发者在选择和实现 PDF 生成方案时提供一些参考。

以上,莉莉爱你♡~

(课后作业:从本文中找到官方推荐的 LAᵗEX 拼写方法。)

Comments

Loading animation

Loading comments...