Skip to main content

xberg_native_pdf/document/
reading_order.rs

1//! Reading-order selection, MCID ordering, and RTL line merging.
2//!
3//! Split out of the parent's single 18,544-line `impl PdfDocument`, which made
4//! `document.rs` 1.2 MiB and tripped the 500 KiB file-safety limit. A child module's
5//! `impl` is the same inherent impl and sees the parent's private items unchanged. ~keep
6
7use super::*;
8
9impl PdfDocument {
10    /// Check if decoded content stream data may contain text.
11    ///
12    /// Returns true if the stream contains either:
13    /// - A BT (Begin Text) operator (text is directly in the page stream)
14    /// - A Do operator (Form XObject invocation that may contain text)
15    ///
16    /// Per §9.4.3, text-showing operators shall only appear within BT...ET text
17    /// objects. However, a page may contain text only inside Form XObjects
18    /// referenced via `Do` operators, so we must also check for those.
19    pub(crate) fn may_contain_text(data: &[u8]) -> bool {
20        // SIMD-accelerated pre-check using memchr to find candidate positions
21        // for BT (Begin Text) and Do (XObject invocation) operators.
22        // ~50x faster than byte-by-byte scanning for large graphics-heavy pages. ~keep
23        fn is_boundary(b: u8) -> bool {
24            b.is_ascii_whitespace() || matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%')
25        }
26
27        let len = data.len();
28        let mut offset = 0;
29        while offset + 1 < len {
30            match memchr::memchr2(b'B', b'D', &data[offset..]) {
31                None => return false,
32                Some(pos) => {
33                    let i = offset + pos;
34                    if i + 1 >= len {
35                        return false;
36                    }
37                    if data[i] == b'B' && data[i + 1] == b'T' {
38                        let before_ok = i == 0 || is_boundary(data[i - 1]);
39                        let after_ok = i + 2 >= len || is_boundary(data[i + 2]);
40                        if before_ok && after_ok {
41                            return true;
42                        }
43                    }
44                    if data[i] == b'D' && data[i + 1] == b'o' {
45                        let before_ok = i == 0 || is_boundary(data[i - 1]);
46                        let after_ok = i + 2 >= len || is_boundary(data[i + 2]);
47                        if before_ok && after_ok {
48                            return true;
49                        }
50                    }
51                    offset = i + 1;
52                }
53            }
54        }
55        false
56    }
57
58    /// Check if a page definitely cannot produce any text based on its resources.
59    ///
60    /// Returns `true` if the page has no `/Font` resources and no Form XObjects
61    /// (which could contain nested text). This allows skipping content stream
62    /// decompression and parsing entirely for image-only/scanned pages.
63    ///
64    /// Returns `false` (conservative) if resources can't be inspected.
65    pub(super) fn page_cannot_have_text(&self, page_dict: &HashMap<String, Object>) -> bool {
66        let resources = match page_dict.get("Resources") {
67            Some(r) => {
68                if let Some(ref_obj) = r.as_reference() {
69                    match self.load_object(ref_obj) {
70                        Ok(obj) => obj,
71                        Err(_) => return false, // Can't resolve — be conservative ~keep
72                    }
73                } else {
74                    r.clone()
75                }
76            }
77            None => return true,
78        };
79
80        let res_dict = match resources.as_dict() {
81            Some(d) => d,
82            None => return false,
83        };
84
85        if let Some(font_obj) = res_dict.get("Font") {
86            let font_dict = if let Some(ref_obj) = font_obj.as_reference() {
87                self.load_object(ref_obj).ok()
88            } else {
89                Some(font_obj.clone())
90            };
91            if let Some(fd) = font_dict
92                && let Some(d) = fd.as_dict()
93                && !d.is_empty()
94            {
95                return false;
96            }
97        }
98
99        // Check XObjects: if any are Form type, they could contain nested text.
100        // Uses lightweight is_form_xobject() peek instead of full load_object()
101        // to avoid expensive I/O for image-heavy PDFs (e.g., Deutsche: 375MB images). ~keep
102        if let Some(xobj_obj) = res_dict.get("XObject") {
103            let xobj_dict_obj = if let Some(ref_obj) = xobj_obj.as_reference() {
104                self.load_object(ref_obj).ok()
105            } else {
106                Some(xobj_obj.clone())
107            };
108            if let Some(xobj_dict_resolved) = xobj_dict_obj
109                && let Some(xobj_dict) = xobj_dict_resolved.as_dict()
110            {
111                for xobj_ref in xobj_dict.values() {
112                    if let Some(ref_obj) = xobj_ref.as_reference() {
113                        if self.is_form_xobject(ref_obj) {
114                            return false;
115                        }
116                    } else if let Some(d) = xobj_ref.as_dict()
117                        && d.get("Subtype").and_then(|s| s.as_name()) == Some("Form")
118                    {
119                        return false;
120                    }
121                }
122            }
123        }
124
125        true
126    }
127
128    /// Assemble the page's text spans via the reading-order
129    /// pipeline, classifying each region with the per-class
130    /// detectors in [`crate::pipeline::reading_order::detectors`].
131    /// Returns the assembled spans plus the detector class that
132    /// fired on each region.
133    ///
134    /// The four detectors handle layout shapes that the plain
135    /// y-then-x assembly cannot produce correctly:
136    ///
137    /// - **DramaticScript**: Macbeth-style speaker-tag layouts —
138    ///   row-major join required.
139    /// - **DenseSingleLine**: SEC DEF 14A 8pt-body interleave —
140    ///   single-row regroup required.
141    /// - **SubSuperBaselineReattach**: chemical-formula
142    ///   subscripts — baseline reattach required.
143    /// - **NarrowTrackedJustified**: stretched justified columns —
144    ///   per-line median-gap threshold normalisation required.
145    ///
146    /// Regions that don't match any specific layout fall through to
147    /// `Default` (plain y-then-x assembly within the block).
148    ///
149    /// Callers can use this as a pre-step before applying their own
150    /// assembly logic, or rely on the classified `ReadingOrderClass`
151    /// to dispatch their assembly strategy. `extract_text` consumes
152    /// this implicitly through `extract_spans` + the existing
153    /// `XYCutStrategy`.
154    pub fn assemble_text_via_reading_order(
155        &self,
156        page_index: usize,
157    ) -> Result<(
158        Vec<crate::layout::TextSpan>,
159        crate::pipeline::reading_order::ReadingOrderClass,
160    )> {
161        if self.is_encrypted_unreadable() {
162            tracing::warn!(target: LOG_TARGET, "PDF is encrypted and could not be decrypted; returning empty text");
163            return Ok((Vec::new(), crate::pipeline::reading_order::ReadingOrderClass::Default));
164        }
165        let spans = self.extract_spans(page_index)?;
166        // Convert spans to detector input. We only need the geometric
167        // signal (x/y/width/font_size), not the full TextSpan
168        // semantics. ~keep
169        let glyphs: Vec<crate::pipeline::reading_order::DetectorGlyph> = spans
170            .iter()
171            .map(|s| crate::pipeline::reading_order::DetectorGlyph {
172                x: s.bbox.x,
173                y: s.bbox.y,
174                width: s.bbox.width,
175                font_size: s.font_size,
176                text_len: s.text.chars().count(),
177            })
178            .collect();
179        // Build per-row text strings for DramaticScript detector,
180        // together with the leftmost glyph of each row (for the X-
181        // consistency check). Group spans by Y (within 0.5 pt),
182        // concatenating their texts in the order they appear in
183        // `spans` and tracking the smallest X seen per row. ~keep
184        let mut rows: Vec<(f32, String, crate::pipeline::reading_order::DetectorGlyph)> = Vec::new();
185        for span in &spans {
186            let span_glyph = crate::pipeline::reading_order::DetectorGlyph {
187                x: span.bbox.x,
188                y: span.bbox.y,
189                width: span.bbox.width,
190                font_size: span.font_size,
191                text_len: span.text.chars().count(),
192            };
193            let mut placed = false;
194            for (y, text, first) in rows.iter_mut() {
195                if (*y - span.bbox.y).abs() < 0.5 {
196                    text.push(' ');
197                    text.push_str(&span.text);
198                    if span_glyph.x < first.x {
199                        *first = span_glyph;
200                    }
201                    placed = true;
202                    break;
203                }
204            }
205            if !placed {
206                rows.push((span.bbox.y, span.text.clone(), span_glyph));
207            }
208        }
209        let row_texts: Vec<&str> = rows.iter().map(|(_, t, _)| t.as_str()).collect();
210        let row_first_glyphs: Vec<crate::pipeline::reading_order::DetectorGlyph> =
211            rows.iter().map(|(_, _, g)| *g).collect();
212        let class = crate::pipeline::reading_order::classify_region(&glyphs, &row_first_glyphs, &row_texts);
213        Ok((spans, class))
214    }
215
216    /// Returns `true` if the page has any text-bearing content (fonts in
217    /// resources + at least one `BT`/`Do` operator in the content stream),
218    /// `false` if the page is image-only or genuinely empty.
219    ///
220    /// Callers can route image-only pages to their own OCR pipeline
221    /// instead of receiving an empty string with no signal.
222    ///
223    /// Conservative: returns `true` when the page resources can't be
224    /// inspected (load error, encrypted-not-authenticated, etc.) so the
225    /// caller still attempts extraction.
226    ///
227    /// # PDF spec basis
228    ///
229    /// §8.8 (Image XObjects): image-only pages have `/Resources` whose
230    /// only `/XObject` entries are `/Subtype /Image` with no `/Font`
231    /// resources.
232    pub fn has_text_layer(&self, page_index: usize) -> Result<bool> {
233        let page = self.get_page(page_index)?;
234        let page_dict = page.as_dict().ok_or_else(|| Error::ParseError {
235            offset: 0,
236            reason: "Page is not a dictionary".to_string(),
237        })?;
238        if self.page_cannot_have_text(page_dict) {
239            return Ok(false);
240        }
241        // Probe content stream for text-showing operators. If we can't
242        // read the content stream, be conservative and say yes (let
243        // extraction try). ~keep
244        match self.get_page_content_data(page_index) {
245            Ok(content_data) => Ok(Self::may_contain_text(&content_data)),
246            Err(_) => Ok(true),
247        }
248    }
249
250    /// Returns the document's `/P` permission flags as a `PdfPermissions`
251    /// struct if the document is encrypted; `None` otherwise.
252    ///
253    /// Per PDF spec §7.6.3.2 the `/P` flag is advisory — xberg-native-pdf
254    /// does not enforce restrictions — but callers who want to
255    /// enforce them (e.g., refuse copy-protected PDF extraction) can
256    /// do so themselves by checking the returned permissions.
257    ///
258    /// # PDF spec basis
259    ///
260    /// §7.6.3.2 Table 22 (`/P` Standard Encryption Dictionary entry).
261    /// Decoding is implemented in `encryption::permissions::PdfPermissions::from_p_flag`.
262    pub fn permissions(&self) -> Option<crate::encryption::PdfPermissions> {
263        // ensure_encryption_initialized may fail on malformed Encrypt
264        // dicts — that's fine, no permissions surface for those. ~keep
265        let _ = self.ensure_encryption_initialized();
266        let handler = self.encryption_handler.lock_or_recover();
267        let handler = handler.as_ref()?;
268        Some(crate::encryption::PdfPermissions::from_p_flag(
269            handler.raw_permissions(),
270        ))
271    }
272
273    /// Order one MCID's spans for emission in the structure-order assemblers
274    ///. A single marked-content element can carry spans across several
275    /// visual lines; emitting them in raw extraction order can mis-order them,
276    /// so sort by the canonical reading-order comparator. Skipped for single-
277    /// span MCIDs and for any MCID containing RTL text (whose span order is
278    /// handled by the bidi passes) — both stay byte-identical.
279    pub(super) fn order_mcid_spans(spans: &[crate::layout::TextSpan]) -> Vec<&crate::layout::TextSpan> {
280        use crate::text::rtl_detector::is_rtl_text;
281        let mut ordered: Vec<&crate::layout::TextSpan> = spans.iter().collect();
282        if spans.len() <= 1 {
283            return ordered;
284        }
285        let has_rtl = spans.iter().any(|s| s.text.chars().any(|c| is_rtl_text(c as u32)));
286        let has_latin = spans.iter().any(|s| s.text.chars().any(|c| c.is_ascii_alphabetic()));
287        if !has_rtl {
288            ordered.sort_by(|a, b| crate::utils::row_aware_span_cmp(a.bbox.y, a.bbox.x, b.bbox.y, b.bbox.x));
289        } else if !has_latin {
290            // Pure-RTL MCID. The tagged struct-tree path never
291            // reaches `reverse_rtl_visual_order_runs`, so without an explicit
292            // span-order pass the words emerge in visual (reversed) sequence.
293            // Emitting each row right-to-left (X descending) reconstructs
294            // logical reading order from geometry, independent of whether the
295            // producer stored the run visually or logically. Per-span glyph ~keep
296            // order is corrected separately by `push_span_text_bidi`.
297            ordered = Self::order_pure_rtl_spans(spans);
298        }
299        // Mixed RTL+Latin MCIDs keep raw order (full UAX #9 bidi deferred). ~keep
300        ordered
301    }
302
303    /// Order a pure-RTL MCID's spans into logical reading order: group spans
304    /// into visual lines using a **font-relative** vertical tolerance, then
305    /// emit each line right-to-left (X descending).
306    ///
307    /// A fixed quantized row band (`row_aware_span_cmp_rtl` with the global
308    /// `ROW_BAND_TOLERANCE_PT`) over-segments Arabic lines. Producers routinely
309    /// draw zero-advance glyphs — hamza seats, shadda/kasra marks, and even
310    /// whole consonants positioned by a separate zero-width show — 1–3 pt off
311    /// the baseline. A coarse fixed band rounds those into adjacent rows, which
312    /// then emit before or after the body of the line and scatter the run (the
313    /// telltale leading run of stray alef/hamza glyphs). Banding by a tolerance
314    /// proportional to the glyph size keeps one jittery line intact while still
315    /// separating genuinely distinct lines, whose leading is ~1.2× the font
316    /// size — comfortably beyond the tolerance. Per-span glyph order is fixed
317    /// separately by [`push_span_text_bidi`]; this function only fixes the order
318    /// in which spans are emitted.
319    pub(super) fn order_pure_rtl_spans(spans: &[crate::layout::TextSpan]) -> Vec<&crate::layout::TextSpan> {
320        use crate::utils::safe_float_cmp;
321        let mut by_y: Vec<&crate::layout::TextSpan> = spans.iter().collect();
322        // Stable sort, Y descending (top of page first). Ties keep extraction
323        // (content-stream) order; the X-descending pass below refines each line. ~keep
324        by_y.sort_by(|a, b| safe_float_cmp(b.bbox.y, a.bbox.y));
325
326        let mut out: Vec<&crate::layout::TextSpan> = Vec::with_capacity(spans.len());
327        let mut line: Vec<&crate::layout::TextSpan> = Vec::new();
328        let mut anchor_y = f32::NAN;
329        let mut tol = 0.0f32;
330        for s in by_y {
331            let fs = if s.font_size.is_finite() && s.font_size > 1.0 {
332                s.font_size
333            } else {
334                10.0
335            };
336            let starts_new_line = anchor_y.is_finite() && (!s.bbox.y.is_finite() || anchor_y - s.bbox.y > tol);
337            if anchor_y.is_nan() || starts_new_line {
338                if !line.is_empty() {
339                    line.sort_by(|a, b| safe_float_cmp(b.bbox.x, a.bbox.x));
340                    out.append(&mut line);
341                }
342                anchor_y = s.bbox.y;
343                tol = 0.5 * fs;
344            }
345            line.push(s);
346        }
347        if !line.is_empty() {
348            line.sort_by(|a, b| safe_float_cmp(b.bbox.x, a.bbox.x));
349            out.append(&mut line);
350        }
351        out
352    }
353
354    /// Pre-pass for the cross-span Arabic GLYPH-interleave defect. Some producers
355    /// draw one Arabic word as a multi-glyph body span PLUS separate zero-width
356    /// mark / consonant spans positioned by their own show, each at its true x.
357    /// The atom-level span sort ([`order_pure_rtl_spans`]) then orders those spans
358    /// as whole units and reverses each independently, so a zero-width glyph whose
359    /// x falls *inside* a sibling span's x-extent lands at a word edge instead of
360    /// interleaved — `الثدييات` extracts as `ثالدييات`.
361    ///
362    /// Returns `Some(owned spans)` with each affected visual LINE collapsed into a
363    /// single visual-order span (so the downstream [`push_span_text_bidi`] reverse
364    /// produces correct logical order), or `None` when no line exhibits the defect
365    /// (then the caller uses the original spans, byte-identical). Gated tightly:
366    /// fires only on a pure-RTL line (no ASCII-alpha) that actually contains a
367    /// zero-width span interleaved inside another — a page with no such interleave
368    /// (BidiSample, ArabicCIDTrueType-logical, hebrew_mirrored) returns `None`.
369    pub(super) fn merge_interleaved_rtl_lines(
370        spans: &[crate::layout::TextSpan],
371    ) -> Option<Vec<crate::layout::TextSpan>> {
372        use crate::utils::safe_float_cmp;
373        if spans.len() < 3 {
374            return None;
375        }
376        // Group into visual lines by a font-relative Y tolerance (mirrors
377        // order_pure_rtl_spans banding). ~keep
378        let mut by_y: Vec<&crate::layout::TextSpan> = spans.iter().collect();
379        by_y.sort_by(|a, b| safe_float_cmp(b.bbox.y, a.bbox.y));
380        let mut lines: Vec<Vec<&crate::layout::TextSpan>> = Vec::new();
381        // Roll the line-break reference forward to the PREVIOUS span's y rather
382        // than pinning it to the band's first (topmost) span. RTL producers seat
383        // a line's glyphs across a few points of vertical jitter — combining
384        // marks ride high, a line-final letter can sit a few points low (P2: a
385        // width-0 final glyph at dy≈3pt below the baseline). Against a fixed top
386        // anchor the line's own span furthest above the baseline sets the band
387        // ceiling, so the lowest glyph can exceed `0.5·fs` and split onto its own
388        // "line" — which then reverses and lands after the sentence terminator,
389        // detaching the line's final letter. Comparing each span to its immediate
390        // predecessor keeps a line whose internal step is < tol intact while a
391        // real inter-line gap (leading ≈ one full em, well over tol) still opens
392        // the next band. ~keep
393        let mut prev_y = f32::NAN;
394        let mut tol = 0.0f32;
395        for s in by_y {
396            let fs = if s.font_size.is_finite() && s.font_size > 1.0 {
397                s.font_size
398            } else {
399                10.0
400            };
401            let new_line = prev_y.is_finite() && (!s.bbox.y.is_finite() || prev_y - s.bbox.y > tol);
402            if prev_y.is_nan() || new_line {
403                lines.push(Vec::new());
404                tol = 0.5 * fs;
405            }
406            prev_y = s.bbox.y;
407            lines.last_mut().unwrap().push(s);
408        }
409
410        let mut any_gated = false;
411        let mut out: Vec<crate::layout::TextSpan> = Vec::with_capacity(spans.len());
412        for line in &lines {
413            if Self::rtl_line_needs_glyph_reorder(line) {
414                any_gated = true;
415                out.push(Self::merge_rtl_line_to_visual_span(line));
416            } else {
417                out.extend(line.iter().map(|s| (*s).clone()));
418            }
419        }
420        if any_gated { Some(out) } else { None }
421    }
422
423    /// True when a visual line exhibits the zero-width-glyph interleave defect:
424    /// (1) pure-RTL — no span carries an ASCII-alphabetic char and at least one
425    /// carries an RTL letter; AND (2) a zero-width span's x lies STRICTLY inside
426    /// another span's `[x, x+width]` on the line. Both are required so ordinary
427    /// pure-RTL text (no interleave) and any mixed-Latin line are left untouched.
428    pub(super) fn rtl_line_needs_glyph_reorder(line: &[&crate::layout::TextSpan]) -> bool {
429        use crate::text::rtl_detector::is_rtl_text;
430        if line.len() < 2 {
431            return false;
432        }
433        let mut has_rtl = false;
434        for s in line {
435            for c in s.text.chars() {
436                if c.is_ascii_alphabetic() {
437                    return false;
438                }
439                if is_rtl_text(c as u32) {
440                    has_rtl = true;
441                }
442            }
443        }
444        if !has_rtl {
445            return false;
446        }
447        line.iter().any(|m| {
448            m.bbox.width.abs() < 0.01
449                && line.iter().any(|b| {
450                    !std::ptr::eq(*m, *b)
451                        && b.bbox.width > 0.01
452                        && m.bbox.x > b.bbox.x
453                        && m.bbox.x < b.bbox.x + b.bbox.width
454                })
455        })
456    }
457
458    pub(super) fn merge_rtl_line_to_visual_span(line: &[&crate::layout::TextSpan]) -> crate::layout::TextSpan {
459        use crate::text::rtl_detector::is_rtl_diacritic;
460        use crate::utils::safe_float_cmp;
461        // Explode to glyphs: split bases from combining marks, DROP shatter spaces
462        // that are interior to a multi-glyph span, but record the x of each
463        // STANDALONE space span — those are the producer's real word boundaries
464        // (geometric gap-thresholding is unreliable for cursive Arabic, so we use
465        // the producer's own segmentation instead). ~keep
466        let mut bases: Vec<(f32, char)> = Vec::new();
467        let mut marks: Vec<(f32, char)> = Vec::new();
468        let mut word_space_x: Vec<f32> = Vec::new();
469        for s in line {
470            if !s.text.is_empty() && s.text.chars().all(|c| c.is_whitespace()) {
471                word_space_x.push(s.bbox.x + s.bbox.width * 0.5);
472                continue;
473            }
474            // Pre-collect the span's chars so a whitespace glyph can see its
475            // non-mark neighbours (to tell a cursive-join shatter space from a
476            // genuine word break). ~keep
477            let span_chars: Vec<char> = s.to_chars().into_iter().map(|t| t.char).collect();
478            for (idx, tc) in s.to_chars().into_iter().enumerate() {
479                let c = tc.char;
480                if c.is_whitespace() {
481                    // ISO 32000-1 §14.8.2.3.3: a SPACE that borders a
482                    // NON-CURSIVE token (clause punctuation / symbol — not an
483                    // Arabic/Hebrew letter and not a digit) is a real word
484                    // break, so record its x. A space flanked by cursive letters
485                    // is the producer's intra-word shatter (dropped), and a
486                    // space between digits is a thousands separator (dropped) —
487                    // neither is a word boundary. ~keep
488                    use crate::text::rtl_detector::{is_arabic_letter, is_arabic_number, is_hebrew_letter};
489                    let neighbour = |it: &mut dyn Iterator<Item = &char>| -> Option<char> {
490                        it.copied().find(|&p| !p.is_whitespace() && !is_rtl_diacritic(p as u32))
491                    };
492                    let is_boundary_marker = |o: Option<char>| {
493                        o.is_some_and(|p| {
494                            let u = p as u32;
495                            !is_arabic_letter(u) && !is_hebrew_letter(u) && !is_arabic_number(u) && !p.is_ascii_digit()
496                        })
497                    };
498                    let prev = neighbour(&mut span_chars[..idx].iter().rev());
499                    let next = neighbour(&mut span_chars[idx + 1..].iter());
500                    if is_boundary_marker(prev) || is_boundary_marker(next) {
501                        word_space_x.push(tc.bbox.x + tc.bbox.width * 0.5);
502                    }
503                    continue;
504                }
505                if is_rtl_diacritic(c as u32) {
506                    marks.push((tc.bbox.x, c));
507                } else {
508                    bases.push((tc.bbox.x, c));
509                }
510            }
511        }
512        if bases.is_empty() {
513            return (*line[0]).clone();
514        }
515        bases.sort_by(|a, b| safe_float_cmp(a.0, b.0));
516        let mut trailing: Vec<Vec<char>> = vec![Vec::new(); bases.len()];
517        for (mx, mc) in &marks {
518            let mut best = 0usize;
519            let mut best_d = f32::MAX;
520            for (i, (bx, _)) in bases.iter().enumerate() {
521                let d = (bx - mx).abs();
522                if d < best_d {
523                    best_d = d;
524                    best = i;
525                }
526            }
527            trailing[best].push(*mc);
528        }
529        // Emit visual (ascending-x) order: each base then its marks, with a single
530        // word-boundary marker wherever a producer word-boundary x falls between two
531        // bases. The marker is the private-use sentinel [`Self::RTL_WORD_BOUNDARY`],
532        // not a plain SPACE, so the downstream `strip_interior_arabic_spaces` (which
533        // only removes U+0020) cannot mistake this AUTHORITATIVE producer-segmented
534        // word break for a cursive-shatter artefact and delete it; each output site
535        // restores it to a SPACE right after the strip. The downstream reverse maps
536        // this to logical order with words intact. ~keep
537        let mut text = String::new();
538        let mut prev_x: Option<f32> = None;
539        for (i, (bx, bc)) in bases.iter().enumerate() {
540            if let Some(px) = prev_x
541                && word_space_x.iter().any(|sx| *sx > px && *sx < *bx)
542                && !text.ends_with(Self::RTL_WORD_BOUNDARY)
543            {
544                text.push(Self::RTL_WORD_BOUNDARY);
545            }
546            text.push(*bc);
547            for m in &trailing[i] {
548                text.push(*m);
549            }
550            prev_x = Some(*bx);
551        }
552        let mut merged = (*line[0]).clone();
553        let x_min = line.iter().map(|s| s.bbox.x).fold(f32::MAX, f32::min);
554        let x_max = line.iter().map(|s| s.bbox.x + s.bbox.width).fold(f32::MIN, f32::max);
555        merged.text = text;
556        merged.bbox.x = x_min;
557        merged.bbox.width = (x_max - x_min).max(0.0);
558        merged.char_widths = Vec::new();
559        merged
560    }
561
562    ///
563    /// Used by paths that operate on raw spans rather than ordered
564    /// spans (`extract_page_text`, `extract_structured`,
565    /// `extract_spans_with_reading_order`). Mutates each covered span's
566    /// text to the replacement (run-first only) or clears it
567    /// (continuation / suppress-only / non-first-page coverage); fully
568    /// suppressed spans are removed.
569    ///
570    /// Untagged documents and pages with no coverage are no-ops.
571    pub(crate) fn apply_actualtext_to_spans(&self, page_index: usize, spans: &mut Vec<crate::layout::TextSpan>) {
572        let Some(idx) = self.actualtext_index() else {
573            return;
574        };
575        if idx.covered_mcids.is_empty() {
576            return;
577        }
578        let mc_wins: HashSet<u32> = self
579            .mc_actualtext_mcids
580            .lock_or_recover()
581            .get(&page_index)
582            .cloned()
583            .unwrap_or_default();
584
585        let default_scope = crate::structure::McidScope::Page(page_index as u32);
586        // Visibility = "has at least one raw span at this (scope, mcid)".
587        // glyph_text accumulates each key's rendered text for the §14.9.4
588        // conformance gate (decline destructive replacements). ~keep
589        let mut present: HashSet<(crate::structure::McidScope, u32)> = HashSet::new();
590        let mut glyph_text: HashMap<(crate::structure::McidScope, u32), String> = HashMap::new();
591        for s in spans.iter() {
592            if let Some(m) = s.mcid {
593                let scope = s.mcid_scope.clone().unwrap_or(default_scope.clone());
594                present.insert((scope.clone(), m));
595                glyph_text.entry((scope, m)).or_default().push_str(&s.text);
596            }
597        }
598        // Walk the structure-tree's per-page MCID order so the
599        // consecutive-run dedup matches the assemblers'. ~keep
600        let mcid_order = self
601            .struct_tree_marked()
602            .map(|t| self.cached_mcid_order_for_page(&t, page_index as u32))
603            .unwrap_or_default();
604        let actions = Self::actualtext_actions_for_page(
605            Some(&idx),
606            &mcid_order,
607            |scope, m| present.contains(&(scope.clone(), m)),
608            &mc_wins,
609            &glyph_text,
610        );
611        if actions.is_empty() {
612            return;
613        }
614
615        // Apply actions to the raw spans. EmitAndSuppress mutates the
616        // first span of the (scope, mcid) key; subsequent spans for
617        // the same key are dropped (so a key with multiple spans
618        // collapses to one span carrying the replacement). Suppress
619        // drops every span with that key. ~keep
620        let mut emit_used: HashSet<(crate::structure::McidScope, u32)> = HashSet::new();
621        let mut drop_idx: Vec<usize> = Vec::new();
622        for (i, s) in spans.iter_mut().enumerate() {
623            let Some(m) = s.mcid else { continue };
624            let scope = s.mcid_scope.clone().unwrap_or(default_scope.clone());
625            let key = (scope, m);
626            match actions.get(&key) {
627                Some(ActualTextAction::EmitAndSuppress(repl)) => {
628                    if emit_used.insert(key) {
629                        s.text = repl.to_string();
630                    } else {
631                        s.text.clear();
632                        drop_idx.push(i);
633                    }
634                }
635                Some(ActualTextAction::Suppress) => {
636                    s.text.clear();
637                    drop_idx.push(i);
638                }
639                None => {}
640            }
641        }
642        for &i in drop_idx.iter().rev() {
643            spans.remove(i);
644        }
645    }
646
647    /// Compute the per-page `MCID → ActualTextAction` map.
648    ///
649    /// Walks `mcid_order` (the structure-tree's per-page MCID sequence
650    /// in pre-order) and groups consecutive covered MCIDs by the
651    /// replacement text they share. Each group emits ONE replacement at
652    /// the first visible-and-not-MC-scope-wins MCID; the rest of the
653    /// group is marked `Suppress` (raw glyphs dropped). MCIDs whose
654    /// `(page, mcid)` lands in `suppress_only` are always `Suppress`
655    /// (their replacement already fired on a different page).
656    ///
657    /// `visible(mcid)` returns `true` when at least one span carries
658    /// the MCID and survives all upstream filters (artifact / OCG /
659    /// region). A run with zero visible MCIDs is dropped entirely (no
660    /// emission, no suppression — nothing to drop).
661    ///
662    /// MCIDs in `mc_wins` keep the in-stream MC-scope `/ActualText`
663    /// replacement applied by the extractor and are exempt from the
664    /// ancestor struct-tree scope; they do not break the run dedup —
665    /// the run can still find a non-MC-wins MCID to emit at.
666    /// §14.9.4 conformance test for a struct-tree `/ActualText` replacement.
667    ///
668    /// Per ISO 32000-1 §14.9.4 (pdf.md:39253) an `/ActualText` value "shall be
669    /// used as a replacement … providing text that is *equivalent to what a
670    /// person would see when viewing the content*"; per §14.8.2.4 NOTE 2
671    /// (pdf.md:37380) a conforming reader *may choose* whether to use it. We
672    /// decline a replacement that is **destructive**: it would suppress glyphs
673    /// carrying alphanumeric (letter/digit, any script) content while itself
674    /// carrying none — e.g. a producer tagging whole words with `" "` or `"-"`.
675    /// Such a value is not "equivalent to what a person would see", so we keep
676    /// the rendered glyphs (extracted via ToUnicode, §14.8.2.4) instead.
677    /// Legitimate ActualText — the spec's hyphenation EXAMPLE `(c)`→`k-`,
678    /// ligature/soft-hyphen substitution (NOTE 3), any real-character
679    /// replacement — is alphanumeric and passes.
680    fn actual_text_is_destructive(replacement: &str, covered_glyphs: &str) -> bool {
681        covered_glyphs.chars().any(char::is_alphanumeric) && !replacement.chars().any(char::is_alphanumeric)
682    }
683
684    fn actualtext_actions_for_page<F: Fn(&crate::structure::McidScope, u32) -> bool>(
685        idx: Option<&crate::structure::ActualTextIndex>,
686        mcid_order: &[(crate::structure::McidScope, u32)],
687        visible: F,
688        mc_wins: &HashSet<u32>,
689        glyph_text: &HashMap<(crate::structure::McidScope, u32), String>,
690    ) -> HashMap<(crate::structure::McidScope, u32), ActualTextAction> {
691        let mut out: HashMap<(crate::structure::McidScope, u32), ActualTextAction> = HashMap::new();
692        let Some(idx) = idx else {
693            return out;
694        };
695        if idx.covered_mcids.is_empty() {
696            return out;
697        }
698
699        // Two-pass walk to support runs that span the input order
700        // perfectly: collect (scope, mcid, replacement?) tuples for
701        // covered MCIDs on this page (across all scopes that render on
702        // it), then group consecutive equal-replacement entries into
703        // runs.
704        //
705        // Replacement = None for `suppress_only` entries and for
706        // covered keys with no text (defensive — shouldn't happen
707        // given the builder invariants). ~keep
708        let mut entries: Vec<(crate::structure::McidScope, u32, Option<&str>)> = Vec::new();
709        for (scope, m) in mcid_order {
710            let key = (scope.clone(), *m);
711            if !idx.covered_mcids.contains(&key) {
712                continue;
713            }
714            if idx.suppress_only.contains(&key) {
715                entries.push((scope.clone(), *m, None));
716                continue;
717            }
718            let text = idx.mcid_to_actual_text.get(&key).map(|s| &**s);
719            entries.push((scope.clone(), *m, text));
720        }
721
722        let mut i = 0usize;
723        while i < entries.len() {
724            let repl_opt = entries[i].2;
725            // Find the end of the consecutive run sharing this
726            // replacement (None matches None — i.e. suppress-only runs
727            // also collapse). ~keep
728            let mut j = i;
729            while j < entries.len() && entries[j].2 == repl_opt {
730                j += 1;
731            }
732
733            if let Some(repl) = repl_opt {
734                // §14.9.4 conformance gate (pdf.md:39253 + NOTE 2 pdf.md:37380):
735                // if this replacement would suppress alphanumeric glyphs while
736                // carrying none itself, it is not "equivalent to what a person
737                // would see" — decline it (emit no action for the run) so the
738                // rendered glyphs survive. See `actual_text_is_destructive`. ~keep
739                let run_glyphs: String = entries[i..j]
740                    .iter()
741                    .filter_map(|e| glyph_text.get(&(e.0.clone(), e.1)))
742                    .map(String::as_str)
743                    .collect();
744                if Self::actual_text_is_destructive(repl, &run_glyphs) {
745                    i = j;
746                    continue;
747                }
748                // Find first emit-eligible entry (visible, not MC-wins).
749                // MC-wins keys are skipped because their replacement
750                // came from the extractor's in-stream BDC /ActualText. ~keep
751                let mut emit_pick: Option<(crate::structure::McidScope, u32)> = None;
752                for entry in &entries[i..j] {
753                    if visible(&entry.0, entry.1) && !mc_wins.contains(&entry.1) {
754                        emit_pick = Some((entry.0.clone(), entry.1));
755                        break;
756                    }
757                }
758                let repl_arc: std::sync::Arc<str> = std::sync::Arc::from(repl);
759                for entry in &entries[i..j] {
760                    if mc_wins.contains(&entry.1) {
761                        // MC-scope wins: do not touch this MCID at all.
762                        // The extractor's inline replacement reaches
763                        // output unmodified. ~keep
764                        continue;
765                    }
766                    let key = (entry.0.clone(), entry.1);
767                    if emit_pick.as_ref() == Some(&key) {
768                        out.insert(key, ActualTextAction::EmitAndSuppress(repl_arc.clone()));
769                    } else {
770                        out.insert(key, ActualTextAction::Suppress);
771                    }
772                }
773            } else {
774                // suppress_only run: every key is suppressed (no
775                // emission). MC-wins MCIDs stay untouched. ~keep
776                for entry in &entries[i..j] {
777                    if mc_wins.contains(&entry.1) {
778                        continue;
779                    }
780                    out.insert((entry.0.clone(), entry.1), ActualTextAction::Suppress);
781                }
782            }
783
784            i = j;
785        }
786        out
787    }
788
789    /// Page's MCID reading order from the all-pages traversal cache
790    /// (`structure_content_cache`, populated once). `build_context` previously
791    /// re-walked the whole tree per page (≈ O(pages²) on a tagged document);
792    /// the cached all-pages walk yields the same per-page order.
793    pub(crate) fn cached_mcid_order_for_page(
794        &self,
795        struct_tree: &crate::structure::StructTreeRoot,
796        page_index: u32,
797    ) -> Vec<(crate::structure::McidScope, u32)> {
798        if self.structure_content_cache.lock_or_recover().is_none() {
799            let all_content = crate::structure::traverse_structure_tree_all_pages(struct_tree);
800            *self.structure_content_cache.lock_or_recover() = Some(all_content);
801        }
802        self.structure_content_cache
803            .lock_or_recover()
804            .as_ref()
805            .and_then(|c| c.get(&page_index))
806            .map(|content| {
807                content
808                    .iter()
809                    .filter_map(|c| {
810                        // Word break markers have mcid=None; skip. ~keep
811                        let m = c.mcid?;
812                        // Page-scoped MCIDs default to Page(c.page) when
813                        // the parser didn't capture a scope. New parses
814                        // always populate `mcid_scope`; the unwrap_or
815                        // is for legacy traversals only. ~keep
816                        let scope = c
817                            .mcid_scope
818                            .clone()
819                            .unwrap_or(crate::structure::McidScope::Page(c.page));
820                        Some((scope, m))
821                    })
822                    .collect()
823            })
824            .unwrap_or_default()
825    }
826
827    /// Extract text from a Tagged PDF page using pre-computed structure traversal cache.
828    ///
829    /// This is the optimized version of `extract_text_structure_order` that uses
830    /// the pre-built `structure_content_cache` for O(1) page content lookup instead
831    /// of re-traversing the entire structure tree for each page.
832    pub(super) fn extract_text_structure_order_cached_with_spans(
833        &self,
834        page_index: usize,
835        all_spans: Vec<TextSpan>,
836        include_artifacts: bool,
837    ) -> Result<String> {
838        tracing::debug!(target: LOG_TARGET, page = page_index, "extracting text using cached structure order");
839
840        if all_spans.is_empty() {
841            let mut text = String::new();
842            self.append_non_widget_annotation_text(page_index, &mut text);
843            return Ok(text);
844        }
845
846        // Drop content marked /Artifact (PDF Spec ISO 32000-1:2008
847        // §14.8.2.2 — headers, footers, page numbers, decorations) —
848        // unless the caller opted in via `include_artifacts` (default
849        // true). The geometric branch in `assemble_text_from_spans`
850        // applies the same filter; tagged PDFs taking the structure-order
851        // path must honour it too, otherwise artifact spans (including
852        // any MC-scope `/ActualText` replacements inside an `/Artifact`
853        // BDC) leak into output. Untagged-PDF running-header
854        // detection runs at document level and feeds the same flag. ~keep
855        let all_spans: Vec<TextSpan> = if include_artifacts {
856            all_spans
857        } else {
858            all_spans.into_iter().filter(|s| s.artifact_type.is_none()).collect()
859        };
860
861        let mut mcid_map: HashMap<u32, Vec<TextSpan>> = HashMap::new();
862        let mut spans_without_mcid: Vec<TextSpan> = Vec::new();
863
864        for span in all_spans {
865            if let Some(mcid) = span.mcid {
866                mcid_map.entry(mcid).or_default().push(span);
867            } else {
868                spans_without_mcid.push(span);
869            }
870        }
871
872        let ordered_content_owned: Vec<crate::structure::OrderedContent>;
873        let ordered_content = {
874            let cache = self.structure_content_cache.lock_or_recover();
875            ordered_content_owned = cache
876                .as_ref()
877                .and_then(|c| c.get(&(page_index as u32)))
878                .cloned()
879                .unwrap_or_default();
880            &ordered_content_owned as &[crate::structure::OrderedContent]
881        };
882
883        let at_index = self.actualtext_index();
884        // MC-scope-wins precedence set: MCIDs whose BDC carried inline
885        // `/ActualText` keep the in-stream replacement (most specific
886        // declaration) and are exempt from ancestor struct-tree
887        // emissions. ~keep
888        let mc_wins: HashSet<u32> = self
889            .mc_actualtext_mcids
890            .lock_or_recover()
891            .get(&page_index)
892            .cloned()
893            .unwrap_or_default();
894        let default_scope = crate::structure::McidScope::Page(page_index as u32);
895        let mcid_order: Vec<(crate::structure::McidScope, u32)> = ordered_content
896            .iter()
897            .filter_map(|c| {
898                c.mcid
899                    .map(|m| (c.mcid_scope.clone().unwrap_or(default_scope.clone()), m))
900            })
901            .collect();
902        // Per-key rendered glyph text for the §14.9.4 conformance gate. ~keep
903        let mut glyph_text: HashMap<(crate::structure::McidScope, u32), String> = HashMap::new();
904        for (scope, m) in &mcid_order {
905            if let Some(sp) = mcid_map.get(m) {
906                let joined: String = sp.iter().map(|s| s.text.as_str()).collect();
907                glyph_text.entry((scope.clone(), *m)).or_default().push_str(&joined);
908            }
909        }
910        let actions = Self::actualtext_actions_for_page(
911            at_index.as_deref(),
912            &mcid_order,
913            |_scope, m| mcid_map.contains_key(&m),
914            &mc_wins,
915            &glyph_text,
916        );
917
918        tracing::debug!(target: LOG_TARGET,
919            "Cached structure content: {} items for page {}, {} MCIDs with spans, {} ActualText actions on this page",
920            ordered_content.len(),
921            page_index,
922            mcid_map.len(),
923            actions.len()
924        );
925
926        let mut text = String::with_capacity(mcid_map.len() * 50);
927        let mut prev_span: Option<TextSpan> = None;
928        let mut prev_in_table = false;
929        let mut consumed_mcids: HashSet<u32> = HashSet::new();
930
931        for content in ordered_content {
932            if content.is_word_break {
933                if !text.is_empty() && !text.ends_with(' ') && !text.ends_with('\n') {
934                    text.push(' ');
935                }
936                continue;
937            }
938
939            let Some(mcid) = content.mcid else {
940                continue;
941            };
942            // ISO 32000-1 §14.7: a marked-content sequence's MCID is unique
943            // within its content stream and is referenced from the structure
944            // hierarchy at most once. A malformed struct tree that re-references
945            // the same MCID multiple times would otherwise emit that MCID's
946            // glyphs once per reference. Emit each MCID once. (A destructive
947            // /ActualText replacement — now declined by the §14.9.4 conformance
948            // gate — can mask this by collapsing each consecutive run to a
949            // single emit.) ~keep
950            if !consumed_mcids.insert(mcid) {
951                continue;
952            }
953            let mcid_scope_key = content.mcid_scope.clone().unwrap_or(default_scope.clone());
954
955            match actions.get(&(mcid_scope_key, mcid)) {
956                Some(ActualTextAction::EmitAndSuppress(repl)) => {
957                    consumed_mcids.insert(mcid);
958                    if !text.is_empty() && !text.ends_with(' ') && !text.ends_with('\n') {
959                        text.push('\n');
960                    }
961                    text.push_str(repl);
962                    continue;
963                }
964                Some(ActualTextAction::Suppress) => {
965                    consumed_mcids.insert(mcid);
966                    continue;
967                }
968                None => {}
969            }
970
971            if let Some(spans) = mcid_map.get(&mcid) {
972                consumed_mcids.insert(mcid);
973                let rtl_run = Self::mcid_run_is_pure_rtl(spans);
974                // Repair the cross-span Arabic glyph-interleave defect (zero-width
975                // mark/consonant spans landing at word edges) before ordering. ~keep
976                let merged_rtl = Self::merge_interleaved_rtl_lines(spans);
977                let use_spans: &[crate::layout::TextSpan] = merged_rtl.as_deref().unwrap_or(spans);
978                for span in Self::order_mcid_spans(use_spans) {
979                    if let Some(prev) = &prev_span {
980                        let y_diff = (prev.bbox.y - span.bbox.y).abs();
981                        if y_diff > Self::same_line_threshold(prev, span) {
982                            // Suppress the break when a Hangul eojeol wrapped
983                            // mid-syllable (no inter-eojeol space at the wrap), so
984                            // the word stays whole for word-segmentation scoring. ~keep
985                            if !Self::hangul_midword_line_wrap(&text, prev, span) {
986                                Self::push_line_breaks(
987                                    &mut text,
988                                    prev,
989                                    span,
990                                    y_diff,
991                                    content.in_table && prev_in_table,
992                                );
993                            }
994                        } else if Self::should_insert_space(prev, span) || Self::stacked_cell_needs_space(prev, span) {
995                            text.push(' ');
996                        }
997                    }
998
999                    Self::push_span_text_bidi(&mut text, span, rtl_run);
1000                    prev_span = Some(span.clone());
1001                    prev_in_table = content.in_table;
1002                }
1003            }
1004        }
1005
1006        let mut unconsumed: Vec<(&u32, &Vec<TextSpan>)> = mcid_map
1007            .iter()
1008            .filter(|(mcid, _)| !consumed_mcids.contains(mcid))
1009            .collect();
1010        unconsumed.sort_by_key(|(mcid, _)| **mcid);
1011        if !unconsumed.is_empty() {
1012            tracing::warn!(target: LOG_TARGET,
1013                "Appending {} unreferenced MCIDs (e.g., from Form XObjects without StructParents)",
1014                unconsumed.len()
1015            );
1016            for (_mcid, spans) in &unconsumed {
1017                let rtl_run = Self::mcid_run_is_pure_rtl(spans);
1018                for span in *spans {
1019                    if let Some(prev) = &prev_span {
1020                        let y_diff = (prev.bbox.y - span.bbox.y).abs();
1021                        if y_diff > Self::same_line_threshold(prev, span) {
1022                            text.push('\n');
1023                        } else if Self::should_insert_space(prev, span) {
1024                            text.push(' ');
1025                        }
1026                    }
1027                    Self::push_span_text_bidi(&mut text, span, rtl_run);
1028                    prev_span = Some(span.clone());
1029                }
1030            }
1031        }
1032
1033        if !spans_without_mcid.is_empty() {
1034            tracing::warn!(target: LOG_TARGET,
1035                "Found {} text spans without MCID (including form field widgets) - appending sorted by position",
1036                spans_without_mcid.len()
1037            );
1038            crate::utils::sort_by_row_band(&mut spans_without_mcid, |s| s.bbox.y, |s| s.bbox.x);
1039            for span in &spans_without_mcid {
1040                if let Some(prev) = &prev_span {
1041                    let y_diff = (prev.bbox.y - span.bbox.y).abs();
1042                    if y_diff > Self::same_line_threshold(prev, span) {
1043                        text.push('\n');
1044                    } else if Self::should_insert_space(prev, span) {
1045                        text.push(' ');
1046                    }
1047                }
1048                Self::push_span_text_bidi(&mut text, span, false);
1049                prev_span = Some(span.clone());
1050            }
1051        }
1052
1053        // Annotation text is already included via annotation_content_spans() in
1054        // extract_spans() — do NOT call append_non_widget_annotation_text() here
1055        // (would cause double-emission of all annotation text). ~keep
1056
1057        Ok(text)
1058    }
1059
1060    /// Extract text spans from a page (PDF spec compliant - RECOMMENDED).
1061    ///
1062    /// This is the recommended method for text extraction. It extracts complete
1063    /// text strings as the PDF provides them via Tj/TJ operators, following the
1064    /// PDF specification ISO 32000-1:2008.
1065    ///
1066    /// # Benefits over extract_chars
1067    /// - Avoids overlapping character issues
1068    /// - Preserves PDF's text positioning intent
1069    /// - More robust for complex layouts
1070    /// - Matches industry best practices (PyMuPDF, etc.)
1071    ///
1072    /// # Arguments
1073    ///
1074    /// * `page_index` - Zero-based page index
1075    ///
1076    /// # Returns
1077    ///
1078    /// Vector of TextSpan objects in reading order
1079    ///
1080    /// # Examples
1081    ///
1082    /// ```no_run
1083    /// # use xberg_native_pdf::PdfDocument;
1084    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
1085    /// let mut doc = PdfDocument::open("document.pdf")?;
1086    /// let spans = doc.extract_spans(0)?;
1087    /// for span in spans {
1088    ///     println!("Text: {} at ({}, {})", span.text, span.bbox.x, span.bbox.y);
1089    /// }
1090    /// # Ok(())
1091    /// # }
1092    /// ```
1093    pub fn extract_spans(&self, page_index: usize) -> Result<Vec<crate::layout::TextSpan>> {
1094        // Serve repeat per-page extractions from cache (the converters reach
1095        // here twice per page; see `page_spans_cache`). ~keep
1096        if let Some(cached) = self.page_spans_cache.lock_or_recover().get(&page_index) {
1097            return Ok((**cached).clone());
1098        }
1099        let spans = self.extract_spans_raw(page_index)?;
1100        let spans = self.postprocess_spans(page_index, spans)?;
1101        self.page_spans_cache
1102            .lock_or_recover()
1103            .insert(page_index, std::sync::Arc::new(spans.clone()));
1104        Ok(spans)
1105    }
1106
1107    /// Get (building and caching if needed) the lightweight search index for
1108    /// one page. Backs `search()`/`search_page()` — see `search_index` field
1109    /// docs for why this is a separate, unbounded cache from
1110    /// `page_spans_cache`.
1111    pub(crate) fn search_page_index(
1112        &self,
1113        page_index: usize,
1114    ) -> Result<std::sync::Arc<crate::search::SearchPageIndex>> {
1115        if let Some(cached) = self.search_index.lock_or_recover().get(&page_index) {
1116            return Ok(std::sync::Arc::clone(cached));
1117        }
1118        let spans = self.extract_spans(page_index)?;
1119        let index = std::sync::Arc::new(crate::search::SearchPageIndex::from_spans(&spans));
1120        self.search_index
1121            .lock_or_recover()
1122            .insert(page_index, std::sync::Arc::clone(&index));
1123        Ok(index)
1124    }
1125
1126    /// Build the search index for every page up front.
1127    ///
1128    /// `search()` builds this lazily one page at a time as needed, so calling
1129    /// this is optional — it exists for callers who want the first `search()`
1130    /// call to be as fast as the rest, at the cost of paying full-document
1131    /// extraction immediately instead of spread across the first sweep.
1132    pub fn prepare_search(&self) -> Result<()> {
1133        for page_index in 0..self.page_count()? {
1134            self.search_page_index(page_index)?;
1135        }
1136        Ok(())
1137    }
1138
1139    /// Drop the cached search index, if any, freeing its memory.
1140    /// `search()`/`search_page()` will rebuild it lazily on next use.
1141    pub fn clear_search_index(&self) {
1142        self.search_index.lock_or_recover().clear();
1143    }
1144
1145    pub(super) fn extract_spans_filtered(
1146        &self,
1147        page_index: usize,
1148        excluded_layers: HashSet<String>,
1149        excluded_inks: HashSet<String>,
1150    ) -> Result<Vec<crate::layout::TextSpan>> {
1151        let spans = self.extract_spans_raw_filtered(page_index, excluded_layers, excluded_inks)?;
1152        self.postprocess_spans(page_index, spans)
1153    }
1154}