1use flate2::write::ZlibEncoder;
13use flate2::Compression;
14use image::GenericImageView;
15use lopdf::{dictionary, Object, ObjectId, Stream};
16use std::io::Write;
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ImageFormat {
21 Jpeg,
23 Png,
25}
26#[derive(Debug, Clone)]
29pub struct ImageXObjectResult {
30 pub object_id: ObjectId,
32 pub width: u32,
34 pub height: u32,
36}
37pub 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}
47pub 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}
82pub 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}
144pub 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 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}
184pub 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 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 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}