#3765·dompdf

Truncated .ufm is cached as a valid font entry with no "C" key, permanently breaking rendering

Author: kiukaCreated Sep 18, 2026Updated Sep 18, 2026

Summary

Cpdf::openFont() can cache a font entry whose glyph table (C) was never populated, stamp it with the current _version_, and then trust that cache forever. Every later render of that font throws:

TypeError: array_key_exists(): Argument #2 ($array) must be of type array, null given
  src/Adapter/CPDF.php:846  Dompdf\Adapter\CPDF::font_supports_char

The nasty part is that it never recovers on its own. Restoring the font files does not help, because the poisoned .ufm.json still passes the _version_ check. Only deleting the cache file fixes it.

We hit this in production: a nightly batch of invoice PDFs started failing and then kept failing for every subsequent render.

Versions: dompdf v3.1.6, php-font-lib 1.0.2, PHP 8.5.10. I diffed lib/Cpdf.php, src/Adapter/CPDF.php and php-font-lib's AdobeFontMetrics.php against master today — all three are byte-identical, so this is not fixed upstream.

Root cause

Two things combine.

1. openFont() caches an incomplete parse. In lib/Cpdf.php, $data['C'] is only ever assigned inside the C: / U: branches of the parse loop. If the .ufm is empty or truncated before the first glyph line, $data comes out with no C key at all — but it is still stamped and written:

php
$data['_version_'] = $this->fontcacheVersion;
$this->fonts[$font] = $data;

if (is_dir($fontcache) && is_writable($fontcache)) {
    file_put_contents("$fontcache/$cache_name", json_encode($data, JSON_PRETTY_PRINT));
}

On the next run the _version_ check passes, the entry is used as-is, and font_supports_char() calls array_key_exists($charCode, $fontInfo["C"]) on a key that does not exist.

2. Font files are written non-atomically to deterministic shared paths. FontMetrics::registerFont() writes $localFilePath.ufm through AdobeFontMetrics::write(), which does fopen($file, "w+") — an in-place truncate — then writes $localFilePath.ttf with file_put_contents(). setFontFamily(), which records the font as installed, only runs after both writes. So with a cold font cache, N concurrent processes each decide the font is unregistered and all write the same paths while other processes are reading them.

Reproduction

Deterministic, and uses only the DejaVu Sans that ships with dompdf:

php
<?php
require $argv[1] ?? __DIR__ . '/vendor/autoload.php';

$fontDir = sys_get_temp_dir() . '/dompdf-ufm-repro';
$dompdfRoot = dirname((new ReflectionClass(Dompdf\Dompdf::class))->getFileName(), 2);
$ttf = $dompdfRoot . '/lib/fonts/DejaVuSans.ttf';   // ships with dompdf

// A custom @font-face family, so dompdf generates the .ufm at runtime
// rather than using the pre-built one bundled for DejaVu Sans.
$html = '<style>
@font-face { font-family: "ReproFont"; font-style: normal; font-weight: normal;
             src: url("' . $ttf . '") format("truetype"); }
body { font-family: "ReproFont"; }
</style><body>Hello</body>';

function render(string $fontDir, string $html, string $ttf): string {
    $o = new Dompdf\Options();
    $o->setFontDir($fontDir);
    $o->setFontCache($fontDir);
    $o->setChroot([dirname($ttf), $fontDir]);
    $d = new Dompdf\Dompdf($o);
    $d->loadHtml($html);
    $d->render();
    return 'OK (' . strlen($d->output()) . ' bytes)';
}

function attempt(string $label, string $fontDir, string $html, string $ttf): void {
    try {
        printf("%-52s %s\n", $label, render($fontDir, $html, $ttf));
    } catch (\Throwable $e) {
        printf("%-52s %s: %s\n    at %s:%d\n", $label, get_class($e), $e->getMessage(),
            basename($e->getFile()), $e->getLine());
    }
}

array_map('unlink', glob("$fontDir/*") ?: []);
@mkdir($fontDir, 0777, true);

attempt('1. cold cache, complete .ufm', $fontDir, $html, $ttf);

$ufm  = current(glob("$fontDir/*.ufm"));
$full = file_get_contents($ufm);
$firstGlyphLine = strpos($full, "\nU ");

// A concurrent reader sees the .ufm mid-write (header only, no glyph lines).
// fopen($file, "w+") truncates in place, so this state is genuinely observable
// by another process while the font is being written.
array_map('unlink', glob("$fontDir/*.ufm.json") ?: []);
file_put_contents($ufm, substr($full, 0, $firstGlyphLine));
printf("   (.ufm truncated to %d of %d bytes)\n", $firstGlyphLine, strlen($full));
attempt('2. reader sees a partially-written .ufm', $fontDir, $html, $ttf);

$cache = current(glob("$fontDir/*.ufm.json"));
if ($cache) {
    $j = json_decode(file_get_contents($cache), true);
    printf("   cached anyway: _version_=%s, has 'C' key: %s\n",
        var_export($j['_version_'] ?? null, true), isset($j['C']) ? 'yes' : 'NO');
}

// The writer finished; the .ufm on disk is complete again.
file_put_contents($ufm, $full);
attempt('3. .ufm complete again (cache left alone)', $fontDir, $html, $ttf);

array_map('unlink', glob("$fontDir/*.ufm.json") ?: []);
attempt('4. after deleting the .ufm.json', $fontDir, $html, $ttf);

Output:

1. cold cache, complete .ufm                         OK (5247 bytes)
   (.ufm truncated to 638 of 226711 bytes)
2. reader sees a partially-written .ufm              TypeError: array_key_exists(): Argument #2 ($array) must be of type array, null given
    at CPDF.php:846
   cached anyway: _version_=6, has 'C' key: NO
3. .ufm complete again (cache left alone)            TypeError: array_key_exists(): Argument #2 ($array) must be of type array, null given
    at CPDF.php:846
4. after deleting the .ufm.json                      OK (5247 bytes)

Step 3 is the point of the report: the font file on disk is complete and correct, and rendering still fails, because the cache written in step 2 is considered valid.

The concurrency is not theoretical

20 concurrent renders against a cold font dir, 8 rounds. Every round produced torn reads of the shared font files (unpack(): Type n: not enough input values from FontLib\BinaryStream, i.e. a process parsing a .ttf another process was still writing):

round 1: 1888 partial-font-read warnings
round 2: 1404 partial-font-read warnings
round 3:  526 partial-font-read warnings
round 4: 1005 partial-font-read warnings
round 5:  250 partial-font-read warnings
round 6:   58 partial-font-read warnings
round 7: 2558 partial-font-read warnings
round 8: 2566 partial-font-read warnings

The .ufm write window is far narrower than the .ttf one, so poisoning the metrics cache specifically is rare — I did not manage to trigger it by brute force locally. But it is permanent when it does happen, which is what makes it worth guarding. Our setup is a queue container with 20 workers; a deploy leaves it with a cold font dir, and a scheduled job then renders many PDFs at once.

Suggested fixes

The smallest change that would have prevented our outage — do not cache or use an incomplete parse in openFont():

php
if (!isset($data['C'])) {
    $this->addMessage("openFont: no glyph metrics in $dir/$metrics_name, not caching");
} else {
    $this->fonts[$font] = $data;
    if (is_dir($fontcache) && is_writable($fontcache)) {
        file_put_contents("$fontcache/$cache_name", json_encode($data, JSON_PRETTY_PRINT));
    }
}

Worth considering alongside it:

  • write the .ufm.json cache, and the .ufm / .ttf in registerFont(), to a temp file followed by rename(), so readers never observe a partial file
  • $fontInfo["C"] ?? [] in font_supports_char() as a belt-and-braces guard

Happy to open a PR for any of these if you have a preference on the approach.