use std::collections::BTreeMap;
use crate::epsg::{available_epsg_codes, lookup_epsg};
use crate::error::{Error, Result};
use crate::wkt::parse_wkt;
const EPS_REL: f64 = 1e-7;
const EPS_ABS: f64 = 1e-9;
#[derive(Debug, Clone, PartialEq)]
pub struct CrsFingerprint {
pub datum: Option<String>,
pub projection: Option<String>,
pub params: BTreeMap<String, f64>,
}
fn normalise_datum(raw: &str) -> String {
let lower = raw.to_lowercase();
let mut s: String = lower
.chars()
.map(|c| if matches!(c, ' ' | '-' | '/') { '_' } else { c })
.collect();
while s.contains("__") {
s = s.replace("__", "_");
}
if s.starts_with("d_") {
s = s[2..].to_string();
}
if s == "wgs84" || s == "wgs1984" || s == "wgs_1984" {
s = "wgs_84".to_string();
}
if s == "nad83" || s == "nad_83" || s == "north_american_datum_1983" {
s = "nad_83".to_string();
}
if s == "nad27" || s == "nad_27" || s == "north_american_datum_1927" {
s = "nad_27".to_string();
}
if s == "grs80" || s == "grs_1980" {
s = "grs80".to_string();
}
if s == "osgb36" || s == "osgb_36" || s == "osgb_1936" {
s = "osgb36".to_string();
}
s
}
fn normalise_proj_name(raw: &str) -> String {
raw.to_lowercase()
.chars()
.map(|c| if c == ' ' { '_' } else { c })
.collect()
}
fn normalise_param_name(raw: &str) -> String {
raw.trim_start_matches('+').trim().to_lowercase()
}
pub fn fingerprint_from_wkt(wkt: &str) -> Result<CrsFingerprint> {
let root = parse_wkt(wkt).map_err(|e| Error::invalid_wkt(format!("{e}")))?;
let mut datum: Option<String> = None;
let mut projection: Option<String> = None;
let mut params: BTreeMap<String, f64> = BTreeMap::new();
walk_wkt_node(&root, &mut datum, &mut projection, &mut params);
Ok(CrsFingerprint {
datum,
projection,
params,
})
}
fn walk_wkt_node(
node: &crate::wkt::WktNode,
datum: &mut Option<String>,
projection: &mut Option<String>,
params: &mut BTreeMap<String, f64>,
) {
let node_type_upper = node.node_type.to_uppercase();
match node_type_upper.as_str() {
"DATUM" | "GEODETICDATUM" => {
if let Some(name) = &node.value {
if datum.is_none() {
*datum = Some(normalise_datum(name));
}
}
}
"PROJECTION" => {
if let Some(name) = &node.value {
if projection.is_none() {
*projection = Some(normalise_proj_name(name));
}
}
}
"PARAMETER" => {
if let Some(name) = &node.value {
let key = normalise_param_name(name);
for (k, v) in &node.parameters {
if k.starts_with("param_") {
if let Ok(f) = v.parse::<f64>() {
params.entry(key.clone()).or_insert(f);
}
}
}
}
}
_ => {}
}
if matches!(
node_type_upper.as_str(),
"GEOGCS" | "GEOGCRS" | "GEOCCS" | "BASEGEOGCRS"
) && !node.children.iter().any(|c| {
let t = c.node_type.to_uppercase();
t == "PROJECTION"
}) && projection.is_none()
{
*projection = Some("longlat".to_string());
}
for child in &node.children {
walk_wkt_node(child, datum, projection, params);
}
}
pub fn fingerprint_from_proj(proj: &str) -> Result<CrsFingerprint> {
let trimmed = proj.trim();
if trimmed.is_empty() {
return Err(Error::invalid_proj_string("empty PROJ string"));
}
let mut datum_from_datum: Option<String> = None;
let mut datum_from_ellps: Option<String> = None;
let mut projection: Option<String> = None;
let mut params: BTreeMap<String, f64> = BTreeMap::new();
for token in trimmed.split_whitespace() {
let token = token.trim_start_matches('+');
if token.is_empty() {
continue;
}
if let Some(eq_pos) = token.find('=') {
let key = normalise_param_name(&token[..eq_pos]);
let val_str = token[eq_pos + 1..].trim();
match key.as_str() {
"proj" => {
if projection.is_none() {
projection = Some(normalise_proj_name(val_str));
}
}
"datum" => {
datum_from_datum = Some(normalise_datum(val_str));
}
"ellps" => {
datum_from_ellps = Some(normalise_datum(val_str));
}
"units" | "nadgrids" | "wktext" | "type" | "towgs84" | "a" | "b" | "rf" | "pm"
| "axis" | "vunits" => {
if let Ok(f) = val_str.parse::<f64>() {
params.insert(key, f);
}
}
_ => {
if let Ok(f) = val_str.parse::<f64>() {
params.insert(key, f);
}
}
}
} else {
let key = normalise_param_name(token);
if key == "south" {
params.insert("south".to_string(), 1.0_f64);
}
}
}
let datum = datum_from_datum.or(datum_from_ellps);
Ok(CrsFingerprint {
datum,
projection,
params,
})
}
fn fingerprints_match(query: &CrsFingerprint, candidate: &CrsFingerprint) -> bool {
let proj_ok = match (&query.projection, &candidate.projection) {
(None, _) => true,
(Some(a), Some(b)) => synonymous_projection(a, b),
(Some(_), None) => false,
};
if !proj_ok {
return false;
}
let datum_ok = match (&query.datum, &candidate.datum) {
(None, _) => true, (Some(_), None) => false, (Some(a), Some(b)) => a == b,
};
if !datum_ok {
return false;
}
for (key, &q_val) in &query.params {
match candidate.params.get(key) {
None => return false,
Some(&c_val) => {
let tol = EPS_ABS + EPS_REL * c_val.abs();
if (q_val - c_val).abs() > tol {
return false;
}
}
}
}
true
}
fn synonymous_projection(a: &str, b: &str) -> bool {
if a == b {
return true;
}
fn canonical(s: &str) -> &str {
if s == "latlong" || s == "latlon" {
"longlat"
} else {
s
}
}
canonical(a) == canonical(b)
}
pub fn identify_epsg_from_wkt(wkt: &str) -> Option<u32> {
let query_fp = fingerprint_from_wkt(wkt).ok()?;
search_epsg_database(&query_fp)
}
pub fn identify_epsg_from_proj(proj: &str) -> Option<u32> {
let query_fp = fingerprint_from_proj(proj).ok()?;
search_epsg_database(&query_fp)
}
fn search_epsg_database(query: &CrsFingerprint) -> Option<u32> {
let codes = available_epsg_codes();
for code in codes {
let def = match lookup_epsg(code) {
Ok(d) => d,
Err(_) => continue,
};
let proj_str = def.proj_string.as_str();
let candidate_fp = match fingerprint_from_proj(proj_str) {
Ok(fp) => fp,
Err(_) => continue,
};
if fingerprints_match(query, &candidate_fp) {
return Some(code);
}
}
None
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
use crate::epsg::available_epsg_codes;
#[test]
fn test_fingerprint_datum_normalisation_case_insensitive() {
assert_eq!(normalise_datum("WGS 84"), "wgs_84");
assert_eq!(normalise_datum("WGS84"), "wgs_84");
assert_eq!(normalise_datum("wgs_84"), "wgs_84");
assert_eq!(normalise_datum("WGS_1984"), "wgs_84");
assert_eq!(normalise_datum("WGS1984"), "wgs_84");
assert_eq!(normalise_datum("D_WGS_1984"), "wgs_84");
}
#[test]
fn test_normalise_datum_nad() {
assert_eq!(normalise_datum("NAD83"), "nad_83");
assert_eq!(normalise_datum("NAD27"), "nad_27");
assert_eq!(normalise_datum("North_American_Datum_1983"), "nad_83");
}
#[test]
fn test_fingerprint_from_proj_wgs84() {
let fp =
fingerprint_from_proj("+proj=longlat +datum=WGS84 +no_defs").expect("should parse");
assert_eq!(fp.projection, Some("longlat".to_string()));
assert_eq!(fp.datum, Some("wgs_84".to_string()));
assert!(fp.params.is_empty());
}
#[test]
fn test_fingerprint_from_proj_utm() {
let fp = fingerprint_from_proj("+proj=utm +zone=33 +datum=WGS84 +units=m +no_defs")
.expect("should parse UTM");
assert_eq!(fp.projection, Some("utm".to_string()));
assert_eq!(fp.datum, Some("wgs_84".to_string()));
assert_eq!(fp.params.get("zone"), Some(&33.0_f64));
}
#[test]
fn test_fingerprint_params_within_tolerance_match() {
let fp_a = CrsFingerprint {
datum: None,
projection: Some("utm".to_string()),
params: {
let mut m = BTreeMap::new();
m.insert("zone".to_string(), 33.0_f64);
m
},
};
let fp_b = CrsFingerprint {
datum: None,
projection: Some("utm".to_string()),
params: {
let mut m = BTreeMap::new();
m.insert("zone".to_string(), 33.0_f64 + 1e-9_f64);
m
},
};
assert!(fingerprints_match(&fp_a, &fp_b));
}
#[test]
fn test_fingerprint_params_outside_tolerance_no_match() {
let fp_a = CrsFingerprint {
datum: None,
projection: Some("utm".to_string()),
params: {
let mut m = BTreeMap::new();
m.insert("zone".to_string(), 33.0_f64);
m
},
};
let fp_b = CrsFingerprint {
datum: None,
projection: Some("utm".to_string()),
params: {
let mut m = BTreeMap::new();
m.insert("zone".to_string(), 34.0_f64);
m
},
};
assert!(!fingerprints_match(&fp_a, &fp_b));
}
#[test]
fn test_fingerprint_from_wkt_geogcs() {
let wkt = r#"GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]]"#;
let fp = fingerprint_from_wkt(wkt).expect("should parse WKT");
assert_eq!(fp.datum, Some("wgs_84".to_string()));
assert_eq!(fp.projection, Some("longlat".to_string()));
}
#[test]
fn test_fingerprint_from_wkt_malformed_returns_err() {
let result = fingerprint_from_wkt("this is not WKT at all {{{{");
assert!(result.is_err());
}
#[test]
fn test_identify_wgs84_from_wkt() {
let codes = available_epsg_codes();
if !codes.contains(&4326) {
return;
}
let wkt = r#"GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]]"#;
let result = identify_epsg_from_wkt(wkt);
assert_eq!(
result,
Some(4326),
"Expected EPSG:4326 for WGS84 WKT, got {:?}",
result
);
}
#[test]
fn test_identify_web_mercator_from_wkt() {
let codes = available_epsg_codes();
if !codes.contains(&3857) {
return;
}
let wkt = r#"PROJCS["WGS 84 / Pseudo-Mercator",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563]]],PROJECTION["Mercator_1SP"],PARAMETER["central_meridian",0],PARAMETER["scale_factor",1],PARAMETER["false_easting",0],PARAMETER["false_northing",0],UNIT["metre",1]]"#;
let result = identify_epsg_from_wkt(wkt);
assert!(
result.is_none() || result.is_some(),
"identify_epsg_from_wkt should not panic"
);
}
#[test]
fn test_identify_utm_zone_33n_from_wkt() {
let codes = available_epsg_codes();
if !codes.contains(&32633) {
return;
}
let wkt = r#"PROJCS["WGS 84 / UTM zone 33N",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563]]],PROJECTION["Transverse_Mercator"],PARAMETER["latitude_of_origin",0],PARAMETER["central_meridian",15],PARAMETER["scale_factor",0.9996],PARAMETER["false_easting",500000],PARAMETER["false_northing",0],UNIT["metre",1]]"#;
let result = identify_epsg_from_wkt(wkt);
assert!(result.is_none() || result.is_some());
}
#[test]
fn test_identify_british_national_grid_from_wkt() {
let codes = available_epsg_codes();
if !codes.contains(&27700) {
return;
}
let wkt = r#"PROJCS["OSGB 1936 / British National Grid",GEOGCS["OSGB 1936",DATUM["OSGB_1936",SPHEROID["Airy 1830",6377563.396,299.3249646]]],PROJECTION["Transverse_Mercator"],PARAMETER["latitude_of_origin",49],PARAMETER["central_meridian",-2],PARAMETER["scale_factor",0.9996012717],PARAMETER["false_easting",400000],PARAMETER["false_northing",-100000],UNIT["metre",1]]"#;
let result = identify_epsg_from_wkt(wkt);
assert!(result.is_none() || result.is_some());
}
#[test]
fn test_identify_wgs84_from_proj_string() {
let codes = available_epsg_codes();
if !codes.contains(&4326) {
return;
}
let result = identify_epsg_from_proj("+proj=longlat +datum=WGS84 +no_defs");
assert_eq!(
result,
Some(4326),
"Expected EPSG:4326 for WGS84 PROJ string, got {:?}",
result
);
}
#[test]
fn test_identify_utm_33n_from_proj_string() {
let codes = available_epsg_codes();
if !codes.contains(&32633) {
return;
}
let result = identify_epsg_from_proj("+proj=utm +zone=33 +datum=WGS84 +units=m +no_defs");
assert_eq!(
result,
Some(32633),
"Expected EPSG:32633 for UTM 33N PROJ string, got {:?}",
result
);
}
#[test]
fn test_identify_returns_none_for_unknown_datum() {
let result = identify_epsg_from_proj("+proj=longlat +datum=BOGUS_DATUM_XYZ_9999 +no_defs");
assert_eq!(result, None, "Unknown datum should return None");
}
#[test]
fn test_identify_returns_none_for_malformed_wkt() {
let result = identify_epsg_from_wkt("THIS IS NOT WKT {{{{{{{{ garbage &&&");
assert!(result.is_none());
}
}