笔者正在开发一款通过模版生成文档的工具库,暂定命名为 Motto。随着开发的进行,觉得实在是没有什么整合的必要,于是这个项目就拆分成了两个相互独立的代码库,motto-html 和 motto-pdf-itext8。
motto-html 使用 Apache Velocity 模版引擎创建 HTML 文档,然后用 Flying Saucer 转换为 PDF 文档。你可以通过 GitHub 访问本项目,或通过 Maven 中央仓库拉取项目 cc.ddrpa.motto:motto-html。
Flying Saucer is a pure-Java library for rendering arbitrary well-formed XML (or XHTML) using CSS 2.1 for layout and formatting, output to Swing panels, PDF, and images.
本文写作时,Flying Saucer 的最新版本为 9.8.0,本文使用的例子均基于该版本。代码稍作修改也可以用于 JDK 11 和 Flying Saucer 9.5.2(从 9.5.0 版本开始 Flying Saucer 需要 JDK 11,从 9.6.0 版本开始需要 JDK 17)。如果你的项目还停留在 JDK 8,由于 Flying Saucer 的早期版本和现版本差的有些多了,我建议:
- 切换到 JDK 11,一般应用场景下它们不会有显著的区别,单元测试跑一跑应该能一次通过;
- 考虑使用其他方案,例如 motto-pdf-itext8;
要完成 HTML 转换 PDF 的工作,只需要引入 org.xhtmlrenderer:flying-saucer-pdf 依赖,这个包是用来替换 org.xhtmlrenderer:flying-saucer-pdf-openpdf 的,依靠 OpenPDF 项目实现了操作 PDF 的能力。你还可以在 mvnRepository 上找到 org.xhtmlrenderer:flying-saucer-pdf-itext5,是利用 iText 5 的版本,二者的 API 有细微的差别。
制作模板
Flying Saucer 支持了一小部分 CSS3 的特性,例如页面控制。可以在 HTML 文档中指定样式:
你可以参考 How do you control page size?,对手动分页等内容也做了详细的说明。
此外,在创建模版时还需要注意:
- 模板样式应当遵循 Cascading Style Sheets Level 2 Revision 1 (CSS 2.1) Specification;
- 尽管
<img>这类标签支持自闭合,请使用<img></img>; - 使用
pt设置图像元素的尺寸,特别是在使用了EmbeddedImage#setDevicePixelRatio的情况下; - 设置字体族(
font-family)时,首选项必须是STSong/STSongStd或其他预先部署到服务器环境并由程序显式读取的字体; - 观察到
E > F这种 Child selectors 在某些情况下似乎没有正确的应用font-family属性,因此如果输出文件中没有出现字符,请在 DOM 元素上通过内联样式设置font-family; - 请把
<style>标签放在<head>里,不要放在<body>之中或之后; - 请使用 Ctrl + P 或 Cmd + P 预览效果,而不是 DevTools;
- 不要尝试在模版内使用 JavaScript;
支持 CJK 字符
和其他 PDF 编辑工作流一样,由于 Base 14 字体不包含中文字符,需要额外加载一些字体。
Flying Saucer 默认支持了一批中文字体,你可以在 org.xhtmlrenderer.pdf.CJKFontResolver 中查看这些字体的 family name,名字中的 -V 后缀表示这些字体用于纵向排版。
可以简单创建一个 PDF 文档,预览这些字体的效果。笔者用 Apache Velocity 模版引擎做了个简单的循环:
在这么多字体中,似乎只有 STSong 和 STSongStd 在显示简体中文内容时不会出现错误或缺失字形(那为什么繁体中文部分也有问题呢?因为港台的繁体中文也不太一样,例如 Noto 就将中文字体分为了 TraditionalChinese 和 TraditionalChineseHK)。

互联网搜索结果表明 STSong 似乎应该是华文宋体,而文档中看起来像是某种黑体。在 Font Book(macOS)中预览 STSong,样式也不太一样:

如果你认为「宋体还是黑体」不是个问题的话,唯二需要注意的点就是:
1)出于性能优化的目的,Flying Saucer 9.2.0 以及之后的版本中需要具体指定 org.xhtmlrenderer.pdf.CJKFontResolver 实例作为 ITextRenderer 的 FontResolver 才能够加载这些 CJK 字体。你可以查看 #184 avoid loading CJK fonts by default 了解事情的经过,不过简单来说就是:
- 在要用于生成 PDF 文件的 HTML 文档中,指定 DOM 元素的
font-family为STSong-Light-H或STSongStd-Light-H。
如果你决定嵌入自己的中文字体,那 CJKFontResolver 就不那么重要了。
从 Windows 11 中复制出中易宋体 SimSun.ttc:
BaseFont.IDENTITY_H 指定这个字体的编码(encoding)按 Unicode/UTF-8 处理,否则就是默认的 Latin-1。BaseFont.EMBEDDED 表明字体会被嵌入到文档中,以便在其他设备上显示 / 使用。虽然没有像 iText8 那样提供 BaseFont.PREFER_EMBEDDED 这样的选项,别担心,不会把没用到的字形也放进去的。
.ttc 扩展名表明这个文件是一个 TrueType® font collection,也就是字体集合。执行上述代码将会分别注册 family name 为 SimSun 和 NSimSun 的两种字体。如果你用不着这么多字体,一种方法是在字体文件路径后添加 ,$FONT_INDEX 后缀,只注册合集中的某个字体,例如:
如果你打算像这个例子一样从自己的 Windows 或者 macOS 里拷一些字体出来的话,可能会碰上一些版权要求比较严格的字体,程序在添加这些字体时会抛出异常 cannot be embedded due to licensing restrictions.。
simsun.ttc 不会抛出这个异常,不过参考「北京市高级人民法院(2010)高民终字第 772 号民事判决书」之类的材料:
在自行开发的软件中嵌入中易宋体需要额外授权,嵌入的定义包括:
- 在 Web 资源中引用本地托管的字体资源
- 在生成的文件中嵌入字体
如果你有版权方面的顾虑,笔者推荐使用 Google Noto CJK SC 字体。在页面上搜索并选择 Noto Sans Simplified Chinese(这是一种无衬线字体,中文字符应该是 Adobe 的思源黑体)和 Noto Serif Simplified Chinese(这是一种衬线字体,中文字符应该是 Adobe 的思源宋体),然后点击下载。*-Regular.ttf 足够应付大部分情况。
至于楷体之类的字体,笔者在自己的项目中使用了来自 寒蝉字型项目 的字体;
在文档中嵌入外部资源
最简单的方法,不要在原始文档中链接外部资源。
HTML 文档可以使用内联样式,或在 <head> 标签中编写;SVG 本来就是 XML 元素,直接插入 DOM 树就行;大部分类型的图片如果是在线资源,可以直接使用 URL。
也可以转换成 Base64 字符串放在 src 属性中:
笔者为 cc.ddrpa.motto.html.embedded.EmbeddedImage 类创建了 toDataURL 方法,试图照着 velocity-engine-core/src/test/java/org/apache/velocity/test/util/introspection/ConversionHandlerTestCase.java 将其注册为 Apache Velocity 的 Type converter,不过没有成功,于是退而求其次重写了 toString 方法。
那如果图片放在 resources 或者什么特定方式访问的地方呢?
Flying Saucer 使用 org.xhtmlrenderer.pdf.ITextUserAgent 处理输入文档中的嵌入资源。我们可以扩展 ITextUserAgent 实现自己的 UserAgent 来解析资源地址,通过不同的 protocol / schema / prefix 指定访问行为。
Logo 等静态资源,可以放在项目的 src/main/resources/ 目录中,在 HTML 文档中使用 <img src='resources://logo.jpeg' ... 引用。
在我们自己的 ResourcesUserAgent 中,匹配到具有 resources:// 前缀的资源路径后,可以通过 ClassLoader#getResourceAsStream 以流的形式获取到资源,之后照抄 ITextUserAgent#getImageResource 的实现就行:
动态获取的资源,如统计图表、用户上传内容等,可以预先保存到对象存储,然后使用 oss://$OBJECT_NAME 引用,方法也是一样的。
最后你需要在创建 ITextRenderer 时注册这个 UserAgent:
值得注意的是嵌入文档真就是字面上的「嵌入」,也就是说如果你的图片资源有 10 MB 之巨,那最终生成的文档也会大上 10 MB。motto-html 提供了一种压缩图片的方案,不过目前只能用于创建 EmbeddedImage 实例的场景,有需求的话可以实现一个能够压缩资源的 ResourcesUserAgent 并注册到 DocumentBuilder。
日志
Flying Saucer 附带了日志实现,不过默认是关闭的,你可以通过 xr.util-logging.loggingEnabled 属性开启。
在覆写 Flying Saucer 的行为时,推荐使用 org.xhtmlrenderer.util.XRLog 中的静态方法记录日志。
整点活
还记得 AcroForm 吗,这个标准的目的就是允许用户在 PDF 中填写表格然后保存(或者点击「提交」按钮将表单内容提交到服务器)。
User Guide 中的 Does Flying Saucer support PDF form components? 提到
… AcroForm support has been prototyped but not completed at this point.
那是因为文档有些滞后:
就能得到:

这些控件都是可以交互并保存填写值的。
不过笔者并没有测试在文件中嵌入 JavaScript 或是提交表单的功能。