Skip to content
116 changes: 110 additions & 6 deletions src/extractor/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3048,13 +3048,15 @@ fn group_single_column(
.map(|i| i.text.trim())
.collect::<Vec<_>>()
.join(" ");
let line_wordy = line_text.split_whitespace().count() >= 2
let line_words = line_text.split_whitespace().count();
let line_wordy = line_words >= 2
&& line_text.chars().filter(|c| c.is_alphabetic()).count() >= 8;
// Lowercase starts are mid-sentence continuations and
// split on prose signals alone. Uppercase starts also
// need a bold-style mismatch between the runs — a bold
// heading beside regular body text — otherwise same-style
// label rows (feature tiles, legends) would shatter.
// split on prose signals alone. A bold heading beside
// regular body text splits too. Same-style uppercase
// splits only when both runs are full clauses (six or
// more words): a feature-tile row is a few words and
// stays on one line.
let starts_lower = item
.text
.trim()
Expand All @@ -3064,7 +3066,19 @@ fn group_single_column(
// The whole line must be bold (a heading), not merely
// its last run — mixed bold-label/value rows stay joined.
let style_mismatch = last_line.items.iter().all(|i| i.is_bold) && !item.is_bold;
if line_wordy && incoming_wordy && (starts_lower || style_mismatch) {
let both_clauses = line_words >= 6 && item.text.split_whitespace().count() >= 6;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
if line_wordy
&& incoming_wordy
&& (starts_lower || style_mismatch || both_clauses)
{
return false;
}
// Wrapped leftovers ("checksum." / "order.") and a
// right-aligned tag beside a title sit a column-width
// void apart. Table header cells on this scale are
// closer than 8em and 100pt, and digit page numbers
// never reach this branch.
if gap > (item.font_size.max(last_item.font_size) * 8.0).max(100.0) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated

@cubic-dev-ai cubic-dev-ai Bot Sep 27, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This void split has no wordiness or prose gate: any same-baseline run that starts with an alphabetic character and sits more than 8em/100pt away becomes its own line, even a one-word token. The test comment claims page numbers "never reach this branch", but only digit page numbers do — page_number_value recognizes ASCII digits only, so a right-aligned Roman-numeral TOC folio (Preface … iii starts with 'i', alphabetic) or a spelled-out number now splits off the entry line it previously joined (the old code merged it because incoming_wordy was false). Short same-style rows are likewise only protected up to the 100pt floor, so the "label rows stay one line" invariant in the PR description holds only for gaps under ~100pt, as the tests only exercise the 50pt case.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/extractor/layout.rs, line 3081:

<comment>This void split has no wordiness or prose gate: any same-baseline run that starts with an alphabetic character and sits more than 8em/100pt away becomes its own line, even a one-word token. The test comment claims page numbers "never reach this branch", but only *digit* page numbers do — `page_number_value` recognizes ASCII digits only, so a right-aligned Roman-numeral TOC folio (`Preface` … `iii` starts with 'i', alphabetic) or a spelled-out number now splits off the entry line it previously joined (the old code merged it because `incoming_wordy` was false). Short same-style rows are likewise only protected up to the 100pt floor, so the "label rows stay one line" invariant in the PR description holds only for gaps under ~100pt, as the tests only exercise the 50pt case.</comment>

<file context>
@@ -3064,7 +3066,19 @@ fn group_single_column(
+                    // void apart. Table header cells on this scale are
+                    // closer than 8em and 100pt, and digit page numbers
+                    // never reach this branch.
+                    if gap > (item.font_size.max(last_item.font_size) * 8.0).max(100.0) {
                         return false;
                     }
</file context>
Fix with cubic

return false;
Comment on lines +3169 to 3173

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Wide table rows split into lines

When table cells sit more than 100pt apart, group_single_column splits a same-baseline row into separate lines. The new three-cell test produces three lines, losing the row structure in single-column output.

Learn more

group_single_column also handles pages whose table-shaped text does not enter a validated column layout. The new wide-table test exercises three cells at the same Y and accepts three output lines. Each gap exceeds the new void threshold, so the second and third cells start new lines despite belonging to one row. This changes the line structure consumed by single-column output and downstream Markdown processing.

Example: At Y=459, Status at x=47, Description at x=200, and Owner at x=400 form one header row. The test now returns three lines instead of one row.

Recommended fix: Distinguish isolated cross-column fragments from aligned table cells before applying the void split. Preserve a row when neighboring same-baseline cells have row or table evidence; add a row-preservation regression test.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

}
}
Expand Down Expand Up @@ -3561,6 +3575,96 @@ mod tests {
assert_eq!(lines.len(), 1, "numbered table cells stay on one line");
}

#[test]
fn same_baseline_same_style_clauses_split() {
// Two column sentences, same face, gutter too narrow for column
// detection (en-22-mixed-layout.pdf: 42pt run ending near 282, next
// run at 318). Both clauses are long, so this is not a label row.
let mut left = make_item(
1,
42.0,
584.0,
"Left column: record source type, page count, and",
);
left.font_size = 10.0;
left.width = 240.0;
let mut right = make_item(
1,
318.0,
584.0,
"Right column: inspect tables, images, links, and reading",
);
right.font_size = 10.0;
right.width = 250.0;
let lines = group_single_column(vec![left, right], 0.10, false);
assert_eq!(lines.len(), 2, "same-style column clauses must not fuse");
}

#[test]
fn column_sized_void_splits_short_leftovers() {
// The wrapped second line of each column is one word, ~230pt apart.
let mut left = make_item(1, 42.0, 569.0, "checksum.");
left.font_size = 10.0;
left.width = 50.0;
let mut right = make_item(1, 318.0, 569.0, "order.");
right.font_size = 10.0;
right.width = 40.0;
let lines = group_single_column(vec![left, right], 0.10, false);
assert_eq!(lines.len(), 2, "a column-width void is not a word space");
}

#[test]
fn right_aligned_tag_does_not_join_a_title() {
// "Mixed layout report" at x=42 and "EN-22" at x=544, baselines 1pt apart.
let mut title = make_item(1, 42.0, 755.0, "Mixed layout report");
title.font_size = 16.0;
title.width = 160.0;
title.is_bold = true;
let mut tag = make_item(1, 544.0, 756.0, "EN-22");
tag.font_size = 9.0;
tag.width = 36.0;
let lines = group_single_column(vec![title, tag], 0.10, false);
assert_eq!(lines.len(), 2, "a far tag must not extend the title");
}

#[test]
fn same_baseline_table_headers_stay_joined() {
// Header cells ~70pt apart stay one row. Digit page numbers on a
// title line stay joined too: they never enter the alphabetic branch.
let mut cells = Vec::new();
for (x, text) in [(47.0, "Status"), (147.0, "Metric"), (247.0, "Value")] {
let mut item = make_item(1, x, 459.0, text);
item.font_size = 8.5;
item.width = text.len() as f32 * 4.5;
item.is_bold = true;
cells.push(item);
}
assert_eq!(group_single_column(cells, 0.10, false).len(), 1);

let mut title = make_item(1, 72.0, 700.0, "Introduction");
title.width = 90.0;
let mut page_no = make_item(1, 500.0, 700.0, "12");
page_no.width = 16.0;
assert_eq!(
group_single_column(vec![title, page_no], 0.10, false).len(),
1,
"a TOC page number stays on the entry line"
);
}

#[test]
fn same_style_label_row_stays_joined() {
let mut left = make_item(1, 40.0, 500.0, "Fast setup");
left.width = 70.0;
let mut right = make_item(1, 160.0, 500.0, "Live preview");
right.width = 80.0;
assert_eq!(
group_single_column(vec![left, right], 0.10, false).len(),
1,
"a short same-style label row stays one line"
);
}

#[test]
fn three_zone_layout_detected() {
// Left months (x=15..330), right months (x=345..660), sidebar (x=675..800)
Expand Down