Skip to main content

xberg_native_pdf/structure/
table_extractor.rs

1//! Table extraction from PDF structure tree.
2//!
3//! Implements table detection and reconstruction according to ISO 32000-1:2008 Section 14.8.4.3.4
4//! (Table Elements).
5//!
6//! Table structure hierarchy:
7//! - Table: Top-level container
8//!   - THead: Optional header row group
9//!   - TBody: One or more body row groups
10//!   - TFoot: Optional footer row group
11//! - TR: Table row (contains TH and/or TD cells)
12//!   - TH: Table header cell
13//!   - TD: Table data cell
14
15use crate::error::Error;
16use crate::geometry::Rect;
17use crate::layout::{Color, FontWeight, TextBlock, TextSpan};
18use crate::structure::types::{McidScope, StructChild, StructElem, StructType};
19
20/// A complete extracted table with rows and optional header information.
21#[derive(Debug, Clone)]
22pub struct Table {
23    /// Rows of the table (alternating between header and body rows)
24    pub rows: Vec<TableRow>,
25
26    /// Whether the table has an explicit header section
27    pub has_header: bool,
28
29    /// Number of columns (inferred from first row)
30    pub col_count: usize,
31
32    /// Bounding box of the table region (used to exclude table spans from normal rendering)
33    pub bbox: Option<Rect>,
34}
35
36/// A single row in a table.
37#[derive(Debug, Clone)]
38pub struct TableRow {
39    /// Cells in this row
40    pub cells: Vec<TableCell>,
41
42    /// Whether this is a header row
43    pub is_header: bool,
44}
45
46/// A single cell in a table.
47#[derive(Debug, Clone)]
48pub struct TableCell {
49    /// Text content of the cell
50    pub text: String,
51
52    /// Original text spans that make up this cell's content, with
53    /// font/style metadata preserved for format-aware rendering.
54    pub spans: Vec<crate::layout::TextSpan>,
55
56    /// Number of columns this cell spans (default 1)
57    pub colspan: u32,
58
59    /// Number of rows this cell spans (default 1)
60    pub rowspan: u32,
61
62    /// MCID values that make up this cell's content
63    pub mcids: Vec<u32>,
64
65    /// Bounding box of the cell
66    pub bbox: Option<Rect>,
67
68    /// Whether this is a header cell
69    pub is_header: bool,
70}
71
72impl Default for Table {
73    fn default() -> Self {
74        Self::new()
75    }
76}
77
78impl Table {
79    /// Create a new extracted table
80    pub fn new() -> Self {
81        Self {
82            rows: Vec::new(),
83            has_header: false,
84            col_count: 0,
85            bbox: None,
86        }
87    }
88
89    /// Check whether this table looks like a real data grid as opposed to
90    /// spurious spatial output (form layouts, label-colon-value pairs).
91    ///
92    /// A real grid has:
93    /// - ≥2 rows
94    /// - ≥2 columns
95    /// - Consistent column population: at least 50% of rows must have
96    ///   at least 2 non-empty cells. Filters out the common
97    ///   form-as-table false positive where rows look like
98    ///   `| Single label, all other slots empty | | | |`.
99    pub fn is_real_grid(&self) -> bool {
100        if self.col_count < 2 || self.rows.len() < 2 {
101            return false;
102        }
103        let rows_with_two_or_more_filled_cells = self
104            .rows
105            .iter()
106            .filter(|r| r.cells.iter().filter(|c| !c.text.trim().is_empty()).count() >= 2)
107            .count();
108        let ratio = rows_with_two_or_more_filled_cells as f32 / self.rows.len() as f32;
109
110        // Wide tables (≥ 8 columns) are high-risk false positives: prose sentences
111        // can be split into many single-phrase cells by decorative rule lines.
112        // Real wide data tables have most rows densely filled (≥ 60% of columns);
113        // prose-split false tables have highly variable row fill counts (some rows
114        // have 1-2 filled cells, others have 10+), so the fraction of "dense" rows
115        // is well below 70%.
116        //
117        // Exception: a consolidated multi-row table can contain a mix
118        // of dense data rows and sparse header / multi-row-label rows.  The sparse
119        // rows are legitimate table content (column headers split across multiple
120        // visual rows, lane-count labels that only appear on the first row of a
121        // sub-group), so a strict dense-row ratio rejects real tables.  Accept the
122        // table if it has BOTH enough dense rows in absolute terms (≥ half the
123        // column count) AND a meaningful dense-row ratio (≥ 40 %). ~keep
124        if self.col_count >= 8 {
125            let min_dense = ((self.col_count as f32 * 0.6) as usize).max(2);
126            let dense_rows = self
127                .rows
128                .iter()
129                .filter(|r| r.cells.iter().filter(|c| !c.text.trim().is_empty()).count() >= min_dense)
130                .count();
131            let dense_row_ratio = dense_rows as f32 / self.rows.len() as f32;
132            if self.rows.len() >= 3 && ratio >= 0.7 && dense_row_ratio >= 0.70 {
133                return true;
134            }
135            let min_absolute_dense = (self.col_count / 2).max(3);
136            return dense_rows >= min_absolute_dense && dense_row_ratio >= 0.40;
137        }
138
139        ratio >= 0.5
140    }
141
142    /// Render the table as clean, space-padded plain text.
143    pub fn render_text(&self) -> String {
144        let col_count = self.col_count;
145        if col_count == 0 || self.rows.is_empty() {
146            return String::new();
147        }
148
149        let mut col_widths = vec![0usize; col_count];
150        for row in &self.rows {
151            let mut col_idx = 0;
152            for cell in &row.cells {
153                if cell.colspan == 1 && col_idx < col_count {
154                    let w = cell.text.trim().chars().count();
155                    col_widths[col_idx] = col_widths[col_idx].max(w);
156                }
157                col_idx += cell.colspan as usize;
158            }
159        }
160
161        for w in &mut col_widths {
162            if *w < 2 {
163                *w = 2;
164            }
165        }
166
167        // Trim trailing empty columns (no non-empty cell contributes content
168        // to that column, including cells with colspan > 1 that cover it). ~keep
169        let effective_cols = {
170            let mut eff = col_widths.len();
171            while eff > 0 {
172                let col = eff - 1;
173                let all_empty = self.rows.iter().all(|row| {
174                    let mut ci = 0;
175                    for cell in &row.cells {
176                        let span = cell.colspan as usize;
177                        let covers_col = ci <= col && col < ci + span;
178                        if covers_col {
179                            return cell.text.trim().is_empty();
180                        }
181                        ci += span;
182                    }
183                    true
184                });
185                if all_empty {
186                    eff -= 1;
187                } else {
188                    break;
189                }
190            }
191            eff
192        };
193        if effective_cols == 0 {
194            return String::new();
195        }
196        let col_widths = &col_widths[..effective_cols];
197
198        let is_right_aligned: Vec<bool> = (0..effective_cols)
199            .map(|c| {
200                let mut has_content = false;
201                for row in &self.rows {
202                    let mut ci = 0;
203                    for cell in &row.cells {
204                        if ci == c && cell.colspan == 1 {
205                            let t = cell.text.trim();
206                            if !t.is_empty() {
207                                has_content = true;
208                                let stripped: String = t
209                                    .chars()
210                                    .filter(|ch| {
211                                        !matches!(ch, '$' | '€' | '£' | ',' | ' ' | '%' | '+' | '-' | '(' | ')')
212                                    })
213                                    .collect();
214                                if stripped.is_empty() || stripped.parse::<f64>().is_err() {
215                                    return false;
216                                }
217                            }
218                        }
219                        ci += cell.colspan as usize;
220                    }
221                }
222                has_content
223            })
224            .collect();
225
226        let mut output = String::new();
227
228        for row in &self.rows {
229            let mut col_idx = 0;
230            let mut cells_text = Vec::new();
231            for cell in &row.cells {
232                let text = cell.text.trim();
233                if col_idx < effective_cols {
234                    let width = if cell.colspan > 1 {
235                        let end = (col_idx + cell.colspan as usize).min(effective_cols);
236                        let base: usize = col_widths[col_idx..end].iter().sum();
237                        base + (end - col_idx).saturating_sub(1) * 2
238                    } else {
239                        col_widths[col_idx]
240                    };
241                    let formatted = if cell.colspan == 1 && is_right_aligned[col_idx] {
242                        format!("{:>width$}", text, width = width)
243                    } else {
244                        format!("{:<width$}", text, width = width)
245                    };
246                    cells_text.push(formatted);
247                } else {
248                    cells_text.push(text.to_string());
249                }
250                col_idx += cell.colspan as usize;
251            }
252            output.push_str(cells_text.join("  ").trim_end());
253            output.push('\n');
254        }
255
256        output
257    }
258
259    /// Add a row to the table
260    pub fn add_row(&mut self, row: TableRow) {
261        if self.col_count == 0 && !row.cells.is_empty() {
262            self.col_count = row.cells.len();
263        }
264        self.rows.push(row);
265    }
266
267    /// Check if table is empty
268    pub fn is_empty(&self) -> bool {
269        self.rows.is_empty()
270    }
271}
272
273impl TableRow {
274    /// Create a new table row
275    pub fn new(is_header: bool) -> Self {
276        Self {
277            cells: Vec::new(),
278            is_header,
279        }
280    }
281
282    /// Check if any cell in this row has a colspan > 1.
283    pub fn has_colspan(&self) -> bool {
284        self.cells.iter().any(|c| c.colspan > 1)
285    }
286
287    /// Add a cell to the row
288    pub fn add_cell(&mut self, cell: TableCell) {
289        self.cells.push(cell);
290    }
291}
292
293impl TableCell {
294    /// Create a new table cell
295    pub fn new(text: String, is_header: bool) -> Self {
296        Self {
297            text,
298            spans: Vec::new(),
299            colspan: 1,
300            rowspan: 1,
301            mcids: Vec::new(),
302            bbox: None,
303            is_header,
304        }
305    }
306
307    /// Set colspan
308    pub fn with_colspan(mut self, colspan: u32) -> Self {
309        self.colspan = colspan;
310        self
311    }
312
313    /// Set rowspan
314    pub fn with_rowspan(mut self, rowspan: u32) -> Self {
315        self.rowspan = rowspan;
316        self
317    }
318
319    /// Add an MCID
320    pub fn add_mcid(&mut self, mcid: u32) {
321        self.mcids.push(mcid);
322    }
323}
324
325/// Find all Table structure elements in the structure tree for a given page.
326///
327/// Recursively walks the structure tree to collect StructElem nodes where
328/// `struct_type == StructType::Table` and the element (or any descendant)
329/// has marked content on the specified page.
330///
331/// # Arguments
332/// * `struct_tree` - The structure tree root
333/// * `page_num` - Page number to match (0-based)
334///
335/// # Returns
336/// * `Vec<&StructElem>` - Table elements found for the page
337pub fn find_table_elements(struct_tree: &crate::structure::types::StructTreeRoot, page_num: u32) -> Vec<&StructElem> {
338    let mut tables = Vec::new();
339    for elem in &struct_tree.root_elements {
340        collect_table_elements(elem, page_num, &mut tables);
341    }
342    tables
343}
344
345/// Recursively collect Table elements that have content on the given page.
346fn collect_table_elements<'a>(elem: &'a StructElem, page_num: u32, tables: &mut Vec<&'a StructElem>) {
347    if elem.struct_type == StructType::Table {
348        if element_has_page_content(elem, page_num) {
349            tables.push(elem);
350        }
351        return; // Don't recurse into table children looking for nested tables ~keep
352    }
353
354    for child in &elem.children {
355        if let StructChild::StructElem(child_elem) = child {
356            collect_table_elements(child_elem, page_num, tables);
357        }
358    }
359}
360
361/// Walk the structure tree once and bucket every `Table` element by each page
362/// it has content on (owned clones), so the converter table path can do an
363/// O(1) per-page lookup instead of `find_table_elements`'s per-page walk
364/// (≈ O(pages²) on a tagged document). For a given page the result matches
365/// `find_table_elements(tree, page)`: same DFS pre-order, and like
366/// `collect_table_elements` it does not recurse into a Table's children.
367pub fn find_table_elements_all_pages(
368    struct_tree: &crate::structure::types::StructTreeRoot,
369) -> std::collections::HashMap<u32, Vec<StructElem>> {
370    let mut by_page: std::collections::HashMap<u32, Vec<StructElem>> = std::collections::HashMap::new();
371    for elem in &struct_tree.root_elements {
372        collect_table_elements_all_pages(elem, &mut by_page);
373    }
374    by_page
375}
376
377fn collect_table_elements_all_pages(elem: &StructElem, by_page: &mut std::collections::HashMap<u32, Vec<StructElem>>) {
378    if elem.struct_type == StructType::Table {
379        // Pages this table (or any descendant) has content on — the exact set
380        // for which `element_has_page_content(elem, page)` is true. ~keep
381        let mut pages = std::collections::BTreeSet::new();
382        collect_content_pages(elem, &mut pages);
383        for p in pages {
384            by_page.entry(p).or_default().push(elem.clone());
385        }
386        return; // mirror collect_table_elements: don't recurse into table children ~keep
387    }
388
389    for child in &elem.children {
390        if let StructChild::StructElem(child_elem) = child {
391            collect_table_elements_all_pages(child_elem, by_page);
392        }
393    }
394}
395
396/// Collect the set of pages on which `elem` (or any descendant) has content.
397/// Mirrors the truth set of [`element_has_page_content`] across all pages.
398fn collect_content_pages(elem: &StructElem, pages: &mut std::collections::BTreeSet<u32>) {
399    if let Some(p) = elem.page {
400        pages.insert(p);
401    }
402    for child in &elem.children {
403        match child {
404            StructChild::MarkedContentRef { page, .. } => {
405                pages.insert(*page);
406            }
407            StructChild::StructElem(child_elem) => {
408                collect_content_pages(child_elem, pages);
409            }
410            StructChild::ObjectRef(_, _) => {}
411        }
412    }
413}
414
415/// Check if a structure element or any descendant has marked content on the given page.
416fn element_has_page_content(elem: &StructElem, page_num: u32) -> bool {
417    if elem.page == Some(page_num) {
418        return true;
419    }
420
421    for child in &elem.children {
422        match child {
423            StructChild::MarkedContentRef { page, .. } => {
424                if *page == page_num {
425                    return true;
426                }
427            }
428            StructChild::StructElem(child_elem) => {
429                if element_has_page_content(child_elem, page_num) {
430                    return true;
431                }
432            }
433            StructChild::ObjectRef(_, _) => {}
434        }
435    }
436
437    false
438}
439
440/// Extract a table from a structure element tree using TextSpans (MCID matching).
441///
442/// Converts TextSpans to a format suitable for MCID-based cell text extraction,
443/// then delegates to the standard `extract_table` function.
444///
445/// # Arguments
446/// * `table_elem` - The Table structure element
447/// * `spans` - Text spans from the page (with MCID values)
448///
449/// # Returns
450/// * `Table` containing all rows and cells
451pub fn extract_table_from_spans(table_elem: &StructElem, spans: &[crate::layout::TextSpan]) -> Result<Table, Error> {
452    // Convert spans to TextBlocks for MCID matching, applying column-spanning
453    // decimal split so that "12.11" (sailing score columns) becomes "12 11". ~keep
454    let text_blocks: Vec<TextBlock> = spans
455        .iter()
456        .filter(|s| s.mcid.is_some())
457        .map(|s| {
458            let text = span_text_for_cell(s);
459            TextBlock {
460                chars: Vec::new(),
461                bbox: s.bbox,
462                text,
463                avg_font_size: s.font_size,
464                dominant_font: s.font_name.clone(),
465                is_bold: s.font_weight.is_bold(),
466                is_italic: s.is_italic,
467                mcid: s.mcid,
468                mcid_scope: s.mcid_scope.clone(),
469                sequence: s.sequence,
470                rotation_degrees: s.rotation_degrees,
471            }
472        })
473        .collect();
474    extract_table(table_elem, &text_blocks)
475}
476
477/// Return the display text for a span when used as a table cell token.
478/// Mirrors `PdfDocument::push_span_text`: splits column-spanning decimals
479/// (e.g. "12.11" across adjacent score columns) at the decimal point.
480pub(super) fn span_text_for_cell(span: &crate::layout::TextSpan) -> String {
481    let text = &span.text;
482    let dot_pos = match text.find('.') {
483        Some(p) if p > 0 && p < text.len() - 1 => p,
484        _ => return text.clone(),
485    };
486    if text[dot_pos + 1..].contains('.') {
487        return text.clone();
488    }
489    if !text[..dot_pos].chars().all(|c| c.is_ascii_digit()) {
490        return text.clone();
491    }
492    if !text[dot_pos + 1..].chars().all(|c| c.is_ascii_digit()) {
493        return text.clone();
494    }
495    let char_count = text.chars().count();
496    // Signal 1: sparse char_widths array (cw.len < char_count) means the
497    // span was assembled from two concatenated Tj runs — see the matching
498    // `is_column_spanning_decimal` rule in document.rs.  Catches sailing-
499    // score cells emitted as a single Tj like "1.10" (cw=[w]) where the
500    // PDF actually means "1" followed by "10" in adjacent score columns
501    // bbox.width can still be tight here, so the
502    // bbox-inflation check below isn't sufficient. ~keep
503    if !span.char_widths.is_empty() && span.char_widths.len() < char_count {
504        return format!("{} {}", &text[..dot_pos], &text[dot_pos + 1..]);
505    }
506    let expected_width = if !span.char_widths.is_empty() {
507        let cw_sum: f32 = span.char_widths.iter().sum();
508        cw_sum * (char_count as f32 / span.char_widths.len() as f32)
509    } else if span.font_size > 0.0 {
510        // 0.50em per char: digits are narrower than average; keeps the
511        // fallback from producing false negatives on word_spans (char_widths=[]). ~keep
512        span.font_size * 0.50 * char_count as f32
513    } else {
514        return text.clone();
515    };
516    let gap = span.bbox.width - expected_width;
517    if span.font_size > 0.0 && gap > span.font_size * 1.0 {
518        format!("{} {}", &text[..dot_pos], &text[dot_pos + 1..])
519    } else {
520        text.clone()
521    }
522}
523
524/// Extract a table from a structure element tree.
525///
526/// According to PDF spec Section 14.8.4.3.4, a Table element may contain:
527/// - Direct TR (table row) children, OR
528/// - THead (optional) + TBody (one or more) + TFoot (optional)
529///
530/// # Arguments
531/// * `table_elem` - The Table structure element
532/// * `text_blocks` - All text blocks in the document (for MCID matching)
533///
534/// # Returns
535/// * `Table` containing all rows and cells
536pub fn extract_table(table_elem: &StructElem, text_blocks: &[TextBlock]) -> Result<Table, Error> {
537    let mut table = Table::new();
538
539    let has_thead = table_elem
540        .children
541        .iter()
542        .any(|child| matches!(child, StructChild::StructElem(elem) if elem.struct_type == StructType::THead));
543
544    if has_thead {
545        table.has_header = true;
546    }
547
548    for child in &table_elem.children {
549        match child {
550            StructChild::StructElem(elem) => match elem.struct_type {
551                StructType::TR => {
552                    let row = extract_row(elem, text_blocks, false)?;
553                    table.add_row(row);
554                }
555                StructType::THead => {
556                    extract_row_group(elem, text_blocks, true, &mut table)?;
557                }
558                StructType::TBody => {
559                    extract_row_group(elem, text_blocks, false, &mut table)?;
560                }
561                StructType::TFoot => {
562                    extract_row_group(elem, text_blocks, false, &mut table)?;
563                }
564                _ => {}
565            },
566            StructChild::MarkedContentRef { .. } => {}
567            StructChild::ObjectRef(_, _) => {}
568        }
569    }
570
571    Ok(table)
572}
573
574/// Extract rows from a row group (THead, TBody, TFoot).
575fn extract_row_group(
576    group_elem: &StructElem,
577    text_blocks: &[TextBlock],
578    is_header: bool,
579    table: &mut Table,
580) -> Result<(), Error> {
581    for child in &group_elem.children {
582        match child {
583            StructChild::StructElem(elem) if elem.struct_type == StructType::TR => {
584                let row = extract_row(elem, text_blocks, is_header)?;
585                table.add_row(row);
586            }
587            _ => {}
588        }
589    }
590    Ok(())
591}
592
593/// Extract a single row (TR element).
594fn extract_row(tr_elem: &StructElem, text_blocks: &[TextBlock], force_header: bool) -> Result<TableRow, Error> {
595    let mut row = TableRow::new(force_header);
596
597    for child in &tr_elem.children {
598        match child {
599            StructChild::StructElem(elem) => match elem.struct_type {
600                StructType::TH => {
601                    let cell = extract_cell(elem, text_blocks, true)?;
602                    row.add_cell(cell);
603                }
604                StructType::TD => {
605                    let cell = extract_cell(elem, text_blocks, false)?;
606                    row.add_cell(cell);
607                }
608                _ => {}
609            },
610            StructChild::MarkedContentRef { .. } => {}
611            StructChild::ObjectRef(_, _) => {}
612        }
613    }
614
615    Ok(row)
616}
617
618/// Extract a single cell (TH or TD element).
619fn extract_cell(cell_elem: &StructElem, text_blocks: &[TextBlock], is_header: bool) -> Result<TableCell, Error> {
620    let mut mcids = Vec::new();
621    collect_mcids(cell_elem, &mut mcids);
622    let mut scoped_mcids = Vec::new();
623    collect_scoped_mcids(cell_elem, &mut scoped_mcids);
624
625    // Find all text blocks that match these MCIDs, joining them with position-aware
626    // spacing: insert a space only when there is a genuine horizontal gap between
627    // adjacent spans on the same line, or when spans are on different lines.
628    // This prevents spurious spaces inside CJK expressions like "Q(peu/d)" whose
629    // glyphs are stored as separate marked-content runs that abut each other. ~keep
630    let mut cell_text = String::new();
631    let mut cell_spans: Vec<TextSpan> = Vec::new();
632
633    // Collect every block the cell's marked-content sequences own, then join
634    // them in reading order: content-stream order routinely interleaves a
635    // wrapped title's lines.
636    //
637    // Reading order follows the run's OWN writing axis, not the page's. ISO
638    // 32000-1 §9.4.4 puts glyph displacement along the text matrix's writing
639    // direction, so a line rotated 90° advances along y while successive
640    // lines step along x, and right-to-left script is stored in logical order
641    // while advancing toward decreasing x. Projecting onto those axes
642    // recovers reading order for upright, rotated and RTL cells alike, where
643    // a fixed y-then-x sort reverses the last two. ~keep
644    let mut blocks: Vec<&TextBlock> = text_blocks.iter().filter(|b| block_belongs(b, &scoped_mcids)).collect();
645
646    let (sin, cos) = cell_rotation_degrees(&blocks).to_radians().sin_cos();
647    let direction = if blocks.iter().any(|b| crate::text::bidi::looks_rtl(&b.text)) {
648        -1.0
649    } else {
650        1.0
651    };
652    // Along the writing direction, and perpendicular to it in the direction
653    // successive lines advance (unrotated: +x and "down the page"). ~keep
654    let along = |b: &TextBlock| direction * (b.bbox.x * cos + b.bbox.y * sin);
655    let perp = |b: &TextBlock| b.bbox.x * sin - b.bbox.y * cos;
656    // How far a run extends along its own writing direction. A span's bbox is
657    // run-local — `width` is the accumulated advance and `height` the font
658    // size, whatever the rotation (see the TextSpan construction in
659    // extractors/text.rs) — so the writing-axis extent is always `width`. ~keep
660    let advance_extent = |b: &TextBlock| b.bbox.width;
661
662    // `perp` multiplies a bbox coordinate by a sine that is exactly 0.0 at
663    // rotation 0, so a non-finite coordinate yields NaN on the axis we are not
664    // even sorting along. Comparing NaN as Equal breaks transitivity and
665    // `sort_by` then panics, so use the crate's NaN ordering. ~keep
666    blocks.sort_by(|a, b| crate::utils::safe_float_cmp(perp(a), perp(b)));
667    // Lines are formed by walking along the perpendicular axis and opening a
668    // new line when the step from the current line's first block exceeds half
669    // the SMALLER of the two heights — a per-pair threshold, so one tall
670    // multi-line block cannot swallow line spacing tighter than its own
671    // height. ~keep
672    let mut keyed: Vec<(usize, &TextBlock)> = Vec::with_capacity(blocks.len());
673    let mut line = 0usize;
674    let mut anchor: Option<&TextBlock> = None;
675    for b in blocks {
676        if let Some(a) = anchor {
677            let threshold = a.bbox.height.min(b.bbox.height).max(1.0) * 0.5;
678            if perp(b) - perp(a) > threshold {
679                line += 1;
680                anchor = Some(b);
681            }
682        } else {
683            anchor = Some(b);
684        }
685        keyed.push((line, b));
686    }
687    keyed.sort_by(|(line_a, a), (line_b, b)| {
688        line_a
689            .cmp(line_b)
690            .then_with(|| crate::utils::safe_float_cmp(along(a), along(b)))
691    });
692    // `direction` reversed the whole cell, but UAX #9 reverses only the
693    // surrounding script: numerals (class EN/AN) keep left-to-right order
694    // inside a right-to-left paragraph. Producers routinely split one figure
695    // across marked-content runs ("4" + "7.500"), so without this the sort
696    // reads it back as "7.5004" — a silently altered figure, not a spacing
697    // artefact. ~keep
698    if direction < 0.0 {
699        restore_numeric_run_order(&mut keyed);
700    }
701
702    let mut prev_block: Option<(usize, &TextBlock)> = None;
703    for (line_index, block) in keyed {
704        let mut leading_space = false;
705        if !cell_text.is_empty() {
706            let need_space = if let Some((prev_line, prev)) = prev_block {
707                // Line membership was already decided above, in the writing
708                // frame. Re-deriving it here from page-frame y would call
709                // every run of a rotated line a new line (they differ in y by
710                // their own advance) and inject a space into every word. ~keep
711                let gap = along(block) - (along(prev) + advance_extent(prev));
712                let font_size = prev.avg_font_size.max(block.avg_font_size).max(1.0);
713                if line_index != prev_line {
714                    true
715                } else if gap <= font_size * 0.15 {
716                    false
717                } else {
718                    // Suppress space insertion when one side is CJK and the
719                    // other is CJK or a fullwidth/math operator (e.g. ≤, <, μ).
720                    // This mirrors the CJK-pair suppression in document.rs and
721                    // converters/mod.rs. ~keep
722                    #[inline(always)]
723                    fn is_cjk(c: char) -> bool {
724                        matches!(c,
725                            '\u{3040}'..='\u{309F}' |   // Hiragana ~keep
726                            '\u{30A0}'..='\u{30FF}' |   // Katakana ~keep
727                            '\u{4E00}'..='\u{9FFF}' |   // CJK Unified Ideographs ~keep
728                            '\u{AC00}'..='\u{D7AF}' |   // Hangul ~keep
729                            '\u{3400}'..='\u{4DBF}' |   // CJK Extension A ~keep
730                            '\u{20000}'..='\u{2A6DF}'   // CJK Extension B ~keep
731                        )
732                    }
733                    #[inline(always)]
734                    fn is_fw_math(c: char) -> bool {
735                        matches!(c,
736                            '\u{FF0B}' | '\u{FF0D}' |
737                            '\u{FF1A}' | '\u{FF1B}' |
738                            '\u{FF1C}'..='\u{FF1E}' |
739                            '\u{2260}' | '\u{2248}' |
740                            '\u{2264}'..='\u{2265}' |
741                            '\u{00B5}' | '\u{03BC}' |
742                            '\u{00B1}' | '\u{00D7}' | '\u{00F7}'
743                        )
744                    }
745                    let p_last = prev.text.chars().next_back();
746                    let b_first = block.text.chars().next();
747                    let suppress = if let (Some(p), Some(b)) = (p_last, b_first) {
748                        let p_cjk = is_cjk(p);
749                        let b_cjk = is_cjk(b);
750                        (p_cjk || is_fw_math(p)) && (b_cjk || is_fw_math(b)) && (p_cjk || b_cjk)
751                    } else {
752                        false
753                    };
754                    !suppress
755                }
756            } else {
757                !cell_text.ends_with(' ')
758            };
759            if need_space {
760                cell_text.push(' ');
761                leading_space = true;
762            }
763        }
764        cell_text.push_str(&block.text);
765        // Synthesize a minimal TextSpan capturing the block's
766        // style. Only the fields the markdown converter
767        // consults (text, font_weight, is_italic, font_size,
768        // bbox) need real values — everything else is filled
769        // from sensible defaults. Carry the inter-block space
770        // into the span text as well: the markdown/HTML table
771        // renderers reconstruct spacing from the spans (not from
772        // cell_text), and their horizontal-gap heuristic cannot
773        // see a line wrap, so without this they glue tokens
774        // across wrapped lines. Both renderers already treat a
775        // leading space in the span text as authoritative
776        // (their `already_has_space` guard), so this never
777        // double-spaces. ~keep
778        let span_text = if leading_space {
779            let mut s = String::with_capacity(block.text.len() + 1);
780            s.push(' ');
781            s.push_str(&block.text);
782            s
783        } else {
784            block.text.clone()
785        };
786        cell_spans.push(TextSpan {
787            provenance: None,
788            artifact_type: None,
789            text: span_text,
790            bbox: block.bbox,
791            font_name: block.dominant_font.clone(),
792            font_size: block.avg_font_size,
793            font_weight: if block.is_bold {
794                FontWeight::Bold
795            } else {
796                FontWeight::Normal
797            },
798            is_italic: block.is_italic,
799            is_monospace: false,
800            color: Color::black(),
801            mcid: block.mcid,
802            mcid_scope: None,
803            sequence: 0,
804            offset_semantic: false,
805            split_boundary_before: false,
806            char_spacing: 0.0,
807            word_spacing: 0.0,
808            horizontal_scaling: 100.0,
809            primary_detected: false,
810            char_widths: vec![],
811            char_x_offsets: Vec::new(),
812            heading_level: None,
813            // Carry the source block's rotation rather than resetting to 0.0: this push
814            // re-synthesises a span from an already-rotated block, so hardcoding zero
815            // silently flattens rotated table cells. Upstream fixed this on the older
816            // per-block push; the fix belongs here now that the push is line-aware. ~keep
817            rotation_degrees: block.rotation_degrees,
818            wmode: 0,
819            text_rise: 0.0,
820            rtl_draw_logical: false,
821            mirrored: false,
822            page_rotation_applied: 0,
823        });
824        prev_block = Some((line_index, block));
825    }
826
827    let mut cell = TableCell::new(cell_text.trim().to_string(), is_header);
828    cell.mcids = mcids;
829    cell.spans = cell_spans;
830
831    Ok(cell)
832}
833
834/// Whether a numeral keeps left-to-right order inside right-to-left text —
835/// UAX #9 class EN (European) or AN (Arabic-Indic).
836fn is_uax9_numeral(c: char) -> bool {
837    c.is_ascii_digit() || matches!(c, '\u{0660}'..='\u{0669}' | '\u{06F0}'..='\u{06F9}')
838}
839
840/// Whether a cell block is a numeric run that must not share the surrounding
841/// script's reversal.
842///
843/// At least one numeral is required deliberately. Absorbing bare separators
844/// (a lone "." or "%") into the run measures worse: it pulls a trailing
845/// separator into a run the sort already placed correctly, turning one wrong
846/// cell into two. Full UAX #9 resolution in the cell sorter is a larger design
847/// question than this defect.
848fn is_numeric_run(text: &str) -> bool {
849    let mut saw_numeral = false;
850    for c in text.trim().chars() {
851        if is_uax9_numeral(c) {
852            saw_numeral = true;
853        } else if !matches!(c, '.' | ',' | '\u{066B}' | '\u{066C}') {
854            return false;
855        }
856    }
857    saw_numeral
858}
859
860/// Undo the right-to-left sort reversal within each maximal run of numeric
861/// blocks, leaving the surrounding script reversed. A run never spans a line
862/// boundary, so line membership bounds it.
863fn restore_numeric_run_order(keyed: &mut [(usize, &TextBlock)]) {
864    let mut start = 0usize;
865    while start < keyed.len() {
866        if !is_numeric_run(&keyed[start].1.text) {
867            start += 1;
868            continue;
869        }
870        let line = keyed[start].0;
871        let mut end = start + 1;
872        while end < keyed.len() && keyed[end].0 == line && is_numeric_run(&keyed[end].1.text) {
873            end += 1;
874        }
875        keyed[start..end].reverse();
876        start = end;
877    }
878}
879
880/// The quadrant the cell's glyph runs are drawn at, as the modal rotation of
881/// its blocks. A cell mixing rotations (a rotated header beside an upright
882/// note) reads in the majority frame; ties resolve to the smaller angle so
883/// the choice is deterministic.
884fn cell_rotation_degrees(blocks: &[&TextBlock]) -> f32 {
885    let mut counts: [usize; 4] = [0; 4];
886    for b in blocks {
887        let normalized = b.rotation_degrees.rem_euclid(360.0);
888        // Snap to a quadrant; a free angle (skew) reads in the upright frame,
889        // which is what the unrotated sort did for it before. ~keep
890        let quadrant = ((normalized / 90.0).round() as usize) % 4;
891        if (normalized - (quadrant as f32) * 90.0).abs() < 0.5 {
892            counts[quadrant] += 1;
893        } else {
894            counts[0] += 1;
895        }
896    }
897    let (index, _) = counts
898        .iter()
899        .enumerate()
900        .max_by_key(|&(ref index, &count)| (count, std::cmp::Reverse(*index)))
901        .expect("counts is non-empty");
902    (index as f32) * 90.0
903}
904
905/// Recursively collect all MCIDs from a structure element and its children.
906fn collect_mcids(elem: &StructElem, mcids: &mut Vec<u32>) {
907    let mut scoped = Vec::new();
908    collect_scoped_mcids(elem, &mut scoped);
909    mcids.extend(scoped.into_iter().map(|(_, mcid)| mcid));
910}
911
912/// Collect a cell's marked-content references with the content stream each one
913/// names.
914///
915/// An MCID is unique only inside its own content stream (ISO 32000-1 §14.7.4.3),
916/// so two Form XObjects on one page routinely both number a run 0. Carrying the
917/// scope lets a cell tell its own run from an identically numbered one drawn by
918/// a different stream.
919fn collect_scoped_mcids(elem: &StructElem, out: &mut Vec<(McidScope, u32)>) {
920    for child in &elem.children {
921        match child {
922            StructChild::MarkedContentRef { mcid, scope, .. } => {
923                out.push((scope.clone(), *mcid));
924            }
925            StructChild::StructElem(child_elem) => {
926                collect_scoped_mcids(child_elem, out);
927            }
928            StructChild::ObjectRef(_, _) => {}
929        }
930    }
931}
932
933/// Whether a text block is one of the runs this cell's references name.
934///
935/// The scope decides only where it can: a block whose stream matches a
936/// reference naming that stream belongs to the cell. Where no block carries the
937/// stream a reference names — the producer omitted `/Stm`, which is legal and
938/// common — the MCID number alone decides, so those cells keep their text
939/// instead of emptying.
940fn block_belongs(block: &TextBlock, scoped: &[(McidScope, u32)]) -> bool {
941    let Some(mcid) = block.mcid else { return false };
942    let named: Vec<&McidScope> = scoped.iter().filter(|(_, m)| *m == mcid).map(|(s, _)| s).collect();
943    if named.is_empty() {
944        return false;
945    }
946    match &block.mcid_scope {
947        // The block names the stream a reference names: it is this cell's run.
948        Some(scope) if named.contains(&scope) => true,
949        // The block names a stream, and it is not one this cell references:
950        // a different stream's run that happens to share the number.
951        Some(_) => false,
952        // The block names no stream, so the producer omitted `/Stm`. The
953        // number alone decides, which is what kept this cell's text before
954        // scopes existed.
955        None => true,
956    }
957}
958
959#[cfg(test)]
960mod tests {
961    use super::*;
962    use crate::structure::types::StructTreeRoot;
963
964    /// A block whose bbox carries a non-finite coordinate, which a malformed
965    /// file can produce. `perp()` multiplies x by a sine that is exactly 0.0 at
966    /// rotation 0, so `inf * 0.0` is NaN on an axis the sort does not even use.
967    fn block_at(x: f32, y: f32, mcid: u32, text: &str) -> TextBlock {
968        let ch = crate::layout::TextChar {
969            char: text.chars().next().unwrap_or('x'),
970            bbox: Rect::new(x, y, 10.0, 10.0),
971            font_name: "Test".to_string(),
972            font_size: 10.0,
973            font_weight: FontWeight::default(),
974            is_italic: false,
975            is_monospace: false,
976            color: Color::default(),
977            mcid: Some(mcid),
978            origin_x: x,
979            origin_y: y,
980            rotation_degrees: 0.0,
981            advance_width: 10.0,
982            rendered_advance: 10.0,
983            ascent: 8.0,
984            descent: -2.0,
985            matrix: None,
986        };
987        let mut block = TextBlock::from_chars(vec![ch]);
988        block.bbox = Rect::new(x, y, 10.0, 10.0);
989        block.text = text.to_string();
990        block.mcid = Some(mcid);
991        block
992    }
993
994    /// `sort_by` panics on a comparator that is not a total order, and NaN
995    /// compares equal to everything. This class of panic has been fixed twice
996    /// already; the cell sorts must go through `utils::safe_float_cmp`.
997    #[test]
998    fn cell_blocks_with_non_finite_bbox_do_not_panic() {
999        // Enough blocks that `sort_by` takes its merge path: Rust's total-order
1000        // violation check is best-effort, and a three-element slice sorts by
1001        // insertion without ever comparing the offending pair. ~keep
1002        const N: u32 = 24;
1003        let children: Vec<StructChild> = (0..N)
1004            .map(|mcid| StructChild::MarkedContentRef {
1005                mcid,
1006                page: 0,
1007                scope: crate::structure::types::McidScope::Page(0),
1008            })
1009            .collect();
1010        let cell = StructElem {
1011            struct_type: StructType::TD,
1012            children,
1013            page: Some(0),
1014            attributes: std::collections::HashMap::new(),
1015            alt_text: None,
1016            expansion: None,
1017            actual_text: None,
1018            source_role: None,
1019        };
1020        // Two non-finite x coordinates scattered through the run. `perp`
1021        // multiplies x by a sine that is exactly 0.0 at rotation 0, so each
1022        // yields NaN on the axis the sort does not even use. ~keep
1023        let blocks: Vec<TextBlock> = (0..N)
1024            .map(|i| {
1025                // Four lines of six: y selects the line, x the position within
1026                // it. The perp sort decides line membership, so a NaN there
1027                // changes which line a block lands on. ~keep
1028                let x = if i == 7 || i == 17 {
1029                    f32::INFINITY
1030                } else {
1031                    10.0 * (i % 6) as f32
1032                };
1033                let text = ((b'a' + i as u8) as char).to_string();
1034                block_at(x, 20.0 + 40.0 * (i / 6) as f32, i, &text)
1035            })
1036            .collect();
1037
1038        // The assertion is that this returns at all: with `partial_cmp(..)
1039        // .unwrap_or(Equal)` the NaN breaks transitivity and sort_by panics. ~keep
1040        let cell = extract_cell(&cell, &blocks, false).expect("cell extraction");
1041        assert!(
1042            cell.text.contains('a') && cell.text.contains('x'),
1043            "finite blocks must survive a non-finite sibling, got {:?}",
1044            cell.text
1045        );
1046    }
1047
1048    #[test]
1049    fn test_table_new() {
1050        let table = Table::new();
1051        assert!(table.is_empty());
1052        assert_eq!(table.col_count, 0);
1053        assert!(!table.has_header);
1054        assert!(table.bbox.is_none());
1055    }
1056
1057    #[test]
1058    fn test_table_bbox() {
1059        let mut table = Table::new();
1060        assert!(table.bbox.is_none());
1061
1062        table.bbox = Some(Rect::new(10.0, 20.0, 100.0, 50.0));
1063        assert!(table.bbox.is_some());
1064        let bbox = table.bbox.unwrap();
1065        assert_eq!(bbox.x, 10.0);
1066        assert_eq!(bbox.y, 20.0);
1067        assert_eq!(bbox.width, 100.0);
1068        assert_eq!(bbox.height, 50.0);
1069    }
1070
1071    #[test]
1072    fn test_table_row_new() {
1073        let header_row = TableRow::new(true);
1074        assert!(header_row.is_header);
1075        assert!(header_row.cells.is_empty());
1076
1077        let body_row = TableRow::new(false);
1078        assert!(!body_row.is_header);
1079    }
1080
1081    #[test]
1082    fn test_table_cell_new() {
1083        let cell = TableCell::new("Hello".to_string(), false);
1084        assert_eq!(cell.text, "Hello");
1085        assert!(!cell.is_header);
1086        assert_eq!(cell.colspan, 1);
1087        assert_eq!(cell.rowspan, 1);
1088        assert!(cell.mcids.is_empty());
1089    }
1090
1091    #[test]
1092    fn test_table_cell_with_spans() {
1093        let cell = TableCell::new("Data".to_string(), false)
1094            .with_colspan(2)
1095            .with_rowspan(3);
1096
1097        assert_eq!(cell.colspan, 2);
1098        assert_eq!(cell.rowspan, 3);
1099    }
1100
1101    #[test]
1102    fn test_table_cell_header() {
1103        let cell = TableCell::new("Header".to_string(), true);
1104        assert!(cell.is_header);
1105    }
1106
1107    #[test]
1108    fn test_table_row_add_cells() {
1109        let mut row = TableRow::new(false);
1110        row.add_cell(TableCell::new("Cell1".to_string(), false));
1111        row.add_cell(TableCell::new("Cell2".to_string(), false));
1112
1113        assert_eq!(row.cells.len(), 2);
1114        assert_eq!(row.cells[0].text, "Cell1");
1115        assert_eq!(row.cells[1].text, "Cell2");
1116    }
1117
1118    #[test]
1119    fn test_table_add_rows() {
1120        let mut table = Table::new();
1121        let mut row1 = TableRow::new(false);
1122        row1.add_cell(TableCell::new("A".to_string(), false));
1123        row1.add_cell(TableCell::new("B".to_string(), false));
1124
1125        table.add_row(row1);
1126        assert_eq!(table.col_count, 2);
1127        assert_eq!(table.rows.len(), 1);
1128    }
1129
1130    #[test]
1131    fn test_table_has_header() {
1132        let mut table = Table::new();
1133        assert!(!table.has_header);
1134
1135        table.has_header = true;
1136        assert!(table.has_header);
1137    }
1138
1139    /// Helper: create a minimal Table StructElem with MarkedContentRefs on a given page
1140    fn make_table_elem(page: u32, mcids: &[u32]) -> StructElem {
1141        let mut table = StructElem::new(StructType::Table);
1142        let mut tr = StructElem::new(StructType::TR);
1143        for &mcid in mcids {
1144            let mut td = StructElem::new(StructType::TD);
1145            td.add_child(StructChild::MarkedContentRef {
1146                mcid,
1147                page,
1148                scope: crate::structure::McidScope::Page(page),
1149            });
1150            tr.add_child(StructChild::StructElem(Box::new(td)));
1151        }
1152        table.add_child(StructChild::StructElem(Box::new(tr)));
1153        table
1154    }
1155
1156    #[test]
1157    fn test_find_table_elements_finds_table_on_matching_page() {
1158        let mut tree = StructTreeRoot::new();
1159        tree.add_root_element(make_table_elem(0, &[1, 2]));
1160
1161        let tables = find_table_elements(&tree, 0);
1162        assert_eq!(tables.len(), 1);
1163        assert_eq!(tables[0].struct_type, StructType::Table);
1164    }
1165
1166    #[test]
1167    fn test_find_table_elements_skips_table_on_different_page() {
1168        let mut tree = StructTreeRoot::new();
1169        tree.add_root_element(make_table_elem(1, &[1, 2]));
1170
1171        let tables = find_table_elements(&tree, 0);
1172        assert!(tables.is_empty());
1173    }
1174
1175    #[test]
1176    fn test_find_table_elements_empty_tree() {
1177        let tree = StructTreeRoot::new();
1178        let tables = find_table_elements(&tree, 0);
1179        assert!(tables.is_empty());
1180    }
1181
1182    #[test]
1183    fn test_find_table_elements_multiple_tables() {
1184        let mut tree = StructTreeRoot::new();
1185        tree.add_root_element(make_table_elem(0, &[1, 2]));
1186        tree.add_root_element(make_table_elem(0, &[3, 4]));
1187
1188        let tables = find_table_elements(&tree, 0);
1189        assert_eq!(tables.len(), 2);
1190    }
1191
1192    #[test]
1193    fn test_find_table_elements_nested_in_section() {
1194        let mut tree = StructTreeRoot::new();
1195        let mut sect = StructElem::new(StructType::Sect);
1196        sect.add_child(StructChild::StructElem(Box::new(make_table_elem(0, &[1]))));
1197        tree.add_root_element(sect);
1198
1199        let tables = find_table_elements(&tree, 0);
1200        assert_eq!(tables.len(), 1);
1201    }
1202
1203    #[test]
1204    fn test_find_table_elements_table_with_page_attribute() {
1205        let mut tree = StructTreeRoot::new();
1206        let mut table = StructElem::new(StructType::Table);
1207        table.page = Some(2);
1208        tree.add_root_element(table);
1209
1210        let tables = find_table_elements(&tree, 2);
1211        assert_eq!(tables.len(), 1);
1212    }
1213
1214    #[test]
1215    fn test_find_table_elements_mixed_pages() {
1216        let mut tree = StructTreeRoot::new();
1217        tree.add_root_element(make_table_elem(0, &[1]));
1218        tree.add_root_element(make_table_elem(1, &[2]));
1219        tree.add_root_element(make_table_elem(0, &[3]));
1220
1221        let page0_tables = find_table_elements(&tree, 0);
1222        assert_eq!(page0_tables.len(), 2);
1223
1224        let page1_tables = find_table_elements(&tree, 1);
1225        assert_eq!(page1_tables.len(), 1);
1226    }
1227
1228    /// The single-walk all-pages bucketing must match, per page, the per-page
1229    /// `find_table_elements` walk it replaces.
1230    #[test]
1231    fn test_find_table_elements_all_pages_matches_per_page() {
1232        let mut tree = StructTreeRoot::new();
1233        tree.add_root_element(make_table_elem(0, &[1, 2]));
1234        let mut sect = StructElem::new(StructType::Sect);
1235        sect.add_child(StructChild::StructElem(Box::new(make_table_elem(0, &[3]))));
1236        tree.add_root_element(sect);
1237        tree.add_root_element(make_table_elem(1, &[4]));
1238        let mut page_attr_table = StructElem::new(StructType::Table);
1239        page_attr_table.page = Some(2);
1240        tree.add_root_element(page_attr_table);
1241
1242        let all = find_table_elements_all_pages(&tree);
1243        for page in 0..4u32 {
1244            let per_page = find_table_elements(&tree, page);
1245            let bucket = all.get(&page).cloned().unwrap_or_default();
1246            assert_eq!(
1247                bucket.len(),
1248                per_page.len(),
1249                "page {page}: bucket count must match per-page walk"
1250            );
1251            for (b, p) in bucket.iter().zip(per_page.iter()) {
1252                // same DFS pre-order ⇒ structurally identical elements ~keep
1253                assert_eq!(b.struct_type, p.struct_type);
1254                assert_eq!(b.page, p.page);
1255                assert_eq!(b.children.len(), p.children.len());
1256            }
1257        }
1258    }
1259
1260    #[test]
1261    fn test_find_table_elements_all_pages_empty_tree() {
1262        let tree = StructTreeRoot::new();
1263        assert!(find_table_elements_all_pages(&tree).is_empty());
1264    }
1265
1266    #[test]
1267    fn test_element_has_page_content_via_mcid() {
1268        let mut elem = StructElem::new(StructType::P);
1269        elem.add_child(StructChild::MarkedContentRef {
1270            mcid: 1,
1271            page: 3,
1272            scope: crate::structure::McidScope::Page(3),
1273        });
1274
1275        assert!(element_has_page_content(&elem, 3));
1276        assert!(!element_has_page_content(&elem, 0));
1277    }
1278
1279    #[test]
1280    fn test_element_has_page_content_via_page_attribute() {
1281        let mut elem = StructElem::new(StructType::P);
1282        elem.page = Some(5);
1283
1284        assert!(element_has_page_content(&elem, 5));
1285        assert!(!element_has_page_content(&elem, 0));
1286    }
1287
1288    #[test]
1289    fn test_element_has_page_content_recursive() {
1290        let mut parent = StructElem::new(StructType::Sect);
1291        let mut child = StructElem::new(StructType::P);
1292        child.add_child(StructChild::MarkedContentRef {
1293            mcid: 1,
1294            page: 2,
1295            scope: crate::structure::McidScope::Page(2),
1296        });
1297        parent.add_child(StructChild::StructElem(Box::new(child)));
1298
1299        assert!(element_has_page_content(&parent, 2));
1300        assert!(!element_has_page_content(&parent, 0));
1301    }
1302
1303    #[test]
1304    fn test_element_has_page_content_empty() {
1305        let elem = StructElem::new(StructType::P);
1306        assert!(!element_has_page_content(&elem, 0));
1307    }
1308
1309    #[test]
1310    fn test_element_has_page_content_object_ref_ignored() {
1311        let mut elem = StructElem::new(StructType::P);
1312        elem.add_child(StructChild::ObjectRef(1, 0));
1313        assert!(!element_has_page_content(&elem, 0));
1314    }
1315
1316    fn make_text_span(text: &str, mcid: Option<u32>) -> crate::layout::TextSpan {
1317        use crate::layout::text_block::{Color, FontWeight};
1318
1319        crate::layout::TextSpan {
1320            provenance: None,
1321            text_rise: 0.0,
1322            artifact_type: None,
1323            text: text.to_string(),
1324            bbox: Rect::new(0.0, 0.0, 50.0, 12.0),
1325            font_name: "Test".to_string(),
1326            font_size: 12.0,
1327            font_weight: FontWeight::Normal,
1328            is_italic: false,
1329            is_monospace: false,
1330            color: Color::black(),
1331            mcid,
1332            mcid_scope: None,
1333            sequence: 0,
1334            split_boundary_before: false,
1335            offset_semantic: false,
1336            char_spacing: 0.0,
1337            word_spacing: 0.0,
1338            horizontal_scaling: 1.0,
1339            primary_detected: false,
1340            char_widths: vec![],
1341            char_x_offsets: Vec::new(),
1342            heading_level: None,
1343            rotation_degrees: 0.0,
1344            wmode: 0,
1345            rtl_draw_logical: false,
1346            mirrored: false,
1347            page_rotation_applied: 0,
1348        }
1349    }
1350
1351    #[test]
1352    fn test_extract_table_from_spans_basic() {
1353        let mut table_elem = StructElem::new(StructType::Table);
1354        let mut tr = StructElem::new(StructType::TR);
1355        let mut td1 = StructElem::new(StructType::TD);
1356        td1.add_child(StructChild::MarkedContentRef {
1357            mcid: 10,
1358            page: 0,
1359            scope: crate::structure::McidScope::Page(0),
1360        });
1361        let mut td2 = StructElem::new(StructType::TD);
1362        td2.add_child(StructChild::MarkedContentRef {
1363            mcid: 11,
1364            page: 0,
1365            scope: crate::structure::McidScope::Page(0),
1366        });
1367        tr.add_child(StructChild::StructElem(Box::new(td1)));
1368        tr.add_child(StructChild::StructElem(Box::new(td2)));
1369        table_elem.add_child(StructChild::StructElem(Box::new(tr)));
1370
1371        let spans = vec![
1372            make_text_span("Hello", Some(10)),
1373            make_text_span("World", Some(11)),
1374            make_text_span("Unrelated", Some(99)),
1375        ];
1376
1377        let result = extract_table_from_spans(&table_elem, &spans).unwrap();
1378        assert_eq!(result.rows.len(), 1);
1379        assert_eq!(result.rows[0].cells.len(), 2);
1380        assert_eq!(result.rows[0].cells[0].text, "Hello");
1381        assert_eq!(result.rows[0].cells[1].text, "World");
1382    }
1383
1384    #[test]
1385    fn test_extract_table_from_spans_no_matching_mcids() {
1386        let mut table_elem = StructElem::new(StructType::Table);
1387        let mut tr = StructElem::new(StructType::TR);
1388        let mut td = StructElem::new(StructType::TD);
1389        td.add_child(StructChild::MarkedContentRef {
1390            mcid: 10,
1391            page: 0,
1392            scope: crate::structure::McidScope::Page(0),
1393        });
1394        tr.add_child(StructChild::StructElem(Box::new(td)));
1395        table_elem.add_child(StructChild::StructElem(Box::new(tr)));
1396
1397        let spans = vec![make_text_span("Other", Some(99))];
1398
1399        let result = extract_table_from_spans(&table_elem, &spans).unwrap();
1400        assert_eq!(result.rows.len(), 1);
1401        assert_eq!(result.rows[0].cells[0].text, "");
1402    }
1403
1404    #[test]
1405    fn test_extract_table_from_spans_filters_no_mcid_spans() {
1406        let mut table_elem = StructElem::new(StructType::Table);
1407        let mut tr = StructElem::new(StructType::TR);
1408        let mut td = StructElem::new(StructType::TD);
1409        td.add_child(StructChild::MarkedContentRef {
1410            mcid: 5,
1411            page: 0,
1412            scope: crate::structure::McidScope::Page(0),
1413        });
1414        tr.add_child(StructChild::StructElem(Box::new(td)));
1415        table_elem.add_child(StructChild::StructElem(Box::new(tr)));
1416
1417        let spans = vec![make_text_span("No MCID", None), make_text_span("Has MCID", Some(5))];
1418
1419        let result = extract_table_from_spans(&table_elem, &spans).unwrap();
1420        assert_eq!(result.rows[0].cells[0].text, "Has MCID");
1421    }
1422
1423    #[test]
1424    fn test_extract_table_from_spans_with_thead() {
1425        let mut table_elem = StructElem::new(StructType::Table);
1426
1427        let mut thead = StructElem::new(StructType::THead);
1428        let mut hdr_tr = StructElem::new(StructType::TR);
1429        let mut th = StructElem::new(StructType::TH);
1430        th.add_child(StructChild::MarkedContentRef {
1431            mcid: 1,
1432            page: 0,
1433            scope: crate::structure::McidScope::Page(0),
1434        });
1435        hdr_tr.add_child(StructChild::StructElem(Box::new(th)));
1436        thead.add_child(StructChild::StructElem(Box::new(hdr_tr)));
1437        table_elem.add_child(StructChild::StructElem(Box::new(thead)));
1438
1439        let mut tbody = StructElem::new(StructType::TBody);
1440        let mut body_tr = StructElem::new(StructType::TR);
1441        let mut td = StructElem::new(StructType::TD);
1442        td.add_child(StructChild::MarkedContentRef {
1443            mcid: 2,
1444            page: 0,
1445            scope: crate::structure::McidScope::Page(0),
1446        });
1447        body_tr.add_child(StructChild::StructElem(Box::new(td)));
1448        tbody.add_child(StructChild::StructElem(Box::new(body_tr)));
1449        table_elem.add_child(StructChild::StructElem(Box::new(tbody)));
1450
1451        let spans = vec![make_text_span("Header", Some(1)), make_text_span("Data", Some(2))];
1452
1453        let result = extract_table_from_spans(&table_elem, &spans).unwrap();
1454        assert!(result.has_header);
1455        assert_eq!(result.rows.len(), 2);
1456        assert!(result.rows[0].is_header);
1457        assert!(!result.rows[1].is_header);
1458        assert_eq!(result.rows[0].cells[0].text, "Header");
1459        assert_eq!(result.rows[1].cells[0].text, "Data");
1460    }
1461
1462    #[test]
1463    fn test_extract_table_from_spans_empty_table() {
1464        let table_elem = StructElem::new(StructType::Table);
1465        let spans: Vec<crate::layout::TextSpan> = vec![];
1466
1467        let result = extract_table_from_spans(&table_elem, &spans).unwrap();
1468        assert!(result.is_empty());
1469    }
1470
1471    /// Adjacent MCID spans (gap ≤ 0) must NOT
1472    /// have a space inserted between them.  The PDF stores e.g. "Q" (MCID 1) and "("
1473    /// (MCID 2) as separate marked-content runs that abut each other on the same line.
1474    /// Before the fix, extract_cell always inserted a space between any two MCID blocks,
1475    /// producing "Q (peu/d)" instead of the correct "Q(peu/d)".
1476    #[test]
1477    fn test_extract_cell_adjacent_mcid_spans_no_space() {
1478        use crate::layout::text_block::{Color, FontWeight};
1479
1480        let mut td = StructElem::new(StructType::TD);
1481        td.add_child(StructChild::MarkedContentRef {
1482            mcid: 1,
1483            page: 0,
1484            scope: crate::structure::McidScope::Page(0),
1485        });
1486        td.add_child(StructChild::MarkedContentRef {
1487            mcid: 2,
1488            page: 0,
1489            scope: crate::structure::McidScope::Page(0),
1490        });
1491        td.add_child(StructChild::MarkedContentRef {
1492            mcid: 3,
1493            page: 0,
1494            scope: crate::structure::McidScope::Page(0),
1495        });
1496        let mut tr = StructElem::new(StructType::TR);
1497        tr.add_child(StructChild::StructElem(Box::new(td)));
1498        let mut table_elem = StructElem::new(StructType::Table);
1499        table_elem.add_child(StructChild::StructElem(Box::new(tr)));
1500
1501        // Exact coordinates for the Q(peu/d) column header case:
1502        //   "Q"     x=345.79 w=8.22  end=354.01
1503        //   "("    x=353.83 w=10.56 end=364.39   gap=-0.18 (overlap → no space)
1504        //   "peu/d" x=364.39 w=25.24             gap=0.00  (touching → no space) ~keep
1505        let base = crate::layout::TextSpan {
1506            provenance: None,
1507            text_rise: 0.0,
1508            artifact_type: None,
1509            text: String::new(),
1510            bbox: Rect::new(0.0, 678.0, 0.0, 10.56),
1511            font_name: "Test".to_string(),
1512            font_size: 10.56,
1513            font_weight: FontWeight::Normal,
1514            is_italic: false,
1515            is_monospace: false,
1516            color: Color::black(),
1517            mcid: None,
1518            mcid_scope: None,
1519            sequence: 0,
1520            split_boundary_before: false,
1521            offset_semantic: false,
1522            char_spacing: 0.0,
1523            word_spacing: 0.0,
1524            horizontal_scaling: 1.0,
1525            primary_detected: false,
1526            char_widths: vec![],
1527            char_x_offsets: Vec::new(),
1528            heading_level: None,
1529            rotation_degrees: 0.0,
1530            wmode: 0,
1531            rtl_draw_logical: false,
1532            mirrored: false,
1533            page_rotation_applied: 0,
1534        };
1535        let spans = vec![
1536            crate::layout::TextSpan {
1537                text: "Q".into(),
1538                bbox: Rect::new(345.79, 678.0, 8.22, 10.56),
1539                mcid: Some(1),
1540                mcid_scope: None,
1541                ..base.clone()
1542            },
1543            crate::layout::TextSpan {
1544                text: "(".into(),
1545                bbox: Rect::new(353.83, 678.0, 10.56, 10.56),
1546                mcid: Some(2),
1547                mcid_scope: None,
1548                ..base.clone()
1549            },
1550            crate::layout::TextSpan {
1551                text: "peu/d".into(),
1552                bbox: Rect::new(364.39, 678.0, 25.24, 10.56),
1553                mcid: Some(3),
1554                mcid_scope: None,
1555                ..base.clone()
1556            },
1557        ];
1558
1559        let result = extract_table_from_spans(&table_elem, &spans).unwrap();
1560        assert_eq!(
1561            result.rows[0].cells[0].text, "Q(peu/d",
1562            "adjacent MCID spans must not get a space inserted between them"
1563        );
1564    }
1565
1566    /// Companion test: MCID spans on different lines (multi-line cell) DO get a space.
1567    #[test]
1568    fn test_extract_cell_multiline_mcid_spans_have_space() {
1569        let mut td = StructElem::new(StructType::TD);
1570        td.add_child(StructChild::MarkedContentRef {
1571            mcid: 1,
1572            page: 0,
1573            scope: crate::structure::McidScope::Page(0),
1574        });
1575        td.add_child(StructChild::MarkedContentRef {
1576            mcid: 2,
1577            page: 0,
1578            scope: crate::structure::McidScope::Page(0),
1579        });
1580        let mut tr = StructElem::new(StructType::TR);
1581        tr.add_child(StructChild::StructElem(Box::new(td)));
1582        let mut table_elem = StructElem::new(StructType::Table);
1583        table_elem.add_child(StructChild::StructElem(Box::new(tr)));
1584
1585        let base = crate::layout::TextSpan {
1586            provenance: None,
1587            text_rise: 0.0,
1588            artifact_type: None,
1589            text: String::new(),
1590            bbox: Rect::new(0.0, 0.0, 0.0, 12.0),
1591            font_name: "Test".to_string(),
1592            font_size: 12.0,
1593            font_weight: crate::layout::text_block::FontWeight::Normal,
1594            is_italic: false,
1595            is_monospace: false,
1596            color: crate::layout::text_block::Color::black(),
1597            mcid: None,
1598            mcid_scope: None,
1599            sequence: 0,
1600            split_boundary_before: false,
1601            offset_semantic: false,
1602            char_spacing: 0.0,
1603            word_spacing: 0.0,
1604            horizontal_scaling: 1.0,
1605            primary_detected: false,
1606            char_widths: vec![],
1607            char_x_offsets: Vec::new(),
1608            heading_level: None,
1609            rotation_degrees: 0.0,
1610            wmode: 0,
1611            rtl_draw_logical: false,
1612            mirrored: false,
1613            page_rotation_applied: 0,
1614        };
1615        // Line 1: "Hello" ends at x=100, y=200.  Line 2: "World" starts at x=10, y=188.
1616        // y_diff = 12 > line_h * 0.5 = 6 → different lines → space inserted. ~keep
1617        let spans = vec![
1618            crate::layout::TextSpan {
1619                text: "Hello".into(),
1620                bbox: Rect::new(10.0, 200.0, 90.0, 12.0),
1621                mcid: Some(1),
1622                mcid_scope: None,
1623                ..base.clone()
1624            },
1625            crate::layout::TextSpan {
1626                text: "World".into(),
1627                bbox: Rect::new(10.0, 188.0, 90.0, 12.0),
1628                mcid: Some(2),
1629                mcid_scope: None,
1630                ..base.clone()
1631            },
1632        ];
1633
1634        let result = extract_table_from_spans(&table_elem, &spans).unwrap();
1635        assert_eq!(result.rows[0].cells[0].text, "Hello World");
1636    }
1637
1638    /// The synthesized `cell.spans` on the tagged-PDF (MCID→TextBlock) path must
1639    /// carry per-block `font_weight`/`is_italic`, otherwise the markdown/HTML
1640    /// table renderers can't emit bold/italic markers and silently fall back to
1641    /// plain text. Also asserts the inter-line space is carried into the span
1642    /// text so renderers reconstructing from spans don't glue tokens across a
1643    /// wrapped line.
1644    #[test]
1645    fn test_extract_cell_spans_carry_bold_italic_and_spacing() {
1646        use crate::layout::text_block::{Color, FontWeight};
1647
1648        let mut td = StructElem::new(StructType::TD);
1649        td.add_child(StructChild::MarkedContentRef {
1650            mcid: 1,
1651            page: 0,
1652            scope: crate::structure::McidScope::Page(0),
1653        });
1654        td.add_child(StructChild::MarkedContentRef {
1655            mcid: 2,
1656            page: 0,
1657            scope: crate::structure::McidScope::Page(0),
1658        });
1659        let mut tr = StructElem::new(StructType::TR);
1660        tr.add_child(StructChild::StructElem(Box::new(td)));
1661        let mut table_elem = StructElem::new(StructType::Table);
1662        table_elem.add_child(StructChild::StructElem(Box::new(tr)));
1663
1664        let base = crate::layout::TextSpan {
1665            provenance: None,
1666            text_rise: 0.0,
1667            artifact_type: None,
1668            text: String::new(),
1669            bbox: Rect::new(0.0, 0.0, 0.0, 12.0),
1670            font_name: "Test".to_string(),
1671            font_size: 12.0,
1672            font_weight: FontWeight::Normal,
1673            is_italic: false,
1674            is_monospace: false,
1675            color: Color::black(),
1676            mcid: None,
1677            mcid_scope: None,
1678            sequence: 0,
1679            split_boundary_before: false,
1680            offset_semantic: false,
1681            char_spacing: 0.0,
1682            word_spacing: 0.0,
1683            horizontal_scaling: 1.0,
1684            primary_detected: false,
1685            char_widths: vec![],
1686            char_x_offsets: Vec::new(),
1687            heading_level: None,
1688            rotation_degrees: 0.0,
1689            wmode: 0,
1690            rtl_draw_logical: false,
1691            mirrored: false,
1692            page_rotation_applied: 0,
1693        };
1694        let spans = vec![
1695            crate::layout::TextSpan {
1696                text: "Bold".into(),
1697                bbox: Rect::new(10.0, 200.0, 40.0, 12.0),
1698                font_weight: FontWeight::Bold,
1699                mcid: Some(1),
1700                mcid_scope: None,
1701                ..base.clone()
1702            },
1703            crate::layout::TextSpan {
1704                text: "Italic".into(),
1705                bbox: Rect::new(10.0, 188.0, 40.0, 12.0),
1706                is_italic: true,
1707                mcid: Some(2),
1708                mcid_scope: None,
1709                ..base.clone()
1710            },
1711        ];
1712
1713        let result = extract_table_from_spans(&table_elem, &spans).unwrap();
1714        let cell = &result.rows[0].cells[0];
1715        assert_eq!(cell.spans.len(), 2, "both MCID blocks must yield a span");
1716        assert_eq!(cell.spans[0].text, "Bold");
1717        assert!(
1718            matches!(cell.spans[0].font_weight, FontWeight::Bold),
1719            "bold block must propagate FontWeight::Bold into the synthesized span"
1720        );
1721        assert!(!cell.spans[0].is_italic, "non-italic block must not be italic");
1722        assert!(
1723            matches!(cell.spans[1].font_weight, FontWeight::Normal),
1724            "non-bold block must stay FontWeight::Normal"
1725        );
1726        assert!(
1727            cell.spans[1].is_italic,
1728            "italic block must propagate is_italic into the synthesized span"
1729        );
1730        assert_eq!(
1731            cell.spans[1].text, " Italic",
1732            "wrapped-line span must carry the leading inter-block space (review #533)"
1733        );
1734    }
1735
1736    /// A figure a producer split across two marked-content runs must keep its
1737    /// own left-to-right order inside a right-to-left cell (UAX #9 class
1738    /// EN/AN); only the surrounding Arabic reverses. Blanket-reversing the
1739    /// whole cell reads "47.500" back as "7.5004", which changes the value
1740    /// rather than merely re-spacing it.
1741    #[test]
1742    fn rtl_table_cell_numeric_run_keeps_ltr_order() {
1743        // Visual layout, left to right on one line:
1744        //   تن   |  "4" "7.500"  |   حداقل
1745        //   x=5     x=30  x=37       x=75
1746        // Reading order is right to left, so حداقل comes first and تن last,
1747        // but the two numeral runs read "4" then "7.500" between them. ~keep
1748        let cell = rtl_cell_from(&[
1749            ("تن", 5.0, 20.0),
1750            ("4", 30.0, 7.0),
1751            ("7.500", 37.0, 33.0),
1752            ("حداقل", 75.0, 35.0),
1753        ]);
1754
1755        assert!(
1756            cell.text.contains("47.500"),
1757            "the split figure must survive intact; got {:?}",
1758            cell.text
1759        );
1760        assert!(
1761            !cell.text.contains("7.5004"),
1762            "numerals must not inherit the surrounding script's reversal; got {:?}",
1763            cell.text
1764        );
1765        let arabic_first = cell.text.find("حداقل").expect("Arabic run present");
1766        let arabic_last = cell.text.find("تن").expect("Arabic run present");
1767        assert!(
1768            arabic_first < arabic_last,
1769            "the surrounding Arabic must still read right to left; got {:?}",
1770            cell.text
1771        );
1772    }
1773
1774    /// The un-reversal is scoped to numerals: an all-Arabic cell keeps the
1775    /// plain right-to-left order the blanket reversal already produced.
1776    #[test]
1777    fn rtl_table_cell_without_numerals_is_unchanged() {
1778        let cell = rtl_cell_from(&[("تن", 5.0, 20.0), ("حداقل", 75.0, 35.0)]);
1779        assert_eq!(
1780            cell.text, "حداقل تن",
1781            "a cell with no numerals must read purely right to left"
1782        );
1783    }
1784
1785    /// Build a single-cell tagged table from `(text, x, width)` runs laid out
1786    /// on one line, and return the extracted cell.
1787    fn rtl_cell_from(runs: &[(&str, f32, f32)]) -> crate::structure::TableCell {
1788        use crate::layout::text_block::{Color, FontWeight};
1789
1790        let mut td = StructElem::new(StructType::TD);
1791        let mut spans = Vec::new();
1792        for (index, (text, x, width)) in runs.iter().enumerate() {
1793            let mcid = index as u32 + 1;
1794            td.add_child(StructChild::MarkedContentRef {
1795                mcid,
1796                page: 0,
1797                scope: crate::structure::McidScope::Page(0),
1798            });
1799            spans.push(crate::layout::TextSpan {
1800                provenance: None,
1801                text_rise: 0.0,
1802                artifact_type: None,
1803                text: (*text).into(),
1804                bbox: Rect::new(*x, 200.0, *width, 12.0),
1805                font_name: "Test".to_string(),
1806                font_size: 12.0,
1807                font_weight: FontWeight::Normal,
1808                is_italic: false,
1809                is_monospace: false,
1810                color: Color::black(),
1811                mcid: Some(mcid),
1812                mcid_scope: None,
1813                sequence: index,
1814                split_boundary_before: false,
1815                offset_semantic: false,
1816                char_spacing: 0.0,
1817                word_spacing: 0.0,
1818                horizontal_scaling: 1.0,
1819                primary_detected: false,
1820                char_widths: vec![],
1821                char_x_offsets: Vec::new(),
1822                heading_level: None,
1823                rotation_degrees: 0.0,
1824                wmode: 0,
1825                rtl_draw_logical: false,
1826                mirrored: false,
1827                page_rotation_applied: 0,
1828            });
1829        }
1830        let mut tr = StructElem::new(StructType::TR);
1831        tr.add_child(StructChild::StructElem(Box::new(td)));
1832        let mut table_elem = StructElem::new(StructType::Table);
1833        table_elem.add_child(StructChild::StructElem(Box::new(tr)));
1834
1835        let result = extract_table_from_spans(&table_elem, &spans).unwrap();
1836        result.rows[0].cells[0].clone()
1837    }
1838
1839    #[test]
1840    fn test_extract_cell_spans_carry_rotation_degrees() {
1841        use crate::layout::text_block::{Color, FontWeight};
1842
1843        let mut td = StructElem::new(StructType::TD);
1844        td.add_child(StructChild::MarkedContentRef {
1845            mcid: 1,
1846            page: 0,
1847            scope: crate::structure::McidScope::Page(0),
1848        });
1849        let mut tr = StructElem::new(StructType::TR);
1850        tr.add_child(StructChild::StructElem(Box::new(td)));
1851        let mut table_elem = StructElem::new(StructType::Table);
1852        table_elem.add_child(StructChild::StructElem(Box::new(tr)));
1853
1854        let span = crate::layout::TextSpan {
1855            provenance: None,
1856            text_rise: 0.0,
1857            mirrored: false,
1858            page_rotation_applied: 0,
1859            artifact_type: None,
1860            text: "Rotated".into(),
1861            bbox: Rect::new(10.0, 200.0, 40.0, 12.0),
1862            font_name: "Test".to_string(),
1863            font_size: 12.0,
1864            font_weight: FontWeight::Normal,
1865            is_italic: false,
1866            is_monospace: false,
1867            color: Color::black(),
1868            mcid: Some(1),
1869            mcid_scope: None,
1870            sequence: 0,
1871            split_boundary_before: false,
1872            offset_semantic: false,
1873            char_spacing: 0.0,
1874            word_spacing: 0.0,
1875            horizontal_scaling: 1.0,
1876            primary_detected: false,
1877            char_widths: vec![],
1878            char_x_offsets: Vec::new(),
1879            heading_level: None,
1880            rotation_degrees: 90.0,
1881            wmode: 0,
1882            rtl_draw_logical: false,
1883        };
1884
1885        let result = extract_table_from_spans(&table_elem, &[span]).unwrap();
1886        let cell = &result.rows[0].cells[0];
1887        assert_eq!(cell.spans.len(), 1);
1888        assert_eq!(
1889            cell.spans[0].rotation_degrees, 90.0,
1890            "cell span re-synthesis must carry the source block's rotation through, \
1891             not silently reset it to 0.0"
1892        );
1893    }
1894
1895    /// CJK + fullwidth operator with a gap that *exceeds* the 0.15em threshold must
1896    /// still suppress space insertion — this exercises the new CJK-suppression branch
1897    /// (the `test_extract_cell_adjacent_mcid_spans_no_space` test above only covers
1898    /// the gap ≤ threshold path, which never reaches this branch).
1899    #[test]
1900    fn test_extract_cell_cjk_fullwidth_gap_suppresses_space() {
1901        use crate::layout::text_block::{Color, FontWeight};
1902
1903        // Build: TD with three MCIDs: "数" (CJK), "≤" (math op), "量" (CJK)
1904        // Place them with a gap of 3.0 pt (> font_size * 0.15 = 1.5 for 10 pt font)
1905        // so the gap branch fires, then the CJK suppression should prevent a space. ~keep
1906        let mut td = StructElem::new(StructType::TD);
1907        td.add_child(StructChild::MarkedContentRef {
1908            mcid: 10,
1909            page: 0,
1910            scope: crate::structure::McidScope::Page(0),
1911        });
1912        td.add_child(StructChild::MarkedContentRef {
1913            mcid: 11,
1914            page: 0,
1915            scope: crate::structure::McidScope::Page(0),
1916        });
1917        td.add_child(StructChild::MarkedContentRef {
1918            mcid: 12,
1919            page: 0,
1920            scope: crate::structure::McidScope::Page(0),
1921        });
1922        let mut tr = StructElem::new(StructType::TR);
1923        tr.add_child(StructChild::StructElem(Box::new(td)));
1924        let mut table_elem = StructElem::new(StructType::Table);
1925        table_elem.add_child(StructChild::StructElem(Box::new(tr)));
1926
1927        let base = crate::layout::TextSpan {
1928            provenance: None,
1929            text_rise: 0.0,
1930            artifact_type: None,
1931            text: String::new(),
1932            bbox: Rect::new(0.0, 100.0, 0.0, 10.0),
1933            font_name: "Test".to_string(),
1934            font_size: 10.0,
1935            font_weight: FontWeight::Normal,
1936            is_italic: false,
1937            is_monospace: false,
1938            color: Color::black(),
1939            mcid: None,
1940            mcid_scope: None,
1941            sequence: 0,
1942            split_boundary_before: false,
1943            offset_semantic: false,
1944            char_spacing: 0.0,
1945            word_spacing: 0.0,
1946            horizontal_scaling: 1.0,
1947            primary_detected: false,
1948            char_widths: vec![],
1949            char_x_offsets: Vec::new(),
1950            heading_level: None,
1951            rotation_degrees: 0.0,
1952            wmode: 0,
1953            rtl_draw_logical: false,
1954            mirrored: false,
1955            page_rotation_applied: 0,
1956        };
1957        // "数" ends at x=10+10=20; "≤" starts at x=23 → gap=3.0 > 1.5 → gap branch fires
1958        // CJK("数")→math_op("≤") with at least one CJK side → suppress space
1959        // "≤" ends at x=23+10=33; "量" starts at x=36 → gap=3.0 → suppress space ~keep
1960        let spans = vec![
1961            crate::layout::TextSpan {
1962                text: "数".into(),
1963                bbox: Rect::new(10.0, 100.0, 10.0, 10.0),
1964                mcid: Some(10),
1965                mcid_scope: None,
1966                ..base.clone()
1967            },
1968            crate::layout::TextSpan {
1969                text: "≤".into(),
1970                bbox: Rect::new(23.0, 100.0, 10.0, 10.0),
1971                mcid: Some(11),
1972                mcid_scope: None,
1973                ..base.clone()
1974            },
1975            crate::layout::TextSpan {
1976                text: "量".into(),
1977                bbox: Rect::new(36.0, 100.0, 10.0, 10.0),
1978                mcid: Some(12),
1979                mcid_scope: None,
1980                ..base.clone()
1981            },
1982        ];
1983
1984        let result = extract_table_from_spans(&table_elem, &spans).unwrap();
1985        assert_eq!(
1986            result.rows[0].cells[0].text, "数≤量",
1987            "CJK + math-op + CJK with gap > 0.15em should not have spaces inserted: \
1988             got '{}'",
1989            result.rows[0].cells[0].text
1990        );
1991    }
1992
1993    /// Counterpart: Latin + Latin with a gap exceeding the threshold MUST insert a space.
1994    /// This guards that the CJK-suppression branch does not affect non-CJK pairs.
1995    #[test]
1996    fn test_extract_cell_latin_gap_inserts_space() {
1997        use crate::layout::text_block::{Color, FontWeight};
1998
1999        let mut td = StructElem::new(StructType::TD);
2000        td.add_child(StructChild::MarkedContentRef {
2001            mcid: 20,
2002            page: 0,
2003            scope: crate::structure::McidScope::Page(0),
2004        });
2005        td.add_child(StructChild::MarkedContentRef {
2006            mcid: 21,
2007            page: 0,
2008            scope: crate::structure::McidScope::Page(0),
2009        });
2010        let mut tr = StructElem::new(StructType::TR);
2011        tr.add_child(StructChild::StructElem(Box::new(td)));
2012        let mut table_elem = StructElem::new(StructType::Table);
2013        table_elem.add_child(StructChild::StructElem(Box::new(tr)));
2014
2015        let base = crate::layout::TextSpan {
2016            provenance: None,
2017            text_rise: 0.0,
2018            artifact_type: None,
2019            text: String::new(),
2020            bbox: Rect::new(0.0, 100.0, 0.0, 10.0),
2021            font_name: "Test".to_string(),
2022            font_size: 10.0,
2023            font_weight: FontWeight::Normal,
2024            is_italic: false,
2025            is_monospace: false,
2026            color: Color::black(),
2027            mcid: None,
2028            mcid_scope: None,
2029            sequence: 0,
2030            split_boundary_before: false,
2031            offset_semantic: false,
2032            char_spacing: 0.0,
2033            word_spacing: 0.0,
2034            horizontal_scaling: 1.0,
2035            primary_detected: false,
2036            char_widths: vec![],
2037            char_x_offsets: Vec::new(),
2038            heading_level: None,
2039            rotation_degrees: 0.0,
2040            wmode: 0,
2041            rtl_draw_logical: false,
2042            mirrored: false,
2043            page_rotation_applied: 0,
2044        };
2045        // "Hello" ends at 50; "world" starts at 53 → gap=3.0 > 1.5 → space inserted
2046        // Neither side is CJK, so the CJK suppression must NOT fire. ~keep
2047        let spans = vec![
2048            crate::layout::TextSpan {
2049                text: "Hello".into(),
2050                bbox: Rect::new(0.0, 100.0, 50.0, 10.0),
2051                mcid: Some(20),
2052                mcid_scope: None,
2053                ..base.clone()
2054            },
2055            crate::layout::TextSpan {
2056                text: "world".into(),
2057                bbox: Rect::new(53.0, 100.0, 30.0, 10.0),
2058                mcid: Some(21),
2059                mcid_scope: None,
2060                ..base.clone()
2061            },
2062        ];
2063
2064        let result = extract_table_from_spans(&table_elem, &spans).unwrap();
2065        assert_eq!(
2066            result.rows[0].cells[0].text, "Hello world",
2067            "Latin→Latin with gap > 0.15em should insert a space: got '{}'",
2068            result.rows[0].cells[0].text
2069        );
2070    }
2071}