diff --git a/src/extractor/content_stream.rs b/src/extractor/content_stream.rs index 40c439f0..cc835206 100644 --- a/src/extractor/content_stream.rs +++ b/src/extractor/content_stream.rs @@ -1356,7 +1356,14 @@ pub(crate) fn extract_page_text_items( page_num, ); - let items = super::merge_text_items(items); + let table_pages = if super::layout::has_narrow_prose_candidate(&items, page_num) + && page_has_data_table(&items, &rects, &lines, page_num) + { + std::collections::HashSet::from([page_num]) + } else { + std::collections::HashSet::new() + }; + let items = super::merge_text_items_with_table_pages(items, &table_pages); let items = super::merge_subscript_items(items); Ok(( (items, rects, lines), @@ -1366,6 +1373,42 @@ pub(crate) fn extract_page_text_items( )) } +fn page_has_data_table( + items: &[crate::types::TextItem], + rects: &[crate::types::PdfRect], + lines: &[crate::types::PdfLine], + page: u32, +) -> bool { + // A small table can share a page with two prose columns. Suppressing the + // narrow-gutter protection for the whole page would stitch those columns; + // only a table carrying most of the page's layout items is page-dominant. + let has_dominant_data_table = |tables: &[crate::tables::Table]| { + let threshold = items.len().div_ceil(2); + tables.iter().any(|table| { + table.kind == crate::tables::TableKind::Data && table.item_indices.len() >= threshold + }) + }; + if !rects.is_empty() { + let (rect_tables, _) = crate::tables::detect_tables_from_rects(items, rects, page); + if has_dominant_data_table(&rect_tables) { + return true; + } + } + + if !lines.is_empty() { + let line_tables = crate::tables::detect_tables_from_lines(items, lines, page); + if has_dominant_data_table(&line_tables) { + return true; + } + } + + // Do not run the body-font heuristic here. On a dense multi-column page it + // can mistake the columns for a table, and the same expensive detector runs + // again in the downstream table pipeline. Structural rects/lines are the + // only stable preflight signal for merge-time protection. + false +} + /// Counts of text operators with horizontal vs rotated combined matrices. struct RotationVotes { horizontal: u32, @@ -1515,6 +1558,25 @@ mod tests { } } + fn layout_item(text: &str, x: f32, y: f32, width: f32) -> TextItem { + TextItem { + text: text.to_string(), + x, + y, + width, + height: 9.5, + font: "Helvetica".to_string(), + font_size: 9.5, + page: 1, + is_bold: false, + is_italic: false, + is_underline: false, + is_strikeout: false, + item_type: crate::types::ItemType::Text, + mcid: None, + } + } + fn simple_doc_with_content(content: &[u8]) -> (lopdf::Document, lopdf::ObjectId) { use lopdf::{dictionary, Object, Stream}; @@ -1581,6 +1643,62 @@ mod tests { assert_eq!(rects.len(), 1); } + #[test] + fn narrow_prose_prefilter_does_not_run_a_whole_page_table_heuristic() { + let mut items = Vec::new(); + for row in 0..50 { + let y = 750.0 - row as f32 * 14.0; + items.push(layout_item( + "justified body copy fills the left column", + 40.0, + y, + 230.0, + )); + items.push(layout_item( + "justified body copy fills the right column", + 274.7, + y, + 230.0, + )); + } + + assert!(!page_has_data_table(&items, &[], &[], 1)); + } + + #[test] + fn small_data_table_does_not_suppress_narrow_prose_for_whole_page() { + let mut table_items = Vec::new(); + let mut rects = Vec::new(); + for row in 0..4 { + let y = 700.0 - row as f32 * 20.0; + for col in 0..4 { + let x = 100.0 + col as f32 * 60.0; + rects.push(rect(x, y, 60.0, 20.0, 1)); + table_items.push(layout_item("1234", x + 5.0, y + 5.0, 30.0)); + } + } + + let mut mixed = table_items.clone(); + for row in 0..50 { + let y = 620.0 - row as f32 * 12.0; + mixed.push(layout_item( + "justified body copy fills the left column", + 40.0, + y, + 230.0, + )); + mixed.push(layout_item( + "justified body copy fills the right column", + 274.7, + y, + 230.0, + )); + } + + assert!(page_has_data_table(&table_items, &rects, &[], 1)); + assert!(!page_has_data_table(&mixed, &rects, &[], 1)); + } + #[test] fn test_dedup_rects_within_tolerance() { let mut rects = vec![ diff --git a/src/extractor/layout.rs b/src/extractor/layout.rs index 4f76252d..b87c31ae 100644 --- a/src/extractor/layout.rs +++ b/src/extractor/layout.rs @@ -23,18 +23,30 @@ pub(crate) fn detect_columns( page: u32, page_has_table: bool, ) -> Vec { - const BIN_WIDTH: f32 = 2.0; - const MIN_GUTTER_WIDTH: f32 = 8.0; - const MIN_VERTICAL_SPAN_RATIO: f32 = 0.30; - const MIN_ITEMS_PER_COLUMN: usize = 10; - const NOISE_FRACTION: f32 = 0.15; - // Get items for this page. Strip Image placeholders — an image's left edge // would otherwise count toward the column projection profile. let page_items: Vec<&TextItem> = items .iter() .filter(|i| i.page == page && crate::extractor::is_text_layout_item(i)) .collect(); + if page_items.is_empty() { + return vec![]; + } + detect_columns_from_refs(&page_items, page, page_has_table) +} + +fn detect_columns_from_refs( + page_items: &[&TextItem], + page: u32, + page_has_table: bool, +) -> Vec { + const BIN_WIDTH: f32 = 2.0; + const MIN_GUTTER_WIDTH: f32 = 8.0; + const MIN_DENSE_PROSE_GUTTER_WIDTH: f32 = 4.0; + const MIN_DENSE_PROSE_ITEMS: usize = 100; + const MIN_VERTICAL_SPAN_RATIO: f32 = 0.30; + const MIN_ITEMS_PER_COLUMN: usize = 10; + const NOISE_FRACTION: f32 = 0.15; if page_items.is_empty() { return vec![]; @@ -168,7 +180,7 @@ pub(crate) fn detect_columns( let num_bins = ((page_width / bin_width).ceil() as usize).clamp(1, MAX_BINS); let mut histogram = vec![0u32; num_bins]; - for item in &page_items { + for item in page_items { let w = effective_width(item); if w > wide_threshold { continue; @@ -206,13 +218,15 @@ pub(crate) fn detect_columns( valleys.push((start, num_bins)); } - // Filter valleys: must be wide enough and not at page margins + // Filter valleys: must be wide enough and not at page margins. Dense prose + // may use a narrower gutter than the general minimum, but that exception is + // validated below with both prose shape and full-body vertical evidence. let margin_threshold = page_width * 0.05; - let valleys: Vec<(usize, usize)> = valleys + let mut valleys: Vec<(usize, usize)> = valleys .into_iter() .filter(|&(start, end)| { let width_pts = (end - start) as f32 * bin_width; - if width_pts < MIN_GUTTER_WIDTH { + if width_pts < MIN_DENSE_PROSE_GUTTER_WIDTH { return false; } // Valley center must not be within 5% of page edges @@ -221,6 +235,50 @@ pub(crate) fn detect_columns( }) .collect(); + let valley_width = |valley: &(usize, usize)| (valley.1 - valley.0) as f32 * bin_width; + if valleys + .iter() + .any(|valley| valley_width(valley) < MIN_GUTTER_WIDTH) + { + // Narrow empty gutters are the one case where a table column and a prose + // gutter have nearly indistinguishable geometry. Require a dense, + // table-free page, paragraph-shaped columns, and a gap that persists + // through most of the body before allowing the lower width limit. + if page_items.len() >= MIN_DENSE_PROSE_ITEMS && !page_has_table { + for center_assign in [true, false] { + let result = validate_and_build_columns( + &valleys, + page_items, + x_min, + bin_width, + x_max, + MIN_ITEMS_PER_COLUMN, + MIN_VERTICAL_SPAN_RATIO, + page, + center_assign, + ); + if result.len() > 1 + && columns_have_prose(&result, page_items) + && narrow_valleys_have_body_support( + &valleys, &result, page_items, x_min, bin_width, + ) + { + debug!( + "page {}: dense prose narrow-gutter detection found {} columns", + page, + result.len() + ); + return result; + } + } + } + + // Keep the pre-existing conservative behavior unless the strong narrow + // gutter evidence above succeeded. In particular, table pages never use + // the reduced width threshold. + valleys.retain(|valley| valley_width(valley) >= MIN_GUTTER_WIDTH); + } + // Fallback: if no absolute valleys found, try relative valley detection. // Justified text can leave gutter bins non-empty because item widths extend // to the column edge. Look for local minima that are significantly lower @@ -249,7 +307,7 @@ pub(crate) fn detect_columns( if !rel_valleys.is_empty() { let result = validate_and_build_columns( &rel_valleys, - &page_items, + page_items, x_min, bin_width, x_max, @@ -263,7 +321,7 @@ pub(crate) fn detect_columns( // Tables, forms, and checklists have short scattered items // that create false gutter signals. Only commit to relative // valley columns when both sides look like flowing prose. - if columns_have_prose(&result, &page_items) { + if columns_have_prose(&result, page_items) { debug!( "page {}: relative valley detection found {} columns", page, @@ -283,7 +341,7 @@ pub(crate) fn detect_columns( // stays here: without it a table page whose valley candidate was // just rejected could take an unvalidated split. if !page_has_table { - if let Some(columns) = try_xy_cut_split(&page_items, x_min, x_max, page) { + if let Some(columns) = try_xy_cut_split(page_items, x_min, x_max, page) { return columns; } } @@ -295,7 +353,7 @@ pub(crate) fn detect_columns( // a degenerate split (one side empty). let result = validate_and_build_columns( &valleys, - &page_items, + page_items, x_min, bin_width, x_max, @@ -309,7 +367,7 @@ pub(crate) fn detect_columns( } let result = validate_and_build_columns( &valleys, - &page_items, + page_items, x_min, bin_width, x_max, @@ -327,7 +385,7 @@ pub(crate) fn detect_columns( // largest horizontal gap between item edges. This is a simplified // single-level XY-cut inspired by opendataloader's XY-Cut++ algorithm. if page_items.len() >= 20 && !page_has_table { - if let Some(columns) = try_xy_cut_split(&page_items, x_min, x_max, page) { + if let Some(columns) = try_xy_cut_split(page_items, x_min, x_max, page) { return columns; } } @@ -335,6 +393,73 @@ pub(crate) fn detect_columns( vec![ColumnRegion { x_min, x_max }] } +/// Find narrow prose boundaries that text-run merging must not cross. +/// +/// This is deliberately not a general replacement for downstream column +/// grouping. It only exposes a two-column boundary when `detect_columns` has +/// accepted a 4–8pt gutter and the physical body still supports it. Ordinary +/// wide gutters already exceed the merge threshold and need no special case. +pub(crate) fn narrow_prose_column_boundaries( + items: &[TextItem], + table_pages: &HashSet, +) -> HashMap> { + let mut pages: Vec = items.iter().map(|item| item.page).collect(); + pages.sort_unstable(); + pages.dedup(); + + let mut boundaries = HashMap::new(); + for page in pages { + if table_pages.contains(&page) { + continue; + } + if !has_narrow_prose_candidate(items, page) { + continue; + } + let page_items: Vec<&TextItem> = items + .iter() + .filter(|item| item.page == page && crate::extractor::is_text_layout_item(item)) + .collect(); + let columns = detect_columns_from_refs(&page_items, page, false); + if columns.len() != 2 { + continue; + } + + let boundary = columns[0].x_max; + if narrow_boundary_has_body_support(boundary, &page_items) { + boundaries.entry(page).or_insert_with(|| vec![boundary]); + } + } + + boundaries +} + +pub(crate) fn has_narrow_prose_candidate(items: &[TextItem], page: u32) -> bool { + let page_items: Vec<&TextItem> = items + .iter() + .filter(|item| item.page == page && crate::extractor::is_text_layout_item(item)) + .collect(); + page_items.len() >= 100 && has_possible_narrow_gutter(&page_items) +} + +fn has_possible_narrow_gutter(page_items: &[&TextItem]) -> bool { + let mut left_edges: Vec = page_items + .iter() + .filter_map(|item| item.x.is_finite().then_some(item.x)) + .collect(); + left_edges.sort_by(|a, b| a.total_cmp(b)); + + page_items.iter().any(|item| { + let end = item.x + effective_width(item); + if !end.is_finite() { + return false; + } + let next = left_edges.partition_point(|&left| left < end); + left_edges + .get(next) + .is_some_and(|&left| (4.0..8.0).contains(&(left - end))) + }) +} + /// Simplified single-level XY-cut: find the largest horizontal gap between /// item right-edges and left-edges. If the gap is wide enough and both sides /// have sufficient items with vertical overlap, split into two columns. @@ -542,6 +667,171 @@ impl ProseLineStats { } } +/// Validate reduced-width histogram valleys against the physical text body. +/// +/// A valley narrower than the normal gutter minimum is only accepted when it +/// maps to a boundary emitted by the column builder and remains observable over +/// most of the body's rows. This is what distinguishes a legal-gazette gutter +/// from an aligned table-cell gap. +fn narrow_valleys_have_body_support( + valleys: &[(usize, usize)], + columns: &[ColumnRegion], + page_items: &[&TextItem], + x_min: f32, + bin_width: f32, +) -> bool { + let mut checked = 0usize; + for &(start, end) in valleys { + let width = (end - start) as f32 * bin_width; + if width >= 8.0 { + continue; + } + + let expected_boundary = x_min + ((start + end) as f32 / 2.0) * bin_width; + let Some(boundary) = columns + .iter() + .map(|column| column.x_max) + .min_by(|left, right| left.total_cmp(right)) + .filter(|boundary| (boundary - expected_boundary).abs() <= bin_width) + else { + return false; + }; + if !narrow_boundary_has_body_support(boundary, page_items) { + return false; + } + checked += 1; + } + checked > 0 +} + +/// Return true when a 4–8pt boundary separates two prose-like sides over most +/// of the body. Wide titles are excluded just as they are from the projection. +fn narrow_boundary_has_body_support(boundary: f32, page_items: &[&TextItem]) -> bool { + const MIN_NARROW_GUTTER: f32 = 4.0; + const MAX_NARROW_GUTTER: f32 = 8.0; + const MIN_SIDE_ITEMS: usize = 20; + const MIN_VERTICAL_OVERLAP: f32 = 0.60; + const WIDE_ITEM_FRACTION: f32 = 0.60; + + let Some((body_left, body_right)) = + page_items.iter().fold(None::<(f32, f32)>, |bounds, item| { + let right = item.x + effective_width(item); + if !item.x.is_finite() || !right.is_finite() { + return bounds; + } + Some(match bounds { + None => (item.x, right), + Some((left, right_max)) => (left.min(item.x), right_max.max(right)), + }) + }) + else { + return false; + }; + let body_width = body_right - body_left; + if !body_width.is_finite() || body_width <= 0.0 { + return false; + } + + let eligible: Vec<&TextItem> = page_items + .iter() + .copied() + .filter(|item| { + let right = item.x + effective_width(item); + item.x.is_finite() + && right.is_finite() + && effective_width(item) <= body_width * WIDE_ITEM_FRACTION + }) + // A centered folio or full-width heading legitimately crosses the + // gutter. Exclude it from edge and row evidence, just as the projection + // excludes wide spanning lines. Body runs that already span both + // columns therefore still cannot manufacture support: the remaining + // non-spanning body rows must provide enough evidence on both sides. + .filter(|item| { + let right = item.x + effective_width(item); + !(item.x < boundary && right > boundary) + }) + .collect(); + let left_items: Vec<&TextItem> = eligible + .iter() + .copied() + .filter(|item| item.x + effective_width(item) / 2.0 <= boundary) + .collect(); + let right_items: Vec<&TextItem> = eligible + .iter() + .copied() + .filter(|item| item.x + effective_width(item) / 2.0 > boundary) + .collect(); + if left_items.len() < MIN_SIDE_ITEMS || right_items.len() < MIN_SIDE_ITEMS { + return false; + } + + let left_gutter_edge = left_items + .iter() + .map(|item| item.x + effective_width(item)) + .fold(f32::NEG_INFINITY, f32::max); + let right_gutter_edge = right_items + .iter() + .map(|item| item.x) + .fold(f32::INFINITY, f32::min); + let physical_gutter = right_gutter_edge - left_gutter_edge; + debug!( + "boundary debug boundary={boundary} left={left_gutter_edge} right={right_gutter_edge} gutter={physical_gutter} left_items={} right_items={}", + left_items.len(), + right_items.len() + ); + if physical_gutter < MIN_NARROW_GUTTER { + let blocker = left_items + .iter() + .copied() + .max_by(|a, b| (a.x + effective_width(a)).total_cmp(&(b.x + effective_width(b)))); + let right_blocker = right_items + .iter() + .copied() + .min_by(|a, b| a.x.total_cmp(&b.x)); + if let (Some(left), Some(right)) = (blocker, right_blocker) { + debug!( + "blockers left=({:.1}, {:.1}, {:.1}) right=({:.1}, {:.1}, {:.1})", + left.x, + effective_width(left), + left.y, + right.x, + effective_width(right), + right.y + ); + } + } + if !(MIN_NARROW_GUTTER..MAX_NARROW_GUTTER).contains(&physical_gutter) { + return false; + } + + let y_edge = |items: &[&TextItem], max: bool| { + items.iter().map(|item| item.y).fold( + if max { + f32::NEG_INFINITY + } else { + f32::INFINITY + }, + if max { f32::max } else { f32::min }, + ) + }; + let left_top = y_edge(&left_items, false); + let left_bottom = y_edge(&left_items, true); + let right_top = y_edge(&right_items, false); + let right_bottom = y_edge(&right_items, true); + let body_top = left_top.min(right_top); + let body_bottom = left_bottom.max(right_bottom); + let body_span = body_bottom - body_top; + if body_span <= 0.0 { + return false; + } + let overlap = left_bottom.min(right_bottom) - left_top.max(right_top); + if overlap / body_span < MIN_VERTICAL_OVERLAP { + return false; + } + + overlap / body_span >= MIN_VERTICAL_OVERLAP +} + /// Check whether each proposed column contains paragraph-like content. /// /// Groups items per column into rough lines by Y-proximity, then measures @@ -3851,6 +4141,59 @@ mod tests { ); } + #[test] + fn dense_prose_with_narrow_empty_gutter_is_detected() { + // Polish legal gazettes use justified columns whose physical gutter can + // be smaller than the old 8pt minimum. The gap is still empty and both + // sides are dense prose, so it is a column boundary rather than a word + // space. + let mut items = Vec::new(); + for row in 0..50 { + let y = 750.0 - row as f32 * 14.0; + let mut left = make_item(1, 40.0, y, "Justified prose line"); + left.width = 230.0; + let mut right = make_item(1, 274.7, y, "Justified prose line"); + right.width = 230.0; + items.push(left); + items.push(right); + } + + let cols = detect_columns(&items, 1, false); + assert_eq!( + cols.len(), + 2, + "expected a 4.7pt prose gutter to split two columns, got {cols:?}" + ); + assert!( + (270.0..=278.0).contains(&cols[0].x_max), + "boundary was {}, expected the 4.7pt gutter near 275", + cols[0].x_max + ); + } + + #[test] + fn narrow_table_gap_does_not_become_a_prose_column() { + // A borderless table can have the same nominal 4.7pt gap between two + // cell groups. Short, scattered cell fragments must not pass the prose + // gate just because the horizontal gap is aligned. + let mut items = Vec::new(); + for row in 0..50 { + let y = 750.0 - row as f32 * 14.0; + for (x, width) in [(40.0, 30.0), (71.0, 30.0), (105.7, 30.0), (136.7, 30.0)] { + let mut item = make_item(1, x, y, "cell"); + item.width = width; + items.push(item); + } + } + + let cols = detect_columns(&items, 1, false); + assert_eq!( + cols.len(), + 1, + "short table cells must not be split at a narrow aligned gap: {cols:?}" + ); + } + #[test] fn relative_valley_rejects_single_column_margin() { // Single column of text — the right margin drop-off should NOT be diff --git a/src/extractor/mod.rs b/src/extractor/mod.rs index c2c7b00b..a4b46760 100644 --- a/src/extractor/mod.rs +++ b/src/extractor/mod.rs @@ -976,16 +976,34 @@ fn trimmed_suffix(next: &TextItem) -> &str { next.text.trim() } +#[cfg(test)] pub(crate) fn merge_text_items(items: Vec) -> Vec { + let narrow_column_boundaries = layout::narrow_prose_column_boundaries(&items, &HashSet::new()); + merge_text_items_with_boundaries(&items, &narrow_column_boundaries) +} + +pub(crate) fn merge_text_items_with_table_pages( + items: Vec, + table_pages: &HashSet, +) -> Vec { + let narrow_column_boundaries = + layout::narrow_prose_column_boundaries(items.as_slice(), table_pages); + merge_text_items_with_boundaries(&items, &narrow_column_boundaries) +} + +fn merge_text_items_with_boundaries( + items: &[TextItem], + narrow_column_boundaries: &HashMap>, +) -> Vec { if items.is_empty() { - return items; + return Vec::new(); } // Group items by (page, Y position) with 5pt tolerance let y_tolerance = 5.0; let mut line_groups: Vec<(u32, f32, Vec<&TextItem>)> = Vec::new(); - for item in &items { + for item in items { let found = line_groups .iter_mut() .find(|(pg, y, _)| *pg == item.page && (item.y - *y).abs() < y_tolerance); @@ -1016,7 +1034,11 @@ pub(crate) fn merge_text_items(items: Vec) -> Vec { let mut merged = Vec::new(); - for (_, _, group, preserve_stream_order) in &ordered_line_groups { + for (page, _, group, preserve_stream_order) in &ordered_line_groups { + let narrow_boundaries = narrow_column_boundaries + .get(page) + .map(Vec::as_slice) + .unwrap_or(&[]); let mut i = 0; while i < group.len() { let first = group[i]; @@ -1060,6 +1082,9 @@ pub(crate) fn merge_text_items(items: Vec) -> Vec { break; } let gap = next.x - end_x; + if gap_crosses_narrow_column_boundary(end_x, next.x, narrow_boundaries) { + break; + } let x_gap_max = if *preserve_stream_order && is_standalone_bullet_text(&text) { first.font_size * 1.2 } else { @@ -1134,6 +1159,14 @@ pub(crate) fn merge_text_items(items: Vec) -> Vec { merged } +/// Whether an adjacent-run gap contains a detected narrow prose boundary. +fn gap_crosses_narrow_column_boundary(left_end: f32, right_start: f32, boundaries: &[f32]) -> bool { + const EDGE_TOLERANCE: f32 = 0.25; + boundaries.iter().any(|boundary| { + left_end - EDGE_TOLERANCE <= *boundary && right_start + EDGE_TOLERANCE >= *boundary + }) +} + /// Merge subscript/superscript items into their adjacent parent items. /// /// Subscripts (e.g. "2" in H₂O) are rendered as separate text items with a @@ -3144,6 +3177,87 @@ mod tests { ); } + #[test] + fn merge_does_not_join_dense_columns_across_a_narrow_gutter() { + // At 9.5pt font, the old merge cutoff was 4.75pt. A 4.7pt gutter is + // below that cutoff, but its alignment across a dense prose body proves + // that it is a column boundary rather than the widest word space. + let mut items = Vec::new(); + for row in 0..50 { + let y = 750.0 - row as f32 * 14.0; + items.push(make_item_fs("justifytheleftcolumn", 40.0, y, 230.0, 9.5)); + items.push(make_item_fs("justifytherightcolumn", 274.7, y, 230.0, 9.5)); + } + + let merged = merge_text_items(items); + assert_eq!( + merged.len(), + 100, + "each column item must remain separate, got {} merged rows", + merged.len() + ); + } + + #[test] + fn narrow_prose_boundaries_honor_table_page_state() { + let mut items = Vec::new(); + for row in 0..50 { + let y = 750.0 - row as f32 * 14.0; + items.push(make_item_fs("justifytheleftcolumn", 40.0, y, 230.0, 9.5)); + items.push(make_item_fs("justifytherightcolumn", 274.7, y, 230.0, 9.5)); + } + + let prose = layout::narrow_prose_column_boundaries(&items, &HashSet::new()); + let table = layout::narrow_prose_column_boundaries(&items, &HashSet::from([1])); + assert_eq!(prose.len(), 1); + assert!(table.is_empty()); + } + + #[test] + fn merge_keeps_narrow_table_fragments_together() { + // The same 4.7pt gap in short, scattered table cells does not have + // full-body prose evidence. It must not become a merge-time column + // boundary and change table extraction. + let mut items = Vec::new(); + for row in 0..50 { + let y = 750.0 - row as f32 * 14.0; + for (x, width) in [(40.0, 30.0), (71.0, 30.0), (105.7, 30.0), (136.7, 30.0)] { + items.push(make_item_fs("cell", x, y, width, 9.5)); + } + } + + let merged = merge_text_items(items); + assert_eq!( + merged.len(), + 50, + "table rows should continue merging into one item per row, got {}", + merged.len() + ); + } + + #[test] + fn merge_does_not_split_moving_monospaced_word_gaps() { + // Code and equal-width text can contain word gaps of the same physical + // width. Unless the gap is vertically aligned across the body, it must + // not be interpreted as a column gutter. + let mut items = Vec::new(); + for row in 0..50 { + let y = 750.0 - row as f32 * 14.0; + let first_width = 115.0 + (row % 5) as f32 * 7.0; + let second_x = 40.0 + first_width + 4.7; + items.push(make_item_fs("let_value", 40.0, y, first_width, 9.5)); + items.push(make_item_fs("=input;", second_x, y, 115.0, 9.5)); + } + + let merged = merge_text_items(items); + assert_eq!( + merged.len(), + 50, + "moving word gaps are not columns, got {} items", + merged.len() + ); + } + #[test] fn small_caps_merge_keeps_word_space_between_same_size_capitals() { // Two uppercase words at sizes the merge band already accepts (9.98 and