Skip to content
Open
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
282 changes: 265 additions & 17 deletions src/markdown/furniture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -352,23 +352,45 @@ fn strip_repeated_lines(lines: Vec<TextLine>, page_count: u32) -> Vec<TextLine>
// page margin.
const EDGE_LINE_COUNT: usize = 5;

/// Returns true if the given Y position is among the first or last N distinct
/// Y positions on the specified page.
fn is_y_at_edge(y: f32, page: u32, page_sorted_ys: &HashMap<u32, Vec<f32>>, n: usize) -> bool {
let ys = match page_sorted_ys.get(&page) {
Some(ys) => ys,
/// One representative baseline per row. Column mates within 0.1pt share
/// a row; that is the same tolerance the edge match uses.
fn cluster_rows(ys: &[f32]) -> Vec<f32> {
let mut rows = Vec::new();
for &y in ys {
match rows.last() {
Some(&last) if y - last < 0.1 => {}
_ => rows.push(y),
}
}
rows
}

let page_rows: HashMap<u32, Vec<f32>> = page_sorted_ys
.iter()
.map(|(&page, ys)| (page, cluster_rows(ys)))
.collect();

/// Returns true if the given Y position is among the first or last N
/// clustered rows on the specified page. `page_rows` is computed once.
fn is_y_at_edge(y: f32, page: u32, page_rows: &HashMap<u32, Vec<f32>>, n: usize) -> bool {
let rows = match page_rows.get(&page) {
Some(rows) => rows,
None => return false,
};
if ys.len() <= n * 2 {
// Page has very few lines — everything is near the edge
return true;
let count = rows.len();
// One or two rows have no interior, so they are not a margin.
if count <= 2 {
return false;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Comment on lines +382 to +383

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Two-row pages retain every repeated header

When every page has a header and one body row, is_y_at_edge rejects every header. No header becomes a candidate, so it appears on every page.

Learn more

The document-wide classifier first gathers candidates from lines it considers at the edge, then removes repeated candidates after their first occurrence. A one- or two-row page contributes no candidates, even when the same header repeats across the document. The later sparse_page exception in strip_repeated_lines can remove only headers that became candidates on other pages, so it cannot handle a document consisting entirely of two-row pages.

Example: Six pages each have Quarterly Bulletin running header text at Y=760 and a different body line at Y=700. The frequency map remains empty, and all six headers survive instead of one.

Recommended fix: Allow sufficiently corroborated repeated headers to be identified on sparse pages without classifying all their rows as furniture. Keep the body line protected by requiring independent evidence, such as stable header position and repetition across pages.

Devin Review


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

}
// Check if this Y is among the first or last N
let pos = match ys.iter().position(|&py| (py - y).abs() < 0.1) {
// On a short page the full N-row zones meet and would mark the body
// as furniture. Keep only the outermost row, which is still enough
// for a running header or footer. Tall pages keep the N-row zone.
let edge = if count <= n * 2 { 1 } else { n };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Eleven-row pages lose repeated prose

On eleven-row pages, edge marks ten rows as margins instead of two. If prose repeats on three pages at fixed baselines, nine or ten body rows disappear from later pages.

Learn more

The repeated-line classifier counts distinct clustered baselines and uses an edge zone to select header/footer candidates. At ten rows, only the first and last rows count as edges; at eleven, five rows from each end count. Repeated ordinary lines at fixed Y coordinates then pass the frequency and Y-consistency checks in strip_repeated_lines and are removed after their first page. This leaves only the central row on subsequent pages.

Example: Three eleven-row pages contain the same eleven-line passage at Y=700, 680, ..., 500. All eleven lines remain on page 1, but pages 2 and 3 each retain only the sixth line.

Recommended fix: Avoid the ten-to-eleven-row jump in is_y_at_edge. Scale or bound the edge zone so it cannot cover nearly all of a short page, and test repeated prose on eleven- and twelve-row pages.

Devin Review


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Short-page headers lose only their first line

On three- to ten-row pages, edge admits only the outermost header row. A repeated two-line header loses its first line, but leaves its second line on every later page.

Learn more

The edge predicate controls both candidate collection and removal in strip_repeated_lines. On pages with three through ten clustered rows, only the first and last rows can enter either pass. A repeated multi-line header therefore has no way to remove its inner header row, even when the outer header row is proven furniture.

Example: Six nine-row pages each have Journal of Testing at Y=760 and Monthly bulletin at Y=740, followed by seven unique body rows. The first header line remains only on page 1; Monthly bulletin remains on all six pages.

Recommended fix: Once an outer row is confirmed as furniture, allow a small adjacent header/footer block to be stripped using repetition and positional evidence, while retaining the short-page guard for ordinary repeated interior prose.

Devin Review


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

let pos = match rows.iter().position(|&py| (py - y).abs() < 0.1) {
Some(p) => p,
None => return false,
};
pos < n || pos >= ys.len() - n
pos < edge || pos + edge >= count
}

// Average page span for normalizing Y variance
Expand Down Expand Up @@ -413,7 +435,7 @@ fn strip_repeated_lines(lines: Vec<TextLine>, page_count: u32) -> Vec<TextLine>
let mut freq: HashMap<String, HashSet<u32>> = HashMap::new();
let mut y_positions: HashMap<String, Vec<f32>> = HashMap::new();
for line in &lines {
if !is_y_at_edge(line.y, line.page, &page_sorted_ys, EDGE_LINE_COUNT) {
if !is_y_at_edge(line.y, line.page, &page_rows, EDGE_LINE_COUNT) {
continue;
}
let text = line.text();
Expand Down Expand Up @@ -443,7 +465,7 @@ fn strip_repeated_lines(lines: Vec<TextLine>, page_count: u32) -> Vec<TextLine>
continue; // single-line bands are already in the individual map
}
let band_y = lines[indices[0]].y;
if !is_y_at_edge(band_y, page, &page_sorted_ys, EDGE_LINE_COUNT) {
if !is_y_at_edge(band_y, page, &page_rows, EDGE_LINE_COUNT) {
continue;
}
let (_, coalesced, normalized) = coalesced_band(&lines, indices);
Expand Down Expand Up @@ -517,7 +539,10 @@ fn strip_repeated_lines(lines: Vec<TextLine>, page_count: u32) -> Vec<TextLine>
// Track which page first shows each candidate (to preserve first occurrence)
let mut first_page_individual: HashMap<String, u32> = HashMap::new();
for (idx, line) in lines.iter().enumerate() {
if !is_y_at_edge(line.y, line.page, &page_sorted_ys, EDGE_LINE_COUNT) {
// A one- or two-row page adds no candidates, but a header already
// confirmed on full pages is still furniture there.
let sparse_page = page_rows.get(&line.page).is_some_and(|r| r.len() <= 2);
if !sparse_page && !is_y_at_edge(line.y, line.page, &page_rows, EDGE_LINE_COUNT) {
continue;
}
let text = line.text();
Expand Down Expand Up @@ -548,7 +573,7 @@ fn strip_repeated_lines(lines: Vec<TextLine>, page_count: u32) -> Vec<TextLine>
continue;
}
let band_y = lines[indices[0]].y;
if !is_y_at_edge(band_y, page, &page_sorted_ys, EDGE_LINE_COUNT) {
if !is_y_at_edge(band_y, page, &page_rows, EDGE_LINE_COUNT) {
continue;
}
let (_, _, normalized) = coalesced_band(&lines, indices);
Expand All @@ -565,7 +590,7 @@ fn strip_repeated_lines(lines: Vec<TextLine>, page_count: u32) -> Vec<TextLine>
continue;
}
let band_y = lines[indices[0]].y;
if !is_y_at_edge(band_y, page, &page_sorted_ys, EDGE_LINE_COUNT) {
if !is_y_at_edge(band_y, page, &page_rows, EDGE_LINE_COUNT) {
continue;
}
let (sorted_indices, coalesced, normalized) = coalesced_band(&lines, indices);
Expand All @@ -590,7 +615,7 @@ fn strip_repeated_lines(lines: Vec<TextLine>, page_count: u32) -> Vec<TextLine>
continue;
}
let band_y = lines[indices[0]].y;
if !is_y_at_edge(band_y, page, &page_sorted_ys, EDGE_LINE_COUNT) {
if !is_y_at_edge(band_y, page, &page_rows, EDGE_LINE_COUNT) {
continue;
}
if indices.iter().any(|idx| removal_set.contains(idx)) {
Expand Down Expand Up @@ -1315,4 +1340,227 @@ mod tests {
.unwrap();
assert_eq!(first_header.page, 1, "first occurrence should be on page 1");
}

#[test]
fn repeated_body_on_a_short_page_is_kept() {
// Eight distinct rows is inside the old "everything is an edge" zone
// (2 * N). The same sentences on three pages are body text that a
// large-print page repeats, not a running header.
let story = [
"left page story opens with a red book on the table",
"the next sentence keeps the same characters in the room",
"dialogue continues across the wide printed lines here",
"a fourth sentence still belongs to the page body text",
"fifth sentence of the repeated passage stays in place",
"sixth sentence should not vanish once neighbors arrive",
"seventh sentence of the short page is still the story",
"eighth sentence closes the passage without being a footer",
];
let mut lines = Vec::new();
for page in [1u32, 2, 5] {
for (row, text) in story.iter().enumerate() {
lines.push(make_line(text, 10.0, page, 700.0 - row as f32 * 40.0, None));
}
}
for page in [3u32, 4, 6] {
for row in 0..8u32 {
lines.push(make_line(
&format!("different page {page} body sentence number {row} here"),
10.0,
page,
700.0 - row as f32 * 40.0,
None,
));
}
}
let result = strip_repeated_lines(lines, 6);
// The outermost rows can still be a header and a footer. The
// interior of the passage must stay on every page that carries it.
assert_eq!(
result.iter().filter(|l| l.page == 2).count(),
story.len() - 2,
"short-page body must keep its interior rows"
);
assert!(
result
.iter()
.any(|l| l.page == 2 && l.text().contains("next sentence")),
"an interior sentence must survive on the repeated page"
);
}

#[test]
fn short_page_still_strips_an_outer_running_header() {
// Eight body rows plus one header is still inside the short-page
// zone. Only the outermost row is furniture, so the header drops
// and the body stays.
let mut lines = Vec::new();
for page in 1..=6u32 {
lines.push(make_line(
"Chapter notes running header text",
10.0,
page,
760.0,
None,
));
for row in 0..8u32 {
lines.push(make_line(
&format!("unique short page {page} body sentence number {row}"),
10.0,
page,
700.0 - row as f32 * 40.0,
None,
));
}
}
let result = strip_repeated_lines(lines, 6);
assert_eq!(
result
.iter()
.filter(|l| l.text().contains("Chapter notes"))
.count(),
1,
"the outer header on a short page must still strip"
);
assert_eq!(
result.iter().filter(|l| l.page == 2).count(),
8,
"the short page body must stay"
);
}

#[test]
fn offset_column_baselines_stay_one_sparse_page() {
// Six logical rows, each with a second column 0.05pt lower. Counting
// those baselines separately yields twelve rows and the fixed zone
// then covers the body.
let mut lines = Vec::new();
for page in [1u32, 2, 5] {
for row in 0..6u32 {
let y = 700.0 - row as f32 * 40.0;
lines.push(make_line(
&format!("left column story sentence number {row} continues onward"),
10.0,
page,
y,
None,
));
lines.push(make_line(
&format!("right column story sentence number {row} continues onward"),
10.0,
page,
y + 0.05,
None,
));
}
}
for page in [3u32, 4, 6] {
for row in 0..6u32 {
let y = 700.0 - row as f32 * 40.0;
lines.push(make_line(
&format!("other page {page} left sentence number {row} unique"),
10.0,
page,
y,
None,
));
lines.push(make_line(
&format!("other page {page} right sentence number {row} unique"),
10.0,
page,
y + 0.05,
None,
));
}
}
let result = strip_repeated_lines(lines, 6);
assert_eq!(
result
.iter()
.filter(|l| l.page == 2 && l.text().contains("column story"))
.count(),
8,
"interior rows of a short two-column page must stay"
);
assert!(
result.iter().any(|l| {
l.page == 2 && l.text().contains("left column story sentence number 1")
}),
"a column mate 0.05pt off the row must stay with that row"
);
}

#[test]
fn repeated_header_on_a_tall_page_still_strips() {
// Just past the sparse cutoff: eleven rows, so the top line is a
// real margin and a running header still drops after its first page.
let mut lines = Vec::new();
for page in 1..=6u32 {
lines.push(make_line(
"Quarterly Bulletin running header text",
10.0,
page,
760.0,
None,
));
for row in 0..10u32 {
lines.push(make_line(
&format!("unique body words page {page} row {row} lorem ipsum"),
10.0,
page,
700.0 - row as f32 * 20.0,
None,
));
}
}
let result = strip_repeated_lines(lines, 6);
assert_eq!(
result
.iter()
.filter(|l| l.text().contains("Quarterly Bulletin"))
.count(),
1,
"a header above a real interior must still strip"
);
}

#[test]
fn running_header_on_a_near_empty_page_still_strips() {
// Five full pages confirm the header as furniture. Page 6 holds only
// the header and one body line; two rows are not a margin, but a
// header already confirmed on full pages must not leak back in.
let mut lines = Vec::new();
for page in 1..=6u32 {
lines.push(make_line(
"Quarterly Bulletin running header text",
10.0,
page,
760.0,
None,
));
let rows = if page == 6 { 1 } else { 10 };
for row in 0..rows {
lines.push(make_line(
&format!("unique body words page {page} row {row} lorem ipsum"),
10.0,
page,
700.0 - row as f32 * 20.0,
None,
));
}
}
let result = strip_repeated_lines(lines, 6);
assert!(
!result
.iter()
.any(|l| l.page == 6 && l.text().contains("Quarterly Bulletin")),
"a confirmed running header must strip on a near-empty page"
);
assert!(
result
.iter()
.any(|l| l.page == 6 && l.text().contains("page 6 row 0")),
"the near-empty page body must stay"
);
}
}