Documentation

Pro handbook

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/OSM and ol/source/XYZ already default to crossOrigin: '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 the fetch, and that tile never loads — a different symptom (missing tiles) of the same underlying problem, not something exportImage needs 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 CSS text-shadow the 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.

See it on a map.