How to Embed Images and Logos Reliably in Generated PDFs
Embedding an image in a PDF sounds like a solved problem. You drop an <img> tag or a drawImage() call into your template, run it locally, and the logo appears. Then it ships to production and half the invoices come back with broken image icons, stretched artwork, or a corporate logo that looks like it was scanned in 1998.
This post covers the practical failure modes when embedding images and logos in programmatically generated PDFs, and how to avoid them.
The three ways images get into a PDF
Regardless of the tool you use (Puppeteer, wkhtmltopdf, PDFKit, Kamy, DocRaptor, etc.), images arrive in a PDF through one of three channels:
- Remote URL — the renderer fetches the image at render time.
- Local file path — the renderer reads from disk.
- Inline (base64 data URI) — the image bytes are embedded directly in the template.
Each has distinct reliability characteristics. Pick the wrong one and you'll pay for it later.
Why remote URLs break in production
The most common bug: a template references https://cdn.example.com/logo.png, works in staging, then fails in production because:
- The rendering worker runs in a VPC that can't reach the CDN.
- The CDN requires an auth header the renderer doesn't send.
- The renderer has a 2-second fetch timeout and the CDN cold-starts in 3.
- SSL on the CDN uses a cert chain the headless browser doesn't trust.
If you must use remote URLs, enforce a few rules:
ts// Validate images are reachable before rendering async function assertImageReachable(url: string) { const res = await fetch(url, { method: "HEAD" }); if (!res.ok) { throw new Error(`Image unreachable: ${url} (${res.status})`); } const type = res.headers.get("content-type") ?? ""; if (!type.startsWith("image/")) { throw new Error(`Not an image: ${url} (${type})`); } }
Run this check in your request pipeline, not inside the renderer. The renderer should assume images are valid.
Inline base64: boring but bulletproof
For logos, signatures, and anything else that rarely changes, inline the image as a data URI. There's no network call at render time, no timeout, no DNS issue.
tsimport fs from "node:fs/promises"; import path from "node:path"; async function toDataUri(filePath: string, mime: string) { const buf = await fs.readFile(filePath); return `data:${mime};base64,${buf.toString("base64")}`; } const logo = await toDataUri(path.join(__dirname, "logo.png"), "image/png");
Then in your HTML template:
html<img src="{{logo}}" alt="Company logo" width="160" />
The downside is template size. A 50 KB logo becomes ~68 KB of base64. For a logo, who cares. For a 2 MB hero banner on every page, you'll regret it.
Handling user-uploaded images
If users upload images (profile pictures, receipt scans, product shots), you have two realistic options:
- Pre-fetch and inline at request time. Download, validate, convert to base64, pass to the renderer.
- Serve from a trusted internal URL. Upload to your own object storage, then reference a signed URL that the renderer can reach.
Option 1 is slower per request but immune to runtime network issues. Option 2 scales better. Pick based on throughput requirements.
Here's a pre-fetch helper that enforces size and format limits:
tsasync function fetchImageAsDataUri(url: string, maxBytes = 2_000_000) { const res = await fetch(url); if (!res.ok) throw new Error(`Fetch failed: ${res.status}`); const type = res.headers.get("content-type") ?? ""; if (!/^image\/(png|jpe?g|webp|svg\+xml)$/.test(type)) { throw new Error(`Unsupported type: ${type}`); } const buf = Buffer.from(await res.arrayBuffer()); if (buf.length > maxBytes) throw new Error("Image too large"); return `data:${type};base64,${buf.toString("base64")}`; }
Vector logos: use SVG, but validate it
SVG logos look crisp at any zoom level and compress well. The catch: PDF renderers vary wildly in SVG support. Puppeteer-based renderers handle modern SVG well. Older WebKit-based tools choke on <foreignObject>, filters, and some gradients.
Rules of thumb:
- Flatten text to paths before embedding. Otherwise font substitution can break the layout.
- Strip
<script>tags and external references. - Avoid CSS-only features that depend on an external stylesheet.
A quick sanitize pass:
tsfunction sanitizeSvg(svg: string) { return svg .replace(/<script[\s\S]*?<\/script>/gi, "") .replace(/\son\w+="[^"]*"/gi, "") .replace(/xlink:href="https?:[^"]*"/gi, ""); }
Avoid stretched or blurry logos
Two template-level fixes catch 90% of visual bugs:
html<img src="{{logo}}" alt="Logo" style="max-width: 160px; height: auto; image-rendering: crisp-edges;" />
- Set an explicit max dimension. Never rely on the natural size of a bitmap.
- Supply logos at 2x the display size for retina-quality rend