fix(underline): rescue snug-owned underlines from the table-ruling filters (#143)

* fix(underline): rescue snug-owned underlines from the table-ruling filters

Documents that underline many full-width lines (dense CJK business docs,
legal redlines, 10-K section links) produce span-similar rules at 3+
y-levels — exactly what the repeated-ruling filter treats as table
rulings, so every semantic underline on such pages was discarded. Three
changes fix detection without re-marking real tables:

1. Snug-owner rescue: a rule survives the repeated-ruling filter when
   the union of touching text runs on its baseline row owns it (rule
   contained within the union's span +0.75em, runs cover >=60% of the
   rule, no column-sized gaps between runs). Table row separators fail
   ownership: they overshoot their cells' text or match gapped items.
   Same-row segmented rules (column-header separators) always stay
   discarded, and a rule enclosed by a drawn cell-sized box (rect-grid
   tables) is never rescued.

2. Vertical window widened 0.35em -> 0.72em below the baseline: CJK
   layouts draw underlines under the full em box, measured at ~0.67em.

3. Prose-table guard in the positions-path suppressor: a detected
   'table' whose cells hold flowing prose (>=30% of cells over 100
   chars) is a detection artifact of boxed callouts + stacked rules,
   not a real table — suppressing there erased every underline on the
   page.

Also fixes cluster_x_positions fabricating phantom table columns from
style-split continuation runs (touching items, gap <2pt, now feed one
column start) — the fix that keeps rect-grid table shapes stable while
underlined links inside cells are correctly marked.

Snapshot updates are underline gains on regulation/form fixtures and one
empty spacer-column change in a subscripted header.

Corpus (508-doc public bench sweep): text output byte-identical on all
docs; underlined items +224/-0; strikeout now fires on redline docs.
Item-level GT coverage: is_underline 86->151/405, is_strikeout 0->10/44.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3U6BKYCS73DVA83odAfYB

* fix(underline): fraction-bar guard + subscript merge across underline marks

Found by a 202-doc real-world corpus diff (pdf-evals) that exercises the
full markdown pipeline, which the bench-corpus item sweep does not:

1. Math fraction bars and lattice grid lines are underline geometry —
   short horizontal rules under digits. Guard: a narrow rule (<=60pt)
   with bar-sized text hanging just below it (denominator) never marks.
   The below-text width bound matters: tightly-leaded REAL underlines
   have a full-width next line below, which must not trip the guard.

2. merge_subscript_items refused to merge when the parent was underlined
   but the tiny digit was not (the drawn rule easily misses the digit's
   own overlap window) — losing the merge broke subscript tokens inside
   table cells (b+2 no longer became b₂). Strikeout boundaries still
   block the merge in both directions; only parent-underlined/digit-bare
   merges, absorbing with the parent's flags.

Corpus after refinement: underlined items +220/-2 (the 2 are fraction
bars the old code wrongly marked), GT rule-text coverage 149/405
underline + 10/44 strikeout, text output identical on all 508 bench
docs and word-count-identical on the 202 pdf-evals docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3U6BKYCS73DVA83odAfYB

* fix(underline): address review — grid-evidence veto, strikeout-safe fraction guard, bounded gaps

- Cell-box veto now requires GRID EVIDENCE (a vertically abutting
  neighbor rect with x-overlap) instead of a height window: multiline
  table cells taller than the old 90pt ceiling veto again, and isolated
  filled callout panels (which legitimately contain underlines) no
  longer veto at all.
- The fraction guard gates only UNDERLINE marking; rule_strikes_item
  still evaluates, so short strikeouts near lower text survive.
- Fraction hug distance tightened to 0.3em so a short last-line at
  normal leading is not mistaken for a denominator.
- Continuation-run suppression bounds the negative gap (-4pt): text
  overhanging from an adjacent cell keeps its own column start.

Corpus after review fixes: underlined items +222/-2, GT coverage
150/405 underline + 10/44 strikeout, bench text output identical on
all 508 docs, pdf-evals word loss bounded at equation-reflow noise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3U6BKYCS73DVA83odAfYB

* chore: appease clippy (redundant closure)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3U6BKYCS73DVA83odAfYB

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-07-13 08:47:45 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent cb3906e7b8
commit 39c31a8404
6 changed files with 367 additions and 34 deletions
+45 -7
View File
@@ -252,17 +252,47 @@ fn suppress_table_underlines(
}
let mut table_item_indices: HashSet<usize> = HashSet::new();
// A detected "table" that swallows nearly every text item on the page
// is a detection artifact (prose pages with boxed callouts or stacked
// underline rules read as one giant grid), not a real table — letting
// it through here erased every legitimate underline on the page
// (text_dense__underline: rect detection claimed 52/52 items). Real
// ruled tables share the page with headings, captions, and body text.
let plausible = |table: &crate::tables::Table| {
// Content sanity gate: prose pages with boxed callouts and stacked
// underline rules can detect as a structurally rich "table" that
// swallows every item on the page (text_dense__underline: a 4x8
// grid claiming 52/52 items, one "cell" holding 806 chars of body
// text) — suppressing there erased every legitimate underline on
// the page. Real data-table cells are short values; a cell with
// hundreds of characters means the grid captured flowing prose.
let lens: Vec<usize> = table
.cells
.iter()
.flatten()
.filter(|cell| !cell.trim().is_empty())
.map(|cell| cell.chars().count())
.collect();
if lens.is_empty() {
return false;
}
let long = lens.iter().filter(|&&n| n > 100).count();
(long as f32) < (lens.len() as f32) * 0.3
};
if !rects.is_empty() {
let (rect_tables, _) = crate::tables::detect_tables_from_rects(items, rects, page);
for table in rect_tables {
table_item_indices.extend(table.item_indices);
for table in rect_tables.iter().filter(|t| plausible(t)) {
table_item_indices.extend(table.item_indices.iter().copied());
}
}
if !lines.is_empty() {
for table in crate::tables::detect_tables_from_lines(items, lines, page) {
table_item_indices.extend(table.item_indices);
for table in crate::tables::detect_tables_from_lines(items, lines, page)
.iter()
.filter(|t| plausible(t))
{
table_item_indices.extend(table.item_indices.iter().copied());
}
}
@@ -873,9 +903,17 @@ pub(crate) fn merge_subscript_items(items: Vec<TextItem>) -> Vec<TextItem> {
.chars()
.last()
.is_some_and(|c| c.is_alphabetic());
let same_marks = parent.is_underline == item.is_underline
&& parent.is_strikeout == item.is_strikeout;
if parent.font_size >= sub_threshold && ends_with_letter && same_marks {
// Strikeout boundaries block the merge (a struck word
// must not extend its strike over a live footnote digit,
// and a struck digit must not lose its own mark). An
// underlined parent with an unmarked digit DOES merge:
// the drawn rule easily misses the tiny digit's overlap
// window, and refusing costs the whole subscript token
// ("b"+"2" staying split). Visually the rule spans both.
let marks_ok = parent.is_strikeout == item.is_strikeout
&& (parent.is_underline == item.is_underline
|| (parent.is_underline && !item.is_underline));
if parent.font_size >= sub_threshold && ends_with_letter && marks_ok {
let parent_right = parent.x + parent.width;
let gap = item.x - parent_right;
// Subscripts must be tightly adjacent (within ~1pt)