Skip to main content

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(&region)
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}