Skip to main content

geotiff_core/
geokeys.rs

1//! GeoKey directory parsing and construction (TIFF tag 34735).
2//!
3//! The GeoKey directory is stored as a TIFF SHORT array with the structure:
4//! - Header: KeyDirectoryVersion, KeyRevision, MinorRevision, NumberOfKeys
5//! - Entries: KeyID, TIFFTagLocation, Count, ValueOffset (repeated)
6//!
7//! GeoKeys reference values either inline (location=0), from the
8//! GeoDoubleParams tag (34736), or from the GeoAsciiParams tag (34737).
9
10// Well-known GeoKey IDs.
11pub const GT_MODEL_TYPE: u16 = 1024;
12pub const GT_RASTER_TYPE: u16 = 1025;
13pub const GT_CITATION: u16 = 1026;
14pub const GEODETIC_CRS_TYPE: u16 = 2048;
15pub const GEOGRAPHIC_TYPE: u16 = GEODETIC_CRS_TYPE;
16pub const GEODETIC_CITATION: u16 = 2049;
17pub const GEOG_CITATION: u16 = 2049;
18pub const GEODETIC_DATUM: u16 = 2050;
19pub const GEOG_GEODETIC_DATUM: u16 = 2050;
20pub const GEOG_ANGULAR_UNITS: u16 = 2054;
21pub const PROJECTED_CRS_TYPE: u16 = 3072;
22pub const PROJECTED_CS_TYPE: u16 = 3072;
23pub const PROJ_CITATION: u16 = 3073;
24pub const PROJECTION: u16 = 3074;
25pub const PROJ_COORD_TRANS: u16 = 3075;
26pub const PROJ_LINEAR_UNITS: u16 = 3076;
27pub const VERTICAL_CITATION: u16 = 4097;
28pub const VERTICAL_CS_TYPE: u16 = 4096;
29pub const VERTICAL_DATUM: u16 = 4098;
30pub const VERTICAL_UNITS: u16 = 4099;
31
32/// A parsed GeoKey entry.
33#[derive(Debug, Clone)]
34pub struct GeoKey {
35    pub id: u16,
36    pub value: GeoKeyValue,
37}
38
39/// The value of a GeoKey.
40#[derive(Debug, Clone)]
41pub enum GeoKeyValue {
42    /// Short value stored inline.
43    Short(u16),
44    /// Double value(s) from GeoDoubleParams.
45    Double(Vec<f64>),
46    /// ASCII string from GeoAsciiParams.
47    Ascii(String),
48}
49
50/// Parsed GeoKey directory.
51#[derive(Debug, Clone)]
52pub struct GeoKeyDirectory {
53    pub version: u16,
54    pub major_revision: u16,
55    pub minor_revision: u16,
56    pub keys: Vec<GeoKey>,
57}
58
59impl GeoKeyDirectory {
60    /// Create an empty directory with default version (1.1.0).
61    pub fn new() -> Self {
62        Self {
63            version: 1,
64            major_revision: 1,
65            minor_revision: 0,
66            keys: Vec::new(),
67        }
68    }
69
70    /// Parse the GeoKey directory from the three GeoTIFF tags.
71    ///
72    /// - `directory`: contents of tag 34735 (SHORT array)
73    /// - `double_params`: contents of tag 34736 (DOUBLE array), may be empty
74    /// - `ascii_params`: contents of tag 34737 (ASCII), may be empty
75    pub fn parse(directory: &[u16], double_params: &[f64], ascii_params: &str) -> Option<Self> {
76        if directory.len() < 4 {
77            return None;
78        }
79
80        let version = directory[0];
81        let major_revision = directory[1];
82        let minor_revision = directory[2];
83        let num_keys = directory[3] as usize;
84
85        if directory.len() < 4 + num_keys * 4 {
86            return None;
87        }
88
89        let mut keys = Vec::with_capacity(num_keys);
90        for i in 0..num_keys {
91            let base = 4 + i * 4;
92            let key_id = directory[base];
93            let location = directory[base + 1];
94            let count = directory[base + 2] as usize;
95            let value_offset = directory[base + 3];
96
97            let value = match location {
98                0 => {
99                    // Value is the offset itself (short).
100                    GeoKeyValue::Short(value_offset)
101                }
102                34736 => {
103                    // Value is in GeoDoubleParams.
104                    let start = value_offset as usize;
105                    let end = start + count;
106                    if end <= double_params.len() {
107                        GeoKeyValue::Double(double_params[start..end].to_vec())
108                    } else {
109                        continue;
110                    }
111                }
112                34737 => {
113                    // Value is in GeoAsciiParams.
114                    let start = value_offset as usize;
115                    let end = start + count;
116                    if let Some(raw) = ascii_params.get(start..end) {
117                        let s = raw.trim_end_matches('|').trim_end_matches('\0').to_string();
118                        GeoKeyValue::Ascii(s)
119                    } else {
120                        continue;
121                    }
122                }
123                _ => continue,
124            };
125
126            keys.push(GeoKey { id: key_id, value });
127        }
128
129        Some(Self {
130            version,
131            major_revision,
132            minor_revision,
133            keys,
134        })
135    }
136
137    /// Look up a GeoKey by ID.
138    pub fn get(&self, id: u16) -> Option<&GeoKey> {
139        self.keys.iter().find(|k| k.id == id)
140    }
141
142    /// Get a short value for a key.
143    pub fn get_short(&self, id: u16) -> Option<u16> {
144        self.get(id).and_then(|k| match &k.value {
145            GeoKeyValue::Short(v) => Some(*v),
146            _ => None,
147        })
148    }
149
150    /// Get an ASCII value for a key.
151    pub fn get_ascii(&self, id: u16) -> Option<&str> {
152        self.get(id).and_then(|k| match &k.value {
153            GeoKeyValue::Ascii(s) => Some(s.as_str()),
154            _ => None,
155        })
156    }
157
158    /// Get double value(s) for a key.
159    pub fn get_double(&self, id: u16) -> Option<&[f64]> {
160        self.get(id).and_then(|k| match &k.value {
161            GeoKeyValue::Double(v) => Some(v.as_slice()),
162            _ => None,
163        })
164    }
165
166    /// Insert or replace a GeoKey.
167    pub fn set(&mut self, id: u16, value: GeoKeyValue) {
168        if let Some(existing) = self.keys.iter_mut().find(|k| k.id == id) {
169            existing.value = value;
170        } else {
171            self.keys.push(GeoKey { id, value });
172        }
173    }
174
175    /// Remove a GeoKey by ID.
176    pub fn remove(&mut self, id: u16) {
177        self.keys.retain(|k| k.id != id);
178    }
179
180    /// Serialize the directory into the three TIFF tag payloads.
181    ///
182    /// Returns `(directory_shorts, double_params, ascii_params)`.
183    /// Keys are sorted by ID per spec. Short values go inline (location=0),
184    /// Double values reference the double_params array (location=34736),
185    /// Ascii values reference the ascii_params string (location=34737).
186    pub fn serialize(&self) -> (Vec<u16>, Vec<f64>, String) {
187        let mut sorted_keys = self.keys.clone();
188        sorted_keys.sort_by_key(|k| k.id);
189
190        let mut directory = Vec::new();
191        let mut double_params = Vec::new();
192        let mut ascii_params = String::new();
193
194        // Header: version, major_revision, minor_revision, num_keys
195        directory.push(self.version);
196        directory.push(self.major_revision);
197        directory.push(self.minor_revision);
198        directory.push(sorted_keys.len() as u16);
199
200        for key in &sorted_keys {
201            directory.push(key.id);
202            match &key.value {
203                GeoKeyValue::Short(v) => {
204                    directory.push(0); // location: inline
205                    directory.push(1); // count
206                    directory.push(*v); // value
207                }
208                GeoKeyValue::Double(v) => {
209                    directory.push(34736); // location: GeoDoubleParams
210                    directory.push(v.len() as u16);
211                    directory.push(double_params.len() as u16); // offset
212                    double_params.extend_from_slice(v);
213                }
214                GeoKeyValue::Ascii(s) => {
215                    directory.push(34737); // location: GeoAsciiParams
216                    let ascii_with_pipe = format!("{}|", s);
217                    directory.push(ascii_with_pipe.len() as u16);
218                    directory.push(ascii_params.len() as u16); // offset
219                    ascii_params.push_str(&ascii_with_pipe);
220                }
221            }
222        }
223
224        (directory, double_params, ascii_params)
225    }
226}
227
228impl Default for GeoKeyDirectory {
229    fn default() -> Self {
230        Self::new()
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn parse_roundtrip() {
240        let mut dir = GeoKeyDirectory::new();
241        dir.set(GT_MODEL_TYPE, GeoKeyValue::Short(2));
242        dir.set(GEOGRAPHIC_TYPE, GeoKeyValue::Short(4326));
243        dir.set(GEOG_CITATION, GeoKeyValue::Ascii("WGS 84".into()));
244
245        let (shorts, doubles, ascii) = dir.serialize();
246        let parsed = GeoKeyDirectory::parse(&shorts, &doubles, &ascii).unwrap();
247
248        assert_eq!(parsed.get_short(GT_MODEL_TYPE), Some(2));
249        assert_eq!(parsed.get_short(GEOGRAPHIC_TYPE), Some(4326));
250        assert_eq!(parsed.get_ascii(GEOG_CITATION), Some("WGS 84"));
251    }
252
253    #[test]
254    fn set_replaces_existing() {
255        let mut dir = GeoKeyDirectory::new();
256        dir.set(GT_MODEL_TYPE, GeoKeyValue::Short(1));
257        dir.set(GT_MODEL_TYPE, GeoKeyValue::Short(2));
258        assert_eq!(dir.get_short(GT_MODEL_TYPE), Some(2));
259        assert_eq!(dir.keys.len(), 1);
260    }
261
262    #[test]
263    fn remove_key() {
264        let mut dir = GeoKeyDirectory::new();
265        dir.set(GT_MODEL_TYPE, GeoKeyValue::Short(1));
266        dir.remove(GT_MODEL_TYPE);
267        assert!(dir.get(GT_MODEL_TYPE).is_none());
268    }
269
270    #[test]
271    fn parse_skips_invalid_ascii_subslice_without_panicking() {
272        let directory = [
273            1u16,
274            1,
275            0,
276            1, // header
277            GEOG_CITATION,
278            34737,
279            1,
280            1, // byte offsets that are invalid for lossy UTF-8
281        ];
282        let ascii = String::from_utf8_lossy(&[0xff, b'|']).into_owned();
283
284        let parsed = GeoKeyDirectory::parse(&directory, &[], &ascii).unwrap();
285        assert!(parsed.get_ascii(GEOG_CITATION).is_none());
286    }
287}