From 51e03524649863d542d1339bcb291e2ac433be67 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Mon, 26 Oct 2026 13:22:43 +0100 Subject: [PATCH 1/8] feat(services): identify WMS layers with GetFeatureInfo Identify (topmost and visible pop-up modes) now asks the WMS layers drawn under the click what is there, with a standard GetFeatureInfo request: - The capabilities give each WMS layer its request: the GetFeatureInfo DCPType GET address, the version (1.3.0 CRS/I/J or 1.1.1 SRS/X/Y), the offered INFO_FORMATs and the layer's queryable attribute (inherited, as the standard says). A 101-pixel window of the map around the point at the view's resolution, FEATURE_COUNT=10, EXCEPTIONS as XML. - Answers are read whatever the format: GeoJSON (preferred), GML (feature collections, MapServer's msGMLOutput), Esri's XML, plain text (GeoServer's and MapServer's name = value lines) and HTML; a ServiceExceptionReport is an error. - identify lists the requests in "featureInfo"; the app fetches them in the background and the answers join the pop-up as they arrive. identify.featureInfo reads an answer, or on the command line asks the service and waits. The project format is unchanged: the request is a runtime-only field on the tile layer (a type that already derives Serialize/Deserialize, so a later format change can keep it). A WMS layer from a project finds its request by reading its service's capabilities on the first click. Co-Authored-By: Claude Opus 5.5 --- crates/data/src/ogc.rs | 48 ++- crates/data/src/ogc_info.rs | 523 +++++++++++++++++++++++++++ crates/engine/src/cmd/select.rs | 51 +-- crates/engine/src/cmd/select_info.rs | 161 +++++++++ crates/engine/src/cmd/service.rs | 5 + crates/engine/src/cmd/tiles.rs | 1 + crates/engine/src/tests.rs | 71 ++++ crates/map/src/tiles.rs | 6 + crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/mapview.rs | 115 +++++- docs/user-guide/editing.md | 9 + 11 files changed, 957 insertions(+), 35 deletions(-) create mode 100644 crates/data/src/ogc_info.rs create mode 100644 crates/engine/src/cmd/select_info.rs diff --git a/crates/data/src/ogc.rs b/crates/data/src/ogc.rs index 962dddcee..cda4b3973 100644 --- a/crates/data/src/ogc.rs +++ b/crates/data/src/ogc.rs @@ -8,6 +8,10 @@ use roxmltree::{Node, ParsingOptions}; use serde_json::value::RawValue; +#[path = "ogc_info.rs"] +mod info; +pub use info::{FeatureInfo, InfoFeature, read_feature_info, wms_layer_of}; + /// What a layer of a service becomes. #[derive(Clone, Debug, PartialEq)] pub enum Offer { @@ -71,6 +75,8 @@ pub struct ServiceLayer { pub tilesets: Option, /// Its time dimension, when its tiles change with time (the template then has `{Time}`). pub time: Option, + /// A WMS layer's GetFeatureInfo request (its formats empty when the layer isn't queryable). + pub feature_info: Option, } /// A service's capabilities: its kind (`WMS`, `WMTS`, `WFS`, `OGC API Features`), title, the @@ -403,16 +409,19 @@ fn wms(url: &str, root: &Node) -> Service { .find(|f| formats.iter().any(|g| g.eq_ignore_ascii_case(f))) .copied() .unwrap_or("image/png"); + let version = if v13 { "1.3.0" } else { "1.1.1" }; + let info_request = info::request_of(root, url, version, format); let mut service = Service { kind: "WMS".into(), title, ..Service::default() }; let mut skipped = 0; - // Layers inherit their parents' coordinate systems and nearest valid geographic bounds. - let mut stack: Vec<(Node, Vec, Option<[f64; 4]>)> = root + // Layers inherit their parents' coordinate systems, nearest valid geographic bounds and + // queryable attribute. + let mut stack: Vec<(Node, Vec, Option<[f64; 4]>, bool)> = root .descendants() .filter(|n| n.is_element() && local(n) == "Capability") .flat_map(|c| children(&c, "Layer").collect::>()) - .map(|l| (l, Vec::new(), None)) + .map(|l| (l, Vec::new(), None, false)) .collect(); - while let Some((layer, mut crs, inherited_bbox)) = stack.pop() { + while let Some((layer, mut crs, inherited_bbox, inherited_queryable)) = stack.pop() { if service.layers.len() >= MAX_LAYERS { break; } @@ -428,8 +437,9 @@ fn wms(url: &str, root: &Node) -> Service { lonlat_box(f("minx")?, f("miny")?, f("maxx")?, f("maxy")?) }) .or(inherited_bbox); + let queryable = layer.attribute("queryable").map_or(inherited_queryable, |q| q.trim() == "1" || q.trim() == "true"); for sub in children(&layer, "Layer") { - stack.push((sub, crs.clone(), bbox)); + stack.push((sub, crs.clone(), bbox, queryable)); } let Some(name) = child_text(&layer, "Name") else { continue }; if !crs.iter().any(|c| c.split_whitespace().any(is_web_mercator)) { @@ -437,7 +447,6 @@ fn wms(url: &str, root: &Node) -> Service { continue; } let crs_key = if v13 { "CRS" } else { "SRS" }; - let version = if v13 { "1.3.0" } else { "1.1.1" }; let template = with_query( &get_map, &format!( @@ -447,7 +456,8 @@ fn wms(url: &str, root: &Node) -> Service { ), ); let title = child_text(&layer, "Title").unwrap_or_else(|| name.clone()); - service.layers.push(ServiceLayer { name, title, offer: Offer::Tiles { template }, bbox, tilesets: None, time: None }); + let feature_info = info_request.as_ref().map(|r| r.for_layer(&name, queryable)); + service.layers.push(ServiceLayer { name, title, offer: Offer::Tiles { template }, bbox, tilesets: None, time: None, feature_info }); } service.layers.reverse(); if skipped > 0 { @@ -598,7 +608,7 @@ fn wmts(url: &str, root: &Node) -> Service { let hi: Vec = child_text(&b, "UpperCorner")?.split_whitespace().filter_map(|t| t.parse().ok()).collect(); lonlat_box(*lo.first()?, *lo.get(1)?, *hi.first()?, *hi.get(1)?) }); - service.layers.push(ServiceLayer { name, title, offer: Offer::Tiles { template }, bbox, tilesets: None, time }); + service.layers.push(ServiceLayer { name, title, offer: Offer::Tiles { template }, bbox, tilesets: None, time, feature_info: None }); } if skipped > 0 { service.notes.push(format!("{skipped} layers have no Web Mercator tile matrix set RustGIS can draw and are left out")); @@ -697,7 +707,15 @@ fn wfs(url: &str, root: &Node) -> Service { ) }; let url = with_query(&get, &query); - service.layers.push(ServiceLayer { name, title, offer: Offer::Features { url }, bbox, tilesets: None, time: None }); + service.layers.push(ServiceLayer { + name, + title, + offer: Offer::Features { url }, + bbox, + tilesets: None, + time: None, + feature_info: None, + }); } service } @@ -822,6 +840,7 @@ fn wcs(url: &str, root: &Node) -> Service { bbox, tilesets: None, time: None, + feature_info: None, }); }; let mut n = 0; @@ -1236,6 +1255,7 @@ fn arcgis(url: &str, v: &serde_json::Value) -> Result { bbox: full, tilesets: None, time: None, + feature_info: None, }); } }; @@ -1257,6 +1277,7 @@ fn arcgis(url: &str, v: &serde_json::Value) -> Result { bbox: full, tilesets: None, time: None, + feature_info: None, }); features(&mut service); // A dynamic map's layers, each drawn alone (`layers=show:`): a national layer is @@ -1276,6 +1297,7 @@ fn arcgis(url: &str, v: &serde_json::Value) -> Result { bbox: full, tilesets: None, time: None, + feature_info: None, }); } } @@ -1289,6 +1311,7 @@ fn arcgis(url: &str, v: &serde_json::Value) -> Result { bbox: full, tilesets: None, time: None, + feature_info: None, }); } else if lower.ends_with("/vectortileserver") { service.kind = "ArcGIS VectorTileServer".into(); @@ -1304,6 +1327,7 @@ fn arcgis(url: &str, v: &serde_json::Value) -> Result { bbox: full, tilesets: None, time: None, + feature_info: None, }); } else { return Err("an ArcGIS address, but not a FeatureServer, MapServer, ImageServer or VectorTileServer".into()); @@ -1387,6 +1411,7 @@ fn arcgis_directory(b: &str, v: &serde_json::Value) -> Service { bbox: None, tilesets: None, time: None, + feature_info: None, }); } for s in v.get("services").and_then(|s| s.as_array()).into_iter().flatten().take(MAX_LAYERS) { @@ -1410,6 +1435,7 @@ fn arcgis_directory(b: &str, v: &serde_json::Value) -> Service { bbox: None, tilesets: None, time: None, + feature_info: None, }); } if skipped > 0 { @@ -1442,6 +1468,7 @@ fn ogc_api(url: &str, v: &serde_json::Value) -> Result { bbox: None, tilesets: Some(list), time: None, + feature_info: None, }); } if !service.layers.is_empty() || service.collections_url.is_some() { @@ -1481,6 +1508,7 @@ fn ogc_api(url: &str, v: &serde_json::Value) -> Result { bbox, tilesets: Some(list.clone()), time: None, + feature_info: None, }); } if let Some((t, list)) = &vector { @@ -1491,6 +1519,7 @@ fn ogc_api(url: &str, v: &serde_json::Value) -> Result { bbox, tilesets: Some(list.clone()), time: None, + feature_info: None, }); } if map.is_some() || vector.is_some() { @@ -1527,6 +1556,7 @@ fn ogc_api(url: &str, v: &serde_json::Value) -> Result { bbox, tilesets: None, time: None, + feature_info: None, }); } if unreadable > 0 { diff --git a/crates/data/src/ogc_info.rs b/crates/data/src/ogc_info.rs new file mode 100644 index 000000000..32ca69da2 --- /dev/null +++ b/crates/data/src/ogc_info.rs @@ -0,0 +1,523 @@ +//! WMS GetFeatureInfo (WMS 1.3.0 §7.4, 1.1.1 §7.3): how a service's layer is asked what is at a +//! point of the map, read from its capabilities; the request for a point; and the answer — +//! GeoJSON, GML (a WFS-style feature collection or MapServer's), Esri's XML, plain text or +//! HTML — read as features. Answers are hostile input: they are capped like capabilities. + +use roxmltree::Node; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::{base, child, children, href, is_web, local, query_value, text_of, with_query, xml}; + +/// How to ask a WMS layer what is at a point. A layer keeps it for the session; it can be saved +/// (a project might keep it one day). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FeatureInfo { + /// The GetFeatureInfo address (the request's HTTP GET online resource). + pub url: String, + /// The WMS version the service is asked in: `1.3.0` or `1.1.1`. + pub version: String, + /// The layer's name: its LAYERS and QUERY_LAYERS. + pub layer: String, + /// The layer's GetMap image format (part of the request). + pub map_format: String, + /// The answer formats (INFO_FORMAT) the service offers; none when the layer isn't queryable. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub formats: Vec, +} + +/// Pixels across the map window a request describes, the point in its middle pixel. +const WINDOW: u32 = 101; +/// Most features an answer is asked for. +const FEATURE_COUNT: usize = 10; +/// Most features read from an answer, attributes kept of each, and characters of a value. +const MAX_FEATURES: usize = 100; +const MAX_ATTRIBUTES: usize = 200; +const MAX_VALUE: usize = 4000; + +impl FeatureInfo { + /// Whether the layer can be asked (the service offers GetFeatureInfo and calls it queryable). + pub fn queryable(&self) -> bool { + !self.formats.is_empty() && !self.layer.is_empty() && is_web(&self.url) + } + + /// The service's request for one of its layers. + pub(super) fn for_layer(&self, layer: &str, queryable: bool) -> FeatureInfo { + let formats = if queryable { self.formats.clone() } else { Vec::new() }; + FeatureInfo { layer: layer.to_string(), formats, ..self.clone() } + } + + /// The answer format asked for: GeoJSON, then GML, then other XML, plain text and HTML. + pub fn info_format(&self) -> Option<&str> { + let rank = |f: &str| { + let f = f.to_ascii_lowercase(); + if f.contains("geojson") || f.contains("geo+json") || f == "application/json" { + 0 + } else if f.contains("gml") { + 1 + } else if f.contains("xml") && !f.contains("html") { + 2 + } else if f.starts_with("text/plain") { + 3 + } else if f.contains("html") { + 4 + } else { + 5 + } + }; + self.formats.iter().filter(|f| rank(f) < 5).min_by_key(|f| rank(f)).map(String::as_str) + } + + /// The GetFeatureInfo address for a point (`x`, `y` in Web Mercator metres) of a map drawn at + /// `m_per_px` metres per pixel: a small window of the map around the point, which is its + /// middle pixel. `None` when the layer can't be asked. + pub fn request(&self, x: f64, y: f64, m_per_px: f64) -> Option { + if !self.queryable() || !(x.is_finite() && y.is_finite() && m_per_px.is_finite() && m_per_px > 0.0) { + return None; + } + let format = self.info_format()?; + let half = f64::from(WINDOW) / 2.0 * m_per_px; + let bbox = format!("{},{},{},{}", x - half, y - half, x + half, y + half); + let mid = WINDOW / 2; + let v13 = self.version.starts_with("1.3"); + let (crs, i, j, exceptions) = if v13 { ("CRS", "I", "J", "XML") } else { ("SRS", "X", "Y", "application/vnd.ogc.se_xml") }; + let layer = super::encode(&self.layer); + let map_format = if self.map_format.is_empty() { "image/png" } else { &self.map_format }; + Some(with_query( + &self.url, + &format!( + "SERVICE=WMS&VERSION={}&REQUEST=GetFeatureInfo&LAYERS={layer}&STYLES=&{crs}=EPSG:3857&BBOX={bbox}&WIDTH={WINDOW}&HEIGHT={WINDOW}&FORMAT={}&TRANSPARENT=TRUE&QUERY_LAYERS={layer}&INFO_FORMAT={}&FEATURE_COUNT={FEATURE_COUNT}&{i}={mid}&{j}={mid}&EXCEPTIONS={}", + super::encode(&self.version), + super::encode(map_format), + super::encode(format), + super::encode(exceptions), + ), + )) + } +} + +/// A capabilities document's GetFeatureInfo request, for no layer yet: `None` when the service +/// doesn't offer one. +pub(super) fn request_of(root: &Node, url: &str, version: &str, map_format: &str) -> Option { + let request = root.descendants().find(|n| n.is_element() && local(n) == "GetFeatureInfo")?; + let formats: Vec = children(&request, "Format").map(|f| text_of(&f)).filter(|f| !f.is_empty()).take(32).collect(); + let get = request + .descendants() + .find(|n| n.is_element() && local(n) == "Get") + .and_then(|g| child(&g, "OnlineResource")) + .and_then(|o| href(&o)) + .filter(|h| is_web(h)) + .unwrap_or_else(|| super::endpoint(url)); + Some(FeatureInfo { url: get, version: version.to_string(), layer: String::new(), map_format: map_format.to_string(), formats }) +} + +/// The GetMap parameters a WMS tile template carries, left out of its service's address. +const GET_MAP_KEYS: [&str; 16] = [ + "service", + "request", + "version", + "layers", + "styles", + "crs", + "srs", + "bbox", + "width", + "height", + "format", + "transparent", + "bgcolor", + "exceptions", + "time", + "elevation", +]; + +/// A WMS tile template's (a GetMap request's) service and layer: its GetCapabilities address and +/// the layer's name. How a layer whose GetFeatureInfo request isn't known (one from a project) +/// finds it. `None` for other templates. +pub fn wms_layer_of(template: &str) -> Option<(String, String)> { + let is = |key: &str, value: &str| query_value(template, key).is_some_and(|v| v.eq_ignore_ascii_case(value)); + if !is_web(template) || !is("service", "WMS") || !is("request", "GetMap") { + return None; + } + let decode = |v: &str| url::form_urlencoded::parse(format!("v={v}").as_bytes()).next().map(|(_, v)| v.into_owned()); + let layer = query_value(template, "layers").and_then(decode).filter(|l| !l.is_empty())?; + let version = query_value(template, "version").and_then(decode).filter(|v| v.starts_with("1.1") || v.starts_with("1.3")); + let kept: Vec<&str> = template + .split('#') + .next() + .and_then(|u| u.split_once('?')) + .map(|(_, q)| q) + .unwrap_or("") + .split('&') + .filter(|kv| { + let k = kv.split_once('=').map(|(k, _)| k).unwrap_or(kv).to_ascii_lowercase(); + !kv.is_empty() && !GET_MAP_KEYS.contains(&k.as_str()) + }) + .collect(); + let service = if kept.is_empty() { base(template).to_string() } else { format!("{}?{}", base(template), kept.join("&")) }; + let mut q = String::from("SERVICE=WMS&REQUEST=GetCapabilities"); + if let Some(v) = version { + q.push_str(&format!("&VERSION={}", super::encode(&v))); + } + Some((with_query(&service, &q), layer)) +} + +/// A feature of a GetFeatureInfo answer: the layer it is in, when the answer says, and its +/// attributes as text, in the answer's order. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct InfoFeature { + pub layer: Option, + pub attributes: Vec<(String, String)>, +} + +/// Reads a GetFeatureInfo answer, whatever its format (found from the answer itself): its +/// features, none when nothing is at the point, or the service's exception as the error. +pub fn read_feature_info(body: &[u8]) -> Result, String> { + let text = std::str::from_utf8(body).map_err(|_| "the service's answer isn't text (perhaps an image)".to_string())?; + let text = text.trim_start_matches('\u{feff}').trim(); + if text.is_empty() { + return Ok(Vec::new()); + } + if text.starts_with('{') || text.starts_with('[') { + let v: Value = serde_json::from_str(text).map_err(|e| format!("the service's JSON: {e}"))?; + return Ok(json_features(&v)); + } + if text.starts_with('<') { + let lower = text.get(..text.len().min(512)).unwrap_or(text).to_ascii_lowercase(); + if lower.contains(" xml_features(&doc.root_element()), + // Not XML after all (an HTML fragment): its text. + Err(_) => Ok(text_feature(&html_text(text))), + }; + } + Ok(plain_features(text)) +} + +fn capped(s: &str) -> String { + let s = s.trim(); + match s.char_indices().nth(MAX_VALUE) { + Some((i, _)) => format!("{}…", s.get(..i).unwrap_or(s)), + None => s.to_string(), + } +} + +fn value_text(v: &Value) -> String { + match v { + Value::Null => String::new(), + Value::String(s) => capped(s), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + other => capped(&other.to_string()), + } +} + +/// A GeoJSON feature collection's (or one feature's) features. The layer is the feature's +/// `layerName` or `layerBodId` (swisstopo's), or the part of a GeoServer id before its dot. +fn json_features(v: &Value) -> Vec { + let features: Vec<&Value> = match v.get("features").and_then(Value::as_array) { + Some(list) => list.iter().collect(), + None if v.get("type").and_then(Value::as_str) == Some("Feature") => vec![v], + None => match v.get("results").and_then(Value::as_array) { + Some(list) => list.iter().collect(), + None => Vec::new(), + }, + }; + features + .into_iter() + .take(MAX_FEATURES) + .map(|f| { + let attributes = f + .get("properties") + .or_else(|| f.get("attributes")) + .and_then(Value::as_object) + .map(|o| o.iter().take(MAX_ATTRIBUTES).map(|(k, v)| (k.clone(), value_text(v))).collect()) + .unwrap_or_default(); + let layer = + ["layerName", "layerBodId", "layer"].iter().find_map(|k| f.get(*k).and_then(Value::as_str)).map(str::to_string).or_else( + || { + let id = f.get("id").and_then(Value::as_str)?; + let (layer, rest) = id.rsplit_once('.')?; + (!layer.is_empty() && !rest.is_empty()).then(|| layer.to_string()) + }, + ); + InfoFeature { layer, attributes } + }) + .collect() +} + +/// Whether an element is in the GML namespace (geometry, bounds, a GML name). +fn is_gml(n: &Node) -> bool { + n.tag_name().namespace().is_some_and(|ns| ns.starts_with("http://www.opengis.net/gml")) +} + +/// A GML feature's attributes: its child elements holding text only, not GML's own. +fn gml_attributes(feature: &Node) -> Vec<(String, String)> { + feature + .children() + .filter(|c| c.is_element() && !is_gml(c) && !c.children().any(|g| g.is_element())) + .take(MAX_ATTRIBUTES) + .map(|c| (local(&c).to_string(), capped(&text_of(&c)))) + .collect() +} + +fn xml_features(root: &Node) -> Result, String> { + let mut out = Vec::new(); + match local(root) { + "ServiceExceptionReport" | "ExceptionReport" => { + return Err(format!("the service answered with an error: {}", text_of(root).chars().take(300).collect::())); + } + // MapServer: … + "msGMLOutput" => { + for layer in root.children().filter(|c| c.is_element()) { + let name = local(&layer).strip_suffix("_layer").unwrap_or(local(&layer)).to_string(); + for f in layer.children().filter(|c| c.is_element() && local(c).ends_with("_feature")) { + out.push(InfoFeature { layer: Some(name.clone()), attributes: gml_attributes(&f) }); + } + } + } + // ArcGIS Server: + // …, or (1.1.1). + "FeatureInfoResponse" => { + for f in root.descendants().filter(|n| n.is_element() && matches!(local(n), "FIELDS" | "FeatureInfo")) { + let layer = f.ancestors().find_map(|a| a.attribute("layername")).map(str::to_string); + let attributes: Vec<(String, String)> = if local(&f) == "FIELDS" { + f.attributes().take(MAX_ATTRIBUTES).map(|a| (a.name().to_string(), capped(a.value()))).collect() + } else { + children(&f, "Field") + .take(MAX_ATTRIBUTES) + .filter_map(|field| { + let name = child(&field, "FieldName").map(|n| text_of(&n)).filter(|n| !n.is_empty())?; + Some((name, capped(&child(&field, "FieldValue").map(|v| text_of(&v)).unwrap_or_default()))) + }) + .collect() + }; + out.push(InfoFeature { layer, attributes }); + } + } + // GML: a feature collection's members (GML 2/3 featureMember(s), WFS 2 member). + _ => { + for member in root.descendants().filter(|n| n.is_element() && matches!(local(n), "featureMember" | "featureMembers" | "member")) + { + for f in member.children().filter(|c| c.is_element()) { + out.push(InfoFeature { layer: Some(local(&f).to_string()), attributes: gml_attributes(&f) }); + } + } + } + } + out.truncate(MAX_FEATURES); + Ok(out) +} + +/// A plain text answer's features: `name = value` lines (GeoServer's and MapServer's), a feature +/// at each layer heading, `Feature` line or dashed rule after attributes; any other text as one. +fn plain_features(text: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut layer: Option = None; + let mut current: Vec<(String, String)> = Vec::new(); + fn flush(layer: &Option, current: &mut Vec<(String, String)>, out: &mut Vec) { + if !current.is_empty() && out.len() < MAX_FEATURES { + out.push(InfoFeature { layer: layer.clone(), attributes: std::mem::take(current) }); + } + current.clear(); + } + for line in text.lines().take(100_000) { + let t = line.trim(); + let quoted = |rest: &str| rest.split(['\'', '"']).nth(1).map(str::to_string); + if let Some(rest) = t.strip_prefix("Layer ").or_else(|| t.strip_prefix("Results for FeatureType")) { + flush(&layer, &mut current, &mut out); + layer = quoted(rest).or(layer.take()); + } else if t.starts_with("Feature ") || (t.len() >= 3 && t.bytes().all(|b| b == b'-' || b == b'=')) { + flush(&layer, &mut current, &mut out); + } else if let Some((k, v)) = t.split_once(" = ") + && current.len() < MAX_ATTRIBUTES + { + let v = v.trim(); + let v = v.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')).unwrap_or(v); + current.push((k.trim().to_string(), capped(v))); + } + } + flush(&layer, &mut current, &mut out); + if out.is_empty() { text_feature(text) } else { out } +} + +/// Text that isn't features (an HTML page's, a sentence): one feature with it, none for no text. +fn text_feature(text: &str) -> Vec { + let t = text.split_whitespace().collect::>().join(" "); + if t.is_empty() { Vec::new() } else { vec![InfoFeature { layer: None, attributes: vec![("text".to_string(), capped(&t))] }] } +} + +/// An HTML answer's text: its tags, scripts and styles left out. +fn html_text(html: &str) -> String { + let mut out = String::new(); + let mut rest = html; + while let Some(open) = rest.find('<') { + out.push_str(rest.get(..open).unwrap_or("")); + out.push(' '); + let after = rest.get(open..).unwrap_or(""); + let lower = after.get(..after.len().min(8)).unwrap_or("").to_ascii_lowercase(); + let skip_to = if lower.starts_with("").map(|i| i + 9) + } else if lower.starts_with("").map(|i| i + 8) + } else { + after.find('>').map(|i| i + 1) + }; + rest = skip_to.and_then(|i| after.get(i..)).unwrap_or(""); + } + out.push_str(rest); + out.replace(" ", " ").replace("&", "&").replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'") +} + +#[cfg(test)] +mod tests { + use super::*; + + const CAPS_13: &str = r#" + + Test + + + image/png + + + text/plainapplication/vnd.ogc.gmlapplication/json + + + + RootEPSG:3857 + roadsRoads + reliefRelief + + +"#; + + fn info_of(caps: &str, layer: &str) -> FeatureInfo { + let service = crate::ogc::parse("https://maps.example.org/wms?SERVICE=WMS&REQUEST=GetCapabilities", caps.as_bytes()).unwrap(); + service.layers.into_iter().find(|l| l.name == layer).and_then(|l| l.feature_info).unwrap() + } + + #[test] + fn capabilities_give_the_request_and_queryable_is_inherited() { + let roads = info_of(CAPS_13, "roads"); + assert!(roads.queryable()); + assert_eq!(roads.url, "https://info.example.org/wms?"); + assert_eq!(roads.version, "1.3.0"); + assert_eq!(roads.info_format(), Some("application/json")); + let relief = info_of(CAPS_13, "relief"); + assert!(!relief.queryable()); + assert_eq!(relief.request(0.0, 0.0, 10.0), None); + } + + #[test] + fn requests_follow_the_version() { + let roads = info_of(CAPS_13, "roads"); + let r = roads.request(1000.0, 2000.0, 2.0).unwrap(); + assert!(r.starts_with( + "https://info.example.org/wms?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetFeatureInfo&LAYERS=roads&STYLES=&CRS=EPSG:3857" + )); + assert!(r.contains("&BBOX=899,1899,1101,2101&WIDTH=101&HEIGHT=101&")); + assert!(r.contains("&QUERY_LAYERS=roads&INFO_FORMAT=application%2Fjson&FEATURE_COUNT=10&I=50&J=50&EXCEPTIONS=XML")); + let old = FeatureInfo { version: "1.1.1".into(), formats: vec!["text/html".into(), "application/vnd.ogc.gml".into()], ..roads }; + let r = old.request(0.0, 0.0, 1.0).unwrap(); + assert!(r.contains("&SRS=EPSG:3857&")); + assert!(r.contains("&INFO_FORMAT=application%2Fvnd.ogc.gml&")); + assert!(r.contains("&X=50&Y=50&EXCEPTIONS=application%2Fvnd.ogc.se_xml")); + assert_eq!(old.request(f64::NAN, 0.0, 1.0), None); + assert_eq!(old.request(0.0, 0.0, 0.0), None); + } + + #[test] + fn without_a_get_feature_info_request_layers_have_none() { + let caps = CAPS_13.replace("GetFeatureInfo", "GetLegendGraphic"); + let service = crate::ogc::parse("https://maps.example.org/wms", caps.as_bytes()).unwrap(); + assert!(service.layers.iter().all(|l| l.feature_info.is_none())); + } + + #[test] + fn a_template_gives_its_service_and_layer() { + let t = "https://maps.example.org/wms?map=x&SERVICE=WMS&VERSION=1.3.0&REQUEST=GetMap&LAYERS=ch.swisstopo%3Aroads&STYLES=&CRS=EPSG:3857&BBOX={bbox-epsg-3857}&WIDTH=256&HEIGHT=256&FORMAT=image%2Fpng&TRANSPARENT=TRUE"; + let (caps, layer) = wms_layer_of(t).unwrap(); + assert_eq!(caps, "https://maps.example.org/wms?map=x&SERVICE=WMS&REQUEST=GetCapabilities&VERSION=1.3.0"); + assert_eq!(layer, "ch.swisstopo:roads"); + assert_eq!(wms_layer_of("https://tile.example.org/{z}/{x}/{y}.png"), None); + } + + #[test] + fn geojson_answers() { + let body = br#"{"type":"FeatureCollection","features":[ + {"type":"Feature","id":"roads.7","properties":{"name":"Main St","lanes":2,"note":null},"geometry":null}, + {"type":"Feature","layerBodId":"ch.bfs.gebaeude","properties":{"egid":123},"geometry":null}]}"#; + let f = read_feature_info(body).unwrap(); + assert_eq!(f.len(), 2); + assert_eq!(f[0].layer.as_deref(), Some("roads")); + assert_eq!(f[0].attributes, vec![("lanes".into(), "2".into()), ("name".into(), "Main St".into()), ("note".into(), String::new())]); + assert_eq!(f[1].layer.as_deref(), Some("ch.bfs.gebaeude")); + assert!(read_feature_info(br#"{"type":"FeatureCollection","features":[]}"#).unwrap().is_empty()); + } + + #[test] + fn gml_answers() { + let gml = br#" + + Main St + 1,22 +"#; + let f = read_feature_info(gml).unwrap(); + assert_eq!( + f, + vec![InfoFeature { + layer: Some("roads".into()), + attributes: vec![("name".into(), "Main St".into()), ("lanes".into(), "2".into())] + }] + ); + let ms = br#"parks + Central"#; + let f = read_feature_info(ms).unwrap(); + assert_eq!(f, vec![InfoFeature { layer: Some("parks".into()), attributes: vec![("NAME".into(), "Central".into())] }]); + let esri = br#""#; + assert_eq!(read_feature_info(esri).unwrap()[0].attributes, vec![("OBJECTID".into(), "4".into()), ("NAME".into(), "Lake".into())]); + } + + #[test] + fn exceptions_are_errors() { + let e = br#"Layer roads is not queryable"#; + let err = read_feature_info(e).unwrap_err(); + assert!(err.contains("not queryable"), "{err}"); + assert!(read_feature_info(&[0x89, b'P', b'N', b'G', 0xff, 0xfe]).is_err()); + } + + #[test] + fn text_and_html_answers() { + let geoserver = "Results for FeatureType 'roads':\n--------------------------------------------\nname = Main St\nlanes = 2\n--------------------------------------------\nname = Side St\nlanes = 1\n"; + let f = read_feature_info(geoserver.as_bytes()).unwrap(); + assert_eq!(f.len(), 2); + assert_eq!(f[1].layer.as_deref(), Some("roads")); + assert_eq!(f[1].attributes, vec![("name".into(), "Side St".into()), ("lanes".into(), "1".into())]); + let mapserver = "GetFeatureInfo results:\n\nLayer 'parks'\n Feature 12: \n NAME = 'Central'\n"; + assert_eq!(read_feature_info(mapserver.as_bytes()).unwrap()[0].attributes, vec![("NAME".into(), "Central".into())]); + let html = "

Lake & shore

"; + assert_eq!(read_feature_info(html.as_bytes()).unwrap()[0].attributes, vec![("text".into(), "Lake & shore".into())]); + assert!(read_feature_info(b" ").unwrap().is_empty()); + } + + #[test] + fn hostile_answers_never_panic() { + for body in [ + &b"{"[..], + b"<", + b"", + b"Layer '", + b"x = ", + b"[1,2]", + b"{\"features\":7}", + b"", + ] { + let _ = read_feature_info(body); + } + let _ = wms_layer_of("http://x?service=wms&request=getmap&layers=%"); + } +} diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index c658d47c3..268f7c64b 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -11,8 +11,11 @@ use serde_json::{Value, json}; use super::{f, need_layer, need_layers, need_selection, ps}; use crate::{CmdResult, CommandSpec as C, Session}; +#[path = "select_info.rs"] +mod info; + pub fn commands() -> Vec { - vec![ + let mut commands = vec![ C::new("select.byAttributes", "Select By Attributes", "Home › Selection", by_attributes) .when(need_layers) .params(r#"{"layer"?, "where": "SQL clause", "mode"?: "new"|"add"|"remove"|"subset"}"#), @@ -81,11 +84,13 @@ pub fn commands() -> Vec { }).params(r#"{"layer"?: name|id (default: the active layer, which need not be the layer last selected in)}"#) .when(need_selection), C::new("identify", "Identify", "Home › Inspect", identify).view().params( - r#"{"lon", "lat", "tolerancePx"?, "mode"?: "topmost"|"visible"|"selectable"|"selectedInContents"|"none" (the map's pop-up mode when omitted; the mode picks the layers, and every feature under the point in them, up to 100, is in "results"; topmost and visible take vector tile layers' features too, with "tileLayer" and "sourceLayer", from the tiles drawn there: one not loaded yet is asked for, and its features come on a later identify)}"#, + r#"{"lon", "lat", "tolerancePx"?, "mode"?: "topmost"|"visible"|"selectable"|"selectedInContents"|"none" (the map's pop-up mode when omitted; the mode picks the layers, and every feature under the point in them, up to 100, is in "results"; topmost and visible take vector tile layers' features too, with "tileLayer" and "sourceLayer", from the tiles drawn there: one not loaded yet is asked for, and its features come on a later identify; and they list in "featureInfo" the WMS layers drawn there to ask, each {tileLayer, url} (its GetFeatureInfo request) or {tileLayer, capabilities} (its service's, when the layer doesn't know its request): identify.featureInfo reads what they answer)}"#, ), C::new("map.popupMode", "Pop-up Mode", "Home › Navigate", popup_mode) .params(r#"{"mode": "topmost"|"visible"|"selectable"|"selectedInContents"|"none"}"#), - ] + ]; + commands.extend(info::commands()); + commands } #[derive(Clone, Copy, PartialEq)] @@ -321,16 +326,31 @@ fn identify(s: &mut Session, p: &Value) -> CmdResult { let room = MAX_IDENTIFIED.saturating_sub(results.len()); results.extend(tile_features(s, lon, lat, tol_px, first_layer, room)); } - let Some(first) = results.first().cloned() else { + // And the WMS layers drawn there are asked (their answers come later). + let asks = if matches!(mode, PopupMode::Topmost | PopupMode::Visible) && (!first_layer || results.is_empty()) { + info::requests(s, lon, lat, first_layer) + } else { + Vec::new() + }; + let mut out = match results.first().cloned() { + Some(first) => first, // No feature: report the pixel of the topmost raster instead. - if mode == PopupMode::SelectedInContents && active.and_then(|id| map.layer(id)).is_some_and(|l| !l.is_raster()) { + None if mode == PopupMode::SelectedInContents && active.and_then(|id| map.layer(id)).is_some_and(|l| !l.is_raster()) => { return Ok(Value::Null); } - return Ok(super::raster::identify(s, lon, lat).unwrap_or(Value::Null)); + None => match super::raster::identify(s, lon, lat) { + Some(pixel) => pixel, + None if asks.is_empty() => return Ok(Value::Null), + None => json!({}), + }, }; - let mut out = first; if let Value::Object(o) = &mut out { - o.insert("results".into(), Value::Array(results)); + if !results.is_empty() { + o.insert("results".into(), Value::Array(results)); + } + if !asks.is_empty() { + o.insert("featureInfo".into(), Value::Array(asks)); + } } Ok(out) } @@ -342,20 +362,7 @@ fn tile_features(s: &Session, lon: f64, lat: f64, tol_px: f64, first_layer: bool use rustgis_map::tiles::TileKind; use rustgis_render::rustgis_tiles::{draw, mvt}; let map = s.map(); - if !(lon.is_finite() && lat.is_finite()) || limit == 0 { - return Vec::new(); - } - let at = rustgis_geom::lonlat_to_merc(Coord::new(lon, lat.clamp(-85.06, 85.06))); - // Web Mercator metres per point at the click, as tiles are drawn in other projections (in - // Web Mercator, the camera's resolution). - let res = map.camera.res; - let m_per_px = map - .to_map(Coord::new(lon, lat)) - .and_then(|m| map.to_lonlat(Coord::new(m.x + res, m.y))) - .map(|c| rustgis_geom::lonlat_to_merc(Coord::new(c.x, c.y.clamp(-85.06, 85.06)))) - .map(|b| (b.x - at.x).hypot(b.y - at.y)) - .filter(|d| d.is_finite() && *d > 0.0) - .unwrap_or(res); + let Some((at, m_per_px)) = info::merc_at(s, lon, lat).filter(|_| limit > 0) else { return Vec::new() }; let mut out = Vec::new(); for t in &map.tiles { let data = matches!(t.kind, TileKind::PmTiles | TileKind::MbTiles | TileKind::Vector) && (t.id.is_empty() || t.overlay); diff --git a/crates/engine/src/cmd/select_info.rs b/crates/engine/src/cmd/select_info.rs new file mode 100644 index 000000000..e5e66ea23 --- /dev/null +++ b/crates/engine/src/cmd/select_info.rs @@ -0,0 +1,161 @@ +//! Identify on WMS layers: what a service says is at a point (GetFeatureInfo). Identify lists the +//! requests for the WMS tile layers drawn there; the app fetches them in the background and passes +//! each answer to `identify.featureInfo` as `"text"`; the command line, MCP and the assistant let +//! `identify.featureInfo` fetch (waiting for the answer). +//! +//! A layer added from the service knows its request from the capabilities; one from a project +//! (which doesn't keep it) is asked for its service's capabilities first. + +use rustgis_data::ogc::{self, FeatureInfo, InfoFeature}; +use rustgis_geom::Coord; +use rustgis_map::tiles::TileKind; +use serde_json::{Value, json}; + +use super::super::{b, f, ps}; +use crate::{CmdResult, CommandSpec as C, Session}; + +/// Most WMS layers one identify asks. +const MAX_ASKED: usize = 8; +/// Most bytes read of a capabilities document or an answer (fetched here natively). +#[cfg(not(target_arch = "wasm32"))] +const MAX_BYTES: usize = 8 << 20; + +pub(super) fn commands() -> Vec { + vec![ + C::new("identify.featureInfo", "Identify WMS Layer", "Home › Inspect", feature_info).view().params( + r#"{"layer": "a WMS tile layer's name", "lon"?, "lat"?: the point (for a request), "text"?: "the GetFeatureInfo answer, when already fetched (identify lists the requests)", "capabilities"?: "the service's capabilities document, when the layer doesn't know its request (identify then asks for it)", "request"?: bool (only say what to fetch next: {url} or {capabilities})} → results, as identify's (with "tileLayer"), or with request, what to fetch"#, + ), + ] +} + +/// Web Mercator metres per pixel at a point, as tiles are drawn (in another projection, the +/// distance of a pixel there; in Web Mercator, the camera's resolution), and the point there. +pub(super) fn merc_at(s: &Session, lon: f64, lat: f64) -> Option<(Coord, f64)> { + let map = s.map(); + if !(lon.is_finite() && lat.is_finite()) { + return None; + } + let at = rustgis_geom::lonlat_to_merc(Coord::new(lon, lat.clamp(-85.06, 85.06))); + let res = map.camera.res; + let m_per_px = map + .to_map(Coord::new(lon, lat)) + .and_then(|m| map.to_lonlat(Coord::new(m.x + res, m.y))) + .map(|c| rustgis_geom::lonlat_to_merc(Coord::new(c.x, c.y.clamp(-85.06, 85.06)))) + .map(|b| (b.x - at.x).hypot(b.y - at.y)) + .filter(|d| d.is_finite() && *d > 0.0) + .unwrap_or(res); + Some((at, m_per_px)) +} + +/// Whether a tile layer is a WMS layer that can be asked: drawn, and queryable or not known yet. +fn askable(t: &rustgis_map::tiles::TileLayer) -> bool { + t.kind == TileKind::Raster + && t.overlay + && t.draws() + && t.opacity > 0.0 + && match &t.feature_info { + Some(fi) => fi.queryable(), + None => ogc::wms_layer_of(&t.url).is_some(), + } +} + +/// What to fetch to ask a WMS tile layer about a point: the GetFeatureInfo request, or the +/// service's capabilities when the layer doesn't know it. `None` when it can't be asked there. +fn next_fetch(t: &rustgis_map::tiles::TileLayer, lon: f64, lat: f64, at: Coord, m_per_px: f64) -> Option { + if t.bounds.is_some_and(|[w, s, e, n]| !(w <= lon && lon <= e && s <= lat && lat <= n)) { + return None; + } + match &t.feature_info { + Some(fi) => fi.request(at.x, at.y, m_per_px).map(|url| json!({"tileLayer": t.name, "url": url})), + None => ogc::wms_layer_of(&t.url).map(|(caps, _)| json!({"tileLayer": t.name, "capabilities": caps})), + } +} + +/// The WMS tile layers drawn at a point to ask what is there, topmost first (only the topmost +/// when `first_layer`), each with what to fetch. +pub(super) fn requests(s: &Session, lon: f64, lat: f64, first_layer: bool) -> Vec { + let Some((at, m_per_px)) = merc_at(s, lon, lat) else { return Vec::new() }; + let wanted = if first_layer { 1 } else { MAX_ASKED }; + s.map().tiles.iter().filter(|t| askable(t)).filter_map(|t| next_fetch(t, lon, lat, at, m_per_px)).take(wanted).collect() +} + +/// The answer's features as identify's results. +fn results(layer: &str, features: Vec) -> Value { + let list: Vec = features + .into_iter() + .map(|f| { + let attrs: serde_json::Map = f.attributes.into_iter().map(|(k, v)| (k, json!(v))).collect(); + let name = match &f.layer { + Some(l) if l != layer => format!("{layer} › {l}"), + _ => layer.to_string(), + }; + json!({"layer": name, "tileLayer": layer, "sourceLayer": f.layer, "attributes": attrs}) + }) + .collect(); + json!({"results": list}) +} + +/// The index of the WMS tile layer named `name`. +fn wms_layer(s: &Session, name: &str) -> Result { + s.map() + .tiles + .iter() + .position(|t| t.name == name && (t.feature_info.is_some() || ogc::wms_layer_of(&t.url).is_some())) + .ok_or_else(|| format!("no WMS tile layer '{name}' (map.tiles lists them)")) +} + +/// Reads the layer's request from its service's capabilities; a layer the service doesn't list, +/// or doesn't offer for identify, isn't asked again. +fn learn(s: &mut Session, index: usize, capabilities: &[u8]) -> Result<(), String> { + let Some(t) = s.map().tiles.get(index) else { return Ok(()) }; + let Some((caps_url, layer)) = ogc::wms_layer_of(&t.url) else { return Ok(()) }; + let service = ogc::parse(&caps_url, capabilities)?; + let info = service + .layers + .into_iter() + .find(|l| l.name == layer) + .and_then(|l| l.feature_info) + .unwrap_or_else(|| FeatureInfo { layer, ..FeatureInfo::default() }); + if let Some(t) = s.map_mut().tiles.get_mut(index) { + t.feature_info = Some(info); + } + Ok(()) +} + +fn feature_info(s: &mut Session, p: &Value) -> CmdResult { + let name = ps(p, "layer").ok_or("layer is required (a WMS tile layer's name)")?.to_string(); + let index = wms_layer(s, &name)?; + if let Some(caps) = ps(p, "capabilities") { + learn(s, index, caps.as_bytes())?; + } + if let Some(text) = ps(p, "text") { + return Ok(results(&name, ogc::read_feature_info(text.as_bytes())?)); + } + let lon = f(p, "lon").ok_or("lon is required")?; + let lat = f(p, "lat").ok_or("lat is required")?; + let (at, m_per_px) = merc_at(s, lon, lat).ok_or("lon and lat must be numbers")?; + let next = |s: &Session| s.map().tiles.get(index).and_then(|t| next_fetch(t, lon, lat, at, m_per_px)); + if b(p, "request") == Some(true) { + // Nothing to ask (not queryable, or outside the layer): no results. + return Ok(next(s).unwrap_or_else(|| json!({"results": []}))); + } + #[cfg(target_arch = "wasm32")] + { + let _ = next; + Err(format!("{name}: in the web app, the map's Identify asks the service")) + } + #[cfg(not(target_arch = "wasm32"))] + { + let fetch = + |url: &str| crate::render::rustgis_tiles::cache::download_blocking_limited(url, MAX_BYTES).map_err(|e| format!("{name}: {e}")); + let mut step = next(s); + if let Some(caps) = step.as_ref().and_then(|v| v["capabilities"].as_str()).map(str::to_string) { + learn(s, index, &fetch(&caps)?)?; + step = next(s); + } + match step.as_ref().and_then(|v| v["url"].as_str()) { + Some(url) => Ok(results(&name, ogc::read_feature_info(&fetch(url)?)?)), + None => Ok(json!({"results": []})), + } + } +} diff --git a/crates/engine/src/cmd/service.rs b/crates/engine/src/cmd/service.rs index ba947a8e5..8f60cf507 100644 --- a/crates/engine/src/cmd/service.rs +++ b/crates/engine/src/cmd/service.rs @@ -383,6 +383,11 @@ pub(crate) fn add(s: &mut Session, p: &Value) -> CmdResult { None => template.to_string(), }; let r = super::tiles::add(s, &json!({"url": template, "kind": "raster", "name": name, "overlay": true, "bounds": layer.bbox}))?; + // A WMS layer's GetFeatureInfo request, for identify. + let added = r["index"].as_u64().and_then(|i| usize::try_from(i).ok()); + if let Some(t) = added.and_then(|i| s.map_mut().tiles.get_mut(i)) { + t.feature_info = layer.feature_info.clone(); + } zoom(s); Ok(r) } diff --git a/crates/engine/src/cmd/tiles.rs b/crates/engine/src/cmd/tiles.rs index b54d644be..6367c7f15 100644 --- a/crates/engine/src/cmd/tiles.rs +++ b/crates/engine/src/cmd/tiles.rs @@ -20,6 +20,7 @@ pub const NETWORK: &[&str] = &[ "layer.addUrl", "service.layers", "service.add", + "identify.featureInfo", "service.import", "service.refresh", "service.groupPlan", diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6b32a618c..b830fb62c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -8549,6 +8549,77 @@ fn an_arcgis_token_in_a_service_address_reaches_its_layers() { assert!(kept.is_none() && arcgis_auth::authorization(wms).is_none()); } +/// Identify asks a WMS layer what is at the point (GetFeatureInfo): the request from the +/// capabilities, the answer read as results; a project's layer, which doesn't keep the request, +/// reads its service's capabilities again; a layer that isn't queryable isn't asked. +#[test] +fn identify_asks_wms_layers() { + const WMS: &str = r#" +Test WMS +image/png +text/plainapplication/geo+json +RootEPSG:3857 +roadsRoads +reliefRelief +"#; + const ANSWER: &str = r#"{"type": "FeatureCollection", "features": [{"type": "Feature", "id": "roads.3", "properties": {"name": "Main St"}, "geometry": null}]}"#; + let mut s = Session::new(Project::default()); + let url = "https://maps.example.com/wms"; + s.run("service.add", &json!({"url": url, "layer": "relief", "text": WMS})).unwrap(); + s.run("service.add", &json!({"url": url, "layer": "roads", "text": WMS})).unwrap(); + s.run("map.goToXY", &json!({"lon": 8.5, "lat": 47.4, "scale": 10000})).unwrap(); + let v = s.run("identify", &json!({"lon": 8.5, "lat": 47.4, "mode": "visible"})).unwrap(); + let asks = v["featureInfo"].as_array().unwrap(); + assert_eq!(asks.len(), 1, "{v}"); + assert_eq!(asks[0]["tileLayer"], "Roads"); + let request = asks[0]["url"].as_str().unwrap(); + assert!( + request.starts_with("https://maps.example.com/info?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetFeatureInfo&LAYERS=roads&"), + "{request}" + ); + assert!(request.contains("&QUERY_LAYERS=roads&INFO_FORMAT=application%2Fgeo%2Bjson&") && request.contains("&I=50&J=50&"), "{request}"); + let r = s.run("identify.featureInfo", &json!({"layer": "Roads", "text": ANSWER})).unwrap(); + assert_eq!(r["results"][0]["layer"], "Roads › roads"); + assert_eq!(r["results"][0]["tileLayer"], "Roads"); + assert_eq!(r["results"][0]["attributes"]["name"], "Main St"); + let e = s.run( + "identify.featureInfo", + &json!({"layer": "Roads", "text": "bad"}), + ); + assert!(e.unwrap_err().contains("bad")); + assert!(s.run("identify.featureInfo", &json!({"layer": "nope", "text": ANSWER})).is_err()); + // A hidden layer isn't asked; nothing else is there, so identify finds nothing. + s.run("map.setTiles", &json!({"name": "Roads", "visible": false})).unwrap(); + assert!(s.run("identify", &json!({"lon": 8.5, "lat": 47.4, "mode": "visible"})).unwrap().is_null()); + s.run("map.setTiles", &json!({"name": "Roads", "visible": true})).unwrap(); + // The project file is as before: it doesn't keep the request. + let project = serde_json::to_string(&s.project).unwrap(); + assert!(!project.contains("featureInfo") && !project.contains("GetFeatureInfo")); + let mut reopened = Session::new(serde_json::from_str(&project).unwrap()); + reopened.run("map.goToXY", &json!({"lon": 8.5, "lat": 47.4, "scale": 10000})).unwrap(); + let v = reopened.run("identify", &json!({"lon": 8.5, "lat": 47.4, "mode": "visible"})).unwrap(); + let asks = v["featureInfo"].as_array().unwrap(); + assert_eq!(asks.len(), 2, "{v}"); + assert!( + asks.iter().all(|a| a["capabilities"] == "https://maps.example.com/wms?SERVICE=WMS&REQUEST=GetCapabilities&VERSION=1.3.0"), + "{v}" + ); + // With its service's capabilities, each knows its request (or that it isn't asked). + let r = reopened + .run("identify.featureInfo", &json!({"layer": "Roads", "lon": 8.5, "lat": 47.4, "capabilities": WMS, "request": true})) + .unwrap(); + assert_eq!(r["url"].as_str(), Some(request)); + let r = reopened + .run("identify.featureInfo", &json!({"layer": "Relief", "lon": 8.5, "lat": 47.4, "capabilities": WMS, "request": true})) + .unwrap(); + assert_eq!(r, json!({"results": []})); + let v = reopened.run("identify", &json!({"lon": 8.5, "lat": 47.4, "mode": "visible"})).unwrap(); + assert_eq!(v["featureInfo"].as_array().map(Vec::len), Some(1), "{v}"); + // A selection mode asks none. + let v = reopened.run("identify", &json!({"lon": 8.5, "lat": 47.4, "mode": "selectable"})).unwrap(); + assert!(v.is_null(), "{v}"); +} + /// Services from their capabilities: a WMS layer becomes a tile layer, an OGC API collection's /// features are downloaded (from a local server); unknown layers and documents are refused. #[test] diff --git a/crates/map/src/tiles.rs b/crates/map/src/tiles.rs index 2f76c8378..b1dc4a33d 100644 --- a/crates/map/src/tiles.rs +++ b/crates/map/src/tiles.rs @@ -156,6 +156,11 @@ pub struct TileLayer { /// Why the archive can't be read (a moved file, or a relative path with no project folder): it draws nothing. #[serde(skip)] pub error: Option, + /// A WMS layer's GetFeatureInfo request, from the service's capabilities: what identify asks + /// it. Kept for the session only (a project's layer reads the capabilities again); the type + /// can be saved, should projects keep it. + #[serde(skip)] + pub feature_info: Option, } /// The most dates a tile layer lists (as the time slider steps through at most). @@ -253,6 +258,7 @@ impl TileLayer { time_urls: Vec::new(), time_hidden: false, error: None, + feature_info: None, } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4c34eea8b..cd6e03f83 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2106,7 +2106,7 @@ impl RustGisApp { let v = self.session.run("identify", &json!({"lon": lon, "lat": lat, "tolerancePx": 5}))?; if let (false, Some(lon), Some(lat)) = (v.is_null(), lon.as_f64(), lat.as_f64()) { let map = self.session.project.active_map; - self.map.popup = Some(mapview::Popup { info: v, at: rustgis_engine::geom::Coord::new(lon, lat), index: 0, map }); + self.map.popup = Some(mapview::Popup::new(v, rustgis_engine::geom::Coord::new(lon, lat), map)); } else { self.map.popup = None; } diff --git a/crates/ui-egui/src/mapview.rs b/crates/ui-egui/src/mapview.rs index cf71b234b..370b94e72 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -74,6 +74,42 @@ pub struct Popup { /// The map it was opened on (the project's `active_map` then): layer and feature ids belong to /// a map, and a duplicated map has the same ones. pub map: usize, + /// The WMS layers being asked what is at the point, whose features join the list. + pub asks: Vec, +} + +/// A WMS layer asked what is at a popup's point (GetFeatureInfo), its answer on the way: the +/// request's, or first its service's capabilities (a layer from a project doesn't know its request). +pub struct Ask { + layer: String, + capabilities: bool, + slot: crate::Slot, String>>, +} + +impl Popup { + /// A popup for what identify found at `at`, asking the WMS layers it lists. + pub fn new(info: Value, at: Coord, map: usize) -> Popup { + let asks = info["featureInfo"] + .as_array() + .into_iter() + .flatten() + .filter_map(|a| { + let layer = a["tileLayer"].as_str()?.to_string(); + let (url, capabilities) = match (a["url"].as_str(), a["capabilities"].as_str()) { + (Some(u), _) => (u, false), + (None, Some(c)) => (c, true), + _ => return None, + }; + Some(Ask { layer, capabilities, slot: crate::fetch_into(url) }) + }) + .collect(); + Popup { info, at, index: 0, map, asks } + } +} + +/// Whether a popup's value is a feature or pixel found, not only WMS layers still being asked. +fn is_found(v: &Value) -> bool { + v.get("layer").is_some() || v.get("attributes").is_some() } /// A notice shown as a toast. @@ -2207,7 +2243,7 @@ fn interact(app: &mut RustGisApp, ui: &mut Ui, resp: &egui::Response, rect: Rect if let (Some(id), Some(row)) = (v["layerId"].as_u64(), v["row"].as_u64()) { let _ = app.session.run("select.rows", &json!({"layer": id, "rows": [row]})); } - Some(Popup { info: v, at: c, index: 0, map: app.session.project.active_map }) + Some(Popup::new(v, c, app.session.project.active_map)) } _ => { let _ = app.session.run("select.clear", &Value::Null); @@ -2778,7 +2814,72 @@ fn follow_popup(app: &mut RustGisApp) { pp.info = info; } +/// Adds what the WMS layers asked answer to the popup, asking a layer from a project once its +/// service's capabilities say how; failures are told. The popup closes when nothing was found. +fn follow_asks(app: &mut RustGisApp, ctx: &egui::Context) { + let Some(pp) = &mut app.map.popup else { return }; + if pp.asks.is_empty() { + return; + } + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + let (lon, lat) = (pp.at.x, pp.at.y); + let mut landed = Vec::new(); + pp.asks.retain(|a| match a.slot.lock().unwrap_or_else(|e| e.into_inner()).take() { + Some(r) => { + landed.push((a.layer.clone(), a.capabilities, r)); + false + } + None => true, + }); + if landed.is_empty() { + return; + } + let mut found = Vec::new(); + let mut next = Vec::new(); + for (layer, capabilities, r) in landed { + let r = r.and_then(|bytes| { + let text = String::from_utf8_lossy(&bytes).into_owned(); + if capabilities { + let p = json!({"layer": layer, "lon": lon, "lat": lat, "capabilities": text, "request": true}); + app.session.run("identify.featureInfo", &p) + } else { + app.session.run("identify.featureInfo", &json!({"layer": layer, "text": text})) + } + }); + match r { + Ok(v) => { + if let Some(url) = v["url"].as_str() { + next.push(Ask { layer, capabilities: false, slot: crate::fetch_into(url) }); + } + found.extend(v["results"].as_array().cloned().unwrap_or_default()); + } + Err(e) => app.session.notify(crate::i18n::fmt("{layer}: {e}", &[("layer", &layer), ("e", &e)]), true), + } + } + let Some(pp) = &mut app.map.popup else { return }; + pp.asks.extend(next); + if !found.is_empty() { + let mut all: Vec = match pp.info["results"].as_array() { + Some(r) => r.clone(), + None if is_found(&pp.info) => vec![pp.info.clone()], + None => Vec::new(), + }; + all.extend(found); + if let Some(mut info) = all.first().cloned() { + if let Value::Object(o) = &mut info { + o.remove("featureInfo"); + o.insert("results".into(), Value::Array(all)); + } + pp.info = info; + } + } + if pp.asks.is_empty() && !is_found(&pp.info) { + app.map.popup = None; + } +} + fn popup(app: &mut RustGisApp, ui: &mut Ui, rect: Rect) { + follow_asks(app, ui.ctx()); follow_popup(app); let Some(pp) = &app.map.popup else { return }; let t = Tokens::get(ui.ctx()); @@ -2793,12 +2894,20 @@ fn popup(app: &mut RustGisApp, ui: &mut Ui, rect: Rect) { let mut zoom = false; // The feature shown, as its layer id and feature id (a raster pixel has neither). let shown = info["layerId"].as_u64().and_then(|id| u32::try_from(id).ok()).zip(info["fid"].as_u64()); - let layer = info["layer"].as_str().unwrap_or("").to_string(); + // Only WMS layers being asked so far: their names. + let waiting = !is_found(&info); + let layer = if waiting { + pp.asks.iter().map(|a| a.layer.as_str()).collect::>().join(", ") + } else { + info["layer"].as_str().unwrap_or("").to_string() + }; let mut attrs = info["attributes"].as_object().cloned().unwrap_or_default(); // A picture value (a photo's thumbnail) shows as the picture, not as its text. let picture_key = attrs.iter().find(|(_, v)| v.as_str().is_some_and(is_picture)).map(|(k, _)| k.clone()); let picture = picture_key.and_then(|k| attrs.remove(&k)).and_then(|v| v.as_str().map(|s| picture_texture(app, ui.ctx(), s))); - let title = if info["raster"].as_bool() == Some(true) { + let title = if waiting { + tl!("Asking the map service…").to_string() + } else if info["raster"].as_bool() == Some(true) { tl!("Pixel").to_string() } else { { diff --git a/docs/user-guide/editing.md b/docs/user-guide/editing.md index 06947c036..7c1de2823 100644 --- a/docs/user-guide/editing.md +++ b/docs/user-guide/editing.md @@ -117,3 +117,12 @@ The topmost and visible modes also identify the features of vector tile layers u PMTiles and MBTiles archives and vector tiles added as data (not catalog basemaps or MapLibre styles), beneath the map's own layers. Each shows its tile layer and source layer (`firenze › buildings`) and its attributes; tile features can't be selected or zoomed to. + +They ask WMS layers too (added from a service, shown, and called queryable in its capabilities): +RustGIS sends the service a standard GetFeatureInfo request for the point (WMS 1.3.0 or 1.1.1, +whichever the service was added in), asking for GeoJSON when it offers it, else GML, plain text or +HTML, and the features it answers join the pop-up as they arrive (topmost asks only the top WMS +layer, and only when no feature of the map's own layers is under the click). A project doesn't save +the request: a WMS layer from a project reads its service's capabilities again on the first click. +`identify` lists the requests in `featureInfo`, and `identify.featureInfo` reads an answer (or, on +the command line, asks the service and waits). From dafa7f7054388fcef81c259560d897c1f65a0fd4 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Mon, 26 Oct 2026 13:42:49 +0100 Subject: [PATCH 2/8] fix(services): GetFeatureInfo answers capped and read in linear time, local endpoints not asked - html_text lowercases the answer once instead of the rest of it at every script or style tag. - The app fetches capabilities and answers with the 8 MiB limit identify.featureInfo uses. - A request whose address reaches into the local network from a service on the web isn't made. - queryable="TRUE" counts as queryable. --- crates/data/src/ogc.rs | 3 ++- crates/data/src/ogc_info.rs | 31 +++++++++++++++++----------- crates/engine/src/cmd/select_info.rs | 7 ++++++- crates/engine/src/tests.rs | 9 ++++++++ crates/ui-egui/src/mapview.rs | 8 +++++-- 5 files changed, 42 insertions(+), 16 deletions(-) diff --git a/crates/data/src/ogc.rs b/crates/data/src/ogc.rs index cda4b3973..7813c9904 100644 --- a/crates/data/src/ogc.rs +++ b/crates/data/src/ogc.rs @@ -437,7 +437,8 @@ fn wms(url: &str, root: &Node) -> Service { lonlat_box(f("minx")?, f("miny")?, f("maxx")?, f("maxy")?) }) .or(inherited_bbox); - let queryable = layer.attribute("queryable").map_or(inherited_queryable, |q| q.trim() == "1" || q.trim() == "true"); + let queryable = + layer.attribute("queryable").map_or(inherited_queryable, |q| q.trim() == "1" || q.trim().eq_ignore_ascii_case("true")); for sub in children(&layer, "Layer") { stack.push((sub, crs.clone(), bbox, queryable)); } diff --git a/crates/data/src/ogc_info.rs b/crates/data/src/ogc_info.rs index 32ca69da2..f3d7ab977 100644 --- a/crates/data/src/ogc_info.rs +++ b/crates/data/src/ogc_info.rs @@ -352,23 +352,25 @@ fn text_feature(text: &str) -> Vec { /// An HTML answer's text: its tags, scripts and styles left out. fn html_text(html: &str) -> String { + // ASCII lowercasing keeps byte offsets, so one copy serves every search (a copy per tag + // would be quadratic on an answer of many short scripts). + let lower = html.to_ascii_lowercase(); let mut out = String::new(); - let mut rest = html; - while let Some(open) = rest.find('<') { - out.push_str(rest.get(..open).unwrap_or("")); + let mut at = 0; + while let Some(open) = html.get(at..).and_then(|r| r.find('<')).map(|i| at + i) { + out.push_str(html.get(at..open).unwrap_or("")); out.push(' '); - let after = rest.get(open..).unwrap_or(""); - let lower = after.get(..after.len().min(8)).unwrap_or("").to_ascii_lowercase(); - let skip_to = if lower.starts_with("").map(|i| i + 9) - } else if lower.starts_with("").map(|i| i + 8) + let tag = lower.get(open..).unwrap_or(""); + let skip_to = if tag.starts_with("").map(|i| i + 9) + } else if tag.starts_with("").map(|i| i + 8) } else { - after.find('>').map(|i| i + 1) + tag.find('>').map(|i| i + 1) }; - rest = skip_to.and_then(|i| after.get(i..)).unwrap_or(""); + at = skip_to.map_or(html.len(), |i| open + i); } - out.push_str(rest); + out.push_str(html.get(at..).unwrap_or("")); out.replace(" ", " ").replace("&", "&").replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'") } @@ -501,6 +503,11 @@ mod tests { assert_eq!(read_feature_info(mapserver.as_bytes()).unwrap()[0].attributes, vec![("NAME".into(), "Central".into())]); let html = "

Lake & shore

"; assert_eq!(read_feature_info(html.as_bytes()).unwrap()[0].attributes, vec![("text".into(), "Lake & shore".into())]); + // Any case of script and style is left out; many short scripts read in linear time. + let shouting = "Peak"; + assert_eq!(html_text(shouting).split_whitespace().collect::>(), ["Peak"]); + let many = format!("{}

end

", "".repeat(200_000)); + assert_eq!(html_text(&many).split_whitespace().collect::>(), ["end"]); assert!(read_feature_info(b" ").unwrap().is_empty()); } diff --git a/crates/engine/src/cmd/select_info.rs b/crates/engine/src/cmd/select_info.rs index e5e66ea23..aec71c917 100644 --- a/crates/engine/src/cmd/select_info.rs +++ b/crates/engine/src/cmd/select_info.rs @@ -66,7 +66,12 @@ fn next_fetch(t: &rustgis_map::tiles::TileLayer, lon: f64, lat: f64, at: Coord, return None; } match &t.feature_info { - Some(fi) => fi.request(at.x, at.y, m_per_px).map(|url| json!({"tileLayer": t.name, "url": url})), + // The request's address comes from the capabilities (hostile input): it must not reach + // into the local network from a service on the web. + Some(fi) => fi + .request(at.x, at.y, m_per_px) + .filter(|url| crate::render::rustgis_tiles::address::check(url, Some(&t.url)).is_ok()) + .map(|url| json!({"tileLayer": t.name, "url": url})), None => ogc::wms_layer_of(&t.url).map(|(caps, _)| json!({"tileLayer": t.name, "capabilities": caps})), } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b830fb62c..3c2d61910 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -8618,6 +8618,15 @@ fn identify_asks_wms_layers() { // A selection mode asks none. let v = reopened.run("identify", &json!({"lon": 8.5, "lat": 47.4, "mode": "selectable"})).unwrap(); assert!(v.is_null(), "{v}"); + // A service on the web whose GetFeatureInfo points into the local network isn't asked. + let local = WMS.replace("https://maps.example.com/info?", "http://192.168.1.5/info?"); + let mut s = Session::new(Project::default()); + s.run("service.add", &json!({"url": url, "layer": "roads", "text": local})).unwrap(); + s.run("map.goToXY", &json!({"lon": 8.5, "lat": 47.4, "scale": 10000})).unwrap(); + let v = s.run("identify", &json!({"lon": 8.5, "lat": 47.4, "mode": "visible"})).unwrap(); + assert!(v.is_null(), "{v}"); + let r = s.run("identify.featureInfo", &json!({"layer": "Roads", "lon": 8.5, "lat": 47.4, "request": true})).unwrap(); + assert_eq!(r, json!({"results": []})); } /// Services from their capabilities: a WMS layer becomes a tile layer, an OGC API collection's diff --git a/crates/ui-egui/src/mapview.rs b/crates/ui-egui/src/mapview.rs index 370b94e72..05a675660 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -19,6 +19,10 @@ use serde_json::{Value, json}; use crate::theme::{Tokens, c32, regular, semibold}; use crate::{RustGisApp, icons}; +/// Most bytes read of a WMS capabilities document or GetFeatureInfo answer, as +/// `identify.featureInfo` reads them natively: each answer is parsed on the frame thread. +const MAX_INFO_BYTES: usize = 8 << 20; + #[derive(Clone, Copy, PartialEq, Debug)] struct Key { rev: u64, @@ -100,7 +104,7 @@ impl Popup { (None, Some(c)) => (c, true), _ => return None, }; - Some(Ask { layer, capabilities, slot: crate::fetch_into(url) }) + Some(Ask { layer, capabilities, slot: crate::fetch_into_limited(url, MAX_INFO_BYTES) }) }) .collect(); Popup { info, at, index: 0, map, asks } @@ -2849,7 +2853,7 @@ fn follow_asks(app: &mut RustGisApp, ctx: &egui::Context) { match r { Ok(v) => { if let Some(url) = v["url"].as_str() { - next.push(Ask { layer, capabilities: false, slot: crate::fetch_into(url) }); + next.push(Ask { layer, capabilities: false, slot: crate::fetch_into_limited(url, MAX_INFO_BYTES) }); } found.extend(v["results"].as_array().cloned().unwrap_or_default()); } From 73798647320d6deee65054829b6d2c78843cbb93 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Mon, 26 Oct 2026 14:44:18 +0100 Subject: [PATCH 3/8] fix(services): attribute lines named Feature or Layer, and attributes named member, read as attributes --- crates/data/src/ogc_info.rs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/data/src/ogc_info.rs b/crates/data/src/ogc_info.rs index f3d7ab977..a5e21bee3 100644 --- a/crates/data/src/ogc_info.rs +++ b/crates/data/src/ogc_info.rs @@ -300,8 +300,9 @@ fn xml_features(root: &Node) -> Result, String> { } // GML: a feature collection's members (GML 2/3 featureMember(s), WFS 2 member). _ => { - for member in root.descendants().filter(|n| n.is_element() && matches!(local(n), "featureMember" | "featureMembers" | "member")) - { + let is_member = |n: &Node| n.is_element() && matches!(local(n), "featureMember" | "featureMembers" | "member"); + // A member inside another is a feature's attribute (one named `member`), not a feature. + for member in root.descendants().filter(|n| is_member(n) && !n.ancestors().skip(1).any(|a| is_member(&a))) { for f in member.children().filter(|c| c.is_element()) { out.push(InfoFeature { layer: Some(local(&f).to_string()), attributes: gml_attributes(&f) }); } @@ -327,10 +328,12 @@ fn plain_features(text: &str) -> Vec { for line in text.lines().take(100_000) { let t = line.trim(); let quoted = |rest: &str| rest.split(['\'', '"']).nth(1).map(str::to_string); - if let Some(rest) = t.strip_prefix("Layer ").or_else(|| t.strip_prefix("Results for FeatureType")) { + // A `name = value` line is an attribute, whatever its name starts with ("Layer name = x"). + let heading = !t.contains(" = "); + if let Some(rest) = t.strip_prefix("Layer ").or_else(|| t.strip_prefix("Results for FeatureType")).filter(|_| heading) { flush(&layer, &mut current, &mut out); layer = quoted(rest).or(layer.take()); - } else if t.starts_with("Feature ") || (t.len() >= 3 && t.bytes().all(|b| b == b'-' || b == b'=')) { + } else if (heading && t.starts_with("Feature ")) || (t.len() >= 3 && t.bytes().all(|b| b == b'-' || b == b'=')) { flush(&layer, &mut current, &mut out); } else if let Some((k, v)) = t.split_once(" = ") && current.len() < MAX_ATTRIBUTES @@ -476,6 +479,12 @@ mod tests { attributes: vec![("name".into(), "Main St".into()), ("lanes".into(), "2".into())] }] ); + // A feature's attribute named `member` is no member of the collection. + let wfs2 = br#" + ChessAnn"#; + let f = read_feature_info(wfs2).unwrap(); + assert_eq!(f.len(), 1, "{f:?}"); + assert_eq!(f[0].layer.as_deref(), Some("clubs")); let ms = br#"parks Central"#; let f = read_feature_info(ms).unwrap(); @@ -499,6 +508,10 @@ mod tests { assert_eq!(f.len(), 2); assert_eq!(f[1].layer.as_deref(), Some("roads")); assert_eq!(f[1].attributes, vec![("name".into(), "Side St".into()), ("lanes".into(), "1".into())]); + let named = "Layer 'roads'\n Feature 3: \n Feature class = 'Road'\n Layer name = 'base'\n"; + let f = read_feature_info(named.as_bytes()).unwrap(); + assert_eq!(f.len(), 1); + assert_eq!(f[0].attributes, vec![("Feature class".into(), "Road".into()), ("Layer name".into(), "base".into())]); let mapserver = "GetFeatureInfo results:\n\nLayer 'parks'\n Feature 12: \n NAME = 'Central'\n"; assert_eq!(read_feature_info(mapserver.as_bytes()).unwrap()[0].attributes, vec![("NAME".into(), "Central".into())]); let html = "

Lake & shore

"; From bb7d3035679876b13fb96510b1e5c101bbb56691 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Mon, 26 Oct 2026 15:37:03 +0100 Subject: [PATCH 4/8] fix(services): GML members found in one walk, and one limit for feature-info downloads --- crates/data/src/ogc.rs | 2 +- crates/data/src/ogc_info.rs | 18 +++++++++++++++--- crates/engine/src/cmd/select_info.rs | 8 +++----- crates/ui-egui/src/mapview.rs | 12 ++++++------ 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/crates/data/src/ogc.rs b/crates/data/src/ogc.rs index 7813c9904..37a9b5c99 100644 --- a/crates/data/src/ogc.rs +++ b/crates/data/src/ogc.rs @@ -10,7 +10,7 @@ use serde_json::value::RawValue; #[path = "ogc_info.rs"] mod info; -pub use info::{FeatureInfo, InfoFeature, read_feature_info, wms_layer_of}; +pub use info::{FeatureInfo, InfoFeature, MAX_INFO_BYTES, read_feature_info, wms_layer_of}; /// What a layer of a service becomes. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/data/src/ogc_info.rs b/crates/data/src/ogc_info.rs index a5e21bee3..9f7cf8ed8 100644 --- a/crates/data/src/ogc_info.rs +++ b/crates/data/src/ogc_info.rs @@ -33,6 +33,9 @@ const WINDOW: u32 = 101; const FEATURE_COUNT: usize = 10; /// Most features read from an answer, attributes kept of each, and characters of a value. const MAX_FEATURES: usize = 100; +/// Most bytes read of a capabilities document or a GetFeatureInfo answer, by the command and by +/// the app alike. +pub const MAX_INFO_BYTES: usize = 8 << 20; const MAX_ATTRIBUTES: usize = 200; const MAX_VALUE: usize = 4000; @@ -300,9 +303,18 @@ fn xml_features(root: &Node) -> Result, String> { } // GML: a feature collection's members (GML 2/3 featureMember(s), WFS 2 member). _ => { - let is_member = |n: &Node| n.is_element() && matches!(local(n), "featureMember" | "featureMembers" | "member"); - // A member inside another is a feature's attribute (one named `member`), not a feature. - for member in root.descendants().filter(|n| is_member(n) && !n.ancestors().skip(1).any(|a| is_member(&a))) { + // The outermost members, in document order: a member inside another is a feature's + // attribute (one named `member`), not a feature, so the walk doesn't go into them. + let mut members = Vec::new(); + let mut stack = vec![*root]; + while let Some(n) = stack.pop() { + if matches!(local(&n), "featureMember" | "featureMembers" | "member") { + members.push(n); + } else { + stack.extend(n.children().filter(|c| c.is_element()).collect::>().into_iter().rev()); + } + } + for member in members { for f in member.children().filter(|c| c.is_element()) { out.push(InfoFeature { layer: Some(local(&f).to_string()), attributes: gml_attributes(&f) }); } diff --git a/crates/engine/src/cmd/select_info.rs b/crates/engine/src/cmd/select_info.rs index aec71c917..7fbefead7 100644 --- a/crates/engine/src/cmd/select_info.rs +++ b/crates/engine/src/cmd/select_info.rs @@ -16,9 +16,6 @@ use crate::{CmdResult, CommandSpec as C, Session}; /// Most WMS layers one identify asks. const MAX_ASKED: usize = 8; -/// Most bytes read of a capabilities document or an answer (fetched here natively). -#[cfg(not(target_arch = "wasm32"))] -const MAX_BYTES: usize = 8 << 20; pub(super) fn commands() -> Vec { vec![ @@ -151,8 +148,9 @@ fn feature_info(s: &mut Session, p: &Value) -> CmdResult { } #[cfg(not(target_arch = "wasm32"))] { - let fetch = - |url: &str| crate::render::rustgis_tiles::cache::download_blocking_limited(url, MAX_BYTES).map_err(|e| format!("{name}: {e}")); + let fetch = |url: &str| { + crate::render::rustgis_tiles::cache::download_blocking_limited(url, ogc::MAX_INFO_BYTES).map_err(|e| format!("{name}: {e}")) + }; let mut step = next(s); if let Some(caps) = step.as_ref().and_then(|v| v["capabilities"].as_str()).map(str::to_string) { learn(s, index, &fetch(&caps)?)?; diff --git a/crates/ui-egui/src/mapview.rs b/crates/ui-egui/src/mapview.rs index 05a675660..45032bc80 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -19,10 +19,6 @@ use serde_json::{Value, json}; use crate::theme::{Tokens, c32, regular, semibold}; use crate::{RustGisApp, icons}; -/// Most bytes read of a WMS capabilities document or GetFeatureInfo answer, as -/// `identify.featureInfo` reads them natively: each answer is parsed on the frame thread. -const MAX_INFO_BYTES: usize = 8 << 20; - #[derive(Clone, Copy, PartialEq, Debug)] struct Key { rev: u64, @@ -104,7 +100,7 @@ impl Popup { (None, Some(c)) => (c, true), _ => return None, }; - Some(Ask { layer, capabilities, slot: crate::fetch_into_limited(url, MAX_INFO_BYTES) }) + Some(Ask { layer, capabilities, slot: crate::fetch_into_limited(url, rustgis_engine::data::ogc::MAX_INFO_BYTES) }) }) .collect(); Popup { info, at, index: 0, map, asks } @@ -2853,7 +2849,11 @@ fn follow_asks(app: &mut RustGisApp, ctx: &egui::Context) { match r { Ok(v) => { if let Some(url) = v["url"].as_str() { - next.push(Ask { layer, capabilities: false, slot: crate::fetch_into_limited(url, MAX_INFO_BYTES) }); + next.push(Ask { + layer, + capabilities: false, + slot: crate::fetch_into_limited(url, rustgis_engine::data::ogc::MAX_INFO_BYTES), + }); } found.extend(v["results"].as_array().cloned().unwrap_or_default()); } From d7e4af5ba1584a09111763868d3b485c2c02242f Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Mon, 26 Oct 2026 16:06:12 +0100 Subject: [PATCH 5/8] fix(services): a WMS without GetFeatureInfo isn't asked, and &lt; reads as < --- crates/data/src/ogc.rs | 8 +++++++- crates/data/src/ogc_info.rs | 9 ++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/data/src/ogc.rs b/crates/data/src/ogc.rs index 37a9b5c99..d7e9f3814 100644 --- a/crates/data/src/ogc.rs +++ b/crates/data/src/ogc.rs @@ -457,7 +457,13 @@ fn wms(url: &str, root: &Node) -> Service { ), ); let title = child_text(&layer, "Title").unwrap_or_else(|| name.clone()); - let feature_info = info_request.as_ref().map(|r| r.for_layer(&name, queryable)); + // A service without GetFeatureInfo still gives each layer one, never asked, so Identify + // doesn't fetch the capabilities again to find out. + let feature_info = Some( + info_request + .as_ref() + .map_or_else(|| FeatureInfo { layer: name.clone(), ..FeatureInfo::default() }, |r| r.for_layer(&name, queryable)), + ); service.layers.push(ServiceLayer { name, title, offer: Offer::Tiles { template }, bbox, tilesets: None, time: None, feature_info }); } service.layers.reverse(); diff --git a/crates/data/src/ogc_info.rs b/crates/data/src/ogc_info.rs index 9f7cf8ed8..86537fc99 100644 --- a/crates/data/src/ogc_info.rs +++ b/crates/data/src/ogc_info.rs @@ -386,7 +386,8 @@ fn html_text(html: &str) -> String { at = skip_to.map_or(html.len(), |i| open + i); } out.push_str(html.get(at..).unwrap_or("")); - out.replace(" ", " ").replace("&", "&").replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'") + // `&` last, so `&lt;` reads as `<`. + out.replace(" ", " ").replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&") } #[cfg(test)] @@ -448,10 +449,11 @@ mod tests { } #[test] - fn without_a_get_feature_info_request_layers_have_none() { + fn without_a_get_feature_info_request_layers_are_not_queryable() { + // Each layer knows it isn't asked, so Identify doesn't fetch the capabilities again. let caps = CAPS_13.replace("GetFeatureInfo", "GetLegendGraphic"); let service = crate::ogc::parse("https://maps.example.org/wms", caps.as_bytes()).unwrap(); - assert!(service.layers.iter().all(|l| l.feature_info.is_none())); + assert!(service.layers.iter().all(|l| l.feature_info.as_ref().is_some_and(|f| !f.queryable() && f.layer == l.name))); } #[test] @@ -527,6 +529,7 @@ mod tests { let mapserver = "GetFeatureInfo results:\n\nLayer 'parks'\n Feature 12: \n NAME = 'Central'\n"; assert_eq!(read_feature_info(mapserver.as_bytes()).unwrap()[0].attributes, vec![("NAME".into(), "Central".into())]); let html = "

Lake & shore

"; + assert_eq!(html_text("a &lt; b").trim(), "a < b", "an escaped entity is read once"); assert_eq!(read_feature_info(html.as_bytes()).unwrap()[0].attributes, vec![("text".into(), "Lake & shore".into())]); // Any case of script and style is left out; many short scripts read in linear time. let shouting = "Peak"; From 8474895bf557ed840c83cd17855fbfd615cd9ae7 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Mon, 26 Oct 2026 16:37:35 +0100 Subject: [PATCH 6/8] fix(services): GetFeatureInfo addresses resolved and their keys replaced, Latin-1 answers read, empty clicks clear the selection - A relative GetFeatureInfo OnlineResource resolves against the capabilities address, and WMS keys it carries (REQUEST, VERSION, LAYERS, INFO_FORMAT, ...) give way to the request's own. - The app reads an answer as the command does (answer_text): Latin-1 XML or HTML as its characters, an image refused. - A click that finds nothing but WMS layers to ask clears the selection, as an empty click did. - GML members past the most features read aren't turned into features. - The capabilities address of a project's WMS layer goes through address::check too. - The user guide says Topmost asks the highest WMS layer that can be asked, after vector tiles. --- crates/data/src/ogc.rs | 2 +- crates/data/src/ogc_info.rs | 97 +++++++++++++++++++++++----- crates/engine/src/cmd/select_info.rs | 5 +- crates/ui-egui/src/mapview.rs | 8 ++- docs/user-guide/editing.md | 8 ++- 5 files changed, 99 insertions(+), 21 deletions(-) diff --git a/crates/data/src/ogc.rs b/crates/data/src/ogc.rs index d7e9f3814..79975da1f 100644 --- a/crates/data/src/ogc.rs +++ b/crates/data/src/ogc.rs @@ -10,7 +10,7 @@ use serde_json::value::RawValue; #[path = "ogc_info.rs"] mod info; -pub use info::{FeatureInfo, InfoFeature, MAX_INFO_BYTES, read_feature_info, wms_layer_of}; +pub use info::{FeatureInfo, InfoFeature, MAX_INFO_BYTES, answer_text, read_feature_info, wms_layer_of}; /// What a layer of a service becomes. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/data/src/ogc_info.rs b/crates/data/src/ogc_info.rs index 86537fc99..b47e0044b 100644 --- a/crates/data/src/ogc_info.rs +++ b/crates/data/src/ogc_info.rs @@ -3,6 +3,8 @@ //! GeoJSON, GML (a WFS-style feature collection or MapServer's), Esri's XML, plain text or //! HTML — read as features. Answers are hostile input: they are capped like capabilities. +use std::borrow::Cow; + use roxmltree::Node; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -110,8 +112,10 @@ pub(super) fn request_of(root: &Node, url: &str, version: &str, map_format: &str .find(|n| n.is_element() && local(n) == "Get") .and_then(|g| child(&g, "OnlineResource")) .and_then(|o| href(&o)) - .filter(|h| is_web(h)) + .and_then(|h| super::resolve(url, &h)) .unwrap_or_else(|| super::endpoint(url)); + // The request's own parameters replace any the advertised address carries. + let get = without_wms_keys(&get); Some(FeatureInfo { url: get, version: version.to_string(), layer: String::new(), map_format: map_format.to_string(), formats }) } @@ -135,6 +139,32 @@ const GET_MAP_KEYS: [&str; 16] = [ "elevation", ]; +/// GetFeatureInfo's own parameters, beyond GetMap's. +const INFO_KEYS: [&str; 7] = ["query_layers", "info_format", "feature_count", "i", "j", "x", "y"]; + +/// An address without the WMS request parameters it carries (GetMap's and GetFeatureInfo's), +/// keeping the others (MapServer's `map=`, a token); as it is when it carries none. +fn without_wms_keys(url: &str) -> String { + let pairs: Vec<&str> = url + .split('#') + .next() + .and_then(|u| u.split_once('?')) + .map(|(_, q)| q) + .unwrap_or("") + .split('&') + .filter(|kv| !kv.is_empty()) + .collect(); + let is_wms_key = |kv: &&str| { + let k = kv.split_once('=').map(|(k, _)| k).unwrap_or(kv).to_ascii_lowercase(); + GET_MAP_KEYS.contains(&k.as_str()) || INFO_KEYS.contains(&k.as_str()) + }; + if !pairs.iter().any(is_wms_key) { + return url.to_string(); + } + let kept: Vec<&str> = pairs.into_iter().filter(|kv| !is_wms_key(kv)).collect(); + if kept.is_empty() { base(url).to_string() } else { format!("{}?{}", base(url), kept.join("&")) } +} + /// A WMS tile template's (a GetMap request's) service and layer: its GetCapabilities address and /// the layer's name. How a layer whose GetFeatureInfo request isn't known (one from a project) /// finds it. `None` for other templates. @@ -146,19 +176,7 @@ pub fn wms_layer_of(template: &str) -> Option<(String, String)> { let decode = |v: &str| url::form_urlencoded::parse(format!("v={v}").as_bytes()).next().map(|(_, v)| v.into_owned()); let layer = query_value(template, "layers").and_then(decode).filter(|l| !l.is_empty())?; let version = query_value(template, "version").and_then(decode).filter(|v| v.starts_with("1.1") || v.starts_with("1.3")); - let kept: Vec<&str> = template - .split('#') - .next() - .and_then(|u| u.split_once('?')) - .map(|(_, q)| q) - .unwrap_or("") - .split('&') - .filter(|kv| { - let k = kv.split_once('=').map(|(k, _)| k).unwrap_or(kv).to_ascii_lowercase(); - !kv.is_empty() && !GET_MAP_KEYS.contains(&k.as_str()) - }) - .collect(); - let service = if kept.is_empty() { base(template).to_string() } else { format!("{}?{}", base(template), kept.join("&")) }; + let service = without_wms_keys(template); let mut q = String::from("SERVICE=WMS&REQUEST=GetCapabilities"); if let Some(v) = version { q.push_str(&format!("&VERSION={}", super::encode(&v))); @@ -174,10 +192,20 @@ pub struct InfoFeature { pub attributes: Vec<(String, String)>, } +/// A GetFeatureInfo answer's text: UTF-8, or an XML or HTML answer in Latin-1 (older servers +/// declare ISO-8859-1) read as its characters; an error for an answer that isn't text. +pub fn answer_text(body: &[u8]) -> Result, String> { + match std::str::from_utf8(body) { + Ok(t) => Ok(Cow::Borrowed(t)), + Err(_) if body.trim_ascii_start().first() == Some(&b'<') => Ok(Cow::Owned(body.iter().map(|&b| char::from(b)).collect())), + Err(_) => Err("the service's answer isn't text (perhaps an image)".to_string()), + } +} + /// Reads a GetFeatureInfo answer, whatever its format (found from the answer itself): its /// features, none when nothing is at the point, or the service's exception as the error. pub fn read_feature_info(body: &[u8]) -> Result, String> { - let text = std::str::from_utf8(body).map_err(|_| "the service's answer isn't text (perhaps an image)".to_string())?; + let text = answer_text(body)?; let text = text.trim_start_matches('\u{feff}').trim(); if text.is_empty() { return Ok(Vec::new()); @@ -315,6 +343,9 @@ fn xml_features(root: &Node) -> Result, String> { } } for member in members { + if out.len() >= MAX_FEATURES { + break; + } for f in member.children().filter(|c| c.is_element()) { out.push(InfoFeature { layer: Some(local(&f).to_string()), attributes: gml_attributes(&f) }); } @@ -448,6 +479,27 @@ mod tests { assert_eq!(old.request(0.0, 0.0, 0.0), None); } + #[test] + fn the_advertised_address_is_resolved_and_its_request_keys_replaced() { + // A relative address is the capabilities' own server's, not the capabilities endpoint. + let relative = CAPS_13.replace("https://info.example.org/wms?", "/feature-info?map=x"); + let fi = info_of(&relative, "roads"); + assert_eq!(fi.url, "https://maps.example.org/feature-info?map=x"); + // WMS keys the address carries give way to the request's own; others (map, a token) stay. + let keyed = CAPS_13.replace( + "https://info.example.org/wms?", + "https://info.example.org/wms?map=x&REQUEST=GetMap&version=1.1.1&Layers=all&INFO_FORMAT=text/html&i=3&token=t", + ); + let fi = info_of(&keyed, "roads"); + assert_eq!(fi.url, "https://info.example.org/wms?map=x&token=t"); + let r = fi.request(0.0, 0.0, 1.0).unwrap(); + assert_eq!(r.matches("REQUEST=").count(), 1, "{r}"); + assert!(r.starts_with("https://info.example.org/wms?map=x&token=t&SERVICE=WMS&VERSION=1.3.0&REQUEST=GetFeatureInfo&"), "{r}"); + // An address that isn't the web's (a script, another scheme) falls back to the endpoint. + let script = CAPS_13.replace("https://info.example.org/wms?", "javascript:alert(1)"); + assert_eq!(info_of(&script, "roads").url, "https://maps.example.org/wms"); + } + #[test] fn without_a_get_feature_info_request_layers_are_not_queryable() { // Each layer knows it isn't asked, so Identify doesn't fetch the capabilities again. @@ -499,6 +551,12 @@ mod tests { let f = read_feature_info(wfs2).unwrap(); assert_eq!(f.len(), 1, "{f:?}"); assert_eq!(f[0].layer.as_deref(), Some("clubs")); + // Members past the most features read aren't turned into features. + let many = format!( + r#"{}"#, + "1".repeat(MAX_FEATURES + 50) + ); + assert_eq!(read_feature_info(many.as_bytes()).unwrap().len(), MAX_FEATURES); let ms = br#"parks Central"#; let f = read_feature_info(ms).unwrap(); @@ -530,6 +588,15 @@ mod tests { assert_eq!(read_feature_info(mapserver.as_bytes()).unwrap()[0].attributes, vec![("NAME".into(), "Central".into())]); let html = "

Lake & shore

"; assert_eq!(html_text("a &lt; b").trim(), "a < b", "an escaped entity is read once"); + // Latin-1 XML and HTML (older servers) read as their characters; other bytes aren't text. + let latin1 = + b""; + assert_eq!(read_feature_info(latin1).unwrap()[0].attributes, vec![("NAME".into(), "Z\u{fc}rich".into())]); + assert_eq!( + read_feature_info(b"

Gen\xe8ve

").unwrap()[0].attributes, + vec![("text".into(), "Gen\u{e8}ve".into())] + ); + assert!(read_feature_info(b"\x89PNG\r\n\x1a\n\xff").unwrap_err().contains("isn't text")); assert_eq!(read_feature_info(html.as_bytes()).unwrap()[0].attributes, vec![("text".into(), "Lake & shore".into())]); // Any case of script and style is left out; many short scripts read in linear time. let shouting = "Peak"; diff --git a/crates/engine/src/cmd/select_info.rs b/crates/engine/src/cmd/select_info.rs index 7fbefead7..e062b1877 100644 --- a/crates/engine/src/cmd/select_info.rs +++ b/crates/engine/src/cmd/select_info.rs @@ -69,7 +69,10 @@ fn next_fetch(t: &rustgis_map::tiles::TileLayer, lon: f64, lat: f64, at: Coord, .request(at.x, at.y, m_per_px) .filter(|url| crate::render::rustgis_tiles::address::check(url, Some(&t.url)).is_ok()) .map(|url| json!({"tileLayer": t.name, "url": url})), - None => ogc::wms_layer_of(&t.url).map(|(caps, _)| json!({"tileLayer": t.name, "capabilities": caps})), + // The capabilities address comes from the layer's template (a project's, untrusted too). + None => ogc::wms_layer_of(&t.url) + .filter(|(caps, _)| crate::render::rustgis_tiles::address::check(caps, Some(&t.url)).is_ok()) + .map(|(caps, _)| json!({"tileLayer": t.name, "capabilities": caps})), } } diff --git a/crates/ui-egui/src/mapview.rs b/crates/ui-egui/src/mapview.rs index 45032bc80..08219338e 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -2242,6 +2242,10 @@ fn interact(app: &mut RustGisApp, ui: &mut Ui, resp: &egui::Response, rect: Rect Ok(v) if !v.is_null() => { if let (Some(id), Some(row)) = (v["layerId"].as_u64(), v["row"].as_u64()) { let _ = app.session.run("select.rows", &json!({"layer": id, "rows": [row]})); + } else if !is_found(&v) { + // Only WMS layers being asked: the click found nothing yet, so it clears + // the selection as an empty click does. + let _ = app.session.run("select.clear", &Value::Null); } Some(Popup::new(v, c, app.session.project.active_map)) } @@ -2838,11 +2842,13 @@ fn follow_asks(app: &mut RustGisApp, ctx: &egui::Context) { let mut next = Vec::new(); for (layer, capabilities, r) in landed { let r = r.and_then(|bytes| { - let text = String::from_utf8_lossy(&bytes).into_owned(); if capabilities { + let text = String::from_utf8_lossy(&bytes).into_owned(); let p = json!({"layer": layer, "lon": lon, "lat": lat, "capabilities": text, "request": true}); app.session.run("identify.featureInfo", &p) } else { + // Read as the command reads a fetched answer (Latin-1 XML, an image refused). + let text = rustgis_engine::data::ogc::answer_text(&bytes)?.into_owned(); app.session.run("identify.featureInfo", &json!({"layer": layer, "text": text})) } }); diff --git a/docs/user-guide/editing.md b/docs/user-guide/editing.md index 7c1de2823..f3473a572 100644 --- a/docs/user-guide/editing.md +++ b/docs/user-guide/editing.md @@ -121,8 +121,10 @@ buildings`) and its attributes; tile features can't be selected or zoomed to. They ask WMS layers too (added from a service, shown, and called queryable in its capabilities): RustGIS sends the service a standard GetFeatureInfo request for the point (WMS 1.3.0 or 1.1.1, whichever the service was added in), asking for GeoJSON when it offers it, else GML, plain text or -HTML, and the features it answers join the pop-up as they arrive (topmost asks only the top WMS -layer, and only when no feature of the map's own layers is under the click). A project doesn't save -the request: a WMS layer from a project reads its service's capabilities again on the first click. +HTML, and the features it answers join the pop-up as they arrive. Topmost asks only the highest WMS +layer that can be asked at the point (one that covers it and is queryable), and only when no +feature of the map's own layers or of its vector tile layers is under the click. A project doesn't +save the request: a WMS layer from a project reads its service's capabilities again on the first +click. `identify` lists the requests in `featureInfo`, and `identify.featureInfo` reads an answer (or, on the command line, asks the service and waits). From 7d8252839ebedcf9b14db79016bc535b84806e8b Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Mon, 26 Oct 2026 16:49:36 +0100 Subject: [PATCH 7/8] fix(services): answers kept to their map and project, every XML shape capped, ncWMS and other XML read, capabilities read once per service - An answer that lands after another map or project is shown is dropped, and one for a layer removed or renamed meanwhile is dropped without an error. - Every XML branch stops at the most features read (MapServer, Esri FIELDS, featureMembers). - ncWMS's FeatureInfo children are its values; XML that is no feature collection shows its values or text; a page's head and title aren't the answer. - Latin-1 answers and capabilities read the same in the app and natively (answer_text). - One layer's capabilities teach the map's other layers from the same service their requests. - The user guide says which WMS layers are asked, in which formats, and Topmost's first click on a project's layer. --- crates/data/src/ogc_info.rs | 79 ++++++++++++++++++++++++---- crates/engine/src/cmd/select_info.rs | 33 ++++++++---- crates/engine/src/tests.rs | 8 +++ crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/mapview.rs | 24 +++++++-- docs/user-guide/editing.md | 17 +++--- 6 files changed, 129 insertions(+), 34 deletions(-) diff --git a/crates/data/src/ogc_info.rs b/crates/data/src/ogc_info.rs index b47e0044b..867d39e5f 100644 --- a/crates/data/src/ogc_info.rs +++ b/crates/data/src/ogc_info.rs @@ -192,12 +192,14 @@ pub struct InfoFeature { pub attributes: Vec<(String, String)>, } -/// A GetFeatureInfo answer's text: UTF-8, or an XML or HTML answer in Latin-1 (older servers -/// declare ISO-8859-1) read as its characters; an error for an answer that isn't text. +/// A service's answer as text (a GetFeatureInfo answer, a capabilities document): UTF-8, or +/// Latin-1 (older servers answer in ISO-8859-1) read as its characters; an error for bytes that +/// aren't text (an image: control characters, C0 or C1, that no ISO-8859-1 text holds). pub fn answer_text(body: &[u8]) -> Result, String> { + let control = |b: u8| (b < 0x20 && !matches!(b, b'\t' | b'\n' | b'\r' | 0x0c)) || (0x7f..=0x9f).contains(&b); match std::str::from_utf8(body) { Ok(t) => Ok(Cow::Borrowed(t)), - Err(_) if body.trim_ascii_start().first() == Some(&b'<') => Ok(Cow::Owned(body.iter().map(|&b| char::from(b)).collect())), + Err(_) if !body.iter().any(|&b| control(b)) => Ok(Cow::Owned(body.iter().map(|&b| char::from(b)).collect())), Err(_) => Err("the service's answer isn't text (perhaps an image)".to_string()), } } @@ -306,18 +308,25 @@ fn xml_features(root: &Node) -> Result, String> { for layer in root.children().filter(|c| c.is_element()) { let name = local(&layer).strip_suffix("_layer").unwrap_or(local(&layer)).to_string(); for f in layer.children().filter(|c| c.is_element() && local(c).ends_with("_feature")) { + if out.len() >= MAX_FEATURES { + return Ok(out); + } out.push(InfoFeature { layer: Some(name.clone()), attributes: gml_attributes(&f) }); } } } // ArcGIS Server: - // …, or (1.1.1). + // …, or (1.1.1); ncWMS + // (THREDDS): , its children's text. "FeatureInfoResponse" => { for f in root.descendants().filter(|n| n.is_element() && matches!(local(n), "FIELDS" | "FeatureInfo")) { + if out.len() >= MAX_FEATURES { + break; + } let layer = f.ancestors().find_map(|a| a.attribute("layername")).map(str::to_string); let attributes: Vec<(String, String)> = if local(&f) == "FIELDS" { f.attributes().take(MAX_ATTRIBUTES).map(|a| (a.name().to_string(), capped(a.value()))).collect() - } else { + } else if children(&f, "Field").next().is_some() { children(&f, "Field") .take(MAX_ATTRIBUTES) .filter_map(|field| { @@ -325,6 +334,8 @@ fn xml_features(root: &Node) -> Result, String> { Some((name, capped(&child(&field, "FieldValue").map(|v| text_of(&v)).unwrap_or_default()))) }) .collect() + } else { + gml_attributes(&f) }; out.push(InfoFeature { layer, attributes }); } @@ -342,17 +353,26 @@ fn xml_features(root: &Node) -> Result, String> { stack.extend(n.children().filter(|c| c.is_element()).collect::>().into_iter().rev()); } } - for member in members { - if out.len() >= MAX_FEATURES { - break; + // XML that is no feature collection (a format not read here): its values, or its text, + // rather than nothing. A collection without members is an answer of nothing there. + if members.is_empty() && !local(root).ends_with("FeatureCollection") { + let attributes = gml_attributes(root); + if !attributes.is_empty() { + return Ok(vec![InfoFeature { layer: None, attributes }]); } + let words: Vec<&str> = root.descendants().filter(|c| c.is_text()).filter_map(|c| c.text()).take(10_000).collect(); + return Ok(text_feature(&words.join(" "))); + } + for member in members { for f in member.children().filter(|c| c.is_element()) { + if out.len() >= MAX_FEATURES { + return Ok(out); + } out.push(InfoFeature { layer: Some(local(&f).to_string()), attributes: gml_attributes(&f) }); } } } } - out.truncate(MAX_FEATURES); Ok(out) } @@ -411,6 +431,11 @@ fn html_text(html: &str) -> String { tag.find("").map(|i| i + 9) } else if tag.starts_with("").map(|i| i + 8) + } else if tag.starts_with("") || tag.starts_with("").map(|i| i + 7) + } else if tag.starts_with("").map(|i| i + 8) } else { tag.find('>').map(|i| i + 1) }; @@ -557,6 +582,37 @@ mod tests { "1".repeat(MAX_FEATURES + 50) ); assert_eq!(read_feature_info(many.as_bytes()).unwrap().len(), MAX_FEATURES); + // In every shape: one featureMembers holding many, Esri's FIELDS, MapServer's features. + let plural = format!( + r#"{}"#, + "1".repeat(MAX_FEATURES + 50) + ); + assert_eq!(read_feature_info(plural.as_bytes()).unwrap().len(), MAX_FEATURES); + let esri = format!("{}", r#""#.repeat(MAX_FEATURES + 50)); + assert_eq!(read_feature_info(esri.as_bytes()).unwrap().len(), MAX_FEATURES); + let ms = format!("{}", "1".repeat(MAX_FEATURES + 50)); + assert_eq!(read_feature_info(ms.as_bytes()).unwrap().len(), MAX_FEATURES); + // A collection with no members is nothing there. + let none = br#"unknown"#; + assert!(read_feature_info(none).unwrap().is_empty()); + // ncWMS (THREDDS): a FeatureInfo's own children are its values. + let ncwms = br#"8.547.4sst284.5"#; + assert_eq!( + read_feature_info(ncwms).unwrap(), + vec![InfoFeature { + layer: None, + attributes: vec![ + ("layer".into(), "sst".into()), + ("time".into(), "2024-01-01T00:00:00Z".into()), + ("value".into(), "284.5".into()) + ] + }] + ); + // Other XML: its values, or its text, rather than nothing. + let other = br#"Lake12"#; + assert_eq!(read_feature_info(other).unwrap()[0].attributes, vec![("name".into(), "Lake".into()), ("depth".into(), "12".into())]); + let prose = br#"

No data here

"#; + assert_eq!(read_feature_info(prose).unwrap()[0].attributes, vec![("text".into(), "No data here".into())]); let ms = br#"parks Central"#; let f = read_feature_info(ms).unwrap(); @@ -597,6 +653,11 @@ mod tests { vec![("text".into(), "Gen\u{e8}ve".into())] ); assert!(read_feature_info(b"\x89PNG\r\n\x1a\n\xff").unwrap_err().contains("isn't text")); + assert_eq!(read_feature_info(b"name = Z\xfcrich\n").unwrap()[0].attributes, vec![("name".into(), "Z\u{fc}rich".into())]); + // A page's head (its title) isn't the answer: GeoServer's empty HTML answer is nothing. + let empty = "Geoserver GetFeatureInfo output"; + assert!(read_feature_info(empty.as_bytes()).unwrap().is_empty()); + assert_eq!(html_text("
Top

x

").split_whitespace().collect::>(), ["Top", "x"], "
isn't "); assert_eq!(read_feature_info(html.as_bytes()).unwrap()[0].attributes, vec![("text".into(), "Lake & shore".into())]); // Any case of script and style is left out; many short scripts read in linear time. let shouting = "Peak"; diff --git a/crates/engine/src/cmd/select_info.rs b/crates/engine/src/cmd/select_info.rs index e062b1877..6cabc437d 100644 --- a/crates/engine/src/cmd/select_info.rs +++ b/crates/engine/src/cmd/select_info.rs @@ -110,19 +110,30 @@ fn wms_layer(s: &Session, name: &str) -> Result { } /// Reads the layer's request from its service's capabilities; a layer the service doesn't list, -/// or doesn't offer for identify, isn't asked again. +/// or doesn't offer for identify, isn't asked again. The map's other layers from the same service +/// that don't know theirs learn it too, so the capabilities are read once. fn learn(s: &mut Session, index: usize, capabilities: &[u8]) -> Result<(), String> { let Some(t) = s.map().tiles.get(index) else { return Ok(()) }; - let Some((caps_url, layer)) = ogc::wms_layer_of(&t.url) else { return Ok(()) }; - let service = ogc::parse(&caps_url, capabilities)?; - let info = service - .layers - .into_iter() - .find(|l| l.name == layer) - .and_then(|l| l.feature_info) - .unwrap_or_else(|| FeatureInfo { layer, ..FeatureInfo::default() }); - if let Some(t) = s.map_mut().tiles.get_mut(index) { - t.feature_info = Some(info); + let Some((caps_url, _)) = ogc::wms_layer_of(&t.url) else { return Ok(()) }; + // Read as an answer is (Latin-1 from older servers), in the app and natively alike. + let service = ogc::parse(&caps_url, ogc::answer_text(capabilities)?.as_bytes())?; + let info_of = |layer: String| { + service + .layers + .iter() + .find(|l| l.name == layer) + .and_then(|l| l.feature_info.clone()) + .unwrap_or_else(|| FeatureInfo { layer, ..FeatureInfo::default() }) + }; + for (i, t) in s.map_mut().tiles.iter_mut().enumerate() { + if i != index && t.feature_info.is_some() { + continue; + } + if let Some((caps, layer)) = ogc::wms_layer_of(&t.url) + && caps == caps_url + { + t.feature_info = Some(info_of(layer)); + } } Ok(()) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3c2d61910..2c341065a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -8572,6 +8572,9 @@ fn identify_asks_wms_layers() { let asks = v["featureInfo"].as_array().unwrap(); assert_eq!(asks.len(), 1, "{v}"); assert_eq!(asks[0]["tileLayer"], "Roads"); + // Topmost asks the highest layer that can be asked there: Relief isn't queryable. + let top = s.run("identify", &json!({"lon": 8.5, "lat": 47.4, "mode": "topmost"})).unwrap(); + assert_eq!(top["featureInfo"], v["featureInfo"], "{top}"); let request = asks[0]["url"].as_str().unwrap(); assert!( request.starts_with("https://maps.example.com/info?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetFeatureInfo&LAYERS=roads&"), @@ -8609,6 +8612,11 @@ fn identify_asks_wms_layers() { .run("identify.featureInfo", &json!({"layer": "Roads", "lon": 8.5, "lat": 47.4, "capabilities": WMS, "request": true})) .unwrap(); assert_eq!(r["url"].as_str(), Some(request)); + // The other layer from the same service learned from those capabilities that it isn't asked, + // without reading them again. + let v = reopened.run("identify", &json!({"lon": 8.5, "lat": 47.4, "mode": "visible"})).unwrap(); + assert_eq!(v["featureInfo"].as_array().map(Vec::len), Some(1), "{v}"); + assert_eq!(v["featureInfo"][0]["tileLayer"], "Roads", "{v}"); let r = reopened .run("identify.featureInfo", &json!({"layer": "Relief", "lon": 8.5, "lat": 47.4, "capabilities": WMS, "request": true})) .unwrap(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cd6e03f83..a12fadec6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2106,7 +2106,7 @@ impl RustGisApp { let v = self.session.run("identify", &json!({"lon": lon, "lat": lat, "tolerancePx": 5}))?; if let (false, Some(lon), Some(lat)) = (v.is_null(), lon.as_f64(), lat.as_f64()) { let map = self.session.project.active_map; - self.map.popup = Some(mapview::Popup::new(v, rustgis_engine::geom::Coord::new(lon, lat), map)); + self.map.popup = Some(mapview::Popup::new(v, rustgis_engine::geom::Coord::new(lon, lat), map, self.session.opened)); } else { self.map.popup = None; } diff --git a/crates/ui-egui/src/mapview.rs b/crates/ui-egui/src/mapview.rs index 08219338e..1d67e802f 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -74,6 +74,9 @@ pub struct Popup { /// The map it was opened on (the project's `active_map` then): layer and feature ids belong to /// a map, and a duplicated map has the same ones. pub map: usize, + /// The project it was opened on (`Session::opened`): answers still on their way when another + /// project opens are dropped. + pub opened: u64, /// The WMS layers being asked what is at the point, whose features join the list. pub asks: Vec, } @@ -88,7 +91,7 @@ pub struct Ask { impl Popup { /// A popup for what identify found at `at`, asking the WMS layers it lists. - pub fn new(info: Value, at: Coord, map: usize) -> Popup { + pub fn new(info: Value, at: Coord, map: usize, opened: u64) -> Popup { let asks = info["featureInfo"] .as_array() .into_iter() @@ -103,7 +106,7 @@ impl Popup { Some(Ask { layer, capabilities, slot: crate::fetch_into_limited(url, rustgis_engine::data::ogc::MAX_INFO_BYTES) }) }) .collect(); - Popup { info, at, index: 0, map, asks } + Popup { info, at, index: 0, map, opened, asks } } } @@ -2247,7 +2250,7 @@ fn interact(app: &mut RustGisApp, ui: &mut Ui, resp: &egui::Response, rect: Rect // the selection as an empty click does. let _ = app.session.run("select.clear", &Value::Null); } - Some(Popup::new(v, c, app.session.project.active_map)) + Some(Popup::new(v, c, app.session.project.active_map, app.session.opened)) } _ => { let _ = app.session.run("select.clear", &Value::Null); @@ -2821,10 +2824,17 @@ fn follow_popup(app: &mut RustGisApp) { /// Adds what the WMS layers asked answer to the popup, asking a layer from a project once its /// service's capabilities say how; failures are told. The popup closes when nothing was found. fn follow_asks(app: &mut RustGisApp, ctx: &egui::Context) { + let (active, opened) = (app.session.project.active_map, app.session.opened); let Some(pp) = &mut app.map.popup else { return }; if pp.asks.is_empty() { return; } + // Another map in view, or another project: the answers belong to layers no longer shown, and + // a layer of the same name there must not take them. + if pp.map != active || pp.opened != opened { + app.map.popup = None; + return; + } ctx.request_repaint_after(std::time::Duration::from_millis(100)); let (lon, lat) = (pp.at.x, pp.at.y); let mut landed = Vec::new(); @@ -2841,13 +2851,17 @@ fn follow_asks(app: &mut RustGisApp, ctx: &egui::Context) { let mut found = Vec::new(); let mut next = Vec::new(); for (layer, capabilities, r) in landed { + // A layer removed or renamed while its answer came: nothing to show, nothing to tell. + if !app.session.map().tiles.iter().any(|t| t.name == layer) { + continue; + } let r = r.and_then(|bytes| { if capabilities { - let text = String::from_utf8_lossy(&bytes).into_owned(); + let text = rustgis_engine::data::ogc::answer_text(&bytes)?.into_owned(); let p = json!({"layer": layer, "lon": lon, "lat": lat, "capabilities": text, "request": true}); app.session.run("identify.featureInfo", &p) } else { - // Read as the command reads a fetched answer (Latin-1 XML, an image refused). + // Read as the command reads a fetched answer (Latin-1, an image refused). let text = rustgis_engine::data::ogc::answer_text(&bytes)?.into_owned(); app.session.run("identify.featureInfo", &json!({"layer": layer, "text": text})) } diff --git a/docs/user-guide/editing.md b/docs/user-guide/editing.md index f3473a572..cadc8ea70 100644 --- a/docs/user-guide/editing.md +++ b/docs/user-guide/editing.md @@ -118,13 +118,14 @@ PMTiles and MBTiles archives and vector tiles added as data (not catalog basemap styles), beneath the map's own layers. Each shows its tile layer and source layer (`firenze › buildings`) and its attributes; tile features can't be selected or zoomed to. -They ask WMS layers too (added from a service, shown, and called queryable in its capabilities): -RustGIS sends the service a standard GetFeatureInfo request for the point (WMS 1.3.0 or 1.1.1, -whichever the service was added in), asking for GeoJSON when it offers it, else GML, plain text or -HTML, and the features it answers join the pop-up as they arrive. Topmost asks only the highest WMS -layer that can be asked at the point (one that covers it and is queryable), and only when no -feature of the map's own layers or of its vector tile layers is under the click. A project doesn't -save the request: a WMS layer from a project reads its service's capabilities again on the first -click. +They ask WMS layers too (WMS tile layers that are shown and, as far as their service's +capabilities say, queryable): RustGIS sends the service a standard GetFeatureInfo request for the +point (WMS 1.3.0 or 1.1.1, as in the layer's address), asking for GeoJSON when it offers it, else +GML, other XML, plain text or HTML, and the features it answers join the pop-up as they arrive. +Topmost asks only the highest WMS layer that can be asked at the point (one that covers it and is +queryable), and only when no feature of the map's own layers or of its vector tile layers is under +the click. A project doesn't save the request: a WMS layer from a project reads its service's +capabilities again on its first click (once for all the map's layers from that service), so until +then Topmost takes it as one that can be asked. `identify` lists the requests in `featureInfo`, and `identify.featureInfo` reads an answer (or, on the command line, asks the service and waits). From 9523e343e9bede6a7263cf0dd8ce6551e66db4a3 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Mon, 26 Oct 2026 17:29:42 +0100 Subject: [PATCH 8/8] fix(services): one capabilities download per service in the pop-up, added WMS layers asked, answers capped as parameters, errors told once - The pop-up fetches a service's capabilities once for all its layers waiting for them; each then asks with the request it learned. - A WMS tile layer added by its address is asked as vector tile layers' features are (any layer but a catalog basemap), not only overlays from a service. - identify.featureInfo refuses text or capabilities past MAX_INFO_BYTES. - The command's errors name the layer, and the pop-up no longer tells them a second time. --- crates/data/src/ogc_info.rs | 2 +- crates/engine/src/cmd/select_info.rs | 22 ++++-- crates/engine/src/tests.rs | 13 ++++ crates/ui-egui/src/mapview.rs | 102 ++++++++++++++++----------- 4 files changed, 92 insertions(+), 47 deletions(-) diff --git a/crates/data/src/ogc_info.rs b/crates/data/src/ogc_info.rs index 867d39e5f..e91f18cda 100644 --- a/crates/data/src/ogc_info.rs +++ b/crates/data/src/ogc_info.rs @@ -185,7 +185,7 @@ pub fn wms_layer_of(template: &str) -> Option<(String, String)> { } /// A feature of a GetFeatureInfo answer: the layer it is in, when the answer says, and its -/// attributes as text, in the answer's order. +/// attributes as text: in the answer's order for XML and plain text, by name for JSON. #[derive(Clone, Debug, Default, PartialEq)] pub struct InfoFeature { pub layer: Option, diff --git a/crates/engine/src/cmd/select_info.rs b/crates/engine/src/cmd/select_info.rs index 6cabc437d..67ba3623a 100644 --- a/crates/engine/src/cmd/select_info.rs +++ b/crates/engine/src/cmd/select_info.rs @@ -16,6 +16,9 @@ use crate::{CmdResult, CommandSpec as C, Session}; /// Most WMS layers one identify asks. const MAX_ASKED: usize = 8; +/// Most bytes of an answer or capabilities passed as text: what may be downloaded, as UTF-8 (a +/// Latin-1 answer grows to at most twice its size). +const MAX_TEXT: usize = 2 * ogc::MAX_INFO_BYTES; pub(super) fn commands() -> Vec { vec![ @@ -46,8 +49,9 @@ pub(super) fn merc_at(s: &Session, lon: f64, lat: f64) -> Option<(Coord, f64)> { /// Whether a tile layer is a WMS layer that can be asked: drawn, and queryable or not known yet. fn askable(t: &rustgis_map::tiles::TileLayer) -> bool { + // As for vector tiles' features: a basemap (one from the catalog) isn't asked, an added layer is. t.kind == TileKind::Raster - && t.overlay + && (t.id.is_empty() || t.overlay) && t.draws() && t.opacity > 0.0 && match &t.feature_info { @@ -141,11 +145,19 @@ fn learn(s: &mut Session, index: usize, capabilities: &[u8]) -> Result<(), Strin fn feature_info(s: &mut Session, p: &Value) -> CmdResult { let name = ps(p, "layer").ok_or("layer is required (a WMS tile layer's name)")?.to_string(); let index = wms_layer(s, &name)?; + // Errors name the layer: the app shows them as they are. + let named = |e: String| format!("{name}: {e}"); if let Some(caps) = ps(p, "capabilities") { - learn(s, index, caps.as_bytes())?; + if caps.len() > MAX_TEXT { + return Err(named(format!("the capabilities are larger than {} MB", MAX_TEXT >> 20))); + } + learn(s, index, caps.as_bytes()).map_err(named)?; } if let Some(text) = ps(p, "text") { - return Ok(results(&name, ogc::read_feature_info(text.as_bytes())?)); + if text.len() > MAX_TEXT { + return Err(named(format!("the answer is larger than {} MB", MAX_TEXT >> 20))); + } + return Ok(results(&name, ogc::read_feature_info(text.as_bytes()).map_err(named)?)); } let lon = f(p, "lon").ok_or("lon is required")?; let lat = f(p, "lat").ok_or("lat is required")?; @@ -167,11 +179,11 @@ fn feature_info(s: &mut Session, p: &Value) -> CmdResult { }; let mut step = next(s); if let Some(caps) = step.as_ref().and_then(|v| v["capabilities"].as_str()).map(str::to_string) { - learn(s, index, &fetch(&caps)?)?; + learn(s, index, &fetch(&caps)?).map_err(named)?; step = next(s); } match step.as_ref().and_then(|v| v["url"].as_str()) { - Some(url) => Ok(results(&name, ogc::read_feature_info(&fetch(url)?)?)), + Some(url) => Ok(results(&name, ogc::read_feature_info(&fetch(url)?).map_err(named)?)), None => Ok(json!({"results": []})), } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2c341065a..4f4fb4534 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -8626,6 +8626,19 @@ fn identify_asks_wms_layers() { // A selection mode asks none. let v = reopened.run("identify", &json!({"lon": 8.5, "lat": 47.4, "mode": "selectable"})).unwrap(); assert!(v.is_null(), "{v}"); + // Answers and capabilities past the download limit are refused, naming the layer. + let huge = "x".repeat(2 * rustgis_data::ogc::MAX_INFO_BYTES + 1); + for key in ["text", "capabilities"] { + let e = reopened.run("identify.featureInfo", &json!({"layer": "Roads", key: huge})).unwrap_err(); + assert!(e.starts_with("Roads: ") && e.contains("larger than"), "{e}"); + } + // A WMS layer added by its address (not from the service, not an overlay) is asked too. + let mut added = Session::new(Project::default()); + let template = "https://maps.example.com/wms?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetMap&LAYERS=roads&STYLES=&CRS=EPSG:3857&BBOX={bbox-epsg-3857}&WIDTH=256&HEIGHT=256&FORMAT=image/png"; + added.run("map.addTiles", &json!({"url": template, "name": "Mine"})).unwrap(); + added.run("map.goToXY", &json!({"lon": 8.5, "lat": 47.4, "scale": 10000})).unwrap(); + let v = added.run("identify", &json!({"lon": 8.5, "lat": 47.4, "mode": "visible"})).unwrap(); + assert_eq!(v["featureInfo"][0]["tileLayer"], "Mine", "{v}"); // A service on the web whose GetFeatureInfo points into the local network isn't asked. let local = WMS.replace("https://maps.example.com/info?", "http://192.168.1.5/info?"); let mut s = Session::new(Project::default()); diff --git a/crates/ui-egui/src/mapview.rs b/crates/ui-egui/src/mapview.rs index 1d67e802f..4a8b3b3dc 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -85,6 +85,8 @@ pub struct Popup { /// request's, or first its service's capabilities (a layer from a project doesn't know its request). pub struct Ask { layer: String, + /// The other layers waiting for the same capabilities: fetched and read once for all. + also: Vec, capabilities: bool, slot: crate::Slot, String>>, } @@ -92,20 +94,28 @@ pub struct Ask { impl Popup { /// A popup for what identify found at `at`, asking the WMS layers it lists. pub fn new(info: Value, at: Coord, map: usize, opened: u64) -> Popup { - let asks = info["featureInfo"] - .as_array() - .into_iter() - .flatten() - .filter_map(|a| { - let layer = a["tileLayer"].as_str()?.to_string(); - let (url, capabilities) = match (a["url"].as_str(), a["capabilities"].as_str()) { - (Some(u), _) => (u, false), - (None, Some(c)) => (c, true), - _ => return None, - }; - Some(Ask { layer, capabilities, slot: crate::fetch_into_limited(url, rustgis_engine::data::ogc::MAX_INFO_BYTES) }) - }) - .collect(); + let mut asks: Vec = Vec::new(); + // The capabilities each fetch is for: layers of one service share it. + let mut fetched: Vec<(String, usize)> = Vec::new(); + for a in info["featureInfo"].as_array().into_iter().flatten() { + let Some(layer) = a["tileLayer"].as_str().map(str::to_string) else { continue }; + let (url, capabilities) = match (a["url"].as_str(), a["capabilities"].as_str()) { + (Some(u), _) => (u, false), + (None, Some(c)) => (c, true), + _ => continue, + }; + if capabilities && let Some(&(_, i)) = fetched.iter().find(|(c, _)| c == url) { + if let Some(ask) = asks.get_mut(i) { + ask.also.push(layer); + } + continue; + } + if capabilities { + fetched.push((url.to_string(), asks.len())); + } + let slot = crate::fetch_into_limited(url, rustgis_engine::data::ogc::MAX_INFO_BYTES); + asks.push(Ask { layer, also: Vec::new(), capabilities, slot }); + } Popup { info, at, index: 0, map, opened, asks } } } @@ -2838,9 +2848,9 @@ fn follow_asks(app: &mut RustGisApp, ctx: &egui::Context) { ctx.request_repaint_after(std::time::Duration::from_millis(100)); let (lon, lat) = (pp.at.x, pp.at.y); let mut landed = Vec::new(); - pp.asks.retain(|a| match a.slot.lock().unwrap_or_else(|e| e.into_inner()).take() { + pp.asks.retain_mut(|a| match a.slot.lock().unwrap_or_else(|e| e.into_inner()).take() { Some(r) => { - landed.push((a.layer.clone(), a.capabilities, r)); + landed.push((a.layer.clone(), std::mem::take(&mut a.also), a.capabilities, r)); false } None => true, @@ -2850,34 +2860,44 @@ fn follow_asks(app: &mut RustGisApp, ctx: &egui::Context) { } let mut found = Vec::new(); let mut next = Vec::new(); - for (layer, capabilities, r) in landed { - // A layer removed or renamed while its answer came: nothing to show, nothing to tell. - if !app.session.map().tiles.iter().any(|t| t.name == layer) { - continue; - } - let r = r.and_then(|bytes| { - if capabilities { - let text = rustgis_engine::data::ogc::answer_text(&bytes)?.into_owned(); - let p = json!({"layer": layer, "lon": lon, "lat": lat, "capabilities": text, "request": true}); - app.session.run("identify.featureInfo", &p) - } else { - // Read as the command reads a fetched answer (Latin-1, an image refused). - let text = rustgis_engine::data::ogc::answer_text(&bytes)?.into_owned(); - app.session.run("identify.featureInfo", &json!({"layer": layer, "text": text})) + let exists = |app: &RustGisApp, layer: &str| app.session.map().tiles.iter().any(|t| t.name == layer); + for (layer, also, capabilities, r) in landed { + // Layers removed or renamed while the answer came: nothing to show, nothing to tell. The + // first of a service's layers still there takes the capabilities for the rest. + let mut group = std::iter::once(layer).chain(also).filter(|l| exists(app, l)); + let Some(layer) = group.next() else { continue }; + let also: Vec = group.collect(); + // The fetch's failure, or an answer that isn't text, is told here; the command tells its + // own errors (naming the layer). + let text = match r.and_then(|bytes| rustgis_engine::data::ogc::answer_text(&bytes).map(std::borrow::Cow::into_owned)) { + Ok(text) => text, + Err(e) => { + app.session.notify(crate::i18n::fmt("{layer}: {e}", &[("layer", &layer), ("e", &e)]), true); + continue; } - }); - match r { - Ok(v) => { - if let Some(url) = v["url"].as_str() { - next.push(Ask { - layer, - capabilities: false, - slot: crate::fetch_into_limited(url, rustgis_engine::data::ogc::MAX_INFO_BYTES), - }); + }; + let mut replies = Vec::new(); + if capabilities { + let p = json!({"layer": layer, "lon": lon, "lat": lat, "capabilities": text, "request": true}); + replies.push((layer, app.session.run("identify.featureInfo", &p))); + // The service's other layers learned their requests from the same capabilities. + if replies.iter().all(|(_, r)| r.is_ok()) { + for other in also { + let p = json!({"layer": other, "lon": lon, "lat": lat, "request": true}); + let r = app.session.run("identify.featureInfo", &p); + replies.push((other, r)); } - found.extend(v["results"].as_array().cloned().unwrap_or_default()); } - Err(e) => app.session.notify(crate::i18n::fmt("{layer}: {e}", &[("layer", &layer), ("e", &e)]), true), + } else { + replies.push((layer.clone(), app.session.run("identify.featureInfo", &json!({"layer": layer, "text": text})))); + } + for (layer, r) in replies { + let Ok(v) = r else { continue }; + if let Some(url) = v["url"].as_str() { + let slot = crate::fetch_into_limited(url, rustgis_engine::data::ogc::MAX_INFO_BYTES); + next.push(Ask { layer, also: Vec::new(), capabilities: false, slot }); + } + found.extend(v["results"].as_array().cloned().unwrap_or_default()); } } let Some(pp) = &mut app.map.popup else { return };