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
10use std::error::Error;
11use std::fmt;
12
13// Well-known GeoKey IDs.
14pub const GT_MODEL_TYPE: u16 = 1024;
15pub const GT_RASTER_TYPE: u16 = 1025;
16pub const GT_CITATION: u16 = 1026;
17pub const GEODETIC_CRS_TYPE: u16 = 2048;
18pub const GEOGRAPHIC_TYPE: u16 = GEODETIC_CRS_TYPE;
19pub const GEODETIC_CITATION: u16 = 2049;
20pub const GEOG_CITATION: u16 = 2049;
21pub const GEODETIC_DATUM: u16 = 2050;
22pub const GEOG_GEODETIC_DATUM: u16 = 2050;
23pub const GEOG_ANGULAR_UNITS: u16 = 2054;
24pub const PROJECTED_CRS_TYPE: u16 = 3072;
25pub const PROJECTED_CS_TYPE: u16 = 3072;
26pub const PROJ_CITATION: u16 = 3073;
27pub const PROJECTION: u16 = 3074;
28pub const PROJ_COORD_TRANS: u16 = 3075;
29pub const PROJ_LINEAR_UNITS: u16 = 3076;
30pub const VERTICAL_CITATION: u16 = 4097;
31pub const VERTICAL_CS_TYPE: u16 = 4096;
32pub const VERTICAL_DATUM: u16 = 4098;
33pub const VERTICAL_UNITS: u16 = 4099;
34const GEO_DOUBLE_PARAMS_TAG: u16 = 34736;
35const GEO_ASCII_PARAMS_TAG: u16 = 34737;
36pub const GEO_KEY_DIRECTORY_VERSION: u16 = 1;
37pub const GEO_KEY_REVISION: u16 = 1;
38pub const GEO_KEY_MINOR_REVISION_1_0: u16 = 0;
39pub const GEO_KEY_MINOR_REVISION_1_1: u16 = 1;
40
41/// Error returned when a GeoKey directory cannot be represented in the
42/// GeoTIFF SHORT-based key directory format.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum GeoKeySerializeError {
45    /// The GeoKey directory minor revision must be 0 or 1.
46    InvalidMinorRevision { minor_revision: u16 },
47    /// The GeoKey directory header stores the key count as a SHORT.
48    TooManyKeys { count: usize },
49    /// A key references more parameter values than fit in a SHORT count.
50    ValueCountTooLarge { key_id: u16, tag: u16, count: usize },
51    /// A key's parameter start offset does not fit in a SHORT value offset.
52    ParameterOffsetTooLarge {
53        key_id: u16,
54        tag: u16,
55        offset: usize,
56    },
57}
58
59impl fmt::Display for GeoKeySerializeError {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        match self {
62            Self::InvalidMinorRevision { minor_revision } => write!(
63                f,
64                "GeoKey directory minor revision {minor_revision} is invalid; expected 0 or 1"
65            ),
66            Self::TooManyKeys { count } => {
67                write!(
68                    f,
69                    "GeoKey directory contains {count} keys, exceeding u16::MAX"
70                )
71            }
72            Self::ValueCountTooLarge { key_id, tag, count } => write!(
73                f,
74                "GeoKey {key_id} references {count} values in tag {tag}, exceeding u16::MAX"
75            ),
76            Self::ParameterOffsetTooLarge {
77                key_id,
78                tag,
79                offset,
80            } => write!(
81                f,
82                "GeoKey {key_id} parameter offset {offset} in tag {tag} exceeds u16::MAX"
83            ),
84        }
85    }
86}
87
88impl Error for GeoKeySerializeError {}
89
90/// A parsed GeoKey entry.
91#[derive(Debug, Clone)]
92pub struct GeoKey {
93    pub id: u16,
94    pub value: GeoKeyValue,
95}
96
97/// The value of a GeoKey.
98#[derive(Debug, Clone)]
99pub enum GeoKeyValue {
100    /// Short value stored inline.
101    Short(u16),
102    /// Double value(s) from GeoDoubleParams.
103    Double(Vec<f64>),
104    /// ASCII string from GeoAsciiParams.
105    Ascii(String),
106}
107
108/// Parsed GeoKey directory.
109#[derive(Debug, Clone)]
110pub struct GeoKeyDirectory {
111    pub version: u16,
112    pub major_revision: u16,
113    pub minor_revision: u16,
114    pub keys: Vec<GeoKey>,
115}
116
117impl GeoKeyDirectory {
118    /// Create an empty directory with a GeoTIFF 1.1 writer header.
119    pub fn new() -> Self {
120        Self {
121            version: GEO_KEY_DIRECTORY_VERSION,
122            major_revision: GEO_KEY_REVISION,
123            minor_revision: GEO_KEY_MINOR_REVISION_1_1,
124            keys: Vec::new(),
125        }
126    }
127
128    /// Parse the GeoKey directory from the three GeoTIFF tags.
129    ///
130    /// - `directory`: contents of tag 34735 (SHORT array)
131    /// - `double_params`: contents of tag 34736 (DOUBLE array), may be empty
132    /// - `ascii_params`: contents of tag 34737 (ASCII), may be empty
133    pub fn parse(directory: &[u16], double_params: &[f64], ascii_params: &str) -> Option<Self> {
134        if directory.len() < 4 {
135            return None;
136        }
137
138        let version = directory[0];
139        let major_revision = directory[1];
140        let minor_revision = directory[2];
141        let num_keys = directory[3] as usize;
142
143        if directory.len() < 4 + num_keys * 4 {
144            return None;
145        }
146
147        let mut keys = Vec::with_capacity(num_keys);
148        for i in 0..num_keys {
149            let base = 4 + i * 4;
150            let key_id = directory[base];
151            let location = directory[base + 1];
152            let count = directory[base + 2] as usize;
153            let value_offset = directory[base + 3];
154
155            let value = match location {
156                0 => {
157                    // Value is the offset itself (short).
158                    GeoKeyValue::Short(value_offset)
159                }
160                34736 => {
161                    // Value is in GeoDoubleParams.
162                    let start = value_offset as usize;
163                    let end = start + count;
164                    if end <= double_params.len() {
165                        GeoKeyValue::Double(double_params[start..end].to_vec())
166                    } else {
167                        continue;
168                    }
169                }
170                34737 => {
171                    // Value is in GeoAsciiParams.
172                    let start = value_offset as usize;
173                    let end = start + count;
174                    if let Some(raw) = ascii_params.get(start..end) {
175                        let s = raw.trim_end_matches('|').trim_end_matches('\0').to_string();
176                        GeoKeyValue::Ascii(s)
177                    } else {
178                        continue;
179                    }
180                }
181                _ => continue,
182            };
183
184            keys.push(GeoKey { id: key_id, value });
185        }
186
187        Some(Self {
188            version,
189            major_revision,
190            minor_revision,
191            keys,
192        })
193    }
194
195    /// Look up a GeoKey by ID.
196    pub fn get(&self, id: u16) -> Option<&GeoKey> {
197        self.keys.iter().find(|k| k.id == id)
198    }
199
200    /// Get a short value for a key.
201    pub fn get_short(&self, id: u16) -> Option<u16> {
202        self.get(id).and_then(|k| match &k.value {
203            GeoKeyValue::Short(v) => Some(*v),
204            _ => None,
205        })
206    }
207
208    /// Get an ASCII value for a key.
209    pub fn get_ascii(&self, id: u16) -> Option<&str> {
210        self.get(id).and_then(|k| match &k.value {
211            GeoKeyValue::Ascii(s) => Some(s.as_str()),
212            _ => None,
213        })
214    }
215
216    /// Get double value(s) for a key.
217    pub fn get_double(&self, id: u16) -> Option<&[f64]> {
218        self.get(id).and_then(|k| match &k.value {
219            GeoKeyValue::Double(v) => Some(v.as_slice()),
220            _ => None,
221        })
222    }
223
224    /// Insert or replace a GeoKey.
225    pub fn set(&mut self, id: u16, value: GeoKeyValue) {
226        if let Some(existing) = self.keys.iter_mut().find(|k| k.id == id) {
227            existing.value = value;
228        } else {
229            self.keys.push(GeoKey { id, value });
230        }
231    }
232
233    /// Remove a GeoKey by ID.
234    pub fn remove(&mut self, id: u16) {
235        self.keys.retain(|k| k.id != id);
236    }
237
238    /// Serialize the directory into the three TIFF tag payloads.
239    ///
240    /// Returns `(directory_shorts, double_params, ascii_params)`.
241    /// Keys are sorted by ID per spec. Short values go inline (location=0),
242    /// Double values reference the double_params array (location=34736),
243    /// Ascii values reference the ascii_params string (location=34737).
244    pub fn serialize(&self) -> Result<(Vec<u16>, Vec<f64>, String), GeoKeySerializeError> {
245        let mut sorted_keys = self.keys.clone();
246        sorted_keys.sort_by_key(|k| k.id);
247        let key_count =
248            u16::try_from(sorted_keys.len()).map_err(|_| GeoKeySerializeError::TooManyKeys {
249                count: sorted_keys.len(),
250            })?;
251
252        let mut directory = Vec::new();
253        let mut double_params = Vec::new();
254        let mut ascii_params = String::new();
255
256        let minor_revision = self.serialized_minor_revision()?;
257
258        // Header: version, major_revision, minor_revision, num_keys
259        directory.push(self.version);
260        directory.push(self.major_revision);
261        directory.push(minor_revision);
262        directory.push(key_count);
263
264        for key in &sorted_keys {
265            directory.push(key.id);
266            match &key.value {
267                GeoKeyValue::Short(v) => {
268                    directory.push(0); // location: inline
269                    directory.push(1); // count
270                    directory.push(*v); // value
271                }
272                GeoKeyValue::Double(v) => {
273                    let count = checked_u16_len(key.id, GEO_DOUBLE_PARAMS_TAG, v.len())?;
274                    let offset =
275                        checked_u16_offset(key.id, GEO_DOUBLE_PARAMS_TAG, double_params.len())?;
276                    directory.push(GEO_DOUBLE_PARAMS_TAG); // location: GeoDoubleParams
277                    directory.push(count);
278                    directory.push(offset);
279                    double_params.extend_from_slice(v);
280                }
281                GeoKeyValue::Ascii(s) => {
282                    let ascii_with_pipe = format!("{}|", s);
283                    let count =
284                        checked_u16_len(key.id, GEO_ASCII_PARAMS_TAG, ascii_with_pipe.len())?;
285                    let offset =
286                        checked_u16_offset(key.id, GEO_ASCII_PARAMS_TAG, ascii_params.len())?;
287                    directory.push(GEO_ASCII_PARAMS_TAG); // location: GeoAsciiParams
288                    directory.push(count);
289                    directory.push(offset);
290                    ascii_params.push_str(&ascii_with_pipe);
291                }
292            }
293        }
294
295        Ok((directory, double_params, ascii_params))
296    }
297
298    fn serialized_minor_revision(&self) -> Result<u16, GeoKeySerializeError> {
299        match self.minor_revision {
300            GEO_KEY_MINOR_REVISION_1_0 if self.requires_geotiff_1_1() => {
301                Ok(GEO_KEY_MINOR_REVISION_1_1)
302            }
303            GEO_KEY_MINOR_REVISION_1_0 | GEO_KEY_MINOR_REVISION_1_1 => Ok(self.minor_revision),
304            minor_revision => Err(GeoKeySerializeError::InvalidMinorRevision { minor_revision }),
305        }
306    }
307
308    fn requires_geotiff_1_1(&self) -> bool {
309        self.keys.iter().any(|key| {
310            matches!(
311                key.id,
312                VERTICAL_CS_TYPE | VERTICAL_CITATION | VERTICAL_DATUM | VERTICAL_UNITS
313            )
314        })
315    }
316}
317
318fn checked_u16_len(key_id: u16, tag: u16, count: usize) -> Result<u16, GeoKeySerializeError> {
319    u16::try_from(count).map_err(|_| GeoKeySerializeError::ValueCountTooLarge {
320        key_id,
321        tag,
322        count,
323    })
324}
325
326fn checked_u16_offset(key_id: u16, tag: u16, offset: usize) -> Result<u16, GeoKeySerializeError> {
327    u16::try_from(offset).map_err(|_| GeoKeySerializeError::ParameterOffsetTooLarge {
328        key_id,
329        tag,
330        offset,
331    })
332}
333
334impl Default for GeoKeyDirectory {
335    fn default() -> Self {
336        Self::new()
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn parse_roundtrip() {
346        let mut dir = GeoKeyDirectory::new();
347        dir.set(GT_MODEL_TYPE, GeoKeyValue::Short(2));
348        dir.set(GEOGRAPHIC_TYPE, GeoKeyValue::Short(4326));
349        dir.set(GEOG_CITATION, GeoKeyValue::Ascii("WGS 84".into()));
350
351        let (shorts, doubles, ascii) = dir.serialize().unwrap();
352        assert_eq!(shorts[..4], [1, 1, 1, 3]);
353        let parsed = GeoKeyDirectory::parse(&shorts, &doubles, &ascii).unwrap();
354
355        assert_eq!(parsed.get_short(GT_MODEL_TYPE), Some(2));
356        assert_eq!(parsed.get_short(GEOGRAPHIC_TYPE), Some(4326));
357        assert_eq!(parsed.get_ascii(GEOG_CITATION), Some("WGS 84"));
358    }
359
360    #[test]
361    fn serialize_preserves_legacy_minor_revision_zero_when_compatible() {
362        let mut dir = GeoKeyDirectory::new();
363        dir.minor_revision = GEO_KEY_MINOR_REVISION_1_0;
364        dir.set(GT_MODEL_TYPE, GeoKeyValue::Short(2));
365
366        let (shorts, _, _) = dir.serialize().unwrap();
367        assert_eq!(shorts[..4], [1, 1, 0, 1]);
368    }
369
370    #[test]
371    fn serialize_promotes_vertical_geokeys_to_geotiff_1_1_minor_revision() {
372        let mut dir = GeoKeyDirectory::new();
373        dir.minor_revision = GEO_KEY_MINOR_REVISION_1_0;
374        dir.set(VERTICAL_CS_TYPE, GeoKeyValue::Short(5703));
375
376        let (shorts, _, _) = dir.serialize().unwrap();
377        assert_eq!(shorts[..4], [1, 1, 1, 1]);
378    }
379
380    #[test]
381    fn serialize_rejects_invalid_minor_revision() {
382        let mut dir = GeoKeyDirectory::new();
383        dir.minor_revision = 2;
384
385        let err = dir.serialize().unwrap_err();
386        assert_eq!(
387            err,
388            GeoKeySerializeError::InvalidMinorRevision { minor_revision: 2 }
389        );
390    }
391
392    #[test]
393    fn set_replaces_existing() {
394        let mut dir = GeoKeyDirectory::new();
395        dir.set(GT_MODEL_TYPE, GeoKeyValue::Short(1));
396        dir.set(GT_MODEL_TYPE, GeoKeyValue::Short(2));
397        assert_eq!(dir.get_short(GT_MODEL_TYPE), Some(2));
398        assert_eq!(dir.keys.len(), 1);
399    }
400
401    #[test]
402    fn remove_key() {
403        let mut dir = GeoKeyDirectory::new();
404        dir.set(GT_MODEL_TYPE, GeoKeyValue::Short(1));
405        dir.remove(GT_MODEL_TYPE);
406        assert!(dir.get(GT_MODEL_TYPE).is_none());
407    }
408
409    #[test]
410    fn parse_skips_invalid_ascii_subslice_without_panicking() {
411        let directory = [
412            1u16,
413            1,
414            0,
415            1, // header
416            GEOG_CITATION,
417            34737,
418            1,
419            1, // byte offsets that are invalid for lossy UTF-8
420        ];
421        let ascii = String::from_utf8_lossy(&[0xff, b'|']).into_owned();
422
423        let parsed = GeoKeyDirectory::parse(&directory, &[], &ascii).unwrap();
424        assert!(parsed.get_ascii(GEOG_CITATION).is_none());
425    }
426
427    #[test]
428    fn serialize_rejects_too_many_keys() {
429        let mut dir = GeoKeyDirectory::new();
430        dir.keys = (0..=u16::MAX as usize)
431            .map(|index| GeoKey {
432                id: index as u16,
433                value: GeoKeyValue::Short(1),
434            })
435            .collect();
436
437        let err = dir.serialize().unwrap_err();
438        assert_eq!(
439            err,
440            GeoKeySerializeError::TooManyKeys {
441                count: u16::MAX as usize + 1
442            }
443        );
444    }
445
446    #[test]
447    fn serialize_rejects_oversized_double_value_count() {
448        let mut dir = GeoKeyDirectory::new();
449        dir.set(
450            GT_CITATION,
451            GeoKeyValue::Double(vec![1.0; u16::MAX as usize + 1]),
452        );
453
454        let err = dir.serialize().unwrap_err();
455        assert_eq!(
456            err,
457            GeoKeySerializeError::ValueCountTooLarge {
458                key_id: GT_CITATION,
459                tag: GEO_DOUBLE_PARAMS_TAG,
460                count: u16::MAX as usize + 1
461            }
462        );
463    }
464
465    #[test]
466    fn serialize_rejects_oversized_ascii_parameter_offset() {
467        let mut dir = GeoKeyDirectory::new();
468        dir.set(
469            GEOG_CITATION,
470            GeoKeyValue::Ascii("a".repeat(u16::MAX as usize - 1)),
471        );
472        dir.set(PROJ_CITATION, GeoKeyValue::Ascii("b".to_string()));
473        dir.set(VERTICAL_CITATION, GeoKeyValue::Ascii("c".to_string()));
474
475        let err = dir.serialize().unwrap_err();
476        assert_eq!(
477            err,
478            GeoKeySerializeError::ParameterOffsetTooLarge {
479                key_id: VERTICAL_CITATION,
480                tag: GEO_ASCII_PARAMS_TAG,
481                offset: u16::MAX as usize + 2
482            }
483        );
484    }
485}