diff --git a/crates/liteparse/src/markdown_layout/tables.rs b/crates/liteparse/src/markdown_layout/tables.rs index dbd6413b..7fa0ff8a 100644 --- a/crates/liteparse/src/markdown_layout/tables.rs +++ b/crates/liteparse/src/markdown_layout/tables.rs @@ -3089,23 +3089,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 } @@ -5601,6 +5622,34 @@ mod tests { assert_eq!(runs.len(), 1); } + #[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 2690dcae..f2d75ca7 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()); @@ -4619,7 +4621,20 @@ pub(crate) fn build_projected_lines( .cloned() .collect(); - let mut out: Vec = Vec::new(); + // Ruled-table ownership per item, resolved once up front. The y-banding + // loop below consults it for every item, and rescanning `table_rects` + // there would make line construction quadratic in the size of a y-band. + let item_regions: Vec> = if table_rects.is_empty() { + Vec::new() + } else { + items + .iter() + .map(|item| table_region_for_item(table_rects, item)) + .collect() + }; + let region_of = |index: usize| item_regions.get(index).copied().flatten(); + + let mut out: 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 @@ -4636,6 +4651,8 @@ pub(crate) fn build_projected_lines( let mut current: Vec = Vec::new(); let mut current_y: f32 = 0.0; let mut current_h: f32 = 0.0; + let mut current_region: Option = None; + let mut current_unowned = false; // PDFium occasionally reports anomalously large item heights (e.g. // 56pt for a single-word run whose real glyph height is ~13pt) when // the font's bounding box / line-height is baked into the text-matrix @@ -4652,6 +4669,8 @@ pub(crate) fn build_projected_lines( current.push(idx); current_y = y; current_h = h; + current_region = region_of(idx); + current_unowned = current_region.is_none(); continue; } // Use the SMALLER of the two heights for the y-band tolerance — @@ -4672,32 +4691,47 @@ 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 = region_of(idx); + let crosses_independent_tables = item_region + .is_some_and(|next| current_region.is_some_and(|current| current != next)); + if same && !crosses_independent_tables { current.push(idx); current_y = current_y.min(y); current_h = current_h.max(h); + match item_region { + Some(region) => current_region = Some(region), + None => current_unowned = true, + } } else { - out.push(build_one_line( - items, - ¤t, - path.clone(), - &heading_excl_figures, - )); + out.push(TableOwnedLine { + line: build_one_line(items, ¤t, path.clone(), &heading_excl_figures), + region: if current_unowned { + None + } else { + current_region + }, + }); current = vec![idx]; current_y = y; current_h = h; + current_region = item_region; + current_unowned = item_region.is_none(); } } if !current.is_empty() { - out.push(build_one_line( - items, - ¤t, - path.clone(), - &heading_excl_figures, - )); + out.push(TableOwnedLine { + line: build_one_line(items, ¤t, path.clone(), &heading_excl_figures), + region: if current_unowned { + None + } else { + current_region + }, + }); } } + reorder_independent_table_lines(&mut out, 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 @@ -4707,7 +4741,8 @@ pub(crate) fn build_projected_lines( { use std::collections::HashMap; let mut leaf_min: HashMap, f32> = HashMap::new(); - for line in &out { + for owned in &out { + let line = &owned.line; let e = leaf_min .entry(line.region_path.clone()) .or_insert(f32::INFINITY); @@ -4715,7 +4750,8 @@ pub(crate) fn build_projected_lines( *e = line.indent_x; } } - for line in &mut out { + for owned in &mut out { + let line = &mut owned.line; if let Some(min) = leaf_min.get(&line.region_path) && min.is_finite() { @@ -4727,7 +4763,173 @@ pub(crate) fn build_projected_lines( } } - (out, region) + (out.into_iter().map(|owned| owned.line).collect(), region) +} + +struct TableOwnedLine { + line: ProjectedLine, + region: Option, +} + +/// Return the ruled-table region that owns an item. Besides text inside the +/// grid, include a short label immediately above it; table captions and +/// section headings commonly sit just outside the top border. A label that +/// overlaps multiple tables remains page-spanning and is not assigned to +/// either one. +fn table_region_for_item(rects: &[Rect], item: &ProjectedTextItem) -> Option { + let item_left = item.orig_x; + let item_right = item.orig_x + item.orig_width; + let item_top = item.orig_y; + let item_bottom = item.orig_y + item.orig_height; + let nearby_above = |rect: &Rect| { + let gap = rect.y - item_bottom; + gap >= -TABLE_LABEL_OVERLAP_PT && gap <= item.orig_height.max(TABLE_LABEL_GAP_PT) + }; + + let mut matches = rects.iter().enumerate().filter_map(|(index, rect)| { + let overlap_x = (item_right.min(rect.x + rect.width) - item_left.max(rect.x)).max(0.0); + let horizontally_owned = item.orig_width > 0.0 && overlap_x / item.orig_width >= 0.5; + let inside_y = item_top <= rect.y + rect.height && item_bottom >= rect.y; + (horizontally_owned && (inside_y || nearby_above(rect))).then_some(index) + }); + + let first = matches.next()?; + matches.next().is_none().then_some(first) +} + +const TABLE_LABEL_GAP_PT: f32 = 12.0; +const TABLE_LABEL_OVERLAP_PT: f32 = 2.0; + +/// Same-y rows from side-by-side grids naturally alternate left/right. Group +/// lines by their owning grid so the table detector sees one complete table +/// at a time. +fn reorder_independent_table_lines(lines: &mut [TableOwnedLine], rects: &[Rect]) { + if rects.len() < 2 { + return; + } + + let Some(region_ranks) = table_region_ranks(rects) else { + return; + }; + + // Reorder one xy-cut leaf at a time. `markdown_layout` recovers regions by + // scanning for maximal runs of equal `region_path` (`classify.rs`), so + // carrying a line across a leaf boundary would shatter that leaf into + // phantom regions and misalign the per-region table runs keyed off it. + let mut start = 0; + while start < lines.len() { + let mut end = start + 1; + while end < lines.len() && lines[end].line.region_path == lines[start].line.region_path { + end += 1; + } + reorder_leaf_table_lines(&mut lines[start..end], ®ion_ranks); + start = end; + } +} + +/// Sort one leaf's table-owned lines into whole-table order. +fn reorder_leaf_table_lines(lines: &mut [TableOwnedLine], region_ranks: &[usize]) { + let Some(first) = lines.iter().position(|owned| owned.region.is_some()) else { + return; + }; + let last = lines + .iter() + .rposition(|owned| owned.region.is_some()) + .unwrap_or(first); + + let span = &mut lines[first..=last]; + if span.iter().any(|owned| owned.region.is_none()) { + return; + } + + span.sort_by(|left, right| { + let (Some(left_region), Some(right_region)) = (left.region, right.region) else { + return std::cmp::Ordering::Equal; + }; + region_ranks[left_region] + .cmp(®ion_ranks[right_region]) + .then(left.line.bbox.y.total_cmp(&right.line.bbox.y)) + .then(left.line.bbox.x.total_cmp(&right.line.bbox.x)) + }); +} + +/// Produce a stable page-reading rank for every table rectangle. Rectangles +/// sharing a common vertical interval form one side-by-side band and sort by +/// x; separate bands sort top-to-bottom. `None` means there is no side-by-side +/// relationship, so projection order does not need rewriting. +fn table_region_ranks(rects: &[Rect]) -> Option> { + struct Band { + top: f32, + common_top: f32, + common_bottom: f32, + regions: Vec, + } + + let mut by_y: Vec = (0..rects.len()).collect(); + by_y.sort_by(|&left, &right| { + rects[left] + .y + .total_cmp(&rects[right].y) + .then(rects[left].x.total_cmp(&rects[right].x)) + }); + let mut bands: Vec = Vec::new(); + for region in by_y { + let rect = &rects[region]; + let bottom = rect.y + rect.height; + if let Some(band) = bands + .iter_mut() + .find(|band| rect.y < band.common_bottom && bottom > band.common_top) + { + band.common_top = band.common_top.max(rect.y); + band.common_bottom = band.common_bottom.min(bottom); + band.regions.push(region); + } else { + bands.push(Band { + top: rect.y, + common_top: rect.y, + common_bottom: bottom, + regions: vec![region], + }); + } + } + + let has_side_by_side = bands.iter().any(|band| { + band.regions.iter().enumerate().any(|(position, &left)| { + band.regions[position + 1..].iter().any(|&right| { + rects[left].x + rects[left].width <= rects[right].x + || rects[right].x + rects[right].width <= rects[left].x + }) + }) + }); + if !has_side_by_side { + return None; + } + + bands.sort_by(|left, right| left.top.total_cmp(&right.top)); + let mut ordered = Vec::with_capacity(rects.len()); + for band in &mut bands { + band.regions.sort_by(|&left, &right| { + rects[left] + .x + .total_cmp(&rects[right].x) + .then(rects[left].y.total_cmp(&rects[right].y)) + }); + ordered.extend(band.regions.iter().copied()); + } + + let mut ranks = vec![0; rects.len()]; + for (rank, region) in ordered.into_iter().enumerate() { + ranks[region] = rank; + } + Some(ranks) +} + +#[cfg(test)] +fn regions_in_rank_order(rects: &[Rect]) -> Vec { + let ranks = table_region_ranks(rects).expect("side-by-side regions"); + let mut regions: Vec = (0..rects.len()).collect(); + regions.sort_by_key(|®ion| ranks[region]); + regions } fn build_one_line( @@ -5073,6 +5275,40 @@ mod tests { } } + fn table_rect(x: f32, y: f32, width: f32, height: f32) -> Rect { + Rect { + x, + y, + width, + height, + } + } + + #[test] + fn table_region_ranks_order_two_side_by_side_bands_top_to_bottom() { + let rects = [ + table_rect(400.0, 300.0, 180.0, 80.0), + table_rect(40.0, 100.0, 180.0, 80.0), + table_rect(40.0, 300.0, 180.0, 80.0), + table_rect(400.0, 100.0, 180.0, 80.0), + ]; + + assert_eq!(regions_in_rank_order(&rects), vec![1, 3, 2, 0]); + } + + #[test] + fn table_region_ranks_do_not_bridge_non_overlapping_outer_tables() { + // A overlaps B and B overlaps C, but A and C only touch. Treating + // overlap as a transitive relation would order A/C by x before B. + let rects = [ + table_rect(40.0, 0.0, 180.0, 100.0), + table_rect(400.0, 50.0, 180.0, 100.0), + table_rect(40.0, 100.0, 180.0, 100.0), + ]; + + assert_eq!(regions_in_rank_order(&rects), vec![0, 1, 2]); + } + #[test] fn xy_cut_finds_column_gutter_on_two_column_layout() { // Two columns, 50pt-wide gutter centered at x=300. Each column has @@ -5126,7 +5362,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); @@ -5159,7 +5395,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!( diff --git a/crates/liteparse/tests/side_by_side_ruled_tables.rs b/crates/liteparse/tests/side_by_side_ruled_tables.rs new file mode 100644 index 00000000..07c5a163 --- /dev/null +++ b/crates/liteparse/tests/side_by_side_ruled_tables.rs @@ -0,0 +1,393 @@ +use liteparse::config::ImageMode; +use liteparse::output::markdown::format_markdown; +use liteparse::projection::project_pages_to_grid; +use liteparse::types::{GraphicPrimitive, Page, TextItem}; + +fn text_item(text: &str, x: f32, y: f32, width: f32, size: f32, bold: bool) -> TextItem { + TextItem { + text: text.to_string(), + x, + y, + width, + height: size * 1.116, + font_name: Some(if bold { + "LiberationSans-Bold".to_string() + } else { + "LiberationSans".to_string() + }), + font_size: Some(size), + font_height: Some(size), + ..TextItem::default() + } +} + +fn stroke(x1: f32, y1: f32, x2: f32, y2: f32) -> GraphicPrimitive { + GraphicPrimitive::Stroke { + x1, + y1, + x2, + y2, + color: Some("ff000000".to_string()), + width: 0.5, + } +} + +fn ruled_grid(xs: &[f32], ys: &[f32]) -> Vec { + let mut graphics = Vec::new(); + for &y in ys { + graphics.push(stroke(xs[0], y, *xs.last().unwrap(), y)); + } + for &x in xs { + graphics.push(stroke(x, ys[0], x, *ys.last().unwrap())); + } + graphics +} + +fn markdown_for(text_items: Vec, graphics: Vec) -> String { + let pages = project_pages_to_grid(vec![Page { + page_number: 1, + page_width: 792.0, + page_height: 612.0, + content_bounds: None, + text_items, + graphics, + vector_graphics: None, + struct_nodes: Vec::new(), + image_refs: Vec::new(), + annotations: None, + form_fields: None, + structure_tree: None, + }]); + format_markdown(&pages, &[], ImageMode::Off) +} + +#[test] +fn side_by_side_ruled_tables_keep_their_own_headings_in_markdown() { + let mut text_items = vec![ + text_item("Public Release Reference", 274.4, 50.4, 243.26, 20.0, true), + text_item( + "A public, synthetic document for evaluating side-by-side table extraction", + 237.45, + 78.7, + 317.13, + 10.0, + false, + ), + text_item( + "This document contains generic release-planning examples only.", + 50.5, + 105.35, + 350.0, + 10.0, + false, + ), + text_item("Release channels", 50.5, 129.99, 100.57, 12.0, true), + text_item("Support windows", 406.9, 130.99, 99.88, 12.0, true), + ]; + + for (y, values) in [ + (154.655, ["Channel", "Status", "Owner"]), + (174.705, ["Stable", "Ready", "Team A"]), + (194.755, ["Beta", "Testing", "Team B"]), + (214.805, ["Nightly", "Active", "Team C"]), + ] { + for ((text, x), width) in values + .into_iter() + .zip([50.5, 118.5, 180.5]) + .zip([35.46, 29.5, 32.0]) + { + text_items.push(text_item(text, x, y, width, 9.0, y == 154.655)); + } + } + + for (y, values) in [ + (155.655, ["Region", "Window", "Contact"]), + (175.705, ["East", "Morning", "Desk 1"]), + (195.755, ["West", "Afternoon", "Desk 2"]), + (215.805, ["Central", "Evening", "Desk 3"]), + ] { + for ((text, x), width) in values + .into_iter() + .zip([406.9, 474.9, 536.9]) + .zip([35.0, 39.0, 34.0]) + { + text_items.push(text_item(text, x, y, width, 9.0, y == 155.655)); + } + } + + let mut graphics = ruled_grid( + &[44.0, 112.0, 174.0, 236.0], + &[148.0, 168.0, 188.0, 208.0, 228.0], + ); + graphics.extend(ruled_grid( + &[400.0, 468.0, 530.0, 594.0], + &[149.0, 169.0, 189.0, 209.0, 229.0], + )); + + let markdown = markdown_for(text_items, graphics); + let expected = "# Public Release Reference\n\n\ +A public, synthetic document for evaluating side-by-side table extraction\n\n\ +This document contains generic release-planning examples only.\n\n\ +## Release channels\n\n\ +| Channel | Status | Owner |\n\ +|---|---|---|\n\ +| Stable | Ready | Team A |\n\ +| Beta | Testing | Team B |\n\ +| Nightly | Active | Team C |\n\n\ +## Support windows\n\n\ +| Region | Window | Contact |\n\ +|---|---|---|\n\ +| East | Morning | Desk 1 |\n\ +| West | Afternoon | Desk 2 |\n\ +| Central | Evening | Desk 3 |"; + + assert_eq!(markdown, expected); +} + +#[test] +fn one_wide_ruled_table_remains_one_table() { + let mut text_items = vec![text_item( + "Quarterly schedule", + 50.0, + 80.0, + 130.0, + 14.0, + true, + )]; + for (y, values) in [ + (124.0, ["Quarter", "Status", "Owner"]), + (148.0, ["Q1", "Ready", "Team A"]), + (172.0, ["Q2", "Testing", "Team B"]), + ] { + for ((text, x), width) in values + .into_iter() + .zip([56.0, 260.0, 464.0]) + .zip([60.0, 70.0, 70.0]) + { + text_items.push(text_item(text, x, y, width, 10.0, y == 124.0)); + } + } + let graphics = ruled_grid(&[50.0, 250.0, 454.0, 660.0], &[116.0, 140.0, 164.0, 188.0]); + + let markdown = markdown_for(text_items, graphics); + let expected = "# Quarterly schedule\n\n\ +---\n\n\ +| Quarter | Status | Owner |\n\ +|---|---|---|\n\ +| Q1 | Ready | Team A |\n\ +| Q2 | Testing | Team B |"; + + assert_eq!(markdown, expected); +} + +#[test] +fn vertically_stacked_ruled_tables_keep_top_to_bottom_order() { + let mut text_items = vec![ + text_item("Operations handbook", 250.0, 35.0, 210.0, 20.0, true), + text_item("Release status", 56.0, 80.0, 100.0, 14.0, true), + text_item("Support status", 56.0, 240.0, 105.0, 14.0, true), + ]; + for (y, values) in [ + (108.0, ["Channel", "Status"]), + (132.0, ["Stable", "Ready"]), + (156.0, ["Beta", "Testing"]), + (268.0, ["Region", "Window"]), + (292.0, ["East", "Morning"]), + (316.0, ["West", "Afternoon"]), + ] { + for ((text, x), width) in values.into_iter().zip([56.0, 206.0]).zip([80.0, 90.0]) { + text_items.push(text_item(text, x, y, width, 10.0, y == 108.0 || y == 268.0)); + } + } + + let mut graphics = ruled_grid(&[50.0, 200.0, 350.0], &[100.0, 124.0, 148.0, 172.0]); + graphics.extend(ruled_grid( + &[50.0, 200.0, 350.0], + &[260.0, 284.0, 308.0, 332.0], + )); + + let markdown = markdown_for(text_items, graphics); + let expected = "# Operations handbook\n\n\ +## Release status\n\n\ +| Channel | Status |\n\ +|---|---|\n\ +| Stable | Ready |\n\ +| Beta | Testing |\n\n\ +---\n\n\ +### Support status\n\n\ +| Region | Window |\n\ +|---|---|\n\ +| East | Morning |\n\ +| West | Afternoon |"; + + assert_eq!(markdown, expected); +} + +#[test] +fn ordinary_two_column_prose_is_not_rendered_as_a_table() { + let mut text_items = vec![text_item( + "Two-column article", + 270.0, + 40.0, + 180.0, + 20.0, + true, + )]; + for row in 0..5 { + let y = 110.0 + row as f32 * 18.0; + text_items.push(text_item( + &format!("Left paragraph sentence number {} continues.", row + 1), + 50.0, + y, + 270.0, + 10.0, + false, + )); + text_items.push(text_item( + &format!("Right paragraph sentence number {} continues.", row + 1), + 430.0, + y + 0.8, + 270.0, + 10.0, + false, + )); + } + + let markdown = markdown_for(text_items, Vec::new()); + + assert!(!markdown.contains("|---"), "unexpected table:\n{markdown}"); +} + +#[test] +fn spanning_heading_above_side_by_side_tables_remains_spanning() { + let mut text_items = vec![text_item( + "Shared service matrix", + 250.0, + 80.0, + 290.0, + 18.0, + true, + )]; + for (y, left, right) in [ + ( + 124.0, + ["Channel", "Status", "Owner"], + ["Region", "Window", "Contact"], + ), + ( + 148.0, + ["Stable", "Ready", "Team A"], + ["East", "Morning", "Desk 1"], + ), + ( + 172.0, + ["Beta", "Testing", "Team B"], + ["West", "Afternoon", "Desk 2"], + ), + ( + 196.0, + ["Nightly", "Active", "Team C"], + ["Central", "Evening", "Desk 3"], + ), + ] { + for ((text, x), width) in left + .into_iter() + .zip([56.0, 136.0, 216.0]) + .zip([60.0, 65.0, 65.0]) + { + text_items.push(text_item(text, x, y, width, 10.0, y == 124.0)); + } + for ((text, x), width) in right + .into_iter() + .zip([426.0, 506.0, 586.0]) + .zip([60.0, 65.0, 65.0]) + { + text_items.push(text_item(text, x, y + 0.8, width, 10.0, y == 124.0)); + } + } + + let mut graphics = ruled_grid( + &[50.0, 130.0, 210.0, 290.0], + &[116.0, 140.0, 164.0, 188.0, 212.0], + ); + graphics.extend(ruled_grid( + &[420.0, 500.0, 580.0, 660.0], + &[116.8, 140.8, 164.8, 188.8, 212.8], + )); + + let markdown = markdown_for(text_items, graphics); + let expected = "# Shared service matrix\n\n\ +---\n\n\ +| Channel | Status | Owner |\n\ +|---|---|---|\n\ +| Stable | Ready | Team A |\n\ +| Beta | Testing | Team B |\n\ +| Nightly | Active | Team C |\n\n\ +| Region | Window | Contact |\n\ +|---|---|---|\n\ +| East | Morning | Desk 1 |\n\ +| West | Afternoon | Desk 2 |\n\ +| Central | Evening | Desk 3 |"; + + assert_eq!(markdown, expected); +} + +#[test] +fn spanning_line_between_rows_does_not_split_side_by_side_tables() { + // A page-spanning note sitting vertically between the data rows of two + // side-by-side grids belongs to neither one, so it has no grid rank to + // sort by. Reordering the tables around it must not strand it mid-table: + // every row of both grids has to survive as table content. + let mut text_items = vec![text_item( + "Shared service matrix", + 250.0, + 80.0, + 290.0, + 18.0, + true, + )]; + for row in 0..6 { + let y = 124.0 + row as f32 * 24.0; + for ((text, x), width) in [format!("L{row}a"), format!("L{row}b"), format!("L{row}c")] + .into_iter() + .zip([56.0, 136.0, 216.0]) + .zip([60.0, 65.0, 65.0]) + { + text_items.push(text_item(&text, x, y, width, 10.0, row == 0)); + } + for ((text, x), width) in [format!("R{row}a"), format!("R{row}b"), format!("R{row}c")] + .into_iter() + .zip([426.0, 506.0, 586.0]) + .zip([60.0, 65.0, 65.0]) + { + text_items.push(text_item(&text, x, y + 0.8, width, 10.0, row == 0)); + } + } + text_items.push(text_item( + "Note: spanning remark placed after the second data row of both tables.", + 56.0, + 184.0, + 604.0, + 10.0, + false, + )); + + let ys = [116.0, 140.0, 164.0, 188.0, 212.0, 236.0, 260.0]; + let mut graphics = ruled_grid(&[50.0, 130.0, 210.0, 290.0], &ys); + graphics.extend(ruled_grid( + &[420.0, 500.0, 580.0, 660.0], + &ys.map(|y| y + 0.8), + )); + + let markdown = markdown_for(text_items, graphics); + for row in 0..6 { + for label in [format!("L{row}a"), format!("R{row}a")] { + assert!( + markdown + .lines() + .any(|line| line.starts_with('|') && line.contains(&label)), + "{label} is not table content:\n{markdown}" + ); + } + } +}