💡 完整可运行示例已在 GitHub 上提供:
sign-pdf-in-linux-container-fonts-dotnet
过去的做法很痛苦
该服务为发票签名。它在一台装有三百种字体的笔记本电脑上运行,经过审查后在周五被容器化。周一,集群中的第一个任务因 Sign document error: Font Arial was not found 而非零退出,某人花了整个上午阅读堆栈跟踪,才有人想到询问 mcr.microsoft.com/dotnet/runtime:8.0 镜像实际包含哪些字体。
答案是:没有。镜像中没有任何字体文件,本文示例运行的镜像即如此。
了解其他运行时的情况也很有价值,因为不同运行时的错误表现不同。eclipse-temurin:17-jre 捆绑了 8 种 DejaVu 字体,node:18-bookworm 捆绑了 6 种,都是用于 AWT,这就是为什么 JVM 和 Node 镜像在签署拉丁文时表现正常,只有在出现日文或中文字符串时才会出错。python:3.11-slim 与 .NET 运行时镜像一样不带任何字体,因此在第一个签名时就会失败。没有哪个镜像能免费提供 CJK 字体。
容器字体的供应是让 GroupDocs.Signature for .NET 在 Linux 镜像中实现文本签名的关键步骤。它很重要,因为库不会自动替代缺失的字体族:使用未安装的字体名称会抛出错误并且不生成文档。本文将无字体镜像与已修复的镜像并排展示,说明变化,并介绍保持相同代码在开发机器上运行的运行时解析方式。
有更好的办法
必须满足两点:镜像中至少要有一种字体,代码则必须停止假设具体是哪一种。
第一点通过 Dockerfile 的一层实现。第二点是解析步骤:不要硬编码 Arial,而是让库检查多个候选字体族中哪些实际可用,并保留第一个可用的。这样得到的代码在精简容器、Windows 以及 CI 中都能保持不变,因为它从不对未检查的环境做断言。
有一种做法是行不通的,值得明确说明,因为这是人们首先尝试的方式:不设置字体。若不提供 SignatureFont,GroupDocs.Signature 会尝试使用默认的 Times New Roman,而无字体镜像同样缺少该字体,调用会同样失败。
新方法:两种镜像,一个差异
步骤 1 - 查看镜像中有哪些字体
在签名之前,先列出字体文件。计数可以把模糊的异常转化为明确的诊断,因为零字体和错误的字体族名称需要不同的修复方式:
string[] roots =
{
"/usr/share/fonts",
"/usr/local/share/fonts",
Path.Combine(home, ".fonts"),
Path.Combine(home, ".local/share/fonts"),
Environment.GetFolderPath(Environment.SpecialFolder.Fonts),
"/System/Library/Fonts",
"/Library/Fonts",
};
注意缺失的内容:System.Drawing。从 .NET 7 开始,System.Drawing.Common 仅限 Windows 使用,在 Linux 上会抛出异常,因此基于它的字体代码在容器中会因第二个无关原因而失败。
步骤 2 - 添加字体层
四个软件包,一条 RUN,错误即消失:
RUN apt-get update && apt-get install -y --no-install-recommends \
fontconfig \
fonts-dejavu-core \
fonts-liberation \
fonts-noto-cjk \
&& fc-cache -f \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
fontconfig 是解析器,并提供 fc-list 供调试使用。fonts-dejavu-core 提供拉丁文、希腊文和西里尔文的最小集合。fonts-liberation 为 Arial、Times New Roman 和 Courier New 提供度量兼容的替代品,这正是 Windows 上创建的文档实际引用的字体。fonts-noto-cjk 覆盖中文、日文和韩文。
步骤 3 - 解析字体族而不是直接命名
便携的做法是对每个候选族尝试一次临时签名,保留第一个不抛异常的族:
foreach (string candidate in candidates)
{
if (TryFamily(sourcePath, candidate).Ok)
{
return candidate;
}
}
return null;
仅凭文件名进行检测是常见的捷径,但这是错误的。Debian 的 fonts-noto-cjk 安装了 NotoSansCJK-Regular.ttc,其字体族名称是 Noto Sans CJK JP。仅匹配文件名会错过实际存在的字体,并且会误报那些传递给 SignatureFont 时无法解析的族。
步骤 4 - 对已解析的字体进行签名,验证签名结果
需要解析到拉丁文字体族;CJK 族是可选的,若缺失则跳过而不是崩溃:
var options = new List<SignOptions>
{
BuildTextOptions(LatinText, latinFamily, top: 50),
};
if (cjkFamily is not null)
{
options.Add(BuildTextOptions(CjkText, cjkFamily, top: 120));
}
SignResult result = signature.Sign(outputPath, options);
随后读取文件,因为没有 CJK 字体时,CJK 文本可能只显示为空白方块且不会抛出任何错误:
var options = new TextSearchOptions { AllPages = true };
List<TextSignature> found = signature.Search<TextSignature>(options);
对比:前后差异
Dockerfile.nofonts |
Dockerfile |
|
|---|---|---|
| 镜像中的字体文件数量 | 0 | DejaVu、Liberation、Noto CJK |
| 拉丁文签名 | 失败,退出码 3 | 写入成功,读取回验证 |
| CJK 文本签名 | 失败 | 写入成功,读取回验证 |
| 报错信息 | Font <name> was not found |
无 |
| 代码差异 | 无 - 同一二进制文件 | 无 - 同一二进制文件 |
最后一行才是关键。两次运行之间应用代码没有任何变化。示例仓库同时提供了这两个 Dockerfile,因而比较只需执行两次 docker build,而不必盲目信任。之后也请保留无字体的变体在仓库中:它是最快复现故障的方式,尤其是当有人在六个月后更换基础镜像导致签名悄然消失时。
为什么不直接安装所有字体?
因为镜像体积是实际限制,而上述四个软件包已经覆盖了大多数文档使用的脚本。仅 fonts-dejavu-core 就足以签署拉丁文、希腊文和西里尔文;当文档按名称引用 Windows 字体族时需要 Liberation;Noto CJK 体积较大,仅在签署东亚文字时才会真正产生价值。请仅安装文档所需的字体,然后通过读取回验证。
实际案例:批量签名工作者
一个队列工作者每晚签署数千个 PDF。启动时进行字体族解析,并记录一行使用的族名称;如果没有任何族可解析,则在触及队列之前直接退出,而不是对每条消息都失败。这个启动检查把原本一连串失败的作业转变为容器因单行原因而拒绝启动。
探测成本足够小,可以在启动时忽略;但若在每个文档上重复则代价过大。每次探测都会生成一个临时签名文件,因此拉丁文列表最多产生四个临时文件,CJK 列表最多八个,全部针对一页 PDF。解析一次后缓存两个族名,后续每个文档的路径与之前完全相同:构建选项,调用 Sign,读取结果计数。
我曾因使用错误的实现浪费了一个下午。那种实现会扫描字体目录,找到 NotoSansCJK-Regular.ttc,报告 CJK 可用,却在随后使用从文件名推导的族名时全部失败。使用真实签名进行探测既更简单也更正确。
容器中还有哪些坑?
还有一点与字体无关:InvariantGlobalization=true。这是在 .NET 镜像中裁剪 ICU 的常规建议,但在使用 GroupDocs.Signature 时会导致第一次 new Signature(...) 抛出 CultureNotFoundException: ... en-US is an invalid culture identifier,因为 SignatureSettings 会创建 CultureInfo("en-US")。请保持全局化开启,让 ICU 保留在镜像中。平台支持情况请在 system requirements 页面查看,确保在选择基础镜像前已确认兼容性。
结论
在本地能正常工作、在 Docker 中失败的签名服务几乎总是缺少字体,解决方案是添加四个软件包的层并使用解析字体族而非硬编码的代码。使用示例构建两种镜像并行运行,查看 [fonts] 行即可看到全部差异。