1use std::collections::HashMap;
17
18use super::super::{fnv60, AssetRef, AssetRole, Descriptors, Place, Processor, ProcessorOutput};
19
20#[derive(Debug, Clone, Default, PartialEq)]
22pub struct ImageMetadata {
23 pub datetime_unix: Option<i64>,
25 pub lat: Option<f64>,
27 pub lon: Option<f64>,
28 pub make: Option<String>,
29 pub model: Option<String>,
30 pub width: Option<u32>,
31 pub height: Option<u32>,
32}
33
34impl ImageMetadata {
35 pub fn has_any(&self) -> bool {
36 self.datetime_unix.is_some()
37 || self.lat.is_some()
38 || self.make.is_some()
39 || self.model.is_some()
40 || self.width.is_some()
41 }
42}
43
44#[derive(Debug, Clone, Default)]
48pub struct ImageProcessor;
49
50impl ImageProcessor {
51 pub fn extract(bytes: &[u8]) -> Option<ImageMetadata> {
54 let meta = if is_jpeg(bytes) {
55 parse_jpeg(bytes)
56 } else if is_png(bytes) {
57 parse_png(bytes)
58 } else {
59 ImageMetadata::default()
60 };
61 if meta.has_any() {
62 Some(meta)
63 } else {
64 None
65 }
66 }
67}
68
69impl Processor for ImageProcessor {
70 fn handles(&self, media_type: &str) -> bool {
71 matches!(media_type, "image/jpeg" | "image/jpg" | "image/png")
72 }
73
74 fn process(&self, asset_uri: &str, bytes: &[u8], _media_type: &str) -> ProcessorOutput {
75 let meta = ImageProcessor::extract(bytes).unwrap_or_default();
76
77 let mut topics = vec!["image".to_string()];
78 for m in meta.make.iter().chain(meta.model.iter()) {
79 let m = m.trim();
80 if !m.is_empty() {
81 topics.push(m.to_lowercase());
82 }
83 }
84
85 let place = match (meta.lat, meta.lon) {
86 (Some(lat), Some(lon)) => Some(Place {
87 label: format!("{lat:.5},{lon:.5}"),
91 lat: lat as f32,
92 lon: lon as f32,
93 }),
94 _ => None,
95 };
96
97 let descriptors = Descriptors {
98 topics,
99 depicts: Vec::new(), occurred_at: meta.datetime_unix,
101 place,
102 document_type: Some("image".to_string()),
103 ..Default::default()
104 };
105
106 let mut summary = String::from("image");
109 if let Some(t) = meta.datetime_unix {
110 summary.push_str(&format!("; taken@{t}"));
111 }
112 if let (Some(lat), Some(lon)) = (meta.lat, meta.lon) {
113 summary.push_str(&format!("; at {lat:.5},{lon:.5}"));
114 }
115 if let Some(m) = &meta.make {
116 summary.push_str(&format!("; make {m}"));
117 }
118 if let Some(m) = &meta.model {
119 summary.push_str(&format!("; model {m}"));
120 }
121 if let (Some(w), Some(h)) = (meta.width, meta.height) {
122 summary.push_str(&format!("; {w}x{h}"));
123 }
124
125 let meta_uri = format!("{asset_uri}#exif");
126 let derived = vec![AssetRef::new(
127 &meta_uri,
128 fnv60(summary.as_bytes()),
129 "text/plain",
130 AssetRole::Analysis,
131 )
132 .derived_from(asset_uri)];
133 let mut derived_bytes = HashMap::new();
134 derived_bytes.insert(meta_uri, summary.into_bytes());
135
136 ProcessorOutput {
137 derived,
138 derived_bytes,
139 descriptors,
140 flags: Vec::new(),
141 }
142 }
143}
144
145fn is_jpeg(b: &[u8]) -> bool {
148 b.len() >= 3 && b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF
149}
150fn is_png(b: &[u8]) -> bool {
151 b.len() >= 8 && b[..8] == [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
152}
153
154fn parse_jpeg(b: &[u8]) -> ImageMetadata {
157 let mut i = 2; while i + 4 <= b.len() {
160 if b[i] != 0xFF {
161 break;
162 }
163 let marker = b[i + 1];
164 if marker == 0xD8 || marker == 0xD9 || (0xD0..=0xD7).contains(&marker) {
166 i += 2;
167 continue;
168 }
169 let seg_len = u16::from_be_bytes([b[i + 2], b[i + 3]]) as usize;
170 if seg_len < 2 || i + 2 + seg_len > b.len() {
171 break;
172 }
173 let seg = &b[i + 4..i + 2 + seg_len];
174 if marker == 0xE1 && seg.len() >= 6 && &seg[..6] == b"Exif\0\0" {
175 return parse_tiff(&seg[6..]);
176 }
177 if marker == 0xDA {
179 break;
180 }
181 i += 2 + seg_len;
182 }
183 ImageMetadata::default()
184}
185
186#[derive(Clone, Copy)]
189struct Tiff<'a> {
190 data: &'a [u8],
191 le: bool,
192}
193
194impl<'a> Tiff<'a> {
195 fn u16(&self, off: usize) -> Option<u16> {
196 let s = self.data.get(off..off + 2)?;
197 Some(if self.le {
198 u16::from_le_bytes([s[0], s[1]])
199 } else {
200 u16::from_be_bytes([s[0], s[1]])
201 })
202 }
203 fn u32(&self, off: usize) -> Option<u32> {
204 let s = self.data.get(off..off + 4)?;
205 Some(if self.le {
206 u32::from_le_bytes([s[0], s[1], s[2], s[3]])
207 } else {
208 u32::from_be_bytes([s[0], s[1], s[2], s[3]])
209 })
210 }
211}
212
213fn type_size(typ: u16) -> usize {
214 match typ {
215 1 | 2 | 6 | 7 => 1, 3 | 8 => 2, 4 | 9 | 11 => 4, 5 | 10 | 12 => 8, _ => 0,
220 }
221}
222
223struct Entry {
225 typ: u16,
226 count: u32,
227 value_off: usize,
229}
230
231fn parse_tiff(t: &[u8]) -> ImageMetadata {
232 let le = match t.get(..2) {
233 Some(b"II") => true,
234 Some(b"MM") => false,
235 _ => return ImageMetadata::default(),
236 };
237 let tiff = Tiff { data: t, le };
238 if tiff.u16(2) != Some(42) {
239 return ImageMetadata::default();
240 }
241 let ifd0 = match tiff.u32(4) {
242 Some(o) => o as usize,
243 None => return ImageMetadata::default(),
244 };
245
246 let mut meta = ImageMetadata::default();
247 let mut exif_ifd: Option<usize> = None;
248 let mut gps_ifd: Option<usize> = None;
249
250 walk_ifd(&tiff, ifd0, &mut |tag, e| match tag {
251 0x010F => meta.make = read_ascii(&tiff, e),
252 0x0110 => meta.model = read_ascii(&tiff, e),
253 0x0132 if meta.datetime_unix.is_none() => {
254 meta.datetime_unix = read_ascii(&tiff, e).and_then(|s| parse_exif_datetime(&s))
255 }
256 0x8769 => exif_ifd = read_long1(&tiff, e).map(|v| v as usize),
257 0x8825 => gps_ifd = read_long1(&tiff, e).map(|v| v as usize),
258 _ => {}
259 });
260
261 if let Some(off) = exif_ifd {
262 walk_ifd(&tiff, off, &mut |tag, e| match tag {
263 0x9003 => {
264 if let Some(dt) = read_ascii(&tiff, e).and_then(|s| parse_exif_datetime(&s)) {
266 meta.datetime_unix = Some(dt);
267 }
268 }
269 0xA002 => meta.width = read_long1(&tiff, e),
270 0xA003 => meta.height = read_long1(&tiff, e),
271 _ => {}
272 });
273 }
274
275 if let Some(off) = gps_ifd {
276 let mut lat_ref = None;
277 let mut lon_ref = None;
278 let mut lat = None;
279 let mut lon = None;
280 walk_ifd(&tiff, off, &mut |tag, e| match tag {
281 0x0001 => lat_ref = read_ascii(&tiff, e),
282 0x0002 => lat = read_gps_dms(&tiff, e),
283 0x0003 => lon_ref = read_ascii(&tiff, e),
284 0x0004 => lon = read_gps_dms(&tiff, e),
285 _ => {}
286 });
287 if let Some(mut v) = lat {
288 if lat_ref
289 .as_deref()
290 .map(|r| r.starts_with('S'))
291 .unwrap_or(false)
292 {
293 v = -v;
294 }
295 meta.lat = Some(v);
296 }
297 if let Some(mut v) = lon {
298 if lon_ref
299 .as_deref()
300 .map(|r| r.starts_with('W'))
301 .unwrap_or(false)
302 {
303 v = -v;
304 }
305 meta.lon = Some(v);
306 }
307 }
308
309 meta
310}
311
312fn walk_ifd(t: &Tiff<'_>, ifd_off: usize, f: &mut dyn FnMut(u16, &Entry)) {
315 let count = match t.u16(ifd_off) {
316 Some(c) => c as usize,
317 None => return,
318 };
319 for k in 0..count {
320 let e_off = ifd_off + 2 + k * 12;
321 let (tag, typ, cnt) = match (t.u16(e_off), t.u16(e_off + 2), t.u32(e_off + 4)) {
322 (Some(a), Some(b), Some(c)) => (a, b, c),
323 _ => return,
324 };
325 let sz = type_size(typ);
326 if sz == 0 {
327 continue;
328 }
329 let total = sz.saturating_mul(cnt as usize);
330 let value_off = if total <= 4 {
332 e_off + 8
333 } else {
334 match t.u32(e_off + 8) {
335 Some(o) => o as usize,
336 None => continue,
337 }
338 };
339 f(
340 tag,
341 &Entry {
342 typ,
343 count: cnt,
344 value_off,
345 },
346 );
347 }
348}
349
350fn read_ascii(t: &Tiff<'_>, e: &Entry) -> Option<String> {
351 if e.typ != 2 {
352 return None;
353 }
354 let n = e.count as usize;
355 let raw = t.data.get(e.value_off..e.value_off + n)?;
356 let end = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
357 let s = std::str::from_utf8(&raw[..end]).ok()?.trim().to_string();
358 if s.is_empty() {
359 None
360 } else {
361 Some(s)
362 }
363}
364
365fn read_long1(t: &Tiff<'_>, e: &Entry) -> Option<u32> {
367 match e.typ {
368 4 => t.u32(e.value_off),
369 3 => t.u16(e.value_off).map(|v| v as u32),
370 _ => None,
371 }
372}
373
374fn read_rational(t: &Tiff<'_>, off: usize) -> Option<f64> {
376 let num = t.u32(off)? as f64;
377 let den = t.u32(off + 4)? as f64;
378 if den == 0.0 {
379 Some(0.0)
380 } else {
381 Some(num / den)
382 }
383}
384
385fn read_gps_dms(t: &Tiff<'_>, e: &Entry) -> Option<f64> {
387 if e.typ != 5 || e.count < 3 {
388 return None;
389 }
390 let d = read_rational(t, e.value_off)?;
391 let m = read_rational(t, e.value_off + 8)?;
392 let s = read_rational(t, e.value_off + 16)?;
393 Some(d + m / 60.0 + s / 3600.0)
394}
395
396fn parse_exif_datetime(s: &str) -> Option<i64> {
398 let s = s.trim();
399 let (date, time) = match s.split_once(' ') {
401 Some((d, t)) => (d, Some(t)),
402 None => (s, None),
403 };
404 let mut dp = date.split(&[':', '-'][..]);
405 let y: i64 = dp.next()?.parse().ok()?;
406 let mo: i64 = dp.next()?.parse().ok()?;
407 let d: i64 = dp.next()?.parse().ok()?;
408 if !(1..=12).contains(&mo) || !(1..=31).contains(&d) {
409 return None;
410 }
411 let (mut h, mut mi, mut se) = (0i64, 0i64, 0i64);
412 if let Some(t) = time {
413 let mut tp = t.split(':');
414 h = tp.next()?.parse().ok()?;
415 mi = tp.next().and_then(|x| x.parse().ok()).unwrap_or(0);
416 se = tp.next().and_then(|x| x.parse().ok()).unwrap_or(0);
417 }
418 let _ = (&mut h, &mut mi, &mut se);
419 Some(days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + se)
420}
421
422fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
425 let y = y - (m <= 2) as i64;
426 let era = (if y >= 0 { y } else { y - 399 }) / 400;
427 let yoe = y - era * 400; let mp = if m > 2 { m - 3 } else { m + 9 }; let doy = (153 * mp + 2) / 5 + d - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146097 + doe - 719468
432}
433
434fn parse_png(b: &[u8]) -> ImageMetadata {
437 let mut meta = ImageMetadata::default();
438 let mut i = 8; while i + 8 <= b.len() {
441 let len = u32::from_be_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize;
442 let typ = match b.get(i + 4..i + 8) {
443 Some(t) => t,
444 None => break,
445 };
446 let data_start = i + 8;
447 let data_end = match data_start.checked_add(len) {
448 Some(e) if e <= b.len() => e,
449 _ => break,
450 };
451 let data = &b[data_start..data_end];
452 match typ {
453 b"IHDR" if data.len() >= 8 => {
454 meta.width = Some(u32::from_be_bytes([data[0], data[1], data[2], data[3]]));
455 meta.height = Some(u32::from_be_bytes([data[4], data[5], data[6], data[7]]));
456 }
457 b"tIME" if data.len() >= 7 && meta.datetime_unix.is_none() => {
458 let year = u16::from_be_bytes([data[0], data[1]]) as i64;
459 let (mo, d, h, mi, se) = (
460 data[2] as i64,
461 data[3] as i64,
462 data[4] as i64,
463 data[5] as i64,
464 data[6] as i64,
465 );
466 if (1..=12).contains(&mo) && (1..=31).contains(&d) {
467 meta.datetime_unix =
468 Some(days_from_civil(year, mo, d) * 86_400 + h * 3600 + mi * 60 + se);
469 }
470 }
471 b"eXIf" => {
472 let exif = parse_tiff(data);
473 if exif.has_any() {
474 let (w, h) = (meta.width, meta.height);
476 meta = exif;
477 meta.width = meta.width.or(w);
478 meta.height = meta.height.or(h);
479 }
480 }
481 b"IEND" => break,
482 _ => {}
483 }
484 i = data_end + 4; }
486 meta
487}
488
489#[cfg(test)]
490mod tests {
491 use super::super::super::{by_place, in_time_range, ingest_with};
492 use super::*;
493
494 fn tiff_with_datetime_and_gps() -> Vec<u8> {
497 let mut b = Vec::new();
500 b.extend_from_slice(b"II"); b.extend_from_slice(&42u16.to_le_bytes());
503 b.extend_from_slice(&8u32.to_le_bytes()); let exif_ifd = 38u32;
511 let gps_ifd = 56u32;
512 let heap = 110u32;
513
514 b.extend_from_slice(&2u16.to_le_bytes()); push_entry(&mut b, 0x8769, 4, 1, exif_ifd);
518 push_entry(&mut b, 0x8825, 4, 1, gps_ifd);
520 b.extend_from_slice(&0u32.to_le_bytes()); assert_eq!(b.len(), exif_ifd as usize);
522
523 let dt = b"2021:07:14 09:30:00\0"; let dt_off = heap;
526 b.extend_from_slice(&1u16.to_le_bytes());
527 push_entry(&mut b, 0x9003, 2, dt.len() as u32, dt_off); b.extend_from_slice(&0u32.to_le_bytes());
529 assert_eq!(b.len(), gps_ifd as usize);
530
531 let latref_inline = u32::from_le_bytes([b'N', 0, 0, 0]);
533 let lonref_inline = u32::from_le_bytes([b'E', 0, 0, 0]);
534 let lat_off = dt_off + dt.len() as u32; let lon_off = lat_off + 24; b.extend_from_slice(&4u16.to_le_bytes());
537 push_entry(&mut b, 0x0001, 2, 2, latref_inline); push_entry(&mut b, 0x0002, 5, 3, lat_off); push_entry(&mut b, 0x0003, 2, 2, lonref_inline); push_entry(&mut b, 0x0004, 5, 3, lon_off); b.extend_from_slice(&0u32.to_le_bytes());
542 assert_eq!(b.len(), heap as usize);
543
544 b.extend_from_slice(dt); push_rational(&mut b, 48, 1);
548 push_rational(&mut b, 51, 1);
549 push_rational(&mut b, 30, 1);
550 push_rational(&mut b, 2, 1);
552 push_rational(&mut b, 17, 1);
553 push_rational(&mut b, 40, 1);
554 b
555 }
556
557 fn push_entry(b: &mut Vec<u8>, tag: u16, typ: u16, count: u32, value: u32) {
558 b.extend_from_slice(&tag.to_le_bytes());
559 b.extend_from_slice(&typ.to_le_bytes());
560 b.extend_from_slice(&count.to_le_bytes());
561 b.extend_from_slice(&value.to_le_bytes());
562 }
563 fn push_rational(b: &mut Vec<u8>, num: u32, den: u32) {
564 b.extend_from_slice(&num.to_le_bytes());
565 b.extend_from_slice(&den.to_le_bytes());
566 }
567
568 fn jpeg_wrapping(tiff: &[u8]) -> Vec<u8> {
569 let mut b = vec![0xFF, 0xD8];
571 let payload_len = 2 + 6 + tiff.len(); b.extend_from_slice(&[0xFF, 0xE1]);
573 b.extend_from_slice(&(payload_len as u16).to_be_bytes());
574 b.extend_from_slice(b"Exif\0\0");
575 b.extend_from_slice(tiff);
576 b.extend_from_slice(&[0xFF, 0xD9]);
577 b
578 }
579
580 #[test]
581 fn exif_datetime_and_gps_become_timeline_and_map_facets() {
582 let tiff = tiff_with_datetime_and_gps();
583 let meta = parse_tiff(&tiff);
584 assert!(meta.datetime_unix.is_some(), "DateTimeOriginal parsed");
585 assert_eq!(meta.datetime_unix, Some(1_626_255_000));
587 let lat = meta.lat.expect("lat");
588 let lon = meta.lon.expect("lon");
589 assert!((lat - 48.858_333).abs() < 1e-4, "lat ≈ 48.8583, got {lat}");
590 assert!((lon - 2.294_444).abs() < 1e-4, "lon ≈ 2.2944, got {lon}");
591 }
592
593 #[test]
594 fn jpeg_photo_ingests_findable_by_time_and_place() {
595 let jpeg = jpeg_wrapping(&tiff_with_datetime_and_gps());
596 let proc = ImageProcessor;
597 assert!(proc.handles("image/jpeg"));
598 let r = ingest_with(&proc, "urn:photo:paris", "image/jpeg", 0xC0FFEE, &jpeg);
599 let subj = r.container.primary.subject();
600 let hits = in_time_range(&r.quins, 1_626_255_000 - 60, 1_626_255_000 + 60);
602 assert!(
603 hits.contains(&subj),
604 "photo findable on the timeline by its EXIF instant"
605 );
606 let place = format!("{:.5},{:.5}", 48.858_333_f32, 2.294_444_f32);
608 assert!(
609 by_place(&r.quins, &place).contains(&subj),
610 "photo findable at its GPS place"
611 );
612 }
613
614 #[test]
615 fn png_time_and_dimensions_are_read() {
616 let mut b = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
618 b.extend_from_slice(&13u32.to_be_bytes());
620 b.extend_from_slice(b"IHDR");
621 b.extend_from_slice(&640u32.to_be_bytes());
622 b.extend_from_slice(&480u32.to_be_bytes());
623 b.extend_from_slice(&[8, 2, 0, 0, 0]); b.extend_from_slice(&0u32.to_be_bytes()); b.extend_from_slice(&7u32.to_be_bytes());
627 b.extend_from_slice(b"tIME");
628 b.extend_from_slice(&2020u16.to_be_bytes());
629 b.extend_from_slice(&[1, 2, 3, 4, 5]);
630 b.extend_from_slice(&0u32.to_be_bytes()); b.extend_from_slice(&0u32.to_be_bytes());
633 b.extend_from_slice(b"IEND");
634 b.extend_from_slice(&0u32.to_be_bytes());
635
636 let meta = ImageProcessor::extract(&b).expect("png metadata");
637 assert_eq!(meta.width, Some(640));
638 assert_eq!(meta.height, Some(480));
639 assert_eq!(meta.datetime_unix, Some(1_577_934_245));
641 }
642}