💡 完整可运行示例已在 GitHub 上提供:
nodejs-docker-signing-with-fonts

介绍

字体解析是容器签名的一部分,它决定了你的 Node 服务是生成文档还是抛出异常。GroupDocs.Signature 并不会用缺失的字体族来替代:如果图像中没有指定的族名,调用会抛出异常,什么也不写。清除字体也不是解决办法,因为库随后会请求自己的默认字体并以同样的方式失败。

决定传入哪个族名有三种方式,其中只有一种能够在容器中存活。本文将对它们进行比较,然后介绍影响代码的供应和绑定行为,因为 Node.js 通过 Java 的方式比该库支持的任何其他平台都更为复杂。

为什么这在 Node.js 上更重要

该包是一个桥梁:node-java 在进程中加载 JVM。因此,一个 Node 签名镜像需要 JDK、用于构建桥梁的 node-gyp 工具链,以及指向 libjvm.so 的 LD_LIBRARY_PATH,所有这些都必须在字体相关之前就准备好。node:18-bookworm 随后会提供 6 个 DejaVu 字体文件用于 AWT——足以支持拉丁字符,但不包含 CJK 字体。

这种组合会产生看起来像应用程序错误的失败。缺失的 JVM 路径、缺失的字体以及封送不匹配都会表现为 Error running instance method,因为这是 node-java 对 Java 端抛出的任何异常的报告方式。

前置条件

Node 18 —— 桥梁基于 NAN 构建,NAN 无法在 Node 20 或 22 的 V8 上编译('AccessorSignature' is not a member of 'v8')。JDK 8 至 17:在 JDK 25 上图像层会因 Cannot open an image. The image size can not be 0! 而失败。

安装

npm install @groupdocs/groupdocs.signature

在镜像中,安装需要 build-essential 和 python3,以及 openjdk-17-jdk-headless 和加载路径:

ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
ENV PATH="${JAVA_HOME}/bin:${PATH}"
# node-java 在运行时 dlopen libjvm.so;它不在默认加载路径中。
ENV LD_LIBRARY_PATH="${JAVA_HOME}/lib/server:${LD_LIBRARY_PATH}"

方法 1 —— 硬编码字体族名

大家最先写的版本:选 Arial,打包,继续。它在开发机器上可以工作,但在第一次容器运行时会失败,因为 Debian 镜像并未安装 Arial——它们安装的是 Liberation Sans,后者在度量上兼容但族名不同。

这里没有值得展示的代码,这正是重点。该方法的全部内容只是一个在某个环境下恰好为真的字符串字面量。

方法 2 —— 从文件系统检测字体

自然的修复方式:扫描字体目录,查看有哪些,挑选一个。代码的一半确实有用——清单可以告诉你镜像中是 0 种字体还是 6 种:

const roots = [
  '/usr/share/fonts',
  '/usr/local/share/fonts',
  path.join(home, '.fonts'),
  path.join(home, '.local', 'share', 'fonts'),
  '/System/Library/Fonts',
  '/Library/Fonts',
];

另一半则不起作用。字体文件很少携带调用方必须传入的族名:Debian 的 fonts-noto-cjk 安装了 NotoSansCJK-Regular.ttc,其族名是 Noto Sans CJK JP。从文件名推导族名会得到 NotoSansCJK-Regular,这根本找不到对应的字体。文件名检测既会漏掉实际存在的字体,又会自信地报告会失败的族名。

把清单仅作为诊断使用。不要用它来做选择。计数可以回答镜像是否根本被供应过,这也是一个不同且同样有价值的问题。

方法 3 —— 向库询问

对每个候选族名尝试一次抛弃式签名,保留第一个不抛异常的族名。每个候选会产生一次 PDF 写入,这是唯一答案权威的方法,因为这正是实际签名会调用的同一接口。

for (const candidate of candidates) {
  if (tryFamily(sourcePath, candidate) === null) {
    return candidate;
  }
}
return null;

在 Node 中还需要额外一步。node-java 会把所有 Java 异常压缩为 Error running instance method,因此必须从包装的堆栈跟踪中恢复真实信息:

const stack = err.stack || '';
const match = stack.match(/com\.groupdocs\.signature\.exception\.[^\n]*/);
return match ? match[0].trim() : (err.message || String(err));

没有这两行代码,缺少字体的容器和 JVM 路径错误的容器会产生完全相同的日志。我花了比想象中更长的时间比较两个容器,它们因完全不同的原因打印相同错误,直到加入正则表达式才分辨出来。

探测的成本

对探测的反对意见是它会写文件,确实如此:每个候选会写一个小 PDF,随后立即删除。示例中的拉丁列表有四项,CJK 列表有八项,所以冷启动时最多会在临时目录写入十二个单页文档,然后服务才准备就绪。

这是一种启动成本,而不是每次请求的成本,并且它会在日志中记录两个已解析的族名。相比于一个干净启动后在第一个客户文档上因桥接错误而失败的容器,十二个临时文件并不是难以接受的权衡。

方法比较:何时使用哪种

方法 适用场景 关键优势 限制
硬编码族名 单一受控环境 简单,无启动成本 在缺少该确切族名的任何镜像上都会破裂
文件名检测 诊断镜像包含的内容 快速,无签名调用 文件名不是族名,基于它的选择会失败
库探测 任意容器化或可移植环境 权威,笔记本和镜像都能工作 每个候选写一次 PDF,需在启动时解析并缓存

两个值得了解的绑定怪癖

一旦族名解析成功,签名调用本身就呈现出 Node 特有的形态。Java API 接受一个选项列表,但 JavaScript 数组不会封送为 java.util.List,因此直接传入会得到 Could not find method "sign(java.lang.String, [Ljava.lang.Object;)"。解决办法是链式调用单选项重载,并通过临时文件分阶段进行:

new signatureLib.Signature(sourcePath)
  .sign(firstOutput, buildTextOptions(LATIN_TEXT, latinFamily, 50));

if (stageTwo) {
  new signatureLib.Signature(firstOutput)
    .sign(outputPath, buildTextOptions(CJK_TEXT, cjkFamily, 120));
}

第二个怪癖是读取回执。TextVerifyOptions 通过此绑定无法往返:verify 会抛出同样的通用桥接错误,因此示例返回一个哨兵值并打印 unavailable,而不是假装签名失败。npm 包的版本为 24.12.0,发布于 2024 年 12 月,内部使用 23.6.1 引擎,而 .NET 为 26.6、Java 为 26.5。签名不受影响;只有验证路径缺失。

在生产环境中仍然使用 Node.js 绑定吗?

对于仅拉丁字符的签名,答案是肯定的:它能够正确签名,缺失字体会抛异常而不是悄悄降级,故障模式明确。对于混合脚本的工作,需要权衡缺失的读取回执,因为此时流程中无法确认 CJK 字形是已嵌入还是仅显示为方框。可以在同一流水线中使用 .NET 或 Java 的小型验证器来弥补这一缺口。

最佳实践与提示

  • 按顺序供应:JDK 与工具链 → 加载路径 → 字体 → 应用。每一层的失败表现不同,混合供应会导致诊断变慢。
  • 在启动时解析一次族名并将其与字体计数一起记录。
  • 锁定 Node 18 并使用 8 至 17 之间的 JDK,将它们视为固定基础设施,而不是常规升级的对象。
  • 将无字体的 Dockerfile 保存在仓库中,这样故障始终只差一次构建即可复现。

结论

选择字体的方式有三种,只有一种能够在部署后存活。探测库、缓存结果,并让清单仅作为诊断工具而非决策依据。随后按绑定的实际情况使用:一次只签名一个选项,从堆栈跟踪中读取 Java 异常,并诚实地报告缺失的验证而不是隐藏它。示例仓库会构建两种镜像,所有声明都可以通过两条命令进行验证。

其他资源