Skip to main content

exiftool_rs/metadata/
iptc.rs

1//! IPTC (International Press Telecommunications Council) metadata reader.
2//!
3//! Reads IPTC-IIM (Information Interchange Model) records, commonly found
4//! in JPEG APP13 Photoshop segments. Mirrors ExifTool's IPTC.pm.
5
6use crate::error::Result;
7use crate::tag::{Tag, TagGroup, TagId};
8use crate::tags::iptc as iptc_tags;
9use crate::value::Value;
10
11/// Family-1 group of an IPTC directory sitting where the format says it should
12/// (`JPEG-APP13-Photoshop-IPTC`, `TIFF-IFD0-IPTC`, …), and of every IPTC block
13/// in a format that has no standard location at all (PDF, MIFF, …).
14const STANDARD_GROUP1: &str = "IPTC";
15
16/// Family-1 group of an IPTC directory found in an unexpected place inside a
17/// format that *does* define a standard one. ExifTool's `ProcessIPTC` counts
18/// those and appends the count + 1 to the group name, so the first is `IPTC2`.
19/// We only ever see one per file, so the counter is not modelled.
20const NONSTANDARD_GROUP1: &str = "IPTC2";
21
22/// IPTC metadata reader.
23pub struct IptcReader;
24
25impl IptcReader {
26    /// Parse IPTC data from a raw byte slice.
27    ///
28    /// IPTC-IIM format: sequences of records, each:
29    ///   - 1 byte:  tag marker (0x1C)
30    ///   - 1 byte:  record number
31    ///   - 1 byte:  dataset number
32    ///   - 2 bytes: data length (big-endian), or extended if >= 0x8000
33    ///   - N bytes: data
34    ///
35    /// Pre-scan IPTC data for CodedCharacterSet (record 1, dataset 90).
36    /// Returns true if UTF-8 is indicated (ESC %G = bytes 0x1B 0x25 0x47).
37    fn detect_iptc_charset(data: &[u8]) -> bool {
38        let mut pos = 0;
39        while pos + 5 <= data.len() {
40            if data[pos] != 0x1C {
41                pos += 1;
42                continue;
43            }
44            let record = data[pos + 1];
45            let dataset = data[pos + 2];
46            let length = u16::from_be_bytes([data[pos + 3], data[pos + 4]]) as usize;
47            pos += 5;
48            if length >= 0x8000 {
49                break;
50            }
51            if pos + length > data.len() {
52                break;
53            }
54            if record == 1 && dataset == 90 {
55                let val = &data[pos..pos + length];
56                // ESC %G = UTF-8
57                return val.windows(3).any(|w| w == [0x1B, 0x25, 0x47]);
58            }
59            pos += length;
60        }
61        false
62    }
63
64    /// Read an IPTC directory found at the format's standard location.
65    pub fn read(data: &[u8]) -> Result<Vec<Tag>> {
66        Self::read_in_group(data, STANDARD_GROUP1)
67    }
68
69    /// Read an IPTC directory found somewhere ExifTool does not expect one — a
70    /// JPEG trailer, a maker note — which lands in its own numbered family-1
71    /// group. See [`NONSTANDARD_GROUP1`].
72    pub fn read_nonstandard(data: &[u8]) -> Result<Vec<Tag>> {
73        Self::read_in_group(data, NONSTANDARD_GROUP1)
74    }
75
76    fn read_in_group(data: &[u8], group1: &str) -> Result<Vec<Tag>> {
77        let mut tags = Vec::new();
78        let is_utf8 = Self::detect_iptc_charset(data);
79        let mut pos = 0;
80
81        while pos + 5 <= data.len() {
82            // Check for IPTC tag marker
83            if data[pos] != 0x1C {
84                // Skip non-IPTC data
85                pos += 1;
86                continue;
87            }
88
89            let record = data[pos + 1];
90            let dataset = data[pos + 2];
91            let length = u16::from_be_bytes([data[pos + 3], data[pos + 4]]) as usize;
92
93            pos += 5;
94
95            // Extended dataset length (bit 15 set means the length field itself
96            // gives the number of bytes in an extended length that follows)
97            if length >= 0x8000 {
98                // Skip extended length datasets for now
99                break;
100            }
101
102            if pos + length > data.len() {
103                break;
104            }
105
106            let value_data = &data[pos..pos + length];
107            pos += length;
108
109            // Only handle Envelope (record 1) and Application (record 2); the
110            // rest carry no tags we surface. The record does NOT select the
111            // family-1 group: ExifTool names it after the IPTC *directory*, so
112            // both records share the group passed in by the caller.
113            if !matches!(record, 1 | 2) {
114                continue;
115            }
116
117            // Check for PhotoMechanic SoftEdit fields BEFORE string decoding
118            // (These are int32s, not strings, so must be decoded as binary)
119            if record == 2 && (209..=222).contains(&dataset) {
120                // Decode as binary (int32s)
121                let bin_value = Value::Binary(value_data.to_vec());
122                if let Some((pm_name, pm_print)) = lookup_photomechanic(dataset, &bin_value) {
123                    tags.push(Tag {
124                        id: TagId::Numeric(((record as u16) << 8) | dataset as u16),
125                        name: pm_name.clone(),
126                        description: pm_name,
127                        group: TagGroup {
128                            family0: "PhotoMechanic".to_string(),
129                            family1: "PhotoMechanic".to_string(),
130                            family2: "Image".to_string(),
131                            family3: "Main".into(),
132                        },
133                        raw_value: bin_value,
134                        print_value: pm_print,
135                        priority: 0,
136                    });
137                    continue;
138                }
139            }
140
141            let value = if iptc_tags::is_string_tag(record, dataset) {
142                let s = if is_utf8 {
143                    crate::encoding::decode_utf8_or_latin1(value_data).to_string()
144                } else {
145                    crate::encoding::decode_latin1(value_data)
146                };
147                Value::String(s.trim_end_matches('\0').to_string())
148            } else if length <= 2 {
149                match length {
150                    1 => Value::U8(value_data[0]),
151                    2 => Value::U16(u16::from_be_bytes([value_data[0], value_data[1]])),
152                    _ => Value::Binary(value_data.to_vec()),
153                }
154            } else {
155                Value::Binary(value_data.to_vec())
156            };
157
158            let tag_info = iptc_tags::lookup(record, dataset);
159            let (name, description) = match tag_info {
160                Some(info) => (info.name.to_string(), info.description.to_string()),
161                None => {
162                    // Suppress unknown IPTC records (don't emit IPTC:N:N format)
163                    continue;
164                }
165            };
166
167            let base = value.to_display_string();
168            let print_value = iptc_print_conv(record, dataset, &base).unwrap_or(base);
169
170            // Repeatable datasets (Keywords, SupplementalCategories, ...) appear
171            // multiple times; ExifTool combines them into one comma-joined list.
172            let id_num = ((record as u16) << 8) | dataset as u16;
173            if let Some(existing) = tags
174                .iter_mut()
175                .find(|t| matches!(t.id, TagId::Numeric(n) if n == id_num))
176            {
177                let prev = std::mem::replace(&mut existing.raw_value, Value::U8(0));
178                let mut items = match prev {
179                    Value::List(v) => v,
180                    single => vec![single],
181                };
182                items.push(value);
183                existing.print_value = items
184                    .iter()
185                    .map(|v| v.to_display_string())
186                    .collect::<Vec<_>>()
187                    .join(", ");
188                existing.raw_value = Value::List(items);
189                continue;
190            }
191
192            tags.push(Tag {
193                id: TagId::Numeric(id_num),
194                name,
195                description,
196                group: TagGroup {
197                    family0: "IPTC".to_string(),
198                    family1: group1.to_string(),
199                    family2: "Other".to_string(),
200                    family3: "Main".into(),
201                },
202                raw_value: value,
203                print_value,
204                priority: 0,
205            });
206        }
207
208        Ok(tags)
209    }
210}
211
212/// IPTC PrintConv for the Application Record (record 2): date reformatting and
213/// the Urgency labels, matching ExifTool.
214/// Convert an IPTC time "HHMMSS[±HHMM]" to ExifTool's "HH:MM:SS[±HH:MM]".
215fn convert_iptc_time(s: &str) -> Option<String> {
216    let b = s.as_bytes();
217    if b.len() < 6 || !b[0..6].iter().all(|c| c.is_ascii_digit()) {
218        return None;
219    }
220    let mut out = format!("{}:{}:{}", &s[0..2], &s[2..4], &s[4..6]);
221    let tz = &s[6..];
222    if !tz.is_empty() {
223        let tb = tz.as_bytes();
224        if tb.len() == 5
225            && (tb[0] == b'+' || tb[0] == b'-')
226            && tb[1..].iter().all(|c| c.is_ascii_digit())
227        {
228            out.push_str(&format!("{}{}:{}", &tz[0..1], &tz[1..3], &tz[3..5]));
229        } else {
230            return None;
231        }
232    }
233    Some(out)
234}
235
236fn iptc_print_conv(record: u8, dataset: u8, s: &str) -> Option<String> {
237    if record != 2 {
238        return None;
239    }
240    let s = s.trim();
241    match dataset {
242        // ObjectPreviewFileFormat (200): %fileFormat enum; unrecognized -> "Unknown (val)".
243        200 => {
244            let name = match s {
245                "0" => Some("No ObjectData"),
246                "1" => Some("IPTC-NAA Digital Newsphoto Parameter Record"),
247                "2" => Some("IPTC7901 Recommended Message Format"),
248                "3" => Some("Tagged Image File Format (Adobe/Aldus Image data)"),
249                "4" => Some("Illustrator (Adobe Graphics data)"),
250                "5" => Some("AppleSingle (Apple Computer Inc)"),
251                _ => None,
252            };
253            Some(
254                name.map(|n| n.to_string())
255                    .unwrap_or_else(|| format!("Unknown ({})", s)),
256            )
257        }
258        // DateCreated (55), DigitizationDate (62): YYYYMMDD -> YYYY:MM:DD
259        55 | 62 if s.len() == 8 && s.bytes().all(|b| b.is_ascii_digit()) => {
260            Some(format!("{}:{}:{}", &s[0..4], &s[4..6], &s[6..8]))
261        }
262        // TimeCreated (60), DigitalCreationTime (63): HHMMSS[±HHMM] -> HH:MM:SS[±HH:MM]
263        60 | 63 => convert_iptc_time(s),
264        // Urgency (10)
265        10 => Some(
266            match s {
267                "0" => "0 (reserved)",
268                "1" => "1 (most urgent)",
269                "5" => "5 (normal urgency)",
270                "8" => "8 (least urgent)",
271                _ => return None,
272            }
273            .to_string(),
274        ),
275        // Prefs (221): IPTC.pm:655 rewrites the first " N: N: N:S" run into the
276        // labelled form "Tagged:N, ColorClass:N, Rating:N, FrameNum:S". The
277        // substitution is applied once; text that doesn't match is returned as-is.
278        221 => Some(prefs_print_conv(s)),
279        // ObjectCycle (75): PrintConv { a, p, b }, unmatched → "Unknown ($val)".
280        75 => Some(match s {
281            "a" => "Morning".to_string(),
282            "p" => "Evening".to_string(),
283            "b" => "Both Morning and Evening".to_string(),
284            other => format!("Unknown ({})", other),
285        }),
286        _ => None,
287    }
288}
289
290/// Perl PrintConv for IPTC Prefs (record 2 dataset 221, IPTC.pm:655):
291///   `s[\s*(\d+):\s*(\d+):\s*(\d+):\s*(\S*)][Tagged:$1, ColorClass:$2, Rating:$3, FrameNum:$4]`
292/// applied once. `\S*` is greedy but stops at whitespace, so FrameNum captures
293/// the remaining non-space token (e.g. a negative "-00001").
294fn prefs_print_conv(s: &str) -> String {
295    let b = s.as_bytes();
296    // Scan for the leftmost match of the regex.
297    for start in 0..b.len() {
298        let mut i = start;
299        // \s*
300        while i < b.len() && b[i].is_ascii_whitespace() {
301            i += 1;
302        }
303        let mut nums: [&str; 3] = [""; 3];
304        let mut ok = true;
305        for num in &mut nums {
306            let d0 = i;
307            while i < b.len() && b[i].is_ascii_digit() {
308                i += 1;
309            }
310            if i == d0 || i >= b.len() || b[i] != b':' {
311                ok = false;
312                break;
313            }
314            *num = &s[d0..i];
315            i += 1; // consume ':'
316                    // \s* before the next field
317            while i < b.len() && b[i].is_ascii_whitespace() {
318                i += 1;
319            }
320        }
321        if !ok {
322            continue;
323        }
324        // (\S*) — the FrameNum token: every following non-whitespace char.
325        let f0 = i;
326        while i < b.len() && !b[i].is_ascii_whitespace() {
327            i += 1;
328        }
329        let frame = &s[f0..i];
330        return format!(
331            "{}Tagged:{}, ColorClass:{}, Rating:{}, FrameNum:{}{}",
332            &s[..start],
333            nums[0],
334            nums[1],
335            nums[2],
336            frame,
337            &s[i..]
338        );
339    }
340    s.to_string()
341}
342
343/// Look up a PhotoMechanic SoftEdit field (IPTC record 2, dataset 209-239).
344/// Returns (tag_name, print_value) or None if unknown.
345fn lookup_photomechanic(dataset: u8, value: &Value) -> Option<(String, String)> {
346    // PhotoMechanic fields are FORMAT='int32s' - 4 bytes big-endian signed int
347    let int_val = if let Value::Binary(ref b) = value {
348        if b.len() == 4 {
349            i32::from_be_bytes([b[0], b[1], b[2], b[3]])
350        } else {
351            return None;
352        }
353    } else {
354        return None;
355    };
356
357    let color_classes = [
358        "0 (None)",
359        "1 (Winner)",
360        "2 (Winner alt)",
361        "3 (Superior)",
362        "4 (Superior alt)",
363        "5 (Typical)",
364        "6 (Typical alt)",
365        "7 (Extras)",
366        "8 (Trash)",
367    ];
368
369    match dataset {
370        209 => Some((
371            "RawCropLeft".to_string(),
372            format!("{:.3}%", int_val as f64 / 655.36),
373        )),
374        210 => Some((
375            "RawCropTop".to_string(),
376            format!("{:.3}%", int_val as f64 / 655.36),
377        )),
378        211 => Some((
379            "RawCropRight".to_string(),
380            format!("{:.3}%", int_val as f64 / 655.36),
381        )),
382        212 => Some((
383            "RawCropBottom".to_string(),
384            format!("{:.3}%", int_val as f64 / 655.36),
385        )),
386        213 => Some(("ConstrainedCropWidth".to_string(), int_val.to_string())),
387        214 => Some(("ConstrainedCropHeight".to_string(), int_val.to_string())),
388        215 => Some(("FrameNum".to_string(), int_val.to_string())),
389        216 => {
390            let rot = match int_val {
391                0 => "0",
392                1 => "90",
393                2 => "180",
394                3 => "270",
395                _ => "0",
396            };
397            Some(("Rotation".to_string(), rot.to_string()))
398        }
399        217 => Some(("CropLeft".to_string(), int_val.to_string())),
400        218 => Some(("CropTop".to_string(), int_val.to_string())),
401        219 => Some(("CropRight".to_string(), int_val.to_string())),
402        220 => Some(("CropBottom".to_string(), int_val.to_string())),
403        221 => {
404            let v = if int_val == 0 { "No" } else { "Yes" };
405            Some(("Tagged".to_string(), v.to_string()))
406        }
407        222 => {
408            let idx = int_val as usize;
409            let class = if idx < color_classes.len() {
410                color_classes[idx].to_string()
411            } else {
412                format!("{}", int_val)
413            };
414            Some(("ColorClass".to_string(), class))
415        }
416        223 => Some(("Rating".to_string(), int_val.to_string())),
417        236 => Some((
418            "PreviewCropLeft".to_string(),
419            format!("{:.3}%", int_val as f64 / 655.36),
420        )),
421        237 => Some((
422            "PreviewCropTop".to_string(),
423            format!("{:.3}%", int_val as f64 / 655.36),
424        )),
425        238 => Some((
426            "PreviewCropRight".to_string(),
427            format!("{:.3}%", int_val as f64 / 655.36),
428        )),
429        239 => Some((
430            "PreviewCropBottom".to_string(),
431            format!("{:.3}%", int_val as f64 / 655.36),
432        )),
433        _ => None,
434    }
435}