Bundling fonts for 21 Indian languages across 12 scripts with Fastify 5 and Puppeteer
Html2Pdf is officially live in production:
Live Web App & Interactive Demo: html2pdf.akcraft.app
Official TypeScript / ESM SDK: @akcraft/html2pdf-sdk
Live Web API: api.html2pdf.akcraft.app
In my previous post, I introduced Html2Pdf and mentioned one problem I wanted to cover in detail: Indian language text turning into empty square boxes in generated PDFs.
That symptom is often called tofu. The HTML can look correct in your editor and still come out wrong in a PDF generated inside a minimal Linux container. Here is how I fixed it in my Fastify 5 + Puppeteer service.
Why the glyphs disappeared
Headless Chromium can only render a character if it can find a usable font with that glyph. A developer laptop may already have fonts for Hindi, Tamil, Bengali, or Urdu. A slim Docker image usually does not.
When Chromium cannot find a glyph, it may show a replacement box. Complex scripts also need correct shaping: characters can combine, reorder, or change form based on context. So a reliable fix needs both suitable fonts and a browser environment that can discover and use them when it prints.
The approach
I bundled Noto font files with the API, configured Linux fontconfig to search that directory while keeping the system fonts, and passed the configuration into the Chromium process that Puppeteer launches.
The bundled font files cover these 12 scripts and 21 languages:
| Script | Languages |
|---|---|
| Devanagari | Hindi, Marathi, Nepali, Sanskrit |
| Bengali | Bengali, Assamese |
| Gurmukhi | Punjabi |
| Gujarati | Gujarati |
| Odia | Odia |
| Tamil | Tamil |
| Telugu | Telugu |
| Kannada | Kannada |
| Malayalam | Malayalam |
| Ol Chiki | Santali |
| Meetei Mayek | Manipuri |
| Nastaliq | Urdu |
Manipuri can also be written in Bengali script, which is why the language count and script count do not map one-to-one.
1. Bundle the fonts with the application
Keep the .ttf files in a directory that is part of the Docker build context. My API keeps its font files in api/fonts/, next to fonts.conf and the Noto font licence.
The important part is that the runtime image receives this directory too. Installing fonts only in a build stage does not help if that stage is discarded before the final image is assembled.
2. Tell fontconfig where to look
This is the fonts.conf used by the API:
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
<!-- Keep fonts provided by the operating system. -->
<include ignore_missing="yes">/etc/fonts/fonts.conf</include>
<!-- Also search the directory containing this config file. -->
<dir prefix="relative">.</dir>
<cachedir prefix="xdg">fontconfig</cachedir>
</fontconfig>
The relative font directory keeps the config portable inside the image. Adding the bundled directory supplements the system configuration instead of replacing it.
3. Pass the config to Puppeteer’s Chromium process
FONTCONFIG_FILE needs to be present in the environment of the browser process. Setting it in a shell where Node runs is not enough if the Chromium process is launched with a different environment.
import puppeteer from 'puppeteer';
import { fileURLToPath } from 'node:url';
const fontsConf = fileURLToPath(
new URL('../fonts/fonts.conf', import.meta.url),
);
const browser = await puppeteer.launch({
headless: true,
env: {
...process.env,
FONTCONFIG_FILE: fontsConf,
},
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
The sandbox flags above are used by this service’s Docker setup, where the default container seccomp profile blocks Chromium’s setuid sandbox setup. Treat Chromium sandboxing as a deployment security decision: use the configuration appropriate for your container runtime rather than copying flags without understanding the environment.
4. Wait for fonts before printing
If submitted HTML uses web fonts, wait until the page reports that its fonts are ready before calling page.pdf(). Otherwise Chromium can capture the page before those fonts finish loading.
await page.setContent(html, { waitUntil: 'load' });
await page.evaluate(() => document.fonts.ready);
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
});
This wait complements the bundled-font setup: fontconfig makes local fonts available to Chromium, while document.fonts.ready helps avoid racing fonts referenced by the document.
5. Build with the right Docker context
Because the Dockerfile, package files, and fonts/ directory all live under api/, build from that directory as the context:
docker build -t html2pdf-api api/
The context matters. Docker can only copy files that are inside the chosen build context, and .dockerignore is applied relative to that context. In this repository, using the API directory keeps the build small and ensures the font assets are available to the Dockerfile.
The runtime stage needs Chromium and its shared libraries as well as the application files and bundled fonts. My image uses Debian’s Chromium package and copies the compiled app plus fonts/ into the final stage.
Verify the actual PDF
I check the rendered PDF from the container, not only the browser preview on my development machine. A useful smoke-test document should include samples from every script you support, then you can inspect the resulting PDF visually for missing glyphs and broken shaping.
My API also includes a static sample PDF with one row per bundled script, which makes it easy to catch regressions when the font bundle or container changes.
The result
The production container now has the fonts it needs, Chromium knows where to find them, and PDF generation waits for document fonts before printing. Html2Pdf supports 21 Indian languages across 12 scripts without asking API users to install fonts in their own environments.
If you are seeing tofu in a container-generated PDF, check these three things first:
- Does the final runtime image contain a font with the needed glyphs?
- Can Chromium’s fontconfig discover that font?
- Are you generating the PDF only after fonts used by the page are ready?
Next in the series: how I designed rate limits, storage cleanup, and API key handling for the production API.
Top comments (0)