Skip to content
SnapDOMGitHub★ 8K
How-To Recipe

Export a PDF with cross-origin images

Fix missing CDN images and map tiles in a PDF: inspect capture warnings, check CSP and CORS, or configure a proxy you control.

TL;DR

With the default placeholders, an unreadable <img> keeps its box, shows a grey placeholder and adds image-fallback to result.warnings. Read the warnings before you call toPdf(). Then let the fetch through your Content Security Policy (connect-src, not img-src), add CORS headers to the images you serve, and send the rest through a proxy of your own with fallbackURL or useProxy. Both options reach the captures toPdf() runs.

Check result.warnings first

The capture you hold already knows what it could not read. The PDF pages are drawn from that capture, so a grey box in the result is a grey box in the PDF. Log the warnings before you export:

import { snapdom } from '@zumer/snapdom'
import { pdf } from '@zumer/snapdom-pdf'

const result = await snapdom(document.querySelector('#report'), { plugins: [pdf()] })
for (const w of result.warnings) console.log(w.code, w.message)
// image-fallback  image failed to inline, using placeholder: https://cdn.example.com/avatars/42.png

const blob = await result.toPdf({ page: 'a4' })

Each entry includes code and message, and debug: true prints the same lines to the console. Keep placeholders on while you look: with placeholders: false the box stays empty and nothing is recorded. A CSS background or an SVG <image> the capture cannot read is dropped without a warning.

Your own CSP: connect-src, not img-src

SnapDOM reads every image it embeds with fetch(), and a Content Security Policy governs that read through connect-src. img-src only decides what the page may display. A policy that lists your CDN under img-src and not under connect-src shows the avatars on the page and exports grey boxes, with no CORS problem anywhere. The same rule covers the proxy origin from the section below, and the stylesheet and font hosts when fonts are embedded (embedFonts, 'auto' by default, fetches the webfonts the element uses).

Content-Security-Policy:
  img-src 'self' https://cdn.example.com;
  connect-src 'self' https://cdn.example.com https://image-proxy.example.com https://fonts.googleapis.com https://fonts.gstatic.com

List the hosts. connect-src * makes the images load, and it also lets any script on the page talk to any server, which is what the policy was there to prevent.

Fix it at the source

If you control the image server, add the header to the image responses and nothing else changes, in your code or in the export. Your own origin needs nothing: a same-origin image is read directly, cookies included.

Access-Control-Allow-Origin: *

A proxy for hosts you do not control

When the images come from a server you cannot change, put a proxy of your own in between that fetches them and adds the header. The CORS guide includes an image proxy, a Cloudflare Worker with a host allowlist; any server that adds the header does the same job. Then hand it to the capture:

const PROXY = 'https://image-proxy.example.com/?url='

const result = await snapdom(report, {
  plugins: [pdf()],
  // only the <img> the capture could not read, fetched again through the proxy
  fallbackURL: ({ src }) => PROXY + encodeURIComponent(src)
})
const blob = await result.toPdf({ page: 'a4' })

fallbackURL is called only for an <img> SnapDOM could not read. useProxy: PROXY instead sends every cross-origin request through the proxy: images that already allow CORS, CSS backgrounds, SVG <image>, stylesheets and font files. Use it when the missing images are backgrounds or SVG <image>, which fallbackURL does not cover, and let the proxy accept your font hosts too. It takes a base ending in ?url= or /, or a template with {url}; any other base gets ?url= appended.

Both are capture options: set them on snapdom(), not on toPdf(). The pages come from that capture, and the elements the export captures on its own (an element header or footer, a watermark element, cover and back pages) get the same useProxy, fallbackURL, placeholders and exclude. Add the proxy origin to connect-src, and note that the proxied request carries no cookies: the proxy has to fetch the image on its own.

Large PDFs

Each image URL is fetched once per capture: identical requests in flight share one response, a failure is remembered for eight seconds so a broken URL is not retried per node, and the data URL stays in a cache that later captures reuse. Images are fetched six at a time and each request has 3000 ms. A proxy that answers in two seconds adds about two seconds per six uncached images; one that does not answer in time gives a placeholder and an image-fallback warning, the same as a CORS failure. Give the proxy a cache (the Worker in the CORS guide sets cache-control: public, max-age=86400), and when result.warnings is not empty, show the warnings before you offer the download.

Frequently asked questions

The image shows on the page but not in the PDF. Why?

The browser displays an image from any host; a script may read one only when the server sends Access-Control-Allow-Origin and the page's Content Security Policy lets fetch() reach it through connect-src. SnapDOM has to read an image to embed it, and result.warnings names the one it could not.

My avatars come from a CDN that needs the user's cookies.

SnapDOM sends cookies only to your own origin: a same-origin image is fetched with credentials: 'include', everything else with 'omit', and no option changes that. A host that needs a cookie answers with a login page or a 401 and the avatar becomes a placeholder. Serve the avatars from your origin, or through a proxy that fetches them with a credential of its own.

Can I skip the images entirely?

exclude: 'img' leaves them out of the capture; an invisible spacer keeps the layout, and excludeMode: 'remove' drops the box as well. placeholders: false keeps the box of an image that failed and leaves it empty, without a warning. Both reach the captures toPdf() runs.