A screenshot with missing glyphs is a successful screenshot. Chromium draws a box where a font has no glyph, Playwright reports no error, and the PNG goes out with □□□ where a customer's name should be. This post shows how to ask Chromium which font it used for each piece of text, how to turn that into a check that fails the capture, and what the numbers look like when fonts are added one package at a time.
Setup for everything below: Playwright 1.63.0, Chromium 153.0.8010.12, Node v24.22.2, Ubuntu 24.04.5, run on 2026-11-08. I did not use Docker. To imitate a slim container I pointed fontconfig at a config that lists only two font directories.
A machine with almost no fonts
fontconfig reads the file named in FONTCONFIG_FILE instead of the system config (fontconfig user guide). This config exposes only DejaVu and Liberation, a deliberately small set:
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
<dir>/usr/share/fonts/truetype/dejavu</dir>
<dir>/usr/share/fonts/truetype/liberation</dir>
<cachedir>/tmp/fonts-demo/cache</cachedir>
</fontconfig>
Run any script below with FONTCONFIG_FILE=/tmp/fonts-demo/slim.conf node script.js, with the config saved at that path. Without the variable you get the host's own fonts, which on my machine are many (1939 faces by fc-list).
Step 1: ask Chromium which fonts it used
The DevTools protocol has CSS.getPlatformFontsForNode. Per the protocol definition it returns, for the text inside a node, each platform font used and how many glyphs were drawn with it. Playwright exposes CDP through newCDPSession on Chromium.
const { chromium } = require('playwright');
const crypto = require('crypto');
const html = `<!doctype html><meta charset=utf-8>
<body style="font-family: Inter, sans-serif; font-size: 28px">
<p id=latin>Invoice total: 1,234.50</p>
<p id=japanese>請求書 日本語のテキスト</p>
<p id=arabic>مرحبا بالعالم</p>
<p id=cyrillic>Привет мир</p>
<p id=emoji>Done ✅ 🎉 🚀</p>
<p id=mixed>Total 合計 ✅</p>
<p id=han>直骨海</p>
<p id=han-ja lang=ja>直骨海</p>
<p id=han-zh lang=zh-CN>直骨海</p>
<p id=han-ko lang=ko>直骨海</p>`;
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(html);
const cdp = await page.context().newCDPSession(page);
await cdp.send('DOM.enable');
await cdp.send('CSS.enable');
const { root } = await cdp.send('DOM.getDocument');
for (const id of ['latin', 'japanese', 'arabic', 'cyrillic', 'emoji', 'mixed',
'han', 'han-ja', 'han-zh', 'han-ko']) {
const { nodeId } = await cdp.send('DOM.querySelector', { nodeId: root.nodeId, selector: '#' + id });
const { fonts } = await cdp.send('CSS.getPlatformFontsForNode', { nodeId });
const png = await page.locator('#' + id).screenshot();
const sha = crypto.createHash('sha1').update(png).digest('hex').slice(0, 6);
console.log(id.padEnd(9), sha, fonts.map(f => `${f.familyName} x${f.glyphCount}`).join(', '));
}
await browser.close();
})();
The first column is the element, the second a short hash of that element's screenshot (only compare hashes within one configuration), then the fonts. On the host's full font set:
latin edd5ac Liberation Sans x23
japanese e64ea3 Liberation Sans x1, WenQuanYi Zen Hei x11
arabic 91f391 Liberation Sans x1, DejaVu Sans x12
cyrillic 2f1547 Liberation Sans x10
emoji d0d093 Liberation Sans x7, Noto Color Emoji x3
mixed d0b604 Liberation Sans x7, Noto Color Emoji x1, WenQuanYi Zen Hei x2
Each non-Latin string is split between the page's font (Liberation Sans, from sans-serif; Inter is not installed) and a fallback. The x1 for Japanese is the space. With the slim config:
latin 3ae57e Liberation Sans x23
japanese cf7835 Liberation Sans x12
arabic b15b4d Liberation Sans x1, DejaVu Sans x12
cyrillic a6a4da Liberation Sans x10
emoji 35216f Liberation Sans x10
mixed bdf4d9 Liberation Sans x10
The tell is Liberation Sans x12 for a 12-character Japanese string. Liberation Sans has no Japanese glyphs, so those 12 are boxes drawn by the primary font. A screenshot of the slim run shows boxes in those places. The limit of this method is that CDP does not say "missing". It only says which font was asked, so you have to know the primary font has no glyph for that character.
Step 2: a check that lists the missing characters
Width gives a way to detect boxes without a font parser. U+10FFFF is a noncharacter that no font covers, so a span containing it always renders as the box of the current font stack. If a character's width equals that box width, it is probably a box too.
My first version used one stack and flagged © as missing on the slim config. At 28 px in Liberation Sans, © is 21 px wide, the same as the box (21 px). In serif the box is 22 px and © is 21, so they differ. The version below requires the match in four stacks: the element's own font, serif, sans-serif and monospace. A character that is really missing is a box in all of them.
const { chromium } = require('playwright');
// Runs inside the page. Returns the characters that render as a .notdef box in every font stack.
// U+10FFFF is a noncharacter that no font covers, so its width is the box width of a stack.
// One stack is not enough: © happens to be as wide as the box in Liberation Sans.
function findTofu() {
const nodes = [];
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
for (let n; (n = walker.nextNode());) nodes.push(n); // collect before adding the probe
const probe = document.createElement('span');
probe.style.cssText = 'position:absolute;visibility:hidden;white-space:pre';
document.body.appendChild(probe);
const widthOf = (text, font) => {
probe.style.font = font;
probe.textContent = text;
return probe.getBoundingClientRect().width;
};
const missing = new Set();
for (const n of nodes) {
const cs = getComputedStyle(n.parentElement);
const stacks = [cs.font, `${cs.fontSize} serif`, `${cs.fontSize} sans-serif`, `${cs.fontSize} monospace`];
for (const ch of new Set(Array.from(n.data))) {
if (/[\x00-\x7f\s]|\p{Cf}|[︀-️]/u.test(ch)) continue; // ASCII, spaces, ZWJ, variation selectors
if (stacks.every(f => Math.abs(widthOf(ch, f) - widthOf('\u{10FFFF}', f)) < 0.01)) missing.add(ch);
}
}
probe.remove();
return [...missing];
}
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(`<!doctype html><meta charset=utf-8>
<body style="font-family: Inter, sans-serif; font-size: 28px">
<p>Invoice total: 1,234.50 © € ≠</p>
<p>請求書 日本語のテキスト</p>
<p>مرحبا بالعالم</p>
<p>Done ✅ 🎉 🚀</p>
<p>Family 👨👩👧 flag 🇩🇪</p>`);
const tofu = await page.evaluate(findTofu);
console.log('tofu:', tofu.length, tofu.map(c => 'U+' + c.codePointAt(0).toString(16).toUpperCase()).join(' '));
await browser.close();
})();
Output of that script on four font setups:
host: tofu: 0
slim: tofu: 19 U+8ACB U+6C42 U+66F8 U+65E5 U+672C U+8A9E U+306E U+30C6 U+30AD U+30B9 U+30C8 U+2705 U+1F389 U+1F680 U+1F468 U+1F469 U+1F467 U+1F1E9 U+1F1EA
slim + CJK: tofu: 8 U+2705 U+1F389 U+1F680 U+1F468 U+1F469 U+1F467 U+1F1E9 U+1F1EA
slim + CJK + emoji: tofu: 0
The last two rows come from adding one directory to the config at a time. In my copy the CJK directory holds only a symlink to NotoSansCJK-Regular.ttc (19.5 MB, Debian package fonts-noto-cjk) and the emoji directory only NotoColorEmoji.ttf (10.8 MB, fonts-noto-color-emoji). On a real image you would install those packages instead of symlinking; I checked the package names with dpkg -S, but did not build an image.
Arabic and Cyrillic never showed up as missing in the slim run, because DejaVu Sans drew the Arabic and Liberation Sans the Cyrillic. That is why a page can look fine in a Latin-only test and break for one customer.
Use it before the screenshot call: const tofu = await page.evaluate(findTofu); if (tofu.length) throw new Error('missing glyphs: ' + tofu.join('')). Run it after document.fonts.ready and after your content has rendered, because it only sees what is in the DOM at that moment.
What it does not cover: it walks text nodes of the main document only. Text in iframes, shadow DOM, ::before content, input values and <canvas> is not seen. It is also a width heuristic. A character whose real glyph is as wide as the box in all four stacks would be missed, though I did not see one in this test.
Two more things the probe showed
A failed web font falls back with only a generic error. I served DejaVu Sans as Inter from a local server, and also pointed an @font-face at a URL that returns 404. Script (run with a .ttf path as its argument, here DejaVuSans.ttf):
const http = require('http');
const fs = require('fs');
const { chromium } = require('playwright');
// node webfont.js /path/to/some.ttf (served as "Inter" at /inter.ttf; /missing.ttf is a 404)
let pageHtml = '';
const server = http.createServer((req, res) => {
if (req.url === '/page.html') { res.setHeader('content-type', 'text/html'); return res.end(pageHtml); }
if (req.url === '/inter.ttf') { res.setHeader('content-type', 'font/ttf'); return res.end(fs.readFileSync(process.argv[2])); }
res.statusCode = 404;
res.end('not found');
});
(async () => {
await new Promise(r => server.listen(0, r));
const base = `http://127.0.0.1:${server.address().port}`;
const browser = await chromium.launch();
for (const file of ['inter.ttf', 'missing.ttf']) {
pageHtml = `<!doctype html><meta charset=utf-8>
<style>@font-face { font-family: Inter; src: url(${base}/${file}); }</style>
<body style="font-family: Inter, sans-serif; font-size: 28px"><p id=t>Invoice total</p>`;
const page = await browser.newPage();
page.on('console', m => console.log(' console:', m.type(), m.text()));
await page.goto(base + '/page.html');
await page.evaluate(() => document.fonts.ready);
const cdp = await page.context().newCDPSession(page);
await cdp.send('DOM.enable');
await cdp.send('CSS.enable');
const { root } = await cdp.send('DOM.getDocument');
const { nodeId } = await cdp.send('DOM.querySelector', { nodeId: root.nodeId, selector: '#t' });
const { fonts } = await cdp.send('CSS.getPlatformFontsForNode', { nodeId });
console.log(file.padEnd(12), fonts.map(f => `${f.familyName}${f.isCustomFont ? ' (web font)' : ''} x${f.glyphCount}`).join(', '));
}
await browser.close();
server.close();
})();
inter.ttf DejaVu Sans (web font) x13
console: error Failed to load resource: the server responded with a status of 404 (Not Found)
missing.ttf Liberation Sans x13
The 404 produces a console error that does not name the font. The text just renders in the fallback. The CDP call shows it: (web font) is present for the first file and the family differs for the second. If your layout depends on a specific web font, assert on isCustomFont instead of trusting that the page loaded.
The lang attribute did not do what I expected. The last four rows of the probe render the same three Han characters with no lang, ja, zh-CN and ko. On the host's full font set all four had the same hash and the same font (WenQuanYi Zen Hei), so lang changed nothing. With the slim config plus the Noto CJK collection, zh-CN and ko produced different pixels from ja:
han 5084e1 Noto Sans CJK JP x3
han-ja 5084e1 Noto Sans CJK JP x3
han-zh 2fa707 Noto Sans CJK JP x3
han-ko 772634 Noto Sans CJK JP x3
The reported family name was Noto Sans CJK JP for all four (the PostScript name too, NotoSansCJKjp-Regular), yet the images differ. So for regional glyph variants the CDP call cannot tell you which one was drawn. If the variant matters, set lang and compare pixels against a reference image. I did not look into why Chromium picks different faces here, so I can only report what I saw.
When this is not the right tool
The check finds missing glyphs, not wrong-looking ones. A font that exists but is the wrong one (a different weight, or the Japanese variant for Chinese text) passes it. For that you need a pixel comparison against a reference you trust. If you own the page, shipping the fonts as web fonts with @font-face removes the dependency on what is installed, at the cost of file size and the fallback problem above.
Sources: CDP CSS.getPlatformFontsForNode, fontconfig user guide.
Written with AI assistance. Every code sample was run on 2026-11-08 with Playwright 1.63.0 (Chromium 153.0.8010.12) on Node v24.22.2; the output above is from that run.
Top comments (0)