How to Embed Images and Logos Reliably in Generated PDFs

Embedding logos and images in generated PDFs sounds trivial until a hotlinked asset 404s in production. Here's how to do it reliably across formats, resolutions, and rendering engines.

pdfimagesnode.jshtml-to-pdfkamy

How to Embed Images and Logos Reliably in Generated PDFs

Embedding an image in a PDF sounds like a one-liner. In practice, it's where most document pipelines quietly break: a logo loads fine locally, then renders as a broken-image icon in production. A customer's receipt goes out with a missing header. A signature appears pixelated on retina displays but fine everywhere else.

This post walks through the practical rules for embedding images and logos in programmatically generated PDFs — formats, sizing, encoding, caching, and the failure modes you should design against.

The three ways to reference an image

When your PDF generator processes HTML (or a template), images can be referenced in three ways:

  1. Remote URL — <img src="https://cdn.example.com/logo.png">
  2. Local file path — <img src="/var/assets/logo.png">
  3. Inline data URI — <img src="data:image/png;base64,iVBORw0KGgo...">

Each has tradeoffs. Remote URLs are convenient but introduce a network dependency at render time. Local paths are fast but tie your rendering to a specific filesystem. Data URIs always work but bloat your HTML.

For anything that absolutely must appear — logos, signatures, legal marks — prefer inline data URIs or pre-bundled local assets. Save remote URLs for user-generated content where you've accepted the tradeoff.

Picking the right format

For PDF embedding, stick to formats the renderer handles natively:

  • PNG — Best for logos, UI elements, and anything with transparency.
  • JPEG — Best for photographs. Smaller files, no transparency.
  • SVG — Best for logos if your renderer supports it (most Chromium-based engines do).

Avoid WebP and AVIF unless you've verified your renderer supports them. HEIC is a non-starter. If users upload arbitrary formats, normalize them server-side before passing them to the PDF engine:

ts
import sharp from "sharp"; async function normalize(input: Buffer): Promise<Buffer> { return sharp(input) .rotate() // honor EXIF orientation .toFormat("png") .toBuffer(); }

That .rotate() call matters — phones routinely upload JPEGs with EXIF orientation flags that PDF renderers ignore, producing sideways images.

Resolution: design for 2x

PDFs are vector containers, but embedded raster images are still raster. If your logo appears at 200×50 px in the final document, embed a 400×100 px asset. Otherwise it will look soft when the PDF is viewed at 100% zoom or printed.

A safe rule: embed images at 2x the display size, then let CSS scale them down:

html
<img src="logo.png" style="width: 200px; height: 50px;" />

The source file here should be 400×100. Don't go higher than 3x — you're just inflating file size for no visible gain.

Making remote images reliable

If you must load images over HTTP at render time, assume the network will fail. A few defensive patterns:

Pre-fetch and inline before rendering. Instead of letting the PDF engine fetch the image, fetch it yourself, convert to a data URI, and inject it into the template:

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

Now you can retry, cache, fall back to a default, or fail loudly before you ever invoke the renderer. If the fetch fails, you still have the option to substitute a placeholder instead of producing a broken document.

Cache aggressively. Logos change rarely. Cache fetched assets by URL + ETag for at least an hour — ideally longer. Every document render that re-downloads the same 40 KB logo is a small waste that scales badly.

Using Kamy

If you're using Kamy, image handling is built around these same principles. You can pass images as part of your template data, and the renderer will inline them automatically:

ts
import { kamy } from "@kamydev/sdk"; const pdf = await kamy.documents.render({ template: "invoice", data: { company: { name: "Acme Corp", logo: "https://cdn.acme.com/[email protected]", }, invoiceNumber: "INV-2041", }, });

Kamy fetches and caches the logo on the first render, so subsequent documents using the same URL skip the network round-trip. If the fetch fails, the render fails fast with a structured error instead of silently producing a broken PDF — which is almost always what you want.

For truly critical assets, upload them once and reference them by ID:

bash
curl -X POST https://api.kamy.dev/v1/assets \ -H "Authorization: Bearer $KAMY_API_KEY" \ -F "file=@[email protected]"

The response gives you an asset handle you can reference in templates without any external HTTP dependency at render time.

Common failure modes to test for

Before shipping, generate test PDFs that exercise these cases:

  • Image URL returns 404
  • Image URL returns 200 but with wrong Content-Type
  • Image file is corrupt or truncated
  • Image is enormous (10 MB+)
  • Image has unusual EXIF orientation
  • Image is referenced over HTTP (not HTTPS) from an HTT