Skip to main content

xberg_native_pdf/document/
images.rs

1//! Image and embedded-file extraction, including form-XObject traversal.
2//!
3//! Split out of the parent's single 18,544-line `impl PdfDocument`, which made
4//! `document.rs` 1.2 MiB and tripped the 500 KiB file-safety limit. A child module's
5//! `impl` is the same inherent impl and sees the parent's private items unchanged. ~keep
6
7use super::*;
8
9impl PdfDocument {
10    /// Check for circular references in the object graph.
11    ///
12    /// This is a diagnostic method that performs a depth-first search
13    /// through the object graph to detect cycles.
14    ///
15    /// # Returns
16    ///
17    /// A vector of tuples representing edges that create cycles.
18    /// Each tuple is (from_object, to_object) where to_object is
19    /// already in the path when encountered again.
20    ///
21    /// # Example
22    ///
23    /// ```no_run
24    /// # use xberg_native_pdf::document::PdfDocument;
25    /// # let mut doc = PdfDocument::open("sample.pdf")?;
26    /// let cycles = doc.check_for_circular_references();
27    /// if !cycles.is_empty() {
28    ///     println!("Found {} circular references", cycles.len());
29    /// }
30    /// # Ok::<(), xberg_native_pdf::error::Error>(())
31    /// ```
32    pub fn check_for_circular_references(&self) -> Vec<(ObjectRef, ObjectRef)> {
33        let mut cycles = Vec::new();
34        let mut visited = HashSet::new();
35        let mut path = Vec::new();
36
37        let obj_nums: Vec<u32> = self.xref.entries.keys().copied().collect();
38        for obj_num in obj_nums {
39            let obj_ref = ObjectRef::new(obj_num, 0);
40            if !visited.contains(&obj_ref) {
41                self.dfs_check_cycles(obj_ref, &mut visited, &mut path, &mut cycles);
42            }
43        }
44
45        cycles
46    }
47
48    /// Depth-first search helper for cycle detection.
49    fn dfs_check_cycles(
50        &self,
51        obj_ref: ObjectRef,
52        visited: &mut HashSet<ObjectRef>,
53        path: &mut Vec<ObjectRef>,
54        cycles: &mut Vec<(ObjectRef, ObjectRef)>,
55    ) {
56        if path.contains(&obj_ref) {
57            if let Some(&prev) = path.last() {
58                cycles.push((prev, obj_ref));
59            }
60            return;
61        }
62
63        if visited.contains(&obj_ref) {
64            return;
65        }
66
67        visited.insert(obj_ref);
68        path.push(obj_ref);
69
70        if let Ok(obj) = self.load_object(obj_ref) {
71            for ref_found in Self::find_references(&obj) {
72                self.dfs_check_cycles(ref_found, visited, path, cycles);
73            }
74        }
75
76        path.pop();
77    }
78
79    /// Find all object references within an object.
80    pub(super) fn find_references(obj: &Object) -> Vec<ObjectRef> {
81        let mut refs = Vec::new();
82
83        match obj {
84            Object::Reference(r) => refs.push(*r),
85            Object::Array(arr) => {
86                for item in arr {
87                    refs.extend(Self::find_references(item));
88                }
89            }
90            Object::Dictionary(dict) => {
91                for value in dict.values() {
92                    refs.extend(Self::find_references(value));
93                }
94            }
95            Object::Stream { dict, .. } => {
96                for value in dict.values() {
97                    refs.extend(Self::find_references(value));
98                }
99            }
100            _ => {}
101        }
102
103        refs
104    }
105
106    /// Extract images from a page.
107    ///
108    /// Extracts all images from the specified page by processing the content stream.
109    /// This includes:
110    /// - Images referenced via `Do` operators (XObject calls)
111    /// - Images in nested Form XObjects (with recursion)
112    /// - Inline images (BI...ID...EI sequences)
113    ///
114    /// This method processes PDF content streams instead of only iterating the XObject
115    /// dictionary. This ensures that images referenced via the `Do` operator in the content
116    /// stream are properly extracted, including those in nested Form XObjects. ColorSpace
117    /// indirect references are also resolved.
118    ///
119    /// Returns a vector of PdfImage objects representing the extracted images.
120    ///
121    /// # Arguments
122    ///
123    /// * `page_index` - Zero-based page index
124    ///
125    /// # Returns
126    ///
127    /// A vector of PdfImage objects, one for each image found on the page.
128    ///
129    /// # Errors
130    ///
131    /// Returns an error if the page cannot be accessed or if image extraction fails.
132    ///
133    /// # Examples
134    ///
135    /// ```no_run
136    /// # use xberg_native_pdf::document::PdfDocument;
137    /// # let mut doc = PdfDocument::open("sample.pdf")?;
138    /// let images = doc.extract_images(0)?;
139    /// println!("Found {} images on page 1", images.len());
140    /// for (i, image) in images.iter().enumerate() {
141    ///     image.save_as_png(&format!("image_{}.png", i))?;
142    /// }
143    /// # Ok::<(), xberg_native_pdf::error::Error>(())
144    /// ```
145    pub fn extract_images(&self, page_index: usize) -> Result<Vec<crate::extractors::PdfImage>> {
146        self.require_authenticated()?;
147        self.extract_images_filtered(page_index, &ImageExtractFilter::default())
148    }
149
150    /// Extract embedded files / attachments (WS1.8a, ISO 32000-1 §7.11.4).
151    ///
152    /// Walks the catalog's `/Names /EmbeddedFiles` name tree and returns
153    /// `(filename, decoded bytes)` for each `/Filespec`'s `/EF /F` (or `/UF`)
154    /// embedded-file stream. Complements the existing embedded-file writer.
155    /// Returns an empty vector when the document has no attachments.
156    pub fn extract_embedded_files(&self) -> Result<Vec<(String, Vec<u8>)>> {
157        self.require_authenticated()?;
158        let catalog = self.catalog()?;
159        let Some(cat_dict) = catalog.as_dict() else {
160            return Ok(Vec::new());
161        };
162        let names = cat_dict.get("Names").and_then(|n| self.resolve_object(n).ok());
163        let Some(ef_root) = names
164            .as_ref()
165            .and_then(|n| n.as_dict())
166            .and_then(|d| d.get("EmbeddedFiles"))
167            .and_then(|e| self.resolve_object(e).ok())
168        else {
169            return Ok(Vec::new());
170        };
171
172        let mut filespecs: Vec<Object> = Vec::new();
173        self.collect_embedded_filespecs(&ef_root, &mut filespecs, 0);
174
175        let mut out = Vec::new();
176        for fs in &filespecs {
177            let Some(fs_dict) = fs.as_dict() else {
178                continue;
179            };
180            let filename = fs_dict
181                .get("UF")
182                .or_else(|| fs_dict.get("F"))
183                .and_then(|n| self.resolve_object(n).ok())
184                .and_then(|n| n.as_string().map(|s| String::from_utf8_lossy(s).into_owned()))
185                .unwrap_or_else(|| "attachment".to_string());
186            let Some(ef) = fs_dict.get("EF").and_then(|e| self.resolve_object(e).ok()) else {
187                continue;
188            };
189            let Some(ef_dict) = ef.as_dict() else {
190                continue;
191            };
192            let Some(stream_ref) = ef_dict
193                .get("F")
194                .or_else(|| ef_dict.get("UF"))
195                .and_then(|r| r.as_reference())
196            else {
197                continue;
198            };
199            if let Ok(stream_obj) = self.load_object(stream_ref)
200                && let Ok(bytes) = self.decode_stream_with_encryption(&stream_obj, stream_ref)
201            {
202                out.push((filename, bytes));
203            }
204        }
205        Ok(out)
206    }
207
208    /// Recursively collect `/Filespec` objects from an `/EmbeddedFiles`
209    /// name-tree node: leaf `/Names [key filespec …]` pairs plus `/Kids`.
210    fn collect_embedded_filespecs(&self, node: &Object, out: &mut Vec<Object>, depth: u8) {
211        if depth > 32 {
212            return;
213        }
214        let Ok(node) = self.resolve_object(node) else {
215            return;
216        };
217        let Some(dict) = node.as_dict() else {
218            return;
219        };
220        if let Some(names) = dict.get("Names").and_then(|n| self.resolve_object(n).ok())
221            && let Some(arr) = names.as_array()
222        {
223            // Flat [key1 filespec1 key2 filespec2 …]: the odd indices. ~keep
224            let mut i = 1;
225            while i < arr.len() {
226                if let Ok(fs) = self.resolve_object(&arr[i]) {
227                    out.push(fs);
228                }
229                i += 2;
230            }
231        }
232        if let Some(kids) = dict.get("Kids").and_then(|k| self.resolve_object(k).ok())
233            && let Some(arr) = kids.as_array()
234        {
235            for kid in arr {
236                self.collect_embedded_filespecs(kid, out, depth + 1);
237            }
238        }
239    }
240
241    /// Build the resource-name → colour-space-object map from a resolved
242    /// `/Resources` dictionary's `/ColorSpace` subdictionary (§8.6.3 / §7.8.3),
243    /// resolving one indirect-ref hop per entry so the stored value is a colour
244    /// space name or array. Empty when there is no `/ColorSpace` subdictionary;
245    /// the standard device names parse directly and need no entry. Consumed by
246    /// the image-handle builders so `decode()` / the handle's `color_space` can
247    /// resolve names like `/CS0` (§8.6.6, §8.9.7).
248    fn build_color_space_map(&self, resources: Option<&Object>) -> std::collections::HashMap<String, Object> {
249        let mut map = std::collections::HashMap::new();
250        let Some(res) = resources else {
251            return map;
252        };
253        let res = if let Some(r) = res.as_reference() {
254            match self.load_object(r) {
255                Ok(o) => o,
256                Err(_) => return map,
257            }
258        } else {
259            res.clone()
260        };
261        let Some(res_dict) = res.as_dict() else {
262            return map;
263        };
264        let Some(cs_entry) = res_dict.get("ColorSpace") else {
265            return map;
266        };
267        let cs_obj = if let Some(r) = cs_entry.as_reference() {
268            match self.load_object(r) {
269                Ok(o) => o,
270                Err(_) => return map,
271            }
272        } else {
273            cs_entry.clone()
274        };
275        let Some(cs_dict) = cs_obj.as_dict() else {
276            return map;
277        };
278        for (name, value) in cs_dict.iter() {
279            let resolved = if let Some(r) = value.as_reference() {
280                self.load_object(r).unwrap_or_else(|_| value.clone())
281            } else {
282                value.clone()
283            };
284            map.insert(name.clone(), resolved);
285        }
286        map
287    }
288
289    /// Enumerate images on a page without decompressing any stream (Phase 1).
290    ///
291    /// Walks the page content stream once and reads image metadata (dimensions,
292    /// colour space, filter chain, compressed size) directly from each Image
293    /// XObject dictionary. No pixel data is decoded. Returns a handle per image
294    /// in content-stream paint order.
295    ///
296    /// Call [`crate::PdfImageHandle::decode`] on individual handles to materialise only
297    /// the images you need, or [`crate::PdfImageHandle::raw_compressed_bytes`] to forward
298    /// compressed data (e.g. JPEG bytes) without recompression.
299    ///
300    /// Form XObjects (subtype `/Form`) are recursed into, matching the behaviour
301    /// of [`PdfDocument::extract_images`]. Cycle detection (depth limit 100) and
302    /// the document's Form stream cache are used. Images inside nested or shared
303    /// Forms receive the correct final CTM-composed `bbox` / `rotation_degrees`.
304    ///
305    /// # Examples
306    ///
307    /// ```no_run
308    /// # use xberg_native_pdf::PdfDocument;
309    /// # let bytes = std::fs::read("page.pdf").unwrap();
310    /// let doc = PdfDocument::from_bytes(bytes).unwrap();
311    ///
312    /// // Decode only images larger than a thumbnail threshold
313    /// let images: Vec<_> = doc.page_image_handles(0)?
314    ///     .into_iter()
315    ///     .filter(|h| h.width >= 200 && h.height >= 200)
316    ///     .map(|h| h.decode())
317    ///     .collect::<Result<_, _>>()?;
318    /// # Ok::<(), xberg_native_pdf::error::Error>(())
319    /// ```
320    pub fn page_image_handles(&self, page_index: usize) -> Result<Vec<crate::extractors::images::PdfImageHandle<'_>>> {
321        use crate::content::Operator;
322        use crate::content::parse_content_stream_images_only;
323        use crate::extractors::images::image_handle_from_inline;
324
325        self.require_authenticated()?;
326
327        let page = self.get_page(page_index)?;
328        let page_dict = page.as_dict().ok_or_else(|| Error::ParseError {
329            offset: 0,
330            reason: "Page is not a dictionary".to_string(),
331        })?;
332
333        let content_data = self.get_page_content_data(page_index)?;
334
335        let resources = match page_dict.get("Resources") {
336            Some(res) => {
337                if let Some(ref_obj) = res.as_reference() {
338                    Some(self.load_object(ref_obj)?)
339                } else {
340                    Some(res.clone())
341                }
342            }
343            None => None,
344        };
345
346        let operators = match parse_content_stream_images_only(&content_data) {
347            Ok(ops) => ops,
348            Err(_) => return Ok(Vec::new()),
349        };
350
351        // Resource-name colour-space map for this page scope (§8.6.6 / §8.9.7). ~keep
352        let cs_map = self.build_color_space_map(resources.as_ref());
353
354        let xobject_dict = if let Some(ref res) = resources {
355            if let Some(res_dict) = res.as_dict() {
356                if let Some(xobj_entry) = res_dict.get("XObject") {
357                    let resolved = if let Some(ref_obj) = xobj_entry.as_reference() {
358                        self.load_object(ref_obj)?
359                    } else {
360                        xobj_entry.clone()
361                    };
362                    resolved.as_dict().cloned()
363                } else {
364                    None
365                }
366            } else {
367                None
368            }
369        } else {
370            None
371        };
372
373        let mut handles = Vec::new();
374        let mut ctm_stack = vec![crate::content::Matrix::identity()];
375        let mut paint_order: usize = 0;
376        let mut xobject_stack: Vec<crate::object::ObjectRef> = Vec::new();
377
378        for op in operators {
379            match op {
380                Operator::SaveState => {
381                    if let Some(current) = ctm_stack.last() {
382                        ctm_stack.push(*current);
383                    }
384                }
385                Operator::RestoreState => {
386                    if ctm_stack.len() > 1 {
387                        ctm_stack.pop();
388                    }
389                }
390                Operator::Cm { a, b, c, d, e, f } => {
391                    if let Some(current) = ctm_stack.last_mut() {
392                        let m = crate::content::Matrix { a, b, c, d, e, f };
393                        *current = m.multiply(current);
394                    }
395                }
396                Operator::Do { name } => {
397                    if let Some(ref xobj_dict_map) = xobject_dict {
398                        let ctm = ctm_stack
399                            .last()
400                            .copied()
401                            .unwrap_or_else(crate::content::Matrix::identity);
402                        if let Ok(mut more) = self.collect_handles_from_do(
403                            &name,
404                            xobj_dict_map,
405                            resources.as_ref(),
406                            ctm,
407                            &mut paint_order,
408                            &mut xobject_stack,
409                        ) {
410                            handles.append(&mut more);
411                        }
412                    }
413                }
414                Operator::InlineImage { dict, data } => {
415                    let ctm = ctm_stack
416                        .last()
417                        .copied()
418                        .unwrap_or_else(crate::content::Matrix::identity);
419                    if let Some(handle) = image_handle_from_inline(self, &dict, data, ctm, paint_order, &cs_map) {
420                        handles.push(handle);
421                        paint_order += 1;
422                    }
423                }
424                _ => {}
425            }
426        }
427
428        Ok(handles)
429    }
430
431    /// Collect zero or more image handles for a `Do` operator.
432    ///
433    /// If the target is an Image XObject, returns a vec containing one handle
434    /// (paint_order is advanced). If it is a Form XObject, recurses and returns
435    /// all image handles found inside (including nested Forms), with correct
436    /// paint_order and CTM composition for every handle.
437    fn collect_handles_from_do<'s>(
438        &'s self,
439        name: &str,
440        xobject_dict: &std::collections::HashMap<String, Object>,
441        resources: Option<&Object>,
442        ctm: crate::content::Matrix,
443        paint_order: &mut usize,
444        xobject_stack: &mut Vec<crate::object::ObjectRef>,
445    ) -> Result<Vec<crate::extractors::images::PdfImageHandle<'s>>> {
446        use crate::extractors::images::image_handle_from_xobject;
447
448        let xobject_ref_obj = match xobject_dict.get(name) {
449            Some(o) => o,
450            None => return Ok(Vec::new()),
451        };
452
453        let xobject_ref_opt = xobject_ref_obj.as_reference();
454        let xobject = if let Some(ref_obj) = xobject_ref_opt {
455            self.load_object(ref_obj)?
456        } else {
457            xobject_ref_obj.clone()
458        };
459        let xobj_dict = match xobject.as_dict() {
460            Some(d) => d,
461            None => return Ok(Vec::new()),
462        };
463
464        let subtype = xobj_dict.get("Subtype").and_then(|s| s.as_name()).unwrap_or("");
465
466        match subtype {
467            "Image" => {
468                if let Some(ref_obj) = xobject_ref_opt {
469                    let cs_map = self.build_color_space_map(resources);
470                    match image_handle_from_xobject(self, ref_obj, xobj_dict, ctm, *paint_order, &cs_map) {
471                        Some(h) => {
472                            *paint_order += 1;
473                            let mut handles = vec![h];
474                            handles.extend(self.collect_referenced_mask_handles(xobj_dict, ctm, paint_order, &cs_map));
475                            Ok(handles)
476                        }
477                        _ => Ok(Vec::new()),
478                    }
479                } else {
480                    Ok(Vec::new())
481                }
482            }
483            "Form" => {
484                if let (Some(ref_obj), Some(parent_res)) = (xobject_ref_opt, resources) {
485                    self.collect_image_handles_from_form_xobject(
486                        ref_obj,
487                        &xobject,
488                        parent_res,
489                        ctm,
490                        paint_order,
491                        xobject_stack,
492                    )
493                } else {
494                    Ok(Vec::new())
495                }
496            }
497            _ => Ok(Vec::new()),
498        }
499    }
500
501    fn collect_referenced_mask_handles<'s>(
502        &'s self,
503        image_dict: &std::collections::HashMap<String, Object>,
504        ctm: crate::content::Matrix,
505        paint_order: &mut usize,
506        color_spaces: &std::collections::HashMap<String, Object>,
507    ) -> Vec<crate::extractors::images::PdfImageHandle<'s>> {
508        use crate::extractors::images::image_handle_from_xobject;
509
510        let mut handles = Vec::new();
511        for key in ["Mask", "SMask"] {
512            let Some(mask_ref) = image_dict.get(key).and_then(Object::as_reference) else {
513                continue;
514            };
515            let Ok(mask) = self.load_object(mask_ref) else {
516                continue;
517            };
518            let Some(mask_dict) = mask.as_dict() else {
519                continue;
520            };
521            if mask_dict.get("Subtype").and_then(Object::as_name) != Some("Image") {
522                continue;
523            }
524            if let Some(handle) = image_handle_from_xobject(self, mask_ref, mask_dict, ctm, *paint_order, color_spaces)
525            {
526                handles.push(handle);
527                *paint_order += 1;
528            }
529        }
530        handles
531    }
532
533    /// Recursively collect image handles from a Form XObject.
534    ///
535    /// This is the handles-side equivalent of `extract_images_from_form_xobject`.
536    /// It uses the same cycle detection (ObjectRef stack + depth 100), the same
537    /// Form Resources fallback rules, the same Form /Matrix handling, and reuses
538    /// the document's xobject_stream_cache (50 MiB bound) for decompressed Form
539    /// content.
540    ///
541    /// Unlike the materialised path, we do not cache "raw" handles — we compose
542    /// the full CTM (`parent_ctm * form_matrix`) at entry and let every inner
543    /// handle (and nested Form) naturally receive the final geometry. This is
544    /// simpler for the two-phase API and produces correct `bbox`/`rotation_degrees`
545    /// / `ctm` fields on the returned handles.
546    fn collect_image_handles_from_form_xobject<'s>(
547        &'s self,
548        xobject_ref: crate::object::ObjectRef,
549        xobject: &Object,
550        parent_resources: &Object,
551        parent_ctm: crate::content::Matrix,
552        paint_order: &mut usize,
553        xobject_stack: &mut Vec<crate::object::ObjectRef>,
554    ) -> Result<Vec<crate::extractors::images::PdfImageHandle<'s>>> {
555        use crate::content::Operator;
556        use crate::content::parse_content_stream_images_only;
557        use crate::extractors::images::image_handle_from_inline;
558
559        // Cycle detection — identical policy to the materialised extraction path. ~keep
560        if xobject_stack.contains(&xobject_ref) || xobject_stack.len() >= 100 {
561            return Ok(Vec::new());
562        }
563
564        xobject_stack.push(xobject_ref);
565
566        let xobj_dict = match xobject.as_dict() {
567            Some(d) => d,
568            None => {
569                xobject_stack.pop();
570                return Ok(Vec::new());
571            }
572        };
573
574        let form_resources = if let Some(form_res) = xobj_dict.get("Resources") {
575            if let Some(ref_obj) = form_res.as_reference() {
576                self.load_object(ref_obj)?
577            } else {
578                form_res.clone()
579            }
580        } else {
581            parent_resources.clone()
582        };
583
584        let form_xobject_dict = if let Some(res_dict) = form_resources.as_dict() {
585            if let Some(xobj_entry) = res_dict.get("XObject") {
586                let resolved = if let Some(ref_obj) = xobj_entry.as_reference() {
587                    self.load_object(ref_obj)?
588                } else {
589                    xobj_entry.clone()
590                };
591                resolved.as_dict().cloned()
592            } else {
593                None
594            }
595        } else {
596            None
597        };
598
599        let form_matrix = if let Some(matrix_obj) = xobj_dict.get("Matrix") {
600            self.parse_matrix_from_object(matrix_obj)
601                .unwrap_or_else(crate::content::Matrix::identity)
602        } else {
603            crate::content::Matrix::identity()
604        };
605
606        let cached_stream = self.xobject_stream_cache.lock_or_recover().get(&xobject_ref).cloned();
607        let stream_data = if let Some(cached) = cached_stream {
608            cached.as_ref().clone()
609        } else {
610            match self.decode_stream_with_encryption(xobject, xobject_ref) {
611                Ok(data) => {
612                    const MAX_STREAM_CACHE_BYTES: usize = 50 * 1024 * 1024;
613                    let current_bytes = self.xobject_stream_cache_bytes.load(Ordering::Relaxed);
614                    if current_bytes + data.len() <= MAX_STREAM_CACHE_BYTES {
615                        self.xobject_stream_cache_bytes
616                            .store(current_bytes + data.len(), Ordering::Relaxed);
617                        self.xobject_stream_cache
618                            .lock_or_recover()
619                            .insert(xobject_ref, std::sync::Arc::new(data.clone()));
620                    }
621                    data
622                }
623                Err(error) => {
624                    tracing::warn!(target: LOG_TARGET,
625                        error_code = error.telemetry_code(),
626                        error_offset = ?error.telemetry_offset(),
627                        "failed to decode Form XObject stream; skipping"
628                    );
629                    xobject_stack.pop();
630                    return Ok(Vec::new());
631                }
632            }
633        };
634
635        let operators = match parse_content_stream_images_only(&stream_data) {
636            Ok(ops) => ops,
637            Err(_) => {
638                xobject_stack.pop();
639                return Ok(Vec::new());
640            }
641        };
642
643        // Critical CTM composition (ISO 32000-1 §8.10.1): the form /Matrix is applied
644        // before the CTM in effect at `Do`. `a.multiply(b)` applies `a` first, so this is
645        // `form_matrix.multiply(parent_ctm)`; the reverse scales the page translation
646        // by the form matrix (GH#2103).
647        // Start the form's internal graphics state with that composition.
648        // Every image (and nested Form) discovered inside will then have its
649        // handle's bbox/rotation/ctm computed with the *final* transform that
650        // will be active when the image is painted on the page. ~keep
651        let start_ctm = form_matrix.multiply(&parent_ctm);
652        let mut ctm_stack = vec![start_ctm];
653        let mut handles = Vec::new();
654
655        for op in operators {
656            match op {
657                Operator::SaveState => {
658                    if let Some(current) = ctm_stack.last() {
659                        ctm_stack.push(*current);
660                    }
661                }
662                Operator::RestoreState => {
663                    if ctm_stack.len() > 1 {
664                        ctm_stack.pop();
665                    }
666                }
667                Operator::Cm { a, b, c, d, e, f } => {
668                    if let Some(current) = ctm_stack.last_mut() {
669                        let m = crate::content::Matrix { a, b, c, d, e, f };
670                        *current = m.multiply(current);
671                    }
672                }
673
674                Operator::Do { name } => {
675                    if let Some(ref xobj_d) = form_xobject_dict {
676                        let current_ctm = ctm_stack
677                            .last()
678                            .copied()
679                            .unwrap_or_else(crate::content::Matrix::identity);
680                        if let Ok(mut more) = self.collect_handles_from_do(
681                            &name,
682                            xobj_d,
683                            Some(&form_resources),
684                            current_ctm,
685                            paint_order,
686                            xobject_stack,
687                        ) {
688                            handles.append(&mut more);
689                        }
690                    }
691                }
692
693                Operator::InlineImage { dict, data } => {
694                    let current_ctm = ctm_stack
695                        .last()
696                        .copied()
697                        .unwrap_or_else(crate::content::Matrix::identity);
698                    let cs_map = self.build_color_space_map(Some(&form_resources));
699                    if let Some(h) = image_handle_from_inline(self, &dict, data, current_ctm, *paint_order, &cs_map) {
700                        handles.push(h);
701                        *paint_order += 1;
702                    }
703                }
704
705                _ => {}
706            }
707        }
708
709        xobject_stack.pop();
710        Ok(handles)
711    }
712
713    /// Extract images with pre-decompression filtering.
714    ///
715    /// Applies dimension and pixel-count checks using XObject dictionary metadata
716    /// BEFORE expensive stream decompression. This avoids decompressing oversized
717    /// images (e.g., 36MP presentation slides) or tiny glyph fragments that will
718    /// be discarded downstream.
719    fn extract_images_filtered(
720        &self,
721        page_index: usize,
722        filter: &ImageExtractFilter,
723    ) -> Result<Vec<crate::extractors::PdfImage>> {
724        use crate::content::Operator;
725        use crate::content::parse_content_stream_images_only;
726
727        let page = self.get_page(page_index)?;
728        let page_dict = page.as_dict().ok_or_else(|| Error::ParseError {
729            offset: 0,
730            reason: "Page is not a dictionary".to_string(),
731        })?;
732
733        let content_data = self.get_page_content_data(page_index)?;
734
735        let resources = match page_dict.get("Resources") {
736            Some(res) => {
737                if let Some(ref_obj) = res.as_reference() {
738                    Some(self.load_object(ref_obj)?)
739                } else {
740                    Some(res.clone())
741                }
742            }
743            None => None,
744        };
745
746        // Parse content stream with image-only fast path (skips BT/ET text blocks) ~keep
747        let operators = match parse_content_stream_images_only(&content_data) {
748            Ok(ops) => ops,
749            Err(_) => {
750                return Ok(Vec::new());
751            }
752        };
753
754        let mut images = Vec::new();
755        let mut ctm_stack = vec![crate::content::Matrix::identity()];
756        // Shared cycle detection stack for Form XObject recursion.
757        // This must persist across all Do operator calls to detect circular references
758        // (e.g., Form X0 references X1 which references X0). ~keep
759        let mut xobject_stack = Vec::new();
760
761        // Pre-resolve XObject dictionary once (avoids re-resolving per Do operator) ~keep
762        let xobject_dict = if let Some(ref res) = resources {
763            if let Some(res_dict) = res.as_dict() {
764                if let Some(xobj_entry) = res_dict.get("XObject") {
765                    let resolved = if let Some(ref_obj) = xobj_entry.as_reference() {
766                        self.load_object(ref_obj)?
767                    } else {
768                        xobj_entry.clone()
769                    };
770                    resolved.as_dict().cloned()
771                } else {
772                    None
773                }
774            } else {
775                None
776            }
777        } else {
778            None
779        };
780
781        for op in operators {
782            match op {
783                Operator::SaveState => {
784                    if let Some(current_ctm) = ctm_stack.last() {
785                        ctm_stack.push(*current_ctm);
786                    }
787                }
788                Operator::RestoreState => {
789                    if ctm_stack.len() > 1 {
790                        ctm_stack.pop();
791                    }
792                }
793                Operator::Cm { a, b, c, d, e, f } => {
794                    if let Some(current_ctm) = ctm_stack.last_mut() {
795                        let matrix = crate::content::Matrix { a, b, c, d, e, f };
796                        // PDF spec ISO 32000-1:2008 §8.3.4: cm concatenates as M_cm × CTM ~keep
797                        *current_ctm = matrix.multiply(current_ctm);
798                    }
799                }
800
801                Operator::Do { name } => {
802                    if let Some(ref xobj_dict) = xobject_dict {
803                        let current_ctm = ctm_stack
804                            .last()
805                            .copied()
806                            .unwrap_or_else(crate::content::Matrix::identity);
807                        if let Ok(mut xobj_images) = self.extract_images_from_xobject_do(
808                            &name,
809                            xobj_dict,
810                            resources.as_ref(),
811                            current_ctm,
812                            &mut xobject_stack,
813                            filter,
814                        ) {
815                            images.append(&mut xobj_images);
816                        }
817                    }
818                }
819
820                Operator::InlineImage { dict, data } => {
821                    let current_ctm = ctm_stack
822                        .last()
823                        .copied()
824                        .unwrap_or_else(crate::content::Matrix::identity);
825                    if let Ok(image) = self.extract_image_from_inline(&dict, &data, current_ctm) {
826                        images.push(image);
827                    }
828                }
829
830                _ => {}
831            }
832        }
833
834        Ok(images)
835    }
836
837    /// Extract images referenced by a Do operator in the content stream.
838    ///
839    /// Accepts a pre-resolved XObject dictionary to avoid redundant lookups
840    /// when called repeatedly (e.g., 194 Do operators on a single page).
841    fn extract_images_from_xobject_do(
842        &self,
843        name: &str,
844        xobject_dict: &std::collections::HashMap<String, Object>,
845        resources: Option<&Object>,
846        ctm: crate::content::Matrix,
847        xobject_stack: &mut Vec<ObjectRef>,
848        filter: &ImageExtractFilter,
849    ) -> Result<Vec<crate::extractors::PdfImage>> {
850        use crate::extractors::extract_image_from_xobject;
851
852        let mut images = Vec::new();
853
854        let xobject_ref_obj = match xobject_dict.get(name) {
855            Some(obj) => obj,
856            None => return Ok(images),
857        };
858
859        let xobject_ref_opt = xobject_ref_obj.as_reference();
860        let xobject = if let Some(ref_obj) = xobject_ref_opt {
861            self.load_object(ref_obj)?
862        } else {
863            xobject_ref_obj.clone()
864        };
865        let xobject_dict = xobject.as_dict().ok_or_else(|| Error::ParseError {
866            offset: 0,
867            reason: "XObject is not a dictionary".to_string(),
868        })?;
869
870        let subtype = xobject_dict.get("Subtype").and_then(|s| s.as_name()).unwrap_or("");
871
872        match subtype {
873            "Image" => {
874                // Pre-decompression filtering using dictionary metadata.
875                // These checks use Width/Height/ColorSpace from the XObject dictionary
876                // which are available WITHOUT decompressing the image stream data.
877                // /Width and /Height may themselves be indirect references
878                // (ISO 32000-1 §7.3.10); resolve them the same way
879                // `extract_image_from_xobject` does, so an indirect width
880                // doesn't fall through to the `unwrap_or(0)` default and get
881                // silently filtered out by the `min_width`/`min_height` gate
882                // below. ~keep
883                let resolve_int = |o: &Object| -> Option<i64> {
884                    match o.as_reference() {
885                        Some(r) => self.load_object(r).ok().and_then(|v| v.as_integer()),
886                        None => o.as_integer(),
887                    }
888                };
889                let w = xobject_dict.get("Width").and_then(resolve_int).unwrap_or(0);
890                let h = xobject_dict.get("Height").and_then(resolve_int).unwrap_or(0);
891                if w < filter.min_width || h < filter.min_height {
892                    return Ok(images);
893                }
894                if (w as u64) * (h as u64) > filter.max_pixels {
895                    return Ok(images);
896                }
897                // Skip small Indexed colorspace images (Type3 font glyph fragments) ~keep
898                if filter.skip_indexed_small > 0
899                    && (w < filter.skip_indexed_small || h < filter.skip_indexed_small)
900                    && let Some(cs_obj) = xobject_dict.get("ColorSpace")
901                {
902                    let is_indexed = match cs_obj {
903                        Object::Name(n) => n == "Indexed",
904                        Object::Array(arr) if !arr.is_empty() => arr[0].as_name() == Some("Indexed"),
905                        _ => false,
906                    };
907                    if is_indexed {
908                        return Ok(images);
909                    }
910                }
911
912                // Only clone+modify when ColorSpace needs resolving from indirect ref ~keep
913                let needs_cs_resolve = matches!(
914                    &xobject,
915                    Object::Stream { dict, .. } if matches!(dict.get("ColorSpace"), Some(Object::Reference(_)))
916                );
917
918                let resolved_xobject;
919                let xobject_for_extract = if needs_cs_resolve {
920                    if let Object::Stream { dict, data } = &xobject {
921                        let mut new_dict = dict.clone();
922                        if let Some(Object::Reference(cs_ref)) = dict.get("ColorSpace")
923                            && let Ok(resolved_cs) = self.load_object(*cs_ref)
924                        {
925                            new_dict.insert("ColorSpace".to_string(), resolved_cs);
926                        }
927                        resolved_xobject = Object::Stream {
928                            dict: new_dict,
929                            data: data.clone(),
930                        };
931                        &resolved_xobject
932                    } else {
933                        &xobject
934                    }
935                } else {
936                    &xobject
937                };
938
939                if let Ok(mut image) =
940                    extract_image_from_xobject(Some(self), xobject_for_extract, xobject_ref_opt, None)
941                {
942                    // In PDF, images are mapped from unit square (0,0 to 1,1) to the CTM. ~keep
943                    let unit_rect = crate::geometry::Rect::new(0.0, 0.0, 1.0, 1.0);
944                    let bbox = self.transform_bbox_with_ctm(&unit_rect, ctm);
945                    image.set_bbox(bbox);
946
947                    image.set_matrix([ctm.a, ctm.b, ctm.c, ctm.d, ctm.e, ctm.f]);
948                    image.set_rotation_degrees(Self::matrix_to_rotation(ctm));
949
950                    images.push(image);
951                }
952            }
953            "Form" => {
954                if let (Some(ref_obj), Some(parent_res)) = (xobject_ref_opt, resources)
955                    && let Ok(mut form_images) =
956                        self.extract_images_from_form_xobject(ref_obj, &xobject, parent_res, ctm, xobject_stack, filter)
957                {
958                    images.append(&mut form_images);
959                }
960            }
961            _ => {}
962        }
963
964        Ok(images)
965    }
966
967    /// Recursively extract images from a Form XObject.
968    ///
969    /// Uses a document-level cache: images are extracted once using only the Form's
970    /// own Matrix, then cached. On subsequent references, cached images are cloned
971    /// and the caller's CTM is applied to transform bboxes.
972    fn extract_images_from_form_xobject(
973        &self,
974        xobject_ref: ObjectRef,
975        xobject: &Object,
976        parent_resources: &Object,
977        parent_ctm: crate::content::Matrix,
978        xobject_stack: &mut Vec<ObjectRef>,
979        filter: &ImageExtractFilter,
980    ) -> Result<Vec<crate::extractors::PdfImage>> {
981        use crate::content::Operator;
982        use crate::content::parse_content_stream_images_only;
983
984        if xobject_stack.contains(&xobject_ref) || xobject_stack.len() >= 100 {
985            return Ok(Vec::new());
986        }
987
988        // Check image result cache — images stored with Form's own Matrix only.
989        // Scope the borrow to ensure it's dropped before potential recursion. ~keep
990        {
991            if let Some(cached_images) = self.form_xobject_images_cache.lock_or_recover().get(&xobject_ref) {
992                let images = cached_images
993                    .iter()
994                    .map(|img| {
995                        let mut cloned = img.clone();
996                        if let Some(rect) = cloned.bbox() {
997                            cloned.set_bbox(self.transform_bbox_with_ctm(rect, parent_ctm));
998                        }
999                        cloned
1000                    })
1001                    .collect();
1002                return Ok(images);
1003            }
1004        }
1005
1006        xobject_stack.push(xobject_ref);
1007
1008        let xobj_dict = xobject.as_dict().ok_or_else(|| Error::ParseError {
1009            offset: 0,
1010            reason: "Form XObject is not a dictionary".to_string(),
1011        })?;
1012
1013        let form_resources = if let Some(form_res) = xobj_dict.get("Resources") {
1014            if let Some(ref_obj) = form_res.as_reference() {
1015                self.load_object(ref_obj)?
1016            } else {
1017                form_res.clone()
1018            }
1019        } else {
1020            parent_resources.clone()
1021        };
1022
1023        let form_xobject_dict = if let Some(res_dict) = form_resources.as_dict() {
1024            if let Some(xobj_entry) = res_dict.get("XObject") {
1025                let resolved = if let Some(ref_obj) = xobj_entry.as_reference() {
1026                    self.load_object(ref_obj)?
1027                } else {
1028                    xobj_entry.clone()
1029                };
1030                resolved.as_dict().cloned()
1031            } else {
1032                None
1033            }
1034        } else {
1035            None
1036        };
1037
1038        let form_matrix = if let Some(matrix_obj) = xobj_dict.get("Matrix") {
1039            self.parse_matrix_from_object(matrix_obj)
1040                .unwrap_or_else(crate::content::Matrix::identity)
1041        } else {
1042            crate::content::Matrix::identity()
1043        };
1044
1045        let cached_stream = self.xobject_stream_cache.lock_or_recover().get(&xobject_ref).cloned();
1046        let stream_data = if let Some(cached) = cached_stream {
1047            cached.as_ref().clone()
1048        } else {
1049            match self.decode_stream_with_encryption(xobject, xobject_ref) {
1050                Ok(data) => {
1051                    const MAX_STREAM_CACHE_BYTES: usize = 50 * 1024 * 1024;
1052                    let current_bytes = self.xobject_stream_cache_bytes.load(Ordering::Relaxed);
1053                    if current_bytes + data.len() <= MAX_STREAM_CACHE_BYTES {
1054                        self.xobject_stream_cache_bytes
1055                            .store(current_bytes + data.len(), Ordering::Relaxed);
1056                        self.xobject_stream_cache
1057                            .lock_or_recover()
1058                            .insert(xobject_ref, std::sync::Arc::new(data.clone()));
1059                    }
1060                    data
1061                }
1062                Err(error) => {
1063                    tracing::warn!(target: LOG_TARGET,
1064                        error_code = error.telemetry_code(),
1065                        error_offset = ?error.telemetry_offset(),
1066                        "failed to decode Form XObject stream; skipping"
1067                    );
1068                    xobject_stack.pop();
1069                    return Ok(Vec::new());
1070                }
1071            }
1072        };
1073
1074        // Parse operators using fast image-only path (skips text operators) ~keep
1075        let operators = match parse_content_stream_images_only(&stream_data) {
1076            Ok(ops) => ops,
1077            Err(_) => {
1078                xobject_stack.pop();
1079                return Ok(Vec::new());
1080            }
1081        };
1082
1083        // Extract using only the Form's own Matrix (no parent_ctm yet).
1084        // This allows caching the results and applying different parent CTMs later. ~keep
1085        let mut raw_images = Vec::new();
1086        let mut ctm_stack = vec![form_matrix];
1087
1088        for op in operators {
1089            match op {
1090                Operator::SaveState => {
1091                    if let Some(current_ctm) = ctm_stack.last() {
1092                        ctm_stack.push(*current_ctm);
1093                    }
1094                }
1095                Operator::RestoreState => {
1096                    if ctm_stack.len() > 1 {
1097                        ctm_stack.pop();
1098                    }
1099                }
1100                Operator::Cm { a, b, c, d, e, f } => {
1101                    if let Some(current_ctm) = ctm_stack.last_mut() {
1102                        let matrix = crate::content::Matrix { a, b, c, d, e, f };
1103                        // PDF spec ISO 32000-1:2008 §8.3.4: cm concatenates as M_cm × CTM ~keep
1104                        *current_ctm = matrix.multiply(current_ctm);
1105                    }
1106                }
1107
1108                Operator::Do { name } => {
1109                    if let Some(ref xobj_d) = form_xobject_dict {
1110                        let current_ctm = ctm_stack
1111                            .last()
1112                            .copied()
1113                            .unwrap_or_else(crate::content::Matrix::identity);
1114                        // For nested Do operators, pass identity as parent_ctm since
1115                        // we're building raw (un-transformed) images for caching ~keep
1116                        if let Ok(mut xobj_images) = self.extract_images_from_xobject_do(
1117                            &name,
1118                            xobj_d,
1119                            Some(&form_resources),
1120                            current_ctm,
1121                            xobject_stack,
1122                            filter,
1123                        ) {
1124                            raw_images.append(&mut xobj_images);
1125                        }
1126                    }
1127                }
1128
1129                Operator::InlineImage { dict, data } => {
1130                    let current_ctm = ctm_stack
1131                        .last()
1132                        .copied()
1133                        .unwrap_or_else(crate::content::Matrix::identity);
1134                    if let Ok(image) = self.extract_image_from_inline(&dict, &data, current_ctm) {
1135                        raw_images.push(image);
1136                    }
1137                }
1138
1139                _ => {}
1140            }
1141        }
1142
1143        xobject_stack.pop();
1144
1145        // Cache the raw images (with Form's own Matrix applied, but no parent CTM) ~keep
1146        self.form_xobject_images_cache
1147            .lock_or_recover()
1148            .insert(xobject_ref, raw_images.clone());
1149
1150        let images = raw_images
1151            .into_iter()
1152            .map(|mut img| {
1153                if let Some(rect) = img.bbox() {
1154                    img.set_bbox(self.transform_bbox_with_ctm(rect, parent_ctm));
1155                }
1156                img
1157            })
1158            .collect();
1159
1160        Ok(images)
1161    }
1162
1163    /// Extract an inline image from the content stream.
1164    fn extract_image_from_inline(
1165        &self,
1166        dict: &std::collections::HashMap<String, Object>,
1167        data: &[u8],
1168        ctm: crate::content::Matrix,
1169    ) -> Result<crate::extractors::PdfImage> {
1170        use crate::extractors::expand_inline_image_dict;
1171
1172        let expanded_dict = expand_inline_image_dict(dict.clone());
1173
1174        let stream_obj = Object::Stream {
1175            dict: expanded_dict,
1176            data: bytes::Bytes::copy_from_slice(data),
1177        };
1178
1179        let mut image = crate::extractors::extract_image_from_xobject(Some(self), &stream_obj, None, None)?;
1180
1181        // In PDF, images are mapped from unit square (0,0 to 1,1) to the CTM. ~keep
1182        let unit_rect = crate::geometry::Rect::new(0.0, 0.0, 1.0, 1.0);
1183        let bbox = self.transform_bbox_with_ctm(&unit_rect, ctm);
1184        image.set_bbox(bbox);
1185
1186        image.set_matrix([ctm.a, ctm.b, ctm.c, ctm.d, ctm.e, ctm.f]);
1187        image.set_rotation_degrees(Self::matrix_to_rotation(ctm));
1188
1189        Ok(image)
1190    }
1191
1192    /// Helper to derive rotation angle from transformation matrix.
1193    fn matrix_to_rotation(m: crate::content::Matrix) -> i32 {
1194        let angle_rad = m.b.atan2(m.a);
1195        let angle_deg = (angle_rad.to_degrees().round() as i32) % 360;
1196        if angle_deg < 0 { angle_deg + 360 } else { angle_deg }
1197    }
1198
1199    /// Transform a bounding box using CTM.
1200    ///
1201    /// Transforms all four corners and computes the axis-aligned bounding box,
1202    /// which correctly handles rotation, shear, and negative scaling.
1203    pub(super) fn transform_bbox_with_ctm(
1204        &self,
1205        rect: &crate::geometry::Rect,
1206        ctm: crate::content::Matrix,
1207    ) -> crate::geometry::Rect {
1208        let x0 = rect.x;
1209        let y0 = rect.y;
1210        let x1 = rect.x + rect.width;
1211        let y1 = rect.y + rect.height;
1212
1213        let tx0 = ctm.a * x0 + ctm.c * y0 + ctm.e;
1214        let ty0 = ctm.b * x0 + ctm.d * y0 + ctm.f;
1215
1216        let tx1 = ctm.a * x1 + ctm.c * y0 + ctm.e;
1217        let ty1 = ctm.b * x1 + ctm.d * y0 + ctm.f;
1218
1219        let tx2 = ctm.a * x0 + ctm.c * y1 + ctm.e;
1220        let ty2 = ctm.b * x0 + ctm.d * y1 + ctm.f;
1221
1222        let tx3 = ctm.a * x1 + ctm.c * y1 + ctm.e;
1223        let ty3 = ctm.b * x1 + ctm.d * y1 + ctm.f;
1224
1225        let min_x = tx0.min(tx1).min(tx2).min(tx3);
1226        let max_x = tx0.max(tx1).max(tx2).max(tx3);
1227        let min_y = ty0.min(ty1).min(ty2).min(ty3);
1228        let max_y = ty0.max(ty1).max(ty2).max(ty3);
1229
1230        crate::geometry::Rect {
1231            x: min_x,
1232            y: min_y,
1233            width: max_x - min_x,
1234            height: max_y - min_y,
1235        }
1236    }
1237
1238    /// Parse a Matrix object from PDF.
1239    pub(super) fn parse_matrix_from_object(&self, obj: &Object) -> Option<crate::content::Matrix> {
1240        if let Some(array) = obj.as_array()
1241            && array.len() >= 6
1242        {
1243            let mut values = [0.0f32; 6];
1244            for (i, val) in array.iter().take(6).enumerate() {
1245                let num = if let Some(f) = val.as_real() {
1246                    f as f32
1247                } else {
1248                    let i_val = val.as_integer()?;
1249                    i_val as f32
1250                };
1251                values[i] = num;
1252            }
1253
1254            return Some(crate::content::Matrix {
1255                a: values[0],
1256                b: values[1],
1257                c: values[2],
1258                d: values[3],
1259                e: values[4],
1260                f: values[5],
1261            });
1262        }
1263        None
1264    }
1265
1266    /// Extract images from a page and save them to files.
1267    ///
1268    /// Each image is saved as a separate file in `output_dir` with the given
1269    /// `prefix` and an incrementing index starting from `start_index`.
1270    #[cfg(not(target_arch = "wasm32"))]
1271    pub fn extract_images_to_files(
1272        &self,
1273        page_index: usize,
1274        output_dir: impl AsRef<Path>,
1275        prefix: Option<&str>,
1276        start_index: Option<usize>,
1277    ) -> Result<Vec<ExtractedImageRef>> {
1278        use std::fs;
1279
1280        let images = self.extract_images(page_index)?;
1281
1282        let output_dir = output_dir.as_ref();
1283        if !output_dir.exists() {
1284            fs::create_dir_all(output_dir).map_err(Error::Io)?;
1285        }
1286
1287        let prefix = prefix.unwrap_or("img");
1288        let mut index = start_index.unwrap_or(1);
1289        let mut result = Vec::new();
1290
1291        for image in images {
1292            let (format, extension) = match image.data() {
1293                crate::extractors::ImageData::Jpeg(_) => (ImageFormat::Jpeg, "jpg"),
1294                _ => (ImageFormat::Png, "png"),
1295            };
1296
1297            let filename = format!("{}_{:03}.{}", prefix, index, extension);
1298            let filepath = output_dir.join(&filename);
1299
1300            match format {
1301                ImageFormat::Jpeg => image.save_as_jpeg(&filepath)?,
1302                ImageFormat::Png => image.save_as_png(&filepath)?,
1303            }
1304
1305            result.push(ExtractedImageRef {
1306                filename,
1307                format,
1308                width: image.width(),
1309                height: image.height(),
1310                bbox: image.bbox().cloned(),
1311                rotation: image.rotation_degrees(),
1312                matrix: image.matrix(),
1313            });
1314
1315            index += 1;
1316        }
1317
1318        Ok(result)
1319    }
1320
1321    /// Public wrapper for `get_page` (normally private).
1322    /// Exposed for profiling examples that need to time page tree lookup separately.
1323    pub fn get_page_for_debug(&self, page_index: usize) -> Result<Object> {
1324        self.get_page(page_index)
1325    }
1326
1327    /// Public wrapper for `may_contain_text` (normally pub(crate)).
1328    /// Returns true if the content stream might contain text operators (BT or Do).
1329    pub fn may_contain_text_public(data: &[u8]) -> bool {
1330        Self::may_contain_text(data)
1331    }
1332}