1use std::path::Path;
6
7const XMP_APP1: &[u8] = b"http://ns.adobe.com/xap/1.0/\0";
9const XMP_PNG: &[u8] = b"XML:com.adobe.xmp";
11const MAX_FIELD: usize = 200;
13
14pub 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
31pub 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
45pub 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
70fn 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 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
96pub 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 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
124pub 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 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
152pub 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
190pub 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 & 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]; 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 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]); 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]); 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("AB&bogus <"), "AB&bogus <");
358 }
359}