You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sedona.apache.org by pa...@apache.org on 2026/06/18 20:47:00 UTC
(sedona-db) branch main updated: feat(rust/sedona-schema): support WKT1/WKT2 CRS strings in deserialize_crs (#953)
This is an automated email from the ASF dual-hosted git repository.
paleolimbot pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/sedona-db.git
The following commit(s) were added to refs/heads/main by this push:
new f95c88665 feat(rust/sedona-schema): support WKT1/WKT2 CRS strings in deserialize_crs (#953)
f95c88665 is described below
commit f95c886655193ab40e0dd19cbf55898c99ac7c52
Author: James Willis <ja...@users.noreply.github.com>
AuthorDate: Thu Jun 18 13:46:55 2026 -0700
feat(rust/sedona-schema): support WKT1/WKT2 CRS strings in deserialize_crs (#953)
---
c/sedona-proj/src/st_transform.rs | 27 +
docs/reference/sql/st_crs.qmd | 3 +-
docs/reference/sql/st_setcrs.qmd | 3 +
.../tests/functions/test_raster_functions.py | 42 ++
python/sedonadb/tests/functions/test_transforms.py | 61 ++
rust/sedona-schema/src/crs.rs | 616 +++++++++++++++++++--
6 files changed, 720 insertions(+), 32 deletions(-)
diff --git a/c/sedona-proj/src/st_transform.rs b/c/sedona-proj/src/st_transform.rs
index fb219ec2e..5cce449a8 100644
--- a/c/sedona-proj/src/st_transform.rs
+++ b/c/sedona-proj/src/st_transform.rs
@@ -456,6 +456,33 @@ mod tests {
const NAD83ZONE6PROJ: &str = "EPSG:2230";
const NAD83_GEOGRAPHIC: &str = "EPSG:4269";
const WGS84: &str = "EPSG:4326";
+ // EPSG:3857 as WKT1 (AUTHORITY[...]) and WKT2 (ID[...]), for exercising
+ // both WKT flavors through `deserialize_crs` (the to-CRS argument is
+ // deserialized before being handed to PROJ).
+ const WEB_MERCATOR_WKT1: &str = r#"PROJCS["WGS 84 / Pseudo-Mercator",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],AUTHORITY["EPSG","4326"]],PROJECTION["Mercator_1SP"],AUTHORITY["EPSG","3857"]]"#;
+ const WEB_MERCATOR_WKT2: &str = r#"PROJCRS["WGS 84 / Pseudo-Mercator",BASEGEOGCRS["WGS 84",DATUM["World Geodetic System 1984",ELLIPSOID["WGS 84",6378137,298.257223563,LENGTHUNIT["metre",1]]],ID["EPSG",4326]],CONVERSION["Popular Visualisation Pseudo-Mercator",METHOD["Popular Visualisation Pseudo Mercator",ID["EPSG",1024]],PARAMETER["Latitude of natural origin",0,ANGLEUNIT["degree",0.0174532925199433]],PARAMETER["Longitude of natural origin",0,ANGLEUNIT["degree",0.0174532925199433]],PA [...]
+
+ /// A WKT-defined target CRS (both WKT1 and WKT2) must transform identically
+ /// to its authority code — the consumer-side proof that `deserialize_crs`
+ /// now accepts WKT and feeds the verbatim definition to PROJ. Compares
+ /// output WKB, which is independent of how the result's CRS metadata is
+ /// represented.
+ #[rstest]
+ #[case::wkt1(WEB_MERCATOR_WKT1)]
+ #[case::wkt2(WEB_MERCATOR_WKT2)]
+ fn transform_to_wkt_crs_matches_authority_code(#[case] wkt: &str) {
+ let udf = SedonaScalarUDF::from_impl("st_transform", st_transform_impl());
+ let geometry_input = SedonaType::Wkb(Edges::Planar, lnglat());
+ let tester = ScalarUdfTester::new(
+ udf.into(),
+ vec![geometry_input.clone(), SedonaType::Arrow(DataType::Utf8)],
+ );
+
+ let wkb = create_array(&[None, Some("POINT (79.3871 43.6426)")], &geometry_input);
+ let via_wkt = tester.invoke_array_scalar(wkb.clone(), wkt).unwrap();
+ let via_code = tester.invoke_array_scalar(wkb, "EPSG:3857").unwrap();
+ assert_array_equal(&via_wkt, &via_code);
+ }
#[test]
fn test_invoke_with_string() {
diff --git a/docs/reference/sql/st_crs.qmd b/docs/reference/sql/st_crs.qmd
index b80bdad89..d1ce152e1 100644
--- a/docs/reference/sql/st_crs.qmd
+++ b/docs/reference/sql/st_crs.qmd
@@ -26,7 +26,8 @@ kernels:
## Description
Returns the CRS (Coordinate Reference System) string associated with a geometry
-or geography. This is abbreviated as an authority/code if possible (e.g., `'EPSG:3857'`).
+or geography. Whether it was set as an authority code (e.g. `'EPSG:3857'`),
+PROJJSON, or WKT, the CRS is returned in the same form it was set.
## Examples
diff --git a/docs/reference/sql/st_setcrs.qmd b/docs/reference/sql/st_setcrs.qmd
index c7df45d72..a95226241 100644
--- a/docs/reference/sql/st_setcrs.qmd
+++ b/docs/reference/sql/st_setcrs.qmd
@@ -37,6 +37,9 @@ Sets a CRS to a geometry/geography. This is metadata-only - no coordinates are t
This is different from ST_Transform which actually transforms coordinates.
+`target_crs` accepts an authority code (e.g. `'EPSG:4326'`), PROJJSON, or WKT.
+It is stored as given and read back unchanged by `ST_CRS`.
+
## Examples
```sql
diff --git a/python/sedonadb/tests/functions/test_raster_functions.py b/python/sedonadb/tests/functions/test_raster_functions.py
index 7412f613c..590d15229 100644
--- a/python/sedonadb/tests/functions/test_raster_functions.py
+++ b/python/sedonadb/tests/functions/test_raster_functions.py
@@ -41,6 +41,48 @@ def test_rs_function(expr, expected):
eng.assert_query_result(f"SELECT {expr}", expected)
+# EPSG:3857 as WKT (carries an embedded EPSG authority) and a bespoke Lambert
+# Conformal Conic WKT with no authority code anywhere.
+WKT_3857 = (
+ 'PROJCS["WGS 84 / Pseudo-Mercator",GEOGCS["WGS 84",DATUM["WGS_1984",'
+ 'SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],'
+ 'AUTHORITY["EPSG","6326"]],AUTHORITY["EPSG","4326"]],'
+ 'PROJECTION["Mercator_1SP"],AUTHORITY["EPSG","3857"]]'
+)
+WKT_LCC_NO_AUTHORITY = (
+ 'PROJCS["Custom LCC",GEOGCS["WGS 84",DATUM["WGS_1984",'
+ 'SPHEROID["WGS 84",6378137,298.257223563]]],'
+ 'PROJECTION["Lambert_Conformal_Conic_2SP"],'
+ 'PARAMETER["standard_parallel_1",33],PARAMETER["standard_parallel_2",45],'
+ 'PARAMETER["latitude_of_origin",39],PARAMETER["central_meridian",-96],'
+ 'UNIT["metre",1]]'
+)
+
+
+# WKT1/WKT2 CRS strings round-trip through RS_SetCRS/RS_CRS unchanged, whether or
+# not they carry an embedded authority.
+@pytest.mark.parametrize("wkt", [WKT_3857, WKT_LCC_NO_AUTHORITY])
+def test_rs_setcrs_wkt_roundtrips(wkt):
+ eng = SedonaDB()
+ eng.assert_query_result(f"SELECT RS_CRS(RS_SetCRS(RS_Example(), '{wkt}'))", wkt)
+
+
+def test_rs_srid_from_wkt():
+ """A WKT carrying an EPSG authority resolves to that SRID."""
+ eng = SedonaDB()
+ eng.assert_query_result(
+ f"SELECT RS_SRID(RS_SetCRS(RS_Example(), '{WKT_3857}'))", 3857
+ )
+
+
+def test_rs_srid_from_authorityless_wkt_errors(con):
+ """A WKT with no authority code anywhere has no SRID to extract."""
+ with pytest.raises(Exception, match="SRID"):
+ con.sql(
+ f"SELECT RS_SRID(RS_SetCRS(RS_Example(), '{WKT_LCC_NO_AUTHORITY}'))"
+ ).to_arrow_table()
+
+
def test_rs_ensureloaded(con, sedona_testing):
path = sedona_testing / "data/raster/sentinel2.tif"
t = con.sql("SELECT RS_FromPath($1) AS raster", params=(str(path),))
diff --git a/python/sedonadb/tests/functions/test_transforms.py b/python/sedonadb/tests/functions/test_transforms.py
index 652b5c0a3..3c00241bb 100644
--- a/python/sedonadb/tests/functions/test_transforms.py
+++ b/python/sedonadb/tests/functions/test_transforms.py
@@ -200,6 +200,67 @@ def test_st_crs_sedonadb(eng):
)
+# EPSG:3857 as WKT (carries an embedded EPSG authority) and a bespoke Lambert
+# Conformal Conic WKT with no authority code anywhere.
+WKT_3857 = (
+ 'PROJCS["WGS 84 / Pseudo-Mercator",GEOGCS["WGS 84",DATUM["WGS_1984",'
+ 'SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],'
+ 'AUTHORITY["EPSG","6326"]],AUTHORITY["EPSG","4326"]],'
+ 'PROJECTION["Mercator_1SP"],AUTHORITY["EPSG","3857"]]'
+)
+WKT_LCC_NO_AUTHORITY = (
+ 'PROJCS["Custom LCC",GEOGCS["WGS 84",DATUM["WGS_1984",'
+ 'SPHEROID["WGS 84",6378137,298.257223563]]],'
+ 'PROJECTION["Lambert_Conformal_Conic_2SP"],'
+ 'PARAMETER["standard_parallel_1",33],PARAMETER["standard_parallel_2",45],'
+ 'PARAMETER["latitude_of_origin",39],PARAMETER["central_meridian",-96],'
+ 'UNIT["metre",1]]'
+)
+
+
+# WKT1/WKT2 CRS strings round-trip through ST_SetCrs/ST_Crs unchanged, whether
+# or not they carry an embedded authority. (ST_SetCrs/ST_Crs are SedonaDB-only.)
+@pytest.mark.parametrize("wkt", [WKT_3857, WKT_LCC_NO_AUTHORITY])
+def test_st_crs_wkt_roundtrips(con, wkt):
+ result = con.sql(
+ "SELECT ST_Crs(ST_SetCrs(ST_GeomFromText('POINT (0 1)'), $1)) AS crs",
+ params=(wkt,),
+ ).to_arrow_table()
+ assert result["crs"][0].as_py() == wkt
+
+
+def test_st_srid_from_wkt(con):
+ """A WKT carrying an EPSG authority resolves to that SRID."""
+ result = con.sql(
+ "SELECT ST_SRID(ST_SetCrs(ST_GeomFromText('POINT (0 1)'), $1)) AS srid",
+ params=(WKT_3857,),
+ ).to_arrow_table()
+ assert result["srid"][0].as_py() == 3857
+
+
+def test_st_srid_from_authorityless_wkt_errors(con):
+ """A WKT with no authority code anywhere has no SRID to extract."""
+ with pytest.raises(Exception, match="SRID"):
+ con.sql(
+ "SELECT ST_SRID(ST_SetCrs(ST_GeomFromText('POINT (0 1)'), $1))",
+ params=(WKT_LCC_NO_AUTHORITY,),
+ ).to_arrow_table()
+
+
+def test_st_transform_from_wkt_crs(con):
+ """A WKT CRS feeds the PROJ transform like any other CRS string: a point at
+ the EPSG:3857 coordinates of (lon 1, lat 1) transforms back to ~POINT (1 1)."""
+ df = con.sql(
+ "SELECT ST_Transform("
+ "ST_SetCrs(ST_GeomFromText('POINT (111319.490793274 111325.142866385)'), $1),"
+ " 'EPSG:4326') AS geom",
+ params=(WKT_3857,),
+ ).to_pandas()
+ point = df.geometry[0]
+ assert point.x == pytest.approx(1.0, abs=1e-6)
+ assert point.y == pytest.approx(1.0, abs=1e-6)
+
+
@pytest.mark.parametrize("eng", [SedonaDB, PostGIS])
@pytest.mark.parametrize(
("geom", "dx", "dy", "expected"),
diff --git a/rust/sedona-schema/src/crs.rs b/rust/sedona-schema/src/crs.rs
index 7fbb5da9a..0183d59f9 100644
--- a/rust/sedona-schema/src/crs.rs
+++ b/rust/sedona-schema/src/crs.rs
@@ -38,8 +38,12 @@ thread_local! {
/// Deserialize a specific GeoArrow "crs" value
///
-/// Currently the only CRS types supported are PROJJSON and authority:code
-/// where the authority and code are well-known lon/lat (WGS84) codes.
+/// Recognizes three encodings: an `authority:code` string (e.g. `"EPSG:4326"`),
+/// a PROJJSON object, and a WKT1/WKT2 CRS string (e.g. `PROJCS[...]` /
+/// `PROJCRS[...]`). WKT is carried verbatim (so it can be handed to PROJ for
+/// transforms) with a best-effort `authority:code` extracted from its trailing
+/// `AUTHORITY[...]` / `ID[...]` tag. The `OGC:CRS84` / `EPSG:4326` lon/lat
+/// special case is recognized for the Geography type.
pub fn deserialize_crs(crs_str: &str) -> Result<Crs> {
if crs_str.is_empty() {
return Ok(None);
@@ -54,11 +58,14 @@ pub fn deserialize_crs(crs_str: &str) -> Result<Crs> {
// Handle "0", "{AUTH}:{CODE}" authority codes (including the OGC:CRS84 /
// EPSG:4326 lon/lat aliases, which are kept verbatim rather than folded),
- // and PROJJSON strings.
+ // a WKT1/WKT2 CRS string, then PROJJSON.
let crs = if crs_str == "0" {
None
} else if AuthorityCode::is_authority_code(crs_str) {
AuthorityCode::crs(crs_str)
+ } else if looks_like_wkt(crs_str) {
+ Some(Arc::new(WktCrs::new(crs_str.to_string()))
+ as Arc<dyn CoordinateReferenceSystem + Send + Sync>)
} else {
// Try to parse as PROJJSON string
let projjson: ProjJSON = crs_str.parse()?;
@@ -464,35 +471,42 @@ impl CoordinateReferenceSystem for AuthorityCode {
/// Hard-code support for several known spherical CRSes so we can support them
/// in the geography type
fn geographic_params(&self) -> Result<Option<GeographicCrsParams>> {
- match self.auth_code.as_str() {
- // Default lnglat(). Here we use S2Earth::RadiusMeters() as a constant
- // for consistent results (if we averaged over the ensemble, distance results
- // may be subtly different between versions as the ensemble is updated).
- "OGC:CRS84" | "EPSG:4326" => Ok(Some(GeographicCrsParams {
- spherical_radius_m: 6371010.0,
- })),
- // NAD83
- // https://spatialreference.org/ref/epsg/4269/
- "OGC:CRS83" | "EPSG:4269" => {
- const A: f64 = 6378137.0;
- const INV_F: f64 = 298.257222101;
- const B: f64 = A * (1.0 - 1.0 / INV_F);
- Ok(Some(GeographicCrsParams {
- spherical_radius_m: (2.0 * A + B) / 3.0,
- }))
- }
- // NAD27
- // Mean radius = (2a + b) / 3
- // https://spatialreference.org/ref/epsg/4267/
- "OGC:CRS27" | "EPSG:4267" => {
- const A: f64 = 6378206.4;
- const B: f64 = 6356583.8;
- Ok(Some(GeographicCrsParams {
- spherical_radius_m: (2.0 * A + B) / 3.0,
- }))
- }
- _ => Ok(None),
+ Ok(geographic_params_for_authority(&self.auth_code))
+ }
+}
+
+/// Spherical parameters for the handful of geographic CRSes the Geography type
+/// supports, keyed by `authority:code`. Shared by [`AuthorityCode`] and [`WktCrs`]
+/// (which resolves its authority code from the WKT first).
+fn geographic_params_for_authority(auth_code: &str) -> Option<GeographicCrsParams> {
+ match auth_code {
+ // Default lnglat(). Here we use S2Earth::RadiusMeters() as a constant
+ // for consistent results (if we averaged over the ensemble, distance results
+ // may be subtly different between versions as the ensemble is updated).
+ "OGC:CRS84" | "EPSG:4326" => Some(GeographicCrsParams {
+ spherical_radius_m: 6371010.0,
+ }),
+ // NAD83
+ // https://spatialreference.org/ref/epsg/4269/
+ "OGC:CRS83" | "EPSG:4269" => {
+ const A: f64 = 6378137.0;
+ const INV_F: f64 = 298.257222101;
+ const B: f64 = A * (1.0 - 1.0 / INV_F);
+ Some(GeographicCrsParams {
+ spherical_radius_m: (2.0 * A + B) / 3.0,
+ })
}
+ // NAD27
+ // Mean radius = (2a + b) / 3
+ // https://spatialreference.org/ref/epsg/4267/
+ "OGC:CRS27" | "EPSG:4267" => {
+ const A: f64 = 6378206.4;
+ const B: f64 = 6356583.8;
+ Some(GeographicCrsParams {
+ spherical_radius_m: (2.0 * A + B) / 3.0,
+ })
+ }
+ _ => None,
}
}
@@ -634,6 +648,319 @@ impl CoordinateReferenceSystem for ProjJSON {
}
}
+/// WKT CRS keywords that begin a recognized WKT1/WKT2 coordinate reference
+/// system, matched case-insensitively against the trimmed input.
+const WKT_CRS_KEYWORDS: &[&str] = &[
+ "PROJCRS",
+ "PROJCS",
+ "GEOGCRS",
+ "GEOGCS",
+ "GEODCRS",
+ "GEODETICCRS",
+ "GEOCCS",
+ "BOUNDCRS",
+ "COMPOUNDCRS",
+ "COMPD_CS",
+ "VERTCRS",
+ "VERT_CS",
+ "ENGCRS",
+ "ENGINEERINGCRS",
+ "PARAMETRICCRS",
+ "TIMECRS",
+ "LOCAL_CS",
+ "FITTED_CS",
+];
+
+/// Heuristic check for a WKT1/WKT2 CRS string: the trimmed value begins with a
+/// recognized CRS keyword followed (after optional whitespace) by `[` or `(`.
+fn looks_like_wkt(s: &str) -> bool {
+ let upper = s.trim_start().to_ascii_uppercase();
+ WKT_CRS_KEYWORDS.iter().any(|kw| {
+ upper
+ .strip_prefix(kw)
+ .is_some_and(|rest| rest.trim_start().starts_with(['[', '(']))
+ })
+}
+
+/// A node in a WKT CRS string. WKT is JSON-ish: a keyword followed by a
+/// bracketed, comma-separated list of children, interspersed with quoted
+/// strings and bare tokens (numbers, axis directions, enum values) as leaves.
+enum WktNode<'a> {
+ /// A bare token — a number or unquoted keyword value, e.g. `6378137`.
+ Unquoted(&'a str),
+ /// A quoted string, e.g. `"WGS 84"`.
+ Quoted(&'a str),
+ /// `KEYWORD[child, child, ...]` (or `(...)`), e.g. `ID["EPSG",4326]`.
+ Tagged(&'a str, Vec<WktNode<'a>>),
+}
+
+impl WktNode<'_> {
+ /// The inner text of a leaf node (quoted string or bare token), or `None`
+ /// for a tagged node.
+ fn leaf_str(&self) -> Option<&str> {
+ match self {
+ WktNode::Quoted(s) | WktNode::Unquoted(s) => Some(s),
+ WktNode::Tagged(..) => None,
+ }
+ }
+}
+
+/// Maximum WKT nesting depth the parser will descend into. Real CRS definitions
+/// nest under a dozen levels deep; the cap exists only to bound stack usage on
+/// pathological or malformed input (the WKT can arrive from untrusted file
+/// metadata), past which authority extraction gives up.
+const WKT_MAX_DEPTH: usize = 128;
+
+/// Recursive-descent parser over a WKT string. Whitespace is insignificant
+/// except inside quoted strings. Recursion is bounded by [`WKT_MAX_DEPTH`].
+struct WktParser<'a> {
+ s: &'a str,
+ pos: usize,
+}
+
+impl<'a> WktParser<'a> {
+ fn skip_ws(&mut self) {
+ self.pos += self.s[self.pos..]
+ .find(|c: char| !c.is_whitespace())
+ .unwrap_or(self.s.len() - self.pos);
+ }
+
+ fn peek(&self) -> Option<char> {
+ self.s[self.pos..].chars().next()
+ }
+
+ /// Parse a quoted string, consuming the surrounding quotes. A doubled `""`
+ /// is the WKT escape for a literal quote and does not terminate the string.
+ fn parse_quoted(&mut self) -> Result<WktNode<'a>, DataFusionError> {
+ self.pos += 1; // opening quote
+ let start = self.pos;
+ loop {
+ let rest = &self.s[self.pos..];
+ let Some(q) = rest.find('"') else {
+ return Err(DataFusionError::Execution(
+ "WKT CRS string has an unterminated quoted string".into(),
+ ));
+ };
+ // A doubled "" is an escaped quote: skip both and keep scanning.
+ if rest[q + 1..].starts_with('"') {
+ self.pos += q + 2;
+ continue;
+ }
+ let value = &self.s[start..self.pos + q];
+ self.pos += q + 1; // closing quote
+ return Ok(WktNode::Quoted(value));
+ }
+ }
+
+ fn parse_node(&mut self, depth: usize) -> Result<WktNode<'a>, DataFusionError> {
+ if depth > WKT_MAX_DEPTH {
+ return Err(DataFusionError::Execution(format!(
+ "WKT CRS string nests beyond {WKT_MAX_DEPTH} levels"
+ )));
+ }
+ self.skip_ws();
+ match self.peek() {
+ Some('"') => return self.parse_quoted(),
+ Some(_) => {}
+ None => {
+ return Err(DataFusionError::Execution(
+ "unexpected end of WKT CRS string".into(),
+ ))
+ }
+ }
+ // A keyword or bare token runs until a structural character.
+ let start = self.pos;
+ let word_len = self.s[self.pos..]
+ .find(|c: char| c.is_whitespace() || "[](),\"".contains(c))
+ .unwrap_or(self.s.len() - self.pos);
+ let word = self.s[start..start + word_len].trim();
+ self.pos += word_len;
+ self.skip_ws();
+
+ // A `[`/`(` makes this a tagged node; otherwise it is a bare token.
+ match self.peek() {
+ Some(open @ ('[' | '(')) => {
+ let close = if open == '[' { ']' } else { ')' };
+ self.pos += 1;
+ let mut children = Vec::new();
+ loop {
+ self.skip_ws();
+ match self.peek() {
+ Some(c) if c == close => {
+ self.pos += 1;
+ break;
+ }
+ Some(',') => {
+ self.pos += 1;
+ }
+ Some(_) => children.push(self.parse_node(depth + 1)?),
+ None => {
+ return Err(DataFusionError::Execution(
+ "WKT CRS string has an unterminated bracket".into(),
+ ))
+ }
+ }
+ }
+ Ok(WktNode::Tagged(word, children))
+ }
+ _ if word.is_empty() => Err(DataFusionError::Execution(
+ "WKT CRS string has an empty token".into(),
+ )),
+ _ => Ok(WktNode::Unquoted(word)),
+ }
+ }
+}
+
+/// Best-effort `authority:code` from a WKT CRS string, read from the **CRS-level**
+/// `AUTHORITY["auth","code"]` (WKT1) or `ID["auth",code]` (WKT2) tag — i.e. a
+/// direct child of the root node. Authorities on nested datums, ellipsoids,
+/// projection methods, parameters, or units are deliberately ignored: a custom
+/// CRS with no top-level authority must not be misidentified by an inner code
+/// (e.g. a Lambert Conformal whose only `ID`s are the `EPSG:9001` metre unit and
+/// projection operation codes). `None` when the root carries no authority tag.
+fn extract_authority_from_wkt(wkt: &str) -> Option<String> {
+ let mut parser = WktParser { s: wkt, pos: 0 };
+ // A malformed or too-deeply-nested WKT simply yields no authority — the
+ // definition is still carried verbatim, it just has no SRID.
+ let Ok(WktNode::Tagged(_, children)) = parser.parse_node(0) else {
+ return None;
+ };
+ for child in &children {
+ let WktNode::Tagged(keyword, args) = child else {
+ continue;
+ };
+ if !keyword.eq_ignore_ascii_case("AUTHORITY") && !keyword.eq_ignore_ascii_case("ID") {
+ continue;
+ }
+ let authority = args.first()?.leaf_str()?.trim();
+ let code = args.get(1)?.leaf_str()?.trim();
+ if authority.is_empty() || code.is_empty() {
+ return None;
+ }
+ return Some(format!("{}:{}", authority.to_uppercase(), code));
+ }
+ None
+}
+
+/// True if `keyword` is a geographic/geodetic CRS root — one with a meaningful
+/// ellipsoid for spherical geography. Projected, engineering, vertical, etc.
+/// roots are excluded so they never resolve geographic parameters.
+fn is_geographic_crs_keyword(keyword: &str) -> bool {
+ ["GEOGCS", "GEOGCRS", "GEODCRS", "GEODETICCRS"]
+ .iter()
+ .any(|k| keyword.eq_ignore_ascii_case(k))
+}
+
+/// Depth-first search for the first `ELLIPSOID` (WKT2) or `SPHEROID` (WKT1) node.
+fn find_ellipsoid<'a, 'n>(node: &'n WktNode<'a>) -> Option<&'n WktNode<'a>> {
+ let WktNode::Tagged(keyword, children) = node else {
+ return None;
+ };
+ if keyword.eq_ignore_ascii_case("ELLIPSOID") || keyword.eq_ignore_ascii_case("SPHEROID") {
+ return Some(node);
+ }
+ children.iter().find_map(find_ellipsoid)
+}
+
+/// Best-effort geographic parameters parsed straight from a WKT definition, for
+/// geographic CRSes whose authority code isn't in the curated lookup (including
+/// authority-less ones). Reads the ellipsoid's semi-major axis and inverse
+/// flattening and reduces them to a mean spherical radius the same way
+/// [`geographic_params_for_authority`] does; a sphere (inverse flattening 0)
+/// uses its radius directly. Returns `None` for non-geographic CRSes (e.g. a
+/// projected CRS, even though it nests a geographic base) or WKT without a
+/// parseable ellipsoid.
+fn geographic_params_from_wkt(wkt: &str) -> Option<GeographicCrsParams> {
+ let root = WktParser { s: wkt, pos: 0 }.parse_node(0).ok()?;
+ let WktNode::Tagged(keyword, _) = &root else {
+ return None;
+ };
+ if !is_geographic_crs_keyword(keyword) {
+ return None;
+ }
+
+ let WktNode::Tagged(_, ellipsoid_args) = find_ellipsoid(&root)? else {
+ return None;
+ };
+ let semi_major: f64 = ellipsoid_args.get(1)?.leaf_str()?.trim().parse().ok()?;
+ let inverse_flattening: f64 = ellipsoid_args.get(2)?.leaf_str()?.trim().parse().ok()?;
+ if !semi_major.is_finite() || semi_major <= 0.0 {
+ return None;
+ }
+
+ let spherical_radius_m = if inverse_flattening == 0.0 {
+ semi_major
+ } else {
+ let semi_minor = semi_major * (1.0 - 1.0 / inverse_flattening);
+ (2.0 * semi_major + semi_minor) / 3.0
+ };
+ Some(GeographicCrsParams { spherical_radius_m })
+}
+
+/// A CRS given as a WKT1/WKT2 string.
+///
+/// The WKT is preserved verbatim (`to_crs_string`) so it can be handed to PROJ
+/// for transforms, while an `authority:code` is extracted once at construction
+/// for equality and SRID. Geographic parameters resolve from that authority
+/// when it's known, otherwise straight from the WKT's own ellipsoid.
+#[derive(Debug)]
+struct WktCrs {
+ wkt: String,
+ authority_code: Option<String>,
+}
+
+impl WktCrs {
+ fn new(wkt: String) -> Self {
+ let authority_code = extract_authority_from_wkt(&wkt);
+ Self {
+ wkt,
+ authority_code,
+ }
+ }
+}
+
+impl CoordinateReferenceSystem for WktCrs {
+ /// Emit the WKT verbatim (as a JSON string), preserving the definition the
+ /// same way [`ProjJSON::to_json`] does — a WKT carrying an embedded
+ /// authority is **not** collapsed to that code, so the full definition
+ /// survives a metadata round-trip and `ST_Crs` reads it back unchanged.
+ /// (The GeoParquet writer resolves it to PROJJSON, or to the lon/lat
+ /// default, via the CRS provider, like any other definition.)
+ fn to_json(&self) -> String {
+ Value::String(self.wkt.clone()).to_string()
+ }
+
+ fn to_authority_code(&self) -> Result<Option<String>> {
+ Ok(self.authority_code.clone())
+ }
+
+ fn crs_equals(&self, other: &dyn CoordinateReferenceSystem) -> bool {
+ if let (Ok(Some(a)), Ok(Some(b))) = (self.to_authority_code(), other.to_authority_code()) {
+ return authority_codes_equal(&a, &b);
+ }
+ // No authority on one side: fall back to comparing raw definitions.
+ self.to_crs_string() == other.to_crs_string()
+ }
+
+ // `srid()` uses the trait default (derived from `to_authority_code`).
+
+ /// The verbatim WKT — the form PROJ/GDAL consume directly.
+ fn to_crs_string(&self) -> String {
+ self.wkt.clone()
+ }
+
+ fn geographic_params(&self) -> Result<Option<GeographicCrsParams>> {
+ // Prefer the curated authority lookup (stable, version-independent
+ // radii); otherwise derive the ellipsoid straight from the WKT so
+ // authority-less geographic CRSes still work.
+ Ok(self
+ .authority_code
+ .as_deref()
+ .and_then(geographic_params_for_authority)
+ .or_else(|| geographic_params_from_wkt(&self.wkt)))
+ }
+}
+
pub const OGC_CRS84_PROJJSON: &str = r#"{"$schema":"https://proj.org/schemas/v0.7/projjson.schema.json","type":"GeographicCRS","name":"WGS 84 (CRS84)","datum_ensemble":{"name":"World Geodetic System 1984 ensemble","members":[{"name":"World Geodetic System 1984 (Transit)","id":{"authority":"EPSG","code":1166}},{"name":"World Geodetic System 1984 (G730)","id":{"authority":"EPSG","code":1152}},{"name":"World Geodetic System 1984 (G873)","id":{"authority":"EPSG","code":1153}},{"name":"World [...]
#[cfg(test)]
@@ -991,3 +1318,230 @@ mod test {
assert_eq!(crs3857.to_json(), r#""EPSG:3857""#);
}
}
+
+#[cfg(test)]
+mod wkt_tests {
+ use super::*;
+
+ // CRS-level AUTHORITY is `EPSG:3857`; inner datum/ellipsoid tags
+ // (7030/6326/4326) must not win.
+ const WKT1_PSEUDO_MERCATOR: &str = r#"PROJCS["WGS 84 / Pseudo-Mercator",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],AUTHORITY["EPSG","4326"]],PROJECTION["Mercator_1SP"],AUTHORITY["EPSG","3857"]]"#;
+ // WKT2 with a bare (unquoted) CRS-level code; the nested ELLIPSOID must not
+ // be mistaken for the CRS identifier.
+ const WKT2_WGS84: &str = r#"GEOGCRS["WGS 84",DATUM["World Geodetic System 1984",ELLIPSOID["WGS 84",6378137,298.257223563,LENGTHUNIT["metre",1]]],CS[ellipsoidal,2],ID["EPSG",4326]]"#;
+ const WKT_NO_AUTHORITY: &str = r#"PROJCS["Custom",GEOGCS["Custom datum",DATUM["D",SPHEROID["S",6378137,298.257223563]]],PROJECTION["Mercator_1SP"]]"#;
+
+ // A custom projected CRS with no CRS-level authority whose inner GEOGCS *does*
+ // carry one (EPSG:4326). The projected CRS is not EPSG:4326 — the base
+ // geographic CRS is — so no authority should be extracted.
+ const WKT1_CUSTOM_LCC_INNER_AUTHORITY: &str = r#"PROJCS["Custom LCC",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563]],AUTHORITY["EPSG","4326"]],PROJECTION["Lambert_Conformal_Conic_2SP"],PARAMETER["standard_parallel_1",33],PARAMETER["standard_parallel_2",45],UNIT["metre",1]]"#;
+
+ // A native HRRR Lambert Conformal CRS that cannot be described by a single
+ // EPSG code. Its only `ID`s are nested unit/operation codes — `EPSG:9001`
+ // (metre), `EPSG:8901` (Greenwich), the `EPSG:9801` projection method, and
+ // its `EPSG:8801..8807` parameters — and the last of them (`EPSG:9001`) is
+ // exactly what a naive "last tag" scan would wrongly return as the CRS.
+ const WKT2_HRRR_LCC: &str = r#"PROJCRS["HRRR Lambert Conformal",BASEGEOGCRS["unknown",DATUM["unknown",ELLIPSOID["unknown",6371229,0,LENGTHUNIT["metre",1,ID["EPSG",9001]]]],PRIMEM["Greenwich",0,ANGLEUNIT["degree",0.0174532925199433],ID["EPSG",8901]]],CONVERSION["unknown",METHOD["Lambert Conic Conformal (1SP)",ID["EPSG",9801]],PARAMETER["Latitude of natural origin",38.5,ANGLEUNIT["degree",0.0174532925199433],ID["EPSG",8801]],PARAMETER["Longitude of natural origin",-97.5,ANGLEUNIT["degr [...]
+
+ #[test]
+ fn detects_wkt_strings() {
+ assert!(looks_like_wkt(WKT1_PSEUDO_MERCATOR));
+ assert!(looks_like_wkt(WKT2_WGS84));
+ assert!(looks_like_wkt(" geogcs[\"x\"]")); // leading ws + lowercase
+ assert!(!looks_like_wkt("EPSG:4326"));
+ assert!(!looks_like_wkt(r#"{"type":"GeographicCRS"}"#));
+ assert!(!looks_like_wkt(r#"PROJECTILE["not a crs"]"#));
+ }
+
+ #[test]
+ fn extracts_crs_level_authority_not_inner_tags() {
+ assert_eq!(
+ extract_authority_from_wkt(WKT1_PSEUDO_MERCATOR).as_deref(),
+ Some("EPSG:3857")
+ );
+ }
+
+ #[test]
+ fn extracts_wkt2_bare_code_and_ignores_ellipsoid() {
+ assert_eq!(
+ extract_authority_from_wkt(WKT2_WGS84).as_deref(),
+ Some("EPSG:4326")
+ );
+ }
+
+ #[test]
+ fn wkt_without_authority_has_no_code() {
+ assert_eq!(extract_authority_from_wkt(WKT_NO_AUTHORITY), None);
+ }
+
+ #[test]
+ fn inner_authority_does_not_win_without_root_authority() {
+ // The inner GEOGCS EPSG:4326 belongs to the base CRS, not the projected
+ // CRS, so the custom LCC has no authority of its own.
+ assert_eq!(
+ extract_authority_from_wkt(WKT1_CUSTOM_LCC_INNER_AUTHORITY),
+ None
+ );
+ // The HRRR LCC's only codes are nested unit/operation IDs; none is the
+ // CRS, so a naive scan returning EPSG:9001 (metre) would be wrong.
+ assert_eq!(extract_authority_from_wkt(WKT2_HRRR_LCC), None);
+ }
+
+ #[test]
+ fn quoted_name_with_structural_chars_is_not_parsed_as_structure() {
+ // The name carries `[`, `]`, `,`, a doubled-quote escape, and the literal
+ // text `ID` — none of which is structure, since it is all inside a quoted
+ // string. The real CRS-level authority must still win.
+ let wkt = r#"PROJCS["Bob""s [ID] zone, N",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563]],AUTHORITY["EPSG","4326"]],PROJECTION["Transverse_Mercator"],UNIT["metre",1],AUTHORITY["EPSG","32633"]]"#;
+ assert_eq!(
+ extract_authority_from_wkt(wkt).as_deref(),
+ Some("EPSG:32633")
+ );
+ }
+
+ #[test]
+ fn unit_nested_authority_does_not_win() {
+ // WKT1 analog of the HRRR trap: the only authority is the metre UNIT's
+ // EPSG:9001, nested below the root, so no CRS authority is extracted.
+ let wkt = r#"PROJCS["Custom TM",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563]]],PROJECTION["Transverse_Mercator"],UNIT["metre",1,AUTHORITY["EPSG","9001"]]]"#;
+ assert_eq!(extract_authority_from_wkt(wkt), None);
+ }
+
+ #[test]
+ fn parser_handles_whitespace_and_lowercase_keywords() {
+ // Pretty-printed across lines, indented, and lowercase keywords.
+ let wkt = "geogcrs[\"WGS 84\",\n datum[\"World Geodetic System 1984\",\n ellipsoid[\"WGS 84\",6378137,298.257223563]],\n cs[ellipsoidal,2],\n id[\"epsg\",4326]]";
+ assert_eq!(
+ extract_authority_from_wkt(wkt).as_deref(),
+ Some("EPSG:4326")
+ );
+ }
+
+ #[test]
+ fn malformed_wkt_returns_none_without_panicking() {
+ for input in [
+ "",
+ "EPSG:4326",
+ "PROJCS", // keyword, no bracket
+ "PROJCS[", // unterminated node
+ "GEOGCRS[\"x\"", // unterminated, dangling child
+ "GEOGCRS[ID[", // unterminated nested node
+ "PROJCS[\"unterminated name",
+ ] {
+ assert_eq!(extract_authority_from_wkt(input), None, "input: {input:?}");
+ }
+ }
+
+ #[test]
+ fn deeply_nested_wkt_is_bounded() {
+ // Pathological nesting must not overflow the stack; extraction bails out
+ // (and logs a warning) well before that, returning None.
+ let deep = format!("GEOGCRS{}", "[A".repeat(WKT_MAX_DEPTH + 500));
+ assert_eq!(extract_authority_from_wkt(&deep), None);
+ }
+
+ #[test]
+ fn wkt2_id_with_trailing_version_is_extracted() {
+ // A third `ID` argument (version) must not prevent extraction.
+ let wkt = r#"GEOGCRS["WGS 84",DATUM["World Geodetic System 1984",ELLIPSOID["WGS 84",6378137,298.257223563]],CS[ellipsoidal,2],ID["EPSG",4326,"2024"]]"#;
+ assert_eq!(
+ extract_authority_from_wkt(wkt).as_deref(),
+ Some("EPSG:4326")
+ );
+ }
+
+ #[test]
+ fn custom_lcc_crs_has_no_srid_and_carries_definition_through() {
+ // A custom Lambert Conformal CRS that cannot be represented as an EPSG
+ // code must not be misidentified: no authority, no SRID, no spurious
+ // equality with the nested unit code, and the WKT survives verbatim.
+ let crs = deserialize_crs(WKT2_HRRR_LCC).unwrap().unwrap();
+ assert_eq!(crs.to_authority_code().unwrap(), None);
+ assert_eq!(crs.srid().unwrap(), None);
+ assert_eq!(crs.to_crs_string(), WKT2_HRRR_LCC);
+ let metre_unit = deserialize_crs("EPSG:9001").unwrap().unwrap();
+ assert!(!crs.crs_equals(metre_unit.as_ref()));
+ }
+
+ #[test]
+ fn deserialize_routes_wkt_to_authority_and_preserves_definition() {
+ let crs = deserialize_crs(WKT1_PSEUDO_MERCATOR).unwrap().unwrap();
+ assert_eq!(
+ crs.to_authority_code().unwrap().as_deref(),
+ Some("EPSG:3857")
+ );
+ assert_eq!(crs.srid().unwrap(), Some(3857));
+ // PROJ/GDAL get the verbatim WKT, not the authority code.
+ assert_eq!(crs.to_crs_string(), WKT1_PSEUDO_MERCATOR);
+ }
+
+ #[test]
+ fn wkt_without_authority_carries_definition_through() {
+ let crs = deserialize_crs(WKT_NO_AUTHORITY).unwrap().unwrap();
+ assert_eq!(crs.to_authority_code().unwrap(), None);
+ assert_eq!(crs.srid().unwrap(), None);
+ assert_eq!(crs.to_crs_string(), WKT_NO_AUTHORITY);
+ }
+
+ #[test]
+ fn wkt_equals_matching_authority_code() {
+ let wkt_crs = deserialize_crs(WKT1_PSEUDO_MERCATOR).unwrap().unwrap();
+ let auth_crs = deserialize_crs("EPSG:3857").unwrap().unwrap();
+ assert!(wkt_crs.crs_equals(auth_crs.as_ref()));
+ assert!(auth_crs.crs_equals(wkt_crs.as_ref()));
+ }
+
+ #[test]
+ fn wkt_geographic_params_resolve_via_authority() {
+ let geographic = deserialize_crs(WKT2_WGS84).unwrap().unwrap();
+ assert!(geographic.geographic_params().unwrap().is_some()); // EPSG:4326
+ let projected = deserialize_crs(WKT1_PSEUDO_MERCATOR).unwrap().unwrap();
+ assert!(projected.geographic_params().unwrap().is_none()); // EPSG:3857
+ }
+
+ #[test]
+ fn wkt_geographic_params_parsed_from_ellipsoid_when_authority_unknown() {
+ // An authority-less geographic WKT: params are read from its ellipsoid
+ // (GRS 1980, a=6378137, 1/f=298.257222101) and reduced to a mean radius.
+ let wkt = r#"GEOGCRS["unknown",DATUM["unknown",ELLIPSOID["GRS 1980",6378137,298.257222101,LENGTHUNIT["metre",1]]],CS[ellipsoidal,2],AXIS["lat",north],AXIS["lon",east]]"#;
+ let crs = deserialize_crs(wkt).unwrap().unwrap();
+ let params = crs.geographic_params().unwrap().expect("geographic params");
+ let a = 6378137.0_f64;
+ let b = a * (1.0 - 1.0 / 298.257222101);
+ assert!((params.spherical_radius() - (2.0 * a + b) / 3.0).abs() < 1e-6);
+ }
+
+ #[test]
+ fn wkt_sphere_radius_parsed_from_ellipsoid() {
+ // A spherical ellipsoid (inverse flattening 0) uses its radius directly.
+ let wkt = r#"GEOGCRS["sphere",DATUM["d",ELLIPSOID["sphere",6371229,0,LENGTHUNIT["metre",1]]],CS[ellipsoidal,2]]"#;
+ let crs = deserialize_crs(wkt).unwrap().unwrap();
+ let params = crs.geographic_params().unwrap().expect("geographic params");
+ assert_eq!(params.spherical_radius(), 6371229.0);
+ }
+
+ #[test]
+ fn projected_wkt_has_no_geographic_params() {
+ // A projected CRS is never a geography, even though it nests a GEOGCS
+ // with a SPHEROID — the parse fallback must reject it.
+ let crs = deserialize_crs(WKT_NO_AUTHORITY).unwrap().unwrap();
+ assert!(crs.geographic_params().unwrap().is_none());
+ }
+
+ #[test]
+ fn wkt_to_json_preserves_definition() {
+ // to_json emits the WKT verbatim (as a quoted JSON string), like
+ // ProjJSON — a WKT carrying an embedded authority is NOT collapsed to
+ // that code, so the full definition survives a metadata round-trip.
+ let crs = deserialize_crs(WKT1_PSEUDO_MERCATOR).unwrap().unwrap();
+ assert_eq!(
+ crs.to_json(),
+ Value::String(WKT1_PSEUDO_MERCATOR.to_string()).to_string()
+ );
+ let bare = deserialize_crs(WKT_NO_AUTHORITY).unwrap().unwrap();
+ assert_eq!(
+ bare.to_json(),
+ Value::String(WKT_NO_AUTHORITY.to_string()).to_string()
+ );
+ }
+}