Skip to main content

lightweight_pdf_core/image/
mod.rs

1//! `Image` element (Phase 5, `plan/phases/phase-5-images.md`): JPEG/PNG
2//! embedding for logos, no image processing.
3//! Validates the header (dimensions, baseline-ness, color type) eagerly at
4//! construction — same "fail fast on unsupported input" spirit as
5//! `lightweight_pdf_fonts::FontData::load` — rather than deferring rejection to
6//! render time. Only header bytes are parsed here; no dependency, no pixel
7//! decoding (that's the facade's job for PNG, and unneeded for JPEG since
8//! it's embedded byte-for-byte as `DCTDecode`).
9//!
10//! Format-specific header parsing lives in the `jpeg`/`png` submodules; this
11//! module owns the public `Image` type and dispatches to whichever parser
12//! matches the file's magic bytes.
13
14mod jpeg;
15mod png;
16
17use crate::style::Common;
18use jpeg::parse_jpeg;
19use png::{parse_png, PNG_SIGNATURE};
20use std::sync::Arc;
21
22/// Generous but finite: guards against a maliciously/accidentally huge
23/// declared pixel count before any decoding happens (ADR-013: "Grenzen für
24/// Pixelzahl ... sind Pflicht"). ~6300x6300 — comfortably more than any
25/// realistic invoice logo or letterhead graphic.
26const MAX_PIXELS: u64 = 40_000_000;
27
28#[derive(Clone, Copy, PartialEq, Eq, Debug)]
29pub enum ImageFormat {
30    Jpeg,
31    Png,
32}
33
34#[derive(Clone, Copy, PartialEq, Eq, Debug)]
35pub enum ImageError {
36    /// Not a JPEG or PNG (bad magic bytes).
37    UnsupportedFormat,
38    /// Truncated or structurally broken header.
39    Malformed,
40    /// Not baseline, or not Gray/RGB (progressive, CMYK, ...).
41    UnsupportedJpeg,
42    /// Not non-interlaced 8-bit RGB/RGBA (palette, 16-bit, interlaced, ...).
43    UnsupportedPng,
44    /// Declared pixel count exceeds `MAX_PIXELS`.
45    ImageTooLarge,
46}
47
48impl core::fmt::Display for ImageError {
49    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
50        match self {
51            ImageError::UnsupportedFormat => write!(f, "unsupported image format (need JPEG or PNG)"),
52            ImageError::Malformed => write!(f, "malformed image header"),
53            ImageError::UnsupportedJpeg => write!(f, "unsupported JPEG variant (need baseline Gray/RGB)"),
54            ImageError::UnsupportedPng => write!(f, "unsupported PNG variant (need non-interlaced 8-bit RGB/RGBA)"),
55            ImageError::ImageTooLarge => write!(f, "image pixel count exceeds the supported limit"),
56        }
57    }
58}
59
60/// A validated, embeddable JPEG or PNG. `bytes` are the original file
61/// bytes, kept as-is — pixel decoding (only ever needed for PNG, to split
62/// out the alpha channel as a `SMask`) happens later, in the facade.
63#[derive(Clone, Debug)]
64pub struct Image {
65    pub bytes: Arc<[u8]>,
66    pub format: ImageFormat,
67    pub width_px: u32,
68    pub height_px: u32,
69    /// 1 = Gray, 3 = RGB, 4 = RGBA (JPEG is always 1 or 3, never 4).
70    pub components: u8,
71    pub common: Common,
72}
73
74impl Image {
75    /// Validates `bytes` as a supported JPEG or PNG and extracts the
76    /// metadata layout needs (dimensions, color components). Rejects
77    /// anything outside V1's explicit scope instead of a silent
78    /// best-effort attempt (`phases/phase-5-images.md` step 2-3).
79    pub fn new(bytes: impl Into<Arc<[u8]>>) -> Result<Self, ImageError> {
80        let bytes: Arc<[u8]> = bytes.into();
81        let (format, width_px, height_px, components) = if bytes.starts_with(&[0xFF, 0xD8]) {
82            let (w, h, c) = parse_jpeg(&bytes)?;
83            (ImageFormat::Jpeg, w, h, c)
84        } else if bytes.starts_with(&PNG_SIGNATURE) {
85            let (w, h, c) = parse_png(&bytes)?;
86            (ImageFormat::Png, w, h, c)
87        } else {
88            return Err(ImageError::UnsupportedFormat);
89        };
90        if (width_px as u64) * (height_px as u64) > MAX_PIXELS {
91            return Err(ImageError::ImageTooLarge);
92        }
93        Ok(Image {
94            bytes,
95            format,
96            width_px,
97            height_px,
98            components,
99            common: Common::default(),
100        })
101    }
102
103    pub fn width(mut self, width: f32) -> Self {
104        self.common.width = Some(width);
105        self
106    }
107
108    pub fn height(mut self, height: f32) -> Self {
109        self.common.height = Some(height);
110        self
111    }
112
113    pub fn flex(mut self, factor: f32) -> Self {
114        self.common.flex = Some(factor);
115        self
116    }
117
118    pub fn keep_with_next(mut self) -> Self {
119        self.common.keep_with_next = true;
120        self
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    fn fixture(name: &str) -> Vec<u8> {
129        std::fs::read(format!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test-fixtures/images/{}"), name)).expect("test fixture present")
130    }
131
132    #[test]
133    fn accepts_rgba_png_with_transparency() {
134        let img = Image::new(fixture("logo_rgba.png")).unwrap();
135        assert_eq!(img.format, ImageFormat::Png);
136        assert_eq!(img.components, 4);
137        assert_eq!((img.width_px, img.height_px), (64, 48));
138    }
139
140    #[test]
141    fn accepts_opaque_rgb_png() {
142        let img = Image::new(fixture("logo_rgb.png")).unwrap();
143        assert_eq!(img.components, 3);
144        assert_eq!((img.width_px, img.height_px), (40, 30));
145    }
146
147    #[test]
148    fn accepts_baseline_rgb_jpeg() {
149        let img = Image::new(fixture("logo_baseline.jpg")).unwrap();
150        assert_eq!(img.format, ImageFormat::Jpeg);
151        assert_eq!(img.components, 3);
152        assert_eq!((img.width_px, img.height_px), (80, 60));
153    }
154
155    #[test]
156    fn accepts_baseline_gray_jpeg() {
157        let img = Image::new(fixture("logo_gray.jpg")).unwrap();
158        assert_eq!(img.components, 1);
159        assert_eq!((img.width_px, img.height_px), (32, 32));
160    }
161
162    #[test]
163    fn rejects_progressive_jpeg() {
164        assert_eq!(Image::new(fixture("progressive.jpg")).unwrap_err(), ImageError::UnsupportedJpeg);
165    }
166
167    #[test]
168    fn rejects_cmyk_jpeg() {
169        assert_eq!(Image::new(fixture("cmyk.jpg")).unwrap_err(), ImageError::UnsupportedJpeg);
170    }
171
172    #[test]
173    fn rejects_palette_png() {
174        assert_eq!(Image::new(fixture("palette.png")).unwrap_err(), ImageError::UnsupportedPng);
175    }
176
177    #[test]
178    fn rejects_sixteen_bit_png() {
179        assert_eq!(Image::new(fixture("sixteen_bit.png")).unwrap_err(), ImageError::UnsupportedPng);
180    }
181
182    #[test]
183    fn rejects_interlaced_png() {
184        // Real Adam7-interlaced fixture generation is unreliable across
185        // PNG encoders; a minimal synthetic IHDR with the interlace byte
186        // set is sufficient here since interlacing must be rejected
187        // before any pixel decoding is ever attempted.
188        let mut bytes = fixture("logo_rgb.png");
189        assert_eq!(&bytes[12..16], b"IHDR");
190        bytes[16 + 12] = 1; // interlace method = Adam7
191        assert_eq!(Image::new(bytes).unwrap_err(), ImageError::UnsupportedPng);
192    }
193
194    #[test]
195    fn rejects_garbage_bytes() {
196        assert_eq!(Image::new(vec![0u8; 16]).unwrap_err(), ImageError::UnsupportedFormat);
197    }
198
199    #[test]
200    fn rejects_oversized_declared_dimensions() {
201        let mut bytes = fixture("logo_rgb.png");
202        bytes[16..20].copy_from_slice(&10_000u32.to_be_bytes());
203        bytes[20..24].copy_from_slice(&10_000u32.to_be_bytes());
204        assert_eq!(Image::new(bytes).unwrap_err(), ImageError::ImageTooLarge);
205    }
206
207    #[test]
208    fn builder_methods_set_common_fields() {
209        let img = Image::new(fixture("logo_rgb.png"))
210            .unwrap()
211            .width(100.0)
212            .height(50.0)
213            .flex(1.0)
214            .keep_with_next();
215        assert_eq!(img.common.width, Some(100.0));
216        assert_eq!(img.common.height, Some(50.0));
217        assert_eq!(img.common.flex, Some(1.0));
218        assert!(img.common.keep_with_next);
219    }
220}