Skip to main content

xberg_native_pdf/structure/
spatial_table_detector.rs

1//! Spatial table detection from PDF text layout.
2//!
3//! Implements table detection according to ISO 32000-1:2008 Section 5.2 (Coordinate Systems).
4//! Uses X and Y coordinate clustering to identify table structure in PDFs that lack explicit
5//! table markup in the structure tree.
6
7use crate::layout::text_block::TextSpan;
8use crate::structure::table_extractor::{Table, TableCell, TableRow, span_text_for_cell};
9use std::collections::HashMap;
10
11/// Disjoint-set (union-find) with path compression.
12struct UnionFind {
13    parent: Vec<usize>,
14}
15
16impl UnionFind {
17    fn new(n: usize) -> Self {
18        Self {
19            parent: (0..n).collect(),
20        }
21    }
22
23    fn find(&mut self, i: usize) -> usize {
24        let mut curr = i;
25        while self.parent[curr] != curr {
26            self.parent[curr] = self.parent[self.parent[curr]];
27            curr = self.parent[curr];
28        }
29        curr
30    }
31
32    fn union(&mut self, i: usize, j: usize) {
33        let ri = self.find(i);
34        let rj = self.find(j);
35        if ri != rj {
36            self.parent[ri] = rj;
37        }
38    }
39
40    fn groups(&mut self) -> HashMap<usize, Vec<usize>> {
41        let mut result: HashMap<usize, Vec<usize>> = HashMap::new();
42        for i in 0..self.parent.len() {
43            let root = self.find(i);
44            result.entry(root).or_default().push(i);
45        }
46        result
47    }
48}
49
50/// Strategy for detecting table boundaries.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
52pub enum TableStrategy {
53    /// Use only vector lines to define boundaries.
54    #[serde(rename = "lines")]
55    Lines,
56    /// Use only text alignment to define boundaries.
57    #[serde(rename = "text")]
58    Text,
59    /// Use both text and lines (hybrid approach).
60    #[default]
61    #[serde(rename = "both")]
62    Both,
63}
64
65/// Configuration for spatial table detection.
66#[derive(Debug, Clone, PartialEq)]
67pub struct TableDetectionConfig {
68    /// Whether table detection is enabled.
69    pub enabled: bool,
70    /// Strategy for horizontal boundary detection.
71    pub horizontal_strategy: TableStrategy,
72    /// Strategy for vertical boundary detection.
73    pub vertical_strategy: TableStrategy,
74    /// X-coordinate tolerance for column grouping.
75    pub column_tolerance: f32,
76    /// Y-coordinate tolerance for row grouping.
77    pub row_tolerance: f32,
78    /// Minimum number of cells required for a valid table.
79    pub min_table_cells: usize,
80    /// Minimum number of columns required for a valid table.
81    pub min_table_columns: usize,
82    /// Ratio of regular rows required for a valid table structure.
83    pub regular_row_ratio: f32,
84    /// Maximum number of columns allowed before rejecting as false positive.
85    pub max_table_columns: usize,
86    /// Merge threshold for post-clustering column merge pass.
87    /// Adjacent columns whose centers are within this distance are merged.
88    pub column_merge_threshold: f32,
89    /// Minimum gap between Y-range groups of vertical lines to trigger a cluster split.
90    /// Default: 20.0. Use smaller values (e.g. 4.0) for strict mode, larger (e.g. 40.0)
91    /// for relaxed mode where V-lines at mixed Y-ranges should stay together.
92    pub v_split_gap: f32,
93    /// Enable text-only spatial detection as a fallback when no ruling lines are found.
94    ///
95    /// When `true` and the page has no table-relevant paths (no ruling lines or
96    /// rectangles), the detector falls through to `detect_tables_from_spans_column_aware`
97    /// rather than returning an empty result.  This is the right default for structured
98    /// output callers (`to_markdown`, `to_html`) that explicitly want tabular layout
99    /// and is also relied on by the public `extract_tables` API for line-less PDFs.
100    /// Set to `false` from callers that want the conservative
101    /// "no ruling lines → no tables" behaviour (e.g. plain-text extraction paths
102    /// that explicitly opt out — see `extract_page_tables`).
103    ///
104    /// False-positive prose / TOC / underline tables that this default would
105    /// previously have surfaced are filtered post-detection by the
106    /// `looks_like_prose_table` shape gate and a ≥ 3-row evidence requirement
107    /// on text-only and h-rule paths.
108    ///
109    /// Default: `true`.
110    pub text_fallback: bool,
111}
112
113impl Default for TableDetectionConfig {
114    fn default() -> Self {
115        Self {
116            enabled: true,
117            horizontal_strategy: TableStrategy::Both,
118            vertical_strategy: TableStrategy::Both,
119            column_tolerance: 15.0,
120            row_tolerance: 2.8,
121            min_table_cells: 4,
122            min_table_columns: 2,
123            regular_row_ratio: 0.3,
124            max_table_columns: 15,
125            column_merge_threshold: 25.0,
126            v_split_gap: 20.0,
127            text_fallback: true,
128        }
129    }
130}
131
132impl TableDetectionConfig {
133    /// Create a strict table detection configuration.
134    pub fn strict() -> Self {
135        Self {
136            enabled: true,
137            horizontal_strategy: TableStrategy::Lines,
138            vertical_strategy: TableStrategy::Lines,
139            column_tolerance: 2.0,
140            row_tolerance: 1.0,
141            min_table_cells: 6,
142            min_table_columns: 3,
143            regular_row_ratio: 0.8,
144            max_table_columns: 12,
145            column_merge_threshold: 10.0,
146            v_split_gap: 4.0,
147            text_fallback: true,
148        }
149    }
150
151    /// Create a relaxed table detection configuration.
152    pub fn relaxed() -> Self {
153        Self {
154            enabled: true,
155            horizontal_strategy: TableStrategy::Text,
156            vertical_strategy: TableStrategy::Text,
157            column_tolerance: 10.0,
158            row_tolerance: 5.0,
159            min_table_cells: 4,
160            min_table_columns: 2,
161            regular_row_ratio: 0.3,
162            max_table_columns: 20,
163            column_merge_threshold: 30.0,
164            v_split_gap: 40.0,
165            text_fallback: true,
166        }
167    }
168}
169
170/// Validate that an extracted table is not a false positive.
171///
172/// Rejects:
173/// - Tables with too many empty cells (> 60%).
174/// - 2-column tables that contain a **continuation-row signature**: any
175///   row whose left-hand cell is empty while the right-hand cell is
176///   non-empty. Product data sheets draw faint cell backgrounds behind
177///   label/value rows, which the spatial detector can cluster into tiny
178///   2-column tables; when the right-hand value wraps onto a second line,
179///   the continuation row leaves an empty left-hand label cell beside
180///   the wrapped value text. This exact shape is a reliable false-positive
181///   signal. Sparse 2-column tables with *legitimately* missing right-hand
182///   values (e.g. "Fax: ", "N/A" rows) are NOT rejected by this rule.
183fn is_valid_table(table: &Table) -> bool {
184    if table.rows.is_empty() || table.col_count == 0 {
185        return false;
186    }
187
188    let total_cells = table.rows.len() * table.col_count;
189    let empty_cells = table
190        .rows
191        .iter()
192        .flat_map(|r| &r.cells)
193        .filter(|c| c.text.trim().is_empty())
194        .count();
195    let empty_ratio = empty_cells as f32 / total_cells.max(1) as f32;
196
197    if empty_ratio > 0.6 {
198        return false;
199    }
200
201    if table.col_count == 2 {
202        let has_continuation_row = table
203            .rows
204            .iter()
205            .any(|r| r.cells.len() == 2 && r.cells[0].text.trim().is_empty() && !r.cells[1].text.trim().is_empty());
206        if has_continuation_row {
207            return false;
208        }
209    }
210
211    true
212}
213
214/// Additional gate applied to SPATIAL-only table detection (no explicit
215/// lines/rulings): reject "word-per-cell" false positives where a
216/// paragraph's visual gaps accidentally align into columns.
217///
218/// Signature: >=5 columns AND >70% of non-empty cells contain only a
219/// single word. Real data tables have multi-word labels, numeric values,
220/// or dense content; a paragraph mis-read as a table reads as a sentence
221/// when the cells are concatenated.
222///
223/// This gate is NOT applied when rulings/lines define the table — in
224/// that case the author explicitly marked the structure and we trust it
225/// even if cells are single-character (census forms, sparse grids).
226fn passes_spatial_quality_gate(table: &Table) -> bool {
227    if table.col_count < 5 {
228        return true;
229    }
230    let non_empty: Vec<&str> = table
231        .rows
232        .iter()
233        .flat_map(|r| &r.cells)
234        .map(|c| c.text.trim())
235        .filter(|t| !t.is_empty())
236        .collect();
237    if non_empty.is_empty() {
238        return true;
239    }
240    // A genuine numeric data table (financial / metrics slides) is legitimately
241    // almost all single tokens — every cell is a *number* — so the generic
242    // single-word prose gate below would wrongly reject it and flatten it into a
243    // bold label plus run-on numbers. Bypass the gate
244    // ONLY when the table is clearly numeric-DOMINATED (≥50% of non-empty cells
245    // are data values). This is deliberately strict: number-heavy prose (an
246    // academic page with inline citations/equations whose words happen to align
247    // into columns) stays below 50% numeric and is still held to the prose gate,
248    // so the bypass does not manufacture false tables. ~keep
249    let data_values = non_empty.iter().filter(|t| is_data_value(t)).count();
250    if data_values * 2 >= non_empty.len() {
251        return true;
252    }
253    let single_word_count = non_empty.iter().filter(|t| t.split_whitespace().count() <= 1).count();
254    let ratio = single_word_count as f32 / non_empty.len() as f32;
255    ratio <= 0.7
256}
257
258/// A numeric / data value token: digits plus the usual numeric punctuation
259/// (decimal point, thousands comma, percent, sign, currency). Requires at least
260/// one digit so a bare `+` or `$` is not treated as data. Used so numeric-table
261/// cells do not read as prose fragments in the spatial quality gate.
262fn is_data_value(t: &str) -> bool {
263    !t.is_empty()
264        && t.chars().any(|c| c.is_ascii_digit())
265        && t.chars().all(|c| {
266            c.is_ascii_digit()
267                || matches!(
268                    c,
269                    '.' | ',' | '%' | '+' | '-' | '\u{2212}' | '$' | '\u{20AC}' | '\u{00A3}'
270                )
271        })
272}
273
274/// Reject a spatial (no-rulings) "table" whose rows are wrapped paragraph
275/// lines — a flowing prose page (heading + body paragraph + footer) whose
276/// inter-word gaps coincidentally aligned into columns.
277///
278/// Signature: at least one row, when its non-empty cells are concatenated
279/// left-to-right, crosses a SENTENCE boundary mid-row — an alphanumeric
280/// character, a sentence terminator, a space, then a new word (e.g. "...to
281/// 23,500. Stockout rate..."). Real data-table rows hold values/labels, not
282/// running sentences that span a terminator into the next clause, so this
283/// almost never fires on genuine tables. Only applied to spatial tables (the
284/// caller is the no-rulings path); ruled tables are author-marked and
285/// trusted.
286///
287/// Case is checked via the Unicode general category (`char::is_uppercase` /
288/// `is_lowercase`), not ASCII-only, so cased non-Latin scripts (Greek,
289/// Cyrillic, Armenian, …) get the same "new sentence starts with a capital"
290/// signal Latin does. Scripts with no case distinction at all (Bengali,
291/// Devanagari, …) can't use that signal, so their sentence-final danda
292/// (`।`, `॥`) is instead treated as a terminator in its own right: a danda
293/// followed by a space and another letter mid-row is itself the
294/// discriminator, since a genuine data cell doesn't embed a sentence stop
295/// followed by more prose in the same row.
296fn looks_like_prose_paragraph(table: &Table) -> bool {
297    const CASELESS_TERMINATORS: [char; 2] = ['।', '॥'];
298
299    for row in &table.rows {
300        let joined = row
301            .cells
302            .iter()
303            .map(|c| c.text.trim())
304            .filter(|t| !t.is_empty())
305            .collect::<Vec<_>>()
306            .join(" ");
307        let chars: Vec<char> = joined.chars().collect();
308        for i in 0..chars.len() {
309            // Cased terminator: the exact prior-release strict signature — a
310            // lowercase/digit, then `.`/`!`/`?`, then space + capital +
311            // lowercase (a genuine new sentence). Kept ASCII with immediate
312            // neighbours on purpose: broadening it (any alphanumeric before,
313            // skip-spaces, Unicode case) over-rejected genuine data tables
314            // whose cells contain abbreviations or capitalised codes ("Fig.
315            // 3", "Dr. Smith", "A. Test") as prose. ~keep
316            if matches!(chars[i], '.' | '!' | '?')
317                && i >= 1
318                && (chars[i - 1].is_ascii_lowercase() || chars[i - 1].is_ascii_digit())
319                && i + 3 < chars.len()
320                && chars[i + 1] == ' '
321                && chars[i + 2].is_ascii_uppercase()
322                && chars[i + 3].is_ascii_lowercase()
323            {
324                return true;
325            }
326            if CASELESS_TERMINATORS.contains(&chars[i]) {
327                let Some(&prev) = chars[..i].iter().rev().find(|c| **c != ' ') else {
328                    continue;
329                };
330                if !prev.is_alphanumeric() {
331                    continue;
332                }
333                let mut after = chars[i + 1..].iter().copied().skip_while(|c| *c == ' ');
334                if after.next().is_some_and(|c| c.is_alphabetic()) {
335                    return true;
336                }
337            }
338        }
339    }
340
341    // Punctuation-independent fallback: a wrapped-prose row whose column
342    // break fell mid-clause carries no sentence terminator at all (a title
343    // caption, a clause with no `.`/`!`/`?`), so the checks above miss it.
344    // Real data-cell values are atomic (numbers, codes, capitalised labels)
345    // and essentially never start with a lowercase letter; running-sentence
346    // fragments frequently do ("text with", "a smaller", "on"). This mirrors
347    // `looks_like_prose_table`'s per-cell signal in document.rs, but applies
348    // PER ROW with no minimum cell count — that function's ≥10-cell floor
349    // (needed there to avoid over-rejecting small genuine tables under a
350    // table-wide ratio) is exactly what lets a small (2–3 row) fabricated
351    // table slip through untested. A 2-cell floor plus a majority-of-row
352    // requirement keeps ordinary short data rows (a units column, a coded
353    // abbreviation) safe: those cells are numeric/capitalised, not lowercase
354    // clause fragments, so they rarely cross the 50% bar even at n=2. ~keep
355    for row in &table.rows {
356        let cells: Vec<&str> = row
357            .cells
358            .iter()
359            .map(|c| c.text.trim())
360            .filter(|t| !t.is_empty())
361            .collect();
362        if cells.len() < 2 {
363            continue;
364        }
365        let lower_starts = cells
366            .iter()
367            .filter(|c| c.chars().next().is_some_and(char::is_lowercase))
368            .count();
369        if lower_starts as f32 / cells.len() as f32 > 0.5 {
370            return true;
371        }
372    }
373
374    // Vertically-stacked single-character lines: a rotated axis label or
375    // figure legend, drawn one glyph per line so its bbox flattens into the
376    // page's normal (unrotated) column grid, reads as a cell whose text is
377    // mostly 1-character lines joined by `\n` (e.g. "k\nc\no\nc\nE-Box" for a
378    // vertically-drawn "clock"-style label). A genuine multi-line data cell
379    // wraps at WORD boundaries — its lines hold whole words, not lone
380    // letters — so this shape is essentially unique to misread rotated text. ~keep
381    for row in &table.rows {
382        for cell in &row.cells {
383            let lines: Vec<&str> = cell.text.split('\n').map(str::trim).filter(|l| !l.is_empty()).collect();
384            if lines.len() < 3 {
385                continue;
386            }
387            let single_char_lines = lines.iter().filter(|l| l.chars().count() == 1).count();
388            if single_char_lines as f32 / lines.len() as f32 > 0.5 {
389                return true;
390            }
391        }
392    }
393
394    false
395}
396
397/// Reject a spatial (no-rulings) "table" that is actually CJK prose split into
398/// columns by the wrap geometry of an unspaced script. CJK (Chinese, Japanese,
399/// Korean Han/kana) writes without inter-word spaces, so wrapped prose lines
400/// align into a grid the column detector mistakes for a table. A genuine CJK
401/// data cell is short (a label or a number); a prose fragment is a long run of
402/// ideographs/kana. Reject when any cell holds a run of `MIN_CJK_RUN` or more
403/// consecutive CJK characters. Ruled / author-marked tables never reach here.
404fn looks_like_cjk_prose(table: &Table) -> bool {
405    const MIN_CJK_RUN: usize = 12;
406    fn is_cjk(c: char) -> bool {
407        matches!(
408            c as u32,
409            0x3040..=0x30FF      // Hiragana + Katakana ~keep
410            | 0x3400..=0x4DBF    // CJK Ext A ~keep
411            | 0x4E00..=0x9FFF    // CJK Unified ~keep
412            | 0xF900..=0xFAFF    // CJK Compatibility ~keep
413            | 0xFF66..=0xFF9F    // Halfwidth Katakana ~keep
414            | 0xAC00..=0xD7AF    // Hangul Syllables ~keep
415        )
416    }
417    table.rows.iter().flat_map(|r| &r.cells).any(|c| {
418        let mut run = 0usize;
419        for ch in c.text.chars() {
420            if is_cjk(ch) {
421                run += 1;
422                if run >= MIN_CJK_RUN {
423                    return true;
424                }
425            } else if !ch.is_whitespace() {
426                run = 0;
427            }
428        }
429        false
430    })
431}
432
433/// Reject a spatial (no-rulings) "table" that is actually a bulleted list whose
434/// markers and bodies aligned into columns. A genuine data table never carries
435/// a cell that is *only* a bullet glyph; an untagged list rendered into two
436/// columns (`• | Ship the API.`) does. Catches the structured-document
437/// false-positive where a heading + bulleted list + prose are mis-fused into a
438/// grid. Ruled / author-marked tables never reach this path.
439fn looks_like_bulleted_list(table: &Table) -> bool {
440    /// An unambiguous bullet glyph (never a legitimate data value).
441    ///
442    /// Deliberately a separate list from `xberg`'s `pdf::structure::list_marker::BULLET_GLYPHS`,
443    /// which lives in the dependent crate and cannot be imported here. Keep the two in step when
444    /// adding a glyph; the arrowheads were added for GH#1790, where an arrow-bullet list was read
445    /// as a table. Widening only ever stops a list becoming a table, never the reverse. ~keep
446    fn is_bullet_glyph(c: char) -> bool {
447        matches!(
448            c,
449            '\u{2022}'
450                | '\u{2023}'
451                | '\u{2043}'
452                | '\u{2219}'
453                | '\u{25AA}'
454                | '\u{25CF}'
455                | '\u{25E6}'
456                | '\u{00B7}'
457                | '\u{2024}'
458                | '\u{27A2}'
459                | '\u{27A4}'
460                | '\u{25BA}'
461                | '\u{25B6}'
462                | '\u{25CB}'
463        )
464    }
465    fn is_list_item(t: &str) -> bool {
466        let mut chars = t.chars();
467        match chars.next() {
468            Some(c) if is_bullet_glyph(c) => true,
469            // A lone "*" cell is also a marker (but "*5" footnote-data is not). ~keep
470            Some('*') => chars.next().is_none(),
471            _ => false,
472        }
473    }
474    table
475        .rows
476        .iter()
477        .flat_map(|r| &r.cells)
478        .filter(|c| !c.text.trim().is_empty())
479        .any(|c| is_list_item(c.text.trim()))
480}
481
482/// Detect page column regions from an X-projection histogram of text spans.
483///
484/// Builds a histogram of horizontal coverage (2pt buckets), then identifies
485/// runs of empty buckets as candidate column gutters.  Only gaps wider than
486/// 20pt **and** at least 4% of the total page X-extent are treated as true
487/// column boundaries, preventing internal table whitespace from being
488/// misidentified as page column gutters.
489///
490/// Returns a list of `(x_min, x_max)` column regions sorted left-to-right.
491fn detect_page_columns(spans: &[TextSpan]) -> Vec<(f32, f32)> {
492    if spans.is_empty() {
493        return Vec::new();
494    }
495
496    // 1. Find page X extent, excluding degenerate outliers.
497    //
498    // Per PDF 32000-1:2008 §8.3.2.3, user space is an infinite plane and
499    // the CTM can produce arbitrarily large coordinates. The visible region
500    // is defined by MediaBox/CropBox. Degenerate CTM transforms (e.g.,
501    // rotated dvips pages) can produce span coordinates ~1e16 pt wide,
502    // which would cause a multi-petabyte histogram allocation.
503    //
504    // Strategy: compute the median X center, then exclude any span whose
505    // center is more than MAX_EXTENT from the median. This fixed safety
506    // bound covers all standard page sizes while rejecting pathological
507    // outliers; pages wider than 10,000pt fall back to single column. ~keep
508    const MAX_EXTENT_FROM_MEDIAN: f32 = 5_000.0;
509
510    let mut x_centers: Vec<f32> = spans.iter().map(|s| s.bbox.x + s.bbox.width * 0.5).collect();
511    x_centers.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
512    let median_x = x_centers[x_centers.len() / 2];
513
514    let mut page_x_min = f32::MAX;
515    let mut page_x_max = f32::MIN;
516    for s in spans {
517        let center = s.bbox.x + s.bbox.width * 0.5;
518        if (center - median_x).abs() > MAX_EXTENT_FROM_MEDIAN {
519            continue;
520        }
521        let left = s.bbox.x;
522        let right = s.bbox.x + s.bbox.width;
523        if left < page_x_min {
524            page_x_min = left;
525        }
526        if right > page_x_max {
527            page_x_max = right;
528        }
529    }
530
531    if page_x_min >= page_x_max {
532        // All spans were outliers or no valid extent ~keep
533        return vec![(
534            spans.iter().map(|s| s.bbox.x).fold(f32::MAX, f32::min),
535            spans.iter().map(|s| s.bbox.x + s.bbox.width).fold(f32::MIN, f32::max),
536        )];
537    }
538
539    let page_width = page_x_max - page_x_min;
540
541    // Final safety: if width is still unreasonable after outlier filtering,
542    // skip column detection entirely. Typical pages are ≤2400pt (A0). ~keep
543    if page_width > 10_000.0 {
544        tracing::warn!(
545            page_width,
546            "detect_page_columns: page width still exceeds safe limit after outlier filtering, \
547             falling back to single column"
548        );
549        return vec![(page_x_min, page_x_max)];
550    }
551
552    let bucket_size = 2.0_f32;
553    let n_buckets = ((page_width) / bucket_size).ceil() as usize + 1;
554    let mut histogram = vec![0u32; n_buckets];
555
556    for s in spans {
557        let center = s.bbox.x + s.bbox.width * 0.5;
558        if (center - median_x).abs() > MAX_EXTENT_FROM_MEDIAN {
559            continue;
560        }
561        let left = s.bbox.x;
562        let right = s.bbox.x + s.bbox.width;
563        let b_start = ((left - page_x_min) / bucket_size).floor() as usize;
564        let b_end = ((right - page_x_min) / bucket_size).ceil() as usize;
565        for b in b_start..b_end.min(n_buckets) {
566            histogram[b] += 1;
567        }
568    }
569
570    let min_gap_pt = 20.0_f32;
571    let min_gap_buckets = ((min_gap_pt / bucket_size).ceil() as usize).max(1);
572
573    struct Gap {
574        start_bucket: usize,
575        len_buckets: usize,
576    }
577
578    let mut gaps = Vec::new();
579    let mut gap_start: Option<usize> = None;
580
581    for (i, &count) in histogram.iter().enumerate() {
582        if count == 0 {
583            if gap_start.is_none() {
584                gap_start = Some(i);
585            }
586        } else if let Some(gs) = gap_start {
587            let gap_len = i - gs;
588            if gap_len >= min_gap_buckets {
589                gaps.push(Gap {
590                    start_bucket: gs,
591                    len_buckets: gap_len,
592                });
593            }
594            gap_start = None;
595        }
596    }
597
598    // 4. For each gap, determine the "immediate region" on each side
599    //    (bounded by adjacent gaps or page edges).  A gap qualifies as a
600    //    page column gutter only if at least one of its immediate regions
601    //    contains a span wider than `min_paragraph_width` (80pt).  This
602    //    prevents inter-cell whitespace in tables from being treated as
603    //    column gutters. ~keep
604    let min_paragraph_width = 80.0_f32;
605    let qualifying_indices: Vec<usize> = (0..gaps.len())
606        .filter(|&gi| {
607            let gap_left_x = page_x_min + gaps[gi].start_bucket as f32 * bucket_size;
608            let gap_right_x = page_x_min + (gaps[gi].start_bucket + gaps[gi].len_buckets) as f32 * bucket_size;
609
610            let left_bound = if gi > 0 {
611                page_x_min + (gaps[gi - 1].start_bucket + gaps[gi - 1].len_buckets) as f32 * bucket_size
612            } else {
613                page_x_min
614            };
615            let right_bound = if gi + 1 < gaps.len() {
616                page_x_min + gaps[gi + 1].start_bucket as f32 * bucket_size
617            } else {
618                page_x_max
619            };
620
621            let has_wide_left = spans.iter().any(|s| {
622                let center = s.bbox.x + s.bbox.width / 2.0;
623                center >= left_bound && center <= gap_left_x && s.bbox.width >= min_paragraph_width
624            });
625
626            let has_wide_right = spans.iter().any(|s| {
627                let center = s.bbox.x + s.bbox.width / 2.0;
628                center >= gap_right_x && center <= right_bound && s.bbox.width >= min_paragraph_width
629            });
630
631            has_wide_left || has_wide_right
632        })
633        .collect();
634    let qualifying_gaps: Vec<&Gap> = qualifying_indices.iter().map(|&i| &gaps[i]).collect();
635
636    if qualifying_gaps.is_empty() {
637        return vec![(page_x_min, page_x_max)];
638    }
639
640    let mut columns = Vec::new();
641
642    let first_occ = match histogram.iter().position(|&c| c > 0) {
643        Some(b) => b,
644        None => return Vec::new(),
645    };
646    let mut region_start = first_occ;
647
648    for gap in &qualifying_gaps {
649        if gap.start_bucket > region_start {
650            let x_min = page_x_min + region_start as f32 * bucket_size;
651            let x_max = page_x_min + gap.start_bucket as f32 * bucket_size;
652            columns.push((x_min, x_max));
653        }
654        region_start = gap.start_bucket + gap.len_buckets;
655    }
656
657    let last_occ = histogram.iter().rposition(|&c| c > 0).unwrap_or(n_buckets - 1);
658    if region_start <= last_occ {
659        let x_min = page_x_min + region_start as f32 * bucket_size;
660        let x_max = page_x_min + (last_occ + 1) as f32 * bucket_size;
661        columns.push((x_min, x_max));
662    }
663
664    columns
665}
666
667/// Column-aware text-only table detection.
668///
669/// Detects page columns first (via X-projection histogram), then runs
670/// `detect_tables_from_spans()` independently on each column partition.
671/// This prevents multi-column academic layouts from being misinterpreted
672/// as wide tables spanning the whole page.
673pub fn detect_tables_from_spans_column_aware(spans: &[TextSpan], config: &TableDetectionConfig) -> Vec<Table> {
674    if !config.enabled || spans.is_empty() {
675        return Vec::new();
676    }
677
678    let page_cols = detect_page_columns(spans);
679
680    if page_cols.len() <= 1 {
681        return detect_tables_from_spans(spans, config);
682    }
683
684    let mut all_tables = Vec::new();
685    for &(col_x_min, col_x_max) in &page_cols {
686        let col_spans: Vec<TextSpan> = spans
687            .iter()
688            .filter(|s| {
689                let span_center = s.bbox.x + s.bbox.width / 2.0;
690                span_center >= col_x_min && span_center <= col_x_max
691            })
692            .cloned()
693            .collect();
694        if col_spans.is_empty() {
695            continue;
696        }
697        let mut tables = detect_tables_from_spans(&col_spans, config);
698        all_tables.append(&mut tables);
699    }
700
701    all_tables
702}
703
704/// Detect tables from spatial layout of text spans.
705pub fn detect_tables_from_spans(spans: &[TextSpan], config: &TableDetectionConfig) -> Vec<Table> {
706    if !config.enabled || spans.is_empty() {
707        return Vec::new();
708    }
709
710    let mut columns = detect_columns(spans, config.column_tolerance, config.column_merge_threshold);
711
712    // Greedy X-center clustering fragments a single logical cell whose
713    // words are internally spaced (e.g. an agenda row "Receiving Dock
714    // Inspection" laid out with wide inter-word gaps) into one column
715    // per word. detect_text_edge_columns instead keeps only X edges that
716    // recur across >= 3 distinct rows, so single-row word positions are
717    // rejected and the true column grid (Time / Activity / Team) is
718    // recovered. Cross-row recurrence is a strictly stronger column
719    // signal than one row's word spacing, so prefer the text-edge result
720    // whenever it yields a valid, strictly-smaller column set.
721    //
722    // Safety: for tables with < 3 rows, text-edge can keep no column
723    // (every edge appears in < 3 rows) so it returns fewer than
724    // min_table_columns and the guard below leaves greedy untouched —
725    // small genuine tables are unaffected. ~keep
726    // If greedy clustering produced too many columns, try text-edge
727    // detection which looks for X positions that recur across multiple rows.
728    if columns.len() > config.max_table_columns {
729        let te_columns = detect_text_edge_columns(spans, config);
730        if te_columns.len() >= config.min_table_columns.max(2) && te_columns.len() < columns.len() {
731            columns = te_columns;
732        }
733    }
734
735    // Borderless numeric lattice (ML / results tables). When the column gap
736    // is below `column_merge_threshold`, greedy clustering fuses a dense grid
737    // of short numeric cells laid out on a regular ~20pt pitch, so two values
738    // share one cell ("0.69 0.76"). The text-edge detector keeps only X edges
739    // that recur across >=3 rows, which on a numeric lattice recovers every
740    // column. Prefer it when the spans are predominantly numeric and it splits
741    // a coarser greedy set into more (still bounded) columns. The numeric-
742    // predominance gate keeps prose / label-value tables (e.g. Google-Docs
743    // exports) on the greedy path untouched. ~keep
744    let numeric_spans = spans.iter().filter(|s| is_numeric_cell(s.text.trim())).count();
745    if numeric_spans >= 10 && columns.len() <= config.max_table_columns {
746        let te_columns = detect_text_edge_columns(spans, config);
747        if te_columns.len() > columns.len()
748            && te_columns.len() >= 5
749            && te_columns.len() <= config.max_table_columns
750            && is_regular_lattice(&te_columns)
751        {
752            // Adopt the finer lattice ONLY when it still forms a fully valid
753            // grid. A sparse split that fails the quality gate would otherwise
754            // drop the whole table to prose — worse than the merged-column
755            // baseline. Probing here means the refinement can only refine a
756            // table that stays valid, never demote one. ~keep
757            let probe_rows = detect_rows(spans, config.row_tolerance);
758            if probe_rows.len() >= 2 {
759                let probe_grid = assign_spans_to_cells(spans, &te_columns, &probe_rows);
760                if validate_table_structure_internal(&probe_grid, config) {
761                    let probe_table = grid_to_table(&probe_grid, spans, None);
762                    if is_valid_table(&probe_table)
763                        && passes_spatial_quality_gate(&probe_table)
764                        && !looks_like_prose_paragraph(&probe_table)
765                    {
766                        columns = te_columns;
767                    }
768                }
769            }
770        }
771    }
772
773    if columns.len() < config.min_table_columns.max(2) || columns.len() > config.max_table_columns {
774        return Vec::new();
775    }
776
777    let rows = detect_rows(spans, config.row_tolerance);
778    if rows.len() < 2 {
779        return Vec::new();
780    }
781
782    // Baseline gate (CRITICAL): the ORIGINAL (unfiltered) columns must
783    // already form a table that passes EVERY emission gate baseline
784    // uses — structural validation AND the final is_valid_table /
785    // passes_spatial_quality_gate checks. The row-coverage cleanup
786    // below only REFINES a table that would have been emitted anyway;
787    // it must never CREATE a table from content baseline treated as
788    // prose. Without checking the FINAL gates here, dropping phantom
789    // columns can flip a borderline case that baseline rejected on the
790    // quality gate into a spurious table (observed on annots.pdf link
791    // lists and right_to_left_01.pdf Arabic prose in the 70-PDF sweep). ~keep
792    let orig_grid = assign_spans_to_cells(spans, &columns, &rows);
793    if !validate_table_structure_internal(&orig_grid, config) {
794        return Vec::new();
795    }
796    let orig_table = grid_to_table(&orig_grid, spans, None);
797    if !is_valid_table(&orig_table)
798        || !passes_spatial_quality_gate(&orig_table)
799        || looks_like_prose_paragraph(&orig_table)
800        || looks_like_bulleted_list(&orig_table)
801        || looks_like_cjk_prose(&orig_table)
802    {
803        return Vec::new();
804    }
805
806    // Drop "phantom" columns created by a single cell whose
807    // words are spaced apart (e.g. an agenda "Receiving Dock Inspection"
808    // laid out with wide gaps → one greedy column per word). A genuine
809    // table column carries content in MOST rows; a per-word phantom
810    // appears in only one or two. Keep only columns whose spans occupy
811    // at least 60% of rows (min 2). Phantom-column spans are then
812    // re-assigned to the nearest surviving column by assign_spans_to_cells,
813    // re-joining the words into their true cell. Skipped for small
814    // tables (< 3 rows) where every column legitimately spans all rows. ~keep
815    if rows.len() >= 3 {
816        columns = filter_columns_by_row_coverage(&columns, &rows, spans);
817        if columns.len() < config.min_table_columns.max(2) {
818            return Vec::new();
819        }
820    }
821
822    let grid = assign_spans_to_cells(spans, &columns, &rows);
823    if !validate_table_structure_internal(&grid, config) {
824        return Vec::new();
825    }
826
827    let table = grid_to_table(&grid, spans, None);
828    if !is_valid_table(&table)
829        || !passes_spatial_quality_gate(&table)
830        || looks_like_prose_paragraph(&table)
831        || looks_like_bulleted_list(&table)
832        || looks_like_cjk_prose(&table)
833    {
834        return Vec::new();
835    }
836
837    // Borderless counterpart of the GH#1358 fix applied to
838    // `detect_tables_from_intersections`: `detect_columns`/`detect_rows`
839    // bucket purely on physical page X/Y, which recovers the right
840    // clusters whatever a span's own rotation is, but the caller-visible
841    // labelling of which axis is "row" and which is "column" is only
842    // correct for an upright table. Applying the reorientation here, on
843    // the already-finished `table` (after every gate above has already
844    // judged the pre-transpose candidate), reuses
845    // `reorient_table_to_rotated_frame` unmodified and keeps every
846    // upstream heuristic -- `detect_columns`, `filter_columns_by_row_coverage`,
847    // `detect_header_row`, and the validity/quality gates just above --
848    // operating on unmodified page-space geometry exactly as before this
849    // fix; only the final row/column order can change. Gated by
850    // `BUCKET_BORDERLESS_TABLE_GRID_IN_ROTATED_FRAME` so it can be A/B'd
851    // independently of the ruled-table fix. ~keep
852    let table = if BUCKET_BORDERLESS_TABLE_GRID_IN_ROTATED_FRAME {
853        reorient_table_to_rotated_frame(table)
854    } else {
855        table
856    };
857    vec![table]
858}
859
860/// Borderless-pipeline counterpart of `BUCKET_TABLE_GRID_IN_ROTATED_FRAME`:
861/// gates the same `reorient_table_to_rotated_frame` post-process for
862/// `detect_tables_from_spans` (the text-clustering table path with no
863/// ruling lines). Flip to `false` to neutralise the fix for an A/B
864/// comparison -- with this `false`, `detect_tables_from_spans` returns its
865/// table byte-for-byte as it did before this change. ~keep
866const BUCKET_BORDERLESS_TABLE_GRID_IN_ROTATED_FRAME: bool = true;
867
868#[derive(Debug, Clone)]
869struct ColumnCluster {
870    x_center: f32,
871    x_min: f32,
872    x_max: f32,
873    span_indices: Vec<usize>,
874}
875
876#[derive(Debug, Clone)]
877struct RowCluster {
878    y_center: f32,
879    y_min: f32,
880    y_max: f32,
881    span_indices: Vec<usize>,
882}
883
884#[derive(Debug, Clone)]
885struct GridStructure {
886    columns: Vec<ColumnCluster>,
887    rows: Vec<RowCluster>,
888    cells: Vec<Vec<Vec<usize>>>,
889}
890
891impl GridStructure {
892    fn is_row_empty(&self, row_idx: usize) -> bool {
893        self.cells[row_idx].iter().all(|cell| cell.is_empty())
894    }
895
896    fn is_column_empty(&self, col_idx: usize) -> bool {
897        for row in &self.cells {
898            if !row[col_idx].is_empty() {
899                return false;
900            }
901        }
902        true
903    }
904
905    fn trim_empty_columns(&self) -> GridStructure {
906        let num_rows = self.cells.len();
907        let num_cols = self.columns.len();
908
909        let mut first_col = 0;
910        while first_col < num_cols && self.is_column_empty(first_col) {
911            first_col += 1;
912        }
913
914        let mut last_col = num_cols;
915        while last_col > first_col && self.is_column_empty(last_col - 1) {
916            last_col -= 1;
917        }
918
919        if first_col >= last_col {
920            return self.clone();
921        }
922
923        let mut active_cols = Vec::new();
924        for c in first_col..last_col {
925            let col_width = self.columns[c].x_max - self.columns[c].x_min;
926            if col_width < 2.0 && self.is_column_empty(c) {
927                continue;
928            }
929            active_cols.push(c);
930        }
931
932        if active_cols.is_empty() {
933            return self.clone();
934        }
935
936        let new_columns: Vec<ColumnCluster> = active_cols.iter().map(|&c| self.columns[c].clone()).collect();
937
938        let mut new_cells = Vec::with_capacity(num_rows);
939        for r in 0..num_rows {
940            let row_cells = active_cols.iter().map(|&c| self.cells[r][c].clone()).collect();
941            new_cells.push(row_cells);
942        }
943
944        GridStructure {
945            columns: new_columns,
946            rows: self.rows.clone(),
947            cells: new_cells,
948        }
949    }
950}
951
952#[derive(Debug, Clone)]
953struct CellMergeInfo {
954    colspan: u32,
955    rowspan: u32,
956    covered: bool,
957}
958
959/// Keep only columns that carry content in a meaningful
960/// fraction of rows. A real table column appears in most rows; a
961/// "phantom" column produced by spaced words inside a single cell (e.g.
962/// "Receiving Dock Inspection" with wide inter-word gaps) appears in
963/// only one or two rows. Each column's distinct-row coverage is the
964/// number of rows in which at least one of its spans falls.
965///
966/// Threshold: >= ceil(0.6 * num_rows), floored at 2. Phantom columns
967/// (coverage 1) are removed; their spans get re-assigned to the nearest
968/// surviving column downstream, rejoining the words into one cell.
969fn filter_columns_by_row_coverage(
970    columns: &[ColumnCluster],
971    rows: &[RowCluster],
972    spans: &[TextSpan],
973) -> Vec<ColumnCluster> {
974    let num_rows = rows.len();
975    if num_rows < 3 {
976        return columns.to_vec();
977    }
978    let min_cov = (((num_rows as f32) * 0.6).ceil() as usize).max(2);
979
980    let span_row = |sidx: usize| -> Option<usize> {
981        let cy = spans[sidx].bbox.center().y;
982        rows.iter().position(|r| cy <= r.y_max && cy >= r.y_min)
983    };
984
985    let kept: Vec<ColumnCluster> = columns
986        .iter()
987        .filter(|col| {
988            let mut seen: Vec<usize> = col.span_indices.iter().filter_map(|&s| span_row(s)).collect();
989            seen.sort_unstable();
990            seen.dedup();
991            seen.len() >= min_cov
992        })
993        .cloned()
994        .collect();
995
996    // Safety: never return fewer than 2 columns from here — if the
997    // coverage filter would collapse the table, fall back to the
998    // original columns (the caller's min-columns guard then decides). ~keep
999    if kept.len() >= 2 { kept } else { columns.to_vec() }
1000}
1001
1002fn detect_columns(spans: &[TextSpan], column_tolerance: f32, merge_threshold: f32) -> Vec<ColumnCluster> {
1003    // Sort span indices by X coordinate before clustering for deterministic results. ~keep
1004    let mut sorted_indices: Vec<usize> = (0..spans.len()).collect();
1005    sorted_indices.sort_by(|&a, &b| crate::utils::safe_float_cmp(spans[a].bbox.left(), spans[b].bbox.left()));
1006
1007    let mut columns: Vec<ColumnCluster> = Vec::new();
1008    for idx in sorted_indices {
1009        let x = spans[idx].bbox.left();
1010        let mut found = false;
1011        for col in &mut columns {
1012            if (x - col.x_center).abs() < column_tolerance {
1013                col.span_indices.push(idx);
1014                col.x_min = col.x_min.min(x);
1015                col.x_max = col.x_max.max(x);
1016                // Update running average so the cluster center tracks
1017                // the actual midpoint. ~keep
1018                let n = col.span_indices.len() as f32;
1019                col.x_center = col.x_center * ((n - 1.0) / n) + x / n;
1020                found = true;
1021                break;
1022            }
1023        }
1024        if !found {
1025            columns.push(ColumnCluster {
1026                x_center: x,
1027                x_min: x,
1028                x_max: x,
1029                span_indices: vec![idx],
1030            });
1031        }
1032    }
1033
1034    columns.sort_by(|a, b| crate::utils::safe_float_cmp(a.x_center, b.x_center));
1035
1036    // `merge_threshold` is a fixed absolute-point gap; on its own it breaks
1037    // down as column count grows and the table's actual pitch shrinks (a
1038    // dense numeric table with a 12pt column pitch has every adjacent pair
1039    // fused by even a modest fixed threshold, collapsing distinct columns
1040    // into one). Scale the *effective* threshold down for narrow-pitch
1041    // tables by capping it at a fraction of the table's own median
1042    // inter-column gap — same ratio-based pitch reasoning as
1043    // `is_regular_lattice`'s on-pitch band (`[0.6, 1.6] * median`): a gap
1044    // under `0.6 * median` is off-pitch-small relative to this table's own
1045    // columns and should merge regardless of the fixed threshold, while a
1046    // sparse table with few, widely-spaced columns keeps the full
1047    // `merge_threshold` (no median signal to scale from). ~keep
1048    let effective_merge_threshold = if columns.len() >= 3 {
1049        let mut gaps: Vec<f32> = columns
1050            .windows(2)
1051            .map(|w| w[1].x_center - w[0].x_center)
1052            .filter(|g| *g > 0.0)
1053            .collect();
1054        if gaps.is_empty() {
1055            merge_threshold
1056        } else {
1057            gaps.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
1058            let median_gap = gaps[gaps.len() / 2];
1059            merge_threshold.min(median_gap * 0.6)
1060        }
1061    } else {
1062        merge_threshold
1063    };
1064
1065    let mut merged: Vec<ColumnCluster> = Vec::new();
1066    for col in columns {
1067        let should_merge = merged.last().is_some_and(|prev: &ColumnCluster| {
1068            (col.x_center - prev.x_center).abs() < effective_merge_threshold || col.x_min <= prev.x_max
1069        });
1070        if should_merge {
1071            let prev = merged.last_mut().unwrap();
1072            prev.x_min = prev.x_min.min(col.x_min);
1073            prev.x_max = prev.x_max.max(col.x_max);
1074            let total = prev.span_indices.len() as f32 + col.span_indices.len() as f32;
1075            prev.x_center = prev.x_center * (prev.span_indices.len() as f32 / total)
1076                + col.x_center * (col.span_indices.len() as f32 / total);
1077            prev.span_indices.extend(col.span_indices);
1078        } else {
1079            merged.push(col);
1080        }
1081    }
1082
1083    merged.sort_by(|a, b| crate::utils::safe_float_cmp(a.x_center, b.x_center));
1084    merged
1085}
1086
1087/// Text-edge column detection inspired by pdfplumber/Tabula "Stream" mode.
1088///
1089/// Instead of greedily clustering span X-centres, this approach:
1090/// 1. Collects left-edge and right-edge X positions of every span.
1091/// 2. Snaps nearby X values into clusters (within `snap_tolerance`).
1092/// 3. Keeps only X positions that appear in `min_row_count` or more distinct
1093///    text rows — these are consistent alignment edges.
1094/// 4. Returns [`ColumnCluster`]s whose centres sit at those surviving edges.
1095///
1096/// The resulting columns are fewer and more faithful to the visual grid of
1097/// forms that have no vector lines.
1098/// A short numeric cell: optional sign, digits with an optional single decimal
1099/// point, optional trailing `%`. Accepts `0.69`, `100`, `-1.2`, `52%`; rejects
1100/// words and identifiers. Used to recognise a borderless data grid.
1101fn is_numeric_cell(t: &str) -> bool {
1102    if t.is_empty() || t.len() > 8 {
1103        return false;
1104    }
1105    let t = t.strip_suffix('%').unwrap_or(t);
1106    let t = t.strip_prefix(['+', '-', '\u{2212}']).unwrap_or(t);
1107    let mut seen_dot = false;
1108    let mut seen_digit = false;
1109    for c in t.chars() {
1110        match c {
1111            '0'..='9' => seen_digit = true,
1112            '.' if !seen_dot => seen_dot = true,
1113            _ => return false,
1114        }
1115    }
1116    seen_digit
1117}
1118
1119/// True when the column centres sit on a near-constant pitch — the signature of
1120/// a numeric data lattice rather than prose that happened to align. Requires
1121/// ≥5 columns and tolerates up to two off-pitch gaps (e.g. a wider row-label
1122/// column at the left edge).
1123fn is_regular_lattice(cols: &[ColumnCluster]) -> bool {
1124    if cols.len() < 5 {
1125        return false;
1126    }
1127    let mut centers: Vec<f32> = cols.iter().map(|c| c.x_center).collect();
1128    centers.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
1129    let gaps: Vec<f32> = centers.windows(2).map(|w| w[1] - w[0]).collect();
1130    let mut sorted = gaps.clone();
1131    sorted.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
1132    let median = sorted[sorted.len() / 2];
1133    if median <= 0.0 {
1134        return false;
1135    }
1136    let on_pitch = gaps.iter().filter(|&&g| g >= median * 0.6 && g <= median * 1.6).count();
1137    on_pitch + 2 >= gaps.len()
1138}
1139
1140fn detect_text_edge_columns(spans: &[TextSpan], config: &TableDetectionConfig) -> Vec<ColumnCluster> {
1141    if spans.is_empty() {
1142        return Vec::new();
1143    }
1144
1145    let snap_tolerance = config.column_tolerance;
1146    let min_row_count: usize = 3;
1147
1148    let row_tol = config.row_tolerance;
1149
1150    let mut row_ids: Vec<usize> = Vec::with_capacity(spans.len());
1151    let mut row_centres: Vec<f32> = Vec::new();
1152    for span in spans {
1153        let y = span.bbox.center().y;
1154        let mut assigned = None;
1155        for (rid, rc) in row_centres.iter().enumerate() {
1156            if (y - rc).abs() < row_tol {
1157                assigned = Some(rid);
1158                break;
1159            }
1160        }
1161        match assigned {
1162            Some(rid) => row_ids.push(rid),
1163            None => {
1164                row_ids.push(row_centres.len());
1165                row_centres.push(y);
1166            }
1167        }
1168    }
1169
1170    let mut edge_obs: Vec<(f32, usize)> = Vec::with_capacity(spans.len() * 2);
1171    for (i, span) in spans.iter().enumerate() {
1172        edge_obs.push((span.bbox.left(), row_ids[i]));
1173        edge_obs.push((span.bbox.right(), row_ids[i]));
1174    }
1175    // Sort by X for deterministic clustering. ~keep
1176    edge_obs.sort_by(|a, b| crate::utils::safe_float_cmp(a.0, b.0));
1177
1178    struct XCluster {
1179        x_center: f32,
1180        count: usize,
1181        rows: Vec<usize>,
1182    }
1183
1184    let mut x_clusters: Vec<XCluster> = Vec::new();
1185    for &(x, rid) in &edge_obs {
1186        let mut found = false;
1187        for cl in &mut x_clusters {
1188            if (x - cl.x_center).abs() < snap_tolerance {
1189                let n = cl.count as f32;
1190                cl.x_center = cl.x_center * (n / (n + 1.0)) + x / (n + 1.0);
1191                cl.count += 1;
1192                cl.rows.push(rid);
1193                found = true;
1194                break;
1195            }
1196        }
1197        if !found {
1198            x_clusters.push(XCluster {
1199                x_center: x,
1200                count: 1,
1201                rows: vec![rid],
1202            });
1203        }
1204    }
1205
1206    let mut edges: Vec<f32> = Vec::new();
1207    for cl in &mut x_clusters {
1208        cl.rows.sort_unstable();
1209        cl.rows.dedup();
1210        if cl.rows.len() >= min_row_count {
1211            edges.push(cl.x_center);
1212        }
1213    }
1214    edges.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
1215
1216    // Deduplicate edges that ended up very close after averaging. ~keep
1217    let mut deduped: Vec<f32> = Vec::new();
1218    for &e in &edges {
1219        if deduped.last().is_some_and(|prev| (e - prev).abs() < snap_tolerance) {
1220            let prev = deduped.last_mut().unwrap();
1221            *prev = (*prev + e) / 2.0;
1222        } else {
1223            deduped.push(e);
1224        }
1225    }
1226
1227    let mut columns: Vec<ColumnCluster> = deduped
1228        .iter()
1229        .map(|&x| ColumnCluster {
1230            x_center: x,
1231            x_min: x,
1232            x_max: x,
1233            span_indices: Vec::new(),
1234        })
1235        .collect();
1236
1237    if columns.is_empty() {
1238        return columns;
1239    }
1240
1241    for (idx, span) in spans.iter().enumerate() {
1242        let sx = span.bbox.left();
1243        let best = columns
1244            .iter()
1245            .enumerate()
1246            .min_by_key(|(_, c)| ((sx - c.x_center).abs() * 1000.0) as i32)
1247            .map(|(i, _)| i)
1248            .unwrap_or(0);
1249        columns[best].span_indices.push(idx);
1250        columns[best].x_min = columns[best].x_min.min(sx);
1251        columns[best].x_max = columns[best].x_max.max(sx);
1252    }
1253
1254    columns.retain(|c| !c.span_indices.is_empty());
1255
1256    columns.sort_by(|a, b| crate::utils::safe_float_cmp(a.x_center, b.x_center));
1257    columns
1258}
1259
1260fn detect_rows(spans: &[TextSpan], row_tolerance: f32) -> Vec<RowCluster> {
1261    // Sort span indices by Y coordinate before clustering for deterministic results. ~keep
1262    let mut sorted_indices: Vec<usize> = (0..spans.len()).collect();
1263    sorted_indices.sort_by(|&a, &b| crate::utils::safe_float_cmp(spans[a].bbox.center().y, spans[b].bbox.center().y));
1264
1265    let mut rows: Vec<RowCluster> = Vec::new();
1266    for idx in sorted_indices {
1267        let y = spans[idx].bbox.center().y;
1268        let mut found = false;
1269        for row in &mut rows {
1270            if (y - row.y_center).abs() < row_tolerance {
1271                row.span_indices.push(idx);
1272                row.y_min = row.y_min.min(y);
1273                row.y_max = row.y_max.max(y);
1274                // Update running average (same rationale as detect_columns) ~keep
1275                let n = row.span_indices.len() as f32;
1276                row.y_center = row.y_center * ((n - 1.0) / n) + y / n;
1277                found = true;
1278                break;
1279            }
1280        }
1281        if !found {
1282            rows.push(RowCluster {
1283                y_center: y,
1284                y_min: y,
1285                y_max: y,
1286                span_indices: vec![idx],
1287            });
1288        }
1289    }
1290    rows.sort_by(|a, b| crate::utils::safe_float_cmp(b.y_center, a.y_center));
1291    rows
1292}
1293
1294fn assign_spans_to_cells(spans: &[TextSpan], columns: &[ColumnCluster], rows: &[RowCluster]) -> GridStructure {
1295    let num_cols = columns.len();
1296    let num_rows = rows.len();
1297    let mut cells: Vec<Vec<Vec<usize>>> = vec![vec![Vec::new(); num_cols]; num_rows];
1298    for (idx, span) in spans.iter().enumerate() {
1299        let span_x = span.bbox.center().x;
1300        let span_y = span.bbox.center().y;
1301        let col_idx = columns
1302            .iter()
1303            .enumerate()
1304            .min_by_key(|(_, col)| ((span_x - col.x_center).abs() * 1000.0) as i32)
1305            .map(|(i, _)| i)
1306            .unwrap_or(0);
1307        let row_idx = rows
1308            .iter()
1309            .enumerate()
1310            .min_by_key(|(_, row)| ((span_y - row.y_center).abs() * 1000.0) as i32)
1311            .map(|(i, _)| i)
1312            .unwrap_or(0);
1313        cells[row_idx][col_idx].push(idx);
1314    }
1315    GridStructure {
1316        columns: columns.to_vec(),
1317        rows: rows.to_vec(),
1318        cells,
1319    }
1320}
1321
1322/// Maximum number of detected columns the split-column detector can
1323/// analyse. Grids wider than this skip the check; extremely wide
1324/// candidates are rare and have other defences upstream.
1325const MAX_MASK_COLUMNS: usize = 128;
1326
1327/// Minimum share of modal rows a column-component must contain to
1328/// count as "significant" for split-detection purposes. Chosen to
1329/// admit the original split-flow shape (the DB10 reproducer's modal
1330/// rows split evenly across two halves) while avoiding obvious
1331/// overfitting. Heuristic, not corpus-calibrated.
1332const MIN_SPLIT_GROUP_ROW_SHARE: f32 = 0.20;
1333
1334fn validate_table_structure_internal(grid: &GridStructure, config: &TableDetectionConfig) -> bool {
1335    let num_cols = grid.columns.len();
1336    let total_cells: usize = grid
1337        .cells
1338        .iter()
1339        .flat_map(|row| row.iter().take(num_cols))
1340        .map(|cell| if cell.is_empty() { 0 } else { 1 })
1341        .sum();
1342    if total_cells < config.min_table_cells {
1343        return false;
1344    }
1345    let cell_counts: Vec<usize> = grid
1346        .cells
1347        .iter()
1348        .map(|row| row.iter().take(num_cols).filter(|cell| !cell.is_empty()).count())
1349        .collect();
1350    if cell_counts.is_empty() {
1351        return false;
1352    }
1353    let most_common_count = *cell_counts
1354        .iter()
1355        .max_by_key(|&&count| cell_counts.iter().filter(|&&c| c == count).count())
1356        .unwrap_or(&0);
1357    if most_common_count == 0 {
1358        return false;
1359    }
1360    let regular_rows = cell_counts.iter().filter(|&&count| count == most_common_count).count();
1361    if (regular_rows as f32 / cell_counts.len() as f32) < config.regular_row_ratio {
1362        return false;
1363    }
1364
1365    if has_split_modal_column_groups(grid, most_common_count) {
1366        return false;
1367    }
1368
1369    true
1370}
1371
1372/// Returns `true` when the modal rows of `grid` partition into two or
1373/// more disconnected column-co-occurrence components, each backed by a
1374/// significant share of modal rows. This signature catches "two prose
1375/// flows mis-clustered as one table" without rejecting hierarchical
1376/// tables whose modal data rows are sparse but internally connected.
1377///
1378/// The check operates only on rows whose populated-cell count equals
1379/// `most_common_count`. For each such row, the populated columns form
1380/// a co-occurrence clique. The union of those cliques forms a graph
1381/// over columns; its connected components are computed via bitmask
1382/// flood-fill. If two or more components each contain at least two
1383/// columns and are supported by at least `MIN_SPLIT_GROUP_ROW_SHARE`
1384/// of the modal rows, the grid is rejected as split-flow.
1385///
1386/// Heuristic, not corpus-calibrated.
1387fn has_split_modal_column_groups(grid: &GridStructure, most_common_count: usize) -> bool {
1388    let num_cols = grid.columns.len();
1389
1390    // A meaningful split needs at least 4 columns (two groups of >=2)
1391    // and at least 2 populated cells per modal row. ~keep
1392    if !(4..=MAX_MASK_COLUMNS).contains(&num_cols) || most_common_count < 2 {
1393        return false;
1394    }
1395
1396    // Collect column-occupancy masks for the modal rows. Bounded by
1397    // `num_cols` so `most_common_count` (computed over the same bounded
1398    // slice upstream) and `populated` here share one column universe;
1399    // also keeps every `1u128 << idx` shift in range of the u128 mask. ~keep
1400    let modal_masks: Vec<u128> = grid
1401        .cells
1402        .iter()
1403        .filter_map(|row| {
1404            let populated = row.iter().take(num_cols).filter(|cell| !cell.is_empty()).count();
1405
1406            if populated != most_common_count {
1407                return None;
1408            }
1409
1410            let mut mask = 0u128;
1411            for (idx, cell) in row.iter().take(num_cols).enumerate() {
1412                if !cell.is_empty() {
1413                    mask |= 1u128 << idx;
1414                }
1415            }
1416
1417            if mask.count_ones() >= 2 { Some(mask) } else { None }
1418        })
1419        .collect();
1420
1421    if modal_masks.len() < 4 {
1422        return false;
1423    }
1424
1425    // Floor at 2 rows so a single-row outlier with a wide/narrow mask
1426    // can never be classified as its own "significant" component
1427    // — when modal_masks.len() == 4 the share alone would round to 1. ~keep
1428    let min_component_rows = (((modal_masks.len() as f32) * MIN_SPLIT_GROUP_ROW_SHARE).ceil() as usize).max(2);
1429
1430    let mut adjacency: Vec<u128> = vec![0u128; num_cols];
1431    let mut active_columns: u128 = 0;
1432
1433    for &mask in &modal_masks {
1434        active_columns |= mask;
1435        let mut bits = mask;
1436        while bits != 0 {
1437            let bit = bits & bits.wrapping_neg();
1438            let col = bit.trailing_zeros() as usize;
1439            adjacency[col] |= mask;
1440            bits &= !bit;
1441        }
1442    }
1443
1444    let mut remaining = active_columns;
1445    let mut significant_components = 0usize;
1446
1447    while remaining != 0 {
1448        let seed_bit = remaining & remaining.wrapping_neg();
1449        let mut component: u128 = 0;
1450        let mut frontier: u128 = seed_bit;
1451
1452        while frontier != 0 {
1453            let bit = frontier & frontier.wrapping_neg();
1454            frontier &= !bit;
1455
1456            if component & bit != 0 {
1457                continue;
1458            }
1459
1460            component |= bit;
1461            let col = bit.trailing_zeros() as usize;
1462            frontier |= adjacency[col] & !component;
1463        }
1464
1465        remaining &= !component;
1466
1467        let component_cols = component.count_ones() as usize;
1468        let component_row_support = modal_masks.iter().filter(|&&mask| mask & component != 0).count();
1469
1470        if component_cols >= 2 && component_row_support >= min_component_rows {
1471            significant_components += 1;
1472            if significant_components >= 2 {
1473                return true;
1474            }
1475        }
1476    }
1477
1478    false
1479}
1480
1481/// Backward compatibility: Indices of spans belonging to a table.
1482#[derive(Debug, Clone)]
1483pub struct DetectedTable {
1484    /// Indices of spans that belong to this table.
1485    pub span_indices: Vec<usize>,
1486}
1487
1488/// Backward compatibility: Table detector wrapper.
1489pub struct SpatialTableDetector {
1490    /// Configuration for this detector.
1491    pub config: TableDetectionConfig,
1492}
1493
1494impl SpatialTableDetector {
1495    /// Create a new detector with config.
1496    pub fn with_config(config: TableDetectionConfig) -> Self {
1497        Self { config }
1498    }
1499    /// Detect tables (wrapper).
1500    pub fn detect_tables(&self, spans: &[TextSpan]) -> Vec<DetectedTable> {
1501        detect_tables_from_spans_column_aware(spans, &self.config)
1502            .into_iter()
1503            .flat_map(|_| None)
1504            .collect()
1505    }
1506    /// Detect tables using visual lines and text (hybrid).
1507    pub fn detect_tables_hybrid(&self, spans: &[TextSpan], lines: &[crate::elements::PathContent]) -> Vec<Table> {
1508        detect_tables_with_lines(spans, lines, &self.config)
1509    }
1510}
1511
1512fn cluster_values(values: &[f32], tolerance: f32) -> Vec<f32> {
1513    let mut clusters: Vec<f32> = Vec::new();
1514    let mut counts: Vec<u32> = Vec::new();
1515    for &v in values {
1516        if let Some(idx) = clusters.iter().position(|&c| (v - c).abs() < tolerance) {
1517            counts[idx] += 1;
1518            clusters[idx] += (v - clusters[idx]) / counts[idx] as f32;
1519        } else {
1520            clusters.push(v);
1521            counts.push(1);
1522        }
1523    }
1524    clusters
1525}
1526
1527struct LineCluster {
1528    lines: Vec<usize>,
1529    bbox: crate::geometry::Rect,
1530}
1531
1532impl LineCluster {
1533    fn new(line_idx: usize, bbox: crate::geometry::Rect) -> Self {
1534        Self {
1535            lines: vec![line_idx],
1536            bbox,
1537        }
1538    }
1539    fn add(&mut self, line_idx: usize, bbox: crate::geometry::Rect) {
1540        self.lines.push(line_idx);
1541        self.bbox = self.bbox.union(&bbox);
1542    }
1543}
1544
1545fn group_lines_into_clusters(
1546    lines: &[crate::elements::PathContent],
1547    config: &TableDetectionConfig,
1548) -> Vec<LineCluster> {
1549    if lines.is_empty() {
1550        return Vec::new();
1551    }
1552    // All clustering geometry below works on RENDERED extents: a table rule
1553    // encoded as a 1 pt segment with a table-height stroke width must
1554    // cluster with (and span) the rules its drawn bar actually touches, not
1555    // the ones near its geometric speck. Computed once — pure
1556    // arithmetic per path. ~keep
1557    let rendered: Vec<crate::geometry::Rect> = lines.iter().map(|p| p.rendered_bbox()).collect();
1558    let mut uf = UnionFind::new(lines.len());
1559    let mut valid_indices: Vec<usize> = lines
1560        .iter()
1561        .enumerate()
1562        .filter(|(_, path)| path.is_table_primitive())
1563        .map(|(i, _)| i)
1564        .collect();
1565
1566    // Optimization: Sort by X-coordinate to enable sweep-line early exit (O(n log n)) ~keep
1567    valid_indices.sort_by(|&a, &b| crate::utils::safe_float_cmp(rendered[a].x, rendered[b].x));
1568
1569    const EXPANSION: f32 = 3.0;
1570    for i in 0..valid_indices.len() {
1571        let idx_a = valid_indices[i];
1572        let bbox_a = &rendered[idx_a];
1573        let expanded_a = crate::geometry::Rect::new(
1574            bbox_a.x - EXPANSION,
1575            bbox_a.y - EXPANSION,
1576            bbox_a.width + EXPANSION * 2.0,
1577            bbox_a.height + EXPANSION * 2.0,
1578        );
1579
1580        for j in (i + 1)..valid_indices.len() {
1581            let idx_b = valid_indices[j];
1582            let bbox_b = &rendered[idx_b];
1583
1584            // Optimization: If the next path's X-start is beyond our search threshold,
1585            // no subsequent paths in the sorted list can possibly intersect. ~keep
1586            if bbox_b.x > expanded_a.x + expanded_a.width {
1587                break;
1588            }
1589
1590            let expanded_b = crate::geometry::Rect::new(
1591                bbox_b.x - EXPANSION,
1592                bbox_b.y - EXPANSION,
1593                bbox_b.width + EXPANSION * 2.0,
1594                bbox_b.height + EXPANSION * 2.0,
1595            );
1596
1597            if expanded_a.intersects(&expanded_b) {
1598                uf.union(idx_a, idx_b);
1599            }
1600        }
1601    }
1602    let mut cluster_map: HashMap<usize, LineCluster> = HashMap::new();
1603    for i in valid_indices {
1604        let root = uf.find(i);
1605        let bbox = rendered[i];
1606        cluster_map
1607            .entry(root)
1608            .and_modify(|c| c.add(i, bbox))
1609            .or_insert_with(|| LineCluster::new(i, bbox));
1610    }
1611
1612    // Post-processing: split clusters whose vertical lines occupy distinct Y-ranges.
1613    // This prevents a small bordered table (e.g. an invoice header) from merging
1614    // with a large main table that happens to be nearby vertically.
1615    // Deterministic order: `cluster_map` is a HashMap (per-process-randomized
1616    // iteration), so sort clusters by their first (smallest) line index — each
1617    // cluster's `lines` Vec is already ascending — to keep downstream table
1618    // boundary order stable across runs. ~keep
1619    let mut raw_clusters: Vec<LineCluster> = cluster_map.into_values().collect();
1620    raw_clusters.sort_by_key(|c| c.lines.first().copied().unwrap_or(usize::MAX));
1621    let mut result: Vec<LineCluster> = Vec::with_capacity(raw_clusters.len());
1622    const LINE_AXIS_TOL: f32 = 2.0;
1623    let v_split_gap = config.v_split_gap;
1624
1625    for cluster in raw_clusters {
1626        let mut v_ranges: Vec<(usize, f32, f32)> = Vec::new();
1627        for &idx in &cluster.lines {
1628            let path = &lines[idx];
1629            if path.is_vertical_line(LINE_AXIS_TOL) && rendered[idx].height.abs() > 5.0 {
1630                let y_min = rendered[idx].y;
1631                let y_max = rendered[idx].y + rendered[idx].height;
1632                let (y_min, y_max) = if y_min <= y_max { (y_min, y_max) } else { (y_max, y_min) };
1633                v_ranges.push((idx, y_min, y_max));
1634            }
1635        }
1636
1637        if v_ranges.len() < 2 {
1638            result.push(cluster);
1639            continue;
1640        }
1641
1642        v_ranges.sort_by(|a, b| crate::utils::safe_float_cmp(a.1, b.1));
1643        let mut bands: Vec<(f32, f32)> = Vec::new();
1644        let mut band_start = v_ranges[0].1;
1645        let mut band_end = v_ranges[0].2;
1646        for &(_, y_min, y_max) in &v_ranges[1..] {
1647            if y_min > band_end + v_split_gap {
1648                bands.push((band_start, band_end));
1649                band_start = y_min;
1650                band_end = y_max;
1651            } else {
1652                band_end = band_end.max(y_max);
1653            }
1654        }
1655        bands.push((band_start, band_end));
1656
1657        if bands.len() < 2 {
1658            result.push(cluster);
1659            continue;
1660        }
1661
1662        let mut sub_clusters: Vec<Vec<usize>> = vec![Vec::new(); bands.len()];
1663        for &idx in &cluster.lines {
1664            let bbox = &rendered[idx];
1665            let line_y_mid = bbox.y + bbox.height * 0.5;
1666            let mut best_band = 0;
1667            let mut best_dist = f32::MAX;
1668            for (bi, &(b_min, b_max)) in bands.iter().enumerate() {
1669                let dist = if line_y_mid >= b_min && line_y_mid <= b_max {
1670                    0.0
1671                } else {
1672                    (line_y_mid - b_min).abs().min((line_y_mid - b_max).abs())
1673                };
1674                if dist < best_dist {
1675                    best_dist = dist;
1676                    best_band = bi;
1677                }
1678            }
1679            sub_clusters[best_band].push(idx);
1680        }
1681
1682        for sub in sub_clusters {
1683            if sub.is_empty() {
1684                continue;
1685            }
1686            let first_bbox = rendered[sub[0]];
1687            let mut lc = LineCluster::new(sub[0], first_bbox);
1688            for &idx in &sub[1..] {
1689                lc.add(idx, rendered[idx]);
1690            }
1691            result.push(lc);
1692        }
1693    }
1694
1695    result
1696}
1697
1698/// (C) WS0.3b — detect a header text row sitting just ABOVE the grid's top
1699/// ruling (a header boxed only by the page-top, or unruled above the grid).
1700///
1701/// `row_ys` is sorted descending (`row_ys[0]` = the grid's top edge) and
1702/// `col_xs` sorted ascending (the detected column boundaries). If a tight band
1703/// immediately above the top ruling — up to ~1.5x the median grid row height —
1704/// holds text whose cells align to the EXISTING columns and that both spans
1705/// at least 2 distinct columns and reaches across at least half the extent, this
1706/// returns the y of a new top boundary that brackets exactly that header row.
1707/// Otherwise `None` (table unchanged).
1708///
1709/// The distinct-columns + horizontal-span gates keep a centred title or a
1710/// left-aligned caption above an already-correct table from being mistaken for
1711/// a header row (their words cluster together instead of spanning the columns),
1712/// so this never alters a table that already detects correctly.
1713fn detect_header_row_above(spans: &[TextSpan], row_ys: &[f32], col_xs: &[f32]) -> Option<f32> {
1714    /// Header band reaches at most this multiple of the median row height above
1715    /// the grid's top ruling.
1716    const WINDOW_ROWS: f32 = 1.5;
1717    /// Header cells must reach across at least this fraction of the column
1718    /// extent (rejects titles/captions whose words cluster together).
1719    const MIN_SPAN_FRAC: f32 = 0.5;
1720
1721    if row_ys.len() < 2 || col_xs.len() < 2 {
1722        return None;
1723    }
1724    let grid_top = row_ys[0];
1725    let col_lo = *col_xs.first().unwrap();
1726    let col_hi = *col_xs.last().unwrap();
1727    let col_extent = col_hi - col_lo;
1728    if col_extent <= 0.0 {
1729        return None;
1730    }
1731
1732    let mut gaps: Vec<f32> = row_ys.windows(2).map(|w| (w[0] - w[1]).abs()).collect();
1733    gaps.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
1734    let median_row_h = gaps[gaps.len() / 2];
1735    if median_row_h <= 0.0 {
1736        return None;
1737    }
1738    let window_top = grid_top + WINDOW_ROWS * median_row_h;
1739
1740    let mut cols_hit: Vec<usize> = Vec::new();
1741    let mut header_max_top = f32::NEG_INFINITY;
1742    let mut cx_min = f32::INFINITY;
1743    let mut cx_max = f32::NEG_INFINITY;
1744    let mut cy_min = f32::INFINITY;
1745    let mut cy_max = f32::NEG_INFINITY;
1746    for span in spans {
1747        let cy = span.bbox.center().y;
1748        if cy <= grid_top || cy > window_top {
1749            continue;
1750        }
1751        let cx = span.bbox.center().x;
1752        if cx < col_lo || cx > col_hi {
1753            continue;
1754        }
1755        let Some(ci) = (0..col_xs.len() - 1).find(|&c| cx >= col_xs[c] && cx <= col_xs[c + 1]) else {
1756            continue;
1757        };
1758        if !cols_hit.contains(&ci) {
1759            cols_hit.push(ci);
1760        }
1761        header_max_top = header_max_top.max(span.bbox.y + span.bbox.height);
1762        cx_min = cx_min.min(cx);
1763        cx_max = cx_max.max(cx);
1764        cy_min = cy_min.min(cy);
1765        cy_max = cy_max.max(cy);
1766    }
1767
1768    if cols_hit.len() < 2 {
1769        return None;
1770    }
1771    if (cx_max - cx_min) < MIN_SPAN_FRAC * col_extent {
1772        return None;
1773    }
1774    if (cy_max - cy_min) > median_row_h {
1775        return None;
1776    }
1777
1778    Some(header_max_top + 1.0)
1779}
1780
1781fn detect_tables_in_cluster(
1782    spans: &[TextSpan],
1783    all_lines: &[crate::elements::PathContent],
1784    cluster: &LineCluster,
1785    config: &TableDetectionConfig,
1786) -> Vec<Table> {
1787    const MIN_LINE_LENGTH: f32 = 5.0;
1788    const LINE_AXIS_TOL: f32 = 2.0;
1789    let mut h_ys: Vec<f32> = Vec::new();
1790    let mut v_xs: Vec<f32> = Vec::new();
1791    for &idx in &cluster.lines {
1792        let path = &all_lines[idx];
1793        // Rendered extents: a stroke-width-encoded rule's center and length
1794        // come from the drawn bar, not the geometric speck. ~keep
1795        let bbox = path.rendered_bbox();
1796        if path.is_horizontal_line(LINE_AXIS_TOL) && bbox.width > MIN_LINE_LENGTH {
1797            h_ys.push(bbox.center().y);
1798        }
1799        if path.is_vertical_line(LINE_AXIS_TOL) && bbox.height.abs() > MIN_LINE_LENGTH {
1800            v_xs.push(bbox.center().x);
1801        }
1802    }
1803    let mut row_ys = cluster_values(&h_ys, config.row_tolerance);
1804    let mut col_xs = cluster_values(&v_xs, config.column_tolerance);
1805    if row_ys.len() < 2 || col_xs.len() < 2 {
1806        return Vec::new();
1807    }
1808    row_ys.sort_by(|a, b| crate::utils::safe_float_cmp(*b, *a));
1809    col_xs.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
1810    // (C) WS0.3b — include a header text row sitting just ABOVE the grid's top
1811    // ruling (boxed by page-top / unruled above the grid) when its cells align
1812    // to the already-detected columns. Adds ONE row boundary above the top and
1813    // widens the span-assignment region to reach it; never adds columns. Returns
1814    // `None` (unchanged) unless the tight gate in `detect_header_row_above` holds. ~keep
1815    let mut assign_bbox = cluster.bbox;
1816    let mut inserted_header_row = false;
1817    if let Some(header_top) = detect_header_row_above(spans, &row_ys, &col_xs) {
1818        row_ys.insert(0, header_top);
1819        inserted_header_row = true;
1820        let new_height = (header_top - assign_bbox.y).max(assign_bbox.height);
1821        assign_bbox = crate::geometry::Rect::new(assign_bbox.x, assign_bbox.y, assign_bbox.width, new_height);
1822    }
1823    let num_rows = row_ys.len() - 1;
1824    let num_cols = col_xs.len() - 1;
1825    if num_cols < config.min_table_columns || num_cols > config.max_table_columns {
1826        return Vec::new();
1827    }
1828    let mut cells: Vec<Vec<Vec<usize>>> = vec![vec![Vec::new(); num_cols]; num_rows];
1829    let mut assigned_any = false;
1830    for (orig_idx, span) in spans.iter().enumerate() {
1831        if !assign_bbox.intersects(&span.bbox) {
1832            continue;
1833        }
1834        let cx = span.bbox.center().x;
1835        let cy = span.bbox.center().y;
1836        let row_idx = (0..num_rows).find(|&r| cy <= row_ys[r] && cy >= row_ys[r + 1]);
1837        let col_idx = (0..num_cols).find(|&c| cx >= col_xs[c] && cx <= col_xs[c + 1]);
1838        if let (Some(r), Some(c)) = (row_idx, col_idx) {
1839            cells[r][c].push(orig_idx);
1840            assigned_any = true;
1841        }
1842    }
1843    if !assigned_any {
1844        return Vec::new();
1845    }
1846    let columns: Vec<ColumnCluster> = (0..num_cols)
1847        .map(|c| ColumnCluster {
1848            x_center: (col_xs[c] + col_xs[c + 1]) / 2.0,
1849            x_min: col_xs[c],
1850            x_max: col_xs[c + 1],
1851            span_indices: Vec::new(),
1852        })
1853        .collect();
1854    let all_rows: Vec<RowCluster> = (0..num_rows)
1855        .map(|r| RowCluster {
1856            y_center: (row_ys[r] + row_ys[r + 1]) / 2.0,
1857            y_min: row_ys[r + 1],
1858            y_max: row_ys[r],
1859            span_indices: Vec::new(),
1860        })
1861        .collect();
1862    let grid_full = GridStructure {
1863        columns: columns.clone(),
1864        rows: all_rows.clone(),
1865        cells: cells.clone(),
1866    };
1867    let mut tables = Vec::new();
1868    let mut current_start_row = 0;
1869    while current_start_row < num_rows {
1870        if grid_full.is_row_empty(current_start_row) {
1871            current_start_row += 1;
1872            continue;
1873        }
1874        let mut current_end_row = current_start_row;
1875        while current_end_row < num_rows {
1876            if grid_full.is_row_empty(current_end_row) {
1877                break;
1878            }
1879            current_end_row += 1;
1880        }
1881        if current_end_row > current_start_row {
1882            let sub_cells = cells[current_start_row..current_end_row].to_vec();
1883            let sub_rows = all_rows[current_start_row..current_end_row].to_vec();
1884            let mut grid = GridStructure {
1885                columns: columns.clone(),
1886                rows: sub_rows,
1887                cells: sub_cells,
1888            };
1889            grid = grid.trim_empty_columns();
1890            if validate_table_structure_internal(&grid, config) {
1891                // (C) WS0.3b — the inserted header row (global row 0) lives in the
1892                // first non-empty run, so it is local row 0 of this sub-table when
1893                // `current_start_row == 0`. Protect it from colspan merging. ~keep
1894                let protected_header_rows = usize::from(inserted_header_row && current_start_row == 0);
1895                let mut table = grid_to_table(
1896                    &grid,
1897                    spans,
1898                    Some(detect_merged_cells_visually(
1899                        &grid,
1900                        spans,
1901                        cluster,
1902                        all_lines,
1903                        protected_header_rows,
1904                    )),
1905                );
1906                let mut min_y = f32::INFINITY;
1907                let mut max_y = f32::NEG_INFINITY;
1908                for r in &grid.rows {
1909                    min_y = min_y.min(r.y_min);
1910                    max_y = max_y.max(r.y_max);
1911                }
1912                table.bbox = Some(crate::geometry::Rect::new(
1913                    cluster.bbox.x,
1914                    min_y,
1915                    cluster.bbox.width,
1916                    max_y - min_y,
1917                ));
1918                let mut header_rows_detected = 0;
1919                let table_width = cluster.bbox.width;
1920                for r in 0..table.rows.len().min(3) {
1921                    let row_bottom = grid.rows[r].y_min;
1922                    let has_separator = cluster.lines.iter().any(|&idx| {
1923                        let path = &all_lines[idx];
1924                        let rendered = path.rendered_bbox();
1925                        path.is_horizontal_line(LINE_AXIS_TOL)
1926                            && rendered.width > table_width * 0.8
1927                            && (rendered.center().y - row_bottom).abs() < config.row_tolerance
1928                    });
1929                    if has_separator {
1930                        header_rows_detected = r + 1;
1931                    } else if r == 0 && table.rows[r].has_colspan() {
1932                        header_rows_detected = 1;
1933                    } else {
1934                        break;
1935                    }
1936                }
1937                if header_rows_detected > 0 {
1938                    table.has_header = true;
1939                    for r in 0..header_rows_detected {
1940                        if r < table.rows.len() {
1941                            table.rows[r].is_header = true;
1942                            for cell in &mut table.rows[r].cells {
1943                                cell.is_header = true;
1944                            }
1945                        }
1946                    }
1947                }
1948                tables.push(table);
1949            }
1950        }
1951        current_start_row = current_end_row + 1;
1952    }
1953    tables
1954}
1955
1956fn detect_merged_cells_visually(
1957    grid: &GridStructure,
1958    spans: &[TextSpan],
1959    cluster: &LineCluster,
1960    all_lines: &[crate::elements::PathContent],
1961    protected_header_rows: usize,
1962) -> Vec<Vec<CellMergeInfo>> {
1963    let num_rows = grid.cells.len();
1964    let num_cols = grid.columns.len();
1965    const LINE_TOLERANCE: f32 = 2.0;
1966    let mut merge_info: Vec<Vec<CellMergeInfo>> = (0..num_rows)
1967        .map(|_| {
1968            (0..num_cols)
1969                .map(|_| CellMergeInfo {
1970                    colspan: 1,
1971                    rowspan: 1,
1972                    covered: false,
1973                })
1974                .collect()
1975        })
1976        .collect();
1977    for r in 0..num_rows {
1978        // (C) WS0.3b — a header row reconstructed from the unruled strip ABOVE
1979        // the grid has no vertical rulings in its band, which would otherwise
1980        // colspan-merge its distinct column cells into one (dropping every cell
1981        // but the first). Its cells were already verified to align to separate
1982        // columns, so skip colspan merging for these leading rows. ~keep
1983        if r < protected_header_rows {
1984            continue;
1985        }
1986        let mut c = 0;
1987        while c < num_cols {
1988            if merge_info[r][c].covered {
1989                c += 1;
1990                continue;
1991            }
1992            let mut colspan = 1;
1993            let mut cell_text_width: f32 = 0.0;
1994            for &idx in &grid.cells[r][c] {
1995                cell_text_width = cell_text_width.max(spans[idx].bbox.width);
1996            }
1997            let mut total_cell_width = grid.columns[c].x_max - grid.columns[c].x_min;
1998            for next_c in (c + 1)..num_cols {
1999                let separator_x = grid.columns[next_c].x_min;
2000                let y_min = grid.rows[r].y_min;
2001                let y_max = grid.rows[r].y_max;
2002                let has_separator = cluster.lines.iter().any(|&idx| {
2003                    let path = &all_lines[idx];
2004                    // Rendered extents: a stroke-width-encoded column rule
2005                    // crosses every row its drawn bar spans, not just the
2006                    // band around its geometric midline. ~keep
2007                    let rendered = path.rendered_bbox();
2008                    path.is_vertical_line(LINE_TOLERANCE)
2009                        && (rendered.center().x - separator_x).abs() < LINE_TOLERANCE
2010                        && rendered.y < y_max
2011                        && (rendered.y + rendered.height) > y_min
2012                });
2013                if !has_separator || (cell_text_width > total_cell_width + 2.0) {
2014                    colspan += 1;
2015                    total_cell_width += grid.columns[next_c].x_max - grid.columns[next_c].x_min;
2016                } else {
2017                    break;
2018                }
2019            }
2020            if colspan > 1 {
2021                merge_info[r][c].colspan = colspan;
2022                for i in 1..colspan {
2023                    merge_info[r][c + i as usize].covered = true;
2024                }
2025            }
2026            c += colspan as usize;
2027        }
2028    }
2029    for c in 0..num_cols {
2030        let mut r = 0;
2031        while r < num_rows {
2032            if merge_info[r][c].covered {
2033                r += 1;
2034                continue;
2035            }
2036            let mut rowspan = 1;
2037            let current_colspan = merge_info[r][c].colspan;
2038            for next_r in (r + 1)..num_rows {
2039                let separator_y = grid.rows[next_r].y_max;
2040                let x_min = grid.columns[c].x_min;
2041                let x_max = grid.columns[c + current_colspan as usize - 1].x_max;
2042                let has_separator = cluster.lines.iter().any(|&idx| {
2043                    let path = &all_lines[idx];
2044                    // Rendered extents, mirroring the colspan check above: a
2045                    // row rule encoded as a short vertical segment with a
2046                    // table-width stroke spans every column its drawn bar
2047                    // crosses. ~keep
2048                    let rendered = path.rendered_bbox();
2049                    path.is_horizontal_line(LINE_TOLERANCE)
2050                        && (rendered.center().y - separator_y).abs() < LINE_TOLERANCE
2051                        && rendered.x < x_max
2052                        && (rendered.x + rendered.width) > x_min
2053                });
2054                if !has_separator {
2055                    rowspan += 1;
2056                } else {
2057                    break;
2058                }
2059            }
2060            if rowspan > 1 {
2061                merge_info[r][c].rowspan = rowspan;
2062                for i in 1..rowspan {
2063                    merge_info[r + i as usize][c].covered = true;
2064                    for j in 1..current_colspan {
2065                        merge_info[r + i as usize][c + j as usize].covered = true;
2066                    }
2067                }
2068            }
2069            r += rowspan as usize;
2070        }
2071    }
2072    merge_info
2073}
2074
2075/// Snap tolerance: parallel lines within this distance share a coordinate.
2076const SNAP_TOL: f32 = 3.0;
2077/// Join tolerance: collinear segments within this gap are merged.
2078const JOIN_TOL: f32 = 3.0;
2079/// Minimum edge length after merging; shorter edges are discarded.
2080const MIN_EDGE_LEN: f32 = 5.0;
2081/// Minimum number of short segments at the same coordinate to consider them a
2082/// dotted/dashed line candidate.
2083const DOTTED_MIN_SEGMENTS: usize = 3;
2084/// Minimum total span (in pt) of collinear short segments to reconstitute them
2085/// as a single continuous edge.
2086const DOTTED_MIN_SPAN: f32 = 50.0;
2087/// Snap precision for grouping dotted-line segments by coordinate (0.1 pt).
2088const DOTTED_COORD_SNAP: f32 = 10.0; // multiplier: coord * DOTTED_COORD_SNAP → i32 key ~keep
2089
2090/// A horizontal or vertical edge (segment).
2091#[derive(Debug, Clone, Copy)]
2092struct Edge {
2093    /// For H edges: the shared y coordinate. For V edges: the shared x coordinate.
2094    coord: f32,
2095    /// Start of the range (min x for H, min y for V).
2096    start: f32,
2097    /// End of the range (max x for H, max y for V).
2098    end: f32,
2099}
2100
2101/// An intersection point on the grid.
2102#[derive(Debug, Clone, Copy, PartialEq)]
2103struct Intersection {
2104    x: f32,
2105    y: f32,
2106}
2107
2108/// A rectangular cell defined by four corner intersections.
2109#[derive(Debug, Clone, Copy)]
2110struct IntersectionCell {
2111    x1: f32,
2112    y1: f32,
2113    x2: f32,
2114    y2: f32,
2115}
2116
2117/// Extract horizontal and vertical edges from path content, decomposing rectangles.
2118fn extract_edges(lines: &[crate::elements::PathContent]) -> (Vec<Edge>, Vec<Edge>) {
2119    const LINE_AXIS_TOL: f32 = 2.0;
2120    let mut h_edges: Vec<Edge> = Vec::new();
2121    let mut v_edges: Vec<Edge> = Vec::new();
2122
2123    let mut rectangles = Vec::new();
2124    for path in lines {
2125        if path.is_horizontal_line(LINE_AXIS_TOL) {
2126            // Rendered extents so a stroke-width-encoded rule contributes
2127            // the edge its drawn bar covers, not its geometric speck
2128            // Identical to `bbox` for ordinary thin rules. ~keep
2129            let rendered = path.rendered_bbox();
2130            h_edges.push(Edge {
2131                coord: rendered.center().y,
2132                start: rendered.left(),
2133                end: rendered.right(),
2134            });
2135        } else if path.is_vertical_line(LINE_AXIS_TOL) {
2136            let rendered = path.rendered_bbox();
2137            v_edges.push(Edge {
2138                coord: rendered.center().x,
2139                start: rendered.top(),
2140                end: rendered.bottom(),
2141            });
2142        } else if path.is_rectangle() {
2143            rectangles.push(path);
2144        }
2145    }
2146
2147    // Thin filled bars are rules too. Merge only these explicit rules before
2148    // considering broad rectangles, so a background cannot justify itself. ~keep
2149    let mut ruled_h = h_edges.clone();
2150    let mut ruled_v = v_edges.clone();
2151    snap_and_merge(&mut ruled_h);
2152    snap_and_merge(&mut ruled_v);
2153    for path in &rectangles {
2154        let bbox = &path.bbox;
2155        let (l, r, t, b) = (bbox.left(), bbox.right(), bbox.top(), bbox.bottom());
2156        // Producers can paint both a cell background and an inset text background.
2157        // Their sides are padding, not columns, when drawn rules already close
2158        // this row band around them. Keep unruled fills (e.g. zebra tables). ~keep
2159        let inset_background = path.has_fill()
2160            && !path.has_stroke()
2161            && rectangles.iter().any(|outer| {
2162                outer.has_fill()
2163                    && !outer.has_stroke()
2164                    && outer.fill_color == path.fill_color
2165                    && outer.bbox.left() < l - SNAP_TOL
2166                    && outer.bbox.right() > r + SNAP_TOL
2167                    && (outer.bbox.top() - t).abs() <= SNAP_TOL
2168                    && (outer.bbox.bottom() - b).abs() <= SNAP_TOL
2169            });
2170        let inside_ruled_band = inset_background
2171            && ruled_h.iter().any(|top| {
2172                (top.coord - t).abs() <= SNAP_TOL
2173                    && top.start <= l + SNAP_TOL
2174                    && top.end >= r - SNAP_TOL
2175                    && ruled_h.iter().any(|bottom| {
2176                        (bottom.coord - b).abs() <= SNAP_TOL
2177                            && bottom.start <= l + SNAP_TOL
2178                            && bottom.end >= r - SNAP_TOL
2179                            && [true, false].into_iter().all(|left| {
2180                                ruled_v.iter().any(|side| {
2181                                    side.start <= t + SNAP_TOL
2182                                        && side.end >= b - SNAP_TOL
2183                                        && if left {
2184                                            side.coord <= l + SNAP_TOL
2185                                                && side.coord >= top.start.max(bottom.start) - SNAP_TOL
2186                                        } else {
2187                                            side.coord >= r - SNAP_TOL
2188                                                && side.coord <= top.end.min(bottom.end) + SNAP_TOL
2189                                        }
2190                                })
2191                            })
2192                    })
2193            });
2194        if inside_ruled_band {
2195            continue;
2196        }
2197        h_edges.push(Edge {
2198            coord: t,
2199            start: l,
2200            end: r,
2201        });
2202        h_edges.push(Edge {
2203            coord: b,
2204            start: l,
2205            end: r,
2206        });
2207        v_edges.push(Edge {
2208            coord: l,
2209            start: t,
2210            end: b,
2211        });
2212        v_edges.push(Edge {
2213            coord: r,
2214            start: t,
2215            end: b,
2216        });
2217    }
2218    (h_edges, v_edges)
2219}
2220
2221/// Snap parallel edges within `SNAP_TOL` to the same coordinate, join collinear
2222/// segments within `JOIN_TOL`, and discard edges shorter than `MIN_EDGE_LEN`.
2223fn snap_and_merge(edges: &mut Vec<Edge>) {
2224    snap_edges(edges);
2225    join_collinear_edges(edges);
2226    reconstitute_dotted_lines(edges);
2227}
2228
2229/// Phase 1: Sort edges by coord and snap nearby coordinates (within `SNAP_TOL`)
2230/// to the first coordinate in each group.
2231fn snap_edges(edges: &mut [Edge]) {
2232    if edges.is_empty() {
2233        return;
2234    }
2235    edges.sort_by(|a, b| crate::utils::safe_float_cmp(a.coord, b.coord));
2236
2237    let mut i = 0;
2238    while i < edges.len() {
2239        let base_coord = edges[i].coord;
2240        let mut j = i + 1;
2241        while j < edges.len() && (edges[j].coord - base_coord).abs() <= SNAP_TOL {
2242            edges[j].coord = base_coord;
2243            j += 1;
2244        }
2245        i = j;
2246    }
2247}
2248
2249/// Phase 2: Sort by (coord, start) and merge overlapping or adjacent collinear
2250/// segments into single edges.
2251fn join_collinear_edges(edges: &mut Vec<Edge>) {
2252    if edges.is_empty() {
2253        return;
2254    }
2255    // Sort by coord then start so a single sweep handles chains of touching
2256    // segments regardless of the order they were originally collected. ~keep
2257    edges.sort_by(|a, b| {
2258        crate::utils::safe_float_cmp(a.coord, b.coord).then_with(|| crate::utils::safe_float_cmp(a.start, b.start))
2259    });
2260
2261    let mut merged: Vec<Edge> = Vec::new();
2262    for &edge in edges.iter() {
2263        // Use SNAP_TOL for the coord comparison (not f32::EPSILON) so that
2264        // edges whose coords were snapped from slightly different originals
2265        // still join correctly. ~keep
2266        let should_merge = merged.last().is_some_and(|prev: &Edge| {
2267            (prev.coord - edge.coord).abs() <= SNAP_TOL && edge.start <= prev.end + JOIN_TOL
2268        });
2269        if should_merge {
2270            let prev = merged.last_mut().unwrap();
2271            prev.end = prev.end.max(edge.end);
2272        } else {
2273            merged.push(edge);
2274        }
2275    }
2276
2277    *edges = merged;
2278}
2279
2280/// Phase 3: Group short segments (below `MIN_EDGE_LEN`) by coordinate.  When a
2281/// group has >= `DOTTED_MIN_SEGMENTS` members spanning >= `DOTTED_MIN_SPAN`
2282/// points, replace them with a single long edge. Short segments that do not
2283/// qualify are discarded.
2284fn reconstitute_dotted_lines(edges: &mut Vec<Edge>) {
2285    let mut dotted_groups: HashMap<i32, Vec<Edge>> = HashMap::new();
2286    let mut long_edges: Vec<Edge> = Vec::new();
2287
2288    for &edge in edges.iter() {
2289        if (edge.end - edge.start) >= MIN_EDGE_LEN {
2290            long_edges.push(edge);
2291        } else {
2292            let key = (edge.coord * DOTTED_COORD_SNAP).round() as i32;
2293            dotted_groups.entry(key).or_default().push(edge);
2294        }
2295    }
2296
2297    // Iterate in sorted key order: `dotted_groups` is a HashMap (per-process-
2298    // randomized), and the reconstituted edges are appended to `long_edges`
2299    // (which becomes `*edges`), so HashMap order would leak into edge order and,
2300    // downstream, table-cell/region order. Sorting the snapped-coordinate keys
2301    // makes it deterministic. ~keep
2302    let mut dotted_keys: Vec<i32> = dotted_groups.keys().copied().collect();
2303    dotted_keys.sort_unstable();
2304    for key in dotted_keys {
2305        let segments = &dotted_groups[&key];
2306        if segments.len() >= DOTTED_MIN_SEGMENTS {
2307            let min_start = segments
2308                .iter()
2309                .map(|e| e.start)
2310                .min_by(|a, b| crate::utils::safe_float_cmp(*a, *b))
2311                .unwrap();
2312            let max_end = segments
2313                .iter()
2314                .map(|e| e.end)
2315                .max_by(|a, b| crate::utils::safe_float_cmp(*a, *b))
2316                .unwrap();
2317            let total_span = max_end - min_start;
2318            if total_span >= DOTTED_MIN_SPAN {
2319                // Use the coordinate of the first segment (they are all snapped
2320                // to the same value within SNAP_TOL anyway). ~keep
2321                long_edges.push(Edge {
2322                    coord: segments[0].coord,
2323                    start: min_start,
2324                    end: max_end,
2325                });
2326            }
2327        }
2328    }
2329
2330    // No additional short-edge discard needed: long_edges already excludes
2331    // short segments that were not reconstituted. ~keep
2332    *edges = long_edges;
2333}
2334
2335/// Remove orphan edges that have no plausible counterpart in the other axis.
2336///
2337/// For each H-edge, keep it only if at least one V-edge has an X coordinate
2338/// within the H-edge's X-range (with generous tolerance).
2339/// For each V-edge, keep it only if at least one H-edge has an X-range that
2340/// overlaps with the V-edge's X coordinate (with generous tolerance).
2341///
2342/// This is purely an X-range overlap check. The Y-axis relationship is
2343/// intentionally ignored because the extended grid projects V-line X positions
2344/// across all H-line Y positions regardless of whether they share Y ranges
2345/// (e.g., Census tables where H-lines and V-lines occupy different Y regions).
2346fn filter_edges_by_coverage(h_edges: &mut Vec<Edge>, v_edges: &mut Vec<Edge>) {
2347    let all_x_min = h_edges
2348        .iter()
2349        .map(|e| e.start)
2350        .chain(v_edges.iter().map(|e| e.coord))
2351        .fold(f32::INFINITY, f32::min);
2352    let all_x_max = h_edges
2353        .iter()
2354        .map(|e| e.end)
2355        .chain(v_edges.iter().map(|e| e.coord))
2356        .fold(f32::NEG_INFINITY, f32::max);
2357    let x_span = (all_x_max - all_x_min).max(1.0);
2358    let x_tol = x_span * 0.5;
2359
2360    h_edges.retain(|h| {
2361        v_edges
2362            .iter()
2363            .any(|v| v.coord >= h.start - x_tol && v.coord <= h.end + x_tol)
2364    });
2365
2366    v_edges.retain(|v| {
2367        h_edges
2368            .iter()
2369            .any(|h| v.coord >= h.start - x_tol && v.coord <= h.end + x_tol)
2370    });
2371}
2372
2373/// Find all intersection points where an H edge and a V edge actually cross.
2374fn find_intersections(h_edges: &[Edge], v_edges: &[Edge]) -> Vec<Intersection> {
2375    let mut pts: Vec<Intersection> = Vec::new();
2376    for h in h_edges {
2377        for v in v_edges {
2378            if v.coord >= h.start - SNAP_TOL
2379                && v.coord <= h.end + SNAP_TOL
2380                && h.coord >= v.start - SNAP_TOL
2381                && h.coord <= v.end + SNAP_TOL
2382            {
2383                pts.push(Intersection { x: v.coord, y: h.coord });
2384            }
2385        }
2386    }
2387    pts.sort_by(|a, b| crate::utils::safe_float_cmp(a.x, b.x).then_with(|| crate::utils::safe_float_cmp(a.y, b.y)));
2388    pts.dedup_by(|a, b| (a.x - b.x).abs() <= SNAP_TOL && (a.y - b.y).abs() <= SNAP_TOL);
2389    pts
2390}
2391
2392/// Tolerance for judging whether a V edge spans a *candidate cell's* Y-range, at
2393/// cell-construction time in `build_cells_from_intersections`.
2394///
2395/// This is deliberately a SEPARATE constant from `BAND_RULE_SPAN_TOL`, not a reuse of it, even
2396/// though both answer the same shape of question ("does this edge actually run through the
2397/// range?") on opposite axes. `BAND_RULE_SPAN_TOL` was tightened from 3.0 (`SNAP_TOL`) down to
2398/// 1.0 specifically because on the X axis a too-LOOSE tolerance was the bug: it let a V rule that
2399/// fell up to 3pt short *at each end* still count as dividing a row's columns, manufacturing a
2400/// phantom column boundary the drawn rule did not justify (xberg-io/xberg#1588). The fix needed
2401/// to be conservative in the direction of NOT crediting a short edge.
2402///
2403/// On the Y axis the failure direction is the opposite. A too-TIGHT tolerance here does not merely
2404/// merge columns within a row that still exists — it can stop the row's cell from forming at all
2405/// (xberg-io/xberg#1601), which is the more severe failure. A per-cell rule drawn a few points
2406/// short of its own row's true top/bottom (ordinary visual padding) must still count as spanning
2407/// that row, while a phantom cell bridging two physically separate ruled regions — tens of points
2408/// apart (40pt in this file's `gh1601_graphics_free_gap_does_not_bridge_two_tables` fixture, 54pt
2409/// in the reporter's carrier document) — must not. `BAND_RULE_SPAN_TOL`'s 1.0pt is too tight for
2410/// the first case; reusing it would silently drop legitimate padded-rule tables' rows. 6.0pt sits
2411/// roughly 1-3x above a "a few points" (2-5pt) legitimate inset and roughly 7x below the smallest
2412/// observed phantom-cell gap in either direction (40 / 6 ≈ 6.7, 54 / 6 ≈ 9), so it separates the
2413/// two classes with comfortable margin on both sides without conflating this axis's tolerance with
2414/// the X axis's differently-motivated one. ~keep
2415const CELL_RULE_SPAN_TOL: f32 = 6.0;
2416
2417/// Cap on how many widened candidate columns `build_cells_from_intersections` tries per
2418/// starting corner once the nearest crossing cannot close a cell (`divider_ends_below`).
2419/// Each extra candidate re-scans `v_edges`/`h_edges` inside `side_closes`/`band_is_ruled`,
2420/// so an unbounded retry turns the outer O(nx*ny) loop into O(nx^2*ny*E) on input with a
2421/// broken divider at every column — PR #1807's own description reports four large corpus
2422/// files timing out from exactly this. Measured maximum candidate index actually needed:
2423/// 2 across `pdf_grouped_table_headers` + `pdf_spanning_table_cells`, 3 across the full
2424/// `spatial_table_detector` unit-test module (including
2425/// `header_cell_search_skips_nonclosing_inner_corner`). 8 leaves comfortable headroom
2426/// above both without reintroducing the unbounded scan. ~keep
2427const MAX_HEADER_CELL_WIDENING_CANDIDATES: usize = 8;
2428
2429/// Build cells from intersection points.
2430/// A cell exists when all four corners (x1,y1), (x2,y1), (x1,y2), (x2,y2) are present
2431/// and there is no intermediate intersection between them on either axis.
2432fn build_cells_from_intersections(pts: &[Intersection], h_edges: &[Edge], v_edges: &[Edge]) -> Vec<IntersectionCell> {
2433    use std::collections::BTreeSet;
2434
2435    let mut xs: Vec<f32> = pts.iter().map(|p| p.x).collect();
2436    let mut ys: Vec<f32> = pts.iter().map(|p| p.y).collect();
2437    xs.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
2438    xs.dedup_by(|a, b| (*a - *b).abs() <= SNAP_TOL);
2439    ys.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
2440    ys.dedup_by(|a, b| (*a - *b).abs() <= SNAP_TOL);
2441
2442    let x_idx = |xv: f32| -> Option<usize> { xs.iter().position(|&c| (c - xv).abs() <= SNAP_TOL) };
2443    let y_idx = |yv: f32| -> Option<usize> { ys.iter().position(|&c| (c - yv).abs() <= SNAP_TOL) };
2444
2445    let nx = xs.len();
2446    let ny = ys.len();
2447    let mut present: BTreeSet<usize> = BTreeSet::new();
2448    for p in pts {
2449        if let (Some(xi), Some(yi)) = (x_idx(p.x), y_idx(p.y)) {
2450            present.insert(yi * nx + xi);
2451        }
2452    }
2453
2454    let has = |xi: usize, yi: usize| -> bool { present.contains(&(yi * nx + xi)) };
2455
2456    // Four corners are not four sides. Two unrelated ruled grids that share column X-positions
2457    // (the same field layout repeated after a section heading) put crossing points at all four
2458    // corners of the graphics-free gap between them, and a cell there swallows whatever text
2459    // sits in the gap (xberg-io/xberg#1601). So a cell forms only when each side is a drawn V
2460    // edge spanning its Y-range, or when the band between the cell's own top and bottom H rules
2461    // is part of a grid: a drawn rule crosses it strictly inside those rules' ends, or drawn
2462    // rules close both ends. That keeps rows whose sides are not drawn in that row: merged cells
2463    // and full-width section rows, and a zebra-shaded table's unshaded rows, where the only edges
2464    // at the outer x are the shaded neighbours' fill sides. A rule at one end only (an enclosing
2465    // table's column rule) or past them (a page frame) runs alongside a gap without closing it.
2466    // `band_column_groups` (xberg-io/xberg#1580) then merges any boundary no rule divides.
2467    // `CELL_RULE_SPAN_TOL`'s doc comment says why this axis does not share
2468    // `BAND_RULE_SPAN_TOL`. ~keep
2469    let spans_band = |edge: &Edge, y_lo: f32, y_hi: f32| -> bool {
2470        edge.start <= y_lo + CELL_RULE_SPAN_TOL && edge.end >= y_hi - CELL_RULE_SPAN_TOL
2471    };
2472    let v_edge_spans = |x: f32, y_lo: f32, y_hi: f32| -> bool {
2473        v_edges
2474            .iter()
2475            .any(|edge| (edge.coord - x).abs() <= SNAP_TOL && spans_band(edge, y_lo, y_hi))
2476    };
2477
2478    let h_edge_across = |y: f32, x1: f32, x2: f32| -> Option<&Edge> {
2479        h_edges
2480            .iter()
2481            .find(|edge| (edge.coord - y).abs() <= SNAP_TOL && edge.start <= x1 + SNAP_TOL && edge.end >= x2 - SNAP_TOL)
2482    };
2483    let band_is_ruled = |x1: f32, x2: f32, y_lo: f32, y_hi: f32| -> bool {
2484        let (Some(top), Some(bottom)) = (h_edge_across(y_lo, x1, x2), h_edge_across(y_hi, x1, x2)) else {
2485            return false;
2486        };
2487        let (from, to) = (top.start.max(bottom.start), top.end.min(bottom.end));
2488        (v_edge_spans(from, y_lo, y_hi) && v_edge_spans(to, y_lo, y_hi))
2489            || v_edges
2490                .iter()
2491                .any(|edge| edge.coord > from + SNAP_TOL && edge.coord < to - SNAP_TOL && spans_band(edge, y_lo, y_hi))
2492    };
2493
2494    let mut cells = Vec::new();
2495    for yi in 0..ny {
2496        for xi in 0..nx {
2497            if !has(xi, yi) {
2498                continue;
2499            }
2500            // A divider may end at this row boundary. Try farther crossings
2501            // when the nearest one cannot close a cell above it. ~keep
2502            let divider_ends_below = ((xi + 1)..nx).find(|&next| has(next, yi)).is_some_and(|next| {
2503                v_edges.iter().any(|edge| {
2504                    (edge.coord - xs[next]).abs() <= SNAP_TOL
2505                        && edge.start < ys[yi] - SNAP_TOL
2506                        && (edge.end - ys[yi]).abs() <= CELL_RULE_SPAN_TOL
2507                })
2508            });
2509            for (candidate, nxi) in ((xi + 1)..nx).filter(|&nxi| has(nxi, yi)).enumerate() {
2510                if candidate > 0 && (!divider_ends_below || candidate > MAX_HEADER_CELL_WIDENING_CANDIDATES) {
2511                    break;
2512                }
2513                let side_closes = |x: f32, nyi: usize| -> bool {
2514                    v_edge_spans(x, ys[yi], ys[nyi]) || band_is_ruled(xs[xi], xs[nxi], ys[yi], ys[nyi])
2515                };
2516                // A neighbouring row separator may end at just one side of a
2517                // spanning cell. Find a closing boundary on both sides rather
2518                // than abandoning the cell at that one-sided crossing. ~keep
2519                let mut next_yi = None;
2520                for (candidate_y, nyi) in ((yi + 1)..ny)
2521                    .filter(|&nyi| has(xi, nyi) && side_closes(xs[xi], nyi))
2522                    .enumerate()
2523                {
2524                    if has(nxi, nyi)
2525                        && side_closes(xs[nxi], nyi)
2526                        && (candidate_y == 0
2527                            || (v_edge_spans(xs[xi], ys[yi], ys[nyi])
2528                                && v_edge_spans(xs[nxi], ys[yi], ys[nyi])
2529                                && h_edge_across(ys[yi], xs[xi], xs[nxi]).is_some()
2530                                && h_edge_across(ys[nyi], xs[xi], xs[nxi]).is_some()))
2531                    {
2532                        next_yi = Some(nyi);
2533                        break;
2534                    }
2535                    // Only skip crossings from a neighbouring cell. A rule
2536                    // entering this cell is a real internal boundary, even if
2537                    // it cannot close at the chosen right edge. ~keep
2538                    if h_edges.iter().any(|edge| {
2539                        (edge.coord - ys[nyi]).abs() <= SNAP_TOL
2540                            && edge.end > xs[xi] + SNAP_TOL
2541                            && edge.start < xs[nxi] - SNAP_TOL
2542                    }) {
2543                        break;
2544                    }
2545                }
2546
2547                if let Some(nyi) = next_yi
2548                    && (candidate == 0
2549                        || (v_edge_spans(xs[xi], ys[yi], ys[nyi])
2550                            && v_edge_spans(xs[nxi], ys[yi], ys[nyi])
2551                            && h_edge_across(ys[yi], xs[xi], xs[nxi]).is_some()
2552                            && h_edge_across(ys[nyi], xs[xi], xs[nxi]).is_some()))
2553                {
2554                    cells.push(IntersectionCell {
2555                        x1: xs[xi],
2556                        y1: ys[yi],
2557                        x2: xs[nxi],
2558                        y2: ys[nyi],
2559                    });
2560                    break;
2561                }
2562            }
2563        }
2564    }
2565    cells
2566}
2567
2568/// Build grid cells from the Cartesian product of H-edge Y-positions and V-edge X-positions.
2569///
2570/// This "extended grid" approach handles the case where horizontal and vertical lines
2571/// don't physically intersect (e.g., H-lines in a header area and V tick marks in a data
2572/// area). Instead of requiring actual crossings, we project every unique V-line X coordinate
2573/// across every unique H-line Y coordinate to create virtual grid intersections.
2574fn build_extended_grid_cells(h_edges: &[Edge], v_edges: &[Edge]) -> Vec<IntersectionCell> {
2575    let mut ys: Vec<f32> = h_edges.iter().map(|e| e.coord).collect();
2576    ys.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
2577    ys.dedup_by(|a, b| (*a - *b).abs() <= SNAP_TOL);
2578
2579    let mut xs: Vec<f32> = v_edges.iter().map(|e| e.coord).collect();
2580    xs.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
2581    xs.dedup_by(|a, b| (*a - *b).abs() <= SNAP_TOL);
2582
2583    if xs.len() < 2 || ys.len() < 2 {
2584        return Vec::new();
2585    }
2586
2587    let mut cells = Vec::new();
2588    for yi in 0..ys.len() - 1 {
2589        for xi in 0..xs.len() - 1 {
2590            cells.push(IntersectionCell {
2591                x1: xs[xi],
2592                y1: ys[yi],
2593                x2: xs[xi + 1],
2594                y2: ys[yi + 1],
2595            });
2596        }
2597    }
2598    cells
2599}
2600
2601/// Group cells that share edges into tables using union-find.
2602fn group_cells_into_tables(cells: &[IntersectionCell]) -> Vec<Vec<usize>> {
2603    if cells.is_empty() {
2604        return Vec::new();
2605    }
2606    let n = cells.len();
2607    let mut uf = UnionFind::new(n);
2608
2609    // Sweep-line prune for the O(n²) edge-adjacency scan (the hot loop on dense
2610    // ruled pages — CFR regulatory megafiles). BOTH adjacency tests below
2611    // require the cells' y-extents to touch within SNAP_TOL: horizontal
2612    // adjacency needs y1≈y1 (so cj.y1 ≤ ci.y2 + SNAP_TOL), and vertical
2613    // adjacency needs cj.y1 ≈ ci.y2 (also ≤ ci.y2 + SNAP_TOL). Iterating cells
2614    // in ascending-y1 order lets us `break` the inner loop once a candidate's
2615    // y1 clears ci.y2 + SNAP_TOL — every later candidate has an even larger y1
2616    // and cannot share an edge. We `union` by ORIGINAL index, and union is
2617    // order-independent, so the resulting partition is byte-identical to the
2618    // full O(n²) scan; only provably-non-adjacent pairs are skipped. ~keep
2619    let mut order: Vec<usize> = (0..n).collect();
2620    order.sort_by(|&a, &b| crate::utils::safe_float_cmp(cells[a].y1, cells[b].y1));
2621    for a in 0..n {
2622        let i = order[a];
2623        let ci = &cells[i];
2624        let y_limit = ci.y2 + SNAP_TOL;
2625        for &j in order.iter().skip(a + 1) {
2626            let cj = &cells[j];
2627            if cj.y1 > y_limit {
2628                break;
2629            }
2630            let shares_edge = // Horizontal adjacency (share a vertical edge) ~keep
2631                (((ci.x2 - cj.x1).abs() <= SNAP_TOL || (ci.x1 - cj.x2).abs() <= SNAP_TOL)
2632                    && (ci.y1 - cj.y1).abs() <= SNAP_TOL
2633                    && (ci.y2 - cj.y2).abs() <= SNAP_TOL)
2634                || // Vertical adjacency (share a horizontal edge) ~keep
2635                (((ci.y2 - cj.y1).abs() <= SNAP_TOL || (ci.y1 - cj.y2).abs() <= SNAP_TOL)
2636                    && (ci.x1 - cj.x1).abs() <= SNAP_TOL
2637                    && (ci.x2 - cj.x2).abs() <= SNAP_TOL);
2638            if shares_edge {
2639                uf.union(i, j);
2640            }
2641        }
2642    }
2643
2644    // Collect groups in a DETERMINISTIC order. `groups()` returns a HashMap
2645    // whose iteration order is randomized per-process (Rust `RandomState`), so
2646    // `into_values().collect()` would yield table clusters in a different order
2647    // each run — leaking non-deterministic reading order on multi-table / figure
2648    // pages (e.g. matrix-figure pages with several detected regions). Each
2649    // group's `Vec` is already ascending (built `for i in 0..n`); sort the outer
2650    // list by each group's first (smallest) cell index so table order is stable. ~keep
2651    let mut groups: Vec<Vec<usize>> = uf.groups().into_values().collect();
2652    groups.sort_by_key(|g| g.first().copied().unwrap_or(usize::MAX));
2653    groups
2654}
2655
2656/// Attach a spanning header only to an independently established body grid.
2657/// A few header boxes alone must not become a newly accepted table: that can
2658/// suppress the fallback detector for the real body on the same page. ~keep
2659fn attach_spanning_headers(cells: &[IntersectionCell], groups: Vec<Vec<usize>>, min_cells: usize) -> Vec<Vec<usize>> {
2660    let mut uf = UnionFind::new(groups.len());
2661    for (body_index, body) in groups.iter().enumerate() {
2662        if body.len() < min_cells {
2663            continue;
2664        }
2665        // One ruled row can be only a header above an unruled body. Attaching
2666        // another heading must not make that fragment claim the whole page. ~keep
2667        let first_y = cells[body[0]].y1;
2668        if body.iter().all(|&i| (cells[i].y1 - first_y).abs() <= SNAP_TOL) {
2669            continue;
2670        }
2671        let left = body.iter().map(|&i| cells[i].x1).fold(f32::INFINITY, f32::min);
2672        let right = body.iter().map(|&i| cells[i].x2).fold(f32::NEG_INFINITY, f32::max);
2673        let top = body.iter().map(|&i| cells[i].y2).fold(f32::NEG_INFINITY, f32::max);
2674        for (header_index, header) in groups.iter().enumerate() {
2675            if body_index == header_index
2676                || !header.iter().all(|&i| {
2677                    let c = cells[i];
2678                    c.y1 >= top - SNAP_TOL && c.x1 >= left - SNAP_TOL && c.x2 <= right + SNAP_TOL
2679                })
2680            {
2681                continue;
2682            }
2683            let shares_edge = header.iter().any(|&h| {
2684                body.iter().any(|&b| {
2685                    let (h, b) = (cells[h], cells[b]);
2686                    (h.y1 - b.y2).abs() <= SNAP_TOL && h.x2.min(b.x2) - h.x1.max(b.x1) > SNAP_TOL
2687                })
2688            });
2689            if shares_edge {
2690                uf.union(body_index, header_index);
2691            }
2692        }
2693    }
2694    let mut joined: Vec<Vec<usize>> = uf
2695        .groups()
2696        .into_values()
2697        .map(|indices| {
2698            let mut members: Vec<_> = indices.into_iter().flat_map(|i| groups[i].iter().copied()).collect();
2699            members.sort_unstable();
2700            members
2701        })
2702        .collect();
2703    joined.sort_by_key(|g| g.first().copied().unwrap_or(usize::MAX));
2704    joined
2705}
2706
2707/// Minimum number of distinct columns a Y-cluster must carry independent text
2708/// evidence in for a row split to be credible. A cluster below this bar is a
2709/// single wrapped cell's continuation line, not a second logical row
2710/// (xberg-io/xberg#1555). ~keep
2711const MIN_ROW_SPLIT_EVIDENCE_COLUMNS: usize = 2;
2712
2713/// Minimum number of Y-clusters that must *independently* clear
2714/// `MIN_ROW_SPLIT_EVIDENCE_COLUMNS` before a producer-drawn row band is treated as more than
2715/// one row at all. One evidenced cluster is not enough: two unrelated single-column spans can
2716/// land within `row_tolerance` of each other by pure chance (see the #1555 regression fixture
2717/// `split_rows_by_text_positions_keeps_drawn_band_as_one_row_when_only_one_cell_wraps`, where a
2718/// row-number span and an unrelated wrapped cell's middle baseline coincide on one Y value and
2719/// nowhere else) and that single coincidence must not be enough to accept a split. A second,
2720/// independently evidenced cluster is what tells a genuinely tabular band apart from one
2721/// baseline that happened to line up (xberg-io/xberg#1565). ~keep
2722const MIN_EVIDENCED_CLUSTERS_FOR_SPLIT: usize = 2;
2723
2724/// Split table rows that contain text spans at multiple distinct Y positions into sub-rows.
2725///
2726/// This handles the hybrid case where column boundaries come from vertical lines but there
2727/// are no horizontal lines between individual rows. In that scenario the intersection-based
2728/// pipeline produces a single mega-row; this function detects multiple Y-clusters within
2729/// each row and splits accordingly.
2730///
2731/// The only caller (`finalize_intersection_tables`) runs exclusively on grids the drawn
2732/// geometry already delimited into rows (`detect_tables_from_intersections` via
2733/// `build_grid_from_lines`), so every row entering here is a producer-drawn row band, and a
2734/// split is a hypothesis about *sub*-dividing that band, not about discovering rows from
2735/// scratch. The drawn band boundary is authoritative; text baselines inside it are only
2736/// advisory.
2737///
2738/// The band is treated as more than one row only once at least
2739/// `MIN_EVIDENCED_CLUSTERS_FOR_SPLIT` of its Y-clusters are *independently* evidenced in
2740/// `MIN_ROW_SPLIT_EVIDENCE_COLUMNS` columns; see that constant's doc for why one evidenced
2741/// cluster is not trusted on its own. Once the band clears that bar, each remaining deficient
2742/// cluster (one that does not clear the evidence bar by itself) is resolved on its own terms
2743/// instead of vetoing the whole band (xberg-io/xberg#1565: a band mixing multi-column data
2744/// rows with single-column section headings, lead-ins, or wrapped continuations was previously
2745/// collapsed back into one mega-row in full, and the headings disappeared into whatever cell
2746/// absorbed them). A deficient cluster folds into the cluster directly above it in reading
2747/// order — the adjacent Y-cluster with the *larger* y, since PDF space is y-up and "above" on
2748/// the page means a larger y — only when the fold cannot manufacture evidence that was not
2749/// already there: the deficient cluster's populated columns must already be a non-empty subset
2750/// of the columns the cluster above populates. That is the signature of a wrapped
2751/// continuation line, which only ever repeats content in a column the cell above was already
2752/// using. A deficient cluster that fails this test — because it has no cluster above it at
2753/// all, or because it carries text in a column the cluster above left empty — is kept as a row
2754/// of its own, one cell wide: that is what a heading or lead-in line inside a ruled band
2755/// actually is.
2756///
2757/// The evidence bar is capped at the band's own column count
2758/// (`MIN_ROW_SPLIT_EVIDENCE_COLUMNS.min(num_cols)`): a single-column band has no second
2759/// column to ever produce corroborating evidence from, so requiring 2 there would reject
2760/// every split unconditionally, including genuinely distinct rows sharing one drawn cell
2761/// (e.g. a label/value pair). For `num_cols == 1` this restores the pre-#1555 behaviour of
2762/// accepting any non-empty cluster as evidence; the #1555 guard itself only applies where
2763/// it can be evaluated, at `num_cols >= 2`.
2764fn split_rows_by_text_positions(
2765    table_rows: Vec<TableRow>,
2766    row_cell_span_indices: &[Vec<Vec<usize>>],
2767    spans: &[TextSpan],
2768    config: &TableDetectionConfig,
2769) -> Vec<TableRow> {
2770    let mut result: Vec<TableRow> = Vec::new();
2771
2772    for (row_idx, row) in table_rows.into_iter().enumerate() {
2773        let cell_indices = &row_cell_span_indices[row_idx];
2774
2775        let mut all_ys: Vec<f32> = Vec::new();
2776        for col_spans in cell_indices {
2777            for &idx in col_spans {
2778                if let Some(s) = spans.get(idx) {
2779                    all_ys.push(s.bbox.center().y);
2780                }
2781            }
2782        }
2783
2784        if all_ys.len() <= 1 {
2785            result.push(row);
2786            continue;
2787        }
2788
2789        all_ys.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
2790        let mut y_clusters: Vec<f32> = Vec::new();
2791        for &y in &all_ys {
2792            let merged = y_clusters
2793                .last()
2794                .is_some_and(|&last| (y - last).abs() < config.row_tolerance);
2795            if merged {
2796                let last = y_clusters.last_mut().unwrap();
2797                *last = (*last + y) / 2.0;
2798            } else {
2799                y_clusters.push(y);
2800            }
2801        }
2802
2803        if y_clusters.len() <= 1 {
2804            result.push(row);
2805            continue;
2806        }
2807
2808        let num_cols = row.cells.len();
2809        let nearest_cluster_value = |sy: f32| -> f32 {
2810            *y_clusters
2811                .iter()
2812                .min_by_key(|&&cy| ((sy - cy).abs() * 1000.0) as i32)
2813                .unwrap_or(&sy)
2814        };
2815
2816        // Tally, per cluster, which columns have at least one span assigned to it. ~keep
2817        let mut cluster_columns: Vec<Vec<bool>> = vec![vec![false; num_cols]; y_clusters.len()];
2818        for (ci, col_spans) in cell_indices.iter().enumerate() {
2819            for &idx in col_spans {
2820                if let Some(s) = spans.get(idx) {
2821                    let nearest = nearest_cluster_value(s.bbox.center().y);
2822                    if let Some(cluster_idx) = y_clusters.iter().position(|&cy| (cy - nearest).abs() < 0.01) {
2823                        cluster_columns[cluster_idx][ci] = true;
2824                    }
2825                }
2826            }
2827        }
2828
2829        // A single-column band (num_cols == 1) can never carry cross-column
2830        // evidence at all — there is only one column to begin with, so the
2831        // wrapped-cell-vs-genuine-row ambiguity #1555 targets is undecidable
2832        // by this signal there. Requiring the full MIN_ROW_SPLIT_EVIDENCE_COLUMNS
2833        // in that case would make the gate reject every split on a
2834        // single-column grid unconditionally, including genuinely distinct
2835        // rows (e.g. label/value pairs sharing one drawn cell). Scale the
2836        // requirement down to the band's own column count so a single-column
2837        // band keeps its pre-#1555 behaviour (any non-empty cluster counts as
2838        // evidence) while a multi-column band still needs the full bar
2839        // (xberg-io/xberg#1555). ~keep
2840        let required_evidence_columns = MIN_ROW_SPLIT_EVIDENCE_COLUMNS.min(num_cols.max(1));
2841        let is_evidenced: Vec<bool> = cluster_columns
2842            .iter()
2843            .map(|cols| cols.iter().filter(|&&present| present).count() >= required_evidence_columns)
2844            .collect();
2845        let evidenced_cluster_count = is_evidenced.iter().filter(|&&evidenced| evidenced).count();
2846
2847        if evidenced_cluster_count < MIN_EVIDENCED_CLUSTERS_FOR_SPLIT {
2848            result.push(row);
2849            continue;
2850        }
2851
2852        // Resolve a fold target for every deficient cluster: it folds into the cluster
2853        // directly above it (ascending index + 1, i.e. the next-larger y) only when doing so
2854        // introduces no column the cluster above didn't already have. Evidenced clusters never
2855        // fold — they are anchors in their own right. Fold targets strictly increase (i can
2856        // only point to i + 1), so following the chain in `resolve_group` always terminates
2857        // without needing cycle protection. ~keep
2858        let mut fold_into: Vec<Option<usize>> = vec![None; y_clusters.len()];
2859        for i in 0..y_clusters.len().saturating_sub(1) {
2860            if is_evidenced[i] {
2861                continue;
2862            }
2863            let is_wrapped_continuation = cluster_columns[i]
2864                .iter()
2865                .zip(cluster_columns[i + 1].iter())
2866                .all(|(&here, &above)| !here || above);
2867            if is_wrapped_continuation {
2868                fold_into[i] = Some(i + 1);
2869            }
2870        }
2871
2872        let resolve_group = |mut idx: usize| -> usize {
2873            while let Some(next) = fold_into[idx] {
2874                idx = next;
2875            }
2876            idx
2877        };
2878
2879        let mut group_members: Vec<Vec<usize>> = vec![Vec::new(); y_clusters.len()];
2880        for i in 0..y_clusters.len() {
2881            group_members[resolve_group(i)].push(i);
2882        }
2883
2884        // Emit one output row per non-empty group, topmost (largest y) first. A group's
2885        // resolved anchor index always carries the largest y in the group, since folding only
2886        // ever points toward a larger-y neighbor. ~keep
2887        let mut group_order: Vec<usize> = (0..y_clusters.len())
2888            .filter(|&g| !group_members[g].is_empty())
2889            .collect();
2890        group_order.sort_by(|&a, &b| crate::utils::safe_float_cmp(y_clusters[b], y_clusters[a]));
2891
2892        for group_idx in group_order {
2893            let members = &group_members[group_idx];
2894            let mut new_row = TableRow::new(row.is_header);
2895            for ci in 0..num_cols {
2896                let matching_indices: Vec<usize> = cell_indices[ci]
2897                    .iter()
2898                    .copied()
2899                    .filter(|&idx| {
2900                        spans
2901                            .get(idx)
2902                            .map(|s| {
2903                                let nearest = nearest_cluster_value(s.bbox.center().y);
2904                                members.iter().any(|&m| (y_clusters[m] - nearest).abs() < 0.01)
2905                            })
2906                            .unwrap_or(false)
2907                    })
2908                    .collect();
2909
2910                let cell_text = extract_cell_text(&matching_indices, spans);
2911                let mcids: Vec<u32> = matching_indices
2912                    .iter()
2913                    .filter_map(|&idx| spans.get(idx).and_then(|s| s.mcid))
2914                    .collect();
2915
2916                let cell_bbox = if matching_indices.is_empty() {
2917                    row.cells[ci].bbox
2918                } else {
2919                    let mut b = spans[matching_indices[0]].bbox;
2920                    for &idx in &matching_indices[1..] {
2921                        b = b.union(&spans[idx].bbox);
2922                    }
2923                    Some(b)
2924                };
2925
2926                let cell_spans = matching_indices
2927                    .iter()
2928                    .filter_map(|&idx| spans.get(idx).cloned())
2929                    .collect::<Vec<_>>();
2930
2931                new_row.cells.push(TableCell {
2932                    text: cell_text,
2933                    spans: cell_spans,
2934                    colspan: 1,
2935                    rowspan: 1,
2936                    mcids,
2937                    bbox: cell_bbox,
2938                    is_header: row.is_header,
2939                });
2940            }
2941            result.push(new_row);
2942        }
2943    }
2944
2945    result
2946}
2947
2948/// Strip form-template numbering artifacts and decorative separators from table rows.
2949///
2950/// PDF form templates sometimes embed single-digit numbering (e.g. "1", "5") as
2951/// separate text spans that get concatenated into cell text as a prefix. They also
2952/// use rows or cells filled with dashes/underscores as decorative separators.
2953/// This function:
2954/// 1. Removes entire rows where every cell is either empty or a lone single digit.
2955/// 2. Strips a leading single-digit prefix from cell text when the remainder looks
2956///    like real content (starts with a letter, `$`, or contains `/` or `-`).
2957/// 3. Clears cells that contain only dashes/underscores (decorative separators).
2958/// 4. Removes rows where all cells are empty after separator stripping.
2959fn strip_form_numbering_artifacts(table_rows: &mut Vec<TableRow>) {
2960    // Phase 1: Remove rows where ALL cells are either empty or a lone single
2961    // digit (1-9), AND at least one cell actually contains a digit.  Rows that
2962    // are completely empty are left intact so the downstream empty-row splitting
2963    // logic can use them as table separators. ~keep
2964    table_rows.retain(|row| {
2965        let all_empty_or_digit = row.cells.iter().all(|c| {
2966            let t = c.text.trim();
2967            t.is_empty() || (t.len() == 1 && t.as_bytes().first().is_some_and(|b| b.is_ascii_digit() && *b != b'0'))
2968        });
2969        let has_digit = row.cells.iter().any(|c| {
2970            let t = c.text.trim();
2971            t.len() == 1 && t.as_bytes().first().is_some_and(|b| b.is_ascii_digit() && *b != b'0')
2972        });
2973        !(all_empty_or_digit && has_digit)
2974    });
2975
2976    // Phase 2: Strip leading single-digit prefix from individual cells.
2977    // Track whether any stripping occurred for Phase 3.
2978    // Only strip when the remainder clearly looks like form data (currency, dates,
2979    // codes with dashes/slashes), NOT when it could be a natural phrase like
2980    // "3 items". ~keep
2981    for row in table_rows.iter_mut() {
2982        let mut stripped_any = false;
2983        for cell in &mut row.cells {
2984            let text = cell.text.trim();
2985            if text.len() < 3 {
2986                continue; // Need at least digit + space + char ~keep
2987            }
2988            let bytes = text.as_bytes();
2989            if bytes[0].is_ascii_digit() && bytes[0] != b'0' && bytes[1] == b' ' {
2990                let rest = text[2..].trim_start();
2991                if !rest.is_empty() {
2992                    let first = rest.as_bytes()[0];
2993                    // Strip when remainder starts with '$' (currency) or starts
2994                    // with a digit (date like "Apr 11" won't, but codes like
2995                    // "12111 - ..." will), or contains '-' or '/' (dates, codes). ~keep
2996                    let looks_like_data = first == b'$'
2997                        || first.is_ascii_digit()
2998                        || (first.is_ascii_alphabetic()
2999                            && (rest.contains('-') || rest.contains('/') || rest.contains(',')));
3000                    if looks_like_data {
3001                        cell.text = rest.to_string();
3002                        stripped_any = true;
3003                    }
3004                }
3005            }
3006        }
3007
3008        // Phase 3: In rows where prefixes were stripped, clear remaining
3009        // lone single-digit cells (they're the same numbering artifact
3010        // but had no content after the digit). ~keep
3011        if stripped_any {
3012            for cell in &mut row.cells {
3013                let t = cell.text.trim();
3014                if t.len() == 1 && t.as_bytes()[0].is_ascii_digit() {
3015                    cell.text.clear();
3016                }
3017            }
3018        }
3019    }
3020
3021    // Phase 4: Clear cells that contain only dashes and/or underscores
3022    // (decorative line separators in form templates, e.g. "------", "____"). ~keep
3023    for row in table_rows.iter_mut() {
3024        for cell in &mut row.cells {
3025            let t = cell.text.trim();
3026            if !t.is_empty() && t.chars().all(|c| c == '-' || c == '_') {
3027                cell.text.clear();
3028            }
3029        }
3030    }
3031
3032    // Note: rows that become fully empty after Phase 4 (e.g. all-dash rows)
3033    // are intentionally left in place.  The downstream empty-row splitting
3034    // logic in detect_tables_from_intersections uses them as table separators. ~keep
3035}
3036
3037/// Detect tables from intersections of horizontal and vertical edges, then assign text.
3038///
3039/// This implements the universal pipeline used by Tabula, pdfplumber, and PyMuPDF:
3040/// `Edges -> Snap/Merge -> Intersections -> Cells -> Table Groups`
3041///
3042/// GH#1358: A ruled table that is itself rotated on the page (its border
3043/// lines rotated along with its text) still forms a geometrically correct
3044/// grid here — `build_grid_from_lines` and `assign_spans_to_intersection_grid`
3045/// bucket every span by its *physical* page position, which is right
3046/// regardless of rotation. What was wrong is the *labelling* of that grid:
3047/// row/column order comes out top-to-bottom / left-to-right in *page* space,
3048/// which is only the table's own reading order when the table is upright.
3049/// Fixed as a post-process (`reorient_table_to_rotated_frame`, applied just
3050/// before this function returns) rather than inline here, so every physical
3051/// merge/split step above it — `merge_vertically_adjacent_tables`,
3052/// section-divider splitting — keeps operating on unmodified page-space
3053/// tables exactly as before; only the caller-visible row/column labelling of
3054/// the final tables changes for a confidently-rotated table. ~keep
3055fn detect_tables_from_intersections(
3056    spans: &[TextSpan],
3057    lines: &[crate::elements::PathContent],
3058    config: &TableDetectionConfig,
3059) -> Vec<Table> {
3060    let (groups, v_edges, cells_are_intersections) = build_grid_from_lines(lines, config);
3061
3062    let mut tables = Vec::new();
3063    for (group_cells, xs, ys, num_cols) in &groups {
3064        let Some((table_rows, row_cell_span_indices)) =
3065            assign_spans_to_intersection_grid(group_cells, xs, ys, *num_cols, spans, &v_edges, cells_are_intersections)
3066        else {
3067            continue;
3068        };
3069        let sub_tables = finalize_intersection_tables(table_rows, &row_cell_span_indices, spans, config, *num_cols);
3070        tables.extend(sub_tables);
3071    }
3072
3073    merge_vertically_adjacent_tables(&mut tables);
3074
3075    // Post-merge: split tables at section dividers — full-width horizontal
3076    // lines that indicate separate form sections within a single grid.
3077    // Use merged H-edges (to detect full-width lines) but only snap (do NOT
3078    // join) V-edges — joining would merge separate per-section V-segments
3079    // into a single long edge, hiding the section boundary discontinuity. ~keep
3080    let (mut h_edges, mut v_edges) = extract_edges(lines);
3081    snap_and_merge(&mut h_edges);
3082    snap_edges(&mut v_edges);
3083    tables = split_tables_at_section_dividers(tables, &h_edges, &v_edges, config);
3084
3085    // GH#1358 fix, gated by `BUCKET_TABLE_GRID_IN_ROTATED_FRAME`: applied last
3086    // so every physical/page-space step above (grid building, row splitting,
3087    // vertical merge, section-divider splitting) is byte-for-byte identical
3088    // to before this fix. Flip the constant to `false` to A/B against the
3089    // pre-fix behaviour with a one-line change. ~keep
3090    if BUCKET_TABLE_GRID_IN_ROTATED_FRAME {
3091        tables = tables.into_iter().map(reorient_table_to_rotated_frame).collect();
3092    }
3093
3094    tables
3095}
3096
3097/// Steps 1-4: extract edges, find intersections, build cells, and group them
3098/// into per-table cell groups with their grid boundaries.
3099///
3100/// Returns one `(group_cells, xs, ys, num_cols)` tuple per table group, plus the V edges used
3101/// to build the cells and whether they came from real crossings (`cells_are_intersections`) —
3102/// both needed downstream by `band_column_groups` (xberg-io/xberg#1580).
3103fn build_grid_from_lines(
3104    lines: &[crate::elements::PathContent],
3105    config: &TableDetectionConfig,
3106) -> (Vec<(Vec<IntersectionCell>, Vec<f32>, Vec<f32>, usize)>, Vec<Edge>, bool) {
3107    let (mut h_edges, mut v_edges) = extract_edges(lines);
3108    snap_and_merge(&mut h_edges);
3109    snap_and_merge(&mut v_edges);
3110
3111    if h_edges.len() < 2 || v_edges.len() < 2 {
3112        return (Vec::new(), v_edges, false);
3113    }
3114
3115    let intersections = find_intersections(&h_edges, &v_edges);
3116
3117    // Step 2b: When intersections are sparse (< 4), filter out orphan edges
3118    // that have no plausible counterpart before building the extended grid.
3119    // This prevents unrelated edges (e.g., decorative lines far from the table)
3120    // from polluting the grid. ~keep
3121    if intersections.len() < 4 {
3122        filter_edges_by_coverage(&mut h_edges, &mut v_edges);
3123        if h_edges.len() < 2 || v_edges.len() < 2 {
3124            return (Vec::new(), v_edges, false);
3125        }
3126    }
3127
3128    // `cells_are_intersections` records whether `cells` came from real crossings
3129    // (`build_cells_from_intersections`) rather than the projected `build_extended_grid_cells`
3130    // grid. Only real crossings license `band_column_groups` to merge a band's columns
3131    // (xberg-io/xberg#1580) — an extended grid has no V edge that ever spans any band, so
3132    // merging there would collapse every row to one cell instead of narrowing a phantom cut.
3133    let (cells, cells_are_intersections) = if intersections.len() >= 4 {
3134        let c = build_cells_from_intersections(&intersections, &h_edges, &v_edges);
3135        if c.is_empty() {
3136            // Lines exist but don't form real intersection cells — try extended grid. ~keep
3137            (build_extended_grid_cells(&h_edges, &v_edges), false)
3138        } else {
3139            (c, true)
3140        }
3141    } else {
3142        // H and V lines don't physically cross (e.g. Census table: H-lines in
3143        // header area, V tick marks in data area). Build a virtual grid by
3144        // projecting all V-line X positions across all H-line Y positions. ~keep
3145        (build_extended_grid_cells(&h_edges, &v_edges), false)
3146    };
3147    if cells.is_empty() {
3148        return (Vec::new(), v_edges, cells_are_intersections);
3149    }
3150
3151    let table_groups = attach_spanning_headers(&cells, group_cells_into_tables(&cells), config.min_table_cells);
3152    let mut result = Vec::new();
3153    for group in &table_groups {
3154        let group_cells: Vec<IntersectionCell> = group.iter().map(|&i| cells[i]).collect();
3155
3156        let mut xs: Vec<f32> = Vec::new();
3157        let mut ys: Vec<f32> = Vec::new();
3158        for c in &group_cells {
3159            xs.push(c.x1);
3160            xs.push(c.x2);
3161            ys.push(c.y1);
3162            ys.push(c.y2);
3163        }
3164        xs.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
3165        xs.dedup_by(|a, b| (*a - *b).abs() <= SNAP_TOL);
3166        ys.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
3167        ys.dedup_by(|a, b| (*a - *b).abs() <= SNAP_TOL);
3168
3169        let num_cols = if xs.len() >= 2 {
3170            xs.len() - 1
3171        } else {
3172            continue;
3173        };
3174        if ys.len() < 2 {
3175            continue;
3176        }
3177
3178        if num_cols < config.min_table_columns || num_cols > config.max_table_columns {
3179            continue;
3180        }
3181
3182        result.push((group_cells, xs, ys, num_cols));
3183    }
3184    (result, v_edges, cells_are_intersections)
3185}
3186
3187/// Tolerance for judging whether a V edge spans a row band's full height.
3188///
3189/// This is a CONTAINMENT tolerance ("does this edge actually run through the band?"), which
3190/// answers a different question than `SNAP_TOL`, an IDENTITY tolerance ("are these two
3191/// coordinates the same coordinate?"). Reusing `SNAP_TOL` let an edge fall up to 3pt short of
3192/// the band at *each* end and still count as spanning it, so a band up to 6pt shorter than the
3193/// rule beside it was cut at a column position the drawn rule does not justify. The regression
3194/// test below fails at 3.0 and passes at 1.0 on exactly that shape.
3195///
3196/// The phantom boundary is what lets a band of prose inside a drawn frame split into two or
3197/// more cells instead of staying one wide cell, which is how such a region comes to satisfy the
3198/// downstream minimums for being accepted as a table at all. GH#1588 reports page text going
3199/// missing as a result; the precise downstream path is not asserted here, because the two
3200/// analyses of it disagreed and this constant's own behaviour is provable without settling it.
3201///
3202/// Keep this constant independent of `SNAP_TOL` even if their values happen to coincide again
3203/// in the future — they measure different things and must be free to diverge. ~keep
3204const BAND_RULE_SPAN_TOL: f32 = 1.0;
3205
3206/// Group a row band's columns into contiguous runs that no V rule actually divides.
3207///
3208/// `build_cells_from_intersections` accepts a cell as soon as its four corners are crossing
3209/// points, but four corners are not four sides (xberg-io/xberg#1580). When a producer stacks
3210/// ruled bands around an unruled full-width strip (a section heading), the strip's internal
3211/// column "boundaries" exist only because the neighbouring bands' V rules happen to terminate
3212/// on the strip's own H rules — no V rule actually runs through the strip itself. This counts
3213/// a column boundary at `xs[c + 1]` only when some edge in `v_edges` truly spans the band
3214/// `[y_lo, y_hi]` (within `BAND_RULE_SPAN_TOL`), and returns the resulting `(first_col,
3215/// last_col)` groups of adjacent columns no rule separates.
3216///
3217/// `cells_are_intersections` gates the whole check off for grids built by
3218/// `build_extended_grid_cells`: that grid exists precisely because H and V lines never cross,
3219/// so no V edge would ever be judged as spanning any band, and every band would collapse into
3220/// a single column. Only grids built from real crossings (`build_cells_from_intersections`)
3221/// may be narrowed this way; `false` returns one group per column, unchanged from before this
3222/// function existed.
3223fn band_column_groups(
3224    xs: &[f32],
3225    y_lo: f32,
3226    y_hi: f32,
3227    num_cols: usize,
3228    v_edges: &[Edge],
3229    cells_are_intersections: bool,
3230) -> Vec<(usize, usize)> {
3231    let boundary_is_drawn = |x: f32| -> bool {
3232        v_edges.iter().any(|edge| {
3233            (edge.coord - x).abs() <= SNAP_TOL
3234                && edge.start <= y_lo + BAND_RULE_SPAN_TOL
3235                && edge.end >= y_hi - BAND_RULE_SPAN_TOL
3236        })
3237    };
3238
3239    let mut groups = Vec::new();
3240    let mut start = 0usize;
3241    for c in 0..num_cols {
3242        if c + 1 == num_cols || !cells_are_intersections || boundary_is_drawn(xs[c + 1]) {
3243            groups.push((start, c));
3244            start = c + 1;
3245        }
3246    }
3247    groups
3248}
3249
3250/// Assign text spans to grid cells and build table rows with per-cell span
3251/// indices. Returns `None` when the grid is degenerate.
3252fn assign_spans_to_intersection_grid(
3253    group_cells: &[IntersectionCell],
3254    xs: &[f32],
3255    ys: &[f32],
3256    num_cols: usize,
3257    spans: &[TextSpan],
3258    v_edges: &[Edge],
3259    cells_are_intersections: bool,
3260) -> Option<(Vec<TableRow>, Vec<Vec<Vec<usize>>>)> {
3261    let num_rows = if ys.len() >= 2 {
3262        ys.len() - 1
3263    } else {
3264        return None;
3265    };
3266
3267    let boundary_index = |value: f32, boundaries: &[f32]| {
3268        boundaries
3269            .iter()
3270            .position(|&boundary| (boundary - value).abs() <= SNAP_TOL)
3271    };
3272
3273    // A detected cell can cover several grid intervals when neighbouring cells
3274    // introduce intermediate row or column boundaries. Mark its entire area;
3275    // each span is still assigned to just one interval below. ~keep
3276    let mut grid_has_cell = vec![vec![false; num_cols]; num_rows];
3277    for cell in group_cells {
3278        if let (Some(left), Some(right), Some(bottom), Some(top)) = (
3279            boundary_index(cell.x1, xs),
3280            boundary_index(cell.x2, xs),
3281            boundary_index(cell.y1, ys),
3282            boundary_index(cell.y2, ys),
3283        ) {
3284            // `boundary_index` proves each boundary exists in `xs`/`ys`, not that the
3285            // pair is ordered: two boundaries within SNAP_TOL of each other can snap to
3286            // indices in either order. `arr[bottom..top]` panics unconditionally on
3287            // bottom > top, so require ordering here and skip malformed geometry instead
3288            // of aborting the whole extraction. ~keep
3289            if bottom < top && left < right {
3290                for row in &mut grid_has_cell[bottom..top] {
3291                    row[left..right].fill(true);
3292                }
3293            }
3294        }
3295    }
3296
3297    // Assign text spans to grid cells based on center point. Prefer the exact
3298    // interval before applying snap tolerance: expanding every interval makes
3299    // points near an internal boundary match both neighbours, and `find()`
3300    // would always bias them into the earlier row or column. ~keep
3301    let mut grid_spans: Vec<Vec<Vec<usize>>> = vec![vec![Vec::new(); num_cols]; num_rows];
3302    for (idx, span) in spans.iter().enumerate() {
3303        let cx = span.bbox.center().x;
3304        let cy = span.bbox.center().y;
3305        let col_idx = grid_interval_for_point(cx, xs);
3306        let row_idx = grid_interval_for_point(cy, ys);
3307        if let (Some(ci), Some(ri)) = (col_idx, row_idx)
3308            && grid_has_cell[ri][ci]
3309        {
3310            grid_spans[ri][ci].push(idx);
3311        }
3312    }
3313
3314    // Build rows sorted top-to-bottom for the table.
3315    // In PDF coordinates, higher y = higher on page, so sort rows descending by y. ~keep
3316    let mut row_order: Vec<usize> = (0..num_rows).collect();
3317    row_order.sort_by(|&a, &b| crate::utils::safe_float_cmp(ys[b], ys[a]));
3318
3319    let mut table_rows = Vec::new();
3320    // Track span indices per cell alongside table_rows for text-based row splitting. ~keep
3321    let mut row_cell_span_indices: Vec<Vec<Vec<usize>>> = Vec::new();
3322    for &ri in &row_order {
3323        let mut row = TableRow::new(false);
3324        let mut cell_indices_for_row: Vec<Vec<usize>> = Vec::new();
3325        let groups = band_column_groups(xs, ys[ri], ys[ri + 1], num_cols, v_edges, cells_are_intersections);
3326        for (first_col, last_col) in groups {
3327            let colspan = (last_col - first_col + 1) as u32;
3328            let group_bbox = crate::geometry::Rect::new(
3329                xs[first_col],
3330                ys[ri],
3331                xs[last_col + 1] - xs[first_col],
3332                ys[ri + 1] - ys[ri],
3333            );
3334            let group_has_cell = (first_col..=last_col).any(|ci| grid_has_cell[ri][ci]);
3335            if !group_has_cell {
3336                // Still emit empty cell so the group is accounted for. ~keep
3337                row.cells.push(TableCell {
3338                    text: String::new(),
3339                    spans: Vec::new(),
3340                    colspan,
3341                    rowspan: 1,
3342                    mcids: Vec::new(),
3343                    bbox: Some(group_bbox),
3344                    is_header: false,
3345                });
3346                cell_indices_for_row.push(Vec::new());
3347                continue;
3348            }
3349
3350            // Concatenate columns left-to-right so spans on one visual line stay in reading
3351            // order: `extract_cell_text` only re-sorts by Y, and each column's own span list
3352            // is already confined to that column's X range. ~keep
3353            let group_span_indices: Vec<usize> = (first_col..=last_col)
3354                .flat_map(|ci| grid_spans[ri][ci].iter().copied())
3355                .collect();
3356            let cell_text = extract_cell_text(&group_span_indices, spans);
3357            let mcids: Vec<u32> = group_span_indices
3358                .iter()
3359                .filter_map(|&idx| spans.get(idx).and_then(|s| s.mcid))
3360                .collect();
3361            let cell_spans = group_span_indices
3362                .iter()
3363                .filter_map(|&idx| spans.get(idx).cloned())
3364                .collect::<Vec<_>>();
3365
3366            row.cells.push(TableCell {
3367                text: cell_text,
3368                spans: cell_spans,
3369                colspan,
3370                rowspan: 1,
3371                mcids,
3372                bbox: Some(group_bbox),
3373                is_header: false,
3374            });
3375            cell_indices_for_row.push(group_span_indices);
3376        }
3377        table_rows.push(row);
3378        row_cell_span_indices.push(cell_indices_for_row);
3379    }
3380
3381    Some((table_rows, row_cell_span_indices))
3382}
3383
3384/// Return the grid interval containing `point`.
3385///
3386/// Internal boundaries are half-open and belong to the interval on their
3387/// right. Snap tolerance is reserved for points just outside the grid, where
3388/// there is no neighbouring interval to compete for ownership.
3389fn grid_interval_for_point(point: f32, boundaries: &[f32]) -> Option<usize> {
3390    let interval_count = boundaries.len().checked_sub(1)?;
3391    if interval_count == 0 || !point.is_finite() {
3392        return None;
3393    }
3394
3395    if point < boundaries[0] {
3396        return (boundaries[0] - point <= SNAP_TOL).then_some(0);
3397    }
3398    if point > boundaries[interval_count] {
3399        return (point - boundaries[interval_count] <= SNAP_TOL).then_some(interval_count - 1);
3400    }
3401
3402    (0..interval_count).find(|&index| {
3403        point >= boundaries[index]
3404            && (point < boundaries[index + 1] || (index + 1 == interval_count && point <= boundaries[index + 1]))
3405    })
3406}
3407
3408/// GH#1358 feature gate: re-orient a fully-built ruled table from the page's
3409/// upright row/column axes to the table's own rotated frame when its spans
3410/// confidently agree on a 90/180/270 rotation. Flip to `false` to neutralise
3411/// the fix for an A/B comparison — with this `false`,
3412/// `detect_tables_from_intersections` skips `reorient_table_to_rotated_frame`
3413/// entirely, byte-for-byte identical to before this change. ~keep
3414const BUCKET_TABLE_GRID_IN_ROTATED_FRAME: bool = true;
3415
3416/// The dominant rotation quadrant (0/90/180/270) of a table's constituent
3417/// spans, decided by *strict majority* vote over their own
3418/// [`TextSpan::rotation_degrees`].
3419///
3420/// This mirrors `table_extractor::cell_rotation_degrees`'s treatment of a
3421/// skewed (non-quadrant) span — it is counted as upright (quadrant 0) rather
3422/// than snapped to its nearest quadrant, since a skewed span's own frame is
3423/// ambiguous and upright is the pre-existing, safe default for it. It is
3424/// intentionally *stricter* than that cell-local helper on the tie-break,
3425/// though: `cell_rotation_degrees` always picks a plurality winner because a
3426/// wrong guess there only affects reading order inside one already-placed
3427/// cell. Getting a whole table's row/column axes wrong is much higher blast
3428/// radius, so this requires the winning quadrant to hold a strict majority
3429/// (> 50%) of classified spans. Anything short of that — a genuine tie, a
3430/// table whose spans disagree on rotation, or a table with no classifiable
3431/// spans at all (e.g. a purely decorative ruled grid with no text) — returns
3432/// `0.0`, and the caller leaves the table bucketed upright exactly as before
3433/// this fix, rather than guessing. ~keep
3434fn dominant_table_rotation_quadrant(table: &Table) -> f32 {
3435    let mut counts: [usize; 4] = [0; 4];
3436    for row in &table.rows {
3437        for cell in &row.cells {
3438            for span in &cell.spans {
3439                let normalized = span.rotation_degrees.rem_euclid(360.0);
3440                let quadrant = ((normalized / 90.0).round() as usize) % 4;
3441                if (normalized - (quadrant as f32) * 90.0).abs() < 0.5 {
3442                    counts[quadrant] += 1;
3443                } else {
3444                    counts[0] += 1;
3445                }
3446            }
3447        }
3448    }
3449    let total: usize = counts.iter().sum();
3450    if total == 0 {
3451        return 0.0;
3452    }
3453    let Some((index, &count)) = counts
3454        .iter()
3455        .enumerate()
3456        .max_by_key(|&(index, &count)| (count, std::cmp::Reverse(index)))
3457    else {
3458        return 0.0;
3459    };
3460    if count * 2 > total { (index as f32) * 90.0 } else { 0.0 }
3461}
3462
3463/// Re-orient one finished table from the page's upright row/column axes to
3464/// the table's own rotated frame, when `dominant_table_rotation_quadrant`
3465/// finds a confident non-zero rotation.
3466///
3467/// The bucketing that already ran (`assign_spans_to_intersection_grid`, plus
3468/// `finalize_intersection_tables`'s row splitting and this function's own
3469/// upstream merge/section-divider steps) places every span by its *physical*
3470/// page position, which is correct regardless of rotation — a point's grid
3471/// cell doesn't depend on what's drawn there. What is wrong for a genuinely
3472/// rotated table is the *axis labelling*: `table.rows` is always ordered
3473/// top-to-bottom by physical Y with each row's cells left-to-right by
3474/// physical X, which is only the table's own reading order when the table is
3475/// upright. Applying the fix here, on the finished `Table`, rather than
3476/// earlier in the pipeline keeps every physical/page-space step upstream
3477/// (row splitting by text-band Y-clustering, empty-row sub-table splitting,
3478/// vertical-adjacency merging, section-divider splitting) working in the
3479/// page's own frame, where those heuristics' assumptions hold; only the
3480/// caller-visible row/column order of the final table changes.
3481///
3482/// `TextSpan::rotation_degrees` is the angle of the composed text-rendering
3483/// matrix: the baseline advances along `(cos θ, sin θ)` and the next-row
3484/// direction (perpendicular, "up" reversed) advances along
3485/// `(sin θ, -cos θ)`. Working through those vectors for each quadrant gives
3486/// the physical direction of the table's own row-advance and column-advance
3487/// axes, which is exactly the standard array-rotation transform applied to
3488/// the `table.rows` grid:
3489/// - 90°: rotate 90° clockwise — new row = old column (ascending), new
3490///   column = old row (reversed).
3491/// - 180°: reverse both row and column order; shape unchanged.
3492/// - 270°: rotate 90° counter-clockwise — new row = old column (reversed),
3493///   new column = old row (ascending).
3494///
3495/// Cell content (text, spans, bbox, mcids) moves as-is — only its (row, col)
3496/// slot in the grid changes; a cell's `bbox` remains the true physical
3497/// location of its content on the page, and `table.bbox` (a physical union
3498/// over all cells) is untouched. `has_header` is passed through unexamined:
3499/// this pipeline never sets it (see `assign_spans_to_intersection_grid`),
3500/// so there is nothing here to re-derive for a header *row* that no longer
3501/// exists after a transpose.
3502fn reorient_table_to_rotated_frame(table: Table) -> Table {
3503    // Only called when `BUCKET_TABLE_GRID_IN_ROTATED_FRAME` is `true` (see
3504    // `detect_tables_from_intersections`); the gate lives at that call site
3505    // so the `false` path never allocates or iterates a `Table` here at all. ~keep
3506    let rotation = dominant_table_rotation_quadrant(&table);
3507    if rotation == 0.0 {
3508        return table;
3509    }
3510    let Some((rows, col_count)) = rotate_table_rows(&table.rows, rotation) else {
3511        // Not a rectangular grid (e.g. an upstream merge padded some rows but
3512        // not others) — reorienting could not be done without guessing at a
3513        // cell's column, so leave the table bucketed upright rather than
3514        // risk indexing past a short row. ~keep
3515        return table;
3516    };
3517    Table {
3518        rows,
3519        has_header: table.has_header,
3520        col_count,
3521        bbox: table.bbox,
3522    }
3523}
3524
3525/// Rotate a rectangular table grid by `rotation_degrees` (90/180/270; `0.0`
3526/// is a defensive no-op). Returns `None` when `rows` isn't rectangular (some
3527/// row's cell count differs from the first row's), since the rotation index
3528/// math below assumes every row has exactly `rows[0].cells.len()` cells.
3529fn rotate_table_rows(rows: &[TableRow], rotation_degrees: f32) -> Option<(Vec<TableRow>, usize)> {
3530    let num_orig_rows = rows.len();
3531    let num_orig_cols = rows.first().map_or(0, |row| row.cells.len());
3532    if rotation_degrees == 0.0 || num_orig_rows == 0 || num_orig_cols == 0 {
3533        return Some((rows.to_vec(), num_orig_cols));
3534    }
3535    if rows.iter().any(|row| row.cells.len() != num_orig_cols) {
3536        return None;
3537    }
3538
3539    // `rotation_degrees` is always an exact multiple of 90.0 produced by
3540    // `dominant_table_rotation_quadrant` (`(index as f32) * 90.0`), so float
3541    // equality against the 90.0 / 270.0 literals below is exact, not
3542    // approximate. ~keep
3543    let transpose = rotation_degrees == 90.0 || rotation_degrees == 270.0;
3544    let (new_num_rows, new_num_cols) = if transpose {
3545        (num_orig_cols, num_orig_rows)
3546    } else {
3547        (num_orig_rows, num_orig_cols)
3548    };
3549
3550    let mut new_rows = Vec::with_capacity(new_num_rows);
3551    for r_new in 0..new_num_rows {
3552        let mut cells = Vec::with_capacity(new_num_cols);
3553        for c_new in 0..new_num_cols {
3554            let (r_old, c_old) = if !transpose {
3555                // 180: both axes reverse, so the grid keeps its shape. ~keep
3556                (num_orig_rows - 1 - r_new, num_orig_cols - 1 - c_new)
3557            } else if rotation_degrees == 90.0 {
3558                (num_orig_rows - 1 - c_new, r_new)
3559            } else {
3560                // 270: transposes the opposite way round from 90. ~keep
3561                (c_new, num_orig_cols - 1 - r_new)
3562            };
3563            cells.push(rows[r_old].cells[c_old].clone());
3564        }
3565        new_rows.push(TableRow {
3566            cells,
3567            is_header: false,
3568        });
3569    }
3570
3571    Some((new_rows, new_num_cols))
3572}
3573
3574/// Row splitting, form-artifact stripping, empty-row splitting, and bbox
3575/// computation. Produces the final `Table` entries for one table group.
3576fn finalize_intersection_tables(
3577    table_rows: Vec<TableRow>,
3578    row_cell_span_indices: &[Vec<Vec<usize>>],
3579    spans: &[TextSpan],
3580    config: &TableDetectionConfig,
3581    num_cols: usize,
3582) -> Vec<Table> {
3583    let mut table_rows = split_rows_by_text_positions(table_rows, row_cell_span_indices, spans, config);
3584
3585    strip_form_numbering_artifacts(&mut table_rows);
3586
3587    // Split on completely empty rows (same strategy as cluster-based approach). ~keep
3588    let mut tables = Vec::new();
3589    let mut sub_start = 0;
3590    while sub_start < table_rows.len() {
3591        let row_is_empty = |r: &TableRow| r.cells.iter().all(|c| c.text.is_empty());
3592        if row_is_empty(&table_rows[sub_start]) {
3593            sub_start += 1;
3594            continue;
3595        }
3596        let mut sub_end = sub_start + 1;
3597        while sub_end < table_rows.len() && !row_is_empty(&table_rows[sub_end]) {
3598            sub_end += 1;
3599        }
3600        let sub_rows: Vec<TableRow> = table_rows[sub_start..sub_end].to_vec();
3601        let filled: usize = sub_rows
3602            .iter()
3603            .flat_map(|r| r.cells.iter())
3604            .filter(|c| !c.text.is_empty())
3605            .count();
3606        if filled >= config.min_table_cells {
3607            let mut min_x = f32::INFINITY;
3608            let mut min_y = f32::INFINITY;
3609            let mut max_x = f32::NEG_INFINITY;
3610            let mut max_y = f32::NEG_INFINITY;
3611            for r in &sub_rows {
3612                for c in &r.cells {
3613                    if let Some(b) = c.bbox {
3614                        min_x = min_x.min(b.left());
3615                        min_y = min_y.min(b.top());
3616                        max_x = max_x.max(b.right());
3617                        max_y = max_y.max(b.bottom());
3618                    }
3619                }
3620            }
3621            let sub_bbox = if min_x.is_finite() {
3622                Some(crate::geometry::Rect::new(min_x, min_y, max_x - min_x, max_y - min_y))
3623            } else {
3624                None
3625            };
3626            tables.push(Table {
3627                rows: sub_rows,
3628                has_header: false,
3629                col_count: num_cols,
3630                bbox: sub_bbox,
3631            });
3632        }
3633        sub_start = sub_end;
3634    }
3635    tables
3636}
3637
3638/// Minimum fraction of the table width that an H-edge must span to qualify
3639/// as a section divider.
3640const SECTION_DIVIDER_WIDTH_RATIO: f32 = 0.80;
3641
3642/// Split each table at interior horizontal edges that span nearly the full
3643/// table width ("section dividers").  Returns a new list of tables where each
3644/// original table may have been broken into multiple smaller ones.
3645fn split_tables_at_section_dividers(
3646    tables: Vec<Table>,
3647    h_edges: &[Edge],
3648    v_edges: &[Edge],
3649    config: &TableDetectionConfig,
3650) -> Vec<Table> {
3651    let mut result = Vec::new();
3652    for table in tables {
3653        let parts = split_table_at_section_dividers(table, h_edges, v_edges, config);
3654        result.extend(parts);
3655    }
3656    result
3657}
3658
3659/// Split a single table at section divider lines.
3660///
3661/// A section divider is a full-width H-edge at a Y position where few or no
3662/// V-edges cross through — indicating that the vertical lines stop at that
3663/// boundary (separate bordered sections stacked vertically).
3664fn split_table_at_section_dividers(
3665    table: Table,
3666    h_edges: &[Edge],
3667    v_edges: &[Edge],
3668    config: &TableDetectionConfig,
3669) -> Vec<Table> {
3670    let Some(bbox) = table.bbox else {
3671        return vec![table];
3672    };
3673    if table.rows.len() < 2 {
3674        return vec![table];
3675    }
3676
3677    let table_width = bbox.right() - bbox.left();
3678    if table_width <= 0.0 {
3679        return vec![table];
3680    }
3681
3682    // Collect Y-coordinates of H-edges that qualify as section dividers:
3683    // - span >= SECTION_DIVIDER_WIDTH_RATIO of the table width
3684    // - fall within the table's vertical range (not at the very top or bottom)
3685    // - few V-edges cross through that Y (sections have separate vertical grids) ~keep
3686    let top = bbox.top();
3687    let bottom = bbox.bottom();
3688    let margin = 2.0; // pts – ignore edges at the very top/bottom boundary ~keep
3689
3690    let table_left = bbox.left();
3691    let table_right = bbox.right();
3692    let relevant_v_edges: Vec<&Edge> = v_edges
3693        .iter()
3694        .filter(|e| e.coord >= table_left - SNAP_TOL && e.coord <= table_right + SNAP_TOL)
3695        .collect();
3696
3697    let mut divider_ys: Vec<f32> = Vec::new();
3698    let mut internal_rule_ys: Vec<f32> = Vec::new();
3699    for edge in h_edges {
3700        let overlap_start = edge.start.max(table_left);
3701        let overlap_end = edge.end.min(table_right);
3702        let overlap = overlap_end - overlap_start;
3703        if overlap < table_width * SECTION_DIVIDER_WIDTH_RATIO {
3704            continue;
3705        }
3706        let y = edge.coord;
3707        if y <= top + margin || y >= bottom - margin {
3708            continue;
3709        }
3710        internal_rule_ys.push(y);
3711        let cross_margin = SNAP_TOL + 1.0;
3712        let crossings = relevant_v_edges
3713            .iter()
3714            .filter(|v| v.start < y - cross_margin && v.end > y + cross_margin)
3715            .count();
3716        // A true section divider has no (or very few) V-edges crossing through.
3717        // Regular grid row boundaries have many V-edges crossing. ~keep
3718        if crossings <= 1 {
3719            divider_ys.push(y);
3720        }
3721    }
3722
3723    if divider_ys.is_empty() {
3724        return vec![table];
3725    }
3726
3727    divider_ys.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
3728    divider_ys.dedup_by(|a, b| (*a - *b).abs() <= SNAP_TOL);
3729    internal_rule_ys.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
3730    internal_rule_ys.dedup_by(|a, b| (*a - *b).abs() <= SNAP_TOL);
3731
3732    // A V rule stopping at an H rule is section evidence only when the other rules on the
3733    // table behave differently. Word draws cell borders as one bar per cell with a corner
3734    // square at each crossing, so every V rule terminates at every H rule and every internal
3735    // rule qualifies above; splitting there turns a ruled table into one-row fragments that
3736    // fall under `min_table_cells` and vanish (GH#1656). With a single internal rule there is
3737    // nothing to compare against, so that case keeps the split. ~keep
3738    if internal_rule_ys.len() >= 2 && divider_ys.len() == internal_rule_ys.len() {
3739        return vec![table];
3740    }
3741
3742    let row_bounds: Vec<Option<(f32, f32)>> = table
3743        .rows
3744        .iter()
3745        .map(|row| {
3746            let mut rmin = f32::INFINITY;
3747            let mut rmax = f32::NEG_INFINITY;
3748            for c in &row.cells {
3749                if let Some(b) = c.bbox {
3750                    rmin = rmin.min(b.top());
3751                    rmax = rmax.max(b.bottom());
3752                }
3753            }
3754            if rmin.is_finite() { Some((rmin, rmax)) } else { None }
3755        })
3756        .collect();
3757
3758    let mut split_after: Vec<usize> = Vec::new();
3759    let tol = SNAP_TOL + 2.0; // generous tolerance for matching divider to row boundary ~keep
3760    for &dy in &divider_ys {
3761        let mut best_idx: Option<usize> = None;
3762        let mut best_dist = f32::INFINITY;
3763        for (i, bounds) in row_bounds.iter().enumerate() {
3764            if i >= table.rows.len().saturating_sub(1) {
3765                continue;
3766            }
3767            let Some((row_top, row_bot)) = bounds else {
3768                continue;
3769            };
3770            let dist_to_bot = (dy - row_bot).abs();
3771            let dist_to_top = (dy - row_top).abs();
3772            let min_dist = dist_to_bot.min(dist_to_top);
3773            if min_dist <= tol && min_dist < best_dist {
3774                if dist_to_bot <= dist_to_top {
3775                    best_idx = Some(i);
3776                } else if i > 0 {
3777                    best_idx = Some(i - 1);
3778                }
3779                best_dist = min_dist;
3780            }
3781        }
3782        if let Some(idx) = best_idx {
3783            split_after.push(idx);
3784        }
3785    }
3786    split_after.sort_unstable();
3787    split_after.dedup();
3788
3789    if split_after.is_empty() {
3790        return vec![table];
3791    }
3792
3793    let num_cols = table.col_count;
3794    let all_rows = table.rows;
3795    let mut sub_tables = Vec::new();
3796    let mut start = 0;
3797    for &split_idx in &split_after {
3798        let end = split_idx + 1;
3799        if end > start {
3800            sub_tables.push(&all_rows[start..end]);
3801        }
3802        start = end;
3803    }
3804    if start < all_rows.len() {
3805        sub_tables.push(&all_rows[start..]);
3806    }
3807
3808    let mut result = Vec::new();
3809    for sub_rows_slice in sub_tables {
3810        let sub_rows: Vec<TableRow> = sub_rows_slice.to_vec();
3811        let filled: usize = sub_rows
3812            .iter()
3813            .flat_map(|r| r.cells.iter())
3814            .filter(|c| !c.text.is_empty())
3815            .count();
3816        if filled < config.min_table_cells {
3817            continue;
3818        }
3819        let mut min_x = f32::INFINITY;
3820        let mut min_y = f32::INFINITY;
3821        let mut max_x = f32::NEG_INFINITY;
3822        let mut max_y = f32::NEG_INFINITY;
3823        for r in &sub_rows {
3824            for c in &r.cells {
3825                if let Some(b) = c.bbox {
3826                    min_x = min_x.min(b.left());
3827                    min_y = min_y.min(b.top());
3828                    max_x = max_x.max(b.right());
3829                    max_y = max_y.max(b.bottom());
3830                }
3831            }
3832        }
3833        let sub_bbox = if min_x.is_finite() {
3834            Some(crate::geometry::Rect::new(min_x, min_y, max_x - min_x, max_y - min_y))
3835        } else {
3836            None
3837        };
3838        result.push(Table {
3839            rows: sub_rows,
3840            has_header: false,
3841            col_count: num_cols,
3842            bbox: sub_bbox,
3843        });
3844    }
3845
3846    if result.is_empty() {
3847        // Don't lose data; return original if all sub-tables were too small. ~keep
3848        return vec![Table {
3849            rows: all_rows,
3850            has_header: false,
3851            col_count: num_cols,
3852            bbox: Some(bbox),
3853        }];
3854    }
3855
3856    result
3857}
3858
3859/// Maximum vertical gap (in points) between two table bboxes to consider them
3860/// adjacent and merge them into a single table.
3861const ADJACENT_TABLE_MERGE_GAP: f32 = 20.0;
3862
3863/// Maximum allowed column count difference for merging vertically adjacent tables.
3864/// Tables whose column counts differ by more than this are not merged.
3865const MERGE_COL_DIFF_TOLERANCE: usize = 2;
3866
3867/// Merge tables that are vertically adjacent (small gap between bottom of one
3868/// and top of another) and have similar column counts (difference <= `MERGE_COL_DIFF_TOLERANCE`).
3869/// When column counts differ, the narrower table's rows are padded with empty cells.
3870fn merge_vertically_adjacent_tables(tables: &mut Vec<Table>) {
3871    if tables.len() < 2 {
3872        return;
3873    }
3874
3875    // Sort tables by the top-Y of their bbox (highest Y first in PDF coords). ~keep
3876    tables.sort_by(|a, b| {
3877        let ay = a.bbox.map_or(f32::NEG_INFINITY, |bb| bb.top());
3878        let by = b.bbox.map_or(f32::NEG_INFINITY, |bb| bb.top());
3879        crate::utils::safe_float_cmp(ay, by)
3880    });
3881
3882    let mut merged: Vec<Table> = Vec::new();
3883    for table in tables.drain(..) {
3884        let should_merge = merged.last().is_some_and(|prev: &Table| {
3885            let col_diff = (prev.col_count as isize - table.col_count as isize).unsigned_abs();
3886            if col_diff > MERGE_COL_DIFF_TOLERANCE {
3887                return false;
3888            }
3889            match (prev.bbox, table.bbox) {
3890                (Some(pb), Some(tb)) => {
3891                    let gap = (tb.top() - pb.bottom()).abs().min((pb.top() - tb.bottom()).abs());
3892                    gap <= ADJACENT_TABLE_MERGE_GAP
3893                }
3894                _ => false,
3895            }
3896        });
3897
3898        if should_merge {
3899            let prev = merged.last_mut().unwrap();
3900            let target_cols = prev.col_count.max(table.col_count);
3901
3902            if prev.col_count < target_cols {
3903                let pad = target_cols - prev.col_count;
3904                for row in &mut prev.rows {
3905                    for _ in 0..pad {
3906                        row.cells.push(TableCell {
3907                            text: String::new(),
3908                            spans: Vec::new(),
3909                            colspan: 1,
3910                            rowspan: 1,
3911                            mcids: Vec::new(),
3912                            bbox: None,
3913                            is_header: row.is_header,
3914                        });
3915                    }
3916                }
3917            }
3918
3919            let mut incoming_rows = table.rows;
3920            if table.col_count < target_cols {
3921                let pad = target_cols - table.col_count;
3922                for row in &mut incoming_rows {
3923                    for _ in 0..pad {
3924                        row.cells.push(TableCell {
3925                            text: String::new(),
3926                            spans: Vec::new(),
3927                            colspan: 1,
3928                            rowspan: 1,
3929                            mcids: Vec::new(),
3930                            bbox: None,
3931                            is_header: row.is_header,
3932                        });
3933                    }
3934                }
3935            }
3936
3937            prev.rows.extend(incoming_rows);
3938            prev.col_count = target_cols;
3939            if let (Some(pb), Some(tb)) = (prev.bbox, table.bbox) {
3940                let min_x = pb.left().min(tb.left());
3941                let min_y = pb.top().min(tb.top());
3942                let max_x = pb.right().max(tb.right());
3943                let max_y = pb.bottom().max(tb.bottom());
3944                prev.bbox = Some(crate::geometry::Rect::new(min_x, min_y, max_x - min_x, max_y - min_y));
3945            }
3946            prev.has_header = prev.has_header || table.has_header;
3947        } else {
3948            merged.push(table);
3949        }
3950    }
3951
3952    *tables = merged;
3953}
3954
3955/// True when the page carries vertical-ruling evidence that should route
3956/// table detection through the grid pipelines instead of the
3957/// horizontal-rule-bounded fallback.
3958///
3959/// A vertical line counts as RULING evidence only when its drawn bar
3960/// crosses at least TWO of the horizontal rules: a ruling vertical bounds
3961/// cells BETWEEN row rules, so it spans from one rule to another (a
3962/// stroke-width-encoded column bar crosses every row rule of its table).
3963/// Anything that crosses fewer says nothing about how the page's tables
3964/// are ruled: an isolated heavy-stroked speck (tick mark, list dash)
3965/// crosses nothing, and the short dash segments of a decorative dashed
3966/// BOX border cross at most the one rule their box happens to overlap.
3967/// Both were disabling the horizontal-rule fallback page-wide, which is
3968/// precisely what scattered booktabs tables on pages carrying a
3969/// dash-bordered affiliation box. Rectangles (decomposed into edges) keep
3970/// their pre-existing veto.
3971fn has_vertical_ruling_evidence(lines: &[crate::elements::PathContent], h_edges: &[Edge]) -> bool {
3972    const LINE_AXIS_TOL: f32 = 2.0;
3973    lines.iter().any(|path| {
3974        if path.is_horizontal_line(LINE_AXIS_TOL) {
3975            return false;
3976        }
3977        if path.is_vertical_line(LINE_AXIS_TOL) {
3978            let r = path.rendered_bbox();
3979            // Count DISTINCT rule levels crossed, not raw edges: one dashed
3980            // rule is several collinear edges at the same y, and a border
3981            // "joint" speck sitting on it would otherwise read as crossing
3982            // two rules while touching only one. ~keep
3983            let mut crossed_ys: Vec<f32> = h_edges
3984                .iter()
3985                .filter(|h| r.y <= h.coord && (r.y + r.height) >= h.coord && (r.x + r.width) >= h.start && r.x <= h.end)
3986                .map(|h| h.coord)
3987                .collect();
3988            crossed_ys.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
3989            crossed_ys.dedup_by(|a, b| (*a - *b).abs() <= LINE_AXIS_TOL);
3990            return crossed_ys.len() >= 2;
3991        }
3992        path.is_rectangle()
3993    })
3994}
3995
3996/// Group wide H-edges into x-range-coherent FAMILIES, in emission order,
3997/// so only rules that could bound the same table ever pair up. This fixes
3998/// two failure shapes in one move: a page of scattered fraction bars never
3999/// forms a family (no fake table from unrelated equation rules), and a
4000/// decorative dashed border whose y-position interleaves a real table's
4001/// rules lands in its own family instead of splitting the table's rules
4002/// apart in the adjacent-pair walk (which silently dropped the table on
4003/// real dash-boxed pages).
4004fn x_coherent_rule_families<'a>(wide: &[&'a Edge]) -> Vec<Vec<&'a Edge>> {
4005    // A table's boundary rules line up: booktabs top/sub-header/bottom
4006    // rules share their x-range to within a point (overlap/union ≈ 1.0),
4007    // while unrelated wide strokes — displayed-equation fraction bars,
4008    // decorative borders — share at most a common left margin
4009    // (overlap/union ≲ 0.8 even when x-starts coincide, since widths
4010    // differ). 0.85 splits the two populations with margin on both sides. ~keep
4011    const X_COHERENCE: f32 = 0.85;
4012
4013    let mut uf = UnionFind::new(wide.len());
4014    for i in 0..wide.len() {
4015        for j in (i + 1)..wide.len() {
4016            let (a, b) = (wide[i], wide[j]);
4017            let overlap = a.end.min(b.end) - a.start.max(b.start);
4018            let union = a.end.max(b.end) - a.start.min(b.start);
4019            if union > 0.0 && overlap / union >= X_COHERENCE {
4020                uf.union(i, j);
4021            }
4022        }
4023    }
4024    // Keyed by union-find root in a BTreeMap, not a HashMap: HashMap iteration
4025    // order is randomized per process, and the sort below cannot recover from
4026    // that on its own. Families are grouped by X-RANGE COHERENCE, so `coord`
4027    // (the Y of a horizontal rule) is not unique across families — two rule
4028    // families side by side, or a decorative border sharing a table's top-rule
4029    // Y, tie on `a[0].coord`. `sort_by` is stable, so a tie leaves the input
4030    // order intact, and with a HashMap that input order is per-process random:
4031    // the two families then emit their tables in a different order each run,
4032    // which reorders the rendered table blocks in the assembled page text.
4033    // The BTreeMap makes the collected order deterministic; the root tiebreak
4034    // makes the emitted order a total order over the families, so it depends
4035    // only on page geometry and edge input order. Each family's `Vec` is pushed
4036    // in ascending `wide` index order, so `a[0]` is still the family's first
4037    // rule and non-tied pages sort exactly as before (byte-identical). ~keep
4038    let mut families: std::collections::BTreeMap<usize, Vec<&'a Edge>> = std::collections::BTreeMap::new();
4039    for (i, e) in wide.iter().enumerate() {
4040        families.entry(uf.find(i)).or_default().push(e);
4041    }
4042    let mut families: Vec<(usize, Vec<&'a Edge>)> = families.into_iter().collect();
4043    families.sort_by(|(root_a, a), (root_b, b)| {
4044        crate::utils::safe_float_cmp(a[0].coord, b[0].coord).then_with(|| root_a.cmp(root_b))
4045    });
4046    families.into_iter().map(|(_root, f)| f).collect()
4047}
4048
4049/// Spans of one rule-bounded band that pass the containment and
4050/// letter-spacing guards, or `None` when the band cannot hold a table.
4051fn rule_band_spans(
4052    spans: &[TextSpan],
4053    y_top: f32,
4054    y_bot: f32,
4055    x_overlap_start: f32,
4056    x_overlap_end: f32,
4057) -> Option<Vec<TextSpan>> {
4058    let pad = 2.0;
4059    let mut region_spans: Vec<TextSpan> = Vec::new();
4060    let mut outside_width = 0.0f32;
4061    let mut inside_width = 0.0f32;
4062    for s in spans {
4063        let cy = s.bbox.center().y;
4064        if cy > y_top + pad || cy < y_bot - pad {
4065            continue;
4066        }
4067        let cx = s.bbox.center().x;
4068        if cx >= x_overlap_start - pad && cx <= x_overlap_end + pad {
4069            inside_width += s.bbox.width.max(0.0);
4070            region_spans.push(s.clone());
4071        } else {
4072            outside_width += s.bbox.width.max(0.0);
4073        }
4074    }
4075
4076    if region_spans.is_empty() {
4077        return None;
4078    }
4079
4080    // A pair of rules bounds a table only if the band's text is
4081    // horizontally CONTAINED by the rules: a table's boundary
4082    // rules span the rows they rule, while a fraction bar floats
4083    // inside surrounding math that continues to its left and
4084    // right (relation symbols, equation numbers). X-range-coherent
4085    // vinculums from an aligned multi-step derivation pass the
4086    // family check above, but the text spilling past the bars
4087    // gives them away — when a third of the band's text mass lies
4088    // outside the rules, they don't bound anything. (Division-free
4089    // so a band of zero-width spans compares 0 > 0 instead of
4090    // taking a NaN branch.) ~keep
4091    if outside_width > (outside_width + inside_width) * 0.3 {
4092        return None;
4093    }
4094
4095    // Letter-spaced monospace guard: framed code and console
4096    // listings (zines, technical reports) draw each glyph on a
4097    // terminal-font grid, so the band's "words" are mostly single
4098    // characters whose aligned x positions look exactly like
4099    // column boundaries — identifiers shatter into single letters
4100    // (`s e g f a u l t`), addresses into single digits
4101    // (`0 0 : 1 4`). A real table's cells are words and numbers:
4102    // one-third single LETTERS or one-half single characters of
4103    // any kind is spread-out text, not a grid. (The digit
4104    // threshold is the looser of the two so genuine single-digit
4105    // table columns, which sit among multi-char label cells, stay
4106    // under it.) ~keep
4107    let word_count = region_spans.len();
4108    let mut single_any = 0usize;
4109    let mut single_alpha = 0usize;
4110    for rs in &region_spans {
4111        let mut chars = rs.text.trim().chars();
4112        if let (Some(c), None) = (chars.next(), chars.next()) {
4113            single_any += 1;
4114            if c.is_alphabetic() {
4115                single_alpha += 1;
4116            }
4117        }
4118    }
4119    if word_count > 0 && (single_alpha * 3 >= word_count || single_any * 2 >= word_count) {
4120        return None;
4121    }
4122
4123    Some(region_spans)
4124}
4125
4126fn detect_rule_run(spans: &[TextSpan], run: Option<(f32, f32, f32, f32)>, config: &TableDetectionConfig) -> Vec<Table> {
4127    run.and_then(|(top, bot, x0, x1)| rule_band_spans(spans, top, bot, x0, x1))
4128        .map(|region| detect_tables_from_spans(&region, config))
4129        .unwrap_or_default()
4130}
4131
4132fn is_single_text_line(spans: &[TextSpan]) -> bool {
4133    let line_h = spans.iter().map(|s| s.bbox.height).fold(0.0f32, f32::max);
4134    let (lo, hi) = spans.iter().fold((f32::MAX, f32::MIN), |(lo, hi), s| {
4135        let cy = s.bbox.center().y;
4136        (lo.min(cy), hi.max(cy))
4137    });
4138    hi - lo <= line_h * 0.5
4139}
4140
4141/// Detect tables in regions bounded by horizontal rules (H-lines) when no vertical
4142/// lines are present.  Groups H-edges by Y-position to find horizontal table
4143/// boundaries, then runs text-edge detection on the spans within each bounded
4144/// region.  This is the "H-lines define regions, text defines columns" hybrid.
4145fn detect_tables_from_horizontal_rules(
4146    spans: &[TextSpan],
4147    h_edges: &[Edge],
4148    config: &TableDetectionConfig,
4149) -> Vec<Table> {
4150    const MIN_RULE_WIDTH: f32 = 100.0;
4151    const Y_SNAP: f32 = 4.0;
4152
4153    let wide: Vec<&Edge> = h_edges.iter().filter(|e| (e.end - e.start) >= MIN_RULE_WIDTH).collect();
4154    if wide.len() < 2 {
4155        return Vec::new();
4156    }
4157
4158    let families = x_coherent_rule_families(&wide);
4159
4160    let mut tables = Vec::new();
4161
4162    for family in &families {
4163        let mut y_coords: Vec<f32> = Vec::new();
4164        for e in family {
4165            let merged = y_coords.iter_mut().find(|y| (e.coord - **y).abs() <= Y_SNAP);
4166            if merged.is_none() {
4167                y_coords.push(e.coord);
4168            }
4169        }
4170        y_coords.sort_by(|a, b| crate::utils::safe_float_cmp(*b, *a));
4171        // ~keep
4172
4173        if y_coords.len() < 2 {
4174            continue;
4175        }
4176
4177        let x_range_for_y = |target_y: f32| -> (f32, f32) {
4178            let mut min_x = f32::MAX;
4179            let mut max_x = f32::MIN;
4180            for e in family {
4181                if (e.coord - target_y).abs() <= Y_SNAP {
4182                    if e.start < min_x {
4183                        min_x = e.start;
4184                    }
4185                    if e.end > max_x {
4186                        max_x = e.end;
4187                    }
4188                }
4189            }
4190            (min_x, max_x)
4191        };
4192
4193        // A band holding one text line is too short for a table on its own:
4194        // a booktabs header above the midrule, or a table ruled under every
4195        // row. Consecutive one-line bands join the band below them. ~keep
4196        let mut run: Option<(f32, f32, f32, f32)> = None;
4197        for pair in y_coords.windows(2) {
4198            let y_top = pair[0];
4199            let y_bot = pair[1];
4200            let (x1_start, x1_end) = x_range_for_y(y_top);
4201            let (x2_start, x2_end) = x_range_for_y(y_bot);
4202            let x_start = x1_start.max(x2_start);
4203            let x_end = x1_end.min(x2_end);
4204            if x_end - x_start < MIN_RULE_WIDTH {
4205                tables.append(&mut detect_rule_run(spans, run.take(), config));
4206                continue;
4207            }
4208            let Some(band) = rule_band_spans(spans, y_top, y_bot, x_start, x_end) else {
4209                tables.append(&mut detect_rule_run(spans, run.take(), config));
4210                continue;
4211            };
4212            let joined = match run.take() {
4213                Some((top, _, x0, x1)) => (top, y_bot, x0.min(x_start), x1.max(x_end)),
4214                None => (y_top, y_bot, x_start, x_end),
4215            };
4216            if is_single_text_line(&band) {
4217                run = Some(joined);
4218                continue;
4219            }
4220            let mut detected = detect_rule_run(spans, Some(joined), config);
4221            if detected.is_empty() && joined.0 != y_top {
4222                tables.append(&mut detect_rule_run(
4223                    spans,
4224                    Some((joined.0, y_top, joined.2, joined.3)),
4225                    config,
4226                ));
4227                detected = detect_tables_from_spans(&band, config);
4228            }
4229            tables.append(&mut detected);
4230        }
4231        tables.append(&mut detect_rule_run(spans, run, config));
4232    }
4233
4234    // Two families can bracket the same text — a dash-bordered decorative
4235    // box drawn around (or through) a ruled table gives both the box's
4236    // border family and the table's rule family a region over the same
4237    // rows, and each detects its own copy. Keep the TIGHTER detection when
4238    // two overlap: the looser region also swallows neighbouring lines
4239    // (footnotes, captions) as junk rows. ~keep
4240    let mut keep: Vec<bool> = vec![true; tables.len()];
4241    for i in 0..tables.len() {
4242        for j in (i + 1)..tables.len() {
4243            if !keep[i] || !keep[j] {
4244                continue;
4245            }
4246            let (Some(a), Some(b)) = (tables[i].bbox, tables[j].bbox) else {
4247                continue;
4248            };
4249            let ov_w = (a.x + a.width).min(b.x + b.width) - a.x.max(b.x);
4250            let ov_h = (a.y + a.height).min(b.y + b.height) - a.y.max(b.y);
4251            if ov_w <= 0.0 || ov_h <= 0.0 {
4252                continue;
4253            }
4254            let ov_area = ov_w * ov_h;
4255            let min_area = (a.width * a.height).min(b.width * b.height);
4256            if min_area > 0.0 && ov_area / min_area > 0.5 {
4257                if a.width * a.height >= b.width * b.height {
4258                    keep[i] = false;
4259                } else {
4260                    keep[j] = false;
4261                }
4262            }
4263        }
4264    }
4265    let mut it = keep.iter();
4266    tables.retain(|_| *it.next().unwrap_or(&true));
4267
4268    tables
4269}
4270
4271/// H-rule bounded detection: horizontal lines bound the table regions and
4272/// text edges define the columns. Pages with vertical ruling are left to the
4273/// grid detectors.
4274fn tables_from_horizontal_rules(
4275    spans: &[TextSpan],
4276    lines: &[crate::elements::PathContent],
4277    config: &TableDetectionConfig,
4278) -> Vec<Table> {
4279    let (mut h_edges, _) = extract_edges(lines);
4280    if h_edges.is_empty() || has_vertical_ruling_evidence(lines, &h_edges) {
4281        return Vec::new();
4282    }
4283    snap_and_merge(&mut h_edges);
4284    let mut tables = detect_tables_from_horizontal_rules(spans, &h_edges, config);
4285    // A logical table ruled between row *bands* (a rule under the
4286    // header, or between groups of rows) is emitted here as one
4287    // fragment per band. Merge vertically-adjacent same-column
4288    // fragments BEFORE the min-row filter so a table cut into e.g. a
4289    // [3, 2] pair rejoins into [5] instead of losing its short band to
4290    // the guard below. Bands sit ~one inter-row pitch apart (the rule
4291    // stroke + leading), so scale the vertical tolerance to the
4292    // fragments' median row height rather than the abutting-fragment
4293    // default. Safety rests on the unchanged column gating in
4294    // `can_merge_tables` (equal col_count + matched X-start/width): a
4295    // lone spurious 2-row prose strip has no same-column neighbour, so
4296    // it stays short and is still dropped — the guard's intent holds. ~keep
4297    let row_h = median_fragment_row_height(&tables);
4298    let y_tol = (row_h * 1.5).max(3.0);
4299    tables = consolidate_adjacent_table_fragments_with_tol(tables, 2.0, y_tol);
4300    // H-rule bounded detection lacks vertical-line evidence —
4301    // columns come from text-edge clustering alone (same shape as
4302    // the text-only fallback in `detect_tables_with_lines`).  Two-row results are
4303    // virtually always prose that happens to live between
4304    // decorative rules (annotation underlines, page borders);
4305    // require three rows of evidence before promoting. ~keep
4306    tables.retain(|t| t.rows.len() >= 3);
4307    tables
4308}
4309
4310/// Runs H-rule detection on the rules no found table covers, so a rules-only
4311/// table survives beside a gridded one on the same page.
4312fn tables_from_leftover_rules(
4313    spans: &[TextSpan],
4314    lines: &[crate::elements::PathContent],
4315    found: &[Table],
4316    config: &TableDetectionConfig,
4317) -> Vec<Table> {
4318    let claimed: Option<Vec<crate::geometry::Rect>> = found.iter().map(|t| t.bbox).collect();
4319    let Some(claimed) = claimed.filter(|c| !c.is_empty()) else {
4320        return Vec::new();
4321    };
4322    let free = |r: &crate::geometry::Rect| !claimed.iter().any(|c| c.intersects(r));
4323    let free_lines: Vec<_> = lines.iter().filter(|p| free(&p.rendered_bbox())).cloned().collect();
4324    tables_from_horizontal_rules(spans, &free_lines, config)
4325        .into_iter()
4326        .filter(|t| is_valid_table(t) && t.bbox.as_ref().is_some_and(&free))
4327        .collect()
4328}
4329
4330/// Detect tables using vector lines and text spans (main entry point for hybrid detection).
4331pub fn detect_tables_with_lines(
4332    spans: &[TextSpan],
4333    lines: &[crate::elements::PathContent],
4334    config: &TableDetectionConfig,
4335) -> Vec<Table> {
4336    if !config.enabled || spans.is_empty() {
4337        return Vec::new();
4338    }
4339    match (config.horizontal_strategy, config.vertical_strategy) {
4340        (TableStrategy::Text, TableStrategy::Text) => {
4341            return detect_tables_from_spans_column_aware(spans, config);
4342        }
4343        (TableStrategy::Lines, TableStrategy::Lines) => {
4344            let mut tables = detect_tables_from_intersections(spans, lines, config);
4345            if tables.is_empty() {
4346                for cluster in group_lines_into_clusters(lines, config) {
4347                    tables.append(&mut detect_tables_in_cluster(spans, lines, &cluster, config));
4348                }
4349            }
4350            let mut tables: Vec<Table> = tables.into_iter().filter(is_valid_table).collect();
4351            let leftover = tables_from_leftover_rules(spans, lines, &tables, config);
4352            tables.extend(leftover);
4353            return tables;
4354        }
4355        _ => {}
4356    }
4357    // Both / hybrid strategy: try intersection-based first, then cluster, then H-rule bounded,
4358    // then text fallback. ~keep
4359    let mut final_tables = detect_tables_from_intersections(spans, lines, config);
4360    if final_tables.is_empty() {
4361        let clusters = group_lines_into_clusters(lines, config);
4362        for cluster in clusters {
4363            final_tables.append(&mut detect_tables_in_cluster(spans, lines, &cluster, config));
4364        }
4365    }
4366    // When intersection and cluster pipelines found nothing, try H-rule bounded detection:
4367    // use horizontal lines as table region boundaries with text-edge column detection. ~keep
4368    if final_tables.is_empty() {
4369        final_tables = tables_from_horizontal_rules(spans, lines, config);
4370    }
4371    // Filter out invalid line-based tables BEFORE overlap checking so that
4372    // spurious line-based tables don't shadow valid text-based ones. ~keep
4373    final_tables.retain(is_valid_table);
4374
4375    // Only allow text-based fallback if BOTH strategies permit it AND the caller
4376    // explicitly enabled text-only detection (config.text_fallback=true).
4377    // This prevents extract_text() callers (text_fallback=false) from
4378    // spuriously running span-column detection alongside ruling-line tables:
4379    // report-style PDFs with decorative horizontal rules (e.g. swimming results)
4380    // would otherwise have all their data detected as a text table that renders
4381    // the page content a second time, causing duplicate extraction.
4382    // Callers that want text-based table detection (to_markdown, to_html) set
4383    // config.text_fallback=true explicitly. ~keep
4384    let allow_text_fallback = config.text_fallback
4385        && config.horizontal_strategy != TableStrategy::Lines
4386        && config.vertical_strategy != TableStrategy::Lines;
4387
4388    if allow_text_fallback {
4389        let text_candidates = detect_tables_from_spans_column_aware(spans, config);
4390        for text_table in text_candidates {
4391            if !passes_spatial_quality_gate(&text_table) {
4392                continue;
4393            }
4394            // Text-only detection (no ruling lines) infers columns from word
4395            // x-alignment alone — two rows of column-aligned words is the
4396            // signature of ordinary prose (a title + a wrapped body line),
4397            // not a table.  Require at least three rows of evidence before
4398            // promoting a span cluster to a table. ~keep
4399            if text_table.rows.len() < 3 {
4400                continue;
4401            }
4402            if let Some(text_bbox) = text_table.bbox {
4403                let overlaps = final_tables.iter().any(|t| {
4404                    if let Some(line_bbox) = t.bbox {
4405                        line_bbox.intersects(&text_bbox)
4406                            || line_bbox.contains_rect(&text_bbox)
4407                            || text_bbox.contains_rect(&line_bbox)
4408                    } else {
4409                        false
4410                    }
4411                });
4412                if !overlaps {
4413                    final_tables.push(text_table);
4414                }
4415            }
4416        }
4417    }
4418    final_tables
4419}
4420
4421fn grid_to_table(
4422    grid: &GridStructure,
4423    spans: &[TextSpan],
4424    visual_merge_info: Option<Vec<Vec<CellMergeInfo>>>,
4425) -> Table {
4426    let num_rows = grid.cells.len();
4427    let num_cols = grid.columns.len();
4428    let merge_info = visual_merge_info.unwrap_or_else(|| detect_merged_cells(grid, spans));
4429    let header_row_idx = detect_header_row(grid, spans);
4430    let mut table_rows = Vec::new();
4431    for (row_idx, row) in grid.cells.iter().enumerate() {
4432        let is_header = header_row_idx == Some(row_idx);
4433        let mut table_row = TableRow::new(is_header);
4434        for (col_idx, cell_span_indices) in row.iter().enumerate() {
4435            let mi = &merge_info[row_idx][col_idx];
4436            if mi.covered {
4437                continue;
4438            }
4439            let cell_text = extract_cell_text(cell_span_indices, spans);
4440            let mut cell_bbox = None;
4441            if !cell_span_indices.is_empty() {
4442                let mut b = spans[cell_span_indices[0]].bbox;
4443                for &idx in &cell_span_indices[1..] {
4444                    b = b.union(&spans[idx].bbox);
4445                }
4446                cell_bbox = Some(b);
4447            }
4448            let mcids = cell_span_indices
4449                .iter()
4450                .filter_map(|&idx| spans.get(idx).and_then(|s| s.mcid))
4451                .collect::<Vec<_>>();
4452            let cell_spans = cell_span_indices
4453                .iter()
4454                .filter_map(|&idx| spans.get(idx).cloned())
4455                .collect::<Vec<_>>();
4456
4457            table_row.cells.push(TableCell {
4458                text: cell_text,
4459                spans: cell_spans,
4460                colspan: mi.colspan.min((num_cols - col_idx) as u32),
4461                rowspan: mi.rowspan.min((num_rows - row_idx) as u32),
4462                mcids,
4463                bbox: cell_bbox,
4464                is_header,
4465            });
4466        }
4467        table_rows.push(table_row);
4468    }
4469    let all_span_indices: Vec<usize> = grid
4470        .cells
4471        .iter()
4472        .flat_map(|row| row.iter().flat_map(|cell| cell.iter().copied()))
4473        .collect();
4474    let mut bbox = None;
4475    if !all_span_indices.is_empty() {
4476        let mut min_x = f32::INFINITY;
4477        let mut min_y = f32::INFINITY;
4478        let mut max_x = f32::NEG_INFINITY;
4479        let mut max_y = f32::NEG_INFINITY;
4480        for &idx in &all_span_indices {
4481            if let Some(s) = spans.get(idx) {
4482                min_x = min_x.min(s.bbox.x);
4483                min_y = min_y.min(s.bbox.y);
4484                max_x = max_x.max(s.bbox.x + s.bbox.width);
4485                max_y = max_y.max(s.bbox.y + s.bbox.height);
4486            }
4487        }
4488        bbox = Some(crate::geometry::Rect::new(min_x, min_y, max_x - min_x, max_y - min_y));
4489    }
4490    Table {
4491        rows: table_rows,
4492        has_header: header_row_idx.is_some(),
4493        col_count: num_cols,
4494        bbox,
4495    }
4496}
4497
4498fn extract_cell_text(cell_span_indices: &[usize], spans: &[TextSpan]) -> String {
4499    if cell_span_indices.is_empty() {
4500        return String::new();
4501    }
4502    // Keep the span reference (not just text) so we can decide spacing based
4503    // on the geometric gap and CJK/fullwidth-operator boundary state, exactly
4504    // like the inline-flow path does in pipeline/converters/mod.rs.  Without
4505    // this, the previous `line.join(" ")` was unconditionally inserting a
4506    // space between every adjacent span on the same row, splitting compound
4507    // tokens like `40000≤Q<55000` into `40000≤Q <55000` and dropping word-F1
4508    // for table-heavy CJK documents. ~keep
4509    let mut span_entries: Vec<(f32, &TextSpan, String)> = cell_span_indices
4510        .iter()
4511        .filter_map(|&idx| spans.get(idx).map(|s| (s.bbox.center().y, s, span_text_for_cell(s))))
4512        .collect();
4513    if span_entries.is_empty() {
4514        return String::new();
4515    }
4516    if span_entries.len() == 1 {
4517        // Trim the same source-space prefix the multi-span path trims (GH#1948):
4518        // a single-word cell has no preceding word, so any leading space is an
4519        // edge artifact, not a word separator.
4520        return span_entries.remove(0).2.trim().to_string();
4521    }
4522    span_entries.sort_by(|a, b| crate::utils::safe_float_cmp(b.0, a.0));
4523
4524    // Group into rows by y proximity, then within a row decide separator per
4525    // pair of spans using the same gap/CJK rules as inline text assembly. ~keep
4526    let mut lines: Vec<Vec<(&TextSpan, String)>> = Vec::new();
4527    let mut current_line: Vec<(&TextSpan, String)> = vec![(span_entries[0].1, span_entries[0].2.clone())];
4528    let mut current_y = span_entries[0].0;
4529    for (y, span, text) in &span_entries[1..] {
4530        if (current_y - y).abs() <= 2.0 {
4531            current_line.push((span, text.clone()));
4532        } else {
4533            lines.push(current_line);
4534            current_line = vec![(span, text.clone())];
4535            current_y = *y;
4536        }
4537    }
4538    lines.push(current_line);
4539
4540    lines
4541        .iter()
4542        .map(|line| {
4543            let mut out = String::new();
4544            for (i, (span, text)) in line.iter().enumerate() {
4545                if i > 0 {
4546                    let (prev_span, _) = line[i - 1];
4547                    let separator = cell_span_separator(prev_span, span);
4548                    out.push_str(separator);
4549                }
4550                out.push_str(text);
4551            }
4552            // A word can carry a leading space from `extract_table_word_spans`,
4553            // which re-emits the source space between two words whose boxes abut
4554            // (GH#1948). When such a word opens a cell or a wrapped line, that
4555            // space is at an edge, not between two words; drop it here. Cell text
4556            // never has meaningful leading/trailing whitespace. ~keep
4557            out.trim().to_string()
4558        })
4559        .collect::<Vec<_>>()
4560        .join("\n")
4561}
4562
4563/// Decide what (if any) separator to put between two spans within the same
4564/// table cell row.  Mirrors the inline-flow has_horizontal_gap logic from
4565/// pipeline/converters/mod.rs: insert a space only when there is a real
4566/// horizontal gap that exceeds the inter-glyph kerning floor AND the
4567/// boundary is not a CJK ↔ CJK / CJK ↔ fullwidth-operator pair.
4568fn cell_span_separator(prev: &TextSpan, current: &TextSpan) -> &'static str {
4569    if prev.text.ends_with(' ') || current.text.starts_with(' ') {
4570        return "";
4571    }
4572
4573    // Locale-independent numeric punctuation stays attached when the PDF
4574    // splits a number immediately before its decimal/grouping separator. The
4575    // digit-only suffix avoids swallowing sentence punctuation. ~keep
4576    let prev_ends_in_digit = prev.text.trim_end().chars().next_back().is_some_and(|c| c.is_numeric());
4577    let mut current_chars = current.text.trim_start().chars();
4578    let starts_numeric_group = matches!(current_chars.next(), Some('.' | ',' | '\u{066B}' | '\u{066C}'))
4579        && current_chars.clone().next().is_some()
4580        && current_chars.all(|c| c.is_numeric());
4581    if prev_ends_in_digit && starts_numeric_group {
4582        return "";
4583    }
4584
4585    let prev_end_x = prev.bbox.x + prev.bbox.width;
4586    let gap = current.bbox.x - prev_end_x;
4587    let font_size = prev.font_size.max(current.font_size).max(1.0);
4588
4589    let space_threshold = font_size * 0.15;
4590    if gap <= space_threshold {
4591        return "";
4592    }
4593    // No upper bound: a very large gap (≥ 5 em) used to be treated as a
4594    // column boundary and yield no separator, but the caller concatenates
4595    // span text when this returns "" — so tokens like `3.80%` and `4.41%`
4596    // were rendered as `3.80%4.41%` on wide rate tables.  Mirroring the
4597    // inline-flow rule (`pipeline::converters::has_horizontal_gap`), any
4598    // gap above the inter-glyph threshold now gets at least a single
4599    // space. ~keep
4600
4601    // CJK / fullwidth-operator suppression — same rule as
4602    // pipeline::converters::has_horizontal_gap.  pdftotext keeps an
4603    // ideograph + adjacent fullwidth/math operator without a separator. ~keep
4604    let is_cjk = |c: char| {
4605        matches!(
4606            c as u32,
4607            0x3040..=0x309F
4608            | 0x30A0..=0x30FF
4609            | 0x4E00..=0x9FFF
4610            | 0xAC00..=0xD7AF
4611            | 0x3400..=0x4DBF
4612            | 0x20000..=0x2A6DF
4613        )
4614    };
4615    let is_fw_op = |c: char| {
4616        matches!(
4617            c as u32,
4618            0xFF0B | 0xFF0D | 0xFF1A | 0xFF1B
4619            | 0xFF1C..=0xFF1E
4620            | 0x2260 | 0x2248
4621            | 0x2264..=0x2265
4622            | 0x00B5 | 0x03BC
4623            | 0x00B1 | 0x00D7 | 0x00F7
4624        )
4625    };
4626    let prev_tail = prev.text.chars().next_back();
4627    let curr_head = current.text.chars().next();
4628    if let (Some(p), Some(c)) = (prev_tail, curr_head) {
4629        let p_cjk = is_cjk(p);
4630        let c_cjk = is_cjk(c);
4631        if (p_cjk || is_fw_op(p)) && (c_cjk || is_fw_op(c)) && (p_cjk || c_cjk) {
4632            return "";
4633        }
4634    }
4635
4636    " "
4637}
4638
4639/// Consolidate vertically-adjacent tables that share an identical column
4640/// structure into a single multi-row table.
4641///
4642/// Root cause: when a logical multi-row table is drawn
4643/// with a horizontal ruling line between every pair of rows (rather than
4644/// only at the top and bottom), the line-based detector emits one Table
4645/// per row strip. Each fragment is a 1- or 2-row table that fails
4646/// `is_real_grid()` (which requires ≥2 rows) and gets dropped, after
4647/// which the cells fall through to the paragraph flow with column-based
4648/// reading order — producing orphan `<p>40000≤Q</p>` / `<p><55000</p>`
4649/// pairs instead of `<table><td>40000≤Q<55000</td></tr></table>`.
4650///
4651/// Two fragments are merge-candidates when:
4652///   * both have a `bbox`
4653///   * X start matches within `X_TOLERANCE`
4654///   * width matches within `X_TOLERANCE`
4655///   * column counts are equal
4656///   * the lower fragment's top edge (`bbox.y + bbox.height`) is within
4657///     `Y_TOLERANCE` of the upper fragment's bottom edge (`bbox.y`)
4658///
4659/// Sort tables top-down (PDF y-up: largest top-Y first) and merge runs
4660/// of consecutive fragments that satisfy the criteria. The merged table
4661/// preserves the union of all rows and a bbox spanning both fragments.
4662pub fn consolidate_adjacent_table_fragments(tables: Vec<Table>) -> Vec<Table> {
4663    consolidate_adjacent_table_fragments_with_tol(tables, 2.0, 3.0)
4664}
4665
4666/// Median per-row height across table fragments, estimated as each
4667/// fragment's bbox height divided by its row count. Used to scale the
4668/// vertical merge tolerance for rule-split bands. Returns 0.0 when no
4669/// fragment carries usable geometry.
4670fn median_fragment_row_height(tables: &[Table]) -> f32 {
4671    let mut heights: Vec<f32> = tables
4672        .iter()
4673        .filter_map(|t| {
4674            let b = t.bbox?;
4675            let n = t.rows.len();
4676            if n == 0 || b.height <= 0.0 {
4677                None
4678            } else {
4679                Some(b.height / n as f32)
4680            }
4681        })
4682        .collect();
4683    if heights.is_empty() {
4684        return 0.0;
4685    }
4686    heights.sort_by(|a, b| crate::utils::safe_float_cmp(*a, *b));
4687    heights[heights.len() / 2]
4688}
4689
4690/// Like [`consolidate_adjacent_table_fragments`] but with a caller-chosen
4691/// vertical merge tolerance. The default 3.0 pt tolerance assumes fragments
4692/// abut (a ruling line between every row leaves ~0 gap). The H-rule-bounded
4693/// detector, however, emits one fragment per rule-delimited band, so two
4694/// bands of the SAME logical table are separated by a full inter-row pitch
4695/// (rule stroke + leading) — often ~10-15 pt. A larger `y_tol` lets those
4696/// bands rejoin. Safety rests on the unchanged column gating in
4697/// `can_merge_tables` (equal col_count + X-start ≤ x_tol + width ≤ x_tol):
4698/// two genuinely distinct tables sharing all three within one row-height are
4699/// vanishingly rare, whereas bands of one ruled table match them exactly.
4700pub fn consolidate_adjacent_table_fragments_with_tol(
4701    tables: Vec<Table>,
4702    x_tolerance: f32,
4703    y_tolerance: f32,
4704) -> Vec<Table> {
4705    if tables.len() < 2 {
4706        return tables;
4707    }
4708
4709    // Sort by top-Y descending (top of page first in PDF y-up coordinates). ~keep
4710    let mut sorted = tables;
4711    sorted.sort_by(|a, b| {
4712        let a_top = a.bbox.map(|b| b.y + b.height).unwrap_or(f32::NEG_INFINITY);
4713        let b_top = b.bbox.map(|b| b.y + b.height).unwrap_or(f32::NEG_INFINITY);
4714        crate::utils::safe_float_cmp(b_top, a_top)
4715    });
4716
4717    let mut consolidated: Vec<Table> = Vec::with_capacity(sorted.len());
4718    for table in sorted {
4719        let merge_into_last = consolidated
4720            .last()
4721            .map(|last| can_merge_tables(last, &table, x_tolerance, y_tolerance))
4722            .unwrap_or(false);
4723        if merge_into_last {
4724            // Safety: merge_into_last is only true when consolidated.last()
4725            // returned Some, so last_mut() must also return Some. ~keep
4726            if let Some(last) = consolidated.last_mut() {
4727                merge_table_into(last, table);
4728            }
4729        } else {
4730            consolidated.push(table);
4731        }
4732    }
4733    consolidated
4734}
4735
4736fn can_merge_tables(upper: &Table, lower: &Table, x_tol: f32, y_tol: f32) -> bool {
4737    let (Some(u_bbox), Some(l_bbox)) = (upper.bbox, lower.bbox) else {
4738        return false;
4739    };
4740    if upper.col_count != lower.col_count || upper.col_count == 0 {
4741        return false;
4742    }
4743    if (u_bbox.x - l_bbox.x).abs() > x_tol {
4744        return false;
4745    }
4746    if (u_bbox.width - l_bbox.width).abs() > x_tol {
4747        return false;
4748    }
4749    // upper sits ABOVE lower in PDF y-up: upper.bbox.y is the BOTTOM of
4750    // upper, lower.bbox.y + lower.bbox.height is the TOP of lower.
4751    // For them to be vertically adjacent, the upper.bottom must be close
4752    // to the lower.top.  We allow a small NEGATIVE gap (overlap) up to
4753    // half the smaller table's height — the line-based detector
4754    // occasionally produces bboxes that overhang the adjacent table by a
4755    // few points when ruling-rule strokes have non-zero thickness or
4756    // include the line's drawn extent above/below the baseline.  Real
4757    // distinct tables almost always have a meaningful positive gap. ~keep
4758    let upper_bottom = u_bbox.y;
4759    let lower_top = l_bbox.y + l_bbox.height;
4760    let gap = upper_bottom - lower_top;
4761    if gap > y_tol {
4762        return false;
4763    }
4764    let min_height = u_bbox.height.min(l_bbox.height);
4765    if -gap > min_height * 0.5 {
4766        return false;
4767    }
4768    true
4769}
4770
4771fn merge_table_into(upper: &mut Table, lower: Table) {
4772    if let (Some(ub), Some(lb)) = (upper.bbox, lower.bbox) {
4773        let new_y = ub.y.min(lb.y);
4774        let new_top = (ub.y + ub.height).max(lb.y + lb.height);
4775        let new_x = ub.x.min(lb.x);
4776        let new_right = (ub.x + ub.width).max(lb.x + lb.width);
4777        upper.bbox = Some(crate::geometry::Rect {
4778            x: new_x,
4779            y: new_y,
4780            width: new_right - new_x,
4781            height: new_top - new_y,
4782        });
4783    }
4784    upper.rows.extend(lower.rows);
4785}
4786
4787fn detect_merged_cells(grid: &GridStructure, spans: &[TextSpan]) -> Vec<Vec<CellMergeInfo>> {
4788    let num_rows = grid.cells.len();
4789    let num_cols = grid.columns.len();
4790    let mut merge_info: Vec<Vec<CellMergeInfo>> = (0..num_rows)
4791        .map(|_| {
4792            (0..num_cols)
4793                .map(|_| CellMergeInfo {
4794                    colspan: 1,
4795                    rowspan: 1,
4796                    covered: false,
4797                })
4798                .collect()
4799        })
4800        .collect();
4801    for row_idx in 0..num_rows {
4802        for col_idx in 0..num_cols {
4803            if grid.cells[row_idx][col_idx].is_empty() {
4804                continue;
4805            }
4806            let cell_right = grid.cells[row_idx][col_idx]
4807                .iter()
4808                .filter_map(|&idx| spans.get(idx).map(|s| s.bbox.right()))
4809                .fold(f32::NEG_INFINITY, f32::max);
4810            if cell_right == f32::NEG_INFINITY {
4811                continue;
4812            }
4813            let mut extra_cols = 0u32;
4814            for next_col in (col_idx + 1)..num_cols {
4815                if !grid.cells[row_idx][next_col].is_empty() {
4816                    break;
4817                }
4818                if cell_right > grid.columns[next_col].x_center {
4819                    extra_cols += 1;
4820                } else {
4821                    break;
4822                }
4823            }
4824            if extra_cols > 0 {
4825                merge_info[row_idx][col_idx].colspan = 1 + extra_cols;
4826                for c in 1..=(extra_cols as usize) {
4827                    merge_info[row_idx][col_idx + c].covered = true;
4828                }
4829            }
4830        }
4831    }
4832    for col_idx in 0..num_cols {
4833        for row_idx in 0..num_rows {
4834            if grid.cells[row_idx][col_idx].is_empty() || merge_info[row_idx][col_idx].covered {
4835                continue;
4836            }
4837            let cell_bottom = grid.cells[row_idx][col_idx]
4838                .iter()
4839                .filter_map(|&idx| spans.get(idx).map(|s| s.bbox.bottom()))
4840                .fold(f32::INFINITY, f32::min);
4841            if cell_bottom == f32::INFINITY {
4842                continue;
4843            }
4844            let mut extra_rows = 0u32;
4845            for next_row in (row_idx + 1)..num_rows {
4846                if !grid.cells[next_row][col_idx].is_empty() {
4847                    break;
4848                }
4849                if cell_bottom < grid.rows[next_row].y_center {
4850                    extra_rows += 1;
4851                } else {
4852                    break;
4853                }
4854            }
4855            if extra_rows > 0 {
4856                merge_info[row_idx][col_idx].rowspan = 1 + extra_rows;
4857                for r in 1..=(extra_rows as usize) {
4858                    merge_info[row_idx + r][col_idx].covered = true;
4859                }
4860            }
4861        }
4862    }
4863    merge_info
4864}
4865
4866fn detect_header_row(grid: &GridStructure, spans: &[TextSpan]) -> Option<usize> {
4867    if grid.cells.len() < 2 {
4868        return None;
4869    }
4870    let first_row_spans: Vec<&TextSpan> = grid.cells[0]
4871        .iter()
4872        .flat_map(|cell| cell.iter().filter_map(|&idx| spans.get(idx)))
4873        .collect();
4874    if first_row_spans.is_empty() {
4875        return None;
4876    }
4877    let data_row_spans: Vec<&TextSpan> = grid.cells[1..]
4878        .iter()
4879        .flat_map(|row| {
4880            row.iter()
4881                .flat_map(|cell| cell.iter().filter_map(|&idx| spans.get(idx)))
4882        })
4883        .collect();
4884    if data_row_spans.is_empty() {
4885        return None;
4886    }
4887    let first_row_bold_ratio =
4888        first_row_spans.iter().filter(|s| s.font_weight.is_bold()).count() as f32 / first_row_spans.len() as f32;
4889    let data_bold_ratio =
4890        data_row_spans.iter().filter(|s| s.font_weight.is_bold()).count() as f32 / data_row_spans.len() as f32;
4891    if first_row_bold_ratio > 0.5 && data_bold_ratio < 0.3 {
4892        return Some(0);
4893    }
4894    let first_row_avg_size: f32 =
4895        first_row_spans.iter().map(|s| s.font_size).sum::<f32>() / first_row_spans.len() as f32;
4896    let data_avg_size: f32 = data_row_spans.iter().map(|s| s.font_size).sum::<f32>() / data_row_spans.len() as f32;
4897    if first_row_avg_size > data_avg_size + 1.5 {
4898        return Some(0);
4899    }
4900    None
4901}
4902
4903#[cfg(test)]
4904mod tests {
4905    use super::*;
4906    use crate::geometry::Rect;
4907    use crate::layout::text_block::{Color, FontWeight};
4908
4909    #[test]
4910    fn row_spanning_cell_ignores_a_neighbours_one_sided_crossing() {
4911        // Left-hand description is split at y=20; its rule stops at x=40.
4912        // The alignment cell to its right is a single box from y=0 to y=40. ~keep
4913        let horizontal = vec![
4914            Edge {
4915                coord: 0.,
4916                start: 0.,
4917                end: 80.,
4918            },
4919            Edge {
4920                coord: 20.,
4921                start: 0.,
4922                end: 40.,
4923            },
4924            Edge {
4925                coord: 40.,
4926                start: 0.,
4927                end: 80.,
4928            },
4929        ];
4930        let vertical = vec![
4931            Edge {
4932                coord: 0.,
4933                start: 0.,
4934                end: 40.,
4935            },
4936            Edge {
4937                coord: 40.,
4938                start: 0.,
4939                end: 40.,
4940            },
4941            Edge {
4942                coord: 80.,
4943                start: 0.,
4944                end: 40.,
4945            },
4946        ];
4947        let points = find_intersections(&horizontal, &vertical);
4948        let cells = build_cells_from_intersections(&points, &horizontal, &vertical);
4949        assert!(
4950            cells
4951                .iter()
4952                .any(|c| c.x1 == 40. && c.x2 == 80. && c.y1 == 0. && c.y2 == 40.),
4953            "{cells:?}"
4954        );
4955        assert_eq!(cells.len(), 3);
4956    }
4957
4958    #[test]
4959    fn later_closing_corners_do_not_bridge_separate_boxes() {
4960        let horizontal = vec![
4961            Edge {
4962                coord: 0.,
4963                start: 0.,
4964                end: 20.,
4965            },
4966            Edge {
4967                coord: 10.,
4968                start: 0.,
4969                end: 20.,
4970            },
4971            Edge {
4972                coord: 40.,
4973                start: 0.,
4974                end: 20.,
4975            },
4976            Edge {
4977                coord: 0.,
4978                start: 40.,
4979                end: 60.,
4980            },
4981            Edge {
4982                coord: 40.,
4983                start: 40.,
4984                end: 60.,
4985            },
4986        ];
4987        let vertical: Vec<_> = [0., 20., 40., 60.]
4988            .into_iter()
4989            .map(|coord| Edge {
4990                coord,
4991                start: 0.,
4992                end: 40.,
4993            })
4994            .collect();
4995        let points = find_intersections(&horizontal, &vertical);
4996        let cells = build_cells_from_intersections(&points, &horizontal, &vertical);
4997        assert!(
4998            !cells
4999                .iter()
5000                .any(|cell| cell.x1 == 20. && cell.x2 == 40. && cell.y1 == 0.),
5001            "{cells:?}"
5002        );
5003    }
5004
5005    #[test]
5006    fn cell_search_does_not_skip_a_rule_entering_its_interior() {
5007        let horizontal = vec![
5008            Edge {
5009                coord: 0.,
5010                start: 0.,
5011                end: 80.,
5012            },
5013            Edge {
5014                coord: 20.,
5015                start: 0.,
5016                end: 30.,
5017            },
5018            Edge {
5019                coord: 40.,
5020                start: 0.,
5021                end: 80.,
5022            },
5023        ];
5024        let vertical = vec![
5025            Edge {
5026                coord: 0.,
5027                start: 0.,
5028                end: 40.,
5029            },
5030            Edge {
5031                coord: 80.,
5032                start: 0.,
5033                end: 40.,
5034            },
5035        ];
5036        let points = find_intersections(&horizontal, &vertical);
5037        let cells = build_cells_from_intersections(&points, &horizontal, &vertical);
5038        assert!(
5039            !cells
5040                .iter()
5041                .any(|cell| cell.x1 == 0. && cell.y1 == 0. && cell.x2 == 80. && cell.y2 == 40.),
5042            "{cells:?}"
5043        );
5044    }
5045
5046    #[test]
5047    fn farther_corners_without_a_divider_ending_below_are_not_header_cells() {
5048        let points = [
5049            Intersection { x: 0., y: 0. },
5050            Intersection { x: 40., y: 0. },
5051            Intersection { x: 80., y: 0. },
5052            Intersection { x: 0., y: 20. },
5053            Intersection { x: 80., y: 20. },
5054        ];
5055        let h = [
5056            Edge {
5057                coord: 0.,
5058                start: 0.,
5059                end: 80.,
5060            },
5061            Edge {
5062                coord: 20.,
5063                start: 0.,
5064                end: 80.,
5065            },
5066        ];
5067        let v = [
5068            Edge {
5069                coord: 0.,
5070                start: 0.,
5071                end: 20.,
5072            },
5073            Edge {
5074                coord: 40.,
5075                start: 0.,
5076                end: 10.,
5077            },
5078            Edge {
5079                coord: 80.,
5080                start: 0.,
5081                end: 20.,
5082            },
5083        ];
5084        assert!(build_cells_from_intersections(&points, &h, &v).is_empty());
5085    }
5086
5087    #[test]
5088    fn a_single_ruled_header_row_does_not_claim_the_unruled_body() {
5089        let mut cells: Vec<_> = (0..6)
5090            .map(|column| IntersectionCell {
5091                x1: column as f32 * 20.,
5092                y1: 0.,
5093                x2: (column + 1) as f32 * 20.,
5094                y2: 20.,
5095            })
5096            .collect();
5097        cells.push(IntersectionCell {
5098            x1: 0.,
5099            y1: 20.,
5100            x2: 120.,
5101            y2: 40.,
5102        });
5103        let groups = attach_spanning_headers(&cells, group_cells_into_tables(&cells), 6);
5104        assert_eq!(groups.len(), 2);
5105    }
5106
5107    #[test]
5108    fn sparse_header_boxes_do_not_form_a_new_table() {
5109        let cells = [
5110            IntersectionCell {
5111                x1: 0.,
5112                y1: 20.,
5113                x2: 80.,
5114                y2: 40.,
5115            },
5116            IntersectionCell {
5117                x1: 0.,
5118                y1: 0.,
5119                x2: 40.,
5120                y2: 20.,
5121            },
5122            IntersectionCell {
5123                x1: 40.,
5124                y1: 0.,
5125                x2: 80.,
5126                y2: 20.,
5127            },
5128        ];
5129        let groups = attach_spanning_headers(&cells, group_cells_into_tables(&cells), 6);
5130        assert_eq!(groups.len(), 2);
5131    }
5132
5133    #[test]
5134    fn inset_fills_do_not_add_columns_inside_a_ruled_band() {
5135        use crate::elements::PathContent;
5136        let mut fill = PathContent::rect(4., 0.5, 72., 19.);
5137        fill.stroke_color = None;
5138        fill.fill_color = Some(Color::black());
5139        let rules = vec![
5140            PathContent::line(0., 0., 80., 0.),
5141            PathContent::line(0., 20., 80., 20.),
5142            PathContent::line(0., 0., 0., 20.),
5143            PathContent::line(80., 0., 80., 20.),
5144        ];
5145        let mut outer = PathContent::rect(0., 0., 80., 20.);
5146        outer.stroke_color = None;
5147        outer.fill_color = Some(Color::black());
5148        let mut shaded = rules.clone();
5149        shaded.extend([outer, fill.clone()]);
5150        let (mut h, mut v) = extract_edges(&shaded);
5151        snap_and_merge(&mut h);
5152        snap_and_merge(&mut v);
5153        assert_eq!(h.len(), 2);
5154        assert_eq!(v.len(), 2);
5155        // A fill without a matching outer background is not assumed decorative.
5156        let mut single_fill = rules.clone();
5157        single_fill.push(fill.clone());
5158        let (h, v) = extract_edges(&single_fill);
5159        assert_eq!(h.len(), 4);
5160        assert_eq!(v.len(), 4);
5161        // Fills remain evidence when the sides of the row are not ruled.
5162        let (h, v) = extract_edges(&[rules[0].clone(), rules[1].clone(), fill.clone()]);
5163        assert_eq!(h.len(), 4);
5164        assert_eq!(v.len(), 2);
5165        // A stroked inner box is real geometry, even inside a ruled band.
5166        fill.stroke_color = Some(Color::black());
5167        let mut boxed = rules;
5168        boxed.push(fill);
5169        let (h, v) = extract_edges(&boxed);
5170        assert_eq!(h.len(), 4);
5171        assert_eq!(v.len(), 4);
5172    }
5173
5174    #[test]
5175    fn spanning_header_stays_in_body_group() {
5176        let cells = [
5177            IntersectionCell {
5178                x1: 0.,
5179                y1: 40.,
5180                x2: 80.,
5181                y2: 60.,
5182            },
5183            IntersectionCell {
5184                x1: 0.,
5185                y1: 0.,
5186                x2: 40.,
5187                y2: 20.,
5188            },
5189            IntersectionCell {
5190                x1: 40.,
5191                y1: 0.,
5192                x2: 80.,
5193                y2: 20.,
5194            },
5195            IntersectionCell {
5196                x1: 0.,
5197                y1: 20.,
5198                x2: 40.,
5199                y2: 40.,
5200            },
5201            IntersectionCell {
5202                x1: 40.,
5203                y1: 20.,
5204                x2: 80.,
5205                y2: 40.,
5206            },
5207        ];
5208        let groups = attach_spanning_headers(&cells, group_cells_into_tables(&cells), 2);
5209        assert_eq!(groups.len(), 1, "{groups:?}");
5210    }
5211
5212    #[test]
5213    fn unrelated_cells_stay_separate_after_header_attachment() {
5214        // A full two-row body clears the attachment guards. Using isolated cells
5215        // here would skip the new attachment logic and only test old grouping.
5216        let body: Vec<_> = (0..2)
5217            .flat_map(|row| {
5218                (0..3).map(move |column| IntersectionCell {
5219                    x1: column as f32 * 40.,
5220                    y1: row as f32 * 20.,
5221                    x2: (column + 1) as f32 * 40.,
5222                    y2: (row + 1) as f32 * 20.,
5223                })
5224            })
5225            .collect();
5226        for (case, candidate) in [
5227            (
5228                "gap",
5229                IntersectionCell {
5230                    x1: 0.,
5231                    y1: 50.,
5232                    x2: 120.,
5233                    y2: 70.,
5234                },
5235            ),
5236            (
5237                "corner",
5238                IntersectionCell {
5239                    x1: 120.,
5240                    y1: 40.,
5241                    x2: 160.,
5242                    y2: 60.,
5243                },
5244            ),
5245        ] {
5246            let mut cells = body.clone();
5247            cells.push(candidate);
5248            let groups = attach_spanning_headers(&cells, group_cells_into_tables(&cells), 6);
5249            assert_eq!(groups, vec![vec![0, 1, 2, 3, 4, 5], vec![6]], "{case}");
5250        }
5251    }
5252
5253    #[test]
5254    fn header_cell_search_skips_nonclosing_inner_corner() {
5255        let points = [
5256            Intersection { x: 0., y: 0. },
5257            Intersection { x: 40., y: 0. },
5258            Intersection { x: 80., y: 0. },
5259            Intersection { x: 0., y: 20. },
5260            Intersection { x: 80., y: 20. },
5261        ];
5262        let h = [
5263            Edge {
5264                coord: 0.,
5265                start: 0.,
5266                end: 80.,
5267            },
5268            Edge {
5269                coord: 20.,
5270                start: 0.,
5271                end: 80.,
5272            },
5273        ];
5274        let v = [
5275            Edge {
5276                coord: 0.,
5277                start: 0.,
5278                end: 20.,
5279            },
5280            Edge {
5281                coord: 40.,
5282                start: -20.,
5283                end: 0.,
5284            },
5285            Edge {
5286                coord: 80.,
5287                start: 0.,
5288                end: 20.,
5289            },
5290        ];
5291        let cells = build_cells_from_intersections(&points, &h, &v);
5292        assert!(
5293            cells
5294                .iter()
5295                .any(|c| c.x1 == 0. && c.x2 == 80. && c.y1 == 0. && c.y2 == 20.),
5296            "{cells:?}"
5297        );
5298    }
5299
5300    #[test]
5301    fn rowspan_preserves_label_once_and_rejects_outside_text() {
5302        let cells = [
5303            IntersectionCell {
5304                x1: 0.,
5305                y1: 0.,
5306                x2: 40.,
5307                y2: 40.,
5308            },
5309            IntersectionCell {
5310                x1: 40.,
5311                y1: 0.,
5312                x2: 80.,
5313                y2: 20.,
5314            },
5315            IntersectionCell {
5316                x1: 40.,
5317                y1: 20.,
5318                x2: 80.,
5319                y2: 40.,
5320            },
5321        ];
5322        let spans = [
5323            create_test_span("Label", 5., 28., 20., 6.),
5324            create_test_span("Outside", 5., 50., 20., 6.),
5325        ];
5326        let edges = [Edge {
5327            coord: 40.,
5328            start: 0.,
5329            end: 40.,
5330        }];
5331        let (rows, _) =
5332            assign_spans_to_intersection_grid(&cells, &[0., 40., 80.], &[0., 20., 40.], 2, &spans, &edges, true)
5333                .unwrap();
5334        let texts: Vec<_> = rows.iter().flat_map(|r| &r.cells).map(|c| c.text.as_str()).collect();
5335        assert_eq!(texts.iter().filter(|&&t| t == "Label").count(), 1, "{texts:?}");
5336        assert!(!texts.iter().any(|t| t.contains("Outside")));
5337    }
5338
5339    #[test]
5340    fn colspan_preserves_off_center_header_once() {
5341        let cells = [
5342            IntersectionCell {
5343                x1: 0.,
5344                y1: 20.,
5345                x2: 80.,
5346                y2: 40.,
5347            },
5348            IntersectionCell {
5349                x1: 0.,
5350                y1: 0.,
5351                x2: 40.,
5352                y2: 20.,
5353            },
5354            IntersectionCell {
5355                x1: 40.,
5356                y1: 0.,
5357                x2: 80.,
5358                y2: 20.,
5359            },
5360        ];
5361        let spans = [create_test_span("Heading", 50., 28., 20., 6.)];
5362        let edges = [Edge {
5363            coord: 40.,
5364            start: 0.,
5365            end: 20.,
5366        }];
5367        let (rows, _) =
5368            assign_spans_to_intersection_grid(&cells, &[0., 40., 80.], &[0., 20., 40.], 2, &spans, &edges, true)
5369                .unwrap();
5370        assert_eq!(
5371            rows.iter()
5372                .flat_map(|r| &r.cells)
5373                .filter(|c| c.text == "Heading")
5374                .count(),
5375            1
5376        );
5377    }
5378
5379    #[test]
5380    fn grid_has_cell_skips_cell_with_reversed_boundaries() {
5381        // y1/y2 are reversed relative to `ys`, so boundary_index resolves bottom > top;
5382        // this must be skipped rather than panic on `grid_has_cell[bottom..top]`.
5383        let cells = [IntersectionCell {
5384            x1: 0.,
5385            y1: 40.,
5386            x2: 40.,
5387            y2: 0.,
5388        }];
5389        let result = assign_spans_to_intersection_grid(&cells, &[0., 40., 80.], &[0., 20., 40.], 2, &[], &[], true);
5390        assert!(result.is_some(), "malformed cell geometry must not panic");
5391    }
5392
5393    #[test]
5394    fn test_is_numeric_cell() {
5395        for ok in ["0.69", "100", "-1.2", "52%", "0", "1.00", "\u{2212}3.5"] {
5396            assert!(is_numeric_cell(ok), "{ok:?} should be numeric");
5397        }
5398        for no in ["Ours", "GLUE", "v3", "1e9", "0.6.6", "", "12345678.9", "p<0.05"] {
5399            assert!(!is_numeric_cell(no), "{no:?} should NOT be numeric");
5400        }
5401    }
5402
5403    fn col_at(x: f32) -> ColumnCluster {
5404        ColumnCluster {
5405            x_center: x,
5406            x_min: x - 3.0,
5407            x_max: x + 3.0,
5408            span_indices: Vec::new(),
5409        }
5410    }
5411
5412    #[test]
5413    fn grid_interval_prefers_exact_side_near_internal_boundary() {
5414        let boundaries = [0.0, 40.0, 70.0, 100.0];
5415
5416        assert_eq!(grid_interval_for_point(68.5, &boundaries), Some(1));
5417        assert_eq!(grid_interval_for_point(71.0, &boundaries), Some(2));
5418        assert_eq!(grid_interval_for_point(70.0, &boundaries), Some(2));
5419        assert_eq!(grid_interval_for_point(-2.0, &boundaries), Some(0));
5420        assert_eq!(grid_interval_for_point(102.0, &boundaries), Some(2));
5421        assert_eq!(grid_interval_for_point(104.0, &boundaries), None);
5422    }
5423
5424    #[test]
5425    fn intersection_grid_keeps_superscripts_and_boundary_text_in_their_cells() {
5426        let group_cells = [
5427            IntersectionCell {
5428                x1: 0.0,
5429                y1: 0.0,
5430                x2: 40.0,
5431                y2: 20.0,
5432            },
5433            IntersectionCell {
5434                x1: 40.0,
5435                y1: 0.0,
5436                x2: 70.0,
5437                y2: 20.0,
5438            },
5439            IntersectionCell {
5440                x1: 70.0,
5441                y1: 0.0,
5442                x2: 100.0,
5443                y2: 20.0,
5444            },
5445        ];
5446        let spans = vec![
5447            create_test_span("273", 10.0, 5.0, 27.0, 10.0),
5448            create_test_span("1", 37.0, 5.0, 2.0, 6.0),
5449            create_test_span("83", 45.0, 5.0, 21.0, 10.0),
5450            create_test_span("2", 66.0, 5.0, 2.0, 6.0),
5451            create_test_span("Europe", 66.0, 5.0, 10.0, 10.0),
5452        ];
5453        // A real ruled grid: column dividers span the row's full height, so
5454        // `band_column_groups` must not merge any of the three columns.
5455        let v_edges = [
5456            Edge {
5457                coord: 40.0,
5458                start: 0.0,
5459                end: 20.0,
5460            },
5461            Edge {
5462                coord: 70.0,
5463                start: 0.0,
5464                end: 20.0,
5465            },
5466        ];
5467
5468        let (rows, _) = assign_spans_to_intersection_grid(
5469            &group_cells,
5470            &[0.0, 40.0, 70.0, 100.0],
5471            &[0.0, 20.0],
5472            3,
5473            &spans,
5474            &v_edges,
5475            true,
5476        )
5477        .expect("synthetic grid should be valid");
5478
5479        assert_eq!(rows.len(), 1);
5480        assert_eq!(rows[0].cells[0].text, "2731");
5481        assert_eq!(rows[0].cells[1].text, "832");
5482        assert_eq!(rows[0].cells[2].text, "Europe");
5483    }
5484
5485    // xberg-io/xberg#1580: an unruled full-width strip (a section heading) stacked between
5486    // two ruled 3-column bands is still cut at the ruled bands' column positions, because its
5487    // four corners exist as crossings (the neighbours' V rules happen to end on the strip's own
5488    // H rules) even though no V rule actually runs through the strip. Geometry matches the
5489    // issue's own reproducer: `xs = [44.8, 289.2, 317.6, 551.5]`, unruled band `y[465.4, 525.9]`.
5490    #[test]
5491    fn unruled_band_between_ruled_bands_is_not_cut_at_phantom_columns() {
5492        let group_cells = [
5493            // Ruled band y[525.9, 585.9]: real column dividers at x=289.2 and x=317.6.
5494            IntersectionCell {
5495                x1: 44.8,
5496                y1: 525.9,
5497                x2: 289.2,
5498                y2: 585.9,
5499            },
5500            IntersectionCell {
5501                x1: 289.2,
5502                y1: 525.9,
5503                x2: 317.6,
5504                y2: 585.9,
5505            },
5506            IntersectionCell {
5507                x1: 317.6,
5508                y1: 525.9,
5509                x2: 551.5,
5510                y2: 585.9,
5511            },
5512            // Unruled band y[465.4, 525.9]: same corner grid, but no V rule spans it — the
5513            // corners only exist because the ruled band above terminates its dividers there.
5514            IntersectionCell {
5515                x1: 44.8,
5516                y1: 465.4,
5517                x2: 289.2,
5518                y2: 525.9,
5519            },
5520            IntersectionCell {
5521                x1: 289.2,
5522                y1: 465.4,
5523                x2: 317.6,
5524                y2: 525.9,
5525            },
5526            IntersectionCell {
5527                x1: 317.6,
5528                y1: 465.4,
5529                x2: 551.5,
5530                y2: 525.9,
5531            },
5532        ];
5533        let xs = [44.8, 289.2, 317.6, 551.5];
5534        let ys = [465.4, 525.9, 585.9];
5535
5536        // Ruled-band control row: one short word per column.
5537        let mut spans = vec![
5538            create_test_span("A", 140.0, 545.0, 20.0, 15.0),
5539            create_test_span("B", 295.0, 545.0, 15.0, 15.0),
5540            create_test_span("C", 400.0, 545.0, 20.0, 15.0),
5541        ];
5542        // Unruled heading strip: one continuous line, but the source spans happen to land at
5543        // the same X positions as the ruled band's columns above — exactly what the issue
5544        // reports for "8.2.7 Geen warmwater (alleen bij toepassing indirect gestookte boiler)".
5545        spans.extend([
5546            create_test_span(
5547                "8.2.7 Geen warmwater (alleen bij toepassing indirect",
5548                60.0,
5549                490.0,
5550                220.0,
5551                15.0,
5552            ),
5553            create_test_span("gest", 295.0, 490.0, 20.0, 15.0),
5554            create_test_span("ookte boiler)", 400.0, 490.0, 60.0, 15.0),
5555        ]);
5556
5557        // The internal column dividers (x=289.2, x=317.6) span only the ruled band's height
5558        // [525.9, 585.9] — they terminate exactly where the unruled strip begins, which is
5559        // the geometry the issue reports (`build_grid_from_lines` would produce edges shaped
5560        // like this: rules that end on the strip's own H rules, never crossing the strip).
5561        let v_edges = [
5562            Edge {
5563                coord: 289.2,
5564                start: 525.9,
5565                end: 585.9,
5566            },
5567            Edge {
5568                coord: 317.6,
5569                start: 525.9,
5570                end: 585.9,
5571            },
5572        ];
5573
5574        let (rows, _) = assign_spans_to_intersection_grid(&group_cells, &xs, &ys, 3, &spans, &v_edges, true)
5575            .expect("synthetic grid should be valid");
5576
5577        assert_eq!(rows.len(), 2);
5578        let ruled_row = &rows[0];
5579        let unruled_row = &rows[1];
5580
5581        // Control: the ruled band keeps its three columns.
5582        assert_eq!(ruled_row.cells.len(), 3, "ruled band must keep its three columns");
5583        assert_eq!(ruled_row.cells[0].text, "A");
5584        assert_eq!(ruled_row.cells[1].text, "B");
5585        assert_eq!(ruled_row.cells[2].text, "C");
5586
5587        // Fix target: the unruled strip is one cell, not cut at the ruled band's column x's.
5588        let unruled_texts: Vec<&str> = unruled_row.cells.iter().map(|c| c.text.as_str()).collect();
5589        assert_eq!(
5590            unruled_texts.len(),
5591            1,
5592            "unruled band must not be split at phantom column positions, got {unruled_texts:?}"
5593        );
5594        assert!(
5595            unruled_texts[0].contains("gest"),
5596            "heading text should be reassembled as one run, got {unruled_texts:?}"
5597        );
5598    }
5599
5600    // xberg-io/xberg#1588: `BAND_RULE_SPAN_TOL` answers a CONTAINMENT question ("does this V
5601    // edge actually run through the band?"), not the IDENTITY question `SNAP_TOL` answers
5602    // ("are these two coordinates the same coordinate?"). Reusing `SNAP_TOL` (3.0) let an edge
5603    // fall up to 3pt short of the band at each end and still count as a drawn boundary.
5604    #[test]
5605    fn should_not_count_a_rule_that_stops_short_of_the_band_as_a_boundary() {
5606        let xs = [0.0, 40.0, 80.0];
5607        let (y_lo, y_hi) = (0.0, 20.0);
5608        let num_cols = 2;
5609
5610        // The divider at x=40.0 stops 2pt short of the band at each end: within the old
5611        // `SNAP_TOL` (3.0) reuse, so it was wrongly counted as spanning; outside the fixed
5612        // 1.0 containment tolerance, so it must no longer count.
5613        let v_edges = [Edge {
5614            coord: 40.0,
5615            start: y_lo + 2.0,
5616            end: y_hi - 2.0,
5617        }];
5618
5619        let groups = band_column_groups(&xs, y_lo, y_hi, num_cols, &v_edges, true);
5620
5621        assert_eq!(
5622            groups,
5623            vec![(0, 1)],
5624            "a rule that stops short of the band must not split it into separate column groups, got {groups:?}"
5625        );
5626    }
5627
5628    #[test]
5629    fn test_is_regular_lattice() {
5630        // Regular ~20pt pitch with one wider row-label gap on the left. ~keep
5631        let regular: Vec<ColumnCluster> = [113.0, 150.0, 170.0, 190.0, 210.0, 230.0]
5632            .iter()
5633            .map(|&x| col_at(x))
5634            .collect();
5635        assert!(is_regular_lattice(&regular));
5636
5637        let small: Vec<ColumnCluster> = [100.0, 200.0, 300.0].iter().map(|&x| col_at(x)).collect();
5638        assert!(!is_regular_lattice(&small));
5639
5640        // Irregular gaps (prose that happened to align) → rejected. ~keep
5641        let irregular: Vec<ColumnCluster> = [100.0, 140.0, 320.0, 330.0, 500.0, 505.0]
5642            .iter()
5643            .map(|&x| col_at(x))
5644            .collect();
5645        assert!(!is_regular_lattice(&irregular));
5646    }
5647
5648    #[test]
5649    fn test_is_data_value() {
5650        for v in ["5,012", "+2%", "240", "-1.5", "1,000.50", "67", "\u{2212}3"] {
5651            assert!(is_data_value(v), "{v:?} should be a data value");
5652        }
5653        for w in ["FY22", "Mercury", "Body", "", "YoY", "$", "+", "Q1"] {
5654            assert!(!is_data_value(w), "{w:?} should NOT be a data value");
5655        }
5656    }
5657
5658    #[test]
5659    fn test_quality_gate_admits_numeric_table_rejects_prose_split() {
5660        let row = |cells: &[&str]| TableRow {
5661            cells: cells.iter().map(|c| prose_cell(c)).collect(),
5662            is_header: false,
5663        };
5664        // A dense numeric metrics table: ~all single-token numeric cells. Must
5665        // PASS (the prose ratio excludes data values). ~keep
5666        let mut numeric = Table::new();
5667        numeric.col_count = 8;
5668        for r in [
5669            ["Body", "FY22", "FY23", "FY24", "FY25", "YoY", "Plan", "Var"],
5670            [
5671                "Mercury Transits",
5672                "5,012",
5673                "5,210",
5674                "5,488",
5675                "5,612",
5676                "+2%",
5677                "5,600",
5678                "+12",
5679            ],
5680            [
5681                "Venus Phases",
5682                "1,840",
5683                "1,902",
5684                "1,975",
5685                "2,041",
5686                "+3%",
5687                "2,030",
5688                "+11",
5689            ],
5690        ] {
5691            numeric.rows.push(row(&r));
5692        }
5693        assert!(
5694            passes_spatial_quality_gate(&numeric),
5695            "dense numeric table must pass the spatial quality gate"
5696        );
5697
5698        // Prose accidentally split into single-word columns must still be REJECTED. ~keep
5699        let mut prose = Table::new();
5700        prose.col_count = 6;
5701        for _ in 0..3 {
5702            prose.rows.push(row(&["the", "quick", "brown", "fox", "jumps", "over"]));
5703        }
5704        assert!(
5705            !passes_spatial_quality_gate(&prose),
5706            "word-dominated single-word split must still be rejected"
5707        );
5708    }
5709
5710    fn prose_cell(text: &str) -> TableCell {
5711        TableCell {
5712            text: text.to_string(),
5713            spans: Vec::new(),
5714            colspan: 1,
5715            rowspan: 1,
5716            mcids: Vec::new(),
5717            bbox: None,
5718            is_header: false,
5719        }
5720    }
5721
5722    #[test]
5723    fn test_looks_like_cjk_prose() {
5724        let row = |cells: &[&str]| TableRow {
5725            cells: cells.iter().map(|c| prose_cell(c)).collect(),
5726            is_header: false,
5727        };
5728        // CJK prose mis-split into columns: a long ideograph/kana run in a cell. ~keep
5729        let mut prose = Table::new();
5730        prose.col_count = 2;
5731        prose.rows.push(row(&[
5732            "\u{30CD}\u{30B3}",
5733            "\u{72ED}\u{7FA9}\u{306B}\u{306F}\u{98DF}\u{8089}\u{76EE}\u{30CD}\u{30B3}\u{79D1}\u{30CD}\u{30B3}\u{5C5E}",
5734        ]));
5735        assert!(looks_like_cjk_prose(&prose));
5736
5737        // A genuine CJK data table — short label + number cells — is NOT prose. ~keep
5738        let mut table = Table::new();
5739        table.col_count = 2;
5740        table.rows.push(row(&["\u{58F2}\u{4E0A}", "1,234"]));
5741        table.rows.push(row(&["\u{5229}\u{76CA}", "567"]));
5742        assert!(!looks_like_cjk_prose(&table));
5743
5744        // Latin prose has no long CJK run. ~keep
5745        let mut latin = Table::new();
5746        latin.col_count = 2;
5747        latin.rows.push(row(&["Region", "North"]));
5748        assert!(!looks_like_cjk_prose(&latin));
5749    }
5750
5751    #[test]
5752    fn test_looks_like_bulleted_list_rejects_bullet_cells() {
5753        let row = |cells: &[&str]| TableRow {
5754            cells: cells.iter().map(|c| prose_cell(c)).collect(),
5755            is_header: false,
5756        };
5757        // A bulleted list mis-fused into two columns: lone bullet markers in
5758        // the first column → recognise as a list, not a table. ~keep
5759        let mut list = Table::new();
5760        list.col_count = 2;
5761        list.rows.push(row(&["\u{2022}", "Ship the API."]));
5762        list.rows.push(row(&["\u{2022}", "Write the docs."]));
5763        assert!(looks_like_bulleted_list(&list));
5764
5765        // A genuine two-column data table has no lone-bullet cell. ~keep
5766        let mut table = Table::new();
5767        table.col_count = 2;
5768        table.rows.push(row(&["Region", "Q1"]));
5769        table.rows.push(row(&["North", "120"]));
5770        assert!(!looks_like_bulleted_list(&table));
5771
5772        // A cell that merely *starts* with a dash but carries text (a value or
5773        // a negative number) is not a bullet marker. ~keep
5774        let mut dash = Table::new();
5775        dash.col_count = 2;
5776        dash.rows.push(row(&["Delta", "-12"]));
5777        assert!(!looks_like_bulleted_list(&dash));
5778    }
5779
5780    /// Prose gate: a wrapped paragraph mis-split into a table — a row
5781    /// crossing a sentence boundary ("...to 23,500. Stockout rate...") must
5782    /// be recognised as prose and rejected.
5783    #[test]
5784    fn test_looks_like_prose_paragraph_detects_sentence_crossing_row() {
5785        let mut t = Table::new();
5786        t.col_count = 4;
5787        t.rows.push(TableRow {
5788            cells: vec![
5789                prose_cell("Total SKU count grew 15%"),
5790                prose_cell("quarter-over-quarter to"),
5791                prose_cell("23,500."),
5792                prose_cell("Stockout rate improved by 200 basis"),
5793            ],
5794            is_header: false,
5795        });
5796        assert!(looks_like_prose_paragraph(&t));
5797    }
5798
5799    /// Caseless scripts (Bengali here) have no capital-letter signal at all,
5800    /// so their sentence-final danda ('।') must be treated as a terminator
5801    /// in its own right. Real-world PDFs sometimes render the danda with a
5802    /// stray gap before it ("প্রাণী ।"), so the check must tolerate that.
5803    #[test]
5804    fn test_looks_like_prose_paragraph_detects_bengali_danda_crossing_row() {
5805        let mut t = Table::new();
5806        t.col_count = 4;
5807        t.rows.push(TableRow {
5808            cells: vec![
5809                prose_cell("বিড়াল একটি গার্হস্থ্য প্রজাতি"),
5810                prose_cell("স্তন্যপায়ী প্রাণী"),
5811                prose_cell("। এটি"),
5812                prose_cell("ফেলিডা পরিবারের একমাত্র গৃহপালিত প্রজাতি"),
5813            ],
5814            is_header: false,
5815        });
5816        assert!(looks_like_prose_paragraph(&t));
5817    }
5818
5819    /// REGRESSION GUARD: a genuine data table (short value/label cells, no
5820    /// sentence crossing a row) must NOT be flagged as prose.
5821    #[test]
5822    fn test_looks_like_prose_paragraph_keeps_real_table() {
5823        let mut t = Table::new();
5824        t.col_count = 4;
5825        for cells in [
5826            ["Zone", "Pallets stored", "11,100", "-2.5%"],
5827            ["A", "Utilization", "87%", "-3pp"],
5828            ["B", "Damage rate", "0.3%", "-0.2pp"],
5829        ] {
5830            t.rows.push(TableRow {
5831                cells: cells.iter().map(|c| prose_cell(c)).collect(),
5832                is_header: false,
5833            });
5834        }
5835        assert!(!looks_like_prose_paragraph(&t));
5836    }
5837
5838    #[test]
5839    fn test_line_clustering_multiple_tables() {
5840        let lines = vec![
5841            make_rect_path(10.0, 100.0, 50.0, 20.0),
5842            make_rect_path(10.0, 50.0, 50.0, 20.0), // Far away vertically ~keep
5843        ];
5844        let config = TableDetectionConfig::default();
5845        let clusters = group_lines_into_clusters(&lines, &config);
5846        assert_eq!(
5847            clusters.len(),
5848            2,
5849            "Should find 2 separate table regions with optimized clustering"
5850        );
5851    }
5852
5853    #[test]
5854    fn test_line_clustering_horizontal_separation() {
5855        let lines = vec![
5856            make_rect_path(10.0, 100.0, 50.0, 20.0), // Table 1: x=10..60 ~keep
5857            make_rect_path(80.0, 100.0, 50.0, 20.0), // Table 2: x=80..130 (20pt gap) ~keep
5858        ];
5859        let config = TableDetectionConfig::default();
5860        let clusters = group_lines_into_clusters(&lines, &config);
5861        assert_eq!(
5862            clusters.len(),
5863            2,
5864            "Should find 2 separate table regions even if nearby horizontally"
5865        );
5866    }
5867
5868    /// GH#1656: a page-sized background rectangle unions every primitive on
5869    /// the page into one cluster (`is_table_primitive`'s `<1000pt` bound
5870    /// admits an A4/Letter page). Document-level callers
5871    /// (`document/tables.rs`) now drop such rectangles via
5872    /// `PathContent::is_page_frame_rectangle` before calling
5873    /// `group_lines_into_clusters`. This documents both the pre-fix
5874    /// behavior (frame present → 1 cluster) and the fixed behavior (frame
5875    /// filtered → the table, the footer rule, and the figure border stay
5876    /// in 3 separate clusters). ~keep
5877    #[test]
5878    fn test_gh1656_page_frame_unions_clusters_until_caller_filters_it() {
5879        let media_box = (0.0, 0.0, 595.28, 842.0);
5880        let page_frame = make_rect_path(0.0, 0.0, 595.28, 842.0);
5881
5882        // A small ruled 2-column table, matching the reporter's control
5883        // page 2 bbox (x[45.36,321.84] y[472.70,609.50]). ~keep
5884        let table_lines = vec![
5885            make_v_line(45.36, 472.70, 136.80),
5886            make_v_line(321.84, 472.70, 136.80),
5887            make_h_line(45.36, 609.50, 276.48),
5888            make_h_line(45.36, 472.70, 276.48),
5889        ];
5890        // A full-page-width footer rule, far below the table (y gap far
5891        // beyond the 3pt cluster expansion) — legitimate ruling, not
5892        // furniture. ~keep
5893        let footer_rule = make_h_line(0.0, 48.30, 595.28);
5894        // A figure border, far from both the table and the footer rule in
5895        // both axes. ~keep
5896        let figure_border = make_rect_path(400.0, 700.0, 100.0, 80.0);
5897
5898        let mut all = vec![page_frame];
5899        all.extend(table_lines.clone());
5900        all.push(footer_rule.clone());
5901        all.push(figure_border.clone());
5902
5903        let config = TableDetectionConfig::default();
5904        let clusters_with_frame = group_lines_into_clusters(&all, &config);
5905        assert_eq!(
5906            clusters_with_frame.len(),
5907            1,
5908            "page-sized background rect must union every primitive (documents today's defect)"
5909        );
5910
5911        let filtered: Vec<_> = all
5912            .into_iter()
5913            .filter(|p| !p.is_page_frame_rectangle(media_box))
5914            .collect();
5915        assert_eq!(
5916            filtered.len(),
5917            6,
5918            "the filter must remove exactly the page frame and nothing else"
5919        );
5920
5921        let clusters_without_frame = group_lines_into_clusters(&filtered, &config);
5922        assert_eq!(
5923            clusters_without_frame.len(),
5924            3,
5925            "table, footer rule, and figure border must stay separate once the frame is filtered"
5926        );
5927    }
5928
5929    /// GH#1656 (the reporter's "observed alongside"): the carrier's drawing style, Word's way
5930    /// of drawing cell borders -- every rule cut at the crossings into per-cell 0.48pt filled
5931    /// bars with 0.48pt corner squares between them. The reporter's page-28 coordinates:
5932    /// 13 H rules at an 11.40pt pitch from y 472.46, V rules at x 45.12 / 151.50 / 321.60. ~keep
5933    fn gh1656_segmented_rule_table() -> (Vec<TextSpan>, Vec<crate::elements::PathContent>) {
5934        const BAR: f32 = 0.48;
5935        const PITCH: f32 = 11.40;
5936        const Y0: f32 = 472.46;
5937        const ROWS: usize = 12;
5938        let col_xs = [45.12_f32, 151.50, 321.60];
5939        let mut paths = Vec::new();
5940        for r in 0..=ROWS {
5941            let y = Y0 + r as f32 * PITCH;
5942            for pair in col_xs.windows(2) {
5943                paths.push(make_rect_path(pair[0] + BAR, y, pair[1] - pair[0] - BAR, BAR));
5944            }
5945            for &x in &col_xs {
5946                paths.push(make_rect_path(x, y, BAR, BAR));
5947            }
5948            if r < ROWS {
5949                for &x in &col_xs {
5950                    paths.push(make_rect_path(x, y + BAR, BAR, PITCH - BAR));
5951                }
5952            }
5953        }
5954        let mut spans = Vec::new();
5955        for r in 0..ROWS {
5956            let y = Y0 + (ROWS - 1 - r) as f32 * PITCH + 2.0;
5957            spans.push(create_test_span(&format!("{}", r + 1), 50.0, y, 10.0, 8.0));
5958            spans.push(create_test_span(&format!("{}", 130 + r * 15), 160.0, y, 20.0, 8.0));
5959        }
5960        (spans, paths)
5961    }
5962
5963    /// GH#1656: with per-cell segmented rules, `build_grid_from_lines` found all 24 cells and
5964    /// 12 row bands, yet the table came back as its last two rows. `split_table_at_section_dividers`
5965    /// treated every internal H rule as a section divider, because every V segment terminates
5966    /// at every rule in this drawing style, split the table into one-row pieces and dropped
5967    /// the pieces below `min_table_cells`. A rule that *every* V rule stops at carries no
5968    /// section information when the same is true of every other rule on the table. ~keep
5969    #[test]
5970    fn gh1656_per_cell_segmented_rules_keep_every_row_of_the_table() {
5971        let (spans, paths) = gh1656_segmented_rule_table();
5972        let table_paths: Vec<_> = paths.into_iter().filter(|p| p.is_table_primitive()).collect();
5973        let config = TableDetectionConfig {
5974            horizontal_strategy: TableStrategy::Lines,
5975            vertical_strategy: TableStrategy::Lines,
5976            ..Default::default()
5977        };
5978
5979        let (groups, _, _) = build_grid_from_lines(&table_paths, &config);
5980        assert_eq!(groups.len(), 1, "fixture assumption: one cell group");
5981        assert_eq!(groups[0].0.len(), 24, "fixture assumption: the grid itself is complete");
5982        assert_eq!(groups[0].2.len(), 13, "fixture assumption: 12 row bands");
5983
5984        let tables = detect_tables_with_lines(&spans, &table_paths, &config);
5985        assert_eq!(tables.len(), 1, "one table, not one-row fragments: {tables:?}");
5986        let table = &tables[0];
5987        assert_eq!(table.col_count, 2);
5988        assert_eq!(
5989            table.rows.len(),
5990            12,
5991            "every row band must survive, got {:?}",
5992            table
5993                .rows
5994                .iter()
5995                .map(|r| r.cells.iter().map(|c| c.text.as_str()).collect::<Vec<_>>())
5996                .collect::<Vec<_>>()
5997        );
5998        assert_eq!(
5999            table.rows[0].cells.iter().map(|c| c.text.as_str()).collect::<Vec<_>>(),
6000            ["1", "130"]
6001        );
6002        assert_eq!(
6003            table.rows[11].cells.iter().map(|c| c.text.as_str()).collect::<Vec<_>>(),
6004            ["12", "295"]
6005        );
6006    }
6007
6008    fn create_test_span(text: &str, x: f32, y: f32, width: f32, height: f32) -> TextSpan {
6009        TextSpan {
6010            provenance: None,
6011            text_rise: 0.0,
6012            artifact_type: None,
6013            text: text.to_string(),
6014            bbox: Rect::new(x, y, width, height),
6015            font_name: "TestFont".to_string(),
6016            font_size: 12.0,
6017            font_weight: FontWeight::Normal,
6018            is_italic: false,
6019            is_monospace: false,
6020            color: Color::black(),
6021            mcid: None,
6022            mcid_scope: None,
6023            sequence: 0,
6024            split_boundary_before: false,
6025            offset_semantic: false,
6026            char_spacing: 0.0,
6027            word_spacing: 0.0,
6028            horizontal_scaling: 1.0,
6029            primary_detected: false,
6030            char_widths: vec![],
6031            char_x_offsets: Vec::new(),
6032            heading_level: None,
6033            rotation_degrees: 0.0,
6034            wmode: 0,
6035            rtl_draw_logical: false,
6036            mirrored: false,
6037            page_rotation_applied: 0,
6038        }
6039    }
6040    fn make_h_line(x: f32, y: f32, width: f32) -> crate::elements::PathContent {
6041        crate::elements::PathContent::line(x, y, x + width, y)
6042    }
6043    fn make_v_line(x: f32, y: f32, height: f32) -> crate::elements::PathContent {
6044        crate::elements::PathContent::line(x, y, x, y + height)
6045    }
6046    fn make_line_path(x1: f32, y1: f32, x2: f32, y2: f32) -> crate::elements::PathContent {
6047        crate::elements::PathContent::line(x1, y1, x2, y2)
6048    }
6049    fn make_rect_path(x: f32, y: f32, w: f32, h: f32) -> crate::elements::PathContent {
6050        crate::elements::PathContent::rect(x, y, w, h)
6051    }
6052
6053    /// An agenda-style table has 3 real columns (Time @72,
6054    /// Activity @200, Team @420). The Activity cell holds multiple words
6055    /// laid out with wide gaps ("Receiving Dock Inspection"), each at a
6056    /// distinct X that occurs in only ONE row. Greedy column clustering
6057    /// turns every word X into a column; the cross-row text-edge
6058    /// detector must instead recover the 3 real columns whose edges
6059    /// recur across rows. Asserts the detected table has 3 columns, not
6060    /// one-per-word.
6061    #[test]
6062    fn test_issue6_agenda_words_not_split_into_columns() {
6063        // y descending = rows top→bottom. 4 rows incl. header. ~keep
6064        let spans = vec![
6065            create_test_span("Time", 72.0, 638.6, 24.4, 12.0),
6066            create_test_span("Activity", 200.0, 638.6, 34.8, 12.0),
6067            create_test_span("Team", 420.0, 638.6, 28.1, 12.0),
6068            create_test_span("06:00 - 07:00", 72.0, 610.6, 61.1, 12.0),
6069            create_test_span("Receiving", 200.0, 610.6, 43.9, 12.0),
6070            create_test_span("Dock", 249.9, 610.6, 22.8, 12.0),
6071            create_test_span("Inspection", 278.7, 610.6, 45.6, 12.0),
6072            create_test_span("Inbound Team", 420.0, 610.6, 65.7, 12.0),
6073            create_test_span("07:00 - 09:00", 72.0, 582.6, 61.1, 12.0),
6074            create_test_span("Bulk", 200.0, 582.6, 19.5, 12.0),
6075            create_test_span("Putaway", 225.4, 582.6, 38.3, 12.0),
6076            create_test_span("Slotting", 282.5, 582.6, 33.4, 12.0),
6077            create_test_span("Warehouse Ops", 420.0, 582.6, 73.5, 12.0),
6078            create_test_span("09:00 - 11:00", 72.0, 554.6, 61.1, 12.0),
6079            create_test_span("Pick", 200.0, 554.6, 18.9, 12.0),
6080            create_test_span("Wave", 230.0, 554.6, 24.0, 12.0),
6081            create_test_span("Processing", 262.0, 554.6, 48.0, 12.0),
6082            create_test_span("Fulfillment", 420.0, 554.6, 55.0, 12.0),
6083        ];
6084        let config = TableDetectionConfig::default();
6085        let tables = detect_tables_from_spans(&spans, &config);
6086        // Either no table (acceptable — agenda is borderline tabular) or
6087        // a table with the 3 real columns. What must NOT happen: a table
6088        // with one column per Activity word (>= 5 columns). ~keep
6089        if let Some(t) = tables.first() {
6090            let ncols = t.rows.iter().map(|r| r.cells.len()).max().unwrap_or(0);
6091            assert!(
6092                ncols <= 4,
6093                "agenda must not fragment Activity words into columns; got {} cols",
6094                ncols
6095            );
6096        }
6097    }
6098
6099    #[test]
6100    fn test_lines_strategy_no_lines_returns_empty() {
6101        let spans = vec![
6102            create_test_span("A", 10.0, 100.0, 10.0, 10.0),
6103            create_test_span("B", 50.0, 100.0, 10.0, 10.0),
6104            create_test_span("C", 10.0, 80.0, 10.0, 10.0),
6105            create_test_span("D", 50.0, 80.0, 10.0, 10.0),
6106        ];
6107        let config = TableDetectionConfig {
6108            horizontal_strategy: TableStrategy::Lines,
6109            vertical_strategy: TableStrategy::Lines,
6110            ..TableDetectionConfig::default()
6111        };
6112        assert!(detect_tables_with_lines(&spans, &[], &config).is_empty());
6113    }
6114
6115    #[test]
6116    fn test_horizontal_lines_only_strategy_no_false_positives() {
6117        let spans = vec![
6118            create_test_span("A", 10.0, 100.0, 10.0, 10.0),
6119            create_test_span("B", 50.0, 100.0, 10.0, 10.0),
6120            create_test_span("C", 10.0, 80.0, 10.0, 10.0),
6121            create_test_span("D", 50.0, 80.0, 10.0, 10.0),
6122        ];
6123        let config = TableDetectionConfig {
6124            horizontal_strategy: TableStrategy::Lines,
6125            vertical_strategy: TableStrategy::Both,
6126            ..TableDetectionConfig::default()
6127        };
6128        assert!(detect_tables_with_lines(&spans, &[], &config).is_empty());
6129    }
6130
6131    /// Text-only spatial fallback for line-less tables.
6132    ///
6133    /// `text_fallback = true` is now the default on `TableDetectionConfig` (the
6134    /// prose-shape filter and ≥3-row guard suppress the false positives that
6135    /// previously motivated a `false` default).  With the default and the `Both`
6136    /// strategy, `detect_tables_with_lines` with an empty lines slice falls
6137    /// through to the text-based path and detects the grid from span alignment
6138    /// alone.  Callers that explicitly want the conservative
6139    /// "no ruling lines → no tables" behaviour set `text_fallback = false` and
6140    /// the `extract_page_tables` early-return guard in document.rs short-circuits
6141    /// before this code is reached.
6142    ///
6143    /// This test directly calls `detect_tables_with_lines` with an empty lines
6144    /// slice to verify that the text-based path inside it finds the table.
6145    #[test]
6146    fn test_text_fallback_detects_lineless_grid() {
6147        let spans = vec![
6148            create_test_span("Pos", 10.0, 200.0, 25.0, 10.0),
6149            create_test_span("Boat", 50.0, 200.0, 25.0, 10.0),
6150            create_test_span("Pts", 90.0, 200.0, 20.0, 10.0),
6151            create_test_span("1", 10.0, 180.0, 25.0, 10.0),
6152            create_test_span("Alpha", 50.0, 180.0, 25.0, 10.0),
6153            create_test_span("14", 90.0, 180.0, 20.0, 10.0),
6154            create_test_span("2", 10.0, 160.0, 25.0, 10.0),
6155            create_test_span("Beta", 50.0, 160.0, 25.0, 10.0),
6156            create_test_span("17", 90.0, 160.0, 20.0, 10.0),
6157            create_test_span("3", 10.0, 140.0, 25.0, 10.0),
6158            create_test_span("Gamma", 50.0, 140.0, 25.0, 10.0),
6159            create_test_span("21", 90.0, 140.0, 20.0, 10.0),
6160        ];
6161
6162        // With the Both strategy, NO lines, and text_fallback explicitly
6163        // enabled, the text-based fallback inside detect_tables_with_lines
6164        // fires and finds the grid.  The default no longer enables
6165        // text_fallback to avoid spurious tables on report-style PDFs that
6166        // would otherwise be double-emitted by extract_text. ~keep
6167        let config = TableDetectionConfig {
6168            text_fallback: true,
6169            ..TableDetectionConfig::default()
6170        };
6171        let tables = detect_tables_with_lines(&spans, &[], &config);
6172        assert_eq!(
6173            tables.len(),
6174            1,
6175            "Text-only fallback in detect_tables_with_lines should detect the grid (got {:?} tables)",
6176            tables.len()
6177        );
6178        let t = &tables[0];
6179        assert_eq!(t.col_count, 3, "Should detect 3 columns");
6180        assert_eq!(t.rows.len(), 4, "Should detect 4 rows");
6181    }
6182
6183    /// Verify that when no lines are present and `text_fallback = false` (the default),
6184    /// the guard in `extract_page_tables` (outside `detect_tables_with_lines`) would
6185    /// prevent the text path from running.  We simulate this at the config level: using
6186    /// a `Lines`-only strategy ensures `detect_tables_with_lines` returns nothing when
6187    /// paths are empty — confirming the safety contract for the public API path.
6188    #[test]
6189    fn test_text_fallback_disabled_lines_strategy_returns_empty() {
6190        let spans = vec![
6191            create_test_span("Pos", 10.0, 200.0, 25.0, 10.0),
6192            create_test_span("Boat", 50.0, 200.0, 25.0, 10.0),
6193            create_test_span("Pts", 90.0, 200.0, 20.0, 10.0),
6194            create_test_span("1", 10.0, 180.0, 25.0, 10.0),
6195            create_test_span("Alpha", 50.0, 180.0, 25.0, 10.0),
6196            create_test_span("14", 90.0, 180.0, 20.0, 10.0),
6197        ];
6198        // Lines-only strategy: no lines → no tables.  This is what the public
6199        // extract_tables() API uses after the early-return guard fires. ~keep
6200        let config = TableDetectionConfig::strict(); // strict() uses Lines/Lines ~keep
6201        let tables = detect_tables_with_lines(&spans, &[], &config);
6202        assert!(
6203            tables.is_empty(),
6204            "Lines-only strategy with no ruling lines should return no tables"
6205        );
6206    }
6207
6208    #[test]
6209    fn test_table_splitting_on_empty_row() {
6210        let spans = vec![
6211            create_test_span("T1-11", 20.0, 115.0, 10.0, 10.0),
6212            create_test_span("T1-12", 40.0, 115.0, 10.0, 10.0),
6213            create_test_span("T1-21", 20.0, 95.0, 10.0, 10.0),
6214            create_test_span("T1-22", 40.0, 95.0, 10.0, 10.0),
6215            create_test_span("T2-11", 20.0, 35.0, 10.0, 10.0),
6216            create_test_span("T2-12", 40.0, 35.0, 10.0, 10.0),
6217            create_test_span("T2-21", 20.0, 15.0, 10.0, 10.0),
6218            create_test_span("T2-22", 40.0, 15.0, 10.0, 10.0),
6219        ];
6220        let lines = vec![
6221            make_h_line(10.0, 130.0, 50.0),
6222            make_h_line(10.0, 110.0, 50.0),
6223            make_h_line(10.0, 90.0, 50.0),
6224            make_v_line(10.0, 90.0, 40.0),
6225            make_v_line(30.0, 90.0, 40.0),
6226            make_v_line(60.0, 90.0, 40.0),
6227            make_h_line(10.0, 50.0, 50.0),
6228            make_h_line(10.0, 30.0, 50.0),
6229            make_h_line(10.0, 10.0, 50.0),
6230            make_v_line(10.0, 10.0, 40.0),
6231            make_v_line(30.0, 10.0, 40.0),
6232            make_v_line(60.0, 10.0, 40.0),
6233            make_v_line(10.0, 50.0, 40.0),
6234        ];
6235        let config = TableDetectionConfig {
6236            horizontal_strategy: TableStrategy::Both,
6237            vertical_strategy: TableStrategy::Both,
6238            ..TableDetectionConfig::default()
6239        };
6240        assert_eq!(detect_tables_with_lines(&spans, &lines, &config).len(), 2);
6241    }
6242
6243    #[test]
6244    fn test_detect_columns_invoice_4_columns() {
6245        let spans = vec![
6246            create_test_span("01/01", 50.0, 100.0, 50.0, 10.0),
6247            create_test_span("Widget", 130.0, 100.0, 220.0, 10.0),
6248            create_test_span("$100", 500.0, 100.0, 50.0, 10.0),
6249            create_test_span("$0", 600.0, 100.0, 50.0, 10.0),
6250            create_test_span("02/15", 50.0, 80.0, 50.0, 10.0),
6251            create_test_span("Service fee", 130.0, 80.0, 220.0, 10.0),
6252            create_test_span("$250", 500.0, 80.0, 50.0, 10.0),
6253            create_test_span("$50", 600.0, 80.0, 50.0, 10.0),
6254            create_test_span("03/20", 50.0, 60.0, 50.0, 10.0),
6255            create_test_span("Consulting", 130.0, 60.0, 220.0, 10.0),
6256            create_test_span("$500", 500.0, 60.0, 50.0, 10.0),
6257            create_test_span("$100", 600.0, 60.0, 50.0, 10.0),
6258        ];
6259        let config = TableDetectionConfig::default();
6260        let columns = detect_columns(&spans, config.column_tolerance, config.column_merge_threshold);
6261        assert_eq!(
6262            columns.len(),
6263            4,
6264            "Invoice with 4 distinct column groups should produce exactly 4 columns, got {}",
6265            columns.len()
6266        );
6267    }
6268
6269    #[test]
6270    fn test_detect_columns_merges_nearby_clusters() {
6271        let spans = vec![
6272            create_test_span("A", 50.0, 100.0, 30.0, 10.0),
6273            create_test_span("B", 130.0, 100.0, 30.0, 10.0),
6274            create_test_span("C", 50.0, 80.0, 30.0, 10.0),
6275            create_test_span("D", 135.0, 80.0, 30.0, 10.0),
6276            create_test_span("E", 50.0, 60.0, 30.0, 10.0),
6277            create_test_span("F", 140.0, 60.0, 30.0, 10.0),
6278        ];
6279        let config = TableDetectionConfig::default();
6280        let columns = detect_columns(&spans, config.column_tolerance, config.column_merge_threshold);
6281        assert_eq!(
6282            columns.len(),
6283            2,
6284            "Spans at x=130/135/140 should merge into 1 column, plus x=50 = 2 total, got {}",
6285            columns.len()
6286        );
6287    }
6288
6289    #[test]
6290    fn test_detect_columns_dense_pitch_stays_distinct() {
6291        // A dense numeric table: 6 columns on a 20pt pitch, each column's
6292        // spans sharing an exact left-x (so the *first* clustering pass,
6293        // gated on column_tolerance=15, keeps every column separate — this
6294        // isolates the merge-threshold pass specifically). Under the
6295        // default config's *fixed* 25pt column_merge_threshold, every
6296        // adjacent 20pt gap is below the threshold, so the old code fused
6297        // all 6 into 1. The threshold must scale down with this table's own
6298        // (much smaller than 25pt) pitch so the columns survive distinct. ~keep
6299        let mut spans = Vec::new();
6300        for row in 0..3 {
6301            let y = 100.0 - row as f32 * 20.0;
6302            for col in 0..6 {
6303                let x = 20.0 + col as f32 * 20.0;
6304                spans.push(create_test_span("9", x, y, 8.0, 10.0));
6305            }
6306        }
6307        let config = TableDetectionConfig::default();
6308        let columns = detect_columns(&spans, config.column_tolerance, config.column_merge_threshold);
6309        assert_eq!(
6310            columns.len(),
6311            6,
6312            "a 20pt-pitch dense table must keep all 6 columns distinct \
6313             despite the 25pt fixed merge_threshold, got {}",
6314            columns.len()
6315        );
6316    }
6317
6318    #[test]
6319    fn test_detect_columns_order_independent() {
6320        let spans_ordered = vec![
6321            create_test_span("A", 50.0, 100.0, 30.0, 10.0),
6322            create_test_span("B", 200.0, 100.0, 30.0, 10.0),
6323            create_test_span("C", 400.0, 100.0, 30.0, 10.0),
6324            create_test_span("D", 50.0, 80.0, 30.0, 10.0),
6325            create_test_span("E", 200.0, 80.0, 30.0, 10.0),
6326            create_test_span("F", 400.0, 80.0, 30.0, 10.0),
6327        ];
6328        let spans_reversed = vec![
6329            create_test_span("F", 400.0, 80.0, 30.0, 10.0),
6330            create_test_span("E", 200.0, 80.0, 30.0, 10.0),
6331            create_test_span("D", 50.0, 80.0, 30.0, 10.0),
6332            create_test_span("C", 400.0, 100.0, 30.0, 10.0),
6333            create_test_span("B", 200.0, 100.0, 30.0, 10.0),
6334            create_test_span("A", 50.0, 100.0, 30.0, 10.0),
6335        ];
6336        let config = TableDetectionConfig::default();
6337        let cols_ordered = detect_columns(&spans_ordered, config.column_tolerance, config.column_merge_threshold);
6338        let cols_reversed = detect_columns(&spans_reversed, config.column_tolerance, config.column_merge_threshold);
6339        assert_eq!(
6340            cols_ordered.len(),
6341            cols_reversed.len(),
6342            "Column count should be independent of span order"
6343        );
6344        let centers_ordered: Vec<f32> = cols_ordered.iter().map(|c| (c.x_center * 10.0).round()).collect();
6345        let centers_reversed: Vec<f32> = cols_reversed.iter().map(|c| (c.x_center * 10.0).round()).collect();
6346        assert_eq!(
6347            centers_ordered, centers_reversed,
6348            "Column centers should match regardless of input order"
6349        );
6350    }
6351
6352    #[test]
6353    fn test_detect_header_row_returns_none_when_no_heuristic_matches() {
6354        // All spans have same font size, none bold -- no header signal ~keep
6355        let spans = vec![
6356            create_test_span("A", 10.0, 100.0, 30.0, 10.0),
6357            create_test_span("B", 50.0, 100.0, 30.0, 10.0),
6358            create_test_span("C", 10.0, 80.0, 30.0, 10.0),
6359            create_test_span("D", 50.0, 80.0, 30.0, 10.0),
6360        ];
6361        let columns = detect_columns(&spans, 15.0, 25.0);
6362        let rows = detect_rows(&spans, 2.8);
6363        let grid = assign_spans_to_cells(&spans, &columns, &rows);
6364        let header = detect_header_row(&grid, &spans);
6365        assert_eq!(header, None, "Should return None when no heuristic matches");
6366    }
6367
6368    #[test]
6369    fn test_hierarchical_header_with_visual_heuristic() {
6370        let spans = vec![
6371            create_test_span("H1", 10.0, 115.0, 35.0, 10.0),
6372            create_test_span("H2", 55.0, 115.0, 35.0, 10.0),
6373            create_test_span("Col 1", 10.0, 95.0, 35.0, 10.0),
6374            create_test_span("Col 2", 55.0, 95.0, 35.0, 10.0),
6375            create_test_span("Data 1", 10.0, 75.0, 35.0, 10.0),
6376            create_test_span("Data 2", 55.0, 75.0, 35.0, 10.0),
6377        ];
6378        let lines = vec![
6379            make_line_path(10.0, 130.0, 90.0, 130.0),
6380            make_line_path(10.0, 110.0, 90.0, 110.0),
6381            make_line_path(10.0, 90.0, 90.0, 90.0),
6382            make_v_line(10.0, 70.0, 60.0),
6383            make_v_line(50.0, 70.0, 20.0),
6384            make_v_line(90.0, 70.0, 60.0),
6385        ];
6386        let config = TableDetectionConfig::default();
6387        let tables = detect_tables_with_lines(&spans, &lines, &config);
6388        assert_eq!(tables.len(), 1);
6389        assert!(tables[0].rows[0].is_header);
6390        assert!(tables[0].rows[1].is_header);
6391    }
6392
6393    #[test]
6394    fn test_intersection_basic_2x2_table() {
6395        let lines = vec![
6396            make_h_line(50.0, 100.0, 350.0),
6397            make_h_line(50.0, 200.0, 350.0),
6398            make_h_line(50.0, 300.0, 350.0),
6399            make_v_line(50.0, 100.0, 200.0),
6400            make_v_line(200.0, 100.0, 200.0),
6401            make_v_line(400.0, 100.0, 200.0),
6402        ];
6403        let spans = vec![
6404            create_test_span("A1", 120.0, 145.0, 20.0, 10.0),
6405            create_test_span("B1", 295.0, 145.0, 20.0, 10.0),
6406            create_test_span("A2", 120.0, 245.0, 20.0, 10.0),
6407            create_test_span("B2", 295.0, 245.0, 20.0, 10.0),
6408        ];
6409        let config = TableDetectionConfig {
6410            horizontal_strategy: TableStrategy::Lines,
6411            vertical_strategy: TableStrategy::Lines,
6412            min_table_cells: 4,
6413            min_table_columns: 2,
6414            ..TableDetectionConfig::default()
6415        };
6416        let tables = detect_tables_with_lines(&spans, &lines, &config);
6417        assert_eq!(tables.len(), 1, "Should detect exactly 1 table");
6418        let table = &tables[0];
6419        assert_eq!(table.rows.len(), 2, "Should have 2 rows");
6420        assert_eq!(table.col_count, 2, "Should have 2 columns");
6421
6422        // Higher y = higher on page, so rows sorted descending by y.
6423        // Row at y=[200,300] (higher) comes first in display order.
6424        // Row at y=[100,200] (lower) comes second. ~keep
6425        let r0_texts: Vec<&str> = table.rows[0].cells.iter().map(|c| c.text.as_str()).collect();
6426        let r1_texts: Vec<&str> = table.rows[1].cells.iter().map(|c| c.text.as_str()).collect();
6427        assert_eq!(r0_texts, vec!["A2", "B2"], "Top row (higher y) should be A2, B2");
6428        assert_eq!(r1_texts, vec!["A1", "B1"], "Bottom row (lower y) should be A1, B1");
6429    }
6430
6431    #[test]
6432    fn test_intersection_snap_and_merge_edges() {
6433        // Two H edges at y=100 and y=101.5 (within SNAP_TOL=3) should snap. ~keep
6434        let mut edges = vec![
6435            Edge {
6436                coord: 100.0,
6437                start: 0.0,
6438                end: 50.0,
6439            },
6440            Edge {
6441                coord: 101.5,
6442                start: 0.0,
6443                end: 50.0,
6444            },
6445        ];
6446        snap_and_merge(&mut edges);
6447        assert_eq!(edges.len(), 1, "Snapped edges should merge into 1");
6448        assert!((edges[0].coord - 100.0).abs() < 0.01);
6449    }
6450
6451    #[test]
6452    fn test_intersection_join_collinear_segments() {
6453        // Two segments on same coord, gap of 2pt (within JOIN_TOL=3). ~keep
6454        let mut edges = vec![
6455            Edge {
6456                coord: 100.0,
6457                start: 0.0,
6458                end: 50.0,
6459            },
6460            Edge {
6461                coord: 100.0,
6462                start: 52.0,
6463                end: 100.0,
6464            },
6465        ];
6466        snap_and_merge(&mut edges);
6467        assert_eq!(edges.len(), 1, "Collinear segments within 3pt should join");
6468        assert!((edges[0].start - 0.0).abs() < 0.01);
6469        assert!((edges[0].end - 100.0).abs() < 0.01);
6470    }
6471
6472    #[test]
6473    fn test_intersection_discard_short_edges() {
6474        let mut edges = vec![
6475            Edge {
6476                coord: 100.0,
6477                start: 0.0,
6478                end: 4.0,
6479            }, // 4pt < MIN_EDGE_LEN ~keep
6480            Edge {
6481                coord: 200.0,
6482                start: 0.0,
6483                end: 50.0,
6484            },
6485        ];
6486        snap_and_merge(&mut edges);
6487        assert_eq!(edges.len(), 1, "Short edge should be discarded");
6488        assert!((edges[0].coord - 200.0).abs() < 0.01);
6489    }
6490
6491    #[test]
6492    fn test_intersection_find_intersections_basic() {
6493        let h = vec![
6494            Edge {
6495                coord: 100.0,
6496                start: 0.0,
6497                end: 200.0,
6498            },
6499            Edge {
6500                coord: 200.0,
6501                start: 0.0,
6502                end: 200.0,
6503            },
6504        ];
6505        let v = vec![
6506            Edge {
6507                coord: 50.0,
6508                start: 50.0,
6509                end: 250.0,
6510            },
6511            Edge {
6512                coord: 150.0,
6513                start: 50.0,
6514                end: 250.0,
6515            },
6516        ];
6517        let pts = find_intersections(&h, &v);
6518        assert_eq!(pts.len(), 4, "2 H x 2 V = 4 intersections");
6519    }
6520
6521    #[test]
6522    fn test_intersection_no_crossing_means_no_intersection() {
6523        let h = vec![Edge {
6524            coord: 100.0,
6525            start: 0.0,
6526            end: 50.0,
6527        }];
6528        let v = vec![Edge {
6529            coord: 100.0,
6530            start: 0.0,
6531            end: 200.0,
6532        }];
6533        let pts = find_intersections(&h, &v);
6534        assert!(pts.is_empty(), "Non-crossing edges should produce no intersection");
6535    }
6536
6537    #[test]
6538    fn test_intersection_build_cells() {
6539        let pts = vec![
6540            Intersection { x: 0.0, y: 0.0 },
6541            Intersection { x: 100.0, y: 0.0 },
6542            Intersection { x: 0.0, y: 100.0 },
6543            Intersection { x: 100.0, y: 100.0 },
6544        ];
6545        // Real drawn V edges spanning the full cell height, so the "four
6546        // corners are not four sides" containment check accepts the cell. ~keep
6547        let v_edges = [
6548            Edge {
6549                coord: 0.0,
6550                start: 0.0,
6551                end: 100.0,
6552            },
6553            Edge {
6554                coord: 100.0,
6555                start: 0.0,
6556                end: 100.0,
6557            },
6558        ];
6559        let cells = build_cells_from_intersections(&pts, &[], &v_edges);
6560        assert_eq!(cells.len(), 1, "4 corners should produce 1 cell");
6561    }
6562
6563    #[test]
6564    fn test_intersection_group_adjacent_cells() {
6565        let cells = vec![
6566            IntersectionCell {
6567                x1: 0.0,
6568                y1: 0.0,
6569                x2: 100.0,
6570                y2: 100.0,
6571            },
6572            IntersectionCell {
6573                x1: 100.0,
6574                y1: 0.0,
6575                x2: 200.0,
6576                y2: 100.0,
6577            },
6578        ];
6579        let groups = group_cells_into_tables(&cells);
6580        assert_eq!(groups.len(), 1, "Adjacent cells should be in 1 group");
6581    }
6582
6583    #[test]
6584    fn test_intersection_separate_tables() {
6585        let cells = vec![
6586            IntersectionCell {
6587                x1: 0.0,
6588                y1: 0.0,
6589                x2: 100.0,
6590                y2: 100.0,
6591            },
6592            IntersectionCell {
6593                x1: 500.0,
6594                y1: 500.0,
6595                x2: 600.0,
6596                y2: 600.0,
6597            },
6598        ];
6599        let groups = group_cells_into_tables(&cells);
6600        assert_eq!(groups.len(), 2, "Distant cells should be in separate groups");
6601    }
6602
6603    /// xberg-io/xberg#1601: two disjoint bordered tables that happen to share
6604    /// column X-positions (a common shape — same field layout repeated after a
6605    /// section heading) are separated by a 40pt band with NO path of any kind
6606    /// in it. `build_cells_from_intersections` accepts a cell as soon as its
6607    /// four corners are independently-existing crossing points, with no check
6608    /// that a drawn V edge actually spans the candidate cell's Y-range — so it
6609    /// manufactures a phantom cell bridging the two tables' shared X columns
6610    /// across the empty band, and the heading text sitting in the band gets
6611    /// admitted into that phantom cell as a one-cell row of the merged table.
6612    #[test]
6613    fn gh1601_graphics_free_gap_does_not_bridge_two_tables() {
6614        let lines = vec![
6615            // Table B (lower): rows y=[10,30],[30,50], cols x=[10,30],[30,60]. ~keep
6616            make_h_line(10.0, 10.0, 50.0),
6617            make_h_line(10.0, 30.0, 50.0),
6618            make_h_line(10.0, 50.0, 50.0),
6619            make_v_line(10.0, 10.0, 40.0),
6620            make_v_line(30.0, 10.0, 40.0),
6621            make_v_line(60.0, 10.0, 40.0),
6622            // Table A (upper): SAME column X-positions, rows y=[90,110],[110,130].
6623            // The gap y=[50,90] (40pt) carries no path at all. ~keep
6624            make_h_line(10.0, 90.0, 50.0),
6625            make_h_line(10.0, 110.0, 50.0),
6626            make_h_line(10.0, 130.0, 50.0),
6627            make_v_line(10.0, 90.0, 40.0),
6628            make_v_line(30.0, 90.0, 40.0),
6629            make_v_line(60.0, 90.0, 40.0),
6630        ];
6631        let spans = vec![
6632            create_test_span("B21", 12.0, 12.0, 8.0, 10.0),
6633            create_test_span("B22", 35.0, 12.0, 8.0, 10.0),
6634            create_test_span("B11", 12.0, 32.0, 8.0, 10.0),
6635            create_test_span("B12", 35.0, 32.0, 8.0, 10.0),
6636            // The section heading, printed inside the graphics-free gap. ~keep
6637            create_test_span("HEADINGTAG", 12.0, 60.0, 20.0, 10.0),
6638            create_test_span("A21", 12.0, 92.0, 8.0, 10.0),
6639            create_test_span("A22", 35.0, 92.0, 8.0, 10.0),
6640            create_test_span("A11", 12.0, 112.0, 8.0, 10.0),
6641            create_test_span("A12", 35.0, 112.0, 8.0, 10.0),
6642        ];
6643        let config = TableDetectionConfig {
6644            horizontal_strategy: TableStrategy::Lines,
6645            vertical_strategy: TableStrategy::Lines,
6646            min_table_cells: 2,
6647            min_table_columns: 2,
6648            ..TableDetectionConfig::default()
6649        };
6650
6651        let tables = detect_tables_from_intersections(&spans, &lines, &config);
6652
6653        let heading_rows: Vec<&TableRow> = tables
6654            .iter()
6655            .flat_map(|t| t.rows.iter())
6656            .filter(|r| r.cells.iter().any(|c| c.text.contains("HEADINGTAG")))
6657            .collect();
6658        assert!(
6659            heading_rows.is_empty(),
6660            "the heading printed in the graphics-free gap must not become a row of either \
6661             table (it must stay out of the element stream as a table row entirely), got: \
6662             {heading_rows:?}"
6663        );
6664        assert_eq!(
6665            tables.len(),
6666            2,
6667            "two disjoint same-column grids separated by a graphics-free gap must remain \
6668             two tables, not be bridged into one, got: {tables:?}"
6669        );
6670    }
6671
6672    /// Negative control for xberg-io/xberg#1601: a heading sitting INSIDE a
6673    /// genuinely ruled band of ONE continuously-ruled table (real V edges span
6674    /// every row, including the heading's) must stay part of that one table.
6675    /// This is the shape the fix must not break — the issue's own page-2
6676    /// control.
6677    #[test]
6678    fn gh1601_heading_row_inside_a_ruled_band_stays_in_one_table() {
6679        let lines = vec![
6680            make_h_line(10.0, 10.0, 50.0),
6681            make_h_line(10.0, 30.0, 50.0),
6682            make_h_line(10.0, 50.0, 50.0),
6683            make_h_line(10.0, 70.0, 50.0),
6684            // V edges span the FULL height across all three row bands —
6685            // this is one continuously-ruled table, not two abutting ones. ~keep
6686            make_v_line(10.0, 10.0, 60.0),
6687            make_v_line(30.0, 10.0, 60.0),
6688            make_v_line(60.0, 10.0, 60.0),
6689        ];
6690        let spans = vec![
6691            create_test_span("R11", 12.0, 12.0, 8.0, 10.0),
6692            create_test_span("R12", 35.0, 12.0, 8.0, 10.0),
6693            // The heading occupies only the left column of the middle row —
6694            // a real row of the table, ruled above and below. ~keep
6695            create_test_span("HEADINGTAG", 12.0, 32.0, 8.0, 10.0),
6696            create_test_span("R21", 12.0, 52.0, 8.0, 10.0),
6697            create_test_span("R22", 35.0, 52.0, 8.0, 10.0),
6698        ];
6699        let config = TableDetectionConfig {
6700            horizontal_strategy: TableStrategy::Lines,
6701            vertical_strategy: TableStrategy::Lines,
6702            min_table_cells: 2,
6703            min_table_columns: 2,
6704            ..TableDetectionConfig::default()
6705        };
6706
6707        let tables = detect_tables_from_intersections(&spans, &lines, &config);
6708
6709        assert_eq!(
6710            tables.len(),
6711            1,
6712            "a heading inside a genuinely ruled band must not split the table apart, got: {tables:?}"
6713        );
6714        assert_eq!(
6715            tables[0].rows.len(),
6716            3,
6717            "all three ruled rows, including the heading's, must stay in the one table, got: {:?}",
6718            tables[0]
6719        );
6720        let heading_present = tables[0]
6721            .rows
6722            .iter()
6723            .any(|r| r.cells.iter().any(|c| c.text.contains("HEADINGTAG")));
6724        assert!(
6725            heading_present,
6726            "the heading row must still be present in the one table"
6727        );
6728    }
6729
6730    /// xberg-io/xberg#1601 follow-up: the containment check in
6731    /// `build_cells_from_intersections` must use its OWN tolerance
6732    /// (`CELL_RULE_SPAN_TOL`), not the X-axis `BAND_RULE_SPAN_TOL` (1.0pt) —
6733    /// that value is too tight for legitimate per-cell rule padding. Direct,
6734    /// low-level test of the gate itself (not the full pipeline): a single
6735    /// row's V edges are drawn 3pt short of the row's true top and bottom on
6736    /// BOTH ends (a realistic per-cell rule inset), well inside "a few
6737    /// points" but past the old 1.0pt X-axis tolerance. The cell must still
6738    /// form.
6739    #[test]
6740    fn gh1601_cell_rule_inset_a_few_points_still_forms_the_cell() {
6741        let pts = vec![
6742            Intersection { x: 0.0, y: 0.0 },
6743            Intersection { x: 100.0, y: 0.0 },
6744            Intersection { x: 0.0, y: 20.0 },
6745            Intersection { x: 100.0, y: 20.0 },
6746        ];
6747        // Row is y=[0,20]; each V edge is inset 3pt from both ends
6748        // (y=[3,17]) instead of running the row's full height — ordinary
6749        // per-cell rule padding, not a phantom-table-bridging gap. ~keep
6750        let v_edges = [
6751            Edge {
6752                coord: 0.0,
6753                start: 3.0,
6754                end: 17.0,
6755            },
6756            Edge {
6757                coord: 100.0,
6758                start: 3.0,
6759                end: 17.0,
6760            },
6761        ];
6762        let cells = build_cells_from_intersections(&pts, &[], &v_edges);
6763        assert_eq!(
6764            cells.len(),
6765            1,
6766            "a V edge inset a few points from its own row's true boundary must still form the cell"
6767        );
6768    }
6769
6770    /// Companion negative control, at the same low level: a 40pt gap between
6771    /// two row bands (the shape from `gh1601_graphics_free_gap_does_not_bridge_two_tables`,
6772    /// isolated to `build_cells_from_intersections` itself) must still be
6773    /// rejected under the new, looser `CELL_RULE_SPAN_TOL` (6.0pt) — the
6774    /// looser constant closes the false-negative gap on legitimate insets
6775    /// without reopening the phantom-cell bug it was introduced to fix.
6776    #[test]
6777    fn gh1601_cell_rule_span_tol_does_not_reopen_the_gap_bridge() {
6778        let pts = vec![
6779            Intersection { x: 0.0, y: 0.0 },
6780            Intersection { x: 100.0, y: 0.0 },
6781            Intersection { x: 0.0, y: 40.0 },
6782            Intersection { x: 100.0, y: 40.0 },
6783        ];
6784        // No V edge reaches anywhere near spanning y=[0,40] — each side's
6785        // edges only cover their own table's real rows, exactly like the
6786        // reporter's 54pt carrier gap and this file's 40pt fixture gap. ~keep
6787        let v_edges = [
6788            Edge {
6789                coord: 0.0,
6790                start: 0.0,
6791                end: 10.0,
6792            },
6793            Edge {
6794                coord: 100.0,
6795                start: 0.0,
6796                end: 10.0,
6797            },
6798            Edge {
6799                coord: 0.0,
6800                start: 30.0,
6801                end: 40.0,
6802            },
6803            Edge {
6804                coord: 100.0,
6805                start: 30.0,
6806                end: 40.0,
6807            },
6808        ];
6809        let cells = build_cells_from_intersections(&pts, &[], &v_edges);
6810        assert!(
6811            cells.is_empty(),
6812            "a 40pt graphics-free gap must still be rejected under the looser CELL_RULE_SPAN_TOL, got: {cells:?}"
6813        );
6814    }
6815
6816    /// Zebra shading: fill-only rectangles on rows 0, 2 and 4 plus two full-height rules between
6817    /// column groups. Rows 1 and 3 have no side edge at the table's outer x (only the shaded
6818    /// neighbours' fill sides reach it), but the rules run through them, so they are rows of the
6819    /// grid and must keep their outer cells.
6820    #[test]
6821    fn zebra_unshaded_rows_keep_their_outer_cells() {
6822        let mut lines: Vec<crate::elements::PathContent> = [0.0, 40.0, 80.0]
6823            .into_iter()
6824            .map(|y| make_rect_path(10.0, y, 150.0, 20.0))
6825            .collect();
6826        lines.push(make_v_line(60.0, 0.0, 100.0));
6827        lines.push(make_v_line(110.0, 0.0, 100.0));
6828        let mut spans = Vec::new();
6829        for row in 0..5 {
6830            let y = 85.0 - row as f32 * 20.0;
6831            spans.push(create_test_span(&format!("L{row}"), 12.0, y, 8.0, 10.0));
6832            spans.push(create_test_span(&format!("M{row}"), 70.0, y, 8.0, 10.0));
6833            spans.push(create_test_span(&format!("R{row}"), 120.0, y, 8.0, 10.0));
6834        }
6835        let config = TableDetectionConfig {
6836            horizontal_strategy: TableStrategy::Lines,
6837            vertical_strategy: TableStrategy::Lines,
6838            min_table_cells: 2,
6839            min_table_columns: 2,
6840            ..TableDetectionConfig::default()
6841        };
6842
6843        let tables = detect_tables_from_intersections(&spans, &lines, &config);
6844
6845        assert_eq!(tables.len(), 1, "got: {tables:?}");
6846        let texts: Vec<Vec<&str>> = tables[0]
6847            .rows
6848            .iter()
6849            .map(|r| r.cells.iter().map(|c| c.text.trim()).collect())
6850            .collect();
6851        for row in 0..5 {
6852            let want = [format!("L{row}"), format!("M{row}"), format!("R{row}")];
6853            assert!(
6854                texts.iter().any(|cells| cells == &want),
6855                "row {row} must keep all three cells, got: {texts:?}"
6856            );
6857        }
6858    }
6859
6860    #[test]
6861    fn cell_with_undrawn_sides_forms_when_a_rule_runs_through_its_band() {
6862        let edge = |coord, start, end| Edge { coord, start, end };
6863        let pts = vec![
6864            Intersection { x: 0.0, y: 0.0 },
6865            Intersection { x: 50.0, y: 0.0 },
6866            Intersection { x: 100.0, y: 0.0 },
6867            Intersection { x: 0.0, y: 20.0 },
6868            Intersection { x: 50.0, y: 20.0 },
6869            Intersection { x: 100.0, y: 20.0 },
6870        ];
6871        let h_edges = [edge(0.0, 0.0, 100.0), edge(20.0, 0.0, 100.0)];
6872        // The outer sides only reach the band's corners from the rows above and below. ~keep
6873        let v_edges = [
6874            edge(0.0, -20.0, 0.0),
6875            edge(0.0, 20.0, 40.0),
6876            edge(100.0, -20.0, 0.0),
6877            edge(100.0, 20.0, 40.0),
6878            edge(50.0, -20.0, 40.0),
6879        ];
6880        let cells = build_cells_from_intersections(&pts, &h_edges, &v_edges);
6881        assert_eq!(cells.len(), 2, "got: {cells:?}");
6882    }
6883
6884    /// A full-width section row: the inner column rule stops at the row, and only the row's own
6885    /// outer rules span it.
6886    #[test]
6887    fn cell_with_an_undrawn_side_forms_when_rules_close_both_ends_of_its_band() {
6888        let edge = |coord, start, end| Edge { coord, start, end };
6889        let pts = vec![
6890            Intersection { x: 0.0, y: 0.0 },
6891            Intersection { x: 50.0, y: 0.0 },
6892            Intersection { x: 100.0, y: 0.0 },
6893            Intersection { x: 0.0, y: 20.0 },
6894            Intersection { x: 50.0, y: 20.0 },
6895            Intersection { x: 100.0, y: 20.0 },
6896        ];
6897        let h_edges = [edge(0.0, 0.0, 100.0), edge(20.0, 0.0, 100.0)];
6898        let v_edges = [
6899            edge(0.0, -20.0, 40.0),
6900            edge(100.0, -20.0, 40.0),
6901            edge(50.0, -20.0, 0.0),
6902            edge(50.0, 20.0, 40.0),
6903        ];
6904        let cells = build_cells_from_intersections(&pts, &h_edges, &v_edges);
6905        assert_eq!(cells.len(), 2, "got: {cells:?}");
6906    }
6907
6908    #[test]
6909    fn cell_with_undrawn_sides_ignores_a_rule_at_or_past_the_ends_of_its_h_rules() {
6910        let edge = |coord, start, end| Edge { coord, start, end };
6911        let pts = vec![
6912            Intersection { x: 0.0, y: 0.0 },
6913            Intersection { x: 100.0, y: 0.0 },
6914            Intersection { x: 0.0, y: 40.0 },
6915            Intersection { x: 100.0, y: 40.0 },
6916        ];
6917        let h_edges = [edge(0.0, 0.0, 100.0), edge(40.0, 0.0, 100.0)];
6918        // Both rules span the gap without crossing it: an enclosing table's column rule 2.5pt
6919        // outside the H rules' left end (within SNAP_TOL), and a page frame at x=150. ~keep
6920        let v_edges = [
6921            edge(0.0, -20.0, 0.0),
6922            edge(0.0, 40.0, 60.0),
6923            edge(100.0, -20.0, 0.0),
6924            edge(100.0, 40.0, 60.0),
6925            edge(-2.5, -100.0, 200.0),
6926            edge(150.0, -100.0, 200.0),
6927        ];
6928        let cells = build_cells_from_intersections(&pts, &h_edges, &v_edges);
6929        assert!(cells.is_empty(), "got: {cells:?}");
6930    }
6931
6932    #[test]
6933    fn test_intersection_rect_decomposition() {
6934        let lines = vec![crate::elements::PathContent::rect(10.0, 10.0, 100.0, 50.0)];
6935        let (h, v) = extract_edges(&lines);
6936        assert_eq!(h.len(), 2, "Rectangle should produce 2 horizontal edges");
6937        assert_eq!(v.len(), 2, "Rectangle should produce 2 vertical edges");
6938    }
6939
6940    #[test]
6941    fn test_intersection_3x3_grid_produces_4_cells() {
6942        let pts = vec![
6943            Intersection { x: 0.0, y: 0.0 },
6944            Intersection { x: 50.0, y: 0.0 },
6945            Intersection { x: 100.0, y: 0.0 },
6946            Intersection { x: 0.0, y: 50.0 },
6947            Intersection { x: 50.0, y: 50.0 },
6948            Intersection { x: 100.0, y: 50.0 },
6949            Intersection { x: 0.0, y: 100.0 },
6950            Intersection { x: 50.0, y: 100.0 },
6951            Intersection { x: 100.0, y: 100.0 },
6952        ];
6953        // Real drawn V edges spanning the full grid height at every column. ~keep
6954        let v_edges = [
6955            Edge {
6956                coord: 0.0,
6957                start: 0.0,
6958                end: 100.0,
6959            },
6960            Edge {
6961                coord: 50.0,
6962                start: 0.0,
6963                end: 100.0,
6964            },
6965            Edge {
6966                coord: 100.0,
6967                start: 0.0,
6968                end: 100.0,
6969            },
6970        ];
6971        let cells = build_cells_from_intersections(&pts, &[], &v_edges);
6972        assert_eq!(cells.len(), 4, "3x3 grid should produce 4 cells");
6973        let groups = group_cells_into_tables(&cells);
6974        assert_eq!(groups.len(), 1, "All 4 cells should form 1 table");
6975    }
6976
6977    #[test]
6978    fn test_dotted_line_reconstitution() {
6979        // 10 short H segments at y=300, each 3pt wide, spanning x=50..350
6980        // Each segment is below MIN_EDGE_LEN (5pt) so would normally be discarded.
6981        // The reconstitution pass should merge them into one edge from x=50 to x=350. ~keep
6982        let mut edges: Vec<Edge> = (0..10)
6983            .map(|i| Edge {
6984                coord: 300.0,
6985                start: 50.0 + i as f32 * 30.0,
6986                end: 53.0 + i as f32 * 30.0,
6987            })
6988            .collect();
6989
6990        snap_and_merge(&mut edges);
6991
6992        assert_eq!(edges.len(), 1, "Dotted segments should reconstitute into 1 edge");
6993        assert!(
6994            (edges[0].coord - 300.0).abs() < 0.01,
6995            "Reconstituted edge should be at y=300"
6996        );
6997        assert!(
6998            (edges[0].start - 50.0).abs() < 0.01,
6999            "Reconstituted edge should start at x=50"
7000        );
7001        assert!(
7002            (edges[0].end - 323.0).abs() < 0.01,
7003            "Reconstituted edge should end at x=323"
7004        );
7005    }
7006
7007    #[test]
7008    fn test_dotted_line_too_few_segments_discarded() {
7009        // Only 2 short segments — below DOTTED_MIN_SEGMENTS threshold.
7010        // Should be discarded entirely (not reconstituted, not kept individually). ~keep
7011        let mut edges = vec![
7012            Edge {
7013                coord: 200.0,
7014                start: 10.0,
7015                end: 13.0,
7016            },
7017            Edge {
7018                coord: 200.0,
7019                start: 20.0,
7020                end: 23.0,
7021            },
7022        ];
7023        snap_and_merge(&mut edges);
7024        assert!(
7025            edges.is_empty(),
7026            "Two short segments should not be reconstituted or kept"
7027        );
7028    }
7029
7030    #[test]
7031    fn test_dotted_line_narrow_span_discarded() {
7032        // 5 short segments at same coord but total span < DOTTED_MIN_SPAN (50pt).
7033        // Gaps between segments are > JOIN_TOL (3pt) so they won't be joined. ~keep
7034        let mut edges: Vec<Edge> = (0..5)
7035            .map(|i| Edge {
7036                coord: 400.0,
7037                start: 10.0 + i as f32 * 8.0,
7038                end: 13.0 + i as f32 * 8.0,
7039            })
7040            .collect();
7041        snap_and_merge(&mut edges);
7042        assert!(
7043            edges.is_empty(),
7044            "Short segments with narrow total span should be discarded"
7045        );
7046    }
7047
7048    #[test]
7049    fn test_dotted_line_mixed_with_long_edges() {
7050        let mut edges = vec![Edge {
7051            coord: 100.0,
7052            start: 0.0,
7053            end: 200.0,
7054        }];
7055        for i in 0..10 {
7056            edges.push(Edge {
7057                coord: 300.0,
7058                start: 50.0 + i as f32 * 30.0,
7059                end: 53.0 + i as f32 * 30.0,
7060            });
7061        }
7062        snap_and_merge(&mut edges);
7063        assert_eq!(edges.len(), 2, "Long edge + reconstituted dotted line = 2 edges");
7064    }
7065
7066    #[test]
7067    fn test_join_chain_of_short_segments() {
7068        let mut edges: Vec<Edge> = (0..10)
7069            .map(|i| Edge {
7070                coord: 100.0,
7071                start: i as f32 * 25.0,
7072                end: (i + 1) as f32 * 25.0,
7073            })
7074            .collect();
7075
7076        snap_and_merge(&mut edges);
7077
7078        assert_eq!(edges.len(), 1, "Chain of 10 touching H segments should join into 1");
7079        assert!((edges[0].start - 0.0).abs() < 0.01, "Joined edge should start at 0");
7080        assert!((edges[0].end - 250.0).abs() < 0.01, "Joined edge should end at 250");
7081    }
7082
7083    #[test]
7084    fn test_join_tiny_vertical_segments() {
7085        let mut edges: Vec<Edge> = (0..10)
7086            .map(|i| Edge {
7087                coord: 50.0,
7088                start: i as f32 * 6.0,
7089                end: (i + 1) as f32 * 6.0,
7090            })
7091            .collect();
7092
7093        snap_and_merge(&mut edges);
7094
7095        assert_eq!(edges.len(), 1, "Chain of 10 touching V segments should join into 1");
7096        assert!((edges[0].start - 0.0).abs() < 0.01, "Joined edge should start at 0");
7097        assert!((edges[0].end - 60.0).abs() < 0.01, "Joined edge should end at 60");
7098    }
7099
7100    #[test]
7101    fn test_join_segments_with_slightly_different_coords() {
7102        // Segments at very close but not identical coords (within SNAP_TOL)
7103        // should snap to the same coord and then join. ~keep
7104        let mut edges = vec![
7105            Edge {
7106                coord: 87.4,
7107                start: 36.0,
7108                end: 117.0,
7109            },
7110            Edge {
7111                coord: 87.41,
7112                start: 117.0,
7113                end: 143.0,
7114            },
7115            Edge {
7116                coord: 87.39,
7117                start: 143.0,
7118                end: 170.0,
7119            },
7120        ];
7121
7122        snap_and_merge(&mut edges);
7123
7124        assert_eq!(edges.len(), 1, "Segments at near-identical coords should snap and join");
7125        assert!((edges[0].start - 36.0).abs() < 0.01, "Joined edge should start at 36");
7126        assert!((edges[0].end - 170.0).abs() < 0.01, "Joined edge should end at 170");
7127    }
7128
7129    #[test]
7130    fn rule_families_tied_on_first_coord_emit_in_input_order() {
7131        // Eight side-by-side families (disjoint x-ranges, so none unify),
7132        // every family's first rule at the same y: `a[0].coord` ties across
7133        // all of them, so only the root tiebreak orders the output. Under
7134        // the pre-fix HashMap grouping this order was per-process hash
7135        // order (8! arrangements), so this assertion fails on all but the
7136        // seed that happens to sort. ~keep
7137        let edges: Vec<Edge> = (0..8)
7138            .flat_map(|k| {
7139                let x0 = 200.0 * k as f32;
7140                [
7141                    Edge {
7142                        coord: 700.0,
7143                        start: x0,
7144                        end: x0 + 150.0,
7145                    },
7146                    Edge {
7147                        coord: 600.0,
7148                        start: x0,
7149                        end: x0 + 150.0,
7150                    },
7151                ]
7152            })
7153            .collect();
7154        let wide: Vec<&Edge> = edges.iter().collect();
7155
7156        let families = x_coherent_rule_families(&wide);
7157
7158        assert_eq!(families.len(), 8, "Disjoint x-ranges should stay separate families");
7159        for (k, family) in families.iter().enumerate() {
7160            assert_eq!(family.len(), 2);
7161            assert!(
7162                (family[0].coord - 700.0).abs() < 0.01,
7163                "First rule should be the top rule"
7164            );
7165            assert!(
7166                (family[0].start - 200.0 * k as f32).abs() < 0.01,
7167                "Tied families should emit in input order, family {k} out of place"
7168            );
7169        }
7170    }
7171
7172    #[test]
7173    fn rule_families_with_distinct_first_coords_order_by_coord() {
7174        // Distinct `a[0].coord` values: the coord key alone decides, so the
7175        // input (and root) order must NOT leak through — the family given
7176        // second sorts first because its top rule has the smaller y. ~keep
7177        let edges = [
7178            Edge {
7179                coord: 500.0,
7180                start: 0.0,
7181                end: 150.0,
7182            },
7183            Edge {
7184                coord: 400.0,
7185                start: 0.0,
7186                end: 150.0,
7187            },
7188            Edge {
7189                coord: 300.0,
7190                start: 300.0,
7191                end: 450.0,
7192            },
7193            Edge {
7194                coord: 200.0,
7195                start: 300.0,
7196                end: 450.0,
7197            },
7198        ];
7199        let wide: Vec<&Edge> = edges.iter().collect();
7200
7201        let families = x_coherent_rule_families(&wide);
7202
7203        assert_eq!(families.len(), 2);
7204        assert!((families[0][0].coord - 300.0).abs() < 0.01);
7205        assert!((families[1][0].coord - 500.0).abs() < 0.01);
7206    }
7207
7208    #[test]
7209    fn test_hybrid_line_cols_text_rows() {
7210        // V lines at x=50, 200, 400 (2 columns) spanning y=100..300
7211        // H lines at y=100 and y=300 only (top and bottom, NO middle rows)
7212        // This creates a single intersection-based row, but text lives at 3 Y positions. ~keep
7213        let lines = vec![
7214            make_h_line(50.0, 100.0, 350.0),
7215            make_h_line(50.0, 300.0, 350.0),
7216            make_v_line(50.0, 100.0, 200.0),
7217            make_v_line(200.0, 100.0, 200.0),
7218            make_v_line(400.0, 100.0, 200.0),
7219        ];
7220        let spans = vec![
7221            create_test_span("A", 60.0, 265.0, 20.0, 10.0),
7222            create_test_span("B", 210.0, 265.0, 20.0, 10.0),
7223            create_test_span("C", 60.0, 205.0, 20.0, 10.0),
7224            create_test_span("D", 210.0, 205.0, 20.0, 10.0),
7225            create_test_span("E", 60.0, 145.0, 20.0, 10.0),
7226            create_test_span("F", 210.0, 145.0, 20.0, 10.0),
7227        ];
7228        let config = TableDetectionConfig {
7229            horizontal_strategy: TableStrategy::Lines,
7230            vertical_strategy: TableStrategy::Lines,
7231            min_table_cells: 4,
7232            min_table_columns: 2,
7233            ..TableDetectionConfig::default()
7234        };
7235        let tables = detect_tables_with_lines(&spans, &lines, &config);
7236        assert_eq!(tables.len(), 1, "Should detect exactly 1 table");
7237        let table = &tables[0];
7238        assert_eq!(
7239            table.rows.len(),
7240            3,
7241            "Should have 3 rows (split from text Y positions), got {}",
7242            table.rows.len()
7243        );
7244        assert_eq!(table.col_count, 2, "Should have 2 columns");
7245
7246        // Rows sorted top-to-bottom (descending Y in PDF coords). ~keep
7247        let r0: Vec<&str> = table.rows[0].cells.iter().map(|c| c.text.as_str()).collect();
7248        let r1: Vec<&str> = table.rows[1].cells.iter().map(|c| c.text.as_str()).collect();
7249        let r2: Vec<&str> = table.rows[2].cells.iter().map(|c| c.text.as_str()).collect();
7250        assert_eq!(r0, vec!["A", "B"], "Top row should be A, B");
7251        assert_eq!(r1, vec!["C", "D"], "Middle row should be C, D");
7252        assert_eq!(r2, vec!["E", "F"], "Bottom row should be E, F");
7253    }
7254
7255    #[test]
7256    fn test_strip_form_numbering_artifacts() {
7257        use crate::structure::table_extractor::{TableCell, TableRow};
7258
7259        let make_cell = |text: &str| TableCell {
7260            text: text.to_string(),
7261            spans: Vec::new(),
7262            colspan: 1,
7263            rowspan: 1,
7264            mcids: Vec::new(),
7265            bbox: None,
7266            is_header: false,
7267        };
7268
7269        let mut rows = vec![
7270            // Row 0: all single-digit -> should be removed entirely ~keep
7271            TableRow {
7272                cells: vec![make_cell("5"), make_cell(""), make_cell(""), make_cell("")],
7273                is_header: false,
7274            },
7275            // Row 1: digit prefix artifacts -> should be stripped ~keep
7276            TableRow {
7277                cells: vec![
7278                    make_cell("1 Apr 11, 2025"),
7279                    make_cell("1 12111 - Rinse-Fluoride Treatment"),
7280                    make_cell("1 $14.60"),
7281                    make_cell("1"),
7282                ],
7283                is_header: false,
7284            },
7285            // Row 2: no artifacts -> unchanged ~keep
7286            TableRow {
7287                cells: vec![
7288                    make_cell("Apr 11, 2025"),
7289                    make_cell("11101 - One unit of time"),
7290                    make_cell("$47.60"),
7291                    make_cell(""),
7292                ],
7293                is_header: false,
7294            },
7295            // Row 3: digit prefix but remainder starts with digit -> NOT stripped ~keep
7296            TableRow {
7297                cells: vec![make_cell("3 items"), make_cell(""), make_cell(""), make_cell("")],
7298                is_header: false,
7299            },
7300        ];
7301
7302        strip_form_numbering_artifacts(&mut rows);
7303
7304        assert_eq!(rows.len(), 3, "Single-digit-only row should be removed");
7305
7306        let r0: Vec<&str> = rows[0].cells.iter().map(|c| c.text.as_str()).collect();
7307        assert_eq!(r0[0], "Apr 11, 2025", "Leading '1 ' should be stripped");
7308        assert_eq!(
7309            r0[1], "12111 - Rinse-Fluoride Treatment",
7310            "Leading '1 ' stripped, rest starts with digit but contains '-'"
7311        );
7312        assert_eq!(r0[2], "$14.60", "Leading '1 ' stripped, rest starts with '$'");
7313        // "1" alone is cleared in Phase 3 because other cells in this row were stripped. ~keep
7314        assert_eq!(r0[3], "", "Lone '1' cleared when other cells in row were stripped");
7315
7316        let r1: Vec<&str> = rows[1].cells.iter().map(|c| c.text.as_str()).collect();
7317        assert_eq!(r1[0], "Apr 11, 2025");
7318        assert_eq!(r1[1], "11101 - One unit of time");
7319        assert_eq!(r1[2], "$47.60");
7320
7321        // Former row 3 is now row 2: "3 items" should NOT be stripped because
7322        // the remainder ("items") is a plain word with no date/code indicators. ~keep
7323        let r2: Vec<&str> = rows[2].cells.iter().map(|c| c.text.as_str()).collect();
7324        assert_eq!(
7325            r2[0], "3 items",
7326            "'3 items' should NOT be stripped (plain word, no date/code/currency)"
7327        );
7328    }
7329
7330    #[test]
7331    fn test_strip_dash_separator_cells() {
7332        // T5 summary table: "------" appears as a decorative line separator
7333        // in a cell. After stripping, that cell should be empty. ~keep
7334        use crate::structure::table_extractor::{TableCell, TableRow};
7335
7336        let make_cell = |text: &str| TableCell {
7337            text: text.to_string(),
7338            spans: Vec::new(),
7339            colspan: 1,
7340            rowspan: 1,
7341            mcids: Vec::new(),
7342            bbox: None,
7343            is_header: false,
7344        };
7345
7346        let mut rows = vec![
7347            TableRow {
7348                cells: vec![make_cell("------"), make_cell("Total"), make_cell("$500.00")],
7349                is_header: false,
7350            },
7351            TableRow {
7352                cells: vec![make_cell("____"), make_cell("Subtotal"), make_cell("$200.00")],
7353                is_header: false,
7354            },
7355            TableRow {
7356                cells: vec![make_cell("--__--"), make_cell("Tax"), make_cell("$10.00")],
7357                is_header: false,
7358            },
7359            TableRow {
7360                cells: vec![make_cell("------"), make_cell("---"), make_cell("------")],
7361                is_header: false,
7362            },
7363            TableRow {
7364                cells: vec![make_cell("2025-01-01"), make_cell("Payment"), make_cell("$100.00")],
7365                is_header: false,
7366            },
7367        ];
7368
7369        strip_form_numbering_artifacts(&mut rows);
7370
7371        assert_eq!(rows[0].cells[0].text.trim(), "", "Dash-only cell should be cleared");
7372        assert_eq!(rows[0].cells[1].text, "Total");
7373        assert_eq!(rows[0].cells[2].text, "$500.00");
7374
7375        assert_eq!(
7376            rows[1].cells[0].text.trim(),
7377            "",
7378            "Underscore-only cell should be cleared"
7379        );
7380
7381        assert_eq!(
7382            rows[2].cells[0].text.trim(),
7383            "",
7384            "Mixed dash/underscore cell should be cleared"
7385        );
7386
7387        // Row with all-dash cells becomes all-empty (kept for downstream
7388        // empty-row splitting, which uses empty rows as table separators). ~keep
7389        assert_eq!(rows.len(), 5, "All-dash row kept as empty separator");
7390        assert!(
7391            rows[3].cells.iter().all(|c| c.text.trim().is_empty()),
7392            "All-dash row should now be all-empty"
7393        );
7394
7395        assert_eq!(rows[4].cells[0].text, "2025-01-01");
7396    }
7397
7398    #[test]
7399    fn test_separate_small_and_large_table_clusters() {
7400        let lines = vec![
7401            make_rect_path(409.0, 83.0, 125.0, 0.5),
7402            make_rect_path(409.0, 142.0, 125.0, 0.5),
7403            make_rect_path(409.0, 71.0, 0.5, 72.0),
7404            make_rect_path(534.0, 71.0, 0.5, 72.0),
7405            make_rect_path(22.0, 150.0, 567.0, 0.5),
7406            make_rect_path(22.0, 553.0, 567.0, 0.5),
7407            make_rect_path(22.0, 150.0, 0.5, 403.0),
7408            make_rect_path(490.0, 150.0, 0.5, 403.0),
7409            make_rect_path(589.0, 150.0, 0.5, 403.0),
7410        ];
7411
7412        let config = TableDetectionConfig::default();
7413        let clusters = group_lines_into_clusters(&lines, &config);
7414        assert!(
7415            clusters.len() >= 2,
7416            "Expected at least 2 clusters (header table + main table), got {}",
7417            clusters.len()
7418        );
7419
7420        for cluster in &clusters {
7421            let mut has_header_vline = false;
7422            let mut has_main_vline = false;
7423            for &idx in &cluster.lines {
7424                let bbox = &lines[idx].bbox;
7425                if bbox.width.abs() < 2.0 && bbox.height.abs() > 5.0 {
7426                    let y_max = bbox.y + bbox.height;
7427                    if y_max < 145.0 {
7428                        has_header_vline = true;
7429                    }
7430                    if bbox.y >= 149.0 {
7431                        has_main_vline = true;
7432                    }
7433                }
7434            }
7435            assert!(
7436                !(has_header_vline && has_main_vline),
7437                "A single cluster should not contain both header V-lines (y<145) and main V-lines (y>149)"
7438            );
7439        }
7440    }
7441
7442    /// (C) A ruled 3-column grid whose header text row sits just ABOVE the top
7443    /// ruling (unruled header). Its three labels align to the three detected
7444    /// columns and span the table width, so the row must be pulled in as the
7445    /// table's header (cells preserved, not merged, marked as header).
7446    #[test]
7447    fn test_ws03b_header_row_above_included() {
7448        let lines = vec![
7449            make_h_line(100.0, 560.0, 300.0),
7450            make_h_line(100.0, 530.0, 300.0),
7451            make_h_line(100.0, 500.0, 300.0),
7452            make_v_line(100.0, 500.0, 60.0),
7453            make_v_line(200.0, 500.0, 60.0),
7454            make_v_line(300.0, 500.0, 60.0),
7455            make_v_line(400.0, 500.0, 60.0),
7456        ];
7457        let spans = vec![
7458            create_test_span("Name", 140.0, 568.0, 20.0, 10.0),
7459            create_test_span("Age", 240.0, 568.0, 20.0, 10.0),
7460            create_test_span("City", 340.0, 568.0, 20.0, 10.0),
7461            create_test_span("Ann", 145.0, 545.0, 10.0, 10.0),
7462            create_test_span("30", 245.0, 545.0, 10.0, 10.0),
7463            create_test_span("NYC", 345.0, 545.0, 10.0, 10.0),
7464            create_test_span("Bob", 145.0, 515.0, 10.0, 10.0),
7465            create_test_span("41", 245.0, 515.0, 10.0, 10.0),
7466            create_test_span("LA", 345.0, 515.0, 10.0, 10.0),
7467        ];
7468        let config = TableDetectionConfig::default();
7469        let clusters = group_lines_into_clusters(&lines, &config);
7470        assert_eq!(clusters.len(), 1, "single ruled grid → one cluster");
7471        let tables = detect_tables_in_cluster(&spans, &lines, &clusters[0], &config);
7472        assert_eq!(tables.len(), 1, "one table expected");
7473        let table = &tables[0];
7474        assert_eq!(
7475            table.rows.len(),
7476            3,
7477            "header row above the top ruling must be included (2 body + 1 header)"
7478        );
7479        assert!(table.has_header, "table must be flagged as having a header");
7480        assert!(table.rows[0].is_header, "row 0 must be the header row");
7481        let header_text: String = table.rows[0]
7482            .cells
7483            .iter()
7484            .map(|c| c.text.trim())
7485            .collect::<Vec<_>>()
7486            .join("|");
7487        for label in ["Name", "Age", "City"] {
7488            assert!(
7489                header_text.contains(label),
7490                "header row must contain {label:?}, got {header_text:?}"
7491            );
7492        }
7493    }
7494
7495    /// (C-negative) Unrelated text above the same grid at an x OUTSIDE the column
7496    /// extent does NOT align to any column, so no header row is added and the
7497    /// stray text is left out of the table (table unchanged: 2 body rows only).
7498    #[test]
7499    fn test_ws03b_unaligned_text_above_not_header() {
7500        let lines = vec![
7501            make_h_line(100.0, 560.0, 300.0),
7502            make_h_line(100.0, 530.0, 300.0),
7503            make_h_line(100.0, 500.0, 300.0),
7504            make_v_line(100.0, 500.0, 60.0),
7505            make_v_line(200.0, 500.0, 60.0),
7506            make_v_line(300.0, 500.0, 60.0),
7507            make_v_line(400.0, 500.0, 60.0),
7508        ];
7509        let spans = vec![
7510            create_test_span("Table", 520.0, 568.0, 20.0, 10.0),
7511            create_test_span("caption", 560.0, 568.0, 40.0, 10.0),
7512            create_test_span("Ann", 145.0, 545.0, 10.0, 10.0),
7513            create_test_span("30", 245.0, 545.0, 10.0, 10.0),
7514            create_test_span("NYC", 345.0, 545.0, 10.0, 10.0),
7515            create_test_span("Bob", 145.0, 515.0, 10.0, 10.0),
7516            create_test_span("41", 245.0, 515.0, 10.0, 10.0),
7517            create_test_span("LA", 345.0, 515.0, 10.0, 10.0),
7518        ];
7519        let config = TableDetectionConfig::default();
7520
7521        let mut row_ys = vec![560.0_f32, 530.0, 500.0];
7522        row_ys.sort_by(|a, b| crate::utils::safe_float_cmp(*b, *a));
7523        let col_xs = vec![100.0_f32, 200.0, 300.0, 400.0];
7524        assert!(
7525            detect_header_row_above(&spans, &row_ys, &col_xs).is_none(),
7526            "text outside the column extent must not be treated as a header row"
7527        );
7528
7529        let clusters = group_lines_into_clusters(&lines, &config);
7530        let tables = detect_tables_in_cluster(&spans, &lines, &clusters[0], &config);
7531        assert_eq!(tables.len(), 1);
7532        assert_eq!(
7533            tables[0].rows.len(),
7534            2,
7535            "no header row must be added for unaligned text above the grid"
7536        );
7537        let all_text: String = tables[0]
7538            .rows
7539            .iter()
7540            .flat_map(|r| r.cells.iter())
7541            .map(|c| c.text.clone())
7542            .collect();
7543        assert!(
7544            !all_text.contains("caption"),
7545            "unaligned caption text must stay out of the table, got {all_text:?}"
7546        );
7547    }
7548
7549    #[test]
7550    fn test_text_edge_columns_form_layout() {
7551        // Simulate a form layout: text aligns to specific X positions across
7552        // many rows, but each row may only use a subset of columns.
7553        //
7554        // Column 1 (employer info):  left edge ~48
7555        // Column 2 (box codes):      left edge ~210
7556        // Column 3 (values):         left edge ~382
7557        // Column 4 (values):         left edge ~516
7558        //
7559        // We place 5+ spans at each column's X across different Y rows. ~keep
7560
7561        let mut spans = Vec::new();
7562        let col_xs = [48.0_f32, 210.0, 382.0, 516.0];
7563        let row_ys = [700.0_f32, 680.0, 660.0, 640.0, 620.0, 600.0];
7564
7565        for &cx in &col_xs {
7566            for &ry in &row_ys {
7567                spans.push(create_test_span("val", cx, ry, 40.0, 10.0));
7568            }
7569        }
7570
7571        // Add some "noise" spans that only appear in 1-2 rows (should NOT
7572        // create extra columns). ~keep
7573        spans.push(create_test_span("noise", 130.0, 700.0, 20.0, 10.0));
7574        spans.push(create_test_span("noise", 132.0, 680.0, 20.0, 10.0));
7575
7576        let config = TableDetectionConfig::default();
7577        let columns = detect_text_edge_columns(&spans, &config);
7578
7579        // We expect roughly 4 column clusters (one per alignment edge),
7580        // possibly a few more for right-edges that also recur, but definitely
7581        // not 8+ as greedy clustering would produce. ~keep
7582        assert!(
7583            columns.len() >= 3 && columns.len() <= 6,
7584            "Expected 3-6 text-edge columns, got {}",
7585            columns.len()
7586        );
7587
7588        let centres: Vec<f32> = columns.iter().map(|c| c.x_center).collect();
7589        for &expected_x in &col_xs {
7590            assert!(
7591                centres
7592                    .iter()
7593                    .any(|&cx| (cx - expected_x).abs() < config.column_tolerance
7594                        || (cx - (expected_x + 40.0)).abs() < config.column_tolerance),
7595                "Expected a column near x={expected_x} (or its right edge), centres={centres:?}"
7596            );
7597        }
7598    }
7599
7600    #[test]
7601    fn test_text_edge_columns_noise_filtered() {
7602        let spans = vec![
7603            // Only 1 span at x=100 — below the min_row_count=3 threshold ~keep
7604            create_test_span("a", 100.0, 500.0, 30.0, 10.0),
7605            create_test_span("c", 300.0, 500.0, 30.0, 10.0),
7606            create_test_span("d", 300.0, 480.0, 30.0, 10.0),
7607            create_test_span("e", 300.0, 460.0, 30.0, 10.0),
7608            create_test_span("f", 300.0, 440.0, 30.0, 10.0),
7609        ];
7610
7611        let config = TableDetectionConfig::default();
7612        let columns = detect_text_edge_columns(&spans, &config);
7613
7614        assert!(!columns.is_empty(), "Should produce at least one column from x=300");
7615        for c in &columns {
7616            assert!(
7617                (c.x_center - 100.0).abs() > 15.0,
7618                "x=100 edge should have been filtered (only 1 row), but got column at {}",
7619                c.x_center
7620            );
7621        }
7622    }
7623
7624    #[test]
7625    fn test_text_edge_fallback_integration() {
7626        // When greedy detect_columns produces >6 columns, detect_tables_from_spans
7627        // should fall back to text-edge detection and produce fewer columns.
7628        //
7629        // Build a layout with 4 true alignment columns but noisy X offsets
7630        // that cause greedy clustering (tolerance=15) to split them. ~keep
7631        let mut spans = Vec::new();
7632        let true_cols = [50.0_f32, 200.0, 350.0, 500.0];
7633        let row_ys = [700.0_f32, 680.0, 660.0, 640.0, 620.0];
7634
7635        for (ci, &cx) in true_cols.iter().enumerate() {
7636            for (ri, &ry) in row_ys.iter().enumerate() {
7637                // Add slight jitter that stays within snap_tolerance but could
7638                // push greedy clustering into creating extra columns when
7639                // combined with different-width spans. ~keep
7640                let jitter = ((ci + ri) % 3) as f32 * 2.0;
7641                spans.push(create_test_span("v", cx + jitter, ry, 30.0, 10.0));
7642            }
7643        }
7644
7645        // Also add extra scattered spans at unique X positions (each only in
7646        // 1 row) to bloat the greedy column count past 6. ~keep
7647        for i in 0..10 {
7648            let x = 80.0 + i as f32 * 30.0;
7649            spans.push(create_test_span("x", x, 700.0, 15.0, 10.0));
7650        }
7651
7652        let config = TableDetectionConfig {
7653            column_tolerance: 8.0, // tight tolerance to force many greedy columns ~keep
7654            ..TableDetectionConfig::default()
7655        };
7656
7657        let greedy_cols = detect_columns(&spans, config.column_tolerance, config.column_merge_threshold);
7658        assert!(
7659            greedy_cols.len() > 6,
7660            "Precondition: greedy should produce >6 columns, got {}",
7661            greedy_cols.len()
7662        );
7663
7664        let te_cols = detect_text_edge_columns(&spans, &config);
7665        assert!(
7666            te_cols.len() < greedy_cols.len(),
7667            "Text-edge should produce fewer columns ({}) than greedy ({})",
7668            te_cols.len(),
7669            greedy_cols.len()
7670        );
7671    }
7672
7673    #[test]
7674    fn test_reject_table_with_too_many_empty_cells() {
7675        use crate::structure::table_extractor::{Table, TableCell, TableRow};
7676        let col_count = 12;
7677        let mut rows = Vec::new();
7678        let mut header = TableRow::new(true);
7679        for c in 0..col_count {
7680            header.cells.push(TableCell {
7681                text: if c < 3 { format!("H{c}") } else { String::new() },
7682                spans: Vec::new(),
7683                colspan: 1,
7684                rowspan: 1,
7685                mcids: vec![],
7686                bbox: None,
7687                is_header: true,
7688            });
7689        }
7690        rows.push(header);
7691        for r in 0..4 {
7692            let mut row = TableRow::new(false);
7693            for c in 0..col_count {
7694                row.cells.push(TableCell {
7695                    text: if c < 2 { format!("R{r}C{c}") } else { String::new() },
7696                    spans: Vec::new(),
7697                    colspan: 1,
7698                    rowspan: 1,
7699                    mcids: vec![],
7700                    bbox: None,
7701                    is_header: false,
7702                });
7703            }
7704            rows.push(row);
7705        }
7706        let table = Table {
7707            rows,
7708            has_header: true,
7709            col_count,
7710            bbox: None,
7711        };
7712        // 5 rows * 12 cols = 60 total, 3 + 4*2 = 11 filled, 49 empty → 81.7% empty ~keep
7713        assert!(
7714            !is_valid_table(&table),
7715            "Table with >60% empty cells should be rejected"
7716        );
7717    }
7718
7719    #[test]
7720    fn test_valid_table_passes_validation() {
7721        use crate::structure::table_extractor::{Table, TableCell, TableRow};
7722        let col_count = 3;
7723        let mut rows = Vec::new();
7724        for r in 0..4 {
7725            let mut row = TableRow::new(r == 0);
7726            for c in 0..col_count {
7727                row.cells.push(TableCell {
7728                    text: format!("R{r}C{c}"),
7729                    spans: Vec::new(),
7730                    colspan: 1,
7731                    rowspan: 1,
7732                    mcids: vec![],
7733                    bbox: None,
7734                    is_header: r == 0,
7735                });
7736            }
7737            rows.push(row);
7738        }
7739        let table = Table {
7740            rows,
7741            has_header: true,
7742            col_count,
7743            bbox: None,
7744        };
7745        assert!(is_valid_table(&table), "Well-populated table should pass validation");
7746    }
7747
7748    /// Product data sheets have label/value rows that look like 2-column
7749    /// tables to the spatial detector (key text on the left, value on
7750    /// the right, with faint cell backgrounds). When the right-hand
7751    /// value wraps, the detector emits a continuation row whose left
7752    /// cell is empty — the hallmark of this false positive. Such tables
7753    /// must be rejected so their rows remain in the flow text.
7754    #[test]
7755    fn test_narrow_shallow_table_rejected_as_false_positive() {
7756        use crate::structure::table_extractor::{Table, TableCell, TableRow};
7757        let col_count = 2;
7758        let rows_data: Vec<(&str, &str)> = vec![
7759            ("Temperature resistance", "adhered to aluminium, -56° C to +82° C"),
7760            (
7761                "Resistance to cleaning agents",
7762                "adhered to aluminium, 8 h in solution (0.5% household",
7763            ),
7764            // Wrapping continuation → empty left cell. ~keep
7765            ("", "cleaning agents) at room temperature and 65° C, no"),
7766        ];
7767        let mut rows = Vec::new();
7768        for (label, value) in &rows_data {
7769            let mut row = TableRow::new(false);
7770            row.cells.push(TableCell {
7771                text: label.to_string(),
7772                spans: Vec::new(),
7773                colspan: 1,
7774                rowspan: 1,
7775                mcids: vec![],
7776                bbox: None,
7777                is_header: false,
7778            });
7779            row.cells.push(TableCell {
7780                text: value.to_string(),
7781                spans: Vec::new(),
7782                colspan: 1,
7783                rowspan: 1,
7784                mcids: vec![],
7785                bbox: None,
7786                is_header: false,
7787            });
7788            rows.push(row);
7789        }
7790        let table = Table {
7791            rows,
7792            has_header: false,
7793            col_count,
7794            bbox: None,
7795        };
7796        assert!(
7797            !is_valid_table(&table),
7798            "Narrow 2-column 'table' with an empty continuation cell must \
7799             be rejected so its rows stay in the flow text"
7800        );
7801    }
7802
7803    /// A 2-column data table with enough filled rows is a real table
7804    /// and must continue to pass validation. Pins the threshold so the
7805    /// narrow-table guard does not regress genuine two-column tables.
7806    #[test]
7807    fn test_narrow_deep_table_still_accepted() {
7808        use crate::structure::table_extractor::{Table, TableCell, TableRow};
7809        let col_count = 2;
7810        let mut rows = Vec::new();
7811        for i in 0..6 {
7812            let mut row = TableRow::new(i == 0);
7813            row.cells.push(TableCell {
7814                text: format!("Key {i}"),
7815                spans: Vec::new(),
7816                colspan: 1,
7817                rowspan: 1,
7818                mcids: vec![],
7819                bbox: None,
7820                is_header: i == 0,
7821            });
7822            row.cells.push(TableCell {
7823                text: format!("Value {i}"),
7824                spans: Vec::new(),
7825                colspan: 1,
7826                rowspan: 1,
7827                mcids: vec![],
7828                bbox: None,
7829                is_header: i == 0,
7830            });
7831            rows.push(row);
7832        }
7833        let table = Table {
7834            rows,
7835            has_header: true,
7836            col_count,
7837            bbox: None,
7838        };
7839        assert!(
7840            is_valid_table(&table),
7841            "A 2-col × 6-row data table should still be accepted"
7842        );
7843    }
7844
7845    /// A sparse 2-column table with a missing value on the right is a
7846    /// legitimate pattern (key/value lists, form layouts, "N/A" rows) and
7847    /// must NOT match the narrow-table false-positive signature, which
7848    /// targets empty-LEFT / filled-RIGHT continuation rows specifically.
7849    #[test]
7850    fn test_narrow_sparse_table_with_missing_right_value_accepted() {
7851        use crate::structure::table_extractor::{Table, TableCell, TableRow};
7852        let col_count = 2;
7853        let rows_data: Vec<(&str, &str)> = vec![
7854            ("Name", "ACME Corp"),
7855            ("Registration", "12345"),
7856            ("Fax", ""),
7857            ("Email", "info@example.com"),
7858        ];
7859        let mut rows = Vec::new();
7860        for (label, value) in &rows_data {
7861            let mut row = TableRow::new(false);
7862            row.cells.push(TableCell {
7863                text: label.to_string(),
7864                spans: Vec::new(),
7865                colspan: 1,
7866                rowspan: 1,
7867                mcids: vec![],
7868                bbox: None,
7869                is_header: false,
7870            });
7871            row.cells.push(TableCell {
7872                text: value.to_string(),
7873                spans: Vec::new(),
7874                colspan: 1,
7875                rowspan: 1,
7876                mcids: vec![],
7877                bbox: None,
7878                is_header: false,
7879            });
7880            rows.push(row);
7881        }
7882        let table = Table {
7883            rows,
7884            has_header: false,
7885            col_count,
7886            bbox: None,
7887        };
7888        assert!(
7889            is_valid_table(&table),
7890            "A 2-col table with a missing right-hand value but no empty-left \
7891             continuation row must still validate"
7892        );
7893    }
7894
7895    #[test]
7896    fn test_text_only_tables_capped_at_max_columns() {
7897        let mut spans = Vec::new();
7898        let col_xs = [50.0_f32, 100.0, 150.0, 200.0, 250.0, 300.0, 350.0, 400.0];
7899        let row_ys = [700.0_f32, 680.0, 660.0, 640.0, 620.0];
7900
7901        for &cx in &col_xs {
7902            for &ry in &row_ys {
7903                spans.push(create_test_span("val", cx, ry, 30.0, 10.0));
7904            }
7905        }
7906
7907        // Use tight tolerance so each X position becomes its own column,
7908        // and cap at 6 columns via config. ~keep
7909        let config = TableDetectionConfig {
7910            column_tolerance: 5.0,
7911            column_merge_threshold: 8.0,
7912            max_table_columns: 6,
7913            ..TableDetectionConfig::default()
7914        };
7915
7916        let tables = detect_tables_from_spans(&spans, &config);
7917        assert!(
7918            tables.is_empty(),
7919            "Text-only table with 8 columns should be rejected (max_table_columns=6), got {} table(s)",
7920            tables.len()
7921        );
7922    }
7923
7924    #[test]
7925    fn test_extended_grid_when_lines_dont_cross() {
7926        // H-lines at y=100 and y=50 spanning full width x=0..500
7927        // V-lines at y=300..350 at x=0, x=100, x=200
7928        // These lines don't physically cross but should produce
7929        // a 1-row x 2-col grid via extended intersections. ~keep
7930        let lines = vec![
7931            make_h_line(0.0, 100.0, 500.0),
7932            make_h_line(0.0, 50.0, 500.0),
7933            make_v_line(0.0, 300.0, 50.0),
7934            make_v_line(100.0, 300.0, 50.0),
7935            make_v_line(200.0, 300.0, 50.0),
7936        ];
7937
7938        let spans = vec![
7939            create_test_span("A", 30.0, 70.0, 20.0, 10.0),
7940            create_test_span("B", 130.0, 70.0, 20.0, 10.0),
7941        ];
7942
7943        let config = TableDetectionConfig {
7944            horizontal_strategy: TableStrategy::Lines,
7945            vertical_strategy: TableStrategy::Lines,
7946            min_table_cells: 2,
7947            min_table_columns: 2,
7948            ..TableDetectionConfig::default()
7949        };
7950
7951        let tables = detect_tables_from_intersections(&spans, &lines, &config);
7952        assert!(
7953            !tables.is_empty(),
7954            "Extended grid should produce at least one table when H and V lines don't cross"
7955        );
7956        let table = &tables[0];
7957        assert!(
7958            table.col_count >= 2,
7959            "Extended grid table should have at least 2 columns, got {}",
7960            table.col_count
7961        );
7962    }
7963
7964    // ========================================================================
7965    // GH#1358: sideways table grid re-orientation
7966    // ======================================================================== ~keep
7967
7968    /// Builds the ruled lines and text spans for a 2-physical-row x
7969    /// 3-physical-column grid: 3 H-lines at y=0/20/40 (each spanning the full
7970    /// x=0..90 width) crossed by 4 V-lines at x=0/30/60/90 (each spanning the
7971    /// full y=0..40 height), with one span per cell whose text encodes its
7972    /// *physical* (page-space) row/col as `"R{row}C{col}"` (row 0 = physical
7973    /// top, i.e. the y=20..40 band; col 0 = physical left, i.e. x=0..30).
7974    /// Every span gets `rotation_degrees` set to `rotation`.
7975    fn rotated_grid_fixture(rotation: f32) -> (Vec<crate::elements::PathContent>, Vec<TextSpan>) {
7976        let lines = vec![
7977            make_h_line(0.0, 0.0, 90.0),
7978            make_h_line(0.0, 20.0, 90.0),
7979            make_h_line(0.0, 40.0, 90.0),
7980            make_v_line(0.0, 0.0, 40.0),
7981            make_v_line(30.0, 0.0, 40.0),
7982            make_v_line(60.0, 0.0, 40.0),
7983            make_v_line(90.0, 0.0, 40.0),
7984        ];
7985        // Column centres 15/45/75 (30pt-wide columns), row centres 30 (top
7986        // band y=20..40) / 10 (bottom band y=0..20); 10x10 spans centred on
7987        // each. ~keep
7988        let col_left_x = [10.0_f32, 40.0, 70.0];
7989        let row_bottom_y = [25.0_f32, 5.0]; // row 0 (top) then row 1 (bottom) ~keep
7990        let mut spans = Vec::new();
7991        for (row, &y) in row_bottom_y.iter().enumerate() {
7992            for (col, &x) in col_left_x.iter().enumerate() {
7993                let text = format!("R{row}C{col}");
7994                spans.push(TextSpan {
7995                    rotation_degrees: rotation,
7996                    ..create_test_span(&text, x, y, 10.0, 10.0)
7997                });
7998            }
7999        }
8000        (lines, spans)
8001    }
8002
8003    fn rotated_grid_config() -> TableDetectionConfig {
8004        TableDetectionConfig {
8005            horizontal_strategy: TableStrategy::Lines,
8006            vertical_strategy: TableStrategy::Lines,
8007            min_table_cells: 2,
8008            min_table_columns: 2,
8009            ..TableDetectionConfig::default()
8010        }
8011    }
8012
8013    /// GH#1358 positive case: a ruled table whose spans are uniformly rotated
8014    /// 90° must have its row/column axes transposed into the table's own
8015    /// frame, not left bucketed on the page's physical top-to-bottom /
8016    /// left-to-right axes.
8017    ///
8018    /// Against unfixed code (`BUCKET_TABLE_GRID_IN_ROTATED_FRAME = false`,
8019    /// or equivalently deleting the `reorient_table_to_rotated_frame` call),
8020    /// this table comes out as the physical 2-row x 3-col grid — row 0 is
8021    /// `["R0C0", "R0C1", "R0C2"]` and `col_count` is `3` — so the first
8022    /// assertion (`col_count == 2`) fails with `col_count == 3`, and the
8023    /// second (row 0 is `["R1C0", "R0C0"]`) fails with row 0 actually being
8024    /// `["R0C0", "R0C1", "R0C2"]`.
8025    #[test]
8026    fn detect_tables_from_intersections_transposes_90_degree_rotated_table_grid() {
8027        let (lines, spans) = rotated_grid_fixture(90.0);
8028        let config = rotated_grid_config();
8029
8030        let tables = detect_tables_from_intersections(&spans, &lines, &config);
8031        assert_eq!(tables.len(), 1, "expected exactly one detected table");
8032        let table = &tables[0];
8033
8034        assert_eq!(
8035            table.col_count, 2,
8036            "a 90-degree table's 3 physical columns must become 2 table columns \
8037             (the physical row count), got {}",
8038            table.col_count
8039        );
8040        assert_eq!(
8041            table.rows.len(),
8042            3,
8043            "expected 3 rows after transposing 2 physical rows into 3"
8044        );
8045
8046        let row_texts: Vec<Vec<&str>> = table
8047            .rows
8048            .iter()
8049            .map(|r| r.cells.iter().map(|c| c.text.as_str()).collect())
8050            .collect();
8051        assert_eq!(
8052            row_texts,
8053            vec![vec!["R1C0", "R0C0"], vec!["R1C1", "R0C1"], vec!["R1C2", "R0C2"],],
8054            "90-degree rotation must rotate the grid 90 degrees clockwise \
8055             (new row = old column ascending, new column = old row reversed); got {row_texts:?}"
8056        );
8057    }
8058
8059    /// GH#1358 regression case: an ordinary upright table (spans at the
8060    /// default `rotation_degrees = 0.0`) must produce the exact physical
8061    /// grid it always did — this is the "0-degree path is bit-for-bit
8062    /// unchanged" guarantee, exercised through the same fixture geometry as
8063    /// the 90-degree test above so the only variable is rotation.
8064    #[test]
8065    fn detect_tables_from_intersections_leaves_upright_table_grid_unchanged() {
8066        let (lines, spans) = rotated_grid_fixture(0.0);
8067        let config = rotated_grid_config();
8068
8069        let tables = detect_tables_from_intersections(&spans, &lines, &config);
8070        assert_eq!(tables.len(), 1, "expected exactly one detected table");
8071        let table = &tables[0];
8072
8073        assert_eq!(table.col_count, 3, "an upright table's column count must not change");
8074        assert_eq!(table.rows.len(), 2, "an upright table's row count must not change");
8075
8076        let row_texts: Vec<Vec<&str>> = table
8077            .rows
8078            .iter()
8079            .map(|r| r.cells.iter().map(|c| c.text.as_str()).collect())
8080            .collect();
8081        assert_eq!(
8082            row_texts,
8083            vec![vec!["R0C0", "R0C1", "R0C2"], vec!["R1C0", "R1C1", "R1C2"]],
8084            "an upright table must keep its physical top-to-bottom, \
8085             left-to-right row/column order; got {row_texts:?}"
8086        );
8087    }
8088
8089    /// GH#1358: `dominant_table_rotation_quadrant` must require a *strict*
8090    /// majority, not merely a plurality — a table whose spans split evenly
8091    /// between two quadrants has no confident rotation and must fall back to
8092    /// `0.0` (upright) rather than guess. This directly exercises the
8093    /// "mixed case" requirement: disagreement is treated honestly (upright
8094    /// fallback), not resolved by silently picking whichever quadrant sorts
8095    /// first.
8096    ///
8097    /// Against a naive plurality-vote implementation (`count >= total / 2`
8098    /// or `max_by_key` alone, no strict-majority check), the tied case below
8099    /// would return `90.0` (quadrant index 1 beats index 0 on the
8100    /// `Reverse(index)` tie-break used for genuine ties within one count),
8101    /// not `0.0`.
8102    #[test]
8103    fn dominant_table_rotation_quadrant_requires_strict_majority_not_plurality() {
8104        let cell = |texts_and_rotations: &[(&str, f32)]| TableCell {
8105            text: String::new(),
8106            spans: texts_and_rotations
8107                .iter()
8108                .map(|&(text, rotation)| TextSpan {
8109                    rotation_degrees: rotation,
8110                    ..create_test_span(text, 0.0, 0.0, 10.0, 10.0)
8111                })
8112                .collect(),
8113            colspan: 1,
8114            rowspan: 1,
8115            mcids: Vec::new(),
8116            bbox: None,
8117            is_header: false,
8118        };
8119
8120        // Exactly 2 spans at 0 degrees and 2 at 90 degrees: a tie, no strict
8121        // majority. Must fall back to upright. ~keep
8122        let tied = Table {
8123            rows: vec![TableRow {
8124                cells: vec![cell(&[("a", 0.0), ("b", 0.0)]), cell(&[("c", 90.0), ("d", 90.0)])],
8125                is_header: false,
8126            }],
8127            has_header: false,
8128            col_count: 2,
8129            bbox: None,
8130        };
8131        assert_eq!(
8132            dominant_table_rotation_quadrant(&tied),
8133            0.0,
8134            "a 2-vs-2 tie between quadrants must not be resolved into a rotation"
8135        );
8136
8137        // 3 spans at 90 degrees vs 1 at 0 degrees: a strict majority (75%). ~keep
8138        let majority = Table {
8139            rows: vec![TableRow {
8140                cells: vec![cell(&[("a", 90.0), ("b", 90.0)]), cell(&[("c", 90.0), ("d", 0.0)])],
8141                is_header: false,
8142            }],
8143            has_header: false,
8144            col_count: 2,
8145            bbox: None,
8146        };
8147        assert_eq!(
8148            dominant_table_rotation_quadrant(&majority),
8149            90.0,
8150            "a 3-vs-1 strict majority must select the majority quadrant"
8151        );
8152    }
8153
8154    // ========================================================================
8155    // Borderless (text-clustering) counterpart of the GH#1358 sideways-table
8156    // fix: `detect_tables_from_spans` has no ruling lines to define grid
8157    // geometry, so its columns/rows come from `detect_columns`/`detect_rows`
8158    // tolerance-clustering on physical page X/Y directly -- but the same
8159    // "physical bucketing is right, axis labelling is wrong for a rotated
8160    // table" defect applies, and is fixed the same way: a post-process
8161    // reorientation of the finished `Table`. ~keep
8162    // ======================================================================== ~keep
8163
8164    /// Same physical layout as `rotated_grid_fixture` (2 physical rows x 3
8165    /// physical columns, one span per cell named by its *physical*
8166    /// `"R{row}C{col}"` position) but with no ruling lines at all, so it
8167    /// exercises the text-clustering path (`detect_tables_from_spans`)
8168    /// instead of the intersection path. Every span gets `rotation_degrees`
8169    /// set to `rotation`.
8170    fn borderless_rotated_grid_fixture(rotation: f32) -> Vec<TextSpan> {
8171        let col_left_x = [10.0_f32, 40.0, 70.0];
8172        let row_bottom_y = [25.0_f32, 5.0]; // row 0 (top) then row 1 (bottom) ~keep
8173        let mut spans = Vec::new();
8174        for (row, &y) in row_bottom_y.iter().enumerate() {
8175            for (col, &x) in col_left_x.iter().enumerate() {
8176                let text = format!("R{row}C{col}");
8177                spans.push(TextSpan {
8178                    rotation_degrees: rotation,
8179                    ..create_test_span(&text, x, y, 10.0, 10.0)
8180                });
8181            }
8182        }
8183        spans
8184    }
8185
8186    /// Borderless positive case: a text-clustered table whose spans are
8187    /// uniformly rotated 90° must have its row/column axes transposed into
8188    /// the table's own frame, exactly like the ruled-line case in
8189    /// `detect_tables_from_intersections_transposes_90_degree_rotated_table_grid`.
8190    ///
8191    /// Against unfixed code (`BUCKET_BORDERLESS_TABLE_GRID_IN_ROTATED_FRAME =
8192    /// false`, or equivalently deleting the `reorient_table_to_rotated_frame`
8193    /// call in `detect_tables_from_spans`), this table comes out as the
8194    /// physical 2-row x 3-col grid -- `col_count == 3` (fails the first
8195    /// assertion, which expects `2`) and row 0 is
8196    /// `["R0C0", "R0C1", "R0C2"]` (fails the second assertion, which expects
8197    /// `["R1C0", "R0C0"]`).
8198    #[test]
8199    fn detect_tables_from_spans_transposes_90_degree_rotated_borderless_grid() {
8200        let spans = borderless_rotated_grid_fixture(90.0);
8201        let config = TableDetectionConfig::default();
8202
8203        let tables = detect_tables_from_spans(&spans, &config);
8204        assert_eq!(tables.len(), 1, "expected exactly one detected table");
8205        let table = &tables[0];
8206
8207        assert_eq!(
8208            table.col_count, 2,
8209            "a 90-degree borderless table's 3 physical columns must become 2 table \
8210             columns (the physical row count), got {}",
8211            table.col_count
8212        );
8213        assert_eq!(
8214            table.rows.len(),
8215            3,
8216            "expected 3 rows after transposing 2 physical rows into 3"
8217        );
8218
8219        let row_texts: Vec<Vec<&str>> = table
8220            .rows
8221            .iter()
8222            .map(|r| r.cells.iter().map(|c| c.text.as_str()).collect())
8223            .collect();
8224        assert_eq!(
8225            row_texts,
8226            vec![vec!["R1C0", "R0C0"], vec!["R1C1", "R0C1"], vec!["R1C2", "R0C2"],],
8227            "90-degree rotation must rotate the borderless grid 90 degrees clockwise \
8228             (new row = old column ascending, new column = old row reversed); got {row_texts:?}"
8229        );
8230    }
8231
8232    /// Borderless positive case for the opposite quadrant: 270° transposes
8233    /// the grid the other way round from 90° (new row = old column
8234    /// *reversed*, new column = old row ascending), pinning down the
8235    /// rotation direction rather than just "some transpose happened".
8236    ///
8237    /// Against unfixed code, this comes out identically to the 90-degree
8238    /// case above: `col_count == 3` (fails the first assertion, expects
8239    /// `2`) with row 0 `["R0C0", "R0C1", "R0C2"]` (fails the second
8240    /// assertion, which expects `["R0C2", "R1C2"]`).
8241    #[test]
8242    fn detect_tables_from_spans_transposes_270_degree_rotated_borderless_grid() {
8243        let spans = borderless_rotated_grid_fixture(270.0);
8244        let config = TableDetectionConfig::default();
8245
8246        let tables = detect_tables_from_spans(&spans, &config);
8247        assert_eq!(tables.len(), 1, "expected exactly one detected table");
8248        let table = &tables[0];
8249
8250        assert_eq!(
8251            table.col_count, 2,
8252            "a 270-degree borderless table's 3 physical columns must become 2 table \
8253             columns, got {}",
8254            table.col_count
8255        );
8256        assert_eq!(
8257            table.rows.len(),
8258            3,
8259            "expected 3 rows after transposing 2 physical rows into 3"
8260        );
8261
8262        let row_texts: Vec<Vec<&str>> = table
8263            .rows
8264            .iter()
8265            .map(|r| r.cells.iter().map(|c| c.text.as_str()).collect())
8266            .collect();
8267        assert_eq!(
8268            row_texts,
8269            vec![vec!["R0C2", "R1C2"], vec!["R0C1", "R1C1"], vec!["R0C0", "R1C0"],],
8270            "270-degree rotation must rotate the borderless grid the opposite way \
8271             round from 90 degrees (new row = old column reversed, new column = old \
8272             row ascending); got {row_texts:?}"
8273        );
8274    }
8275
8276    #[test]
8277    fn test_merge_vertically_adjacent_tables() {
8278        // Two tables with 3 columns each, bboxes separated by 5pt (< ADJACENT_TABLE_MERGE_GAP).
8279        // ~keep
8280        let table1 = Table {
8281            rows: vec![TableRow {
8282                cells: vec![
8283                    TableCell {
8284                        text: "A".into(),
8285                        spans: Vec::new(),
8286                        colspan: 1,
8287                        rowspan: 1,
8288                        mcids: vec![],
8289                        bbox: None,
8290                        is_header: false,
8291                    },
8292                    TableCell {
8293                        text: "B".into(),
8294                        spans: Vec::new(),
8295                        colspan: 1,
8296                        rowspan: 1,
8297                        mcids: vec![],
8298                        bbox: None,
8299                        is_header: false,
8300                    },
8301                    TableCell {
8302                        text: "C".into(),
8303                        spans: Vec::new(),
8304                        colspan: 1,
8305                        rowspan: 1,
8306                        mcids: vec![],
8307                        bbox: None,
8308                        is_header: false,
8309                    },
8310                ],
8311                is_header: false,
8312            }],
8313            has_header: false,
8314            col_count: 3,
8315            bbox: Some(Rect::new(0.0, 100.0, 300.0, 50.0)),
8316        };
8317        let table2 = Table {
8318            rows: vec![TableRow {
8319                cells: vec![
8320                    TableCell {
8321                        text: "D".into(),
8322                        spans: Vec::new(),
8323                        colspan: 1,
8324                        rowspan: 1,
8325                        mcids: vec![],
8326                        bbox: None,
8327                        is_header: false,
8328                    },
8329                    TableCell {
8330                        text: "E".into(),
8331                        spans: Vec::new(),
8332                        colspan: 1,
8333                        rowspan: 1,
8334                        mcids: vec![],
8335                        bbox: None,
8336                        is_header: false,
8337                    },
8338                    TableCell {
8339                        text: "F".into(),
8340                        spans: Vec::new(),
8341                        colspan: 1,
8342                        rowspan: 1,
8343                        mcids: vec![],
8344                        bbox: None,
8345                        is_header: false,
8346                    },
8347                ],
8348                is_header: false,
8349            }],
8350            has_header: false,
8351            col_count: 3,
8352            bbox: Some(Rect::new(0.0, 155.0, 300.0, 50.0)),
8353        };
8354
8355        let mut tables = vec![table1, table2];
8356        merge_vertically_adjacent_tables(&mut tables);
8357        assert_eq!(tables.len(), 1, "Adjacent tables should be merged into one");
8358        assert_eq!(tables[0].rows.len(), 2, "Merged table should have 2 rows");
8359        assert_eq!(tables[0].col_count, 3);
8360    }
8361
8362    #[test]
8363    fn test_no_merge_when_gap_too_large() {
8364        let table1 = Table {
8365            rows: vec![TableRow {
8366                cells: vec![TableCell {
8367                    text: "A".into(),
8368                    spans: Vec::new(),
8369                    colspan: 1,
8370                    rowspan: 1,
8371                    mcids: vec![],
8372                    bbox: None,
8373                    is_header: false,
8374                }],
8375                is_header: false,
8376            }],
8377            has_header: false,
8378            col_count: 1,
8379            bbox: Some(Rect::new(0.0, 100.0, 300.0, 50.0)),
8380        };
8381        let table2 = Table {
8382            rows: vec![TableRow {
8383                cells: vec![TableCell {
8384                    text: "B".into(),
8385                    spans: Vec::new(),
8386                    colspan: 1,
8387                    rowspan: 1,
8388                    mcids: vec![],
8389                    bbox: None,
8390                    is_header: false,
8391                }],
8392                is_header: false,
8393            }],
8394            has_header: false,
8395            col_count: 1,
8396            // Top at 200, gap = 200 - 150 = 50pt >> ADJACENT_TABLE_MERGE_GAP ~keep
8397            bbox: Some(Rect::new(0.0, 200.0, 300.0, 50.0)),
8398        };
8399
8400        let mut tables = vec![table1, table2];
8401        merge_vertically_adjacent_tables(&mut tables);
8402        assert_eq!(tables.len(), 2, "Tables with large gap should NOT be merged");
8403    }
8404
8405    #[test]
8406    fn test_census_h_and_v_in_different_regions() {
8407        // Census-style layout: H-lines at y=100, y=50 spanning full width (x=36..576)
8408        // V-lines at y=500..550 at positions x=36, 117, 197, 277, 357, 437, 517, 576
8409        // The H and V lines DON'T physically cross (different Y regions)
8410        // But they should produce a table via extended grid. ~keep
8411        let lines = vec![
8412            make_h_line(36.0, 100.0, 540.0),
8413            make_h_line(36.0, 50.0, 540.0),
8414            make_v_line(36.0, 500.0, 50.0),
8415            make_v_line(117.0, 500.0, 50.0),
8416            make_v_line(197.0, 500.0, 50.0),
8417            make_v_line(277.0, 500.0, 50.0),
8418            make_v_line(357.0, 500.0, 50.0),
8419            make_v_line(437.0, 500.0, 50.0),
8420            make_v_line(517.0, 500.0, 50.0),
8421            make_v_line(576.0, 500.0, 50.0),
8422        ];
8423
8424        let spans = vec![
8425            create_test_span("A", 60.0, 70.0, 20.0, 10.0),
8426            create_test_span("B", 140.0, 70.0, 20.0, 10.0),
8427            create_test_span("C", 220.0, 70.0, 20.0, 10.0),
8428            create_test_span("D", 300.0, 70.0, 20.0, 10.0),
8429            create_test_span("E", 380.0, 70.0, 20.0, 10.0),
8430            create_test_span("F", 460.0, 70.0, 20.0, 10.0),
8431            create_test_span("G", 540.0, 70.0, 20.0, 10.0),
8432        ];
8433
8434        let config = TableDetectionConfig {
8435            horizontal_strategy: TableStrategy::Lines,
8436            vertical_strategy: TableStrategy::Lines,
8437            min_table_cells: 2,
8438            min_table_columns: 2,
8439            ..TableDetectionConfig::default()
8440        };
8441
8442        let tables = detect_tables_with_lines(&spans, &lines, &config);
8443        assert!(
8444            !tables.is_empty(),
8445            "Census layout with H/V in different Y regions should produce at least 1 table"
8446        );
8447        let table = &tables[0];
8448        // Should have ~7 columns (8 V-lines = 7 column spans) and 1 row (2 H-lines = 1 row) ~keep
8449        assert!(
8450            table.col_count >= 5,
8451            "Census table should have at least 5 columns, got {}",
8452            table.col_count
8453        );
8454    }
8455
8456    #[test]
8457    fn test_w2_grid_not_fragmented() {
8458        // W-2 style: V-lines at x=100,200,300 spanning y=100..700 (full form)
8459        // V-lines at x=350,450 spanning y=300..500 (sub-section only)
8460        // H-lines at y=100,200,300,400,500,600,700
8461        // Should produce 1 table (or a few that merge), not 5+ fragments. ~keep
8462        let lines = vec![
8463            make_v_line(100.0, 100.0, 600.0),
8464            make_v_line(200.0, 100.0, 600.0),
8465            make_v_line(300.0, 100.0, 600.0),
8466            make_v_line(350.0, 300.0, 200.0),
8467            make_v_line(450.0, 300.0, 200.0),
8468            make_h_line(100.0, 100.0, 350.0),
8469            make_h_line(100.0, 200.0, 350.0),
8470            make_h_line(100.0, 300.0, 350.0),
8471            make_h_line(100.0, 400.0, 350.0),
8472            make_h_line(100.0, 500.0, 350.0),
8473            make_h_line(100.0, 600.0, 350.0),
8474            make_h_line(100.0, 700.0, 350.0),
8475        ];
8476
8477        let spans = vec![
8478            create_test_span("R1C1", 120.0, 150.0, 30.0, 10.0),
8479            create_test_span("R1C2", 220.0, 150.0, 30.0, 10.0),
8480            create_test_span("R2C1", 120.0, 250.0, 30.0, 10.0),
8481            create_test_span("R2C2", 220.0, 250.0, 30.0, 10.0),
8482            create_test_span("R3C1", 120.0, 350.0, 30.0, 10.0),
8483            create_test_span("R3C2", 220.0, 350.0, 30.0, 10.0),
8484            create_test_span("R4C1", 120.0, 450.0, 30.0, 10.0),
8485            create_test_span("R4C2", 220.0, 450.0, 30.0, 10.0),
8486            create_test_span("R5C1", 120.0, 550.0, 30.0, 10.0),
8487            create_test_span("R5C2", 220.0, 550.0, 30.0, 10.0),
8488            create_test_span("R6C1", 120.0, 650.0, 30.0, 10.0),
8489            create_test_span("R6C2", 220.0, 650.0, 30.0, 10.0),
8490        ];
8491
8492        let config = TableDetectionConfig {
8493            horizontal_strategy: TableStrategy::Lines,
8494            vertical_strategy: TableStrategy::Lines,
8495            min_table_cells: 4,
8496            min_table_columns: 2,
8497            ..TableDetectionConfig::default()
8498        };
8499
8500        let tables = detect_tables_with_lines(&spans, &lines, &config);
8501        assert!(
8502            tables.len() <= 2,
8503            "W-2 grid should produce at most 2 tables (not fragmented into {})",
8504            tables.len()
8505        );
8506        let total_filled: usize = tables
8507            .iter()
8508            .flat_map(|t| &t.rows)
8509            .flat_map(|r| &r.cells)
8510            .filter(|c| !c.text.is_empty())
8511            .count();
8512        assert!(
8513            total_filled >= 8,
8514            "W-2 tables should capture most text spans, got {}",
8515            total_filled
8516        );
8517    }
8518
8519    #[test]
8520    fn test_invoice_still_separate_tables() {
8521        let lines = vec![
8522            make_h_line(410.0, 83.0, 125.0),
8523            make_h_line(410.0, 142.0, 125.0),
8524            make_v_line(410.0, 71.0, 72.0),
8525            make_v_line(535.0, 71.0, 72.0),
8526            make_h_line(22.0, 150.0, 567.0),
8527            make_h_line(22.0, 553.0, 567.0),
8528            make_v_line(22.0, 150.0, 403.0),
8529            make_v_line(103.0, 150.0, 403.0),
8530            make_v_line(490.0, 150.0, 403.0),
8531            make_v_line(541.0, 150.0, 403.0),
8532            make_v_line(589.0, 150.0, 403.0),
8533        ];
8534
8535        let mut spans = vec![
8536            create_test_span("Balance Due", 420.0, 100.0, 80.0, 10.0),
8537            create_test_span("$500.00", 420.0, 120.0, 60.0, 10.0),
8538        ];
8539        for i in 0..6 {
8540            let y = 160.0 + i as f32 * 60.0;
8541            spans.push(create_test_span("Date", 30.0, y, 40.0, 10.0));
8542            spans.push(create_test_span("Code", 110.0, y, 40.0, 10.0));
8543            spans.push(create_test_span("Desc", 200.0, y, 200.0, 10.0));
8544            spans.push(create_test_span("$100", 500.0, y, 30.0, 10.0));
8545        }
8546
8547        let config = TableDetectionConfig {
8548            horizontal_strategy: TableStrategy::Lines,
8549            vertical_strategy: TableStrategy::Lines,
8550            min_table_cells: 2,
8551            min_table_columns: 1,
8552            ..TableDetectionConfig::default()
8553        };
8554
8555        let tables = detect_tables_with_lines(&spans, &lines, &config);
8556        assert!(
8557            tables.len() >= 2,
8558            "Invoice should produce at least 2 separate tables (header + main), got {}",
8559            tables.len()
8560        );
8561    }
8562
8563    #[test]
8564    fn test_two_column_table_detection() {
8565        let mut spans = vec![
8566            create_test_span("Abstract", 50.0, 700.0, 60.0, 12.0),
8567            create_test_span("We present a novel approach to language", 50.0, 680.0, 230.0, 12.0),
8568            create_test_span("Results show improvements across all", 50.0, 660.0, 230.0, 12.0),
8569            create_test_span("benchmarks with significant gains on", 50.0, 640.0, 230.0, 12.0),
8570            create_test_span("standard evaluation metrics.", 50.0, 620.0, 180.0, 12.0),
8571        ];
8572
8573        spans.push(create_test_span("Model", 320.0, 700.0, 40.0, 12.0));
8574        spans.push(create_test_span("F1", 420.0, 700.0, 15.0, 12.0));
8575        spans.push(create_test_span("Acc", 500.0, 700.0, 20.0, 12.0));
8576        spans.push(create_test_span("BERT", 320.0, 680.0, 30.0, 12.0));
8577        spans.push(create_test_span("92.4", 420.0, 680.0, 25.0, 12.0));
8578        spans.push(create_test_span("89.1", 500.0, 680.0, 25.0, 12.0));
8579        spans.push(create_test_span("GPT", 320.0, 660.0, 25.0, 12.0));
8580        spans.push(create_test_span("91.2", 420.0, 660.0, 25.0, 12.0));
8581        spans.push(create_test_span("88.3", 500.0, 660.0, 25.0, 12.0));
8582
8583        let config = TableDetectionConfig::default();
8584        let tables = detect_tables_from_spans_column_aware(&spans, &config);
8585
8586        assert_eq!(
8587            tables.len(),
8588            1,
8589            "Should detect exactly 1 table in the right column, got {}",
8590            tables.len()
8591        );
8592        assert_eq!(
8593            tables[0].col_count, 3,
8594            "Table should have 3 columns, got {}",
8595            tables[0].col_count
8596        );
8597    }
8598
8599    #[test]
8600    fn test_single_column_no_regression() {
8601        let spans = vec![
8602            create_test_span("Introduction", 50.0, 700.0, 80.0, 14.0),
8603            create_test_span(
8604                "This paper presents a comprehensive study of natural language",
8605                50.0,
8606                680.0,
8607                450.0,
8608                12.0,
8609            ),
8610            create_test_span(
8611                "processing techniques applied to large-scale document analysis.",
8612                50.0,
8613                660.0,
8614                430.0,
8615                12.0,
8616            ),
8617            create_test_span(
8618                "Our approach builds on recent advances in transformer architectures",
8619                50.0,
8620                640.0,
8621                460.0,
8622                12.0,
8623            ),
8624            create_test_span(
8625                "and demonstrates improvements across multiple benchmarks.",
8626                50.0,
8627                620.0,
8628                400.0,
8629                12.0,
8630            ),
8631            create_test_span(
8632                "We evaluate our method on standard datasets and report results.",
8633                50.0,
8634                600.0,
8635                420.0,
8636                12.0,
8637            ),
8638        ];
8639
8640        let config = TableDetectionConfig::default();
8641        let tables = detect_tables_from_spans_column_aware(&spans, &config);
8642        assert!(
8643            tables.is_empty(),
8644            "Single-column paragraph text should not be detected as a table, got {} table(s)",
8645            tables.len()
8646        );
8647    }
8648
8649    #[test]
8650    fn test_h_rule_bounded_text_table() {
8651        let lines = vec![make_h_line(50.0, 750.0, 350.0), make_h_line(50.0, 700.0, 350.0)];
8652
8653        let spans = vec![
8654            create_test_span("Model", 60.0, 740.0, 50.0, 10.0),
8655            create_test_span("Acc", 180.0, 740.0, 30.0, 10.0),
8656            create_test_span("F1", 280.0, 740.0, 20.0, 10.0),
8657            create_test_span("BERT", 60.0, 728.0, 40.0, 10.0),
8658            create_test_span("84.6", 180.0, 728.0, 30.0, 10.0),
8659            create_test_span("83.4", 280.0, 728.0, 30.0, 10.0),
8660            create_test_span("GPT", 60.0, 716.0, 35.0, 10.0),
8661            create_test_span("82.1", 180.0, 716.0, 30.0, 10.0),
8662            create_test_span("81.0", 280.0, 716.0, 30.0, 10.0),
8663            create_test_span("XLNet", 60.0, 704.0, 45.0, 10.0),
8664            create_test_span("85.2", 180.0, 704.0, 30.0, 10.0),
8665            create_test_span("84.1", 280.0, 704.0, 30.0, 10.0),
8666        ];
8667
8668        let config = TableDetectionConfig::default();
8669        let tables = detect_tables_with_lines(&spans, &lines, &config);
8670        assert!(
8671            !tables.is_empty(),
8672            "Should detect at least 1 table from text within H-line boundaries"
8673        );
8674        let table = &tables[0];
8675        assert!(
8676            table.col_count >= 3,
8677            "Expected at least 3 columns, got {}",
8678            table.col_count
8679        );
8680        assert!(
8681            table.rows.len() >= 3,
8682            "Expected at least 3 rows, got {}",
8683            table.rows.len()
8684        );
8685    }
8686
8687    fn ruled_rows(rows: &[[&str; 3]], top: f32) -> Vec<TextSpan> {
8688        rows.iter()
8689            .enumerate()
8690            .flat_map(|(i, row)| {
8691                let y = top - 12.0 - i as f32 * 15.0;
8692                row.iter()
8693                    .zip([60.0, 180.0, 280.0])
8694                    .map(move |(text, x)| create_test_span(text, x, y, 40.0, 10.0))
8695            })
8696            .collect()
8697    }
8698
8699    #[test]
8700    fn strict_lines_keep_a_rules_only_table_beside_a_grid() {
8701        let grid = [
8702            ["Item", "Qty", "Price"],
8703            ["Bolt", "40", "0.10"],
8704            ["Nut", "40", "0.05"],
8705            ["Washer", "80", "0.02"],
8706        ];
8707        let ruled = [
8708            ["Region", "Sales", "Growth"],
8709            ["Northland", "120", "3.1"],
8710            ["Eastmark", "95", "2.4"],
8711            ["Southvale", "88", "1.9"],
8712        ];
8713        let mut lines: Vec<_> = (0..=4)
8714            .map(|i| make_h_line(50.0, 750.0 - i as f32 * 15.0, 300.0))
8715            .collect();
8716        lines.extend([50.0, 150.0, 250.0, 350.0].map(|x| make_v_line(x, 690.0, 60.0)));
8717        lines.extend([600.0, 585.0, 540.0].map(|y| make_h_line(50.0, y, 300.0)));
8718        let mut spans = ruled_rows(&grid, 750.0);
8719        spans.extend(ruled_rows(&ruled, 600.0));
8720
8721        let tables = detect_tables_with_lines(&spans, &lines, &TableDetectionConfig::strict());
8722
8723        let booktabs = tables
8724            .iter()
8725            .find(|t| t.rows.iter().any(|r| r.cells.iter().any(|c| c.text == "Southvale")))
8726            .expect("rules-only table beside the grid");
8727        assert_eq!(tables.len(), 2);
8728        assert_eq!(booktabs.rows.len(), 4, "header above the midrule stays in the table");
8729        assert_eq!(booktabs.rows[0].cells[0].text, "Region");
8730    }
8731
8732    #[test]
8733    fn h_rules_under_every_row_form_one_table() {
8734        let rows = [
8735            ["Code", "Name", "Rate"],
8736            ["A1", "Alpha", "1.5"],
8737            ["B2", "Beta", "2.5"],
8738            ["C3", "Gamma", "3.5"],
8739        ];
8740        let lines: Vec<_> = (0..=4)
8741            .map(|i| make_h_line(50.0, 600.0 - i as f32 * 15.0, 300.0))
8742            .collect();
8743        let spans = ruled_rows(&rows, 600.0);
8744
8745        let tables = detect_tables_with_lines(&spans, &lines, &TableDetectionConfig::default());
8746
8747        assert_eq!(tables.len(), 1);
8748        assert_eq!(tables[0].rows.len(), 4);
8749    }
8750
8751    #[test]
8752    fn joined_header_band_keeps_the_body_columns_past_its_rule() {
8753        let lines = vec![
8754            make_h_line(50.0, 600.0, 300.0),
8755            make_h_line(60.0, 585.0, 300.0),
8756            make_h_line(70.0, 540.0, 300.0),
8757        ];
8758        let mut spans: Vec<_> = ["Code", "Name", "Rate"]
8759            .iter()
8760            .zip([80.0, 180.0, 280.0])
8761            .map(|(t, x)| create_test_span(t, x, 589.0, 40.0, 10.0))
8762            .collect();
8763        for (i, row) in [
8764            ["A1", "Alpha", "1.5", "x"],
8765            ["B2", "Beta", "2.5", "y"],
8766            ["C3", "Gamma", "3.5", "z"],
8767        ]
8768        .iter()
8769        .enumerate()
8770        {
8771            let y = 574.0 - i as f32 * 12.0;
8772            spans.extend(
8773                row.iter()
8774                    .zip([80.0, 180.0, 280.0, 345.0])
8775                    .map(|(t, x)| create_test_span(t, x, y, 20.0, 10.0)),
8776            );
8777        }
8778
8779        let tables = detect_tables_with_lines(&spans, &lines, &TableDetectionConfig::default());
8780
8781        let cells: Vec<&str> = tables
8782            .iter()
8783            .flat_map(|t| &t.rows)
8784            .flat_map(|r| &r.cells)
8785            .map(|c| c.text.as_str())
8786            .collect();
8787        assert!(
8788            cells.contains(&"z"),
8789            "body column past the header rule stays in the table: {cells:?}"
8790        );
8791    }
8792
8793    #[test]
8794    fn test_split_table_at_section_dividers() {
8795        // Simulate a multi-section form with 3 sections separated by
8796        // full-width H-lines.  Each section has its OWN vertical grid lines
8797        // (they don't span across section boundaries).
8798        //
8799        // Section 1: y=10..40   (3 rows, H-lines at y=10,20,30,40)
8800        // Section 2: y=40..70   (3 rows, H-lines at y=40,50,60,70)
8801        // Section 3: y=70..100  (3 rows, H-lines at y=70,80,90,100)
8802        //
8803        // V-lines per section: x=10,40,70,100 but only within each section's
8804        // Y-range, so no V-edge crosses y=40 or y=70. ~keep
8805
8806        let mut lines: Vec<crate::elements::PathContent> = Vec::new();
8807
8808        for i in 0..=9 {
8809            let y = 10.0 + i as f32 * 10.0;
8810            lines.push(make_h_line(10.0, y, 90.0));
8811        }
8812
8813        for &x in &[10.0, 40.0, 70.0, 100.0] {
8814            lines.push(make_v_line(x, 10.0, 30.0));
8815        }
8816        for &x in &[10.0, 40.0, 70.0, 100.0] {
8817            lines.push(make_v_line(x, 40.0, 30.0));
8818        }
8819        for &x in &[10.0, 40.0, 70.0, 100.0] {
8820            lines.push(make_v_line(x, 70.0, 30.0));
8821        }
8822
8823        let mut spans = Vec::new();
8824        for row in 0..9 {
8825            let y = 15.0 + row as f32 * 10.0;
8826            for col in 0..3 {
8827                let x = 15.0 + col as f32 * 30.0;
8828                let label = format!("S{}-R{}-C{}", row / 3 + 1, row % 3 + 1, col + 1);
8829                spans.push(create_test_span(&label, x, y, 20.0, 8.0));
8830            }
8831        }
8832
8833        let config = TableDetectionConfig {
8834            horizontal_strategy: TableStrategy::Lines,
8835            vertical_strategy: TableStrategy::Lines,
8836            ..TableDetectionConfig::default()
8837        };
8838
8839        let tables = detect_tables_with_lines(&spans, &lines, &config);
8840
8841        // The full-width H-lines at y=40 and y=70 have no V-edges crossing
8842        // through them, so they should be detected as section dividers.
8843        // We expect 3 tables (one per section). ~keep
8844        assert!(
8845            tables.len() >= 3,
8846            "Expected at least 3 tables after section-divider splitting, got {}",
8847            tables.len()
8848        );
8849        for (i, t) in tables.iter().enumerate() {
8850            assert_eq!(t.col_count, 3, "Table {} should have 3 columns, got {}", i, t.col_count);
8851        }
8852    }
8853
8854    // -----------------------------------------------------------------
8855    // validate_table_structure_internal: split-column-group tests
8856    //
8857    // These exercise has_split_modal_column_groups, the structural
8858    // check that replaced the row-density gate. The detector rejects
8859    // grids whose modal rows partition into two or more disconnected
8860    // column-co-occurrence components. make_split_grid models the
8861    // false-positive shape (two prose flows mis-clustered into one
8862    // grid); make_grouped_grid models the sparse grouped-row-header
8863    // shape from the scientific-table regression class; the real
8864    // failure may also involve upstream column over-counting, while
8865    // this unit fixture pins the validator-level property that sparse
8866    // modal rows with connected populated columns are accepted.
8867    // ----------------------------------------------------------------- ~keep
8868
8869    /// Build a minimal GridStructure with `num_rows` rows and
8870    /// `num_cols` columns, where every row populates exactly
8871    /// `populated_per_row` cells (the first N columns). Numeric
8872    /// fields of `ColumnCluster` / `RowCluster` are arbitrary —
8873    /// `validate_table_structure_internal` reads only
8874    /// `grid.columns.len()` and the emptiness of each cell.
8875    fn make_uniform_grid(num_cols: usize, num_rows: usize, populated_per_row: usize) -> GridStructure {
8876        let columns = (0..num_cols)
8877            .map(|_| ColumnCluster {
8878                x_center: 0.0,
8879                x_min: 0.0,
8880                x_max: 0.0,
8881                span_indices: vec![],
8882            })
8883            .collect();
8884        let rows = (0..num_rows)
8885            .map(|_| RowCluster {
8886                y_center: 0.0,
8887                y_min: 0.0,
8888                y_max: 0.0,
8889                span_indices: vec![],
8890            })
8891            .collect();
8892        let cells = (0..num_rows)
8893            .map(|_| {
8894                (0..num_cols)
8895                    .map(|c| if c < populated_per_row { vec![0usize] } else { vec![] })
8896                    .collect()
8897            })
8898            .collect();
8899        GridStructure { columns, rows, cells }
8900    }
8901
8902    /// Build a GridStructure modelling two adjacent text flows
8903    /// mis-clustered into one candidate grid. The first `num_cols / 2`
8904    /// columns form the "left flow"; the remaining columns form the
8905    /// "right flow". Rows alternate between populating only the left
8906    /// flow and only the right flow. All rows have the same populated
8907    /// cardinality (`num_cols / 2`), so the regular-row-ratio gate
8908    /// passes at 1.00. `num_cols` must be even.
8909    fn make_split_grid(num_cols: usize, num_rows: usize) -> GridStructure {
8910        assert!(num_cols.is_multiple_of(2), "make_split_grid requires even num_cols");
8911        let half = num_cols / 2;
8912        let columns = (0..num_cols)
8913            .map(|_| ColumnCluster {
8914                x_center: 0.0,
8915                x_min: 0.0,
8916                x_max: 0.0,
8917                span_indices: vec![],
8918            })
8919            .collect();
8920        let rows = (0..num_rows)
8921            .map(|_| RowCluster {
8922                y_center: 0.0,
8923                y_min: 0.0,
8924                y_max: 0.0,
8925                span_indices: vec![],
8926            })
8927            .collect();
8928        let cells = (0..num_rows)
8929            .map(|r| {
8930                let left_row = r % 2 == 0;
8931                (0..num_cols)
8932                    .map(|c| {
8933                        let in_left_half = c < half;
8934                        if left_row == in_left_half { vec![0usize] } else { vec![] }
8935                    })
8936                    .collect()
8937            })
8938            .collect();
8939        GridStructure { columns, rows, cells }
8940    }
8941
8942    /// Build a GridStructure modelling a hierarchical scientific table.
8943    /// `total_cols` columns; the first `group_cols` are populated only
8944    /// in the first row of each group of `group_size` consecutive rows;
8945    /// the remaining columns are populated in every row. Models the
8946    /// failure shape from arxiv_2510.24670v2: grouped row-headers above
8947    /// dense data columns. The over-counting that the maintainer
8948    /// described occurs upstream of this fixture; here we model the
8949    /// post-clustering grid the validator actually sees. Numeric
8950    /// cluster fields are arbitrary, matching the convention used by
8951    /// `make_uniform_grid` and `make_split_grid`.
8952    fn make_grouped_grid(total_cols: usize, num_rows: usize, group_cols: usize, group_size: usize) -> GridStructure {
8953        assert!(group_cols < total_cols, "group_cols must be < total_cols");
8954        assert!(group_size > 0, "group_size must be positive");
8955        let columns = (0..total_cols)
8956            .map(|_| ColumnCluster {
8957                x_center: 0.0,
8958                x_min: 0.0,
8959                x_max: 0.0,
8960                span_indices: vec![],
8961            })
8962            .collect();
8963        let rows = (0..num_rows)
8964            .map(|_| RowCluster {
8965                y_center: 0.0,
8966                y_min: 0.0,
8967                y_max: 0.0,
8968                span_indices: vec![],
8969            })
8970            .collect();
8971        let cells = (0..num_rows)
8972            .map(|r| {
8973                let is_group_header = r % group_size == 0;
8974                (0..total_cols)
8975                    .map(|c| {
8976                        let populated = if c < group_cols { is_group_header } else { true };
8977                        if populated { vec![0usize] } else { vec![] }
8978                    })
8979                    .collect()
8980            })
8981            .collect();
8982        GridStructure { columns, rows, cells }
8983    }
8984
8985    /// 6 columns, 6 rows, modal rows alternate between {0,1,2} and
8986    /// {3,4,5}. Two disconnected components of 3 columns each, each
8987    /// with 3 modal rows of support. Default profile.
8988    #[test]
8989    fn validate_rejects_split_column_groups() {
8990        let grid = make_split_grid(6, 6);
8991        let config = TableDetectionConfig::default();
8992        assert!(!validate_table_structure_internal(&grid, &config));
8993    }
8994
8995    /// Same fixture, strict profile. The strict profile's stronger
8996    /// regular_row_ratio (0.8) does not catch this; the split-column
8997    /// detector does.
8998    #[test]
8999    fn validate_rejects_split_column_groups_under_strict_profile() {
9000        let grid = make_split_grid(6, 6);
9001        let config = TableDetectionConfig::strict();
9002        assert!(!validate_table_structure_internal(&grid, &config));
9003    }
9004
9005    /// 11 columns, 5 rows, every row populates every column. One
9006    /// component spanning all columns → accepted.
9007    #[test]
9008    fn validate_accepts_dense_table() {
9009        let grid = make_uniform_grid(11, 5, 11);
9010        let config = TableDetectionConfig::default();
9011        assert!(validate_table_structure_internal(&grid, &config));
9012    }
9013
9014    /// 6 columns, 5 rows, every row populates the first 4 columns.
9015    /// The old density gate would have admitted this at the boundary
9016    /// (4/6 = 2/3). One connected component of 4 columns → accepted.
9017    #[test]
9018    fn validate_accepts_sparse_connected_table() {
9019        let grid = make_uniform_grid(6, 5, 4);
9020        let config = TableDetectionConfig::default();
9021        assert!(validate_table_structure_internal(&grid, &config));
9022    }
9023
9024    /// 8 columns, 12 rows, grouped row-headers occupy the first 2
9025    /// columns and are populated only in the first row of each group
9026    /// of 4. Models arxiv_2510.24670v2's failure shape (post-
9027    /// clustering): 9 modal data rows populate columns 2..8, six data
9028    /// columns all connected, one component → accepted. The real
9029    /// failure may also involve upstream column over-counting; this
9030    /// fixture pins the validator-level property we care about:
9031    /// sparse modal rows whose populated columns form one connected
9032    /// component must be accepted.
9033    #[test]
9034    fn validate_accepts_hierarchical_grouped_table() {
9035        let grid = make_grouped_grid(8, 12, 2, 4);
9036        let config = TableDetectionConfig::default();
9037        assert!(validate_table_structure_internal(&grid, &config));
9038    }
9039
9040    /// num_cols = 3 short-circuits has_split_modal_column_groups
9041    /// (num_cols < 4), so a small dense grid passes. Documents the
9042    /// boundary.
9043    #[test]
9044    fn validate_accepts_three_column_grid() {
9045        let grid = make_uniform_grid(3, 4, 3);
9046        let config = TableDetectionConfig::default();
9047        assert!(validate_table_structure_internal(&grid, &config));
9048    }
9049
9050    // ========================================================================
9051    // consolidate_adjacent_table_fragments regression tests
9052    // ======================================================================== ~keep
9053
9054    /// Build a minimal Table with a bbox and col_count for consolidation tests.
9055    fn make_fragment(x: f32, y: f32, width: f32, height: f32, cols: u32) -> Table {
9056        let mut t = Table::new();
9057        t.bbox = Some(Rect::new(x, y, width, height));
9058        t.col_count = cols as usize;
9059        // Push one empty row so consolidation has something to extend; the
9060        // row count grows as fragments get merged. ~keep
9061        t.rows.push(TableRow::new(false));
9062        t
9063    }
9064
9065    /// Two vertically-adjacent fragments with identical column structure
9066    /// merge into a single multi-row table.  Models the fragmented-table
9067    /// pattern where every horizontal ruling line produces a
9068    /// separate 1-row Table.
9069    #[test]
9070    fn consolidate_merges_adjacent_aligned_fragments() {
9071        let upper = make_fragment(90.0, 480.0, 420.0, 16.0, 8);
9072        let lower = make_fragment(90.0, 464.0, 420.0, 16.0, 8);
9073        // ~keep
9074        let merged = consolidate_adjacent_table_fragments(vec![upper, lower]);
9075        assert_eq!(merged.len(), 1, "two aligned adjacent fragments must merge");
9076        assert_eq!(merged[0].rows.len(), 2, "merged rows are concatenated");
9077        let bb = merged[0].bbox.expect("merged bbox preserved");
9078        assert!((bb.x - 90.0).abs() < 0.1);
9079        assert!((bb.width - 420.0).abs() < 0.1);
9080        assert!((bb.height - 32.0).abs() < 0.1);
9081    }
9082
9083    /// Fragments separated by more than `Y_TOLERANCE = 3pt` must NOT merge —
9084    /// they represent two distinct tables (e.g. two unrelated grids on the
9085    /// same page).
9086    #[test]
9087    fn consolidate_does_not_merge_non_adjacent_fragments() {
9088        let upper = make_fragment(90.0, 600.0, 420.0, 16.0, 8);
9089        let lower = make_fragment(90.0, 400.0, 420.0, 16.0, 8); // 200pt gap ~keep
9090        let result = consolidate_adjacent_table_fragments(vec![upper, lower]);
9091        assert_eq!(result.len(), 2, "well-separated fragments stay distinct");
9092    }
9093
9094    /// A table ruled between row *bands* (not every row) emits fragments
9095    /// separated by a full inter-row pitch, not abutting. The default 3pt
9096    /// tolerance leaves them split (dropping the short band to the >=3-row
9097    /// guard); the row-height-scaled tolerance rejoins them. Models the
9098    /// PMC8103272 Table-1 [3, 2] → [5] recovery.
9099    #[test]
9100    fn consolidate_with_tol_merges_row_pitch_separated_bands() {
9101        // Fragment with `extra` additional empty rows beyond make_fragment's one. ~keep
9102        let frag = |x, y, cols, rows: usize| {
9103            let mut t = make_fragment(x, y, 420.0, 16.0, cols);
9104            for _ in 1..rows {
9105                t.rows.push(TableRow::new(false));
9106            }
9107            t
9108        };
9109        // upper.bottom = 480; lower.top = 452 + 16 = 468 → 12pt gap (one pitch). ~keep
9110        let upper = frag(90.0, 480.0, 6, 3);
9111        let lower = frag(90.0, 452.0, 6, 2);
9112        // Default tolerance: bands stay split (this is the pre-fix behaviour). ~keep
9113        let split = consolidate_adjacent_table_fragments(vec![upper.clone(), lower.clone()]);
9114        assert_eq!(split.len(), 2, "3pt tolerance leaves row-pitch bands split");
9115        // Row-height-scaled tolerance (1.5 * ~16pt row height = 24 > 12pt gap): merge. ~keep
9116        let merged = consolidate_adjacent_table_fragments_with_tol(vec![upper, lower], 2.0, 24.0);
9117        assert_eq!(merged.len(), 1, "row-pitch bands rejoin under scaled tolerance");
9118        assert_eq!(merged[0].rows.len(), 5, "[3,2] bands rejoin into a 5-row table");
9119    }
9120
9121    /// The scaled tolerance still refuses fragments that are farther apart
9122    /// than a plausible single row pitch — two genuinely distinct grids.
9123    #[test]
9124    fn consolidate_with_tol_still_rejects_far_bands() {
9125        let upper = make_fragment(90.0, 600.0, 420.0, 16.0, 6);
9126        let lower = make_fragment(90.0, 400.0, 420.0, 16.0, 6);
9127        let merged = consolidate_adjacent_table_fragments_with_tol(vec![upper, lower], 2.0, 24.0);
9128        assert_eq!(merged.len(), 2, "a scaled tolerance is not an unbounded merge");
9129    }
9130
9131    /// Fragments with different column counts must NOT merge even if
9132    /// they sit adjacent vertically — they aren't a single logical table.
9133    #[test]
9134    fn consolidate_does_not_merge_different_col_counts() {
9135        let upper = make_fragment(90.0, 480.0, 420.0, 16.0, 8);
9136        let lower = make_fragment(90.0, 464.0, 420.0, 16.0, 5);
9137        let result = consolidate_adjacent_table_fragments(vec![upper, lower]);
9138        assert_eq!(result.len(), 2, "different col_count blocks merging");
9139    }
9140
9141    /// Fragments at different x-positions must NOT merge — they're in
9142    /// different columns of the page even if their Y ranges are adjacent.
9143    #[test]
9144    fn consolidate_does_not_merge_misaligned_x() {
9145        let upper = make_fragment(90.0, 480.0, 420.0, 16.0, 8);
9146        let lower = make_fragment(50.0, 464.0, 420.0, 16.0, 8);
9147        let result = consolidate_adjacent_table_fragments(vec![upper, lower]);
9148        assert_eq!(result.len(), 2, "misaligned-x blocks merging");
9149    }
9150
9151    /// A chain of three+ adjacent fragments should chain-merge into one
9152    /// multi-row table.  This chains 8 column-aligned
9153    /// fragments → one 18-row table after consolidation.
9154    #[test]
9155    fn consolidate_chains_multiple_adjacent_fragments() {
9156        let f1 = make_fragment(90.0, 480.0, 420.0, 16.0, 8);
9157        let f2 = make_fragment(90.0, 464.0, 420.0, 16.0, 8);
9158        let f3 = make_fragment(90.0, 448.0, 420.0, 16.0, 8);
9159        let f4 = make_fragment(90.0, 432.0, 420.0, 16.0, 8);
9160        let merged = consolidate_adjacent_table_fragments(vec![f1, f2, f3, f4]);
9161        assert_eq!(merged.len(), 1, "chain of 4 adjacent fragments → 1 table");
9162        assert_eq!(merged[0].rows.len(), 4, "all rows preserved in chain merge");
9163    }
9164
9165    /// Small vertical overlap (line-detector quirk) up to half the
9166    /// smaller fragment's height is still considered adjacent.
9167    #[test]
9168    fn consolidate_tolerates_small_overlap() {
9169        // upper bottom = 480, lower top = 484 (4pt overlap, smaller_h = 16, half = 8) ~keep
9170        let upper = make_fragment(90.0, 480.0, 420.0, 16.0, 8);
9171        let lower = make_fragment(90.0, 468.0, 420.0, 16.0, 8);
9172        let merged = consolidate_adjacent_table_fragments(vec![upper, lower]);
9173        assert_eq!(merged.len(), 1, "small overlap (< ½ row) still merges");
9174    }
9175
9176    /// Empty input passes through unchanged.
9177    #[test]
9178    fn consolidate_empty_input() {
9179        let merged = consolidate_adjacent_table_fragments(vec![]);
9180        assert!(merged.is_empty());
9181    }
9182
9183    /// Single-table input passes through unchanged (nothing to merge).
9184    #[test]
9185    fn consolidate_single_table_passthrough() {
9186        let t = make_fragment(90.0, 480.0, 420.0, 16.0, 8);
9187        let merged = consolidate_adjacent_table_fragments(vec![t]);
9188        assert_eq!(merged.len(), 1);
9189    }
9190
9191    // ========================================================================
9192    // cell_span_separator regression tests
9193    // ======================================================================== ~keep
9194
9195    /// Helper to construct a TextSpan with just the fields the separator
9196    /// rule actually reads: bbox, font_size, and text.
9197    fn ts(text: &str, x: f32, y: f32, width: f32, fs: f32) -> TextSpan {
9198        TextSpan {
9199            provenance: None,
9200            text_rise: 0.0,
9201            artifact_type: None,
9202            text: text.to_string(),
9203            bbox: Rect::new(x, y, width, fs),
9204            font_size: fs,
9205            font_name: String::new(),
9206            font_weight: crate::layout::text_block::FontWeight::Normal,
9207            color: Color::black(),
9208            mcid: None,
9209            mcid_scope: None,
9210            sequence: 0,
9211            split_boundary_before: false,
9212            offset_semantic: false,
9213            is_italic: false,
9214            is_monospace: false,
9215            char_spacing: 0.0,
9216            word_spacing: 0.0,
9217            horizontal_scaling: 100.0,
9218            primary_detected: false,
9219            char_widths: vec![],
9220            char_x_offsets: Vec::new(),
9221            heading_level: None,
9222            rotation_degrees: 0.0,
9223            wmode: 0,
9224            rtl_draw_logical: false,
9225            mirrored: false,
9226            page_rotation_applied: 0,
9227        }
9228    }
9229
9230    /// Adjacent spans with a sub-em gap must NOT have a separator
9231    /// inserted.  Models `60000` + `≤` + `Q` + `<` + `80000`
9232    /// where the operator glyphs touch / slightly overlap the digit
9233    /// glyphs and must be rendered as a single compound token.
9234    #[test]
9235    fn cell_span_separator_no_space_for_tight_glyphs() {
9236        let prev = ts("60000≤Q", 100.0, 200.0, 39.8, 10.6);
9237        let curr = ts("<", 139.8, 200.0, 10.6, 10.6);
9238        assert_eq!(cell_span_separator(&prev, &curr), "");
9239    }
9240
9241    /// Adjacent spans with a real gap (clear word boundary) get a
9242    /// single space separator inserted.
9243    #[test]
9244    fn cell_span_separator_inserts_space_for_real_gap() {
9245        let prev = ts("Quarter", 100.0, 200.0, 30.0, 10.0);
9246        let curr = ts("Total", 140.0, 200.0, 25.0, 10.0); // gap = 10pt = 1em ~keep
9247        assert_eq!(cell_span_separator(&prev, &curr), " ");
9248    }
9249
9250    /// CJK ↔ fullwidth-operator boundary suppresses separator even when
9251    /// the geometric gap would otherwise warrant a space.
9252    #[test]
9253    fn cell_span_separator_suppresses_cjk_fullwidth_boundary() {
9254        let prev = ts("中", 100.0, 200.0, 12.0, 12.0);
9255        let curr = ts("<", 115.0, 200.0, 12.0, 12.0); // gap = 3pt = 0.25 em ~keep
9256        assert_eq!(cell_span_separator(&prev, &curr), "");
9257    }
9258
9259    /// Trailing whitespace on the preceding span suppresses separator
9260    /// (no double-space).
9261    #[test]
9262    fn cell_span_separator_suppresses_on_existing_trailing_space() {
9263        let prev = ts("Quarter ", 100.0, 200.0, 35.0, 10.0);
9264        let curr = ts("Total", 140.0, 200.0, 25.0, 10.0);
9265        assert_eq!(cell_span_separator(&prev, &curr), "");
9266    }
9267
9268    /// GH#1948: the table word source marks a word that its source span
9269    /// separated with a whitespace by prefixing the word's text with a space.
9270    /// When the two words' boxes abut (a tight face draws the space glyph over
9271    /// its neighbours), the separator must not add a *second* space, and the
9272    /// one carried in the text must survive.
9273    #[test]
9274    fn cell_text_keeps_a_source_space_between_abutting_words() {
9275        let spans = vec![
9276            ts("corrispettivi", 100.0, 200.0, 37.7, 7.83),
9277            ts(" superiori", 137.0, 200.0, 27.6, 7.83),
9278        ];
9279        assert_eq!(extract_cell_text(&[0, 1], &spans), "corrispettivi superiori");
9280    }
9281
9282    /// GH#2092: a native PDF may draw the leading digits and the locale's
9283    /// thousands-separator group as separate words with a visible glyph gap. ~keep
9284    #[test]
9285    fn cell_text_keeps_numeric_separator_group_attached() {
9286        for separator in ['.', ',', '\u{066B}', '\u{066C}'] {
9287            let suffix = format!("{separator}804");
9288            let spans = vec![
9289                ts("59", 100.0, 200.0, 10.0, 10.0),
9290                ts(&suffix, 113.0, 200.0, 20.0, 10.0),
9291            ];
9292
9293            assert_eq!(extract_cell_text(&[0, 1], &spans), format!("59{separator}804"));
9294        }
9295    }
9296
9297    #[test]
9298    fn cell_text_does_not_attach_sentence_punctuation() {
9299        let spans = vec![
9300            ts("Section", 100.0, 200.0, 10.0, 10.0),
9301            ts(".", 113.0, 200.0, 20.0, 10.0),
9302        ];
9303
9304        assert_eq!(extract_cell_text(&[0, 1], &spans), "Section .");
9305    }
9306
9307    /// A word that opens a cell can carry the same source-space prefix, but a
9308    /// cell's first word has no preceding word to separate from; the leading
9309    /// space must not leak into the cell text (GH#1948).
9310    #[test]
9311    fn cell_text_trims_a_source_space_prefix_at_the_cell_start() {
9312        let spans = vec![ts(" 400", 100.0, 200.0, 20.0, 7.83)];
9313        assert_eq!(extract_cell_text(&[0], &spans), "400");
9314    }
9315
9316    /// Reproduces xberg-io/xberg#1555: a drawn row band whose baselines are
9317    /// 288.7 (col0), 294.7/282.7 (col1, wraps to 2 lines) and
9318    /// 300.7/288.7/276.7 (col2, wraps to 3 lines). Clustering naively at
9319    /// `strict()`'s `row_tolerance` of 1.0 yields 5 Y-clusters, and only the
9320    /// 288.7 cluster (col0 + col2's middle line) has evidence in >= 2
9321    /// columns. The split must be rejected in full and the drawn band must
9322    /// survive as the single row it was already built as. ~keep
9323    #[test]
9324    fn split_rows_by_text_positions_keeps_drawn_band_as_one_row_when_only_one_cell_wraps() {
9325        let spans = vec![
9326            create_test_span("1", 90.2, 288.7, 5.0, 0.0),
9327            create_test_span("Versterking van het", 118.6, 294.7, 60.0, 0.0),
9328            create_test_span("MKB-segment", 118.6, 282.7, 40.0, 0.0),
9329            create_test_span("Ontwikkel gerichte campagnes", 246.1, 300.7, 90.0, 0.0),
9330            create_test_span("een vereenvoudigde waardepropositie", 246.1, 288.7, 100.0, 0.0),
9331            create_test_span("om de conversie te verhogen.", 246.1, 276.7, 90.0, 0.0),
9332        ];
9333
9334        let row = TableRow {
9335            cells: vec![
9336                prose_cell("1"),
9337                prose_cell("Versterking van het MKB-segment"),
9338                prose_cell(
9339                    "Ontwikkel gerichte campagnes een vereenvoudigde waardepropositie \
9340                     om de conversie te verhogen.",
9341                ),
9342            ],
9343            is_header: false,
9344        };
9345
9346        let row_cell_span_indices = vec![vec![vec![0], vec![1, 2], vec![3, 4, 5]]];
9347        let config = TableDetectionConfig::strict();
9348
9349        let result = split_rows_by_text_positions(vec![row], &row_cell_span_indices, &spans, &config);
9350
9351        assert_eq!(
9352            result.len(),
9353            1,
9354            "one wrapped cell must not fracture the drawn band into extra rows"
9355        );
9356        assert_eq!(result[0].cells[0].text, "1");
9357        assert_eq!(result[0].cells[1].text, "Versterking van het MKB-segment");
9358    }
9359
9360    #[test]
9361    fn split_rows_by_text_positions_keeps_single_column_split_behavior() {
9362        let spans = vec![
9363            create_test_span("Label", 10.0, 200.0, 30.0, 0.0),
9364            create_test_span("Value", 10.0, 180.0, 30.0, 0.0),
9365        ];
9366        let row = TableRow {
9367            cells: vec![prose_cell("Label Value")],
9368            is_header: false,
9369        };
9370        let row_cell_span_indices = vec![vec![vec![0, 1]]];
9371
9372        let result = split_rows_by_text_positions(
9373            vec![row],
9374            &row_cell_span_indices,
9375            &spans,
9376            &TableDetectionConfig::strict(),
9377        );
9378
9379        assert_eq!(
9380            result.len(),
9381            2,
9382            "the two-column evidence floor must be capped for one-column bands"
9383        );
9384        assert_eq!(result[0].cells[0].text, "Label");
9385        assert_eq!(result[1].cells[0].text, "Value");
9386    }
9387
9388    /// Page-3-shaped downstream regression for #1555: one header band and three
9389    /// data bands each contain 1/2/3-line cells on staggered baselines. Keeping
9390    /// every drawn band whole must leave a four-row, three-column table that
9391    /// clears the real-grid gate; the old sixteen-row expansion failed it at 4/16.
9392    #[test]
9393    fn wrapped_drawn_bands_remain_a_real_four_row_grid() {
9394        let mut spans = vec![
9395            create_test_span("#", 90.2, 330.0, 5.0, 0.0),
9396            create_test_span("Aanbeveling", 118.6, 330.0, 60.0, 0.0),
9397            create_test_span("Toelichting", 246.1, 330.0, 90.0, 0.0),
9398        ];
9399        let mut rows = vec![TableRow {
9400            cells: vec![prose_cell("#"), prose_cell("Aanbeveling"), prose_cell("Toelichting")],
9401            is_header: true,
9402        }];
9403        let mut row_cell_span_indices = vec![vec![vec![0], vec![1], vec![2]]];
9404
9405        for (row_number, baseline) in [(1, 288.7), (2, 242.7), (3, 196.7)] {
9406            let start = spans.len();
9407            spans.extend([
9408                create_test_span(&row_number.to_string(), 90.2, baseline, 5.0, 0.0),
9409                create_test_span("Versterking van het", 118.6, baseline + 6.0, 60.0, 0.0),
9410                create_test_span("MKB-segment", 118.6, baseline - 6.0, 40.0, 0.0),
9411                create_test_span("Ontwikkel gerichte campagnes", 246.1, baseline + 12.0, 90.0, 0.0),
9412                create_test_span("een vereenvoudigde waardepropositie", 246.1, baseline, 100.0, 0.0),
9413                create_test_span("om de conversie te verhogen.", 246.1, baseline - 12.0, 90.0, 0.0),
9414            ]);
9415            rows.push(TableRow {
9416                cells: vec![
9417                    prose_cell(&row_number.to_string()),
9418                    prose_cell("Versterking van het MKB-segment"),
9419                    prose_cell(
9420                        "Ontwikkel gerichte campagnes een vereenvoudigde waardepropositie \
9421                         om de conversie te verhogen.",
9422                    ),
9423                ],
9424                is_header: false,
9425            });
9426            row_cell_span_indices.push(vec![
9427                vec![start],
9428                vec![start + 1, start + 2],
9429                vec![start + 3, start + 4, start + 5],
9430            ]);
9431        }
9432
9433        let rows = split_rows_by_text_positions(rows, &row_cell_span_indices, &spans, &TableDetectionConfig::strict());
9434        let table = Table {
9435            rows,
9436            has_header: true,
9437            col_count: 3,
9438            bbox: None,
9439        };
9440
9441        assert_eq!(table.rows.len(), 4, "four producer-drawn bands must remain four rows");
9442        assert!(
9443            table.is_real_grid(),
9444            "the repaired table must survive the downstream real-grid gate"
9445        );
9446        assert_eq!(table.rows[1].cells[0].text, "1");
9447        assert_eq!(table.rows[1].cells[1].text, "Versterking van het MKB-segment");
9448        assert_eq!(table.rows[3].cells[0].text, "3");
9449        assert_eq!(table.rows[3].cells[1].text, "Versterking van het MKB-segment");
9450    }
9451
9452    /// The hybrid case `split_rows_by_text_positions` exists for: vertical
9453    /// column lines exist but no horizontal rule separates two real rows
9454    /// crammed into one intersection cell. Each candidate row has
9455    /// independent text in both columns, so this split must be kept intact
9456    /// and produce exactly 2 rows in page order. ~keep
9457    #[test]
9458    fn split_rows_by_text_positions_splits_genuine_multi_row_band_with_two_column_evidence() {
9459        let spans = vec![
9460            create_test_span("Alice", 10.0, 200.0, 30.0, 0.0),
9461            create_test_span("30", 60.0, 200.0, 10.0, 0.0),
9462            create_test_span("Bob", 10.0, 180.0, 30.0, 0.0),
9463            create_test_span("25", 60.0, 180.0, 10.0, 0.0),
9464        ];
9465
9466        let row = TableRow {
9467            cells: vec![prose_cell("Alice Bob"), prose_cell("30 25")],
9468            is_header: false,
9469        };
9470
9471        let row_cell_span_indices = vec![vec![vec![0, 2], vec![1, 3]]];
9472        let config = TableDetectionConfig::strict();
9473
9474        let result = split_rows_by_text_positions(vec![row], &row_cell_span_indices, &spans, &config);
9475
9476        assert_eq!(result.len(), 2, "two genuinely distinct rows must survive the split");
9477        assert_eq!(result[0].cells[0].text, "Alice");
9478        assert_eq!(result[0].cells[1].text, "30");
9479        assert_eq!(result[1].cells[0].text, "Bob");
9480        assert_eq!(result[1].cells[1].text, "25");
9481    }
9482
9483    /// REGRESSION xberg-io/xberg#1565: a producer-drawn band mixing genuine multi-column data
9484    /// rows with a single-column section lead-in ("Mogelijke oorzaken:") must not collapse
9485    /// back into one mega-row just because the lead-in itself lacks two-column evidence. The
9486    /// two data rows are independently evidenced, so the split is accepted; the lead-in shares
9487    /// no column with anything to its left/right that it doesn't already have on its own — it
9488    /// is the topmost cluster and has no cluster above it to fold into — so it must survive as
9489    /// its own one-cell-wide row instead of being fused into a data row's cell. ~keep
9490    #[test]
9491    fn split_rows_by_text_positions_keeps_heading_line_separate_amid_multi_column_rows() {
9492        let spans = vec![
9493            create_test_span("Mogelijke oorzaken:", 10.0, 220.0, 100.0, 0.0),
9494            create_test_span("Symptom A", 10.0, 200.0, 40.0, 0.0),
9495            create_test_span("Cause A", 60.0, 200.0, 40.0, 0.0),
9496            create_test_span("Symptom B", 10.0, 180.0, 40.0, 0.0),
9497            create_test_span("Cause B", 60.0, 180.0, 40.0, 0.0),
9498        ];
9499
9500        let row = TableRow {
9501            cells: vec![
9502                prose_cell("Mogelijke oorzaken: Symptom A Symptom B"),
9503                prose_cell("Cause A Cause B"),
9504            ],
9505            is_header: false,
9506        };
9507
9508        let row_cell_span_indices = vec![vec![vec![0, 1, 3], vec![2, 4]]];
9509        let config = TableDetectionConfig::strict();
9510
9511        let result = split_rows_by_text_positions(vec![row], &row_cell_span_indices, &spans, &config);
9512
9513        assert_eq!(
9514            result.len(),
9515            3,
9516            "the heading and both genuine data rows must each survive as their own row"
9517        );
9518        assert_eq!(result[0].cells[0].text, "Mogelijke oorzaken:");
9519        assert_eq!(
9520            result[0].cells[1].text, "",
9521            "the heading must not be fused into a data row's cell text"
9522        );
9523        assert_eq!(result[1].cells[0].text, "Symptom A");
9524        assert_eq!(result[1].cells[1].text, "Cause A");
9525        assert_eq!(result[2].cells[0].text, "Symptom B");
9526        assert_eq!(result[2].cells[1].text, "Cause B");
9527    }
9528
9529    /// FINDING 2 (adversarial review): does the #1565 row-split fix create a new
9530    /// single-cell-wide row shaped exactly like `strip_form_numbering_artifacts`'s Phase 1
9531    /// numbering-artifact signature (every cell empty or a lone digit 1-9, with at least one
9532    /// lone digit) out of what used to be a non-foldable single-column lead-in? Runs the
9533    /// *whole* `finalize_intersection_tables` pipeline (split, then artifact-strip, then
9534    /// empty-row table segmentation) rather than just `split_rows_by_text_positions`, since
9535    /// that is the real caller order and Phase 1 runs immediately after the split.
9536    ///
9537    /// `min_table_cells` is deliberately set to 1 so the outer table-emission gate cannot
9538    /// mask what happens to this one row -- the question under test is row-level survival,
9539    /// not table-level acceptance.
9540    #[test]
9541    fn finalize_intersection_tables_deletes_lone_digit_lead_in_row() {
9542        let spans = vec![
9543            create_test_span("5", 10.0, 220.0, 5.0, 0.0),
9544            create_test_span("Symptom A", 10.0, 200.0, 40.0, 0.0),
9545            create_test_span("Cause A", 60.0, 200.0, 40.0, 0.0),
9546            create_test_span("Symptom B", 10.0, 180.0, 40.0, 0.0),
9547            create_test_span("Cause B", 60.0, 180.0, 40.0, 0.0),
9548        ];
9549
9550        let row = TableRow {
9551            cells: vec![prose_cell("5 Symptom A Symptom B"), prose_cell("Cause A Cause B")],
9552            is_header: false,
9553        };
9554
9555        let row_cell_span_indices = vec![vec![vec![0, 1, 3], vec![2, 4]]];
9556        let config = TableDetectionConfig {
9557            min_table_cells: 1,
9558            ..TableDetectionConfig::strict()
9559        };
9560
9561        // Confirm, as a checkpoint, that the split itself still isolates "5" into its own
9562        // one-cell-wide row -- the same shape as the heading test above, just digit-shaped
9563        // instead of alphabetic. If this checkpoint ever stops holding the rest of this test
9564        // is testing a different mechanism than the one under review.
9565        let split_only = split_rows_by_text_positions(vec![row.clone()], &row_cell_span_indices, &spans, &config);
9566        assert_eq!(
9567            split_only.len(),
9568            3,
9569            "checkpoint: split must isolate the lead-in as its own row"
9570        );
9571        assert_eq!(split_only[0].cells[0].text, "5");
9572        assert_eq!(split_only[0].cells[1].text, "");
9573
9574        let tables = finalize_intersection_tables(vec![row], &row_cell_span_indices, &spans, &config, 2);
9575
9576        assert_eq!(tables.len(), 1, "the two evidenced data rows must still form one table");
9577        let surviving_rows: Vec<Vec<&str>> = tables[0]
9578            .rows
9579            .iter()
9580            .map(|r| r.cells.iter().map(|c| c.text.as_str()).collect())
9581            .collect();
9582
9583        // Documents observed behavior: Phase 1 of `strip_form_numbering_artifacts` deletes
9584        // this row whole. This is the same rule the module already pins for a row that was
9585        // ALWAYS a standalone lone-digit row (`test_strip_form_numbering_artifacts` row 0):
9586        // that rule is not new, and reachable-from-a-real-band content shaped exactly like a
9587        // classic form row-number (a bare digit, sharing no column with any neighboring data)
9588        // is indistinguishable, by any signal Phase 1 has, from the artifact it exists to
9589        // remove. Judgment call: NOT a regression fix here -- see the finding-2 report for
9590        // why a content-preserving change is not being made.
9591        assert_eq!(
9592            surviving_rows,
9593            vec![vec!["Symptom A", "Cause A"], vec!["Symptom B", "Cause B"]],
9594            "lone-digit lead-in row is removed by Phase 1, same as a pre-existing standalone artifact row"
9595        );
9596    }
9597
9598    /// "CLEARED" CLAIM 1 (adversarial review): a multi-hop fold chain must not manufacture
9599    /// column evidence that was never actually present. Three-cluster band: the topmost and
9600    /// bottommost clusters are each independently evidenced (2 columns), and a middle
9601    /// deficient cluster carries text in only ONE of those two columns. The deficient
9602    /// cluster's own column footprint ({0}) is a subset of the cluster above it ({0, 1}), so
9603    /// it is allowed to fold -- but the emitted rows must still reflect only the columns each
9604    /// physical cluster actually had text in; folding must never cause a group's rendered
9605    /// column count, or the per-column text, to exceed what the real spans support.
9606    #[test]
9607    fn split_rows_by_text_positions_fold_chain_does_not_fabricate_column_evidence() {
9608        let spans = vec![
9609            // Bottom cluster (y=180): evidenced, both columns. ~keep
9610            create_test_span("Alice", 10.0, 180.0, 30.0, 0.0),
9611            create_test_span("30", 60.0, 180.0, 10.0, 0.0),
9612            // Middle cluster (y=200): deficient, column 0 only -- a wrapped continuation of
9613            // the row above it (y=220), not of the row below. ~keep
9614            create_test_span("continued", 10.0, 200.0, 40.0, 0.0),
9615            // Top cluster (y=220): evidenced, both columns. ~keep
9616            create_test_span("Bob", 10.0, 220.0, 30.0, 0.0),
9617            create_test_span("25", 60.0, 220.0, 10.0, 0.0),
9618        ];
9619
9620        let row = TableRow {
9621            cells: vec![prose_cell("Alice continued Bob"), prose_cell("30 25")],
9622            is_header: false,
9623        };
9624
9625        let row_cell_span_indices = vec![vec![vec![0, 2, 3], vec![1, 4]]];
9626        let config = TableDetectionConfig::strict();
9627
9628        let result = split_rows_by_text_positions(vec![row], &row_cell_span_indices, &spans, &config);
9629
9630        assert_eq!(
9631            result.len(),
9632            2,
9633            "the deficient middle cluster must fold, not survive alone"
9634        );
9635        // Top group (y=220 anchor with y=200 folded in): column 0 gets both lines, column 1
9636        // only ever had "25" -- folding must not invent a "25 continued" or duplicate value. ~keep
9637        assert_eq!(result[0].cells[0].text, "Bob\ncontinued");
9638        assert_eq!(
9639            result[0].cells[1].text, "25",
9640            "folding a column-0-only cluster must not fabricate evidence in column 1"
9641        );
9642        assert_eq!(result[1].cells[0].text, "Alice");
9643        assert_eq!(result[1].cells[1].text, "30");
9644    }
9645
9646    /// "CLEARED" CLAIM 2 (adversarial review): every span fed into a deficient-cluster-heavy
9647    /// band must land in exactly one output cell -- never duplicated across two groups, and
9648    /// never silently dropped by the `members.iter().any(...)` filter in the final
9649    /// cell-assembly pass. Uses the same fold-chain fixture as claim 1 and checks span
9650    /// accounting by content rather than by trusting row/column counts alone.
9651    #[test]
9652    fn split_rows_by_text_positions_fold_chain_drops_no_span_and_duplicates_none() {
9653        let spans = vec![
9654            create_test_span("Alice", 10.0, 180.0, 30.0, 0.0),
9655            create_test_span("30", 60.0, 180.0, 10.0, 0.0),
9656            create_test_span("continued", 10.0, 200.0, 40.0, 0.0),
9657            create_test_span("Bob", 10.0, 220.0, 30.0, 0.0),
9658            create_test_span("25", 60.0, 220.0, 10.0, 0.0),
9659        ];
9660
9661        let row = TableRow {
9662            cells: vec![prose_cell("Alice continued Bob"), prose_cell("30 25")],
9663            is_header: false,
9664        };
9665
9666        let row_cell_span_indices = vec![vec![vec![0, 2, 3], vec![1, 4]]];
9667        let config = TableDetectionConfig::strict();
9668
9669        let result = split_rows_by_text_positions(vec![row], &row_cell_span_indices, &spans, &config);
9670
9671        let mut seen_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
9672        for r in &result {
9673            for c in &r.cells {
9674                for word in c.text.split_whitespace() {
9675                    *seen_counts.entry(word).or_insert(0) += 1;
9676                }
9677            }
9678        }
9679        for expected in ["Alice", "30", "continued", "Bob", "25"] {
9680            assert_eq!(
9681                seen_counts.get(expected).copied().unwrap_or(0),
9682                1,
9683                "span text {expected:?} must appear exactly once across all output cells, got {:?}",
9684                seen_counts.get(expected)
9685            );
9686        }
9687    }
9688}