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