diff --git a/crates/liteparse/src/markdown_layout/tables.rs b/crates/liteparse/src/markdown_layout/tables.rs index b0ca3b27..0f63dff4 100644 --- a/crates/liteparse/src/markdown_layout/tables.rs +++ b/crates/liteparse/src/markdown_layout/tables.rs @@ -2524,23 +2524,44 @@ fn extract_h_v_segments(graphics: &[GraphicPrimitive]) -> (Vec, Vec) (hs, vs) } -/// Cluster H segments sharing a y-coordinate (within `TABLE_GRID_CLUSTER_PT`) -/// into a single wider grid line whose x-extent is the union of the inputs. +fn ranges_overlap_or_nearly_touch(a_min: f32, a_max: f32, b_min: f32, b_max: f32) -> bool { + a_min <= b_max + TABLE_CROSS_TOLERANCE_PT && b_min <= a_max + TABLE_CROSS_TOLERANCE_PT +} + +/// Cluster connected H segments sharing a y-coordinate (within +/// `TABLE_GRID_CLUSTER_PT`) into a single wider grid line whose x-extent is +/// the union of the inputs. Collinear segments separated by whitespace stay +/// distinct so unrelated side-by-side tables do not become one component. fn cluster_h_segments(mut segs: Vec) -> Vec { if segs.is_empty() { return segs; } segs.sort_by(|a, b| a.y.total_cmp(&b.y)); let mut out: Vec = Vec::with_capacity(segs.len()); - for seg in segs { - if let Some(last) = out.last_mut() - && (last.y - seg.y).abs() <= TABLE_GRID_CLUSTER_PT - { - last.x_min = last.x_min.min(seg.x_min); - last.x_max = last.x_max.max(seg.x_max); - continue; + let mut band_start = 0; + while band_start < segs.len() { + let band_y = segs[band_start].y; + let mut band_end = band_start + 1; + while band_end < segs.len() && (segs[band_end].y - band_y).abs() <= TABLE_GRID_CLUSTER_PT { + band_end += 1; + } + + let band = &mut segs[band_start..band_end]; + band.sort_by(|a, b| a.x_min.total_cmp(&b.x_min)); + let mut current = band[0]; + current.y = band_y; + for seg in &band[1..] { + if ranges_overlap_or_nearly_touch(current.x_min, current.x_max, seg.x_min, seg.x_max) { + current.x_min = current.x_min.min(seg.x_min); + current.x_max = current.x_max.max(seg.x_max); + } else { + out.push(current); + current = *seg; + current.y = band_y; + } } - out.push(seg); + out.push(current); + band_start = band_end; } out } @@ -2551,15 +2572,30 @@ fn cluster_v_segments(mut segs: Vec) -> Vec { } segs.sort_by(|a, b| a.x.total_cmp(&b.x)); let mut out: Vec = Vec::with_capacity(segs.len()); - for seg in segs { - if let Some(last) = out.last_mut() - && (last.x - seg.x).abs() <= TABLE_GRID_CLUSTER_PT - { - last.y_min = last.y_min.min(seg.y_min); - last.y_max = last.y_max.max(seg.y_max); - continue; + let mut band_start = 0; + while band_start < segs.len() { + let band_x = segs[band_start].x; + let mut band_end = band_start + 1; + while band_end < segs.len() && (segs[band_end].x - band_x).abs() <= TABLE_GRID_CLUSTER_PT { + band_end += 1; } - out.push(seg); + + let band = &mut segs[band_start..band_end]; + band.sort_by(|a, b| a.y_min.total_cmp(&b.y_min)); + let mut current = band[0]; + current.x = band_x; + for seg in &band[1..] { + if ranges_overlap_or_nearly_touch(current.y_min, current.y_max, seg.y_min, seg.y_max) { + current.y_min = current.y_min.min(seg.y_min); + current.y_max = current.y_max.max(seg.y_max); + } else { + out.push(current); + current = *seg; + current.x = band_x; + } + } + out.push(current); + band_start = band_end; } out } @@ -3966,6 +4002,69 @@ mod tests { assert_eq!(runs.len(), 1); } + #[test] + fn ruled_tables_with_shared_columns_remain_separate_components() { + // Two vertically separated 2x2 tables intentionally reuse the same + // x coordinates. Collinear border segments must not be extended + // through the whitespace between the tables, or both grids collapse + // into one table with a phantom separator row. + let mut graphics = Vec::new(); + for top in [100.0_f32, 300.0] { + for y in [top, top + 40.0, top + 80.0] { + graphics.push(stroke(50.0, y, 250.0, y, 0.5)); + } + for x in [50.0_f32, 150.0, 250.0] { + graphics.push(stroke(x, top, x, top + 80.0, 0.5)); + } + } + + let lines = vec![ + line("a1", 90.0, 115.0, 10.0, 10.0), + line("b1", 190.0, 115.0, 10.0, 10.0), + line("c1", 90.0, 155.0, 10.0, 10.0), + line("d1", 190.0, 155.0, 10.0, 10.0), + line("a2", 90.0, 315.0, 10.0, 10.0), + line("b2", 190.0, 315.0, 10.0, 10.0), + line("c2", 90.0, 355.0, 10.0, 10.0), + line("d2", 190.0, 355.0, 10.0, 10.0), + ]; + + let runs = detect_ruled_tables(&lines, &graphics, 612.0, 792.0); + assert_eq!(runs.len(), 2, "expected two separate tables, got {runs:?}"); + assert!( + runs.iter() + .all(|run| { matches!(&run.block, Block::Table { rows, .. } if rows.len() == 2) }) + ); + } + + #[test] + fn ruled_grids_with_shared_rows_remain_separate_components() { + // Two side-by-side 2x2 grids intentionally reuse the same y + // coordinates. Their horizontal borders must not be extended through + // the whitespace between the grids. + let mut graphics = Vec::new(); + for left in [50.0_f32, 350.0] { + for y in [100.0_f32, 140.0, 180.0] { + graphics.push(stroke(left, y, left + 200.0, y, 0.5)); + } + for x in [left, left + 100.0, left + 200.0] { + graphics.push(stroke(x, 100.0, x, 180.0, 0.5)); + } + } + + let (hs, vs) = extract_h_v_segments(&graphics); + let hs = cluster_h_segments(hs); + let vs = cluster_v_segments(vs); + let components = find_grid_components(&hs, &vs); + + assert_eq!(components.len(), 2, "expected two separate grid components"); + assert!( + components + .iter() + .all(|(h_indices, v_indices)| h_indices.len() == 3 && v_indices.len() == 3) + ); + } + #[test] fn ruled_table_page_border_rejected() { // Single big rect covering ~the whole page → should NOT be treated as a diff --git a/crates/liteparse/src/projection.rs b/crates/liteparse/src/projection.rs index 6c22e020..17fa868c 100644 --- a/crates/liteparse/src/projection.rs +++ b/crates/liteparse/src/projection.rs @@ -2889,6 +2889,7 @@ pub fn project_pages_to_grid(pages: Vec) -> Vec { page.page_width, page.page_height, &obstacles, + &table_rects, ); ParsedPage { page_number: page.page_number, @@ -4597,6 +4598,7 @@ pub(crate) fn build_projected_lines( page_width: f32, page_height: f32, figures: &[Rect], + table_rects: &[Rect], ) -> (Vec, Region) { if items.is_empty() { return (Vec::new(), Region::default()); @@ -4620,6 +4622,7 @@ pub(crate) fn build_projected_lines( .collect(); let mut out: Vec = Vec::new(); + let mut out_table_regions: Vec> = Vec::new(); for (path, indices) in leaves { // Sort within the leaf by y, tie-break by x. `build_one_line` re-sorts // by x for left→right concatenation; the y-banding loop here only @@ -4672,11 +4675,18 @@ pub(crate) fn build_projected_lines( let height_mismatch = raw_h > Y_BAND_HEIGHT_CAP && raw_h > current_h * 2.0; let tol_factor = if height_mismatch { 0.3 } else { 0.5 }; let same = (y - current_y).abs() < current_h.min(h) * tol_factor; - if same { + let item_region = table_region_for_line_group(table_rects, &items[idx]); + let crosses_independent_tables = current.iter().any(|¤t_idx| { + let current_region = table_region_for_line_group(table_rects, &items[current_idx]); + matches!((current_region, item_region), (Some(a), Some(b)) if a != b) + }); + if same && !crosses_independent_tables { current.push(idx); current_y = current_y.min(y); current_h = current_h.max(h); } else { + out_table_regions + .push(table_region_for_line_group(table_rects, &items[current[0]])); out.push(build_one_line( items, ¤t, @@ -4689,6 +4699,7 @@ pub(crate) fn build_projected_lines( } } if !current.is_empty() { + out_table_regions.push(table_region_for_line_group(table_rects, &items[current[0]])); out.push(build_one_line( items, ¤t, @@ -4698,6 +4709,12 @@ pub(crate) fn build_projected_lines( } } + // Same-y lines from side-by-side ruled tables alternate in the natural + // y/x order. Keep the table-region lines contiguous so ruled-table + // detection can consume each independent grid without seeing the other + // table's text as overhang. + reorder_independent_table_lines(&mut out, &out_table_regions, table_rects); + // Normalize `indent_x` to be leaf-relative: subtract each leaf's minimum // line bbox.x from every line in that leaf. This way list-nesting and // paragraph-indent comparisons in `markdown_layout.rs` use offsets from @@ -4730,6 +4747,52 @@ pub(crate) fn build_projected_lines( (out, region) } +fn table_region_for_line_group(rects: &[Rect], item: &ProjectedTextItem) -> Option { + let cx = item.orig_x + item.orig_width * 0.5; + let cy = item.orig_y + item.orig_height * 0.5; + rects + .iter() + .position(|r| cx >= r.x && cx <= r.x + r.width && cy >= r.y && cy <= r.y + r.height) +} + +fn reorder_independent_table_lines( + lines: &mut [ProjectedLine], + line_regions: &[Option], + rects: &[Rect], +) { + if rects.len() < 2 { + return; + } + let positions: Vec = line_regions + .iter() + .enumerate() + .filter_map(|(i, region)| region.map(|_| i)) + .collect(); + if positions.len() < 2 { + return; + } + let mut table_lines: Vec<(usize, ProjectedLine)> = positions + .iter() + .map(|&i| (line_regions[i].unwrap(), lines[i].clone())) + .collect(); + table_lines.sort_by(|(a_region, a), (b_region, b)| { + let ar = &rects[*a_region]; + let br = &rects[*b_region]; + let overlap_y = ar.y.max(br.y) < (ar.y + ar.height).min(br.y + br.height); + let region_order = if overlap_y { + ar.x.total_cmp(&br.x) + } else { + ar.y.total_cmp(&br.y).then(ar.x.total_cmp(&br.x)) + }; + region_order + .then(a.bbox.y.total_cmp(&b.bbox.y)) + .then(a.bbox.x.total_cmp(&b.bbox.x)) + }); + for (position, (_, line)) in positions.into_iter().zip(table_lines) { + lines[position] = line; + } +} + fn build_one_line( items: &[ProjectedTextItem], idxs: &[usize], @@ -5105,7 +5168,7 @@ mod tests { items.push(item_at("L", 50.0, y, 200.0, 10.0)); items.push(item_at("R", 350.0, y, 200.0, 10.0)); } - let (lines, _region) = build_projected_lines(&items, 612.0, 792.0, &[]); + let (lines, _region) = build_projected_lines(&items, 612.0, 792.0, &[], &[]); // Left col's 5 lines come first (path [0, ...]), then right col's 5 // (path [1, ...]). assert_eq!(lines.len(), 10); @@ -5117,6 +5180,40 @@ mod tests { } } + #[test] + fn projected_lines_do_not_span_independent_side_by_side_tables() { + let table_rects = [ + Rect { + x: 40.0, + y: 100.0, + width: 200.0, + height: 100.0, + }, + Rect { + x: 360.0, + y: 100.0, + width: 200.0, + height: 100.0, + }, + ]; + let items = vec![ + item_at("left-a", 50.0, 120.0, 50.0, 10.0), + item_at("left-b", 130.0, 120.0, 50.0, 10.0), + item_at("right-a", 370.0, 120.0, 50.0, 10.0), + item_at("right-b", 450.0, 120.0, 50.0, 10.0), + item_at("left-c", 50.0, 150.0, 50.0, 10.0), + item_at("left-d", 130.0, 150.0, 50.0, 10.0), + item_at("right-c", 370.0, 150.0, 50.0, 10.0), + item_at("right-d", 450.0, 150.0, 50.0, 10.0), + ]; + let (lines, _region) = build_projected_lines(&items, 612.0, 792.0, &[], &table_rects); + assert_eq!(lines.len(), 4); + assert_eq!(lines[0].text, "left-a left-b"); + assert_eq!(lines[1].text, "left-c left-d"); + assert_eq!(lines[2].text, "right-a right-b"); + assert_eq!(lines[3].text, "right-c right-d"); + } + #[test] fn banner_cut_isolates_full_width_title_above_two_columns() { // Layout: a centered wide title at the top, clear gap, then 2-column @@ -5138,7 +5235,7 @@ mod tests { items.push(item_at("left side text", 50.0, y, 200.0, 10.0)); items.push(item_at("right side text", 350.0, y, 200.0, 10.0)); } - let (lines, _region) = build_projected_lines(&items, 612.0, 792.0, &[]); + let (lines, _region) = build_projected_lines(&items, 612.0, 792.0, &[], &[]); // First line (in pre-order) must be the title. let first = lines.first().expect("at least one line"); assert!(