//! A WKT 1 (OGC and ESRI) reader that turns a CRS definition into a PROJ string, as GDAL does,
//! with its linear and angular units.
/// A WKT node: `KEYWORD[arg, arg, ...]`.
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Arg {
Str(String),
Num(f64),
Ident(String),
Node(Node),
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Node {
pub key: String,
pub args: Vec<Arg>,
}
impl Node {
/// The first child node with a keyword.
pub fn child(&self, key: &str) -> Option<&Node> {
self.args.iter().find_map(|a| match a {
Arg::Node(n) if n.key.eq_ignore_ascii_case(key) => Some(n),
_ => None,
})
}
/// The child nodes with a keyword.
pub fn children<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'a Node> + 'a {
self.args.iter().filter_map(move |a| match a {
Arg::Node(n) if n.key.eq_ignore_ascii_case(key) => Some(n),
_ => None,
})
}
/// The node's name (its first argument, when a string).
pub fn name(&self) -> Option<&str> {
match self.args.first() {
Some(Arg::Str(s)) => Some(s),
_ => None,
}
}
/// The `i`th argument as a number.
pub fn num(&self, i: usize) -> Option<f64> {
match self.args.get(i) {
Some(Arg::Num(v)) => Some(*v),
Some(Arg::Str(s)) => s.trim().parse().ok(),
_ => None,
}
}
/// The EPSG code of the node's `AUTHORITY` or `ID`.
pub fn epsg(&self) -> Option<u32> {
let n = self.child("AUTHORITY").or_else(|| self.child("ID"))?;
if !n.name()?.eq_ignore_ascii_case("EPSG") {
return None;
}
match n.args.get(1)? {
Arg::Num(v) => Some(*v as u32),
Arg::Str(s) => s.trim().parse().ok(),
_ => None,
}
}
}
/// Parses WKT into its tree.
pub(crate) fn parse(s: &str) -> Option<Node> {
let b = s.as_bytes();
let mut i = 0;
let n = node(b, &mut i)?;
Some(n)
}
fn skip_ws(b: &[u8], i: &mut usize) {
while *i < b.len() && b[*i].is_ascii_whitespace() {
*i += 1;
}
}
fn node(b: &[u8], i: &mut usize) -> Option<Node> {
skip_ws(b, i);
let start = *i;
while *i < b.len() && (b[*i].is_ascii_alphanumeric() || b[*i] == b'_') {
*i += 1;
}
let key = std::str::from_utf8(&b[start..*i]).ok()?.to_string();
skip_ws(b, i);
if *i >= b.len() || !(b[*i] == b'[' || b[*i] == b'(') {
return None;
}
let close = if b[*i] == b'[' { b']' } else { b')' };
*i += 1;
let mut args = Vec::new();
loop {
skip_ws(b, i);
if *i >= b.len() {
return None;
}
if b[*i] == close {
*i += 1;
break;
}
if b[*i] == b',' {
*i += 1;
continue;
}
if b[*i] == b'"' {
*i += 1;
let mut s = Vec::new();
while *i < b.len() {
if b[*i] == b'"' {
if *i + 1 < b.len() && b[*i + 1] == b'"' {
s.push(b'"');
*i += 2;
continue;
}
break;
}
s.push(b[*i]);
*i += 1;
}
*i += 1;
args.push(Arg::Str(String::from_utf8_lossy(&s).into_owned()));
continue;
}
// A number, an identifier or a nested node.
let start = *i;
while *i < b.len() && !matches!(b[*i], b',' | b'[' | b'(' | b']' | b')') {
*i += 1;
}
let tok = std::str::from_utf8(&b[start..*i]).ok()?.trim();
if *i < b.len() && (b[*i] == b'[' || b[*i] == b'(') {
*i = start;
args.push(Arg::Node(node(b, i)?));
} else if let Ok(v) = tok.parse::<f64>() {
args.push(Arg::Num(v));
} else {
args.push(Arg::Ident(tok.to_string()));
}
}
Some(Node { key, args })
}
fn norm(s: &str) -> String {
s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase()
}
/// A WKT 1 CRS as a PROJ string, with the code of its geographic CRS when it declares one.
pub(crate) struct Converted {
pub proj: String,
/// The `GEOGCS` node, for identifying the datum.
pub geogcs: Node,
}
/// Converts a WKT 1 `GEOGCS` or `PROJCS` (ESRI or OGC flavour) into a PROJ string. `None` for
/// other kinds of CRS and projections terrio does not know.
pub(crate) fn to_proj(root: &Node) -> Option<Converted> {
let key = root.key.to_ascii_uppercase();
let geogcs = match key.as_str() {
"GEOGCS" => root,
"PROJCS" => root.child("GEOGCS")?,
_ => return None,
};
let datum = geogcs.child("DATUM")?;
let sph = datum.child("SPHEROID").or_else(|| datum.child("ELLIPSOID"))?;
let a = sph.num(1)?;
let rf = sph.num(2)?;
// Angular unit of the geographic CRS, in degrees.
let ang = geogcs.child("UNIT").and_then(|u| u.num(1)).map(|r| r.to_degrees()).unwrap_or(1.0);
// WKT rounds the size of a degree (0.0174532925199433) and of a grad.
let ang = [1.0, 0.9].into_iter().find(|u| (ang - u).abs() < 1e-9).unwrap_or(ang);
let pm = geogcs.child("PRIMEM").and_then(|p| p.num(1)).unwrap_or(0.0) * ang;
let mut out = Vec::new();
let mut tail = vec![format!("+a={a}"), format!("+rf={rf}")];
if rf == 0.0 {
tail[1] = format!("+b={a}");
}
if pm != 0.0 {
tail.push(format!("+pm={pm}"));
}
if let Some(t) = datum.child("TOWGS84") {
let v: Vec<String> = (0..t.args.len()).filter_map(|i| t.num(i)).map(|v| v.to_string()).collect();
if !v.is_empty() {
tail.push(format!("+towgs84={}", v.join(",")));
}
}
if key == "GEOGCS" {
out.push("+proj=longlat".to_string());
if (ang - 1.0).abs() > 1e-12 {
tail.push(format!("+to_deg={ang}"));
}
out.extend(tail);
return Some(Converted { proj: out.join(" "), geogcs: geogcs.clone() });
}
let to_meter = root.children("UNIT").last().and_then(|u| u.num(1)).unwrap_or(1.0);
let params: Vec<(String, f64)> = root.children("PARAMETER").filter_map(|p| Some((norm(p.name()?), p.num(1)?))).collect();
let get = |names: &[&str]| names.iter().find_map(|n| params.iter().find(|(k, _)| k == n).map(|(_, v)| *v));
let angle = |names: &[&str]| get(names).map(|v| v * ang);
let lon_0 = angle(&["centralmeridian", "longitudeofcenter", "longitudeoforigin", "longitudeofnaturalorigin", "longitudeofcentre"]);
let lat_0 = angle(&["latitudeoforigin", "latitudeofcenter", "latitudeofnaturalorigin", "latitudeofcentre"]);
let k = get(&["scalefactor", "scalefactoratnaturalorigin", "scalefactoroninitialline"]);
let sp1 = angle(&["standardparallel1", "standardparallel", "latitudeof1ststandardparallel"]);
let sp2 = angle(&["standardparallel2", "latitudeof2ndstandardparallel"]);
let fe = get(&["falseeasting", "eastingatfalseorigin", "eastingatprojectioncentre"]).unwrap_or(0.0) * to_meter;
let fn_ = get(&["falsenorthing", "northingatfalseorigin", "northingatprojectioncentre"]).unwrap_or(0.0) * to_meter;
let azimuth = angle(&["azimuth", "azimuthofinitialline"]);
let gamma = angle(&["rectifiedgridangle", "angleofrectifiedtoskewedgrid"]);
let method = norm(root.child("PROJECTION")?.name()?);
let push = |out: &mut Vec<String>, k: &str, v: Option<f64>| {
if let Some(v) = v {
out.push(format!("+{k}={v}"));
}
};
match method.as_str() {
"transversemercator" | "gausskruger" | "transversemercatorcomplex" => {
out.push("+proj=tmerc".into());
push(&mut out, "lat_0", lat_0.or(Some(0.0)));
push(&mut out, "lon_0", lon_0);
push(&mut out, "k", k.or(Some(1.0)));
}
"transversemercatorsouthorientated" => {
out.push("+proj=tmerc +axis=wsu".into());
push(&mut out, "lat_0", lat_0.or(Some(0.0)));
push(&mut out, "lon_0", lon_0);
push(&mut out, "k", k.or(Some(1.0)));
}
"lambertconformalconic1sp" => {
out.push("+proj=lcc".into());
push(&mut out, "lat_1", lat_0);
push(&mut out, "lat_0", lat_0);
push(&mut out, "lon_0", lon_0);
push(&mut out, "k_0", k.or(Some(1.0)));
}
"lambertconformalconic2sp" | "lambertconformalconic" | "lambertconformalconic2spbelgium" => {
out.push("+proj=lcc".into());
match (sp1, sp2) {
(Some(s1), Some(s2)) => {
push(&mut out, "lat_1", Some(s1));
push(&mut out, "lat_2", Some(s2));
}
// ESRI's single-parallel form.
(Some(s1), None) => push(&mut out, "lat_1", Some(s1)),
_ => push(&mut out, "lat_1", lat_0),
}
push(&mut out, "lat_0", lat_0);
push(&mut out, "lon_0", lon_0);
push(&mut out, "k_0", k);
}
"albersconicequalarea" | "albers" | "albersequalarea" => {
out.push("+proj=aea".into());
push(&mut out, "lat_0", lat_0);
push(&mut out, "lon_0", lon_0);
push(&mut out, "lat_1", sp1);
push(&mut out, "lat_2", sp2);
}
"mercator1sp" => {
out.push("+proj=merc".into());
push(&mut out, "lon_0", lon_0);
push(&mut out, "k", k.or(Some(1.0)));
}
"mercator2sp" | "mercator" => {
out.push("+proj=merc".into());
push(&mut out, "lon_0", lon_0);
if sp1.is_some() {
push(&mut out, "lat_ts", sp1);
} else {
push(&mut out, "k", k.or(Some(1.0)));
}
}
"mercatorauxiliarysphere" | "popularvisualisationpseudomercator" => {
out.push("+proj=merc".into());
push(&mut out, "lon_0", lon_0);
// Spherical formulas on the semi-major axis.
tail[1] = format!("+b={a}");
}
"polarstereographic" | "polarstereographicvarianta" | "polarstereographicvariantb" => {
out.push("+proj=stere".into());
let lat = lat_0.or(sp1).unwrap_or(90.0);
push(&mut out, "lat_0", Some(if lat < 0.0 { -90.0 } else { 90.0 }));
if (lat.abs() - 90.0).abs() > 1e-9 {
push(&mut out, "lat_ts", Some(lat));
}
push(&mut out, "lon_0", lon_0);
push(&mut out, "k", k.or(Some(1.0)));
}
"stereographicnorthpole" | "stereographicsouthpole" => {
out.push("+proj=stere".into());
push(&mut out, "lat_0", Some(if method.ends_with("southpole") { -90.0 } else { 90.0 }));
push(&mut out, "lat_ts", sp1);
push(&mut out, "lon_0", lon_0);
push(&mut out, "k", k.or(Some(1.0)));
}
"obliquestereographic" | "doublestereographic" => {
out.push("+proj=sterea".into());
push(&mut out, "lat_0", lat_0);
push(&mut out, "lon_0", lon_0);
push(&mut out, "k", k.or(Some(1.0)));
}
"stereographic" => {
out.push("+proj=stere".into());
push(&mut out, "lat_0", lat_0);
push(&mut out, "lon_0", lon_0);
push(&mut out, "k", k.or(Some(1.0)));
}
"lambertazimuthalequalarea" => {
out.push("+proj=laea".into());
push(&mut out, "lat_0", lat_0);
push(&mut out, "lon_0", lon_0);
}
"hotineobliquemercator" | "hotineobliquemercatorazimuthnaturalorigin" | "hotineobliquemercatorvarianta" => {
out.push("+proj=omerc +no_uoff".into());
push(&mut out, "lat_0", lat_0);
push(&mut out, "lonc", lon_0);
push(&mut out, "alpha", azimuth);
push(&mut out, "gamma", gamma.or(azimuth));
push(&mut out, "k", k.or(Some(1.0)));
}
"hotineobliquemercatorazimuthcenter" | "obliquemercator" | "hotineobliquemercatorvariantb" => {
out.push("+proj=omerc".into());
push(&mut out, "lat_0", lat_0);
push(&mut out, "lonc", lon_0);
push(&mut out, "alpha", azimuth);
push(&mut out, "gamma", gamma.or(azimuth));
push(&mut out, "k", k.or(Some(1.0)));
}
"swissobliquecylindrical" | "swissobliquemercator" | "hotineobliquemercatorazimuthcenterswiss" => {
out.push("+proj=somerc".into());
push(&mut out, "lat_0", lat_0);
push(&mut out, "lon_0", lon_0);
push(&mut out, "k_0", k.or(Some(1.0)));
}
"krovak" => {
out.push("+proj=krovak".into());
push(&mut out, "lat_0", lat_0);
push(&mut out, "lon_0", lon_0);
push(&mut out, "alpha", azimuth);
push(&mut out, "k", k.or(Some(0.9999)));
}
"cassinisoldner" | "cassini" => {
out.push("+proj=cass".into());
push(&mut out, "lat_0", lat_0);
push(&mut out, "lon_0", lon_0);
}
"polyconic" | "americanpolyconic" => {
out.push("+proj=poly".into());
push(&mut out, "lat_0", lat_0);
push(&mut out, "lon_0", lon_0);
}
"equirectangular" | "equidistantcylindrical" | "platecarree" => {
out.push("+proj=eqc".into());
push(&mut out, "lat_ts", sp1);
push(&mut out, "lat_0", lat_0);
push(&mut out, "lon_0", lon_0);
}
"cylindricalequalarea" | "lambertcylindricalequalarea" => {
out.push("+proj=cea".into());
push(&mut out, "lat_ts", sp1);
push(&mut out, "lon_0", lon_0);
}
"azimuthalequidistant" | "modifiedazimuthalequidistant" => {
out.push("+proj=aeqd".into());
push(&mut out, "lat_0", lat_0);
push(&mut out, "lon_0", lon_0);
}
"newzealandmapgrid" => out.push("+proj=nzmg".into()),
"equalearth" => {
out.push("+proj=eqearth".into());
push(&mut out, "lon_0", lon_0);
}
"orthographic" => {
out.push("+proj=ortho".into());
push(&mut out, "lat_0", lat_0);
push(&mut out, "lon_0", lon_0);
}
_ => return None,
}
out.push(format!("+x_0={fe}"));
out.push(format!("+y_0={fn_}"));
out.push(format!("+to_meter={to_meter}"));
out.extend(tail);
Some(Converted { proj: out.join(" "), geogcs: geogcs.clone() })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn converts_esri_feet() {
let esri = r#"PROJCS["NAD_1983_StatePlane_New_York_Long_Island_FIPS_3104_Feet",GEOGCS["GCS_North_American_1983",DATUM["D_North_American_1983",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Lambert_Conformal_Conic"],PARAMETER["False_Easting",984250.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",-74.0],PARAMETER["Standard_Parallel_1",40.66666666666666],PARAMETER["Standard_Parallel_2",41.03333333333333],PARAMETER["Latitude_Of_Origin",40.16666666666666],UNIT["Foot_US",0.3048006096012192]]"#;
let c = to_proj(&parse(esri).unwrap()).unwrap();
assert!(c.proj.starts_with("+proj=lcc +lat_1=40.66666666666666 +lat_2=41.03333333333333"), "{}", c.proj);
assert!(c.proj.contains("+to_meter=0.3048006096012192"), "{}", c.proj);
assert_eq!(c.geogcs.name(), Some("GCS_North_American_1983"));
}
}