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}