Why Your Server-Generated PDF Shows Boxes and Fallback Fonts (Unicode, CJK, Emoji)

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…

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 ships with zero fonts of its own

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 silently fail to load in headless browsers

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.

Glyphs vanish only in Adobe Acrobat

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.

Diagnosis checklist: which failure mode do you have

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.

  • Check if the problem appears in screenshots too or only in page.pdf output. If screenshots are also broken, you have a system font problem.
  • Check fontconfig on the server with fc-list and fc-match to see what the system actually substitutes [1].
  • Check whether Google Fonts loaded at all by inspecting network traffic or replacing with a base64 font.
  • Check header and footer templates separately. MarkupGo's PDF header and footer templates support no JavaScript and their CSS is independent of the main HTML document's CSS [7]. Chromium's header and footer templates never load external webfonts; only fonts installed in the Docker image are loaded, and assets are not loaded including CSS files, scripts, and fonts [7]. You must embed base64 fonts there or accept system fallbacks.
  • Check the same PDF in Chrome, macOS Preview, and Acrobat to spot the Acrobat-only drop.

Fixes for self-managed renderers

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].

What a hosted renderer changes

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.

Sources

  1. oneuptime.com
  2. google chrome (stackoverflow.com)
  3. stackoverflow.com
  4. How to Set Up Docker for PDF Generation with Headless Chrome (oneuptime.com)
  5. waylonwalker.com
  6. github.com
  7. markupgo.com
  8. markupgo.com