Skip to main content

pdfluent_extract/
images.rs

1//! Image extraction from PDF documents.
2//!
3//! Iterates over all XObject Image streams and decodes them based on their filter.
4
5// Copyright (c) 2026 Innovation Trigger B.V.
6//
7// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
8// the PDFluent Commercial Licence. See the LICENSE file in this repository --
9// that file travels with the copy you received, which a URL does not.
10
11use crate::error::{ExtractError, Result};
12use lopdf::{Document, Object, ObjectId};
13use std::collections::BTreeMap;
14use std::io::Read;
15
16/// The compression filter applied to an image stream.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum ImageFilter {
19    /// DCT (JPEG) compression.
20    Jpeg,
21    /// Flate (zlib/deflate) compression.
22    Flate,
23    /// JBIG2 compression.
24    Jbig2,
25    /// JPX (JPEG 2000) compression.
26    Jpx,
27    /// CCITT fax compression.
28    CcittFax,
29    /// No compression (raw).
30    Raw,
31    /// Unknown or unsupported filter.
32    Unknown(String),
33}
34
35/// An image extracted from a PDF document.
36#[derive(Debug, Clone)]
37pub struct ExtractedImage {
38    /// The PDF object ID of the image stream.
39    pub object_id: ObjectId,
40    /// The page number (1-based) where this image appears.
41    pub page: u32,
42    /// Image width in pixels.
43    pub width: u32,
44    /// Image height in pixels.
45    pub height: u32,
46    /// Bits per color component.
47    pub bits_per_component: u32,
48    /// Color space name (e.g., "DeviceRGB", "DeviceGray").
49    pub color_space: String,
50    /// The compression filter used.
51    pub filter: ImageFilter,
52    /// The decoded (or raw) image data.
53    pub data: Vec<u8>,
54}
55
56/// A document-ready encoded image: valid PNG or JPEG bytes together with the
57/// matching MIME type and file extension. Produced by
58/// [`encode_image_for_document`] for embedding in OOXML packages (DOCX/PPTX/XLSX).
59#[derive(Debug, Clone)]
60pub struct EncodedImage {
61    /// Encoded image bytes — a real PNG (`\x89PNG…`) or JPEG (`\xFF\xD8…`).
62    pub data: Vec<u8>,
63    /// MIME type of [`Self::data`]: `"image/png"` or `"image/jpeg"`.
64    pub mime: &'static str,
65    /// File extension matching [`Self::mime`]: `"png"` or `"jpeg"`.
66    pub ext: &'static str,
67}
68
69/// Encode an [`ExtractedImage`] into document-ready bytes (a valid PNG or JPEG)
70/// suitable for embedding in an OOXML package.
71///
72/// PDF image streams are not directly embeddable: a `FlateDecode` stream holds
73/// *raw, decompressed pixel samples* (no PNG container), so writing those bytes
74/// with a `.png` name produces a file Word/PowerPoint reject as corrupt. This
75/// helper turns each supported image into a real encoded file:
76///
77/// - **JPEG** (`DCTDecode`): the stream already is a JPEG → passed through as
78///   `image/jpeg` (after a `\xFF\xD8` signature check).
79/// - **Flate / Raw** raw samples: re-encoded as a real PNG using
80///   `width`/`height`/`bits_per_component`/`color_space`. DeviceGray → 8-bit
81///   grayscale, DeviceRGB → RGB, DeviceCMYK → RGB (naive conversion). When the
82///   colour-space name is opaque (e.g. `ICCBased`), the component count is
83///   inferred from the byte length.
84/// - **Everything else** (JPX/JBIG2/CCITT, non-8-bit depths, indexed palettes,
85///   or a sample buffer too short for the declared geometry): returns `None` so
86///   the caller embeds *nothing* rather than corrupt bytes.
87///
88/// This function never returns raw, unencoded bytes labelled as an image.
89pub fn encode_image_for_document(img: &ExtractedImage) -> Option<EncodedImage> {
90    match &img.filter {
91        // DCTDecode streams are already a JPEG bitstream.
92        ImageFilter::Jpeg => {
93            if img.data.starts_with(&[0xFF, 0xD8]) {
94                Some(EncodedImage {
95                    data: img.data.clone(),
96                    mime: "image/jpeg",
97                    ext: "jpeg",
98                })
99            } else {
100                None
101            }
102        }
103        // Raw decompressed samples — encode a real PNG.
104        ImageFilter::Flate | ImageFilter::Raw => encode_samples_as_png(img),
105        // JPX/JBIG2/CCITT/Unknown carry compressed bytes we can't decode to
106        // samples here; never embed them as a PNG.
107        _ => None,
108    }
109}
110
111/// Number of colour components implied by a PDF colour-space *name*, or `None`
112/// when the name is opaque (ICCBased/Indexed/Separation/…) and the caller should
113/// infer from the data length instead.
114fn components_for_color_space(cs: &str) -> Option<usize> {
115    match cs {
116        "DeviceGray" | "CalGray" | "G" => Some(1),
117        "DeviceRGB" | "CalRGB" | "RGB" => Some(3),
118        "DeviceCMYK" | "CMYK" => Some(4),
119        _ => None,
120    }
121}
122
123/// Infer component count from total sample bytes and pixel count (8-bit only).
124fn infer_components(byte_len: usize, pixels: usize) -> Option<usize> {
125    if pixels == 0 {
126        return None;
127    }
128    match byte_len / pixels {
129        1 => Some(1),
130        3 => Some(3),
131        4 => Some(4),
132        _ => None,
133    }
134}
135
136/// Naive DeviceCMYK→RGB conversion (additive complement). 4 bytes → 3 bytes.
137fn cmyk_to_rgb(samples: &[u8]) -> Vec<u8> {
138    let mut out = Vec::with_capacity(samples.len() / 4 * 3);
139    for px in samples.chunks_exact(4) {
140        let (c, m, y, k) = (px[0] as u32, px[1] as u32, px[2] as u32, px[3] as u32);
141        out.push(((255 - c) * (255 - k) / 255) as u8);
142        out.push(((255 - m) * (255 - k) / 255) as u8);
143        out.push(((255 - y) * (255 - k) / 255) as u8);
144    }
145    out
146}
147
148/// Encode 8-bit raw samples into a real PNG. Returns `None` for unsupported
149/// depths/geometries so the caller can skip rather than embed garbage.
150fn encode_samples_as_png(img: &ExtractedImage) -> Option<EncodedImage> {
151    use image::codecs::png::{CompressionType, FilterType, PngEncoder};
152    use image::{ExtendedColorType, ImageEncoder};
153
154    // Only 8-bit components are supported; other depths need bit-unpacking we
155    // don't (yet) perform — skip rather than emit a wrong image.
156    if img.bits_per_component != 8 || img.width == 0 || img.height == 0 {
157        return None;
158    }
159    let pixels = (img.width as usize).checked_mul(img.height as usize)?;
160
161    // Prefer the named colour space; fall back to inferring from byte length
162    // (covers ICCBased and similar opaque names).
163    let components = components_for_color_space(&img.color_space)
164        .or_else(|| infer_components(img.data.len(), pixels))?;
165
166    let expected = pixels.checked_mul(components)?;
167    if img.data.len() < expected {
168        // Not enough data for the declared geometry — don't emit a corrupt PNG.
169        return None;
170    }
171    let samples = &img.data[..expected];
172
173    let (buf, color) = match components {
174        1 => (samples.to_vec(), ExtendedColorType::L8),
175        3 => (samples.to_vec(), ExtendedColorType::Rgb8),
176        4 => (cmyk_to_rgb(samples), ExtendedColorType::Rgb8),
177        _ => return None,
178    };
179
180    let mut out = Vec::new();
181    // Fast compression keeps export latency low for full-page rasters; the
182    // bytes are still a fully valid PNG.
183    PngEncoder::new_with_quality(&mut out, CompressionType::Fast, FilterType::Adaptive)
184        .write_image(&buf, img.width, img.height, color)
185        .ok()?;
186    Some(EncodedImage {
187        data: out,
188        mime: "image/png",
189        ext: "png",
190    })
191}
192
193/// Extract all images from all pages of a PDF document.
194pub fn extract_all_images(doc: &Document) -> Result<Vec<ExtractedImage>> {
195    let page_map = build_page_image_map(doc);
196    let mut images = Vec::new();
197
198    for (page_num, obj_ids) in &page_map {
199        for &obj_id in obj_ids {
200            if let Ok(img) = decode_image(doc, obj_id, *page_num) {
201                images.push(img);
202            }
203        }
204    }
205
206    Ok(images)
207}
208
209/// Extract images from a specific page (1-based page number).
210pub fn extract_page_images(doc: &Document, page_num: u32) -> Result<Vec<ExtractedImage>> {
211    let pages = doc.get_pages();
212    let total = pages.len() as u32;
213
214    if page_num == 0 || page_num > total {
215        return Err(ExtractError::PageOutOfRange(page_num, total));
216    }
217
218    let page_id = *pages
219        .get(&page_num)
220        .ok_or(ExtractError::PageOutOfRange(page_num, total))?;
221
222    extract_images_from_page_id(doc, page_id, page_num)
223}
224
225/// Extract images from a page identified by its object ID.
226///
227/// Unlike [`extract_page_images`], this function does not call `get_pages()`
228/// internally. Use this when the caller already holds a page map from a
229/// prior `doc.get_pages()` call to avoid redundant page-tree traversals.
230/// Fixes #447: eliminates the double `get_pages()` call in the images test.
231pub fn extract_images_from_page_id(
232    doc: &Document,
233    page_id: lopdf::ObjectId,
234    page_num: u32,
235) -> Result<Vec<ExtractedImage>> {
236    let obj_ids = collect_page_xobject_ids(doc, page_id);
237    let mut images = Vec::new();
238
239    for obj_id in obj_ids {
240        if let Ok(img) = decode_image(doc, obj_id, page_num) {
241            images.push(img);
242        }
243    }
244
245    Ok(images)
246}
247
248/// Check if a stream object is an image XObject.
249fn is_image_stream(dict: &lopdf::Dictionary) -> bool {
250    if let Ok(subtype) = dict.get(b"Subtype") {
251        if let Ok(name) = subtype.as_name() {
252            return name == b"Image";
253        }
254    }
255    false
256}
257
258/// Get the filter from a stream dictionary.
259fn get_filter(dict: &lopdf::Dictionary) -> ImageFilter {
260    if let Ok(filter_obj) = dict.get(b"Filter") {
261        match filter_obj {
262            Object::Name(name) => filter_from_name(name),
263            Object::Array(arr) => {
264                // Use the first filter in the array.
265                if let Some(Object::Name(name)) = arr.first() {
266                    filter_from_name(name)
267                } else {
268                    ImageFilter::Raw
269                }
270            }
271            _ => ImageFilter::Raw,
272        }
273    } else {
274        ImageFilter::Raw
275    }
276}
277
278/// Convert a filter name to an `ImageFilter` variant.
279fn filter_from_name(name: &[u8]) -> ImageFilter {
280    match name {
281        b"DCTDecode" => ImageFilter::Jpeg,
282        b"FlateDecode" => ImageFilter::Flate,
283        b"JBIG2Decode" => ImageFilter::Jbig2,
284        b"JPXDecode" => ImageFilter::Jpx,
285        b"CCITTFaxDecode" => ImageFilter::CcittFax,
286        _ => ImageFilter::Unknown(String::from_utf8_lossy(name).to_string()),
287    }
288}
289
290/// Get color space from an image stream dictionary.
291fn get_color_space(dict: &lopdf::Dictionary) -> String {
292    if let Ok(cs) = dict.get(b"ColorSpace") {
293        match cs {
294            Object::Name(name) => String::from_utf8_lossy(name).to_string(),
295            Object::Array(arr) => {
296                if let Some(Object::Name(name)) = arr.first() {
297                    String::from_utf8_lossy(name).to_string()
298                } else {
299                    "Unknown".to_string()
300                }
301            }
302            _ => "Unknown".to_string(),
303        }
304    } else {
305        "Unknown".to_string()
306    }
307}
308
309/// Get an integer value from a dictionary key.
310fn get_int(dict: &lopdf::Dictionary, key: &[u8]) -> u32 {
311    dict.get(key)
312        .ok()
313        .and_then(|v| match v {
314            Object::Integer(i) => Some(*i as u32),
315            _ => None,
316        })
317        .unwrap_or(0)
318}
319
320/// Decode an image from a PDF object.
321fn decode_image(doc: &Document, obj_id: ObjectId, page: u32) -> Result<ExtractedImage> {
322    let obj = doc
323        .get_object(obj_id)
324        .map_err(|e| ExtractError::Other(format!("object not found: {e}")))?;
325
326    let stream = match obj {
327        Object::Stream(ref s) => s,
328        _ => return Err(ExtractError::Other("not a stream object".into())),
329    };
330
331    let dict = &stream.dict;
332    if !is_image_stream(dict) {
333        return Err(ExtractError::Other("not an image stream".into()));
334    }
335
336    let width = get_int(dict, b"Width");
337    let height = get_int(dict, b"Height");
338    let bits_per_component = get_int(dict, b"BitsPerComponent");
339    let color_space = get_color_space(dict);
340    let filter = get_filter(dict);
341
342    let data = match filter {
343        ImageFilter::Jpeg | ImageFilter::Jbig2 | ImageFilter::Jpx | ImageFilter::CcittFax => {
344            // For these formats, return the raw compressed bytes.
345            get_raw_stream_bytes(stream)
346        }
347        ImageFilter::Flate => {
348            let raw = get_raw_stream_bytes(stream);
349            decompress_flate(&raw).unwrap_or(raw)
350        }
351        ImageFilter::Raw => get_raw_stream_bytes(stream),
352        ImageFilter::Unknown(_) => get_raw_stream_bytes(stream),
353    };
354
355    Ok(ExtractedImage {
356        object_id: obj_id,
357        page,
358        width,
359        height,
360        bits_per_component,
361        color_space,
362        filter,
363        data,
364    })
365}
366
367/// Get raw bytes from a stream (without decompression).
368fn get_raw_stream_bytes(stream: &lopdf::Stream) -> Vec<u8> {
369    stream.content.clone()
370}
371
372/// Get decompressed stream bytes using lopdf's built-in decompression.
373#[allow(dead_code)]
374fn get_stream_bytes(stream: &lopdf::Stream, _doc: &Document) -> Vec<u8> {
375    let mut s = stream.clone();
376    if s.decompress().is_ok() {
377        s.content.clone()
378    } else {
379        stream.content.clone()
380    }
381}
382
383/// Maximum bytes to decompress from a single image stream (64 MB).
384/// Prevents zip-bomb hangs on pathological PDFs.
385const FLATE_MAX_DECOMPRESS_BYTES: u64 = 64 * 1024 * 1024;
386
387/// Decompress flate-encoded data, capped at `FLATE_MAX_DECOMPRESS_BYTES`.
388fn decompress_flate(data: &[u8]) -> std::result::Result<Vec<u8>, std::io::Error> {
389    let decoder = flate2::read::ZlibDecoder::new(data);
390    let mut decoded = Vec::new();
391    decoder
392        .take(FLATE_MAX_DECOMPRESS_BYTES)
393        .read_to_end(&mut decoded)?;
394    Ok(decoded)
395}
396
397/// Build a map of page number -> list of image object IDs.
398fn build_page_image_map(doc: &Document) -> BTreeMap<u32, Vec<ObjectId>> {
399    let mut map = BTreeMap::new();
400    let pages = doc.get_pages();
401
402    for (&page_num, &page_id) in &pages {
403        let ids = collect_page_xobject_ids(doc, page_id);
404        if !ids.is_empty() {
405            map.insert(page_num, ids);
406        }
407    }
408
409    map
410}
411
412/// Collect all XObject IDs referenced by a page that are image streams.
413fn collect_page_xobject_ids(doc: &Document, page_id: ObjectId) -> Vec<ObjectId> {
414    let mut ids = Vec::new();
415
416    let page_obj = match doc.get_object(page_id) {
417        Ok(obj) => obj,
418        Err(_) => return ids,
419    };
420
421    let page_dict = match page_obj {
422        Object::Dictionary(ref d) => d,
423        _ => return ids,
424    };
425
426    // Get Resources dict.
427    let resources = match page_dict.get(b"Resources") {
428        Ok(res_obj) => match res_obj {
429            Object::Dictionary(ref d) => d.clone(),
430            Object::Reference(r) => match doc.get_object(*r) {
431                Ok(Object::Dictionary(ref d)) => d.clone(),
432                _ => return ids,
433            },
434            _ => return ids,
435        },
436        Err(_) => return ids,
437    };
438
439    // Get XObject dict from Resources.
440    let xobjects = match resources.get(b"XObject") {
441        Ok(xo) => match xo {
442            Object::Dictionary(ref d) => d.clone(),
443            Object::Reference(r) => match doc.get_object(*r) {
444                Ok(Object::Dictionary(ref d)) => d.clone(),
445                _ => return ids,
446            },
447            _ => return ids,
448        },
449        Err(_) => return ids,
450    };
451
452    for (_name, obj) in xobjects.iter() {
453        if let Object::Reference(obj_id) = obj {
454            if let Ok(Object::Stream(ref stream)) = doc.get_object(*obj_id) {
455                if is_image_stream(&stream.dict) {
456                    ids.push(*obj_id);
457                }
458            }
459        }
460    }
461
462    ids
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468    use lopdf::{dictionary, Document, Object, Stream};
469
470    // ── encode_image_for_document ──────────────────────────────────────
471
472    fn mk_img(
473        filter: ImageFilter,
474        cs: &str,
475        bpc: u32,
476        w: u32,
477        h: u32,
478        data: Vec<u8>,
479    ) -> ExtractedImage {
480        ExtractedImage {
481            object_id: (1, 0),
482            page: 1,
483            width: w,
484            height: h,
485            bits_per_component: bpc,
486            color_space: cs.to_string(),
487            filter,
488            data,
489        }
490    }
491
492    fn assert_decodes_to(enc: &EncodedImage, w: u32, h: u32) {
493        assert_eq!(
494            &enc.data[..8],
495            b"\x89PNG\r\n\x1a\n",
496            "missing PNG signature"
497        );
498        let decoded = image::load_from_memory(&enc.data).expect("encoded PNG must decode");
499        assert_eq!((decoded.width(), decoded.height()), (w, h));
500    }
501
502    #[test]
503    fn encode_rgb_flate_samples_produce_valid_png() {
504        // 2x2 DeviceRGB = 12 raw bytes (what FlateDecode yields after decompression).
505        let img = mk_img(ImageFilter::Flate, "DeviceRGB", 8, 2, 2, vec![10u8; 12]);
506        let enc = encode_image_for_document(&img).expect("RGB samples should encode");
507        assert_eq!(enc.mime, "image/png");
508        assert_eq!(enc.ext, "png");
509        assert_decodes_to(&enc, 2, 2);
510    }
511
512    #[test]
513    fn encode_grayscale_samples_produce_valid_png() {
514        // 2x2 DeviceGray = 4 raw bytes.
515        let img = mk_img(ImageFilter::Flate, "DeviceGray", 8, 2, 2, vec![200u8; 4]);
516        let enc = encode_image_for_document(&img).expect("gray samples should encode");
517        assert_eq!(enc.mime, "image/png");
518        assert_decodes_to(&enc, 2, 2);
519    }
520
521    #[test]
522    fn encode_cmyk_samples_produce_valid_rgb_png() {
523        // 2x2 DeviceCMYK = 16 raw bytes → converted to RGB PNG.
524        let img = mk_img(ImageFilter::Flate, "DeviceCMYK", 8, 2, 2, vec![0u8; 16]);
525        let enc = encode_image_for_document(&img).expect("cmyk samples should encode");
526        assert_eq!(enc.mime, "image/png");
527        assert_decodes_to(&enc, 2, 2);
528    }
529
530    #[test]
531    fn encode_iccbased_infers_components_from_length() {
532        // Opaque colour-space name; 12 bytes / 4 px = 3 components → RGB.
533        let img = mk_img(ImageFilter::Flate, "ICCBased", 8, 2, 2, vec![55u8; 12]);
534        let enc = encode_image_for_document(&img).expect("ICCBased should infer RGB");
535        assert_decodes_to(&enc, 2, 2);
536    }
537
538    #[test]
539    fn encode_raw_uncompressed_samples_produce_valid_png() {
540        let img = mk_img(ImageFilter::Raw, "DeviceRGB", 8, 2, 2, vec![255u8; 12]);
541        let enc = encode_image_for_document(&img).expect("raw samples should encode");
542        assert_decodes_to(&enc, 2, 2);
543    }
544
545    #[test]
546    fn encode_jpeg_stream_passes_through() {
547        // A minimal JPEG-signed buffer is embedded as-is.
548        let mut data = vec![0xFF, 0xD8, 0xFF, 0xE0];
549        data.extend_from_slice(&[1, 2, 3, 4]);
550        let img = mk_img(ImageFilter::Jpeg, "DeviceRGB", 8, 10, 10, data.clone());
551        let enc = encode_image_for_document(&img).expect("jpeg should pass through");
552        assert_eq!(enc.mime, "image/jpeg");
553        assert_eq!(enc.ext, "jpeg");
554        assert_eq!(enc.data, data, "jpeg bytes must be preserved verbatim");
555    }
556
557    #[test]
558    fn encode_jpeg_without_signature_is_skipped() {
559        let img = mk_img(ImageFilter::Jpeg, "DeviceRGB", 8, 10, 10, vec![0u8; 8]);
560        assert!(encode_image_for_document(&img).is_none());
561    }
562
563    #[test]
564    fn encode_short_buffer_is_skipped_not_corrupted() {
565        // Declares 2x2 RGB (needs 12 bytes) but only 6 present → must skip.
566        let img = mk_img(ImageFilter::Flate, "DeviceRGB", 8, 2, 2, vec![1u8; 6]);
567        assert!(encode_image_for_document(&img).is_none());
568    }
569
570    #[test]
571    fn encode_non_8bit_depth_is_skipped() {
572        let img = mk_img(ImageFilter::Flate, "DeviceGray", 1, 8, 8, vec![0u8; 8]);
573        assert!(encode_image_for_document(&img).is_none());
574    }
575
576    #[test]
577    fn encode_unsupported_filters_are_skipped() {
578        for f in [
579            ImageFilter::Jpx,
580            ImageFilter::Jbig2,
581            ImageFilter::CcittFax,
582            ImageFilter::Unknown("Foo".into()),
583        ] {
584            let img = mk_img(f, "DeviceRGB", 8, 2, 2, vec![0u8; 12]);
585            assert!(
586                encode_image_for_document(&img).is_none(),
587                "compressed/unknown filters must never be embedded as PNG"
588            );
589        }
590    }
591
592    /// Helper: create a minimal PDF document with a JPEG image on page 1.
593    fn make_doc_with_jpeg_image() -> Document {
594        let mut doc = Document::with_version("1.7");
595
596        let img_dict = dictionary! {
597            "Type" => "XObject",
598            "Subtype" => "Image",
599            "Width" => 100_i64,
600            "Height" => 50_i64,
601            "BitsPerComponent" => 8_i64,
602            "ColorSpace" => "DeviceRGB",
603            "Filter" => "DCTDecode",
604        };
605        let img_stream = Stream::new(img_dict, vec![0xFF, 0xD8, 0xFF, 0xE0]);
606        let img_id = doc.add_object(Object::Stream(img_stream));
607
608        let xobject_dict = dictionary! {
609            "Im0" => Object::Reference(img_id),
610        };
611        let resources_dict = dictionary! {
612            "XObject" => Object::Dictionary(xobject_dict),
613        };
614        let content_data = b"q 100 0 0 50 0 0 cm /Im0 Do Q".to_vec();
615        let content_stream = Stream::new(dictionary! {}, content_data);
616        let content_id = doc.add_object(Object::Stream(content_stream));
617
618        let page_dict = dictionary! {
619            "Type" => "Page",
620            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
621            "Resources" => Object::Dictionary(resources_dict),
622            "Contents" => Object::Reference(content_id),
623        };
624        let page_id = doc.add_object(Object::Dictionary(page_dict));
625
626        let pages_dict = dictionary! {
627            "Type" => "Pages",
628            "Kids" => vec![Object::Reference(page_id)],
629            "Count" => 1_i64,
630        };
631        let pages_id = doc.add_object(Object::Dictionary(pages_dict));
632
633        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
634            d.set("Parent", Object::Reference(pages_id));
635        }
636
637        let catalog = dictionary! {
638            "Type" => "Catalog",
639            "Pages" => Object::Reference(pages_id),
640        };
641        let catalog_id = doc.add_object(Object::Dictionary(catalog));
642        doc.trailer.set("Root", Object::Reference(catalog_id));
643
644        doc
645    }
646
647    /// Helper: create a doc with a raw (uncompressed) image.
648    fn make_doc_with_raw_image() -> Document {
649        let mut doc = Document::with_version("1.7");
650
651        let img_dict = dictionary! {
652            "Type" => "XObject",
653            "Subtype" => "Image",
654            "Width" => 2_i64,
655            "Height" => 2_i64,
656            "BitsPerComponent" => 8_i64,
657            "ColorSpace" => "DeviceRGB",
658        };
659        // 2x2 RGB image = 12 bytes.
660        let img_stream = Stream::new(img_dict, vec![255; 12]);
661        let img_id = doc.add_object(Object::Stream(img_stream));
662
663        let xobject_dict = dictionary! {
664            "Im0" => Object::Reference(img_id),
665        };
666        let resources_dict = dictionary! {
667            "XObject" => Object::Dictionary(xobject_dict),
668        };
669        let content_data = b"q 2 0 0 2 0 0 cm /Im0 Do Q".to_vec();
670        let content_stream = Stream::new(dictionary! {}, content_data);
671        let content_id = doc.add_object(Object::Stream(content_stream));
672
673        let page_dict = dictionary! {
674            "Type" => "Page",
675            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
676            "Resources" => Object::Dictionary(resources_dict),
677            "Contents" => Object::Reference(content_id),
678        };
679        let page_id = doc.add_object(Object::Dictionary(page_dict));
680
681        let pages_dict = dictionary! {
682            "Type" => "Pages",
683            "Kids" => vec![Object::Reference(page_id)],
684            "Count" => 1_i64,
685        };
686        let pages_id = doc.add_object(Object::Dictionary(pages_dict));
687
688        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
689            d.set("Parent", Object::Reference(pages_id));
690        }
691
692        let catalog = dictionary! {
693            "Type" => "Catalog",
694            "Pages" => Object::Reference(pages_id),
695        };
696        let catalog_id = doc.add_object(Object::Dictionary(catalog));
697        doc.trailer.set("Root", Object::Reference(catalog_id));
698
699        doc
700    }
701
702    /// Helper: create a doc with a flate-compressed image.
703    fn make_doc_with_flate_image() -> Document {
704        let mut doc = Document::with_version("1.7");
705
706        // Compress raw data with flate.
707        let raw_data = vec![128u8; 12]; // 2x2 RGB
708        let mut encoder =
709            flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
710        std::io::Write::write_all(&mut encoder, &raw_data).unwrap();
711        let compressed = encoder.finish().unwrap();
712
713        let img_dict = dictionary! {
714            "Type" => "XObject",
715            "Subtype" => "Image",
716            "Width" => 2_i64,
717            "Height" => 2_i64,
718            "BitsPerComponent" => 8_i64,
719            "ColorSpace" => "DeviceRGB",
720            "Filter" => "FlateDecode",
721        };
722        let img_stream = Stream::new(img_dict, compressed);
723        let img_id = doc.add_object(Object::Stream(img_stream));
724
725        let xobject_dict = dictionary! {
726            "Im0" => Object::Reference(img_id),
727        };
728        let resources_dict = dictionary! {
729            "XObject" => Object::Dictionary(xobject_dict),
730        };
731        let content_data = b"q 2 0 0 2 0 0 cm /Im0 Do Q".to_vec();
732        let content_stream = Stream::new(dictionary! {}, content_data);
733        let content_id = doc.add_object(Object::Stream(content_stream));
734
735        let page_dict = dictionary! {
736            "Type" => "Page",
737            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
738            "Resources" => Object::Dictionary(resources_dict),
739            "Contents" => Object::Reference(content_id),
740        };
741        let page_id = doc.add_object(Object::Dictionary(page_dict));
742
743        let pages_dict = dictionary! {
744            "Type" => "Pages",
745            "Kids" => vec![Object::Reference(page_id)],
746            "Count" => 1_i64,
747        };
748        let pages_id = doc.add_object(Object::Dictionary(pages_dict));
749
750        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
751            d.set("Parent", Object::Reference(pages_id));
752        }
753
754        let catalog = dictionary! {
755            "Type" => "Catalog",
756            "Pages" => Object::Reference(pages_id),
757        };
758        let catalog_id = doc.add_object(Object::Dictionary(catalog));
759        doc.trailer.set("Root", Object::Reference(catalog_id));
760
761        doc
762    }
763
764    #[test]
765    fn extract_jpeg_image() {
766        let doc = make_doc_with_jpeg_image();
767        let images = extract_all_images(&doc).unwrap();
768        assert_eq!(images.len(), 1);
769        assert_eq!(images[0].width, 100);
770        assert_eq!(images[0].height, 50);
771        assert_eq!(images[0].bits_per_component, 8);
772        assert_eq!(images[0].color_space, "DeviceRGB");
773        assert_eq!(images[0].filter, ImageFilter::Jpeg);
774        assert_eq!(images[0].page, 1);
775    }
776
777    #[test]
778    fn extract_from_specific_page() {
779        let doc = make_doc_with_jpeg_image();
780        let images = extract_page_images(&doc, 1).unwrap();
781        assert_eq!(images.len(), 1);
782        assert_eq!(images[0].filter, ImageFilter::Jpeg);
783    }
784
785    #[test]
786    fn extract_page_out_of_range() {
787        let doc = make_doc_with_jpeg_image();
788        let result = extract_page_images(&doc, 5);
789        assert!(result.is_err());
790    }
791
792    #[test]
793    fn extract_raw_image() {
794        let doc = make_doc_with_raw_image();
795        let images = extract_all_images(&doc).unwrap();
796        assert_eq!(images.len(), 1);
797        assert_eq!(images[0].filter, ImageFilter::Raw);
798        assert_eq!(images[0].data.len(), 12);
799    }
800
801    #[test]
802    fn extract_flate_compressed_image() {
803        let doc = make_doc_with_flate_image();
804        let images = extract_all_images(&doc).unwrap();
805        assert_eq!(images.len(), 1);
806        assert_eq!(images[0].filter, ImageFilter::Flate);
807        // After decompression, we should have 12 bytes (2x2 RGB).
808        assert_eq!(images[0].data.len(), 12);
809    }
810
811    #[test]
812    fn no_images_returns_empty() {
813        let mut doc = Document::with_version("1.7");
814
815        let content_stream = Stream::new(dictionary! {}, b"BT /F1 12 Tf (Hello) Tj ET".to_vec());
816        let content_id = doc.add_object(Object::Stream(content_stream));
817
818        let page_dict = dictionary! {
819            "Type" => "Page",
820            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
821            "Contents" => Object::Reference(content_id),
822        };
823        let page_id = doc.add_object(Object::Dictionary(page_dict));
824
825        let pages_dict = dictionary! {
826            "Type" => "Pages",
827            "Kids" => vec![Object::Reference(page_id)],
828            "Count" => 1_i64,
829        };
830        let pages_id = doc.add_object(Object::Dictionary(pages_dict));
831
832        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
833            d.set("Parent", Object::Reference(pages_id));
834        }
835
836        let catalog = dictionary! {
837            "Type" => "Catalog",
838            "Pages" => Object::Reference(pages_id),
839        };
840        let catalog_id = doc.add_object(Object::Dictionary(catalog));
841        doc.trailer.set("Root", Object::Reference(catalog_id));
842
843        let images = extract_all_images(&doc).unwrap();
844        assert!(images.is_empty());
845    }
846}