Skip to main content

xberg_native_pdf/document/
extract_api.rs

1//! Public per-page extraction surfaces for spans, chars, words, and lines.
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    /// Internal helper: extract raw (unsorted) text spans from a page.
11    ///
12    /// This is the common extraction logic shared by `extract_spans`
13    /// `extract_spans_with_reading_order`. Spans are returned without any
14    /// sorting or erase-region filtering applied.
15    pub(super) fn extract_spans_raw(&self, page_index: usize) -> Result<Vec<crate::layout::TextSpan>> {
16        self.extract_spans_raw_with_extraction_config(page_index, crate::extractors::TextExtractionConfig::default())
17    }
18
19    /// Internal helper: extract raw text spans using a specific extraction config.
20    ///
21    /// This allows callers to provide a [`TextExtractionConfig`] (optionally
22    /// configured with an [`ExtractionProfile`]) to control TJ offset thresholds
23    /// and word boundary detection during span extraction.
24    fn extract_spans_raw_with_extraction_config(
25        &self,
26        page_index: usize,
27        config: crate::extractors::TextExtractionConfig,
28    ) -> Result<Vec<crate::layout::TextSpan>> {
29        self.extract_spans_impl(page_index, config, HashSet::new(), HashSet::new())
30    }
31
32    pub(super) fn extract_spans_raw_filtered(
33        &self,
34        page_index: usize,
35        excluded_layers: HashSet<String>,
36        excluded_inks: HashSet<String>,
37    ) -> Result<Vec<crate::layout::TextSpan>> {
38        self.extract_spans_impl(
39            page_index,
40            crate::extractors::TextExtractionConfig::default(),
41            excluded_layers,
42            excluded_inks,
43        )
44    }
45
46    fn extract_spans_impl(
47        &self,
48        page_index: usize,
49        config: crate::extractors::TextExtractionConfig,
50        excluded_layers: HashSet<String>,
51        excluded_inks: HashSet<String>,
52    ) -> Result<Vec<crate::layout::TextSpan>> {
53        if self.is_encrypted_unreadable() {
54            tracing::warn!(target: LOG_TARGET, "PDF is encrypted and could not be decrypted; returning no spans");
55            return Ok(Vec::new());
56        }
57        use crate::extractors::TextExtractor;
58
59        let page = self.get_page(page_index)?;
60        let page_dict = page.as_dict().ok_or_else(|| Error::ParseError {
61            offset: 0,
62            reason: "Page is not a dictionary".to_string(),
63        })?;
64
65        if self.page_cannot_have_text(page_dict) {
66            // A whole page discarded with no log line is why GH#1754 needed a downstream
67            // consumer to find it. Both this guard and `may_contain_text` below drop every
68            // span on the page, so both say which page and why. ~keep
69            tracing::debug!(
70                target: LOG_TARGET,
71                page_index,
72                reason = "no /Font resources and no Form XObjects",
73                "skipping page text extraction"
74            );
75            return Ok(Vec::new());
76        }
77
78        let content_data = match self.get_page_content_data(page_index) {
79            Ok(data) => data,
80            Err(error) => {
81                tracing::warn!(
82                    target: crate::LOG_TARGET_ROOT,
83                    operation = "decode_page_content",
84                    page_index,
85                    error_code = error.telemetry_code(),
86                    error_offset = ?error.telemetry_offset(),
87                    "returning empty page content after decode failure"
88                );
89                return Ok(Vec::new());
90            }
91        };
92
93        if !Self::may_contain_text(&content_data) {
94            tracing::debug!(
95                target: LOG_TARGET,
96                page_index,
97                content_bytes = content_data.len(),
98                reason = "content stream has no BT and no Do operator",
99                "skipping page text extraction"
100            );
101            return Ok(Vec::new());
102        }
103
104        let mut extractor = TextExtractor::with_config(config);
105        // Stamp the page index so spans carry McidScope::Page(page_index)
106        // by default; Form XObject Do invocations push their own scope
107        // on top of the stack inside the extractor. ~keep ~keep
108        extractor.set_page_index(page_index as u32);
109        if !excluded_layers.is_empty() {
110            extractor.set_excluded_layers(excluded_layers);
111        }
112        if !excluded_inks.is_empty() {
113            extractor.set_excluded_inks(excluded_inks);
114        }
115        if let Some(resources) = page_dict.get("Resources") {
116            extractor.set_resources(resources.clone());
117            extractor.set_document(self);
118            if let Err(error) = self.load_fonts(resources, &mut extractor) {
119                tracing::warn!(
120                    target: crate::LOG_TARGET_ROOT,
121                    operation = "load_page_fonts",
122                    page_index,
123                    error_code = error.telemetry_code(),
124                    error_offset = ?error.telemetry_offset(),
125                    "using fallback font encoding"
126                );
127            }
128        }
129
130        let spans = extractor.extract_text_spans(&content_data)?;
131        // Drain MCIDs whose in-stream /ActualText was applied during
132        // extraction and stash on the document so the struct-tree-
133        // scope applier honours MC-scope-wins precedence (§14.9.4).
134        //
135        // The per-page entry is REPLACED, not extended: every
136        // `extract_spans_impl` call is a self-contained per-page
137        // extraction and its own MC-scope detections must be
138        // authoritative. Accumulating would make stale results from
139        // an earlier filter-set leak into a later, differently-
140        // filtered call. ~keep ~keep
141        let mc_set = extractor.take_mc_actualtext_mcids();
142        let mut guard = self.mc_actualtext_mcids.lock_or_recover();
143        if mc_set.is_empty() {
144            guard.remove(&page_index);
145        } else {
146            guard.insert(page_index, mc_set);
147        }
148        Ok(spans)
149    }
150
151    /// Extract text from a page, excluding content from specified layers and inks.
152    ///
153    /// Uses the same full text assembly pipeline as [`extract_text`](Self::extract_text)
154    /// (structure-tree ordering, table detection, column detection), but with
155    /// layer/ink-excluded spans removed before assembly.
156    ///
157    /// **Ink filtering note:** For DeviceN color spaces, text is suppressed if
158    /// ANY ink in the DeviceN array matches an excluded ink name. Tint values
159    /// are not evaluated — this is an all-or-nothing match.
160    ///
161    /// # Arguments
162    ///
163    /// * `page_index` - Zero-based page index
164    /// * `excluded_layers` - OCG layer names to suppress (empty = no layer filtering)
165    /// * `excluded_inks` - Separation/DeviceN ink names to suppress (empty = no ink filtering)
166    pub fn extract_text_filtered(
167        &self,
168        page_index: usize,
169        excluded_layers: HashSet<String>,
170        excluded_inks: HashSet<String>,
171    ) -> Result<String> {
172        if excluded_layers.is_empty() && excluded_inks.is_empty() {
173            return self.extract_text(page_index);
174        }
175
176        let spans = self.extract_spans_filtered(page_index, excluded_layers, excluded_inks)?;
177        let options = crate::converters::ConversionOptions {
178            extract_tables: true,
179            ..Default::default()
180        };
181        self.assemble_text_from_spans(page_index, spans, &options)
182    }
183
184    /// Extract text from a region of a page with layer/ink filtering applied.
185    ///
186    /// Composes [`Self::extract_text_filtered`] with [`Self::extract_text_in_rect`]: spans
187    /// are filtered by layer/ink first, then by region, then assembled via
188    /// the full text pipeline (structure-tree ordering, table detection,
189    /// column detection, whitespace + line breaks).
190    pub fn extract_text_filtered_in_rect(
191        &self,
192        page_index: usize,
193        excluded_layers: HashSet<String>,
194        excluded_inks: HashSet<String>,
195        region: crate::geometry::Rect,
196        mode: crate::layout::RectFilterMode,
197    ) -> Result<String> {
198        let spans = if excluded_layers.is_empty() && excluded_inks.is_empty() {
199            self.extract_spans(page_index)?
200        } else {
201            self.extract_spans_filtered(page_index, excluded_layers, excluded_inks)?
202        };
203        let options = crate::converters::ConversionOptions {
204            extract_tables: true,
205            include_region: Some((region, mode)),
206            ..Default::default()
207        };
208        self.assemble_text_from_spans(page_index, spans, &options)
209    }
210
211    /// Geometric `ColumnAware` (XY-cut) span ordering. Shared by the
212    /// `ColumnAware` and `Structure` reading-order branches (the latter uses it
213    /// as its baseline and its tiebreak for unstructured spans).
214    fn order_spans_column_aware(
215        &self,
216        spans: Vec<crate::layout::TextSpan>,
217        page_index: usize,
218    ) -> Result<Vec<crate::layout::TextSpan>> {
219        use crate::pipeline::reading_order::{ReadingOrderContext as ROContext, ReadingOrderStrategy, XYCutStrategy};
220        let strategy = XYCutStrategy::new();
221        // The heading-run pre-pass needs the gutter to tell a heading opening
222        // the other column apart from a second `Tj` segment of the same
223        // heading line (GH#1757). This is the ordering that reaches the text
224        // and markdown lenses, so leaving it `None` leaves the defect live
225        // even when `postprocess_spans` has the gutter. ~keep
226        let mut context = ROContext::new().with_page(page_index as u32);
227        if let Some(gutter_x) = Self::detect_column_gutter(&spans) {
228            context = context.with_column_gutter(gutter_x);
229        }
230        let ordered = strategy.apply(spans, &context)?;
231        Ok(ordered.into_iter().map(|o| o.span).collect())
232    }
233
234    /// Extract text spans from a page using a specified reading order strategy.
235    ///
236    /// This method extracts text spans identically to [`extract_spans`](Self::extract_spans),
237    /// then applies the chosen reading order strategy to sort them.
238    ///
239    /// # Arguments
240    ///
241    /// * `page_index` - Zero-based page index
242    /// * `reading_order` - The reading order strategy to apply
243    ///
244    /// # Returns
245    ///
246    /// Vector of TextSpan objects sorted according to the chosen reading order.
247    ///
248    /// # Examples
249    ///
250    /// ```no_run
251    /// # use xberg_native_pdf::document::{PdfDocument, ReadingOrder};
252    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
253    /// let mut doc = PdfDocument::open("two_column.pdf")?;
254    /// let spans = doc.extract_spans_with_reading_order(0, ReadingOrder::ColumnAware)?;
255    /// for span in spans {
256    ///     println!("{}", span.text);
257    /// }
258    /// # Ok(())
259    /// # }
260    /// ```
261    pub fn extract_spans_with_reading_order(
262        &self,
263        page_index: usize,
264        reading_order: ReadingOrder,
265    ) -> Result<Vec<crate::layout::TextSpan>> {
266        self.extract_spans_filtered_with_reading_order(page_index, reading_order, HashSet::new(), HashSet::new())
267    }
268
269    /// Extract positioned spans in a reading order, excluding optional-content
270    /// layers and/or Separation/DeviceN inks.
271    ///
272    /// This is [`extract_spans_with_reading_order`](Self::extract_spans_with_reading_order)
273    /// and [`extract_text_filtered`](Self::extract_text_filtered) combined: the
274    /// former cannot filter, and the latter returns assembled text rather than
275    /// positioned spans. A consumer that lays spans out itself - an HTML/XML
276    /// emitter placing each span at its own rectangle - needs both at once.
277    ///
278    /// The motivating case is render/extract parity. `render_page` honours the
279    /// document's default configuration `/OCProperties/D`, but span extraction
280    /// treats everything as visible unless the caller names layers (see the
281    /// `optional_content` module note). Passing
282    /// `optional_content::compute_default_off_ocgs(&doc)` as `excluded_layers`
283    /// makes extraction agree with what the page actually displays - without it,
284    /// a default-OFF layer holding a copy of the page contributes a SECOND copy
285    /// of every word.
286    ///
287    /// Empty sets are exactly equivalent to the unfiltered call, so this is a
288    /// superset of the existing API and costs nothing when no filtering is asked
289    /// for.
290    ///
291    /// # Arguments
292    ///
293    /// * `page_index` - Zero-based page index
294    /// * `reading_order` - The reading order strategy to apply
295    /// * `excluded_layers` - OCG layer names to suppress (empty = no filtering)
296    /// * `excluded_inks` - Separation/DeviceN ink names to suppress (empty = none)
297    ///
298    /// # Examples
299    ///
300    /// ```no_run
301    /// # use xberg_native_pdf::document::{PdfDocument, ReadingOrder};
302    /// # use xberg_native_pdf::optional_content;
303    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
304    /// let doc = PdfDocument::open("layered.pdf")?;
305    /// // Agree with what the page displays: drop the default-off layers.
306    /// let hidden = optional_content::compute_default_off_ocgs(&doc);
307    /// let spans = doc.extract_spans_filtered_with_reading_order(
308    ///     0,
309    ///     ReadingOrder::ColumnAware,
310    ///     hidden,
311    ///     Default::default(),
312    /// )?;
313    /// for span in spans {
314    ///     println!("{}", span.text);
315    /// }
316    /// # Ok(())
317    /// # }
318    /// ```
319    ///
320    /// **Rotation is handled differently here than in [`Self::extract_spans`].**
321    /// This function calls only `extract_spans_raw`/`_filtered` plus
322    /// `drop_offpage_spans` (see the comment in the body below) and deliberately
323    /// skips `postprocess_spans`, so `map_span_into_rotated_frame` never runs on
324    /// this path and every returned span's `page_rotation_applied` is always
325    /// `0`. Bboxes here stay in raw user space even on a `/Rotate`d page; the
326    /// caller owns the upright transform. xberg's own extraction layer — the
327    /// only caller of this function — already does this via `upright_origin`,
328    /// which rotates by the span's own content-matrix angle. Making this path
329    /// call `postprocess_spans` "for consistency" would be a regression, not a
330    /// fix: `upright_origin` would then rotate a bbox `postprocess_spans` had
331    /// already mapped into the displayed frame, double-transforming it into a
332    /// third, wrong coordinate — this was worked out numerically, not assumed.
333    pub fn extract_spans_filtered_with_reading_order(
334        &self,
335        page_index: usize,
336        reading_order: ReadingOrder,
337        excluded_layers: HashSet<String>,
338        excluded_inks: HashSet<String>,
339    ) -> Result<Vec<crate::layout::TextSpan>> {
340        // Extract raw spans using the common extraction logic. The unfiltered
341        // path is kept verbatim for empty sets so existing callers are unchanged. ~keep
342        let mut spans = if excluded_layers.is_empty() && excluded_inks.is_empty() {
343            self.extract_spans_raw(page_index)?
344        } else {
345            self.extract_spans_raw_filtered(page_index, excluded_layers, excluded_inks)?
346        };
347
348        // Drop text lying entirely off the MediaBox - a doc that reuses one big
349        // Form XObject across pages relies on the `W n` clip to hide the off-page
350        // portion, which the raw extractor does not honour. `extract_spans` applies
351        // this via `postprocess_spans`; the reading-order path must too, or it
352        // emits every page's worth of spans (measured: a stats report emitted a
353        // chart's full hidden data table, ~5x the visible label count).
354        //
355        // This ports only the off-page filter out of `postprocess_spans`, not the
356        // whole pipeline: the page-/Rotate mapping it also does (via
357        // `map_span_into_rotated_frame`) is deliberately left out, so
358        // `page_rotation_applied` stays 0 on every span this function returns.
359        // See this function's doc comment above for why adding that mapping back
360        // in would make results wrong, not just more consistent. ~keep
361        self.drop_offpage_spans(page_index, &mut spans);
362
363        match reading_order {
364            ReadingOrder::TopToBottom => {
365                crate::utils::sort_by_visual_rows(
366                    &mut spans,
367                    |span| span.bbox.y,
368                    |span| span.bbox.x,
369                    |span| span.font_size.max(span.bbox.height),
370                );
371            }
372            ReadingOrder::ColumnAware => {
373                spans = self.order_spans_column_aware(spans, page_index)?;
374            }
375            ReadingOrder::Structure => {
376                // Geometric order is the baseline. The structure tree then fixes ONLY
377                // the spans it can fix unambiguously: TABLE cells.
378                //
379                // A geometric XY-cut reads a wide table column-major and drops cells;
380                // the structure tree's pre-order traversal gives the authoritative
381                // row-major order (§14.8.2.3). But applying that traversal to the WHOLE
382                // page also reorders flowing prose - where the tree's section order can
383                // legitimately differ from visual order (it de-prioritises page
384                // artifacts, for one) - which is a change, not an improvement. So table
385                // content is reordered IN PLACE by structure rank while every non-table
386                // span keeps its geometric position. ~keep
387                let mut ordered = self.order_spans_column_aware(spans, page_index)?;
388                if let Some(tree) = self.struct_tree_trustworthy() {
389                    // Populate the structure-content cache, then read it (it carries the
390                    // per-MCID `in_table` flag the mcid-order list does not). ~keep
391                    let _ = self.cached_mcid_order_for_page(&tree, page_index as u32);
392                    let content: Vec<crate::structure::OrderedContent> = self
393                        .structure_content_cache
394                        .lock_or_recover()
395                        .as_ref()
396                        .and_then(|c| c.get(&(page_index as u32)))
397                        .cloned()
398                        .unwrap_or_default();
399
400                    let page_scope = crate::structure::McidScope::Page(page_index as u32);
401                    let mut table_rank: HashMap<(crate::structure::McidScope, u32), usize> = HashMap::new();
402                    for (i, c) in content.iter().enumerate() {
403                        if let (true, Some(m)) = (c.in_table, c.mcid) {
404                            let scope = c.mcid_scope.clone().unwrap_or_else(|| page_scope.clone());
405                            table_rank.entry((scope, m)).or_insert(i);
406                        }
407                    }
408
409                    if !table_rank.is_empty() {
410                        let key_of = |s: &crate::layout::TextSpan| {
411                            s.mcid.and_then(|m| {
412                                let scope = s.mcid_scope.clone().unwrap_or_else(|| page_scope.clone());
413                                table_rank
414                                    .get(&(scope, m))
415                                    .or_else(|| table_rank.get(&(page_scope.clone(), m)))
416                                    .copied()
417                            })
418                        };
419                        let mut slots: Vec<usize> = Vec::new();
420                        let mut cells: Vec<(usize, crate::layout::TextSpan)> = Vec::new();
421                        for (idx, s) in ordered.iter().enumerate() {
422                            if let Some(r) = key_of(s) {
423                                slots.push(idx);
424                                cells.push((r, s.clone()));
425                            }
426                        }
427                        // Re-fill those exact slots with the cells in structure
428                        // (row-major) order. Non-table spans never move. ~keep
429                        cells.sort_by_key(|(r, _)| *r);
430                        for (slot, (_, cell)) in slots.into_iter().zip(cells) {
431                            ordered[slot] = cell;
432                        }
433                    }
434                }
435                spans = ordered;
436            }
437        }
438
439        if reading_order == ReadingOrder::ColumnAware {
440            reorder_ltr_key_value_rows(&mut spans);
441        }
442
443        let erase = self.erase_regions.lock_or_recover().get(&page_index).cloned();
444        if let Some(regions) = erase {
445            spans.retain(|span| !regions.iter().any(|r| r.intersects(&span.bbox)));
446        }
447
448        self.apply_actualtext_to_spans(page_index, &mut spans);
449
450        Ok(spans)
451    }
452
453    /// Extract complete page text data in a single call.
454    ///
455    /// Returns a [`PageText`](crate::layout::text_block::PageText) containing spans in reading order, per-character
456    /// data derived from those spans (using font-metric widths when available),
457    /// and the page dimensions. Uses the default `TopToBottom` reading order.
458    ///
459    /// # Arguments
460    ///
461    /// * `page_index` - Zero-based page index
462    ///
463    /// # Examples
464    ///
465    /// ```no_run
466    /// # use xberg_native_pdf::document::PdfDocument;
467    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
468    /// let mut doc = PdfDocument::open("example.pdf")?;
469    /// let page_text = doc.extract_page_text(0)?;
470    /// println!("Page {}x{} pt", page_text.page_width, page_text.page_height);
471    /// println!("{} spans, {} chars", page_text.spans.len(), page_text.chars.len());
472    /// # Ok(())
473    /// # }
474    /// ```
475    pub fn extract_page_text(&self, page_index: usize) -> Result<crate::layout::PageText> {
476        self.extract_page_text_with_options(page_index, ReadingOrder::default())
477    }
478
479    /// Extract a page as typed [`StructuredPage`](crate::structured::StructuredPage)
480    /// regions.
481    ///
482    /// Returns the page's text grouped into
483    /// [`StructuredRegion`](crate::structured::StructuredRegion)s — body blocks,
484    /// headings, header/footer/page-number chrome, and marginal labels — in
485    /// reading order, with a best-effort `column_index` for two-column bodies.
486    ///
487    /// Roles are derived from signals already attached to each span: `/Artifact`
488    /// marked content (ISO 32000-1:2008 §14.8.2.2), structure-tree heading levels
489    /// (§14.7.2), and span geometry (§14.8.2.3.1). A tagged PDF with a
490    /// trustworthy `/StructTreeRoot` (see
491    /// [`prefers_structure_reading_order`](Self::prefers_structure_reading_order))
492    /// therefore yields tree-driven roles; untagged PDFs use the geometric /
493    /// font-size fallbacks. This is an additive aggregation layer — it does not
494    /// change any existing extraction output.
495    ///
496    /// # Arguments
497    ///
498    /// * `page_index` - Zero-based page index
499    ///
500    /// # Examples
501    ///
502    /// ```no_run
503    /// # use xberg_native_pdf::document::PdfDocument;
504    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
505    /// let mut doc = PdfDocument::open("two_column.pdf")?;
506    /// let page = doc.extract_structured(0)?;
507    /// for region in &page.regions {
508    ///     println!("{:?} col={:?}: {}", region.kind, region.column_index, region.text);
509    /// }
510    /// # Ok(())
511    /// # }
512    /// ```
513    pub fn extract_structured(&self, page_index: usize) -> Result<crate::structured::StructuredPage> {
514        self.extract_structured_with_column_mode(page_index, crate::structured::ColumnMode::Auto)
515    }
516
517    /// Extract a page as [`StructuredPage`](crate::structured::StructuredPage)
518    /// regions with an explicit column-detection
519    /// [`ColumnMode`](crate::structured::ColumnMode) (Fix 3).
520    ///
521    /// `Auto` runs the geometric gutter heuristic (same as
522    /// [`extract_structured`](Self::extract_structured)); `Two` forces a
523    /// two-column split for layouts the conservative heuristic rejects (short,
524    /// ragged reference-edition lines); `Single` suppresses column detection.
525    /// The override applies to the geometric path only — ISO 32000-1:2008
526    /// §14.8.2.3 leaves untagged reading order undefined — and never overrides a
527    /// trustworthy structure tree.
528    pub fn extract_structured_with_column_mode(
529        &self,
530        page_index: usize,
531        column_mode: crate::structured::ColumnMode,
532    ) -> Result<crate::structured::StructuredPage> {
533        let page_text = self.extract_page_text(page_index)?;
534        let struct_info = self.structured_mcid_info(page_index);
535        Ok(crate::structured::build_structured_page_full(
536            page_index,
537            page_text.page_width,
538            page_text.page_height,
539            page_text.spans,
540            column_mode,
541            &struct_info,
542        ))
543    }
544
545    /// Per-MCID structure facts for `extract_structured` (ISO 32000-1:2008
546    /// §14.8.4): which MCIDs are `Lbl` labels and which logical `Sect`/`Art`
547    /// section each belongs to. Empty for untagged or suspect-tagged PDFs, so
548    /// the structured output is identical to the geometric path there. Section
549    /// ids are document-stable (the same `Sect` element yields the same id on
550    /// every page it spans), giving cross-page chapter continuity for free
551    /// (§4/§5/§6).
552    fn structured_mcid_info(&self, page_index: usize) -> crate::structured::McidStructInfo {
553        let mut info = crate::structured::McidStructInfo::default();
554        let Some(ref struct_tree) = self.struct_tree_trustworthy() else {
555            return info;
556        };
557        if self.structure_content_cache.lock_or_recover().is_none() {
558            let all = crate::structure::traverse_structure_tree_all_pages(struct_tree);
559            *self.structure_content_cache.lock_or_recover() = Some(all);
560        }
561        let cache = self.structure_content_cache.lock_or_recover();
562        if let Some(content) = cache.as_ref().and_then(|c| c.get(&(page_index as u32))) {
563            for item in content {
564                if let Some(mcid) = item.mcid {
565                    if matches!(item.list_role, Some(crate::structure::ListRole::Lbl)) {
566                        info.lbl.insert(mcid);
567                    }
568                    if let Some(sid) = item.section_id {
569                        info.section.insert(mcid, sid as usize);
570                    }
571                }
572            }
573        }
574        info
575    }
576
577    /// Extract complete page text data with a specific reading order.
578    ///
579    /// Like [`extract_page_text`](Self::extract_page_text) but allows choosing
580    /// between `TopToBottom` and `ColumnAware` reading order.
581    ///
582    /// # Arguments
583    ///
584    /// * `page_index` - Zero-based page index
585    /// * `reading_order` - Reading order strategy to apply
586    ///
587    /// # Examples
588    ///
589    /// ```no_run
590    /// # use xberg_native_pdf::document::{PdfDocument, ReadingOrder};
591    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
592    /// let mut doc = PdfDocument::open("two_column.pdf")?;
593    /// let page_text = doc.extract_page_text_with_options(0, ReadingOrder::ColumnAware)?;
594    /// for span in &page_text.spans {
595    ///     println!("{}", span.text);
596    /// }
597    /// # Ok(())
598    /// # }
599    /// ```
600    pub fn extract_page_text_with_options(
601        &self,
602        page_index: usize,
603        reading_order: ReadingOrder,
604    ) -> Result<crate::layout::PageText> {
605        let spans = self.extract_spans_with_reading_order(page_index, reading_order)?;
606
607        let chars: Vec<crate::layout::TextChar> = spans.iter().flat_map(|s| s.to_chars()).collect();
608
609        let media_box = self.get_page_media_box(page_index)?;
610
611        Ok(crate::layout::PageText {
612            spans,
613            chars,
614            // GH#1653: MediaBox is (llx, lly, urx, ury); the page EXTENT is
615            // (urx - llx, ury - lly), not the raw upper-right corner. A
616            // non-zero-origin MediaBox (e.g. `[10 -100 622 692]`) previously
617            // reported 622x692 instead of the true 612x792. ~keep
618            page_width: media_box.2 - media_box.0,
619            page_height: media_box.3 - media_box.1,
620        })
621    }
622
623    /// Extract text spans from a page with custom configuration.
624    ///
625    /// This method allows controlling span merging behavior through configuration,
626    /// including adaptive threshold settings for improved extraction quality.
627    ///
628    /// # Arguments
629    ///
630    /// * `page_index` - Zero-based page index
631    /// * `config` - SpanMergingConfig controlling extraction parameters
632    ///
633    /// # Returns
634    ///
635    /// A vector of TextSpan objects extracted from the page with applied configuration.
636    ///
637    /// # Examples
638    ///
639    /// ```no_run
640    /// # use xberg_native_pdf::document::PdfDocument;
641    /// # use xberg_native_pdf::extractors::SpanMergingConfig;
642    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
643    /// let mut doc = PdfDocument::open("example.pdf")?;
644    ///
645    /// // Use adaptive threshold configuration
646    /// let config = SpanMergingConfig::adaptive();
647    /// let spans = doc.extract_spans_with_config(0, config)?;
648    /// # Ok(())
649    /// # }
650    /// ```
651    pub fn extract_spans_with_config(
652        &self,
653        page_index: usize,
654        config: crate::extractors::SpanMergingConfig,
655    ) -> Result<Vec<crate::layout::TextSpan>> {
656        use crate::extractors::TextExtractor;
657
658        let page = self.get_page(page_index)?;
659        let page_dict = page.as_dict().ok_or_else(|| Error::ParseError {
660            offset: 0,
661            reason: "Page is not a dictionary".to_string(),
662        })?;
663
664        // Fast pre-check: skip image-only pages before decompression ~keep
665        if self.page_cannot_have_text(page_dict) {
666            return Ok(Vec::new());
667        }
668
669        // Get content stream data — skip page on decode failure (Annex I) ~keep
670        let content_data = match self.get_page_content_data(page_index) {
671            Ok(data) => data,
672            Err(error) => {
673                tracing::warn!(
674                    target: crate::LOG_TARGET_ROOT,
675                    operation = "decode_page_content",
676                    page_index,
677                    error_code = error.telemetry_code(),
678                    error_offset = ?error.telemetry_offset(),
679                    "returning empty page content after decode failure"
680                );
681                return Ok(Vec::new());
682            }
683        };
684
685        // Early-out for pages with no text content (§9.4.3) ~keep
686        if !Self::may_contain_text(&content_data) {
687            return Ok(Vec::new());
688        }
689
690        let mut extractor = TextExtractor::new().with_merging_config(config);
691
692        if let Some(resources) = page_dict.get("Resources") {
693            extractor.set_resources(resources.clone());
694            extractor.set_document(self);
695
696            if let Err(error) = self.load_fonts(resources, &mut extractor) {
697                tracing::warn!(
698                    target: crate::LOG_TARGET_ROOT,
699                    operation = "load_page_fonts",
700                    page_index,
701                    error_code = error.telemetry_code(),
702                    error_offset = ?error.telemetry_offset(),
703                    "using fallback font encoding"
704                );
705            }
706        }
707
708        extractor.extract_text_spans(&content_data)
709    }
710
711    /// Extract individual characters from a PDF page.
712    ///
713    /// This is a **low-level API** for character-level granularity. For most use cases,
714    /// prefer `extract_spans()` which provides complete text strings as PDF defines them.
715    ///
716    /// # Character-level extraction details:
717    ///
718    /// - Returns individual `TextChar` objects with position, font, and style information
719    /// - Characters are sorted in reading order (top-to-bottom, left-to-right)
720    /// - Overlapping characters (rendered multiple times for effects) are deduplicated
721    /// - Useful for layout analysis, debugging, or custom text processing pipelines
722    ///
723    /// # Arguments
724    ///
725    /// * `page_index` - Page number (0-indexed)
726    ///
727    /// # Returns
728    ///
729    /// Vector of `TextChar` objects in reading order, or error if extraction fails
730    ///
731    /// # Examples
732    ///
733    /// ```no_run
734    /// # use xberg_native_pdf::document::PdfDocument;
735    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
736    /// let mut doc = PdfDocument::open("document.pdf")?;
737    /// let chars = doc.extract_chars(0)?;
738    /// for ch in chars {
739    ///     println!("'{}' at ({:.1}, {:.1}), font: {}",
740    ///         ch.char, ch.bbox.x, ch.bbox.y, ch.font_name);
741    /// }
742    /// # Ok(())
743    /// # }
744    /// ```
745    ///
746    /// List all Optional Content Group (OCG) layer names in the document.
747    ///
748    /// Reads `/OCProperties` from the document catalog and returns the `/Name`
749    /// of each OCG dictionary listed in `/OCGs`. These names can be passed to
750    /// `extract_text_filtered` / `extract_chars_filtered` via `excluded_layers`.
751    ///
752    /// Returns an empty vec if the document has no optional content.
753    pub fn get_layers(&self) -> Result<Vec<String>> {
754        let catalog = self.catalog()?;
755        let catalog_dict = catalog
756            .as_dict()
757            .ok_or_else(|| Error::InvalidPdf("Catalog is not a dictionary".to_string()))?;
758
759        let oc_props = match catalog_dict.get("OCProperties") {
760            Some(obj) => {
761                if let Some(r) = obj.as_reference() {
762                    self.load_object(r)?
763                } else {
764                    obj.clone()
765                }
766            }
767            None => return Ok(Vec::new()),
768        };
769
770        let oc_dict = match oc_props.as_dict() {
771            Some(d) => d,
772            None => return Ok(Vec::new()),
773        };
774
775        let ocgs_obj = match oc_dict.get("OCGs") {
776            Some(obj) => {
777                if let Some(r) = obj.as_reference() {
778                    self.load_object(r)?
779                } else {
780                    obj.clone()
781                }
782            }
783            None => return Ok(Vec::new()),
784        };
785
786        let ocgs_arr = match ocgs_obj.as_array() {
787            Some(a) => a,
788            None => return Ok(Vec::new()),
789        };
790
791        let mut names = Vec::new();
792        for item in ocgs_arr {
793            let ocg_obj = if let Some(r) = item.as_reference() {
794                match self.load_object(r) {
795                    Ok(o) => o,
796                    Err(_) => continue,
797                }
798            } else {
799                item.clone()
800            };
801            if let Some(d) = ocg_obj.as_dict() {
802                if let Some(Object::Name(n)) = d.get("Name") {
803                    names.push(n.clone());
804                } else if let Some(Object::String(s)) = d.get("Name")
805                    && let Ok(text) = String::from_utf8(s.clone())
806                {
807                    names.push(text);
808                }
809            }
810        }
811        Ok(names)
812    }
813
814    /// List ink / separation names used on a specific page.
815    ///
816    /// Scans the page's `/Resources /ColorSpace` dictionary for `/Separation`
817    /// and `/DeviceN` color space definitions and returns their ink names.
818    /// These names can be passed to `extract_text_filtered` /
819    /// `extract_chars_filtered` via `excluded_inks`.
820    ///
821    /// **Note:** Only the page's own `/Resources` is walked. Spot inks
822    /// declared inside a Form XObject's local `/Resources /ColorSpace`
823    /// dictionary will not be enumerated — even though the renderer and
824    /// extractor will still honor them at use time. Callers populating a
825    /// UI picker from this list may miss XObject-local inks.
826    ///
827    /// For the full walk that follows `Do` operators into Form XObject
828    /// resources, use [`Self::get_page_inks_deep`] — that is what the
829    /// separation renderer uses to allocate plates.
830    pub fn get_page_inks(&self, page_index: usize) -> Result<Vec<String>> {
831        let page = self.get_page(page_index)?;
832        let page_dict = page.as_dict().ok_or_else(|| Error::ParseError {
833            offset: 0,
834            reason: "Page is not a dictionary".to_string(),
835        })?;
836
837        let resources = match page_dict.get("Resources") {
838            Some(r) => {
839                if let Some(rr) = r.as_reference() {
840                    self.load_object(rr)?
841                } else {
842                    r.clone()
843                }
844            }
845            None => return Ok(Vec::new()),
846        };
847
848        let res_dict = match resources.as_dict() {
849            Some(d) => d,
850            None => return Ok(Vec::new()),
851        };
852
853        let cs_obj = match res_dict.get("ColorSpace") {
854            Some(obj) => {
855                if let Some(r) = obj.as_reference() {
856                    self.load_object(r)?
857                } else {
858                    obj.clone()
859                }
860            }
861            None => return Ok(Vec::new()),
862        };
863
864        let cs_dict = match cs_obj.as_dict() {
865            Some(d) => d,
866            None => return Ok(Vec::new()),
867        };
868
869        // Resolve any indirect references so the extractor sees inline
870        // arrays. Mirrors the pre-existing per-entry resolve loop. ~keep
871        let mut resolved: std::collections::HashMap<String, Object> =
872            std::collections::HashMap::with_capacity(cs_dict.len());
873        for (name, cs_def) in cs_dict.iter() {
874            let v = if let Some(r) = cs_def.as_reference() {
875                match self.load_object(r) {
876                    Ok(o) => o,
877                    Err(_) => continue,
878                }
879            } else {
880                cs_def.clone()
881            };
882            resolved.insert(name.clone(), v);
883        }
884
885        let mut ink_names = Vec::new();
886        extract_inks_from_color_space_dict(&resolved, Some(self), &mut ink_names);
887
888        ink_names.sort();
889        ink_names.dedup();
890        Ok(ink_names)
891    }
892
893    /// List ink / separation names declared on a page **including** those
894    /// declared inside Form XObjects reached through the page's content-stream
895    /// `Do` operators.
896    ///
897    /// Walks the page's content stream looking for `Do` operators that invoke
898    /// Form XObjects (§8.10), recurses into each form's `/Resources/ColorSpace`
899    /// dictionary, and accumulates `/Separation` and `/DeviceN` ink names from
900    /// every visited resource tree.
901    ///
902    /// **Cycle handling:** indirect XObject references are deduplicated by
903    /// `ObjectRef`; recursion depth is bounded at `MAX_RECURSION_DEPTH` (100).
904    /// A cycle below the depth bound is silently terminated; a tree deeper
905    /// than the bound returns [`Error::RecursionLimitExceeded`].
906    ///
907    /// **Out of scope:** tiling / shading patterns (§8.7) and annotation
908    /// appearance streams (§12.5.5) — both can declare their own colour
909    /// spaces but the separation renderer does not paint into them, so
910    /// surfacing their inks here would create plates that stay empty.
911    pub fn get_page_inks_deep(&self, page_index: usize) -> Result<Vec<String>> {
912        let resources = self.page_resources_for_inks(page_index)?;
913        let content_data = self.get_page_content_data(page_index)?;
914        let operators = crate::content::parser::parse_content_stream(&content_data)?;
915
916        let mut ink_names: Vec<String> = Vec::new();
917        let mut visited: std::collections::HashSet<crate::object::ObjectRef> = std::collections::HashSet::new();
918
919        self.collect_inks_from_resources(&resources, &mut ink_names)?;
920        self.walk_form_xobject_tree_for_inks(&operators, &resources, &mut ink_names, &mut visited, 0)?;
921
922        ink_names.sort();
923        ink_names.dedup();
924        Ok(ink_names)
925    }
926
927    /// Resolve the page's `/Resources` entry, following an indirect
928    /// reference if present. Mirrors the same pattern used by
929    /// [`Self::get_page_inks`]. Internal helper that does not depend on
930    /// [`Self::get_page_resources`].
931    fn page_resources_for_inks(&self, page_index: usize) -> Result<Object> {
932        let page = self.get_page(page_index)?;
933        let page_dict = page.as_dict().ok_or_else(|| Error::ParseError {
934            offset: 0,
935            reason: "Page is not a dictionary".to_string(),
936        })?;
937        let resources = match page_dict.get("Resources") {
938            Some(r) => match r.as_reference() {
939                Some(rr) => self.load_object(rr)?,
940                None => r.clone(),
941            },
942            None => Object::Dictionary(std::collections::HashMap::new()),
943        };
944        Ok(resources)
945    }
946
947    /// Dereference `obj` if it is an indirect reference; otherwise clone.
948    /// Internal helper that mirrors the rendering-gated
949    /// [`Self::resolve_object`] without taking the gate.
950    pub(super) fn deref_object_for_inks(&self, obj: &Object) -> Result<Object> {
951        match obj.as_reference() {
952            Some(r) => self.load_object(r),
953            None => Ok(obj.clone()),
954        }
955    }
956
957    /// Append inks declared in `resources./ColorSpace` (resolving indirect
958    /// references) to `out`. Internal helper for both
959    /// [`Self::get_page_inks_deep`] and the recursive form walker.
960    fn collect_inks_from_resources(&self, resources: &Object, out: &mut Vec<String>) -> Result<()> {
961        let res_dict = match resources.as_dict() {
962            Some(d) => d,
963            None => return Ok(()),
964        };
965        let cs_obj = match res_dict.get("ColorSpace") {
966            Some(obj) => self.deref_object_for_inks(obj)?,
967            None => return Ok(()),
968        };
969        let cs_dict_raw = match cs_obj.as_dict() {
970            Some(d) => d,
971            None => return Ok(()),
972        };
973
974        let mut resolved: std::collections::HashMap<String, Object> =
975            std::collections::HashMap::with_capacity(cs_dict_raw.len());
976        for (name, cs_def) in cs_dict_raw.iter() {
977            let v = match cs_def.as_reference() {
978                Some(r) => match self.load_object(r) {
979                    Ok(o) => o,
980                    Err(_) => continue,
981                },
982                None => cs_def.clone(),
983            };
984            resolved.insert(name.clone(), v);
985        }
986        extract_inks_from_color_space_dict(&resolved, Some(self), out);
987        Ok(())
988    }
989
990    /// Recursive walker: for every `Operator::Do { name }` in `operators` that
991    /// resolves to a Form XObject, scan that form's `/Resources/ColorSpace`
992    /// and recurse into the form's own content stream.
993    ///
994    /// `visited` is keyed on the XObject's `ObjectRef` (indirect references
995    /// only). Inline-stream forms cannot self-reference (no name to invoke);
996    /// the depth limit is the backstop for any other malformed shape.
997    fn walk_form_xobject_tree_for_inks(
998        &self,
999        operators: &[crate::content::operators::Operator],
1000        parent_resources: &Object,
1001        out: &mut Vec<String>,
1002        visited: &mut std::collections::HashSet<crate::object::ObjectRef>,
1003        depth: u32,
1004    ) -> Result<()> {
1005        if depth >= MAX_RECURSION_DEPTH {
1006            return Err(Error::RecursionLimitExceeded(MAX_RECURSION_DEPTH));
1007        }
1008        let xobjects = match parent_resources.as_dict() {
1009            Some(rd) => match rd.get("XObject") {
1010                Some(o) => self.deref_object_for_inks(o)?,
1011                None => return Ok(()),
1012            },
1013            None => return Ok(()),
1014        };
1015        let xobj_dict = match xobjects.as_dict() {
1016            Some(d) => d,
1017            None => return Ok(()),
1018        };
1019
1020        for op in operators {
1021            let name = match op {
1022                crate::content::operators::Operator::Do { name } => name,
1023                _ => continue,
1024            };
1025            let xobj_entry = match xobj_dict.get(name) {
1026                Some(o) => o,
1027                None => continue,
1028            };
1029            let xobj_ref = xobj_entry.as_reference();
1030            if let Some(r) = xobj_ref {
1031                // Cycle through indirect refs: silent skip below depth bound. ~keep
1032                if !visited.insert(r) {
1033                    continue;
1034                }
1035            }
1036            let xobj = match self.deref_object_for_inks(xobj_entry) {
1037                Ok(o) => o,
1038                Err(_) => continue,
1039            };
1040            let (form_dict, form_stream) = match xobj {
1041                Object::Stream { ref dict, .. } => {
1042                    if dict.get("Subtype").and_then(Object::as_name) != Some("Form") {
1043                        continue;
1044                    }
1045                    let data = match xobj_ref {
1046                        Some(r) => self.decode_stream_with_encryption(&xobj, r)?,
1047                        None => xobj.decode_stream_data()?,
1048                    };
1049                    (dict.clone(), data)
1050                }
1051                _ => continue,
1052            };
1053
1054            // §8.10.1: form may override resources or inherit the parent's. ~keep
1055            let form_resources = match form_dict.get("Resources") {
1056                Some(res) => self.deref_object_for_inks(res)?,
1057                None => parent_resources.clone(),
1058            };
1059            self.collect_inks_from_resources(&form_resources, out)?;
1060
1061            // Recurse into the form's own content stream looking for nested
1062            // `Do`. Malformed streams are tolerated — we want graceful
1063            // degradation in a discovery API, not a hard error. ~keep
1064            let form_ops = match crate::content::parser::parse_content_stream(&form_stream) {
1065                Ok(ops) => ops,
1066                Err(_) => continue,
1067            };
1068            self.walk_form_xobject_tree_for_inks(&form_ops, &form_resources, out, visited, depth + 1)?;
1069        }
1070        Ok(())
1071    }
1072
1073    /// # Performance Note
1074    ///
1075    /// Character extraction is typically 30-50% faster than span extraction
1076    /// because it skips the text grouping and merging logic.
1077    pub fn extract_chars(&self, page_index: usize) -> Result<Vec<crate::layout::TextChar>> {
1078        Ok((*self.cached_page_chars(page_index)?).clone())
1079    }
1080
1081    /// Shared, cached character sequence for a page — identical to what
1082    /// [`Self::extract_chars`] returns, minus the clone. Only the unfiltered
1083    /// extraction is cached; the layer/ink-filtered variant is keyed on its
1084    /// filters and stays uncached.
1085    pub(super) fn cached_page_chars(&self, page_index: usize) -> Result<std::sync::Arc<Vec<crate::layout::TextChar>>> {
1086        if let Some(cached) = self.page_chars_cache.lock_or_recover().get(&page_index) {
1087            return Ok(std::sync::Arc::clone(cached));
1088        }
1089        let chars = std::sync::Arc::new(self.extract_chars_impl(page_index, HashSet::new(), HashSet::new())?);
1090        self.page_chars_cache
1091            .lock_or_recover()
1092            .insert(page_index, std::sync::Arc::clone(&chars));
1093        Ok(chars)
1094    }
1095
1096    /// Extract characters from a page, excluding content from specified layers and inks.
1097    ///
1098    /// # Arguments
1099    ///
1100    /// * `page_index` - Zero-based page index
1101    /// * `excluded_layers` - OCG layer names to suppress (empty = no layer filtering)
1102    /// * `excluded_inks` - Separation/DeviceN ink names to suppress (empty = no ink filtering)
1103    pub fn extract_chars_filtered(
1104        &self,
1105        page_index: usize,
1106        excluded_layers: HashSet<String>,
1107        excluded_inks: HashSet<String>,
1108    ) -> Result<Vec<crate::layout::TextChar>> {
1109        self.extract_chars_impl(page_index, excluded_layers, excluded_inks)
1110    }
1111
1112    fn extract_chars_impl(
1113        &self,
1114        page_index: usize,
1115        excluded_layers: HashSet<String>,
1116        excluded_inks: HashSet<String>,
1117    ) -> Result<Vec<crate::layout::TextChar>> {
1118        use crate::extractors::TextExtractor;
1119
1120        let page = self.get_page(page_index)?;
1121        let page_dict = page.as_dict().ok_or_else(|| Error::ParseError {
1122            offset: 0,
1123            reason: "Page is not a dictionary".to_string(),
1124        })?;
1125
1126        let content_data = match self.get_page_content_data(page_index) {
1127            Ok(data) => data,
1128            Err(error) => {
1129                tracing::warn!(
1130                    target: crate::LOG_TARGET_ROOT,
1131                    operation = "decode_page_content",
1132                    page_index,
1133                    error_code = error.telemetry_code(),
1134                    error_offset = ?error.telemetry_offset(),
1135                    "returning empty page content after decode failure"
1136                );
1137                return Ok(Vec::new());
1138            }
1139        };
1140
1141        if !Self::may_contain_text(&content_data) {
1142            return Ok(Vec::new());
1143        }
1144
1145        let mut extractor = TextExtractor::new();
1146        if !excluded_layers.is_empty() {
1147            extractor.set_excluded_layers(excluded_layers);
1148        }
1149        if !excluded_inks.is_empty() {
1150            extractor.set_excluded_inks(excluded_inks);
1151        }
1152
1153        if let Some(resources) = page_dict.get("Resources") {
1154            extractor.set_resources(resources.clone());
1155            extractor.set_document(self);
1156            if let Err(error) = self.load_fonts(resources, &mut extractor) {
1157                tracing::warn!(
1158                    target: crate::LOG_TARGET_ROOT,
1159                    operation = "load_page_fonts",
1160                    page_index,
1161                    error_code = error.telemetry_code(),
1162                    error_offset = ?error.telemetry_offset(),
1163                    "using fallback font encoding"
1164                );
1165            }
1166        }
1167
1168        let mut chars = extractor.extract_owned(&content_data)?;
1169
1170        chars.sort_by(|a, b| {
1171            let y_cmp = crate::utils::safe_float_cmp(b.bbox.y, a.bbox.y);
1172            if y_cmp != std::cmp::Ordering::Equal {
1173                return y_cmp;
1174            }
1175            crate::utils::safe_float_cmp(a.bbox.x, b.bbox.x)
1176        });
1177
1178        Ok(chars)
1179    }
1180
1181    /// Extract words from a page.
1182    ///
1183    /// Groups characters into words based on spatial proximity.
1184    /// Uses adaptive thresholds based on the document's font size and spacing.
1185    ///
1186    /// # Example
1187    ///
1188    /// ```ignore
1189    /// let words = doc.extract_words(0)?;
1190    /// for word in words {
1191    ///     println!("Word: {} at {:?}", word.text, word.bbox);
1192    /// }
1193    /// ```
1194    pub fn extract_words(&self, page_index: usize) -> Result<Vec<crate::layout::Word>> {
1195        self.extract_words_with_thresholds(page_index, None, None)
1196    }
1197
1198    /// Extract words from a page with optional threshold and profile overrides.
1199    ///
1200    /// When `word_gap_threshold` is `None`, the adaptive threshold is computed
1201    /// automatically from page statistics (median character width × 0.3).
1202    /// Providing a value (in PDF points) overrides the adaptive computation,
1203    /// which is useful for tuning word segmentation on specific document types.
1204    ///
1205    /// When `profile` is provided, it controls how the underlying text spans are
1206    /// extracted from the PDF content stream (TJ offset thresholds, word margin
1207    /// ratios). This affects the raw character data before word clustering.
1208    pub fn extract_words_with_thresholds(
1209        &self,
1210        page_index: usize,
1211        word_gap_threshold: Option<f32>,
1212        profile: Option<crate::config::ExtractionProfile>,
1213    ) -> Result<Vec<crate::layout::Word>> {
1214        // Default: include /Artifact-tagged spans (matches pre-0.3.42
1215        // behavior). The spec-correct (§14.8.2.2.1) variant lives in
1216        // [`Self::extract_words_with_thresholds_no_artifacts`]. ~keep
1217        Ok(self
1218            .extract_words_inner(page_index, word_gap_threshold, profile, true)?
1219            .0)
1220    }
1221
1222    /// Same as [`Self::extract_words_with_thresholds`] but drops spans tagged
1223    /// as `/Artifact` (running headers/footers, page numbers, watermarks;
1224    /// ISO 32000-1:2008 §14.8.2.2.1). The spec-correct variant.
1225    pub fn extract_words_with_thresholds_no_artifacts(
1226        &self,
1227        page_index: usize,
1228        word_gap_threshold: Option<f32>,
1229        profile: Option<crate::config::ExtractionProfile>,
1230    ) -> Result<Vec<crate::layout::Word>> {
1231        Ok(self
1232            .extract_words_inner(page_index, word_gap_threshold, profile, false)?
1233            .0)
1234    }
1235
1236    pub(super) fn extract_words_inner(
1237        &self,
1238        page_index: usize,
1239        word_gap_threshold: Option<f32>,
1240        profile: Option<crate::config::ExtractionProfile>,
1241        include_artifacts: bool,
1242    ) -> Result<(Vec<crate::layout::Word>, Vec<bool>, Vec<bool>)> {
1243        use crate::layout::{AdaptiveLayoutParams, DocumentProperties, Word, clustering};
1244
1245        // Span source. The default (no profile) flows through the canonical
1246        // `page_reading_order` helper: tagged → struct tree,
1247        // untagged → geometric top-to-bottom. The legacy profile path keeps
1248        // its previous XY-Cut + row-aware-sort behavior pending the planned
1249        // removal of `profile`. ~keep
1250        let spans: Vec<crate::layout::TextSpan> = match profile {
1251            Some(p) => {
1252                use crate::pipeline::reading_order::xycut::XYCutStrategy;
1253                let config = crate::extractors::TextExtractionConfig::new().with_profile(p);
1254                let mut s = self.extract_spans_raw_with_extraction_config(page_index, config)?;
1255                s.sort_by(|a, b| crate::utils::row_aware_span_cmp(a.bbox.y, a.bbox.x, b.bbox.y, b.bbox.x));
1256                if !include_artifacts {
1257                    s.retain(|span| span.artifact_type.is_none());
1258                }
1259                let erase = self.erase_regions.lock_or_recover().get(&page_index).cloned();
1260                if let Some(regions) = erase {
1261                    s.retain(|span| !regions.iter().any(|r| r.intersects(&span.bbox)));
1262                }
1263                let strategy = XYCutStrategy::new();
1264                let column_gutter = Self::detect_column_gutter(&s);
1265                strategy
1266                    .partition_region(&s, column_gutter)
1267                    .into_iter()
1268                    .flatten()
1269                    .collect()
1270            }
1271            None => {
1272                let ordered = if include_artifacts {
1273                    crate::pipeline::page_reading_order(self, page_index)?
1274                } else {
1275                    crate::pipeline::page_reading_order_no_artifacts(self, page_index)?
1276                };
1277                ordered.into_iter().map(|os| os.span).collect()
1278            }
1279        };
1280        if spans.is_empty() {
1281            return Ok((Vec::new(), Vec::new(), Vec::new()));
1282        }
1283
1284        let media_box = self.get_page_media_box(page_index).unwrap_or((0.0, 0.0, 612.0, 792.0));
1285        let page_bbox = crate::geometry::Rect::new(media_box.0, media_box.1, media_box.2, media_box.3);
1286
1287        // Materialize each span's chars ONCE (to_chars allocates + decodes); the
1288        // word-clustering loop below reuses chars_per_span instead of calling
1289        // to_chars a second time per span. Byte-identical, halves to_chars work. ~keep
1290        let mut all_chars: Vec<_> = Vec::new();
1291        let mut span_char_ranges: Vec<std::ops::Range<usize>> = Vec::with_capacity(spans.len());
1292        for s in spans.iter() {
1293            let start = all_chars.len();
1294            all_chars.extend(s.to_chars());
1295            span_char_ranges.push(start..all_chars.len());
1296        }
1297        if all_chars.is_empty() {
1298            return Ok((Vec::new(), Vec::new(), Vec::new()));
1299        }
1300        let props = DocumentProperties::analyze(&all_chars, page_bbox).map_err(Error::LayoutAnalysis)?;
1301        let mut params = AdaptiveLayoutParams::from_properties(&props);
1302
1303        if let Some(wgt) = word_gap_threshold {
1304            params.word_gap_threshold = wgt;
1305        }
1306
1307        // Walk spans in canonical reading order, clustering chars within each span
1308        // into words. Since spans come pre-ordered, a flat iteration suffices —
1309        // no block-by-block partition is needed.
1310        //
1311        // Track word indices where the source span had split_boundary_before = true.
1312        // The post-processing merge must not cross these boundaries (table cells, columns). ~keep
1313        let mut split_boundary_word_indices: std::collections::HashSet<usize> = std::collections::HashSet::new();
1314        // Track word indices produced from spans drawn with a rotated text matrix
1315        // (rotation_degrees != 0 — figure/axis labels, rotated table headers,
1316        // vertical margin stamps). Such a run's glyphs advance along a rotated
1317        // axis, but the span bbox flattens them onto the x-axis (width = Σ glyph
1318        // advances, height = font). Its flattened bbox therefore overlaps
1319        // unrelated perpendicular columns, and the reading-order-adjacent word
1320        // merge below would fuse those columns into one giant token (a whole
1321        // rotated column returned as a 1000+ char "word"). Never merge
1322        // into or out of a rotated run. ~keep
1323        let mut rotated_word_indices: std::collections::HashSet<usize> = std::collections::HashSet::new();
1324        let mut words = Vec::new();
1325        // continues_prev[i]: the span-level merger joined word i to word i-1
1326        // with no word boundary — their glyphs are consecutive in one span
1327        // with not even a whitespace char between them, and only the
1328        // geometric clustering below re-split them. Kept parallel to
1329        // `words` through the merge loop; consumed by the table path, which
1330        // must not re-decide a join the merger already made from per-glyph
1331        // advance evidence. ~keep
1332        let mut continues_prev: Vec<bool> = Vec::new();
1333        // space_before[i]: word i's first glyph is immediately preceded in the
1334        // source span by a whitespace character. This is the producer's own word
1335        // boundary, and it outranks the geometric merge below: a space whose
1336        // glyphs happen to abut or overlap their neighbours (a tight Times face at
1337        // 7.83 pt in GH#1948) has a gap of zero or less, which the merge would
1338        // otherwise read as "one word" and fuse. Kept parallel to `words`,
1339        // surviving the merge loop, and consumed by the table path, which must
1340        // re-emit the source space even when the two words' boxes touch. ~keep
1341        let mut space_before: Vec<bool> = Vec::new();
1342        for (span_idx, span) in spans.iter().enumerate() {
1343            let span_chars = &all_chars[span_char_ranges[span_idx].clone()];
1344            if span_chars.is_empty() {
1345                continue;
1346            }
1347
1348            // Group characters within THIS SPAN. Since PDF spans are often words or line fragments,
1349            // this is much safer than global character clustering. ~keep
1350            let clusters = clustering::cluster_chars_into_words(span_chars, params.word_gap_threshold);
1351
1352            // Record split boundary: the first word created from this span is a hard
1353            // boundary when split_boundary_before = true (e.g. table cell boundary). ~keep
1354            let first_word_idx = words.len();
1355            let is_split_boundary = span.split_boundary_before;
1356            let is_rotated_run = span.rotation_degrees != 0.0;
1357
1358            // Source-index interval of each word emitted for this span,
1359            // aligned to `words[first_word_idx..]`. Two words continue each
1360            // other exactly when the second's lowest source index directly
1361            // follows the first's highest — a dropped whitespace char (or a
1362            // glyph of another word) in between would occupy that index. ~keep
1363            let mut word_src_ranges: Vec<(usize, usize)> = Vec::new();
1364            // Whether a word's first glyph is preceded by source whitespace; see
1365            // `space_before`. Read from the span's own character stream so the
1366            // marker reflects source order even when the clustering below visited
1367            // the glyphs in a different (x-sorted) order. ~keep
1368            let preceded_by_space = |lo: usize| {
1369                lo > 0 && {
1370                    let prev = span_chars[lo - 1].char;
1371                    prev.is_whitespace() || prev == '\n' || prev == '\r'
1372                }
1373            };
1374            for cluster_indices in clusters {
1375                let mut current_word_chars = Vec::new();
1376                let mut src_lo = usize::MAX;
1377                let mut src_hi = 0usize;
1378                for &ci in &cluster_indices {
1379                    let c = span_chars[ci].clone();
1380                    if c.char.is_whitespace() || c.char == '\n' || c.char == '\r' {
1381                        if !current_word_chars.is_empty() {
1382                            let mut word = Word::from_chars(std::mem::take(&mut current_word_chars));
1383                            word.sequence = span.sequence;
1384                            words.push(word);
1385                            continues_prev.push(false);
1386                            space_before.push(preceded_by_space(src_lo));
1387                            word_src_ranges.push((src_lo, src_hi));
1388                            src_lo = usize::MAX;
1389                            src_hi = 0;
1390                        }
1391                    } else {
1392                        current_word_chars.push(c);
1393                        src_lo = src_lo.min(ci);
1394                        src_hi = src_hi.max(ci);
1395                    }
1396                }
1397                if !current_word_chars.is_empty() {
1398                    let mut word = Word::from_chars(current_word_chars);
1399                    word.sequence = span.sequence;
1400                    words.push(word);
1401                    continues_prev.push(false);
1402                    space_before.push(preceded_by_space(src_lo));
1403                    word_src_ranges.push((src_lo, src_hi));
1404                }
1405            }
1406            // Rotated and vertical runs advance perpendicular to the line
1407            // axis the clustering assumes, so every glyph lands in its own
1408            // cluster and index adjacency would mark a whole column as one
1409            // continued word. Leave those unmarked; the rotated case is also
1410            // what the merge loop below skips.
1411            //
1412            // The mark is a source-order statement only. It deliberately
1413            // carries no geometric test: `to_chars` stamps the span's single
1414            // `bbox.y` onto every glyph it emits, so all words from one span
1415            // share a y and no same-line test can discriminate here. Geometry
1416            // is the consumer's job, where the real coordinates are in hand. ~keep
1417            if !is_rotated_run && span.wmode == 0 {
1418                for k in 1..word_src_ranges.len() {
1419                    let (_, prev_hi) = word_src_ranges[k - 1];
1420                    let (cur_lo, _) = word_src_ranges[k];
1421                    if cur_lo == prev_hi + 1 {
1422                        continues_prev[first_word_idx + k] = true;
1423                    }
1424                }
1425            }
1426
1427            if is_split_boundary && words.len() > first_word_idx {
1428                split_boundary_word_indices.insert(first_word_idx);
1429            }
1430            if is_rotated_run {
1431                rotated_word_indices.extend(first_word_idx..words.len());
1432            }
1433        }
1434
1435        // Post-processing: merge adjacent words whose spans abut or overlap on
1436        // the same line. PDFs (especially tagged CJK documents) sometimes encode
1437        // typographically-adjacent glyphs as separate marked-content runs, e.g.
1438        // "Q" and "(peu/d)" with a gap of -0.18 points. Without merging these
1439        // remain separate tokens and never match the ground-truth "Q(peu/d)". ~keep
1440        //
1441        // Merge condition: same line (y_diff ≤ 0.5 × max line height) AND
1442        // horizontal gap ≤ 0.15 × font_size (same threshold as should_insert_space).
1443        // Skip merge when the current word index is a split boundary.
1444        //
1445        // `gap` has no lower bound above, so a word that BACKTRACKS far behind
1446        // the previous word's origin also satisfies `gap ≤ 0.15 × font_size`
1447        // (a large negative number is always ≤ a small positive one). Displayed
1448        // math draws a fraction's denominator AFTER the relation sign that
1449        // follows the numerator (`dx/dt = …` → the `=` is emitted, then `dt`
1450        // starts ~2em further left at a small baseline offset) — this is the
1451        // exact geometry `assemble_text_from_spans`'s backtrack branch breaks
1452        // the line on, just reached here through word bboxes instead of span
1453        // bboxes. Left unguarded, this loop fuses the pair into `"=dt"`, and
1454        // because the merge is incremental (`prev` grows to the union bbox),
1455        // a chain of such backtracks collapses into one word spanning an
1456        // entire equation — the far worse case reported against `main`.
1457        // Mirror the emitter's guard: a word that starts at-or-left of the
1458        // previous word's ORIGIN (not just its end), with a real baseline
1459        // offset and an overlap far beyond ordinary kerning, is a backtrack,
1460        // not a same-line neighbour — never merge across it. Gated off for
1461        // RTL text, whose leftward flow is ordinary reading order. ~keep
1462        let mut merged: Vec<Word> = Vec::with_capacity(words.len());
1463        // RTL-ness of each entry in `merged`, carried alongside it. `looks_rtl`
1464        // scans a whole string, and `prev` below GROWS by `push_str` on every
1465        // merge — re-deriving it per iteration made a chain of k merges cost
1466        // O(k^2) characters, the same blow-up the backtrack guard above exists
1467        // to prevent. It is an `any()` over the chars, so
1468        // `looks_rtl(a + b) == looks_rtl(a) || looks_rtl(b)`: maintain it
1469        // incrementally instead. ~keep
1470        let mut merged_rtl: Vec<bool> = Vec::with_capacity(words.len());
1471        let mut merged_continues: Vec<bool> = Vec::with_capacity(words.len());
1472        let mut merged_space_before: Vec<bool> = Vec::with_capacity(words.len());
1473        let mut prev_rotated = false;
1474        for (idx, ((word, word_continues), word_space_before)) in
1475            words.into_iter().zip(continues_prev).zip(space_before).enumerate()
1476        {
1477            let cur_rotated = rotated_word_indices.contains(&idx);
1478            let word_rtl = crate::text::bidi::looks_rtl(&word.text);
1479            if !cur_rotated
1480                && !prev_rotated
1481                && !word_space_before
1482                && !split_boundary_word_indices.contains(&idx)
1483                && let Some(prev) = merged.last_mut()
1484            {
1485                let gap = word.bbox.x - (prev.bbox.x + prev.bbox.width);
1486                let y_diff = (word.bbox.y - prev.bbox.y).abs();
1487                let delta_x = word.bbox.x - prev.bbox.x;
1488                let line_h = prev.bbox.height.max(word.bbox.height);
1489                let font_size = prev.avg_font_size.max(word.avg_font_size).max(1.0);
1490                let not_rtl = !merged_rtl.last().copied().unwrap_or(false) && !word_rtl;
1491                let is_math_backtrack = y_diff > 1.0 && delta_x <= 0.5 && gap < -font_size && not_rtl;
1492                // A LINE WRAP can land at nearly the same y as the line
1493                // above it (some producers emit sub-1pt baseline drift
1494                // between consecutive lines, so `y_diff > 1.0` above
1495                // doesn't always hold), but it always resets x back
1496                // toward the page's left margin — an order of magnitude
1497                // further than any real same-line construct (ordinary
1498                // kerning is near 0; the math backtrack above is ~1-2em).
1499                // A multi-em backtrack this large can only be two
1500                // different lines, never a genuine adjacency — reject it
1501                // regardless of y_diff, or a wrapped line's tail gets
1502                // fused onto its own next line's head (e.g. "of whom" +
1503                // "tered with books" → "whomteredwithbooks"). ~keep
1504                let is_line_wrap_reset = delta_x < -5.0 * font_size && not_rtl;
1505                if y_diff <= line_h * 0.5 && gap <= font_size * 0.15 && !is_math_backtrack && !is_line_wrap_reset {
1506                    // Incremental merge — O(k) per merge, O(total_chars) overall.
1507                    // Avoids the O(n²) clone+from_chars pattern that caused
1508                    // catastrophic slowdown on TOC dot-leader pages. ~keep
1509                    prev.absorb(word);
1510                    if let Some(flag) = merged_rtl.last_mut() {
1511                        *flag |= word_rtl;
1512                    }
1513                    continue;
1514                }
1515            }
1516            merged.push(word);
1517            merged_rtl.push(word_rtl);
1518            merged_continues.push(word_continues);
1519            merged_space_before.push(word_space_before);
1520            prev_rotated = cur_rotated;
1521        }
1522
1523        Ok((merged, merged_continues, merged_space_before))
1524    }
1525
1526    /// Extract text lines from a page.
1527    ///
1528    /// Groups words into lines based on vertical proximity.
1529    ///
1530    /// # Example
1531    ///
1532    /// ```ignore
1533    /// let lines = doc.extract_text_lines(0)?;
1534    /// for line in lines {
1535    ///     println!("Line: {} at {:?}", line.text, line.bbox);
1536    /// }
1537    /// ```
1538    pub fn extract_text_lines(&self, page_index: usize) -> Result<Vec<crate::layout::TextLine>> {
1539        self.extract_text_lines_with_thresholds(page_index, None, None, None)
1540    }
1541
1542    /// Extract text lines from a page with optional threshold and profile overrides.
1543    ///
1544    /// When thresholds are `None`, adaptive values are computed automatically
1545    /// from page statistics. Providing values (in PDF points) overrides the
1546    /// adaptive computation for fine-grained control over segmentation.
1547    ///
1548    /// When `profile` is provided, it controls how the underlying text spans are
1549    /// extracted from the PDF content stream (TJ offset thresholds, word margin
1550    /// ratios). This affects the raw character data before word/line clustering.
1551    ///
1552    /// # Arguments
1553    ///
1554    /// * `page_index` - Zero-based page index
1555    /// * `word_gap_threshold` - Optional override for the horizontal gap (in PDF points)
1556    ///   used to split characters into words. Smaller values produce more words.
1557    /// * `line_gap_threshold` - Optional override for the vertical gap (in PDF points)
1558    ///   used to group words into lines. Smaller values produce more lines.
1559    /// * `profile` - Optional extraction profile for span-level tuning.
1560    ///
1561    /// # Example
1562    ///
1563    /// ```ignore
1564    /// // Use adaptive thresholds (default behavior)
1565    /// let lines = doc.extract_text_lines_with_thresholds(0, None, None, None)?;
1566    ///
1567    /// // Tune both thresholds for dense forms
1568    /// let lines = doc.extract_text_lines_with_thresholds(0, Some(1.5), Some(4.0), None)?;
1569    /// ```
1570    pub fn extract_text_lines_with_thresholds(
1571        &self,
1572        page_index: usize,
1573        word_gap_threshold: Option<f32>,
1574        line_gap_threshold: Option<f32>,
1575        profile: Option<crate::config::ExtractionProfile>,
1576    ) -> Result<Vec<crate::layout::TextLine>> {
1577        self.extract_text_lines_inner(page_index, word_gap_threshold, line_gap_threshold, profile, true)
1578    }
1579
1580    /// Same as [`Self::extract_text_lines_with_thresholds`] but drops spans
1581    /// tagged as `/Artifact` (running headers/footers, page numbers,
1582    /// watermarks; ISO 32000-1:2008 §14.8.2.2.1). Spec-correct variant.
1583    pub fn extract_text_lines_with_thresholds_no_artifacts(
1584        &self,
1585        page_index: usize,
1586        word_gap_threshold: Option<f32>,
1587        line_gap_threshold: Option<f32>,
1588        profile: Option<crate::config::ExtractionProfile>,
1589    ) -> Result<Vec<crate::layout::TextLine>> {
1590        self.extract_text_lines_inner(page_index, word_gap_threshold, line_gap_threshold, profile, false)
1591    }
1592
1593    fn extract_text_lines_inner(
1594        &self,
1595        page_index: usize,
1596        word_gap_threshold: Option<f32>,
1597        line_gap_threshold: Option<f32>,
1598        profile: Option<crate::config::ExtractionProfile>,
1599        include_artifacts: bool,
1600    ) -> Result<Vec<crate::layout::TextLine>> {
1601        use crate::layout::{AdaptiveLayoutParams, DocumentProperties, TextLine, Word, clustering};
1602
1603        // Span source. Default (no profile) → canonical `page_reading_order`
1604        // helper. Legacy profile path keeps XY-Cut + row-aware
1605        // sort pending the planned removal of `profile`. ~keep
1606        let spans: Vec<crate::layout::TextSpan> = match profile {
1607            Some(p) => {
1608                use crate::pipeline::reading_order::xycut::XYCutStrategy;
1609                let config = crate::extractors::TextExtractionConfig::new().with_profile(p);
1610                let mut s = self.extract_spans_raw_with_extraction_config(page_index, config)?;
1611                s.sort_by(|a, b| crate::utils::row_aware_span_cmp(a.bbox.y, a.bbox.x, b.bbox.y, b.bbox.x));
1612                if !include_artifacts {
1613                    s.retain(|span| span.artifact_type.is_none());
1614                }
1615                let erase = self.erase_regions.lock_or_recover().get(&page_index).cloned();
1616                if let Some(regions) = erase {
1617                    s.retain(|span| !regions.iter().any(|r| r.intersects(&span.bbox)));
1618                }
1619                let strategy = XYCutStrategy::new();
1620                let column_gutter = Self::detect_column_gutter(&s);
1621                strategy
1622                    .partition_region(&s, column_gutter)
1623                    .into_iter()
1624                    .flatten()
1625                    .collect()
1626            }
1627            None => {
1628                let ordered = if include_artifacts {
1629                    crate::pipeline::page_reading_order(self, page_index)?
1630                } else {
1631                    crate::pipeline::page_reading_order_no_artifacts(self, page_index)?
1632                };
1633                ordered.into_iter().map(|os| os.span).collect()
1634            }
1635        };
1636        if spans.is_empty() {
1637            return Ok(Vec::new());
1638        }
1639
1640        let media_box = self.get_page_media_box(page_index).unwrap_or((0.0, 0.0, 612.0, 792.0));
1641        let page_bbox = crate::geometry::Rect::new(media_box.0, media_box.1, media_box.2, media_box.3);
1642
1643        // Materialize each span's chars once (see extract_text_as_words). ~keep
1644        let mut all_chars: Vec<_> = Vec::new();
1645        let mut span_char_ranges: Vec<std::ops::Range<usize>> = Vec::with_capacity(spans.len());
1646        for s in spans.iter() {
1647            let start = all_chars.len();
1648            all_chars.extend(s.to_chars());
1649            span_char_ranges.push(start..all_chars.len());
1650        }
1651        let props = DocumentProperties::analyze(&all_chars, page_bbox).map_err(Error::LayoutAnalysis)?;
1652        let mut params = AdaptiveLayoutParams::from_properties(&props);
1653
1654        if let Some(wgt) = word_gap_threshold {
1655            params.word_gap_threshold = wgt;
1656        }
1657        if let Some(lgt) = line_gap_threshold {
1658            params.line_gap_threshold = lgt;
1659        }
1660
1661        // Walk spans in canonical reading order, clustering chars → words.
1662        // No block partition; spans are already pre-ordered.
1663        //
1664        // `word_rot_run` maps each word to the index of the rotated span it came
1665        // from (`None` for horizontal spans). A rotated run's glyphs advance
1666        // along a rotated axis but the span bbox flattens them onto the x-axis,
1667        // so the flattened y-band line clustering below would fuse the run with
1668        // its perpendicular neighbours into one giant line. Rotated runs
1669        // are therefore lifted out and each emitted as its own line. ~keep
1670        let mut words: Vec<Word> = Vec::new();
1671        let mut word_rot_run: Vec<Option<usize>> = Vec::new();
1672        for (span_idx, span) in spans.iter().enumerate() {
1673            let span_chars = &all_chars[span_char_ranges[span_idx].clone()];
1674            if span_chars.is_empty() {
1675                continue;
1676            }
1677            let rot_run = (span.rotation_degrees != 0.0).then_some(span_idx);
1678
1679            let clusters = clustering::cluster_chars_into_words(span_chars, params.word_gap_threshold);
1680            for cluster_indices in clusters {
1681                let cluster_chars: Vec<_> = cluster_indices.iter().map(|&i| span_chars[i].clone()).collect();
1682                let mut current_word_chars = Vec::new();
1683                for c in cluster_chars {
1684                    if c.char.is_whitespace() || c.char == '\n' || c.char == '\r' {
1685                        if !current_word_chars.is_empty() {
1686                            let mut word = Word::from_chars(current_word_chars);
1687                            word.sequence = span.sequence;
1688                            words.push(word);
1689                            word_rot_run.push(rot_run);
1690                            current_word_chars = Vec::new();
1691                        }
1692                    } else {
1693                        current_word_chars.push(c);
1694                    }
1695                }
1696                if !current_word_chars.is_empty() {
1697                    let mut word = Word::from_chars(current_word_chars);
1698                    word.sequence = span.sequence;
1699                    words.push(word);
1700                    word_rot_run.push(rot_run);
1701                }
1702            }
1703        }
1704
1705        if words.is_empty() {
1706            return Ok(Vec::new());
1707        }
1708
1709        // Fast path (byte-identical): no rotated runs on the page → cluster every
1710        // word by global y-tolerance exactly as before. Same-y words merge into
1711        // the same line regardless of source span (span ordering already handled
1712        // the multi-column / structure-tree sequencing upstream). ~keep
1713        if word_rot_run.iter().all(Option::is_none) {
1714            let line_clusters = clustering::cluster_words_into_lines(&words, params.line_gap_threshold);
1715            let mut all_lines = Vec::new();
1716            for cluster_indices in line_clusters {
1717                let cluster_words: Vec<_> = cluster_indices.iter().map(|&i| words[i].clone()).collect();
1718                all_lines.push(TextLine::new(cluster_words));
1719            }
1720            return Ok(all_lines);
1721        }
1722
1723        let horizontal: Vec<Word> = words
1724            .iter()
1725            .zip(word_rot_run.iter())
1726            .filter(|(_, r)| r.is_none())
1727            .map(|(w, _)| w.clone())
1728            .collect();
1729        let mut lines: Vec<Vec<Word>> = Vec::new();
1730        if !horizontal.is_empty() {
1731            for cluster_indices in clustering::cluster_words_into_lines(&horizontal, params.line_gap_threshold) {
1732                lines.push(cluster_indices.iter().map(|&i| horizontal[i].clone()).collect());
1733            }
1734        }
1735        // Rotated runs, grouped into lines. A run's own extents are recorded in
1736        // its frame, so the offset BETWEEN lines is the coordinate the run does
1737        // not advance along: x for a +-90 degree run, y for 180. Runs that share
1738        // that offset are one visual line however many `Tm`s drew them — a
1739        // rotated table row is typically one run per cell. Grouping by run alone
1740        // returns one line per cell; grouping without the rotation key
1741        // fuses perpendicular columns into one line. ~keep
1742        let mut run_first_word: Vec<(usize, usize)> = Vec::new();
1743        let mut run_start = 0;
1744        while run_start < words.len() {
1745            match word_rot_run[run_start] {
1746                None => run_start += 1,
1747                Some(run_id) => {
1748                    let mut run_end = run_start + 1;
1749                    while run_end < words.len() && word_rot_run[run_end] == Some(run_id) {
1750                        run_end += 1;
1751                    }
1752                    run_first_word.push((run_start, run_end));
1753                    run_start = run_end;
1754                }
1755            }
1756        }
1757
1758        // On a /Rotate page `postprocess_spans` rect-maps each span's bbox into
1759        // the displayed frame but leaves `rotation_degrees` describing the
1760        // pre-display one, so the bbox axes and the rotation no longer agree and
1761        // the offset read below would be taken off the wrong axis. ~keep
1762        let page_is_unrotated = self.get_page_rotation(page_index).unwrap_or(0) == 0;
1763
1764        let mut rotated_lines: Vec<(f32, f32, Vec<Word>)> = Vec::new();
1765        // Offsets of the quarter-turn lines, quantized to 1/100 pt, mapped to
1766        // the indices of the lines carrying them. The match below used to scan
1767        // every line built so far, which is O(runs^2) — and a rotated table,
1768        // the exact page this grouping exists for, is where the run count
1769        // climbs. The index answers the same question over a bounded range.
1770        //
1771        // Semantics are preserved exactly. A line's offset is frozen when it is
1772        // created, and the winner is the FIRST such line in insertion order, so
1773        // candidates are filtered by the original predicate and the smallest
1774        // index wins. Merging neighbours instead would group differently:
1775        // offsets 0, 3, 6 with tolerance 4 give {0,3},{6} under this rule but
1776        // {0,3,6} under a chained merge. ~keep
1777        let mut offset_index: std::collections::BTreeMap<i64, Vec<usize>> = std::collections::BTreeMap::new();
1778        for (start, end) in run_first_word {
1779            let word = &words[start];
1780            let rotation = spans[word_rot_run[start].expect("rotated run")].rotation_degrees;
1781            // Only a quarter-turn run has its line offset on x; 180 degrees and
1782            // free angles keep one line per run, as before. Widening past this
1783            // merges runs that were never one line. ~keep
1784            let quarter_turn = (rotation.abs() - 90.0).abs() < 0.5;
1785            if !quarter_turn || !page_is_unrotated {
1786                rotated_lines.push((rotation, f32::NAN, words[start..end].to_vec()));
1787                continue;
1788            }
1789            let across = word.bbox.x;
1790            let tolerance = word.bbox.height.max(1.0) * 0.5;
1791            let quantize = |v: f32| (f64::from(v) * 100.0).round() as i64;
1792            // Widened by one step each way so a value sitting on a bucket edge
1793            // cannot be missed by rounding. Saturating: the cast in `quantize`
1794            // clamps |v| >= ~9.2e16 to i64::MAX/MIN, and a plain +-1 there
1795            // overflows (panic in debug, an inverted `range` panic in release). ~keep
1796            let (lo, hi) = (
1797                quantize(across - tolerance).saturating_sub(1),
1798                quantize(across + tolerance).saturating_add(1),
1799            );
1800            let winner = offset_index
1801                .range(lo..=hi)
1802                .flat_map(|(_, indices)| indices.iter().copied())
1803                .filter(|&i| {
1804                    let (rot, off, _) = &rotated_lines[i];
1805                    (*rot - rotation).abs() < 0.5 && (*off - across).abs() <= tolerance
1806                })
1807                .min();
1808            match winner {
1809                Some(i) => rotated_lines[i].2.extend_from_slice(&words[start..end]),
1810                None => {
1811                    offset_index
1812                        .entry(quantize(across))
1813                        .or_default()
1814                        .push(rotated_lines.len());
1815                    rotated_lines.push((rotation, across, words[start..end].to_vec()));
1816                }
1817            }
1818        }
1819        lines.extend(rotated_lines.into_iter().map(|(rot, off, mut line)| {
1820            // A merged quarter-turn line collects its runs in arrival order,
1821            // which is content-stream order: a subscript drawn after the
1822            // line's tail lands at the end of the string. Order members
1823            // along the writing axis instead — ascending y for +90,
1824            // descending for -90 — the order the text assembler reads them
1825            // in. Stable, so runs sharing a coordinate keep drawing order. ~keep
1826            if !off.is_nan() {
1827                if rot > 0.0 {
1828                    line.sort_by(|a, b| a.bbox.y.total_cmp(&b.bbox.y));
1829                } else {
1830                    line.sort_by(|a, b| b.bbox.y.total_cmp(&a.bbox.y));
1831                }
1832            }
1833            line
1834        }));
1835        // Reading order: sort lines by the span sequence of their first word
1836        // (stable so intra-line order is preserved). ~keep
1837        lines.sort_by_key(|line| line.first().map(|w| w.sequence).unwrap_or(usize::MAX));
1838
1839        Ok(lines.into_iter().map(TextLine::new).collect())
1840    }
1841
1842    /// Apply intelligent text post-processing to extracted text spans.
1843    ///
1844    /// This method applies several text quality improvements:
1845    /// - Ligature expansion (fi, fl, ffi, ffl → component characters)
1846    /// - Hyphenation reconstruction (rejoins words split across lines)
1847    /// - Whitespace normalization (removes excess spaces within words)
1848    /// - Special character spacing (Greek letters, math symbols)
1849    /// - OCR text cleanup (when font_name == "OCR" or from known OCR engines)
1850    ///
1851    /// # Arguments
1852    ///
1853    /// * `spans` - Vector of TextSpan extracted from pages
1854    ///
1855    /// # Returns
1856    ///
1857    /// Processed spans with improved text quality
1858    ///
1859    /// # Examples
1860    ///
1861    /// ```no_run
1862    /// # use xberg_native_pdf::PdfDocument;
1863    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
1864    /// let mut doc = PdfDocument::open("example.pdf")?;
1865    ///
1866    /// // Extract spans from page
1867    /// let spans = doc.extract_spans(0)?;
1868    ///
1869    /// // Apply intelligent processing
1870    /// let processed = doc.apply_intelligent_text_processing(spans);
1871    ///
1872    /// for span in &processed {
1873    ///     println!("{}", span.text); // Ligatures expanded, hyphenation fixed
1874    /// }
1875    /// # Ok(())
1876    /// # }
1877    /// ```
1878    pub fn apply_intelligent_text_processing(&self, mut spans: Vec<TextSpan>) -> Vec<TextSpan> {
1879        use crate::converters::text_post_processor::TextPostProcessor;
1880
1881        for span in &mut spans {
1882            let is_ocr = span.font_name == "OCR"
1883                || span.font_name.to_lowercase().contains("tesseract")
1884                || span.font_name.to_lowercase().contains("abbyy");
1885
1886            // Step 2: Apply text post-processing pipeline
1887            // (hyphenation, whitespace, special char spacing).
1888            // Ligature characters from the font's ToUnicode map are preserved as-is. ~keep
1889            span.text = TextPostProcessor::process(&span.text);
1890
1891            if is_ocr {
1892                span.text = span
1893                    .text
1894                    .replace("fi", "fi")
1895                    .replace("fl", "fl")
1896                    .replace("ff", "ff")
1897                    .replace("  ", " ");
1898            }
1899        }
1900
1901        spans
1902    }
1903
1904    /// Extract hierarchical content structure from a page.
1905    ///
1906    /// Returns the page's hierarchical content structure with all children populated.
1907    /// For tagged PDFs with structure trees, returns the structure with extracted content.
1908    /// For untagged PDFs, returns a synthetic hierarchy based on geometric analysis.
1909    ///
1910    /// # Arguments
1911    ///
1912    /// * `page_index` - The page to extract from (0-indexed)
1913    ///
1914    /// # Returns
1915    ///
1916    /// `Ok(Some(structure))` if structure is found or generated,
1917    /// `Ok(None)` if no structure is available,
1918    /// `Err` if an error occurs during extraction
1919    ///
1920    /// # PDF Spec Compliance
1921    ///
1922    /// - ISO 32000-1:2008, Section 14.7 - Logical Structure
1923    /// - ISO 32000-1:2008, Section 14.8 - Tagged PDF
1924    ///
1925    /// # Example
1926    ///
1927    /// ```no_run
1928    /// # use xberg_native_pdf::document::PdfDocument;
1929    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
1930    /// let mut doc = PdfDocument::open("example.pdf")?;
1931    ///
1932    /// // Extract hierarchical structure from first page
1933    /// if let Some(structure) = doc.extract_hierarchical_content(0)? {
1934    ///     println!("Document structure type: {}", structure.structure_type);
1935    ///     println!("Number of children: {}", structure.children.len());
1936    /// }
1937    /// # Ok(())
1938    /// # }
1939    /// ```
1940    pub fn extract_hierarchical_content(&self, page_index: usize) -> Result<Option<crate::elements::StructureElement>> {
1941        use crate::extractors::HierarchicalExtractor;
1942        HierarchicalExtractor::extract_page(self, page_index)
1943    }
1944
1945    /// Get the raw content stream data for a page.
1946    ///
1947    /// This returns the decoded content stream bytes for the specified page.
1948    /// The content stream contains PDF operators that define the page's appearance.
1949    pub fn get_page_content_data(&self, page_index: usize) -> Result<Vec<u8>> {
1950        Ok((*self.cached_page_content(page_index)?).clone())
1951    }
1952
1953    /// Shared, cached content-stream bytes for a page — the same data
1954    /// [`Self::get_page_content_data`] returns, minus the copy. Extraction
1955    /// only ever reads the bytes, and a single `extract_words` page touches
1956    /// this twice (once for spans, once for chars), so handing back the `Arc`
1957    /// avoids copying the decompressed stream on every call.
1958    fn cached_page_content(&self, page_index: usize) -> Result<std::sync::Arc<Vec<u8>>> {
1959        {
1960            let mut cache = self.page_content_cache.lock_or_recover();
1961            if let Some(data) = cache.get(&page_index) {
1962                return Ok(std::sync::Arc::clone(data));
1963            }
1964        }
1965
1966        self.ensure_encryption_initialized()?;
1967
1968        let page = self.get_page(page_index)?;
1969        let page_dict = page.as_dict().ok_or_else(|| Error::ParseError {
1970            offset: 0,
1971            reason: "Page is not a dictionary".to_string(),
1972        })?;
1973
1974        // Get content stream(s) — Contents is optional per ISO 32000-1:2008 Table 30 ~keep
1975        let contents_ref = match page_dict.get("Contents") {
1976            Some(Object::Null) | None => {
1977                tracing::debug!(target: LOG_TARGET, "Page {} has no /Contents (blank page)", page_index);
1978                return Ok(std::sync::Arc::new(Vec::new()));
1979            }
1980            Some(c) => c,
1981        };
1982
1983        let content_data = if let Some(contents_ref_val) = contents_ref.as_reference() {
1984            let contents = self.load_object(contents_ref_val)?;
1985
1986            if let Some(contents_array) = contents.as_array() {
1987                let mut combined = Vec::new();
1988
1989                for content_item in contents_array.iter() {
1990                    if matches!(content_item, Object::Null) {
1991                        continue;
1992                    }
1993                    match (|| -> Result<Vec<u8>> {
1994                        if let Some(ref_val) = content_item.as_reference() {
1995                            let content_obj = self.load_object(ref_val)?;
1996                            self.decode_stream_with_encryption(&content_obj, ref_val)
1997                        } else {
1998                            content_item.decode_stream_data()
1999                        }
2000                    })() {
2001                        Ok(decoded) => {
2002                            combined.extend_from_slice(&decoded);
2003                            combined.push(b'\n');
2004                        }
2005                        Err(error) => {
2006                            tracing::warn!(
2007                                target: crate::LOG_TARGET_ROOT,
2008                                operation = "decode_optional_page_content",
2009                                page_index,
2010                                error_code = error.telemetry_code(),
2011                                error_offset = ?error.telemetry_offset(),
2012                                "skipping corrupt optional page content stream"
2013                            );
2014                        }
2015                    }
2016                }
2017
2018                combined
2019            } else {
2020                self.decode_stream_with_encryption(&contents, contents_ref_val)?
2021            }
2022        } else if let Some(contents_array) = contents_ref.as_array() {
2023            let mut combined = Vec::new();
2024
2025            for content_item in contents_array.iter() {
2026                if matches!(content_item, Object::Null) {
2027                    continue;
2028                }
2029                match (|| -> Result<Vec<u8>> {
2030                    if let Some(ref_val) = content_item.as_reference() {
2031                        let content_obj = self.load_object(ref_val)?;
2032                        self.decode_stream_with_encryption(&content_obj, ref_val)
2033                    } else {
2034                        content_item.decode_stream_data()
2035                    }
2036                })() {
2037                    Ok(decoded) => {
2038                        combined.extend_from_slice(&decoded);
2039                        combined.push(b'\n');
2040                    }
2041                    Err(error) => {
2042                        tracing::warn!(
2043                            target: crate::LOG_TARGET_ROOT,
2044                            operation = "decode_optional_page_content",
2045                            page_index,
2046                            error_code = error.telemetry_code(),
2047                            error_offset = ?error.telemetry_offset(),
2048                            "skipping corrupt optional page content stream"
2049                        );
2050                    }
2051                }
2052            }
2053
2054            combined
2055        } else {
2056            // Direct stream object (rare but possible)
2057            // For direct objects, use regular decoding (no encryption key) ~keep
2058            contents_ref.decode_stream_data()?
2059        };
2060
2061        tracing::trace!(target: LOG_TARGET,
2062            page = page_index,
2063            bytes = content_data.len(),
2064            "retrieved page content data"
2065        );
2066
2067        let content_data = std::sync::Arc::new(content_data);
2068        self.page_content_cache
2069            .lock_or_recover()
2070            .insert(page_index, std::sync::Arc::clone(&content_data));
2071
2072        Ok(content_data)
2073    }
2074}