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}