Tofu: Missing Characters in Fonts and Screenshots

Tofu is the empty box a browser draws when no font has a character. See why font reports miss it, and a tested pixel check that fails a capture with tofu.

By Simon Chiu · Updated

Tofu is the empty rectangle a browser draws for a character when neither the requested fonts nor any fallback font on the machine contains a glyph for it; each box is the font's "missing glyph" (.notdef) shape.

On a laptop you rarely see it, because desktop systems ship fonts for most scripts. In headless rendering on servers it is common, because the server has whatever fonts its image installed and nothing else. This entry is part of the screenshot and rendering glossary.

A plain explanation

Text on a web page names fonts in a list: font-family: Inter, Helvetica, sans-serif. For each character, the browser walks that list. If no listed font has the character, it asks the operating system for any installed font that has it. This is font fallback. Only when that search finds nothing does it draw the missing-glyph box: tofu.

So tofu is not a font problem in the CSS sense. It is a property of the machine that renders the page. The same page can be perfect on your Mac and full of boxes in a container.

Fallback and tofu in one capture

Five lines of text. latin Hello, japanese with three kanji, emoji with a rocket, then private_use and unassigned, each followed by two empty rectangles instead of characters.
Five test strings rendered with Puppeteer on 2026-10-15 in a page whose only font is Helvetica. Japanese and emoji fell back to system fonts. The Private Use Area and unassigned code points had no font anywhere and became tofu.

We also asked Chromium, through the DevTools Protocol method CSS.getPlatformFontsForNode, which font drew each line:

Sample Code points Font reported by Chromium Tofu?
latin Hello Helvetica x5 no
japanese U+65E5 U+672C U+8A9E PingFang (macOS CJK font) x3 no
emoji U+1F680 Apple Color Emoji x1 no
private_use U+E000 U+E001 Helvetica x2 yes
unassigned U+0378 U+0379 Helvetica x2 yes

The last two rows are the useful finding. The font report names Helvetica for the tofu, because Helvetica's own missing-glyph shape drew the box. A check based on "which font was used" does not catch tofu. A pixel check does.

How it works technically

  • Fallback is per character, not per string. One word can mix three fonts.
  • The fallback list depends on the OS. macOS and Windows have large system font sets. Debian and Alpine container images often have only DejaVu or nothing, so CJK, Thai, Devanagari and emoji become tofu.
  • Web fonts are often subset. A brand font with only Latin glyphs falls back for "é" or "ł" if the subset was too tight, and to tofu on a bare server.
  • Some code points have no glyph anywhere. Private Use Area characters only work with the icon font that defines them. If that icon font fails to load, every icon becomes a box.

Detect tofu before you ship a capture

The trick: draw a code point that no font has (U+0378 is unassigned in Unicode) on a canvas in the page's font. That is the missing-glyph shape. Any character whose pixels are identical is tofu.

Puppeteer shows both the font report and the pixel test on the sample strings. Excerpt of the check:

// Fragment: runs inside the Puppeteer script after page.setContent(...).
const tofu = await page.evaluate((list) => {
  const font = getComputedStyle(document.body).font;
  const draw = (ch) => {
    const c = new OffscreenCanvas(64, 64);
    const ctx = c.getContext('2d');
    ctx.font = font;
    ctx.fillText(ch, 4, 48);
    return ctx.getImageData(0, 0, 64, 64).data.join(',');
  };
  const missing = draw('\u0378'); // U+0378 is unassigned: always the missing-glyph box
  return Object.fromEntries(Object.entries(list).map(([id, text]) =>
    [id, [...text].some((ch) => draw(ch) === missing)]));
}, samples);
console.log('tofu:', tofu);
// tofu: { latin: false, japanese: false, emoji: false, private_use: true, unassigned: true }

Playwright, as a complete gate for any URL: it lists missing characters and exits with code 1 instead of saving a broken image.

import { chromium } from 'playwright';

// Fail a capture when visible text contains characters drawn as empty boxes.
const url = process.argv[2] ?? 'https://example.com';
const browser = await chromium.launch();
try {
  const page = await browser.newPage();
  await page.goto(url, { waitUntil: 'load' });
  await page.evaluate(() => document.fonts.ready);

  const boxes = await page.evaluate(() => {
    const font = getComputedStyle(document.body).font;
    const draw = (ch) => {
      const ctx = new OffscreenCanvas(64, 64).getContext('2d');
      ctx.font = font;
      ctx.fillText(ch, 4, 48);
      return ctx.getImageData(0, 0, 64, 64).data.join(',');
    };
    const missing = draw('\u0378'); // unassigned code point: always the missing-glyph box
    const chars = new Set([...document.body.innerText].filter((ch) => ch.trim()));
    return [...chars].filter((ch) => draw(ch) === missing)
      .map((ch) => 'U+' + ch.codePointAt(0).toString(16).toUpperCase().padStart(4, '0'));
  });

  if (boxes.length) {
    console.error(`Missing glyphs on ${url}: ${boxes.join(' ')}`);
    process.exitCode = 1;
  } else {
    await page.screenshot({ path: 'out/tofu-checked.png' });
    console.log(`No missing glyphs on ${url}`);
  }
} finally {
  await browser.close();
}

Results: No missing glyphs on https://example.com, and for a test page containing U+E000, Missing glyphs on ...: U+E000 with exit code 1. The check uses the body font, so it can miss tofu inside elements with a different font stack. Run it per element if your page mixes fonts.

RenderScreenshot renders in Cloudflare Browser Run. Its supported fonts list (checked 2026-10-15) includes Noto CJK and other CJK fonts, Arabic, Thai, several Indic scripts and Noto Color Emoji, so the common causes of tofu on self-hosted Linux do not apply. There is no font option to set. For anything outside that list, load a web font in the page, or in your own HTML:

curl -X POST https://api.renderscreenshot.com/v1/screenshot \
  -H "Authorization: Bearer $RS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "html": "<!doctype html><meta charset=\"utf-8\"><link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=Noto+Sans+Adlam&display=block\"><p style=\"font:48px Noto Sans Adlam,sans-serif\">𞤀𞤁𞤂</p>",
    "viewport": { "width": 800, "height": 200 },
    "wait": { "until": "networkidle" }
  }' \
  --output adlam.png

The emoji rendering guide and the localized screenshots guide cover the renderer's fonts in detail.

Tested on 2026-10-15 on macOS with Node 22.18, Puppeteer 24.43.1 (headless shell, Chrome 148) and Playwright 1.64.1 (Chromium 156). Font names in the table come from macOS; on Linux they differ. The RenderScreenshot request was not run for this page.

Common mistakes

  • Testing fonts on your laptop only. Your Mac falls back to fonts the server does not have. Run the check where you render.
  • Icon fonts with no fallback. When the icon font fails, every icon is a Private Use Area box. Prefer SVG icons in templates.
  • Over-tight font subsets. A Latin-only subset makes user names with accents or non-Latin scripts fall back, or fail, in cards.
  • Reading the font report as a tofu check. As the table shows, tofu is reported under the primary font's name.

To render a page with the renderer's full font set, get a free API key.

Frequently asked questions

Why is it called tofu?
The missing-glyph box looks like a white block of tofu. Google's Noto font family is named after the goal of 'no more tofu': a font for every script.
Why do Chinese or Japanese characters show as boxes in my headless Chrome screenshots?
The machine running Chrome has no font with those characters. Slim Linux containers often ship without CJK fonts. Install a CJK font package in the image, or load a web font that covers the characters.
Do RenderScreenshot captures show tofu for CJK or emoji?
The renderer runs in Cloudflare Browser Run, whose font list includes Noto CJK, other CJK fonts, Arabic, Thai, several Indic scripts and Noto Color Emoji. Characters outside every installed font, such as Private Use Area code points, still render as boxes.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.