From 253e34e57cac2354b2052e5359a04cd39af91282 Mon Sep 17 00:00:00 2001 From: choupeanut Date: Tue, 10 Nov 2026 12:24:28 +0800 Subject: [PATCH 1/3] fix(data): a Web Mercator name or method needs Web Mercator's parameters (#1699) A system named or projected as Web Mercator was read as Web Mercator after only its axes were checked, so a shifted origin or another auxiliary sphere was placed at the wrong spot without a word. The shortcut now needs Web Mercator's parameters; otherwise a Mercator method is read from its own, and Esri's and EPSG's Web Mercator methods are refused. --- crates/data/src/raster/wkt.rs | 75 +++++++++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 8 deletions(-) diff --git a/crates/data/src/raster/wkt.rs b/crates/data/src/raster/wkt.rs index 3e057e6ae..e3d2b5627 100644 --- a/crates/data/src/raster/wkt.rs +++ b/crates/data/src/raster/wkt.rs @@ -913,15 +913,22 @@ fn geographic(g: &Node) -> Result { fn projected(p: &Node) -> Result { let name = norm(p.title()); let method = norm(p.child("PROJECTION").map_or("", Node::title)); - if name.contains("WEB_MERCATOR") - || name.contains("PSEUDO_MERCATOR") - || method == "MERCATOR_AUXILIARY_SPHERE" - || method == "POPULAR_VISUALISATION_PSEUDO_MERCATOR" - { - if axis_flip(p)?.is_some() { - return Err(REVERSED_AXES.into()); + let pseudo = method == "MERCATOR_AUXILIARY_SPHERE" || method == "POPULAR_VISUALISATION_PSEUDO_MERCATOR"; + if name.contains("WEB_MERCATOR") || name.contains("PSEUDO_MERCATOR") || pseudo { + if is_web_mercator(p, &method) { + if axis_flip(p)?.is_some() { + return Err(REVERSED_AXES.into()); + } + return Ok(Crs::WebMercator); + } + // The name is Web Mercator's, the parameters aren't: a named Mercator method is read from + // them below, but these two methods are Web Mercator's alone and `custom` has no sphere + // to put them on, so refuse instead of placing points at Web Mercator's origin. + if pseudo { + return Err( + "a Web Mercator system with parameters of its own (a shifted origin, scale or auxiliary sphere) isn't supported".into() + ); } - return Ok(Crs::WebMercator); } // The name gives a UTM zone's EPSG code, but the parameters say where the coordinates are. if let Some(code) = utm(&name) @@ -933,6 +940,24 @@ fn projected(p: &Node) -> Result { custom(p, &method) } +/// Whether a system named or projected as Web Mercator has Web Mercator's parameters: a Mercator +/// method, in metres, with central meridian, origin latitude, standard parallel, false easting and +/// northing 0, scale 1 and auxiliary sphere type 0 (the datum's semi-major axis). A parameter that +/// isn't given counts as [`custom`] reads it. +fn is_web_mercator(p: &Node, method: &str) -> bool { + let params = parameters(p); + let is = |names: &[&str], to: f64| parameter(¶ms, names).is_none_or(|v| (v - to).abs() <= 1e-9); + (method.is_empty() || method.starts_with("MERCATOR") || method.ends_with("PSEUDO_MERCATOR")) + && p.child("UNIT").and_then(|u| u.num(0)).is_none_or(|m| (m - 1.0).abs() <= 1e-9) + && is(&CENTRAL_MERIDIAN, 0.0) + && is(&LATITUDE_OF_ORIGIN, 0.0) + && is(&["STANDARD_PARALLEL_1"], 0.0) + && is(&["FALSE_EASTING"], 0.0) + && is(&["FALSE_NORTHING"], 0.0) + && is(&["SCALE_FACTOR"], 1.0) + && is(&["AUXILIARY_SPHERE_TYPE"], 0.0) +} + /// UTM's scale on the central meridian. const UTM_SCALE: f64 = 0.9996; /// UTM's false easting, in metres. @@ -2965,6 +2990,40 @@ pub(crate) mod tests { assert_eq!(read_wkt(&zero), Ok((Crs::WebMercator, None))); } + /// A Web Mercator name or method gives `Crs::WebMercator` only with Web Mercator's parameters. + #[test] + fn a_web_mercator_name_gives_web_mercator_only_with_its_parameters() { + assert_eq!(crs_from_wkt(ESRI_3857), Ok(Crs::WebMercator)); + let gdal = WEB_MERCATOR; + assert_eq!(crs_from_wkt(gdal), Ok(Crs::WebMercator)); + // Esri's method: a parameter off is refused, never placed at Web Mercator's origin. + for (from, to) in [ + (r#""False_Easting",0.0"#, r#""False_Easting",1000.0"#), + (r#""False_Northing",0.0"#, r#""False_Northing",1000.0"#), + (r#""Central_Meridian",0.0"#, r#""Central_Meridian",10.0"#), + (r#""Auxiliary_Sphere_Type",0.0"#, r#""Auxiliary_Sphere_Type",1.0"#), + (r#"UNIT["Meter",1.0]]"#, r#"UNIT["Foot_US",0.3048006096012192]]"#), + ] { + let off = ESRI_3857.replace(from, to); + assert_ne!(off, ESRI_3857, "{from}"); + assert!(crs_from_wkt(&off).is_err(), "{from}: {:?}", crs_from_wkt(&off)); + } + // The same name on a Mercator method is read from its parameters: 1 km east here. + // (Without its EPSG code: a file's own code is trusted over its parameters.) + let shifted = gdal + .replace(r#"PARAMETER["false_easting",0]"#, r#"PARAMETER["false_easting",1000]"#) + .replace(r#",AUTHORITY["EPSG","3857"]]"#, "]"); + assert!(!shifted.ends_with(r#""3857"]]"#) && shifted.contains("1000")); + let crs = crs_from_wkt(&shifted).unwrap(); + assert!(matches!(crs, Crs::Custom(_)), "{crs:?}"); + let ll = Coord::new(60.0, 45.0); + let xy = crs.transformer().unwrap().from_lonlat(ll).unwrap(); + // (On the file's ellipsoid, as written: 2 km east of the origin is the reference.) + let two_km = shifted.replace("1000", "2000"); + let web = crs_from_wkt(&two_km).unwrap().transformer().unwrap().from_lonlat(ll).unwrap(); + assert!((web.x - xy.x - 1000.0).abs() < 0.001 && (xy.y - web.y).abs() < 0.001, "{xy:?} {web:?}"); + } + /// PROJ's `+towgs84` as WKT2: a `BOUNDCRS` with the shift to WGS 84 as its transformation. #[test] fn wkt2_bound_crs() { From 1a5745f876df8527f4d9aef684539ea9f9fbafec Mon Sep 17 00:00:00 2001 From: choupeanut Date: Tue, 10 Nov 2026 15:35:24 +0800 Subject: [PATCH 2/3] fix(wkt): Web Mercator needs its radius, and GDAL's shifted form is refused (#1699) A datum of another radius isn't Web Mercator's sphere. A shifted system with a PROJ4 extension says its sphere where the parameter reader can't see it, so it is refused instead of being put on the ellipsoid. --- crates/data/src/raster/wkt.rs | 80 +++++++++++++++++++++++++---------- 1 file changed, 58 insertions(+), 22 deletions(-) diff --git a/crates/data/src/raster/wkt.rs b/crates/data/src/raster/wkt.rs index e3d2b5627..48e7d1122 100644 --- a/crates/data/src/raster/wkt.rs +++ b/crates/data/src/raster/wkt.rs @@ -923,10 +923,11 @@ fn projected(p: &Node) -> Result { } // The name is Web Mercator's, the parameters aren't: a named Mercator method is read from // them below, but these two methods are Web Mercator's alone and `custom` has no sphere - // to put them on, so refuse instead of placing points at Web Mercator's origin. - if pseudo { + // to put them on, and an EXTENSION says the sphere in a form `custom` doesn't read, so + // refuse instead of placing points at Web Mercator's origin or on the wrong ellipsoid. + if pseudo || p.child("EXTENSION").is_some() { return Err( - "a Web Mercator system with parameters of its own (a shifted origin, scale or auxiliary sphere) isn't supported".into() + "a Web Mercator system with parameters of its own (a shifted origin, scale, auxiliary sphere, unit or datum radius) isn't supported".into() ); } } @@ -942,20 +943,25 @@ fn projected(p: &Node) -> Result { /// Whether a system named or projected as Web Mercator has Web Mercator's parameters: a Mercator /// method, in metres, with central meridian, origin latitude, standard parallel, false easting and -/// northing 0, scale 1 and auxiliary sphere type 0 (the datum's semi-major axis). A parameter that -/// isn't given counts as [`custom`] reads it. +/// northing 0, scale 1 and auxiliary sphere type 0 (the datum's semi-major axis), on a datum with +/// Web Mercator's radius. A parameter that isn't given counts as the default. fn is_web_mercator(p: &Node, method: &str) -> bool { let params = parameters(p); - let is = |names: &[&str], to: f64| parameter(¶ms, names).is_none_or(|v| (v - to).abs() <= 1e-9); + let is_default = |names: &[&str], to: f64| parameter(¶ms, names).is_none_or(|v| (v - to).abs() <= 1e-9); (method.is_empty() || method.starts_with("MERCATOR") || method.ends_with("PSEUDO_MERCATOR")) && p.child("UNIT").and_then(|u| u.num(0)).is_none_or(|m| (m - 1.0).abs() <= 1e-9) - && is(&CENTRAL_MERIDIAN, 0.0) - && is(&LATITUDE_OF_ORIGIN, 0.0) - && is(&["STANDARD_PARALLEL_1"], 0.0) - && is(&["FALSE_EASTING"], 0.0) - && is(&["FALSE_NORTHING"], 0.0) - && is(&["SCALE_FACTOR"], 1.0) - && is(&["AUXILIARY_SPHERE_TYPE"], 0.0) + && p.child("GEOGCS") + .and_then(|g| g.child("DATUM")) + .and_then(|d| d.child_of(&["SPHEROID", "ELLIPSOID"])) + .and_then(|s| s.num(0)) + .is_none_or(|a| (a - 6_378_137.0).abs() <= 0.01) + && is_default(&CENTRAL_MERIDIAN, 0.0) + && is_default(&LATITUDE_OF_ORIGIN, 0.0) + && is_default(&["STANDARD_PARALLEL_1"], 0.0) + && is_default(&["FALSE_EASTING"], 0.0) + && is_default(&["FALSE_NORTHING"], 0.0) + && is_default(&["SCALE_FACTOR"], 1.0) + && is_default(&["AUXILIARY_SPHERE_TYPE"], 0.0) } /// UTM's scale on the central meridian. @@ -3008,20 +3014,50 @@ pub(crate) mod tests { assert_ne!(off, ESRI_3857, "{from}"); assert!(crs_from_wkt(&off).is_err(), "{from}: {:?}", crs_from_wkt(&off)); } - // The same name on a Mercator method is read from its parameters: 1 km east here. - // (Without its EPSG code: a file's own code is trusted over its parameters.) - let shifted = gdal - .replace(r#"PARAMETER["false_easting",0]"#, r#"PARAMETER["false_easting",1000]"#) - .replace(r#",AUTHORITY["EPSG","3857"]]"#, "]"); - assert!(!shifted.ends_with(r#""3857"]]"#) && shifted.contains("1000")); + // The same name on a Mercator method is read from its parameters: 1 km east here. Not + // with GDAL's EXTENSION, which says the sphere in a form the parameters don't. (Without + // the EPSG code: a file's own code is trusted over its parameters.) + let shift = |east: &str| { + gdal.replace(r#"PARAMETER["false_easting",0]"#, &format!(r#"PARAMETER["false_easting",{east}]"#)) + .replace(r#",AUTHORITY["EPSG","3857"]]"#, "]") + }; + let no_ext = |east: &str| { + let w = shift(east); + let from = w.find(",EXTENSION[").unwrap(); + let to = from + w[from..].find(r#""]"#).unwrap() + 2; + format!("{}{}", &w[..from], &w[to..]) + }; + let e = crs_from_wkt(&shift("1000")).unwrap_err(); + assert!(e.contains("isn't supported"), "{e}"); + let shifted = no_ext("1000"); + assert!(!shifted.contains("EXTENSION") && !shifted.contains("3857") && shifted.contains("1000"), "{shifted}"); let crs = crs_from_wkt(&shifted).unwrap(); assert!(matches!(crs, Crs::Custom(_)), "{crs:?}"); let ll = Coord::new(60.0, 45.0); let xy = crs.transformer().unwrap().from_lonlat(ll).unwrap(); - // (On the file's ellipsoid, as written: 2 km east of the origin is the reference.) - let two_km = shifted.replace("1000", "2000"); - let web = crs_from_wkt(&two_km).unwrap().transformer().unwrap().from_lonlat(ll).unwrap(); + // The same system 2 km east of the origin: 1 km further than the first, on the file's + // ellipsoid as written. + let web = crs_from_wkt(&no_ext("2000")).unwrap().transformer().unwrap().from_lonlat(ll).unwrap(); assert!((web.x - xy.x - 1000.0).abs() < 0.001 && (xy.y - web.y).abs() < 0.001, "{xy:?} {web:?}"); + // A datum with another radius isn't Web Mercator's: Esri's method is refused, and a + // Mercator method is read from its parameters. + let clarke = ESRI_3857.replace("6378137.0,298.257223563", "6378206.4,294.978698213898"); + assert_ne!(clarke, ESRI_3857); + assert!(crs_from_wkt(&clarke).is_err(), "{:?}", crs_from_wkt(&clarke)); + let mercator = no_ext("0").replace("6378137,298.257223563", "6378206.4,294.978698213898"); + assert!(mercator.contains("6378206.4")); + assert!(matches!(crs_from_wkt(&mercator), Ok(Crs::Custom(_))), "{:?}", crs_from_wkt(&mercator)); + // WKT2's method: Web Mercator without a shift, refused with one. + let wkt2 = include_str!("wkt2_corpus.tsv") + .lines() + .find(|l| l.contains("Popular Visualisation Pseudo Mercator")) + .and_then(|l| l.rsplit('\t').next()) + .unwrap(); + assert_eq!(crs_from_wkt(wkt2), Ok(Crs::WebMercator)); + let east = wkt2.replace(r#"PARAMETER["False easting",0,"#, r#"PARAMETER["False easting",1000,"#); + assert_ne!(east, wkt2); + let e = crs_from_wkt(&east).unwrap_err(); + assert!(e.contains("a Web Mercator system with parameters of its own") && e.contains("isn't supported"), "{e}"); } /// PROJ's `+towgs84` as WKT2: a `BOUNDCRS` with the shift to WGS 84 as its transformation. From 0af0c362239942593598d629ec0e1afb4761c762 Mon Sep 17 00:00:00 2001 From: choupeanut Date: Tue, 10 Nov 2026 22:45:17 +0800 Subject: [PATCH 3/3] fix(wkt): a Web Mercator parameter that isn't a finite number is refused (#1699) `parameters` leaves out a value like 1e400, and a parameter left out counts as Web Mercator's default, so the system was read at Web Mercator's origin with nothing said. A system named or projected as Web Mercator with such a parameter is now refused, in WKT1 and WKT2. WKT2's conversion keeps the parameter, with no number, so it can be told from one not given. --- crates/data/src/raster/wkt.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/data/src/raster/wkt.rs b/crates/data/src/raster/wkt.rs index 48e7d1122..a275a3160 100644 --- a/crates/data/src/raster/wkt.rs +++ b/crates/data/src/raster/wkt.rs @@ -657,7 +657,11 @@ fn projected2(p: &Node) -> Result { let method_id = mapped.map(|&(code, _, _)| Item::Node(Node::new("AUTHORITY", "EPSG", [Item::Num(f64::from(code))]))); items.push(Item::Node(Node::new("PROJECTION", name, method_id))); for param in conv.nodes().filter(|n| n.name.eq_ignore_ascii_case("PARAMETER")) { - let Some(v) = param.num(0) else { continue }; + let Some(v) = param.num(0) else { + // Kept with no number, so it can be told from a parameter not given. + items.push(Item::Node(Node::new("PARAMETER", param.title(), None))); + continue; + }; let (name, measure) = match lookup(param, PARAMETERS, |r| (r.0, r.1)) { Some(&(_, _, wkt1, m)) => (wkt1, Some(m)), None => (param.title(), None), @@ -915,6 +919,12 @@ fn projected(p: &Node) -> Result { let method = norm(p.child("PROJECTION").map_or("", Node::title)); let pseudo = method == "MERCATOR_AUXILIARY_SPHERE" || method == "POPULAR_VISUALISATION_PSEUDO_MERCATOR"; if name.contains("WEB_MERCATOR") || name.contains("PSEUDO_MERCATOR") || pseudo { + // `parameters` leaves out a value that isn't a finite number, and one left out counts as + // Web Mercator's default. + let unusable = |i: &Item| matches!(i, Item::Node(n) if n.name.eq_ignore_ascii_case("PARAMETER") && n.num(0).is_none()); + if p.items.iter().any(unusable) { + return Err("a Web Mercator system with a parameter that isn't a finite number isn't supported".into()); + } if is_web_mercator(p, &method) { if axis_flip(p)?.is_some() { return Err(REVERSED_AXES.into()); @@ -3058,6 +3068,16 @@ pub(crate) mod tests { assert_ne!(east, wkt2); let e = crs_from_wkt(&east).unwrap_err(); assert!(e.contains("a Web Mercator system with parameters of its own") && e.contains("isn't supported"), "{e}"); + // A parameter that isn't a finite number isn't a default: refused, never read as one. + for huge in [ + ESRI_3857.replace(r#""False_Easting",0.0"#, r#""False_Easting",1e400"#), + no_ext("1e400"), + wkt2.replace(r#"PARAMETER["False easting",0,"#, r#"PARAMETER["False easting",1e400,"#), + ] { + assert!(huge.contains("1e400"), "{huge}"); + let e = crs_from_wkt(&huge).unwrap_err(); + assert!(e.contains("isn't a finite number"), "{e}"); + } } /// PROJ's `+towgs84` as WKT2: a `BOUNDCRS` with the shift to WGS 84 as its transformation.