Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 117 additions & 18 deletions crates/liteparse/src/markdown_layout/tables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2524,23 +2524,44 @@ fn extract_h_v_segments(graphics: &[GraphicPrimitive]) -> (Vec<HSeg>, Vec<VSeg>)
(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<HSeg>) -> Vec<HSeg> {
if segs.is_empty() {
return segs;
}
segs.sort_by(|a, b| a.y.total_cmp(&b.y));
let mut out: Vec<HSeg> = 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
}
Expand All @@ -2551,15 +2572,30 @@ fn cluster_v_segments(mut segs: Vec<VSeg>) -> Vec<VSeg> {
}
segs.sort_by(|a, b| a.x.total_cmp(&b.x));
let mut out: Vec<VSeg> = 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
}
Expand Down Expand Up @@ -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
Expand Down
103 changes: 100 additions & 3 deletions crates/liteparse/src/projection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2889,6 +2889,7 @@ pub fn project_pages_to_grid(pages: Vec<Page>) -> Vec<ParsedPage> {
page.page_width,
page.page_height,
&obstacles,
&table_rects,
);
ParsedPage {
page_number: page.page_number,
Expand Down Expand Up @@ -4597,6 +4598,7 @@ pub(crate) fn build_projected_lines(
page_width: f32,
page_height: f32,
figures: &[Rect],
table_rects: &[Rect],
) -> (Vec<ProjectedLine>, Region) {
if items.is_empty() {
return (Vec::new(), Region::default());
Expand All @@ -4620,6 +4622,7 @@ pub(crate) fn build_projected_lines(
.collect();

let mut out: Vec<ProjectedLine> = Vec::new();
let mut out_table_regions: Vec<Option<usize>> = 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
Expand Down Expand Up @@ -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(|&current_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,
&current,
Expand All @@ -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,
&current,
Expand All @@ -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
Expand Down Expand Up @@ -4730,6 +4747,52 @@ pub(crate) fn build_projected_lines(
(out, region)
}

fn table_region_for_line_group(rects: &[Rect], item: &ProjectedTextItem) -> Option<usize> {
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<usize>],
rects: &[Rect],
) {
if rects.len() < 2 {
return;
}
let positions: Vec<usize> = 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],
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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!(
Expand Down