Skip to main content

falcon_library/
keywords.rs

1//! Embedded keywords that can become tags on import: IPTC keywords (IIM
2//! record 2, dataset 25) and XMP `dc:subject`, read from JPEG, PNG and TIFF.
3//! See the Import chapter of the docs for how Eagle does it.
4
5use std::path::Path;
6
7/// Header of the APP1 segment that holds an XMP packet.
8const XMP_APP1: &[u8] = b"http://ns.adobe.com/xap/1.0/\0";
9/// Keyword of the PNG `iTXt` chunk that holds an XMP packet.
10const XMP_PNG: &[u8] = b"XML:com.adobe.xmp";
11/// Longest keyword field Eagle accepts, in UTF-16 units.
12const MAX_FIELD: usize = 200;
13
14/// The keywords of a file, IPTC first, then XMP, for the formats falcon
15/// reads (jpg, jpeg, png, tif, tiff). Each field over 200 characters is
16/// dropped, the rest are split on `;`, and duplicates and empty parts are
17/// removed.
18pub fn read(path: &Path, ext: &str) -> Vec<String> {
19    let Ok(data) = std::fs::read(path) else { return Vec::new() };
20    let (iptc, xmp) = match crate::alias::canonical(ext).as_str() {
21        "jpg" | "jpeg" => jpeg_blocks(&data),
22        "png" => (None, png_xmp(&data)),
23        "tiff" => tiff_blocks(&data),
24        _ => (None, None),
25    };
26    let mut fields = iptc.map(|b| iim_keywords(&b)).unwrap_or_default();
27    fields.extend(xmp.map(|x| xmp_subjects(&String::from_utf8_lossy(&x))).unwrap_or_default());
28    split_fields(fields)
29}
30
31/// Applies Eagle's field rules: skip fields over 200 characters, split on
32/// `;`, drop empty parts and repeats.
33pub fn split_fields(fields: Vec<String>) -> Vec<String> {
34    let mut out: Vec<String> = Vec::new();
35    for f in fields.iter().filter(|f| f.encode_utf16().count() <= MAX_FIELD) {
36        for part in f.split(';') {
37            if !part.is_empty() && !out.iter().any(|o| o == part) {
38                out.push(part.to_string());
39            }
40        }
41    }
42    out
43}
44
45/// The IPTC block (from Photoshop's APP13 resource 0x0404) and the XMP
46/// packet of a JPEG, scanning the segments up to the image data.
47pub fn jpeg_blocks(data: &[u8]) -> (Option<Vec<u8>>, Option<Vec<u8>>) {
48    let (mut iptc, mut xmp) = (None, None);
49    if data.get(..2) != Some(&[0xFF, 0xD8]) {
50        return (None, None);
51    }
52    let mut at = 2;
53    while at + 4 <= data.len() && data[at] == 0xFF {
54        let marker = data[at + 1];
55        if marker == 0xDA || marker == 0xD9 {
56            break;
57        }
58        let len = usize::from(u16::from_be_bytes([data[at + 2], data[at + 3]]));
59        let Some(body) = data.get(at + 4..at + 2 + len) else { break };
60        if marker == 0xED && iptc.is_none() {
61            iptc = photoshop_iptc(body);
62        } else if marker == 0xE1 && xmp.is_none() && body.starts_with(XMP_APP1) {
63            xmp = Some(body[XMP_APP1.len()..].to_vec());
64        }
65        at += 2 + len;
66    }
67    (iptc, xmp)
68}
69
70/// Resource 0x0404 (IPTC-NAA) inside a "Photoshop 3.0" image-resource block.
71fn photoshop_iptc(body: &[u8]) -> Option<Vec<u8>> {
72    let mut at = b"Photoshop 3.0\0".len();
73    if !body.starts_with(b"Photoshop 3.0") {
74        return None;
75    }
76    while at + 12 <= body.len() {
77        if &body[at..at + 4] != b"8BIM" {
78            return None;
79        }
80        let id = u16::from_be_bytes([body[at + 4], body[at + 5]]);
81        // Pascal name, padded to an even length including its length byte.
82        let name_len = usize::from(body[at + 6]);
83        let name_total = (name_len + 1).div_ceil(2) * 2;
84        let size_at = at + 6 + name_total;
85        let size = u32::from_be_bytes(body.get(size_at..size_at + 4)?.try_into().ok()?) as usize;
86        let start = size_at + 4;
87        let block = body.get(start..start.checked_add(size)?)?;
88        if id == 0x0404 {
89            return Some(block.to_vec());
90        }
91        at = start + size + (size & 1);
92    }
93    None
94}
95
96/// Keywords (record 2, dataset 25) from an IPTC IIM block.
97pub fn iim_keywords(block: &[u8]) -> Vec<String> {
98    let mut out = Vec::new();
99    let mut at = 0;
100    while at + 5 <= block.len() {
101        if block[at] != 0x1C {
102            at += 1;
103            continue;
104        }
105        let (record, dataset) = (block[at + 1], block[at + 2]);
106        let mut len = usize::from(u16::from_be_bytes([block[at + 3], block[at + 4]]));
107        let mut start = at + 5;
108        if len & 0x8000 != 0 {
109            // Extended length: the low bits say how many bytes hold it.
110            let n = len & 0x7FFF;
111            let Some(bytes) = block.get(start..start + n) else { break };
112            len = bytes.iter().fold(0usize, |a, &b| (a << 8) | usize::from(b));
113            start += n;
114        }
115        let Some(value) = block.get(start..start + len) else { break };
116        if record == 2 && dataset == 25 {
117            out.push(String::from_utf8_lossy(value).into_owned());
118        }
119        at = start + len;
120    }
121    out
122}
123
124/// The XMP packet of a PNG's `iTXt` chunk.
125pub fn png_xmp(data: &[u8]) -> Option<Vec<u8>> {
126    if data.get(..8) != Some(b"\x89PNG\r\n\x1a\n") {
127        return None;
128    }
129    let mut at = 8;
130    while at + 12 <= data.len() {
131        let len = u32::from_be_bytes(data[at..at + 4].try_into().ok()?) as usize;
132        let kind = &data[at + 4..at + 8];
133        let body = data.get(at + 8..at + 8 + len)?;
134        if kind == b"iTXt" && body.starts_with(XMP_PNG) && body.get(XMP_PNG.len()) == Some(&0) {
135            let rest = &body[XMP_PNG.len() + 1..];
136            let (compressed, rest) = (*rest.first()? == 1, rest.get(2..)?);
137            // Skip the language tag and the translated keyword.
138            let lang_end = rest.iter().position(|&b| b == 0)?;
139            let rest = &rest[lang_end + 1..];
140            let kw_end = rest.iter().position(|&b| b == 0)?;
141            let text = &rest[kw_end + 1..];
142            return if compressed { miniz_oxide::inflate::decompress_to_vec_zlib(text).ok() } else { Some(text.to_vec()) };
143        }
144        if kind == b"IEND" {
145            break;
146        }
147        at += 12 + len;
148    }
149    None
150}
151
152/// The IPTC (tag 33723) and XMP (tag 700) entries of a TIFF's first IFD.
153pub fn tiff_blocks(data: &[u8]) -> (Option<Vec<u8>>, Option<Vec<u8>>) {
154    let le = match data.get(..4) {
155        Some(b"II*\0") => true,
156        Some(b"MM\0*") => false,
157        _ => return (None, None),
158    };
159    let u16_at = |o: usize| data.get(o..o + 2).map(|b| if le { u16::from_le_bytes([b[0], b[1]]) } else { u16::from_be_bytes([b[0], b[1]]) });
160    let u32_at = |o: usize| {
161        data.get(o..o + 4).map(|b| {
162            let a = [b[0], b[1], b[2], b[3]];
163            if le { u32::from_le_bytes(a) } else { u32::from_be_bytes(a) }
164        })
165    };
166    let (mut iptc, mut xmp) = (None, None);
167    let Some(ifd) = u32_at(4).map(|o| o as usize) else { return (None, None) };
168    let count = u16_at(ifd).unwrap_or(0);
169    for i in 0..usize::from(count) {
170        let e = ifd + 2 + i * 12;
171        let (Some(tag), Some(typ), Some(n)) = (u16_at(e), u16_at(e + 2), u32_at(e + 4)) else { break };
172        let unit = match typ {
173            1 | 2 | 6 | 7 => 1,
174            3 | 8 => 2,
175            4 | 9 => 4,
176            _ => continue,
177        };
178        let size = n as usize * unit;
179        let start = if size <= 4 { e + 8 } else { u32_at(e + 8).unwrap_or(0) as usize };
180        let Some(bytes) = data.get(start..start.saturating_add(size)) else { continue };
181        match tag {
182            33723 => iptc = Some(bytes.to_vec()),
183            700 => xmp = Some(bytes.to_vec()),
184            _ => {}
185        }
186    }
187    (iptc, xmp)
188}
189
190/// The `rdf:li` items of an XMP packet's `dc:subject`.
191pub fn xmp_subjects(xmp: &str) -> Vec<String> {
192    let Some(start) = xmp.find("<dc:subject") else { return Vec::new() };
193    let rest = &xmp[start..];
194    let end = rest.find("</dc:subject>").unwrap_or(rest.len());
195    let mut body = &rest[..end];
196    let mut out = Vec::new();
197    while let Some(i) = body.find("<rdf:li") {
198        body = &body[i..];
199        let Some(open_end) = body.find('>') else { break };
200        if body[..open_end].ends_with('/') {
201            body = &body[open_end + 1..];
202            continue;
203        }
204        body = &body[open_end + 1..];
205        let Some(close) = body.find("</rdf:li>") else { break };
206        let text = unescape(body[..close].trim());
207        if !text.is_empty() {
208            out.push(text);
209        }
210        body = &body[close..];
211    }
212    out
213}
214
215fn unescape(s: &str) -> String {
216    let mut out = String::with_capacity(s.len());
217    let mut rest = s;
218    while let Some(i) = rest.find('&') {
219        out.push_str(&rest[..i]);
220        rest = &rest[i..];
221        let Some(end) = rest.find(';') else { break };
222        let ent = &rest[1..end];
223        let ch = match ent {
224            "amp" => Some('&'),
225            "lt" => Some('<'),
226            "gt" => Some('>'),
227            "quot" => Some('"'),
228            "apos" => Some('\''),
229            _ => ent
230                .strip_prefix("#x")
231                .and_then(|h| u32::from_str_radix(h, 16).ok())
232                .or_else(|| ent.strip_prefix('#').and_then(|d| d.parse().ok()))
233                .and_then(char::from_u32),
234        };
235        match ch {
236            Some(c) => {
237                out.push(c);
238                rest = &rest[end + 1..];
239            }
240            None => {
241                out.push('&');
242                rest = &rest[1..];
243            }
244        }
245    }
246    out.push_str(rest);
247    out
248}
249
250#[cfg(test)]
251pub(crate) mod tests {
252    use super::*;
253
254    pub(crate) const XMP: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
255<rdf:Description xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:subject><rdf:Bag>
256<rdf:li>sunset</rdf:li><rdf:li>sea &amp; sky</rdf:li><rdf:li>beach</rdf:li></rdf:Bag></dc:subject></rdf:Description></rdf:RDF></x:xmpmeta>"#;
257
258    fn iim(keywords: &[&str]) -> Vec<u8> {
259        let mut b = vec![0x1C, 2, 0, 0, 2, 0, 4]; // record version
260        for k in keywords {
261            b.extend_from_slice(&[0x1C, 2, 25]);
262            b.extend_from_slice(&(k.len() as u16).to_be_bytes());
263            b.extend_from_slice(k.as_bytes());
264        }
265        b
266    }
267
268    fn segment(marker: u8, body: &[u8]) -> Vec<u8> {
269        let mut s = vec![0xFF, marker];
270        s.extend_from_slice(&((body.len() + 2) as u16).to_be_bytes());
271        s.extend_from_slice(body);
272        s
273    }
274
275    /// A JPEG header with an APP13 IPTC block and an APP1 XMP packet (no
276    /// image data; only the segments are read).
277    pub(crate) fn jpeg_with_keywords() -> Vec<u8> {
278        let block = iim(&["sunset", "a;b", "sunset"]);
279        let mut ps = b"Photoshop 3.0\0".to_vec();
280        ps.extend_from_slice(b"8BIM");
281        ps.extend_from_slice(&0x0404u16.to_be_bytes());
282        ps.extend_from_slice(&[0, 0]); // empty name, padded
283        ps.extend_from_slice(&(block.len() as u32).to_be_bytes());
284        ps.extend_from_slice(&block);
285        if block.len() % 2 == 1 {
286            ps.push(0);
287        }
288        let mut jpeg = vec![0xFF, 0xD8];
289        jpeg.extend(segment(0xE0, b"JFIF\0\x01\x01\0\0\x01\0\x01\0\0"));
290        jpeg.extend(segment(0xED, &ps));
291        let mut app1 = XMP_APP1.to_vec();
292        app1.extend_from_slice(XMP.as_bytes());
293        jpeg.extend(segment(0xE1, &app1));
294        jpeg.extend_from_slice(&[0xFF, 0xDA, 0, 2, 0xFF, 0xD9]);
295        jpeg
296    }
297
298    pub(crate) fn png_with_xmp(compressed: bool) -> Vec<u8> {
299        let img = image::RgbImage::from_pixel(2, 2, image::Rgb([200, 30, 30]));
300        let mut png = Vec::new();
301        img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
302        let mut body = XMP_PNG.to_vec();
303        body.extend_from_slice(&[0, u8::from(compressed), 0, 0, 0]);
304        if compressed {
305            body.extend(miniz_oxide::deflate::compress_to_vec_zlib(XMP.as_bytes(), 6));
306        } else {
307            body.extend_from_slice(XMP.as_bytes());
308        }
309        let mut chunk = (body.len() as u32).to_be_bytes().to_vec();
310        chunk.extend_from_slice(b"iTXt");
311        chunk.extend_from_slice(&body);
312        chunk.extend_from_slice(&[0; 4]); // CRC, not checked here
313        // Insert after IHDR (8-byte signature + 25-byte chunk).
314        png.splice(33..33, chunk);
315        png
316    }
317
318    #[test]
319    fn jpeg_keywords() {
320        let dir = tempfile::tempdir().unwrap();
321        let p = dir.path().join("k.jpg");
322        std::fs::write(&p, jpeg_with_keywords()).unwrap();
323        assert_eq!(read(&p, "jpg"), ["sunset", "a", "b", "sea & sky", "beach"]);
324    }
325
326    #[test]
327    fn png_keywords() {
328        for compressed in [false, true] {
329            assert_eq!(xmp_subjects(&String::from_utf8(png_xmp(&png_with_xmp(compressed)).unwrap()).unwrap()), ["sunset", "sea & sky", "beach"]);
330        }
331    }
332
333    #[test]
334    fn tiff_keywords() {
335        let block = iim(&["red"]);
336        let mut t = b"II*\0\x08\0\0\0".to_vec();
337        t.extend_from_slice(&2u16.to_le_bytes());
338        let data_at = 8 + 2 + 24 + 4;
339        for (tag, typ, n, off) in [(700u16, 1u16, XMP.len() as u32, data_at as u32), (33723, 7, block.len() as u32, (data_at + XMP.len()) as u32)] {
340            t.extend_from_slice(&tag.to_le_bytes());
341            t.extend_from_slice(&typ.to_le_bytes());
342            t.extend_from_slice(&n.to_le_bytes());
343            t.extend_from_slice(&off.to_le_bytes());
344        }
345        t.extend_from_slice(&0u32.to_le_bytes());
346        t.extend_from_slice(XMP.as_bytes());
347        t.extend_from_slice(&block);
348        let (iptc, xmp) = tiff_blocks(&t);
349        assert_eq!(iim_keywords(&iptc.unwrap()), ["red"]);
350        assert_eq!(xmp_subjects(&String::from_utf8(xmp.unwrap()).unwrap()).len(), 3);
351    }
352
353    #[test]
354    fn field_rules() {
355        let long = "x".repeat(201);
356        assert_eq!(split_fields(vec![long, "a;;b".into(), "b".into()]), ["a", "b"]);
357        assert_eq!(unescape("&#x41;&#66;&bogus &lt;"), "AB&bogus <");
358    }
359}