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