How to Embed Images and Logos Reliably in Generated PDFs

Embedding images in PDFs sounds trivial until a logo goes missing in production. Here's how to handle paths, formats, encoding, and sizing so your generated PDFs render correctly every time.

pdfimagesnode.jsbackenddocument-generation

How to Embed Images and Logos Reliably in Generated PDFs

Embedding an image in a generated PDF is one of those tasks that looks like a one-liner in the docs and turns into a two-day debugging session in production. A logo that renders perfectly on your laptop silently disappears from the invoice your customer downloads. A product photo shows up stretched, pixelated, or rotated 90 degrees. A base64 string works fine for small icons and blows up your memory when you batch-render 10,000 reports.

This post walks through the practical issues you'll hit when embedding images in PDFs and how to avoid them.

Choose the right source: URL, file, or base64

There are three common ways to supply an image to a PDF renderer:

  1. A remote URL (https://cdn.example.com/logo.png)
  2. A file path on disk (/app/assets/logo.png)
  3. An inline base64 data URI (data:image/png;base64,...)

Each has tradeoffs:

  • Remote URLs are convenient but introduce a network dependency at render time. If your CDN hiccups, your PDFs break. Worse, some renderers silently skip images that fail to load instead of raising an error.
  • File paths are fast and reliable but require the file to be present in the environment running the renderer. This matters in serverless or containerized setups.
  • Base64 data URIs eliminate network and filesystem concerns but inflate payload size by ~33% and make templates harder to read.

For logos and other static assets that rarely change, bundle them with the renderer or inline them as base64. For user-uploaded content, fetch and cache them with explicit error handling.

Here's a defensive helper for pulling a remote image into base64:

ts
import { Buffer } from "node:buffer"; async function fetchImageAsDataUri(url: string): Promise<string> { const res = await fetch(url, { signal: AbortSignal.timeout(5000) }); if (!res.ok) { throw new Error(`Failed to fetch image ${url}: ${res.status}`); } const contentType = res.headers.get("content-type") ?? "image/png"; const buf = Buffer.from(await res.arrayBuffer()); return `data:${contentType};base64,${buf.toString("base64")}`; }

Always set a timeout. A hanging image fetch will take down your render queue.

Use the right format

PDFs natively support JPEG, PNG, and (in modern renderers) WebP. Avoid SVG unless your renderer explicitly supports it — many PDF engines rasterize SVG inconsistently or not at all.

Rough guidelines:

  • Logos, icons, UI chrome: PNG with transparency
  • Photographs: JPEG at 80–85% quality
  • Diagrams: PNG, or convert SVG to PNG at 2x resolution before embedding

If your logo looks blurry, the issue is almost always resolution. A 100×30 logo displayed at the same pixel dimensions will look crisp on screen but fuzzy on a printed PDF. Supply images at roughly 2x the display size — a logo rendered at 200pt wide should start as a 400px-wide PNG.

Control dimensions explicitly

Never let the renderer guess the size. In HTML-based templates, always set both width and either height or max-width, and use object-fit to prevent distortion:

html
<img src="data:image/png;base64,..." width="180" height="60" style="object-fit: contain;" alt="Company logo" />

For PDF libraries that work in points rather than pixels, remember 1 point = 1/72 inch. A logo that's 200px at 96 DPI is roughly 150pt.

Handle missing images gracefully

In production you want three things: no silent failures, no broken PDFs, and clear logs when something goes wrong. Wrap image loading in a guard that either substitutes a placeholder or fails the whole render based on your business rules.

ts
async function resolveLogo(tenantId: string): Promise<string> { try { return await fetchImageAsDataUri(`https://cdn.example.com/logos/${tenantId}.png`); } catch (err) { console.warn(`Logo missing for tenant ${tenantId}, using default`, err); return DEFAULT_LOGO_DATA_URI; } }

For invoices and legal documents, a missing logo should probably fail the render. For dashboards and reports, a placeholder is usually fine.

Cache aggressively

If you render the same template thousands of times a day, don't re-fetch the same logo every time. Cache the resolved image buffer in memory or in a shared store:

ts
const logoCache = new Map<string, { data: string; expires: number }>(); async function cachedLogo(tenantId: string): Promise<string> { const cached = logoCache.get(tenantId); if (cached && cached.expires > Date.now()) return cached.data; const data = await resolveLogo(tenantId); logoCache.set(tenantId, { data, expires: Date.now() + 3600_000 }); return data; }

Using Kamy

If you're using Kamy, you can pass images through template variables and let the renderer handle fetching, encoding, and caching:

ts
import { kamy } from "@kamydev/sdk"; const pdf = await kamy.documents.render({ template: "invoice", data: { logoUrl: "https://cdn.example.com/logos/acme.png", invoiceNumber: "INV-00042", }, });

Kamy fetches the image once per render job, caches it across concurrent renders of the same template, and surfaces a structured error if the