use crate::copc_vlr::Vlr;
use crate::error::CopcError;
use crate::extended_vlr::ExtendedVlr;
pub const RECORD_ID_GEO_KEY_DIRECTORY: u16 = 34735;
pub const RECORD_ID_GEO_DOUBLE_PARAMS: u16 = 34736;
pub const RECORD_ID_GEO_ASCII_PARAMS: u16 = 34737;
pub const RECORD_ID_OGC_WKT: u16 = 2112;
pub const USER_ID_LASF_PROJECTION: &str = "LASF_Projection";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GeoKeyEntry {
pub key_id: u16,
pub tiff_tag_location: u16,
pub count: u16,
pub value_offset: u16,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GeoKeyDirectory {
pub key_directory_version: u16,
pub key_revision: u16,
pub minor_revision: u16,
pub keys: Vec<GeoKeyEntry>,
}
#[derive(Debug, Clone, Default)]
pub struct CrsInfo {
pub geotiff_keys: Option<GeoKeyDirectory>,
pub geo_doubles: Vec<f64>,
pub geo_ascii: Option<String>,
pub wkt: Option<String>,
}
impl CrsInfo {
pub fn is_empty(&self) -> bool {
self.geotiff_keys.is_none()
&& self.geo_doubles.is_empty()
&& self.geo_ascii.is_none()
&& self.wkt.is_none()
}
}
fn read_u16_le(data: &[u8], offset: usize) -> Result<u16, CopcError> {
let end = offset
.checked_add(2)
.ok_or_else(|| CopcError::InvalidFormat("GeoKey u16 read offset overflow".into()))?;
if end > data.len() {
return Err(CopcError::InvalidFormat(format!(
"GeoKeyDirectory truncated: need {end} bytes but only {} available",
data.len()
)));
}
Ok(u16::from_le_bytes([data[offset], data[offset + 1]]))
}
pub fn parse_geo_key_directory(data: &[u8]) -> Result<GeoKeyDirectory, CopcError> {
if data.len() < 8 {
return Err(CopcError::InvalidFormat(format!(
"GeoKeyDirectory too short: {} bytes (need >= 8)",
data.len()
)));
}
let key_directory_version = read_u16_le(data, 0)?;
let key_revision = read_u16_le(data, 2)?;
let minor_revision = read_u16_le(data, 4)?;
let number_of_keys = read_u16_le(data, 6)? as usize;
let bytes_needed =
8usize
.checked_add(number_of_keys.checked_mul(8).ok_or_else(|| {
CopcError::InvalidFormat("GeoKeyDirectory key count overflow".into())
})?)
.ok_or_else(|| CopcError::InvalidFormat("GeoKeyDirectory size overflow".into()))?;
if data.len() < bytes_needed {
return Err(CopcError::InvalidFormat(format!(
"GeoKeyDirectory truncated: {number_of_keys} keys need {bytes_needed} bytes \
but only {} available",
data.len()
)));
}
let mut keys = Vec::with_capacity(number_of_keys);
for i in 0..number_of_keys {
let base = 8 + i * 8;
keys.push(GeoKeyEntry {
key_id: read_u16_le(data, base)?,
tiff_tag_location: read_u16_le(data, base + 2)?,
count: read_u16_le(data, base + 4)?,
value_offset: read_u16_le(data, base + 6)?,
});
}
Ok(GeoKeyDirectory {
key_directory_version,
key_revision,
minor_revision,
keys,
})
}
fn parse_geo_doubles(data: &[u8]) -> Vec<f64> {
data.chunks_exact(8)
.map(|c| f64::from_le_bytes([c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7]]))
.collect()
}
fn parse_ascii(data: &[u8]) -> String {
String::from_utf8_lossy(data)
.trim_end_matches('\0')
.to_string()
}
fn apply_record(info: &mut CrsInfo, record_id: u16, data: &[u8]) {
match record_id {
RECORD_ID_GEO_KEY_DIRECTORY if info.geotiff_keys.is_none() => {
if let Ok(dir) = parse_geo_key_directory(data) {
info.geotiff_keys = Some(dir);
}
}
RECORD_ID_GEO_DOUBLE_PARAMS if info.geo_doubles.is_empty() => {
info.geo_doubles = parse_geo_doubles(data);
}
RECORD_ID_GEO_ASCII_PARAMS if info.geo_ascii.is_none() => {
info.geo_ascii = Some(parse_ascii(data));
}
RECORD_ID_OGC_WKT if info.wkt.is_none() => {
info.wkt = Some(parse_ascii(data));
}
_ => {}
}
}
pub fn extract_crs_info(vlrs: &[Vlr], evlrs: &[ExtendedVlr]) -> CrsInfo {
let mut info = CrsInfo::default();
for vlr in vlrs {
apply_record(&mut info, vlr.key.record_id, &vlr.data);
}
for evlr in evlrs {
apply_record(&mut info, evlr.record_id, &evlr.data);
}
info
}
#[cfg(test)]
mod tests {
use super::*;
use crate::copc_vlr::VlrKey;
fn make_vlr(record_id: u16, data: Vec<u8>) -> Vlr {
Vlr {
key: VlrKey {
user_id: USER_ID_LASF_PROJECTION.into(),
record_id,
},
description: String::new(),
data,
}
}
fn build_geo_key_directory(keys: &[GeoKeyEntry]) -> Vec<u8> {
let mut buf = Vec::new();
buf.extend_from_slice(&1u16.to_le_bytes()); buf.extend_from_slice(&1u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&(keys.len() as u16).to_le_bytes());
for k in keys {
buf.extend_from_slice(&k.key_id.to_le_bytes());
buf.extend_from_slice(&k.tiff_tag_location.to_le_bytes());
buf.extend_from_slice(&k.count.to_le_bytes());
buf.extend_from_slice(&k.value_offset.to_le_bytes());
}
buf
}
#[test]
fn test_parse_geo_key_directory_minimal() {
let entry = GeoKeyEntry {
key_id: 1024,
tiff_tag_location: 0,
count: 1,
value_offset: 1,
};
let bytes = build_geo_key_directory(std::slice::from_ref(&entry));
let dir = parse_geo_key_directory(&bytes).expect("parse");
assert_eq!(dir.key_directory_version, 1);
assert_eq!(dir.keys.len(), 1);
assert_eq!(dir.keys[0], entry);
}
#[test]
fn test_parse_geo_key_directory_too_short() {
assert!(parse_geo_key_directory(&[0u8; 4]).is_err());
}
#[test]
fn test_parse_geo_key_directory_truncated_keys() {
let mut bytes = build_geo_key_directory(&[GeoKeyEntry {
key_id: 1,
tiff_tag_location: 0,
count: 1,
value_offset: 0,
}]);
bytes[6..8].copy_from_slice(&3u16.to_le_bytes());
assert!(parse_geo_key_directory(&bytes).is_err());
}
#[test]
fn test_extract_wkt_from_classic_vlr() {
let wkt = "PROJCS[\"test\"]";
let info = extract_crs_info(&[make_vlr(RECORD_ID_OGC_WKT, wkt.as_bytes().to_vec())], &[]);
assert_eq!(info.wkt.as_deref(), Some(wkt));
}
#[test]
fn test_extract_geo_ascii() {
let mut data = b"WGS 84".to_vec();
data.push(0); let info = extract_crs_info(&[make_vlr(RECORD_ID_GEO_ASCII_PARAMS, data)], &[]);
assert_eq!(info.geo_ascii.as_deref(), Some("WGS 84"));
}
#[test]
fn test_extract_geo_doubles() {
let mut data = Vec::new();
data.extend_from_slice(&6378137.0f64.to_le_bytes());
data.extend_from_slice(&298.257_223_563f64.to_le_bytes());
let info = extract_crs_info(&[make_vlr(RECORD_ID_GEO_DOUBLE_PARAMS, data)], &[]);
assert_eq!(info.geo_doubles.len(), 2);
assert!((info.geo_doubles[0] - 6378137.0).abs() < 1e-6);
}
#[test]
fn test_extract_geotiff_keys() {
let bytes = build_geo_key_directory(&[GeoKeyEntry {
key_id: 3072,
tiff_tag_location: 0,
count: 1,
value_offset: 32633,
}]);
let info = extract_crs_info(&[make_vlr(RECORD_ID_GEO_KEY_DIRECTORY, bytes)], &[]);
let dir = info.geotiff_keys.expect("keys present");
assert_eq!(dir.keys[0].value_offset, 32633);
}
#[test]
fn test_extract_absent_all_none() {
let info = extract_crs_info(&[], &[]);
assert!(info.is_empty());
assert!(info.geotiff_keys.is_none());
assert!(info.geo_doubles.is_empty());
assert!(info.geo_ascii.is_none());
assert!(info.wkt.is_none());
}
#[test]
fn test_extract_wkt_from_evlr_fallback() {
let wkt = "GEOGCS[\"evlr\"]";
let evlr = ExtendedVlr {
reserved: 0,
user_id: [0u8; 16],
record_id: RECORD_ID_OGC_WKT,
record_length: wkt.len() as u64,
description: [0u8; 32],
data: wkt.as_bytes().to_vec(),
};
let info = extract_crs_info(&[], &[evlr]);
assert_eq!(info.wkt.as_deref(), Some(wkt));
}
}