xberg_native_pdf/document/tables.rs
1//! Table detection and row-label reconstruction.
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
9/// Gap, in em, within which two source-adjacent word fragments are re-glued into
10/// one table word. One em rather than the original half em: a font can kern the
11/// two halves of one word apart by more than a normal inter-word space, while a
12/// genuine column jump is many ems wide. See `extract_table_word_spans`. ~keep
13const TABLE_WORD_FUSE_EM: f32 = 1.0;
14
15fn preserve_source_space(text: &mut String, preceded_by_space: bool) {
16 if preceded_by_space {
17 text.insert(0, ' ');
18 }
19}
20
21impl PdfDocument {
22 /// Promote labels in rowspan-sparse columns so they sort at the top
23 /// of their data-row block instead of landing mid-group.
24 ///
25 /// A "label" here is a span in an X-cluster that contains far fewer
26 /// spans than the most populous X-cluster (i.e., it spans multiple
27 /// rows of the adjacent data column). Labels are typically vertically
28 /// centred in their block, so a strict Y sort places them between
29 /// the rows they describe. This post-processor detects the pattern
30 /// and rewrites each label's effective sort Y to sit just above the
31 /// topmost data row it visually covers.
32 ///
33 /// Data rows are partitioned between adjacent labels at the midpoint
34 /// of their Y coordinates (nearest-label assignment). The topmost
35 /// data row in a label's partition becomes the anchor for promotion.
36 ///
37 /// Nothing is mutated if there are no sparse columns or not enough
38 /// data rows to confidently infer row-grouping (min 6 rows in the
39 /// dense reference column).
40 /// Identify span indices that look like multi-row-spanning labels —
41 /// sparse-X-column spans whose Y values sit inside the data Y range
42 /// of the dense columns on the page. These are the same spans that
43 /// `reorder_rowspan_labels` would promote to the top of their row
44 /// block, except this function returns them **before** the spatial
45 /// table detector's retain filter has a chance to drop them from
46 /// the flow span list.
47 ///
48 /// The retain filter in `extract_text_with_options` removes every
49 /// span whose bbox is contained in a detected table's bbox. On CJK
50 /// reference-data PDFs the test-name label column is
51 /// narrow and vertically centred within each multi-row data block,
52 /// so its spans are inside the table bbox and would be dropped
53 /// without replacement — the spatial table extractor does not emit
54 /// these labels as `TableCell`s either. Preserving the identified
55 /// labels through the retain filter lets `reorder_rowspan_labels`
56 /// promote them to their proper reading-order position alongside
57 /// the surviving flow spans.
58 ///
59 /// Returns a `HashSet` of indices into the provided `spans` slice.
60 /// Callers must use the returned indices **before** any reordering
61 /// or retention mutates the slice.
62 pub(crate) fn identify_multi_row_labels(spans: &[crate::layout::TextSpan]) -> std::collections::HashSet<usize> {
63 use std::collections::{BTreeSet, HashMap as StdHashMap, HashSet};
64
65 let mut out: HashSet<usize> = HashSet::new();
66 if spans.len() < 10 {
67 return out;
68 }
69
70 // Cluster by X proximity (15pt gap threshold) — same heuristic
71 // as `reorder_rowspan_labels`. ~keep
72 let mut by_x: Vec<usize> = (0..spans.len()).collect();
73 by_x.sort_by(|&a, &b| crate::utils::safe_float_cmp(spans[a].bbox.x, spans[b].bbox.x));
74 const X_GAP: f32 = 15.0;
75 let mut columns: Vec<Vec<usize>> = Vec::new();
76 let mut cur: Vec<usize> = Vec::new();
77 let mut last_x = f32::NEG_INFINITY;
78 for &idx in &by_x {
79 let x = spans[idx].bbox.x;
80 if !cur.is_empty() && x - last_x > X_GAP {
81 columns.push(std::mem::take(&mut cur));
82 }
83 cur.push(idx);
84 last_x = x;
85 }
86 if !cur.is_empty() {
87 columns.push(cur);
88 }
89 if columns.len() < 2 {
90 return out;
91 }
92
93 let max_count = columns.iter().map(|c| c.len()).max().unwrap_or(0);
94 if max_count < 6 {
95 return out;
96 }
97
98 let mut col_order: Vec<usize> = (0..columns.len()).collect();
99 col_order.sort_by(|&a, &b| columns[b].len().cmp(&columns[a].len()));
100 let dense_cols_count = columns.iter().filter(|c| c.len() * 2 > max_count).count();
101
102 let band_of = |y: f32| (y / crate::utils::ROW_BAND_TOLERANCE_PT).round() as i32;
103 let data_bands: BTreeSet<i32> = if dense_cols_count >= 3 {
104 let top: Vec<&Vec<usize>> = col_order.iter().take(3).map(|&i| &columns[i]).collect();
105 let mut support: StdHashMap<i32, usize> = StdHashMap::new();
106 for col in &top {
107 let bands: HashSet<i32> = col.iter().map(|&i| band_of(spans[i].bbox.y)).collect();
108 for b in bands {
109 *support.entry(b).or_insert(0) += 1;
110 }
111 }
112 support.into_iter().filter(|(_, c)| *c >= 3).map(|(b, _)| b).collect()
113 } else if dense_cols_count == 2 {
114 let a: HashSet<i32> = columns[col_order[0]]
115 .iter()
116 .map(|&i| band_of(spans[i].bbox.y))
117 .collect();
118 let b: HashSet<i32> = columns[col_order[1]]
119 .iter()
120 .map(|&i| band_of(spans[i].bbox.y))
121 .collect();
122 a.intersection(&b).copied().collect()
123 } else {
124 columns[col_order[0]]
125 .iter()
126 .map(|&i| band_of(spans[i].bbox.y))
127 .collect()
128 };
129
130 if data_bands.len() < 4 {
131 return out;
132 }
133
134 let band_pt = crate::utils::ROW_BAND_TOLERANCE_PT;
135 let data_top = (*data_bands.iter().next_back().unwrap() as f32) * band_pt + band_pt / 2.0;
136 let data_bot = (*data_bands.iter().next().unwrap() as f32) * band_pt - band_pt / 2.0;
137
138 for col in &columns {
139 if col.len() < 2 || col.len() * 2 >= max_count {
140 continue;
141 }
142 let in_data: Vec<usize> = col
143 .iter()
144 .copied()
145 .filter(|&i| {
146 let y = spans[i].bbox.y;
147 y > data_bot && y < data_top
148 })
149 .collect();
150 if in_data.len() >= 2 {
151 out.extend(in_data);
152 }
153 }
154
155 out
156 }
157
158 pub(crate) fn reorder_rowspan_labels(spans: &mut Vec<crate::layout::TextSpan>) {
159 use std::collections::HashMap;
160
161 if spans.len() < 10 {
162 return;
163 }
164
165 // Cluster by X proximity (15pt gap threshold). Walk spans ordered
166 // by left edge; start a new cluster whenever the gap exceeds the
167 // threshold. ~keep
168 let mut by_x: Vec<usize> = (0..spans.len()).collect();
169 by_x.sort_by(|&a, &b| crate::utils::safe_float_cmp(spans[a].bbox.x, spans[b].bbox.x));
170 const X_GAP: f32 = 15.0;
171 let mut columns: Vec<Vec<usize>> = Vec::new();
172 let mut cur: Vec<usize> = Vec::new();
173 let mut last_x = f32::NEG_INFINITY;
174 for &idx in &by_x {
175 let x = spans[idx].bbox.x;
176 if !cur.is_empty() && x - last_x > X_GAP {
177 columns.push(std::mem::take(&mut cur));
178 }
179 cur.push(idx);
180 last_x = x;
181 }
182 if !cur.is_empty() {
183 columns.push(cur);
184 }
185 if columns.len() < 2 {
186 return;
187 }
188
189 let max_count = columns.iter().map(|c| c.len()).max().unwrap_or(0);
190 if max_count < 6 {
191 return;
192 }
193
194 let mut col_order: Vec<usize> = (0..columns.len()).collect();
195 col_order.sort_by(|&a, &b| columns[b].len().cmp(&columns[a].len()));
196
197 // A column is "dense" when it holds a strict majority of the
198 // most populous column's spans. Pages with multiple dense data
199 // columns (three or more) let us derive the data-row range by
200 // intersecting their Y bands — headers and sub-headers populate
201 // only a subset of columns at their Y and fall out. ~keep
202 let dense_cols_count = columns.iter().filter(|c| c.len() * 2 > max_count).count();
203
204 let dense_col = &columns[col_order[0]];
205 let mut dense_ys: Vec<f32> = dense_col.iter().map(|&i| spans[i].bbox.y).collect();
206 dense_ys.sort_by(|a, b| crate::utils::safe_float_cmp(*b, *a));
207
208 // Compute the set of Y bands that count as "data". When several
209 // dense columns are available, require a band to have support in
210 // the top three; otherwise fall back to the single dense column's
211 // own Y values. ~keep
212 let band_of = |y: f32| (y / crate::utils::ROW_BAND_TOLERANCE_PT).round() as i32;
213 use std::collections::{BTreeSet, HashMap as StdHashMap, HashSet};
214
215 let data_bands: BTreeSet<i32> = if dense_cols_count >= 3 {
216 let top: Vec<&Vec<usize>> = col_order.iter().take(3).map(|&i| &columns[i]).collect();
217 let mut support: StdHashMap<i32, usize> = StdHashMap::new();
218 for col in &top {
219 let bands: HashSet<i32> = col.iter().map(|&i| band_of(spans[i].bbox.y)).collect();
220 for b in bands {
221 *support.entry(b).or_insert(0) += 1;
222 }
223 }
224 support.into_iter().filter(|(_, c)| *c >= 3).map(|(b, _)| b).collect()
225 } else if dense_cols_count == 2 {
226 let a: HashSet<i32> = columns[col_order[0]]
227 .iter()
228 .map(|&i| band_of(spans[i].bbox.y))
229 .collect();
230 let b: HashSet<i32> = columns[col_order[1]]
231 .iter()
232 .map(|&i| band_of(spans[i].bbox.y))
233 .collect();
234 a.intersection(&b).copied().collect()
235 } else {
236 dense_col.iter().map(|&i| band_of(spans[i].bbox.y)).collect()
237 };
238
239 if data_bands.len() < 4 {
240 return;
241 }
242 let band_pt = crate::utils::ROW_BAND_TOLERANCE_PT;
243 let data_top = (*data_bands.iter().next_back().unwrap() as f32) * band_pt + band_pt / 2.0;
244 let data_bot = (*data_bands.iter().next().unwrap() as f32) * band_pt - band_pt / 2.0;
245
246 // Y-bands occupied by the dense column. Genuine rowspan labels are
247 // vertically centred *between* data rows, so their Y-band must NOT
248 // appear in this set. Spans whose Y aligns with the dense column are
249 // line-continuation text on the same logical line, not labels. ~keep
250 let dense_bands: HashSet<i32> = dense_col.iter().map(|&i| band_of(spans[i].bbox.y)).collect();
251
252 // Numbered reference/bibliography lists render the leading marker
253 // ("1.", "2.", "3.") in a narrow column to the left of the body
254 // text. That marker column is sparse (one per entry) and its markers
255 // sit between body rows, so they look exactly like rowspan labels to
256 // the heuristic below — but they are NOT: each number belongs to its
257 // own entry and promoting them scrambles the reference order. Detect
258 // the pattern (>=3 numbered markers sharing a tight left-edge cluster
259 // and spread down >=3 distinct rows = a vertical numbered list) and
260 // exclude those markers from label promotion. ~keep
261 let is_numbered_marker = |i: usize| -> bool {
262 let t = spans[i].text.trim_start();
263 let digits = t.chars().take_while(|c| c.is_ascii_digit()).count();
264 (1..=3).contains(&digits) && t[digits..].starts_with(['.', ')'])
265 };
266 let numbered_excluded: HashSet<usize> = {
267 let markers: Vec<usize> = (0..spans.len()).filter(|&i| is_numbered_marker(i)).collect();
268 if markers.len() >= 3 {
269 let mut xs: Vec<f32> = markers.iter().map(|&i| spans[i].bbox.x).collect();
270 xs.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
271 let median_x = xs[xs.len() / 2];
272 let cluster: Vec<usize> = markers
273 .iter()
274 .copied()
275 .filter(|&i| (spans[i].bbox.x - median_x).abs() <= 6.0)
276 .collect();
277 let rows: HashSet<i32> = cluster.iter().map(|&i| band_of(spans[i].bbox.y)).collect();
278 if cluster.len() >= 3 && rows.len() >= 3 {
279 cluster.into_iter().collect()
280 } else {
281 HashSet::new()
282 }
283 } else {
284 HashSet::new()
285 }
286 };
287
288 // Collect "label" candidates: spans that sit in a "sparse"
289 // column — one that holds meaningfully fewer spans than the
290 // most populous column. A candidate only qualifies when it
291 // sits strictly inside the data Y range AND the sparse column
292 // it belongs to has at least two entries inside that range —
293 // single-span sparse cells are almost always stray annotations,
294 // not labels. ~keep
295 let mut labels: Vec<usize> = Vec::new();
296 for col in &columns {
297 if col.len() < 2 || col.len() * 2 >= max_count {
298 continue;
299 }
300 let in_data: Vec<usize> = col
301 .iter()
302 .copied()
303 .filter(|&i| {
304 let y = spans[i].bbox.y;
305 // Exclude spans on the same Y-band as the dense column:
306 // those are line-continuation text, not rowspan labels.
307 // Also exclude numbered-list markers (reference numbers),
308 // which would otherwise be hoisted out of reading order. ~keep
309 y > data_bot
310 && y < data_top
311 && !dense_bands.contains(&band_of(y))
312 && !numbered_excluded.contains(&i)
313 })
314 .collect();
315 if in_data.len() >= 2 {
316 labels.extend(in_data);
317 }
318 }
319 if labels.is_empty() {
320 return;
321 }
322 labels.sort_by(|&a, &b| crate::utils::safe_float_cmp(spans[b].bbox.y, spans[a].bbox.y));
323
324 // Labels that sit at near-identical Y values almost always
325 // annotate the same logical row block (e.g. a test-name in the
326 // "name" column alongside a unit "×10⁹/L" in the "unit" column,
327 // both vertically centred in the same 6-row group). Cluster
328 // labels by Y proximity so each logical block is promoted as a
329 // unit. ~keep
330 const CLUSTER_GAP: f32 = 10.0;
331 let mut clusters: Vec<Vec<usize>> = Vec::new();
332 let mut cur: Vec<usize> = Vec::new();
333 let mut last_y = f32::NAN;
334 for &idx in &labels {
335 let y = spans[idx].bbox.y;
336 if !cur.is_empty() && (last_y - y).abs() > CLUSTER_GAP {
337 clusters.push(std::mem::take(&mut cur));
338 }
339 cur.push(idx);
340 last_y = y;
341 }
342 if !cur.is_empty() {
343 clusters.push(cur);
344 }
345 let cluster_ys: Vec<f32> = clusters
346 .iter()
347 .map(|c| c.iter().map(|&i| spans[i].bbox.y).sum::<f32>() / c.len() as f32)
348 .collect();
349
350 let mut promoted: HashMap<usize, f32> = HashMap::new();
351 for (k, cluster) in clusters.iter().enumerate() {
352 let c_y = cluster_ys[k];
353 let upper = if k > 0 {
354 (cluster_ys[k - 1] + c_y) / 2.0
355 } else {
356 f32::INFINITY
357 };
358 let lower = if k + 1 < clusters.len() {
359 (c_y + cluster_ys[k + 1]) / 2.0
360 } else {
361 f32::NEG_INFINITY
362 };
363 let upper_clamped = upper.min(data_top);
364 let lower_clamped = lower.max(data_bot - 1.0);
365 let mut anchor = f32::NEG_INFINITY;
366 for &y in &dense_ys {
367 if y <= upper_clamped && y > lower_clamped && y > anchor {
368 anchor = y;
369 }
370 }
371 if anchor.is_finite() {
372 for &i in cluster {
373 promoted.insert(i, anchor + 1.0);
374 }
375 }
376 }
377 if promoted.is_empty() {
378 return;
379 }
380
381 let mut order: Vec<usize> = (0..spans.len()).collect();
382 order.sort_by(|&a, &b| {
383 let ya = promoted.get(&a).copied().unwrap_or(spans[a].bbox.y);
384 let yb = promoted.get(&b).copied().unwrap_or(spans[b].bbox.y);
385 crate::utils::row_aware_span_cmp(ya, spans[a].bbox.x, yb, spans[b].bbox.x)
386 });
387 let reordered: Vec<crate::layout::TextSpan> = order.into_iter().map(|i| spans[i].clone()).collect();
388 *spans = reordered;
389 }
390
391 /// Extract tables from a page.
392 ///
393 /// Uses a hybrid spatial algorithm that combines text alignment and vector lines
394 /// for robust table detection without explicit structure markup.
395 ///
396 /// # Example
397 ///
398 /// ```ignore
399 /// let tables = doc.extract_tables(0)?;
400 /// for table in tables {
401 /// println!("Table with {} rows and {} columns", table.rows.len(), table.col_count);
402 /// }
403 /// ```
404 pub fn extract_tables(&self, page_index: usize) -> Result<Vec<crate::structure::table_extractor::Table>> {
405 self.extract_tables_with_config(
406 page_index,
407 crate::structure::spatial_table_detector::TableDetectionConfig::default(),
408 )
409 }
410
411 /// Word-level span source for the spatial table detector.
412 ///
413 /// Words give the detector cell granularity (a spaced string splits into
414 /// separate columns), but the geometric word clustering re-decides every
415 /// join from raw bbox gaps — and a sub-em kerned split defeats any gap
416 /// threshold, so a word the span-level merger had correctly assembled
417 /// from per-glyph advance evidence can come back in fragments and
418 /// surface as spaces inside a table cell. Re-glue the fragments the
419 /// merger marked as boundary-free before handing spans to the detector.
420 fn extract_table_word_spans(&self, page_index: usize) -> Result<Vec<crate::layout::TextSpan>> {
421 let (words, continues_prev, space_before) = self.extract_words_inner(page_index, None, None, true)?;
422 let mut fused: Vec<crate::layout::Word> = Vec::with_capacity(words.len());
423 for ((word, continues), preceded_by_space) in words.into_iter().zip(continues_prev).zip(space_before) {
424 let mut word = word;
425 // The cell assembler re-decides joins from bbox gaps alone, and on a
426 // tight face the space's glyphs abut or overlap their neighbours, so
427 // that gap reads as zero or negative and the two words fuse
428 // (GH#1948: `corrispettivisuperiori`). Carry the source space in the
429 // word's own text so `cell_span_separator` emits it. Use the exact
430 // source-character marker: a non-continuation can instead mean that
431 // clustering visited glyphs out of source order or skipped a
432 // non-whitespace glyph. ~keep
433 preserve_source_space(&mut word.text, preceded_by_space);
434 // Source adjacency says the producer drew these glyphs consecutively;
435 // it does not say they are typographically adjacent, so geometry still
436 // has a veto on both bounds:
437 // above — the merger also concatenates runs across a column jump
438 // when neither side carries a space glyph, and honouring
439 // that here would dissolve the grid;
440 // below — a large NEGATIVE gap is the signature of a backtrack
441 // (displayed-math denominators) or a line-wrap reset, the
442 // two cases the word merge loop guards with
443 // `gap < -font_size` and `delta_x < -5 * font_size`.
444 // The band is one em, not the original half em: a font can kern two
445 // halves of one word apart by more than a normal space (GH#1948:
446 // `d’impresa` seams at 0.68 em), and a genuine column jump is many
447 // ems wide. ~keep
448 match fused.last_mut() {
449 Some(prev)
450 if continues
451 && (word.bbox.x - (prev.bbox.x + prev.bbox.width)).abs()
452 < prev.avg_font_size.max(word.avg_font_size).max(1.0) * TABLE_WORD_FUSE_EM =>
453 {
454 prev.absorb(word)
455 }
456 _ => fused.push(word),
457 }
458 }
459 Ok(fused
460 .into_iter()
461 .map(|w| crate::layout::TextSpan {
462 provenance: None,
463 artifact_type: None,
464 text: w.text,
465 bbox: w.bbox,
466 font_name: w.dominant_font,
467 font_size: w.avg_font_size,
468 font_weight: if w.is_bold {
469 crate::layout::FontWeight::Bold
470 } else {
471 crate::layout::FontWeight::Normal
472 },
473 is_italic: w.is_italic,
474 is_monospace: false,
475 color: crate::layout::Color::black(),
476 mcid: w.mcid,
477 mcid_scope: None,
478 sequence: 0,
479 split_boundary_before: false,
480 offset_semantic: false,
481 char_spacing: 0.0,
482 word_spacing: 0.0,
483 horizontal_scaling: 1.0,
484 primary_detected: false,
485 char_widths: vec![],
486 char_x_offsets: Vec::new(),
487 heading_level: None,
488 rotation_degrees: 0.0,
489 wmode: 0,
490 text_rise: 0.0,
491 rtl_draw_logical: false,
492 mirrored: false,
493 page_rotation_applied: 0,
494 })
495 .collect())
496 }
497
498 /// Extract tables from a page using a custom configuration.
499 pub fn extract_tables_with_config(
500 &self,
501 page_index: usize,
502 config: crate::structure::spatial_table_detector::TableDetectionConfig,
503 ) -> Result<Vec<crate::structure::table_extractor::Table>> {
504 use crate::structure::spatial_table_detector::detect_tables_with_lines;
505
506 // Use words instead of spans for better granularity.
507 // This ensures that strings with spaces are split into separate columns
508 // for the spatial detector. ~keep
509 let spans = self.extract_table_word_spans(page_index)?;
510 // GH#1656: a page-sized background rectangle passes is_table_primitive
511 // (its <1000pt bound doesn't exclude A4/Letter) and then unions every
512 // other primitive on the page into one cluster. Drop it here, where the
513 // real MediaBox is available; a failed MediaBox lookup leaves every
514 // primitive alone rather than filtering on garbage. ~keep
515 let media_box = self.get_page_media_box(page_index).ok();
516 let lines: Vec<_> = self
517 .extract_paths(page_index)?
518 .into_iter()
519 .filter(|p| p.is_table_primitive())
520 .filter(|p| match media_box {
521 Some(mb) => !p.is_page_frame_rectangle(mb),
522 None => true,
523 })
524 .collect();
525
526 // Same prose-rejection filter `extract_page_tables` applies to the
527 // extract_text/to_markdown/to_html path — this public API called
528 // `detect_tables_with_lines` directly with no post-filter at all, so
529 // it was already able to fabricate/garble tables on any prose-shaped
530 // spatial candidate, independent of anything else in this function. ~keep
531 Ok(detect_tables_with_lines(&spans, &lines, &config)
532 .into_iter()
533 .filter(|t| t.is_real_grid() && !looks_like_prose_table(t))
534 .collect())
535 }
536
537 /// Extract tables from a specific rectangular region of a page.
538 pub fn extract_tables_in_rect(
539 &self,
540 page_index: usize,
541 region: crate::geometry::Rect,
542 ) -> Result<Vec<crate::structure::table_extractor::Table>> {
543 self.extract_tables_in_rect_with_config(
544 page_index,
545 region,
546 crate::structure::spatial_table_detector::TableDetectionConfig::relaxed(),
547 )
548 }
549
550 /// Extract tables from a specific region using custom configuration.
551 pub fn extract_tables_in_rect_with_config(
552 &self,
553 page_index: usize,
554 region: crate::geometry::Rect,
555 config: crate::structure::spatial_table_detector::TableDetectionConfig,
556 ) -> Result<Vec<crate::structure::table_extractor::Table>> {
557 let tables = self.extract_tables_with_config(page_index, config)?;
558 Ok(tables
559 .into_iter()
560 .filter(|table| {
561 if let Some(bbox) = table.bbox {
562 bbox.intersects(®ion)
563 } else {
564 false
565 }
566 })
567 .collect())
568 }
569
570 /// Extract tables from a page using structure tree and spatial detection.
571 ///
572 /// Tries two strategies in order:
573 /// 1. **Structure tree** (tagged PDFs): Finds Table elements in the structure
574 /// tree and extracts cell content via MCID matching.
575 /// 2. **Spatial detection** (untagged PDFs): Uses X/Y coordinate clustering
576 /// to detect grid-aligned text as tables.
577 ///
578 /// Returns early with structure tree tables if found (high confidence).
579 pub(super) fn extract_page_tables(
580 &self,
581 page_index: usize,
582 spans: &[TextSpan],
583 options: &crate::converters::ConversionOptions,
584 text_fallback: bool,
585 ) -> Vec<crate::structure::Table> {
586 let struct_tree_opt = {
587 let cached = self.structure_tree_cache.lock_or_recover().clone();
588 match cached {
589 Some(tree) => tree,
590 None => {
591 let is_marked = self.mark_info().map(|m| m.marked).unwrap_or(false);
592 let has_struct_tree_root = !is_marked
593 && self
594 .catalog()
595 .ok()
596 .and_then(|cat| cat.as_dict().map(|d| d.contains_key("StructTreeRoot")))
597 .unwrap_or(false);
598 let tree = if is_marked || has_struct_tree_root {
599 self.structure_tree().ok().flatten().map(Arc::new)
600 } else {
601 None
602 };
603 *self.structure_tree_cache.lock_or_recover() = Some(tree.clone());
604 tree
605 }
606 }
607 };
608 if let Some(ref struct_tree) = struct_tree_opt {
609 if self.table_elements_cache.lock_or_recover().is_none() {
610 let all = crate::structure::find_table_elements_all_pages(struct_tree);
611 *self.table_elements_cache.lock_or_recover() = Some(all);
612 }
613 let table_elems: Vec<crate::structure::StructElem> = self
614 .table_elements_cache
615 .lock_or_recover()
616 .as_ref()
617 .and_then(|c| c.get(&(page_index as u32)))
618 .cloned()
619 .unwrap_or_default();
620 if !table_elems.is_empty() {
621 let mut tables = Vec::new();
622 for table_elem in &table_elems {
623 match crate::structure::extract_table_from_spans(table_elem, spans) {
624 Ok(mut table) if !table.is_empty() => {
625 if table.bbox.is_none() {
626 let all_mcids: HashSet<u32> = table
627 .rows
628 .iter()
629 .flat_map(|r| r.cells.iter().flat_map(|c| c.mcids.iter().copied()))
630 .collect();
631 if !all_mcids.is_empty() {
632 let mut min_x = f32::INFINITY;
633 let mut min_y = f32::INFINITY;
634 let mut max_x = f32::NEG_INFINITY;
635 let mut max_y = f32::NEG_INFINITY;
636 for span in spans {
637 if let Some(mcid) = span.mcid
638 && all_mcids.contains(&mcid)
639 {
640 min_x = min_x.min(span.bbox.x);
641 min_y = min_y.min(span.bbox.y);
642 max_x = max_x.max(span.bbox.x + span.bbox.width);
643 max_y = max_y.max(span.bbox.y + span.bbox.height);
644 }
645 }
646 if min_x < max_x && min_y < max_y {
647 table.bbox = Some(crate::geometry::Rect::new(
648 min_x,
649 min_y,
650 max_x - min_x,
651 max_y - min_y,
652 ));
653 }
654 }
655 }
656 tables.push(table);
657 }
658 _ => {}
659 }
660 }
661 if !tables.is_empty() {
662 tracing::debug!(target: LOG_TARGET,
663 "Found {} table(s) via structure tree for page {}",
664 tables.len(),
665 page_index
666 );
667 return tables;
668 }
669 }
670 }
671
672 let mut config = options.table_detection_config.clone().unwrap_or_default();
673 // Honour the caller's text_fallback choice regardless of the default
674 // on `TableDetectionConfig` — `extract_text` / `to_plain_text` pass
675 // `text_fallback=false` to opt out of text-only spatial fallback even
676 // though the type-level default is `true`. ~keep
677 config.text_fallback = text_fallback;
678
679 let paths = self.extract_paths(page_index).unwrap_or_default();
680
681 // Filter to table-relevant paths (lines and rectangles only).
682 // Chart/plot pages often have hundreds of curves and fills that
683 // extract_edges ignores anyway — passing them through the full
684 // detection pipeline wastes O(n²) time. ~keep
685 const LINE_TOL: f32 = 2.0;
686 // GH#1656: same page-frame furniture filter as extract_tables_with_config
687 // above — required here too, since this entry point builds its own
688 // `table_paths` and feeds them straight into detect_tables_with_lines
689 // without going through that method. ~keep
690 let media_box = self.get_page_media_box(page_index).ok();
691 let table_paths: Vec<_> = paths
692 .into_iter()
693 .filter(|p| p.is_horizontal_line(LINE_TOL) || p.is_vertical_line(LINE_TOL) || p.is_rectangle())
694 .filter(|p| match media_box {
695 Some(mb) => !p.is_page_frame_rectangle(mb),
696 None => true,
697 })
698 .collect();
699
700 // A page with thousands of line/rect paths is a drawing or chart, not a
701 // ruled table; skip the O(E²) collinear-join + intersection sweep. Real
702 // ruled tables have at most a few hundred edges. (Tagged tables already
703 // returned above via the structure tree.) ~keep
704 const MAX_TABLE_EDGES: usize = 1500;
705 if table_paths.len() > MAX_TABLE_EDGES {
706 tracing::debug!(target: LOG_TARGET,
707 "Page {} has {} line/rect paths (> {}) — skipping spatial table sweep",
708 page_index,
709 table_paths.len(),
710 MAX_TABLE_EDGES
711 );
712 return Vec::new();
713 }
714
715 if table_paths.is_empty() {
716 use crate::structure::spatial_table_detector::TableStrategy;
717 let is_text_only = matches!(
718 (config.horizontal_strategy, config.vertical_strategy),
719 (TableStrategy::Text, TableStrategy::Text)
720 );
721 if !is_text_only && !config.text_fallback {
722 return Vec::new();
723 }
724 if !is_text_only && config.text_fallback {
725 tracing::debug!(target: LOG_TARGET,
726 "No ruling lines on page {} — using text-only spatial fallback",
727 page_index
728 );
729 }
730 }
731 let paths = table_paths;
732
733 let word_spans = self.extract_table_word_spans(page_index).unwrap_or_default();
734
735 let input_spans = if !word_spans.is_empty() { &word_spans } else { spans };
736
737 let raw_tables =
738 crate::structure::spatial_table_detector::detect_tables_with_lines(input_spans, &paths, &config);
739
740 // Issue 484/486/487: when a logical multi-row table is drawn with a
741 // horizontal ruling line between every pair of rows, the line-based
742 // detector emits one Table per row strip. Each fragment is a 1- or
743 // 2-row table that fails is_real_grid below and gets dropped, after
744 // which the cells fall through to paragraph flow with column-based
745 // reading order — producing orphan `<p>40000≤Q</p>` /
746 // `<p><55000</p>` pairs. Consolidate vertically-adjacent fragments
747 // that share an identical column structure BEFORE applying
748 // is_real_grid so the merged multi-row table survives the filter. ~keep
749 let raw_tables = crate::structure::spatial_table_detector::consolidate_adjacent_table_fragments(raw_tables);
750
751 // Step 4: spatial detection without struct-tree backing
752 // is prone to false positives on form-style layouts (label-colon-
753 // value pairs that align horizontally, form fillable boxes drawn
754 // with thin lines). Drop tables that don't look like real grids. ~keep
755 let raw_count = raw_tables.len();
756 let mut tables: Vec<crate::structure::Table> = raw_tables
757 .into_iter()
758 .filter(|t| t.is_real_grid())
759 // Prose-shape filter — applies to line-based detection too: a
760 // PDF with decorative horizontal rules (newsletter mastheads,
761 // press-release banners) can hand `is_real_grid` a "wide data
762 // table" that is actually wrapped paragraphs partitioned by
763 // word x-alignment. Reject those before they reach the
764 // converter. See `looks_like_prose_table` for the heuristic. ~keep
765 .filter(|t| !looks_like_prose_table(t))
766 .collect();
767
768 if raw_count != tables.len() {
769 tracing::debug!(target: LOG_TARGET,
770 "Spatial table detection: filtered {} non-real-grid candidates on page {} ({} kept)",
771 raw_count - tables.len(),
772 page_index,
773 tables.len(),
774 );
775 } else if !tables.is_empty() {
776 tracing::debug!(target: LOG_TARGET,
777 "Found {} table(s) via hybrid spatial detection for page {}",
778 tables.len(),
779 page_index
780 );
781 }
782
783 // Text-only spatial fallback for converter paths (to_markdown / to_html).
784 //
785 // Wide data tables (e.g. sailing-score grids with 16-18 columns) exceed the default
786 // `max_table_columns: 15` limit and are rejected by the main pipeline. When the
787 // caller explicitly opted in to text-only detection (text_fallback=true), retry with
788 // a relaxed config that raises the column ceiling and adjusts tolerances so that
789 // genuinely wide data tables are captured.
790 //
791 // Safety guards:
792 // - Only fires when the main pipeline returned no tables (avoids double-counting).
793 // - Only fires when the caller is a converter (text_fallback=true).
794 // - Skipped for tagged PDFs: the structure tree already provides the authoritative
795 // layout; spatial heuristics produce false-positive tables from structure elements
796 // (e.g. headings detected as single-row tables).
797 // - Skipped for predominantly-RTL pages: Arabic/Hebrew text alignment patterns
798 // mimic table columns in spatial heuristics.
799 // - When ruling lines exist, spans are filtered to the line-bounded region to
800 // prevent page headers/footers from being erroneously included in the table.
801 // - Results must pass is_real_grid() just like main-pipeline tables. ~keep
802
803 // Guard 1 — Tagged PDFs: presence of a structure tree means the document has an
804 // explicit semantic layout. Spatial text-only detection would misfire on
805 // structure elements (headings, paragraphs) that happen to share a Y band. ~keep
806 if config.text_fallback && struct_tree_opt.is_some() {
807 tracing::debug!(target: LOG_TARGET,
808 "Text-only spatial fallback skipped for page {} — document has a structure tree (tagged PDF)",
809 page_index
810 );
811 return tables;
812 }
813
814 // Guard 2 — RTL pages: Arabic and Hebrew text naturally aligns horizontally in
815 // patterns that the column-clustering algorithm mistakes for table columns.
816 // Skip spatial detection when more than 30 % of the input spans are RTL. ~keep
817 if config.text_fallback {
818 let rtl_count = input_spans
819 .iter()
820 .filter(|s| crate::text::bidi::looks_rtl(&s.text))
821 .count();
822 let rtl_fraction = rtl_count as f32 / input_spans.len().max(1) as f32;
823 if rtl_fraction > 0.30 {
824 tracing::debug!(target: LOG_TARGET,
825 "Text-only spatial fallback skipped for page {} — {:.0}% RTL spans (threshold 30%)",
826 page_index,
827 rtl_fraction * 100.0
828 );
829 return tables;
830 }
831 }
832
833 if config.text_fallback && tables.is_empty() {
834 use crate::structure::spatial_table_detector::detect_tables_from_spans_column_aware;
835 // Build a relaxed config derived from the caller's config.
836 // We only raise the limits known to block wide data tables (e.g. sailing
837 // score grids with 16-18 columns that exceed the default max_table_columns=15). ~keep
838 let relaxed_config = crate::structure::spatial_table_detector::TableDetectionConfig {
839 // Allow up to 25 columns — covers 17-column sailing score tables. ~keep
840 max_table_columns: config.max_table_columns.max(25),
841 // Tighter column grouping than the default 15 pt so that nearby
842 // score columns are not merged into each other. ~keep
843 column_tolerance: config.column_tolerance.min(10.0),
844 // Looser merge threshold so that columns with slight X scatter
845 // (e.g. centred numeric cells) are aggregated correctly. ~keep
846 column_merge_threshold: config.column_merge_threshold.max(30.0),
847 ..config.clone()
848 };
849
850 // When ruling lines are present on the page, restrict text detection to
851 // spans that fall within the VERTICAL-LINE Y bounds. Vertical lines
852 // define the table's column structure and their Y extent precisely
853 // delineates the table rows, excluding page headers and footers which
854 // sit above/below the table frame.
855 //
856 // Note: we use V-line Y bounds specifically (not total path bbox) because
857 // H-lines in these PDFs often span the full page height (outer frame),
858 // while V-lines are confined to the interior table region. ~keep
859 let candidate_spans: Vec<crate::layout::TextSpan>;
860 let fallback_spans: &[crate::layout::TextSpan] = {
861 let v_lines: Vec<_> = paths.iter().filter(|p| p.is_vertical_line(2.0)).collect();
862 if !v_lines.is_empty() {
863 // Rendered extents: a stroke-width-encoded column rule's
864 // drawn bar spans the table height while its geometric
865 // bbox is a ~0pt speck at the midline —
866 // banding on the speck would filter out the table's own
867 // spans. ~keep
868 let vline_y_min = v_lines
869 .iter()
870 .map(|p| p.rendered_bbox().y)
871 .fold(f32::INFINITY, f32::min);
872 let vline_y_max = v_lines
873 .iter()
874 .map(|p| {
875 let r = p.rendered_bbox();
876 r.y + r.height
877 })
878 .fold(f32::NEG_INFINITY, f32::max);
879 // Small margin to include spans whose centres just touch the frame. ~keep
880 const V_MARGIN: f32 = 5.0;
881 candidate_spans = input_spans
882 .iter()
883 .filter(|s| {
884 let cy = s.bbox.y + s.bbox.height * 0.5;
885 cy >= vline_y_min - V_MARGIN && cy <= vline_y_max + V_MARGIN
886 })
887 .cloned()
888 .collect();
889 tracing::debug!(target: LOG_TARGET,
890 "Text fallback (page {}): V-lines Y=[{:.1},{:.1}] — filtered {} spans to {}",
891 page_index,
892 vline_y_min,
893 vline_y_max,
894 input_spans.len(),
895 candidate_spans.len()
896 );
897 &candidate_spans
898 } else {
899 input_spans
900 }
901 };
902
903 let text_candidates = detect_tables_from_spans_column_aware(fallback_spans, &relaxed_config);
904 let pre_filter = text_candidates.len();
905 let text_tables: Vec<_> = text_candidates
906 .into_iter()
907 // Text-only detection infers columns from word x-alignment
908 // alone; a title + a wrapped body line (two rows) is the
909 // signature of ordinary prose, not a table. Require ≥3
910 // rows of evidence before promoting to a table. ~keep
911 .filter(|t| t.rows.len() >= 3 && t.is_real_grid())
912 // Prose split across many "columns" is the dominant
913 // false-positive shape for text-only detection on
914 // line-less pages: a paragraph wraps to N lines, words
915 // cluster into N×K cells, and `is_real_grid` accepts the
916 // shape. Real data-table cells almost never end with a
917 // comma or semicolon (those punctuation marks belong to
918 // running sentences), so a high comma-tail ratio is the
919 // most discriminating prose signal we have. ~keep
920 .filter(|t| !looks_like_prose_table(t))
921 .collect();
922 if !text_tables.is_empty() {
923 tracing::debug!(target: LOG_TARGET,
924 "Text-only relaxed fallback found {} table(s) on page {} ({} filtered by is_real_grid)",
925 text_tables.len(),
926 page_index,
927 pre_filter - text_tables.len(),
928 );
929 tables = text_tables;
930 }
931 }
932
933 tables
934 }
935}
936
937#[cfg(test)]
938mod source_space_tests {
939 use super::preserve_source_space;
940
941 #[test]
942 fn non_contiguous_source_glyphs_without_whitespace_do_not_gain_a_space() {
943 let mut text = "fragment".to_string();
944
945 preserve_source_space(&mut text, false);
946
947 assert_eq!(text, "fragment");
948 }
949
950 #[test]
951 fn source_whitespace_is_preserved_when_geometry_cannot_show_it() {
952 let mut text = "fragment".to_string();
953
954 preserve_source_space(&mut text, true);
955
956 assert_eq!(text, " fragment");
957 }
958}