Motto:motto-html 使用 Flying Saucer 从 HTML 创建 PDF 文档

2024-06-03, 星期一, 19:29

MAKEJava

笔者正在开发一款通过模版生成文档的工具库,暂定命名为 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 的早期版本和现版本差的有些多了,我建议:

  1. 切换到 JDK 11,一般应用场景下它们不会有显著的区别,单元测试跑一跑应该能一次通过;
  2. 考虑使用其他方案,例如 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 文档中指定样式:

@page {
  /* A4 大小,竖版 */
  size: A4 portrait;
  /* 或者横版 size: A4 landscape;*/
  /* 用 margin 指定页边距也是支持的 */
}

你可以参考 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 + PCmd + P 预览效果,而不是 DevTools;
  • 不要尝试在模版内使用 JavaScript;

支持 CJK 字符

和其他 PDF 编辑工作流一样,由于 Base 14 字体不包含中文字符,需要额外加载一些字体。

Flying Saucer 默认支持了一批中文字体,你可以在 org.xhtmlrenderer.pdf.CJKFontResolver 中查看这些字体的 family name,名字中的 -V 后缀表示这些字体用于纵向排版。

private static final String[][] cjkFonts = {
    {"STSong-Light-H", "STSong-Light", "UniGB-UCS2-H"},
    {"STSong-Light-V", "STSong-Light", "UniGB-UCS2-V"},
    {"STSongStd-Light-H", "STSongStd-Light", "UniGB-UCS2-H"},
    {"STSongStd-Light-V", "STSongStd-Light", "UniGB-UCS2-V"},
    {"MHei-Medium-H", "MHei-Medium", "UniCNS-UCS2-H"},
    {"MHei-Medium-V", "MHei-Medium", "UniCNS-UCS2-V"},
    {"MSung-Light-H", "MSung-Light", "UniCNS-UCS2-H"},
    {"MSung-Light-V", "MSung-Light", "UniCNS-UCS2-V"},
    {"MSungStd-Light-H", "MSungStd-Light", "UniCNS-UCS2-H"},
    {"MSungStd-Light-V", "MSungStd-Light", "UniCNS-UCS2-V"},
    {"HeiseiMin-W3-H", "HeiseiMin-W3", "UniJIS-UCS2-H"},
    {"HeiseiMin-W3-V", "HeiseiMin-W3", "UniJIS-UCS2-V"},
    {"HeiseiKakuGo-W5-H", "HeiseiKakuGo-W5", "UniJIS-UCS2-H"},
    {"HeiseiKakuGo-W5-V", "HeiseiKakuGo-W5", "UniJIS-UCS2-V"},
    {"KozMinPro-Regular-H", "KozMinPro-Regular", "UniJIS-UCS2-HW-H"},
    {"KozMinPro-Regular-V", "KozMinPro-Regular", "UniJIS-UCS2-HW-V"},
    {"HYGoThic-Medium-H", "HYGoThic-Medium", "UniKS-UCS2-H"},
    {"HYGoThic-Medium-V", "HYGoThic-Medium", "UniKS-UCS2-V"},
    {"HYSMyeongJo-Medium-H", "HYSMyeongJo-Medium", "UniKS-UCS2-H"},
    {"HYSMyeongJo-Medium-V", "HYSMyeongJo-Medium", "UniKS-UCS2-V"},
    {"HYSMyeongJoStd-Medium-H", "HYSMyeongJoStd-Medium", "UniKS-UCS2-H"},
    {"HYSMyeongJoStd-Medium-V", "HYSMyeongJoStd-Medium", "UniKS-UCS2-V"}
};

可以简单创建一个 PDF 文档,预览这些字体的效果。笔者用 Apache Velocity 模版引擎做了个简单的循环:

#foreach( $font_family in $all_font_families )
<p>$font_family</p>
<p style="font-family: $font_family">SC: 子曰:“学而时习之,不亦说乎?有朋自远方来,不亦乐乎?人不知而不愠,不亦君子乎?”</p>
<p style="font-family: $font_family">TC: 子曰:「學而時習之,不亦說乎?有朋自遠方來,不亦樂乎?人不知而不慍,不亦君子乎?」</p>
#end

在这么多字体中,似乎只有 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 了解事情的经过,不过简单来说就是:

CJKFontResolver fontResolver = new CJKFontResolver();
ITextRenderer renderer = new ITextRenderer(fontResolver);
  1. 在要用于生成 PDF 文件的 HTML 文档中,指定 DOM 元素的 font-familySTSong-Light-HSTSongStd-Light-H

如果你决定嵌入自己的中文字体,那 CJKFontResolver 就不那么重要了。

从 Windows 11 中复制出中易宋体 SimSun.ttc:

fontResolver.addFont("/Users/yufan/Library/Fonts/SimSun.ttc", BaseFont.IDENTITY_H, BaseFont.EMBEDDED);

BaseFont.IDENTITY_H 指定这个字体的编码(encoding)按 Unicode/UTF-8 处理,否则就是默认的 Latin-1BaseFont.EMBEDDED 表明字体会被嵌入到文档中,以便在其他设备上显示 / 使用。虽然没有像 iText8 那样提供 BaseFont.PREFER_EMBEDDED 这样的选项,别担心,不会把没用到的字形也放进去的。

.ttc 扩展名表明这个文件是一个 TrueType® font collection,也就是字体集合。执行上述代码将会分别注册 family name 为 SimSun 和 NSimSun 的两种字体。如果你用不着这么多字体,一种方法是在字体文件路径后添加 ,$FONT_INDEX 后缀,只注册合集中的某个字体,例如:

fontResolver.addFont("/Users/yufan/Library/Fonts/SimSun.ttc,0", BaseFont.IDENTITY_H, BaseFont.EMBEDDED);

如果你打算像这个例子一样从自己的 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 足够应付大部分情况。

$ tree
.
├── Noto_Sans_SC
│   ├── NotoSansSC-VariableFont_wght.ttf
│   ├── OFL.txt
│   ├── README.txt
│   └── static
│       ├── NotoSansSC-Black.ttf
│       ├── NotoSansSC-Bold.ttf
│       ├── NotoSansSC-ExtraBold.ttf
│       ├── NotoSansSC-ExtraLight.ttf
│       ├── NotoSansSC-Light.ttf
│       ├── NotoSansSC-Medium.ttf
│       ├── NotoSansSC-Regular.ttf
│       ├── NotoSansSC-SemiBold.ttf
│       └── NotoSansSC-Thin.ttf
└── Noto_Serif_SC
    ├── NotoSerifSC-VariableFont_wght.ttf
    ├── OFL.txt
    ├── README.txt
    └── static
        ├── NotoSerifSC-Black.ttf
        ├── NotoSerifSC-Bold.ttf
        ├── NotoSerifSC-ExtraBold.ttf
        ├── NotoSerifSC-ExtraLight.ttf
        ├── NotoSerifSC-Light.ttf
        ├── NotoSerifSC-Medium.ttf
        ├── NotoSerifSC-Regular.ttf
        └── NotoSerifSC-SemiBold.ttf

至于楷体之类的字体,笔者在自己的项目中使用了来自 寒蝉字型项目 的字体;

在文档中嵌入外部资源

最简单的方法,不要在原始文档中链接外部资源。

HTML 文档可以使用内联样式,或在 <head> 标签中编写;SVG 本来就是 XML 元素,直接插入 DOM 树就行;大部分类型的图片如果是在线资源,可以直接使用 URL。

也可以转换成 Base64 字符串放在 src 属性中:

// avatar 对应了一个对象实例,其 handleAsImageSrcContent 方法返回图片 Base64 编码字符串
<img src="$avatar.handleAsImageSrcContent()"></img>

笔者为 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 方法。

<img src="$avatar"></img>

那如果图片放在 resources 或者什么特定方式访问的地方呢?

Flying Saucer 使用 org.xhtmlrenderer.pdf.ITextUserAgent 处理输入文档中的嵌入资源。我们可以扩展 ITextUserAgent 实现自己的 UserAgent 来解析资源地址,通过不同的 protocol / schema / prefix 指定访问行为。

Logo 等静态资源,可以放在项目的 src/main/resources/ 目录中,在 HTML 文档中使用 <img src='resources://logo.jpeg' ... 引用。

src/ $ tree
.
├── main
│   ├── java
// ...
│   └── resources
│       └── logo.jpeg
// ...

在我们自己的 ResourcesUserAgent 中,匹配到具有 resources:// 前缀的资源路径后,可以通过 ClassLoader#getResourceAsStream 以流的形式获取到资源,之后照抄 ITextUserAgent#getImageResource 的实现就行:

@Override
public ImageResource getImageResource(String uriStr) {
    if (!uriStr.startsWith(RESOURCES_PREFIX)) {
        return super.getImageResource(uriStr);
    }
    String filePath = uriStr.substring(RESOURCES_PREFIX_LENGTH);
    try (InputStream is = this.getClass().getClassLoader().getResourceAsStream(filePath);
        ContentTypeDetectingInputStreamWrapper cis = new ContentTypeDetectingInputStreamWrapper(
            is)) {
        Image image = Image.getInstance(readBytes(cis));
        scaleToOutputResolution(image);
        return new ImageResource(uriStr, new ITextFSImage(image));
    } catch (IOException e) {
        XRLog.exception(
            "Can't read image file; unexpected problem for URI '" + uriStr + "'", e);
        return new ImageResource(uriStr, null);
    }
}

动态获取的资源,如统计图表、用户上传内容等,可以预先保存到对象存储,然后使用 oss://$OBJECT_NAME 引用,方法也是一样的。

最后你需要在创建 ITextRenderer 时注册这个 UserAgent:

renderer = new ITextRenderer(ITextRenderer.DEFAULT_DOTS_PER_POINT,
    ITextRenderer.DEFAULT_DOTS_PER_PIXEL, outputDevice,
    new ResourcesUserAgent(outputDevice, ITextRenderer.DEFAULT_DOTS_PER_PIXEL),
    fontResolver);

值得注意的是嵌入文档真就是字面上的「嵌入」,也就是说如果你的图片资源有 10 MB 之巨,那最终生成的文档也会大上 10 MB。motto-html 提供了一种压缩图片的方案,不过目前只能用于创建 EmbeddedImage 实例的场景,有需求的话可以实现一个能够压缩资源的 ResourcesUserAgent 并注册到 DocumentBuilder。

日志

Flying Saucer 附带了日志实现,不过默认是关闭的,你可以通过 xr.util-logging.loggingEnabled 属性开启。

static {
    System.setProperty("xr.util-logging.loggingEnabled", "true");
}

在覆写 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.

那是因为文档有些滞后:

<input type="text" name="text-input" value="123" />
<input type="checkbox" name="checkbox-checked" checked="checked" />
<input type="checkbox" name="checkbox-unchecked" />
<input type="radio" name="radio-input" />

就能得到:

这些控件都是可以交互并保存填写值的。

不过笔者并没有测试在文件中嵌入 JavaScript 或是提交表单的功能。