From 80208e216c8dc60b70265ae0fda5e56c8aad148c Mon Sep 17 00:00:00 2001 From: strebdom Date: Sun, 25 Oct 2026 08:29:44 +0100 Subject: [PATCH 1/4] feat(map): QGIS import colours rasters as QGIS does, and QGIS raster styles apply to rasters QGIS's raster renderers become RustGIS raster styles instead of a "raster colouring" note: - singleband grey: a stretch from black to white; - singleband pseudocolour: a stretch along its colour map (interpolated), Classify (discrete: a value takes the first class at or above it) or Unique Values (exact); - paletted: Unique Values; multiband colour: RGB with a stretch per band; - hillshade: Shaded Relief. The contrast enhancement's bounds become the stretch (following the view when QGIS works them out from the canvas), with brightness, contrast, gamma and bilinear resampling; values made transparent, clipped by the colour map or set as no data hide as the raster's mask. Contours, hue and saturation, partial transparency and alpha bands are noted. A project's rasters take their colouring and blending, and style.importQgis now styles raster layers too; a style reading a band the raster hasn't is noted and leaves it as it was. --- crates/engine/src/cmd/qgis.rs | 37 ++-- crates/engine/src/tests.rs | 22 +++ crates/map/src/qgis/mod.rs | 48 ++++-- crates/map/src/qgis/raster.rs | 310 ++++++++++++++++++++++++++++++++++ crates/map/src/qgis/tests.rs | 114 +++++++++++++ docs/user-guide/workspace.md | 15 +- 6 files changed, 512 insertions(+), 34 deletions(-) create mode 100644 crates/map/src/qgis/raster.rs diff --git a/crates/engine/src/cmd/qgis.rs b/crates/engine/src/cmd/qgis.rs index 3eef1e4b8..2fd403b4a 100644 --- a/crates/engine/src/cmd/qgis.rs +++ b/crates/engine/src/cmd/qgis.rs @@ -7,7 +7,7 @@ use rustgis_geom::{Coord, Extent}; use rustgis_map::qgis::{self, NoteKind, Report, Source}; use serde_json::{Value, json}; -use super::{need_feature_layer, ps}; +use super::{need_layer, ps}; use crate::{CmdResult, CommandSpec as C, Session}; pub fn commands() -> Vec { @@ -15,7 +15,7 @@ pub fn commands() -> Vec { C::new("project.importQgis", "Import QGIS Project", "Project › Import", import_project).params( r#"{"path"?: "*.qgs|*.qgz", "text"?: "a .qgs file's text", "data"?: "a .qgs or .qgz file, base64", "dir"?: "the folder its relative paths start from (default: the file's)"} → the layers added and notes on what wasn't carried over"#, ), - C::new("style.importQgis", "Import QGIS Style", "Style › Styles", import_style).when(need_feature_layer).params( + C::new("style.importQgis", "Import QGIS Style", "Style › Styles", import_style).when(need_layer).params( r#"{"layer"?, "path"?: "*.qml", "text"?: "a .qml file's text", "data"?: "a .qml file, base64"} → notes on what wasn't carried over"#, ), ] @@ -198,20 +198,13 @@ fn add_layer(s: &mut Session, layer: &qgis::ProjectLayer, has_dir: bool, report: let Some(l) = s.map_mut().layer_mut(id) else { return Err("the layer wasn't added".into()) }; let added_name = l.name.clone(); l.visible = layer.visible; - if l.is_raster() { - // A raster takes its opacity, visibility and scale range; its colouring is noted. - let st = &layer.style; - let t = st.transparency; - let (min, max) = (st.min_scale, st.max_scale); - if let Some(t) = t { - l.transparency = t; - } - if let Some(m) = min { - l.min_scale = m; - } - if let Some(m) = max { - l.max_scale = m; + if l.raster.is_some() { + // A raster takes its opacity, blending, scale range and colouring (when its bands are + // there). + if let Some(why) = l.raster.as_ref().and_then(|r| layer.style.raster_misfit(r)) { + report.skipped(name, why); } + layer.style.apply(l); return Ok(Some(added_name)); } layer.style.apply(l); @@ -336,7 +329,17 @@ fn import_style(s: &mut Session, p: &Value) -> CmdResult { let mut report = Report::default(); let style = qgis::read_style(&text, &name, &mut report)?; let l = s.map_mut().layer_mut(id).ok_or("no such layer")?; - style.apply(l); - check_fields(l, &name, &mut report); + if l.raster.is_some() { + if let Some(why) = l.raster.as_ref().and_then(|r| style.raster_misfit(r)) { + report.skipped(&name, why); + } else if style.raster.is_none() && style.renderer.is_some() { + report + .skipped(&name, "a feature layer's symbols and labels, which a raster hasn't (only its opacity and scale range are taken)"); + } + style.apply(l); + } else { + style.apply(l); + check_fields(l, &name, &mut report); + } Ok(json!({"notes": report.notes.iter().map(note_json).collect::>()})) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 818af8266..4ca919035 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -949,6 +949,28 @@ fn with_raster() -> Session { s } +/// A QGIS raster style colours a raster layer; one reading a band the raster hasn't is noted and +/// leaves it as it was. +#[test] +fn a_qgis_raster_style_colours_a_raster() { + let mut s = with_raster(); + let qml = r#" + "#; + let out = s.run("style.importQgis", &json!({"layer": "scene", "text": qml})).unwrap(); + assert_eq!(out["notes"], json!([])); + let l = s.map().layer_by_name("scene").unwrap(); + let st = &l.raster.as_ref().unwrap().style; + assert_eq!( + (&st.renderer, st.contrast, l.transparency), + (&rustgis_map::raster::RasterRenderer::Rgb { red: 2, green: 1, blue: 0 }, 25.0, 50.0) + ); + let before = st.clone(); + let qml = r#""#; + let out = s.run("style.importQgis", &json!({"layer": "scene", "text": qml})).unwrap(); + assert!(serde_json::to_string(&out["notes"]).unwrap().contains("band 5 of a raster with 3"), "{out}"); + assert_eq!(s.map().layer_by_name("scene").unwrap().raster.as_ref().unwrap().style, before); +} + /// The parts of a large GeoTIFF the web app reads (#140), opened and kept in a project: a /// cloud-optimised file read a part at a time, as `parts.rs` reads a picked file. #[test] diff --git a/crates/map/src/qgis/mod.rs b/crates/map/src/qgis/mod.rs index 0b09db0e0..3646e952d 100644 --- a/crates/map/src/qgis/mod.rs +++ b/crates/map/src/qgis/mod.rs @@ -11,6 +11,7 @@ mod expr; mod labels; +mod raster; mod renderer; mod source; mod symbol; @@ -108,6 +109,8 @@ pub struct Style { /// 0 opaque to 100 invisible. pub transparency: Option, pub blend: Option, + /// A raster's colouring, stretch, resampling, brightness, contrast and mask. + pub raster: Option, /// Drawn between these scales (RustGIS's, see [`QGIS_SCALE`]): zoomed in beyond 1:`min_scale` /// and out beyond 1:`max_scale`. `Some(None)` clears a limit. pub min_scale: Option>, @@ -117,6 +120,26 @@ pub struct Style { impl Style { /// Gives `layer` this style's parts (those it sets). pub fn apply(&self, layer: &mut crate::Layer) { + if let Some(t) = self.transparency { + layer.transparency = t; + } + if let Some(b) = self.blend { + layer.blend = b; + } + if let Some(s) = self.min_scale { + layer.min_scale = s; + } + if let Some(s) = self.max_scale { + layer.max_scale = s; + } + // A raster takes its raster style (when its bands are there), and none of a feature + // layer's parts. + if let Some(r) = layer.raster.as_mut() { + if let Some(st) = self.raster.as_ref().filter(|_| self.raster_misfit(r).is_none()) { + r.style = st.clone(); + } + return; + } if let Some(r) = &self.renderer { layer.renderer = r.clone(); } @@ -132,18 +155,15 @@ impl Style { if let Some(l) = &self.labels { layer.labels = l.clone(); } - if let Some(t) = self.transparency { - layer.transparency = t; - } - if let Some(b) = self.blend { - layer.blend = b; - } - if let Some(s) = self.min_scale { - layer.min_scale = s; - } - if let Some(s) = self.max_scale { - layer.max_scale = s; - } + } + + /// Why this style's raster colouring doesn't fit the raster `r` (it reads a band the file + /// hasn't), or `None` when it fits or there is none. + pub fn raster_misfit(&self, r: &crate::raster::RasterLayer) -> Option { + let st = self.raster.as_ref()?; + let count = r.data.as_ref()?.band_count(); + let band = st.renderer.bands().into_iter().find(|b| *b >= count)?; + Some(format!("its raster colouring, which reads band {} of a raster with {count}", band + 1)) } } @@ -305,12 +325,12 @@ fn read_style_node(node: Node, layer: &str, report: &mut Report) -> Style { if let Some(r) = child(node, "renderer-v2") { renderer::read(r, layer, &mut style, report); } - // A raster's renderer: its opacity carried over, its colouring not yet. + // A raster's renderer: its opacity, and its colouring as RustGIS's raster style. if let Some(r) = child(node, "pipe").and_then(|p| child(p, "rasterrenderer")) { if let Some(o) = r.attribute("opacity").and_then(|o| o.trim().parse::().ok()).filter(|o| o.is_finite() && *o < 1.0) { style.transparency = Some(((1.0 - o.clamp(0.0, 1.0)) * 100.0).round()); } - report.skipped(layer, format!("its raster colouring ({} renderer)", r.attribute("type").unwrap_or("unknown"))); + style.raster = raster::read(node, layer, report); } if let Some(l) = child(node, "labeling") { // Set up but switched off: carried over, switched off. diff --git a/crates/map/src/qgis/raster.rs b/crates/map/src/qgis/raster.rs new file mode 100644 index 000000000..e9d78ac3a --- /dev/null +++ b/crates/map/src/qgis/raster.rs @@ -0,0 +1,310 @@ +//! QGIS raster renderers (``) as a RustGIS [`RasterStyle`]: singleband +//! grey as a stretch from black to white, singleband pseudocolour as a stretch along its colour +//! map (interpolated), as classes (discrete) or as unique values (exact), paletted as unique +//! values, multiband colour as RGB, hillshade as shaded relief; each with its contrast +//! enhancement as the stretch, its brightness, contrast and gamma, its resampling, and the values +//! its transparency and user no-data hide as the mask. + +use roxmltree::Node; + +use super::{Report, child, color}; +use crate::Rgba; +use crate::color::{CustomRamp, Ramp, RampKind}; +use crate::raster::{MAX_UNIQUE_VALUES, RasterMask, RasterRenderer, RasterStyle, Resampling, Stretch}; +use crate::symbology::ClassMethod; + +/// The most bands a QGIS renderer is read naming (RustGIS's bands are checked against the file +/// when the style is applied). +const MAX_BAND: usize = 10_000; +/// The most colour map entries read. +const MAX_ITEMS: usize = 4096; + +/// A raster's style from its layer's `` (`node`: a project's `` or a style's +/// root), or `None` (noted) for a renderer RustGIS has no match for. +pub(super) fn read(node: Node, layer: &str, report: &mut Report) -> Option { + let pipe = child(node, "pipe")?; + let r = child(pipe, "rasterrenderer")?; + let kind = r.attribute("type").unwrap_or("unknown"); + let band = |k: &str| -> Option { + let b = r.attribute(k)?.trim().parse::().ok()?; + (1..=MAX_BAND).contains(&b).then(|| b - 1) + }; + let mut mask = RasterMask::default(); + let (renderer, stretch) = match kind { + "singlebandgray" => { + let b = band("grayBand")?; + let black_to_white = r.attribute("gradient") != Some("WhiteToBlack"); + let ramp = grey(black_to_white)?; + (RasterRenderer::Stretch { band: b, ramp, invert: false }, stretch_of(r, child(r, "contrastEnhancement"), &mut mask)) + } + "singlebandpseudocolor" => pseudocolor(r, band("band")?, &mut mask, layer, report)?, + "paletted" => { + let entries = child(r, "colorPalette") + .map(|p| { + p.children() + .filter(|e| e.has_tag_name("paletteEntry")) + .take(MAX_ITEMS) + .filter_map(|e| { + let v = e.attribute("value")?.trim().parse::().ok().filter(|v| v.is_finite())?; + let c = color(e.attribute("color")?)?; + let a = e.attribute("alpha").and_then(|a| a.trim().parse::().ok()).unwrap_or(c.a()); + Some((v, c.with_alpha(a))) + }) + .collect::>() + }) + .unwrap_or_default(); + (unique(band("band")?, entries, layer, report)?, Stretch::None) + } + "multibandcolor" => { + let (Some(red), Some(green), Some(blue)) = (band("redBand"), band("greenBand"), band("blueBand")) else { + report.approximated(layer, "a multiband raster missing a red, green or blue band, drawn by RustGIS's default"); + return None; + }; + let enh = ["redContrastEnhancement", "greenContrastEnhancement", "blueContrastEnhancement"].map(|t| child(r, t)); + let bounds: Vec> = enh.iter().map(|e| e.and_then(bounds_of)).collect(); + let stretch = match bounds.as_slice() { + [Some(a), Some(b), Some(c)] => { + clip(enh[0], *a, &mut mask); + Stretch::custom_bands([*a, *b, *c]) + } + _ => dynamic(r).unwrap_or(Stretch::None), + }; + (RasterRenderer::Rgb { red, green, blue }, stretch) + } + "hillshade" => { + let num = |k: &str, d: f64| r.attribute(k).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()).unwrap_or(d); + if r.attribute("multidirection") == Some("1") { + report.approximated(layer, "a hillshade lit from several directions, drawn lit from one"); + } + let renderer = RasterRenderer::ShadedRelief { + band: band("band")?, + ramp: None, + azimuth: num("azimuth", 315.0).rem_euclid(360.0), + altitude: num("angle", 45.0).clamp(0.0, 90.0), + z_factor: num("zfactor", 1.0).clamp(0.0001, 10_000.0), + }; + (renderer, Stretch::None) + } + other => { + report.skipped(layer, format!("its raster colouring ({other} renderer)")); + return None; + } + }; + if r.attribute("alphaBand").and_then(|a| a.trim().parse::().ok()).is_some_and(|a| a > 0) { + report.approximated(layer, "a raster band as its transparency, left out"); + } + transparency(r, &mut mask, layer, report); + no_data(node, &mut mask, layer, report); + let mut style = RasterStyle { + renderer, + stretch, + dra: false, + resampling: resampling(pipe), + brightness: 0.0, + contrast: 0.0, + gamma: 1.0, + rotation: 0.0, + mask, + }; + if let Some(bc) = child(pipe, "brightnesscontrast") { + let num = |k: &str| bc.attribute(k).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); + // QGIS's brightness runs -255 to 255, RustGIS's -100 to 100; contrast is -100 to 100 in both. + style.brightness = num("brightness").map_or(0.0, |b| (b / 2.55).clamp(-100.0, 100.0)); + style.contrast = num("contrast").map_or(0.0, |c| c.clamp(-100.0, 100.0)); + style.gamma = num("gamma").map_or(1.0, |g| g.clamp(0.1, 10.0)); + } + if let Some(hs) = child(pipe, "huesaturation") { + let set = |k: &str, off: &str| hs.attribute(k).is_some_and(|v| v.trim() != off); + if set("saturation", "0") || set("grayscaleMode", "0") || set("colorizeOn", "0") || set("invertColors", "0") { + report.approximated(layer, "raster hue and saturation settings (saturation, greyscale, colourise, invert), left out"); + } + } + style.dra = + dynamic(r).is_some() && matches!(style.stretch, Stretch::MinMax | Stretch::PercentClip { .. } | Stretch::StandardDeviation { .. }); + Some(style) +} + +/// Black to white (or white to black) as a two-stop ramp: QGIS's grey is the full range, where +/// RustGIS's Greys ramp stops short of both ends. +fn grey(black_to_white: bool) -> Option { + let (lo, hi) = + if black_to_white { (Rgba::rgb(0, 0, 0), Rgba::rgb(255, 255, 255)) } else { (Rgba::rgb(255, 255, 255), Rgba::rgb(0, 0, 0)) }; + CustomRamp::new("QGIS grey", RampKind::Sequential, vec![(Some(0.0), lo), (Some(1.0), hi)]).ok().map(|c| Ramp::Custom(Box::new(c))) +} + +/// A singleband pseudocolour renderer's colour map: interpolated as a stretch along it, discrete +/// as classes (a value takes the first entry at or above it, as in QGIS), exact as unique values. +fn pseudocolor(r: Node, band: usize, mask: &mut RasterMask, layer: &str, report: &mut Report) -> Option<(RasterRenderer, Stretch)> { + let shader = child(r, "rastershader").and_then(|s| child(s, "colorrampshader"))?; + let mut items: Vec<(f64, Rgba)> = shader + .children() + .filter(|n| n.has_tag_name("item")) + .take(MAX_ITEMS) + .filter_map(|n| { + let v = n.attribute("value")?.trim().parse::().ok().filter(|v| !v.is_nan())?; + let c = color(n.attribute("color")?)?; + let a = n.attribute("alpha").and_then(|a| a.trim().parse::().ok()).unwrap_or(c.a()); + Some((v, c.with_alpha(a))) + }) + .collect(); + items.sort_by(|a, b| a.0.total_cmp(&b.0)); + if items.is_empty() { + report.approximated(layer, "a pseudocolour raster without a colour map, drawn by RustGIS's default"); + return None; + } + let num = |k: &str| shader.attribute(k).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); + // Values outside the map's range are left out when it clips. + if shader.attribute("clip") == Some("1") { + mask.min = num("minimumValue").or(items.first().map(|i| i.0).filter(|v| v.is_finite())); + mask.max = num("maximumValue").or(items.last().map(|i| i.0).filter(|v| v.is_finite())); + } + match shader.attribute("colorRampType").unwrap_or("INTERPOLATED") { + "EXACT" => Some((unique(band, items, layer, report)?, Stretch::None)), + "DISCRETE" => { + // The last entry is often "inf": every value above the one before. + let breaks: Vec = items.iter().map(|i| i.0.clamp(f64::from(f32::MIN), f64::from(f32::MAX))).collect(); + let colors = items.iter().map(|i| (None, i.1)).collect(); + let ramp = CustomRamp::new("QGIS classes", RampKind::Qualitative, colors).ok()?; + let classes = breaks.len(); + Some(( + RasterRenderer::Classify { band, method: ClassMethod::Manual, ramp: Ramp::Custom(Box::new(ramp)), breaks, classes }, + Stretch::None, + )) + } + _ => { + let finite: Vec<(f64, Rgba)> = items.into_iter().filter(|i| i.0.is_finite()).collect(); + let (lo, hi) = (finite.first()?.0, finite.last()?.0); + let stops: Vec<(Option, Rgba)> = if hi > lo { + finite.iter().map(|(v, c)| (Some(((v - lo) / (hi - lo)).clamp(0.0, 1.0)), *c)).collect() + } else { + let c = finite.first()?.1; + vec![(Some(0.0), c), (Some(1.0), c)] + }; + let ramp = CustomRamp::new("QGIS colour map", RampKind::Sequential, stops).ok()?; + let (lo, hi) = if hi > lo { (lo, hi) } else { (lo, lo + 1.0) }; + Some((RasterRenderer::Stretch { band, ramp: Ramp::Custom(Box::new(ramp)), invert: false }, Stretch::custom(lo, hi))) + } + } +} + +/// A colour per value, at most [`MAX_UNIQUE_VALUES`] (noted past them), sorted as RustGIS looks +/// them up; the values as the pixels hold them (in f32). +fn unique(band: usize, mut values: Vec<(f64, Rgba)>, layer: &str, report: &mut Report) -> Option { + values.retain(|v| v.0.is_finite()); + for v in &mut values { + v.0 = f64::from(v.0 as f32); + } + values.sort_by(|a, b| a.0.total_cmp(&b.0)); + values.dedup_by(|a, b| a.0 == b.0); + if values.is_empty() { + return None; + } + if values.len() > MAX_UNIQUE_VALUES { + report.skipped(layer, format!("raster values past the first {MAX_UNIQUE_VALUES} with a colour of their own")); + values.truncate(MAX_UNIQUE_VALUES); + } + Some(RasterRenderer::UniqueValues { band, ramp: Ramp::Harbour, values }) +} + +/// A ``'s minimum and maximum, when it stretches between them. +fn bounds_of(e: Node) -> Option<[f64; 2]> { + let algorithm = child(e, "algorithm").and_then(|a| a.text()).map(str::trim).unwrap_or("NoEnhancement"); + if algorithm == "NoEnhancement" || algorithm == "UserDefinedEnhancement" { + return None; + } + let v = |t: &str| child(e, t).and_then(|n| n.text()).and_then(|t| t.trim().parse::().ok()).filter(|v| v.is_finite()); + let (lo, hi) = (v("minValue")?, v("maxValue")?); + (hi > lo).then_some([lo, hi]) +} + +/// A clipping enhancement leaves out the values outside its bounds. +fn clip(e: Option, [lo, hi]: [f64; 2], mask: &mut RasterMask) { + let algorithm = e.and_then(|e| child(e, "algorithm")).and_then(|a| a.text()).map(str::trim); + if matches!(algorithm, Some("StretchAndClipToMinimumMaximum" | "ClipToMinimumMaximum")) { + mask.min = Some(lo); + mask.max = Some(hi); + } +} + +/// A single band's stretch: its enhancement's bounds, or none. +fn stretch_of(r: Node, e: Option, mask: &mut RasterMask) -> Stretch { + match e.and_then(bounds_of) { + Some(b) => { + clip(e, b, mask); + // Bounds worked out from the canvas follow the view: RustGIS's dynamic range. + dynamic(r).unwrap_or(Stretch::custom(b[0], b[1])) + } + None => Stretch::None, + } +} + +/// The stretch QGIS works out again as the view changes (`` with a canvas extent), +/// as RustGIS's (its dynamic range adjustment then on). +fn dynamic(r: Node) -> Option { + let o = child(r, "minMaxOrigin")?; + let text = |t: &str| child(o, t).and_then(|n| n.text()).map(str::trim); + if !matches!(text("extent"), Some("CurrentCanvas" | "UpdatedCanvas")) { + return None; + } + let num = |t: &str| text(t).and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()); + match text("limits")? { + "MinMax" => Some(Stretch::MinMax), + "CumulativeCut" => { + let low = num("cumulativeCutLower").unwrap_or(0.02) * 100.0; + let high = (1.0 - num("cumulativeCutUpper").unwrap_or(0.98)) * 100.0; + Some(Stretch::PercentClip { low: low.clamp(0.0, 49.0), high: high.clamp(0.0, 49.0) }) + } + "StdDev" => Some(Stretch::StandardDeviation { n: num("stdDevFactor").unwrap_or(2.0).clamp(0.1, 10.0) }), + _ => None, + } +} + +/// Bilinear when QGIS smooths the raster (bilinear or cubic, zoomed in), else nearest. +fn resampling(pipe: Node) -> Resampling { + let smooth = |m: Option<&str>| m.is_some_and(|m| m != "nearestNeighbour" && !m.is_empty()); + let provider = child(pipe, "provider").and_then(|p| child(p, "resampling")); + let by_provider = provider.is_some_and(|p| p.attribute("enabled") == Some("true") && smooth(p.attribute("zoomedInResamplingMethod"))); + let by_resampler = child(pipe, "rasterresampler").is_some_and(|r| smooth(r.attribute("zoomedInResampler"))); + if by_provider || by_resampler { Resampling::Bilinear } else { Resampling::Nearest } +} + +/// The values a layer's own no-data settings (``, the user's on top of the file's) hide. +fn no_data(node: Node, mask: &mut RasterMask, layer: &str, report: &mut Report) { + let Some(nd) = child(node, "noData") else { return }; + let mut ranges = false; + for e in nd.descendants().filter(|e| e.has_tag_name("noDataRange")).take(MAX_ITEMS) { + let num = |k: &str| e.attribute(k).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); + match (num("min"), num("max")) { + (Some(lo), Some(hi)) if lo == hi => mask.values.push(lo), + _ => ranges = true, + } + } + mask.values.sort_by(f64::total_cmp); + mask.values.dedup(); + if ranges { + report.approximated(layer, "ranges of raster values set as no data, left out"); + } +} + +/// The values QGIS's raster transparency makes fully transparent, as mask values; ranges and +/// partial transparency are noted. +fn transparency(r: Node, mask: &mut RasterMask, layer: &str, report: &mut Report) { + let Some(t) = child(r, "rasterTransparency") else { return }; + let mut other = false; + for e in t.descendants().filter(|e| e.has_tag_name("pixelListEntry")).take(MAX_ITEMS) { + let num = |k: &str| e.attribute(k).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); + let full = num("percentTransparent").is_none_or(|p| p >= 100.0); + match (num("min"), num("max")) { + (Some(lo), Some(hi)) if lo == hi && full => mask.values.push(lo), + _ => other = true, + } + } + if t.descendants().any(|e| e.has_tag_name("threeValuePixelList") && e.children().any(|c| c.is_element())) { + other = true; + } + mask.values.sort_by(f64::total_cmp); + mask.values.dedup(); + if other { + report.approximated(layer, "raster transparency for ranges of values or in part, left out"); + } +} diff --git a/crates/map/src/qgis/tests.rs b/crates/map/src/qgis/tests.rs index acc881dcb..b1dffc792 100644 --- a/crates/map/src/qgis/tests.rs +++ b/crates/map/src/qgis/tests.rs @@ -420,3 +420,117 @@ fn what_rustgis_has_no_place_for_is_noted() { assert_eq!(report.notes.iter().find(|n| n.text.contains("draw effects")).map(|n| n.count), Some(1)); assert!(!said("A", "repeated"), "a repeat distance of 0 is none"); } + +/// A raster style file with `renderer` as its renderer and `pipe` after it in the pipe. +fn raster_qml(renderer: &str, pipe: &str) -> String { + format!( + r#"{renderer}{pipe}0"# + ) +} + +#[test] +fn raster_renderers_read_as_rustgis_raster_styles() { + use crate::raster::{RasterRenderer, Resampling, Stretch}; + let read = |renderer: &str, pipe: &str| { + let mut report = Report::default(); + let s = read_style(&raster_qml(renderer, pipe), "R", &mut report).unwrap(); + (s, report) + }; + // Pseudocolour, interpolated (SMV25's rock): a ramp from transparent white to grey over 0 to 1. + let (s, report) = read( + r##" + + + "##, + r#" + "#, + ); + let r = s.raster.unwrap(); + let RasterRenderer::Stretch { band: 0, ramp, invert: false } = &r.renderer else { panic!("{:?}", r.renderer) }; + assert_eq!((ramp.at(0.0).a(), ramp.at(1.0)), (0, Rgba::rgb(0x3f, 0x3f, 0x3f))); + assert_eq!(r.stretch, Stretch::custom(0.0, 1.0)); + assert_eq!((r.brightness, r.contrast, r.gamma, r.resampling), (20.0, -10.0, 2.0, Resampling::Bilinear)); + assert_eq!(s.transparency, Some(50.0)); + assert!(report.notes.is_empty(), "{:?}", report.notes); + + // Discrete: classes up to each entry's value, the last open-ended; values outside clipped. + let (s, _) = read( + r##" + "##, + "", + ); + let r = s.raster.unwrap(); + let RasterRenderer::Classify { band: 1, ramp, breaks, .. } = &r.renderer else { panic!("{:?}", r.renderer) }; + assert_eq!((breaks.len(), breaks[0], breaks[1]), (3, 450.0, 500.0)); + assert!(breaks[2].is_finite() && breaks[2] > 1e30, "inf kept as a number a project can hold"); + assert_eq!((ramp.pick(0, 3), ramp.pick(2, 3)), (Rgba::rgb(255, 0, 0), Rgba::rgb(0, 0, 255))); + assert_eq!((r.mask.min, r.mask.max), (Some(400.0), Some(600.0))); + + // Exact and paletted: a colour per value, sorted. + for renderer in [ + r##" + "##, + r##""##, + ] { + let (s, _) = read(renderer, ""); + let r = s.raster.unwrap(); + let RasterRenderer::UniqueValues { band: 0, values, .. } = &r.renderer else { panic!("{:?}", r.renderer) }; + assert_eq!(values, &[(3.0, Rgba::rgb(255, 0, 0)), (7.0, Rgba::rgb(0, 255, 0))]); + } + + // Grey: black to white, stretched to its enhancement's bounds; a value made transparent and + // a user no-data value hide those values. + let gray = r#" + + 10200StretchToMinimumMaximum"#; + let qml = raster_qml(gray, "").replace( + "", + r#""#, + ); + let mut report = Report::default(); + let r = read_style(&qml, "R", &mut report).unwrap().raster.unwrap(); + let RasterRenderer::Stretch { ramp, .. } = &r.renderer else { panic!() }; + assert_eq!((ramp.at(0.0), ramp.at(1.0)), (Rgba::rgb(0, 0, 0), Rgba::rgb(255, 255, 255))); + assert_eq!(r.stretch, Stretch::custom(10.0, 200.0)); + assert_eq!(r.mask.values, [-9999.0, 0.0]); + assert!(report.notes.iter().any(|n| n.text.contains("in part")), "{:?}", report.notes); + + // Multiband: RGB from 1-based bands, a stretch per band; a canvas-wide cut follows the view. + let (s, _) = read( + r#" + 12StretchToMinimumMaximum + 34StretchToMinimumMaximum + 56StretchToMinimumMaximum"#, + "", + ); + let r = s.raster.unwrap(); + assert_eq!(r.renderer, RasterRenderer::Rgb { red: 2, green: 1, blue: 0 }); + assert_eq!(r.stretch, Stretch::custom_bands([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])); + let (s, _) = read( + r#"CumulativeCutUpdatedCanvas0.020.98 + 10200StretchToMinimumMaximum"#, + "", + ); + let r = s.raster.unwrap(); + assert!( + r.dra && matches!(r.stretch, Stretch::PercentClip { low, high } if (low - 2.0).abs() < 1e-9 && (high - 2.0).abs() < 1e-9), + "{r:?}" + ); + + // Hillshade as shaded relief; hue and saturation and contours are noted. + let (s, report) = read( + r#""#, + r#""#, + ); + assert_eq!( + s.raster.unwrap().renderer, + RasterRenderer::ShadedRelief { band: 0, ramp: None, azimuth: 300.0, altitude: 35.0, z_factor: 2.0 } + ); + assert!(report.notes.iter().any(|n| n.text.contains("saturation"))); + let (s, report) = read(r#""#, ""); + assert!(s.raster.is_none() && report.notes.iter().any(|n| n.text.contains("contour"))); + // Nonsense bands and values: nothing, without a crash. + for bad in [r#""#, r#""#] { + assert!(read(bad, "").0.raster.is_none()); + } +} diff --git a/docs/user-guide/workspace.md b/docs/user-guide/workspace.md index 077ebde55..9ab847aad 100644 --- a/docs/user-guide/workspace.md +++ b/docs/user-guide/workspace.md @@ -40,6 +40,12 @@ Each layer keeps its look: rendering passes as their levels when the renderer draws in symbol levels. - **Layer settings**: opacity, blending, visibility range, filter (subset string), and the reference scale. +- **Rasters**: singleband grey as a stretch from black to white, singleband pseudocolour as a + stretch along its colour map (interpolated), as Classify (discrete) or as Unique Values (exact), + paletted as Unique Values, multiband colour as RGB and hillshade as Shaded Relief; with the + contrast enhancement's bounds as the stretch (following the view when QGIS works them out from + the canvas), brightness, contrast and gamma, bilinear resampling, and the values its + transparency, clipping and no-data settings hide as the raster's mask. - **Labels**: simple and rule-based, each rule a label class whose SQL clause is its filter and its parents' (an else rule's: none of its siblings'), with the text's field, size, colour, bold and italic, its font family (drawn in it where it is installed, else in RustGIS's label @@ -56,13 +62,16 @@ approximated (pattern fills drawn as a tint of their colour, font and SVG marker dashed polygon outlines solid, label halos in RustGIS's own colour and width, label offsets and wrapping, values outside a graduated renderer's ranges in its first or last class, file options such as `layerid` that RustGIS doesn't read) and what was skipped (marker lines, data-defined -settings, draw effects, label callouts, labels from expressions, raster colouring, PostGIS, WFS +settings, draw effects, label callouts, labels from expressions, contours and other raster +renderers RustGIS has no match for, PostGIS, WFS and other sources RustGIS doesn't read, diagrams, joins, virtual fields, a layer's other styles, and the project's print layouts, annotations, bookmarks, map themes and relations). A database password in a project never reaches the notes. -A QGIS layer style (`.qml`) styles the selected layer the same way: **Import QGIS Style…** in -the Symbology pane (`style.importQgis={"layer": "Roads", "path": "…/roads.qml"}`). +A QGIS layer style (`.qml`) styles the selected layer the same way, a feature layer or a raster: +**Import QGIS Style…** in the Symbology pane (`style.importQgis={"layer": "Roads", "path": +"…/roads.qml"}`). A raster style that reads a band the raster hasn't is noted and leaves the +raster as it was. RustGIS works out a map's scale with the OGC's 0.28 mm screen pixel, QGIS at 96 pixels to the inch, so the same view is a larger scale in QGIS; scales from QGIS (reference scales, visibility From bea9dd1a4df59b3f9889a2d4f7b0abb463b9b3c9 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Sun, 25 Oct 2026 08:40:21 +0100 Subject: [PATCH 2/4] fix(map): a QGIS raster style that doesn't fit leaves the raster's opacity too; a raster style on a feature layer and an unreadable raster renderer are noted --- crates/engine/src/cmd/qgis.rs | 16 ++++++++++++---- crates/engine/src/tests.rs | 10 ++++++++-- crates/map/src/qgis/raster.rs | 12 ++++++++++++ crates/map/src/qgis/tests.rs | 5 +++-- 4 files changed, 35 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/qgis.rs b/crates/engine/src/cmd/qgis.rs index 2fd403b4a..b916de948 100644 --- a/crates/engine/src/cmd/qgis.rs +++ b/crates/engine/src/cmd/qgis.rs @@ -330,14 +330,22 @@ fn import_style(s: &mut Session, p: &Value) -> CmdResult { let style = qgis::read_style(&text, &name, &mut report)?; let l = s.map_mut().layer_mut(id).ok_or("no such layer")?; if l.raster.is_some() { + // A style that doesn't fit leaves the raster as it was, its opacity and scale range too. if let Some(why) = l.raster.as_ref().and_then(|r| style.raster_misfit(r)) { report.skipped(&name, why); - } else if style.raster.is_none() && style.renderer.is_some() { - report - .skipped(&name, "a feature layer's symbols and labels, which a raster hasn't (only its opacity and scale range are taken)"); + } else { + if style.raster.is_none() && style.renderer.is_some() { + report.skipped( + &name, + "a feature layer's symbols and labels, which a raster hasn't (only its opacity and scale range are taken)", + ); + } + style.apply(l); } - style.apply(l); } else { + if style.raster.is_some() && style.renderer.is_none() { + report.skipped(&name, "a raster's colouring, which a feature layer hasn't (only its opacity and scale range are taken)"); + } style.apply(l); check_fields(l, &name, &mut report); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4ca919035..4ce7199ae 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -689,6 +689,10 @@ fn a_qgis_project_opens_as_a_map_and_a_qgis_style_styles_a_layer() { s.run("style.importQgis", &json!({"layer": "Rivers", "text": qml})).unwrap(); let rivers = s.map().layer_by_name("Rivers").unwrap(); assert_eq!((rivers.line_strokes.len(), rivers.transparency), (2, 50.0)); + // A raster's style on a feature layer says its colouring is left out. + let qml = r#""#; + let out = s.run("style.importQgis", &json!({"layer": "Rivers", "text": qml})).unwrap(); + assert!(serde_json::to_string(&out["notes"]).unwrap().contains("a raster's colouring"), "{out}"); assert!(s.run("style.importQgis", &json!({"layer": "Rivers", "text": ""#; + // A style that reads a band the raster hasn't leaves it as it was, its opacity too. + let qml = r#"0.2"#; let out = s.run("style.importQgis", &json!({"layer": "scene", "text": qml})).unwrap(); assert!(serde_json::to_string(&out["notes"]).unwrap().contains("band 5 of a raster with 3"), "{out}"); - assert_eq!(s.map().layer_by_name("scene").unwrap().raster.as_ref().unwrap().style, before); + let l = s.map().layer_by_name("scene").unwrap(); + assert_eq!((&l.raster.as_ref().unwrap().style, l.transparency), (&before, 50.0)); } /// The parts of a large GeoTIFF the web app reads (#140), opened and kept in a project: a diff --git a/crates/map/src/qgis/raster.rs b/crates/map/src/qgis/raster.rs index e9d78ac3a..97861256c 100644 --- a/crates/map/src/qgis/raster.rs +++ b/crates/map/src/qgis/raster.rs @@ -25,6 +25,18 @@ pub(super) fn read(node: Node, layer: &str, report: &mut Report) -> Option Option { let band = |k: &str| -> Option { let b = r.attribute(k)?.trim().parse::().ok()?; (1..=MAX_BAND).contains(&b).then(|| b - 1) diff --git a/crates/map/src/qgis/tests.rs b/crates/map/src/qgis/tests.rs index b1dffc792..b3aa1c87f 100644 --- a/crates/map/src/qgis/tests.rs +++ b/crates/map/src/qgis/tests.rs @@ -529,8 +529,9 @@ fn raster_renderers_read_as_rustgis_raster_styles() { assert!(report.notes.iter().any(|n| n.text.contains("saturation"))); let (s, report) = read(r#""#, ""); assert!(s.raster.is_none() && report.notes.iter().any(|n| n.text.contains("contour"))); - // Nonsense bands and values: nothing, without a crash. + // Nonsense bands and values: nothing, without a crash, and noted. for bad in [r#""#, r#""#] { - assert!(read(bad, "").0.raster.is_none()); + let (s, report) = read(bad, ""); + assert!(s.raster.is_none() && report.notes.iter().any(|n| n.text.contains("couldn't read")), "{bad}: {report:?}"); } } From 9b05958a818a7796116c92a5b806a4a4a3cd7ac7 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Sun, 25 Oct 2026 09:06:20 +0100 Subject: [PATCH 3/4] fix(engine): a QGIS raster style imported while the raster is still loading is noted as unchecked --- crates/engine/src/cmd/qgis.rs | 8 ++++++++ crates/engine/src/tests.rs | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/crates/engine/src/cmd/qgis.rs b/crates/engine/src/cmd/qgis.rs index b916de948..7039437f9 100644 --- a/crates/engine/src/cmd/qgis.rs +++ b/crates/engine/src/cmd/qgis.rs @@ -334,6 +334,14 @@ fn import_style(s: &mut Session, p: &Value) -> CmdResult { if let Some(why) = l.raster.as_ref().and_then(|r| style.raster_misfit(r)) { report.skipped(&name, why); } else { + // Pixels still on their way (a remote raster) can't be checked: the style is applied, + // and a band it reads that the file hasn't is drawn from the file's last band. + if style.raster.is_some() && l.raster.as_ref().is_some_and(|r| r.data.is_none()) { + report.approximated( + &name, + "its raster colouring, applied before the raster's bands could be checked (a band the file hasn't is drawn from its last)", + ); + } if style.raster.is_none() && style.renderer.is_some() { report.skipped( &name, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4ce7199ae..b6cedc75a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -975,6 +975,11 @@ fn a_qgis_raster_style_colours_a_raster() { assert!(serde_json::to_string(&out["notes"]).unwrap().contains("band 5 of a raster with 3"), "{out}"); let l = s.map().layer_by_name("scene").unwrap(); assert_eq!((&l.raster.as_ref().unwrap().style, l.transparency), (&before, 50.0)); + // Pixels not there yet (a remote raster still loading): applied, and noted as unchecked. + let id = s.map().layer_by_name("scene").unwrap().id; + s.map_mut().layer_mut(id).unwrap().raster.as_mut().unwrap().data = None; + let out = s.run("style.importQgis", &json!({"layer": "scene", "text": qml})).unwrap(); + assert!(serde_json::to_string(&out["notes"]).unwrap().contains("before the raster's bands could be checked"), "{out}"); } /// The parts of a large GeoTIFF the web app reads (#140), opened and kept in a project: a From b375ca34a2a389ca0651ae4c655e95b0848fa035 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Sun, 25 Oct 2026 10:23:14 +0100 Subject: [PATCH 4/4] docs: a QGIS raster style on a raster still loading is applied unchecked --- docs/user-guide/workspace.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/user-guide/workspace.md b/docs/user-guide/workspace.md index 9ab847aad..146d20596 100644 --- a/docs/user-guide/workspace.md +++ b/docs/user-guide/workspace.md @@ -71,7 +71,8 @@ password in a project never reaches the notes. A QGIS layer style (`.qml`) styles the selected layer the same way, a feature layer or a raster: **Import QGIS Style…** in the Symbology pane (`style.importQgis={"layer": "Roads", "path": "…/roads.qml"}`). A raster style that reads a band the raster hasn't is noted and leaves the -raster as it was. +raster as it was. On a raster still loading (a remote file), the bands can't be checked yet: the +style is applied, noted as unchecked, and a band the file hasn't is drawn from its last band. RustGIS works out a map's scale with the OGC's 0.28 mm screen pixel, QGIS at 96 pixels to the inch, so the same view is a larger scale in QGIS; scales from QGIS (reference scales, visibility