Skip to main content

pdf_manip/
downsample.rs

1//! Image downsampling for PDF optimization.
2//!
3//! Scans embedded images and downsamples those above a target DPI
4//! to reduce file size. Re-encodes as JPEG via DCTDecode.
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 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/// Configuration for image downsampling.
22#[derive(Debug, Clone)]
23pub struct DownsampleConfig {
24    /// Target DPI threshold — images above this will be downsampled.
25    pub target_dpi: u32,
26    /// JPEG output quality (1–100).
27    pub jpeg_quality: u8,
28    /// Skip images smaller than this width in pixels.
29    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/// Report from a downsampling pass.
43#[derive(Debug, Clone)]
44pub struct DownsampleReport {
45    /// Number of images inspected.
46    pub images_inspected: usize,
47    /// Number of images downsampled.
48    pub images_downsampled: usize,
49    /// Total bytes saved (approximate).
50    pub bytes_saved: i64,
51}
52
53/// Downsample all embedded images above `target_dpi` in the document.
54pub 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
228/// Collect display sizes of images from page content streams (CTM analysis).
229fn 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}