feat(tables): extract rects from filled m/l/h paths for row-stripe tables

PDFs like fare1011.pdf draw table row stripes and borders using
m/l/h/f* filled paths instead of `re` operators. Extract axis-aligned
rectangles from these filled subpaths as a third-priority rect source
(after `re` rects and clip-path rects).

Key changes:
- Track completed subpaths in `h` handler for f/f* rect extraction
- Update W/W* handler to read from pending_subpaths when pending_lines
  is empty (after h clears them)
- Add row-stripe fallback in detect_rects when clustering produces no
  large clusters (non-overlapping row stripes), with ≥15 rects and
  ≥10 rows required to prevent decorative fill false positives
- Revert body-font upper bound to 1.05x (no longer needed at 1.10x)

fare1011.pdf: 12-col heuristic table → 14-col rect-based table with
all fare values correctly captured.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-03-05 23:27:01 -08:00
co-authored by Claude Opus 4.6
parent 5ab92e03e3
commit b4d127eae3
2 changed files with 97 additions and 2 deletions
+73 -2
View File
@@ -37,6 +37,9 @@ pub(crate) fn extract_page_text_items(
let mut path_subpath_start: Option<(f32, f32)> = None; let mut path_subpath_start: Option<(f32, f32)> = None;
let mut path_current: Option<(f32, f32)> = None; let mut path_current: Option<(f32, f32)> = None;
let mut pending_lines: Vec<(f32, f32, f32, f32)> = Vec::new(); let mut pending_lines: Vec<(f32, f32, f32, f32)> = Vec::new();
// Completed subpaths (each a vec of line segments) for f/f* rect extraction
let mut pending_subpaths: Vec<Vec<(f32, f32, f32, f32)>> = Vec::new();
let mut fill_rects: Vec<PdfRect> = Vec::new();
// Get fonts for encoding // Get fonts for encoding
let fonts = doc.get_page_fonts(page_id).unwrap_or_default(); let fonts = doc.get_page_fonts(page_id).unwrap_or_default();
@@ -639,6 +642,11 @@ pub(crate) fn extract_page_text_items(
} }
path_current = path_subpath_start; path_current = path_subpath_start;
} }
// Save completed subpath for f/f* rect extraction and clear pending_lines.
// The W/W* handler reads from pending_subpaths (last entry) instead.
if !pending_lines.is_empty() {
pending_subpaths.push(std::mem::take(&mut pending_lines));
}
} }
// ── Path painting operators ────────────────────────── // ── Path painting operators ──────────────────────────
"S" | "s" => { "S" | "s" => {
@@ -664,6 +672,7 @@ pub(crate) fn extract_page_text_items(
page: page_num, page: page_num,
}); });
} }
pending_subpaths.clear();
path_subpath_start = None; path_subpath_start = None;
path_current = None; path_current = None;
} }
@@ -690,11 +699,63 @@ pub(crate) fn extract_page_text_items(
page: page_num, page: page_num,
}); });
} }
pending_subpaths.clear();
path_subpath_start = None; path_subpath_start = None;
path_current = None; path_current = None;
} }
"f" | "F" | "f*" => { "f" | "F" | "f*" => {
// fill-only: discard path without emitting lines // fill-only: extract axis-aligned rects from completed subpaths
// Also check any un-closed segments still in pending_lines
if !pending_lines.is_empty() {
pending_subpaths.push(std::mem::take(&mut pending_lines));
}
for subpath in pending_subpaths.drain(..) {
// Synthesize closing segment if only 3 segments
let mut segs = subpath;
if segs.len() == 3 {
let (x0, y0, _, _) = segs[0];
let (_, _, ex, ey) = segs[2];
if (ex - x0).abs() > 0.01 || (ey - y0).abs() > 0.01 {
segs.push((ex, ey, x0, y0));
}
}
if segs.len() == 4 {
let mut xs = Vec::with_capacity(8);
let mut ys = Vec::with_capacity(8);
for &(x1, y1, x2, y2) in &segs {
xs.push(x1);
xs.push(x2);
ys.push(y1);
ys.push(y2);
}
let min_x = xs.iter().copied().fold(f32::INFINITY, f32::min);
let max_x = xs.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let min_y = ys.iter().copied().fold(f32::INFINITY, f32::min);
let max_y = ys.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let w = max_x - min_x;
let h = max_y - min_y;
let eps: f32 = 0.5;
let axis_aligned = xs
.iter()
.all(|&x| (x - min_x).abs() < eps || (x - max_x).abs() < eps)
&& ys
.iter()
.all(|&y| (y - min_y).abs() < eps || (y - max_y).abs() < eps);
if axis_aligned && w > 1.0 && h > 1.0 {
let x_dev = min_x * ctm[0] + min_y * ctm[2] + ctm[4];
let y_dev = min_x * ctm[1] + min_y * ctm[3] + ctm[5];
let w_dev = w * ctm[0];
let h_dev = h * ctm[3];
fill_rects.push(PdfRect {
x: x_dev,
y: y_dev,
width: w_dev,
height: h_dev,
page: page_num,
});
}
}
}
pending_lines.clear(); pending_lines.clear();
path_subpath_start = None; path_subpath_start = None;
path_current = None; path_current = None;
@@ -702,7 +763,13 @@ pub(crate) fn extract_page_text_items(
"W" | "W*" => { "W" | "W*" => {
// Clip operator: check if pending path forms an axis-aligned rectangle. // Clip operator: check if pending path forms an axis-aligned rectangle.
// Many PDFs define table cells as clipping paths instead of stroked rects. // Many PDFs define table cells as clipping paths instead of stroked rects.
let mut segs: Vec<(f32, f32, f32, f32)> = pending_lines.clone(); // After `h` closes a subpath, pending_lines is cleared and the subpath
// is saved to pending_subpaths. Read from the last subpath entry.
let mut segs: Vec<(f32, f32, f32, f32)> = if pending_lines.is_empty() {
pending_subpaths.last().cloned().unwrap_or_default()
} else {
pending_lines.clone()
};
// If only 3 segments, synthesize closing segment back to subpath start // If only 3 segments, synthesize closing segment back to subpath start
if segs.len() == 3 { if segs.len() == 3 {
if let Some((sx, sy)) = path_subpath_start { if let Some((sx, sy)) = path_subpath_start {
@@ -756,6 +823,7 @@ pub(crate) fn extract_page_text_items(
"n" => { "n" => {
// end path (no-op): discard // end path (no-op): discard
pending_lines.clear(); pending_lines.clear();
pending_subpaths.clear();
path_subpath_start = None; path_subpath_start = None;
path_current = None; path_current = None;
} }
@@ -765,8 +833,11 @@ pub(crate) fn extract_page_text_items(
// Only use clipping-path rects when no `re` rects exist on this page, // Only use clipping-path rects when no `re` rects exist on this page,
// to avoid diluting real table rects with decorative clip regions. // to avoid diluting real table rects with decorative clip regions.
// Fill-path rects are third priority: only when both `re` and clip rects are empty.
if rects.is_empty() && !clip_rects.is_empty() { if rects.is_empty() && !clip_rects.is_empty() {
rects = clip_rects; rects = clip_rects;
} else if rects.is_empty() && clip_rects.is_empty() && !fill_rects.is_empty() {
rects = fill_rects;
} }
let items = super::merge_text_items(items); let items = super::merge_text_items(items);
+24
View File
@@ -261,6 +261,30 @@ pub fn detect_tables_from_rects(
} }
} }
} }
// Row-stripe fallback: when clustering produces no large clusters
// (row stripes don't overlap so each is its own cluster of 1),
// try all page rects directly as a row-stripe table.
// Require ≥15 rects and ≥10 result rows to avoid decorative fill false positives.
if tables.is_empty() && clusters.is_empty() && page_rects.len() >= 15 {
if let Some(table) = detect_row_stripe_table(items, &page_rects, page) {
if table.rows.len() >= 10 {
debug!(
"page {}: row-stripe fallback succeeded ({} rects, {} rows)",
page,
page_rects.len(),
table.rows.len()
);
tables.push(table);
} else {
debug!(
"page {}: row-stripe fallback rejected: only {} rows",
page,
table.rows.len()
);
}
}
}
} }
// On rect-sparse pages (≤ 6 rects), a few cell-border rects may define the // On rect-sparse pages (≤ 6 rects), a few cell-border rects may define the