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}