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, String> { + let name = layer.name.as_str(); + if let Source::File { path, .. } | Source::Csv { path, .. } = &layer.source { + if !has_dir && qgis::is_relative(path) { + report.skipped(name, format!("the file {path}, relative to the project's folder, which the import wasn't given (pass dir)")); + return Ok(None); + } + // `C:roads.gpkg`: relative to the working directory on drive C, which no folder fixes. + if qgis::is_drive_relative(path) { + report.skipped(name, format!("the file {path}, relative to a drive's working directory (give a full path in QGIS)")); + return Ok(None); + } + } + match &layer.source { + Source::File { path, layer: table, .. } => { + let mut q = json!({"path": path, "name": name}); + if let Some(t) = table { + q["layer"] = json!(t); + } + s.run_part("layer.addData", &q)?; + } + Source::Csv { path, x, y, wkt, crs } => { + let mut q = json!({"path": path, "name": name}); + for (k, v) in [("x", x), ("y", y), ("wkt", wkt), ("crs", crs)] { + if let Some(v) = v { + q[k] = json!(v); + } + } + s.run_part("layer.addData", &q)?; + } + Source::Xyz { url, zmin, zmax, vector } => { + // An overlay, as a WMS layer is, so web layers keep the tree's order between them. + let mut q = json!({"url": url, "name": name, "kind": if *vector { "vector" } else { "raster" }, "overlay": true}); + if let Some(z) = zmin { + q["minZoom"] = json!(z); + } + if let Some(z) = zmax { + q["maxZoom"] = json!(z); + } + let before = s.active_layer; + let added = s.run_part("map.addTiles", &q)?; + web_settings(s, &added, before, layer, report); + return Ok(Some(name.to_string())); + } + Source::Wms { url, layer: wms, .. } => { + let before = s.active_layer; + let added = s.run_part("service.add", &json!({"url": url, "type": "wms", "layer": wms, "name": name}))?; + web_settings(s, &added, before, layer, report); + return Ok(Some(name.to_string())); + } + Source::Wfs { url, layer: typename } => { + report.skipped(name, format!("the WFS layer {typename} at {url} (add it with Add Data › Web Data)")); + return Ok(None); + } + Source::Postgres { connection, table } => { + report.skipped(name, format!("the PostGIS table {table} ({connection}): connect to the database to add it")); + return Ok(None); + } + Source::Other { provider } => { + report.skipped(name, format!("the layer: RustGIS doesn't read QGIS's {provider} sources")); + return Ok(None); + } + } + let id = s.active_layer.ok_or("the layer wasn't added")?; + let Some(l) = s.map_mut().layer_mut(id) else { return Err("the layer wasn't added".into()) }; + let added_name = l.name.clone(); + l.visible = layer.visible; + if l.is_raster() { + // A raster takes its opacity, visibility and scale range; its colouring is noted. + let st = &layer.style; + let t = st.transparency; + let (min, max) = (st.min_scale, st.max_scale); + if let Some(t) = t { + l.transparency = t; + } + if let Some(m) = min { + l.min_scale = m; + } + if let Some(m) = max { + l.max_scale = m; + } + return Ok(Some(added_name)); + } + layer.style.apply(l); + // What reads only against the layer's fields: its definition query, and what the style names. + if let Some(q) = &layer.definition_query { + match rustgis_data::query::parse(q, &l.dataset) { + Ok(_) => l.definition_query = Some(q.clone()), + Err(e) => report.skipped(name, format!("its filter `{q}` ({e})")), + } + } + check_fields(l, name, report); + Ok(Some(added_name)) +} + +/// Checks what a style names against the layer's fields: its label classes' clauses and fields +/// (a class that can't label is switched off) and the field a Unique Values or Graduated Colors +/// renderer classes by. +fn check_fields(l: &mut rustgis_map::Layer, name: &str, report: &mut Report) { + for c in std::iter::once(&mut l.labels.default).chain(l.labels.classes.iter_mut()) { + if let Some(w) = c.where_.clone() + && let Err(e) = rustgis_data::query::parse(&w, &l.dataset) + { + report.skipped(name, format!("the label class \"{}\", whose clause `{w}` doesn't fit the layer ({e})", c.name)); + c.visible = false; + } + // (A class with no field set labels nothing, and isn't noted.) + if c.visible && !c.field.is_empty() && l.dataset.field_index(&c.field).is_none() { + report.skipped(name, format!("labels from the field {}, which the layer hasn't", c.field)); + c.visible = false; + } + } + let field = match &l.renderer { + rustgis_map::Renderer::Unique { field, .. } | rustgis_map::Renderer::Graduated { field, .. } => Some(field.clone()), + _ => None, + }; + if let Some(f) = field.filter(|f| l.dataset.field_index(f).is_none()) { + report.approximated(name, format!("classes by the field {f}, which the layer hasn't: every feature draws as the rest")); + } +} + +/// Gives what a QGIS web layer was added as the layer's visibility, opacity and blending mode. +/// `added` is what `map.addTiles` or `service.add` answered: a tile layer's `index`, or no index +/// when the service offered the layer as features or a coverage, which come in as a map layer +/// (the active one, if it isn't `before` any more), which takes the scale range too. A tile layer +/// has no scale range, so one is noted. +pub(crate) fn web_settings(s: &mut Session, added: &Value, before: Option, layer: &qgis::ProjectLayer, report: &mut Report) { + let name = layer.name.as_str(); + let st = &layer.style; + let transparency = st.transparency.filter(|t| t.is_finite() && *t > 0.0); + let index = added.get("index").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + if index.is_none() + && let Some(id) = s.active_layer.filter(|id| Some(*id) != before) + && let Some(l) = s.map_mut().layer_mut(id) + { + l.visible = layer.visible; + if let Some(tr) = transparency { + l.transparency = tr.clamp(0.0, 100.0); + } + if let Some(blend) = st.blend { + l.blend = blend; + } + if let Some(m) = st.min_scale { + l.min_scale = m; + } + if let Some(m) = st.max_scale { + l.max_scale = m; + } + return; + } + match index.and_then(|i| s.map_mut().tiles.get_mut(i)) { + Some(t) => { + t.visible = layer.visible; + if let Some(blend) = st.blend { + t.blend = blend; + } + if let Some(tr) = transparency { + t.opacity = (1.0 - tr / 100.0).clamp(0.0, 1.0); + } + } + None => { + if !layer.visible { + report.approximated(name, "a hidden web layer, added shown"); + } + if transparency.is_some() { + report.approximated(name, "its transparency: added opaque"); + } + if st.blend.is_some_and(|b| b != rustgis_map::effects::Blend::default()) { + report.approximated(name, "its blending mode: added as normal"); + } + } + } + if st.min_scale.flatten().is_some() || st.max_scale.flatten().is_some() { + report.approximated(name, "its scale range: a web layer draws at every scale"); + } +} + +/// Goes to the project's saved view, or to its layers when that view shows none of them. +fn go_to_view(s: &mut Session, project: &qgis::Project) { + let data = s.map().full_extent(); + let saved = project.extent.and_then(|[x0, y0, x1, y1]| { + let m = s.map(); + let corners = [(x0, y0), (x1, y0), (x0, y1), (x1, y1)].map(|(x, y)| m.to_lonlat(Coord::new(x, y))); + corners.iter().flatten().fold(None, |e: Option, c| Some(e.unwrap_or(Extent::EMPTY).union(&Extent::new(c.x, c.y, c.x, c.y)))) + }); + let target = match saved { + Some(e) if !e.is_empty() && (data.is_empty() || e.intersects(&data)) => e, + _ => data, + }; + if !target.is_empty() { + s.zoom_to(&target); + } +} + +fn note_json(n: &qgis::Note) -> Value { + json!({"layer": n.layer, "kind": n.kind, "text": n.text, "count": n.count}) +} + +fn import_style(s: &mut Session, p: &Value) -> CmdResult { + let text = qgis_text(p)?; + let id = s.layer_id(p)?; + let name = s.map().layer(id).map(|l| l.name.clone()).ok_or("no such layer")?; + let mut report = Report::default(); + let style = qgis::read_style(&text, &name, &mut report)?; + let l = s.map_mut().layer_mut(id).ok_or("no such layer")?; + style.apply(l); + check_fields(l, &name, &mut report); + Ok(json!({"notes": report.notes.iter().map(note_json).collect::>()})) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c40d164fa..e3342d4fc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -835,6 +835,18 @@ impl Session { cmd::tiles::follow_style_view(self, web_time::Duration::ZERO) } + /// Runs command `id` as a part of the one running now (a command made of others): its + /// changes are in that command's one undo step, not a step of their own. A part that fails + /// (or panics) is undone by its own `run`, which restores what it started from; the parts + /// before it stay until the command made of them fails too, whose `run` restores the project + /// as the whole command found it. The stacks are put back on every return. + pub(crate) fn run_part(&mut self, id: &str, params: &Value) -> CmdResult { + let (undo, redo) = (std::mem::take(&mut self.undo), std::mem::take(&mut self.redo)); + let res = self.run(id, params); + (self.undo, self.redo) = (undo, redo); + res + } + /// Runs a command by id. Mutating commands snapshot the project for undo; an error or a /// panic restores it. pub fn run(&mut self, id: &str, params: &Value) -> CmdResult { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 033cefa8c..b5f270460 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -623,6 +623,133 @@ fn rivers_are_labelled_along_their_lines_in_italics() { assert!(!text.contains("italic"), "{text}"); } +#[test] +fn a_qgis_project_opens_as_a_map_and_a_qgis_style_styles_a_layer() { + let dir = std::env::temp_dir().join(format!("rustgis-qgis-{}", std::process::id())); + std::fs::create_dir_all(dir.join("data")).unwrap(); + let roads = r#"{"type":"FeatureCollection","features":[ + {"type":"Feature","properties":{"class":"motorway","name":"A1","lanes":3},"geometry":{"type":"LineString","coordinates":[[8.70,47.49],[8.75,47.51]]}}, + {"type":"Feature","properties":{"class":"track","name":"Feldweg","lanes":1},"geometry":{"type":"LineString","coordinates":[[8.71,47.50],[8.74,47.48]]}}]}"#; + std::fs::write(dir.join("data/roads.geojson"), roads).unwrap(); + std::fs::write(dir.join("data/towns.csv"), "name,lon,lat\nWinterthur,8.72,47.50\n").unwrap(); + let qgs = r#" +Winterthur + EPSG:2056 + + 001010 + + r./data/roads.geojson|subset="lanes" > 0Roadsogr + + + + + + tfile:./data/towns.csv?type=csv&xField=lon&yField=lat&crs=EPSG:4326Townsdelimitedtext + + dbdbname='gis' host=db password='secret' table="public"."parcels" (geom)Parcelspostgres + "#; + let path = dir.join("winterthur.qgs"); + std::fs::write(&path, qgs).unwrap(); + let mut s = world(); + let maps = s.project.maps.len(); + let depth = s.undo_depth(); + let out = s.run("project.importQgis", &json!({"path": path.display().to_string()})).unwrap(); + assert_eq!((s.project.maps.len(), s.map().name.as_str()), (maps + 1, "Winterthur"), "a map of its own"); + assert_eq!(s.undo_depth(), depth + 1, "one step"); + assert_eq!(s.map().projection.code(), "EPSG:2056"); + assert_eq!(s.map().basemap, rustgis_map::Basemap::Blank); + let names: Vec<&str> = s.map().layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["Towns", "Roads"], "top first; the database table isn't added"); + let roads = s.map().layer_by_name("Roads").unwrap(); + assert!(!roads.visible, "unchecked in the layer tree"); + assert_eq!(roads.definition_query.as_deref(), Some("\"lanes\" > 0")); + assert!(roads.reference_scale.is_some()); + let rustgis_map::Renderer::Unique { field, class_strokes, .. } = &roads.renderer else { panic!("{:?}", roads.renderer) }; + assert_eq!((field.as_str(), class_strokes.get("motorway").map(Vec::len)), ("class", Some(2))); + let classes: Vec<(&str, bool)> = roads.labels.classes.iter().map(|c| (c.name.as_str(), c.visible)).collect(); + assert_eq!(classes, [("Big", true), ("Broken", false)], "a clause the layer can't read is switched off"); + assert_eq!(roads.labels.classes[0].placement, rustgis_map::LabelPlacement::Parallel); + let towns = s.map().layer_by_name("Towns").unwrap(); + assert_eq!((towns.dataset.features.len(), towns.renderer.base_symbol().marker), (1, rustgis_map::Marker::Square)); + // What wasn't carried over: the database table, with no password in the note. + let notes = out["notes"].as_array().unwrap(); + let text = serde_json::to_string(notes).unwrap(); + assert!(text.contains("parcels") && !text.contains("secret"), "{text}"); + assert!(text.contains("Broken"), "{text}"); + // Drawn, and gone again with one undo. + s.run("share.exportMap", &json!({"path": dir.join("map.png").display().to_string(), "width": 320, "height": 200})).unwrap(); + s.run("edit.undo", &Value::Null).unwrap(); + assert_eq!(s.project.maps.len(), maps); + + // A style file on a sample layer: Rivers drawn as a cased line. + let qml = r#" + + + 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 `