How to Embed Images and Logos Reliably in Generated PDFs

Embedding images in PDFs sounds trivial until you hit broken renders, missing logos, and bloated file sizes. Here's how to do it right.

pdfimagesnode.jshtml-to-pdfkamy

How to Embed Images and Logos Reliably in Generated PDFs

Embedding an image in a PDF sounds like a solved problem. Drop an <img> tag in your HTML template, point it at a URL, render. Done. Except in practice, generated PDFs end up with broken image placeholders, logos that load on your laptop but disappear in production, watermarks rendered at the wrong DPI, and 12MB invoices that should be 200KB.

This post walks through the failure modes and the patterns that actually hold up in production.

Why images break in generated PDFs

Most PDF generators that accept HTML (Chromium headless, WeasyPrint, wkhtmltopdf, Kamy, etc.) work by loading a page in a sandboxed environment, waiting for resources, and printing to PDF. Image rendering breaks when:

  1. The PDF renderer can't reach the image URL. Your renderer runs in a container with restricted networking, or behind a different DNS, or the image is on an internal host.
  2. The image loads asynchronously after the print event fires. The renderer captures the page before the image decodes.
  3. The image URL requires authentication the renderer doesn't have.
  4. The image is huge and either times out or balloons the PDF file size.
  5. The format isn't supported. SVGs with external fonts, WebP in older renderers, AVIF almost anywhere.

Let's tackle each.

Strategy 1: Inline images as base64 data URIs

The single most reliable approach is to never make the renderer fetch the image at all. Convert the image to a base64 data URI and embed it directly in the HTML.

ts
import fs from "node:fs/promises"; import path from "node:path"; async function toDataUri(filePath: string): Promise<string> { const buf = await fs.readFile(filePath); const ext = path.extname(filePath).slice(1).toLowerCase(); const mime = ext === "svg" ? "svg+xml" : ext; return `data:image/${mime};base64,${buf.toString("base64")}`; } const logoDataUri = await toDataUri("./assets/logo.png"); const html = `<img src="${logoDataUri}" width="120" alt="Logo">`;

Pros: no network call from the renderer, no auth issues, no race conditions. Cons: every render carries the bytes again, which matters if you're sending HTML over the wire to a PDF service.

For static assets like logos, cache the data URI in memory at boot:

ts
const LOGO = await toDataUri("./assets/logo.png"); // reuse LOGO across every render

Strategy 2: Use a public, CDN-hosted URL

If your logo is genuinely public, host it behind a CDN with aggressive caching and reference it normally:

html
<img src="https://cdn.example.com/brand/logo-v3.png" width="120">

Two rules:

  • Version the filename. Don't rely on cache invalidation. logo-v3.png, not logo.png?v=3.
  • Make sure the renderer can reach the host. Test from inside the actual container/region where PDFs are generated.

Strategy 3: Pre-sign URLs for private assets

If the image lives in S3, GCS, or an authenticated CMS, generate a short-lived signed URL at render time:

ts
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3"; const s3 = new S3Client({ region: "us-east-1" }); const signedLogoUrl = await getSignedUrl( s3, new GetObjectCommand({ Bucket: "brand-assets", Key: "acme/logo.png" }), { expiresIn: 300 }, );

Pass that URL into the template instead of relying on the renderer to authenticate.

Wait for images before printing

If you control the renderer, make sure it waits for image decode before capturing. With Puppeteer:

ts
await page.setContent(html, { waitUntil: "networkidle0" }); await page.evaluate(() => Promise.all( Array.from(document.images).map((img) => img.complete ? Promise.resolve() : new Promise((r) => { img.onload = img.onerror = r; }), ), ), ); const pdf = await page.pdf({ format: "A4" });

If you're using a managed service like Kamy, this waiting logic is handled for you — the renderer blocks on image decode before producing the PDF, so a normal <img> tag just works:

ts
import { kamy } from "@kamydev/sdk"; const pdf = await kamy.documents.render({ template: "invoice", data: { logoUrl: signedLogoUrl, invoiceNumber: "INV-2025-001", }, });

Pick the right format and size

A 4000×4000 PNG logo will produce a multi-megabyte PDF even though it renders at 120px wide. Resize before embedding:

bash
# One-time preprocessing of brand assets magick logo-original.png -resize 480x480 -strip logo.png

Rules of thumb:

  • Logos and UI marks: PNG or SVG. SVG is best for sharp scaling but inline the SVG markup rather than referencing it — external font and image references inside SVGs are a common failure point.
  • Photos: JPEG at 72–150 DPI for screen-targeted PDFs, 300 DPI only for print.
  • Avoid WebP and AVIF unless you're sure your renderer supports them. PNG and JPEG are universally safe.

For inline SVGs, drop the markup directly into the H