Skip to main content

lightweight_pdf_core/
image.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
10use crate::style::Common;
11use std::sync::Arc;
12
13/// Generous but finite: guards against a maliciously/accidentally huge
14/// declared pixel count before any decoding happens (ADR-013: "Grenzen für
15/// Pixelzahl ... sind Pflicht"). ~6300x6300 — comfortably more than any
16/// realistic invoice logo or letterhead graphic.
17const MAX_PIXELS: u64 = 40_000_000;
18
19#[derive(Clone, Copy, PartialEq, Eq, Debug)]
20pub enum ImageFormat {
21    Jpeg,
22    Png,
23}
24
25#[derive(Clone, Copy, PartialEq, Eq, Debug)]
26pub enum ImageError {
27    /// Not a JPEG or PNG (bad magic bytes).
28    UnsupportedFormat,
29    /// Truncated or structurally broken header.
30    Malformed,
31    /// Not baseline, or not Gray/RGB (progressive, CMYK, ...).
32    UnsupportedJpeg,
33    /// Not non-interlaced 8-bit RGB/RGBA (palette, 16-bit, interlaced, ...).
34    UnsupportedPng,
35    /// Declared pixel count exceeds `MAX_PIXELS`.
36    ImageTooLarge,
37}
38
39impl core::fmt::Display for ImageError {
40    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
41        match self {
42            ImageError::UnsupportedFormat => write!(f, "unsupported image format (need JPEG or PNG)"),
43            ImageError::Malformed => write!(f, "malformed image header"),
44            ImageError::UnsupportedJpeg => write!(f, "unsupported JPEG variant (need baseline Gray/RGB)"),
45            ImageError::UnsupportedPng => write!(f, "unsupported PNG variant (need non-interlaced 8-bit RGB/RGBA)"),
46            ImageError::ImageTooLarge => write!(f, "image pixel count exceeds the supported limit"),
47        }
48    }
49}
50
51const PNG_SIGNATURE: [u8; 8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
52
53/// Parses just enough of a JPEG to validate it and extract
54/// `(width, height, components)`. Scans markers up to the first
55/// start-of-frame; `SOF0` (0xFFC0) is baseline, any other `SOFn` is
56/// rejected as unsupported (progressive, extended sequential, lossless,
57/// arithmetic-coded, ...). `components` is 1 (Gray) or 3 (RGB); 4 (CMYK)
58/// or anything else is rejected.
59fn parse_jpeg(bytes: &[u8]) -> Result<(u32, u32, u8), ImageError> {
60    if bytes.len() < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8 {
61        return Err(ImageError::Malformed);
62    }
63    let mut i = 2usize;
64    while i + 1 < bytes.len() {
65        if bytes[i] != 0xFF {
66            return Err(ImageError::Malformed);
67        }
68        let mut marker_pos = i + 1;
69        while marker_pos < bytes.len() && bytes[marker_pos] == 0xFF {
70            marker_pos += 1; // fill bytes between markers are legal
71        }
72        if marker_pos >= bytes.len() {
73            return Err(ImageError::Malformed);
74        }
75        let marker = bytes[marker_pos];
76        i = marker_pos + 1;
77
78        // Standalone markers carry no length field.
79        if marker == 0x01 || (0xD0..=0xD9).contains(&marker) {
80            continue;
81        }
82        if i + 2 > bytes.len() {
83            return Err(ImageError::Malformed);
84        }
85        let length = u16::from_be_bytes([bytes[i], bytes[i + 1]]) as usize;
86        if length < 2 || i + length > bytes.len() {
87            return Err(ImageError::Malformed);
88        }
89
90        let is_sof = (0xC0..=0xCF).contains(&marker) && marker != 0xC4 && marker != 0xC8 && marker != 0xCC;
91        if is_sof {
92            if marker != 0xC0 {
93                return Err(ImageError::UnsupportedJpeg);
94            }
95            let payload = &bytes[i + 2..i + length];
96            if payload.len() < 6 {
97                return Err(ImageError::Malformed);
98            }
99            let height = u16::from_be_bytes([payload[1], payload[2]]) as u32;
100            let width = u16::from_be_bytes([payload[3], payload[4]]) as u32;
101            let components = payload[5];
102            if components != 1 && components != 3 {
103                return Err(ImageError::UnsupportedJpeg); // CMYK (4) or exotic
104            }
105            return Ok((width, height, components));
106        }
107        if marker == 0xDA {
108            return Err(ImageError::Malformed); // reached scan data, no SOF seen
109        }
110        i += length;
111    }
112    Err(ImageError::Malformed)
113}
114
115/// Parses a PNG's `IHDR` chunk (always the first chunk) and validates V1's
116/// supported subset: non-interlaced, 8-bit, RGB or RGBA (ADR-013).
117fn parse_png(bytes: &[u8]) -> Result<(u32, u32, u8), ImageError> {
118    if bytes.len() < 8 + 8 + 13 || bytes[0..8] != PNG_SIGNATURE {
119        return Err(ImageError::Malformed);
120    }
121    let chunk_len = u32::from_be_bytes(bytes[8..12].try_into().unwrap()) as usize;
122    if &bytes[12..16] != b"IHDR" || chunk_len != 13 {
123        return Err(ImageError::Malformed);
124    }
125    let ihdr = &bytes[16..16 + 13];
126    let width = u32::from_be_bytes(ihdr[0..4].try_into().unwrap());
127    let height = u32::from_be_bytes(ihdr[4..8].try_into().unwrap());
128    let bit_depth = ihdr[8];
129    let color_type = ihdr[9];
130    let interlace = ihdr[12];
131    if width == 0 || height == 0 {
132        return Err(ImageError::Malformed);
133    }
134    if interlace != 0 || bit_depth != 8 {
135        return Err(ImageError::UnsupportedPng);
136    }
137    let components = match color_type {
138        2 => 3,                                      // RGB
139        6 => 4,                                      // RGBA
140        _ => return Err(ImageError::UnsupportedPng), // grayscale(0)/palette(3)/gray+alpha(4) — not V1 scope
141    };
142    Ok((width, height, components))
143}
144
145/// A validated, embeddable JPEG or PNG. `bytes` are the original file
146/// bytes, kept as-is — pixel decoding (only ever needed for PNG, to split
147/// out the alpha channel as a `SMask`) happens later, in the facade.
148#[derive(Clone, Debug)]
149pub struct Image {
150    pub bytes: Arc<[u8]>,
151    pub format: ImageFormat,
152    pub width_px: u32,
153    pub height_px: u32,
154    /// 1 = Gray, 3 = RGB, 4 = RGBA (JPEG is always 1 or 3, never 4).
155    pub components: u8,
156    pub common: Common,
157}
158
159impl Image {
160    /// Validates `bytes` as a supported JPEG or PNG and extracts the
161    /// metadata layout needs (dimensions, color components). Rejects
162    /// anything outside V1's explicit scope instead of a silent
163    /// best-effort attempt (`phases/phase-5-images.md` step 2-3).
164    pub fn new(bytes: impl Into<Arc<[u8]>>) -> Result<Self, ImageError> {
165        let bytes: Arc<[u8]> = bytes.into();
166        let (format, width_px, height_px, components) = if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xD8 {
167            let (w, h, c) = parse_jpeg(&bytes)?;
168            (ImageFormat::Jpeg, w, h, c)
169        } else if bytes.len() >= 8 && bytes[0..8] == PNG_SIGNATURE {
170            let (w, h, c) = parse_png(&bytes)?;
171            (ImageFormat::Png, w, h, c)
172        } else {
173            return Err(ImageError::UnsupportedFormat);
174        };
175        if (width_px as u64) * (height_px as u64) > MAX_PIXELS {
176            return Err(ImageError::ImageTooLarge);
177        }
178        Ok(Image {
179            bytes,
180            format,
181            width_px,
182            height_px,
183            components,
184            common: Common::default(),
185        })
186    }
187
188    pub fn width(mut self, width: f32) -> Self {
189        self.common.width = Some(width);
190        self
191    }
192
193    pub fn height(mut self, height: f32) -> Self {
194        self.common.height = Some(height);
195        self
196    }
197
198    pub fn flex(mut self, factor: f32) -> Self {
199        self.common.flex = Some(factor);
200        self
201    }
202
203    pub fn keep_with_next(mut self) -> Self {
204        self.common.keep_with_next = true;
205        self
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    fn fixture(name: &str) -> Vec<u8> {
214        std::fs::read(format!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test-fixtures/images/{}"), name)).expect("test fixture present")
215    }
216
217    #[test]
218    fn accepts_rgba_png_with_transparency() {
219        let img = Image::new(fixture("logo_rgba.png")).unwrap();
220        assert_eq!(img.format, ImageFormat::Png);
221        assert_eq!(img.components, 4);
222        assert_eq!((img.width_px, img.height_px), (64, 48));
223    }
224
225    #[test]
226    fn accepts_opaque_rgb_png() {
227        let img = Image::new(fixture("logo_rgb.png")).unwrap();
228        assert_eq!(img.components, 3);
229        assert_eq!((img.width_px, img.height_px), (40, 30));
230    }
231
232    #[test]
233    fn accepts_baseline_rgb_jpeg() {
234        let img = Image::new(fixture("logo_baseline.jpg")).unwrap();
235        assert_eq!(img.format, ImageFormat::Jpeg);
236        assert_eq!(img.components, 3);
237        assert_eq!((img.width_px, img.height_px), (80, 60));
238    }
239
240    #[test]
241    fn accepts_baseline_gray_jpeg() {
242        let img = Image::new(fixture("logo_gray.jpg")).unwrap();
243        assert_eq!(img.components, 1);
244        assert_eq!((img.width_px, img.height_px), (32, 32));
245    }
246
247    #[test]
248    fn rejects_progressive_jpeg() {
249        assert_eq!(Image::new(fixture("progressive.jpg")).unwrap_err(), ImageError::UnsupportedJpeg);
250    }
251
252    #[test]
253    fn rejects_cmyk_jpeg() {
254        assert_eq!(Image::new(fixture("cmyk.jpg")).unwrap_err(), ImageError::UnsupportedJpeg);
255    }
256
257    #[test]
258    fn rejects_palette_png() {
259        assert_eq!(Image::new(fixture("palette.png")).unwrap_err(), ImageError::UnsupportedPng);
260    }
261
262    #[test]
263    fn rejects_sixteen_bit_png() {
264        assert_eq!(Image::new(fixture("sixteen_bit.png")).unwrap_err(), ImageError::UnsupportedPng);
265    }
266
267    #[test]
268    fn rejects_interlaced_png() {
269        // Real Adam7-interlaced fixture generation is unreliable across
270        // PNG encoders; a minimal synthetic IHDR with the interlace byte
271        // set is sufficient here since interlacing must be rejected
272        // before any pixel decoding is ever attempted.
273        let mut bytes = fixture("logo_rgb.png");
274        assert_eq!(&bytes[12..16], b"IHDR");
275        bytes[16 + 12] = 1; // interlace method = Adam7
276        assert_eq!(Image::new(bytes).unwrap_err(), ImageError::UnsupportedPng);
277    }
278
279    #[test]
280    fn rejects_garbage_bytes() {
281        assert_eq!(Image::new(vec![0u8; 16]).unwrap_err(), ImageError::UnsupportedFormat);
282    }
283
284    #[test]
285    fn rejects_oversized_declared_dimensions() {
286        let mut bytes = fixture("logo_rgb.png");
287        bytes[16..20].copy_from_slice(&10_000u32.to_be_bytes());
288        bytes[20..24].copy_from_slice(&10_000u32.to_be_bytes());
289        assert_eq!(Image::new(bytes).unwrap_err(), ImageError::ImageTooLarge);
290    }
291
292    #[test]
293    fn builder_methods_set_common_fields() {
294        let img = Image::new(fixture("logo_rgb.png"))
295            .unwrap()
296            .width(100.0)
297            .height(50.0)
298            .flex(1.0)
299            .keep_with_next();
300        assert_eq!(img.common.width, Some(100.0));
301        assert_eq!(img.common.height, Some(50.0));
302        assert_eq!(img.common.flex, Some(1.0));
303        assert!(img.common.keep_with_next);
304    }
305}