diff --git a/crates/assistant/src/tests.rs b/crates/assistant/src/tests.rs
index 345df398c..67f9bcd65 100644
--- a/crates/assistant/src/tests.rs
+++ b/crates/assistant/src/tests.rs
@@ -264,6 +264,9 @@ fn gate_rules() {
assert!(g("run_command", json!({"command": "edit.undo"})).is_none());
assert!(g("run_command", json!({"command": "layer.addData", "params": {"path": "/etc/passwd"}})).is_some());
assert!(g("run_command", json!({"command": "project.new"})).is_some());
+ // A QGIS project given as text names its files and addresses inside: asked, as with a path.
+ let qgis = g("run_command", json!({"command": "project.importQgis", "params": {"text": ""}}));
+ assert!(qgis.as_deref().is_some_and(|w| w.contains("QGIS project")), "{qgis:?}");
assert!(g("run_command", json!({"command": "script.run", "params": {"script": "map.zoomFull {}"}})).is_some());
assert!(g("run_command", json!({"command": "plugin.install", "params": {"path": "x.rgplugin"}})).is_some());
assert!(g("run_command", json!({"command": "map.addTiles", "params": {"url": "https://tile.example/{z}/{x}/{y}.png"}})).is_some());
diff --git a/crates/assistant/src/tools.rs b/crates/assistant/src/tools.rs
index 6f7bc4332..811fa4879 100644
--- a/crates/assistant/src/tools.rs
+++ b/crates/assistant/src/tools.rs
@@ -186,6 +186,9 @@ pub fn gate(s: &Session, call: &ToolCall) -> Option {
"project.new" | "project.open" | "project.openText" | "project.sample" | "project.fromTemplate" => {
"it replaces the open project".into()
}
+ // Whether given a path, text or data: the project names the files and web addresses
+ // its layers are added from.
+ "project.importQgis" => "it adds the files and web addresses a QGIS project names".into(),
_ => "it is reserved to you (plugins, scripts, tasks, models and the GeoLibre runner)".into(),
});
}
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
index fe5bdf9b0..df0ce1e1b 100644
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -29,6 +29,7 @@ pub mod plugins;
pub mod portals;
pub mod presentation;
pub mod project;
+pub mod qgis;
pub mod raster;
pub mod report;
pub mod s3;
@@ -55,6 +56,7 @@ use crate::{CommandSpec, Session};
pub fn all() -> Vec {
let mut v = Vec::new();
v.extend(project::commands());
+ v.extend(qgis::commands());
v.extend(navigate::commands());
v.extend(geocode::commands());
v.extend(directions::commands());
diff --git a/crates/engine/src/cmd/plugins.rs b/crates/engine/src/cmd/plugins.rs
index 8e36722e9..9b79c610f 100644
--- a/crates/engine/src/cmd/plugins.rs
+++ b/crates/engine/src/cmd/plugins.rs
@@ -267,6 +267,9 @@ const NEVER: &[&str] = &[
"project.openText",
"project.sample",
"project.fromTemplate",
+ // A QGIS project names the files and web addresses its layers come from, and the import
+ // adds them without each one passing a plugin's permissions.
+ "project.importQgis",
"edit.undo",
"edit.redo",
// Credentials: a plugin never sets where the user's are sent.
diff --git a/crates/engine/src/cmd/qgis.rs b/crates/engine/src/cmd/qgis.rs
new file mode 100644
index 000000000..dd6bcbbe3
--- /dev/null
+++ b/crates/engine/src/cmd/qgis.rs
@@ -0,0 +1,334 @@
+//! QGIS projects and layer styles: `project.importQgis` opens a `.qgs` (or `.qgz`) as a new map,
+//! with its layers, their order, visibility and styles, its coordinate system and its view;
+//! `style.importQgis` gives a layer a `.qml` style. Both say what they couldn't carry over
+//! (`rustgis_map::qgis::Report`).
+
+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 crate::{CmdResult, CommandSpec as C, Session};
+
+pub fn commands() -> Vec {
+ 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(
+ r#"{"layer"?, "path"?: "*.qml", "text"?: "a .qml file's text", "data"?: "a .qml file, base64"} → notes on what wasn't carried over"#,
+ ),
+ ]
+}
+
+/// A QGIS file's text: a `.qgs` or `.qml` as it is, a `.qgz` (a zip archive) its `.qgs`.
+fn qgis_text(p: &Value) -> Result {
+ let bytes = match (ps(p, "data"), ps(p, "text"), ps(p, "path")) {
+ (Some(d), ..) => {
+ // Base64 is 4 characters for 3 bytes: a payload past the file limit isn't decoded.
+ if d.len() as u64 > MAX_FILE / 3 * 4 + 4 {
+ return Err("data is too large for a QGIS file".into());
+ }
+ use base64::Engine;
+ base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|_| "data must be base64".to_string())?
+ }
+ (None, Some(t), _) if t.len() > MAX_XML => return Err("text is too large for a QGIS project or style".into()),
+ (None, Some(t), _) => return Ok(t.to_string()),
+ (None, None, Some(path)) => read_file(path)?,
+ _ => return Err("path, text or data is required".into()),
+ };
+ if bytes.starts_with(b"PK") {
+ let entries = rustgis_data::zip::read_matching_within(&bytes, |n| n.to_ascii_lowercase().ends_with(".qgs"), MAX_XML)?;
+ let (_, qgs) = entries.into_iter().next().ok_or("the .qgz holds no .qgs project")?;
+ return String::from_utf8(qgs).map_err(|_| "the .qgz's project isn't text".to_string());
+ }
+ if bytes.len() > MAX_XML {
+ return Err("too large for a QGIS project or style".into());
+ }
+ String::from_utf8(bytes).map_err(|_| "not a QGIS file (it isn't text)".to_string())
+}
+
+/// The most bytes of a QGIS file read (a `.qgz` can carry data files beside its project).
+const MAX_FILE: u64 = 256 << 20;
+/// The most bytes of a project's or style's XML parsed: its tree is several times larger than
+/// the text, and real ones are a few megabytes.
+const MAX_XML: usize = 64 << 20;
+
+/// Reads at most [`MAX_FILE`] bytes of `path` (natively: the web app passes `text` or `data`).
+fn read_file(path: &str) -> Result, String> {
+ use std::io::Read;
+ let mut bytes = Vec::new();
+ std::fs::File::open(path).and_then(|f| f.take(MAX_FILE + 1).read_to_end(&mut bytes)).map_err(|e| format!("{path}: {e}"))?;
+ if bytes.len() as u64 > MAX_FILE {
+ return Err(format!("{path} is too large for a QGIS project"));
+ }
+ Ok(bytes)
+}
+
+fn import_project(s: &mut Session, p: &Value) -> CmdResult {
+ let text = qgis_text(p)?;
+ // The folder relative paths start from: `dir` (a blank one is none, so they don't start
+ // from the working directory), else the file's own (`.` for a file named without one).
+ let dir = ps(p, "dir").filter(|d| !d.trim().is_empty()).map(str::to_string).or_else(|| {
+ ps(p, "path").and_then(|path| std::path::Path::new(path).parent()).map(|d| match d.to_string_lossy() {
+ d if d.is_empty() => ".".to_string(),
+ d => d.into_owned(),
+ })
+ });
+ let mut report = Report::default();
+ let project = qgis::read_project(&text, dir.as_deref().unwrap_or_default(), &mut report)?;
+ // A map of its own, named as the project.
+ let name = if project.title.is_empty() { "QGIS project" } else { project.title.as_str() };
+ s.run_part("map.new", &json!({"name": name}))?;
+ // QGIS draws a project on its canvas alone: no basemap, no graticule.
+ let m = s.map_mut();
+ m.basemap = rustgis_map::Basemap::Blank;
+ m.graticule = false;
+ if let Some(crs) = &project.crs {
+ let code = match crs.as_str() {
+ "EPSG:3857" | "EPSG:900913" => "mercator",
+ other => other,
+ };
+ if let Err(e) = s.run_part("map.projection", &json!({"type": code})) {
+ report.approximated("", format!("the project's coordinate system {crs} ({e}), drawn in Web Mercator"));
+ }
+ }
+ // Bottom first, so each is added on top of the ones below it.
+ let mut added = Vec::new();
+ for layer in project.layers.iter().rev() {
+ match add_layer(s, layer, dir.is_some(), &mut report) {
+ Ok(Some(name)) => added.push(name),
+ Ok(None) => {}
+ Err(e) => report.skipped(&layer.name, format!("the layer: {e}")),
+ }
+ }
+ go_to_view(s, &project);
+ let notes: Vec = report.notes.iter().map(note_json).collect();
+ if !report.notes.is_empty() {
+ let left = report.notes.iter().filter(|n| n.kind == NoteKind::Skipped).count();
+ s.notify(
+ format!(
+ "Imported {} of {} QGIS layers; {} things drawn differently, {left} left out (see the import's notes)",
+ added.len(),
+ project.layers.len(),
+ report.notes.len() - left
+ ),
+ false,
+ );
+ }
+ Ok(json!({"map": name, "layers": added, "notes": notes}))
+}
+
+/// Adds one QGIS layer to the active map with its style: `Ok(Some(name))` when added, `Ok(None)`
+/// when its source is one RustGIS doesn't read (noted). Without the project's folder (`has_dir`:
+/// a project given as text or data with no `dir`), a relative path has nothing to start from and
+/// is noted rather than looked for in the working directory.
+fn add_layer(s: &mut Session, layer: &qgis::ProjectLayer, has_dir: bool, report: &mut Report) -> Result
+
+ 0.5"#;
+ 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));
+ assert!(s.run("style.importQgis", &json!({"layer": "Rivers", "text": "
+
+ "#;
+ let out = s.run("style.importQgis", &json!({"layer": "Rivers", "text": qml})).unwrap();
+ let text = serde_json::to_string(&out["notes"]).unwrap();
+ assert!(text.contains("nofield") && text.contains("nolabelfield"), "{text}");
+ let rivers = s.map().layer_by_name("Rivers").unwrap();
+ assert!(rivers.labels.classes.iter().chain(std::iter::once(&rivers.labels.default)).all(|c| c.field != "nolabelfield" || !c.visible));
+ // A project's web layer keeps its visibility and opacity; its scale range is noted.
+ let qgs = r#"Web
+
+ xtype=xyz&url=https://tiles.example/%7Bz%7D/%7Bx%7D/%7By%7D.png&zmax=19&zmin=0Tileswms
+ 13"#;
+ let out = s.run("project.importQgis", &json!({"text": qgs})).unwrap();
+ let t = s.map().tiles.iter().find(|t| t.name == "Tiles").expect("the web layer is added");
+ assert!(!t.visible, "unchecked in the layer tree");
+ assert!((t.opacity - 0.25).abs() < 1e-6, "{}", t.opacity);
+ let text = serde_json::to_string(&out["notes"]).unwrap();
+ assert!(text.contains("scale range"), "{text}");
+ // Web layers are overlays, so they stay in the tree's order, and take the layer's blending.
+ 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 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"#;
+ for p in [json!({"text": rel}), json!({"text": rel, "dir": " "})] {
+ let out = s.run("project.importQgis", &p).unwrap();
+ assert!(out["layers"].as_array().unwrap().is_empty(), "{p}");
+ let text = serde_json::to_string(&out["notes"]).unwrap();
+ assert!(text.contains("pass dir"), "{text}");
+ }
+ // XML past what's parsed (64 MiB) is refused before parsing.
+ assert!(s.run("project.importQgis", &json!({"text": " ".repeat((64 << 20) + 1)})).is_err());
+ // A path relative to a drive's working directory is noted, with or without a folder.
+ let drive = rel.replace("./data/roads.geojson", "C:roads.geojson");
+ let out = s.run("project.importQgis", &json!({"text": drive, "dir": dir.display().to_string()})).unwrap();
+ assert!(serde_json::to_string(&out["notes"]).unwrap().contains("drive"), "{out}");
+ // A web layer a service offers as features or a coverage comes in as a map layer, which takes
+ // the QGIS layer's visibility, opacity, blending and scale range.
+ let web = r#"
+ wurl=https://wms.example/wms&layers=aWebwms
+ 13"#;
+ let mut report = rustgis_map::qgis::Report::default();
+ let project = rustgis_map::qgis::read_project(web, "", &mut report).unwrap();
+ let mut w = world();
+ let before = w.active_layer;
+ w.active_layer = w.map().layers.iter().map(|l| l.id).find(|id| Some(*id) != before);
+ crate::cmd::qgis::web_settings(&mut w, &json!({"name": "Web"}), before, &project.layers[0], &mut report);
+ let l = w.map().layers.iter().find(|l| Some(l.id) == w.active_layer).unwrap();
+ assert!(!l.visible);
+ assert_eq!((l.transparency, l.blend), (50.0, rustgis_map::effects::Blend::Multiply));
+ assert!(l.min_scale.is_some() && l.max_scale.is_some());
+ // Plugins can't run the import: a project names files and web addresses its layers come from.
+ assert!(crate::cmd::plugins::user_only("project.importQgis"));
+ std::fs::remove_dir_all(&dir).ok();
+}
+
#[test]
fn project_save_and_open_roundtrip() {
let mut s = world();
@@ -1358,6 +1485,8 @@ fn hostile_params_never_panic() {
json!({"type": "albers", "lon_0": f64::MAX, "lat_1": "x", "lat_2": -1e308}),
json!({"kind": "legend", "element": 1, "exclude": [null, 7, "", {"x": 1}]}),
json!({"kind": "text", "element": 1, "font": {"family": "serif"}, "italic": [], "align": 7}),
+ // A QGIS project and style whose web layer and scale range are junk.
+ json!({"text": "type=xyz&url=&zmax=999wms"}),
// A label class placed along lines, with junk the placement and style read after it.
json!({"layer": "Countries", "position": "curved", "italic": "maybe", "size": -1e308}),
// A publication sheet's style, accent and orientation, and a shape's rounded corners.
diff --git a/crates/map/src/lib.rs b/crates/map/src/lib.rs
index b6aed7995..4b174d19b 100644
--- a/crates/map/src/lib.rs
+++ b/crates/map/src/lib.rs
@@ -14,6 +14,7 @@ pub mod palette;
pub mod planets;
pub mod presentation;
pub mod projection;
+pub mod qgis;
pub mod raster;
pub mod report;
pub mod source;
diff --git a/crates/map/src/palette.rs b/crates/map/src/palette.rs
index 27ee53b14..cc85af0eb 100644
--- a/crates/map/src/palette.rs
+++ b/crates/map/src/palette.rs
@@ -264,22 +264,7 @@ fn qgis_ramp(node: Node, name: &str) -> Result
, String> {
/// A ramp's properties: `` children (older QGIS) or `
` inside an
/// `
` (QGIS 3.26 and later).
fn qgis_props(node: Node) -> Vec<(String, String)> {
- let mut props = Vec::new();
- for c in node.children().filter(Node::is_element) {
- if c.has_tag_name("prop")
- && let (Some(k), Some(v)) = (c.attribute("k"), c.attribute("v"))
- {
- props.push((k.to_string(), v.to_string()));
- }
- if c.has_tag_name("Option") {
- for o in c.children().filter(|o| o.has_tag_name("Option")) {
- if let (Some(k), Some(v)) = (o.attribute("name"), o.attribute("value")) {
- props.push((k.to_string(), v.to_string()));
- }
- }
- }
- }
- props
+ crate::qgis::props(node)
}
/// The stops of a QGIS gradient, `pos;color[;spec;direction]` joined by `:`. Newer colours
@@ -302,16 +287,7 @@ fn qgis_stops(s: &str) -> Vec<&str> {
/// A QGIS colour: `r,g,b,a` (with anything after, such as `,rgb:…`) or `#RRGGBB[AA]`.
fn qgis_color(s: &str) -> Option {
- let s = s.trim();
- if s.starts_with('#') {
- return Rgba::from_hex(s);
- }
- let v: Vec = s.split(',').take(4).map(|p| p.trim().parse::().ok()).collect::
>()?;
- match v.as_slice() {
- [r, g, b] => Some(Rgba::rgb(*r, *g, *b)),
- [r, g, b, a] => Some(Rgba::new(*r, *g, *b, *a)),
- _ => None,
- }
+ crate::qgis::color(s)
}
/// Stops at 0..=1 made into steps: each colour holds until the next stop's position.
diff --git a/crates/map/src/qgis/expr.rs b/crates/map/src/qgis/expr.rs
new file mode 100644
index 000000000..ba7a524b6
--- /dev/null
+++ b/crates/map/src/qgis/expr.rs
@@ -0,0 +1,190 @@
+//! QGIS expressions as RustGIS SQL clauses: the part of QGIS's expression language a filter
+//! mostly is (fields compared with values, `LIKE`, `IS NULL`, `IN`, joined by `AND`, `OR` and
+//! `NOT`) carried over as it is, and anything else (functions, arithmetic, `CASE`, variables)
+//! refused rather than guessed at, so the caller can note it.
+
+/// The most characters of an expression read.
+const MAX_LEN: usize = 10_000;
+
+#[derive(Clone, Debug, PartialEq)]
+enum Token {
+ /// A field name: written back double-quoted.
+ Field(String),
+ /// A text value: written back single-quoted.
+ Text(String),
+ /// A number, a comparison, a keyword, a bracket or a comma: written back as it is.
+ Word(String),
+}
+
+/// `expr` as a RustGIS SQL clause, or `None` when it uses more of QGIS's language than that has.
+/// The clause is checked against a layer's fields where it is used.
+pub fn to_sql(expr: &str) -> Option {
+ let expr = expr.trim();
+ if expr.is_empty() || expr.len() > MAX_LEN {
+ return None;
+ }
+ let tokens = tokenize(expr)?;
+ let mut out = Vec::with_capacity(tokens.len());
+ for t in &tokens {
+ out.push(match t {
+ Token::Field(f) => format!("\"{}\"", f.replace('"', "")),
+ Token::Text(s) => format!("'{}'", s.replace('\'', "''")),
+ Token::Word(w) => w.clone(),
+ });
+ }
+ Some(out.join(" "))
+}
+
+/// The field and values of a filter that only picks values of one field: `"f" = 'v'`, `f = 3`,
+/// `"f" IN ('a', 'b')`. `None` for any other.
+pub(super) fn equality(expr: &str) -> Option<(String, Vec)> {
+ let tokens = tokenize(expr.trim())?;
+ let value = |t: &Token| match t {
+ Token::Text(s) => Some(s.clone()),
+ Token::Word(w) if w.parse::().is_ok() => Some(w.clone()),
+ _ => None,
+ };
+ match tokens.as_slice() {
+ [Token::Field(f), Token::Word(eq), v] if eq == "=" => Some((f.clone(), vec![value(v)?])),
+ [Token::Field(f), Token::Word(kw), Token::Word(open), rest @ .., Token::Word(close)]
+ if kw == "IN" && open == "(" && close == ")" =>
+ {
+ let values: Vec = rest.iter().step_by(2).map(value).collect::
>()?;
+ let commas_ok = rest.iter().skip(1).step_by(2).all(|t| *t == Token::Word(",".into()));
+ (commas_ok && !values.is_empty()).then(|| (f.clone(), values))
+ }
+ _ => None,
+ }
+}
+
+/// The words RustGIS's SQL has (`ILIKE` is its `LIKE`, which ignores case).
+fn keyword(w: &str) -> Option<&'static str> {
+ Some(match w.to_ascii_uppercase().as_str() {
+ "AND" => "AND",
+ "OR" => "OR",
+ "NOT" => "NOT",
+ "IN" => "IN",
+ "LIKE" | "ILIKE" => "LIKE",
+ "IS" => "IS",
+ "NULL" => "NULL",
+ _ => return None,
+ })
+}
+
+fn tokenize(s: &str) -> Option> {
+ let chars: Vec = s.chars().collect();
+ let mut out = Vec::new();
+ let mut i = 0;
+ while let Some(&c) = chars.get(i) {
+ match c {
+ c if c.is_whitespace() => i += 1,
+ '"' => {
+ let (text, next) = quoted(&chars, i, '"')?;
+ out.push(Token::Field(text));
+ i = next;
+ }
+ '\'' => {
+ let (text, next) = quoted(&chars, i, '\'')?;
+ out.push(Token::Text(text));
+ i = next;
+ }
+ '(' | ')' | ',' => {
+ out.push(Token::Word(c.to_string()));
+ i += 1;
+ }
+ '=' => {
+ out.push(Token::Word("=".into()));
+ i += 1;
+ }
+ '<' | '>' | '!' => {
+ let two: String = chars.get(i..(i + 2).min(chars.len())).unwrap_or_default().iter().collect();
+ let op = match two.as_str() {
+ "<=" | ">=" | "<>" | "!=" => two,
+ _ if c != '!' => c.to_string(),
+ _ => return None,
+ };
+ i += op.chars().count();
+ out.push(Token::Word(op));
+ }
+ c if c.is_ascii_digit() || (c == '-' && chars.get(i + 1).is_some_and(char::is_ascii_digit)) => {
+ let start = i;
+ i += 1;
+ while chars.get(i).is_some_and(|d| d.is_ascii_digit() || *d == '.') {
+ i += 1;
+ }
+ // An exponent: 1e3, 2.5E-4.
+ if chars.get(i).is_some_and(|e| *e == 'e' || *e == 'E') {
+ let sign = usize::from(chars.get(i + 1).is_some_and(|s| *s == '-' || *s == '+'));
+ if chars.get(i + 1 + sign).is_some_and(char::is_ascii_digit) {
+ i += 1 + sign;
+ while chars.get(i).is_some_and(char::is_ascii_digit) {
+ i += 1;
+ }
+ }
+ }
+ let n: String = chars.get(start..i).unwrap_or_default().iter().collect();
+ n.parse::().ok()?;
+ out.push(Token::Word(n));
+ }
+ c if c.is_alphabetic() || c == '_' => {
+ let start = i;
+ while chars.get(i).is_some_and(|d| d.is_alphanumeric() || *d == '_') {
+ i += 1;
+ }
+ let w: String = chars.get(start..i).unwrap_or_default().iter().collect();
+ // A word followed by a bracket is a function call.
+ let next = chars.get(i..).unwrap_or_default().iter().find(|c| !c.is_whitespace());
+ match keyword(&w) {
+ Some(k) => out.push(Token::Word(k.into())),
+ None if next == Some(&'(') => return None,
+ None => out.push(Token::Field(w)),
+ }
+ }
+ // Arithmetic, concatenation, variables (@…) and the rest.
+ _ => return None,
+ }
+ if out.len() > 2000 {
+ return None;
+ }
+ }
+ (!out.is_empty()).then_some(out)
+}
+
+/// The text between the quote at `start` and its closing one (a doubled quote stands for
+/// itself), and where reading goes on.
+fn quoted(chars: &[char], start: usize, q: char) -> Option<(String, usize)> {
+ let mut text = String::new();
+ let mut i = start + 1;
+ loop {
+ match chars.get(i) {
+ None => return None,
+ Some(&c) if c == q && chars.get(i + 1) == Some(&q) => {
+ text.push(q);
+ i += 2;
+ }
+ Some(&c) if c == q => return Some((text, i + 1)),
+ Some(&c) => {
+ text.push(c);
+ i += 1;
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::to_sql;
+
+ #[test]
+ fn filters_carry_over_and_the_rest_is_refused() {
+ assert_eq!(to_sql(r#""Symbol" = 'Ortsteil_20-49'"#).as_deref(), Some(r#""Symbol" = 'Ortsteil_20-49'"#));
+ assert_eq!(
+ to_sql(r#"pop >= 1e3 AND ("name" ILIKE 'z%' OR kind IN ('a', 'b''s')) AND note IS NOT NULL"#).as_deref(),
+ Some(r#""pop" >= 1e3 AND ( "name" LIKE 'z%' OR "kind" IN ( 'a' , 'b''s' ) ) AND "note" IS NOT NULL"#)
+ );
+ assert_eq!(to_sql("a <> -2.5").as_deref(), Some(r#""a" <> -2.5"#));
+ for refused in ["upper(\"name\") = 'X'", "\"a\" + 1 > 2", "\"a\" || 'x' = 'y'", "@map_scale < 5000", "'unclosed", "", "a ! b"] {
+ assert_eq!(to_sql(refused), None, "{refused}");
+ }
+ }
+}
diff --git a/crates/map/src/qgis/labels.rs b/crates/map/src/qgis/labels.rs
new file mode 100644
index 000000000..9e507e626
--- /dev/null
+++ b/crates/map/src/qgis/labels.rs
@@ -0,0 +1,258 @@
+//! QGIS labels (``) as RustGIS label classes: each rule a
+//! class whose SQL clause is its filter (and its parents'), an else rule the features no other
+//! rule takes; the text's field, size, colour, weight and slant, its buffer as a halo, its
+//! placement and its scale range.
+
+use roxmltree::Node;
+
+use super::{QGIS_SCALE, Report, child, color, expr};
+use crate::Rgba;
+use crate::symbology::{DEFAULT_LABEL_CLASS, LABEL_SIZE, LabelClass, LabelPlacement, Labeling, MAX_LABEL_CLASSES};
+
+/// A layer's labels from its ``, or `None` when none can be carried over.
+pub(super) fn read(l: Node, layer: &str, report: &mut Report) -> Option {
+ let mut classes: Vec = Vec::new();
+ match l.attribute("type") {
+ Some("simple") => {
+ let settings = child(l, "settings")?;
+ classes.extend(class(settings, DEFAULT_LABEL_CLASS, None, (None, None), layer, report));
+ }
+ Some("rule-based") => {
+ let mut rules = Vec::new();
+ if let Some(root) = child(l, "rules") {
+ flatten(root, Vec::new(), (None, None), &mut rules, 0);
+ }
+ for (i, r) in rules.iter().enumerate() {
+ let Some(settings) = child(r.node, "settings") else { continue };
+ let name = r
+ .node
+ .attribute("description")
+ .filter(|d| !d.trim().is_empty())
+ .map_or_else(|| format!("Rule {}", i + 1), str::to_string);
+ // Its filters and its parents', and for an else rule, none of its siblings'.
+ let mut parts: Vec
> = r.filters.iter().map(|f| expr::to_sql(f)).collect();
+ if let Some(siblings) = &r.else_of {
+ let others: Option> = siblings.iter().map(|f| expr::to_sql(f)).collect();
+ parts.push(others.map(|o| if o.is_empty() { String::new() } else { format!("NOT (({}))", o.join(") OR (")) }));
+ }
+ let where_ = parts.into_iter().collect::
>>().map(|p| {
+ let p: Vec = p.into_iter().filter(|c| !c.is_empty()).collect();
+ match p.as_slice() {
+ [] => None,
+ [one] => Some(one.clone()),
+ many => Some(format!("({})", many.join(") AND ("))),
+ }
+ });
+ let Some(where_) = where_ else {
+ report.skipped(
+ layer,
+ format!(
+ "the label rule \"{name}\", whose filter `{}` doesn't read as a RustGIS SQL clause",
+ r.filters.join(" AND ")
+ ),
+ );
+ continue;
+ };
+ if r.node.attribute("active") == Some("0") {
+ continue;
+ }
+ classes.extend(class(settings, &name, where_, r.scales, layer, report));
+ }
+ }
+ Some(other) => {
+ report.skipped(layer, format!("its {other} labels"));
+ return None;
+ }
+ None => return None,
+ }
+ if classes.is_empty() {
+ return None;
+ }
+ if classes.len() > MAX_LABEL_CLASSES {
+ report.skipped(layer, format!("label rules past the first {MAX_LABEL_CLASSES}"));
+ classes.truncate(MAX_LABEL_CLASSES);
+ }
+ // A simple labeling is the Default class; rules are classes of their own, Default off.
+ let default = if l.attribute("type") == Some("simple") {
+ classes.remove(0)
+ } else {
+ let first = classes.first()?.clone();
+ LabelClass { name: DEFAULT_LABEL_CLASS.into(), visible: false, where_: None, ..first }
+ };
+ let classes = if l.attribute("type") == Some("simple") { Vec::new() } else { classes };
+ Some(Labeling { enabled: true, default, classes })
+}
+
+fn is_else(f: &str) -> bool {
+ f.trim().eq_ignore_ascii_case("ELSE")
+}
+
+/// A rule-based labeling's rule with settings: its filter and its parents' (an else rule's own
+/// left out), for an else rule its siblings' filters (it takes what none of them does), and the
+/// scale range along the way (RustGIS's min and max scale).
+struct Rule<'a, 'i> {
+ node: Node<'a, 'i>,
+ filters: Vec,
+ else_of: Option>,
+ scales: (Option, Option),
+}
+
+fn flatten<'a, 'i>(
+ node: Node<'a, 'i>,
+ filters: Vec,
+ scales: (Option, Option),
+ out: &mut Vec>,
+ depth: usize,
+) {
+ if depth > 32 || out.len() > MAX_LABEL_CLASSES * 4 {
+ return;
+ }
+ let rules: Vec = node.children().filter(|c| c.has_tag_name("rule")).collect();
+ // The filters an else rule here is the rest of.
+ let siblings: Vec = rules
+ .iter()
+ .filter_map(|r| r.attribute("filter").map(str::trim))
+ .filter(|f| !f.is_empty() && !is_else(f))
+ .map(str::to_string)
+ .collect();
+ for rule in rules {
+ let mut f = filters.clone();
+ let own = rule.attribute("filter").map(str::trim).filter(|f| !f.is_empty());
+ let else_of = own.filter(|o| is_else(o)).map(|_| siblings.clone());
+ if let Some(filter) = own.filter(|o| !is_else(o)) {
+ f.push(filter.to_string());
+ }
+ // A rule's scalemaxdenom is the out-beyond limit, its scalemindenom the in-beyond.
+ let denom = |k: &str| {
+ rule.attribute(k).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite() && *v > 0.0).map(|v| v * QGIS_SCALE)
+ };
+ let s = (denom("scalemaxdenom").or(scales.0), denom("scalemindenom").or(scales.1));
+ if child(rule, "settings").is_some() {
+ out.push(Rule { node: rule, filters: f.clone(), else_of, scales: s });
+ }
+ flatten(rule, f, s, out, depth + 1);
+ }
+}
+
+/// A label class from QGIS's label `settings`, or `None` when its text can't be carried over.
+fn class(
+ settings: Node,
+ name: &str,
+ where_: Option,
+ scales: (Option, Option),
+ layer: &str,
+ report: &mut Report,
+) -> Option {
+ let text = child(settings, "text-style")?;
+ let field = text.attribute("fieldName").map(str::trim).unwrap_or_default();
+ let field = if text.attribute("isExpression") == Some("1") {
+ // An expression that is only a field.
+ let bare = field.strip_prefix('"').and_then(|f| f.strip_suffix('"')).unwrap_or(field);
+ if bare.is_empty() || !bare.chars().all(|c| c.is_alphanumeric() || c == '_' || c == ' ') {
+ report.skipped(layer, format!("labels whose text is the expression `{field}` (only a field's)"));
+ return None;
+ }
+ bare.to_string()
+ } else {
+ field.to_string()
+ };
+ if field.is_empty() {
+ return None;
+ }
+ let num = |n: Node, k: &str| n.attribute(k).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite());
+ // The size in typographic points, as RustGIS's label sizes are.
+ let raw = num(text, "fontSize").unwrap_or(10.0);
+ let size = match text.attribute("fontSizeUnit").unwrap_or("Point") {
+ "Point" => raw,
+ "MM" => raw * 72.0 / 25.4,
+ "Pixel" => raw * 0.75,
+ "Inch" => raw * 72.0,
+ _ => {
+ report.approximated(layer, "label sizes in map units, taken at 1:25,000");
+ raw / 25_000.0 * 72.0 / 0.0254
+ }
+ };
+ let opacity = num(text, "textOpacity").unwrap_or(1.0).clamp(0.0, 1.0) as f32;
+ let c = text.attribute("textColor").and_then(color).unwrap_or(Rgba::rgb(0, 0, 0));
+ let c = c.with_alpha((f32::from(c.a()) * opacity).round() as u8);
+ let style = text.attribute("namedStyle").unwrap_or_default().to_ascii_lowercase();
+ let weight = num(text, "fontWeight").unwrap_or(50.0);
+ let bold = style.contains("bold")
+ || style.contains("black")
+ || style.contains("heavy")
+ || weight >= 63.0
+ || text.attribute("fontBold") == Some("1");
+ let italic = style.contains("italic") || style.contains("oblique") || text.attribute("fontItalic") == Some("1");
+ if let Some(family) = text.attribute("fontFamily").filter(|f| !f.is_empty()) {
+ report.approximated(layer, format!("labels in {family}, drawn in RustGIS's label font"));
+ }
+ if num(text, "fontLetterSpacing").is_some_and(|s| s != 0.0) {
+ report.approximated(layer, "label letter spacing, left out");
+ }
+ let halo = child(text, "text-buffer").is_some_and(|b| b.attribute("bufferDraw") == Some("1"));
+ 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));
+ // The settings' own scale range, else the rule's.
+ let rendering = child(settings, "rendering");
+ let (mut min_scale, mut max_scale) = scales;
+ if let Some(r) = rendering.filter(|r| r.attribute("scaleVisibility") == Some("1")) {
+ min_scale = num(r, "scaleMin").filter(|v| *v > 0.0).map(|v| v * QGIS_SCALE).or(min_scale);
+ max_scale = num(r, "scaleMax").filter(|v| *v > 0.0).map(|v| v * QGIS_SCALE).or(max_scale);
+ }
+ Some(LabelClass {
+ name: name.to_string(),
+ visible: true,
+ where_,
+ field,
+ size: size.clamp(*LABEL_SIZE.start(), *LABEL_SIZE.end()) as f32,
+ color: c,
+ halo,
+ bold,
+ min_scale,
+ max_scale,
+ closest: None,
+ farthest: None,
+ placement,
+ italic,
+ })
+}
+
+/// QGIS's label placement, as RustGIS's.
+fn placement(p: Node, layer: &str, report: &mut Report) -> LabelPlacement {
+ let quad = |q: Option<&str>| match q {
+ Some("0") => LabelPlacement::AboveLeft,
+ Some("1") => LabelPlacement::Above,
+ Some("2") => LabelPlacement::AboveRight,
+ Some("3") => LabelPlacement::Left,
+ Some("5") => LabelPlacement::Right,
+ Some("6") => LabelPlacement::BelowLeft,
+ Some("7") => LabelPlacement::Below,
+ Some("8") => LabelPlacement::BelowRight,
+ _ => LabelPlacement::Center,
+ };
+ match p.attribute("placement").unwrap_or("0") {
+ // Around the point: QGIS picks the best spot, RustGIS's automatic is above it.
+ "0" | "6" => LabelPlacement::Auto,
+ "1" => quad(p.attribute("quadOffset")),
+ "2" => LabelPlacement::Parallel,
+ "3" => LabelPlacement::Curved,
+ "4" | "5" => LabelPlacement::Center,
+ other => {
+ report.approximated(layer, format!("label placement {}, centred", placement_name(other)));
+ LabelPlacement::Center
+ }
+ }
+}
+
+fn placement_name(code: &str) -> &'static str {
+ match code {
+ "7" => "around polygon outlines",
+ "8" => "outside polygons",
+ _ => "of an unknown kind",
+ }
+}
diff --git a/crates/map/src/qgis/mod.rs b/crates/map/src/qgis/mod.rs
new file mode 100644
index 000000000..44c1c0d10
--- /dev/null
+++ b/crates/map/src/qgis/mod.rs
@@ -0,0 +1,429 @@
+//! QGIS projects (`.qgs`) and layer styles (`.qml`) read into RustGIS: a project's layers (their
+//! data sources, order, visibility, scale ranges and opacity), its coordinate system and view,
+//! and each layer's style (renderer, symbols, labels, reference scale and symbol levels), as the
+//! map's own types. What RustGIS can't draw as QGIS does is carried over as near as it can, and
+//! the [`Report`] says so, layer by layer: nothing is left out without a note.
+//!
+//! The XML is hostile input: it is read with a node limit and no DTD, every list is bounded, and
+//! sizes and scales are brought into range before they reach a layer. Both of QGIS's ways of
+//! writing a symbol layer's settings are read: the `
` of QGIS 3.26 and later,
+//! and the `` before it.
+
+mod expr;
+mod labels;
+mod renderer;
+mod source;
+mod symbol;
+#[cfg(test)]
+mod tests;
+
+use std::collections::HashMap;
+
+use roxmltree::{Document, Node, ParsingOptions};
+use serde::Serialize;
+
+use crate::Rgba;
+use crate::effects::Blend;
+use crate::symbology::{Labeling, LineStroke, Renderer};
+pub use expr::to_sql;
+pub use source::{Source, is_drive_relative, is_relative};
+
+/// The most XML nodes a project or style is read with (a large project's file is some hundred
+/// thousand).
+const MAX_NODES: u32 = 5_000_000;
+/// The most layers read from a project.
+pub const MAX_LAYERS: usize = 1000;
+/// The most notes a report keeps.
+const MAX_NOTES: usize = 5000;
+
+/// RustGIS's scale takes a screen pixel to be 0.28 mm (OGC); QGIS works out its scale at the
+/// screen's 96 dots to the inch. The same view is a larger scale in QGIS by this much, so QGIS's
+/// scales (reference scales, visibility ranges) are multiplied by it.
+pub const QGIS_SCALE: f64 = (25.4 / 96.0) / 0.28;
+
+/// What an import carried over only in part, or left out, layer by layer.
+#[derive(Clone, Debug, Default, PartialEq, Serialize)]
+pub struct Report {
+ /// Added to by [`Report::note`] only, which keeps `index` in step.
+ pub notes: Vec,
+ /// Where each note is in `notes`, by layer, kind and text: a hostile project can make
+ /// thousands of different notes, and a repeat is found without going through them all.
+ #[serde(skip)]
+ index: HashMap<(String, NoteKind, String), usize>,
+}
+
+/// One thing an import couldn't carry over as QGIS has it.
+#[derive(Clone, Debug, PartialEq, Serialize)]
+pub struct Note {
+ /// The layer it is about (empty for the project).
+ pub layer: String,
+ pub kind: NoteKind,
+ pub text: String,
+ /// How many times it came up (once per symbol, class or rule it applies to).
+ pub count: usize,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum NoteKind {
+ /// Drawn, but not quite as QGIS draws it.
+ Approximated,
+ /// Not carried over at all.
+ Skipped,
+}
+
+impl Report {
+ /// Notes `text` about `layer`, counting a repeat rather than listing it again.
+ pub fn note(&mut self, layer: &str, kind: NoteKind, text: impl Into) {
+ let key = (layer.to_string(), kind, text.into());
+ if let Some(n) = self.index.get(&key).and_then(|&i| self.notes.get_mut(i)) {
+ n.count = n.count.saturating_add(1);
+ } else if self.notes.len() < MAX_NOTES {
+ self.index.insert(key.clone(), self.notes.len());
+ let (layer, kind, text) = key;
+ self.notes.push(Note { layer, kind, text, count: 1 });
+ }
+ }
+
+ pub fn approximated(&mut self, layer: &str, text: impl Into) {
+ self.note(layer, NoteKind::Approximated, text);
+ }
+
+ pub fn skipped(&mut self, layer: &str, text: impl Into) {
+ self.note(layer, NoteKind::Skipped, text);
+ }
+}
+
+/// A layer's look as a QGIS style has it, in RustGIS's terms. Each part is `None` (or empty) when
+/// the style doesn't set it, so applying it leaves the layer's own.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct Style {
+ pub renderer: Option,
+ /// A line layer's stacked strokes (a single-symbol line with more than one line, or an
+ /// offset one).
+ pub line_strokes: Option>,
+ pub stroke_levels: Option,
+ pub reference_scale: Option,
+ pub labels: Option,
+ /// 0 opaque to 100 invisible.
+ pub transparency: Option,
+ pub blend: 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
>,
+ pub max_scale: Option
>,
+}
+
+impl Style {
+ /// Gives `layer` this style's parts (those it sets).
+ pub fn apply(&self, layer: &mut crate::Layer) {
+ if let Some(r) = &self.renderer {
+ layer.renderer = r.clone();
+ }
+ if let Some(s) = &self.line_strokes {
+ layer.line_strokes = s.clone();
+ }
+ if let Some(on) = self.stroke_levels {
+ layer.stroke_levels = on;
+ }
+ if let Some(r) = self.reference_scale {
+ layer.reference_scale = Some(r);
+ }
+ 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;
+ }
+ }
+}
+
+/// A QGIS project's layers and view, read from its `.qgs` text.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct Project {
+ pub title: String,
+ /// The project's coordinate system (`EPSG:2056`, or a PROJ string), the map's projection.
+ pub crs: Option,
+ /// The view when it was saved: [xmin, ymin, xmax, ymax] in the project's coordinate system.
+ pub extent: Option<[f64; 4]>,
+ /// Its layers, top first, as its layer tree orders them.
+ pub layers: Vec,
+}
+
+/// One layer of a QGIS project.
+#[derive(Clone, Debug, PartialEq)]
+pub struct ProjectLayer {
+ pub id: String,
+ pub name: String,
+ /// QGIS's data provider (`ogr`, `gdal`, `wms`, `delimitedtext`, …).
+ pub provider: String,
+ pub source: Source,
+ /// Its coordinate system (`EPSG:…`), when QGIS records one.
+ pub crs: Option,
+ /// 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.
+ pub visible: bool,
+ /// Its subset string, as a RustGIS definition query when it reads as one.
+ pub definition_query: Option,
+ pub style: Style,
+}
+
+/// Reads a QGIS project. `dir` is the folder the project is in, which its relative paths start
+/// from. Notes on what can't be carried over go to `report`.
+pub fn read_project(text: &str, dir: &str, report: &mut Report) -> Result {
+ let doc = parse(text, "a QGIS project")?;
+ let root = doc.root_element();
+ if !root.has_tag_name("qgis") {
+ return Err("not a QGIS project (.qgs): its root element isn't ".into());
+ }
+ 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,
+ 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");
+ let mut order: Vec<(String, bool)> = Vec::new();
+ if let Some(t) = tree {
+ tree_layers(t, true, &mut order, 0);
+ }
+ 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 {
+ report.skipped("", format!("layers past the first {MAX_LAYERS}"));
+ }
+ let mut layers: Vec<(Option, ProjectLayer)> = Vec::new();
+ for ml in maplayers.into_iter().take(MAX_LAYERS) {
+ let id = child(ml, "id").and_then(|n| n.text()).unwrap_or_default().trim().to_string();
+ let name = child(ml, "layername").and_then(|n| n.text()).unwrap_or_default().trim().to_string();
+ let name = if name.is_empty() { id.clone() } else { name };
+ let at = order.iter().position(|(i, _)| *i == id);
+ let checked = at.and_then(|i| order.get(i)).is_none_or(|(_, c)| *c);
+ 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 definition_query = match &source {
+ Source::File { subset: Some(q), .. } => match expr::to_sql(q) {
+ Some(sql) => Some(sql),
+ None => {
+ report.skipped(&name, format!("its filter (subset string) `{q}`, which doesn't read as a RustGIS definition query"));
+ None
+ }
+ },
+ _ => None,
+ };
+ let style = read_style_node(ml, &name, report);
+ layers.push((
+ at,
+ ProjectLayer {
+ id,
+ name,
+ provider,
+ source,
+ crs: child(ml, "srs").and_then(crs_of),
+ vector,
+ visible: checked,
+ definition_query,
+ style,
+ },
+ ));
+ }
+ // In the layer tree's order; layers it doesn't hold go below.
+ layers.sort_by_key(|(at, _)| at.unwrap_or(usize::MAX));
+ project.layers = layers.into_iter().map(|(_, l)| l).collect();
+ Ok(project)
+}
+
+/// Reads a QGIS layer style (a `.qml` file's text) for the layer named `layer` (for the notes).
+pub fn read_style(text: &str, layer: &str, report: &mut Report) -> Result