fix(tables): read tables ruled with hairline fills, with row-group labels and merged cell runs

YI
5d ago
+792-7

Fixes #353

Some generators draw every table rule as a thin filled rectangle instead of a stroke. Those slivers are too small for rect clustering and never reach line-based detection, so the table falls to the heuristic pass and its rows and columns come out broken.

Changes

  • Hairline fills → rules (synthesize_lines_from_thin_rects): thin fills become line segments for the line-based pass, with per-cell slivers welded into rules. It only returns lines when they form a grid (≥3 horizontal and ≥2 vertical rules of table length). Stray underlines can't fabricate a table, and horizontal-only layouts keep their per-cell segment endpoints for the existing thin-rect fallback.
  • Page rules aren't row edges: horizontals more than one row pitch outside the vertical rules' span (running-header and footer rules, note boxes) are ignored. Rules just past the span, like a header rule above partial-height verticals, still count.
  • Runs crossing a column rule are cut: when a drawn vertical rule crosses a run's baseline, the run is cut at the nearest word space. This handles two cells painted so close that extraction merges them ("…corpuscular MCHC"). The cut is limited to the line-based path and to drawn rules. Fragments map back to their item index once.
  • Rotated row-group labels: a rotated label that sits wholly inside the grid goes into the merged cell its column's rules describe. Labels that cross a ruled interior row edge are rejected.
  • Row-group entries stay separate rows: in a table whose first column is mostly empty, a row that fills the same columns as the entry above is not merged on either continuation path. Wrapped tails still merge.

Review feedback addressed

  • The old double-space split in assign_items_to_grid was removed. It depended on an extraction quirk that #517 removed, and it also applied to inferred column edges. It's replaced by the rule-crossing cut above, so detect_rects.rs is unchanged.
  • Duplicate item_indices from split runs are fixed: fragments are deduplicated back to one original index.
  • Weld clustering: segments are grouped by coordinate first, then welded in axis order, so near-collinear runs can't interleave.
  • Row-group heuristic: the empty-first-cell count skips blank and footnote rows, the lowercase check looks only at the entry label (lowercase values like yes are fine), and the guard covers is_wrapped_continuation too.
  • Tests: fixtures renamed to describe what they cover. There is one test_snapshot_* per fixture with accurate doc comments, the order assertion compares positions, and the redundant prefix check is gone. Unit tests were added for the row-group heuristic (positive and wrapped-tail negative), the run split, synthesis welding, the grid gate, and the weld.

Second round:

  • Ruled-span slack uses the median rule gap, not the largest, so one tall merged row can't re-admit page furniture.
  • The rule-crossing test uses the run's baseline instead of its box midpoint, and only applies to level runs. Oblique runs are left whole.
  • Row-group detection also covers a three-row group with two blank first cells. Larger tables keep the 3/4 ratio, because a 2/3 ratio also matched nexo-price-en, whose blank-first-cell rows are wrapped tails.
  • Two-column row-group entries stay separate rows: one filled entry cell is enough, and lowercase wrapped tails still merge.

Not changed: the trailing 1-3 chapter-page footer in the second snapshot. Stripping N-N lines is a separate postprocess change, and a standalone range like that is ambiguous, so I'd rather not fold it into this PR.

Verification

  • cargo fmt, cargo clippy -- -D warnings and cargo test pass.
  • Output on all 46 fixture PDFs was compared against main. Besides the two new fixtures, only td9264 changes: in its three-column cross-reference table (section / remove / add), four references that were merged into one cell now get one row each, as they appear in the PDF. That table's other two columns are still lost, as on main.
  • pdf-evals (bench.py test) has not been run yet; it isn't available in my environment.

🤖 Generated with Claude Code