Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 124 additions & 9 deletions crates/data/src/raster/wkt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -657,7 +657,11 @@ fn projected2(p: &Node) -> Result<Node, String> {
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),
Expand Down Expand Up @@ -913,15 +917,29 @@ fn geographic(g: &Node) -> Result<Crs, String> {
fn projected(p: &Node) -> Result<Crs, String> {
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 {
// `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());
}
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, 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, auxiliary sphere, unit or datum radius) 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)
Expand All @@ -933,6 +951,29 @@ fn projected(p: &Node) -> Result<Crs, String> {
custom(p, &method)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

/// 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), 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_default = |names: &[&str], to: f64| parameter(&params, names).is_none_or(|v| (v - to).abs() <= 1e-9);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (confidence: low-medium). parameters() drops any PARAMETER whose value isn't finite (n.num(0)? filters non-finite values). is_default then treats the dropped parameter as "not given" and counts it as the default. So a value like PARAMETER["False_Easting",1e400] (which parses to infinity) on a named Web Mercator system returns Crs::WebMercator at Web Mercator's origin, with no notice. The doc comment says "a parameter that isn't given counts as the default". That's true here only for parameters that are absent, not for ones that are present but unusable.

Suggested fix: distinguish "absent" from "present but not a finite number". For example, check for the PARAMETER node by name before calling parameter(), and refuse when it is present with no finite value. Add a test with 1e400 as a false easting, expecting an error or a custom read, but not Ok(Crs::WebMercator).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0af0c36: a system named or projected as Web Mercator with a parameter that isn't a finite number is refused, in WKT1 and WKT2. The test gives 1e400 as a false easting to Esri's method, a Mercator method and WKT2's. Other projected systems are unchanged.

(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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (confidence: medium-high): is_web_mercator never checks the datum, but Crs::WebMercator is always a 6378137 m sphere (see the towgs84 note at line 314). The doc comment says auxiliary sphere type 0 is "the datum's semi-major axis", so a system on another ellipsoid with type 0 (for example a 6378206.4 m Clarke-type datum) is read as Web Mercator with no note, and its points come out about 70 m off at x ≈ 6.7e6 m. The pseudo methods would be refused on the same input, and name-only systems take the same path. The fix belongs in this predicate. Suggested change, plus a test that feeds ESRI_3857 with a 6378206.4 spheroid and expects a result other than Ok(Crs::WebMercator):

Suggested change
&& p.child("UNIT").and_then(|u| u.num(0)).is_none_or(|m| (m - 1.0).abs() <= 1e-9)
&& p.child("UNIT").and_then(|u| u.num(0)).is_none_or(|m| (m - 1.0).abs() <= 1e-9)
&& 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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1a5745f: the datum's radius must be 6378137. Esri's method on another radius is refused, and a named Mercator method is read from its parameters. Both are tested.

&& 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-raised from an earlier thread (confidence: medium-high). The semi-major check added here is the part of that thread's ask that landed. The flattening is still not checked, and the problem remains.

is_web_mercator accepts an ordinary Mercator method (Mercator_1SP, Mercator_Variant_A, Mercator_2SP, plain Mercator) on WGS 84 with no EXTENSION. WGS 84 has a = 6378137, so the predicate returns Crs::WebMercator, which is spherical. A real ellipsoidal Mercator on that datum is about 30 km off in northing at 45° N. Nothing is noted, which breaks the "Never silent" rule for an approximated read.

Suggested direction: for methods other than the auxiliary-sphere and pseudo ones, require a sphere (inverse flattening == 0, or an EXTENSION that declares +b= equal to +a=) before returning Crs::WebMercator. Otherwise fall through to custom. GDAL's own 3857 WKT (with +a=6378137 +b=6378137 in its EXTENSION) still reads as Web Mercator under that rule. Please add a test: ESRI_3857-style text with Mercator_1SP and SPHEROID["WGS_1984",6378137.0,298.257223563], no EXTENSION, no AUTHORITY, expecting Ok(Crs::Custom(_)) and not Ok(Crs::WebMercator).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changed. A Web Mercator name on a Mercator method and WGS 84's flattened ellipsoid, with default parameters, is a form real .prj files use, and reading it from the ellipsoid would move those points by about 30 km at 45° (about 21 km at 30°). This also predates the PR and isn't part of #1699.

Comment on lines +962 to +967

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (confidence: medium). The new is_none_or checks treat a value that is present but not finite the same as one that is absent. Node::num returns None for 1e400 (it parses to infinity), so the two cases can't be told apart here. The PARAMETER case was fixed in 0af0c36, but the same gap is still open for two other values in the predicate:

  • WKT1 keeps the raw SPHEROID and UNIT nodes (wkt1 returns WKT1 nodes unchanged), so SPHEROID["WGS_1984",1e400,298.257223563] or UNIT["Meter",1e400] on a system named WGS_1984_Web_Mercator_Auxiliary_Sphere (with default parameters) returns Crs::WebMercator. The unreadable datum or unit is replaced by the default, with no notice.
  • WKT2 already errors for a non-finite ellipsoid axis (geographic2), so this affects WKT1 only.

Suggested fix: require the node to be present and its number to be finite. Absent nodes still count as the default:

Suggested change
&& p.child("UNIT").and_then(|u| u.num(0)).is_none_or(|m| (m - 1.0).abs() <= 1e-9)
&& 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)
&& p.child("UNIT").is_none_or(|u| u.num(0).is_some_and(|m| (m - 1.0).abs() <= 1e-9))
&& p.child("GEOGCS")
.and_then(|g| g.child("DATUM"))
.and_then(|d| d.child_of(&["SPHEROID", "ELLIPSOID"]))
.is_none_or(|s| s.num(0).is_some_and(|a| (a - 6_378_137.0).abs() <= 0.01))

Trade-off: a UNIT with no number (UNIT["Meter"]) would no longer be Web Mercator and would go to the refusal or custom. That is the safer result. A regression test is needed: ESRI_3857.replace("6378137.0,298.257223563", "1e400,298.257223563") and the same with UNIT["Meter",1e400], each expecting an error or a non-WebMercator result.

&& 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)
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
/// UTM's scale on the central meridian.
const UTM_SCALE: f64 = 0.9996;
/// UTM's false easting, in metres.
Expand Down Expand Up @@ -2965,6 +3006,80 @@ 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. 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();
// 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}");
// 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}");
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
/// PROJ's `+towgs84` as WKT2: a `BOUNDCRS` with the shift to WGS 84 as its transformation.
#[test]
fn wkt2_bound_crs() {
Expand Down
Loading