💡 Full working example available on GitHub: nodejs-docker-signing-with-fonts

Introduction

Font resolution is the part of container signing that decides whether your Node service produces documents or exceptions. GroupDocs.Signature does not substitute a missing family: name one the image does not have and the call raises, writing nothing. Clearing the font is not a workaround either, since the library then asks for its own default and fails the same way.

There are three ways to decide which family to pass, and only one of them survives a container. This article compares them, then covers the provisioning and the binding behaviour that shape the code around them, because Node.js via Java has more of both than any other platform this library ships on.

Why This Matters More on Node.js

The package is a bridge: node-java loads a JVM in process. So a Node signing image needs a JDK, the node-gyp toolchain to build the bridge, and LD_LIBRARY_PATH pointing at libjvm.so, all before fonts are relevant. node:18-bookworm then contributes 6 DejaVu font files for AWT - enough for Latin, nothing for CJK.

That combination produces failures that look like application bugs. A missing JVM path, a missing font and a marshalling mismatch all surface as Error running instance method, because that is what node-java reports for anything thrown on the Java side.

Prerequisites

Node 18 - the bridge builds against NAN, which does not compile against the V8 in Node 20 or 22 ('AccessorSignature' is not a member of 'v8'). JDK 8 through 17: on JDK 25 the imaging layer fails with Cannot open an image. The image size can not be 0!.

Installation

npm install @groupdocs/groupdocs.signature

In the image, that install needs build-essential and python3 present, plus openjdk-17-jdk-headless and the loader path:

ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
ENV PATH="${JAVA_HOME}/bin:${PATH}"
# node-java dlopens libjvm.so at run time; it is not on the default loader path.
ENV LD_LIBRARY_PATH="${JAVA_HOME}/lib/server:${LD_LIBRARY_PATH}"

Method 1 - Hard-code the family name

The version everyone writes first: pick Arial, ship it, move on. It works on the developer machine and fails on the first container run, because Debian images do not install Arial - they install Liberation Sans, which is metric-compatible under a different family name.

There is no code worth showing here, which is the point. The method’s entire content is a string literal that happens to be true in one environment.

Method 2 - Detect fonts from the filesystem

The natural fix: scan the font directories, see what is there, pick something. Half of it is genuinely useful - the inventory tells you whether the image has 0 fonts or 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',
];

The other half does not work. Font files rarely carry the family string a caller must pass: Debian’s fonts-noto-cjk installs NotoSansCJK-Regular.ttc, whose family is Noto Sans CJK JP. Deriving a family from that filename gives you NotoSansCJK-Regular, which resolves to nothing. Filename detection both misses fonts that are present and confidently reports families that will fail.

Keep the inventory as a diagnostic. Do not use it to choose. The count answers whether the image was provisioned at all, which is a different and equally useful question.

Method 3 - Ask the library

Attempt a throwaway signature per candidate family and keep the first that does not throw. It costs one PDF write per candidate and it is the only method whose answer is authoritative, because it is the same call the real signature will make.

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

On Node the probe needs one extra piece. node-java collapses every Java exception into Error running instance method, so the real message has to be recovered from the wrapped stack trace:

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

Without those two lines, a fontless container and a broken JVM path produce identical logs. I spent longer than I want to admit comparing two containers that printed the same error for entirely different reasons before adding the regex.

What the probe costs

The objection to probing is that it writes files, and it does: one small PDF per candidate, deleted immediately. The Latin list in the sample has four entries and the CJK list has eight, so a cold start writes at most twelve one-page documents into the temp directory before the service is ready.

That is a startup cost, not a per-request one, and it buys a log line naming both resolved families. Compared with a container that starts cleanly and then fails on the first customer document with a bridge error, twelve temp files is not a difficult trade.

Comparing Methods: When to Use Each

Method Best For Key Advantages Limitations
Hard-coded family a single controlled environment trivial, no startup cost breaks on any image that lacks that exact family
Filename detection diagnosing what an image contains fast, no signing calls file names are not family names, so choices derived from it fail
Library probing anything containerised or portable authoritative, works on laptop and image alike one PDF write per candidate, so resolve at startup and cache

The Two Binding Quirks Worth Knowing

Once a family resolves, the signing call itself has a Node-specific shape. The Java API takes a list of options, but a JavaScript array does not marshal to java.util.List, so passing one produces Could not find method "sign(java.lang.String, [Ljava.lang.Object;)". The workaround is to chain the single-option overload and stage through a temp file:

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

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

The second quirk is the read-back. TextVerifyOptions does not round-trip through this binding: verify raises the same generic bridge error, so the sample returns a sentinel and prints unavailable rather than pretending the signature failed. The npm package is versioned 24.12.0, published in December 2024, and bundles a 23.6.1 engine while .NET is at 26.6 and Java at 26.5. Signing is unaffected; only the verification path is missing.

Should I still use the Node.js binding in production?

For Latin-only signing, yes: it signs correctly, and a missing font raises rather than degrading silently, so the failure mode is loud. For mixed-script work, weigh the missing read-back, since nothing in the process can then confirm CJK glyphs embedded rather than rendering as boxes. A small verifier on .NET or Java in the same pipeline covers that gap.

Best Practices and Tips

  • Provision in order: JDK and toolchain, loader path, fonts, then the app. Each layer fails differently and mixing them makes diagnosis slow.
  • Resolve the families once at startup and log them next to the font count.
  • Pin Node 18 and a JDK between 8 and 17, and treat both as fixed infrastructure rather than routine upgrades.
  • Keep the fontless Dockerfile in the repository, so the failure stays one build away.

Conclusion

Three ways to pick a font, one that survives deployment. Probe the library, cache the answer, and let the inventory serve as a diagnostic rather than a decision. Then work with the binding as it is: sign one option at a time, read the Java exception out of the stack trace, and report the missing verification honestly instead of hiding it. The sample repository builds both images so every claim here can be checked in two commands.

Additional Resources