Skip to main content

pivot_pdf/
images.rs

1use crate::textflow::Rect;
2
3/// Opaque handle to a loaded image within a PdfDocument.
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
5pub struct ImageId(pub usize);
6
7/// Supported image formats.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ImageFormat {
10    /// JPEG (DCT-encoded). Embedded as-is without re-encoding.
11    Jpeg,
12    /// PNG (decoded to raw pixels before embedding).
13    Png,
14}
15
16/// How an image should be scaled to fit a bounding rectangle.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ImageFit {
19    /// Scale to fit within the rect, preserving aspect ratio.
20    Fit,
21    /// Scale to cover the rect, clipping overflow.
22    Fill,
23    /// Stretch to fill the rect exactly (may distort).
24    Stretch,
25    /// Natural size: 1 pixel = 1 point, no scaling.
26    None,
27}
28
29/// PDF color space for image data.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ColorSpace {
32    /// Full-color RGB (24-bit).
33    DeviceRGB,
34    /// Grayscale (8-bit).
35    DeviceGray,
36}
37
38impl ColorSpace {
39    /// Returns the PDF name string for this color space (e.g. `"DeviceRGB"`).
40    pub fn pdf_name(&self) -> &'static str {
41        match self {
42            ColorSpace::DeviceRGB => "DeviceRGB",
43            ColorSpace::DeviceGray => "DeviceGray",
44        }
45    }
46}
47
48/// Parsed image data ready for embedding into a PDF.
49pub struct ImageData {
50    /// Image width in pixels.
51    pub width: u32,
52    /// Image height in pixels.
53    pub height: u32,
54    /// Encoding format of the source image.
55    pub format: ImageFormat,
56    /// PDF color space for the pixel data.
57    pub color_space: ColorSpace,
58    /// Number of bits per color component (typically 8).
59    pub bits_per_component: u8,
60    /// Raw pixel data (RGB/Gray) or raw JPEG bytes.
61    pub data: Vec<u8>,
62    /// Separate alpha channel (grayscale), if present.
63    pub smask_data: Option<Vec<u8>>,
64}
65
66/// Computed placement of an image on a PDF page.
67#[derive(Debug)]
68pub struct ImagePlacement {
69    /// X position in PDF coordinates (bottom-left origin).
70    pub x: f64,
71    /// Y position in PDF coordinates (bottom-left origin).
72    pub y: f64,
73    /// Display width in points.
74    pub width: f64,
75    /// Display height in points.
76    pub height: f64,
77    /// Optional clip rectangle (for Fill mode) in PDF coordinates.
78    pub clip: Option<ClipRect>,
79}
80
81/// A clip rectangle in PDF coordinates (bottom-left origin).
82#[derive(Debug)]
83pub struct ClipRect {
84    /// Left edge in PDF coordinates.
85    pub x: f64,
86    /// Bottom edge in PDF coordinates.
87    pub y: f64,
88    /// Width in PDF points.
89    pub width: f64,
90    /// Height in PDF points.
91    pub height: f64,
92}
93
94/// Detect image format from magic bytes.
95pub fn detect_format(data: &[u8]) -> Result<ImageFormat, String> {
96    if data.len() < 4 {
97        return Err("Image data too short to detect format".to_string());
98    }
99    if data[0] == 0xFF && data[1] == 0xD8 {
100        Ok(ImageFormat::Jpeg)
101    } else if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
102        Ok(ImageFormat::Png)
103    } else {
104        Err("Unsupported image format (expected JPEG or PNG)".to_string())
105    }
106}
107
108/// Load and parse image data from raw bytes.
109pub fn load_image(data: Vec<u8>) -> Result<ImageData, String> {
110    let format = detect_format(&data)?;
111    match format {
112        ImageFormat::Jpeg => parse_jpeg(data),
113        ImageFormat::Png => parse_png(data),
114    }
115}
116
117/// Parse JPEG SOF marker to extract dimensions and color space.
118/// JPEG data is embedded as-is (DCTDecode); no pixel decoding needed.
119fn parse_jpeg(data: Vec<u8>) -> Result<ImageData, String> {
120    let (width, height, components) = jpeg_dimensions(&data)?;
121    let color_space = match components {
122        1 => ColorSpace::DeviceGray,
123        3 => ColorSpace::DeviceRGB,
124        _ => {
125            return Err(format!(
126                "Unsupported JPEG component count: {} (expected 1 or 3)",
127                components
128            ))
129        }
130    };
131
132    Ok(ImageData {
133        width,
134        height,
135        format: ImageFormat::Jpeg,
136        color_space,
137        bits_per_component: 8,
138        data,
139        smask_data: None,
140    })
141}
142
143/// Scan JPEG data for SOF0-SOF3 markers and extract width/height/components.
144fn jpeg_dimensions(data: &[u8]) -> Result<(u32, u32, u8), String> {
145    let len = data.len();
146    let mut i = 0;
147    while i + 1 < len {
148        if data[i] != 0xFF {
149            i += 1;
150            continue;
151        }
152        let marker = data[i + 1];
153        // SOF0 (0xC0) through SOF3 (0xC3) — baseline, extended, progressive, lossless
154        if (0xC0..=0xC3).contains(&marker) {
155            if i + 9 >= len {
156                return Err("JPEG SOF marker truncated".to_string());
157            }
158            let height = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
159            let width = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
160            let components = data[i + 9];
161            return Ok((width, height, components));
162        }
163        // Skip non-SOF markers
164        if marker == 0xFF || marker == 0x00 {
165            i += 1;
166            continue;
167        }
168        // Standalone markers (no length)
169        if marker == 0xD8 || marker == 0xD9 || (0xD0..=0xD7).contains(&marker) {
170            i += 2;
171            continue;
172        }
173        // Markers with length
174        if i + 3 >= len {
175            break;
176        }
177        let seg_len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
178        i += 2 + seg_len;
179    }
180    Err("No SOF marker found in JPEG data".to_string())
181}
182
183/// Decode PNG using the `png` crate and produce raw pixel data.
184fn parse_png(data: Vec<u8>) -> Result<ImageData, String> {
185    let decoder = png::Decoder::new(data.as_slice());
186    let mut reader = decoder
187        .read_info()
188        .map_err(|e| format!("PNG decode error: {}", e))?;
189
190    let mut buf = vec![0u8; reader.output_buffer_size()];
191    let info = reader
192        .next_frame(&mut buf)
193        .map_err(|e| format!("PNG frame error: {}", e))?;
194    buf.truncate(info.buffer_size());
195
196    let width = info.width;
197    let height = info.height;
198
199    match info.color_type {
200        png::ColorType::Rgb => Ok(ImageData {
201            width,
202            height,
203            format: ImageFormat::Png,
204            color_space: ColorSpace::DeviceRGB,
205            bits_per_component: 8,
206            data: buf,
207            smask_data: None,
208        }),
209        png::ColorType::Rgba => {
210            let pixel_count = (width * height) as usize;
211            let mut rgb = Vec::with_capacity(pixel_count * 3);
212            let mut alpha = Vec::with_capacity(pixel_count);
213            for chunk in buf.chunks_exact(4) {
214                rgb.push(chunk[0]);
215                rgb.push(chunk[1]);
216                rgb.push(chunk[2]);
217                alpha.push(chunk[3]);
218            }
219            Ok(ImageData {
220                width,
221                height,
222                format: ImageFormat::Png,
223                color_space: ColorSpace::DeviceRGB,
224                bits_per_component: 8,
225                data: rgb,
226                smask_data: Some(alpha),
227            })
228        }
229        png::ColorType::Grayscale => Ok(ImageData {
230            width,
231            height,
232            format: ImageFormat::Png,
233            color_space: ColorSpace::DeviceGray,
234            bits_per_component: 8,
235            data: buf,
236            smask_data: None,
237        }),
238        png::ColorType::GrayscaleAlpha => {
239            let pixel_count = (width * height) as usize;
240            let mut gray = Vec::with_capacity(pixel_count);
241            let mut alpha = Vec::with_capacity(pixel_count);
242            for chunk in buf.chunks_exact(2) {
243                gray.push(chunk[0]);
244                alpha.push(chunk[1]);
245            }
246            Ok(ImageData {
247                width,
248                height,
249                format: ImageFormat::Png,
250                color_space: ColorSpace::DeviceGray,
251                bits_per_component: 8,
252                data: gray,
253                smask_data: Some(alpha),
254            })
255        }
256        other => Err(format!("Unsupported PNG color type: {:?}", other)),
257    }
258}
259
260/// Calculate image placement within a rect.
261///
262/// `rect` must be in PDF space (bottom-left origin: `rect.y` is the bottom edge).
263/// Returns placement coordinates also in PDF space.
264pub fn calculate_placement(img_w: u32, img_h: u32, rect: &Rect, fit: ImageFit) -> ImagePlacement {
265    let iw = img_w as f64;
266    let ih = img_h as f64;
267    let pdf_bottom = rect.y;
268
269    match fit {
270        ImageFit::Fit => {
271            let scale_x = rect.width / iw;
272            let scale_y = rect.height / ih;
273            let scale = scale_x.min(scale_y);
274            let w = iw * scale;
275            let h = ih * scale;
276            // Center within the rect
277            let x = rect.x + (rect.width - w) / 2.0;
278            let y = pdf_bottom + (rect.height - h) / 2.0;
279            ImagePlacement {
280                x,
281                y,
282                width: w,
283                height: h,
284                clip: None,
285            }
286        }
287        ImageFit::Fill => {
288            let scale_x = rect.width / iw;
289            let scale_y = rect.height / ih;
290            let scale = scale_x.max(scale_y);
291            let w = iw * scale;
292            let h = ih * scale;
293            // Center the image (some parts will be clipped)
294            let x = rect.x + (rect.width - w) / 2.0;
295            let y = pdf_bottom + (rect.height - h) / 2.0;
296            ImagePlacement {
297                x,
298                y,
299                width: w,
300                height: h,
301                clip: Some(ClipRect {
302                    x: rect.x,
303                    y: pdf_bottom,
304                    width: rect.width,
305                    height: rect.height,
306                }),
307            }
308        }
309        ImageFit::Stretch => ImagePlacement {
310            x: rect.x,
311            y: pdf_bottom,
312            width: rect.width,
313            height: rect.height,
314            clip: None,
315        },
316        ImageFit::None => {
317            // 1 pixel = 1 point, positioned at top-left of rect (i.e. top edge = rect.y + height)
318            let y = pdf_bottom + (rect.height - ih);
319            ImagePlacement {
320                x: rect.x,
321                y,
322                width: iw,
323                height: ih,
324                clip: None,
325            }
326        }
327    }
328}