How to Embed Images and Logos Reliably in Generated PDFs

Embedding images in generated PDFs sounds trivial until logos go missing, resolutions blur, or renders time out. Here's how to do it reliably across common PDF pipelines.

pdfimageslogosnode.jshtml-to-pdf

How to Embed Images and Logos Reliably in Generated PDFs

Embedding images in a PDF sounds like a solved problem. In practice, it's one of the top sources of bug reports in document pipelines: logos appear in staging but not production, images render pixelated on retina screens, remote image fetches time out, and SVG logos silently fall back to broken image icons. This post covers the practical patterns for embedding images and logos in generated PDFs that actually hold up under load.

The three ways images get into a PDF

No matter what tool you use, images reach the PDF via one of three paths:

  1. A URL the renderer fetches at generation time.
  2. A local file path on the rendering server.
  3. An inline data URI (base64) embedded directly in the HTML or template.

Each has tradeoffs that matter in production.

Remote URLs

Easiest to set up, worst for reliability. The renderer makes an HTTP request mid-generation, which means:

  • Any blip on the image host becomes a broken logo in your invoice.
  • CDN edge caches may serve different versions to your renderer than to users.
  • Headless browser renderers often have aggressive network timeouts (often 30s), and a slow image can abort the whole render.

If you must use URLs, enforce short timeouts and preload. Here's a pattern using Puppeteer that waits only for the logo, not every tracking pixel:

ts
await page.setContent(html, { waitUntil: "domcontentloaded" }); await page.waitForSelector("img.logo", { timeout: 5000 }); await page.waitForFunction( () => { const img = document.querySelector("img.logo") as HTMLImageElement; return img && img.complete && img.naturalWidth > 0; }, { timeout: 5000 } ); const pdf = await page.pdf({ format: "A4" });

Local files

Fastest and most reliable, but requires that every image your templates might reference is actually present on the rendering host. This works well for:

  • A fixed set of brand assets (your own logos, icons).
  • Containerized renderers where images are baked into the image at build time.

A simple Dockerfile fragment:

dockerfile
COPY ./assets/logos /app/assets/logos ENV ASSET_PATH=/app/assets/logos

Then in your template, reference file:///app/assets/logos/acme.png or use the renderer's asset resolver.

Inline data URIs

The most portable option: the image travels with the HTML. No network, no filesystem, no race conditions. The downside is payload size — a 200 KB logo repeated across every page bloats your template.

For small logos (ideally under 50 KB), inline is the right default:

ts
import { readFileSync } from "node:fs"; const logo = readFileSync("./assets/acme-logo.png"); const dataUri = `data:image/png;base64,${logo.toString("base64")}`; const html = ` <header> <img src="${dataUri}" alt="Acme" style="height: 48px;" /> </header> `;

Pick the right format

A few rules that cover 95% of cases:

  • SVG for logos. Vector-crisp at any zoom level, tiny file size, no DPI issues. Inline the SVG directly in your HTML — don't reference it as an <img src="file.svg"> unless your renderer explicitly supports it (some PDF engines don't rasterize referenced SVGs correctly).
  • PNG for screenshots, UI, and anything with transparency.
  • JPEG for photographs. Don't use JPEG for logos — the compression artifacts around sharp edges look awful.
  • Avoid WebP and AVIF. Headless Chromium handles them, but older PDF libraries like wkhtmltopdf and some native renderers don't.

Fix the resolution problem

PDFs are vector containers, but raster images inside them have a fixed pixel count. If you supply a 100×30 PNG and display it at 100×30 CSS pixels, it will look fine on screen but blurry when zoomed or printed.

Rule of thumb: provide images at 2x the display size, minimum.

html
<!-- Logo displays at 120x40, image is 240x80 --> <img src="[email protected]" style="width: 120px; height: 40px;" />

For print-quality PDFs, go to 3x. Even better: use SVG and skip this problem entirely.

Handling SVG logos correctly

Inline SVG is the gold standard for logos in generated PDFs:

ts
import { readFileSync } from "node:fs"; const svg = readFileSync("./assets/acme-logo.svg", "utf8"); const html = ` <header class="pdf-header"> <div class="logo">${svg}</div> </header> `;

Watch out for:

  • <style> tags inside SVGs leaking into the parent document. Scope them or inline attributes.
  • Fonts referenced inside SVGs. If your logo contains text, convert the text to paths before embedding.

A reliability checklist

Before shipping an image-heavy PDF template to production, verify:

  • Every image either loads from disk or is inlined. No live external fetches during render.
  • Logos are SVG (preferred) or at least 2x resolution PNG.
  • Image dimensions are explicitly set in CSS to prevent reflow.
  • A fallback path exists if an image fails (hidden element or placeholder).
  • Your renderer has a hard timeout for image loading.

Letting a service handle this

If you'd rather not run and babysit a headless browser pipeline, this is the sort of thing Kamy handles out of the box. You upload assets once, reference them in templates by key,