Skip to main content

lightweight_pdf/
images.rs

1//! Bridges validated `lightweight-pdf-core::Image` data to `lightweight-pdf-writer::ImageXObject`
2//! (Phase 5, `plan/phases/phase-5-images.md` steps 2-3). JPEG passes
3//! through byte-for-byte as `DCTDecode` (`Image::new` already validated
4//! baseline-ness/color type in `lightweight-pdf-core`, no re-encoding). PNG is
5//! decoded — only PNG actually needs it, to split the alpha channel out
6//! into a separate `SMask` — via the feature-reduced, pure-Rust `png`
7//! crate (ADR-013); RGB/alpha pixels are then embedded *uncompressed*,
8//! consistent with content streams and embedded fonts elsewhere in V1
9//! (`plan/progress.md`: no Flate encoder implemented, so nothing here
10//! re-compresses either).
11
12use lightweight_pdf_core::ImageFormat;
13use lightweight_pdf_writer::{ColorSpace, ImageDataFilter, ImageXObject};
14
15#[derive(Debug)]
16pub enum ImageEmbedError {
17    /// The document contains a PNG but the crate was built without the
18    /// `png` feature — there is no decoder available to split out alpha.
19    PngFeatureDisabled,
20    /// The `png` crate rejected the data at decode time (should be rare:
21    /// `lightweight-pdf-core::Image::new` already validated the header).
22    DecodeFailed,
23}
24
25impl core::fmt::Display for ImageEmbedError {
26    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
27        match self {
28            ImageEmbedError::PngFeatureDisabled => write!(f, "PNG image in document, but the `png` feature is disabled"),
29            ImageEmbedError::DecodeFailed => write!(f, "failed to decode PNG image data"),
30        }
31    }
32}
33
34/// Guards decompressed PNG output size (ADR-013: "Grenzen für ... dekomprimierte
35/// Bytes sind Pflicht") — generous but finite, enforced by the `png` crate
36/// itself via `Limits` during decode, not just this module's own pixel-count
37/// check in `lightweight-pdf-core`.
38#[cfg(feature = "png")]
39const MAX_DECOMPRESSED_BYTES: usize = 200_000_000;
40
41pub fn build_pdf_image(bytes: &[u8], format: ImageFormat, components: u8) -> Result<ImageXObject, ImageEmbedError> {
42    match format {
43        ImageFormat::Jpeg => Ok(build_jpeg(bytes, components)),
44        ImageFormat::Png => build_png(bytes),
45    }
46}
47
48fn build_jpeg(bytes: &[u8], components: u8) -> ImageXObject {
49    let color_space = if components == 1 {
50        ColorSpace::DeviceGray
51    } else {
52        ColorSpace::DeviceRgb
53    };
54    ImageXObject {
55        // Width/Height are read from the same validated header
56        // `lightweight-pdf-core::Image` already parsed; re-deriving them from the
57        // JPEG SOF marker a second time here would just duplicate that
58        // parsing, so the facade takes them from the `RenderNode::Image`
59        // it's translating (see `render.rs`) and overwrites these
60        // placeholders before returning.
61        width_px: 0,
62        height_px: 0,
63        color_space,
64        bits_per_component: 8,
65        filter: ImageDataFilter::DctDecode,
66        bytes: bytes.to_vec(),
67        smask: None,
68    }
69}
70
71#[cfg(feature = "png")]
72fn build_png(bytes: &[u8]) -> Result<ImageXObject, ImageEmbedError> {
73    let limits = png::Limits {
74        bytes: MAX_DECOMPRESSED_BYTES,
75    };
76    let decoder = png::Decoder::new_with_limits(std::io::Cursor::new(bytes), limits);
77    let mut reader = decoder.read_info().map_err(|_| ImageEmbedError::DecodeFailed)?;
78    let mut buf = vec![0u8; reader.output_buffer_size().ok_or(ImageEmbedError::DecodeFailed)?];
79    let info = reader.next_frame(&mut buf).map_err(|_| ImageEmbedError::DecodeFailed)?;
80    let pixels = &buf[..info.buffer_size()];
81
82    match info.color_type {
83        png::ColorType::Rgb => Ok(ImageXObject {
84            width_px: info.width,
85            height_px: info.height,
86            color_space: ColorSpace::DeviceRgb,
87            bits_per_component: 8,
88            filter: ImageDataFilter::None,
89            bytes: pixels.to_vec(),
90            smask: None,
91        }),
92        png::ColorType::Rgba => {
93            // `info.width`/`info.height` are `u32`; `usize` is only
94            // guaranteed to be at least 16 bits, so the widening is made
95            // explicit and fallible rather than an `as` cast. The
96            // multiplication is a different story: both values come from
97            // the PNG header (caller-supplied, not internally controlled),
98            // so a pathological image could overflow `usize` on a 32-bit
99            // target — `checked_mul` fails closed via `ImageEmbedError`
100            // instead of panicking (debug) or silently wrapping to an
101            // undersized allocation (release).
102            let width = usize::try_from(info.width).expect("u32 width fits in usize on every supported target");
103            let height = usize::try_from(info.height).expect("u32 height fits in usize on every supported target");
104            let pixel_count = width.checked_mul(height).ok_or(ImageEmbedError::DecodeFailed)?;
105            let mut rgb = Vec::with_capacity(pixel_count * 3);
106            let mut alpha = Vec::with_capacity(pixel_count);
107            for px in pixels.chunks_exact(4) {
108                rgb.extend_from_slice(&px[0..3]);
109                alpha.push(px[3]);
110            }
111            let smask = ImageXObject {
112                width_px: info.width,
113                height_px: info.height,
114                color_space: ColorSpace::DeviceGray,
115                bits_per_component: 8,
116                filter: ImageDataFilter::None,
117                bytes: alpha,
118                smask: None,
119            };
120            Ok(ImageXObject {
121                width_px: info.width,
122                height_px: info.height,
123                color_space: ColorSpace::DeviceRgb,
124                bits_per_component: 8,
125                filter: ImageDataFilter::None,
126                bytes: rgb,
127                smask: Some(Box::new(smask)),
128            })
129        }
130        // `lightweight-pdf-core::Image::new` only accepts color types 2 (RGB) and
131        // 6 (RGBA) — anything else reaching here would be a contract bug,
132        // not user input, but fail closed rather than embedding garbage.
133        _ => Err(ImageEmbedError::DecodeFailed),
134    }
135}
136
137#[cfg(not(feature = "png"))]
138fn build_png(_bytes: &[u8]) -> Result<ImageXObject, ImageEmbedError> {
139    Err(ImageEmbedError::PngFeatureDisabled)
140}