Skip to main content

ithmb_core/photodb/
parser.rs

1//! PhotoDB/ArtworkDB tree walker — extracts `.ithmb` thumbnail entries from the
2//! binary chunk container format.
3//!
4//! Ported from `IthmbCodec.PhotoDb.Core` (C#).
5#![allow(
6    clippy::wildcard_imports,
7    clippy::doc_markdown,
8    clippy::cast_possible_truncation,
9    clippy::cast_sign_loss
10)]
11
12use crate::error::DecodeError;
13use crate::photodb::types::*;
14use crate::profile_db::ProfileDb;
15
16// ---------------------------------------------------------------------------
17// Constants
18// ---------------------------------------------------------------------------
19
20/// Maximum recursion depth when walking the chunk tree.
21const MAX_DEPTH: u32 = 64;
22
23// ---------------------------------------------------------------------------
24// Public types
25// ---------------------------------------------------------------------------
26
27/// A single thumbnail entry extracted from a PhotoDB/ArtworkDB binary file.
28#[derive(Debug, Clone)]
29pub struct PhotoDbEntry {
30    /// Format identifier matching a profile prefix (e.g. 1019).
31    pub format_id: i32,
32    /// Raw thumbnail pixel data (empty for external `.ithmb` references).
33    pub data: Vec<u8>,
34    /// Byte offset of the pixel data within the `.ithmb` file.
35    /// `-1` for external (Apple TV / Animal) entries.
36    pub ithmb_offset: i32,
37    /// Byte size of the image data.
38    pub image_size: i32,
39    /// Image width in pixels.
40    pub width: i32,
41    /// Image height in pixels.
42    pub height: i32,
43}
44
45// ---------------------------------------------------------------------------
46// Endianness detection
47// ---------------------------------------------------------------------------
48
49/// Detect the endianness of a PhotoDB/ArtworkDB file by examining the first 4
50/// raw bytes.
51///
52/// Returns `Some(true)` for little-endian (`"mhfd"`), `Some(false)` for
53/// big-endian (`"dfhm"`), and `None` if the prefix matches neither pattern.
54#[must_use]
55pub fn detect_endianness(data: &[u8]) -> Option<bool> {
56    if data.len() < 4 {
57        return None;
58    }
59    // LE file: raw bytes are "mhfd" = [0x6d, 0x68, 0x66, 0x64].
60    if data[0] == 0x6d && data[1] == 0x68 && data[2] == 0x66 && data[3] == 0x64 {
61        return Some(true);
62    }
63    // BE file: raw bytes are "dfhm" = [0x64, 0x66, 0x68, 0x6d].
64    if data[0] == 0x64 && data[1] == 0x66 && data[2] == 0x68 && data[3] == 0x6d {
65        return Some(false);
66    }
67    None
68}
69
70/// Quick magic check — returns `true` if `data` starts with a valid PhotoDB
71/// magic prefix in either endianness.
72#[must_use]
73pub fn can_open_photodb(data: &[u8]) -> bool {
74    detect_endianness(data).is_some()
75}
76
77// ---------------------------------------------------------------------------
78// Main parse entry-point
79// ---------------------------------------------------------------------------
80
81/// Parse a PhotoDB/ArtworkDB binary buffer and extract all MHNI thumbnail
82/// entries into `entries`.
83///
84/// # Errors
85///
86/// Returns [`DecodeError::InvalidFormat`] if the data does not start with a
87/// recognised MHFD magic, or if a required chunk header is truncated beyond
88/// the buffer boundary.
89pub fn try_parse_photodb(data: &[u8], entries: &mut Vec<PhotoDbEntry>) -> Result<(), DecodeError> {
90    let little_endian = detect_endianness(data)
91        .ok_or_else(|| DecodeError::InvalidFormat("not a valid PhotoDB/ArtworkDB file".into()))?;
92
93    // Parse the MHFD root header.
94    let mut offset = 0usize;
95    let mhfd = MhfdHeader::parse(data, &mut offset, little_endian)?;
96    if mhfd.magic != MHFD {
97        return Err(DecodeError::InvalidFormat("MHFD header has wrong magic".into()));
98    }
99    // `offset` is now 12 (end of the 12-byte MHFD header). Children begin here
100    // and extend to the end of the data buffer.
101
102    walk_entries(data, offset, data.len(), little_endian, entries, 0)?;
103
104    // Post-process: trim JPEG entries that have no matching profile.
105    let db = ProfileDb::load_builtin().ok();
106    for entry in entries.iter_mut() {
107        let has_profile = db.as_ref().and_then(|d| d.get(entry.format_id)).is_some();
108        if !has_profile && entry.data.len() >= 2 && entry.data[0] == 0xFF && entry.data[1] == 0xD8 {
109            trim_jpeg(&mut entry.data);
110        }
111    }
112
113    Ok(())
114}
115
116// ---------------------------------------------------------------------------
117// Tree-walking helpers
118// ---------------------------------------------------------------------------
119
120/// Check whether the data at `start` (within the range [`start`, `end`))
121/// appears to be a valid child chunk.
122///
123/// Verifies that:
124/// 1. There is room for at least an 8-byte header (magic + header_size).
125/// 2. The `header_size` field at `start + 4` is >= 8.
126/// 3. The magic at `start` is a recognised chunk type.
127#[must_use]
128fn has_child_chunks(data: &[u8], start: usize, end: usize, little_endian: bool) -> bool {
129    if start + 8 > end || start + 8 > data.len() {
130        return false;
131    }
132    let hdr_size = read_u32(data, start + 4, little_endian);
133    if hdr_size < 8 {
134        return false;
135    }
136    let magic = read_u32(data, start, little_endian);
137    is_known_magic(magic)
138}
139
140/// Recursively walk the chunk tree within [`start`, `end`), collecting MHNI
141/// entries into `entries`.
142///
143/// Stops silently when `depth` exceeds [`MAX_DEPTH`] to guard against
144/// pathological or cyclic chunk graphs.
145///
146/// # Errors
147///
148/// Returns [`DecodeError::BufferTooShort`] if a chunk header declares a size
149/// that extends beyond the data buffer, or if a required header cannot be
150/// parsed.
151#[allow(clippy::too_many_lines)]
152fn walk_entries(
153    data: &[u8],
154    start: usize,
155    end: usize,
156    little_endian: bool,
157    entries: &mut Vec<PhotoDbEntry>,
158    depth: u32,
159) -> Result<(), DecodeError> {
160    if depth > MAX_DEPTH {
161        return Ok(());
162    }
163    if start >= end || start >= data.len() {
164        return Ok(());
165    }
166
167    let mut pos = start;
168    while pos < end && pos < data.len() {
169        // Every chunk needs at least 8 bytes (magic + header_size).
170        if pos + 8 > data.len() || pos + 8 > end {
171            break;
172        }
173
174        let magic = read_u32(data, pos, little_endian);
175        let hdr_size = read_u32(data, pos + 4, little_endian);
176
177        // Validate: must be a known magic with a reasonable header size.
178        if !is_known_magic(magic) || hdr_size < 8 {
179            break;
180        }
181
182        // Total span of this chunk, including its header and all children.
183        let hdr_size_usize = hdr_size as usize;
184        let chunk_end = pos.saturating_add(hdr_size_usize).min(data.len());
185        if chunk_end <= pos {
186            break;
187        }
188        // Default next_pos equals chunk_end. Handlers (e.g. MHII) may
189        // override to advance past their total_len instead of just hdr_size.
190        let mut next_pos = chunk_end;
191        match magic {
192            MHFD => {
193                // Root file header. Parse to validate and advance, then walk
194                // children past the 12-byte header.
195                let mut hdr_pos = pos;
196                let _ = MhfdHeader::parse(data, &mut hdr_pos, little_endian)?;
197                walk_entries(data, hdr_pos, chunk_end, little_endian, entries, depth + 1)?;
198            }
199
200            MHSD => {
201                // Section descriptor. Children start after the 16-byte header.
202                let mut hdr_pos = pos;
203                let _ = MhsdHeader::parse(data, &mut hdr_pos, little_endian)?;
204                let child_start = pos + MhsdHeader::SIZE;
205                if child_start < chunk_end && has_child_chunks(data, child_start, chunk_end, little_endian) {
206                    walk_entries(data, child_start, chunk_end, little_endian, entries, depth + 1)?;
207                }
208            }
209
210            MHL => {
211                // Photo list. Children start after the 12-byte header.
212                let mut hdr_pos = pos;
213                let _ = MhlHeader::parse(data, &mut hdr_pos, little_endian)?;
214                let child_start = pos + MhlHeader::SIZE;
215                if child_start < chunk_end {
216                    walk_entries(data, child_start, chunk_end, little_endian, entries, depth + 1)?;
217                }
218            }
219
220            MHII => {
221                // Photo item container. Header is 12 bytes, but the total
222                // extent (including children) is the u32 value at `pos + 8`.
223                let mut hdr_pos = pos;
224                let _ = MhiiHeader::parse(data, &mut hdr_pos, little_endian)?;
225                let total_len = read_u32(data, pos + 8, little_endian) as usize;
226                let child_start = pos + MhiiHeader::SIZE;
227                let child_end = pos.saturating_add(total_len).min(data.len());
228                if child_start < child_end {
229                    walk_entries(data, child_start, child_end, little_endian, entries, depth + 1)?;
230                }
231                // Advance pos past total_len, not hdr_size, so the outer
232                // loop doesn't re-visit children as siblings.
233                next_pos = child_end;
234            }
235
236            MHNI => {
237                // Thumbnail info entry — leaf node. Parse the header and
238                // extract the inline data if present.
239                let mut mhni_pos = pos;
240                let mhni = MhniHeader::parse(data, &mut mhni_pos, little_endian)?;
241
242                let entry_data = if mhni.ithmb_offset >= 0 && mhni.image_size > 0 {
243                    let off = mhni.ithmb_offset as usize;
244                    let sz = mhni.image_size as usize;
245                    if off.saturating_add(sz) <= data.len() {
246                        data[off..off + sz].to_vec()
247                    } else {
248                        Vec::new()
249                    }
250                } else {
251                    Vec::new()
252                };
253
254                entries.push(PhotoDbEntry {
255                    format_id: mhni.format_id,
256                    data: entry_data,
257                    ithmb_offset: mhni.ithmb_offset,
258                    image_size: mhni.image_size,
259                    width: mhni.width,
260                    height: mhni.height,
261                });
262            }
263
264            MHBA => {
265                // Album container. Children start after the 12-byte header.
266                let mut hdr_pos = pos;
267                let _ = MhbaHeader::parse(data, &mut hdr_pos, little_endian)?;
268                let child_start = pos + MhbaHeader::SIZE;
269                if child_start < chunk_end {
270                    walk_entries(data, child_start, chunk_end, little_endian, entries, depth + 1)?;
271                }
272            }
273
274            MHIA => {
275                // Album item container. Children start after the 12-byte header.
276                let mut hdr_pos = pos;
277                let _ = MhiaHeader::parse(data, &mut hdr_pos, little_endian)?;
278                let child_start = pos + MhiaHeader::SIZE;
279                if child_start < chunk_end {
280                    walk_entries(data, child_start, chunk_end, little_endian, entries, depth + 1)?;
281                }
282            }
283
284            MHIF | MHOD => {
285                // File info and metadata records — skip. These carry
286                // supplementary data, not thumbnails.
287            }
288
289            _ => {
290                // Unreachable in practice because we validated `is_known_magic`
291                // above, but break defensively.
292                break;
293            }
294        }
295
296        pos = next_pos;
297    }
298
299    Ok(())
300}
301
302// ---------------------------------------------------------------------------
303// JPEG trimming
304// ---------------------------------------------------------------------------
305
306/// Trim a JPEG byte buffer at the first EOI marker (`0xFF`, `0xD9`) searching
307/// backwards from the end.
308///
309/// JPEG streams are self-delimiting by their EOI marker. If raw pixel data
310/// follows the JPEG stream (common in PhotoDB inline entries where the
311/// `image_size` includes both the JPEG and any padding), this removes the
312/// trailing garbage.
313fn trim_jpeg(data: &mut Vec<u8>) {
314    if data.len() < 2 {
315        return;
316    }
317    // Search backwards from the end for the EOI marker 0xFF, 0xD9.
318    let mut i = data.len().saturating_sub(2);
319    loop {
320        if data[i] == 0xFF && data[i + 1] == 0xD9 {
321            data.truncate(i + 2);
322            return;
323        }
324        if i == 0 {
325            break;
326        }
327        i -= 1;
328    }
329}
330
331// ---------------------------------------------------------------------------
332// Format ID naming
333// ---------------------------------------------------------------------------
334
335/// Return a human-readable display name for the given `format_id` by looking
336/// it up in the built-in profile database.
337///
338/// When the format ID is known, the name includes the profile prefix,
339/// dimensions, and encoding (e.g. `"F1019.720x480 rgb565"`). Unknown IDs
340/// are labelled with a fallback string.
341#[must_use]
342pub fn get_format_id_name(format_id: i32) -> String {
343    match ProfileDb::load_builtin() {
344        Ok(db) => match db.get(format_id) {
345            Some(profile) => {
346                format!(
347                    "F{}.{}x{} {}",
348                    profile.prefix,
349                    profile.width,
350                    profile.height,
351                    format!("{:?}", profile.encoding).to_lowercase(),
352                )
353            }
354            None => format!("F{format_id} (unknown)"),
355        },
356        Err(_) => format!("F{format_id} (no profile db)"),
357    }
358}
359
360// ---------------------------------------------------------------------------
361// Tests
362// ---------------------------------------------------------------------------
363
364#[cfg(test)]
365#[allow(clippy::unwrap_used)]
366mod tests {
367    use super::*;
368
369    // -- detect_endianness ---------------------------------------------------
370
371    #[test]
372    fn detect_endianness_le() {
373        let data = b"mhfd\x0c\x00\x00\x00";
374        assert_eq!(detect_endianness(data), Some(true));
375    }
376
377    #[test]
378    fn detect_endianness_be() {
379        let data: &[u8] = &[0x64, 0x66, 0x68, 0x6d, 0x00, 0x00, 0x00, 0x0c];
380        assert_eq!(detect_endianness(data), Some(false));
381    }
382
383    #[test]
384    fn detect_endianness_invalid() {
385        assert_eq!(detect_endianness(b"xxxx"), None);
386    }
387
388    #[test]
389    fn detect_endianness_too_short() {
390        assert_eq!(detect_endianness(b"abc"), None);
391        assert_eq!(detect_endianness(b""), None);
392    }
393
394    // -- can_open_photodb ----------------------------------------------------
395
396    #[test]
397    fn can_open_photodb_le() {
398        assert!(can_open_photodb(b"mhfd..."));
399    }
400
401    #[test]
402    fn can_open_photodb_be() {
403        let data: &[u8] = &[0x64, 0x66, 0x68, 0x6d];
404        assert!(can_open_photodb(data));
405    }
406
407    #[test]
408    fn can_open_photodb_invalid() {
409        assert!(!can_open_photodb(b"xxxx"));
410        assert!(!can_open_photodb(b""));
411    }
412
413    // -- has_child_chunks ----------------------------------------------------
414
415    #[test]
416    fn has_child_chunks_recognises_mhsd() {
417        // Simulate a valid MHSD header at `start`.
418        let mut data = vec![0u8; 32];
419        data[0..4].copy_from_slice(b"mhsd");
420        // header_size at +4 = 16
421        data[4..8].copy_from_slice(&[16, 0, 0, 0]);
422        assert!(has_child_chunks(&data, 0, 32, true));
423    }
424
425    #[test]
426    fn has_child_chunks_rejects_short_buffer() {
427        let data = b"mhsd";
428        assert!(!has_child_chunks(data, 0, 4, true));
429    }
430
431    #[test]
432    fn has_child_chunks_rejects_unknown_magic() {
433        let mut data = vec![0u8; 16];
434        data[0..4].copy_from_slice(b"xxxx");
435        data[4..8].copy_from_slice(&[16, 0, 0, 0]);
436        assert!(!has_child_chunks(&data, 0, 16, true));
437    }
438
439    #[test]
440    fn has_child_chunks_rejects_tiny_header_size() {
441        let mut data = vec![0u8; 16];
442        data[0..4].copy_from_slice(b"mhsd");
443        data[4..8].copy_from_slice(&[4, 0, 0, 0]); // hdr_size < 8
444        assert!(!has_child_chunks(&data, 0, 16, true));
445    }
446
447    // -- try_parse_photodb / walk_entries ------------------------------------
448
449    /// Build a minimal LE PhotoDB with one MHSD section containing one MHL
450    /// containing one MHII containing one MHNI (classic inline).
451    fn build_minimal_photodb_le() -> Vec<u8> {
452        // Layout:
453        //   MHFD  (12 bytes)
454        //   MHSD  (16 bytes)
455        //   MHL   (12 bytes, magic "mhli")
456        //   MHII  (12 bytes)
457        //   MHNI  (36 bytes classic inline)
458        // We place the inline pixel data right after the chunk tree.
459        // The MHNI's ithmb_offset points to that trailing data.
460        let tree_size = 12 + 16 + 12 + 12 + 36;
461        let pixel_data: &[u8] = &[
462            0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01,
463            0x00, 0x00, 0xFF, 0xD9, // JPEG EOI
464            0xCC, 0xCC, 0xCC, 0xCC,
465        ]; // trailing garbage
466        let pixel_offset = tree_size;
467
468        let mut data = vec![0u8; tree_size + pixel_data.len()];
469
470        let mut off = 0usize;
471
472        // MHFD: magic(4) + header_size(4, 12) + entry_count(4, 1)
473        data[off..off + 4].copy_from_slice(b"mhfd");
474        data[off + 4..off + 8].copy_from_slice(&[12, 0, 0, 0]); // hdr_size = 12
475        data[off + 8..off + 12].copy_from_slice(&[1, 0, 0, 0]); // entry_count = 1
476        off += 12;
477
478        // MHSD: magic(4) + hdr_size(4, 16) + index(2, 0) + rec_type(2, 4) +
479        //        entry_count(4, 1)
480        // total section size = 16 + 12 + 12 + 36 = 76
481        let mhsd_total: u32 = 16 + 12 + 12 + 36;
482        data[off..off + 4].copy_from_slice(b"mhsd");
483        data[off + 4..off + 8].copy_from_slice(&mhsd_total.to_le_bytes());
484        data[off + 8..off + 10].copy_from_slice(&[0, 0]); // index = 0
485        data[off + 10..off + 12].copy_from_slice(&[4, 0]); // record_type = 4
486        data[off + 12..off + 16].copy_from_slice(&[1, 0, 0, 0]); // entry_count = 1
487        off += 16;
488
489        // MHL: magic "mhli"(4) + hdr_size(4, 12) + count(4, 1)
490        data[off..off + 4].copy_from_slice(b"mhli");
491        data[off + 4..off + 8].copy_from_slice(&[12, 0, 0, 0]); // hdr_size = 12
492        data[off + 8..off + 12].copy_from_slice(&[1, 0, 0, 0]); // count = 1
493        off += 12;
494
495        // MHII: magic(4) + hdr_size(4, 12) + total_len(4, 12 + 36 = 48)
496        let mhii_total: u32 = 12 + 36;
497        data[off..off + 4].copy_from_slice(b"mhii");
498        data[off + 4..off + 8].copy_from_slice(&[12, 0, 0, 0]); // hdr_size = 12
499        data[off + 8..off + 12].copy_from_slice(&mhii_total.to_le_bytes()); // total_len = 48
500        off += 12;
501
502        // MHNI: classic inline, 36 bytes
503        // format_id at +16 = 1019, ithmb_offset at +20 = pixel_offset,
504        // image_size at +24 = 22 (JPEG until EOI), width at +34 = 16,
505        // height at +32 = 16
506        let img_size = 22i32; // just the JPEG SOI..EOI part
507        data[off..off + 4].copy_from_slice(b"mhni");
508        data[off + 4..off + 8].copy_from_slice(&[36, 0, 0, 0]); // hdr_size = 36
509        // +8..+16 padding (zeros)
510        data[off + 16..off + 20].copy_from_slice(&[0xFB, 0x03, 0, 0]); // format_id = 1019 LE
511        data[off + 20..off + 24].copy_from_slice(&i32::try_from(pixel_offset).unwrap().to_le_bytes()); // ithmb_offset
512        data[off + 24..off + 28].copy_from_slice(&img_size.to_le_bytes()); // image_size
513        // +28..+32 reserved / padding
514        data[off + 32..off + 34].copy_from_slice(&[16, 0]); // height = 16 LE u16
515        data[off + 34..off + 36].copy_from_slice(&[16, 0]); // width = 16 LE u16
516        off += 36;
517
518        // Pixel data follows the chunk tree.
519        data[off..off + pixel_data.len()].copy_from_slice(pixel_data);
520
521        data
522    }
523
524    #[test]
525    fn try_parse_photodb_extracts_inline_mhni() {
526        let photodb = build_minimal_photodb_le();
527        let mut entries = Vec::new();
528        try_parse_photodb(&photodb, &mut entries).unwrap();
529
530        assert_eq!(entries.len(), 1);
531        let entry = &entries[0];
532        assert_eq!(entry.format_id, 1019);
533        assert_eq!(entry.ithmb_offset, 88);
534        // off after tree (12+16+12+12+36 = 88)
535        let pixel_offset: usize = 12 + 16 + 12 + 12 + 36;
536        assert_eq!(entry.ithmb_offset as usize, pixel_offset);
537        // image_size is 22, but JPEG trimming with no profile will cut at EOI
538        // profile 1019 exists in the built-in DB, so trimming should NOT happen.
539        assert_eq!(entry.image_size, 22);
540        assert_eq!(entry.width, 16);
541        assert_eq!(entry.height, 16);
542        // Since 1019 exists in profiles, data should NOT be trimmed
543        assert_eq!(entry.data.len(), 22);
544    }
545
546    #[test]
547    fn try_parse_photodb_invalid_magic() {
548        let data = b"xxxx";
549        let mut entries = Vec::new();
550        let result = try_parse_photodb(data, &mut entries);
551        assert!(result.is_err());
552    }
553
554    #[test]
555    fn try_parse_photodb_empty() {
556        let data = b"";
557        let mut entries = Vec::new();
558        let result = try_parse_photodb(data, &mut entries);
559        assert!(result.is_err());
560    }
561
562    #[test]
563    fn try_parse_photodb_trims_jpeg_for_unknown_profile() {
564        // Build a minimal PhotoDB where the MHNI uses an unknown format_id
565        // (e.g. 9999) and the data starts with JPEG SOI. The post-process
566        // should trim trailing garbage at EOI.
567        let mut photodb = build_minimal_photodb_le();
568        // Overwrite the format_id to 9999 (unknown).
569        // MHNI is at offset 12 + 16 + 12 + 12 = 52
570        let mhni_offset = 52usize;
571        photodb[mhni_offset + 16..mhni_offset + 20].copy_from_slice(&[0x0F, 0x27, 0, 0]); // 9999 LE
572
573        let mut entries = Vec::new();
574        try_parse_photodb(&photodb, &mut entries).unwrap();
575
576        assert_eq!(entries.len(), 1);
577        let entry = &entries[0];
578        assert_eq!(entry.format_id, 9999);
579        // Data should be trimmed to just JPEG SOI..EOI (22 bytes)
580        assert_eq!(entry.data.len(), 22);
581        assert_eq!(&entry.data[..2], &[0xFF, 0xD8]);
582        assert_eq!(&entry.data[entry.data.len() - 2..], &[0xFF, 0xD9]);
583    }
584
585    // -- has_child_chunks (additional) ---------------------------------------
586
587    #[test]
588    fn has_child_chunks_outside_end_range() {
589        let mut data = vec![0u8; 16];
590        data[0..4].copy_from_slice(b"mhsd");
591        data[4..8].copy_from_slice(&[16, 0, 0, 0]);
592        // `end` is before the header
593        assert!(!has_child_chunks(&data, 0, 7, true));
594    }
595
596    // -- Depth limit ---------------------------------------------------------
597
598    #[test]
599    fn walk_entries_depth_limit_returns_early() {
600        // Depth > MAX_DEPTH should return Ok(()) without processing.
601        let data = b"mhfd\x0c\x00\x00\x00\x00\x00\x00\x00";
602        let mut entries = Vec::new();
603        let result = walk_entries(data, 0, data.len(), true, &mut entries, MAX_DEPTH + 1);
604        assert!(result.is_ok());
605        assert!(entries.is_empty());
606    }
607
608    #[test]
609    fn walk_entries_empty_range() {
610        let data = b"";
611        let mut entries = Vec::new();
612        let result = walk_entries(data, 0, 0, true, &mut entries, 0);
613        assert!(result.is_ok());
614        assert!(entries.is_empty());
615    }
616
617    // -- get_format_id_name --------------------------------------------------
618
619    #[test]
620    fn get_format_id_name_known() {
621        let name = get_format_id_name(1007);
622        assert!(name.contains("1007"));
623        assert!(name.contains("480"));
624        assert!(name.contains("864"));
625    }
626
627    #[test]
628    fn get_format_id_name_unknown() {
629        let name = get_format_id_name(9999);
630        assert!(name.contains("unknown") || name.contains("9999"));
631    }
632
633    // -- trim_jpeg -----------------------------------------------------------
634
635    #[test]
636    fn trim_jpeg_finds_eoi() {
637        let mut data = vec![0xFF, 0xD8, 0x00, 0x00, 0xFF, 0xD9, 0xCC, 0xCC, 0xCC];
638        trim_jpeg(&mut data);
639        assert_eq!(data.len(), 6);
640        assert_eq!(&data[4..6], &[0xFF, 0xD9]);
641    }
642
643    #[test]
644    fn trim_jpeg_no_eoi() {
645        let mut data = vec![0xFF, 0xD8, 0x00, 0x00, 0x00, 0x00];
646        trim_jpeg(&mut data);
647        assert_eq!(data.len(), 6); // unchanged
648    }
649
650    #[test]
651    fn trim_jpeg_short_buffer() {
652        let mut data = vec![0xFF];
653        trim_jpeg(&mut data);
654        assert_eq!(data.len(), 1); // unchanged
655    }
656}