DEV Community

NullPointerZen
NullPointerZen

Posted on

My PDF came out blank because the load event doesn't wait for fonts

I made a one-page placeholder statement to test PDF export, the kind of thing a billing page spits out. The body uses Noto Sans SC and the title uses ZCOOL KuaiLe, a round, playful Chinese display font that's easy to spot when it's missing. Both came from Google Fonts through a <link> tag. My print script did what most snippets online do. It opened the page, waited for the load event and called page.pdf() straight away, in an open-source Chromium 149 build on an M4 Mac. The PDF looked fine at a glance, except the title was in PingFang, the Mac system font. I ran it again in a fresh browser. Same thing, 3 times out of 3. Then I dropped display=swap from the font URL, and the PDF came back as 1,073 bytes of nothing. A blank page, no text layer, no error, also 3 out of 3.

That sent me back to a question I had never really asked. When the browser prints, where do its fonts come from, and are they ready at that moment? On a page like mine there are three sources. System fonts are already on the machine, so they're ready, but they're whatever that machine has. Remote web fonts have to be downloaded, and load doesn't wait for them. The Google Fonts version of my page pulled 16 woff2 slices, 412,984 bytes in total, because the CSS splits the font by unicode-range and only fetches the slices the page uses. At the moment load fired, 13 of the 101 Noto slices were still loading. Fonts bundled with the page are the third source, and I assumed they'd be instant. They weren't.

Top: PDF printed right after load, the title fell back to the system font. Bottom: the same page printed after document.fonts.ready, the title is in ZCOOL KuaiLe

What to look at in this one is the title line. The top PDF was printed the moment load fired, and the rounded KuaiLe title is simply gone, replaced by the system font. The body text looks normal, so if you only skim the PDF you'd never notice.

Left: PDF printed at load with a remote font and no font-display swap, the page is blank. Right: the normal PDF

This one is the version without swap. The default font-display treats the font as blocking, so while it's still downloading the text is drawn invisible. Print during that window and you get the empty page on the left, with no warning at all.

For the bundled case I put the two TTF files in a fonts/ folder next to index.html and pointed @font-face at them with relative URLs. Printing right after DOMContentLoaded in a cold browser, the body text and the table text vanished in 4 of 8 runs. Only the KuaiLe title and the empty table lines were left, 6,476 bytes. One earlier run came out fully blank. Printing after load was correct every time, 71,102 bytes. Local files were faster than Google Fonts, just not fast enough for a print call fired that early.

Two waits fixed the remote case. Printing after networkidle, or after load plus await document.fonts.ready, gave the correct 117,799-byte PDF in all 3 runs each. I went with fonts.ready because it waits for the thing I care about. My script now also logs how many fonts are still loading before it prints.

const pending = await tab.evaluate(() =>
  [...document.fonts].filter(f => f.status === 'loading').length);
console.log('fonts still loading:', pending);
await tab.evaluate(() => document.fonts.ready);
const pdfBytes = await tab.pdf({ format: 'A4', printBackground: true });
Enter fullscreen mode Exit fullscreen mode

Code notes: tab is the page I opened. document.fonts lists every font face the page declared, with a status for each, so the first line counts the ones still in flight. In my runs it was never zero right after load, so I keep the log line in. document.fonts.ready is a promise that resolves once nothing is loading anymore.

Then I wanted to see what happens on a machine I don't control. I used ImgIng (https://imging.ai/) HTML to PDF with the same placeholder statement, importing it once as a single file and once as a whole folder. Import and preview happen in the browser. When you click convert, it sends one POST with a script-free snapshot to a server-side Chromium 151 on Linux. I counted 0 non-GET requests before the click and exactly 1 after. For pages with @font-face rules, its quality report says it waited for document.fonts and the page images. Remote fonts still never made it. With the network toggle off, which is the default, the report said the remote resource was blocked. With it on, the preview downloaded the fonts, but the snapshot kept the <link> as is, and the server PDF used Noto Sans CJK SC. Inlined fonts weren't a sure thing either. The full 3.27 MB KuaiLe file didn't show up in the server PDF, while a 48 KB subset of the same font did. I can't tell why from the outside.

What I haven't tested is Firefox and WebKit printing, the real Chrome print dialog, and folder fonts with font-display: swap. If you print pages to PDF, log the loading count before page.pdf(). Don't print until it reaches zero, then open the PDF and check the title font.

Top comments (0)