Skip to main content

pdf_xfa/
image_bridge.rs

1//! Image embedding for PDF XObjects.
2//!
3//! Converts XFA image data (JPEG/PNG) into PDF Image XObject dictionaries
4//! and provides PDF content stream operators for rendering.
5
6// Copyright (c) 2026 Innovation Trigger B.V.
7//
8// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
9// the PDFluent Commercial Licence. See the LICENSE file in this repository --
10// that file travels with the copy you received, which a URL does not.
11
12use flate2::write::ZlibEncoder;
13use flate2::Compression;
14use image::GenericImageView;
15use lopdf::{dictionary, Object, ObjectId, Stream};
16use std::io::Write;
17/// ImageFormat.
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ImageFormat {
21    /// Jpeg.
22    Jpeg,
23    /// Png.
24    Png,
25}
26/// ImageXObjectResult.
27
28#[derive(Debug, Clone)]
29pub struct ImageXObjectResult {
30    /// object_id.
31    pub object_id: ObjectId,
32    /// width.
33    pub width: u32,
34    /// height.
35    pub height: u32,
36}
37/// detect_image_format.
38pub fn detect_image_format(data: &[u8]) -> Option<ImageFormat> {
39    if data.len() >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
40        Some(ImageFormat::Jpeg)
41    } else if data.len() >= 8 && data[0..4] == [0x89, 0x50, 0x4E, 0x47] {
42        Some(ImageFormat::Png)
43    } else {
44        None
45    }
46}
47/// embed_jpeg.
48pub fn embed_jpeg(
49    doc: &mut lopdf::Document,
50    jpeg_data: &[u8],
51) -> Result<ImageXObjectResult, String> {
52    let (width, height, components) = parse_jpeg_dimensions(jpeg_data)
53        .map_err(|e| format!("failed to parse JPEG dimensions: {}", e))?;
54
55    let color_space = match components {
56        1 => Object::Name(b"DeviceGray".to_vec()),
57        3 => Object::Name(b"DeviceRGB".to_vec()),
58        4 => Object::Name(b"DeviceCMYK".to_vec()),
59        _ => Object::Name(b"DeviceRGB".to_vec()),
60    };
61
62    let stream_dict = dictionary! {
63        "Type" => "XObject",
64        "Subtype" => "Image",
65        "Width" => Object::Integer(width as i64),
66        "Height" => Object::Integer(height as i64),
67        "BitsPerComponent" => Object::Integer(8),
68        "ColorSpace" => color_space,
69        "Filter" => "DCTDecode",
70        "Length" => Object::Integer(jpeg_data.len() as i64),
71    };
72
73    let stream = Stream::new(stream_dict, jpeg_data.to_vec());
74    let object_id = doc.add_object(Object::Stream(stream));
75
76    Ok(ImageXObjectResult {
77        object_id,
78        width,
79        height,
80    })
81}
82/// embed_png.
83pub fn embed_png(doc: &mut lopdf::Document, png_data: &[u8]) -> Result<ImageXObjectResult, String> {
84    let img = image::load_from_memory_with_format(png_data, image::ImageFormat::Png)
85        .map_err(|e| format!("failed to decode PNG: {}", e))?;
86
87    let (width, height) = img.dimensions();
88    let has_alpha = img.color().has_alpha();
89
90    let (raw_rgb, alpha_channel) = if has_alpha {
91        let rgba = img.to_rgba8();
92        let mut rgb = Vec::with_capacity((width * height * 3) as usize);
93        let mut alpha = Vec::with_capacity((width * height) as usize);
94        for pixel in rgba.pixels() {
95            rgb.extend_from_slice(&pixel.0[..3]);
96            alpha.push(pixel.0[3]);
97        }
98        (rgb, Some(alpha))
99    } else {
100        (img.to_rgb8().into_raw(), None)
101    };
102
103    let compressed_rgb =
104        flate_compress(&raw_rgb).map_err(|e| format!("compression failed: {}", e))?;
105
106    let mut stream_dict = dictionary! {
107        "Type" => "XObject",
108        "Subtype" => "Image",
109        "Width" => Object::Integer(width as i64),
110        "Height" => Object::Integer(height as i64),
111        "BitsPerComponent" => Object::Integer(8),
112        "ColorSpace" => "DeviceRGB",
113        "Filter" => "FlateDecode",
114        "Length" => Object::Integer(compressed_rgb.len() as i64),
115    };
116
117    if let Some(alpha) = alpha_channel {
118        let compressed_alpha =
119            flate_compress(&alpha).map_err(|e| format!("alpha compression failed: {}", e))?;
120        let smask_dict = dictionary! {
121            "Type" => "XObject",
122            "Subtype" => "Image",
123            "Width" => Object::Integer(width as i64),
124            "Height" => Object::Integer(height as i64),
125            "BitsPerComponent" => Object::Integer(8),
126            "ColorSpace" => "DeviceGray",
127            "Filter" => "FlateDecode",
128            "Length" => Object::Integer(compressed_alpha.len() as i64),
129        };
130        let smask_stream = Stream::new(smask_dict, compressed_alpha);
131        let smask_id = doc.add_object(Object::Stream(smask_stream));
132        stream_dict.set("SMask", Object::Reference(smask_id));
133    }
134
135    let stream = Stream::new(stream_dict, compressed_rgb);
136    let object_id = doc.add_object(Object::Stream(stream));
137
138    Ok(ImageXObjectResult {
139        object_id,
140        width,
141        height,
142    })
143}
144/// embed_image.
145pub fn embed_image(
146    doc: &mut lopdf::Document,
147    data: &[u8],
148    mime_type: &str,
149) -> Result<ImageXObjectResult, String> {
150    let format = detect_image_format(data).or(match mime_type {
151        "image/jpeg" | "image/jpg" => Some(ImageFormat::Jpeg),
152        "image/png" => Some(ImageFormat::Png),
153        _ => None,
154    });
155
156    match format {
157        Some(ImageFormat::Jpeg) => embed_jpeg(doc, data),
158        Some(ImageFormat::Png) => embed_png(doc, data),
159        // XFA 3.3 §20.2 allows JPEG, PNG, GIF, BMP, TIFF. For anything the
160        // native embedders don't handle (GIF/BMP/TIFF/etc.), let the `image`
161        // crate decode the bytes and re-encode as PNG before embedding —
162        // this preserves the image at the cost of one decode/encode pass
163        // instead of dropping it entirely (see 01de9ce4's Finance Corp logo
164        // which ships as image/tif).
165        None => embed_via_reencode(doc, data, mime_type),
166    }
167}
168
169fn embed_via_reencode(
170    doc: &mut lopdf::Document,
171    data: &[u8],
172    mime_type: &str,
173) -> Result<ImageXObjectResult, String> {
174    let img = image::load_from_memory(data)
175        .map_err(|e| format!("unsupported image format (mime={mime_type}); decode failed: {e}"))?;
176    let mut png_buf: Vec<u8> = Vec::new();
177    img.write_to(
178        &mut std::io::Cursor::new(&mut png_buf),
179        image::ImageFormat::Png,
180    )
181    .map_err(|e| format!("re-encode to PNG failed: {e}"))?;
182    embed_png(doc, &png_buf)
183}
184/// render_image_ops.
185pub fn render_image_ops(name: &str, x: f64, y: f64, w: f64, h: f64) -> Vec<u8> {
186    let mut ops = Vec::new();
187    ops.extend_from_slice(b"q\n");
188    ops.extend(format!("{:.2} 0 0 {:.2} {:.2} {:.2} cm\n", w, h, x, y).bytes());
189    ops.extend(format!("/{name} Do\n",).bytes());
190    ops.extend_from_slice(b"Q\n");
191    ops
192}
193
194fn parse_jpeg_dimensions(data: &[u8]) -> Result<(u32, u32, u8), String> {
195    if data.len() < 4 || data[0] != 0xFF || data[1] != 0xD8 {
196        return Err("not a valid JPEG".into());
197    }
198
199    let mut i = 2;
200    while i + 1 < data.len() {
201        if data[i] != 0xFF {
202            return Err("invalid JPEG marker".into());
203        }
204
205        let marker = data[i + 1];
206
207        if marker == 0xFF {
208            i += 1;
209            continue;
210        }
211
212        let is_sof = matches!(marker, 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF);
213
214        if is_sof {
215            if i + 9 >= data.len() {
216                return Err("truncated JPEG SOF".into());
217            }
218            let height = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
219            let width = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
220            let components = data[i + 9];
221            return Ok((width, height, components));
222        }
223
224        if i + 3 >= data.len() {
225            break;
226        }
227        let segment_len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
228        i += 2 + segment_len;
229    }
230
231    Err("no SOF marker found in JPEG".into())
232}
233
234fn flate_compress(data: &[u8]) -> Result<Vec<u8>, String> {
235    let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
236    encoder
237        .write_all(data)
238        .map_err(|e| format!("compression failed: {}", e))?;
239    encoder
240        .finish()
241        .map_err(|e| format!("compression finalize failed: {}", e))
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    fn minimal_jpeg() -> Vec<u8> {
249        let mut data = Vec::new();
250        data.extend_from_slice(&[0xFF, 0xD8]);
251        data.extend_from_slice(&[0xFF, 0xC0]);
252        data.extend_from_slice(&[0x00, 0x11]);
253        data.push(0x08);
254        data.extend_from_slice(&[0x00, 0x02]);
255        data.extend_from_slice(&[0x00, 0x02]);
256        data.push(0x03);
257        for id in 1..=3u8 {
258            data.push(id);
259            data.push(0x11);
260            data.push(0x00);
261        }
262        data.extend_from_slice(&[0xFF, 0xD9]);
263        data
264    }
265
266    fn minimal_png() -> Vec<u8> {
267        use std::io::Cursor;
268        let mut buf = Cursor::new(Vec::new());
269        let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([255, 0, 0, 128]));
270        img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
271        buf.into_inner()
272    }
273
274    #[test]
275    fn test_detect_format_jpeg() {
276        let jpeg = minimal_jpeg();
277        assert_eq!(detect_image_format(&jpeg), Some(ImageFormat::Jpeg));
278    }
279
280    #[test]
281    fn test_detect_format_png() {
282        let png = minimal_png();
283        assert_eq!(detect_image_format(&png), Some(ImageFormat::Png));
284    }
285
286    #[test]
287    fn test_detect_format_unknown() {
288        assert_eq!(detect_image_format(&[0x00, 0x01, 0x02]), None);
289    }
290
291    #[test]
292    fn test_embed_jpeg() {
293        let mut doc = lopdf::Document::with_version("1.7");
294        let jpeg = minimal_jpeg();
295        let result = embed_jpeg(&mut doc, &jpeg).unwrap();
296        assert_eq!(result.width, 2);
297        assert_eq!(result.height, 2);
298    }
299
300    #[test]
301    fn test_embed_png() {
302        let mut doc = lopdf::Document::with_version("1.7");
303        let png = minimal_png();
304        let result = embed_png(&mut doc, &png).unwrap();
305        assert_eq!(result.width, 2);
306        assert_eq!(result.height, 2);
307    }
308
309    #[test]
310    fn test_embed_image_tiff_via_reencode() {
311        // XFA templates sometimes ship image/tif data (e.g. 01de9ce4's
312        // Finance Corp logo). Before the re-encode fallback these would be
313        // dropped with "unsupported image format". Now they should be
314        // decoded by the `image` crate and re-embedded as PNG.
315        use std::io::Cursor;
316        let img = image::RgbaImage::from_pixel(3, 4, image::Rgba([32, 64, 96, 255]));
317        let mut tiff_buf = Cursor::new(Vec::new());
318        img.write_to(&mut tiff_buf, image::ImageFormat::Tiff)
319            .unwrap();
320        let tiff_data = tiff_buf.into_inner();
321        assert_eq!(detect_image_format(&tiff_data), None);
322
323        let mut doc = lopdf::Document::with_version("1.7");
324        let result = embed_image(&mut doc, &tiff_data, "image/tif")
325            .expect("TIFF should be accepted via re-encode fallback");
326        assert_eq!(result.width, 3);
327        assert_eq!(result.height, 4);
328    }
329
330    #[test]
331    fn test_embed_image_gif_via_reencode() {
332        // GIF is also allowed by XFA 3.3 §20.2 but not natively supported
333        // by embed_jpeg/embed_png. Verify it goes through the re-encode path.
334        use std::io::Cursor;
335        let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([10, 20, 30, 255]));
336        let mut gif_buf = Cursor::new(Vec::new());
337        img.write_to(&mut gif_buf, image::ImageFormat::Gif).unwrap();
338        let gif_data = gif_buf.into_inner();
339
340        let mut doc = lopdf::Document::with_version("1.7");
341        let result = embed_image(&mut doc, &gif_data, "image/gif")
342            .expect("GIF should be accepted via re-encode fallback");
343        assert_eq!(result.width, 2);
344        assert_eq!(result.height, 2);
345    }
346
347    #[test]
348    fn test_render_image_ops() {
349        let ops = render_image_ops("Im1", 100.0, 200.0, 50.0, 75.0);
350        let content = String::from_utf8_lossy(&ops);
351        assert!(content.contains("q\n"));
352        assert!(content.contains("50.00 0 0 75.00 100.00 200.00 cm\n"));
353        assert!(content.contains("/Im1 Do\n"));
354        assert!(content.contains("Q\n"));
355    }
356}