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:
- A remote URL (
https://cdn.example.com/logo.png) - A file path on disk (
/app/assets/logo.png) - 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:
tsimport { 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.
tsasync 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:
tsconst 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:
tsimport { 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