Your PDF looks perfect on your MacBook. Every glyph, every emoji, every CJK character in the customer name renders exactly as designed. Then you ship to production and the same code starts spitting out hollow rectangles where Japanese characters should be, serif fallbacks where Arabic script belongs, and blank spaces where emoji lived.
Headless Chrome relies entirely on fontconfig and whatever the host operating system provides — and most Docker base images ship with zero fonts installed [1]. Your developer machine has full font suites installed through years of normal use. Your production Docker container, Alpine Linux image, or Lambda runtime has almost none.
CJK characters become empty rectangles. Arabic collapses to a generic serif fallback. Emoji vanish entirely. English and basic Latin usually survive because something like DejaVu or Liberation is present by accident, but anything outside ASCII range breaks.
Puppeteer inside an Alpine Linux Docker container renders English fine by default but does not render Japanese at all until you install Noto CJK fonts and run fc-cache [2]. The same emoji character that renders correctly in a locally run Puppeteer PDF renders incorrectly when the same code runs inside Docker [3]. Most Docker base images ship with zero fonts installed, which causes missing characters, empty boxes, or garbled text in PDF and image output [1].
The fix starts with installing the right stack. A recommended Docker font stack for headless Chrome PDF generation includes fonts-liberation, fonts-noto, fonts-noto-cjk, fonts-noto-color-emoji, fonts-dejavu-core, and fonts-freefont-ttf installed via apt [4]. The Noto font family covers virtually every writing system in existence, and without these packages text renders in a fallback font that looks wrong [4]. Without CJK font packages, CJK characters render as empty rectangles; the Debian packages fonts-noto-cjk and fonts-noto-cjk-extra (or Alpine's font-noto-cjk) fix this [1]. The fonts-noto-color-emoji package adds emoji support, which matters for rendering modern web content in headless browser images [1].
Installation alone is not enough. You must run fc-cache -fv after installing fonts so fontconfig discovers them, and forgetting this step is a common cause of persistent font problems [1]. The fc-match command shows which font the system substitutes when a specific font is requested, which is useful for diagnosing fallback behavior [1]. A developer fixed emoji missing from headless-Chrome-in-Docker screenshots by adding fonts-noto-color-emoji to the Dockerfile [5].
Google Fonts and similar services check the user-agent string to decide which font format to serve. Puppeteer's own stripped user-agent makes those webfonts useless: the service returns nothing, and the browser falls back to system fonts without logging any error. Your CSS says font-family: 'Inter', sans-serif but the browser silently uses whatever sans-serif happens to exist on the system.
As Browserless documents, setting a legitimate browser user-agent string fixes this for self-managed Puppeteer. You can also remove the network dependency entirely by self-hosting fonts via @font-face with base64 data URIs. This eliminates the user-agent problem, the CORS problem, and the external network dependency in one move.
Some Unicode glyphs render correctly in Chromium and Chrome's built-in PDF viewer but drop out in Adobe Acrobat. A Puppeteer-generated PDF can show missing-font errors in Acrobat Reader while displaying correctly in Chrome and other PDF readers, because headless Chrome embeds fonts differently than Acrobat expects [6]. The Acrobat font problem varies by machine: PDFs generated on different Windows systems had different fonts missing [6].
This failure mode was reported against Puppeteer 1.10.0 on Windows 10 in December 2018 [6]. The fix is to test output across multiple PDF readers, not just Chrome, and to embed fonts more aggressively where possible.
Use a glyph soup string containing CJK, Arabic, and emoji to force every code path at once. A string like "Hello 世界 مرحبا 🚀" will stress test Latin, CJK, Arabic, and emoji in one render.
Install the Noto + Liberation + DejaVu stack via apt or apk with exact package names [4]. Run fc-cache -fv after installation so fontconfig discovers the new fonts [1]. For Google Fonts failures, set a real browser user-agent or self-host WOFF2 files as base64 data URIs in @font-face. For Acrobat-only drops, embed fonts more aggressively or test output across multiple readers. As Browserless documents, the --font-render-hinting=none launch flag can improve kerning and spacing as a side benefit. For header and footer templates in Chromium, embed base64 fonts or accept system fallbacks; external webfonts will not load [7].
MarkupGo's homepage claims SVG and Emoji support in its PDF generation [8]. PDF generation from HTML, URLs, templates, and markdown inherits that stack without Dockerfile changes [7]. Templates support custom HTML and CSS, so you can ship your own brand typography with the document. Header and footer templates are supported with injected values like pageNumber and totalPages [7]. The same Chromium-level constraint applies everywhere: header and footer templates support no JavaScript, their CSS is independent of the main document, and only installed fonts load [7]. MarkupGo offers a free trial of 100 credits per month to test glyph-heavy documents before committing [8].
Send a test PDF through the /pdf endpoint with your glyph soup string:
fetch('/api/v1/pdf', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'YOUR_API_KEY'
},
body: JSON.stringify({
source: {
type: 'html',
data: '<html><body><p>Hello 世界 مرحبا 🚀</p></body></html>'
},
options: {
properties: {
size: { width: 210, height: 297 },
margins: { top: 10, bottom: 10, left: 10, right: 10 }
}
}
})
})
Inspect the resulting file in multiple PDF readers.