From afab3c739c383b96e8d4cb5d59c51e3f3e7681f6 Mon Sep 17 00:00:00 2001 From: strebdom Date: Mon, 26 Oct 2026 20:39:01 +0100 Subject: [PATCH] fix(services): ask a WMS layer again in the next answer format after the service's error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit swisstopo's MapServer (wms.geo.admin.ch) offers GeoJSON for GetFeatureInfo but fails to write it for some layers ("OGR_DS_CreateLayer failed … with driver 'GEOJSON'", e.g. the swissGEOCOVER2D lines), while GML answers. Identify showed the error instead of the features. - An answer that is the service's exception report, without a code no format changes (LayerNotQueryable, InvalidPoint, …), gives the request in the next format the layer offers: both names of GeoJSON are left out together, then GML, other XML, plain text, HTML. - The format comes from the request answered (its INFO_FORMAT), so a repeated or late answer doesn't drop another; the layer keeps its own until a later format answers, then asks in that one for the session. - identify.featureInfo takes "url" (the request the text answers) with lon and lat; the app passes them, and turns the returned url into the next background fetch. On the command line the fetch loop does the same. - Without url or the point, or with no format left, the error is told as before. Project files are unchanged. --- crates/data/src/ogc.rs | 2 +- crates/data/src/ogc_info.rs | 161 ++++++++++++++++++++++++--- crates/engine/src/cmd/select_info.rs | 78 +++++++++++-- crates/engine/src/tests.rs | 62 +++++++++++ crates/ui-egui/src/mapview.rs | 17 ++- docs/user-guide/editing.md | 3 + 6 files changed, 289 insertions(+), 34 deletions(-) diff --git a/crates/data/src/ogc.rs b/crates/data/src/ogc.rs index 16fc73525..cd3389453 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, answer_text, read_feature_info, wms_layer_of}; +pub use info::{FeatureInfo, InfoFeature, MAX_INFO_BYTES, answer_text, may_answer_in_another_format, 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 e91f18cda..6a550a367 100644 --- a/crates/data/src/ogc_info.rs +++ b/crates/data/src/ogc_info.rs @@ -55,23 +55,35 @@ impl FeatureInfo { /// 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) + self.formats.iter().filter(|f| format_rank(f) < 5).min_by_key(|f| format_rank(f)).map(String::as_str) + } + + /// The answer format (one the layer offers) a request of the layer asked in: its INFO_FORMAT. + fn format_of(&self, request: &str) -> Option<&str> { + let asked = query_value(request, "info_format")?; + self.formats.iter().find(|f| super::encode(f).eq_ignore_ascii_case(asked)).map(String::as_str) + } + + /// After the service answered `request` with its error: the request in the formats read after + /// the one it asked in (those of its kind left out too: a service whose GeoJSON fails for a + /// layer offers it under two names). `None` when no other format is left. + pub fn retry_after(&self, request: &str) -> Option { + let rank = format_rank(self.format_of(request)?); + let formats: Vec = self.formats.iter().filter(|f| format_rank(f) > rank).cloned().collect(); + let next = FeatureInfo { formats, ..self.clone() }; + next.info_format().is_some().then_some(next) + } + + /// After the service answered `request`: when it asked in a format read after the layer's (a + /// retry that worked), the layer asking in that one from then on, the formats before it left + /// out. `None` when the layer keeps its request. + pub fn answered(&self, request: &str) -> Option { + let rank = format_rank(self.format_of(request)?); + if self.info_format().is_none_or(|f| format_rank(f) >= rank) { + return None; + } + let formats: Vec = self.formats.iter().filter(|f| format_rank(f) >= rank).cloned().collect(); + Some(FeatureInfo { formats, ..self.clone() }) } /// The GetFeatureInfo address for a point (`x`, `y` in Web Mercator metres) of a map drawn at @@ -204,6 +216,58 @@ pub fn answer_text(body: &[u8]) -> Result, String> { } } +/// An answer format's place in [`FeatureInfo::info_format`]'s order: 5 for one not read. +fn format_rank(f: &str) -> u8 { + 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 + } +} + +/// Exception codes (WMS 1.3.0 Annex E, OWS) that another answer format won't change. +const NOT_THE_FORMAT: [&str; 10] = [ + "LayerNotDefined", + "LayerNotQueryable", + "InvalidPoint", + "InvalidCRS", + "InvalidSRS", + "StyleNotDefined", + "MissingDimensionValue", + "InvalidDimensionValue", + "CurrentUpdateSequence", + "InvalidUpdateSequence", +]; + +/// Whether a GetFeatureInfo answer is the service's error that another answer format may not meet +/// (MapServer's GeoJSON output fails for some layers its GML answers): an exception report +/// without a code saying the layer, the point or the request is at fault. +pub fn may_answer_in_another_format(body: &[u8]) -> bool { + let Ok(text) = answer_text(body) else { return false }; + let text = text.trim_start_matches('\u{feff}').trim(); + if !text.starts_with('<') { + return false; + } + let Ok(doc) = xml(text, 1_000_000) else { return false }; + let root = doc.root_element(); + matches!(local(&root), "ServiceExceptionReport" | "ExceptionReport") + && !root.descendants().any(|n| { + n.is_element() + && ["code", "exceptionCode"] + .iter() + .any(|a| n.attribute(*a).is_some_and(|c| NOT_THE_FORMAT.iter().any(|k| k.eq_ignore_ascii_case(c)))) + }) +} + /// 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> { @@ -629,6 +693,69 @@ mod tests { assert!(read_feature_info(&[0x89, b'P', b'N', b'G', 0xff, 0xfe]).is_err()); } + #[test] + fn a_format_the_service_fails_in_gives_way_to_the_next() { + // swisstopo's MapServer: GeoJSON fails for some layers ("OGR_DS_CreateLayer failed … with + // driver 'GEOJSON'"), its GML answers. + let ms = br#" +msOGRWriteFromQuery(): General error message."#; + assert!(may_answer_in_another_format(ms)); + assert!(may_answer_in_another_format(br#""#)); + assert!(may_answer_in_another_format("\u{feff}".as_bytes())); + let invalid_format = + br#"no"#; + assert!(may_answer_in_another_format(invalid_format)); + // An error no format changes: the layer, the point. + for code in ["LayerNotQueryable", "InvalidPoint"] { + let e = format!(r#"no"#); + assert!(!may_answer_in_another_format(e.as_bytes()), "{code}"); + } + let ows = br#""#; + assert!(!may_answer_in_another_format(ows)); + // Features, empty answers, text and HTML that mention exceptions aren't errors. + assert!(!may_answer_in_another_format( + br#"ServiceExceptionReport"# + )); + assert!(!may_answer_in_another_format(br#"{"type": "FeatureCollection", "features": []}"#)); + assert!(!may_answer_in_another_format(b"Search returned no results. ")); + assert!(!may_answer_in_another_format(b"")); + + let formats = ["application/json", "application/json; subtype=geojson", "application/vnd.ogc.gml", "text/plain", "text/html"]; + let fi = FeatureInfo { + url: "https://wms.example.org/".into(), + version: "1.3.0".into(), + layer: "lines".into(), + map_format: "image/png".into(), + formats: formats.iter().map(|f| f.to_string()).collect(), + }; + let ask = |fi: &FeatureInfo| fi.request(971_712.0, 6_021_494.0, 2.0).unwrap(); + assert_eq!(fi.info_format(), Some("application/json")); + // Both names of GeoJSON go, then GML, then plain text, then HTML; nothing after the last. + let gml = fi.retry_after(&ask(&fi)).unwrap(); + assert_eq!(gml.info_format(), Some("application/vnd.ogc.gml")); + assert_eq!(gml.formats, ["application/vnd.ogc.gml", "text/plain", "text/html"]); + assert_eq!(gml.layer, "lines"); + assert!(ask(&gml).contains("&INFO_FORMAT=application%2Fvnd.ogc.gml&")); + let plain = fi.retry_after(&ask(&gml)).unwrap(); + assert_eq!(plain.info_format(), Some("text/plain")); + let html = fi.retry_after(&ask(&plain)).unwrap(); + assert_eq!(html.info_format(), Some("text/html")); + assert!(fi.retry_after(&ask(&html)).is_none()); + // The format comes from the request answered, not from the layer: a second error to a + // GeoJSON request leads to GML again. + assert_eq!(fi.retry_after(&ask(&fi)), Some(gml.clone())); + // A request in no format the layer offers isn't retried. + assert!(fi.retry_after("https://wms.example.org/?INFO_FORMAT=text%2Fcsv").is_none()); + assert!(fi.retry_after("https://wms.example.org/").is_none()); + // Answered in GML, the layer asks in GML; answered in its own format, it keeps its request. + assert_eq!(fi.answered(&ask(&gml)), Some(gml.clone())); + assert!(fi.answered(&ask(&fi)).is_none()); + assert!(gml.answered(&ask(&fi)).is_none()); + // Formats RustGIS doesn't read don't count as left. + let odd = FeatureInfo { formats: vec!["text/xml".into(), "image/png".into()], ..fi.clone() }; + assert!(odd.retry_after(&ask(&odd)).is_none()); + } + #[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"; diff --git a/crates/engine/src/cmd/select_info.rs b/crates/engine/src/cmd/select_info.rs index 67ba3623a..4d11afd5e 100644 --- a/crates/engine/src/cmd/select_info.rs +++ b/crates/engine/src/cmd/select_info.rs @@ -23,7 +23,7 @@ const MAX_TEXT: usize = 2 * ogc::MAX_INFO_BYTES; 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"#, + 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)", "url"?: "the request text answers: with lon and lat, the service's error in its format gives the request in the next format the layer offers ({url}), and features answered in a later format keep the layer asking in it", "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 or after an error, what to fetch"#, ), ] } @@ -67,12 +67,7 @@ fn next_fetch(t: &rustgis_map::tiles::TileLayer, lon: f64, lat: f64, at: Coord, return None; } match &t.feature_info { - // 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})), + Some(fi) => ask(t, fi, at, m_per_px), // 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()) @@ -80,6 +75,15 @@ fn next_fetch(t: &rustgis_map::tiles::TileLayer, lon: f64, lat: f64, at: Coord, } } +/// A WMS tile layer's GetFeatureInfo request for a point, in `fi`'s terms. +fn ask(t: &rustgis_map::tiles::TileLayer, fi: &FeatureInfo, at: Coord, m_per_px: f64) -> Option { + // The request's address comes from the capabilities (hostile input): it must not reach into + // the local network from a service on the web. + 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})) +} + /// 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 { @@ -157,7 +161,26 @@ fn feature_info(s: &mut Session, p: &Value) -> CmdResult { 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 asked = ps(p, "url"); + return match ogc::read_feature_info(text.as_bytes()) { + Ok(features) => { + if let Some(request) = asked { + answered(s, index, request); + } + Ok(results(&name, features)) + } + // The service's error in the format asked in: the request in the next, for the point. + Err(e) => { + let point = f(p, "lon").zip(f(p, "lat")).and_then(|(lon, lat)| merc_at(s, lon, lat).map(|m| (lon, lat, m))); + let retry = match (asked, point) { + (Some(request), Some((lon, lat, (at, m_per_px)))) if ogc::may_answer_in_another_format(text.as_bytes()) => { + retry(s, index, request, lon, lat, at, m_per_px) + } + _ => None, + }; + retry.ok_or_else(|| named(e)) + } + }; } let lon = f(p, "lon").ok_or("lon is required")?; let lat = f(p, "lat").ok_or("lat is required")?; @@ -182,9 +205,42 @@ fn feature_info(s: &mut Session, p: &Value) -> CmdResult { 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)?).map_err(named)?)), - None => Ok(json!({"results": []})), + // Each of the service's errors asks in a format read after the last, so this ends. + loop { + let Some(url) = step.as_ref().and_then(|v| v["url"].as_str()).map(str::to_string) else { + return Ok(json!({"results": []})); + }; + let answer = fetch(&url)?; + match ogc::read_feature_info(&answer) { + Ok(features) => { + answered(s, index, &url); + return Ok(results(&name, features)); + } + Err(e) => match retry(s, index, &url, lon, lat, at, m_per_px) { + Some(next) if ogc::may_answer_in_another_format(&answer) => step = Some(next), + _ => return Err(named(e)), + }, + } } } } + +/// After the service answered `request` with its error: the request in the next answer format +/// the layer offers, when there is one. The layer keeps its own until another format answers. +fn retry(s: &Session, index: usize, request: &str, lon: f64, lat: f64, at: Coord, m_per_px: f64) -> Option { + let t = s.map().tiles.get(index)?; + if t.bounds.is_some_and(|[w, s, e, n]| !(w <= lon && lon <= e && s <= lat && lat <= n)) { + return None; + } + let next = t.feature_info.as_ref()?.retry_after(request)?; + ask(t, &next, at, m_per_px) +} + +/// After the service answered `request` with features (or none): a layer asked in a later format +/// than its own (a retry that worked) asks in that one for the rest of the session. +fn answered(s: &mut Session, index: usize, request: &str) { + let Some(t) = s.map_mut().tiles.get_mut(index) else { return }; + if let Some(next) = t.feature_info.as_ref().and_then(|fi| fi.answered(request)) { + t.feature_info = Some(next); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0e2b23312..98e9ff407 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -8656,6 +8656,68 @@ fn identify_asks_wms_layers() { assert_eq!(r, json!({"results": []})); } +/// A service whose answer in one format is its error (swisstopo's MapServer fails to write GeoJSON +/// for some layers, its GML answers) is asked again in the next format it offers; once that one +/// answers, the layer keeps asking in it. Without the request or the point, after an error no +/// format changes, or with no format left, the error is told. +#[test] +fn identify_asks_again_in_the_next_format_after_the_services_error() { + const WMS: &str = r#" +Test WMS +image/png +application/jsonapplication/json; subtype=geojsonapplication/vnd.ogc.gml +RootEPSG:3857linesLines"#; + const FAILED: &str = r#" +msOGRWriteFromQuery(): General error message."#; + const GML: &str = r#"thrustThrust"#; + let added = || { + let mut s = Session::new(Project::default()); + s.run("service.add", &json!({"url": "https://maps.example.com/wms", "layer": "lines", "text": WMS})).unwrap(); + s.run("map.goToXY", &json!({"lon": 8.73, "lat": 47.5, "scale": 10000})).unwrap(); + s + }; + let ask = |s: &mut Session| { + let v = s.run("identify", &json!({"lon": 8.73, "lat": 47.5, "mode": "visible"})).unwrap(); + v["featureInfo"][0]["url"].as_str().unwrap().to_string() + }; + let answer = |text: &str, url: &str| json!({"layer": "Lines", "text": text, "url": url, "lon": 8.73, "lat": 47.5}); + let mut s = added(); + let first = ask(&mut s); + assert!(first.contains("&INFO_FORMAT=application%2Fjson&"), "{first}"); + // Without the request or the point, the error is told as before. + let e = s.run("identify.featureInfo", &json!({"layer": "Lines", "text": FAILED})).unwrap_err(); + assert!(e.starts_with("Lines: the service answered with an error: msOGRWriteFromQuery()"), "{e}"); + assert!(s.run("identify.featureInfo", &json!({"layer": "Lines", "text": FAILED, "url": first})).is_err()); + // With them, the request in GML (GeoJSON's other name is left out too); the layer keeps its + // own until GML answers, so the same error again leads to GML again. + let r = s.run("identify.featureInfo", &answer(FAILED, &first)).unwrap(); + let gml = r["url"].as_str().unwrap().to_string(); + assert!(gml.contains("&INFO_FORMAT=application%2Fvnd.ogc.gml&"), "{r}"); + assert_eq!(r["tileLayer"], "Lines"); + assert_eq!(ask(&mut s), first); + assert_eq!(s.run("identify.featureInfo", &answer(FAILED, &first)).unwrap()["url"], gml.as_str()); + let r = s.run("identify.featureInfo", &answer(GML, &gml)).unwrap(); + assert_eq!(r["results"][0]["layer"], "Lines › thrust", "{r}"); + assert_eq!(r["results"][0]["attributes"]["kind_desc"], "Thrust"); + assert_eq!(ask(&mut s), gml); + // The last format's error is told, and the layer keeps asking in it; a late GeoJSON answer + // doesn't bring it back. + let e = s.run("identify.featureInfo", &answer(FAILED, &gml)).unwrap_err(); + assert!(e.contains("msOGRWriteFromQuery()"), "{e}"); + s.run("identify.featureInfo", &answer(GML, &first)).unwrap(); + assert_eq!(ask(&mut s), gml); + // An error no format changes, or an answer that isn't the service's error, isn't retried. + let mut t = added(); + let not_queryable = + r#"no"#; + assert!(t.run("identify.featureInfo", &answer(not_queryable, &first)).is_err()); + assert!(t.run("identify.featureInfo", &answer("{oops", &first)).is_err()); + assert_eq!(ask(&mut t), first); + // The project file is as before. + let project = serde_json::to_string(&s.project).unwrap(); + assert!(!project.contains("featureInfo") && !project.contains("vnd.ogc.gml")); +} + /// 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/ui-egui/src/mapview.rs b/crates/ui-egui/src/mapview.rs index 9dd69b4ef..b37806856 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -88,6 +88,9 @@ pub struct Ask { /// The other layers waiting for the same capabilities: fetched and read once for all. also: Vec, capabilities: bool, + /// The GetFeatureInfo request asked (none for capabilities): its answer format tells what to + /// ask next after the service's error. + request: Option, slot: crate::Slot, String>>, } @@ -114,7 +117,8 @@ impl Popup { 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 }); + let request = (!capabilities).then(|| url.to_string()); + asks.push(Ask { layer, also: Vec::new(), capabilities, request, slot }); } Popup { info, at, index: 0, map, opened, asks } } @@ -2853,7 +2857,7 @@ fn follow_asks(app: &mut RustGisApp, ctx: &egui::Context) { let mut landed = Vec::new(); pp.asks.retain_mut(|a| match a.slot.lock().unwrap_or_else(|e| e.into_inner()).take() { Some(r) => { - landed.push((a.layer.clone(), std::mem::take(&mut a.also), a.capabilities, r)); + landed.push((a.layer.clone(), std::mem::take(&mut a.also), a.capabilities, a.request.take(), r)); false } None => true, @@ -2864,7 +2868,7 @@ fn follow_asks(app: &mut RustGisApp, ctx: &egui::Context) { let mut found = Vec::new(); let mut next = Vec::new(); let exists = |app: &RustGisApp, layer: &str| app.session.map().tiles.iter().any(|t| t.name == layer); - for (layer, also, capabilities, r) in landed { + for (layer, also, capabilities, request, 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)); @@ -2892,13 +2896,16 @@ fn follow_asks(app: &mut RustGisApp, ctx: &egui::Context) { } } } else { - replies.push((layer.clone(), app.session.run("identify.featureInfo", &json!({"layer": layer, "text": text})))); + // With the request and the point: the service's error in one answer format asks again + // in the next. + let p = json!({"layer": layer, "text": text, "url": request, "lon": lon, "lat": lat}); + replies.push((layer.clone(), app.session.run("identify.featureInfo", &p))); } 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 }); + next.push(Ask { layer, also: Vec::new(), capabilities: false, request: Some(url.to_string()), slot }); } found.extend(v["results"].as_array().cloned().unwrap_or_default()); } diff --git a/docs/user-guide/editing.md b/docs/user-guide/editing.md index 4a06ed105..ff0b8fbdf 100644 --- a/docs/user-guide/editing.md +++ b/docs/user-guide/editing.md @@ -153,6 +153,9 @@ They ask WMS layers too (WMS tile layers that are shown and, as far as their ser 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. +When the service answers with an error in that format (swisstopo's MapServer can't write some of +its layers as GeoJSON, but answers them in GML), RustGIS asks again in the next format it offers, +and keeps asking that layer in it for the rest of the session. 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