Skip to content
getnextpdf.com

Provision fonts in production

Your PDF renders correctly on your laptop, then ships to a container and comes out as a row of empty boxes — the “tofu” glyph — or with missing accents and non-Latin characters. The cause is almost always the same: the font you selected is not present in the deployed image.

The native, in-process NextPDF engine resolves fonts from font files that the font registry can read. It does not discover OS or fontconfig fonts automatically — OS-installed font files only help if you explicitly register those files or add their containing directory to the FontRegistry search path. A container built from a slim base image has no apt/apk-installed fonts, and even when it does, the native engine ignores them unless you point the registry at their files. The fix is to bundle the actual font files inside your application or image and register them with the engine. The registry reads TrueType (.ttf), OpenType (.otf), and TrueType Collection (.ttc) files; legacy Type1 (.pfb) is also accepted but rarely needed for new work.

Before you start, confirm these pieces are in place:

  • NextPDF core is installed.
  • You have the actual font files you intend to use, and you are licensed to embed them. Embedding rights are your responsibility — see Embed and subset a TrueType font.
  • Your build can copy those files into the deployed artifact.

This is an operations how-to. The code is minimal; the work is in the build and the filesystem layout. For the API-level mechanics of registering and subsetting a single face, read the embed-and-subset recipe linked above. This page covers getting the files onto the box and pointing the engine at them.

Why the native engine does not find OS fonts automatically

Section titled “Why the native engine does not find OS fonts automatically”

There are two distinct rendering paths, and the font story differs between them.

  • Native in-process engine (the default, Document / writeHtml): the engine does not call into the operating system’s font system or fontconfig for discovery. It resolves a face through the font registry, which reads a specific font file you registered or finds one inside a directory you configured as a search path. Installing a font with apt-get install fonts-noto or running fc-cache does nothing on its own — the native engine sees those files only if you register them or add their directory to the registry’s search path.
  • Chrome bridge (the HTML-to-PDF renderer that drives a headless browser): this path does use the host’s installed fonts through the browser’s normal font discovery, so apt/apk font packages and fontconfig matter there.

If you read general “install these system font packages in your Dockerfile” guidance, it applies to the Chrome bridge, not to the native engine covered on this page. For native generation, bundle the files and register them.

Put the font files inside your application tree so they are versioned and ship with every build. A conventional location is a resources/fonts/ directory.

your-app/
├── resources/
│ └── fonts/
│ ├── DejaVuSans.ttf
│ ├── DejaVuSans-B.ttf
│ └── NotoSansCJK-Regular.ttc
└── src/

Name the files so the engine’s directory search can find them by family and style. When you register a directory (rather than a specific file) and later call setFont('DejaVuSans', 'B', 12), the engine looks for files such as DejaVuSans-B.ttf, DejaVuSansB.ttf, or DejaVuSans.ttf in each configured directory. The directory search builds those candidate names from the same single-letter style code you pass to setFont (B for bold, I for italic, BI for bold-italic), not a spelled-out word — so the reliable form is Family-<StyleCode>.ttf (for example DejaVuSans-B.ttf or DejaVuSans-BI.ttf), not Family-Bold.ttf. A file named DejaVuSans-Bold.ttf is never found by directory search; to use such a file, register it explicitly with register() — which parses the font and indexes it under the family and style read from the file’s own name tables, so the spelled-out filename no longer matters (see Step 2).

Step 2 — Register the fonts with the engine

Section titled “Step 2 — Register the fonts with the engine”

You have two equivalent ways to make the files visible. Both go through NextPDF\Typography\FontRegistry, which implements NextPDF\Contracts\FontRegistryInterface.

Register a specific file under an alias when you control the exact face:

use NextPDF\Typography\FontRegistry;
$registry = new FontRegistry();
$registry->register(__DIR__ . '/../resources/fonts/DejaVuSans.ttf', alias: 'DejaVuSans');

register(string $fontFile, string $alias = '', int $fontIndex = 0) accepts .ttf, .otf, and .ttc files, plus legacy Type1 .pfb (which loads its companion .afm metrics from the same path); $fontIndex selects a sub-font inside a TrueType Collection (.ttc). register() parses the file and indexes the face by the family and style read from its own name tables, so the physical filename is irrelevant once registered. The optional $alias is just an extra lookup name for the face — it is not a style code and does not change which style the file provides; pass it when you want to call setFont() with a name other than the font’s embedded family name. It returns the parsed FontInfo.

Register a directory when you want the engine to resolve faces by name from a folder you control:

$registry = new FontRegistry('/var/www/app/resources/fonts');
// or, equivalently, after construction:
$registry->addFontDirectory('/var/www/app/resources/fonts');

The FontRegistry constructor takes that directory as its first argument, and addFontDirectory() adds more search paths. A bare Document also exposes addFontDirectory() for the standalone case.

To use a registry you populated yourself, build documents through DocumentFactory, which wires that exact registry into every document it creates:

use NextPDF\Core\DocumentFactory;
use NextPDF\Graphics\ImageRegistry;
$factory = new DocumentFactory($registry, new ImageRegistry(maxCacheBytes: 0));
$doc = $factory->create();
$doc->addPage();
$doc->setFont('DejaVuSans', '', 12);
$doc->cell(0, 10, 'Réndéred wîth a bundled face — no tofu.', newLine: true);
$doc->save('/tmp/out.pdf');

Document::createStandalone() builds its own internal registry, so a face you registered on a separate FontRegistry is invisible to it. In production, go through DocumentFactory (or your framework’s factory) so the populated registry is the one in use.

Each framework integration exposes the same two concepts as configuration, so you rarely touch the registry directly. In the Laravel package’s nextpdf.php, fonts_path (default NEXTPDF_FONTS_PATH, falling back to resource_path('fonts')) is the search directory, and preload_fonts is a list of absolute font-file paths parsed at worker boot. Point fonts_path at the directory you bundled and your registered faces resolve automatically.

Step 3 — Provision fonts in a Docker image

Section titled “Step 3 — Provision fonts in a Docker image”

In a container, the font files must be part of the image layer, copied in at build time. Because the application code and the fonts ship together when you bundle them under resources/fonts/, a normal COPY . . already carries them. If you keep fonts outside the build context, copy them explicitly and make sure the path you register matches the path inside the image.

# Native engine: NO system font packages are required.
# The native engine does not discover OS-installed fonts automatically; install OS
# font packages (`apt-get install fonts-*`) only if you also register them or point
# the font registry's search directory at their files.
FROM php:8.4-cli
WORKDIR /var/www/app
# Bundle the application, including resources/fonts/, into the image.
COPY . /var/www/app
# Make the bundled directory the engine's font search path.
ENV NEXTPDF_FONTS_PATH=/var/www/app/resources/fonts
CMD ["php", "bin/generate.php"]

On an immutable or read-only filesystem (a readOnlyRootFilesystem container, a serverless image, or a hardened host), the font files are read at generation time and never written, so a read-only mount is fine. The only write the engine may want is its parsed-font cache: either give that directory a small writable volume, or warm and lock the registry at boot (next section) so no runtime write or registration is attempted.

In a long-running worker, parse every face once at boot, then lock the registry so no per-request registration happens and a misconfiguration fails loudly instead of silently falling back:

$registry = new FontRegistry('/var/www/app/resources/fonts');
$registry->warmup([
'/var/www/app/resources/fonts/DejaVuSans.ttf',
'/var/www/app/resources/fonts/DejaVuSans-B.ttf',
]);
$registry->lock();

After lock(), register(), addFontDirectory(), and warmup() throw, which turns a “wrong path in the image” mistake into a hard boot failure rather than a tofu page in production.

Add a deployment smoke check that renders one page with each required face. The header check below only verifies the document produced output — it does not prove the font parsed, embedded, or even resolved. A face the engine cannot find may fall back to a standard base font (and, under the current non-strict behaviour, a conformance profile may instead supply a bundled substitute) while still emitting a valid, non-empty PDF — so even where that fallback happens this check alone will not catch the silent degradation. Do not rely on the fallback being guaranteed or silent on every path; verify the embedded program directly, as shown below:

$doc = $factory->create();
$doc->addPage();
$doc->setFont('DejaVuSans', '', 12);
$doc->cell(0, 10, 'warmup check', newLine: true);
$pdf = $doc->getPdfData();
// `getPdfData()` would normally throw on a real failure; this header check only
// confirms serialization returned PDF bytes, not that any specific font resolved.
if (!str_starts_with($pdf, '%PDF')) {
throw new RuntimeException('Font warmup smoke check produced no PDF output.');
}

To actually fail the deploy when a face is missing, check the emitted PDF for the embedded font program. A registered face that resolves carries its own font dictionary with an embedded program, so asserting its presence catches the case where the requested face never resolved (whatever the engine fell back to) that the header check misses. Which key holds the program depends on the outline format: TrueType outlines (.ttf, .ttc) use /FontFile2, CFF/OpenType outlines (.otf with PostScript outlines) use /FontFile3, and legacy Type1 (.pfb) uses /FontFile.

If all you need is a format-agnostic “some font program embedded” signal, test for /FontFile alone — because /FontFile is a substring of both /FontFile2 and /FontFile3, a bare substring check already matches every outline type, and adding /FontFile2//FontFile3 as extra || branches is redundant:

if (!str_contains($pdf, '/FontFile')) {
throw new RuntimeException('No embedded font program found — face fell back.');
}

A bare /FontFile substring cannot tell the outline types apart, though. To distinguish them, match on the exact token with a word boundary so /FontFile does not also fire on /FontFile2 or /FontFile3:

$isTrueType = preg_match('~/FontFile2\b~', $pdf) === 1; // TrueType (.ttf/.ttc)
$isCffOtf = preg_match('~/FontFile3\b~', $pdf) === 1; // CFF/OpenType (.otf)
$isType1 = preg_match('~/FontFile(?![23])\b~', $pdf) === 1; // Type1 (.pfb)
if (!$isTrueType && !$isCffOtf && !$isType1) {
throw new RuntimeException('No embedded font program found — face fell back.');
}

Either way, treat this as a coarse heuristic only, not a reliable deploy gate. A raw byte search over the serialized PDF is inaccurate for several reasons: font programs can live inside compressed object streams (where /FontFile* never appears as plain bytes), incremental updates can append or supersede objects, non-embedded or standard-14 fonts legitimately carry no font program at all, and serialization differences (object ordering, whitespace, name encoding) can move or hide the token. At best it confirms some face embedded a program — never that the specific face you wanted resolved.

For a real deploy gate, do not rely on the byte search. Parse the emitted PDF with a proper PDF parser or object inspector and assert that the font object for your target face carries an embedded /FontFile//FontFile2//FontFile3 program, or use a product-provided font-resolution assertion if one is available to your integration. The token-aware regexes above are useful for a quick local quick check, but a structural inspection is what should fail the deploy. The embedding and font-dictionary structure are described in Embed and subset a TrueType font.

  • createStandalone() has its own registry. A face registered on a separate FontRegistry is not visible to a standalone document. Use DocumentFactory (or the framework factory) so your registry is the active one.
  • Style files must exist as files. The engine does not synthesize bold or italic from a regular face. If you call setFont('DejaVuSans', 'B'), directory search looks for DejaVuSans-B.ttf, DejaVuSansB.ttf, or DejaVuSans.ttf (lowercase and .otf variants too) — it forms the candidate from the literal B style code, so it never looks for DejaVuSans-Bold.ttf. A file with a spelled-out name like DejaVuSans-Bold.ttf only resolves when you register it explicitly with register(), which indexes it by the family and style read from the file’s own name tables regardless of the filename; relying on directory search to find it produces a miss, after which the engine may fall back to a base font (not a guaranteed or always-silent path) — the degradation this page warns about.
  • Stream-wrapper and remote paths are rejected. The registry refuses paths containing a URI scheme or a null byte. Register local files only; for fonts fetched at runtime use registerFromBinary() with the raw bytes.
  • Locked registry is immutable. Once you call lock(), any later register(), addFontDirectory(), or warmup() throws. Lookup methods stay available. Register and warm up everything before locking.
  • CJK collections are large. Register the right sub-font of a .ttc with $fontIndex, and budget for a larger embedded subset. See the CJK notes in the embed-and-subset recipe.
  • A font file is untrusted binary input. Only bundle fonts from sources you trust, and validate the provenance of any face accepted from end users.
  • Locking the registry after warmup removes a runtime mutation surface and makes a path mistake fail at boot rather than silently degrade output.
  • Do not interpolate user input into a registered file path. Register a fixed set of bundled faces; do not let a request choose an arbitrary filesystem path.

This guide makes no normative standards claim. Every symbol shown is verified public surface: NextPDF\Typography\FontRegistry (register(), addFontDirectory(), warmup(), lock(), the directory constructor argument), its NextPDF\Contracts\FontRegistryInterface contract, NextPDF\Core\DocumentFactory::create(), and NextPDF\Core\Document::setFont() / addFontDirectory(). The Laravel fonts_path and preload_fonts keys are the documented configuration of the nextpdf/laravel package. The embedding and subset-tag behavior, with its ISO 32000-2 citations, is documented on the embed-and-subset recipe linked under See also.