You generate an invoice PDF from HTML. On your laptop it looks perfect. In production, every Arabic, Chinese or Hindi character is an empty box (□□□). No error, no warning, a perfectly valid PDF. Usually a customer is the first to notice.
I ran into this enough times that I wrote down what is going on. Nothing is wrong with your code or with Puppeteer.
The problem
Here is a script that reproduces it. On a machine with suitable fonts the output looks fine. In a slim container you get boxes:
const puppeteer = require("puppeteer");
(async () => {
const browser = await puppeteer.launch({ args: ["--no-sandbox"] });
const page = await browser.newPage();
await page.setContent(
'<html lang="ar" dir="rtl"><body>' +
"<h1>فاتورة</h1><p>你好,世界 · नमस्ते</p>" +
"</body></html>"
);
await page.pdf({ path: "invoice.pdf", format: "A4" });
await browser.close();
})();
Why it happens
Chrome draws text with the fonts installed on the machine that runs it. Your laptop has hundreds. A minimal Docker image, a CI runner or a serverless function usually has a handful of Latin fonts and nothing else.
When no installed font contains a character, Chrome draws the "missing glyph" box and moves on. It does not throw. That is why the classic symptom is "works on my machine, broken in production".
The same is true for Playwright, Selenium or anything else that prints through headless Chrome: the fonts come from the machine, not from the library.
The fix: install Noto fonts in the image
Noto covers Arabic, Hebrew, Devanagari (Hindi) and most other scripts. Chinese, Japanese and Korean live in a separate package. On a Debian-based image:
FROM node:20-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
fontconfig fonts-noto-core fonts-noto-cjk fonts-noto-color-emoji \
&& fc-cache -f \
&& rm -rf /var/lib/apt/lists/*
Rebuild, then check that the system can see a font for each language:
fc-list :lang=ar family | head -3
fc-list :lang=zh-cn family | head -3
fc-list :lang=hi family | head -3
If one of these prints nothing, that language still has no font. Be aware that the CJK package makes the image noticeably bigger, so if size matters, install it only in the image that renders PDFs.
Fonts are not enough: set lang and dir
Two more details decide whether the result looks right, not just not-broken.
Right-to-left text. For Arabic and Hebrew, put dir="rtl" on the <html> tag (or the container) together with a matching lang. Chrome handles letter joining and mixed-direction text once a font exists, but without dir="rtl" alignment, punctuation and numbers can land in the wrong place.
Chinese vs Japanese. The same Han character can be drawn with a different regional shape depending on the language. Characters like 直 and 骨 are good examples. If the page has no lang, Chrome may pick a font you did not intend, and Chinese text can show up with Japanese shapes. Set lang="zh-CN", lang="zh-TW" or lang="ja", and for mixed documents put lang on the element that holds each piece.
:lang(zh-CN) { font-family: "Noto Sans CJK SC", sans-serif; }
:lang(zh-TW) { font-family: "Noto Sans CJK TC", sans-serif; }
:lang(ja) { font-family: "Noto Sans CJK JP", sans-serif; }
Turkish has its own trap: the dotted and dotless İ / ı pair is only upper- and lower-cased correctly when the page declares lang="tr".
Test with a real document
A one-line test hides most problems. Real invoices mix scripts, digits, currency symbols and punctuation. Render one realistic PDF per language in your CI and actually look at it. That is the only way to catch this before a customer does.
If you would rather not maintain this
I built a small hosted API around exactly this problem, because I did not want to maintain Chromium and font packages in every project. You POST HTML and get a PDF back, with Noto fonts for 11 languages already installed (en, tr, es, fr, de, ru, ar, he, zh, ja, hi):
curl -X POST https://getprintoza.com/v1/convert \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"language": "ar", "html": "<h1>مرحبا</h1><p>فاتورة مبيعات</p>"}' \
--output arabic.pdf
If your HTML contains characters that none of the fonts can draw, the PDF is still created and the response carries an X-Getprintoza-Warnings header listing them. It is a best-effort check, so still look at a few real documents. The free plan is 50 PDFs a month. It is new and run by one person, so I would genuinely like to hear which languages or edge cases still break for you.
The guides behind this article work with plain Puppeteer and Docker, no API needed:
Top comments (0)