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 filesThe 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] TRUEThe 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] TRUEWhere 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_linedbname='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: 8It 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.
