Skip to main content

xberg_native_pdf/document/
paths.rs

1//! Vector path extraction and optional-content layer resolution.
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    /// Extract path (vector graphics) content from a page.
11    ///
12    /// This extracts all vector graphics operations from the page's content stream,
13    /// including lines, curves, rectangles, and shapes.
14    ///
15    /// # Arguments
16    ///
17    /// * `page_index` - Zero-based page index
18    ///
19    /// # Returns
20    ///
21    /// A vector of `PathContent` objects representing all paths on the page.
22    ///
23    /// # Examples
24    ///
25    /// ```no_run
26    /// # use xberg_native_pdf::document::PdfDocument;
27    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
28    /// let mut doc = PdfDocument::open("example.pdf")?;
29    ///
30    /// // Extract paths from first page
31    /// let paths = doc.extract_paths(0)?;
32    ///
33    /// for path in paths {
34    ///     println!("Path with {} operations, bbox: {:?}",
35    ///         path.operations.len(), path.bbox);
36    ///     if path.has_stroke() {
37    ///         println!(" Stroked with width: {}", path.stroke_width);
38    ///     }
39    ///     if path.has_fill() {
40    ///         println!(" Filled");
41    ///     }
42    /// }
43    /// # Ok(())
44    /// # }
45    /// ```
46    pub fn extract_paths(&self, page_index: usize) -> Result<Vec<crate::elements::PathContent>> {
47        use crate::content::{Operator, parse_content_stream_paths_only};
48        use crate::elements::{LineCap, LineJoin};
49        use crate::extractors::paths::{FillRule, PathExtractor, PathGraphicsStateStack};
50        use crate::layout::Color;
51
52        let page = self.get_page(page_index)?;
53        let page_dict = page.as_dict().ok_or_else(|| Error::ParseError {
54            offset: 0,
55            reason: "Page is not a dictionary".to_string(),
56        })?;
57
58        // Get content stream data — skip page on decode failure (Annex I) ~keep
59        let content_data = match self.get_page_content_data(page_index) {
60            Ok(data) => data,
61            Err(error) => {
62                tracing::warn!(
63                    target: crate::LOG_TARGET_ROOT,
64                    operation = "decode_page_path_content",
65                    page_index,
66                    error_code = error.telemetry_code(),
67                    error_offset = ?error.telemetry_offset(),
68                    "returning empty paths after decode failure"
69                );
70                return Ok(Vec::new());
71            }
72        };
73
74        let operators = match parse_content_stream_paths_only(&content_data) {
75            Ok(ops) => ops,
76            Err(error) => {
77                tracing::warn!(
78                    target: crate::LOG_TARGET_ROOT,
79                    operation = "parse_page_paths",
80                    page_index,
81                    error_code = error.telemetry_code(),
82                    error_offset = ?error.telemetry_offset(),
83                    "returning empty paths after parse failure"
84                );
85                return Ok(Vec::new());
86            }
87        };
88
89        let mut extractor = PathExtractor::new();
90        let mut state_stack = PathGraphicsStateStack::new();
91
92        if let Some(resources) = page_dict.get("Resources") {
93            let resolved_resources = if let Some(ref_obj) = resources.as_reference() {
94                self.load_object(ref_obj)?
95            } else {
96                resources.clone()
97            };
98            extractor.set_resources(resolved_resources);
99        }
100
101        for op in operators {
102            match op {
103                Operator::SaveState => {
104                    state_stack.save();
105                }
106                Operator::RestoreState => {
107                    state_stack.restore();
108                    extractor.update_from_path_state(state_stack.current());
109                }
110                Operator::Cm { a, b, c, d, e, f } => {
111                    let state = state_stack.current_mut();
112                    let new_matrix = crate::content::Matrix { a, b, c, d, e, f };
113                    // PDF spec ISO 32000-1:2008 §8.3.4: cm concatenates as M_cm × CTM ~keep
114                    state.ctm = new_matrix.multiply(&state.ctm);
115                    extractor.set_ctm(state.ctm);
116                }
117
118                Operator::SetStrokeRgb { r, g, b } => {
119                    state_stack.current_mut().stroke_color_rgb = (r, g, b);
120                    extractor.set_stroke_color(Color::new(r, g, b));
121                }
122                Operator::SetStrokeGray { gray } => {
123                    state_stack.current_mut().stroke_color_rgb = (gray, gray, gray);
124                    extractor.set_stroke_color(Color::new(gray, gray, gray));
125                }
126                Operator::SetStrokeCmyk { c, m, y, k } => {
127                    let (r, g, b) = crate::color::cmyk_to_rgb(c, m, y, k);
128                    state_stack.current_mut().stroke_color_rgb = (r, g, b);
129                    extractor.set_stroke_color(Color::new(r, g, b));
130                }
131
132                Operator::SetFillRgb { r, g, b } => {
133                    state_stack.current_mut().fill_color_rgb = (r, g, b);
134                    extractor.set_fill_color(Color::new(r, g, b));
135                }
136                Operator::SetFillGray { gray } => {
137                    state_stack.current_mut().fill_color_rgb = (gray, gray, gray);
138                    extractor.set_fill_color(Color::new(gray, gray, gray));
139                }
140                Operator::SetFillCmyk { c, m, y, k } => {
141                    let (r, g, b) = crate::color::cmyk_to_rgb(c, m, y, k);
142                    state_stack.current_mut().fill_color_rgb = (r, g, b);
143                    extractor.set_fill_color(Color::new(r, g, b));
144                }
145
146                Operator::SetLineWidth { width } => {
147                    state_stack.current_mut().line_width = width;
148                    extractor.set_line_width(width);
149                }
150                Operator::SetLineCap { cap_style } => {
151                    state_stack.current_mut().line_cap = cap_style;
152                    let cap = match cap_style {
153                        1 => LineCap::Round,
154                        2 => LineCap::Square,
155                        _ => LineCap::Butt,
156                    };
157                    extractor.set_line_cap(cap);
158                }
159                Operator::SetLineJoin { join_style } => {
160                    state_stack.current_mut().line_join = join_style;
161                    let join = match join_style {
162                        1 => LineJoin::Round,
163                        2 => LineJoin::Bevel,
164                        _ => LineJoin::Miter,
165                    };
166                    extractor.set_line_join(join);
167                }
168
169                Operator::MoveTo { x, y } => {
170                    extractor.move_to(x, y);
171                }
172                Operator::LineTo { x, y } => {
173                    extractor.line_to(x, y);
174                }
175                Operator::CurveTo { x1, y1, x2, y2, x3, y3 } => {
176                    extractor.curve_to(x1, y1, x2, y2, x3, y3);
177                }
178                Operator::CurveToV { x2, y2, x3, y3 } => {
179                    extractor.curve_to_v(x2, y2, x3, y3);
180                }
181                Operator::CurveToY { x1, y1, x3, y3 } => {
182                    extractor.curve_to_y(x1, y1, x3, y3);
183                }
184                Operator::Rectangle { x, y, width, height } => {
185                    extractor.rectangle(x, y, width, height);
186                }
187                Operator::ClosePath => {
188                    extractor.close_path();
189                }
190
191                Operator::Stroke => {
192                    extractor.stroke();
193                }
194                Operator::Fill => {
195                    extractor.fill(FillRule::NonZero);
196                }
197                Operator::FillEvenOdd => {
198                    extractor.fill(FillRule::EvenOdd);
199                }
200                Operator::CloseStroke => {
201                    extractor.close_and_stroke();
202                }
203                Operator::CloseFillStroke => {
204                    extractor.close_fill_and_stroke(FillRule::NonZero);
205                }
206                Operator::FillStroke => {
207                    extractor.fill_and_stroke(FillRule::NonZero);
208                }
209                Operator::FillStrokeEvenOdd => {
210                    extractor.fill_and_stroke(FillRule::EvenOdd);
211                }
212                Operator::CloseFillStrokeEvenOdd => {
213                    extractor.close_fill_and_stroke(FillRule::EvenOdd);
214                }
215                Operator::EndPath => {
216                    extractor.end_path();
217                }
218
219                Operator::ClipNonZero => {
220                    extractor.clip_non_zero();
221                }
222                Operator::ClipEvenOdd => {
223                    extractor.clip_even_odd();
224                }
225
226                Operator::Do { name } => {
227                    if let Err(error) = self.process_form_xobject_paths(&name, &mut extractor, &mut state_stack) {
228                        tracing::warn!(target: LOG_TARGET,
229                            error_code = error.telemetry_code(),
230                            error_offset = ?error.telemetry_offset(),
231                            "failed to process XObject in path extraction"
232                        );
233                    }
234                }
235
236                // Marked content operators — maintain the active Optional
237                // Content Group (PDF "layer") so each finalized path gets
238                // tagged with the OCG it was emitted under. Per ISO 32000-1
239                // §14.6, every `BDC`/`BMC` must be balanced by an `EMC`,
240                // so we always push (with `None` for non-`/OC` tags) and
241                // always pop — keeps the stack depth in sync with the
242                // marked-content nesting. ~keep
243                Operator::BeginMarkedContent { .. } => {
244                    extractor.push_oc_layer(None);
245                }
246                Operator::BeginMarkedContentDict { tag, properties } => {
247                    let layer = if tag == "OC" {
248                        self.resolve_oc_layer_name(extractor.current_resources(), &properties)
249                    } else {
250                        None
251                    };
252                    extractor.push_oc_layer(layer);
253                }
254                Operator::EndMarkedContent => {
255                    extractor.pop_oc_layer();
256                }
257
258                _ => {}
259            }
260        }
261
262        Ok(extractor.finish())
263    }
264
265    /// Resolve a `BDC /OC <properties>` property operand to the human-readable
266    /// layer name of the Optional Content it refers to (PDF spec
267    /// ISO 32000-1:2008 §8.11, §14.6).
268    ///
269    /// `properties` is the operand parsed by `Operator::BeginMarkedContentDict`
270    /// — per spec it is either:
271    ///
272    /// 1. An inline dictionary: an OCG (or OCMD) — read its name directly.
273    /// 2. A name (e.g. `/MC0`) that references `<resources> /Properties
274    ///    <name>` → an OCG or OCMD dictionary → read its name.
275    ///
276    /// `resources` is the resource dictionary currently in scope: the page
277    /// `/Resources` at page level, or the active Form XObject's own
278    /// `/Resources` when extracting inside an XObject (§14.6.2, §8.10.1).
279    ///
280    /// Returns `None` for malformed PDFs, missing `/Resources /Properties`
281    /// entries, or optional-content objects without a resolvable name.
282    /// Callers treat `None` as "path belongs to no named layer" — extraction
283    /// continues normally.
284    pub(super) fn resolve_oc_layer_name(
285        &self,
286        resources: Option<&crate::object::Object>,
287        properties: &crate::object::Object,
288    ) -> Option<String> {
289        const OC_NAME_MAX_DEPTH: u8 = 8;
290
291        if let Some(dict) = properties.as_dict() {
292            return self.read_oc_name(dict, OC_NAME_MAX_DEPTH);
293        }
294
295        let prop_name = properties.as_name()?;
296        let resources_obj = self.deref_object(resources?)?;
297        let properties_dict = resources_obj.as_dict()?.get("Properties")?;
298        let properties_obj = self.deref_object(properties_dict)?;
299        let target = properties_obj.as_dict()?.get(prop_name)?;
300        let target_obj = self.deref_object(target)?;
301        self.read_oc_name(target_obj.as_dict()?, OC_NAME_MAX_DEPTH)
302    }
303
304    /// Read the human-readable layer name from an Optional Content dictionary.
305    ///
306    /// - An **OCG** (§8.11.2.1) carries its label in `/Name` — a PDF *text
307    ///   string*, decoded via [`Self::decode_pdf_text_string`] so
308    ///   PDFDocEncoding (Annex D) and UTF-16 (BE/LE, with BOM) layer names
309    ///   round-trip identically to the rest of the library.
310    /// - An **OCMD** (§8.11.3.2, Table 99) has no `/Name` of its own; its
311    ///   member OCGs live in `/OCGs`, which is *either* a single OCG *or* an
312    ///   array of them (array entries may be `null`). We follow the first
313    ///   entry that resolves to a dictionary and read its name.
314    ///
315    /// `depth` bounds the `/OCGs` chain so a malformed PDF whose membership
316    /// dictionary points back to another OCMD cannot recurse forever.
317    /// Returns `None` for missing / non-dictionary / nameless inputs — the
318    /// path is simply left unlabelled.
319    pub(super) fn read_oc_name(
320        &self,
321        dict: &std::collections::HashMap<String, crate::object::Object>,
322        depth: u8,
323    ) -> Option<String> {
324        use crate::object::Object;
325
326        if depth == 0 {
327            return None;
328        }
329
330        if matches!(dict.get("Type").and_then(|t| t.as_name()), Some("OCMD")) {
331            let ocgs = self.deref_object(dict.get("OCGs")?)?;
332            let first_ocg = match ocgs.as_array() {
333                Some(entries) => entries
334                    .iter()
335                    .find_map(|e| self.deref_object(e).filter(|o| o.as_dict().is_some())),
336                None => Some(ocgs.clone()),
337            };
338            return self.read_oc_name(first_ocg?.as_dict()?, depth - 1);
339        }
340
341        match dict.get("Name")? {
342            Object::String(bytes) => Some(Self::decode_pdf_text_string(bytes)),
343            // Tolerate a /Name written as a PDF name object (non-conformant,
344            // but seen in real exports). ~keep
345            Object::Name(s) => Some(s.clone()),
346            _ => None,
347        }
348    }
349
350    /// Dereference one level of indirection, loading the target object;
351    /// pass direct objects through unchanged. `None` if a reference fails to
352    /// load — callers treat that as "unresolvable, leave unlabelled".
353    fn deref_object(&self, obj: &crate::object::Object) -> Option<crate::object::Object> {
354        match obj.as_reference() {
355            Some(r) => self.load_object(r).ok(),
356            None => Some(obj.clone()),
357        }
358    }
359
360    /// Extract rectangles from a page.
361    ///
362    /// Identifies paths that form axis-aligned rectangles.
363    pub fn extract_rects(&self, page_index: usize) -> Result<Vec<crate::elements::PathContent>> {
364        let paths = self.extract_paths(page_index)?;
365        Ok(paths.into_iter().filter(|p| p.is_rectangle()).collect())
366    }
367
368    /// Extract straight lines from a page.
369    ///
370    /// Identifies paths that form a single straight line segment.
371    pub fn extract_lines(&self, page_index: usize) -> Result<Vec<crate::elements::PathContent>> {
372        let paths = self.extract_paths(page_index)?;
373        Ok(paths.into_iter().filter(|p| p.is_straight_line()).collect())
374    }
375
376    /// Process paths from a Form XObject.
377    ///
378    /// This method recursively extracts paths from Form XObjects encountered via the `Do` operator.
379    /// It handles:
380    /// - XObject resolution from resources
381    /// - Type checking (Form vs Image)
382    /// - Stream decoding and operator parsing
383    /// - Coordinate transformations via /Matrix
384    /// - Graphics state isolation
385    ///
386    /// # Arguments
387    ///
388    /// * `name` - The XObject name from the `Do` operator
389    /// * `extractor` - The path extractor to accumulate paths
390    /// * `state_stack` - The graphics state stack for transformations
391    fn process_form_xobject_paths(
392        &self,
393        name: &str,
394        extractor: &mut crate::extractors::paths::PathExtractor,
395        state_stack: &mut crate::extractors::paths::PathGraphicsStateStack,
396    ) -> Result<()> {
397        use crate::content::{Matrix, Operator, parse_content_stream_paths_only};
398        use crate::elements::{LineCap, LineJoin};
399        use crate::extractors::paths::FillRule;
400        use crate::layout::Color;
401
402        let xobject_ref = match extractor.resolve_xobject_ref(name, |ref_obj| self.load_object(ref_obj)) {
403            Some(r) => r,
404            None => return Ok(()),
405        };
406
407        if !extractor.can_process_xobject(xobject_ref) {
408            return Ok(());
409        }
410        extractor.push_xobject(xobject_ref);
411
412        let xobject = match self.load_object(xobject_ref) {
413            Ok(obj) => obj,
414            Err(e) => {
415                extractor.pop_xobject_failed();
416                return Err(e);
417            }
418        };
419        let xobject_dict = match xobject.as_dict() {
420            Some(dict) => dict,
421            None => {
422                extractor.pop_xobject_failed();
423                return Err(Error::ParseError {
424                    offset: 0,
425                    reason: "XObject is not a dictionary".to_string(),
426                });
427            }
428        };
429
430        match xobject_dict.get("Subtype") {
431            Some(subtype_obj) => {
432                if let Some(subtype_name) = subtype_obj.as_name() {
433                    if subtype_name != "Form" {
434                        extractor.pop_xobject();
435                        return Ok(());
436                    }
437                } else {
438                    extractor.pop_xobject();
439                    return Ok(());
440                }
441            }
442            None => {
443                extractor.pop_xobject();
444                return Ok(());
445            }
446        }
447
448        // Decode stream — reuse document-level cache shared with text extraction. ~keep
449        let cached_stream = { self.xobject_stream_cache.lock_or_recover().get(&xobject_ref).cloned() };
450        let stream_data = if let Some(cached) = cached_stream {
451            cached.as_ref().clone()
452        } else {
453            match self.decode_stream_with_encryption(&xobject, xobject_ref) {
454                Ok(data) => {
455                    const MAX_STREAM_CACHE_BYTES: usize = 50 * 1024 * 1024;
456                    let current = self.xobject_stream_cache_bytes.load(Ordering::Relaxed);
457                    if current + data.len() <= MAX_STREAM_CACHE_BYTES {
458                        self.xobject_stream_cache_bytes
459                            .store(current + data.len(), Ordering::Relaxed);
460                        self.xobject_stream_cache
461                            .lock_or_recover()
462                            .insert(xobject_ref, std::sync::Arc::new(data.clone()));
463                    }
464                    data
465                }
466                Err(e) => {
467                    extractor.pop_xobject_failed();
468                    return Err(e);
469                }
470            }
471        };
472
473        let operators = match parse_content_stream_paths_only(&stream_data) {
474            Ok(ops) => ops,
475            Err(e) => {
476                extractor.pop_xobject_failed();
477                return Err(e);
478            }
479        };
480
481        let matrix = if let Some(matrix_obj) = xobject_dict.get("Matrix") {
482            if let Some(array) = matrix_obj.as_array() {
483                if array.len() >= 6 {
484                    let mut matrix = Matrix::identity();
485                    let mut values = [0.0f32; 6];
486                    let mut valid = true;
487
488                    for (i, val) in array.iter().take(6).enumerate() {
489                        let num = if let Some(f) = val.as_real() {
490                            f as f32
491                        } else if let Some(i_val) = val.as_integer() {
492                            i_val as f32
493                        } else {
494                            valid = false;
495                            break;
496                        };
497                        values[i] = num;
498                    }
499
500                    if valid {
501                        matrix.a = values[0];
502                        matrix.b = values[1];
503                        matrix.c = values[2];
504                        matrix.d = values[3];
505                        matrix.e = values[4];
506                        matrix.f = values[5];
507                        matrix
508                    } else {
509                        Matrix::identity()
510                    }
511                } else {
512                    Matrix::identity()
513                }
514            } else {
515                Matrix::identity()
516            }
517        } else {
518            Matrix::identity()
519        };
520
521        state_stack.save();
522
523        if extractor.has_current_path() {
524            extractor.end_path();
525        }
526
527        // Apply XObject transformation to CTM
528        // PDF spec ISO 32000-1:2008 §8.10.1: Form XObject Matrix concatenates as M × CTM ~keep
529        let state = state_stack.current_mut();
530        state.ctm = matrix.multiply(&state.ctm);
531        extractor.set_ctm(state.ctm);
532
533        // Switch resource scope to this Form XObject's own /Resources, if any.
534        // Form XObjects with their own Resources define a fresh XObject name
535        // scope (ISO 32000-1 §8.10.1). Looking up nested `Do` names against the
536        // parent scope can pick up unrelated sibling forms with colliding
537        // names, which turns sibling Form XObjects into a cross-recursive tree
538        // (O(N!) traversals and unbounded path accumulation). ~keep
539        let saved_scope = if let Some(xobj_resources) = xobject_dict.get("Resources") {
540            let resolved = if let Some(res_ref) = xobj_resources.as_reference() {
541                self.load_object(res_ref).unwrap_or_else(|_| xobj_resources.clone())
542            } else {
543                xobj_resources.clone()
544            };
545            Some(extractor.swap_resources(Some(resolved)))
546        } else {
547            None
548        };
549
550        // Remember the marked-content nesting depth on entry so we can drop
551        // anything this XObject leaves unbalanced (see truncate below). ~keep
552        let oc_base_depth = extractor.oc_layer_depth();
553
554        for op in operators {
555            match op {
556                Operator::SaveState => {
557                    state_stack.save();
558                }
559                Operator::RestoreState => {
560                    state_stack.restore();
561                    extractor.update_from_path_state(state_stack.current());
562                }
563                Operator::Cm { a, b, c, d, e, f } => {
564                    let state = state_stack.current_mut();
565                    let new_matrix = Matrix { a, b, c, d, e, f };
566                    // PDF spec ISO 32000-1:2008 §8.3.4: cm concatenates as M_cm × CTM ~keep
567                    state.ctm = new_matrix.multiply(&state.ctm);
568                    extractor.set_ctm(state.ctm);
569                }
570
571                // Color and line style operators — must update both state_stack
572                // and extractor so q/Q save/restore works correctly. ~keep
573                Operator::SetStrokeRgb { r, g, b } => {
574                    state_stack.current_mut().stroke_color_rgb = (r, g, b);
575                    extractor.set_stroke_color(Color::new(r, g, b));
576                }
577                Operator::SetStrokeGray { gray } => {
578                    state_stack.current_mut().stroke_color_rgb = (gray, gray, gray);
579                    extractor.set_stroke_color(Color::new(gray, gray, gray));
580                }
581                Operator::SetStrokeCmyk { c, m, y, k } => {
582                    let (r, g, b) = crate::color::cmyk_to_rgb(c, m, y, k);
583                    state_stack.current_mut().stroke_color_rgb = (r, g, b);
584                    extractor.set_stroke_color(Color::new(r, g, b));
585                }
586                Operator::SetFillRgb { r, g, b } => {
587                    state_stack.current_mut().fill_color_rgb = (r, g, b);
588                    extractor.set_fill_color(Color::new(r, g, b));
589                }
590                Operator::SetFillGray { gray } => {
591                    state_stack.current_mut().fill_color_rgb = (gray, gray, gray);
592                    extractor.set_fill_color(Color::new(gray, gray, gray));
593                }
594                Operator::SetFillCmyk { c, m, y, k } => {
595                    let (r, g, b) = crate::color::cmyk_to_rgb(c, m, y, k);
596                    state_stack.current_mut().fill_color_rgb = (r, g, b);
597                    extractor.set_fill_color(Color::new(r, g, b));
598                }
599                Operator::SetLineWidth { width } => {
600                    state_stack.current_mut().line_width = width;
601                    extractor.set_line_width(width);
602                }
603                Operator::SetLineCap { cap_style } => {
604                    state_stack.current_mut().line_cap = cap_style;
605                    let cap = match cap_style {
606                        1 => LineCap::Round,
607                        2 => LineCap::Square,
608                        _ => LineCap::Butt,
609                    };
610                    extractor.set_line_cap(cap);
611                }
612                Operator::SetLineJoin { join_style } => {
613                    state_stack.current_mut().line_join = join_style;
614                    let join = match join_style {
615                        1 => LineJoin::Round,
616                        2 => LineJoin::Bevel,
617                        _ => LineJoin::Miter,
618                    };
619                    extractor.set_line_join(join);
620                }
621
622                Operator::MoveTo { x, y } => extractor.move_to(x, y),
623                Operator::LineTo { x, y } => extractor.line_to(x, y),
624                Operator::CurveTo { x1, y1, x2, y2, x3, y3 } => {
625                    extractor.curve_to(x1, y1, x2, y2, x3, y3);
626                }
627                Operator::CurveToV { x2, y2, x3, y3 } => {
628                    extractor.curve_to_v(x2, y2, x3, y3);
629                }
630                Operator::CurveToY { x1, y1, x3, y3 } => {
631                    extractor.curve_to_y(x1, y1, x3, y3);
632                }
633                Operator::Rectangle { x, y, width, height } => {
634                    extractor.rectangle(x, y, width, height);
635                }
636                Operator::ClosePath => extractor.close_path(),
637
638                Operator::Stroke => extractor.stroke(),
639                Operator::CloseStroke => extractor.close_and_stroke(),
640                Operator::Fill => extractor.fill(FillRule::NonZero),
641                Operator::FillEvenOdd => extractor.fill(FillRule::EvenOdd),
642                Operator::CloseFillStroke => extractor.close_fill_and_stroke(FillRule::NonZero),
643                Operator::FillStroke => extractor.fill_and_stroke(FillRule::NonZero),
644                Operator::FillStrokeEvenOdd => extractor.fill_and_stroke(FillRule::EvenOdd),
645                Operator::CloseFillStrokeEvenOdd => {
646                    extractor.close_fill_and_stroke(FillRule::EvenOdd);
647                }
648                Operator::EndPath => extractor.end_path(),
649
650                Operator::ClipNonZero => extractor.clip_non_zero(),
651                Operator::ClipEvenOdd => extractor.clip_even_odd(),
652
653                Operator::Do { name: nested_name } => {
654                    if let Err(error) = self.process_form_xobject_paths(&nested_name, extractor, state_stack) {
655                        tracing::warn!(target: LOG_TARGET,
656                            error_code = error.telemetry_code(),
657                            error_offset = ?error.telemetry_offset(),
658                            "failed to process nested XObject"
659                        );
660                    }
661                }
662
663                // Marked content — same Optional Content Group ("layer")
664                // tracking as the page-level loop, but `/OC` property
665                // references resolve against *this* XObject's resource scope
666                // (swapped in above), per §14.6.2 + §8.10.1. CAD exports that
667                // reuse Form XObjects for repeated symbols (gridline labels,
668                // callouts) carry their `/OC` markers and local `/Properties`
669                // here rather than on the page. ~keep
670                Operator::BeginMarkedContent { .. } => {
671                    extractor.push_oc_layer(None);
672                }
673                Operator::BeginMarkedContentDict { tag, properties } => {
674                    let layer = if tag == "OC" {
675                        self.resolve_oc_layer_name(extractor.current_resources(), &properties)
676                    } else {
677                        None
678                    };
679                    extractor.push_oc_layer(layer);
680                }
681                Operator::EndMarkedContent => {
682                    extractor.pop_oc_layer();
683                }
684
685                _ => {}
686            }
687        }
688
689        if extractor.has_current_path() {
690            extractor.end_path();
691        }
692
693        extractor.truncate_oc_layers(oc_base_depth);
694
695        if let Some(saved) = saved_scope {
696            extractor.restore_resources(saved);
697        }
698
699        state_stack.restore();
700        extractor.update_from_path_state(state_stack.current());
701
702        extractor.pop_xobject();
703
704        Ok(())
705    }
706
707    /// Extract paths from a specific rectangular region of a page.
708    ///
709    /// Only paths whose bounding box intersects the specified region are returned.
710    ///
711    /// # Arguments
712    ///
713    /// * `page_index` - Zero-based page index
714    /// * `region` - The rectangular region to extract from
715    ///
716    /// # Returns
717    ///
718    /// A vector of `PathContent` objects within the specified region.
719    ///
720    /// # Examples
721    ///
722    /// ```no_run
723    /// # use xberg_native_pdf::document::PdfDocument;
724    /// # use xberg_native_pdf::geometry::Rect;
725    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
726    /// let mut doc = PdfDocument::open("example.pdf")?;
727    ///
728    /// // Extract paths from a specific region (e.g., header area)
729    /// let header_region = Rect::new(0.0, 700.0, 612.0, 92.0);
730    /// let paths = doc.extract_paths_in_rect(0, header_region)?;
731    ///
732    /// println!("Found {} paths in header region", paths.len());
733    /// # Ok(())
734    /// # }
735    /// ```
736    pub fn extract_paths_in_rect(
737        &self,
738        page_index: usize,
739        region: crate::geometry::Rect,
740    ) -> Result<Vec<crate::elements::PathContent>> {
741        let paths = self.extract_paths(page_index)?;
742
743        // Filter paths by region intersection against RENDERED extents: a
744        // region query answers "what does the reader see here", so a rule
745        // whose drawn bar crosses the region must match even when its
746        // geometric bbox is a distant speck. Identical to the
747        // geometric test for unstroked paths. ~keep
748        Ok(paths
749            .into_iter()
750            .filter(|path| path.rendered_bbox().intersects(&region))
751            .collect())
752    }
753}