SnapDOMGitHub★ 8K
How-To Recipe

Capture images without CORS headers

Map tiles, marker icons and CDN images often come from a server that sends no CORS headers. The page shows them, but no script can read them. Here is what a capture does with them and how to get their pixels back.

TL;DR

A capture never fails on an image it cannot read: the image keeps its box, shows a grey placeholder, and its URL lands in result.warnings. To get the real pixels, serve the image with CORS headers, or send the images that fail through a small proxy of your own with fallbackURL.

What a capture does with an image it cannot read

The capture resolves as usual. Each image SnapDOM could not read keeps its size and position and shows a grey box marked "img"; with placeholders: false the box stays empty. Every one of them is listed in result.warnings with its URL, and debug: true prints the same to the console.

import { snapdom } from '@zumer/snapdom'

const result = await snapdom(document.querySelector('#map'))
for (const w of result.warnings) console.log(w.code, w.message)
// image-fallback  image failed to inline, using placeholder: https://tiles.example.com/5/16/10.png

Why the page shows it and the capture cannot

SnapDOM renders the element as an SVG image, and an SVG image loads nothing from the network, so every image is read and embedded first. A browser displays an image from any site, but it hands the pixels to a script only when the image's server allows it with an Access-Control-Allow-Origin header. Without that header no script can read the image, SnapDOM included.

Fix it at the source

If you control the server, add the header to the image responses. Nothing else changes, in your code or in the capture:

Access-Control-Allow-Origin: *

Send the images that fail through your own proxy

When the images come from a server you do not control, put a proxy in between that fetches them and adds the header. Hand it to fallbackURL, which SnapDOM calls only for an <img> it could not read:

const PROXY = 'https://image-proxy.your-account.workers.dev/?url='

const result = await snapdom(map, {
  fallbackURL: ({ src }) => PROXY + encodeURIComponent(src)
})

useProxy: PROXY works too, but it sends every request to another origin through the proxy: images that already allow CORS, CSS backgrounds, stylesheets and font files. Use it when the images you cannot read are CSS backgrounds or SVG <image>, which fallbackURL does not cover. The proxy then has to accept your font hosts and their stylesheets and font files as well, or embedded fonts stop loading.

A proxy in 25 lines

This Cloudflare Worker does the job. It fetches only from the hosts you list, because an open proxy fetches anything for anyone, and it returns only images.

// A CORS proxy for the images SnapDOM cannot read, as a Cloudflare Worker.
// Only the hosts listed here: an open proxy fetches anything for anyone.
const HOSTS = ['tiles.example.com', 'icons.example.com']

export default {
  async fetch(request) {
    let target
    try { target = new URL(new URL(request.url).searchParams.get('url')) } catch {}
    if (request.method !== 'GET' || !target || !HOSTS.includes(target.hostname)) {
      return new Response('Not allowed', { status: 403 })
    }
    const upstream = await fetch(target)
    const type = upstream.headers.get('content-type') || ''
    if (!upstream.ok || !type.startsWith('image/')) {
      return new Response('Not an image', { status: 502 })
    }
    return new Response(upstream.body, {
      headers: {
        'content-type': type,
        'cache-control': 'public, max-age=86400',
        'access-control-allow-origin': '*'
      }
    })
  }
}

Create a Worker in the Cloudflare dashboard, paste the code, put your image hosts in HOSTS and deploy. Any server that adds the header does the same job.

Maps drawn on a canvas

OpenLayers paints its tiles into a <canvas>, and so does any code that calls drawImage. A canvas that drew an image from a server without CORS headers is tainted: the browser blocks every read of it, from any script. The capture still resolves, the canvas comes out blank, and result.warnings holds a canvas-tainted entry. Nothing can read that canvas afterwards, so the fix is to load the tiles with CORS in the first place, from a server or a proxy that sends the header:

import TileLayer from 'ol/layer/Tile'
import XYZ from 'ol/source/XYZ'

new TileLayer({
  source: new XYZ({
    // a tile URL without a query string of its own can follow ?url= as it is
    url: PROXY + 'https://tiles.example.com/{z}/{x}/{y}.png',
    crossOrigin: 'anonymous'
  })
})

In your own canvas code, set img.crossOrigin = 'anonymous' before img.src.

Frequently asked questions

Why does the image show on the page but not in the capture?

The browser displays images from any site, but lets a script read one only when its server sends Access-Control-Allow-Origin. SnapDOM has to read an image to embed it.

Do I need a proxy if my images already send CORS headers?

No. SnapDOM fetches them directly.

Can SnapDOM capture a tainted canvas?

No script can read a tainted canvas. Load the images it draws with crossOrigin from a server or proxy that allows it, and the canvas captures normally.

Capture, modify and convert any element

Install SnapDOM and export the DOM to PNG, SVG, PDF or any other format.

Open the demoInstallation