Skip to main content

fop_render/pdf/
image.rs

1//! PDF Image XObject support
2//!
3//! Handles embedding images (PNG, JPEG) as XObjects in PDF documents.
4
5use crate::image::{ImageFormat, ImageInfo};
6use flate2::write::ZlibEncoder;
7use flate2::Compression;
8use fop_types::Result;
9use jpeg_decoder::Decoder;
10use std::io::{Cursor, Write};
11
12/// PDF Image XObject representation
13#[derive(Debug, Clone)]
14pub struct ImageXObject {
15    /// Image width in pixels
16    pub width: u32,
17
18    /// Image height in pixels
19    pub height: u32,
20
21    /// Color space (DeviceRGB, DeviceGray, etc.)
22    pub color_space: String,
23
24    /// Bits per component (typically 8)
25    pub bits_per_component: u8,
26
27    /// Filter name (DCTDecode for JPEG, FlateDecode for PNG)
28    pub filter: String,
29
30    /// Raw image data (JPEG data for DCTDecode, compressed for FlateDecode)
31    pub data: Vec<u8>,
32}
33
34impl ImageXObject {
35    /// Create an XObject from ImageInfo
36    pub fn from_image_info(info: &ImageInfo) -> Result<Self> {
37        match info.format {
38            ImageFormat::JPEG => Self::from_jpeg(&info.data),
39            ImageFormat::PNG => Self::from_png(&info.data),
40            ImageFormat::Unknown => Err(fop_types::FopError::Generic(
41                "Cannot create XObject from unknown image format".to_string(),
42            )),
43        }
44    }
45
46    /// Create an XObject from JPEG data
47    ///
48    /// JPEG images can be embedded directly in PDF using the DCTDecode filter,
49    /// which allows the raw JPEG data to be used without decompression.
50    pub fn from_jpeg(jpeg_data: &[u8]) -> Result<Self> {
51        // Decode JPEG to extract metadata
52        let mut decoder = Decoder::new(Cursor::new(jpeg_data));
53        decoder.read_info().map_err(|e| {
54            fop_types::FopError::Generic(format!("Failed to read JPEG info: {}", e))
55        })?;
56
57        let metadata = decoder.info().ok_or_else(|| {
58            fop_types::FopError::Generic("JPEG decoder info not available".to_string())
59        })?;
60
61        let width = metadata.width;
62        let height = metadata.height;
63
64        // Determine color space from pixel format
65        let color_space = match metadata.pixel_format {
66            jpeg_decoder::PixelFormat::L8 => "DeviceGray",
67            jpeg_decoder::PixelFormat::L16 => "DeviceGray",
68            jpeg_decoder::PixelFormat::RGB24 => "DeviceRGB",
69            jpeg_decoder::PixelFormat::CMYK32 => "DeviceCMYK",
70        };
71
72        Ok(Self {
73            width: width as u32,
74            height: height as u32,
75            color_space: color_space.to_string(),
76            bits_per_component: 8,
77            filter: "DCTDecode".to_string(),
78            data: jpeg_data.to_vec(),
79        })
80    }
81
82    /// Create an XObject from PNG data
83    ///
84    /// PNG images require decompression and recompression with FlateDecode.
85    pub fn from_png(png_data: &[u8]) -> Result<Self> {
86        use std::io::Cursor;
87        // Decode PNG using png crate
88        let decoder = png::Decoder::new(Cursor::new(png_data));
89        let mut reader = decoder
90            .read_info()
91            .map_err(|e| fop_types::FopError::Generic(format!("PNG decode error: {}", e)))?;
92
93        let info = reader.info();
94        let width = info.width;
95        let height = info.height;
96        let color_type = info.color_type;
97        let bit_depth = info.bit_depth;
98
99        // Validate bit depth
100        if bit_depth != png::BitDepth::Eight {
101            return Err(fop_types::FopError::Generic(
102                "Only 8-bit PNG images are supported".to_string(),
103            ));
104        }
105
106        // Determine color space and expected components
107        let (color_space, components) = match color_type {
108            png::ColorType::Rgb => ("DeviceRGB", 3),
109            png::ColorType::Rgba => ("DeviceRGB", 3), // We'll strip alpha
110            png::ColorType::Grayscale => ("DeviceGray", 1),
111            png::ColorType::GrayscaleAlpha => ("DeviceGray", 1), // We'll strip alpha
112            png::ColorType::Indexed => {
113                return Err(fop_types::FopError::Generic(
114                    "Indexed PNG images are not supported".to_string(),
115                ))
116            }
117        };
118
119        // Allocate buffer for decoded image
120        let buf_size = reader.output_buffer_size().ok_or_else(|| {
121            fop_types::FopError::Generic("PNG: could not determine output buffer size".to_string())
122        })?;
123        let mut buf = vec![0; buf_size];
124        let output_info = reader
125            .next_frame(&mut buf)
126            .map_err(|e| fop_types::FopError::Generic(format!("PNG frame error: {}", e)))?;
127
128        // Get actual decoded data
129        let decoded_data = &buf[..output_info.buffer_size()];
130
131        // Strip alpha channel if present
132        let rgb_data = if color_type == png::ColorType::Rgba {
133            Self::strip_alpha_rgba(decoded_data, width, height)
134        } else if color_type == png::ColorType::GrayscaleAlpha {
135            Self::strip_alpha_grayscale(decoded_data, width, height)
136        } else {
137            decoded_data.to_vec()
138        };
139
140        // Validate data size
141        let expected_size = (width * height) as usize * components;
142        if rgb_data.len() != expected_size {
143            return Err(fop_types::FopError::Generic(format!(
144                "Unexpected PNG data size: got {}, expected {}",
145                rgb_data.len(),
146                expected_size
147            )));
148        }
149
150        // Compress data using FlateDecode (zlib)
151        let compressed_data = Self::compress_data(&rgb_data)?;
152
153        Ok(Self {
154            width,
155            height,
156            color_space: color_space.to_string(),
157            bits_per_component: 8,
158            filter: "FlateDecode".to_string(),
159            data: compressed_data,
160        })
161    }
162
163    /// Strip alpha channel from RGBA data
164    fn strip_alpha_rgba(rgba_data: &[u8], width: u32, height: u32) -> Vec<u8> {
165        let pixel_count = (width * height) as usize;
166        let mut rgb_data = Vec::with_capacity(pixel_count * 3);
167
168        for i in 0..pixel_count {
169            let offset = i * 4;
170            rgb_data.push(rgba_data[offset]); // R
171            rgb_data.push(rgba_data[offset + 1]); // G
172            rgb_data.push(rgba_data[offset + 2]); // B
173                                                  // Skip alpha channel
174        }
175
176        rgb_data
177    }
178
179    /// Strip alpha channel from grayscale+alpha data
180    fn strip_alpha_grayscale(ga_data: &[u8], width: u32, height: u32) -> Vec<u8> {
181        let pixel_count = (width * height) as usize;
182        let mut gray_data = Vec::with_capacity(pixel_count);
183
184        for i in 0..pixel_count {
185            let offset = i * 2;
186            gray_data.push(ga_data[offset]); // Gray
187                                             // Skip alpha channel
188        }
189
190        gray_data
191    }
192
193    /// Compress data using zlib (FlateDecode)
194    fn compress_data(data: &[u8]) -> Result<Vec<u8>> {
195        let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
196        encoder
197            .write_all(data)
198            .map_err(|e| fop_types::FopError::Generic(format!("Compression error: {}", e)))?;
199        encoder
200            .finish()
201            .map_err(|e| fop_types::FopError::Generic(format!("Compression finish error: {}", e)))
202    }
203
204    /// Generate the PDF XObject dictionary and stream
205    pub fn to_pdf_stream(&self, object_id: u32) -> String {
206        let mut result = String::new();
207
208        // Object header
209        result.push_str(&format!("{} 0 obj\n", object_id));
210        result.push_str("<<\n");
211        result.push_str("/Type /XObject\n");
212        result.push_str("/Subtype /Image\n");
213        result.push_str(&format!("/Width {}\n", self.width));
214        result.push_str(&format!("/Height {}\n", self.height));
215        result.push_str(&format!("/ColorSpace /{}\n", self.color_space));
216        result.push_str(&format!("/BitsPerComponent {}\n", self.bits_per_component));
217        result.push_str(&format!("/Filter /{}\n", self.filter));
218        result.push_str(&format!("/Length {}\n", self.data.len()));
219        result.push_str(">>\n");
220        result.push_str("stream\n");
221
222        // Binary data will be added separately
223        result
224    }
225
226    /// Get the stream data
227    pub fn stream_data(&self) -> &[u8] {
228        &self.data
229    }
230
231    /// Get the stream end marker
232    pub fn stream_end() -> &'static str {
233        "\nendstream\nendobj\n"
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    // Minimal JPEG header (SOI + SOF0 + EOI)
242    fn minimal_jpeg() -> Vec<u8> {
243        vec![
244            0xFF, 0xD8, // SOI (Start of Image)
245            0xFF, 0xE0, // APP0
246            0x00, 0x10, // Length
247            0x4A, 0x46, 0x49, 0x46, 0x00, // "JFIF\0"
248            0x01, 0x01, // Version 1.1
249            0x00, // Density units
250            0x00, 0x01, 0x00, 0x01, // X and Y density
251            0x00, 0x00, // Thumbnail size
252            0xFF, 0xC0, // SOF0 (Start of Frame, baseline DCT)
253            0x00, 0x11, // Length
254            0x08, // Precision (8 bits)
255            0x00, 0x64, // Height (100)
256            0x00, 0x64, // Width (100)
257            0x03, // Number of components (RGB)
258            0x01, 0x22, 0x00, // Component 1 (Y)
259            0x02, 0x11, 0x01, // Component 2 (Cb)
260            0x03, 0x11, 0x01, // Component 3 (Cr)
261            0xFF, 0xD9, // EOI (End of Image)
262        ]
263    }
264
265    #[test]
266    fn test_jpeg_xobject_creation() {
267        let jpeg_data = minimal_jpeg();
268        let xobject = ImageXObject::from_jpeg(&jpeg_data).expect("test: should succeed");
269
270        assert_eq!(xobject.width, 100);
271        assert_eq!(xobject.height, 100);
272        assert_eq!(xobject.color_space, "DeviceRGB");
273        assert_eq!(xobject.bits_per_component, 8);
274        assert_eq!(xobject.filter, "DCTDecode");
275        assert_eq!(xobject.data.len(), jpeg_data.len());
276    }
277
278    #[test]
279    fn test_jpeg_xobject_pdf_stream() {
280        let jpeg_data = minimal_jpeg();
281        let xobject = ImageXObject::from_jpeg(&jpeg_data).expect("test: should succeed");
282        let pdf_stream = xobject.to_pdf_stream(5);
283
284        assert!(pdf_stream.contains("5 0 obj"));
285        assert!(pdf_stream.contains("/Type /XObject"));
286        assert!(pdf_stream.contains("/Subtype /Image"));
287        assert!(pdf_stream.contains("/Width 100"));
288        assert!(pdf_stream.contains("/Height 100"));
289        assert!(pdf_stream.contains("/ColorSpace /DeviceRGB"));
290        assert!(pdf_stream.contains("/BitsPerComponent 8"));
291        assert!(pdf_stream.contains("/Filter /DCTDecode"));
292        assert!(pdf_stream.contains(&format!("/Length {}", jpeg_data.len())));
293        assert!(pdf_stream.contains("stream"));
294    }
295
296    #[test]
297    fn test_jpeg_xobject_stream_data() {
298        let jpeg_data = minimal_jpeg();
299        let xobject = ImageXObject::from_jpeg(&jpeg_data).expect("test: should succeed");
300        let stream_data = xobject.stream_data();
301
302        assert_eq!(stream_data, &jpeg_data[..]);
303    }
304
305    #[test]
306    fn test_jpeg_xobject_stream_end() {
307        let end = ImageXObject::stream_end();
308        assert_eq!(end, "\nendstream\nendobj\n");
309    }
310
311    /// Create a minimal valid PNG image (1x1 red pixel)
312    fn create_test_png() -> Vec<u8> {
313        let mut png_data = Vec::new();
314        let mut encoder = png::Encoder::new(&mut png_data, 1, 1);
315        encoder.set_color(png::ColorType::Rgb);
316        encoder.set_depth(png::BitDepth::Eight);
317
318        let mut writer = encoder.write_header().expect("test: should succeed");
319        let data = vec![255, 0, 0]; // Red pixel
320        writer
321            .write_image_data(&data)
322            .expect("test: should succeed");
323        drop(writer);
324
325        png_data
326    }
327
328    #[test]
329    fn test_png_xobject_creation() {
330        let png_data = create_test_png();
331        let xobject = ImageXObject::from_png(&png_data).expect("test: should succeed");
332
333        assert_eq!(xobject.width, 1);
334        assert_eq!(xobject.height, 1);
335        assert_eq!(xobject.color_space, "DeviceRGB");
336        assert_eq!(xobject.bits_per_component, 8);
337        assert_eq!(xobject.filter, "FlateDecode");
338        assert!(!xobject.data.is_empty());
339    }
340
341    #[test]
342    fn test_png_xobject_pdf_stream() {
343        let png_data = create_test_png();
344        let xobject = ImageXObject::from_png(&png_data).expect("test: should succeed");
345        let pdf_stream = xobject.to_pdf_stream(6);
346
347        assert!(pdf_stream.contains("6 0 obj"));
348        assert!(pdf_stream.contains("/Type /XObject"));
349        assert!(pdf_stream.contains("/Subtype /Image"));
350        assert!(pdf_stream.contains("/Width 1"));
351        assert!(pdf_stream.contains("/Height 1"));
352        assert!(pdf_stream.contains("/ColorSpace /DeviceRGB"));
353        assert!(pdf_stream.contains("/BitsPerComponent 8"));
354        assert!(pdf_stream.contains("/Filter /FlateDecode"));
355        assert!(pdf_stream.contains("stream"));
356    }
357
358    #[test]
359    fn test_strip_alpha_rgba() {
360        let rgba = vec![
361            255, 0, 0, 255, // Red with full alpha
362            0, 255, 0, 128, // Green with half alpha
363        ];
364
365        let rgb = ImageXObject::strip_alpha_rgba(&rgba, 2, 1);
366
367        assert_eq!(rgb, vec![255, 0, 0, 0, 255, 0]);
368    }
369
370    #[test]
371    fn test_strip_alpha_grayscale() {
372        let ga = vec![
373            128, 255, // Gray 128 with full alpha
374            64, 128, // Gray 64 with half alpha
375        ];
376
377        let gray = ImageXObject::strip_alpha_grayscale(&ga, 2, 1);
378
379        assert_eq!(gray, vec![128, 64]);
380    }
381
382    #[test]
383    fn test_from_image_info_jpeg() {
384        let jpeg_data = minimal_jpeg();
385        let image_info = ImageInfo {
386            format: ImageFormat::JPEG,
387            width_px: 100,
388            height_px: 100,
389            bits_per_component: 8,
390            color_space: "DeviceRGB".to_string(),
391            data: jpeg_data,
392        };
393
394        let xobject = ImageXObject::from_image_info(&image_info).expect("test: should succeed");
395        assert_eq!(xobject.filter, "DCTDecode");
396    }
397
398    #[test]
399    fn test_from_image_info_unknown() {
400        let image_info = ImageInfo {
401            format: ImageFormat::Unknown,
402            width_px: 100,
403            height_px: 100,
404            bits_per_component: 8,
405            color_space: "DeviceRGB".to_string(),
406            data: vec![],
407        };
408
409        let result = ImageXObject::from_image_info(&image_info);
410        assert!(result.is_err());
411    }
412}
413
414#[cfg(test)]
415mod tests_extended {
416    use super::*;
417
418    // ── JPEG magic bytes and header ──────────────────────────────────────────
419
420    /// Minimal valid JPEG with real scan data (enough for jpeg_decoder)
421    fn minimal_jpeg() -> Vec<u8> {
422        vec![
423            0xFF, 0xD8, // SOI
424            0xFF, 0xE0, // APP0
425            0x00, 0x10, // Length 16
426            0x4A, 0x46, 0x49, 0x46, 0x00, // "JFIF\0"
427            0x01, 0x01, // Version 1.1
428            0x00, // Density units
429            0x00, 0x01, 0x00, 0x01, // X and Y density
430            0x00, 0x00, // Thumbnail size
431            0xFF, 0xC0, // SOF0
432            0x00, 0x11, // Length 17
433            0x08, // Precision 8
434            0x00, 0x64, // Height 100
435            0x00, 0x64, // Width 100
436            0x03, // 3 components
437            0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xD9, // EOI
438        ]
439    }
440
441    fn create_test_png_1x1() -> Vec<u8> {
442        let mut png_data = Vec::new();
443        let mut encoder = png::Encoder::new(&mut png_data, 1, 1);
444        encoder.set_color(png::ColorType::Rgb);
445        encoder.set_depth(png::BitDepth::Eight);
446        let mut writer = encoder.write_header().expect("test: should succeed");
447        writer
448            .write_image_data(&[255u8, 0, 0])
449            .expect("test: should succeed");
450        drop(writer);
451        png_data
452    }
453
454    fn create_test_png_gray() -> Vec<u8> {
455        let mut png_data = Vec::new();
456        let mut encoder = png::Encoder::new(&mut png_data, 2, 2);
457        encoder.set_color(png::ColorType::Grayscale);
458        encoder.set_depth(png::BitDepth::Eight);
459        let mut writer = encoder.write_header().expect("test: should succeed");
460        writer
461            .write_image_data(&[100u8, 150, 200, 250])
462            .expect("test: should succeed");
463        drop(writer);
464        png_data
465    }
466
467    fn create_test_png_rgba() -> Vec<u8> {
468        let mut png_data = Vec::new();
469        let mut encoder = png::Encoder::new(&mut png_data, 1, 1);
470        encoder.set_color(png::ColorType::Rgba);
471        encoder.set_depth(png::BitDepth::Eight);
472        let mut writer = encoder.write_header().expect("test: should succeed");
473        writer
474            .write_image_data(&[255u8, 128, 0, 200])
475            .expect("test: should succeed");
476        drop(writer);
477        png_data
478    }
479
480    // ── JPEG magic bytes ─────────────────────────────────────────────────────
481
482    #[test]
483    fn test_jpeg_soi_marker() {
484        let data = minimal_jpeg();
485        // JPEG files start with SOI marker 0xFF 0xD8
486        assert_eq!(data[0], 0xFF);
487        assert_eq!(data[1], 0xD8);
488    }
489
490    #[test]
491    fn test_jpeg_xobject_stores_original_data() {
492        let jpeg_data = minimal_jpeg();
493        let xobj = ImageXObject::from_jpeg(&jpeg_data).expect("test: should succeed");
494        // DCT decode stores the raw JPEG bytes
495        assert_eq!(xobj.data, jpeg_data);
496        assert_eq!(xobj.stream_data(), &jpeg_data[..]);
497    }
498
499    #[test]
500    fn test_jpeg_xobject_filter_is_dctdecode() {
501        let jpeg_data = minimal_jpeg();
502        let xobj = ImageXObject::from_jpeg(&jpeg_data).expect("test: should succeed");
503        assert_eq!(xobj.filter, "DCTDecode");
504    }
505
506    #[test]
507    fn test_jpeg_color_space_is_device_rgb() {
508        let jpeg_data = minimal_jpeg();
509        let xobj = ImageXObject::from_jpeg(&jpeg_data).expect("test: should succeed");
510        assert_eq!(xobj.color_space, "DeviceRGB");
511    }
512
513    #[test]
514    fn test_jpeg_bits_per_component_is_8() {
515        let jpeg_data = minimal_jpeg();
516        let xobj = ImageXObject::from_jpeg(&jpeg_data).expect("test: should succeed");
517        assert_eq!(xobj.bits_per_component, 8);
518    }
519
520    // ── Image dictionary entries ─────────────────────────────────────────────
521
522    #[test]
523    fn test_pdf_stream_type_xobject() {
524        let jpeg_data = minimal_jpeg();
525        let xobj = ImageXObject::from_jpeg(&jpeg_data).expect("test: should succeed");
526        let stream = xobj.to_pdf_stream(10);
527        assert!(stream.contains("/Type /XObject"), "/Type /XObject missing");
528    }
529
530    #[test]
531    fn test_pdf_stream_subtype_image() {
532        let jpeg_data = minimal_jpeg();
533        let xobj = ImageXObject::from_jpeg(&jpeg_data).expect("test: should succeed");
534        let stream = xobj.to_pdf_stream(10);
535        assert!(
536            stream.contains("/Subtype /Image"),
537            "/Subtype /Image missing"
538        );
539    }
540
541    #[test]
542    fn test_pdf_stream_width_entry() {
543        let png_data = create_test_png_1x1();
544        let xobj = ImageXObject::from_png(&png_data).expect("test: should succeed");
545        let stream = xobj.to_pdf_stream(7);
546        assert!(stream.contains("/Width 1"), "/Width entry wrong");
547    }
548
549    #[test]
550    fn test_pdf_stream_height_entry() {
551        let png_data = create_test_png_1x1();
552        let xobj = ImageXObject::from_png(&png_data).expect("test: should succeed");
553        let stream = xobj.to_pdf_stream(7);
554        assert!(stream.contains("/Height 1"), "/Height entry wrong");
555    }
556
557    #[test]
558    fn test_pdf_stream_colorspace_device_rgb() {
559        let png_data = create_test_png_1x1();
560        let xobj = ImageXObject::from_png(&png_data).expect("test: should succeed");
561        let stream = xobj.to_pdf_stream(7);
562        assert!(
563            stream.contains("/ColorSpace /DeviceRGB"),
564            "ColorSpace entry wrong: {}",
565            stream
566        );
567    }
568
569    #[test]
570    fn test_pdf_stream_bits_per_component_8() {
571        let png_data = create_test_png_1x1();
572        let xobj = ImageXObject::from_png(&png_data).expect("test: should succeed");
573        let stream = xobj.to_pdf_stream(7);
574        assert!(
575            stream.contains("/BitsPerComponent 8"),
576            "BitsPerComponent missing"
577        );
578    }
579
580    #[test]
581    fn test_pdf_stream_has_stream_keyword() {
582        let png_data = create_test_png_1x1();
583        let xobj = ImageXObject::from_png(&png_data).expect("test: should succeed");
584        let stream = xobj.to_pdf_stream(7);
585        assert!(stream.contains("stream\n"), "stream keyword missing");
586    }
587
588    #[test]
589    fn test_pdf_stream_length_matches_data() {
590        let jpeg_data = minimal_jpeg();
591        let xobj = ImageXObject::from_jpeg(&jpeg_data).expect("test: should succeed");
592        let stream = xobj.to_pdf_stream(5);
593        let expected = format!("/Length {}", jpeg_data.len());
594        assert!(stream.contains(&expected), "Length entry wrong: {}", stream);
595    }
596
597    #[test]
598    fn test_stream_end_marker() {
599        let end = ImageXObject::stream_end();
600        assert!(end.contains("endstream"), "endstream missing");
601        assert!(end.contains("endobj"), "endobj missing");
602    }
603
604    // ── PNG-specific tests ───────────────────────────────────────────────────
605
606    #[test]
607    fn test_png_xobject_filter_is_flatedecode() {
608        let png_data = create_test_png_1x1();
609        let xobj = ImageXObject::from_png(&png_data).expect("test: should succeed");
610        assert_eq!(xobj.filter, "FlateDecode");
611    }
612
613    #[test]
614    fn test_png_grayscale_color_space() {
615        let png_data = create_test_png_gray();
616        let xobj = ImageXObject::from_png(&png_data).expect("test: should succeed");
617        assert_eq!(xobj.color_space, "DeviceGray");
618    }
619
620    #[test]
621    fn test_png_rgba_strips_alpha_to_rgb() {
622        let png_data = create_test_png_rgba();
623        let xobj = ImageXObject::from_png(&png_data).expect("test: should succeed");
624        // Alpha stripped → DeviceRGB
625        assert_eq!(xobj.color_space, "DeviceRGB");
626    }
627
628    #[test]
629    fn test_png_data_is_compressed() {
630        let png_data = create_test_png_1x1();
631        let xobj = ImageXObject::from_png(&png_data).expect("test: should succeed");
632        // Compressed data should be non-empty
633        assert!(!xobj.data.is_empty());
634        // The raw pixel data (3 bytes for 1x1 RGB) gets compressed; the result
635        // is not equal to the raw pixel bytes.
636        assert_ne!(xobj.data, vec![255u8, 0, 0]);
637    }
638
639    #[test]
640    fn test_png_2x2_dimensions() {
641        let png_data = create_test_png_gray();
642        let xobj = ImageXObject::from_png(&png_data).expect("test: should succeed");
643        assert_eq!(xobj.width, 2);
644        assert_eq!(xobj.height, 2);
645    }
646
647    // ── Strip alpha helpers ──────────────────────────────────────────────────
648
649    #[test]
650    fn test_strip_alpha_rgba_pixel_order() {
651        // 2 pixels: RGBA RGBA → RGB RGB
652        let rgba = vec![10u8, 20, 30, 255, 40, 50, 60, 128];
653        let rgb = ImageXObject::strip_alpha_rgba(&rgba, 2, 1);
654        assert_eq!(rgb, vec![10, 20, 30, 40, 50, 60]);
655    }
656
657    #[test]
658    fn test_strip_alpha_grayscale_alpha_removed() {
659        // 3 pixels: GA GA GA → G G G
660        let ga = vec![50u8, 255, 100, 128, 200, 64];
661        let gray = ImageXObject::strip_alpha_grayscale(&ga, 3, 1);
662        assert_eq!(gray, vec![50, 100, 200]);
663    }
664
665    // ── Object ID in PDF stream ──────────────────────────────────────────────
666
667    #[test]
668    fn test_pdf_stream_uses_provided_object_id() {
669        let jpeg_data = minimal_jpeg();
670        let xobj = ImageXObject::from_jpeg(&jpeg_data).expect("test: should succeed");
671
672        for id in [1u32, 42, 100, 999] {
673            let stream = xobj.to_pdf_stream(id);
674            assert!(
675                stream.starts_with(&format!("{} 0 obj\n", id)),
676                "object id {} not at start: {}",
677                id,
678                &stream[..20]
679            );
680        }
681    }
682
683    // ── from_image_info dispatch ─────────────────────────────────────────────
684
685    #[test]
686    fn test_from_image_info_png_dispatch() {
687        let png_data = create_test_png_1x1();
688        let info = ImageInfo {
689            format: crate::image::ImageFormat::PNG,
690            width_px: 1,
691            height_px: 1,
692            bits_per_component: 8,
693            color_space: "DeviceRGB".to_string(),
694            data: png_data,
695        };
696        let xobj = ImageXObject::from_image_info(&info).expect("test: should succeed");
697        assert_eq!(xobj.filter, "FlateDecode");
698    }
699}