Skip to main content

pdfplumber_core/
images.rs

1//! Image extraction from XObject Do operator.
2//!
3//! Extracts Image objects from the CTM active when the `Do` operator
4//! is invoked for an Image XObject. The image is placed in a 1×1 unit
5//! square that is mapped to the page via the CTM.
6
7use crate::geometry::{BBox, Ctm, Point};
8
9/// Metadata about an image XObject from the PDF resource dictionary.
10#[derive(Debug, Clone, Default, PartialEq)]
11#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
12pub struct ImageMetadata {
13    /// Original pixel width of the image.
14    pub src_width: Option<u32>,
15    /// Original pixel height of the image.
16    pub src_height: Option<u32>,
17    /// Bits per component (e.g., 8).
18    pub bits_per_component: Option<u32>,
19    /// Color space name (e.g., "DeviceRGB", "DeviceGray").
20    pub color_space: Option<String>,
21}
22
23/// An image extracted from a PDF page via the Do operator.
24///
25/// Coordinates use pdfplumber's top-left origin system.
26#[derive(Debug, Clone, PartialEq)]
27#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
28pub struct Image {
29    /// Left x coordinate.
30    pub x0: f64,
31    /// Top y coordinate (distance from top of page).
32    pub top: f64,
33    /// Right x coordinate.
34    pub x1: f64,
35    /// Bottom y coordinate (distance from top of page).
36    pub bottom: f64,
37    /// Display width in points.
38    pub width: f64,
39    /// Display height in points.
40    pub height: f64,
41    /// XObject name (e.g., "Im0").
42    pub name: String,
43    /// Original pixel width.
44    pub src_width: Option<u32>,
45    /// Original pixel height.
46    pub src_height: Option<u32>,
47    /// Bits per component.
48    pub bits_per_component: Option<u32>,
49    /// Color space name.
50    pub color_space: Option<String>,
51}
52
53/// Extract an Image from the CTM active during a Do operator invocation.
54///
55/// Image XObjects are defined in a 1×1 unit square. The CTM maps this
56/// unit square to the actual display area on the page. The four corners
57/// of the unit square `(0,0), (1,0), (0,1), (1,1)` are transformed
58/// through the CTM to compute the bounding box.
59///
60/// Coordinates are converted from PDF bottom-left origin to top-left origin
61/// using `page_height`.
62pub fn image_from_ctm(ctm: &Ctm, name: &str, page_height: f64, metadata: &ImageMetadata) -> Image {
63    // Transform the 4 corners of the unit square through the CTM
64    let corners = [
65        ctm.transform_point(Point::new(0.0, 0.0)),
66        ctm.transform_point(Point::new(1.0, 0.0)),
67        ctm.transform_point(Point::new(0.0, 1.0)),
68        ctm.transform_point(Point::new(1.0, 1.0)),
69    ];
70
71    // Find bounding box in PDF coordinates
72    let pdf_x0 = corners.iter().map(|p| p.x).fold(f64::INFINITY, f64::min);
73    let pdf_x1 = corners
74        .iter()
75        .map(|p| p.x)
76        .fold(f64::NEG_INFINITY, f64::max);
77    let pdf_y0 = corners.iter().map(|p| p.y).fold(f64::INFINITY, f64::min);
78    let pdf_y1 = corners
79        .iter()
80        .map(|p| p.y)
81        .fold(f64::NEG_INFINITY, f64::max);
82
83    // Convert to top-left origin
84    let top = page_height - pdf_y1;
85    let bottom = page_height - pdf_y0;
86
87    let width = pdf_x1 - pdf_x0;
88    let height = bottom - top;
89
90    Image {
91        x0: pdf_x0,
92        top,
93        x1: pdf_x1,
94        bottom,
95        width,
96        height,
97        name: name.to_string(),
98        src_width: metadata.src_width,
99        src_height: metadata.src_height,
100        bits_per_component: metadata.bits_per_component,
101        color_space: metadata.color_space.clone(),
102    }
103}
104
105impl Image {
106    /// Returns the bounding box in top-left origin coordinates.
107    pub fn bbox(&self) -> BBox {
108        BBox::new(self.x0, self.top, self.x1, self.bottom)
109    }
110}
111
112/// Format of extracted image data.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
115pub enum ImageFormat {
116    /// JPEG image (DCTDecode filter).
117    Jpeg,
118    /// PNG image.
119    Png,
120    /// Raw uncompressed pixel data.
121    Raw,
122    /// JBIG2 compressed image.
123    Jbig2,
124    /// CCITT fax compressed image.
125    CcittFax,
126}
127
128impl ImageFormat {
129    /// Returns the typical file extension for this image format.
130    pub fn extension(&self) -> &str {
131        match self {
132            ImageFormat::Jpeg => "jpg",
133            ImageFormat::Png => "png",
134            ImageFormat::Raw => "raw",
135            ImageFormat::Jbig2 => "jbig2",
136            ImageFormat::CcittFax => "ccitt",
137        }
138    }
139}
140
141/// Extracted image content (raw bytes) from a PDF image XObject.
142#[derive(Debug, Clone, PartialEq)]
143#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
144pub struct ImageContent {
145    /// The image data bytes.
146    pub data: Vec<u8>,
147    /// The format of the image data.
148    pub format: ImageFormat,
149    /// Image width in pixels.
150    pub width: u32,
151    /// Image height in pixels.
152    pub height: u32,
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    fn assert_approx(a: f64, b: f64) {
160        assert!(
161            (a - b).abs() < 1e-6,
162            "expected {b}, got {a}, diff={}",
163            (a - b).abs()
164        );
165    }
166
167    const PAGE_HEIGHT: f64 = 792.0;
168
169    // --- Image struct ---
170
171    #[test]
172    fn test_image_construction_and_field_access() {
173        let img = Image {
174            x0: 72.0,
175            top: 100.0,
176            x1: 272.0,
177            bottom: 250.0,
178            width: 200.0,
179            height: 150.0,
180            name: "Im0".to_string(),
181            src_width: Some(1920),
182            src_height: Some(1080),
183            bits_per_component: Some(8),
184            color_space: Some("DeviceRGB".to_string()),
185        };
186        assert_eq!(img.x0, 72.0);
187        assert_eq!(img.top, 100.0);
188        assert_eq!(img.x1, 272.0);
189        assert_eq!(img.bottom, 250.0);
190        assert_eq!(img.width, 200.0);
191        assert_eq!(img.height, 150.0);
192        assert_eq!(img.name, "Im0");
193        assert_eq!(img.src_width, Some(1920));
194        assert_eq!(img.src_height, Some(1080));
195        assert_eq!(img.bits_per_component, Some(8));
196        assert_eq!(img.color_space, Some("DeviceRGB".to_string()));
197
198        let bbox = img.bbox();
199        assert_approx(bbox.x0, 72.0);
200        assert_approx(bbox.top, 100.0);
201        assert_approx(bbox.x1, 272.0);
202        assert_approx(bbox.bottom, 250.0);
203    }
204
205    #[test]
206    fn test_image_bbox() {
207        let img = Image {
208            x0: 100.0,
209            top: 200.0,
210            x1: 300.0,
211            bottom: 400.0,
212            width: 200.0,
213            height: 200.0,
214            name: "Im0".to_string(),
215            src_width: Some(640),
216            src_height: Some(480),
217            bits_per_component: Some(8),
218            color_space: Some("DeviceRGB".to_string()),
219        };
220        let bbox = img.bbox();
221        assert_approx(bbox.x0, 100.0);
222        assert_approx(bbox.top, 200.0);
223        assert_approx(bbox.x1, 300.0);
224        assert_approx(bbox.bottom, 400.0);
225    }
226
227    // --- image_from_ctm ---
228
229    #[test]
230    fn test_image_from_ctm_simple_placement() {
231        // CTM places a 200x150 image at (100, 500) in PDF coords
232        // a=200 (width), d=150 (height), e=100 (x), f=500 (y)
233        let ctm = Ctm::new(200.0, 0.0, 0.0, 150.0, 100.0, 500.0);
234        let meta = ImageMetadata {
235            src_width: Some(640),
236            src_height: Some(480),
237            bits_per_component: Some(8),
238            color_space: Some("DeviceRGB".to_string()),
239        };
240
241        let img = image_from_ctm(&ctm, "Im0", PAGE_HEIGHT, &meta);
242
243        assert_approx(img.x0, 100.0);
244        assert_approx(img.x1, 300.0);
245        // y-flip: top = 792 - 650 = 142, bottom = 792 - 500 = 292
246        assert_approx(img.top, 142.0);
247        assert_approx(img.bottom, 292.0);
248        assert_approx(img.width, 200.0);
249        assert_approx(img.height, 150.0);
250        assert_eq!(img.name, "Im0");
251        assert_eq!(img.src_width, Some(640));
252        assert_eq!(img.src_height, Some(480));
253        assert_eq!(img.bits_per_component, Some(8));
254        assert_eq!(img.color_space, Some("DeviceRGB".to_string()));
255    }
256
257    #[test]
258    fn test_image_from_ctm_identity() {
259        // Identity CTM: image is 1×1 at origin
260        let ctm = Ctm::identity();
261        let meta = ImageMetadata::default();
262
263        let img = image_from_ctm(&ctm, "Im1", PAGE_HEIGHT, &meta);
264
265        assert_approx(img.x0, 0.0);
266        assert_approx(img.x1, 1.0);
267        // y-flip: top = 792 - 1 = 791, bottom = 792 - 0 = 792
268        assert_approx(img.top, 791.0);
269        assert_approx(img.bottom, 792.0);
270        assert_approx(img.width, 1.0);
271        assert_approx(img.height, 1.0);
272    }
273
274    #[test]
275    fn test_image_from_ctm_translation_only() {
276        // 1×1 image translated to (300, 400)
277        let ctm = Ctm::new(1.0, 0.0, 0.0, 1.0, 300.0, 400.0);
278        let meta = ImageMetadata::default();
279
280        let img = image_from_ctm(&ctm, "Im2", PAGE_HEIGHT, &meta);
281
282        assert_approx(img.x0, 300.0);
283        assert_approx(img.x1, 301.0);
284        // y-flip: top = 792 - 401 = 391, bottom = 792 - 400 = 392
285        assert_approx(img.top, 391.0);
286        assert_approx(img.bottom, 392.0);
287    }
288
289    #[test]
290    fn test_image_from_ctm_scale_and_translate() {
291        // 400×300 image at (50, 200)
292        let ctm = Ctm::new(400.0, 0.0, 0.0, 300.0, 50.0, 200.0);
293        let meta = ImageMetadata::default();
294
295        let img = image_from_ctm(&ctm, "Im3", PAGE_HEIGHT, &meta);
296
297        assert_approx(img.x0, 50.0);
298        assert_approx(img.x1, 450.0);
299        // y-flip: top = 792 - 500 = 292, bottom = 792 - 200 = 592
300        assert_approx(img.top, 292.0);
301        assert_approx(img.bottom, 592.0);
302        assert_approx(img.width, 400.0);
303        assert_approx(img.height, 300.0);
304    }
305
306    #[test]
307    fn test_image_from_ctm_no_metadata() {
308        let ctm = Ctm::new(100.0, 0.0, 0.0, 100.0, 200.0, 300.0);
309        let meta = ImageMetadata::default();
310
311        let img = image_from_ctm(&ctm, "ImX", PAGE_HEIGHT, &meta);
312
313        assert_eq!(img.name, "ImX");
314        assert_eq!(img.src_width, None);
315        assert_eq!(img.src_height, None);
316        assert_eq!(img.bits_per_component, None);
317        assert_eq!(img.color_space, None);
318    }
319
320    #[test]
321    fn test_image_from_ctm_different_page_height() {
322        // Letter-size page (11 inches = 792pt) vs A4 (842pt)
323        let ctm = Ctm::new(100.0, 0.0, 0.0, 100.0, 0.0, 0.0);
324        let meta = ImageMetadata::default();
325
326        let img_letter = image_from_ctm(&ctm, "Im0", 792.0, &meta);
327        let img_a4 = image_from_ctm(&ctm, "Im0", 842.0, &meta);
328
329        // Same width
330        assert_approx(img_letter.width, img_a4.width);
331        // Different top due to different page height
332        assert_approx(img_letter.top, 692.0); // 792 - 100
333        assert_approx(img_a4.top, 742.0); // 842 - 100
334    }
335
336    #[test]
337    fn test_image_metadata_default() {
338        let meta = ImageMetadata::default();
339        assert_eq!(meta.src_width, None);
340        assert_eq!(meta.src_height, None);
341        assert_eq!(meta.bits_per_component, None);
342        assert_eq!(meta.color_space, None);
343    }
344
345    // --- ImageFormat ---
346
347    #[test]
348    fn test_image_format_extension() {
349        assert_eq!(ImageFormat::Jpeg.extension(), "jpg");
350        assert_eq!(ImageFormat::Png.extension(), "png");
351        assert_eq!(ImageFormat::Raw.extension(), "raw");
352        assert_eq!(ImageFormat::Jbig2.extension(), "jbig2");
353        assert_eq!(ImageFormat::CcittFax.extension(), "ccitt");
354    }
355
356    #[test]
357    fn test_image_format_clone_eq() {
358        let fmt = ImageFormat::Jpeg;
359        let fmt2 = fmt;
360        assert_eq!(fmt, fmt2);
361    }
362
363    // --- ImageContent ---
364
365    #[test]
366    fn test_image_content_construction() {
367        let content = ImageContent {
368            data: vec![0xFF, 0xD8, 0xFF, 0xE0],
369            format: ImageFormat::Jpeg,
370            width: 640,
371            height: 480,
372        };
373        assert_eq!(content.data.len(), 4);
374        assert_eq!(content.format, ImageFormat::Jpeg);
375        assert_eq!(content.width, 640);
376        assert_eq!(content.height, 480);
377    }
378
379    #[test]
380    fn test_image_content_raw_format() {
381        // 2x2 RGB image = 12 bytes
382        let data = vec![255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0];
383        let content = ImageContent {
384            data: data.clone(),
385            format: ImageFormat::Raw,
386            width: 2,
387            height: 2,
388        };
389        assert_eq!(content.data, data);
390        assert_eq!(content.format, ImageFormat::Raw);
391        assert_eq!(content.width, 2);
392        assert_eq!(content.height, 2);
393    }
394
395    #[test]
396    fn test_image_content_clone_eq() {
397        let content = ImageContent {
398            data: vec![1, 2, 3],
399            format: ImageFormat::Png,
400            width: 10,
401            height: 10,
402        };
403        let content2 = content.clone();
404        assert_eq!(content, content2);
405    }
406}