Skip to contents

Insert a raster into a .qgs with surgical xml2 edits, leaving the project at whatever version QGIS stamped it. Adding a layer in QGIS Desktop remains the alternative; this exists so a project assembled headlessly - from a STAC query, a DEM clip, an orthophoto - does not require a desktop round trip.

Usage

rfp_qgs_raster_add(
  qgs,
  raster,
  name,
  style = NULL,
  qml = NULL,
  stretch = NULL,
  group = "Base - lidar",
  position = c("bottom", "top"),
  visible = TRUE,
  themes = "all",
  companion = FALSE,
  name_companion = NULL,
  restyle = FALSE
)

Arguments

qgs

Character. Path to the .qgs.

raster

Character. Path to the raster, relative to the project directory or absolute within it. Written as ./<path> - a datasource outside the project resolves nowhere on another machine.

name

Character. Layer name as it appears in QGIS.

style

Character. A key from rfp_raster_styles(). Resolves to both the .qml and its stretch rule, which is why it exists alongside qml - rfp_raster_style_path() returns a bare path and discards the row.

qml

Character. Path to a style to apply, for a .qml that is not registered. NULL leaves the layer with QGIS's default rendering.

stretch

How the renderer's range is set: "from_data" reads the raster's min and max, "from_data_p2_98" reads its 2nd-98th percentile, "none" leaves the style's own range, or c(min, max) sets it explicitly. Defaults to the registry row when style is given.

"from_data_p2_98" is for scanned imagery, where a scan border or a dust speck puts min/max at an extreme no meaningful part of the image occupies and the rest of the range compresses toward flat. On four scanned airphotos it reproduces the stretch a human chose in QGIS.

Applies to every renderer that carries a range, not only ramped ones - a singlebandgray keeps its stretch in <contrastEnhancement> and was silently unreachable until #235. A paletted renderer has no stretch: its value= are class codes, and rescaling those is data corruption.

Two rasters that must agree need the same explicit range. Either from_data mode gives each its own, so a detail tile inside a context tile renders discontinuously at their shared boundary - which is the defect this argument exists for.

"none" is not "no stretch" for every style. airphoto_gray ships minMaxOrigin/extent = UpdatedCanvas, so left alone it re-derives its range from what is on the canvas - which is what lets one greyscale style fit any scan, and is also the seam above. Any range written here is pinned with limits = None so it cannot be re-derived.

group

Character. Layer-tree group to place it in, matched on trimmed name and created when absent. Defaults to "Base - lidar", which both shipped templates already carry.

position

One of "bottom" (default) or "top" - where in the group. A context raster belongs under a detail raster; a hillshade belongs over the elevation it shades.

visible

Logical. Checked in the layer tree. Default TRUE.

themes

Character. Map themes to add the layer to. A layer absent from a theme is hidden whenever that theme is applied, so "all" (the default) keeps it visible; character(0) adds it to none.

companion

Logical. Also add the style's registered companion as a second layer over the same raster, drawn above it. Requires style. Default FALSE.

name_companion

Character. Name for that second layer. Default is "<label> - <name>" from the companion's registry row.

restyle

Logical. Re-render a layer this project already holds, instead of the no-op the idempotence rule otherwise gives. Default FALSE.

Without it there is no way to change a layer's style: the id is derived from name and datasource, so a restyled layer hashes to the id already present and the call returns early. Removing and re-adding is not the workaround it looks like - .prune_empty_groups() drops the layer-tree group once its last layer goes, taking hand-configured map-theme entries with it.

Because the id is unchanged, only <maplayer> is replaced. Every other surface holding that id - layer tree, themes, custom-order, legend - keeps pointing at a node that still exists, so group position and theme visibility survive.

Value

Invisibly, the layer id - or both ids, base first, when companion = TRUE.

Details

rfp_qgs_raster_add(qgs, "background_layers/dem.tif", name = "Elevation",
                   qml = "background_layers/dem.qml")

Why this does not re-save the project

No available headless QGIS matches the version the field projects are saved at, so project.write() either downgrades or upgrades the file. xml2 edits the document in place and write_xml() preserves the root version attribute - asserted here, not assumed.

A layer id lives in several places

Inserting is the inverse of .qgs_drop_layer_refs(), and harder: a sweep can walk the whole document looking for an id, while an insert has to know every surface. Those surfaces differ by project - the legacy legendlayerfile legend and custom-order are present in some projects and absent in others - so each is written only when the project already uses it, and a reference checker asserts afterwards that nothing dangles.

Idempotence

The layer id is derived from the name and datasource, so adding the same raster twice is a no-op and leaves the file byte-identical. A random id would dirty the .qgs on every run, and these projects sync on file bytes.

A pair is two layers over one file

A DEM reads as terrain only with a hillshade over it; the elevation ramp alone is a smooth paint blob. companion = TRUE adds the second style named by the registry as its own <maplayer> over the same raster, above the first. It is opt-in because asking for a DEM should not silently produce two layers.

A pair cannot be shipped as a sidecar - QGIS reads one foo.qml per foo.tif - so it exists only where a project is written.

Examples

qgs <- system.file("testdata", "raster_add_fixture.qgs", package = "rfp")
dir <- withr::local_tempdir()
file.copy(qgs, fs::path(dir, "p.qgs"))
#> Warning: cannot create file '/tmp/RtmpfSNDuG/file1c082ae7831f/p.qgs', reason 'No such file or directory'
#> [1] FALSE
file.copy(system.file("testdata", "subset_fixture", "parent", "dtm.tif",
                      package = "rfp"), fs::path(dir, "dtm.tif"))
#> Warning: cannot create file '/tmp/RtmpfSNDuG/file1c082ae7831f/dtm.tif', reason 'No such file or directory'
#> [1] FALSE

rfp_qgs_raster_add(fs::path(dir, "p.qgs"), "dtm.tif", name = "Elevation")
#> Error: File does not exist: /tmp/RtmpfSNDuG/file1c082ae7831f/p.qgs

# a DEM and its hillshade, over one file, in one call
rfp_qgs_raster_add(fs::path(dir, "p.qgs"), "dtm.tif", name = "Terrain",
                   style = "dem_turbo", companion = TRUE)
#> Error: File does not exist: /tmp/RtmpfSNDuG/file1c082ae7831f/p.qgs