Skip to main content

xberg_native_pdf/document/
text_assembly.rs

1//! Plain-text assembly from ordered spans.
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    /// Circular references and recursion limit errors are handled gracefully
11    /// with warning messages in the output.
12    ///
13    /// # Example
14    ///
15    /// ```no_run
16    /// # use xberg_native_pdf::document::PdfDocument;
17    /// # let mut doc = PdfDocument::open("sample.pdf")?;
18    /// let text = doc.extract_text(0)?;
19    /// println!("Page 1 text: {}", text);
20    /// # Ok::<(), xberg_native_pdf::error::Error>(())
21    /// ```
22    ///
23    /// # Extract text from a page
24    ///
25    /// xberg-native-pdf exposes three plain-text surfaces with different strengths.
26    /// Pick by document shape:
27    ///
28    /// - `extract_text(page)` (this method) — glyph-walk assembly with
29    ///   row-aware ordering, inline table rendering, and artifact filtering.
30    ///   The most discoverable default; strongest on single-column prose.
31    /// - `to_plain_text(page, opts)` / `to_plain_text_all(opts)` — runs the
32    ///   full pipeline (reading-order strategy incl. XY-cut). Best on
33    ///   multi-column / complex layouts where reading order matters.
34    /// - `to_markdown_all(opts)` then strip markup — preserves structure
35    ///   (headings, lists, tables) and often scores highest on heavily
36    ///   structured documents; lossiest for pure prose.
37    ///
38    /// No single mode wins on every PDF; when extraction quality is critical
39    /// and the layout is unknown, compare `to_plain_text_all` and
40    /// markdown-stripped output and keep whichever is better for your corpus.
41    #[tracing::instrument(name = "pdf.extract_text", skip_all, fields(page = page_index))]
42    pub fn extract_text(&self, page_index: usize) -> Result<String> {
43        let options = crate::converters::ConversionOptions {
44            extract_tables: true,
45            ..Default::default()
46        };
47        let result = self.extract_text_with_options(page_index, &options);
48        if let Err(error) = &result {
49            crate::error::trace_failure("extract_text", error);
50        }
51        result
52    }
53
54    /// Extract text from a page with specific options.
55    pub fn extract_text_with_options(
56        &self,
57        page_index: usize,
58        options: &crate::converters::ConversionOptions,
59    ) -> Result<String> {
60        let base_spans = self.extract_spans(page_index)?;
61        // Vertical CJK (tategaki, ISO 32000-1 §9.7.4.3 vertical writing mode):
62        // glyphs run top-to-bottom in columns that progress right-to-left, so
63        // the horizontal row-major assembler shreds the reading order. When the
64        // page is geometrically vertical, read it column-major instead. ~keep
65        if let Some(vertical) = Self::try_assemble_vertical_cjk(&base_spans) {
66            return Ok(vertical);
67        }
68        let text = self.assemble_text_from_spans(page_index, base_spans, options)?;
69        Ok(Self::apply_mixed_rtl_line_pass(text))
70    }
71
72    /// [`Self::map_dominant_rotation_into_reading_frame`] with the
73    /// mapped/unchanged distinction collapsed, for the call sites that only
74    /// want "spans as a reader sees them".
75    ///
76    /// Every library text surface goes through this — via
77    /// [`Self::assemble_text_from_spans`] or the converter pipelines. Applying
78    /// the frame at one call site made the same page read correctly through
79    /// `extract_text` and incorrectly through `to_markdown` / `to_html` /
80    /// `to_plain_text`. Callers must apply `ConversionOptions` region filters
81    /// BEFORE this: region rects are page-space coordinates, and the map
82    /// rewrites the geometry they select against.
83    fn spans_in_reading_frame(
84        &self,
85        page_index: usize,
86        spans: Vec<crate::layout::TextSpan>,
87    ) -> Vec<crate::layout::TextSpan> {
88        match self.map_dominant_rotation_into_reading_frame(page_index, spans) {
89            Ok(mapped) => mapped,
90            Err(original) => original,
91        }
92    }
93
94    /// Map a dominant-rotation page's spans into their rotated reading
95    /// frame so the standard horizontal assembler applies.
96    ///
97    /// Returns `Ok(mapped)` when the page is unrotated (`/Rotate 0` — on
98    /// rotated pages `postprocess_spans` already handles content rotation,
99    ///) and at least half its non-whitespace spans share one
100    /// quadrant text rotation; the mapped spans are horizontal in the
101    /// frame a reader turns the page into, with `rotation_degrees` cleared
102    /// so downstream passes treat them as the upright text they now are.
103    /// Returns `Err(spans)` — the input unchanged — on every other page,
104    /// keeping output byte-identical there.
105    ///
106    /// Only used for plain-text assembly, where no coordinates leak to the
107    /// caller (region rects leak IN, so they are filtered out before this
108    /// runs); coordinate-bearing APIs (`extract_words`) reorder in the
109    /// rotated frame but report true page-space bboxes instead (see
110    /// `crate::pipeline::page_reading_order`).
111    fn map_dominant_rotation_into_reading_frame(
112        &self,
113        page_index: usize,
114        spans: Vec<crate::layout::TextSpan>,
115    ) -> std::result::Result<Vec<crate::layout::TextSpan>, Vec<crate::layout::TextSpan>> {
116        if self.get_page_rotation(page_index).unwrap_or(0) != 0 {
117            return Err(spans);
118        }
119        let Some(deg) = crate::utils::dominant_rotation(&spans) else {
120            return Err(spans);
121        };
122        // Same quadrant mapping as the word path: 90° text reads upright
123        // under a /Rotate-90-style display transform, -90° under 270,
124        // 180° under 180. Mirrored / free-angle runs have no frame. ~keep
125        let rot = if (deg - 90.0).abs() < 0.5 {
126            90
127        } else if (deg - 180.0).abs() < 0.5 {
128            180
129        } else if (deg + 90.0).abs() < 0.5 {
130            270
131        } else {
132            return Err(spans);
133        };
134        tracing::debug!(target: LOG_TARGET, "page {page_index}: dominant text rotation {deg}° — assembling text in rotated frame");
135        let (llx, lly, urx, ury) = self.get_page_media_box(page_index).unwrap_or((0.0, 0.0, 612.0, 792.0));
136        let (w, h) = (urx - llx, ury - lly);
137        let mut spans = spans;
138        // Rotated spans store TEXT-LOCAL extents (origin + advance-along-
139        // the-run as `width` + font size as `height`): rotate the ORIGIN
140        // as a point and keep the extents, which already describe the run
141        // in its own upright frame (same convention as
142        // `order_rotated_blocks`). ~keep
143        for s in &mut spans {
144            let (rx, ry) = (s.bbox.x - llx, s.bbox.y - lly);
145            let (mx, my) = match rot {
146                90 => (ry, w - rx),
147                180 => (w - rx, h - ry),
148                270 => (h - ry, rx),
149                _ => (rx, ry),
150            };
151            s.bbox.x = llx + mx;
152            s.bbox.y = lly + my;
153            s.rotation_degrees = 0.0;
154        }
155        Ok(spans)
156    }
157
158    /// Assemble page text from the page's native spans **plus** caller-supplied
159    /// extra spans, positioned together in a single reading-order pass.
160    ///
161    /// The Auto extractor uses this to drop text recovered from an image region
162    /// (via OCR) into the page at the image's spatial location — so a chart
163    /// caption embedded in a figure reads in its correct place rather than being
164    /// appended after the whole page. The native spans are extracted and
165    /// assembled exactly as [`extract_text_with_options`](Self::extract_text_with_options)
166    /// would, so the native text is byte-for-byte preserved; the extra spans
167    /// only add content, sorted in by their bounding box.
168    // Only the unit test that pins span placement calls this, so it is
169    // compiled only under `cfg(test)` (no dead code). ~keep
170    #[cfg(test)]
171    pub(crate) fn extract_text_with_extra_spans(
172        &self,
173        page_index: usize,
174        extra: Vec<crate::layout::TextSpan>,
175        options: &crate::converters::ConversionOptions,
176    ) -> Result<String> {
177        let mut base_spans = self.extract_spans(page_index)?;
178        base_spans.extend(extra);
179        let text = self.assemble_text_from_spans(page_index, base_spans, options)?;
180        Ok(Self::apply_mixed_rtl_line_pass(text))
181    }
182
183    /// Returns column-major text when the page is a vertical-CJK (tategaki)
184    /// layout, or `None` for every other page (so horizontal documents are
185    /// byte-for-byte unchanged).
186    ///
187    /// Detection is purely geometric: among CJK glyph spans, count how many
188    /// neighbour pairs are stacked *vertically* (same column, one glyph-height
189    /// apart) versus *horizontally* (same row, one glyph-width apart). Vertical
190    /// writing is declared only when CJK is the clear majority of the page and
191    /// vertical adjacencies dominate horizontal ones — so horizontal CJK
192    /// (Chinese/Japanese prose set left-to-right) never triggers it. Assembly
193    /// then orders spans by column right-to-left (X descending, banded to the
194    /// glyph width) and top-to-bottom within a column (Y descending), matching
195    /// how the script is read.
196    pub(super) fn try_assemble_vertical_cjk(spans: &[TextSpan]) -> Option<String> {
197        fn is_cjk(c: char) -> bool {
198            matches!(
199                c as u32,
200                0x3040..=0x30FF
201                | 0x3400..=0x4DBF
202                | 0x4E00..=0x9FFF
203                | 0xF900..=0xFAFF
204                | 0xFF66..=0xFF9F
205            )
206        }
207        let cjk: Vec<&TextSpan> = spans.iter().filter(|s| s.text.chars().any(is_cjk)).collect();
208        if cjk.len() < 8 {
209            return None;
210        }
211        let single_glyph_cjk = cjk
212            .iter()
213            .filter(|s| {
214                let t = s.text.trim();
215                t.chars().count() == 1 && t.chars().all(is_cjk)
216            })
217            .count();
218        if single_glyph_cjk * 2 < cjk.len() {
219            return None;
220        }
221        let total_chars: usize = spans
222            .iter()
223            .map(|s| s.text.chars().filter(|c| !c.is_whitespace()).count())
224            .sum();
225        let cjk_chars: usize = cjk.iter().map(|s| s.text.chars().filter(|c| is_cjk(*c)).count()).sum();
226        if total_chars == 0 || cjk_chars * 2 < total_chars {
227            return None;
228        }
229
230        let mut widths: Vec<f32> = cjk.iter().map(|s| s.bbox.width).filter(|w| *w > 0.0).collect();
231        if widths.is_empty() {
232            return None;
233        }
234        widths.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
235        let gw = widths[widths.len() / 2];
236
237        let sample = &cjk[..cjk.len().min(250)];
238        let (mut vert, mut horiz) = (0usize, 0usize);
239        for (i, a) in sample.iter().enumerate() {
240            let (mut best, mut bdx, mut bdy) = (f32::MAX, 0.0f32, 0.0f32);
241            for (j, b) in sample.iter().enumerate() {
242                if i == j {
243                    continue;
244                }
245                let dx = a.bbox.x - b.bbox.x;
246                let dy = a.bbox.y - b.bbox.y;
247                let d2 = dx * dx + dy * dy;
248                if d2 < best {
249                    best = d2;
250                    bdx = dx.abs();
251                    bdy = dy.abs();
252                }
253            }
254            if bdy > bdx {
255                vert += 1;
256            } else if bdx > bdy {
257                horiz += 1;
258            }
259        }
260        if vert == 0 || vert <= horiz * 2 {
261            return None;
262        }
263
264        let band = (gw * 0.5).max(1.0);
265        let mut ordered: Vec<&TextSpan> = spans.iter().collect();
266        ordered.sort_by(|a, b| {
267            let ca = (a.bbox.x / band).round() as i32;
268            let cb = (b.bbox.x / band).round() as i32;
269            cb.cmp(&ca).then(crate::utils::safe_float_cmp(b.bbox.y, a.bbox.y))
270        });
271        Some(ordered.iter().map(|s| s.text.as_str()).collect())
272    }
273
274    /// Per-line UAX #9 pass for mixed-direction lines (bidi item 4): for each
275    /// output line that is confidently RTL and mixes Arabic/Hebrew with
276    /// European/Arabic-Indic numerals or Latin words (e.g. a date
277    /// `14 april 1434 ٤٣٤١`), give the embedded LTR sub-runs their left-to-right
278    /// sublevel (UAX #9 §3.3.4) while leaving the already-logical RTL runs fixed.
279    /// Gated inside `reorder_mixed_rtl_line`, so pure-RTL, pure-LTR, and
280    /// non-RTL lines are returned byte-for-byte unchanged; the ASCII fast path
281    /// keeps all Latin-only extraction identical.
282    fn apply_mixed_rtl_line_pass(text: String) -> String {
283        if text.is_ascii() {
284            return text;
285        }
286        text.split('\n')
287            .map(crate::text::bidi::reorder_mixed_rtl_line)
288            .collect::<Vec<_>>()
289            .join("\n")
290    }
291
292    /// Apply caller-specified region filters to a span set: drop spans matching
293    /// any `exclude_regions` (under `exclude_regions_mode`), then keep only spans
294    /// inside `include_region` if one is set. Exclusion runs first so it takes
295    /// precedence. Shared by the plain-text, markdown, and HTML conversion paths
296    /// so `ConversionOptions` region filtering behaves identically across every
297    /// text surface. A no-op when neither field is set.
298    fn apply_region_filters(
299        base_spans: Vec<crate::layout::TextSpan>,
300        options: &crate::converters::ConversionOptions,
301    ) -> Vec<crate::layout::TextSpan> {
302        use crate::layout::SpatialCollectionFiltering;
303        let mut spans = base_spans;
304        if !options.exclude_regions.is_empty() {
305            spans = spans.exclude_rects(&options.exclude_regions, options.exclude_regions_mode);
306        }
307        if let Some((ref region, mode)) = options.include_region {
308            spans = spans.filter_by_rect(region, mode);
309        }
310        spans
311    }
312
313    pub(super) fn assemble_text_from_spans(
314        &self,
315        page_index: usize,
316        base_spans: Vec<crate::layout::TextSpan>,
317        options: &crate::converters::ConversionOptions,
318    ) -> Result<String> {
319        if self.is_encrypted_unreadable() {
320            tracing::warn!(target: LOG_TARGET, "PDF is encrypted and could not be decrypted; returning empty text");
321            return Ok(String::new());
322        }
323
324        let base_spans = Self::apply_region_filters(base_spans, options);
325        // Dominant text-matrix rotation (a landscape table typeset on a
326        // portrait page): the row-major assembler groups lines in the
327        // portrait frame and interleaves every rotated row. Assemble such
328        // pages in their rotated reading frame instead — after the region
329        // filters, whose rects select in page space, and before table
330        // detection, which consumes the geometry this maps. ~keep
331        let base_spans = self.spans_in_reading_frame(page_index, base_spans);
332        // Struct-tree-scope `/ActualText` is applied per branch below
333        // — the structure-order assembler handles it natively via the
334        // per-page action map, and the geometric branch applies the
335        // raw-span applier on its own input. Pre-applying here would
336        // double-process: the structure-order path would see already-
337        // mutated spans and lose run-position information, dropping
338        // sibling MCIDs of a nested scope (CRITICAL-1 shape). ~keep
339
340        // Structure tree: use it for reading order only when it is trustworthy
341        // per the shared predicate (§14.8.2.3.1) — the document is /Marked or
342        // the catalog references a /StructTreeRoot (PDF 1.4 documents such as
343        // hello_structure.pdf predate /MarkInfo but are still tagged, §14.7.1),
344        // AND /MarkInfo /Suspects is not true. Suspect documents fall through to
345        // the geometric `else` arm below, the spec-correct behaviour. ~keep
346        let cached_tree = self.struct_tree_trustworthy();
347        let widget_spans = self.extract_widget_spans(page_index);
348
349        let tables = if options.extract_tables {
350            // text_fallback=false: extract_text preserves the prior behaviour
351            // where line-less pages return no tables. Only the structured-output
352            // converters (to_markdown, to_html) opt in to text-only spatial fallback. ~keep
353            self.extract_page_tables(page_index, &base_spans, options, false)
354        } else {
355            Vec::new()
356        };
357
358        let mut all_spans = base_spans;
359        all_spans.extend(widget_spans);
360
361        if all_spans.is_empty() {
362            let page = self.get_page(page_index)?;
363            let page_dict = page.as_dict().ok_or_else(|| Error::ParseError {
364                offset: 0,
365                reason: "Page is not a dictionary".to_string(),
366            })?;
367            let no_content_text = if self.page_cannot_have_text(page_dict) {
368                true
369            } else {
370                match self.get_page_content_data(page_index) {
371                    Ok(ref content_data) => !Self::may_contain_text(content_data),
372                    Err(_) => false, // Can't read content stream — be conservative ~keep
373                }
374            };
375            if no_content_text {
376                let mut text = String::new();
377                self.append_non_widget_annotation_text(page_index, &mut text);
378                return Ok(text);
379            }
380        }
381
382        let text = if let Some(ref struct_tree) = cached_tree {
383            // Build per-page traversal cache once, then O(1) lookup per page. ~keep
384            if self.structure_content_cache.lock_or_recover().is_none() {
385                let all_content = crate::structure::traverse_structure_tree_all_pages(struct_tree);
386                *self.structure_content_cache.lock_or_recover() = Some(all_content);
387            }
388            self.extract_text_structure_order_cached_with_spans(page_index, all_spans, options.include_artifacts)?
389        } else {
390            // Untagged or Suspects=true PDF: use page content
391            // (geometric) order. Apply struct-tree-scope `/ActualText`
392            // here — the structure-order assembler above handles it
393            // natively for the trustworthy branch. Suspects=true
394            // documents still get their producer-supplied replacement
395            // because `actualtext_index()` is decoupled from
396            // `struct_tree_marked` (§14.9.4 is content replacement,
397            // not a reading-order signal). ~keep
398            let mut spans = all_spans;
399            self.apply_actualtext_to_spans(page_index, &mut spans);
400
401            // Exclude spans that are inside detected tables, BUT
402            // preserve multi-row-spanning label columns.
403            // The spatial table extractor clusters data cells into
404            // table cells but does NOT emit the sparse label column
405            // that sits vertically centred within each multi-row data
406            // block (common on CJK lab-report reference tables like
407            // WS/T 779). Those labels would otherwise be dropped
408            // entirely from the output: the retain below would remove
409            // them because their bbox is inside the table,
410            // `table.render_text()` would not re-emit them because the
411            // extractor never captured them as cells. Before running
412            // the retain filter we identify these rowspan labels (same
413            // heuristic `reorder_rowspan_labels` uses) and keep them in
414            // the span list so `reorder_rowspan_labels` below can
415            // promote them to the top of their row block. ~keep
416            if !tables.is_empty() {
417                // Absorb floating-point accumulation error in the
418                // difference between a span's directly-computed
419                // bbox.right (origin + width, small accumulation)
420                // and a table bbox.right (min/max reduction across
421                // many cell edges, larger accumulation). Without
422                // this slack, a span whose real geometry is inside
423                // the table by construction but whose f32 right-edge
424                // exceeds the table's f32 right-edge by ~0.01–0.05pt
425                // gets wrongly kept in the flow stream, producing
426                // duplicated output. 0.1pt is well below any
427                // visually meaningful PDF layout distance. ~keep
428                const RETAIN_TOLERANCE: f32 = 0.1;
429
430                // Build the set of cell text strings that every detected
431                // table will render via `table.render_text()`. Labels
432                // whose exact text already appears as a cell in some
433                // table are already covered by the inline-table flush
434                // below, so we must NOT also preserve them in the flow
435                // span list (it would produce duplicate output). ~keep
436                let mut table_cell_texts: std::collections::HashSet<&str> = std::collections::HashSet::new();
437                for t in &tables {
438                    for row in &t.rows {
439                        for cell in &row.cells {
440                            let trimmed = cell.text.trim();
441                            if !trimmed.is_empty() {
442                                table_cell_texts.insert(trimmed);
443                            }
444                        }
445                    }
446                }
447
448                // For tagged PDFs, collect the MCIDs that are actually owned by
449                // table cells. When a span's MCID is NOT in this set, the span is
450                // NOT part of the table even if it lies inside the table's bbox
451                // (e.g. a paragraph physically adjacent to a table that was tagged
452                // as a sibling <P> element, not as a <TD>). Filtering such spans
453                // by bbox alone would silently drop real content.
454                // Falls back to bbox-only filtering when no MCIDs are present
455                // (untagged PDFs or spatial-detection tables).
456                // Only tables with a bbox reach the inline flush below; a
457                // cell of an unflushed table renders nowhere and must not
458                // absorb anything — whether ownership is decided by MCID or
459                // by geometry. ~keep
460                let table_cell_mcids: HashSet<u32> = tables
461                    .iter()
462                    .filter(|t| t.bbox.is_some())
463                    .flat_map(|t| {
464                        t.rows
465                            .iter()
466                            .flat_map(|r| r.cells.iter().flat_map(|c| c.mcids.iter().copied()))
467                    })
468                    .collect();
469                // Flatten every flushed cell once (bbox plus the cell, whose
470                // text and member spans drive the ownership test below) and
471                // index it into coarse y-bands, so the per-span containment
472                // test below scans only the cells in the span's y-band
473                // instead of every cell on the page (was O(spans x cells) on
474                // untagged table pages). A cell that contains a span
475                // necessarily shares the span's y-band, so this is
476                // byte-identical to the full scan. ~keep
477                let flushed_cells: Vec<(crate::geometry::Rect, &crate::structure::table_extractor::TableCell)> = tables
478                    .iter()
479                    .filter(|t| t.bbox.is_some())
480                    .flat_map(|t| {
481                        t.rows
482                            .iter()
483                            .flat_map(|r| r.cells.iter().filter_map(|c| c.bbox.map(|b| (b, c))))
484                    })
485                    .collect();
486                // Same-text cell lookup for the fallback path: a span that no
487                // cell bbox contains can still be absorbed by a cell whose
488                // exact text it carries, as long as that cell's budget is
489                // untouched. ~keep
490                let mut cell_text_index: std::collections::HashMap<&str, Vec<usize>> = std::collections::HashMap::new();
491                for (ci, (_, c)) in flushed_cells.iter().enumerate() {
492                    let text = c.text.trim();
493                    if !text.is_empty() {
494                        cell_text_index.entry(text).or_default().push(ci);
495                    }
496                }
497                const CELL_Y_BIN: f32 = 18.0;
498                let cell_bin = |y: f32| (y / CELL_Y_BIN).floor() as i32;
499                let mut cell_y_index: std::collections::HashMap<i32, Vec<usize>> = std::collections::HashMap::new();
500                for (ci, (b, _)) in flushed_cells.iter().enumerate() {
501                    for bin in cell_bin(b.y)..=cell_bin(b.y + b.height) {
502                        cell_y_index.entry(bin).or_default().push(ci);
503                    }
504                }
505                // Per-cell token budgets. `render_text()` emits each cell's
506                // content exactly once, so a cell can absorb dropped spans
507                // totalling at most the material it was built from. Bbox
508                // containment alone cannot decide a drop: a rowspan-merged
509                // cell's bbox can cover far more spans than its text was
510                // built from (a schedule column's tall cell covering
511                // hundreds of repeated marks while holding six lines of
512                // them), and an empty cell re-emits nothing at all (a value
513                // column without row rules becomes one tall empty cell whose
514                // bbox swallows every figure in the column). A span leaves
515                // the flow only by consuming its tokens from a covering
516                // cell's budget — the fragment-level form of the same rule
517                // the text fallback needs: membership cannot see
518                // multiplicity.
519                //
520                // Budgets are denominated in the cell's member-span tokens,
521                // not its rendered text: the cell builders rewrite text
522                // while joining spans (sub-em gaps and CJK/fullwidth pairs
523                // glue two spans with no separator, column-spanning decimals
524                // split "12.11" into "12 11"), so the rendered text is not a
525                // token superset of the spans the cell was built from. The
526                // member spans are, by construction — they are clones of the
527                // same flow spans this filter walks. A cell recorded without
528                // member spans falls back to its rendered text. ~keep
529                let mut cell_remaining: Vec<std::collections::HashMap<&str, usize>> = flushed_cells
530                    .iter()
531                    .map(|(_, c)| {
532                        let mut m = std::collections::HashMap::new();
533                        if c.spans.is_empty() {
534                            for tok in c.text.split_whitespace() {
535                                *m.entry(tok).or_insert(0) += 1;
536                            }
537                        } else {
538                            for sp in &c.spans {
539                                for tok in sp.text.split_whitespace() {
540                                    *m.entry(tok).or_insert(0) += 1;
541                                }
542                            }
543                        }
544                        m
545                    })
546                    .collect();
547                // The detector runs on `extract_words` output, so a budget
548                // token can itself be a glued compound of several flow spans
549                // drawn with no gap ("12,3" + "45" -> "12,345"). A contained
550                // span may therefore consume a substring of one budget
551                // token; the unmatched remainder returns to the budget so
552                // the sibling fragments can follow. Absorption stays bounded
553                // by the material the cell was built from. The text-fallback
554                // path keeps whole-token matching: it has no containment
555                // evidence, so fragments would let distant same-substring
556                // text vanish. ~keep
557                fn take_one(
558                    work: &mut std::collections::HashMap<&str, usize>,
559                    tok: &str,
560                    allow_fragment: bool,
561                ) -> bool {
562                    if let Some(r) = work.get_mut(tok)
563                        && *r > 0
564                    {
565                        *r -= 1;
566                        return true;
567                    }
568                    if !allow_fragment {
569                        return false;
570                    }
571                    // Shortest host wins: splits are deterministic (HashMap
572                    // iteration order is not) and minimal. ~keep
573                    let Some(host) = work
574                        .iter()
575                        .filter(|(k, n)| **n > 0 && k.len() > tok.len() && k.contains(tok))
576                        .map(|(k, _)| *k)
577                        .min_by_key(|k| (k.len(), *k))
578                    else {
579                        return false;
580                    };
581                    *work.get_mut(host).unwrap() -= 1;
582                    let start = host.find(tok).unwrap();
583                    let before = &host[..start];
584                    let after = &host[start + tok.len()..];
585                    if !before.is_empty() {
586                        *work.entry(before).or_insert(0) += 1;
587                    }
588                    if !after.is_empty() {
589                        *work.entry(after).or_insert(0) += 1;
590                    }
591                    true
592                }
593                // Test plus decrement, in one place so the containment and
594                // text-fallback paths cannot drift apart — they must draw on
595                // the SAME accounting. All-or-nothing: a span either fits
596                // the cell's remaining budget whole or leaves it untouched. ~keep
597                fn try_consume(
598                    remaining: &mut std::collections::HashMap<&str, usize>,
599                    span_tokens: &[(&str, usize)],
600                    allow_fragment: bool,
601                ) -> bool {
602                    let mut work = remaining.clone();
603                    for (tok, n) in span_tokens {
604                        for _ in 0..*n {
605                            if !take_one(&mut work, tok, allow_fragment) {
606                                return false;
607                            }
608                        }
609                    }
610                    *remaining = work;
611                    true
612                }
613
614                let preserved_label_indices: std::collections::HashSet<usize> = Self::identify_multi_row_labels(&spans)
615                    .into_iter()
616                    .filter(|&idx| {
617                        // Only preserve labels whose text is NOT
618                        // already emitted by any table's
619                        // `render_text()`. This is what makes the
620                        // fix safe on pages where the spatial
621                        // extractor captured the sparse label
622                        // column as cells — we let the table
623                        // render them and drop them from flow.
624                        // On pages like WS/T 779 where the label
625                        // column is a genuine multi-row-spanning
626                        // column that the extractor did NOT
627                        // capture, the set is empty and every
628                        // identified label stays in flow where
629                        // `reorder_rowspan_labels` below can
630                        // promote it. ~keep
631                        let t = spans[idx].text.trim();
632                        !t.is_empty() && !table_cell_texts.contains(t)
633                    })
634                    .collect();
635
636                // One pass covers both cases: with no preserved labels the
637                // index test is simply never true.
638                //
639                // A cell-covered span leaves the flow by consuming its tokens
640                // from the covering cell's budget. A text-fallback span
641                // (matching a cell's exact text without lying inside any cell
642                // — the ascent-overshoot geometry the fallback exists for)
643                // claims a same-text cell whose budget is still whole. Both
644                // paths draw on the SAME per-cell budgets, so a cell that
645                // absorbed its own spans cannot also be claimed from a
646                // distance: with split accounting, a page carrying one
647                // repeated label per row dropped three spans against two
648                // rendering cells.
649                //
650                // Once every cell that could absorb a text is spoken for,
651                // further spans matching it stay in the flow, because
652                // `render_text()` will not emit them again. Membership cannot
653                // see multiplicity. Spans no cell can absorb stay in the
654                // flow: better to duplicate than to silently drop. ~keep
655                let mut kept: Vec<crate::layout::TextSpan> = Vec::with_capacity(spans.len());
656                for (i, s) in spans.drain(..).enumerate() {
657                    if preserved_label_indices.contains(&i) {
658                        kept.push(s);
659                        continue;
660                    }
661                    if !table_cell_mcids.is_empty() {
662                        // Tagged PDF: MCID decides ownership precisely. A span
663                        // with no MCID (widget/annotation) stays in flow —
664                        // better to duplicate than to silently drop. ~keep
665                        if s.mcid.is_some_and(|m| table_cell_mcids.contains(&m)) {
666                            continue;
667                        }
668                        kept.push(s);
669                        continue;
670                    }
671                    // Inner scope: every borrow of `s.text` ends before the
672                    // span is moved into `kept`. ~keep
673                    let dropped = {
674                        let trimmed = s.text.trim();
675                        // A span rarely carries more than a few tokens; a
676                        // linear-scan Vec beats allocating a HashMap per span. ~keep
677                        let mut span_tokens: Vec<(&str, usize)> = Vec::new();
678                        for tok in trimmed.split_whitespace() {
679                            if let Some(entry) = span_tokens.iter_mut().find(|(t, _)| *t == tok) {
680                                entry.1 += 1;
681                            } else {
682                                span_tokens.push((tok, 1));
683                            }
684                        }
685                        // Probe only the cells in the span's y-band (±1 bin
686                        // guards the containment tolerance). Equivalent to
687                        // scanning every cell. ~keep
688                        let slo = cell_bin(s.bbox.y) - 1;
689                        let shi = cell_bin(s.bbox.y + s.bbox.height) + 1;
690                        let mut decided = false;
691                        'cells: for bin in slo..=shi {
692                            let Some(cands) = cell_y_index.get(&bin) else {
693                                continue;
694                            };
695                            for &ci in cands {
696                                let (cell_bbox, _) = &flushed_cells[ci];
697                                if !Self::contains_rect_with_tolerance(cell_bbox, &s.bbox, RETAIN_TOLERANCE) {
698                                    continue;
699                                }
700                                if try_consume(&mut cell_remaining[ci], &span_tokens, true) {
701                                    decided = true;
702                                    break 'cells;
703                                }
704                            }
705                        }
706                        // Fallback: text-based match. The bbox check above
707                        // uses a tight 0.1pt tolerance and rejects spans whose
708                        // font ascent extends slightly above the cell's ink
709                        // box ("FY 15 1st Q TTL" labels in a regional airline's
710                        // traffic table). Require spatial proximity so body
711                        // text that coincidentally matches a cell's text
712                        // elsewhere on the page is not dropped. ~keep
713                        if !decided && !trimmed.is_empty() && cell_text_index.contains_key(trimmed) {
714                            let near_table = tables.iter().any(|t| {
715                                t.bbox.is_some_and(|tb| {
716                                    let cx = s.bbox.x + s.bbox.width / 2.0;
717                                    let cy = s.bbox.y + s.bbox.height / 2.0;
718                                    cx >= tb.x - RETAIN_TOLERANCE
719                                        && cx <= tb.x + tb.width + RETAIN_TOLERANCE
720                                        && cy >= tb.y - RETAIN_TOLERANCE
721                                        && cy <= tb.y + tb.height + RETAIN_TOLERANCE
722                                })
723                            });
724                            if near_table {
725                                for &ci in &cell_text_index[trimmed] {
726                                    if try_consume(&mut cell_remaining[ci], &span_tokens, false) {
727                                        decided = true;
728                                        break;
729                                    }
730                                }
731                            }
732                        }
733                        decided
734                    };
735                    if !dropped {
736                        kept.push(s);
737                    }
738                }
739                spans = kept;
740            }
741
742            // Row-aware ordering: quantize Y into bands and sort band-
743            // descending, then X ascending within a band. Strict Y sorting
744            // would interleave cells from the same tabular row whose Y
745            // values differ by typographic jitter (common in CJK layouts,
746            // superscripts, and centered multi-line labels).
747            //
748            // Skip for multi-column pages: extract_spans() already applied
749            // XY-cut column ordering. Re-sorting with row-aware would interleave
750            // left/right columns line-by-line, splicing words from adjacent
751            // columns into each other. A topological block order (a precede
752            // relation over text blocks) handles genuine multi-region pages (a
753            // two-column body/footer, a sidebar beside the body) that a flat
754            // row-aware (y,x) sort interleaves. The gate (substantial, text-dense,
755            // dominant side-by-side blocks) rejects single-column pages, tables,
756            // TOCs and forms; it de-interleaves real two-column bodies and
757            // sidebar+body layouts. topological_block_order runs unconditionally;
758            // it self-gates to None unless the page has dominant side-by-side
759            // text-dense blocks (see its side_by_side gate), so single-column,
760            // table, and TOC pages are byte-identical.
761            //
762            // Item 2 (M2): first lift a narrow, sparse, body-aligned marginalia
763            // rail (manuscript line numbers / a folio rail) OUT of the body, so
764            // the column dispatch below sees a clean body. A rail otherwise
765            // injects a spurious second corridor (disqualifying prose detection)
766            // and a sparse block (defeating the topological side-by-side gate),
767            // and a flat (y,x) sort then weaves its numerals into the prose. The
768            // rail is re-appended at the end of the reading order after the
769            // ladder, before the artifact retain. No-op (None) on ordinary pages
770            // → byte-identical. ~keep
771            let marginalia_trailing: Vec<crate::layout::TextSpan> =
772                if let Some(idx) = Self::lift_marginalia_column(&spans) {
773                    let idxset: std::collections::HashSet<usize> = idx.into_iter().collect();
774                    let mut keep = Vec::with_capacity(spans.len());
775                    let mut marg = Vec::new();
776                    for (i, s) in std::mem::take(&mut spans).into_iter().enumerate() {
777                        if idxset.contains(&i) {
778                            marg.push(s);
779                        } else {
780                            keep.push(s);
781                        }
782                    }
783                    marg.sort_by(|a, b| crate::utils::row_aware_span_cmp(a.bbox.y, a.bbox.x, b.bbox.y, b.bbox.x));
784                    spans = keep;
785                    marg
786                } else {
787                    Vec::new()
788                };
789
790            let mut topo_applied = false;
791            if let Some(reordered) = Self::topological_block_order(&spans) {
792                spans = reordered;
793                topo_applied = true;
794            }
795            if topo_applied {
796            } else if let Some(ordered) = Self::sidebar_body_reading_order(&spans) {
797                // Narrow metadata SIDEBAR + wide body (e.g. an MDPI first page
798                // whose left rail carries Citation:/Received:/Accepted:/Copyright
799                // furniture). The row-aware (y,x) sort otherwise threads that rail
800                // INTO the body paragraphs at matching Y-bands; segregating it so
801                // each region reads contiguously matches a block-based extractor
802                // and stops the rail-into-body interleave. Already used by the
803                // md/html/structured paths; this wires the SAME ordering into the
804                // plain-text path. Tightly gated (≥30 spans, narrow sidebar with
805                // ≥2 furniture labels), so it is a no-op (None) on ordinary pages. ~keep
806                spans = ordered;
807            } else if let Some(gutter_x) =
808                Self::prose_two_column_gutter(&spans).or_else(|| Self::classifier_column_gutter(&spans))
809            {
810                // Genuine two-column prose (content-balance gated — forms /
811                // TOC / tables / figures are rejected), OR a ragged
812                // reference list / dense results body that the clean corridor
813                // sweep and `is_multi_column_page` MISS but the per-column region
814                // classifier confirms (`classifier_column_gutter`). Both read
815                // column-major with band separation: full-width rows (titles,
816                // mid-body section headings, footers — spans crossing the gutter)
817                // are emitted at their vertical position, between the column runs
818                // around them, so they are never split across the gutter
819                // (§14.8.3). This branch is tried BEFORE the single-column
820                // row-aware path so a 2-column reference page (which fails
821                // `is_multi_column_page`) is reordered instead of interleaved;
822                // both gutter detectors return None on single-column pages, which
823                // then fall through to the row-aware branch unchanged. ~keep
824                Self::reorder_column_major_with_bands(&mut spans, gutter_x);
825                // NB: do NOT run reorder_same_line_runs here. The column emit
826                // already orders each column by (y desc, x asc); a same-line
827                // X-sort would re-merge vertically-adjacent lines whenever the
828                // body leading (e.g. ~9pt) is tighter than same_line_threshold
829                // (min_fs·1.2 ≈ 10.8pt), pulling a new left-margin reference
830                // ahead of the previous reference's indented continuation
831                // (bibliography interleave) or shattering wrapped hyphenated
832                // lines in dense two-column bodies. ~keep
833            } else if !Self::is_multi_column_page(&spans)
834                || (!tables.is_empty() && Self::multicol_signal_is_tabular(&spans, &tables))
835            {
836                // Either a genuine single-column page, OR a single-column page
837                // whose only multi-column geometric signal comes from a TABLE
838                // (a data grid whose column-aligned cells trip
839                // `is_multi_column_page`). In the latter case the genuine
840                // two-column branches (topological / prose-gutter / classifier)
841                // above all declined, so the page is NOT a two-column body; the
842                // multi-column false positive is purely tabular. The correct
843                // reading order is then the row-aware (y desc, x asc) band sort
844                // — it linearises both the surrounding prose AND the table rows.
845                // Without it the page keeps raw content-stream order, which on
846                // these journal pages interleaves the table's column-major cell
847                // stream INTO the prose paragraph (PMC8078162 §3.1). Gated on a
848                // detected table whose region accounts for the multi-column
849                // signal (`multicol_signal_is_tabular`), so genuine two-column
850                // pages — which the column branches catch first, and which carry
851                // no page-dominating table — are unaffected. ~keep
852                spans.sort_by(|a, b| {
853                    let cmp = crate::utils::row_aware_span_cmp(a.bbox.y, a.bbox.x, b.bbox.y, b.bbox.x);
854                    if cmp != std::cmp::Ordering::Equal {
855                        return cmp;
856                    }
857                    a.sequence.cmp(&b.sequence)
858                });
859
860                Self::reorder_rowspan_labels(&mut spans);
861
862                // Restore intra-line reading order after the row-aware band sort.
863                // Off-baseline glyphs (e.g. superscripts/subscripts) can land in
864                // adjacent bands and be emitted out of X order; fix that per line. ~keep
865                Self::reorder_same_line_runs(&mut spans);
866            }
867
868            // Re-append the lifted marginalia rail at the end of the body
869            // reading order (Item 2 / M2). Done before the artifact retain so
870            // any artifact-marked rail spans are still dropped. ~keep
871            spans.extend(marginalia_trailing);
872
873            // Drop content marked /Artifact (PDF Spec ISO 32000-1:2008
874            // §14.8.2.2 — headers, footers, page numbers, decorations) —
875            // unless the caller opted in via `options.include_artifacts`
876            // (default true). Untagged-PDF running-header detection
877            // runs at document level and feeds the same artifact_type flag. ~keep
878            if !options.include_artifacts {
879                spans.retain(|s| s.artifact_type.is_none());
880            }
881
882            Self::reverse_rtl_visual_order_runs(&mut spans);
883
884            spans.retain(|s| {
885                s.bbox.x.is_finite()
886                    && s.bbox.y.is_finite()
887                    && s.bbox.width.is_finite()
888                    && s.bbox.height.is_finite()
889                    && s.font_size.is_finite()
890            });
891
892            // as isolated fragments interleaved with other spans (pdfa_004).
893            Self::merge_sub_superscript_spans(&mut spans);
894
895            // Inline table insertion.
896            //
897            // Tables were previously rendered in a single block appended
898            // at the end of the page text, after all flow spans. That
899            // matches how `extract_text` historically worked but it means
900            // tabular content appears far away from the prose that
901            // surrounds it in reading order — on product data sheets
902            // like ORAFOL 5900 the "Physical and Chemical Properties"
903            // label/value rows showed up 20+ lines below the section
904            // they belong to, which was perceived as
905            // the content being dropped entirely.
906            //
907            // Instead, maintain a sorted queue of tables keyed by their
908            // top-Y (the larger Y coordinate of the table's bbox, per PDF
909            // user-space conventions where Y grows upward). As we walk
910            // the flow spans in row-aware reading order, whenever the
911            // next span's top-Y falls below the top-Y of the queue's
912            // leading table, we flush that table's rendered text at
913            // that point, then continue. A final pass at the end emits
914            // any tables whose top-Y is below all remaining spans (or
915            // that have no flow spans at all).
916            //
917            // Tables are emitted at most once regardless of how many
918            // spans sit above them, preserving existing behaviour
919            // semantics while inlining the rendering at its spatial
920            // reading-order position. ~keep
921            let mut pending_tables: Vec<(f32, &crate::structure::table_extractor::Table)> = tables
922                .iter()
923                .filter_map(|t| t.bbox.map(|b| (b.y + b.height, t)))
924                .collect();
925            // Sort descending by top-Y so `pop()` returns the next table
926            // to emit in reading order (larger Y first). ~keep
927            pending_tables.sort_by(|(a, _), (b, _)| crate::utils::safe_float_cmp(*b, *a));
928
929            let flush_table = |text: &mut String, table: &crate::structure::table_extractor::Table| {
930                if !text.is_empty() && !text.ends_with('\n') {
931                    text.push('\n');
932                }
933                text.push('\n');
934                text.push_str(&table.render_text());
935                if !text.ends_with('\n') {
936                    text.push('\n');
937                }
938            };
939
940            let mut text = String::with_capacity(spans.len() * 20);
941            let mut prev_span: Option<TextSpan> = None;
942
943            for span in &spans {
944                // Flush any tables that sit above this span in PDF
945                // reading order (their top-Y is greater than or equal
946                // to the span's top-Y, meaning they should appear first). ~keep
947                while let Some(&(table_top_y, table)) = pending_tables.last() {
948                    let span_top_y = span.bbox.y + span.bbox.height;
949                    if table_top_y >= span_top_y {
950                        flush_table(&mut text, table);
951                        pending_tables.pop();
952                        // Reset prev_span so the flow-text glue logic
953                        // doesn't try to stitch the table's rendered
954                        // block together with the next flow span. ~keep
955                        prev_span = None;
956                    } else {
957                        break;
958                    }
959                }
960
961                if let Some(prev) = &prev_span {
962                    let prev_end_x = prev.bbox.x + prev.bbox.width;
963                    let span_end_x = span.bbox.x + span.bbox.width;
964                    // Containment check: skip a span only if it is geometrically
965                    // contained within the previous span AND has identical text.
966                    // Without the text comparison, distinct lines that happen to
967                    // overlap spatially (e.g., due to small Tm-scaled offsets)
968                    // would be silently dropped. ~keep
969                    let y_same = (prev.bbox.y - span.bbox.y).abs() < 2.0;
970                    if y_same
971                        && span.bbox.x >= prev.bbox.x - 0.5
972                        && span_end_x <= prev_end_x + 0.5
973                        && span.text == prev.text
974                    {
975                        continue;
976                    }
977
978                    let y_diff = (prev.bbox.y - span.bbox.y).abs();
979                    let gap = span.bbox.x - prev_end_x;
980                    let delta_x = span.bbox.x - prev.bbox.x;
981
982                    // Korean mid-eojeol soft wrap (SEG-KO): keep a Hangul word whole
983                    // when it wrapped mid-syllable. The wrap surfaces either as a
984                    // y-line break OR (when the two halves share a baseline band) as a
985                    // large backward X jump, so it is gated at each break site below. ~keep
986                    let hangul_midword_wrap = Self::hangul_midword_line_wrap(&text, prev, span);
987                    if y_diff > Self::same_line_threshold(prev, span) {
988                        let font_size = prev.font_size.max(span.font_size).max(10.0);
989                        let line_height = font_size * 1.2;
990                        let num_breaks = (y_diff / line_height).round() as usize;
991                        if !(hangul_midword_wrap && num_breaks == 1) {
992                            for _ in 0..num_breaks.clamp(1, 3) {
993                                text.push('\n');
994                            }
995                        }
996                    } else if gap < -1.0 {
997                        let fs = span.font_size.max(prev.font_size).max(6.0);
998                        if gap < -(fs * 20.0) {
999                            if !hangul_midword_wrap && !text.ends_with('\n') {
1000                                text.push('\n');
1001                            }
1002                        } else if delta_x < -fs * 3.0 {
1003                            // Same visual line, but the current span starts well to the LEFT of the
1004                            // previous span's start — i.e., the upstream ordering is non-monotonic in X.
1005                            // This commonly occurs with multi-column layouts or XY-cut artifacts where
1006                            // spans from different visual rows fall within the same Y tolerance band
1007                            // (see `same_line_threshold`).
1008                            //
1009                            // Without inserting a separator, these spans would be concatenated
1010                            // (e.g. `instancesinstancesinstances` from adjacent table headers).
1011                            // Treat this backward X jump as a logical break and emit a newline.
1012                            // ~keep
1013                            if !text.ends_with('\n') {
1014                                text.push('\n');
1015                            }
1016                        } else if gap < -fs * 3.0 && y_diff > fs * 0.5 && delta_x <= fs * 0.5 {
1017                            // Soft-wrapped next line (carriage return). Three
1018                            // signals must coincide so inline math/super-scripts
1019                            // and chart labels are NOT mistaken for a wrap:
1020                            //   • `gap < -fs*3` — the previous span ended far to
1021                            //     the RIGHT of where this one starts, i.e. the
1022                            //     line filled and wrapped (adjacent math glyphs
1023                            //     have a near-zero gap, so they are excluded);
1024                            //   • `y_diff > fs*0.5` — a real baseline drop (a
1025                            //     super/sub-script shift is smaller);
1026                            //   • `delta_x <= fs*0.5` — it returned to ~the line's
1027                            //     left margin.
1028                            // Its leading sits UNDER `same_line_threshold`
1029                            // (single-spaced body at ~1.0 em vs the 1.2 em
1030                            // threshold) so the y-newline branch above never
1031                            // fired, leaving the line-final and line-initial words
1032                            // glued together. A line break encodes no space (ISO
1033                            // 32000 §9.4.2 — positioning is geometric); synthesize
1034                            // one as a newline, which the downstream join /
1035                            // `normalize_text` collapses to a space and
1036                            // de-hyphenates. ~keep
1037                            if !hangul_midword_wrap && !text.ends_with('\n') {
1038                                text.push('\n');
1039                            }
1040                        } else if y_diff > 1.0
1041                            && delta_x <= 0.5
1042                            && gap < -fs
1043                            && !prev.rtl_draw_logical
1044                            && !span.rtl_draw_logical
1045                            && !crate::text::bidi::looks_rtl(&prev.text)
1046                            && !crate::text::bidi::looks_rtl(&span.text)
1047                        {
1048                            // Backtracking span with a real baseline offset,
1049                            // under the soft-wrap thresholds above: displayed
1050                            // math draws a fraction's denominator AFTER the
1051                            // relation sign that follows the numerator, so
1052                            // the next span starts at-or-left-of the previous
1053                            // span's ORIGIN with an overlap far beyond
1054                            // kerning (a denominator sits ~2 em back at a
1055                            // ~0.3 em baseline offset; stacked column cells
1056                            // land at delta_x ≈ 0 a row-pitch down). Bare
1057                            // concatenation fused these into tokens like
1058                            // "=dt" — break the line instead. Same-baseline
1059                            // kerned runs (y_diff ≈ 0) and forward-advancing
1060                            // subscripts (gap > -1 em) never reach here. ~keep
1061                            if !hangul_midword_wrap && !text.ends_with('\n') {
1062                                text.push('\n');
1063                            }
1064                        } else if prev.font_name != span.font_name
1065                            && span_end_x > prev_end_x + 0.5
1066                            && !text.ends_with(' ')
1067                            && !text.ends_with('\n')
1068                        {
1069                            text.push(' ');
1070                        } else if delta_x > fs * 1.5
1071                            && !text.ends_with(' ')
1072                            && !text.ends_with('\n')
1073                            && !Self::is_reliable_kerning_overlap(prev, span, gap)
1074                        {
1075                            // Inflated-width overlap recovery.
1076                            // A negative raw gap here usually comes from a
1077                            // font whose `/Widths` array is missing
1078                            // `FontInfo::new` fell back to the 550/1000-em
1079                            // constant, which over-reports each glyph's
1080                            // advance and drags `prev_end_x` past the real
1081                            // start of the next span. When the two spans'
1082                            // actual origins (`delta_x`) are separated by
1083                            // more than 1.5 em, they cannot both belong to
1084                            // the same word — the overlap is a width-table
1085                            // artifact, not real kerning — so insert a
1086                            // space to preserve the word boundary. This
1087                            // rescues cases like "STATION" + "FREEDOM"
1088                            // "UTILIZATION" + "CONFERENCE" in the NASA
1089                            // Apollo report header where raw gaps of
1090                            // -1.75 pt and -12.75 pt sit alongside
1091                            // delta_x values of 56 pt and 78 pt. ~keep
1092                            text.push(' ');
1093                        } else if y_diff > 1.0
1094                            && gap < -fs
1095                            && (span_end_x - prev_end_x).abs() <= fs / 12.0
1096                            && !prev.rtl_draw_logical
1097                            && !span.rtl_draw_logical
1098                            && !crate::text::bidi::looks_rtl(&prev.text)
1099                            && !crate::text::bidi::looks_rtl(&span.text)
1100                        {
1101                            // Right-aligned column stack. The backtracking arm
1102                            // above only reaches stacks that share a LEFT edge
1103                            // (delta_x ≈ 0); a right-aligned numeric column
1104                            // shares its RIGHT edge instead, so a shorter
1105                            // successor starts further right by the width
1106                            // difference — the one band no arm above tests. A
1107                            // row pitch under `same_line_threshold` then fused
1108                            // figures from consecutive rows ("22,796" + "3,052"
1109                            // → "22,7963,052"). Coincident right edges plus a
1110                            // full-em backward gap at a real baseline offset is
1111                            // the stack signature. The right-edge tolerance
1112                            // is a twelfth of the font size: the calibration
1113                            // column right-aligns its negative rows 0.371 pt
1114                            // left of its positive ones at 6 pt (0.062 em),
1115                            // and fs/12 (0.083 em) covers that with margin
1116                            // while reproducing the validated 0.5 pt band at
1117                            // the calibration size. Wider em-fractions sweep
1118                            // in coincidental ragged-edge alignments (a
1119                            // half-advance band separated twenty times the
1120                            // measured column population). Placed last, so it
1121                            // can only add a separator where none is emitted
1122                            // today. ~keep
1123                            if !hangul_midword_wrap && !text.ends_with('\n') {
1124                                text.push('\n');
1125                            }
1126                        }
1127                    } else if y_diff > 2.0 && gap > FORWARD_GAP_K * prev.font_size.max(span.font_size).max(1.0) {
1128                        // Forward-gap guard: pairs newly admitted to same-line
1129                        // handling by the widened threshold get a column/field-
1130                        // boundary check against FORWARD_GAP_K * max(fs).
1131                        // the constant's doc comment for calibration notes. ~keep
1132                        if !text.ends_with('\n') {
1133                            text.push('\n');
1134                        }
1135                    } else if prev.font_name != span.font_name
1136                        && gap > 0.5
1137                        && gap < prev.font_size.max(span.font_size).max(6.0) * 3.0
1138                        && !text.ends_with(' ')
1139                        && !text.ends_with('\n')
1140                    {
1141                        // Same-line font transition with a meaningful
1142                        // positive gap. Cross-font runs that survive the
1143                        // upstream `cross_font_word_glue` merge (i.e.
1144                        // both sides are multi-char) are word boundaries
1145                        // even when the gap is too small for the generic
1146                        // `should_insert_space` threshold (0.15 × fs) —
1147                        // e.g. roman → italic transitions in academic
1148                        // paper headers sit at ~2.7 pt at 10.9 pt body. ~keep
1149                        text.push(' ');
1150                    } else if Self::should_insert_space(prev, span) {
1151                        text.push(' ');
1152                    } else {
1153                        let fs = span.font_size.max(prev.font_size).max(6.0);
1154                        if gap > fs * 3.0 {
1155                            text.push('\n');
1156                        }
1157                    }
1158                }
1159
1160                Self::push_span_text(&mut text, span);
1161                prev_span = Some(span.clone());
1162            }
1163
1164            // Drain any tables that sit below all flow spans (or the
1165            // page had no flow spans at all). Without this final
1166            // pass they would be silently dropped now that the
1167            // end-of-page `for table in tables` block has been
1168            // removed. ~keep
1169            while let Some((_, table)) = pending_tables.pop() {
1170                flush_table(&mut text, table);
1171            }
1172            text
1173        };
1174
1175        // Annotation text is already included via annotation_content_spans() in
1176        // extract_spans() — do NOT call append_non_widget_annotation_text() here,
1177        // as that would emit every annotation a second time. ~keep
1178
1179        let final_text = Self::filter_leaked_metadata(&text);
1180
1181        let final_text = Self::normalize_kangxi_radicals(&final_text);
1182
1183        let final_text = Self::normalize_arabic_presentation_forms(&final_text);
1184
1185        let cleaned_text = crate::converters::whitespace::cleanup_plain_text(&final_text);
1186
1187        // For tagged PDFs, the structure-tree traversal at line 4306 already
1188        // captures all table-cell content via MCIDs. Appending tables here
1189        // would double-emit that content (structure-tree text + table render),
1190        // dropping precision. For untagged PDFs, tables are inlined via
1191        // pending_tables above, so this block is never reached (cached_tree
1192        // is None → condition would be false). The block is removed. ~keep
1193
1194        // UTF-8 mojibake repair: a run of Latin-1 Supplement chars
1195        // whose raw bytes form valid UTF-8 decoding to non-Latin-1 code
1196        // points is almost certainly a double-encoded non-Latin string
1197        // (Cyrillic, Greek, CJK, Arabic, Hebrew, …) that surfaced
1198        // because the producing font had no ToUnicode CMap and the
1199        // /Differences / AGL lookup returned the UTF-8 byte sequence
1200        // re-interpreted as Latin-1. Re-decode those runs in place. ~keep
1201        let cleaned_text = Self::repair_utf8_mojibake(&cleaned_text);
1202
1203        let cleaned_text = if options.expand_ligatures {
1204            cleaned_text
1205                .replace('\u{FB00}', "ff")
1206                .replace('\u{FB01}', "fi")
1207                .replace('\u{FB02}', "fl")
1208                .replace('\u{FB03}', "ffi")
1209                .replace('\u{FB04}', "ffl")
1210                .replace(['\u{FB05}', '\u{FB06}'], "st")
1211        } else {
1212            cleaned_text
1213        };
1214
1215        // Drop stray spaces a producer inserted between a CJK ideograph and an
1216        // embedded ASCII number (e.g. "公元前 1000 年" → "公元前1000年"). ~keep
1217        let cleaned_text = crate::extractors::text::strip_cjk_digit_boundary_spaces(&cleaned_text);
1218
1219        // Drop a space the word-break heuristic injected inside a prime-notation
1220        // number (e.g. "0′′ .28" / "0′′. 28" → "0′′.28"). ~keep
1221        let cleaned_text = crate::extractors::text::strip_prime_decimal_boundary_spaces(&cleaned_text);
1222
1223        Ok(cleaned_text)
1224    }
1225
1226    /// Walk `input` and repair runs of Latin-1 Supplement characters
1227    /// whose raw byte values form a valid UTF-8 sequence whose decoded
1228    /// codepoints include at least one non-Latin-1 character.
1229    ///
1230    /// This undoes the most common shape of "Cyrillic served as
1231    /// Latin-1" mojibake that surfaces on PDFs whose fonts have no
1232    /// ToUnicode CMap. The decoded-codepoint gate (≥ U+0100 somewhere
1233    /// in the decoded run) ensures genuine Latin-1 content like
1234    /// "Résumé" — which also decodes as valid UTF-8 but stays entirely
1235    /// within U+0000..U+00FF — is left alone.
1236    fn repair_utf8_mojibake(input: &str) -> String {
1237        // Fast-path: if the string contains no Latin-1 Supplement codepoints
1238        // (U+0080..=U+00FF), there is nothing to repair. This avoids the
1239        // O(n) `Vec<char>` allocation on every ASCII-only page. ~keep
1240        if !input.chars().any(|c| matches!(c as u32, 0x80..=0xFF)) {
1241            return input.to_string();
1242        }
1243        let mut out = String::with_capacity(input.len());
1244        let chars: Vec<char> = input.chars().collect();
1245        let mut i = 0;
1246        while i < chars.len() {
1247            let mut j = i;
1248            while j < chars.len() {
1249                let cc = chars[j] as u32;
1250                if (0x80..=0xFF).contains(&cc) {
1251                    j += 1;
1252                } else {
1253                    break;
1254                }
1255            }
1256            if j - i >= 2 {
1257                let bytes: Vec<u8> = chars[i..j].iter().map(|&c| c as u8).collect();
1258                if let Ok(decoded) = std::str::from_utf8(&bytes)
1259                    && decoded.chars().any(|c| c as u32 > 0xFF)
1260                {
1261                    out.push_str(decoded);
1262                    i = j;
1263                    continue;
1264                }
1265            }
1266            out.push(chars[i]);
1267            i += 1;
1268        }
1269        out
1270    }
1271
1272    /// Extract text from all pages of the document.
1273    ///
1274    /// Concatenates text from every page, separated by form feed characters (`\x0c`).
1275    /// This is a convenience method equivalent to calling `extract_text()` for each page.
1276    ///
1277    /// # Returns
1278    ///
1279    /// The combined text from all pages.
1280    ///
1281    /// # Examples
1282    ///
1283    /// ```no_run
1284    /// # use xberg_native_pdf::document::PdfDocument;
1285    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
1286    /// let mut doc = PdfDocument::open("paper.pdf")?;
1287    /// let all_text = doc.extract_all_text()?;
1288    /// println!("Full document: {} chars", all_text.len());
1289    /// # Ok(())
1290    /// # }
1291    /// ```
1292    pub fn extract_all_text(&self) -> Result<String> {
1293        let num_pages = self.page_count()?;
1294        let mut result = String::new();
1295
1296        for i in 0..num_pages {
1297            if i > 0 {
1298                result.push('\x0c');
1299            }
1300            match self.extract_text(i) {
1301                Ok(text) => result.push_str(&text),
1302                Err(error) => {
1303                    tracing::warn!(target: LOG_TARGET,
1304                        page_index = i,
1305                        error_code = error.telemetry_code(),
1306                        error_offset = ?error.telemetry_offset(),
1307                        "failed to extract text from page"
1308                    );
1309                }
1310            }
1311        }
1312
1313        Ok(result)
1314    }
1315}