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"}`).