QGIS2WebMap by NIKA Powered by QGIS

Architecture

The one rule that shapes everything: QGIS interpretation, manifest generation, and packaging must stay separate.

qgis2web fused them, and the cost is measurable — 7,481 lines of renderer-specific JavaScript-string generation across two parallel families (Leaflet and OpenLayers), against roughly 1,700 lines of renderer-neutral core. Every symbology fix has to be made twice, and where it wasn’t, output diverged: stroke width 0.988 survives in its OpenLayers path and rounds to 1.0 in its Leaflet path.

We avoid that with a normalized model in the middle and exactly one renderer.

PyQGIS objects
    │
    ▼  core/project_reader.py, core/layer_reader.py
ExportProject / ExportLayer / RendererSpec / PopupSpec / AssetDependency
    │
    ▼  core/fidelity_report.py
FidelityReport          ← every unsupported or approximated property, named
    │
    ▼  writers/onlymap_writer.py, core/manifest_builder.py
OnlyMap manifest        ← declarative <om-map>/<om-layer>/<om-widget> markup
    │
    ▼  packaging/artifact_builder.py, exporters/*
Standalone HTML  |  Share ZIP  |  Folder  |  Hosted

One place to test QGIS semantics, one place to build markup, one place to package.

Layer responsibilities

Package Owns Must not
core/ Reading the QGIS project into plain dataclasses; classifying fidelity Touch Qt widgets, produce markup, or write files
writers/ Normalized model → OnlyMap manifest markup Read Qgs* objects or decide where files go
exporters/ Destination — where an already-built artifact lands Re-interpret the project
packaging/ Embedding assets, scanning dependencies, runtime bytes Know about QGIS
ui/ The dialog Contain export logic
processing/ Processing algorithms, calling the same services as the dialog Be a second export implementation

core/export_ir.py is the boundary: it holds no QWidget and, after project reading completes, minimises live Qgs* references.

Pure versus QGIS-dependent modules

core/ splits again along a line worth stating, because it determines what CI can test. PyQGIS is unavailable on a GitHub runner, so anything importing qgis can only be exercised in tests/qgis/. Everything else runs in tests/unit/ on every push.

Pure — testable in CI Imports PyQGIS — tests/qgis/ only
export_ir.py — the dataclasses project_reader.py
extent_math.py — extent + antimeridian layer_reader.py
fidelity_report.py — the accumulator renderer_translator.py
license_policy.py — cap detection and policies labeling_translator.py, popup_translator.py
symbol_atlas.py — which icons, and where in the sheet symbol_rasterizer.py — drawing them

Three of these are not in issue #29’s file list and are deliberate additions:

The rule for new code: if it can be pure, make it pure. Push PyQGIS access to the edge so the logic underneath stays testable.

Why declarative markup, not generated JavaScript

OnlyMapJS is driven by HTML custom elements — <om-map>, <om-layer>, <om-widget>, <om-overlay>, <om-behavior>, <om-fallback>. So the writer emits attributes, not code. Consequences:

Do not create per-renderer string-generation modules. If a change feels like it needs one, the normalized model is missing a field.

Why the output is an HTML file

The goal is an artifact a recipient can open with zero setup — no install, no extraction, no server, no account. Working backwards from that, the browser is the only runtime already present on every machine that can render an interactive WebGL map, and a single .html file is the only thing a browser opens by double-click from a local disk.

Alternatives, and why each fails:

Option Why not
.html.gz Browsers do not transparently decompress a local file. Double-click fails
.mhtml / .webarchive Chromium-only in practice — Firefox dropped MHTML. Loses the universality that is the entire point
.zip Requires extraction. Correct for the Share ZIP tier, wrong as the default — and it is precisely the incumbent’s failure mode
Custom extension (.ommap) + viewer Requires installing a viewer. That is the problem we are solving
Electron / Tauri desktop app Produces a double-clickable binary, but: per-platform builds, 50 MB+, code signing, antivirus false positives, and a recipient must trust an executable. #29 forbids it in the standalone path, correctly
Self-extracting archive Same trust and platform problems as a binary
PDF / GeoPDF No WebGL. Layer toggling at best; not an interactive map
SVG No tiles, no WebGL, no data binding at scale
PWA Needs a server and a service worker. Not a file
Notebook (Jupyter / Observable) Needs a runtime the recipient does not have

So .html is not a compromise — it is the only format that satisfies the constraint. The differentiation is what the file contains: the whole runtime and all data inline, an <om-fallback> for previewers that run no JavaScript, and a manifest that is readable, declarative markup rather than generated JavaScript — which is what makes later AI-assisted editing tractable.

Honest limitations of the format

Recorded so they are designed around rather than discovered late:

Packaging

Measured on a real export: 5.45 MB uncompressed, 1.84 MB packed - a 3.0x reduction, entirely lossless.

The runtime is embedded as gzipped base64 and inflated in the browser by a small bootstrap using DecompressionStream. Verified working from file:// in Chromium; Firefox is checked at release verification.

Two rules that are easy to break:

  1. The stylesheet is never compressed. <om-fallback> is gated by a pure-CSS rule (om-map:not(:defined)), which is what shows a message instead of a blank frame in mail previews and iOS QuickLook. CSS that only arrives after a script has run cannot do that.
  2. The bootstrap inflates data before importing the runtime. Importing defines the custom elements, which immediately upgrades every <om-layer> and reads its inline data. Inflating afterwards would be too late.

Layer data is compressed only above a size threshold. Readable GeoJSON is what lets a person or an AI assistant edit the exported map, and that is worth more than a few hundred kilobytes on a small file.

Symbology fidelity

The renderer choice is settled and defensible on its own: the artifact is built from <om-map> / <om-layer> custom elements, so only OnlyMapJS renders it, and it brings 3D/WebGL capability that Leaflet and OpenLayers do not. Nothing needs to gate users into it.

What a QGIS user actually judges is whether their map still looks like their map. That is where the incumbent is weakest, and it is where our effort goes:

The fidelity report is the mechanism, and upstream has no equivalent.

Non-negotiables

These exist because the alternative was observed to fail in the incumbent:

  1. Never terminate QGIS. No os._exit, sys.exit, or QApplication.quit — enforced by CI.
  2. No dependency installers. Never shell out to a package manager — enforced by CI.
  3. Exports make no network requests beyond the runtime’s own telemetry. One anonymous usage report on load, no map-id, and no remote basemap unless explicitly chosen. The exporter stopped emitting telemetry="off" in 0.1.3; tests/browser/test_exported_map.py pins the permitted endpoint so a second one cannot appear unnoticed.
  4. Lossless by default. Gzip shrinks bytes without changing data. Coordinate quantisation and geometry simplification are opt-in and reported.
  5. Never write a silently broken artifact. The word doing the work is “silently”. Breakage the user has been shown and accepted is their call; breakage they discover when a client opens the file is ours.

    In practice that means two surfaces, not one:

    • At export time, the fidelity report names exactly what will not appear, and the dialog shows it before the Export button is pressed.
    • In the artifact, the map explains itself. Over a licence cap the writer sets validate on <om-map>, which mounts the runtime’s error panel, so a recipient sees why a layer is missing rather than an unexplained gap. A clean export carries no such panel.

    A genuinely unrecoverable condition — a required asset missing, nothing exportable at all — still fails with an actionable message.

  6. One writer serves preview and export. Preview cannot drift from output if there is one code path producing both.
  7. Refresh must not mutate settings. Derive the layer list from the project; do not cache a widget tree that then needs refreshing.

Background