Arabic, Chinese or Hindi showing as empty boxes in your Puppeteer PDF
If your PDF shows empty boxes (□□□, often called “tofu”) where Arabic, Hebrew, Chinese, Japanese or Hindi text should be, nothing is broken in your code. The browser that prints the PDF simply has no font that contains those characters. This page explains why it happens, shows two ways to fix it, and how to check that it worked.
Why it happens Reproduce it Fix it yourself RTL and CJK details Skip the setup FAQ
Why it happens
Chrome draws text with the fonts installed on the machine that runs it. Your laptop has plenty. A minimal Docker image, a CI runner or a serverless function usually has only a few Latin fonts.
When no installed font has a character, Chrome draws the “missing glyph” box and carries on. There is no error, the PDF is created normally, and the problem is often noticed only when a customer opens the file. “It looks right on my machine and wrong in production” is the classic sign.
The same applies to Playwright, Selenium and any other tool that prints with headless Chrome or Chromium, because the fonts come from the machine, not from the library.
Reproduce it
This small script prints Arabic, Chinese and Hindi text. On a computer with suitable fonts it looks fine. In a slim container the same script gives you 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();
})();Fix it yourself: install fonts
Install the Noto font family on the machine that runs Chrome. Noto covers Arabic, Hebrew, Devanagari (Hindi) and most other scripts, and has a separate package for Chinese, Japanese and Korean. In 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 the image, then check that the system can see fonts for each language:
fc-list :lang=ar family | head -3
fc-list :lang=zh-cn family | head -3
fc-list :lang=hi family | head -3Each command should list at least one family. If one prints nothing, that language still has no font. Keep in mind that the CJK package makes the image noticeably bigger.
Alternative: web fonts. You can load fonts from a CDN inside your HTML instead. This needs outbound network access at render time, and you must wait for the fonts to finish loading before printing, or the PDF can be created with fallback boxes:
await page.setContent(html, { waitUntil: "networkidle0" });
await page.evaluate(() => document.fonts.ready);
await page.pdf({ path: "invoice.pdf", format: "A4" });Right-to-left and CJK details
- Arabic and Hebrew: set
dir="rtl"on your<html>tag (or on the container) and a matchinglang(more in our right-to-left layout guide). Chrome handles letter joining and mixed-direction text once a font is available. Withoutdir="rtl"the paragraph direction stays left-to-right, so alignment and the position of punctuation and numbers can look wrong. - Chinese and Japanese (full guide): set
lang="zh-CN",lang="zh-TW"orlang="ja". The same characters have different regional shapes, and the language attribute helps the browser pick the right ones. - Turkish: the letters ğ, ş, ı and İ have their own rules for encoding and capital letters. See the Turkish characters guide.
- Hindi: needs a Devanagari font. The Noto core package above includes it.
- Numbers and punctuation: check a real document, not only a one-line test. Invoices mix scripts, digits and symbols, and that is where problems hide.
Skip the setup: one API call
Getprintoza is an HTML to PDF API that runs real Chromium with fonts for 12 languages already installed: Arabic, Hebrew, Chinese, Japanese, Korean, Hindi, English, Turkish, Spanish, French, German and Russian. You send HTML, you get a PDF back. No font packages, no browser to host.
curl -X POST https://getprintoza.com/v1/convert \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"language": "zh", "html": "<h1>发票</h1><p>你好,世界</p>"}' \
--output invoice.pdfThe same call from Node.js, including a check for characters that could not be drawn:
const fs = require("fs");
const res = await fetch("https://getprintoza.com/v1/convert", {
method: "POST",
headers: {
"x-api-key": process.env.GETPRINTOZA_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
language: "ar",
html: "<h1>فاتورة</h1><p>شكراً لكم</p>",
format: "A4",
}),
});
if (!res.ok) throw new Error((await res.json()).error);
// Present only if some characters could not be drawn with the installed fonts
const warnings = res.headers.get("x-getprintoza-warnings");
if (warnings) console.warn("Check these characters:", warnings);
fs.writeFileSync("invoice.pdf", Buffer.from(await res.arrayBuffer()));If your HTML contains characters that none of the installed fonts can draw, the PDF is still created and the response carries an X-Getprintoza-Warnings header that lists them. This is a best-effort check, so look at a few real documents before you launch. Full details are in the API documentation.
The Free plan includes 50 PDFs a month and needs no card. Get a free API key or see the pricing.
FAQ
Why does it work on my laptop but not in Docker or production?
Your laptop has many fonts installed. The production image has only the few it was built with. Fonts are part of the environment, not part of your code.
Do I need to list a font for every language in CSS?
Usually not. When fonts are installed, Chrome falls back to them automatically. A font-family list is still useful when you want a specific look.
How can I catch this before customers do?
Render one test PDF per language in your CI and look at it. With Getprintoza you also get the X-Getprintoza-Warnings header when characters cannot be drawn.
Is the problem in Puppeteer?
No. Puppeteer only tells Chrome to print. Missing fonts on the machine are the cause.