1use crate::error::{ManipError, Result};
13use crate::flate_decode::decode_zlib;
14use crate::image_insert::checked_image_allocation_len;
15use image::imageops::FilterType;
16use image::{DynamicImage, RgbImage};
17use lopdf::content::Content;
18use lopdf::{dictionary, Document, Object, ObjectId, Stream};
19use std::collections::HashMap;
20
21#[derive(Debug, Clone)]
23pub struct DownsampleConfig {
24 pub target_dpi: u32,
26 pub jpeg_quality: u8,
28 pub min_width: u32,
30}
31
32impl Default for DownsampleConfig {
33 fn default() -> Self {
34 Self {
35 target_dpi: 150,
36 jpeg_quality: 80,
37 min_width: 64,
38 }
39 }
40}
41
42#[derive(Debug, Clone)]
44pub struct DownsampleReport {
45 pub images_inspected: usize,
47 pub images_downsampled: usize,
49 pub bytes_saved: i64,
51}
52
53pub fn downsample_images(
55 doc: &mut Document,
56 config: &DownsampleConfig,
57) -> Result<DownsampleReport> {
58 let mut report = DownsampleReport {
59 images_inspected: 0,
60 images_downsampled: 0,
61 bytes_saved: 0,
62 };
63
64 let display_sizes = collect_image_display_sizes(doc);
65
66 let image_ids: Vec<ObjectId> = doc
67 .objects
68 .keys()
69 .copied()
70 .filter(|id| is_image_xobject(doc, *id))
71 .collect();
72
73 for id in image_ids {
74 report.images_inspected += 1;
75
76 let (pixel_w, pixel_h, components, filter, content_len) = {
77 let Some(Object::Stream(stream)) = doc.objects.get(&id) else {
78 continue;
79 };
80 let w = get_int(&stream.dict, b"Width").unwrap_or(0) as u32;
81 let h = get_int(&stream.dict, b"Height").unwrap_or(0) as u32;
82 let bpc = get_int(&stream.dict, b"BitsPerComponent").unwrap_or(8);
83 if bpc != 8 || w == 0 || h == 0 {
84 continue;
85 }
86 let cs = get_name(&stream.dict, b"ColorSpace");
87 let comp = match cs.as_deref() {
88 Some("DeviceGray") => 1u32,
89 Some("DeviceCMYK") => 4,
90 _ => 3,
91 };
92 let f = get_name(&stream.dict, b"Filter");
93 (w, h, comp, f, stream.content.len())
94 };
95
96 if pixel_w < config.min_width {
97 continue;
98 }
99
100 let current_dpi = if let Some((dw, dh)) = display_sizes.get(&id).copied() {
101 if dw > 0.0 && dh > 0.0 {
102 let dpi_x = (pixel_w as f64) / (dw / 72.0);
103 let dpi_y = (pixel_h as f64) / (dh / 72.0);
104 dpi_x.max(dpi_y)
105 } else {
106 continue;
107 }
108 } else {
109 300.0
110 };
111
112 if current_dpi <= config.target_dpi as f64 {
113 continue;
114 }
115
116 let scale = config.target_dpi as f64 / current_dpi;
117 let new_w = ((pixel_w as f64 * scale).round() as u32).max(1);
118 let new_h = ((pixel_h as f64 * scale).round() as u32).max(1);
119 let expected_cmyk_len = if components == 4 {
120 Some(checked_image_allocation_len(pixel_w, pixel_h, 4)?)
121 } else {
122 None
123 };
124
125 let raw_pixels = {
126 let Some(Object::Stream(stream)) = doc.objects.get(&id) else {
127 continue;
128 };
129 match decode_image_data(
130 &stream.content,
131 filter.as_deref(),
132 pixel_w,
133 pixel_h,
134 components,
135 ) {
136 Ok(data) => data,
137 Err(_) => continue,
138 }
139 };
140
141 let img = match components {
142 1 => image::GrayImage::from_raw(pixel_w, pixel_h, raw_pixels)
143 .map(DynamicImage::ImageLuma8),
144 3 => RgbImage::from_raw(pixel_w, pixel_h, raw_pixels).map(DynamicImage::ImageRgb8),
145 4 => {
146 let Some(expected_cmyk_len) = expected_cmyk_len else {
147 continue;
148 };
149 if raw_pixels.len() != expected_cmyk_len {
150 continue;
151 }
152
153 let mut rgb_data =
154 Vec::with_capacity(checked_image_allocation_len(pixel_w, pixel_h, 3)?);
155 for chunk in raw_pixels.chunks(4) {
156 let c = chunk[0] as f32 / 255.0;
157 let m = chunk[1] as f32 / 255.0;
158 let y = chunk[2] as f32 / 255.0;
159 let k = chunk[3] as f32 / 255.0;
160 rgb_data.push(((1.0 - c) * (1.0 - k) * 255.0) as u8);
161 rgb_data.push(((1.0 - m) * (1.0 - k) * 255.0) as u8);
162 rgb_data.push(((1.0 - y) * (1.0 - k) * 255.0) as u8);
163 }
164 RgbImage::from_raw(pixel_w, pixel_h, rgb_data).map(DynamicImage::ImageRgb8)
165 }
166 _ => None,
167 };
168
169 let Some(img) = img else { continue };
170
171 let resized = img.resize_exact(new_w, new_h, FilterType::Lanczos3);
172 let rgb = resized.to_rgb8();
173
174 let jpeg_data = match encode_jpeg(&rgb) {
175 Ok(data) => data,
176 Err(_) => continue,
177 };
178
179 let bytes_before = content_len as i64;
180 let bytes_after = jpeg_data.len() as i64;
181
182 if bytes_after >= bytes_before {
183 continue;
184 }
185
186 let new_dict = dictionary! {
187 "Type" => "XObject",
188 "Subtype" => "Image",
189 "Width" => Object::Integer(new_w as i64),
190 "Height" => Object::Integer(new_h as i64),
191 "BitsPerComponent" => Object::Integer(8),
192 "ColorSpace" => "DeviceRGB",
193 "Filter" => "DCTDecode",
194 "Length" => Object::Integer(jpeg_data.len() as i64),
195 };
196 doc.objects
197 .insert(id, Object::Stream(Stream::new(new_dict, jpeg_data)));
198
199 report.images_downsampled += 1;
200 report.bytes_saved += bytes_before - bytes_after;
201 }
202
203 Ok(report)
204}
205
206fn is_image_xobject(doc: &Document, id: ObjectId) -> bool {
207 if let Some(Object::Stream(stream)) = doc.objects.get(&id) {
208 get_name(&stream.dict, b"Subtype").as_deref() == Some("Image")
209 } else {
210 false
211 }
212}
213
214fn get_int(dict: &lopdf::Dictionary, key: &[u8]) -> Option<i64> {
215 match dict.get(key).ok()? {
216 Object::Integer(n) => Some(*n),
217 _ => None,
218 }
219}
220
221fn get_name(dict: &lopdf::Dictionary, key: &[u8]) -> Option<String> {
222 match dict.get(key).ok()? {
223 Object::Name(n) => String::from_utf8(n.clone()).ok(),
224 _ => None,
225 }
226}
227
228fn collect_image_display_sizes(doc: &Document) -> HashMap<ObjectId, (f64, f64)> {
230 let mut sizes = HashMap::new();
231
232 for (_page_num, page_id) in doc.get_pages() {
233 let Some(Object::Dictionary(page_dict)) = doc.objects.get(&page_id) else {
234 continue;
235 };
236
237 let xobject_map = get_page_xobjects(doc, page_dict);
238 let Some(content_data) = get_page_content_data(doc, page_dict) else {
239 continue;
240 };
241 let Ok(content) = Content::decode(&content_data) else {
242 continue;
243 };
244
245 let mut ctm_stack: Vec<[f64; 6]> = vec![[1.0, 0.0, 0.0, 1.0, 0.0, 0.0]];
246
247 for op in &content.operations {
248 match op.operator.as_str() {
249 "q" => {
250 let current = *ctm_stack.last().unwrap_or(&[1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
251 ctm_stack.push(current);
252 }
253 "Q" if ctm_stack.len() > 1 => {
254 ctm_stack.pop();
255 }
256 "cm" if op.operands.len() >= 6 => {
257 let vals: Vec<f64> = op.operands.iter().filter_map(obj_to_f64).collect();
258 if vals.len() == 6 {
259 if let Some(ctm) = ctm_stack.last_mut() {
260 *ctm = multiply_ctm(ctm, &vals);
261 }
262 }
263 }
264 "Do" if op.operands.len() == 1 => {
265 if let Object::Name(ref name) = op.operands[0] {
266 let name_str = String::from_utf8_lossy(name);
267 if let Some(&obj_id) = xobject_map.get(name_str.as_ref()) {
268 let ctm = ctm_stack
269 .last()
270 .copied()
271 .unwrap_or([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
272 let dw = (ctm[0] * ctm[0] + ctm[2] * ctm[2]).sqrt();
273 let dh = (ctm[1] * ctm[1] + ctm[3] * ctm[3]).sqrt();
274 if dw > 0.0 && dh > 0.0 {
275 sizes.insert(obj_id, (dw, dh));
276 }
277 }
278 }
279 }
280 _ => {}
281 }
282 }
283 }
284
285 sizes
286}
287
288fn multiply_ctm(current: &[f64; 6], new: &[f64]) -> [f64; 6] {
289 [
290 new[0] * current[0] + new[1] * current[2],
291 new[0] * current[1] + new[1] * current[3],
292 new[2] * current[0] + new[3] * current[2],
293 new[2] * current[1] + new[3] * current[3],
294 new[4] * current[0] + new[5] * current[2] + current[4],
295 new[4] * current[1] + new[5] * current[3] + current[5],
296 ]
297}
298
299fn obj_to_f64(obj: &Object) -> Option<f64> {
300 match obj {
301 Object::Integer(n) => Some(*n as f64),
302 Object::Real(n) => Some(*n as f64),
303 _ => None,
304 }
305}
306
307fn get_page_xobjects(doc: &Document, page_dict: &lopdf::Dictionary) -> HashMap<String, ObjectId> {
308 let mut map = HashMap::new();
309 let res = match page_dict.get(b"Resources").ok() {
310 Some(Object::Dictionary(d)) => d,
311 Some(Object::Reference(id)) => match doc.objects.get(id) {
312 Some(Object::Dictionary(d)) => d,
313 _ => return map,
314 },
315 _ => return map,
316 };
317 let xobjects = match res.get(b"XObject").ok() {
318 Some(Object::Dictionary(d)) => d,
319 Some(Object::Reference(id)) => match doc.objects.get(id) {
320 Some(Object::Dictionary(d)) => d,
321 _ => return map,
322 },
323 _ => return map,
324 };
325 for (key, val) in xobjects.iter() {
326 if let Object::Reference(id) = val {
327 map.insert(String::from_utf8_lossy(key).to_string(), *id);
328 }
329 }
330 map
331}
332
333fn get_page_content_data(doc: &Document, page_dict: &lopdf::Dictionary) -> Option<Vec<u8>> {
334 match page_dict.get(b"Contents").ok()? {
335 Object::Reference(id) => {
336 if let Some(Object::Stream(stream)) = doc.objects.get(id) {
337 Some(stream.content.clone())
338 } else {
339 None
340 }
341 }
342 Object::Array(arr) => {
343 let mut data = Vec::new();
344 for item in arr {
345 if let Object::Reference(id) = item {
346 if let Some(Object::Stream(stream)) = doc.objects.get(id) {
347 data.extend_from_slice(&stream.content);
348 data.push(b'\n');
349 }
350 }
351 }
352 Some(data)
353 }
354 _ => None,
355 }
356}
357
358fn decode_image_data(
359 data: &[u8],
360 filter: Option<&str>,
361 _width: u32,
362 _height: u32,
363 _components: u32,
364) -> Result<Vec<u8>> {
365 match filter {
366 Some("FlateDecode") => decode_zlib(data, |e| {
367 ManipError::Image(format!("FlateDecode failed: {e}"))
368 }),
369 Some("DCTDecode") => {
370 let img = image::load_from_memory_with_format(data, image::ImageFormat::Jpeg)
371 .map_err(|e| ManipError::Image(format!("JPEG decode failed: {e}")))?;
372 Ok(img.to_rgb8().into_raw())
373 }
374 None => Ok(data.to_vec()),
375 Some(other) => Err(ManipError::Image(format!(
376 "unsupported image filter for downsampling: {other}"
377 ))),
378 }
379}
380
381fn encode_jpeg(img: &RgbImage) -> Result<Vec<u8>> {
382 let mut buf = std::io::Cursor::new(Vec::new());
383 img.write_to(&mut buf, image::ImageFormat::Jpeg)
384 .map_err(|e| ManipError::Image(format!("JPEG encoding failed: {e}")))?;
385 Ok(buf.into_inner())
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use flate2::write::ZlibEncoder;
392 use flate2::Compression;
393 use std::io::Write;
394
395 fn make_doc_with_image() -> Document {
396 let mut doc = Document::with_version("1.7");
397 let pages_id = doc.new_object_id();
398
399 let raw_pixels: Vec<u8> = (0..4 * 4 * 3).map(|i| (i % 256) as u8).collect();
400 let compressed = {
401 let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
402 enc.write_all(&raw_pixels).unwrap();
403 enc.finish().unwrap()
404 };
405
406 let img_dict = dictionary! {
407 "Type" => "XObject",
408 "Subtype" => "Image",
409 "Width" => Object::Integer(4),
410 "Height" => Object::Integer(4),
411 "BitsPerComponent" => Object::Integer(8),
412 "ColorSpace" => "DeviceRGB",
413 "Filter" => "FlateDecode",
414 "Length" => Object::Integer(compressed.len() as i64),
415 };
416 let img_id = doc.add_object(Object::Stream(Stream::new(img_dict, compressed)));
417
418 let content_ops = vec![
419 lopdf::content::Operation::new("q", vec![]),
420 lopdf::content::Operation::new(
421 "cm",
422 vec![
423 Object::Real(72.0),
424 Object::Real(0.0),
425 Object::Real(0.0),
426 Object::Real(72.0),
427 Object::Real(0.0),
428 Object::Real(0.0),
429 ],
430 ),
431 lopdf::content::Operation::new("Do", vec![Object::Name(b"Im1".to_vec())]),
432 lopdf::content::Operation::new("Q", vec![]),
433 ];
434 let content_data = lopdf::content::Content {
435 operations: content_ops,
436 }
437 .encode()
438 .unwrap();
439 let content_id = doc.add_object(Object::Stream(Stream::new(dictionary! {}, content_data)));
440
441 let mut xobject_dict = lopdf::Dictionary::new();
442 xobject_dict.set("Im1", Object::Reference(img_id));
443 let mut res_dict = lopdf::Dictionary::new();
444 res_dict.set("XObject", Object::Dictionary(xobject_dict));
445
446 let page = dictionary! {
447 "Type" => "Page",
448 "Parent" => Object::Reference(pages_id),
449 "MediaBox" => Object::Array(vec![
450 Object::Integer(0), Object::Integer(0),
451 Object::Integer(612), Object::Integer(792),
452 ]),
453 "Contents" => Object::Reference(content_id),
454 "Resources" => Object::Dictionary(res_dict),
455 };
456 let page_id = doc.add_object(Object::Dictionary(page));
457
458 let pages = dictionary! {
459 "Type" => "Pages",
460 "Count" => Object::Integer(1),
461 "Kids" => Object::Array(vec![Object::Reference(page_id)]),
462 };
463 doc.objects.insert(pages_id, Object::Dictionary(pages));
464
465 let catalog = dictionary! {
466 "Type" => "Catalog",
467 "Pages" => Object::Reference(pages_id),
468 };
469 let catalog_id = doc.add_object(Object::Dictionary(catalog));
470 doc.trailer.set("Root", Object::Reference(catalog_id));
471
472 doc
473 }
474
475 fn make_doc_with_cmyk_image(width: u32, height: u32, image_data: Vec<u8>) -> Document {
476 let mut doc = Document::with_version("1.7");
477 let pages_id = doc.new_object_id();
478
479 let img_dict = dictionary! {
480 "Type" => "XObject",
481 "Subtype" => "Image",
482 "Width" => Object::Integer(width as i64),
483 "Height" => Object::Integer(height as i64),
484 "BitsPerComponent" => Object::Integer(8),
485 "ColorSpace" => "DeviceCMYK",
486 "Length" => Object::Integer(image_data.len() as i64),
487 };
488 let img_id = doc.add_object(Object::Stream(Stream::new(img_dict, image_data)));
489
490 let content_ops = vec![
491 lopdf::content::Operation::new("q", vec![]),
492 lopdf::content::Operation::new(
493 "cm",
494 vec![
495 Object::Real(72.0),
496 Object::Real(0.0),
497 Object::Real(0.0),
498 Object::Real(72.0),
499 Object::Real(0.0),
500 Object::Real(0.0),
501 ],
502 ),
503 lopdf::content::Operation::new("Do", vec![Object::Name(b"Im1".to_vec())]),
504 lopdf::content::Operation::new("Q", vec![]),
505 ];
506 let content_data = lopdf::content::Content {
507 operations: content_ops,
508 }
509 .encode()
510 .unwrap();
511 let content_id = doc.add_object(Object::Stream(Stream::new(dictionary! {}, content_data)));
512
513 let mut xobject_dict = lopdf::Dictionary::new();
514 xobject_dict.set("Im1", Object::Reference(img_id));
515 let mut res_dict = lopdf::Dictionary::new();
516 res_dict.set("XObject", Object::Dictionary(xobject_dict));
517
518 let page = dictionary! {
519 "Type" => "Page",
520 "Parent" => Object::Reference(pages_id),
521 "MediaBox" => Object::Array(vec![
522 Object::Integer(0), Object::Integer(0),
523 Object::Integer(612), Object::Integer(792),
524 ]),
525 "Contents" => Object::Reference(content_id),
526 "Resources" => Object::Dictionary(res_dict),
527 };
528 let page_id = doc.add_object(Object::Dictionary(page));
529
530 let pages = dictionary! {
531 "Type" => "Pages",
532 "Count" => Object::Integer(1),
533 "Kids" => Object::Array(vec![Object::Reference(page_id)]),
534 };
535 doc.objects.insert(pages_id, Object::Dictionary(pages));
536
537 let catalog = dictionary! {
538 "Type" => "Catalog",
539 "Pages" => Object::Reference(pages_id),
540 };
541 let catalog_id = doc.add_object(Object::Dictionary(catalog));
542 doc.trailer.set("Root", Object::Reference(catalog_id));
543
544 doc
545 }
546
547 #[test]
548 fn test_downsample_default_config() {
549 let mut doc = make_doc_with_image();
550 let config = DownsampleConfig {
551 target_dpi: 2,
552 jpeg_quality: 80,
553 min_width: 1,
554 };
555 let report = downsample_images(&mut doc, &config).unwrap();
556 assert_eq!(report.images_inspected, 1);
557 }
558
559 #[test]
560 fn test_no_downsample_below_target() {
561 let mut doc = make_doc_with_image();
562 let config = DownsampleConfig {
563 target_dpi: 300,
564 jpeg_quality: 80,
565 min_width: 1,
566 };
567 let report = downsample_images(&mut doc, &config).unwrap();
568 assert_eq!(report.images_inspected, 1);
569 assert_eq!(report.images_downsampled, 0);
570 }
571
572 #[test]
573 fn test_skip_small_images() {
574 let mut doc = make_doc_with_image();
575 let config = DownsampleConfig {
576 target_dpi: 2,
577 jpeg_quality: 80,
578 min_width: 100,
579 };
580 let report = downsample_images(&mut doc, &config).unwrap();
581 assert_eq!(report.images_downsampled, 0);
582 }
583
584 #[test]
585 fn test_empty_document() {
586 let mut doc = Document::with_version("1.7");
587 let pages_id = doc.new_object_id();
588 let pages = dictionary! {
589 "Type" => "Pages",
590 "Count" => Object::Integer(0),
591 "Kids" => Object::Array(vec![]),
592 };
593 doc.objects.insert(pages_id, Object::Dictionary(pages));
594 let catalog = dictionary! {
595 "Type" => "Catalog",
596 "Pages" => Object::Reference(pages_id),
597 };
598 let catalog_id = doc.add_object(Object::Dictionary(catalog));
599 doc.trailer.set("Root", Object::Reference(catalog_id));
600
601 let report = downsample_images(&mut doc, &DownsampleConfig::default()).unwrap();
602 assert_eq!(report.images_inspected, 0);
603 }
604
605 #[test]
606 fn test_reject_oversized_cmyk_image() {
607 let mut doc = make_doc_with_cmyk_image(
608 crate::image_insert::MAX_IMAGE_DIMENSION,
609 crate::image_insert::MAX_IMAGE_DIMENSION,
610 vec![0, 0, 0, 0],
611 );
612 let config = DownsampleConfig {
613 target_dpi: 2,
614 jpeg_quality: 80,
615 min_width: 1,
616 };
617
618 let err = downsample_images(&mut doc, &config).unwrap_err();
619 assert!(matches!(
620 err,
621 ManipError::ImageTooLarge {
622 width,
623 height,
624 bytes_per_pixel: 4,
625 ..
626 } if width == crate::image_insert::MAX_IMAGE_DIMENSION
627 && height == crate::image_insert::MAX_IMAGE_DIMENSION
628 ));
629 }
630}