Skip to main content

nom_exif/exif/
png_text.rs

1//! PNG `tEXt` chunks as Latin-1-decoded key/value pairs.
2//!
3//! See [`PngTextChunks`] for accessors. Used as the payload of
4//! [`crate::ImageFormatMetadata::Png`].
5
6/// PNG `tEXt` chunks, decoded as Latin-1 `(key, value)` pairs in file
7/// order.
8///
9/// Duplicate keys are preserved (PNG spec permits multiple `tEXt`
10/// chunks with the same keyword). Encoding is strict Latin-1 per spec
11/// — no UTF-8 sniffing.
12///
13/// **Note**: when a PNG carries EXIF inside a `Raw profile type exif` /
14/// `Raw profile type APP1` text chunk (legacy ImageMagick / Photoshop
15/// pattern), the EXIF entries are merged into the `Exif` (under
16/// `ImageMetadata.exif`) transparently; the original text chunk is
17/// also visible here.
18///
19/// Forward-compatible: future iTXt / zTXt support can extend
20/// `PngTextChunks` non-breakingly.
21#[derive(Debug, Clone, Default)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23pub struct PngTextChunks {
24    pub(crate) entries: Vec<(String, String)>,
25}
26
27impl PngTextChunks {
28    /// First value whose key matches exactly, or `None`.
29    pub fn get(&self, key: &str) -> Option<&str> {
30        self.entries
31            .iter()
32            .find(|(k, _)| k == key)
33            .map(|(_, v)| v.as_str())
34    }
35
36    /// All values whose key matches exactly, in file order.
37    pub fn get_all<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'a str> + 'a {
38        self.entries
39            .iter()
40            .filter(move |(k, _)| k == key)
41            .map(|(_, v)| v.as_str())
42    }
43
44    /// All `(key, value)` pairs in file order, including duplicates.
45    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> + '_ {
46        self.entries.iter().map(|(k, v)| (k.as_str(), v.as_str()))
47    }
48
49    /// Number of `(key, value)` pairs (counts duplicates).
50    pub fn len(&self) -> usize {
51        self.entries.len()
52    }
53
54    /// `true` if no `tEXt` entries are present.
55    pub fn is_empty(&self) -> bool {
56        self.entries.is_empty()
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    fn fixture() -> PngTextChunks {
65        PngTextChunks {
66            entries: vec![
67                ("Title".into(), "Hello".into()),
68                ("Author".into(), "Alice".into()),
69                ("Comment".into(), "first comment".into()),
70                ("Comment".into(), "second comment".into()),
71            ],
72        }
73    }
74
75    #[test]
76    fn get_returns_first_match() {
77        let t = fixture();
78        assert_eq!(t.get("Title"), Some("Hello"));
79        assert_eq!(t.get("Comment"), Some("first comment"));
80        assert_eq!(t.get("nonexistent"), None);
81    }
82
83    #[test]
84    fn get_all_returns_all_in_order() {
85        let t = fixture();
86        let comments: Vec<&str> = t.get_all("Comment").collect();
87        assert_eq!(comments, vec!["first comment", "second comment"]);
88        let titles: Vec<&str> = t.get_all("Title").collect();
89        assert_eq!(titles, vec!["Hello"]);
90        let nothing: Vec<&str> = t.get_all("nonexistent").collect();
91        assert!(nothing.is_empty());
92    }
93
94    #[test]
95    fn iter_in_file_order_with_duplicates() {
96        let t = fixture();
97        let pairs: Vec<(&str, &str)> = t.iter().collect();
98        assert_eq!(pairs.len(), 4);
99        assert_eq!(pairs[2], ("Comment", "first comment"));
100        assert_eq!(pairs[3], ("Comment", "second comment"));
101    }
102
103    #[test]
104    fn len_and_is_empty() {
105        let t = fixture();
106        assert_eq!(t.len(), 4);
107        assert!(!t.is_empty());
108
109        let empty = PngTextChunks::default();
110        assert_eq!(empty.len(), 0);
111        assert!(empty.is_empty());
112    }
113}