Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion crates/engine/src/cmd/qgis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
5 changes: 5 additions & 0 deletions crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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#"<qgis version="3.42.0"><title>Notes</title><projectlayers><maplayer type="annotation"><id>a</id><layername>Notes</layername></maplayer></projectlayers></qgis>"#;
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#"<qgis version="3.42.0"><projectlayers><maplayer type="vector"><id>a</id><datasource>./data/roads.geojson</datasource><layername>Rel</layername><provider>ogr</provider></maplayer></projectlayers></qgis>"#;
Expand Down
39 changes: 38 additions & 1 deletion crates/map/src/qgis/labels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -191,13 +191,20 @@ 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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (low confidence): this note fires for every label with a buffer, even though a buffer is the case RustGIS does carry over as a halo. Most real projects will show it on nearly every labelled layer. Consider noting it only when the buffer's colour or size differs notably from what RustGIS draws.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping it as is. The note is true for every halo: RustGIS draws its own halo (thin, white or dark by the text colour), so a QGIS buffer's colour and size are always dropped, not only when they differ a lot. Notes repeat per layer once, so a project with many labelled layers gets one line each, which is what the notes list is for. Guessing what counts as "close enough" would hide real differences.

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");
}
if child(text, "shadow").is_some_and(|b| b.attribute("shadowDraw") == Some("1")) {
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;
Expand All @@ -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<Node>, k: &str| n.and_then(|n| n.attribute(k)).and_then(|v| v.trim().parse::<f64>().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 {
Expand Down
170 changes: 156 additions & 14 deletions crates/map/src/qgis/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ pub struct ProjectLayer {
pub source: Source,
/// Its coordinate system (`EPSG:…`), when QGIS records one.
pub crs: Option<String>,
/// 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.
Expand All @@ -186,21 +188,30 @@ pub fn read_project(text: &str, dir: &str, report: &mut Report) -> Result<Projec
if !root.has_tag_name("qgis") {
return Err("not a QGIS project (.qgs): its root element isn't <qgis>".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<Node> =
child(root, "projectlayers").map(|p| p.children().filter(|c| c.has_tag_name("maplayer")).collect()).unwrap_or_default();
if maplayers.len() > MAX_LAYERS {
Expand All @@ -216,7 +227,8 @@ pub fn read_project(text: &str, dir: &str, report: &mut Report) -> Result<Projec
let provider = child(ml, "provider").and_then(|n| n.text()).unwrap_or_default().trim().to_string();
let datasource = child(ml, "datasource").and_then(|n| n.text()).unwrap_or_default().trim().to_string();
let source = source::read(&provider, &datasource, dir);
let vector = ml.attribute("type") == Some("vector");
let kind = ml.attribute("type").unwrap_or_default().to_string();
let vector = kind == "vector";
let definition_query = match &source {
Source::File { subset: Some(q), .. } => match expr::to_sql(q) {
Some(sql) => Some(sql),
Expand All @@ -236,6 +248,7 @@ pub fn read_project(text: &str, dir: &str, report: &mut Report) -> Result<Projec
provider,
source,
crs: child(ml, "srs").and_then(crs_of),
kind,
vector,
visible: checked,
definition_query,
Expand All @@ -262,8 +275,12 @@ pub fn read_style(text: &str, layer: &str, report: &mut Report) -> Result<Style,
/// A layer's style from its `<maplayer>` (a project's) or a style file's `<qgis>` 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::<f32>().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::<f32>().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::<i32>().ok()) {
Expand All @@ -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::<f64>().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);
}
Expand All @@ -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
/// (`<label>1</label>`).
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 `<maplayer>` 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) {
Expand All @@ -317,6 +439,26 @@ fn tree_layers(group: Node, checked: bool, out: &mut Vec<(String, bool)>, depth:
}
}

/// The layers in QGIS 2's legend (`<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<Blend> {
Some(match mode {
Expand Down
13 changes: 10 additions & 3 deletions crates/map/src/qgis/renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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<String, Node> = child(r, "symbols")
.map(|s| {
Expand Down Expand Up @@ -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 <val>s.
// A category of several values (QGIS's merged categories) lists them as <val>s, or as a
// list <Option>.
let mut values: Vec<String> = cat
.descendants()
.filter(|v| v.has_tag_name("Option") && v.attribute("type") == Some("QString"))
.filter(|v| v.has_tag_name("val") || (v.has_tag_name("Option") && v.attribute("type") == Some("QString")))
.filter_map(|v| v.attribute("value").map(str::to_string))
.collect();
if values.is_empty() {
Expand Down
Loading
Loading