Skip to main content

xberg_native_pdf/elements/
image.rs

1//! Image content element types.
2//!
3//! This module provides the `ImageContent` type for representing
4//! images in PDFs.
5
6use crate::geometry::Rect;
7
8/// Image content that can be extracted from or written to a PDF.
9///
10/// This represents an embedded image with its positioning information.
11#[derive(Debug, Clone)]
12pub struct ImageContent {
13    /// Bounding box where the image is placed
14    pub bbox: Rect,
15    /// Image format
16    pub format: ImageFormat,
17    /// Image bytes. When constructed via `from_bytes()` or `new()` this holds the
18    /// encoded file bytes (PNG/JPEG). When extracted from an existing PDF the bytes
19    /// are the raw decoded pixel data as stored in the XObject stream.
20    pub data: Vec<u8>,
21    /// Image width in pixels
22    pub width: u32,
23    /// Image height in pixels
24    pub height: u32,
25    /// Bits per component (typically 8)
26    pub bits_per_component: u8,
27    /// Color space
28    pub color_space: ColorSpace,
29    /// Reading order index
30    pub reading_order: Option<usize>,
31    /// Alternative text for accessibility
32    pub alt_text: Option<String>,
33
34    /// Horizontal DPI (dots per inch) calculated from pixel width and bbox
35    pub horizontal_dpi: Option<f32>,
36    /// Vertical DPI (dots per inch) calculated from pixel height and bbox
37    pub vertical_dpi: Option<f32>,
38
39    /// Optional alpha / transparency channel for PNG-style soft masks.
40    /// Already compressed (FlateDecode) by the image decoder. Carried
41    /// through to the PDF as an `/SMask` XObject so transparent PNGs
42    /// render with their alpha preserved. `None` for opaque images
43    /// and for formats that don't carry transparency (JPEG, CMYK).
44    pub soft_mask: Option<Vec<u8>>,
45    /// Optional 2D affine transform in PDF row order `[a b c d e f]`.
46    /// When set, the image is wrapped in `q ... cm ... Q` on emission
47    /// so graphics-state stays scoped. Populated by
48    /// `FluentPageBuilder::{rotated, scaled, translated, with_transform}`
49    /// closures. #393 Bundle A-2 follow-up.
50    pub matrix: Option<[f32; 6]>,
51    /// When `true` the image is purely decorative and must be marked as an
52    /// `/Artifact` in the content stream so assistive technology ignores it.
53    /// PDF/UA-1 §7.1 (F-3). Mutually exclusive with `alt_text`.
54    pub is_artifact: bool,
55}
56
57impl ImageContent {
58    /// Create a new image content element.
59    pub fn new(bbox: Rect, format: ImageFormat, data: Vec<u8>, width: u32, height: u32) -> Self {
60        let mut image = Self {
61            bbox,
62            format,
63            data,
64            width,
65            height,
66            bits_per_component: 8,
67            color_space: ColorSpace::RGB,
68            reading_order: None,
69            alt_text: None,
70            horizontal_dpi: None,
71            vertical_dpi: None,
72            soft_mask: None,
73            matrix: None,
74            is_artifact: false,
75        };
76        image.calculate_dpi();
77        image
78    }
79
80    /// Mark this image as a decorative artifact (PDF/UA-1 §7.1). The image
81    /// will be wrapped in `/Artifact BDC`/`EMC` so assistive technology
82    /// skips it. Clears any `alt_text` that was previously set.
83    pub fn as_artifact(mut self) -> Self {
84        self.is_artifact = true;
85        self.alt_text = None;
86        self
87    }
88
89    /// Attach a pre-compressed soft-mask (alpha) channel. The bytes are
90    /// emitted verbatim as a separate XObject stream linked via
91    /// `/SMask` so PDF viewers composite the alpha at paint time.
92    pub fn with_soft_mask(mut self, mask: Vec<u8>) -> Self {
93        self.soft_mask = Some(mask);
94        self
95    }
96
97    /// Set the reading order.
98    pub fn with_reading_order(mut self, order: usize) -> Self {
99        self.reading_order = Some(order);
100        self
101    }
102
103    /// Set alternative text for accessibility.
104    pub fn with_alt_text(mut self, text: impl Into<String>) -> Self {
105        self.alt_text = Some(text.into());
106        self
107    }
108
109    /// Get the aspect ratio (width / height).
110    pub fn aspect_ratio(&self) -> f32 {
111        if self.height == 0 {
112            1.0
113        } else {
114            self.width as f32 / self.height as f32
115        }
116    }
117
118    /// Check if this is a grayscale image.
119    pub fn is_grayscale(&self) -> bool {
120        matches!(self.color_space, ColorSpace::Gray)
121    }
122
123    /// Calculate and set the DPI values based on pixel dimensions and bounding box.
124    ///
125    /// DPI = pixels / inches, where inches = points / 72 (1 inch = 72 points)
126    pub fn calculate_dpi(&mut self) {
127        // Convert bbox dimensions from points to inches (72 points = 1 inch) ~keep
128        let width_inches = self.bbox.width / 72.0;
129        let height_inches = self.bbox.height / 72.0;
130
131        if width_inches > 0.0 && self.width > 0 {
132            self.horizontal_dpi = Some(self.width as f32 / width_inches);
133        }
134
135        if height_inches > 0.0 && self.height > 0 {
136            self.vertical_dpi = Some(self.height as f32 / height_inches);
137        }
138    }
139
140    /// Get the resolution as (horizontal_dpi, vertical_dpi).
141    ///
142    /// Returns None if DPI hasn't been calculated or bbox is invalid.
143    pub fn resolution(&self) -> Option<(f32, f32)> {
144        match (self.horizontal_dpi, self.vertical_dpi) {
145            (Some(h), Some(v)) => Some((h, v)),
146            _ => None,
147        }
148    }
149
150    /// Get the horizontal DPI.
151    pub fn get_horizontal_dpi(&self) -> Option<f32> {
152        self.horizontal_dpi
153    }
154
155    /// Get the vertical DPI.
156    pub fn get_vertical_dpi(&self) -> Option<f32> {
157        self.vertical_dpi
158    }
159
160    /// Check if this image is high resolution (>= 300 DPI in both dimensions).
161    ///
162    /// 300 DPI is considered the standard for print-quality images.
163    pub fn is_high_resolution(&self) -> bool {
164        match self.resolution() {
165            Some((h, v)) => h >= 300.0 && v >= 300.0,
166            None => false,
167        }
168    }
169
170    /// Check if this image is low resolution (< 150 DPI in either dimension).
171    ///
172    /// 150 DPI is often considered the minimum for reasonable quality.
173    pub fn is_low_resolution(&self) -> bool {
174        match self.resolution() {
175            Some((h, v)) => h < 150.0 || v < 150.0,
176            None => false,
177        }
178    }
179
180    /// Check if this image is medium resolution (>= 150 DPI but < 300 DPI).
181    pub fn is_medium_resolution(&self) -> bool {
182        match self.resolution() {
183            Some((h, v)) => {
184                let min_dpi = h.min(v);
185                (150.0..300.0).contains(&min_dpi)
186            }
187            None => false,
188        }
189    }
190
191    /// Create an `ImageContent` from raw image bytes, auto-detecting JPEG/PNG by magic number.
192    ///
193    /// Pixel dimensions, color space, and soft mask are parsed from the image header
194    /// automatically — no need to supply `width` or `height`. `bbox` controls the
195    /// on-page display rectangle in PDF points (72 pt = 1 inch).
196    ///
197    /// Returns an error if the bytes do not start with a recognised image magic number.
198    pub fn from_bytes(bbox: Rect, data: Vec<u8>) -> Result<Self, crate::error::Error> {
199        let parsed = parse_image_header(&data)?;
200        let mut image = Self {
201            bbox,
202            format: parsed.format,
203            data,
204            width: parsed.width,
205            height: parsed.height,
206            bits_per_component: parsed.bits_per_component,
207            color_space: parsed.color_space,
208            reading_order: None,
209            alt_text: None,
210            horizontal_dpi: None,
211            vertical_dpi: None,
212            soft_mask: parsed.soft_mask,
213            matrix: None,
214            is_artifact: false,
215        };
216        image.calculate_dpi();
217        Ok(image)
218    }
219}
220
221impl Default for ImageContent {
222    fn default() -> Self {
223        Self {
224            bbox: Rect::new(0.0, 0.0, 0.0, 0.0),
225            format: ImageFormat::Unknown,
226            data: Vec::new(),
227            width: 0,
228            height: 0,
229            bits_per_component: 8,
230            color_space: ColorSpace::RGB,
231            reading_order: None,
232            alt_text: None,
233            horizontal_dpi: None,
234            vertical_dpi: None,
235            soft_mask: None,
236            matrix: None,
237            is_artifact: false,
238        }
239    }
240}
241
242/// Supported image formats.
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
244pub enum ImageFormat {
245    /// JPEG format
246    Jpeg,
247    /// PNG format
248    Png,
249    /// JPEG 2000 format (JPX)
250    Jpeg2000,
251    /// JBIG2 format (typically for scanned documents)
252    Jbig2,
253    /// Raw uncompressed image data
254    Raw,
255    /// Unknown or unsupported format
256    Unknown,
257}
258
259impl ImageFormat {
260    /// Get the MIME type for this format.
261    pub fn mime_type(&self) -> &'static str {
262        match self {
263            ImageFormat::Jpeg => "image/jpeg",
264            ImageFormat::Png => "image/png",
265            ImageFormat::Jpeg2000 => "image/jp2",
266            ImageFormat::Jbig2 => "image/jbig2",
267            ImageFormat::Raw => "application/octet-stream",
268            ImageFormat::Unknown => "application/octet-stream",
269        }
270    }
271
272    /// Get the typical file extension for this format.
273    pub fn extension(&self) -> &'static str {
274        match self {
275            ImageFormat::Jpeg => "jpg",
276            ImageFormat::Png => "png",
277            ImageFormat::Jpeg2000 => "jp2",
278            ImageFormat::Jbig2 => "jbig2",
279            ImageFormat::Raw => "raw",
280            ImageFormat::Unknown => "bin",
281        }
282    }
283}
284
285/// Color space for images.
286#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
287#[allow(clippy::upper_case_acronyms)]
288pub enum ColorSpace {
289    /// Grayscale (1 component)
290    Gray,
291    /// RGB color (3 components)
292    #[default]
293    RGB,
294    /// CMYK color (4 components)
295    CMYK,
296    /// Indexed color (palette-based)
297    Indexed,
298    /// Lab color space
299    Lab,
300}
301
302impl ColorSpace {
303    /// Get the number of components for this color space.
304    pub fn components(&self) -> u8 {
305        match self {
306            ColorSpace::Gray => 1,
307            ColorSpace::RGB => 3,
308            ColorSpace::CMYK => 4,
309            ColorSpace::Indexed => 1,
310            ColorSpace::Lab => 3,
311        }
312    }
313}
314
315/// Parsed image header fields extracted by [`parse_image_header`].
316struct ParsedImageHeader {
317    width: u32,
318    height: u32,
319    bits_per_component: u8,
320    color_space: ColorSpace,
321    format: ImageFormat,
322    soft_mask: Option<Vec<u8>>,
323}
324
325/// Parse a JPEG or PNG image header, auto-detecting the format by magic number.
326///
327/// This extracts pixel dimensions, colour space, and (for PNG) an alpha
328/// channel encoded as a PDF-ready soft mask. The original encoded bytes are
329/// left untouched by the caller; this function only inspects them.
330///
331/// Returns an error if the bytes do not start with a recognised image magic
332/// number.
333fn parse_image_header(data: &[u8]) -> Result<ParsedImageHeader, crate::error::Error> {
334    if data.len() >= 2 && data[0] == 0xFF && data[1] == 0xD8 {
335        let (width, height, color_space) = parse_jpeg_dimensions(data).map_err(crate::error::Error::Image)?;
336        return Ok(ParsedImageHeader {
337            width,
338            height,
339            bits_per_component: 8,
340            color_space,
341            format: ImageFormat::Jpeg,
342            soft_mask: None,
343        });
344    }
345
346    if data.len() >= 8 && &data[0..8] == b"\x89PNG\r\n\x1a\n" {
347        return parse_png_header(data);
348    }
349
350    Err(crate::error::Error::Image("Unsupported image format".to_string()))
351}
352
353/// Parse a JPEG SOF marker to extract width, height, and colour space
354/// without decoding the compressed image data.
355fn parse_jpeg_dimensions(data: &[u8]) -> Result<(u32, u32, ColorSpace), String> {
356    if data.len() < 2 || data[0] != 0xFF || data[1] != 0xD8 {
357        return Err("Not a valid JPEG".to_string());
358    }
359
360    let mut pos = 2;
361    while pos < data.len() - 1 {
362        if data[pos] != 0xFF {
363            pos += 1;
364            continue;
365        }
366
367        let marker = data[pos + 1];
368        pos += 2;
369
370        if marker == 0xFF || marker == 0x00 {
371            continue;
372        }
373
374        // SOF markers (Start of Frame) ~keep
375        if matches!(
376            marker,
377            0xC0 | 0xC1 | 0xC2 | 0xC3 | 0xC5 | 0xC6 | 0xC7 | 0xC9 | 0xCA | 0xCB | 0xCD | 0xCE | 0xCF
378        ) {
379            if pos + 7 > data.len() {
380                return Err("Truncated JPEG header".to_string());
381            }
382
383            let _precision = data[pos + 2];
384            let height = u16::from_be_bytes([data[pos + 3], data[pos + 4]]) as u32;
385            let width = u16::from_be_bytes([data[pos + 5], data[pos + 6]]) as u32;
386            let components = data[pos + 7];
387
388            let color_space = match components {
389                1 => ColorSpace::Gray,
390                3 => ColorSpace::RGB,
391                4 => ColorSpace::CMYK,
392                _ => ColorSpace::RGB,
393            };
394
395            return Ok((width, height, color_space));
396        }
397
398        if pos + 2 > data.len() {
399            break;
400        }
401        let length = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize;
402        pos += length;
403    }
404
405    Err("Could not find JPEG dimensions".to_string())
406}
407
408/// Decode a PNG to extract width, height, colour space, and (if present) an
409/// alpha channel encoded as a PDF-ready soft mask.
410///
411/// Unlike the JPEG path this must fully decode the pixel data: PNG's alpha
412/// channel is interleaved with colour samples, so there is no header field
413/// to read the way JPEG's SOF marker gives dimensions directly.
414fn parse_png_header(data: &[u8]) -> Result<ParsedImageHeader, crate::error::Error> {
415    use image::GenericImageView;
416
417    let img = image::load_from_memory_with_format(data, image::ImageFormat::Png)
418        .map_err(|e| crate::error::Error::Image(e.to_string()))?;
419    let (width, height) = img.dimensions();
420
421    let (color_space, alpha): (ColorSpace, Option<Vec<u8>>) = match img.color() {
422        image::ColorType::L8 | image::ColorType::L16 => (ColorSpace::Gray, None),
423        image::ColorType::La8 | image::ColorType::La16 => {
424            let luma_alpha = img.to_luma_alpha8();
425            let alpha_channel = luma_alpha.pixels().map(|pixel| pixel.0[1]).collect();
426            (ColorSpace::Gray, Some(alpha_channel))
427        }
428        image::ColorType::Rgb8 | image::ColorType::Rgb16 => (ColorSpace::RGB, None),
429        image::ColorType::Rgba8 | image::ColorType::Rgba16 => {
430            let rgba = img.to_rgba8();
431            let alpha_channel = rgba.pixels().map(|pixel| pixel.0[3]).collect();
432            (ColorSpace::RGB, Some(alpha_channel))
433        }
434        _ => (ColorSpace::RGB, None),
435    };
436
437    // Alpha is 1 component (gray) per pixel. ~keep
438    let soft_mask = alpha.map(|a| compress_soft_mask(&a, width)).transpose()?;
439
440    Ok(ParsedImageHeader {
441        width,
442        height,
443        bits_per_component: 8,
444        color_space,
445        format: ImageFormat::Png,
446        soft_mask,
447    })
448}
449
450/// Compress a single-component (grayscale) alpha channel using Flate with a
451/// PNG None-filter byte (0x00) prepended to each scanline.
452///
453/// The PDF spec requires that when `FlateDecode` is used with
454/// `DecodeParms/Predictor=15` (PNG adaptive predictor), the decompressed
455/// byte stream must begin each row with a filter-type byte followed by the
456/// (possibly filtered) row samples. Filter type 0 = None means the samples
457/// are stored verbatim. Flate still compresses the whole stream, including
458/// the filter bytes, so LZ compression quality is unaffected.
459fn compress_soft_mask(pixels: &[u8], width: u32) -> Result<Vec<u8>, crate::error::Error> {
460    use std::io::Write;
461
462    use flate2::Compression;
463    use flate2::write::ZlibEncoder;
464
465    let row_bytes = width as usize;
466
467    if row_bytes == 0 || pixels.is_empty() {
468        let encoder = ZlibEncoder::new(Vec::new(), Compression::default());
469        return encoder.finish().map_err(|e| crate::error::Error::Image(e.to_string()));
470    }
471
472    // Pre-allocate: 1 filter byte per row + all pixel bytes. ~keep
473    let num_rows = pixels.len().div_ceil(row_bytes);
474    let mut filtered = Vec::with_capacity(pixels.len() + num_rows);
475    for row in pixels.chunks(row_bytes) {
476        filtered.push(0u8);
477        filtered.extend_from_slice(row);
478    }
479
480    let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
481    encoder
482        .write_all(&filtered)
483        .map_err(|e| crate::error::Error::Image(e.to_string()))?;
484    encoder.finish().map_err(|e| crate::error::Error::Image(e.to_string()))
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    #[test]
492    fn test_image_content_creation() {
493        let image = ImageContent::new(
494            Rect::new(0.0, 0.0, 100.0, 100.0),
495            ImageFormat::Jpeg,
496            vec![0u8; 1000],
497            800,
498            600,
499        );
500
501        assert_eq!(image.width, 800);
502        assert_eq!(image.height, 600);
503        assert_eq!(image.format, ImageFormat::Jpeg);
504    }
505
506    #[test]
507    fn test_aspect_ratio() {
508        let image = ImageContent::new(Rect::new(0.0, 0.0, 100.0, 100.0), ImageFormat::Png, vec![], 1920, 1080);
509
510        let ratio = image.aspect_ratio();
511        assert!((ratio - (1920.0 / 1080.0)).abs() < 0.001);
512    }
513
514    #[test]
515    fn test_color_space_components() {
516        assert_eq!(ColorSpace::Gray.components(), 1);
517        assert_eq!(ColorSpace::RGB.components(), 3);
518        assert_eq!(ColorSpace::CMYK.components(), 4);
519    }
520
521    #[test]
522    fn test_image_format_extension() {
523        assert_eq!(ImageFormat::Jpeg.extension(), "jpg");
524        assert_eq!(ImageFormat::Png.extension(), "png");
525        assert_eq!(ImageFormat::Jpeg2000.extension(), "jp2");
526    }
527
528    #[test]
529    fn test_dpi_calculation_high_res() {
530        // 600 pixels in 2 inches (144 points) = 300 DPI ~keep
531        let image = ImageContent::new(Rect::new(0.0, 0.0, 144.0, 144.0), ImageFormat::Jpeg, vec![], 600, 600);
532
533        let (h, v) = image.resolution().unwrap();
534        assert!((h - 300.0).abs() < 1.0);
535        assert!((v - 300.0).abs() < 1.0);
536        assert!(image.is_high_resolution());
537        assert!(!image.is_low_resolution());
538    }
539
540    #[test]
541    fn test_dpi_calculation_low_res() {
542        // 100 pixels in 1 inch (72 points) = ~100 DPI ~keep
543        let image = ImageContent::new(Rect::new(0.0, 0.0, 72.0, 72.0), ImageFormat::Png, vec![], 100, 100);
544
545        let (h, v) = image.resolution().unwrap();
546        assert!((h - 100.0).abs() < 1.0);
547        assert!((v - 100.0).abs() < 1.0);
548        assert!(!image.is_high_resolution());
549        assert!(image.is_low_resolution());
550    }
551
552    #[test]
553    fn test_dpi_calculation_medium_res() {
554        // 200 pixels in 1 inch = 200 DPI (medium) ~keep
555        let image = ImageContent::new(Rect::new(0.0, 0.0, 72.0, 72.0), ImageFormat::Png, vec![], 200, 200);
556
557        let (h, v) = image.resolution().unwrap();
558        assert!((h - 200.0).abs() < 1.0);
559        assert!((v - 200.0).abs() < 1.0);
560        assert!(image.is_medium_resolution());
561    }
562
563    #[test]
564    fn test_dpi_asymmetric() {
565        // Different DPI in horizontal and vertical
566        // 300 pixels in 1 inch (horizontal) = 300 DPI
567        // 100 pixels in 1 inch (vertical) = 100 DPI ~keep
568        let image = ImageContent::new(Rect::new(0.0, 0.0, 72.0, 72.0), ImageFormat::Png, vec![], 300, 100);
569
570        let (h, v) = image.resolution().unwrap();
571        assert!((h - 300.0).abs() < 1.0);
572        assert!((v - 100.0).abs() < 1.0);
573
574        // Low resolution because vertical is < 150 ~keep
575        assert!(image.is_low_resolution());
576        // Not high resolution because both need to be >= 300 ~keep
577        assert!(!image.is_high_resolution());
578    }
579
580    #[test]
581    fn test_dpi_zero_dimensions() {
582        // Zero bbox should result in no DPI ~keep
583        let image = ImageContent::default();
584        assert!(image.resolution().is_none());
585    }
586
587    #[test]
588    fn test_dpi_getters() {
589        let image = ImageContent::new(Rect::new(0.0, 0.0, 72.0, 72.0), ImageFormat::Png, vec![], 300, 300);
590
591        assert!(image.get_horizontal_dpi().is_some());
592        assert!(image.get_vertical_dpi().is_some());
593        assert!((image.get_horizontal_dpi().unwrap() - 300.0).abs() < 1.0);
594    }
595
596    fn encode_png(image: image::DynamicImage) -> Vec<u8> {
597        let mut bytes = Vec::new();
598        image
599            .write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png)
600            .expect("encode test PNG");
601        bytes
602    }
603
604    fn encode_jpeg(image: image::DynamicImage) -> Vec<u8> {
605        let mut bytes = Vec::new();
606        image
607            .write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Jpeg)
608            .expect("encode test JPEG");
609        bytes
610    }
611
612    #[test]
613    fn from_bytes_should_parse_rgb_png_header_without_soft_mask() {
614        let rgb = image::RgbImage::from_pixel(4, 3, image::Rgb([10, 20, 30]));
615        let png_bytes = encode_png(image::DynamicImage::ImageRgb8(rgb));
616
617        let image = ImageContent::from_bytes(Rect::new(0.0, 0.0, 72.0, 72.0), png_bytes.clone())
618            .expect("from_bytes should parse a valid PNG header");
619
620        assert_eq!(image.width, 4);
621        assert_eq!(image.height, 3);
622        assert_eq!(image.format, ImageFormat::Png);
623        assert_eq!(image.color_space, ColorSpace::RGB);
624        assert_eq!(image.bits_per_component, 8);
625        assert!(image.soft_mask.is_none());
626        // The original encoded bytes are preserved verbatim, not re-encoded. ~keep
627        assert_eq!(image.data, png_bytes);
628    }
629
630    #[test]
631    fn from_bytes_should_extract_soft_mask_from_rgba_png() {
632        let rgba = image::RgbaImage::from_pixel(2, 2, image::Rgba([1, 2, 3, 128]));
633        let png_bytes = encode_png(image::DynamicImage::ImageRgba8(rgba));
634
635        let image = ImageContent::from_bytes(Rect::new(0.0, 0.0, 72.0, 72.0), png_bytes)
636            .expect("from_bytes should parse an RGBA PNG header");
637
638        assert_eq!(image.width, 2);
639        assert_eq!(image.height, 2);
640        assert_eq!(image.color_space, ColorSpace::RGB);
641        assert!(image.soft_mask.is_some(), "RGBA PNG must produce a soft mask");
642    }
643
644    #[test]
645    fn from_bytes_should_parse_grayscale_png_header() {
646        let gray = image::GrayImage::from_pixel(5, 6, image::Luma([42]));
647        let png_bytes = encode_png(image::DynamicImage::ImageLuma8(gray));
648
649        let image = ImageContent::from_bytes(Rect::new(0.0, 0.0, 72.0, 72.0), png_bytes)
650            .expect("from_bytes should parse a grayscale PNG header");
651
652        assert_eq!(image.width, 5);
653        assert_eq!(image.height, 6);
654        assert_eq!(image.color_space, ColorSpace::Gray);
655        assert!(image.soft_mask.is_none());
656    }
657
658    #[test]
659    fn from_bytes_should_parse_jpeg_header_via_sof_marker() {
660        let rgb = image::RgbImage::from_pixel(16, 8, image::Rgb([200, 100, 50]));
661        let jpeg_bytes = encode_jpeg(image::DynamicImage::ImageRgb8(rgb));
662
663        let image = ImageContent::from_bytes(Rect::new(0.0, 0.0, 72.0, 72.0), jpeg_bytes.clone())
664            .expect("from_bytes should parse a valid JPEG SOF marker");
665
666        assert_eq!(image.width, 16);
667        assert_eq!(image.height, 8);
668        assert_eq!(image.format, ImageFormat::Jpeg);
669        assert_eq!(image.color_space, ColorSpace::RGB);
670        assert_eq!(image.bits_per_component, 8);
671        assert!(image.soft_mask.is_none());
672        assert_eq!(image.data, jpeg_bytes);
673    }
674
675    #[test]
676    fn from_bytes_should_reject_unrecognised_magic_number() {
677        let result = ImageContent::from_bytes(Rect::new(0.0, 0.0, 72.0, 72.0), vec![0u8; 16]);
678        assert!(result.is_err(), "non-image bytes must not parse as an image header");
679    }
680}