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