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