How to Fix Custom Fonts Not Appearing in wkhtmltopdf PDFs
Fix missing fonts in wkhtmltopdf by checking URLs, local-file access, fontconfig, permissions, caches, and renderer compatibility.

Custom fonts can render correctly in Chrome and still disappear from a wkhtmltopdf PDF. The reliable fix is to separate font loading from layout debugging: prove that the renderer can reach the font, check local-file policy, install and index the font in the runtime image, align fontconfig settings and permissions, then retest with the exact production binary.
wkhtmltopdf uses a Qt WebKit rendering stack. Its output depends on the fonts, fontconfig, and FreeType installed where the command runs, not only on the HTML and CSS. The current stable series is 0.12.6, released June 11, 2020, and the project repository is archived. See the official downloads page and command reference when recording your runtime.
Fast diagnosis
- Record the exact
wkhtmltopdf --version, operating-system image, execution user, flags, HTML entry point, stylesheet URL, and font filenames. - Reduce the document to one distinctive sentence and one
@font-facerule. - Use one font source at a time: a fully qualified HTTPS URL, an absolute
file:///URL, or a locally installed family. - Check that the request succeeds, the CSS family name matches the element, and the selected weight and style exist.
- For local assets, use a narrowly scoped
--allowdirectory or explicitly enable local access. - In Linux or Docker, install the font in a fontconfig directory, rebuild caches, and run
fc-listandfc-matchas the wkhtmltopdf user. - If a readable, indexed font is still ignored, test a simple TTF face and consider the old Qt/WebKit engine the limiting factor.
Why Chrome works while wkhtmltopdf fails
A browser test proves that a browser process can resolve the URL, follow its redirects, read its local files, and support the font format. It does not prove that wkhtmltopdf can do the same. Common differences include:

- Different network context: the PDF process may have no DNS, proxy, certificate, or outbound-network access.
- Different URL base: a relative font URL is resolved from the stylesheet and document location, which may be different for a local HTML file.
- Local-file restrictions: wkhtmltopdf can block one local file from reading another.
- Different installed fonts: production containers often contain fewer fonts than a workstation.
- Different fontconfig environment:
FONTCONFIG_PATH,FONTCONFIG_FILE, cache directories, and permissions can vary by distribution. - Older CSS and font support: Qt WebKit may reject variable fonts, unusual OpenType tables, or faces that modern browsers accept.
Build a minimal reproducible test
Start with a page that has no layout complexity. Keep the family name, file name, weight, and style explicit. Test a distinctive sentence so a fallback face is obvious.

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
@font-face {
font-family: "AcmeTest";
src: url("file:///srv/app/assets/fonts/acme-regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
}
.sample { font-family: "AcmeTest", sans-serif; font-size: 32px; }
</style>
</head>
<body>
<p class="sample">Distinctive font verification: 0 O 1 l I — € ✓</p>
</body>
</html>
Use an absolute URL during diagnosis. Once it works, test the relative URL form used by your application. The family in font-family must exactly match the family declared in @font-face; the filename does not define the CSS family name.
Check remote font loading
For a remote stylesheet or font, resolve every URL before invoking wkhtmltopdf. Check the response status, redirects, certificate chain, MIME type, and case-sensitive path. A 403 response caused by a missing user agent or authorization header looks like a font-format problem in the final PDF.
curl -I https://example.com/assets/fonts/acme-regular.woff2
curl -L --fail --output /tmp/acme.ttf https://example.com/assets/fonts/acme-regular.ttf
wkhtmltopdf https://example.com/test-font.html /tmp/font-test.pdf
Test one source at a time. The wkhtmltopdf issue history documents failures involving remote, relative, absolute, and local sources independently. If a downloaded TTF works locally but the remote URL does not, fix connectivity or access control before changing CSS.
Configure local-file access safely
The command reference defines --disable-local-file-access as blocking local files from reading other local files unless they are explicitly allowed. --enable-local-file-access permits those reads. Prefer a directory-specific allow rule for a controlled build:
# Broad local access, suitable only for trusted input
wkhtmltopdf --enable-local-file-access input.html output.pdf
# Restricted access to the asset directory
wkhtmltopdf \
--disable-local-file-access \
--allow /srv/app/assets \
input.html output.pdf
Do not broadly enable filesystem access for untrusted HTML. If an AppArmor profile or another sandbox is active, it can impose an additional boundary even when wkhtmltopdf flags are correct. Permit the font files and fontconfig cache paths required by the execution user.
Install fonts in Linux and Docker
Copy fonts into a directory included by fontconfig, commonly /usr/local/share/fonts or a configured application directory. Rebuild the cache after every image change, then query it as the same user that runs wkhtmltopdf.
mkdir -p /usr/local/share/fonts/acme
cp assets/fonts/*.ttf /usr/local/share/fonts/acme/
fc-cache -f -v /usr/local/share/fonts/acme/
fc-list | grep -i "Acme"
fc-match "AcmeTest:style=Regular"
wkhtmltopdf --version
wkhtmltopdf --enable-local-file-access /srv/app/font-test.html /tmp/font-test.pdf
fc-cache scans configured directories and records font properties and filenames for applications. If fc-list finds the family but wkhtmltopdf does not, compare users, environment variables, and sandbox policy. If fc-list cannot find it, the renderer cannot reliably use it as an installed family.
Example Docker build stage
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends fontconfig wkhtmltopdf \
&& rm -rf /var/lib/apt/lists/*
COPY fonts/ /usr/local/share/fonts/acme/
RUN fc-cache -f -v
USER nobody
CMD ["wkhtmltopdf", "--version"]
Adapt package names to your distribution. Verify that the final runtime image, rather than only a build image, contains the font files, fontconfig, FreeType, and cache data. Ensure the non-root user can read both fonts and caches.
Align fontconfig and permissions
Packaged and serverless deployments may use a custom fontconfig directory. Inspect FONTCONFIG_PATH and FONTCONFIG_FILE in the process environment and compare them with the machine where the font works. Confirm that the configured directories contain your font and that cache directories are writable during image construction and readable at runtime.
env | grep '^FONTCONFIG_'
stat /usr/local/share/fonts/acme/acme-regular.ttf
fc-match "AcmeTest"
If a security profile denies access, audit the denial and allow only the needed font and cache paths. A permission error can be silent in the generated PDF, so command-line success alone does not prove that the face loaded.
Validate the font file and face mapping
Before changing page layout, validate the font itself. Confirm that the requested family, weight, style, and Unicode coverage are present. Start with a simple static TTF. Test variable fonts, icon fonts, multiple weights, and unusual OpenType tables only after the basic face works.
- Open the file with a font validator and check for corruption.
- Use
fc-matchto see which installed face matches the requested family and style. - Render characters that are often missing, including accented letters, currency symbols, and non-Latin scripts.
- Declare each weight and style you actually use; do not request weight 600 from a file declared as 400.
- Check that the license permits server-side embedding and PDF distribution.
Missing glyphs, wrong weights, and corrupted or unsupported faces can all look like fallback rendering. A successful font request does not guarantee that the selected face contains every character.
Control rendering variables
Freeze the command and environment while diagnosing. Record the binary path, version, OS distribution, container digest, execution user, command flags, input URL, stylesheet URL, and font filenames. Compare local and production with the same binary and image.
Once the minimal page works, add complexity one item at a time: external CSS, multiple faces, web fonts, page breaks, JavaScript, and the full document. This identifies whether the failure is in loading, face selection, or layout. Do not infer font success from a screenshot of a browser tab; inspect the generated PDF and, when needed, extract text or render a page to an image for comparison.
Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
Fallback font with a local file:/// URL |
Local-file access disabled | Use --allow /path or enable local access for trusted input. |
| Works on macOS, fails in Linux | Font absent or fontconfig cache not built | Install it in the runtime image, run fc-cache, and query as the renderer user. |
| 404 or 403 in production | Wrong base URL, case mismatch, authentication, or network policy | Use a fully resolved URL and check it with curl -I from the same runtime. |
| Family appears but weight is wrong | Requested weight is not declared or file lacks that face | Add matching @font-face declarations and verify with fc-match. |
| Some characters are blank or substituted | Missing glyph coverage | Choose a face containing the required Unicode ranges or add a deliberate fallback. |
| All checks pass but font is ignored | Unsupported format, corrupted tables, or old Qt WebKit behavior | Test a clean TTF, validate the file, then evaluate a maintained renderer. |
| Intermittent results | Network timing, cache state, or asynchronous font loading | Use local assets for deterministic builds and make the input wait for the required content before capture. |
When to migrate from wkhtmltopdf
wkhtmltopdf’s stable 0.12.6 release dates from 2020, and its repository is archived and read-only. If URL resolution, permissions, installation, cache rebuilding, font validation, and policy checks all pass, persistent incompatibility may be an engine constraint rather than an application bug.
Compare migration options across font-format and CSS support, local-file and sandbox controls, reproducibility across operating systems, maintenance and security status, deployment size, and the effort required to revalidate layout. Keep a golden set of PDFs containing every important weight, script, glyph, page break, and footer before switching engines.
Performance, reliability, and cost considerations
- Performance: local fonts avoid network latency and reduce a source of nondeterminism. Reusing a warmed runtime can avoid repeated font installation and cache work.
- Reliability: pin the binary and OS image, keep font files versioned, rebuild caches in the image, and run a smoke test as the production user.
- Security: allow only the asset directory needed by trusted HTML. Treat remote HTML, CSS, and fonts as untrusted inputs.
- Reproducibility: record the exact font files and hashes. A family name alone is not enough because different files can share a family name.
- Cost: failed PDF jobs still consume compute in your infrastructure. Eliminating retries with deterministic local assets can reduce wasted work even when the PDF tool itself has no per-document charge.
Or skip the browser setup
If your real requirement is a dependable screenshot or PDF capture rather than maintaining a wkhtmltopdf runtime, ScreenshotNeo provides a website screenshot API and MCP server. It accepts one GET request and returns PNG, JPEG, WebP, or PDF. Cookie and consent banners, newsletter popups, and chat widgets are removed before capture. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing status.
See the ScreenshotNeo documentation for the complete option set, including custom CSS and JavaScript, waits, headers, cookies, user agents, authorization, device presets, full-page capture, PDF settings, caching, async jobs, bulk capture, and signed links.
cURL
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://stripe.com \
-o shot.webp
Python
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
Node.js
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://stripe.com'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const image = Buffer.from(await res.arrayBuffer());
require('fs').writeFileSync('shot.webp', image);
An MCP server lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf. You get 1,000 screenshots each month on the free plan with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
FAQ
Should I use WOFF, WOFF2, or TTF?
Start diagnosis with a simple static TTF. Once that works, test the format you need. A modern browser accepting WOFF2 does not prove the wkhtmltopdf version in your image supports it.
Does --enable-local-file-access fix every font issue?
No. It only permits local-file reads. The font can still be missing, unreadable, incorrectly mapped, corrupt, unsupported, or blocked by another sandbox.
Why does fc-list show the font but the PDF does not?
Check that wkhtmltopdf runs as the same user with the same fontconfig environment, and test the CSS family, weight, style, and glyph coverage separately.
Can a CSS fallback hide a loading failure?
Yes. A fallback family can make the page look complete while the custom face never loaded. Use a distinctive test sentence and inspect each face and weight independently.
When should I stop debugging wkhtmltopdf?
After the URL, local policy, installation, cache, permissions, font validity, and minimal-face tests pass. At that point, an archived engine’s compatibility limits may make migration cheaper than further CSS changes.


