Skip to main content

smugmug_cli/raw/
jpeg.rs

1//! Finding the JPEG previews cameras embed in RAW files.
2//!
3//! Rather than parsing each maker's container (TIFF variants, CR3's ISO
4//! BMFF boxes, RAF's header, ...), this walks the file for JPEG streams and
5//! validates each one's marker structure. Lossless JPEG streams (SOF3 and
6//! friends) are rejected: CR2 and DNG store the sensor data itself that way,
7//! and it is not a viewable image.
8
9/// A JPEG stream found inside a larger file.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct EmbeddedJpeg {
12    /// Byte range of the stream, SOI through EOI.
13    pub start: usize,
14    pub end: usize,
15    pub width: u16,
16    pub height: u16,
17    /// Whether the stream has its own EXIF (APP1) segment.
18    pub has_exif: bool,
19}
20
21impl EmbeddedJpeg {
22    pub fn pixels(&self) -> u64 {
23        self.width as u64 * self.height as u64
24    }
25
26    pub fn long_edge(&self) -> u16 {
27        self.width.max(self.height)
28    }
29}
30
31/// Every well-formed baseline or progressive JPEG stream in `data`, in file
32/// order. Streams nested inside another one (e.g. an EXIF thumbnail inside a
33/// preview's APP1 segment) are not listed separately.
34pub fn find_jpegs(data: &[u8]) -> Vec<EmbeddedJpeg> {
35    let mut found = Vec::new();
36    let mut i = 0;
37    while i + 3 < data.len() {
38        let Some(offset) = data[i..].windows(3).position(|w| w == [0xFF, 0xD8, 0xFF]) else {
39            break;
40        };
41        let start = i + offset;
42        match parse_jpeg(data, start) {
43            Some(jpeg) => {
44                i = jpeg.end;
45                found.push(jpeg);
46            }
47            None => i = start + 1,
48        }
49    }
50    found
51}
52
53fn u16_be(data: &[u8], at: usize) -> Option<u16> {
54    Some(u16::from_be_bytes([*data.get(at)?, *data.get(at + 1)?]))
55}
56
57/// Validate the JPEG stream starting (with SOI) at `start` and find its end.
58fn parse_jpeg(data: &[u8], start: usize) -> Option<EmbeddedJpeg> {
59    let mut p = start + 2;
60    let mut size: Option<(u16, u16)> = None;
61    let mut has_exif = false;
62
63    loop {
64        if *data.get(p)? != 0xFF {
65            return None;
66        }
67        // Any number of 0xFF fill bytes may precede a marker.
68        while *data.get(p + 1)? == 0xFF {
69            p += 1;
70        }
71        let marker = *data.get(p + 1)?;
72        match marker {
73            // Standalone markers
74            0x01 | 0xD0..=0xD7 => {
75                p += 2;
76                continue;
77            }
78            // A second SOI or an EOI before any scan: not a real stream
79            0xD8 | 0xD9 => return None,
80            0xC0..=0xFE => {}
81            _ => return None,
82        }
83
84        let len = u16_be(data, p + 2)? as usize;
85        if len < 2 {
86            return None;
87        }
88        let segment = data.get(p + 4..p + 2 + len)?;
89
90        match marker {
91            // Baseline, extended sequential and progressive Huffman: viewable
92            0xC0..=0xC2 => {
93                if segment.len() < 6 || segment[0] != 8 {
94                    return None;
95                }
96                let height = u16::from_be_bytes([segment[1], segment[2]]);
97                let width = u16::from_be_bytes([segment[3], segment[4]]);
98                if width == 0 || height == 0 {
99                    return None;
100                }
101                size = Some((width, height));
102            }
103            // Lossless (RAW sensor data), hierarchical and arithmetic-coded
104            // frames: not something to upload
105            0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => return None,
106            0xE1 if segment.starts_with(b"Exif\0\0") => has_exif = true,
107            _ => {}
108        }
109
110        p += 2 + len;
111
112        if marker == 0xDA {
113            // Start of scan: a frame header must have come first.
114            size?;
115            // Entropy-coded data runs to the next marker other than a
116            // stuffed zero or a restart marker.
117            loop {
118                let offset = data.get(p..)?.iter().position(|&b| b == 0xFF)?;
119                p += offset;
120                match *data.get(p + 1)? {
121                    0x00 | 0xD0..=0xD7 => p += 2,
122                    0xFF => p += 1,
123                    0xD9 => {
124                        let (width, height) = size?;
125                        return Some(EmbeddedJpeg {
126                            start,
127                            end: p + 2,
128                            width,
129                            height,
130                            has_exif,
131                        });
132                    }
133                    // Another segment (e.g. the next scan of a progressive
134                    // JPEG): back to reading markers.
135                    _ => break,
136                }
137            }
138        }
139    }
140}
141
142#[cfg(test)]
143pub(crate) mod tests {
144    use super::*;
145
146    /// A minimal, structurally valid JPEG: SOI, optional APP1, DQT, SOF
147    /// (`sof` marker), DHT, SOS, a few bytes of entropy data (with a stuffed
148    /// 0xFF00 and a restart marker) and EOI. Not decodable, which is fine
149    /// since only the structure is checked.
150    pub fn fake_jpeg(sof: u8, width: u16, height: u16, exif: Option<&[u8]>) -> Vec<u8> {
151        let mut j = vec![0xFF, 0xD8];
152        if let Some(exif) = exif {
153            j.extend([0xFF, 0xE1]);
154            j.extend(((exif.len() + 2) as u16).to_be_bytes());
155            j.extend(exif);
156        }
157        // DQT
158        j.extend([0xFF, 0xDB, 0x00, 0x43, 0x00]);
159        j.extend([1u8; 64]);
160        // SOF
161        j.extend([0xFF, sof, 0x00, 0x0B, 0x08]);
162        j.extend(height.to_be_bytes());
163        j.extend(width.to_be_bytes());
164        j.extend([0x01, 0x01, 0x11, 0x00]);
165        // DHT (empty tables are fine for a structure check)
166        j.extend([0xFF, 0xC4, 0x00, 0x03, 0x00]);
167        // SOS
168        j.extend([0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00]);
169        j.extend([0x12, 0xFF, 0x00, 0x34, 0xFF, 0xD0, 0x56]);
170        j.extend([0xFF, 0xD9]);
171        j
172    }
173
174    #[test]
175    fn finds_streams_and_skips_noise() {
176        let big = fake_jpeg(0xC0, 6000, 4000, None);
177        let small = fake_jpeg(0xC2, 160, 120, None);
178        let mut data = b"junk \xFF\xD8\xFF not a jpeg".to_vec();
179        let big_start = data.len();
180        data.extend(&big);
181        data.extend(b"more junk");
182        let small_start = data.len();
183        data.extend(&small);
184        data.extend(b"tail");
185
186        let found = find_jpegs(&data);
187        assert_eq!(found.len(), 2);
188        assert_eq!(found[0].start, big_start);
189        assert_eq!(found[0].end, big_start + big.len());
190        assert_eq!((found[0].width, found[0].height), (6000, 4000));
191        assert_eq!(found[1].start, small_start);
192        assert_eq!((found[1].width, found[1].height), (160, 120));
193    }
194
195    #[test]
196    fn rejects_lossless_sensor_data() {
197        let data = fake_jpeg(0xC3, 6000, 4000, None);
198        assert!(find_jpegs(&data).is_empty());
199    }
200
201    #[test]
202    fn nested_thumbnail_is_not_listed() {
203        let thumb = fake_jpeg(0xC0, 160, 120, None);
204        let mut exif = b"Exif\0\0".to_vec();
205        exif.extend(&thumb);
206        let data = fake_jpeg(0xC0, 1920, 1280, Some(&exif));
207
208        let found = find_jpegs(&data);
209        assert_eq!(found.len(), 1);
210        assert_eq!(found[0].width, 1920);
211        assert!(found[0].has_exif);
212    }
213
214    #[test]
215    fn truncated_stream_is_ignored() {
216        let mut data = fake_jpeg(0xC0, 1920, 1280, None);
217        data.truncate(data.len() - 2);
218        assert!(find_jpegs(&data).is_empty());
219    }
220}