fix(tables): Merge continuation tables across page breaks

When consecutive pages each have exactly one table with the same column
count, treat them as a single table spanning multiple pages. Strips the
redundant header+separator rows from continuation pages and appends
their data rows to the first page's table.

Closed-Business-Accounts PDF now produces one 2080-row table instead of
19 separate tables each with their own header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-02-14 13:46:02 -08:00
co-authored by Claude Opus 4.6
parent b6907494b6
commit 9bca448da2
+112
View File
@@ -214,6 +214,11 @@ pub fn to_markdown_from_items(items: Vec<TextItem>, options: MarkdownOptions) ->
}
}
// Merge continuation tables across page breaks
// When consecutive pages each have exactly one table with the same column count,
// treat them as a single table spanning multiple pages.
merge_continuation_tables(&mut page_tables);
// Filter out table items and process the rest
let non_table_items: Vec<TextItem> = text_items
.into_iter()
@@ -248,6 +253,113 @@ fn calculate_font_stats_from_items(items: &[TextItem]) -> FontStats {
FontStats { most_common_size }
}
/// Merge continuation tables that span across page breaks.
///
/// When consecutive pages each have exactly one table with the same number of columns,
/// the later pages are continuations. We strip their header+separator rows and append
/// their data rows to the first page's table, then remove them from later pages.
fn merge_continuation_tables(page_tables: &mut std::collections::HashMap<u32, Vec<(f32, String)>>) {
let mut sorted_pages: Vec<u32> = page_tables.keys().copied().collect();
sorted_pages.sort();
if sorted_pages.len() < 2 {
return;
}
// Find runs of consecutive pages that each have exactly one table with matching columns
let mut i = 0;
while i < sorted_pages.len() {
let first_page = sorted_pages[i];
let first_tables = match page_tables.get(&first_page) {
Some(t) if t.len() == 1 => t,
_ => {
i += 1;
continue;
}
};
let first_col_count = count_table_columns(&first_tables[0].1);
if first_col_count == 0 {
i += 1;
continue;
}
// Collect continuation pages
let mut continuation_pages = Vec::new();
let mut j = i + 1;
while j < sorted_pages.len() {
let next_page = sorted_pages[j];
// Must be consecutive page numbers
let prev_page = if continuation_pages.is_empty() {
first_page
} else {
*continuation_pages.last().unwrap()
};
if next_page != prev_page + 1 {
break;
}
let next_tables = match page_tables.get(&next_page) {
Some(t) if t.len() == 1 => t,
_ => break,
};
let next_col_count = count_table_columns(&next_tables[0].1);
if next_col_count != first_col_count {
break;
}
continuation_pages.push(next_page);
j += 1;
}
if !continuation_pages.is_empty() {
// Collect data rows from continuation pages
let mut extra_rows = String::new();
for &cont_page in &continuation_pages {
if let Some(tables) = page_tables.get(&cont_page) {
let table_md = &tables[0].1;
// Skip header row (line 1) and separator row (line 2), keep the rest
for (line_idx, line) in table_md.lines().enumerate() {
if line_idx >= 2 {
extra_rows.push_str(line);
extra_rows.push('\n');
}
}
}
}
// Append continuation rows to the first page's table
if let Some(tables) = page_tables.get_mut(&first_page) {
tables[0].1.push_str(&extra_rows);
}
// Remove continuation pages from the map
for &cont_page in &continuation_pages {
page_tables.remove(&cont_page);
}
// Skip past the merged pages
i = j;
} else {
i += 1;
}
}
}
/// Count the number of columns in a markdown table by counting `|` in the separator row.
fn count_table_columns(table_md: &str) -> usize {
// The separator row is the second line, containing "| --- | --- |"
if let Some(sep_line) = table_md.lines().nth(1) {
if sep_line.contains("---") {
// Count cells: number of | minus 1 (leading |), but handle edge cases
let pipes = sep_line.chars().filter(|&c| c == '|').count();
return if pipes >= 2 { pipes - 1 } else { 0 };
}
}
0
}
/// Convert text lines to markdown, inserting tables and images at appropriate Y positions
fn to_markdown_from_lines_with_tables_and_images(
lines: Vec<TextLine>,