Skip to contents

Overview

A GeoPackage can carry its own styling in a layer_styles table, so a layer arrives with the right cartography wherever it is used without a project — QGIS’s Add Layer, QGIS Server and QWC2, rfp_map_render(), any OGR consumer. That is what makes a project’s background layers portable.

This provisions a layer; it does not render one. QGIS queries layer_styles when a layer is added to a project, then stores the resulting style in the .qgs. On project open it draws from that stored style and never re-queries the data — so a layer with no style block in the project gets a default symbol, not a lookup. Confirmed on QGIS desktop and on Mergin Maps mobile, which is QGIS core and behaves identically. The practical consequence: styling the data is how a layer gets its cartography into a project, and the saved .qgs remains authoritative for what that project draws.

Projects style their own data. rfp_project_create() and rfp_project_update() write layer_styles into the background GeoPackages from the project’s .qgs, so you do not normally call anything below by hand — a layer added to another project, or re-added after a refresh, arrives styled. Styles are lifted after the layer set is reconciled, so a project only carries styles for layers it actually holds.

A layer the .qgs does not reference gets no style. Adding it in QGIS Desktop and re-running rfp_project_update() supplies one.

The rest of this vignette is the machinery underneath, and what to reach for when styling a project someone else built.

rfp fills that table from a QGIS project you already have:

.qgs project → per-layer QML → layer_styles table → QGIS auto-loads

This is a copy, not a translation, and it runs in pure R.

Styling from a project

library(rfp)

rfp_styles_apply(
  "~/Projects/gis/restoration_wedzin_kwa/background_layers",
  qgs = "~/Projects/gis/restoration_wedzin_kwa/restoration_wedzin_kwa.qgs"
)
#> ✔ Applied 52 styles to 21 files

The project file is read only. Nothing here re-saves a .qgs, so this is safe to run against a project saved by a newer QGIS than any headless build you have — see vignette("project-creation") on why that matters.

Why a QML copy rather than a rebuild

A .qml is a QGIS project’s <maplayer> node minus its source binding — the data location, id and CRS. Everything else, symbology and labelling and form configuration alike, is the same XML in both files. So lifting a style out of a project is an XML copy.

You can see that on the bundled fixture, whose ground-truth .qml was saved by QGIS Desktop from the same project:

library(rfp)
#> 
#>  'You have to be comfortable with being uncomfortable if you're going to grow.' - Killer Mike

qgs <- system.file("testdata", "styles_fixture.qgs", package = "rfp")
truth <- system.file(
  "testdata", "styles_fixture_form_exceedance.qml", package = "rfp"
)

styles <- rfp_qgs_style_export(
  qgs, layers = "form_exceedance", path_out = tempfile()
)
#>  Exported 1 style to: /tmp/RtmpI8y0MU/file242e56c78b2a

lifted <- xml2::read_xml(styles$path_qml)
saved <- xml2::read_xml(truth)

# same elements, in the same order
identical(
  xml2::xml_name(xml2::xml_children(lifted)),
  xml2::xml_name(xml2::xml_children(saved))
)
#> [1] TRUE

The alternative — rebuilding symbols from the gq cartographic registry — cannot match that, and the reason is structural rather than a matter of effort. gq’s registry is extracted from QGIS projects to drive tmap and mapgl, so it models what those backends can render: one symbol layer, roughly twenty properties, no labelling. Feeding it back into QGIS is a lossy round-trip. Against the project above it drops per-class dash on 56 of 163 classes, labelling on all 24 layers that carry it, and every symbol layer past the first.

Carrying the QML keeps all of it:

roads <- rfp_qgs_style_export(qgs, layers = "roads_dra", path_out = tempfile())
#>  Exported 1 style to: /tmp/RtmpI8y0MU/file242e53635752
doc <- xml2::read_xml(roads$path_qml)

# 8 categories, dashed and dotted road classes, and labelling
length(xml2::xml_find_all(doc, ".//category"))
#> [1] 8
unique(xml2::xml_attr(
  xml2::xml_find_all(doc, ".//Option[@name='line_style'][@value!='solid']"),
  "value"
))
#> [1] "dash" "dot"
length(xml2::xml_find_all(doc, "./labeling")) > 0
#> [1] TRUE

Where styles are stored

rfp_styles_apply() writes to whatever the target supports, and searches directories recursively so the one-GeoPackage-per-layer layout works:

Target How styles are stored
GeoPackage / SQLite layer_styles table inside the file
GeoJSON, Shapefile, KML .qml sidecar beside the data
Directory Each file styled in place, recursively

A table may carry several styles, distinguished by styleName — a project that styles one table two ways keeps both. Exactly one is flagged useAsDefault, and that is the one QGIS opens with; re-applying demotes the previous default rather than leaving two to compete.

Matching layers to tables

The target table comes from the QGIS layer’s own datasource, so both of these resolve without configuration:

  • ./background_layers.gpkg|layername=whse_basemapping.transport_line
  • dbname='x' table="bcfishpass"."crossings_vw" (geom)

When a layer should land somewhere else, redirect it:

rfp_styles_apply(
  "background_layers.gpkg",
  qgs = "project.qgs",
  xref_layers = c("Roads - DRA" = "whse_basemapping.transport_line")
)

Names are layer names in the project; values are table names in your data. layers = selects which project layers to take, and both arguments name the layer as the project knows it.

Reusing exported QML

rfp_qgs_style_export() is also useful on its own — to keep a directory of styles under version control, or to apply one project’s cartography to another:

styles <- rfp_qgs_style_export("project.qgs", path_out = "styles/")
styles
#> # A tibble: 147 × 3
#>   layer            table            path_qml
#>   <chr>            <chr>            <chr>
#> 1 form_exceedance  form_exceedance  styles/form_exceedance.qml
#> …

# feed it straight back in, here or in another project
rfp_styles_apply("other_project/background_layers", qml = styles)

Passing a directory instead of the tibble works too; there the file stem names the target table.

Styling from the gq registry

For a layer that no project styles, the registry path is still available. It rebuilds symbols with PyQGIS inside the qgis/qgis Docker container:

rfp_styles_apply("background_layers.gpkg", registry = TRUE)
#> ! Styling from the gq registry is approximate - it carries one symbol layer
#>   and no labelling. Pass `qgs =` for a lossless copy of a project's styles.
#> Registry fields dropped by this path (style from a .qgs to keep them):
#>   class.dash: 56
#>   layer.label: 24
#>   class.casing_color: 8

It reports what it dropped rather than reporting success and quietly leaving it out, and it skips renderers it cannot rebuild — a rule-based renderer is left alone instead of being overwritten with a default symbol. Which fields are handled, and which are deliberately ignored and why, is recorded in inst/pyqgis/registry_fields.json; a test walks gq’s registry against that file, so a field gq adds surfaces as a failure rather than as missing symbology.

The gq registry remains the source of truth for R and web rendering — gq_tmap_style() and gq_mapgl_style() — where a cross-backend abstraction is exactly what you want.

Rendering a preview

Check styling without opening QGIS:

rfp_map_render("background_layers.gpkg", path_out = "preview.png")

This renders the styled layers to a PNG via headless PyQGIS in Docker, reading the same layer_styles table QGIS would.