Skip to main content

fovea_io/
lib.rs

1#![doc = include_str!("../README.md")]
2#![cfg_attr(docsrs, feature(doc_cfg))]
3#![deny(missing_docs)]
4#![warn(unreachable_pub)]
5#![deny(rustdoc::broken_intra_doc_links)]
6
7mod error;
8
9#[cfg(feature = "png")]
10#[cfg_attr(docsrs, doc(cfg(feature = "png")))]
11pub mod png;
12
13#[cfg(feature = "jpeg")]
14#[cfg_attr(docsrs, doc(cfg(feature = "jpeg")))]
15pub mod jpeg;
16
17#[cfg(feature = "bmp")]
18#[cfg_attr(docsrs, doc(cfg(feature = "bmp")))]
19pub mod bmp;
20
21pub use error::IoError;
22
23// ═══════════════════════════════════════════════════════════════════════════════
24// ImageFormat — format detection
25// ═══════════════════════════════════════════════════════════════════════════════
26
27/// Detected image format based on magic bytes.
28///
29/// Used by [`detect_format`] and internally by [`load`] to dispatch to the
30/// correct codec.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum ImageFormat {
33    /// Portable Network Graphics.
34    Png,
35    /// JPEG / JFIF compressed image.
36    Jpeg,
37    /// Windows Bitmap.
38    Bmp,
39}
40
41/// Detect the image format by inspecting the leading magic bytes.
42///
43/// Returns `None` if the signature doesn't match any known format.
44/// This never reads more than the first 16 bytes.
45///
46/// | Format | Signature                            |
47/// |--------|--------------------------------------|
48/// | PNG    | `89 50 4E 47 0D 0A 1A 0A`           |
49/// | JPEG   | `FF D8 FF`                           |
50/// | BMP    | `42 4D`                              |
51///
52/// # Examples
53///
54/// ```
55/// use fovea_io::{detect_format, ImageFormat};
56///
57/// let png_sig = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
58/// assert_eq!(detect_format(&png_sig), Some(ImageFormat::Png));
59///
60/// assert_eq!(detect_format(&[0x00, 0x00]), None);
61/// ```
62pub fn detect_format(bytes: &[u8]) -> Option<ImageFormat> {
63    if bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
64        Some(ImageFormat::Png)
65    } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
66        Some(ImageFormat::Jpeg)
67    } else if bytes.starts_with(&[0x42, 0x4D]) {
68        Some(ImageFormat::Bmp)
69    } else {
70        None
71    }
72}
73
74// ═══════════════════════════════════════════════════════════════════════════════
75// DecodedImage — top-level union of all codecs
76// ═══════════════════════════════════════════════════════════════════════════════
77
78/// Universal decoded image — union of all per-codec decoded structs.
79///
80/// This enum is `#[non_exhaustive]` because new codecs (e.g. WebP) may be
81/// added over time.  Users should include a wildcard arm when matching.
82///
83/// Each variant wraps the per-codec decoded struct (e.g. [`png::PngDecoded`]),
84/// which itself contains the pixel data and ancillary metadata.  This means
85/// `load()` always gives you metadata for free — no separate "with metadata"
86/// function needed.
87///
88/// Per-codec pixel enums (e.g. [`png::PngImage`]) are **not**
89/// `#[non_exhaustive]` — they are exhaustive spec sheets.  If you know the
90/// format, prefer decoding with the per-codec API directly; you get
91/// exhaustive matching and avoid the wildcard arm.
92///
93/// # Examples
94///
95/// ```no_run
96/// # use fovea_io::{load, DecodedImage};
97/// let bytes = std::fs::read("image.png").unwrap();
98/// match load(&bytes).unwrap() {
99///     #[cfg(feature = "png")]
100///     DecodedImage::Png(decoded) => {
101///         // `decoded.image` is a `PngImage` — match exhaustively
102///         // `decoded.metadata` carries colour-space info, text chunks, etc.
103///     }
104///     _ => { /* unknown or unsupported codec */ }
105/// }
106/// ```
107#[non_exhaustive]
108pub enum DecodedImage {
109    /// A decoded PNG file — pixel data + metadata.
110    /// Only available with the `png` feature.
111    #[cfg(feature = "png")]
112    #[cfg_attr(docsrs, doc(cfg(feature = "png")))]
113    Png(png::PngDecoded),
114    /// A decoded JPEG file — pixel data + metadata.
115    /// Only available with the `jpeg` feature.
116    #[cfg(feature = "jpeg")]
117    #[cfg_attr(docsrs, doc(cfg(feature = "jpeg")))]
118    Jpeg(jpeg::JpegDecoded),
119    /// A decoded BMP file — pixel data + metadata.
120    /// Only available with the `bmp` feature.
121    #[cfg(feature = "bmp")]
122    #[cfg_attr(docsrs, doc(cfg(feature = "bmp")))]
123    Bmp(bmp::BmpDecoded),
124}
125
126// ═══════════════════════════════════════════════════════════════════════════════
127// load — format-agnostic convenience entry point
128// ═══════════════════════════════════════════════════════════════════════════════
129
130/// Load an image from an in-memory byte slice, auto-detecting the format.
131///
132/// Inspects the leading bytes with [`detect_format`], then dispatches to the
133/// appropriate per-codec decoder.  Returns a [`DecodedImage`] wrapping the
134/// per-codec result (pixels + metadata).
135///
136/// # Errors
137///
138/// - [`IoError::InvalidFormat`] if the magic bytes don't match any known
139///   (and enabled) format.
140/// - Any error the underlying codec decoder can produce.
141///
142/// # Examples
143///
144/// ```no_run
145/// # use fovea_io::{load, DecodedImage};
146/// let bytes = std::fs::read("photo.png").unwrap();
147/// match load(&bytes).unwrap() {
148///     #[cfg(feature = "png")]
149///     DecodedImage::Png(decoded) => {
150///         use fovea_io::png::PngImage;
151///         match decoded.image {
152///             PngImage::Srgb8(image) => { /* Image<Srgb8> */ }
153///             _ => {}
154///         }
155///     }
156///     _ => {}
157/// }
158/// ```
159pub fn load(bytes: &[u8]) -> Result<DecodedImage, IoError> {
160    match detect_format(bytes) {
161        #[cfg(feature = "png")]
162        Some(ImageFormat::Png) => Ok(DecodedImage::Png(png::decode(bytes)?)),
163        #[cfg(feature = "jpeg")]
164        Some(ImageFormat::Jpeg) => Ok(DecodedImage::Jpeg(jpeg::decode(bytes)?)),
165        #[cfg(feature = "bmp")]
166        Some(ImageFormat::Bmp) => Ok(DecodedImage::Bmp(bmp::decode(bytes)?)),
167        // Reachable iff a feature is disabled: e.g. with `--features jpeg`,
168        // a PNG signature reaches this arm because the PNG case is gated
169        // out above. With `--features all-codecs` every codec is enabled
170        // and the compiler reports this arm unreachable — the allow is
171        // necessary for the feature-disabled cases.
172        #[allow(unreachable_patterns)]
173        Some(_) => Err(IoError::InvalidFormat {
174            reason: "detected format is not supported (enable the corresponding feature)",
175        }),
176        None => Err(IoError::InvalidFormat {
177            reason: "unrecognised image format (magic bytes don't match any known codec)",
178        }),
179    }
180}
181
182/// Load an image from a streaming reader, auto-detecting the format.
183///
184/// Reads enough leading bytes to detect the format, then dispatches to the
185/// appropriate per-codec streaming decoder.
186///
187/// # Errors
188///
189/// Same as [`load`], plus [`IoError::Io`] for read failures.
190pub fn load_reader(mut reader: impl std::io::Read) -> Result<DecodedImage, IoError> {
191    let mut buf = Vec::new();
192    reader.read_to_end(&mut buf)?;
193    load(&buf)
194}
195
196// ═══════════════════════════════════════════════════════════════════════════════
197// Tests
198// ═══════════════════════════════════════════════════════════════════════════════
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    #[allow(unused_imports)]
204    use fovea::image::ImageView;
205
206    // ── detect_format — positive tests per format ────────────────────────
207
208    #[test]
209    fn detect_format_png() {
210        let sig = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
211        assert_eq!(detect_format(&sig), Some(ImageFormat::Png));
212    }
213
214    #[test]
215    fn detect_format_png_with_trailing_data() {
216        let mut data = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
217        data.extend_from_slice(&[0x00; 100]);
218        assert_eq!(detect_format(&data), Some(ImageFormat::Png));
219    }
220
221    #[test]
222    fn detect_format_jpeg() {
223        let sig = [0xFF, 0xD8, 0xFF];
224        assert_eq!(detect_format(&sig), Some(ImageFormat::Jpeg));
225    }
226
227    #[test]
228    fn detect_format_jpeg_with_trailing_data() {
229        let data = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
230        assert_eq!(detect_format(&data), Some(ImageFormat::Jpeg));
231    }
232
233    #[test]
234    fn detect_format_tiff_le_is_no_longer_recognised() {
235        // P1-8: TIFF support was removed. The signature must now return
236        // None so callers don't see a phantom variant they can't decode.
237        let sig = [0x49, 0x49, 0x2A, 0x00];
238        assert_eq!(detect_format(&sig), None);
239    }
240
241    #[test]
242    fn detect_format_tiff_be_is_no_longer_recognised() {
243        let sig = [0x4D, 0x4D, 0x00, 0x2A];
244        assert_eq!(detect_format(&sig), None);
245    }
246
247    #[test]
248    fn detect_format_bmp() {
249        let sig = [0x42, 0x4D];
250        assert_eq!(detect_format(&sig), Some(ImageFormat::Bmp));
251    }
252
253    #[test]
254    fn detect_format_bmp_with_trailing_data() {
255        let data = [0x42, 0x4D, 0x00, 0x00, 0x00, 0x00];
256        assert_eq!(detect_format(&data), Some(ImageFormat::Bmp));
257    }
258
259    // ── detect_format — negative / edge cases ────────────────────────────
260
261    #[test]
262    fn detect_format_empty() {
263        assert_eq!(detect_format(&[]), None);
264    }
265
266    #[test]
267    fn detect_format_single_byte() {
268        assert_eq!(detect_format(&[0x89]), None);
269    }
270
271    #[test]
272    fn detect_format_unknown_signature() {
273        assert_eq!(detect_format(&[0x00, 0x00, 0x00, 0x00]), None);
274    }
275
276    #[test]
277    fn detect_format_short_for_png() {
278        // First 7 of 8 PNG signature bytes — should not match.
279        let short = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A];
280        assert_eq!(detect_format(&short), None);
281    }
282
283    #[test]
284    fn detect_format_near_miss_png() {
285        // Correct first 4 bytes but wrong continuation.
286        let near = [0x89, 0x50, 0x4E, 0x47, 0x00, 0x00, 0x00, 0x00];
287        assert_eq!(detect_format(&near), None);
288    }
289
290    #[test]
291    fn detect_format_short_for_jpeg() {
292        // Only 2 of 3 JPEG signature bytes.
293        assert_eq!(detect_format(&[0xFF, 0xD8]), None);
294    }
295
296    #[test]
297    fn detect_format_short_for_legacy_tiff_le_signature() {
298        // Even the full 4-byte TIFF LE signature is no longer recognised
299        // (P1-8). Shorter prefixes were never recognised.
300        assert_eq!(detect_format(&[0x49, 0x49, 0x2A]), None);
301    }
302
303    // ── ImageFormat — trait coverage ─────────────────────────────────────
304
305    #[test]
306    fn image_format_debug_and_eq() {
307        let fmt = ImageFormat::Png;
308        let dbg = format!("{:?}", fmt);
309        assert_eq!(dbg, "Png");
310        assert_eq!(fmt, fmt.clone());
311        assert_ne!(ImageFormat::Png, ImageFormat::Jpeg);
312
313        // Cover all variants in Debug.
314        let _ = format!("{:?}", ImageFormat::Jpeg);
315        let _ = format!("{:?}", ImageFormat::Bmp);
316    }
317
318    // ── detect_format — priority: first matching format wins ─────────────
319
320    #[test]
321    fn detect_format_bmp_prefix_not_confused_with_longer() {
322        // BMP signature is only 2 bytes — make sure it doesn't false-match
323        // when followed by arbitrary trailing bytes (here, the bytes that
324        // used to be a TIFF LE signature before P1-8).
325        let data = [0x42, 0x4D, 0x49, 0x49, 0x2A, 0x00];
326        assert_eq!(detect_format(&data), Some(ImageFormat::Bmp));
327    }
328
329    // ── load / load_reader ───────────────────────────────────────────────
330
331    #[cfg(feature = "jpeg")]
332    #[test]
333    fn load_jpeg_dispatches_correctly() {
334        // Build a minimal JPEG via the codec, then load() it.
335        use fovea::image::Image;
336        use fovea::pixel::Srgb8;
337        let img = Image::fill(4, 4, Srgb8::new(100, 150, 200));
338        let bytes = jpeg::encode(&img, &jpeg::JpegEncodeOptions::default()).unwrap();
339        let decoded = load(&bytes).unwrap();
340        match decoded {
341            DecodedImage::Jpeg(d) => match &d.image {
342                jpeg::JpegImage::Srgb8(img) => {
343                    assert_eq!(img.width(), 4);
344                    assert_eq!(img.height(), 4);
345                }
346                other => panic!("expected Srgb8, got {:?}", other),
347            },
348            #[allow(unreachable_patterns)]
349            _ => panic!("expected DecodedImage::Jpeg"),
350        }
351    }
352
353    #[cfg(feature = "jpeg")]
354    #[test]
355    fn load_reader_jpeg_dispatches_correctly() {
356        use fovea::image::Image;
357        use fovea::pixel::SrgbMono8;
358        let img = Image::fill(8, 6, SrgbMono8::new(128));
359        let bytes = jpeg::encode(&img, &jpeg::JpegEncodeOptions::default()).unwrap();
360        let decoded = load_reader(std::io::Cursor::new(&bytes)).unwrap();
361        match decoded {
362            DecodedImage::Jpeg(d) => match &d.image {
363                jpeg::JpegImage::SrgbMono8(img) => {
364                    assert_eq!(img.width(), 8);
365                    assert_eq!(img.height(), 6);
366                }
367                other => panic!("expected SrgbMono8, got {:?}", other),
368            },
369            #[allow(unreachable_patterns)]
370            _ => panic!("expected DecodedImage::Jpeg"),
371        }
372    }
373
374    #[test]
375    fn load_unknown_format_returns_error() {
376        let result = load(&[0x00, 0x00, 0x00, 0x00]);
377        assert!(matches!(result, Err(IoError::InvalidFormat { .. })));
378    }
379
380    #[test]
381    fn load_empty_returns_error() {
382        let result = load(&[]);
383        assert!(matches!(result, Err(IoError::InvalidFormat { .. })));
384    }
385
386    #[test]
387    fn load_unsupported_format_returns_error() {
388        // What used to be a TIFF magic prefix is no longer recognised at all
389        // (P1-8). `load` must surface an `InvalidFormat` error rather than
390        // silently picking the wrong codec.
391        let tiff_le = [0x49, 0x49, 0x2A, 0x00];
392        let result = load(&tiff_le);
393        assert!(matches!(result, Err(crate::IoError::InvalidFormat { .. })));
394    }
395
396    #[cfg(feature = "jpeg")]
397    #[test]
398    fn load_jpeg_returns_metadata() {
399        use fovea::image::Image;
400        use fovea::pixel::Srgb8;
401        let img = Image::fill(2, 2, Srgb8::new(0, 0, 0));
402        let bytes = jpeg::encode(&img, &jpeg::JpegEncodeOptions::default()).unwrap();
403        let decoded = load(&bytes).unwrap();
404        match decoded {
405            DecodedImage::Jpeg(d) => {
406                assert_eq!(d.metadata.source_bit_depth, jpeg::JpegBitDepth::Eight);
407                assert_eq!(d.metadata.color_space, jpeg::JpegColorSpace::Srgb);
408            }
409            #[allow(unreachable_patterns)]
410            _ => panic!("expected DecodedImage::Jpeg"),
411        }
412    }
413
414    #[cfg(feature = "bmp")]
415    #[test]
416    fn load_bmp_dispatches_correctly() {
417        use fovea::image::Image;
418        use fovea::pixel::Srgb8;
419        let img = Image::fill(4, 4, Srgb8::new(100, 150, 200));
420        let bytes = bmp::encode(&img, &bmp::BmpEncodeOptions::default()).unwrap();
421        let decoded = load(&bytes).unwrap();
422        match decoded {
423            DecodedImage::Bmp(d) => match &d.image {
424                bmp::BmpImage::Srgb8(img) => {
425                    assert_eq!(img.width(), 4);
426                    assert_eq!(img.height(), 4);
427                }
428                other => panic!("expected Srgb8, got {:?}", other),
429            },
430            #[allow(unreachable_patterns)]
431            _ => panic!("expected DecodedImage::Bmp"),
432        }
433    }
434
435    #[cfg(feature = "bmp")]
436    #[test]
437    fn load_reader_bmp_dispatches_correctly() {
438        use fovea::image::Image;
439        use fovea::pixel::Srgb8;
440        let img = Image::fill(3, 2, Srgb8::new(42, 84, 126));
441        let bytes = bmp::encode(&img, &bmp::BmpEncodeOptions::default()).unwrap();
442        let decoded = load_reader(std::io::Cursor::new(&bytes)).unwrap();
443        match decoded {
444            DecodedImage::Bmp(d) => match &d.image {
445                bmp::BmpImage::Srgb8(img) => {
446                    assert_eq!(img.width(), 3);
447                    assert_eq!(img.height(), 2);
448                }
449                other => panic!("expected Srgb8, got {:?}", other),
450            },
451            #[allow(unreachable_patterns)]
452            _ => panic!("expected DecodedImage::Bmp"),
453        }
454    }
455
456    #[cfg(feature = "bmp")]
457    #[test]
458    fn load_bmp_returns_metadata() {
459        use fovea::image::Image;
460        use fovea::pixel::Srgb8;
461        let img = Image::fill(2, 2, Srgb8::new(0, 0, 0));
462        let bytes = bmp::encode(&img, &bmp::BmpEncodeOptions::default()).unwrap();
463        let decoded = load(&bytes).unwrap();
464        match decoded {
465            DecodedImage::Bmp(d) => {
466                assert_eq!(d.metadata.source_bit_depth, bmp::BmpBitDepth::TwentyFour);
467                assert_eq!(d.metadata.color_space, bmp::BmpColorSpace::Srgb);
468                assert_eq!(d.metadata.header_version, bmp::BmpHeaderVersion::Info);
469                assert_eq!(d.metadata.compression, bmp::BmpCompression::None);
470            }
471            #[allow(unreachable_patterns)]
472            _ => panic!("expected DecodedImage::Bmp"),
473        }
474    }
475}