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#[cfg(test)]
113mod tests {
114    use super::*;
115
116    fn assert_approx(a: f64, b: f64) {
117        assert!(
118            (a - b).abs() < 1e-6,
119            "expected {b}, got {a}, diff={}",
120            (a - b).abs()
121        );
122    }
123
124    const PAGE_HEIGHT: f64 = 792.0;
125
126    // --- Image struct ---
127
128    #[test]
129    fn test_image_construction_and_field_access() {
130        let img = Image {
131            x0: 72.0,
132            top: 100.0,
133            x1: 272.0,
134            bottom: 250.0,
135            width: 200.0,
136            height: 150.0,
137            name: "Im0".to_string(),
138            src_width: Some(1920),
139            src_height: Some(1080),
140            bits_per_component: Some(8),
141            color_space: Some("DeviceRGB".to_string()),
142        };
143        assert_eq!(img.x0, 72.0);
144        assert_eq!(img.top, 100.0);
145        assert_eq!(img.x1, 272.0);
146        assert_eq!(img.bottom, 250.0);
147        assert_eq!(img.width, 200.0);
148        assert_eq!(img.height, 150.0);
149        assert_eq!(img.name, "Im0");
150        assert_eq!(img.src_width, Some(1920));
151        assert_eq!(img.src_height, Some(1080));
152        assert_eq!(img.bits_per_component, Some(8));
153        assert_eq!(img.color_space, Some("DeviceRGB".to_string()));
154
155        let bbox = img.bbox();
156        assert_approx(bbox.x0, 72.0);
157        assert_approx(bbox.top, 100.0);
158        assert_approx(bbox.x1, 272.0);
159        assert_approx(bbox.bottom, 250.0);
160    }
161
162    #[test]
163    fn test_image_bbox() {
164        let img = Image {
165            x0: 100.0,
166            top: 200.0,
167            x1: 300.0,
168            bottom: 400.0,
169            width: 200.0,
170            height: 200.0,
171            name: "Im0".to_string(),
172            src_width: Some(640),
173            src_height: Some(480),
174            bits_per_component: Some(8),
175            color_space: Some("DeviceRGB".to_string()),
176        };
177        let bbox = img.bbox();
178        assert_approx(bbox.x0, 100.0);
179        assert_approx(bbox.top, 200.0);
180        assert_approx(bbox.x1, 300.0);
181        assert_approx(bbox.bottom, 400.0);
182    }
183
184    // --- image_from_ctm ---
185
186    #[test]
187    fn test_image_from_ctm_simple_placement() {
188        // CTM places a 200x150 image at (100, 500) in PDF coords
189        // a=200 (width), d=150 (height), e=100 (x), f=500 (y)
190        let ctm = Ctm::new(200.0, 0.0, 0.0, 150.0, 100.0, 500.0);
191        let meta = ImageMetadata {
192            src_width: Some(640),
193            src_height: Some(480),
194            bits_per_component: Some(8),
195            color_space: Some("DeviceRGB".to_string()),
196        };
197
198        let img = image_from_ctm(&ctm, "Im0", PAGE_HEIGHT, &meta);
199
200        assert_approx(img.x0, 100.0);
201        assert_approx(img.x1, 300.0);
202        // y-flip: top = 792 - 650 = 142, bottom = 792 - 500 = 292
203        assert_approx(img.top, 142.0);
204        assert_approx(img.bottom, 292.0);
205        assert_approx(img.width, 200.0);
206        assert_approx(img.height, 150.0);
207        assert_eq!(img.name, "Im0");
208        assert_eq!(img.src_width, Some(640));
209        assert_eq!(img.src_height, Some(480));
210        assert_eq!(img.bits_per_component, Some(8));
211        assert_eq!(img.color_space, Some("DeviceRGB".to_string()));
212    }
213
214    #[test]
215    fn test_image_from_ctm_identity() {
216        // Identity CTM: image is 1×1 at origin
217        let ctm = Ctm::identity();
218        let meta = ImageMetadata::default();
219
220        let img = image_from_ctm(&ctm, "Im1", PAGE_HEIGHT, &meta);
221
222        assert_approx(img.x0, 0.0);
223        assert_approx(img.x1, 1.0);
224        // y-flip: top = 792 - 1 = 791, bottom = 792 - 0 = 792
225        assert_approx(img.top, 791.0);
226        assert_approx(img.bottom, 792.0);
227        assert_approx(img.width, 1.0);
228        assert_approx(img.height, 1.0);
229    }
230
231    #[test]
232    fn test_image_from_ctm_translation_only() {
233        // 1×1 image translated to (300, 400)
234        let ctm = Ctm::new(1.0, 0.0, 0.0, 1.0, 300.0, 400.0);
235        let meta = ImageMetadata::default();
236
237        let img = image_from_ctm(&ctm, "Im2", PAGE_HEIGHT, &meta);
238
239        assert_approx(img.x0, 300.0);
240        assert_approx(img.x1, 301.0);
241        // y-flip: top = 792 - 401 = 391, bottom = 792 - 400 = 392
242        assert_approx(img.top, 391.0);
243        assert_approx(img.bottom, 392.0);
244    }
245
246    #[test]
247    fn test_image_from_ctm_scale_and_translate() {
248        // 400×300 image at (50, 200)
249        let ctm = Ctm::new(400.0, 0.0, 0.0, 300.0, 50.0, 200.0);
250        let meta = ImageMetadata::default();
251
252        let img = image_from_ctm(&ctm, "Im3", PAGE_HEIGHT, &meta);
253
254        assert_approx(img.x0, 50.0);
255        assert_approx(img.x1, 450.0);
256        // y-flip: top = 792 - 500 = 292, bottom = 792 - 200 = 592
257        assert_approx(img.top, 292.0);
258        assert_approx(img.bottom, 592.0);
259        assert_approx(img.width, 400.0);
260        assert_approx(img.height, 300.0);
261    }
262
263    #[test]
264    fn test_image_from_ctm_no_metadata() {
265        let ctm = Ctm::new(100.0, 0.0, 0.0, 100.0, 200.0, 300.0);
266        let meta = ImageMetadata::default();
267
268        let img = image_from_ctm(&ctm, "ImX", PAGE_HEIGHT, &meta);
269
270        assert_eq!(img.name, "ImX");
271        assert_eq!(img.src_width, None);
272        assert_eq!(img.src_height, None);
273        assert_eq!(img.bits_per_component, None);
274        assert_eq!(img.color_space, None);
275    }
276
277    #[test]
278    fn test_image_from_ctm_different_page_height() {
279        // Letter-size page (11 inches = 792pt) vs A4 (842pt)
280        let ctm = Ctm::new(100.0, 0.0, 0.0, 100.0, 0.0, 0.0);
281        let meta = ImageMetadata::default();
282
283        let img_letter = image_from_ctm(&ctm, "Im0", 792.0, &meta);
284        let img_a4 = image_from_ctm(&ctm, "Im0", 842.0, &meta);
285
286        // Same width
287        assert_approx(img_letter.width, img_a4.width);
288        // Different top due to different page height
289        assert_approx(img_letter.top, 692.0); // 792 - 100
290        assert_approx(img_a4.top, 742.0); // 842 - 100
291    }
292
293    #[test]
294    fn test_image_metadata_default() {
295        let meta = ImageMetadata::default();
296        assert_eq!(meta.src_width, None);
297        assert_eq!(meta.src_height, None);
298        assert_eq!(meta.bits_per_component, None);
299        assert_eq!(meta.color_space, None);
300    }
301}