Skip to main content

omp_tui/
imagefmt.rs

1//! Header-only image dimension probes.
2//!
3//! These probes inspect container headers only; they never decode pixel data.
4
5/// An image container recognized from its magic bytes.
6#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7pub enum ImageFormat {
8	/// Portable Network Graphics.
9	Png,
10	/// Joint Photographic Experts Group.
11	Jpeg,
12	/// Graphics Interchange Format.
13	Gif,
14	/// WebP.
15	Webp,
16}
17
18/// Identifies a supported image container from its magic bytes.
19pub fn format(bytes: &[u8]) -> Option<ImageFormat> {
20	if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
21		Some(ImageFormat::Png)
22	} else if bytes.starts_with(&[0xff, 0xd8]) {
23		Some(ImageFormat::Jpeg)
24	} else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
25		Some(ImageFormat::Gif)
26	} else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
27		Some(ImageFormat::Webp)
28	} else {
29		None
30	}
31}
32
33/// Pixel dimensions read from an image header.
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub struct ImageDimensions {
36	/// Image width in pixels.
37	pub width:  u32,
38	/// Image height in pixels.
39	pub height: u32,
40}
41
42/// Reads pixel dimensions from a PNG, JPEG, GIF, or WebP header.
43///
44/// The format is selected from its magic bytes. Truncated or malformed headers
45/// and unsupported formats return `None`.
46pub fn dimensions(bytes: &[u8]) -> Option<ImageDimensions> {
47	match format(bytes)? {
48		ImageFormat::Png => png_dimensions(bytes),
49		ImageFormat::Jpeg => jpeg_dimensions(bytes),
50		ImageFormat::Gif => gif_dimensions(bytes),
51		ImageFormat::Webp => webp_dimensions(bytes),
52	}
53}
54
55fn image_dimensions(width: u32, height: u32) -> Option<ImageDimensions> {
56	(width != 0 && height != 0).then_some(ImageDimensions { width, height })
57}
58
59fn png_dimensions(bytes: &[u8]) -> Option<ImageDimensions> {
60	if bytes.get(12..16)? != b"IHDR" {
61		return None;
62	}
63	let width = u32::from_be_bytes(bytes.get(16..20)?.try_into().ok()?);
64	let height = u32::from_be_bytes(bytes.get(20..24)?.try_into().ok()?);
65	image_dimensions(width, height)
66}
67
68fn jpeg_dimensions(bytes: &[u8]) -> Option<ImageDimensions> {
69	let mut at = 2;
70	while at < bytes.len() {
71		if bytes[at] != 0xff {
72			at += 1;
73			continue;
74		}
75		// A marker may have any number of FF padding bytes.
76		while bytes.get(at) == Some(&0xff) {
77			at += 1;
78		}
79		let marker = *bytes.get(at)?;
80		at += 1;
81
82		match marker {
83			// Stuffed bytes are not segment markers. Once a scan begins, dimensions
84			// must already have appeared, so continuing cannot discover a SOF safely.
85			0x00 => continue,
86			0xd9 | 0xda => return None,
87			// Standalone markers have no length field.
88			0x01 | 0xd0..=0xd8 => continue,
89			_ => {},
90		}
91
92		let length = usize::from(u16::from_be_bytes(bytes.get(at..at + 2)?.try_into().ok()?));
93		if length < 2 {
94			return None;
95		}
96		let end = at.checked_add(length)?;
97		if end > bytes.len() {
98			return None;
99		}
100
101		// SOF0..SOF15, excluding DHT (C4), JPG (C8), and DAC (CC).
102		if matches!(marker, 0xc0..=0xcf) && !matches!(marker, 0xc4 | 0xc8 | 0xcc) {
103			if length < 7 {
104				return None;
105			}
106			let height = u32::from(u16::from_be_bytes(bytes.get(at + 3..at + 5)?.try_into().ok()?));
107			let width = u32::from(u16::from_be_bytes(bytes.get(at + 5..at + 7)?.try_into().ok()?));
108			return image_dimensions(width, height);
109		}
110		at = end;
111	}
112	None
113}
114
115fn gif_dimensions(bytes: &[u8]) -> Option<ImageDimensions> {
116	let width = u32::from(u16::from_le_bytes(bytes.get(6..8)?.try_into().ok()?));
117	let height = u32::from(u16::from_le_bytes(bytes.get(8..10)?.try_into().ok()?));
118	image_dimensions(width, height)
119}
120
121fn webp_dimensions(bytes: &[u8]) -> Option<ImageDimensions> {
122	if bytes.get(8..12)? != b"WEBP" {
123		return None;
124	}
125	match bytes.get(12..16)? {
126		b"VP8 " => {
127			let width = u32::from(u16::from_le_bytes(bytes.get(26..28)?.try_into().ok()?) & 0x3fff);
128			let height = u32::from(u16::from_le_bytes(bytes.get(28..30)?.try_into().ok()?) & 0x3fff);
129			image_dimensions(width, height)
130		},
131		b"VP8L" => {
132			let packed = u32::from_le_bytes(bytes.get(21..25)?.try_into().ok()?);
133			let width = (packed & 0x3fff) + 1;
134			let height = ((packed >> 14) & 0x3fff) + 1;
135			image_dimensions(width, height)
136		},
137		b"VP8X" => {
138			let packed = bytes.get(24..30)?;
139			let width = u32::from(packed[0]) | u32::from(packed[1]) << 8 | u32::from(packed[2]) << 16;
140			let height = u32::from(packed[3]) | u32::from(packed[4]) << 8 | u32::from(packed[5]) << 16;
141			image_dimensions(width + 1, height + 1)
142		},
143		_ => None,
144	}
145}
146
147#[cfg(test)]
148mod tests {
149	use super::*;
150
151	fn assert_dimensions(bytes: &[u8], width: u32, height: u32) {
152		assert_eq!(dimensions(bytes), Some(ImageDimensions { width, height }));
153	}
154	#[test]
155	fn sniffs_supported_magic_bytes() {
156		assert_eq!(format(b"\x89PNG\r\n\x1a\n"), Some(ImageFormat::Png));
157		assert_eq!(format(&[0xff, 0xd8]), Some(ImageFormat::Jpeg));
158		assert_eq!(format(b"GIF89a"), Some(ImageFormat::Gif));
159		assert_eq!(format(b"RIFF\0\0\0\0WEBP"), Some(ImageFormat::Webp));
160		assert_eq!(format(b"RIFF\0\0\0\0WAVE"), None);
161		assert_eq!(format(b"garbage"), None);
162	}
163
164	#[test]
165	fn probes_png_ihdr() {
166		let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
167		bytes.extend_from_slice(&640_u32.to_be_bytes());
168		bytes.extend_from_slice(&480_u32.to_be_bytes());
169		assert_dimensions(&bytes, 640, 480);
170	}
171
172	#[test]
173	fn probes_jpeg_baseline_and_progressive_sof() {
174		for marker in [0xc0, 0xc2] {
175			let bytes = [
176				0xff, 0xd8, 0xff, 0xe0, 0x00, 0x04, 0, 0, 0xff, marker, 0x00, 0x08, 8, 0x01, 0xe0,
177				0x02, 0x80, 1,
178			];
179			assert_dimensions(&bytes, 640, 480);
180		}
181	}
182
183	#[test]
184	fn probes_gif87a_and_gif89a() {
185		for signature in [b"GIF87a", b"GIF89a"] {
186			let mut bytes = signature.to_vec();
187			bytes.extend_from_slice(&320_u16.to_le_bytes());
188			bytes.extend_from_slice(&200_u16.to_le_bytes());
189			assert_dimensions(&bytes, 320, 200);
190		}
191	}
192
193	#[test]
194	fn probes_webp_vp8() {
195		let mut bytes = vec![0; 30];
196		bytes[0..4].copy_from_slice(b"RIFF");
197		bytes[8..12].copy_from_slice(b"WEBP");
198		bytes[12..16].copy_from_slice(b"VP8 ");
199		bytes[26..28].copy_from_slice(&(0xc000_u16 | 0x03e8).to_le_bytes());
200		bytes[28..30].copy_from_slice(&(0x8000_u16 | 0x02bc).to_le_bytes());
201		assert_dimensions(&bytes, 1000, 700);
202	}
203
204	#[test]
205	fn probes_webp_vp8l() {
206		let mut bytes = vec![0; 25];
207		bytes[0..4].copy_from_slice(b"RIFF");
208		bytes[8..12].copy_from_slice(b"WEBP");
209		bytes[12..16].copy_from_slice(b"VP8L");
210		let packed = (511_u32 - 1) | ((257_u32 - 1) << 14);
211		bytes[21..25].copy_from_slice(&packed.to_le_bytes());
212		assert_dimensions(&bytes, 511, 257);
213	}
214
215	#[test]
216	fn probes_webp_vp8x() {
217		let mut bytes = vec![0; 30];
218		bytes[0..4].copy_from_slice(b"RIFF");
219		bytes[8..12].copy_from_slice(b"WEBP");
220		bytes[12..16].copy_from_slice(b"VP8X");
221		bytes[24..27].copy_from_slice(&[0xff, 0x00, 0x01]);
222		bytes[27..30].copy_from_slice(&[0x01, 0x02, 0x03]);
223		assert_dimensions(&bytes, 65_792, 197_122);
224	}
225
226	#[test]
227	fn rejects_truncated_headers() {
228		let headers: &[&[u8]] = &[
229			b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0",
230			&[0xff, 0xd8, 0xff, 0xc0, 0, 8, 8],
231			b"GIF89a\x01",
232			b"RIFF\0\0\0\0WEBPVP8 ",
233			b"RIFF\0\0\0\0WEBPVP8L",
234			b"RIFF\0\0\0\0WEBPVP8X",
235		];
236		for header in headers {
237			assert_eq!(dimensions(header), None, "accepted truncated header: {header:?}");
238		}
239	}
240}