·5 min read

How to embed images, logos, and custom fonts in a generated PDF

For images under roughly 200KB (logos, icons, small photos), embed them as base64 data URIs — it removes network round-trips and any chance the renderer starts painting before the asset arrives. For larger photos, use hosted HTTPS URLs so your HTML payload stays manageable. For fonts, base64-encode a @font-face src or bundle a local font file — Chromium has to finish loading the font before it paints text, and a slow or blocked font fetch will silently fall back to a system font instead of erroring.

Two ways to get an asset into the page

A headless-Chromium PDF renderer like the one behind Docweave paints your HTML exactly like a browser would, which means image and font handling follows normal web rules — with one twist: there's no user sitting there waiting for a spinner to finish. If an image or font hasn't loaded by the time the page is considered "ready," it just won't be there when the PDF is captured. That's the root cause of almost every embedding bug, so the fix is always some version of "make sure the asset is available before paint."

You have two options for any image or font: inline it as a base64 data URI, or reference it by URL and let Chromium fetch it. Both work. The right choice depends on size and how much control you have over the render pipeline.

Data URIs: the safe default for small assets

A data URI embeds the file's bytes directly in the HTML, so there's no network request at render time at all:

<img
  src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB..."
  alt="Company logo"
  style="height: 40px; object-fit: contain;"
/>

This is the right call for logos, icons, signature images, and QR codes — anything under roughly 200KB. Base64 inflates file size by about 33%, so a 150KB PNG becomes ~200KB of HTML, which is still trivial next to a full page of text. The upside is real: no CORS headers to configure, no SSRF guard to reason about for that asset, and no race condition where the network is slow and the browser prints before the image lands.

For anything generated server-side — a user's uploaded logo, a chart you rendered to PNG, a signature captured from a canvas — encoding to base64 before you build the HTML is usually one line:

import { readFile } from "node:fs/promises";

const logoBuffer = await readFile("./logo.png");
const logoDataUri = `data:image/png;base64,${logoBuffer.toString("base64")}`;

const html = `
  <img src="${logoDataUri}" alt="Logo" style="height:40px" />
`;

Hosted images: fine for large or reused assets

For product photos, large hero images, or anything you're reusing across many documents (say, a catalog image referenced in hundreds of quotes), a hosted URL is more practical than re-embedding the same bytes every time:

<img
  src="https://assets.example.com/products/sku-4471.jpg"
  alt="Product photo"
  width="400"
  height="300"
/>

The trade-off is that the render now depends on a live fetch: the host has to resolve, respond fast, and serve a Content-Type the browser recognizes. If you control the render pipeline yourself, this is also exactly the kind of request an SSRF guard should be scrutinizing — a URL-to-PDF or HTML-to-PDF service that fetches arbitrary image URLs on your behalf is a textbook SSRF surface, which is why Docweave's renderer validates and restricts outbound fetches rather than trusting whatever URL shows up in the HTML.

Practical rule of thumb: data URI under ~200KB, hosted URL above that, and always set explicit width/height (or aspect-ratio in CSS) so the layout doesn't shift while the image is loading.

Custom fonts: the part people actually get wrong

Fonts fail more often than images because the failure is silent — you don't get an error, you get Times New Roman. The most reliable pattern is a base64-encoded @font-face, same logic as images:

@font-face {
  font-family: "Inter";
  src: url(data:font/woff2;base64,d09GMgABAAAAAA...) format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: block;
}

body {
  font-family: "Inter", -apple-system, sans-serif;
}

A few things matter here:

  • Use font-display: block, not swap. swap is a good default on the live web because it shows fallback text immediately rather than making a real visitor wait — but a PDF render is a one-shot paint, and you'd rather it wait an extra 50ms for the real font than lock in the fallback font permanently.
  • Stick to WOFF2 — it's smaller than TTF/OTF for the same glyph coverage, and every Chromium version used for PDF rendering supports it.
  • Subset the font if you can (most tools only ever print Latin glyphs) — a subsetted WOFF2 is often 20-40KB, trivial to inline.
  • Double-check the font-family name matches exactly, including case. A typo here doesn't error, it just silently falls back.

Hosted web fonts (a Google Fonts <link>, a self-hosted CDN file) work too, and are simpler to author, but they add a network dependency to every render and can behave differently under an SSRF guard that restricts outbound requests to a known allowlist. If you need pixel-perfect brand fonts on every single document — invoices, certificates, contracts — base64 embedding removes that variable entirely.

Print-specific gotchas

  • **Background colors and images on branded headers** — Chromium's print CSS strips backgrounds by default. Add -webkit-print-color-adjust: exact; print-color-adjust: exact; to any element whose background color matters (a logo's background band, a colored table header).
  • **Blurry logos** — export logos at 2x the display resolution (or use SVG, which is resolution-independent and usually smaller than a PNG for simple marks). A 40px-tall logo raster export should be at least 80px tall.
  • **Fonts loading after paint** — this is the #1 cause of "it worked in my browser preview but not in the PDF." A renderer that only waits for DOMContentLoaded can print before a slow font fetch resolves. Docweave's Chromium engine waits for network idle and font loading to settle before capturing the page, but embedding fonts as base64 removes the dependency on that timing altogether.
  • **SVGs with external references** — an SVG that references an external stylesheet or xlink:href image will hit the same loading-order problem images do. Inline SVGs (<svg>...</svg> directly in the HTML) sidestep it completely.

Putting it together: a branded invoice

A typical invoice template embeds a small company logo and a brand font, both as base64, then sends the HTML to Docweave's /pdf endpoint with Accept: application/pdf so the response comes back as raw PDF bytes rather than JSON:

curl -X POST https://docweave.dev/api/v1/pdf \
  -H "Authorization: Bearer dw_live_your_key" \
  -H "Content-Type: application/json" \
  -H "Accept: application/pdf" \
  -d '{
    "source": { "type": "html", "html": "<html>...</html>" },
    "idempotencyKey": "invoice-1042"
  }' \
  --output invoice.pdf

The same call from Node, saving the response body correctly as binary — see generating PDFs in Node for the full walkthrough:

const res = await fetch("https://docweave.dev/api/v1/pdf", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.DOCWEAVE_API_KEY}`,
    "Content-Type": "application/json",
    Accept: "application/pdf",
  },
  body: JSON.stringify({
    source: { type: "html", html },
    idempotencyKey: "invoice-1042",
  }),
});

const buffer = Buffer.from(await res.arrayBuffer());
await fs.writeFile("invoice.pdf", buffer);

Or, if you're wiring this into an AI agent via MCP instead of raw REST — relevant if you're building PDF generation for AI agents — the same HTML goes through generate_pdf:

await generate_pdf({
  source: { type: "html", html },
  outputPath: "./invoice.pdf",
});

Either path, the embedding rules are identical because it's the same Chromium-based renderer underneath: base64 the logo and font, set explicit dimensions, and add print-color-adjust: exact if your header has a background color.

FAQ

Should I always use base64 data URIs for images in a generated PDF?

For small assets — logos, icons, signatures, QR codes, generally under ~200KB — yes, because it removes the network fetch as a source of render failures entirely. For large photos or assets reused across many documents, a hosted HTTPS URL is more practical; just make sure the host responds quickly and the URL isn't pointing at something an SSRF guard would (correctly) refuse to fetch.

Why does my custom font sometimes render as Times New Roman or Arial in the PDF?

Almost always one of three things: the font hadn't finished loading before the page was captured, the font-family name has a typo or case mismatch, or a hosted font URL was blocked or timed out. Base64-encoding the font into a @font-face src with font-display: block eliminates the timing issue, since there's no separate fetch to race against.

What image formats can I use?

Anything Chromium's <img> tag supports natively: PNG, JPEG, WebP, GIF, and SVG. SVG is worth defaulting to for logos and icons — it's resolution-independent, usually smaller than a PNG, and can be inlined directly as markup rather than base64-encoded.

Can I use Google Fonts or another font CDN instead of embedding the font myself?

Yes, a standard <link> to a font CDN works the same way it would in a browser. It's simpler to author but adds a live network dependency to every render, which is one more thing that can be slow or fail. For documents where the brand font is non-negotiable on every single page — contracts, certificates — base64 embedding is the more deterministic choice.

Related

Generate your first PDF in minutes.

Get an API key