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, sizes, and rendering environments.

pdfimageslogosnode.jshtml-to-pdf

How to Embed Images and Logos Reliably in Generated PDFs

Embedding a logo in a generated PDF seems like a five-minute task. Then one of your invoices renders without a header because the CDN hiccuped, or the logo is 2x too small on Retina output, or the PDF balloons to 14 MB because someone dropped in a 6000×6000 PNG. Images are the single most common source of "the PDF looks broken in production" bug reports.

This post walks through the practical rules for embedding images and logos reliably in generated PDFs, whether you're using a headless browser, a PDF library like pdf-lib, or an API like Kamy.

Pick the right source: inline, not remote

The first rule: don't rely on remote image URLs at render time if you can avoid it.

If your PDF template does something like this:

html
<img src="https://cdn.example.com/logos/acme.png" />

…then every PDF render performs a network fetch. That fetch can fail, time out, or hit rate limits. Worse, if the renderer runs in a sandboxed environment (common for serverless PDF services), outbound HTTP may be blocked entirely.

The fix is to inline the image as a base64 data URI:

ts
import fs from "node:fs/promises"; const buf = await fs.readFile("./assets/logo.png"); const dataUri = `data:image/png;base64,${buf.toString("base64")}`; const html = `<img src="${dataUri}" alt="Logo" />`;

Inlined images render deterministically. No DNS, no TLS, no cache quirks.

Choose the right format

For logos and UI chrome, use SVG or PNG. For photos, use JPEG.

  • SVG scales to any resolution with zero file-size cost. Perfect for logos.
  • PNG supports transparency and lossless compression. Good for logos with raster effects.
  • JPEG is small and fine for photographs, but has no transparency and will look terrible on flat-color logos.
  • WebP and AVIF are hit-or-miss depending on the renderer. Chrome-based engines handle them; many PDF libraries do not. Avoid them for portable templates.

A quick check for your logo asset:

bash
file ./assets/logo.png identify ./assets/logo.png

If identify reports anything over ~1000px on the long edge for a logo that renders at 150px, you're shipping dead weight.

Size images for their rendered dimensions

A common mistake: dropping a 4000×4000 PNG into a template that renders it at 120×120. The PDF embeds the full-resolution image, bloating the file.

Pre-resize assets to roughly 2x their rendered size (for crisp output on high-DPI readers), then compress:

bash
# Resize to 300px wide at 2x density magick logo-original.png -resize 300x logo.png # Lossless PNG compression pngquant --quality=80-95 --output logo.min.png logo.png

For JPEGs:

bash
magick photo.jpg -resize 1600x -quality 82 photo.min.jpg

A 50 KB logo will render identically to a 2 MB logo for most practical purposes.

Fix the rendering: dimensions, DPI, and aspect ratio

In HTML-to-PDF pipelines, always set explicit dimensions on <img> tags. Without them, the renderer may guess based on the raw pixel size and produce comically large or small results.

html
<img src="data:image/png;base64,..." width="160" height="40" style="display: block;" />

Use display: block; to avoid baseline-alignment gaps. Set object-fit: contain; if you're forcing a logo into a fixed container:

html
<div style="width: 180px; height: 60px;"> <img src="..." style="width: 100%; height: 100%; object-fit: contain;" /> </div>

For pdf-lib or similar lower-level APIs, embed and draw explicitly:

ts
import { PDFDocument } from "pdf-lib"; import fs from "node:fs/promises"; const pdfDoc = await PDFDocument.create(); const page = pdfDoc.addPage([612, 792]); const logoBytes = await fs.readFile("./assets/logo.png"); const logo = await pdfDoc.embedPng(logoBytes); const scaled = logo.scale(0.25); page.drawImage(logo, { x: 50, y: 720, width: scaled.width, height: scaled.height, });

Always compute dimensions from the embedded image. Don't hardcode and hope the aspect ratio matches.

Cache and version your assets

If your logo lives in object storage, fetch it once at build time or app startup and bake it into the template (or into memory):

ts
const LOGO_CACHE = new Map<string, string>(); async function getLogo(key: string): Promise<string> { if (LOGO_CACHE.has(key)) return LOGO_CACHE.get(key)!; const buf = await s3.getObject({ Bucket: "assets", Key: key }); const uri = `data:image/png;base64,${buf.Body.toString("base64")}`; LOGO_CACHE.set(key, uri); return uri; }

Version the asset keys (logo-v3.png) so you can invalidate without cache-busting the whole system.

Using Kamy

If you're using Kamy, assets uploaded to your workspace are served from an edge cache with signed URLs that work inside the renderer sandbox. You reference them by handle rather than URL:

ts
import { kamy } from "@kamydev/sdk