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///
64/// `serde` (issue #17): serializes as `{"bytes_base64": "...", "common":
65/// {..}}` — `format`/`width_px`/`height_px`/`components` are re-derived
66/// by re-running `Image::new`'s own header validation on deserialize
67/// rather than trusting redundant JSON fields that could disagree with
68/// the actual bytes.
69#[derive(Clone, Debug)]
70pub struct Image {
71    pub bytes: Arc<[u8]>,
72    pub format: ImageFormat,
73    pub width_px: u32,
74    pub height_px: u32,
75    /// 1 = Gray, 3 = RGB, 4 = RGBA (JPEG is always 1 or 3, never 4).
76    pub components: u8,
77    pub common: Common,
78    /// Alternate text description (issue #27) — becomes `/Alt` on this
79    /// image's `/Figure` structure element when `Document::pdf_ua()` is
80    /// set. `None` still renders (and still gets a `/Figure` tag, with an
81    /// empty `/Alt` so the structure tree stays well-formed) but
82    /// `render_with_diagnostics()` reports a
83    /// `LayoutWarningKind::MissingAltText` for it — and, verified against
84    /// veraPDF, the resulting PDF genuinely isn't PDF/UA-1-conformant
85    /// until real alt text is supplied (an empty `/Alt` doesn't satisfy
86    /// it; this crate won't invent placeholder text, since that would
87    /// mislead a screen reader user worse than an honest gap does).
88    pub alt: Option<String>,
89}
90
91#[cfg(feature = "serde")]
92impl serde::Serialize for Image {
93    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
94        use base64::Engine;
95        use serde::ser::SerializeStruct;
96        let mut state = serializer.serialize_struct("Image", 3)?;
97        state.serialize_field("bytes_base64", &base64::engine::general_purpose::STANDARD.encode(&self.bytes))?;
98        state.serialize_field("common", &self.common)?;
99        state.serialize_field("alt", &self.alt)?;
100        state.end()
101    }
102}
103
104#[cfg(feature = "serde")]
105impl<'de> serde::Deserialize<'de> for Image {
106    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
107    where
108        D: serde::Deserializer<'de>,
109    {
110        use base64::Engine;
111
112        #[derive(serde::Deserialize)]
113        #[serde(deny_unknown_fields)]
114        struct Raw {
115            bytes_base64: String,
116            #[serde(default)]
117            common: Common,
118            #[serde(default)]
119            alt: Option<String>,
120        }
121
122        let raw = Raw::deserialize(deserializer)?;
123        let bytes = base64::engine::general_purpose::STANDARD
124            .decode(raw.bytes_base64.as_bytes())
125            .map_err(serde::de::Error::custom)?;
126        let mut image = Image::new(bytes).map_err(serde::de::Error::custom)?;
127        image.common = raw.common;
128        image.alt = raw.alt;
129        Ok(image)
130    }
131}
132
133/// Hand-written to match the custom `Serialize`/`Deserialize` above.
134#[cfg(feature = "schemars")]
135impl schemars::JsonSchema for Image {
136    fn schema_name() -> std::borrow::Cow<'static, str> {
137        "Image".into()
138    }
139
140    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
141        schemars::json_schema!({
142            "type": "object",
143            "properties": {
144                "bytes_base64": { "type": "string" },
145                "common": generator.subschema_for::<Common>(),
146                "alt": { "type": ["string", "null"] }
147            },
148            "required": ["bytes_base64"],
149            "additionalProperties": false
150        })
151    }
152}
153
154impl Image {
155    /// Validates `bytes` as a supported JPEG or PNG and extracts the
156    /// metadata layout needs (dimensions, color components). Rejects
157    /// anything outside V1's explicit scope instead of a silent
158    /// best-effort attempt (`phases/phase-5-images.md` step 2-3).
159    pub fn new(bytes: impl Into<Arc<[u8]>>) -> Result<Self, ImageError> {
160        let bytes: Arc<[u8]> = bytes.into();
161        let (format, width_px, height_px, components) = if bytes.starts_with(&[0xFF, 0xD8]) {
162            let (w, h, c) = parse_jpeg(&bytes)?;
163            (ImageFormat::Jpeg, w, h, c)
164        } else if bytes.starts_with(&PNG_SIGNATURE) {
165            let (w, h, c) = parse_png(&bytes)?;
166            (ImageFormat::Png, w, h, c)
167        } else {
168            return Err(ImageError::UnsupportedFormat);
169        };
170        if (width_px as u64) * (height_px as u64) > MAX_PIXELS {
171            return Err(ImageError::ImageTooLarge);
172        }
173        Ok(Image {
174            bytes,
175            format,
176            width_px,
177            height_px,
178            components,
179            common: Common::default(),
180            alt: None,
181        })
182    }
183
184    /// Sets this image's alternate text description (issue #27) — see the
185    /// `alt` field's doc comment.
186    pub fn alt(mut self, alt: impl Into<String>) -> Self {
187        self.alt = Some(alt.into());
188        self
189    }
190
191    pub fn width(mut self, width: f32) -> Self {
192        self.common.width = Some(width);
193        self
194    }
195
196    pub fn height(mut self, height: f32) -> Self {
197        self.common.height = Some(height);
198        self
199    }
200
201    pub fn flex(mut self, factor: f32) -> Self {
202        self.common.flex = Some(factor);
203        self
204    }
205
206    pub fn keep_with_next(mut self) -> Self {
207        self.common.keep_with_next = true;
208        self
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    fn fixture(name: &str) -> Vec<u8> {
217        std::fs::read(format!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test-fixtures/images/{}"), name)).expect("test fixture present")
218    }
219
220    #[test]
221    fn accepts_rgba_png_with_transparency() {
222        let img = Image::new(fixture("logo_rgba.png")).unwrap();
223        assert_eq!(img.format, ImageFormat::Png);
224        assert_eq!(img.components, 4);
225        assert_eq!((img.width_px, img.height_px), (64, 48));
226    }
227
228    #[test]
229    fn accepts_opaque_rgb_png() {
230        let img = Image::new(fixture("logo_rgb.png")).unwrap();
231        assert_eq!(img.components, 3);
232        assert_eq!((img.width_px, img.height_px), (40, 30));
233    }
234
235    #[test]
236    fn accepts_baseline_rgb_jpeg() {
237        let img = Image::new(fixture("logo_baseline.jpg")).unwrap();
238        assert_eq!(img.format, ImageFormat::Jpeg);
239        assert_eq!(img.components, 3);
240        assert_eq!((img.width_px, img.height_px), (80, 60));
241    }
242
243    #[test]
244    fn accepts_baseline_gray_jpeg() {
245        let img = Image::new(fixture("logo_gray.jpg")).unwrap();
246        assert_eq!(img.components, 1);
247        assert_eq!((img.width_px, img.height_px), (32, 32));
248    }
249
250    #[test]
251    fn rejects_progressive_jpeg() {
252        assert_eq!(Image::new(fixture("progressive.jpg")).unwrap_err(), ImageError::UnsupportedJpeg);
253    }
254
255    #[test]
256    fn rejects_cmyk_jpeg() {
257        assert_eq!(Image::new(fixture("cmyk.jpg")).unwrap_err(), ImageError::UnsupportedJpeg);
258    }
259
260    #[test]
261    fn rejects_palette_png() {
262        assert_eq!(Image::new(fixture("palette.png")).unwrap_err(), ImageError::UnsupportedPng);
263    }
264
265    #[test]
266    fn rejects_sixteen_bit_png() {
267        assert_eq!(Image::new(fixture("sixteen_bit.png")).unwrap_err(), ImageError::UnsupportedPng);
268    }
269
270    #[test]
271    fn rejects_interlaced_png() {
272        // Real Adam7-interlaced fixture generation is unreliable across
273        // PNG encoders; a minimal synthetic IHDR with the interlace byte
274        // set is sufficient here since interlacing must be rejected
275        // before any pixel decoding is ever attempted.
276        let mut bytes = fixture("logo_rgb.png");
277        assert_eq!(&bytes[12..16], b"IHDR");
278        bytes[16 + 12] = 1; // interlace method = Adam7
279        assert_eq!(Image::new(bytes).unwrap_err(), ImageError::UnsupportedPng);
280    }
281
282    #[test]
283    fn rejects_garbage_bytes() {
284        assert_eq!(Image::new(vec![0u8; 16]).unwrap_err(), ImageError::UnsupportedFormat);
285    }
286
287    #[test]
288    fn rejects_oversized_declared_dimensions() {
289        let mut bytes = fixture("logo_rgb.png");
290        bytes[16..20].copy_from_slice(&10_000u32.to_be_bytes());
291        bytes[20..24].copy_from_slice(&10_000u32.to_be_bytes());
292        assert_eq!(Image::new(bytes).unwrap_err(), ImageError::ImageTooLarge);
293    }
294
295    #[test]
296    fn builder_methods_set_common_fields() {
297        let img = Image::new(fixture("logo_rgb.png"))
298            .unwrap()
299            .width(100.0)
300            .height(50.0)
301            .flex(1.0)
302            .keep_with_next();
303        assert_eq!(img.common.width, Some(100.0));
304        assert_eq!(img.common.height, Some(50.0));
305        assert_eq!(img.common.flex, Some(1.0));
306        assert!(img.common.keep_with_next);
307    }
308}