Skip to main content

exiftool_rs/
tag.rs

1use crate::value::Value;
2
3/// Identifies the metadata group hierarchy (mirrors ExifTool's Group0..Group3).
4#[derive(Debug, Clone, PartialEq, Eq, Hash)]
5pub struct TagGroup {
6    /// Family 0: Information type (EXIF, IPTC, XMP, ICC_Profile, etc.)
7    pub family0: String,
8    /// Family 1: Specific location (IFD0, ExifIFD, GPS, XMP-dc, etc.)
9    pub family1: String,
10    /// Family 2: Category (Image, Camera, Location, Time, Author, etc.)
11    pub family2: String,
12    /// Family 3: Document number ([`MAIN_DOCUMENT`], "Doc1", "Doc2", etc.).
13    ///
14    /// Tags read from the file itself belong to the main document. Formats that
15    /// carry repeated or embedded sub-documents (e.g. the timed messages of a
16    /// Garmin FIT file under `ExtractEmbedded`) place each one in its own
17    /// numbered document, exactly as ExifTool's `-G3` output does.
18    pub family3: String,
19}
20
21/// Family 3 value for tags belonging to the file's main document.
22pub const MAIN_DOCUMENT: &str = "Main";
23
24/// Sentinel [`Tag::priority`] meaning "the source table gives this tag a
25/// priority of 0", as opposed to the struct's plain `0`, which means "the
26/// decoder said nothing".
27///
28/// ExifTool draws the same distinction, through definedness rather than a
29/// sentinel. `FoundTag` resolves the priority in two stages: first from the tag
30/// and its table (ExifTool.pm:9469-9472),
31///
32/// ```text
33/// my $priority = $$tagInfo{Priority};
34/// unless (defined $priority) {
35///     $priority = $$tbl{PRIORITY};
36///     $priority = 0 if not defined $priority and $$tagInfo{Avoid};
37/// }
38/// ```
39///
40/// then, only when that left it undefined, from the directory
41/// (ExifTool.pm:9552-9562): 0 for a LOW_PRIORITY_DIR, otherwise the normal
42/// default of 1. A table-stated 0 and an unstated priority therefore take
43/// different branches, and a stated 0 is additionally promoted back to 1 inside
44/// the PRIORITY_DIR. `Avoid => 1` lands in the *stated* branch, since the block
45/// above assigns to `$priority` and thereby defines it.
46///
47/// A distinct value is used rather than a new field because the priority takes
48/// part in the duplicate competition as a plain number everywhere else, and
49/// several readers already set values of their own (-1, 2, 5, 10).
50pub const PRIORITY_EXPLICIT_ZERO: i32 = i32::MIN;
51
52/// Build a `Warning` tag the way `Image::ExifTool::Warn` does.
53///
54/// `%Image::ExifTool::Extra` declares `Warning => { Priority => 0, Groups =>
55/// \%allGroupsExifTool }` (ExifTool.pm:1298-1300). The stated `Priority => 0` is
56/// what makes the FIRST warning of a file the one reported when duplicates are
57/// collapsed: FoundTag promotes the stored tag's 0 to 1 for `Warning`
58/// unconditionally ("never override a Warning tag because they may be added by
59/// ValueConv", ExifTool.pm:9541-9548), so the incoming 0 never reaches it.
60pub fn warning_tag(message: impl Into<String>) -> Tag {
61    let message = message.into();
62    Tag {
63        id: TagId::Text("Warning".into()),
64        name: "Warning".into(),
65        description: "Warning".into(),
66        group: TagGroup {
67            family0: "ExifTool".into(),
68            family1: "ExifTool".into(),
69            family2: "Other".into(),
70            family3: MAIN_DOCUMENT.into(),
71        },
72        raw_value: crate::value::Value::String(message.clone()),
73        print_value: message,
74        priority: PRIORITY_EXPLICIT_ZERO,
75    }
76}
77
78impl Default for TagGroup {
79    /// An empty group in the main document.
80    fn default() -> Self {
81        Self {
82            family0: String::new(),
83            family1: String::new(),
84            family2: String::new(),
85            family3: MAIN_DOCUMENT.to_string(),
86        }
87    }
88}
89
90/// A resolved metadata tag with its value and metadata.
91#[derive(Debug, Clone)]
92pub struct Tag {
93    /// Tag identifier (numeric for EXIF/IPTC, string key for XMP)
94    pub id: TagId,
95    /// Canonical tag name (e.g., "ExposureTime", "Artist", "GPSLatitude")
96    pub name: String,
97    /// Human-readable description
98    pub description: String,
99    /// Group hierarchy
100    pub group: TagGroup,
101    /// The raw value
102    pub raw_value: Value,
103    /// Human-readable print conversion of the value
104    pub print_value: String,
105    /// Priority for conflict resolution (higher wins)
106    pub priority: i32,
107}
108
109impl Tag {
110    /// The priority as a plain comparable number, with
111    /// [`PRIORITY_EXPLICIT_ZERO`] folded back to the 0 it stands for.
112    ///
113    /// Use it wherever priorities are merely ranked against each other. Only
114    /// the duplicate arbitration needs to tell a table-stated 0 from an
115    /// unstated priority, and it reads [`Tag::priority`] directly.
116    pub fn priority_rank(&self) -> i32 {
117        if self.priority == PRIORITY_EXPLICIT_ZERO {
118            0
119        } else {
120            self.priority
121        }
122    }
123
124    /// Get the display value respecting the print_conv option.
125    /// When `numeric` is true (-n flag), returns the raw value.
126    /// When `numeric` is false, returns the print-converted value.
127    pub fn display_value(&self, numeric: bool) -> String {
128        if numeric {
129            self.raw_value.to_display_string()
130        } else {
131            self.print_value.clone()
132        }
133    }
134}
135
136/// Tag identifier - can be numeric (EXIF/IPTC) or string (XMP).
137#[derive(Debug, Clone, PartialEq, Eq, Hash)]
138pub enum TagId {
139    /// Numeric ID (EXIF IFD tag, IPTC record:dataset)
140    Numeric(u16),
141    /// String key (XMP property path)
142    Text(String),
143}
144
145impl std::fmt::Display for TagId {
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        match self {
148            TagId::Numeric(id) => write!(f, "0x{:04x}", id),
149            TagId::Text(s) => write!(f, "{}", s),
150        }
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    fn make_tag(raw: Value, print: &str) -> Tag {
159        Tag {
160            id: TagId::Numeric(0x0001),
161            name: "TestTag".to_string(),
162            description: "Test Tag".to_string(),
163            group: TagGroup {
164                family0: "EXIF".to_string(),
165                family1: "IFD0".to_string(),
166                family2: "Image".to_string(),
167                family3: "Main".into(),
168            },
169            raw_value: raw,
170            print_value: print.to_string(),
171            priority: 0,
172        }
173    }
174
175    // ── TagId Display ──────────────────────────────────────────────
176
177    #[test]
178    fn tag_id_numeric_display_low() {
179        assert_eq!(format!("{}", TagId::Numeric(0x0001)), "0x0001");
180    }
181
182    #[test]
183    fn tag_id_numeric_display_hex() {
184        assert_eq!(format!("{}", TagId::Numeric(0x00FF)), "0x00ff");
185    }
186
187    #[test]
188    fn tag_id_numeric_display_zero() {
189        assert_eq!(format!("{}", TagId::Numeric(0)), "0x0000");
190    }
191
192    #[test]
193    fn tag_id_numeric_display_max() {
194        assert_eq!(format!("{}", TagId::Numeric(0xFFFF)), "0xffff");
195    }
196
197    #[test]
198    fn tag_id_text_display() {
199        assert_eq!(format!("{}", TagId::Text("dc:title".into())), "dc:title");
200    }
201
202    #[test]
203    fn tag_id_text_display_empty() {
204        assert_eq!(format!("{}", TagId::Text(String::new())), "");
205    }
206
207    // ── Tag::display_value ─────────────────────────────────────────
208
209    #[test]
210    fn display_value_numeric_true_returns_raw() {
211        let tag = make_tag(Value::URational(1, 100), "0.01 s");
212        assert_eq!(tag.display_value(true), "0.01");
213    }
214
215    #[test]
216    fn display_value_numeric_false_returns_print() {
217        let tag = make_tag(Value::URational(1, 100), "0.01 s");
218        assert_eq!(tag.display_value(false), "0.01 s");
219    }
220
221    #[test]
222    fn display_value_string_raw() {
223        let tag = make_tag(Value::String("Canon EOS R5".into()), "Canon EOS R5");
224        assert_eq!(tag.display_value(true), "Canon EOS R5");
225        assert_eq!(tag.display_value(false), "Canon EOS R5");
226    }
227
228    // ── TagId equality ─────────────────────────────────────────────
229
230    #[test]
231    fn tag_id_equality() {
232        assert_eq!(TagId::Numeric(42), TagId::Numeric(42));
233        assert_ne!(TagId::Numeric(1), TagId::Numeric(2));
234        assert_eq!(TagId::Text("foo".into()), TagId::Text("foo".into()));
235        assert_ne!(TagId::Numeric(1), TagId::Text("1".into()));
236    }
237
238    // ── TagGroup equality ──────────────────────────────────────────
239
240    #[test]
241    fn tag_group_equality() {
242        let g1 = TagGroup {
243            family0: "EXIF".into(),
244            family1: "IFD0".into(),
245            family2: "Image".into(),
246            family3: "Main".into(),
247        };
248        let g2 = g1.clone();
249        assert_eq!(g1, g2);
250    }
251}