From 36c7bbf0109449189d8b6d3752bb1fec41033110 Mon Sep 17 00:00:00 2001 From: strebdom Date: Sun, 25 Oct 2026 07:52:25 +0100 Subject: [PATCH] feat(map): QGIS import reads merged categories, dashes and QGIS 1/2 names, and notes what it drops An audit of the importer against 149 QGIS files (QGIS 1.8 to 4.2) found settings it dropped without a note, and some it read wrongly. This fixes those and adds the missing notes. - Merged categories: QGIS 4 writes a category of several values as s, which were missed, so the category was taken as "all other values". - Dashes: custom dash patterns (in their unit) and QGIS's named pen styles (dash, dot, ...) become the stroke's dash pattern instead of a "drawn solid" note. - Files from QGIS 1 and 2: layer order and visibility from , the project's coordinate system from the map canvas or SpatialRefSys, the scale range's old names, opacity as layerTransparency or transparencyLevelInt, the old names of simple line and fill settings (whose lines weren't drawn at all), and a symbol's outputUnit. - New notes: print layouts, annotations, bookmarks, map themes and relations; a layer's time settings, diagrams, other styles, joins, virtual fields and vector tile style; draw effects, order-by, dashed polygon outlines; label halos, distances, offsets, repeats, priorities, wrapping, callouts, data-defined settings and QGIS 2's labels. - A layer of no provider (QGIS's annotation layer) is noted by its kind. --- crates/engine/src/cmd/qgis.rs | 4 +- crates/engine/src/tests.rs | 5 + crates/map/src/qgis/labels.rs | 39 ++++++- crates/map/src/qgis/mod.rs | 170 +++++++++++++++++++++++++--- crates/map/src/qgis/renderer.rs | 13 ++- crates/map/src/qgis/symbol.rs | 192 +++++++++++++++++++++++--------- crates/map/src/qgis/tests.rs | 149 +++++++++++++++++++++++++ docs/user-guide/workspace.md | 18 ++- 8 files changed, 512 insertions(+), 78 deletions(-) diff --git a/crates/engine/src/cmd/qgis.rs b/crates/engine/src/cmd/qgis.rs index dee40c793..3eef1e4b8 100644 --- a/crates/engine/src/cmd/qgis.rs +++ b/crates/engine/src/cmd/qgis.rs @@ -188,7 +188,9 @@ fn add_layer(s: &mut Session, layer: &qgis::ProjectLayer, has_dir: bool, report: return Ok(None); } Source::Other { provider } => { - report.skipped(name, format!("the layer: RustGIS doesn't read QGIS's {provider} sources")); + // A layer of no provider (an annotation layer, a group layer) is told by its kind. + let what = if provider.is_empty() { format!("{} layers", layer.kind) } else { format!("{provider} sources") }; + report.skipped(name, format!("the layer: RustGIS doesn't read QGIS's {what}")); return Ok(None); } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 53b676bbc..c9364b356 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -715,6 +715,11 @@ fn a_qgis_project_opens_as_a_map_and_a_qgis_style_styles_a_layer() { let t = s.map().tiles.iter().find(|t| t.name == "Tiles").unwrap(); assert!(t.overlay); assert_eq!(t.blend, rustgis_map::effects::Blend::Multiply); + // A layer of no provider (QGIS's annotation layer) is noted by its kind. + let qgs = r#"NotesaNotes"#; + let out = s.run("project.importQgis", &json!({"text": qgs})).unwrap(); + let text = serde_json::to_string(&out["notes"]).unwrap(); + assert!(text.contains("doesn't read QGIS's annotation layers"), "{text}"); // A project given as text with no folder: a relative path is noted, not looked for in the // working directory. let rel = r#"a./data/roads.geojsonRelogr"#; diff --git a/crates/map/src/qgis/labels.rs b/crates/map/src/qgis/labels.rs index f0c61dcf9..ba5999e14 100644 --- a/crates/map/src/qgis/labels.rs +++ b/crates/map/src/qgis/labels.rs @@ -5,7 +5,7 @@ use roxmltree::Node; -use super::{QGIS_SCALE, Report, child, color, expr}; +use super::{QGIS_SCALE, Report, child, color, data_defined_on, expr, prop, props}; use crate::Rgba; use crate::symbology::{DEFAULT_LABEL_CLASS, LABEL_SIZE, LabelClass, LabelPlacement, Labeling, MAX_LABEL_CLASSES}; @@ -191,6 +191,12 @@ fn class( report.approximated(layer, "label letter spacing, left out"); } let halo = child(text, "text-buffer").is_some_and(|b| b.attribute("bufferDraw") == Some("1")); + if halo { + report.approximated( + layer, + "label halos in their own colour and width, drawn as RustGIS's (thin, white or dark by the text's colour)", + ); + } if child(text, "background").is_some_and(|b| b.attribute("shapeDraw") == Some("1")) { report.approximated(layer, "label backgrounds (shapes behind the text), left out"); } @@ -198,6 +204,7 @@ fn class( report.approximated(layer, "label shadows, left out"); } let placement = child(settings, "placement").map_or(LabelPlacement::Auto, |p| placement(p, layer, report)); + unread(settings, layer, report); // The settings' own scale range, else the rule's. let rendering = child(settings, "rendering"); let (mut min_scale, mut max_scale) = scales; @@ -224,6 +231,36 @@ fn class( }) } +/// Notes the label settings RustGIS has no place for, where they are set off QGIS's defaults. +fn unread(settings: Node, layer: &str, report: &mut Report) { + let num = + |n: Option, k: &str| n.and_then(|n| n.attribute(k)).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); + let p = child(settings, "placement"); + if num(p, "dist").is_some_and(|d| d != 0.0) { + report.approximated(layer, "label distances from their features, left out"); + } + if num(p, "xOffset").is_some_and(|d| d != 0.0) || num(p, "yOffset").is_some_and(|d| d != 0.0) { + report.approximated(layer, "label offsets, left out"); + } + if num(p, "repeatDistance").is_some_and(|d| d > 0.0) { + report.approximated(layer, "labels repeated along lines, drawn once"); + } + if num(p, "priority").is_some_and(|d| d != 5.0) { + report.approximated(layer, "label priorities, left out"); + } + let format = child(settings, "text-format"); + if format.and_then(|f| f.attribute("wrapChar")).is_some_and(|w| !w.is_empty()) || num(format, "autoWrapLength").is_some_and(|w| w > 0.0) + { + report.approximated(layer, "wrapped labels, drawn on one line"); + } + if child(settings, "callout").is_some_and(|c| prop(&props(c), "enabled") == Some("1")) { + report.skipped(layer, "label callouts (lines from a label to its feature)"); + } + if data_defined_on(settings) { + report.skipped(layer, "data-defined label settings (from fields or expressions)"); + } +} + /// QGIS's label placement, as RustGIS's. fn placement(p: Node, layer: &str, report: &mut Report) -> LabelPlacement { let quad = |q: Option<&str>| match q { diff --git a/crates/map/src/qgis/mod.rs b/crates/map/src/qgis/mod.rs index 44c1c0d10..0b09db0e0 100644 --- a/crates/map/src/qgis/mod.rs +++ b/crates/map/src/qgis/mod.rs @@ -169,6 +169,8 @@ pub struct ProjectLayer { pub source: Source, /// Its coordinate system (`EPSG:…`), when QGIS records one. pub crs: Option, + /// QGIS's kind of layer (`vector`, `raster`, `vector-tile`, `annotation`, `mesh`, …). + pub kind: String, /// A vector layer (`false`: raster, mesh, point cloud and the like). pub vector: bool, /// Checked in the layer tree, and every group holding it too. @@ -186,21 +188,30 @@ pub fn read_project(text: &str, dir: &str, report: &mut Report) -> Result".into()); } + // The main map canvas: its view, and (before QGIS 3) the project's coordinate system. + let canvas = root.children().find(|c| c.has_tag_name("mapcanvas") && c.attribute("name").is_none_or(|n| n == "theMapCanvas")); + let crs = child(root, "projectCrs") + .and_then(crs_of) + .or_else(|| canvas.and_then(|c| child(c, "destinationsrs")).and_then(crs_of)) + .or_else(|| { + let p = child(root, "properties").and_then(|p| child(p, "SpatialRefSys")).and_then(|s| child(s, "ProjectCrs"))?; + p.text().map(str::trim).filter(|t| t.contains(':')).map(str::to_string) + }); let mut project = Project { title: child(root, "title").and_then(|t| t.text()).map(str::trim).unwrap_or_default().to_string(), - crs: child(root, "projectCrs").and_then(crs_of), - extent: None, + crs, + extent: canvas.and_then(|c| child(c, "extent")).and_then(extent_of), layers: Vec::new(), }; - // The main map canvas's view. - let canvas = root.children().find(|c| c.has_tag_name("mapcanvas") && c.attribute("name").is_none_or(|n| n == "theMapCanvas")); - project.extent = canvas.and_then(|c| child(c, "extent")).and_then(extent_of); - // The layer tree: the order layers draw in (top first) and which are checked. - let tree = child(root, "layer-tree-group"); + // The layer tree (QGIS 2's legend before it): the order layers draw in (top first) and which + // are checked. let mut order: Vec<(String, bool)> = Vec::new(); - if let Some(t) = tree { + if let Some(t) = child(root, "layer-tree-group") { tree_layers(t, true, &mut order, 0); + } else if let Some(l) = child(root, "legend") { + legend_layers(l, true, &mut order, 0); } + project_notes(root, report); let maplayers: Vec = child(root, "projectlayers").map(|p| p.children().filter(|c| c.has_tag_name("maplayer")).collect()).unwrap_or_default(); if maplayers.len() > MAX_LAYERS { @@ -216,7 +227,8 @@ pub fn read_project(text: &str, dir: &str, report: &mut Report) -> Result match expr::to_sql(q) { Some(sql) => Some(sql), @@ -236,6 +248,7 @@ pub fn read_project(text: &str, dir: &str, report: &mut Report) -> Result Result` (a project's) or a style file's `` root. fn read_style_node(node: Node, layer: &str, report: &mut Report) -> Style { let mut style = Style::default(); - let opacity = child(node, "layerOpacity").and_then(|n| n.text()).and_then(|t| t.trim().parse::().ok()); - if let Some(o) = opacity.filter(|o| o.is_finite()) { + let number = |tag: &str| child(node, tag).and_then(|n| n.text()).and_then(|t| t.trim().parse::().ok()).filter(|v| v.is_finite()); + // Its opacity: 0 to 1; QGIS 2's transparency 0 to 100; QGIS 1's opacity 0 to 255. + let opacity = number("layerOpacity") + .or_else(|| number("layerTransparency").map(|t| 1.0 - t / 100.0)) + .or_else(|| number("transparencyLevelInt").map(|o| o / 255.0)); + if let Some(o) = opacity { style.transparency = Some(((1.0 - o.clamp(0.0, 1.0)) * 100.0).round()); } if let Some(mode) = child(node, "blendMode").and_then(|n| n.text()).and_then(|t| t.trim().parse::().ok()) { @@ -273,11 +290,18 @@ fn read_style_node(node: Node, layer: &str, report: &mut Report) -> Style { })); } if node.attribute("hasScaleBasedVisibilityFlag") == Some("1") { - // QGIS's minScale is the out-beyond limit (the larger number), maxScale the in-beyond. + // QGIS's minScale is the out-beyond limit (the larger number), maxScale the in-beyond; + // before QGIS 3 they were maximumScale and minimumScale. let scale = |k: &str| node.attribute(k).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite() && *v > 0.0); - style.min_scale = Some(scale("minScale").map(|s| s * QGIS_SCALE)); - style.max_scale = Some(scale("maxScale").map(|s| s * QGIS_SCALE)); + let (out, inside) = if node.attribute("minScale").is_some() || node.attribute("maxScale").is_some() { + (scale("minScale"), scale("maxScale")) + } else { + (scale("maximumScale"), scale("minimumScale")) + }; + style.min_scale = Some(out.map(|s| s * QGIS_SCALE)); + style.max_scale = Some(inside.map(|s| s * QGIS_SCALE)); } + layer_notes(node, layer, report); if let Some(r) = child(node, "renderer-v2") { renderer::read(r, layer, &mut style, report); } @@ -295,10 +319,108 @@ fn read_style_node(node: Node, layer: &str, report: &mut Report) -> Style { l.enabled &= !off; l }); + } else if old_labels(node) { + report.skipped(layer, "its labels, in the format of QGIS 2 and before"); } style } +/// Whether a layer has labels switched on in QGIS 2's way (its custom properties) or QGIS 1's +/// (``). +fn old_labels(node: Node) -> bool { + let custom = child(node, "customproperties").is_some_and(|c| { + c.children() + .any(|p| p.has_tag_name("property") && p.attribute("key") == Some("labeling/enabled") && p.attribute("value") == Some("true")) + }); + custom || child(node, "label").and_then(|n| n.text()).is_some_and(|t| t.trim() == "1") +} + +/// Notes a layer's settings RustGIS has no place for (a project's `` or a style's root). +fn layer_notes(node: Node, layer: &str, report: &mut Report) { + if child(node, "temporal").is_some_and(|t| t.attribute("enabled") == Some("1")) { + report.skipped(layer, "its time settings"); + } + let diagrams = node + .children() + .filter(|c| c.tag_name().name().ends_with("DiagramRenderer")) + .any(|r| r.descendants().any(|d| d.has_tag_name("DiagramCategory") && d.attribute("enabled") == Some("1"))); + if diagrams { + report.skipped(layer, "its diagrams"); + } + if let Some(m) = child(node, "map-layer-style-manager") { + let current = m.attribute("current").unwrap_or_default(); + let others: Vec<&str> = m + .children() + .filter(|s| s.has_tag_name("map-layer-style")) + .filter_map(|s| s.attribute("name")) + .filter(|n| *n != current) + .take(8) + .collect(); + if !others.is_empty() { + report.skipped(layer, format!("its other styles ({}): only the current one is carried over", others.join(", "))); + } + } + let count = |list: &str, item: &str| child(node, list).map_or(0, |l| l.children().filter(|c| c.has_tag_name(item)).count()); + if count("vectorjoins", "join") > 0 { + report.skipped(layer, "its joins to other layers' fields"); + } + if count("expressionfields", "field") > 0 { + report.skipped(layer, "its virtual fields (fields computed from an expression)"); + } + if node.attribute("type") == Some("vector-tile") && child(node, "renderer").is_some() { + report.approximated(layer, "its vector tile style, drawn in RustGIS's own"); + } +} + +/// Notes the project's parts RustGIS has no place for: print layouts, annotations, bookmarks, +/// map themes and relations. +fn project_notes(root: Node, report: &mut Report) { + let count = |list: &str, item: &str| child(root, list).map_or(0, |l| l.children().filter(|c| c.has_tag_name(item)).count()); + let layouts = count("Layouts", "Layout") + root.children().filter(|c| c.has_tag_name("Composer")).count(); + if layouts > 0 { + report.skipped("", format!("its print layouts ({layouts})")); + } + let items = child(root, "main-annotation-layer") + .and_then(|a| child(a, "items")) + .map_or(0, |i| i.children().filter(|c| c.has_tag_name("item")).count()); + if items > 0 { + report.skipped("", format!("its annotations ({items}: text, shapes and pictures on the map)")); + } + let bookmarks = count("Bookmarks", "Bookmark"); + if bookmarks > 0 { + report.skipped("", format!("its bookmarks ({bookmarks})")); + } + let themes = count("visibility-presets", "visibility-preset"); + if themes > 0 { + report.skipped("", format!("its map themes ({themes})")); + } + let relations = count("relations", "relation"); + if relations > 0 { + report.skipped("", format!("its relations between layers ({relations})")); + } +} + +/// Whether `node` (a symbol layer, a renderer) has a draw effect on: a shadow, glow, blur or the +/// like, not only the plain drawing QGIS's effect stack starts with. +pub(crate) fn has_effect(node: Node) -> bool { + let Some(top) = node.children().find(|c| c.has_tag_name("effect")) else { return false }; + if top.attribute("enabled") != Some("1") { + return false; + } + top.descendants().filter(|e| e.has_tag_name("effect")).any(|e| { + let kind = e.attribute("type").unwrap_or_default(); + kind != "effectStack" && kind != "drawSource" && prop(&props(e), "enabled").is_none_or(|on| on == "1") + }) +} + +/// Whether `node` (a symbol layer, label settings) has a data-defined setting switched on: one +/// taken from a field or an expression. +pub(crate) fn data_defined_on(node: Node) -> bool { + node.children().filter(|c| c.has_tag_name("data_defined_properties") || c.has_tag_name("dd_properties")).any(|dd| { + dd.descendants().any(|o| o.has_tag_name("Option") && o.attribute("name") == Some("active") && o.attribute("value") == Some("true")) + }) +} + /// The layers in a layer tree group, top first, each with whether it is checked (and every /// group above it). Groups are walked to a bounded depth. fn tree_layers(group: Node, checked: bool, out: &mut Vec<(String, bool)>, depth: usize) { @@ -317,6 +439,26 @@ fn tree_layers(group: Node, checked: bool, out: &mut Vec<(String, bool)>, depth: } } +/// The layers in QGIS 2's legend (``, before the layer tree), top first, each with +/// whether it is checked (and its group). Groups are walked to a bounded depth. +fn legend_layers(group: Node, checked: bool, out: &mut Vec<(String, bool)>, depth: usize) { + if depth > 64 || out.len() > MAX_LAYERS * 4 { + return; + } + let on = |n: Node| n.attribute("checked").is_none_or(|c| c == "Qt::Checked" || c == "Qt::PartiallyChecked"); + for c in group.children().filter(Node::is_element) { + if c.has_tag_name("legendlayer") { + for f in c.descendants().filter(|f| f.has_tag_name("legendlayerfile")) { + if let Some(id) = f.attribute("layerid") { + out.push((id.to_string(), checked && on(c))); + } + } + } else if c.has_tag_name("legendgroup") { + legend_layers(c, checked && on(c), out, depth + 1); + } + } +} + /// QGIS's blending modes (`QPainter::CompositionMode`) RustGIS has. fn blend_of(mode: i32) -> Option { Some(match mode { diff --git a/crates/map/src/qgis/renderer.rs b/crates/map/src/qgis/renderer.rs index 90f2d5511..72a48b859 100644 --- a/crates/map/src/qgis/renderer.rs +++ b/crates/map/src/qgis/renderer.rs @@ -8,7 +8,7 @@ use std::collections::BTreeMap; use roxmltree::Node; use super::symbol::{self, Converted}; -use super::{QGIS_SCALE, Report, Style, child, expr}; +use super::{QGIS_SCALE, Report, Style, child, expr, has_effect}; use crate::color::Ramp; use crate::symbology::{ClassMethod, LineStroke, Renderer, Symbol}; @@ -30,6 +30,12 @@ fn read_depth(r: Node, layer: &str, style: &mut Style, report: &mut Report, dept if r.attribute("symbollevels") == Some("1") { style.stroke_levels = Some(true); } + if r.attribute("enableorderby") == Some("1") { + report.approximated(layer, "features drawn in the order QGIS's order-by sets, drawn in the data's order"); + } + if has_effect(r) { + report.skipped(layer, "draw effects (shadows, glows, blurs and the like)"); + } // The symbols the renderer names, by name. let symbols: BTreeMap = child(r, "symbols") .map(|s| { @@ -103,10 +109,11 @@ fn categorized(r: Node, layer: &str, style: &mut Style, report: &mut Report, sym if cat.attribute("render") == Some("false") { c = Converted { symbol: symbol::hidden(), strokes: Vec::new() }; } - // A category of several values (QGIS's merged categories) lists them as s. + // A category of several values (QGIS's merged categories) lists them as s, or as a + // list + + + + + + + + + + "# + ); + let mut report = Report::default(); + read_project(&qgs, "", &mut report).unwrap(); + let said = |layer: &str, part: &str| report.notes.iter().any(|n| n.layer == layer && n.text.contains(part)); + for part in ["print layouts (1)", "annotations (2", "bookmarks (1)", "map themes (1)", "relations between layers (1)"] { + assert!(said("", part), "{part}: {:?}", report.notes); + } + for part in [ + "time settings", + "other styles (night)", + "joins", + "virtual fields", + "diagrams", + "order-by", + "draw effects", + "label halos", + "wrapped labels", + "label distances", + "label offsets", + "label priorities", + "callouts", + "data-defined label settings", + ] { + assert!(said("A", part), "{part}: {:?}", report.notes); + } + // The renderer's effect is noted once; the symbol layer's, only plain drawing, isn't an effect. + 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"); +} diff --git a/docs/user-guide/workspace.md b/docs/user-guide/workspace.md index c2feec1cd..077ebde55 100644 --- a/docs/user-guide/workspace.md +++ b/docs/user-guide/workspace.md @@ -36,8 +36,8 @@ Each layer keeps its look: rule-based where every rule picks values of one field (an else rule the rest); point cluster, displacement and other renderers that wrap one are drawn by the one inside. - **Symbols**: simple fills, lines and markers with their colours, widths in any unit, offsets, - caps, joins and the symbol's opacity; a line's simple lines as stacked strokes, its rendering - passes as their levels when the renderer draws in symbol levels. + caps, joins, dashes and the symbol's opacity; a line's simple lines as stacked strokes, its + 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. - **Labels**: simple and rule-based, each rule a label class whose SQL clause is its filter and @@ -46,14 +46,20 @@ Each layer keeps its look: font), its buffer as a halo, its placement (Parallel and Curved along lines, around and over points) and its scale range. +Projects and styles from QGIS 1 and 2 read too: their legend gives the layers' order, and the +older names of the same settings are read. + QGIS expressions carry over where they are SQL: fields compared with values, `LIKE`, `IN`, `IS NULL`, joined by `AND`, `OR` and `NOT`. What RustGIS doesn't draw as QGIS does is drawn as near as it can, or left out, and said: the import's notes list, layer by layer, what was approximated (pattern fills drawn as a tint of their colour, font and SVG markers as circles, -dashed lines solid, 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, labels from expressions, raster colouring, PostGIS, WFS and other sources -RustGIS doesn't read). A database password in a project never reaches the notes. +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 +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"}`).