Exporting the map view as an image
adapter.exportImage(): Promise<Blob> turns the current map view into a PNG image — the usual
use case is a site plan for a document. The same method exists on every adapter, with the same
behavior, so an application written against the Adapter interface doesn't need to know which map
library is active:
const blob = await adapter.exportImage();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'map.png';
a.click();
exportImage only builds the Blob; it never triggers a download itself (user decision
12.10.2026) — the application decides the file name and whether to offer it as a download at all.
The app bundled with this repository does exactly the above in its "Export image" button
(app/src/dokumentleiste.ts), named from the document's own name plus .png.
What's in the picture#
Everything the current Scene shows as content: the
background tiles (with the same CSS darkening the screen shows, see below), faces, edges,
free-standing points (both shapes — circle and pin), labels and the map's attribution line.
Selection, hover and preview states (a face mid-edit, a handle's target hovered) come through
exactly as shown on screen — this exports the current view, not a "clean" one.
Not included: handles, snap markers, control symbols and guide lines — those are editing UI,
not drawn geometry, the same distinction SceneFace/SceneEdge/ScenePoint/SceneLabel already
make against SceneHandle/SceneGuide. On OpenLayers and MapLibre, free-standing points and
guide lines share a rendering layer with handles; exportImage hides that whole layer, redraws the
map, reads the canvas and restores the layer's visibility, redrawing once more — the whole
sequence synchronous, with no await anywhere in it, specifically so the hidden state cannot be
observed or left behind: the browser cannot present a frame in the middle of synchronous
JavaScript execution, and while a second, overlapping exportImage() call may already be running,
its own hide–read–restore block cannot interleave with the first one's (there is no await inside
either block).
The points that layer would otherwise have contributed are redrawn from the Scene itself, after
the restore.
Resolution#
The image comes out at the screen's current resolution (devicePixelRatio), the same pixel
density the map is already rendering at — not a separate, higher resolution for print (user
decision 12.10.2026: screen resolution is enough for the first version, and simpler).
Why this needed its own drawing code, not just "read the canvas"#
The three renderers don't draw faces, edges, points and labels the same way:
| Faces / edges / circular points | Labels | Pin-shaped points | |
|---|---|---|---|
| Leaflet | SVG (overlayPane) |
HTML (DivIcon) |
HTML (DivIcon) |
| OpenLayers | its own canvas (ol/style) |
its own canvas (ol/style/Text) |
its own canvas (ol/style/Icon) |
| MapLibre | WebGL (native GL layers) | HTML (maplibregl.Marker) |
HTML (maplibregl.Marker) |
Only OpenLayers draws everything onto a canvas already — its exportImage composites OpenLayers'
own layer canvases (ol/renderer/Composite.js's own trick, reimplemented here since that part
isn't public API), hiding the griffe/hilfslinien layers for the frame as described above, and
redraws only the now-missing points from data. Leaflet's faces/edges/points are SVG and its labels
and pins are HTML — none of that is a bitmap to copy, so its exportImage redraws the entire scene
directly from data, with the same style functions (FLAECHE, KANTE, punktDarstellung,
beschriftungsStil, …) every renderer already uses, straight onto a plain Canvas 2D context
(packages/adapter/src/bildexport.ts). MapLibre's faces and edges are already on its one WebGL
canvas (native GL layers) and are copied as-is; circular points share a GL layer with the handles
and get hidden along with them, so — like OpenLayers — they're redrawn from data; labels and
pin-shaped points were always HTML markers, drawn from data regardless.
This is also why a label or a pin always appears in the exported image regardless of which map
library is active: they're drawn from the same Scene data on every adapter, never scraped from
whatever HTML happened to be on screen. The map's attribution line is the same case one level up:
a DOM control next to the map, never on any canvas or GL buffer, so each adapter reads its own
attribution control's current text and draws it into the bottom-right corner itself
(zeichneAttributionAufCanvas) — not optional, since most tile providers' terms (OpenStreetMap's
among them) require their attribution to survive into any reproduction of the map, a printed site
plan included. Only the attribution text itself, not the control's own collapse toggle: OL's
.ol-attribution element contains a <button> (an "i"/"›" glyph) before its <ul>, so reading
the list items on their own, not the whole element's textContent, is what keeps that glyph out
of the image.
The background tiles' own CSS filter travels into the image the same way. The app recolors its
tiles for the dark theme with a CSS filter on the tile layer's container (.dunkel in
thema.css), and drawImage/the WebGL pipeline don't apply CSS filters on their own — Leaflet and
OpenLayers therefore read the filter and set it as ctx.filter while drawing. Leaflet composes the
filters of every ancestor up to the map container (filter isn't an inherited CSS property, and
Leaflet's markup puts the class two levels above the tile <img>); an ancestor with no filter
contributes 'none'. OpenLayers reads only the filter of the tile layer's own container, which is
where the app sets it.
MapLibre never had this problem — it recolors its tiles in the raster shader itself
(karte-maplibre.ts), so the WebGL canvas it copies is already dark.
The background and CORS#
Drawing a cross-origin image without CORS permission onto a canvas (drawImage) doesn't throw —
it silently marks the canvas "tainted" (origin-clean = false). Only reading that canvas's
pixels afterward (toBlob/toDataURL/getImageData) throws a SecurityError, and that's exactly
where exportImage catches it, regardless of which adapter: it rejects with an ExportError
(code: 'adapter.exportCorsBlocked', message in English and German) instead of letting the raw
exception escape, which an application can show directly:
import { isCodedError, formatMessage } from '@vectorstream/core';
try {
const blob = await adapter.exportImage();
} catch (fehler) {
const text = isCodedError(fehler) ? formatMessage(fehler.code, fehler.params) : 'The export failed.';
}
A different failure to encode (a canvas of size 0, one too large, or an encoder error —
toBlob resolving with null rather than throwing) gets its own code, adapter.exportFailed:
not a CORS problem, and mapping it to the CORS message would mislead whoever reads it.
Whether the CORS case ever triggers depends on how the tile layer was built, not on the
adapter. A tile <img> loaded without the crossorigin attribute taints a canvas regardless of
what the tile server's own CORS headers say — the opt-in has to happen at fetch time:
- Leaflet:
L.tileLayer(url, { crossOrigin: true, … }). Off by default. - OpenLayers:
ol/source/OSMandol/source/XYZalready default tocrossOrigin: 'anonymous'. - MapLibre: fetches raster tiles itself via
fetch(), never through an<img crossorigin>— so its own tile loading never taints the canvas this way. It isn't immune to CORS, though: a tile server that doesn't grant it at all simply fails thefetch, and that tile never loads — a different symptom (missing tiles) of the same underlying problem, not somethingexportImageneeds to translate.
The app bundled with this repository uses OpenStreetMap's tile servers, which do send
Access-Control-Allow-Origin: *; its Leaflet map sets crossOrigin: true accordingly
(app/src/karte-leaflet.ts) so the export actually succeeds there instead of always failing. A
tile source that doesn't send that header would simply fail to load tiles at all with
crossOrigin turned on — which is exactly why this is the application's decision about its own
tile source, not something the adapter can fix on its own.
MapLibre and preserveDrawingBuffer#
Reading a WebGL canvas (toBlob/toDataURL/readPixels) only sees the current frame's pixels if
either preserveDrawingBuffer: true was set when the map's rendering context was created, or the
read happens before the browser presents that frame at all. The first costs something on every
frame for as long as the map lives, whether an export ever happens or not — the usual WebGL
caveat (disables buffer-swap optimizations some implementations rely on). exportImage avoids it
with map.redraw(), which forces a synchronous render (MapLibre calls its internal render
function directly, not via requestAnimationFrame the way the normal render loop or
triggerRepaint do) — reading map.getCanvas() right after redraw() returns, still in the same
synchronous block, is always safe: the browser cannot present a frame (and, per the WebGL
specification, clear the drawing buffer as part of presenting it) in the middle of synchronous
JavaScript execution, regardless of timing. The app's MapLibre map (app/src/karte-maplibre.ts)
therefore needs no special option at all, and exportImage needs no timeout either — there is no
asynchronous wait left in the render step that a hidden tab or an unloaded style could leave
hanging.
Known limitations#
- No rounded corners on a label's plate. The HTML/CSS version (
BESCHRIFTUNG_KASTEN.eckenradius) has one; the Canvas 2D version drawn here is a plain rectangle, the same simplification OpenLayers' own canvas-drawn labels already make. - The glow around a plate-less label is an approximation, via Canvas 2D's
shadowBlur— close to the CSStext-shadowthe HTML markers use, but not bit-identical. - Control symbols, snap markers and handles are never drawn, by design (see "What's in the picture" above) — if a screenshot of the editing UI itself is ever needed, this isn't it.