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/// IPTC metadata reader.
12pub struct IptcReader;
13
14impl IptcReader {
15    /// Parse IPTC data from a raw byte slice.
16    ///
17    /// IPTC-IIM format: sequences of records, each:
18    ///   - 1 byte:  tag marker (0x1C)
19    ///   - 1 byte:  record number
20    ///   - 1 byte:  dataset number
21    ///   - 2 bytes: data length (big-endian), or extended if >= 0x8000
22    ///   - N bytes: data
23    pub fn read(data: &[u8]) -> Result<Vec<Tag>> {
24        let mut tags = Vec::new();
25        let mut pos = 0;
26
27        while pos + 5 <= data.len() {
28            // Check for IPTC tag marker
29            if data[pos] != 0x1C {
30                // Skip non-IPTC data
31                pos += 1;
32                continue;
33            }
34
35            let record = data[pos + 1];
36            let dataset = data[pos + 2];
37            let length = u16::from_be_bytes([data[pos + 3], data[pos + 4]]) as usize;
38
39            pos += 5;
40
41            // Extended dataset length (bit 15 set means the length field itself
42            // gives the number of bytes in an extended length that follows)
43            if length >= 0x8000 {
44                // Skip extended length datasets for now
45                break;
46            }
47
48            if pos + length > data.len() {
49                break;
50            }
51
52            let value_data = &data[pos..pos + length];
53            pos += length;
54
55            // Only handle Application Record (record 2) for now, it has the useful tags
56            let ifd_name = match record {
57                1 => "IPTCEnvelope",
58                2 => "IPTCApplication",
59                _ => continue,
60            };
61
62            // Check for PhotoMechanic SoftEdit fields BEFORE string decoding
63            // (These are int32s, not strings, so must be decoded as binary)
64            if record == 2 && dataset >= 209 && dataset <= 222 {
65                // Decode as binary (int32s)
66                let bin_value = Value::Binary(value_data.to_vec());
67                if let Some((pm_name, pm_print)) = lookup_photomechanic(dataset, &bin_value) {
68                    tags.push(Tag {
69                        id: TagId::Numeric(((record as u16) << 8) | dataset as u16),
70                        name: pm_name.clone(),
71                        description: pm_name,
72                        group: TagGroup {
73                            family0: "PhotoMechanic".to_string(),
74                            family1: "PhotoMechanic".to_string(),
75                            family2: "Image".to_string(),
76                        },
77                        raw_value: bin_value,
78                        print_value: pm_print,
79                        priority: 0,
80                    });
81                    continue;
82                }
83            }
84
85            let value = if iptc_tags::is_string_tag(record, dataset) {
86                Value::String(
87                    String::from_utf8_lossy(value_data)
88                        .trim_end_matches('\0')
89                        .to_string(),
90                )
91            } else if length <= 2 {
92                match length {
93                    1 => Value::U8(value_data[0]),
94                    2 => Value::U16(u16::from_be_bytes([value_data[0], value_data[1]])),
95                    _ => Value::Binary(value_data.to_vec()),
96                }
97            } else {
98                Value::Binary(value_data.to_vec())
99            };
100
101            let tag_info = iptc_tags::lookup(record, dataset);
102            let (name, description) = match tag_info {
103                Some(info) => (info.name.to_string(), info.description.to_string()),
104                None => {
105                    // Suppress unknown IPTC records (don't emit IPTC:N:N format)
106                    continue;
107                },
108            };
109
110            let print_value = value.to_display_string();
111
112            tags.push(Tag {
113                id: TagId::Numeric(((record as u16) << 8) | dataset as u16),
114                name,
115                description,
116                group: TagGroup {
117                    family0: "IPTC".to_string(),
118                    family1: ifd_name.to_string(),
119                    family2: "Other".to_string(),
120                },
121                raw_value: value,
122                print_value,
123                priority: 0,
124            });
125        }
126
127        Ok(tags)
128    }
129}
130
131/// Look up a PhotoMechanic SoftEdit field (IPTC record 2, dataset 209-239).
132/// Returns (tag_name, print_value) or None if unknown.
133fn lookup_photomechanic(dataset: u8, value: &Value) -> Option<(String, String)> {
134    // PhotoMechanic fields are FORMAT='int32s' - 4 bytes big-endian signed int
135    let int_val = if let Value::Binary(ref b) = value {
136        if b.len() == 4 {
137            i32::from_be_bytes([b[0], b[1], b[2], b[3]])
138        } else {
139            return None;
140        }
141    } else {
142        return None;
143    };
144
145    let color_classes = [
146        "0 (None)", "1 (Winner)", "2 (Winner alt)", "3 (Superior)",
147        "4 (Superior alt)", "5 (Typical)", "6 (Typical alt)", "7 (Extras)", "8 (Trash)",
148    ];
149
150    match dataset {
151        209 => Some(("RawCropLeft".to_string(), format!("{:.3}%", int_val as f64 / 655.36))),
152        210 => Some(("RawCropTop".to_string(), format!("{:.3}%", int_val as f64 / 655.36))),
153        211 => Some(("RawCropRight".to_string(), format!("{:.3}%", int_val as f64 / 655.36))),
154        212 => Some(("RawCropBottom".to_string(), format!("{:.3}%", int_val as f64 / 655.36))),
155        213 => Some(("ConstrainedCropWidth".to_string(), int_val.to_string())),
156        214 => Some(("ConstrainedCropHeight".to_string(), int_val.to_string())),
157        215 => Some(("FrameNum".to_string(), int_val.to_string())),
158        216 => {
159            let rot = match int_val {
160                0 => "0", 1 => "90", 2 => "180", 3 => "270", _ => "0",
161            };
162            Some(("Rotation".to_string(), rot.to_string()))
163        }
164        217 => Some(("CropLeft".to_string(), int_val.to_string())),
165        218 => Some(("CropTop".to_string(), int_val.to_string())),
166        219 => Some(("CropRight".to_string(), int_val.to_string())),
167        220 => Some(("CropBottom".to_string(), int_val.to_string())),
168        221 => {
169            let v = if int_val == 0 { "No" } else { "Yes" };
170            Some(("Tagged".to_string(), v.to_string()))
171        }
172        222 => {
173            let idx = int_val as usize;
174            let class = if idx < color_classes.len() {
175                color_classes[idx].to_string()
176            } else {
177                format!("{}", int_val)
178            };
179            Some(("ColorClass".to_string(), class))
180        }
181        223 => Some(("Rating".to_string(), int_val.to_string())),
182        236 => Some(("PreviewCropLeft".to_string(), format!("{:.3}%", int_val as f64 / 655.36))),
183        237 => Some(("PreviewCropTop".to_string(), format!("{:.3}%", int_val as f64 / 655.36))),
184        238 => Some(("PreviewCropRight".to_string(), format!("{:.3}%", int_val as f64 / 655.36))),
185        239 => Some(("PreviewCropBottom".to_string(), format!("{:.3}%", int_val as f64 / 655.36))),
186        _ => None,
187    }
188}