Skip to main content

qualia_core_db/hypermedia/processors/
image.rs

1//! **ImageProcessor** — derive searchability from a photo's *own* embedded
2//! metadata (EXIF / PNG chunks). Model-free and deterministic: it reads what
3//! the camera already wrote into the file.
4//!
5//! This is the engine behind "the events of a day/period on a timeline or a
6//! map — travelling, with locations, photos": a JPEG's `DateTimeOriginal`
7//! becomes the asset's `occurred_at` (timeline anchor) and its GPS tags become
8//! a [`Place`] with real coordinates (map pin). Camera make/model become
9//! topics. No pixels are interpreted.
10//!
11//! **Honest boundary.** *What the image depicts* (object/scene recognition) and
12//! OCR are a **vision-model** concern — that is the [`Processor`] plug-in point
13//! for the `qualia-vision` engine, not something this metadata reader fabricates.
14//! It derives `depicts` **only** from tags the file itself carries, never guesses.
15
16use std::collections::HashMap;
17
18use super::super::{fnv60, AssetRef, AssetRole, Descriptors, Place, Processor, ProcessorOutput};
19
20/// Metadata read out of an image's own header — none of it inferred from pixels.
21#[derive(Debug, Clone, Default, PartialEq)]
22pub struct ImageMetadata {
23    /// Capture instant (unix seconds, naive-UTC — EXIF carries no timezone).
24    pub datetime_unix: Option<i64>,
25    /// GPS latitude / longitude in signed decimal degrees.
26    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/// A real, **model-free** image processor: extracts EXIF (JPEG) / PNG metadata
45/// → timeline + map facets. The depicted-subject / OCR path is a vision-model
46/// plug-in, not faked here.
47#[derive(Debug, Clone, Default)]
48pub struct ImageProcessor;
49
50impl ImageProcessor {
51    /// Parse an image's embedded metadata (JPEG EXIF or PNG chunks). Returns
52    /// `None` if the bytes are not a recognised image or carry no metadata.
53    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                // No reverse-geocoding here (that needs an external gazetteer —
88                // a plug-in). The coordinate string is the honest place label;
89                // the map view uses the real lat/lon below.
90                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(), // vision-model plug-in point — never guessed
100            occurred_at: meta.datetime_unix,
101            place,
102            document_type: Some("image".to_string()),
103            ..Default::default()
104        };
105
106        // A human-readable, searchable metadata derivation of the original — what
107        // makes an otherwise-opaque photo findable by meaning.
108        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
145// ── format sniffing ─────────────────────────────────────────────────────────
146
147fn 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
154// ── JPEG: find the APP1/Exif segment, parse the TIFF/IFD tree ────────────────
155
156fn parse_jpeg(b: &[u8]) -> ImageMetadata {
157    // Walk JPEG marker segments looking for APP1 (0xFFE1) carrying "Exif\0\0".
158    let mut i = 2; // skip SOI (FFD8)
159    while i + 4 <= b.len() {
160        if b[i] != 0xFF {
161            break;
162        }
163        let marker = b[i + 1];
164        // Standalone markers (RSTn, SOI, EOI, TEM) have no length.
165        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        // Stop at start-of-scan; EXIF always precedes it.
178        if marker == 0xDA {
179            break;
180        }
181        i += 2 + seg_len;
182    }
183    ImageMetadata::default()
184}
185
186// ── TIFF/EXIF IFD parser ─────────────────────────────────────────────────────
187
188#[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, // BYTE, ASCII, SBYTE, UNDEFINED
216        3 | 8 => 2,         // SHORT, SSHORT
217        4 | 9 | 11 => 4,    // LONG, SLONG, FLOAT
218        5 | 10 | 12 => 8,   // RATIONAL, SRATIONAL, DOUBLE
219        _ => 0,
220    }
221}
222
223/// One IFD entry, resolved to where its value bytes live.
224struct Entry {
225    typ: u16,
226    count: u32,
227    /// Absolute offset (into the TIFF buffer) of the value bytes.
228    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                // DateTimeOriginal takes precedence over IFD0 DateTime.
265                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
312/// Iterate an IFD's entries, calling `f(tag, entry)` for each with a resolved
313/// value offset. Bounds-checked; a malformed IFD simply yields fewer entries.
314fn 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        // Value is inline in the 4-byte field when it fits, else `field` is an offset.
331        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
365/// Read a single LONG or SHORT value (used for IFD pointers and dimensions).
366fn 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
374/// Read a RATIONAL at `off` as f64 (num/den).
375fn 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
385/// GPS coordinate: three RATIONALs (deg, min, sec) → signed decimal degrees.
386fn 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
396/// Parse EXIF `"YYYY:MM:DD HH:MM:SS"` to unix seconds (naive UTC — EXIF has no tz).
397fn parse_exif_datetime(s: &str) -> Option<i64> {
398    let s = s.trim();
399    // Expect at least the date; time is optional.
400    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
422/// Days since the unix epoch for a civil (proleptic Gregorian) date.
423/// Howard Hinnant's algorithm.
424fn 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; // [0, 399]
428    let mp = if m > 2 { m - 3 } else { m + 9 }; // [0, 11]
429    let doy = (153 * mp + 2) / 5 + d - 1; // [0, 365]
430    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
431    era * 146097 + doe - 719468
432}
433
434// ── PNG: tIME chunk + optional eXIf chunk ─────────────────────────────────────
435
436fn parse_png(b: &[u8]) -> ImageMetadata {
437    let mut meta = ImageMetadata::default();
438    let mut i = 8; // skip signature
439                   // First chunk is IHDR: width/height are the first two u32 of its data.
440    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                    // eXIf wins for datetime/gps/make/model; keep IHDR dims.
475                    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; // skip CRC
485    }
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    /// Build a minimal little-endian TIFF/EXIF blob with DateTimeOriginal + GPS,
495    /// then confirm the processor turns it into timeline + map facets.
496    fn tiff_with_datetime_and_gps() -> Vec<u8> {
497        // Layout: header(8) | IFD0 | EXIF-IFD | GPS-IFD | value heap.
498        // We hand-place offsets. All little-endian.
499        let mut b = Vec::new();
500        // --- header ---
501        b.extend_from_slice(b"II"); // little-endian
502        b.extend_from_slice(&42u16.to_le_bytes());
503        b.extend_from_slice(&8u32.to_le_bytes()); // IFD0 at offset 8
504
505        // We will compute offsets as we go. Reserve fixed positions:
506        // IFD0 at 8: 2 entries (Exif ptr, GPS ptr) + next=0 → 2 + 2*12 + 4 = 30 bytes → ends at 38.
507        // EXIF-IFD at 38: 1 entry (DateTimeOriginal) → 2 + 12 + 4 = 18 → ends at 56.
508        // GPS-IFD at 56: 4 entries (latRef, lat, lonRef, lon) → 2 + 4*12 + 4 = 54 → ends at 110.
509        // value heap starts at 110.
510        let exif_ifd = 38u32;
511        let gps_ifd = 56u32;
512        let heap = 110u32;
513
514        // --- IFD0 (offset 8) ---
515        b.extend_from_slice(&2u16.to_le_bytes()); // entry count
516                                                  // Exif IFD pointer (tag 0x8769, LONG, count1)
517        push_entry(&mut b, 0x8769, 4, 1, exif_ifd);
518        // GPS IFD pointer (tag 0x8825, LONG, count1)
519        push_entry(&mut b, 0x8825, 4, 1, gps_ifd);
520        b.extend_from_slice(&0u32.to_le_bytes()); // next IFD = 0
521        assert_eq!(b.len(), exif_ifd as usize);
522
523        // --- EXIF IFD (offset 38) ---
524        let dt = b"2021:07:14 09:30:00\0"; // 20 bytes → on heap
525        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); // DateTimeOriginal ASCII
528        b.extend_from_slice(&0u32.to_le_bytes());
529        assert_eq!(b.len(), gps_ifd as usize);
530
531        // --- GPS IFD (offset 56) ---
532        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; // after datetime on heap
535        let lon_off = lat_off + 24; // 3 rationals = 24 bytes
536        b.extend_from_slice(&4u16.to_le_bytes());
537        push_entry(&mut b, 0x0001, 2, 2, latref_inline); // GPSLatitudeRef 'N'
538        push_entry(&mut b, 0x0002, 5, 3, lat_off); // GPSLatitude (3 rationals)
539        push_entry(&mut b, 0x0003, 2, 2, lonref_inline); // GPSLongitudeRef 'E'
540        push_entry(&mut b, 0x0004, 5, 3, lon_off); // GPSLongitude (3 rationals)
541        b.extend_from_slice(&0u32.to_le_bytes());
542        assert_eq!(b.len(), heap as usize);
543
544        // --- value heap (offset 110) ---
545        b.extend_from_slice(dt); // datetime string
546                                 // lat = 48° 51' 30" (≈ Paris 48.858333)
547        push_rational(&mut b, 48, 1);
548        push_rational(&mut b, 51, 1);
549        push_rational(&mut b, 30, 1);
550        // lon = 2° 17' 40" (≈ 2.294444)
551        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        // FFD8 SOI | FFE1 APP1 [len][Exif\0\0][tiff] | FFD9 EOI
570        let mut b = vec![0xFF, 0xD8];
571        let payload_len = 2 + 6 + tiff.len(); // len field + "Exif\0\0" + tiff
572        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        // 2021-07-14 09:30:00 UTC = 1626255000
586        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        // On the timeline: a window around the capture instant finds the photo.
601        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        // On the map: the coordinate-labelled place finds it.
607        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        // 8-byte sig | IHDR(len13) | tIME(len7) | IEND
617        let mut b = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
618        // IHDR
619        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]); // bit depth, colour, etc.
624        b.extend_from_slice(&0u32.to_be_bytes()); // fake CRC
625                                                  // tIME = 2020-01-02 03:04:05
626        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()); // fake CRC
631                                                  // IEND
632        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        // 2020-01-02 03:04:05 UTC = 1577934245
640        assert_eq!(meta.datetime_unix, Some(1_577_934_245));
641    }
642}