Compare commits

...
Author SHA1 Message Date
Abimael Martell cabc108d5c test: add crop bbox plausibility coverage
Cover in-crop, out-of-crop, slack-boundary, and non-positive DPI behavior for vector grid cell bbox validation.

Made-with: Cursor
2026-04-28 17:14:17 -07:00
Abimael Martell 860328bd74 fix: address vector grid review feedback
Return null for rotated vector grids until the coordinate transform has coverage and reject out-of-crop cell boxes surfaced by real-PDF smoke testing.

Made-with: Cursor
2026-04-28 16:56:10 -07:00
Abimael Martell 0674ba5155 feat: add vector grid region detector napi export
Expose region-scoped vector PDF grid detection so TSR callers can reuse native geometry before model fallback.

Made-with: Cursor
2026-04-28 16:38:11 -07:00
Abimael MartellandClaude Opus 4.7 d196d435d1 fix: TSR auto-fallback bugs found in review, v1.7.2 (#68)
Three fixes to extract_tables_with_structure_auto_mem (added in
1.7.1) caught by external review:

1. multi_row_in_cell over-triggered on legitimate multi-line cells.
   The previous threshold (item span > 1.3× either smallest cell or
   tallest item height) fires on any cell with 2+ y-separated text
   items — including rowspan>1 cells, wrapped descriptions, and
   superscript/subscript runs. Replaced with two gates:
   - skip cells whose declared rowspan > 1 (intentional multi-line)
   - require an actual whitespace gap (>~half a line height)
     between the bottom of one item and the top of the next, in
     PDF-native y-coordinates. Same-line items with tall glyphs or
     superscripts have negative or near-zero gap; truly separate
     visual rows have gap ≈ leading − line-height.
   FNBO regression test still passes; new test covers a rowspan=2
   cell with two visible text lines and verifies no fallback fires.

2. Heuristic returning empty silently replaced TSR markdown with
   "". The auto wrapper now keeps the TSR markdown when the
   heuristic markdown is empty/whitespace and tags fallback_reason
   with `_heuristic_empty` suffix (e.g.
   `multi_row_in_cell_heuristic_empty`). Worst case we ship the
   same wrong-but-non-empty TSR output we'd have shipped before
   1.7.1; we never replace useful output with literally nothing.

3. One bad input blanked the whole batch. Errors from
   detect_tsr_quality_issue or extract_tables_in_regions_mem now
   stay scoped to the single input — that input falls through to
   raw TSR markdown with a `_error` reason label so callers can
   metric on it. Other inputs in the batch are unaffected.

3 new integration tests:
- test_auto_does_not_fire_on_legit_rowspan_cell
- test_auto_keeps_tsr_markdown_when_heuristic_returns_empty
- test_auto_isolates_per_input_failures

All 6 auto tests + full 123-test suite pass. FNBO local replay
still triggers fallback (phantom_empty_row signal in this run) and
emits correct Shawnee/BVP/Sonoma rows with correct census tracts.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 11:23:03 -07:00
5 changed files with 1226 additions and 61 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@firecrawl/pdf-inspector",
"version": "1.7.1",
"version": "1.7.2",
"description": "Fast PDF classification and text extraction. Detect text-based vs scanned PDFs, extract text by region with quality checks. Native Rust performance via napi-rs.",
"main": "index.js",
"types": "index.d.ts",
+54
View File
@@ -99,6 +99,13 @@ pub struct PageRegionTexts {
pub regions: Vec<RegionText>,
}
/// Vector-grid detection result compatible with `extractTablesWithStructure*`.
#[napi(object)]
pub struct VectorGridDetectionJs {
pub structure_tokens: Vec<String>,
pub cell_bboxes: Vec<Vec<f64>>,
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -317,6 +324,53 @@ pub fn extract_tables_in_regions(
})
}
/// Detect a vector ruled-line / rectangle grid inside one page region.
///
/// Returns TSR-compatible structure tokens plus crop-pixel cell bboxes, or
/// `null` when the region does not contain a valid vector grid.
///
/// `pageIdx` is 0-indexed. `regionPdfPtBbox` is `[x1,y1,x2,y2]` in PDF
/// points with top-left origin. `renderDpi` is the DPI of the crop image that
/// will consume the returned cell bboxes.
#[napi]
pub fn detect_vector_grid_in_region(
buffer: Buffer,
page_idx: u32,
region_pdf_pt_bbox: Vec<f64>,
render_dpi: f64,
) -> Result<Option<VectorGridDetectionJs>> {
let bytes: Vec<u8> = buffer.to_vec();
let region = if region_pdf_pt_bbox.len() == 4 {
[
region_pdf_pt_bbox[0] as f32,
region_pdf_pt_bbox[1] as f32,
region_pdf_pt_bbox[2] as f32,
region_pdf_pt_bbox[3] as f32,
]
} else {
[0.0, 0.0, 0.0, 0.0]
};
catch_panic("detect_vector_grid_in_region", move || {
let result = pdf_inspector::detect_vector_grid_in_region_mem(
&bytes,
page_idx,
region,
render_dpi as f32,
)
.map_err(|e| to_napi_err(e, "detect_vector_grid_in_region"))?;
Ok(result.map(|r| VectorGridDetectionJs {
structure_tokens: r.structure_tokens,
cell_bboxes: r
.cell_bboxes
.into_iter()
.map(|bbox| bbox.into_iter().map(|v| v as f64).collect())
.collect(),
}))
})
}
/// One cropped table region plus its raw structure-recovery output, for
/// `extractTablesWithStructure`.
///
+12
View File
@@ -7,6 +7,7 @@ import {
extractText,
extractTextWithPositions,
extractTextInRegions,
detectVectorGridInRegion,
extractPagesMarkdown,
} from './index.js';
@@ -90,6 +91,17 @@ assert.equal(typeof regionResults[0].regions[0].text, 'string');
assert.equal(typeof regionResults[0].regions[0].needsOcr, 'boolean');
console.log(' extractTextInRegions: OK');
// --- detectVectorGridInRegion ---
console.log('Testing detectVectorGridInRegion...');
const vectorGrid = detectVectorGridInRegion(fixture, 0, [0, 0, 600, 800], 72);
assert.ok(vectorGrid === null || typeof vectorGrid === 'object');
if (vectorGrid) {
assert.ok(Array.isArray(vectorGrid.structureTokens));
assert.ok(Array.isArray(vectorGrid.cellBboxes));
assert.ok(vectorGrid.cellBboxes.every(bbox => Array.isArray(bbox) && bbox.length === 4));
}
console.log(' detectVectorGridInRegion: OK');
// --- extractPagesMarkdown ---
console.log('Testing extractPagesMarkdown...');
+742 -56
View File
@@ -784,6 +784,591 @@ pub fn extract_tables_in_regions_mem(
Ok(results)
}
/// Region-scoped vector grid detection result for TSR-compatible callers.
#[derive(Debug, Clone)]
pub struct VectorGridDetection {
/// HTML-like structure tokens consumed by the TSR path.
pub structure_tokens: Vec<String>,
/// One crop-pixel bbox per `<td>` token, in document order.
pub cell_bboxes: Vec<Vec<f32>>,
}
#[derive(Clone, Copy)]
enum VectorGridSource {
Rects,
Lines,
}
/// Detect a vector ruled-line / rectangle grid inside one page region.
///
/// The returned shape intentionally matches [`TsrTableInput`]'s structure
/// fields so callers can hand it to `extract_tables_with_structure_*` and let
/// the existing PDF-text cell fill path populate contents.
pub fn detect_vector_grid_in_region_mem(
buffer: &[u8],
page_idx: u32,
region_pdf_pt_bbox: [f32; 4],
render_dpi: f32,
) -> Result<Option<VectorGridDetection>, PdfError> {
validate_pdf_bytes(buffer)?;
let (doc, _page_count) = load_document_from_mem(buffer)?;
let pages = doc.get_pages();
let page_1idx = page_idx + 1;
let Some(&page_id) = pages.get(&page_1idx) else {
return Ok(None);
};
let needed_pages = HashSet::from([page_1idx]);
let font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed_pages));
let page_h = get_page_height(&doc, page_id).unwrap_or(792.0);
let ((mut items, rects, lines), _has_gid, coords_rotated) =
extractor::content_stream::extract_page_text_items(
&doc,
page_id,
page_1idx,
&font_cmaps,
false,
)?;
text_utils::fix_letterspaced_items(&mut items);
let coords = if coords_rotated {
RegionCoordSpace::Rotated90Ccw
} else {
RegionCoordSpace::Standard
};
if matches!(coords, RegionCoordSpace::Rotated90Ccw) {
// TODO: add a rotated-page vector-grid fixture before enabling this.
// The TSR crop contract is top-left page coordinates, while rotated
// extraction normalizes vector geometry into a synthetic coordinate
// space. Returning None is safer than emitting misleading bboxes.
return Ok(None);
}
let [rx1, ry1, rx2, ry2] = region_pdf_pt_bbox;
let bounds = region_bounds(rx1, ry1, rx2, ry2, page_h, coords);
let items_in_region: Vec<TextItem> = items
.iter()
.filter(|item| region_overlaps_item(item, bounds))
.cloned()
.collect();
if items_in_region.is_empty() {
return Ok(None);
}
let rects_in_region: Vec<PdfRect> = rects
.iter()
.filter(|rect| region_overlaps_rect(rect, bounds))
.cloned()
.collect();
let lines_in_region: Vec<PdfLine> = lines
.iter()
.filter(|line| region_overlaps_line(line, bounds))
.cloned()
.collect();
// Match the existing geometry pipeline priority: rect-backed grids first,
// then line-backed grids. The detector output is only the validity gate;
// bboxes below are rebuilt from the filtered vector geometry because Table
// stores centers for some rect paths and row starts for line paths.
let (rect_tables, _) =
tables::detect_tables_from_rects(&items_in_region, &rects_in_region, page_1idx);
for table in rect_tables {
if let Some(result) = vector_grid_result_from_table(
&table,
VectorGridSource::Rects,
&rects_in_region,
&lines_in_region,
region_pdf_pt_bbox,
render_dpi,
page_h,
coords,
) {
return Ok(Some(result));
}
}
let line_tables =
tables::detect_tables_from_lines(&items_in_region, &lines_in_region, page_1idx);
for table in line_tables {
if let Some(result) = vector_grid_result_from_table(
&table,
VectorGridSource::Lines,
&rects_in_region,
&lines_in_region,
region_pdf_pt_bbox,
render_dpi,
page_h,
coords,
) {
return Ok(Some(result));
}
}
Ok(None)
}
#[allow(clippy::too_many_arguments)]
fn vector_grid_result_from_table(
table: &tables::Table,
source: VectorGridSource,
rects: &[PdfRect],
lines: &[PdfLine],
crop_pdf_pt_bbox: [f32; 4],
render_dpi: f32,
page_height: f32,
coord_space: RegionCoordSpace,
) -> Option<VectorGridDetection> {
let num_rows = table.cells.len();
let num_cols = table.cells.first().map_or(0, Vec::len);
if num_rows == 0 || num_cols == 0 || table.cells.iter().any(|row| row.len() != num_cols) {
return None;
}
let (x_edges, y_edges) = match source {
VectorGridSource::Rects => rect_grid_edges(rects, num_cols, num_rows)
.or_else(|| inferred_grid_edges(table, rects, lines, num_cols, num_rows))?,
VectorGridSource::Lines => line_grid_edges(table, lines, num_cols, num_rows)
.or_else(|| inferred_grid_edges(table, rects, lines, num_cols, num_rows))?,
};
if x_edges.len() != num_cols + 1 || y_edges.len() != num_rows + 1 {
return None;
}
let mut structure_tokens = Vec::with_capacity(num_rows * (num_cols + 2) + 2);
let mut cell_bboxes = Vec::with_capacity(num_rows * num_cols);
structure_tokens.push("<table>".to_string());
// TODO: refactor vector detectors to return normalized `(x_edges, y_edges)`.
// Today `Table.columns` / `Table.rows` have detector-specific semantics,
// so this export reconstructs edges from the validated table plus geometry.
// Keep v1 structural output uniform: the downstream TSR text-fill path
// does not require header semantics, and reliable header detection can be
// layered later without changing the geometry contract.
for r in 0..num_rows {
structure_tokens.push("<tr>".to_string());
for c in 0..num_cols {
structure_tokens.push("<td></td>".to_string());
let bbox_px = extracted_cell_to_crop_px(
[x_edges[c], y_edges[r + 1], x_edges[c + 1], y_edges[r]],
crop_pdf_pt_bbox,
render_dpi,
page_height,
coord_space,
)?;
if !crop_px_bbox_is_plausible(bbox_px, crop_pdf_pt_bbox, render_dpi) {
return None;
}
cell_bboxes.push(bbox_px.to_vec());
}
structure_tokens.push("</tr>".to_string());
}
structure_tokens.push("</table>".to_string());
Some(VectorGridDetection {
structure_tokens,
cell_bboxes,
})
}
fn crop_px_bbox_is_plausible(
bbox_px: [f32; 4],
crop_pdf_pt_bbox: [f32; 4],
render_dpi: f32,
) -> bool {
let ppi = if render_dpi > 0.0 {
render_dpi / 72.0
} else {
1.0
};
let crop_w = (crop_pdf_pt_bbox[2] - crop_pdf_pt_bbox[0]).abs() * ppi;
let crop_h = (crop_pdf_pt_bbox[3] - crop_pdf_pt_bbox[1]).abs() * ppi;
let slack = 1.0;
bbox_px[0] >= -slack
&& bbox_px[1] >= -slack
&& bbox_px[2] <= crop_w + slack
&& bbox_px[3] <= crop_h + slack
}
#[cfg(test)]
mod vector_grid_tests {
use super::crop_px_bbox_is_plausible;
#[test]
fn test_crop_px_bbox_is_plausible_bounds() {
let crop = [10.0, 20.0, 110.0, 220.0];
assert!(crop_px_bbox_is_plausible(
[0.0, 0.0, 100.0, 200.0],
crop,
72.0
));
assert!(crop_px_bbox_is_plausible(
[0.0, 0.0, 200.0, 400.0],
crop,
144.0
));
assert!(!crop_px_bbox_is_plausible(
[-2.0, 0.0, 50.0, 100.0],
crop,
72.0
));
assert!(!crop_px_bbox_is_plausible(
[0.0, -2.0, 50.0, 100.0],
crop,
72.0
));
assert!(!crop_px_bbox_is_plausible(
[0.0, 0.0, 102.0, 200.0],
crop,
72.0
));
assert!(!crop_px_bbox_is_plausible(
[0.0, 0.0, 100.0, 202.0],
crop,
72.0
));
// Boundary values at the existing 1px slack should remain valid.
assert!(crop_px_bbox_is_plausible(
[-1.0, -1.0, 101.0, 201.0],
crop,
72.0
));
// Non-positive DPI falls back to 1.0 ppi, so crop points equal pixels.
assert!(crop_px_bbox_is_plausible(
[0.0, 0.0, 100.0, 200.0],
crop,
0.0
));
assert!(crop_px_bbox_is_plausible(
[0.0, 0.0, 100.0, 200.0],
crop,
-144.0
));
assert!(!crop_px_bbox_is_plausible(
[0.0, 0.0, 102.0, 200.0],
crop,
-144.0
));
}
}
fn line_grid_edges(
table: &tables::Table,
lines: &[PdfLine],
num_cols: usize,
num_rows: usize,
) -> Option<(Vec<f32>, Vec<f32>)> {
if lines.is_empty() || table.columns.len() != num_cols + 1 || table.rows.len() != num_rows {
return None;
}
let angle_tolerance = 2.0_f32.to_radians().tan();
let mut ys = Vec::new();
for line in lines {
let dx = (line.x2 - line.x1).abs();
let dy = (line.y2 - line.y1).abs();
let length = (dx * dx + dy * dy).sqrt();
if length < 20.0 {
continue;
}
if dx > 0.01 && dy / dx <= angle_tolerance {
ys.push((line.y1 + line.y2) * 0.5);
}
}
let mut x_edges = table.columns.clone();
x_edges.sort_by(|a, b| a.total_cmp(b));
let snapped_y = snap_vector_edges(ys, true);
let mut y_edges = Vec::with_capacity(num_rows + 1);
for &row_top in &table.rows {
let matched = snapped_y
.iter()
.copied()
.find(|y| (*y - row_top).abs() <= 3.0)
.unwrap_or(row_top);
y_edges.push(matched);
}
let last_top = *y_edges.last()?;
let bottom = snapped_y
.iter()
.copied()
.filter(|y| *y < last_top - 3.0)
.max_by(|a, b| a.total_cmp(b))?;
y_edges.push(bottom);
if x_edges.len() == num_cols + 1 && y_edges.len() == num_rows + 1 {
Some((x_edges, y_edges))
} else {
None
}
}
fn rect_grid_edges(
rects: &[PdfRect],
num_cols: usize,
num_rows: usize,
) -> Option<(Vec<f32>, Vec<f32>)> {
if rects.is_empty() {
return None;
}
let mut xs = Vec::new();
let mut ys = Vec::new();
for rect in rects {
let (x1, y1, x2, y2) = normalized_rect_edges(rect);
if (x2 - x1) < 5.0 || (y2 - y1) < 5.0 {
continue;
}
xs.push(x1);
xs.push(x2);
ys.push(y1);
ys.push(y2);
}
let x_edges = snap_vector_edges(xs, false);
let y_edges = snap_vector_edges(ys, true);
if x_edges.len() == num_cols + 1 && y_edges.len() == num_rows + 1 {
Some((x_edges, y_edges))
} else {
None
}
}
fn inferred_grid_edges(
table: &tables::Table,
rects: &[PdfRect],
lines: &[PdfLine],
num_cols: usize,
num_rows: usize,
) -> Option<(Vec<f32>, Vec<f32>)> {
let bounds = vector_geometry_bounds(rects, lines);
let x_edges = if table.columns.len() == num_cols + 1 {
let mut edges = table.columns.clone();
edges.sort_by(|a, b| a.total_cmp(b));
Some(edges)
} else {
infer_ascending_edges(&table.columns, num_cols, bounds.map(|b| (b.x_min, b.x_max)))
}?;
let y_edges = if table.rows.len() == num_rows + 1 {
let mut edges = table.rows.clone();
edges.sort_by(|a, b| b.total_cmp(a));
Some(edges)
} else {
infer_descending_edges(&table.rows, num_rows, bounds.map(|b| (b.y_min, b.y_max)))
}?;
Some((x_edges, y_edges))
}
fn infer_ascending_edges(
positions: &[f32],
expected_centers: usize,
bounds: Option<(f32, f32)>,
) -> Option<Vec<f32>> {
if positions.len() != expected_centers || positions.is_empty() {
return None;
}
let mut centers = positions.to_vec();
centers.sort_by(|a, b| a.total_cmp(b));
if centers.len() == 1 {
return None;
}
let mut edges = Vec::with_capacity(centers.len() + 1);
let first_gap = centers[1] - centers[0];
let last_gap = centers[centers.len() - 1] - centers[centers.len() - 2];
let left = bounds
.map(|(min, _)| min)
.filter(|min| min.is_finite() && *min < centers[0])
.unwrap_or(centers[0] - first_gap * 0.5);
let right = bounds
.map(|(_, max)| max)
.filter(|max| max.is_finite() && *max > *centers.last().unwrap())
.unwrap_or(*centers.last().unwrap() + last_gap * 0.5);
edges.push(left);
for pair in centers.windows(2) {
edges.push((pair[0] + pair[1]) * 0.5);
}
edges.push(right);
strictly_ordered(&edges, false).then_some(edges)
}
fn infer_descending_edges(
positions: &[f32],
expected_centers: usize,
bounds: Option<(f32, f32)>,
) -> Option<Vec<f32>> {
if positions.len() != expected_centers || positions.is_empty() {
return None;
}
let mut centers = positions.to_vec();
centers.sort_by(|a, b| b.total_cmp(a));
if centers.len() == 1 {
return None;
}
let mut edges = Vec::with_capacity(centers.len() + 1);
let first_gap = centers[0] - centers[1];
let last_gap = centers[centers.len() - 2] - centers[centers.len() - 1];
let top = bounds
.map(|(_, max)| max)
.filter(|max| max.is_finite() && *max > centers[0])
.unwrap_or(centers[0] + first_gap * 0.5);
let bottom = bounds
.map(|(min, _)| min)
.filter(|min| min.is_finite() && *min < *centers.last().unwrap())
.unwrap_or(*centers.last().unwrap() - last_gap * 0.5);
edges.push(top);
for pair in centers.windows(2) {
edges.push((pair[0] + pair[1]) * 0.5);
}
edges.push(bottom);
strictly_ordered(&edges, true).then_some(edges)
}
fn snap_vector_edges(mut values: Vec<f32>, descending: bool) -> Vec<f32> {
values.retain(|v| v.is_finite());
values.sort_by(|a, b| a.total_cmp(b));
let mut snapped: Vec<f32> = Vec::new();
let mut cluster: Vec<f32> = Vec::new();
for value in values {
if cluster
.last()
.is_some_and(|last| (value - *last).abs() <= 3.0)
{
cluster.push(value);
} else {
if !cluster.is_empty() {
snapped.push(cluster.iter().sum::<f32>() / cluster.len() as f32);
}
cluster = vec![value];
}
}
if !cluster.is_empty() {
snapped.push(cluster.iter().sum::<f32>() / cluster.len() as f32);
}
if descending {
snapped.sort_by(|a, b| b.total_cmp(a));
}
snapped
}
fn strictly_ordered(values: &[f32], descending: bool) -> bool {
values.windows(2).all(|pair| {
pair[0].is_finite()
&& pair[1].is_finite()
&& if descending {
pair[0] > pair[1]
} else {
pair[0] < pair[1]
}
})
}
fn normalized_rect_edges(rect: &PdfRect) -> (f32, f32, f32, f32) {
let x2 = rect.x + rect.width;
let y2 = rect.y + rect.height;
(
rect.x.min(x2),
rect.y.min(y2),
rect.x.max(x2),
rect.y.max(y2),
)
}
fn vector_geometry_bounds(rects: &[PdfRect], lines: &[PdfLine]) -> Option<RegionBounds> {
let mut bounds: Option<RegionBounds> = None;
let mut include = |x1: f32, y1: f32, x2: f32, y2: f32| {
let next = RegionBounds {
x_min: x1.min(x2),
y_min: y1.min(y2),
x_max: x1.max(x2),
y_max: y1.max(y2),
};
bounds = Some(if let Some(prev) = bounds {
RegionBounds {
x_min: prev.x_min.min(next.x_min),
y_min: prev.y_min.min(next.y_min),
x_max: prev.x_max.max(next.x_max),
y_max: prev.y_max.max(next.y_max),
}
} else {
next
});
};
for rect in rects {
let (x1, y1, x2, y2) = normalized_rect_edges(rect);
include(x1, y1, x2, y2);
}
for line in lines {
include(line.x1, line.y1, line.x2, line.y2);
}
bounds
}
fn extracted_cell_to_crop_px(
bbox: [f32; 4],
crop_pdf_pt_bbox: [f32; 4],
render_dpi: f32,
page_height: f32,
coord_space: RegionCoordSpace,
) -> Option<[f32; 4]> {
let [x1, y1, x2, y2] = extracted_bbox_to_page_top_left(bbox, page_height, coord_space);
if !(x1.is_finite() && y1.is_finite() && x2.is_finite() && y2.is_finite()) {
return None;
}
if x1 >= x2 || y1 >= y2 {
return None;
}
let ppi = if render_dpi > 0.0 {
render_dpi / 72.0
} else {
1.0
};
let [crop_x1, crop_y1, _, _] = crop_pdf_pt_bbox;
Some([
(x1 - crop_x1) * ppi,
(y1 - crop_y1) * ppi,
(x2 - crop_x1) * ppi,
(y2 - crop_y1) * ppi,
])
}
fn extracted_bbox_to_page_top_left(
bbox: [f32; 4],
page_height: f32,
coord_space: RegionCoordSpace,
) -> [f32; 4] {
let [x1, y1, x2, y2] = bbox;
let x_min = x1.min(x2);
let x_max = x1.max(x2);
let y_min = y1.min(y2);
let y_max = y1.max(y2);
match coord_space {
RegionCoordSpace::Standard => [x_min, page_height - y_max, x_max, page_height - y_min],
RegionCoordSpace::Rotated90Ccw => {
[-y_max, page_height - x_max, -y_min, page_height - x_min]
}
}
}
// =========================================================================
// Region-based table extraction with external structure recovery (TSR)
// =========================================================================
@@ -1208,11 +1793,13 @@ pub struct TableExtractionResult {
/// * `phantom_empty_row` — a row whose every cell is empty, surrounded
/// above and below by rows with content. SLANet sometimes emits an
/// extra row that doesn't correspond to any visible PDF row.
/// * `multi_row_in_cell` — at least one cell's matched PDF text items
/// span more than 1.3× either the smallest cell height or the tallest
/// contained item's own height, meaning the cell has absorbed text
/// from two adjacent visual rows. SLANet's row under-detection on
/// tightly-packed tables produces this.
/// * `multi_row_in_cell` — at least one `rowspan==1` cell encloses
/// PDF text items that cluster into two distinct visual lines
/// separated by a whitespace gap larger than the line height. Cells
/// declared as `rowspan>1` are excluded since they are *expected*
/// to span multiple lines. SLANet's row under-detection on
/// tightly-packed tables produces the rowspan==1-but-multi-line
/// pattern (the FNBO failure mode).
fn detect_tsr_quality_issue(
buffer: &[u8],
input: &TsrTableInput,
@@ -1238,10 +1825,12 @@ fn detect_tsr_quality_issue(
}
}
// Multi-row-in-cell: re-extract PDF text items in the page and check
// whether any non-empty cell's bbox encloses items whose y-centers
// span across multiple visual lines. This is the FNBO failure mode
// a tall TSR cell catches text from two adjacent PDF rows.
// Multi-row-in-cell: re-extract PDF text items in the page and look
// for `rowspan==1` cells that contain items grouped into ≥2 visual
// lines separated by a real whitespace gap. This is the FNBO mode:
// a tall TSR cell catches text from two adjacent PDF rows that
// SLANet failed to separate. Cells declared `rowspan>1` are
// expected to be multi-line and are excluded.
let (doc, _page_count) = load_document_from_mem(buffer)?;
let pages = doc.get_pages();
let page_1idx = input.page + 1;
@@ -1267,20 +1856,11 @@ fn detect_tsr_quality_issue(
RegionCoordSpace::Standard
};
// Use the minimum non-empty cell height as the typical-row baseline.
// The pathology is that some cells are abnormally tall (multi-row),
// so taking the median or mean would scale with the bad cells. The
// smallest cell is likely a tightly-bound single-row cell, which is
// a better proxy for a real row's height.
let mut heights: Vec<f32> = cells
.iter()
.map(|c| (c.page_pt_bbox[3] - c.page_pt_bbox[1]).abs())
.filter(|h| *h > 0.0)
.collect();
heights.sort_by(|a, b| a.total_cmp(b));
let typical_row_h = heights.first().copied().unwrap_or(15.0).max(5.0);
for cell in cells {
// rowspan>1 cells are intentionally multi-line — skip them.
if cell.rowspan > 1 {
continue;
}
if cell.text.trim().is_empty() {
continue;
}
@@ -1289,31 +1869,51 @@ fn detect_tsr_quality_issue(
continue;
}
let bounds = region_bounds(x1, y1, x2, y2, page_h, coords);
let mut min_y = f32::INFINITY;
let mut max_y = f32::NEG_INFINITY;
let mut max_item_h = 0f32;
let mut count = 0u32;
// Collect the items inside this cell, with their y-centers and
// half-heights so we can cluster them into visual lines.
let mut cell_items: Vec<(f32, f32)> = Vec::new();
for item in &items {
if tsr_region_contains_item(item, bounds) {
let cy = item.y + item.height * 0.5;
min_y = min_y.min(cy);
max_y = max_y.max(cy);
max_item_h = max_item_h.max(item.height);
count += 1;
let half_h = (item.height * 0.5).max(2.5);
cell_items.push((cy, half_h));
}
}
if count < 2 {
if cell_items.len() < 2 {
continue;
}
// Items on the same visual line have y-centers within ~one
// line-height. Flag a cell whose items span > 1.3× both the
// typical row height AND the largest item's own height —
// either signal alone is a strong indicator of multi-line text
// inside a cell that should be a single row.
let span = max_y - min_y;
let row_threshold = typical_row_h * 1.3;
let item_threshold = max_item_h.max(5.0) * 1.3;
if span > row_threshold || span > item_threshold {
// Sort by y-center descending (top-of-page first in PDF native
// coords where y grows upward) — direction doesn't matter, we
// just need consecutive items to be neighbors in the sort.
cell_items.sort_by(|a, b| b.0.total_cmp(&a.0));
// Walk pairs and see if there's a real whitespace gap between
// any two adjacent items — defined as their bounding-box edges
// separated by more than half a line height. This rules out
// tall glyphs / superscripts / accents on a single visual line.
let max_half_h = cell_items
.iter()
.map(|(_, h)| *h)
.fold(0f32, f32::max)
.max(2.5);
let gap_threshold = max_half_h; // ≈ half a line height
let mut found_gap = false;
for w in cell_items.windows(2) {
let (cy_a, h_a) = w[0];
let (cy_b, h_b) = w[1];
// Gap = distance between the bottom of the upper item and
// the top of the lower item, measured in PDF-native coords
// (y grows upward, so the upper item has the larger cy).
let upper_bottom = cy_a - h_a;
let lower_top = cy_b + h_b;
let gap = upper_bottom - lower_top;
if gap > gap_threshold {
found_gap = true;
break;
}
}
if found_gap {
return Ok(Some("multi_row_in_cell".to_string()));
}
}
@@ -1331,6 +1931,20 @@ fn detect_tsr_quality_issue(
/// On flagged inputs the heuristic markdown replaces the TSR markdown
/// and the result's `fallback_reason` is set to the diagnostic label.
///
/// Two failure modes are guarded against per-input:
///
/// * **Empty heuristic**: if the heuristic returns empty/whitespace
/// markdown for a flagged region, the original TSR markdown is
/// preserved and `fallback_reason` is suffixed with
/// `_heuristic_empty` (e.g. `multi_row_in_cell_heuristic_empty`).
/// This avoids replacing a usable wrong-but-non-empty TSR output
/// with literally nothing.
/// * **Per-input errors**: any failure in detection or heuristic
/// extraction for a single input is contained — that input
/// returns the raw TSR markdown with `fallback_reason` set to
/// an `_error` label so callers can metric on it. Other inputs
/// in the same batch are unaffected.
///
/// Use this from production callers that want self-healing output.
/// Use [`extract_tables_with_structure_mem`] when you want raw TSR
/// output regardless of quality (e.g. eval harnesses comparing the
@@ -1344,32 +1958,65 @@ pub fn extract_tables_with_structure_auto_mem(
for (i, input) in inputs.iter().enumerate() {
let cells = &tsr_cells[i];
let issue = detect_tsr_quality_issue(buffer, input, cells)?;
let tsr_md = if cells.is_empty() {
String::new()
} else {
tables::cells_to_markdown(cells)
};
let issue = match detect_tsr_quality_issue(buffer, input, cells) {
Ok(opt) => opt,
Err(_) => {
// Detection failed for this input — fall through with
// the raw TSR markdown so the rest of the batch is
// unaffected. Tag the reason for caller metrics.
results.push(TableExtractionResult {
markdown: tsr_md,
fallback_reason: Some("detection_error".to_string()),
});
continue;
}
};
let result = match issue {
None => TableExtractionResult {
markdown: if cells.is_empty() {
String::new()
} else {
tables::cells_to_markdown(cells)
},
markdown: tsr_md,
fallback_reason: None,
},
Some(reason) => {
// Fall back to heuristic on the input's table region.
// The crop's PDF-pt bbox IS the table region.
let heuristic = extract_tables_in_regions_mem(
let heuristic_md = match extract_tables_in_regions_mem(
buffer,
&[(input.page, vec![input.crop_pdf_pt_bbox])],
)?;
let md = heuristic
.into_iter()
.next()
.and_then(|page_result| page_result.regions.into_iter().next().map(|r| r.text))
.unwrap_or_default();
TableExtractionResult {
markdown: md,
fallback_reason: Some(reason),
) {
Ok(pages) => pages
.into_iter()
.next()
.and_then(|p| p.regions.into_iter().next().map(|r| r.text))
.unwrap_or_default(),
Err(_) => {
// Heuristic threw — keep raw TSR markdown.
results.push(TableExtractionResult {
markdown: tsr_md,
fallback_reason: Some(format!("{reason}_heuristic_error")),
});
continue;
}
};
if heuristic_md.trim().is_empty() {
// Heuristic produced nothing useful — keep TSR
// markdown rather than ship empty. The reason
// suffix lets callers count this case.
TableExtractionResult {
markdown: tsr_md,
fallback_reason: Some(format!("{reason}_heuristic_empty")),
}
} else {
TableExtractionResult {
markdown: heuristic_md,
fallback_reason: Some(reason),
}
}
}
};
@@ -1591,6 +2238,45 @@ fn region_overlaps_item(item: &TextItem, bounds: RegionBounds) -> bool {
x_overlap > 0.0 && y_overlap > 0.0
}
fn region_overlaps_rect(rect: &PdfRect, bounds: RegionBounds) -> bool {
const REGION_MARGIN: f32 = 1.5;
let (x_min, y_min, x_max, y_max) = normalized_rect_edges(rect);
ranges_overlap(
x_min,
x_max,
bounds.x_min - REGION_MARGIN,
bounds.x_max + REGION_MARGIN,
) && ranges_overlap(
y_min,
y_max,
bounds.y_min - REGION_MARGIN,
bounds.y_max + REGION_MARGIN,
)
}
fn region_overlaps_line(line: &PdfLine, bounds: RegionBounds) -> bool {
const REGION_MARGIN: f32 = 1.5;
let x_min = line.x1.min(line.x2);
let x_max = line.x1.max(line.x2);
let y_min = line.y1.min(line.y2);
let y_max = line.y1.max(line.y2);
ranges_overlap(
x_min,
x_max,
bounds.x_min - REGION_MARGIN,
bounds.x_max + REGION_MARGIN,
) && ranges_overlap(
y_min,
y_max,
bounds.y_min - REGION_MARGIN,
bounds.y_max + REGION_MARGIN,
)
}
fn ranges_overlap(a_min: f32, a_max: f32, b_min: f32, b_max: f32) -> bool {
a_max >= b_min && b_max >= a_min
}
fn tsr_region_contains_item(item: &TextItem, bounds: RegionBounds) -> bool {
let item_x_min = item.x;
let item_x_max = item.x + text_utils::effective_width(item);
+417 -4
View File
@@ -4,10 +4,11 @@ use pdf_inspector::detector::{estimate_page_count_from_bytes, DetectionConfig, S
use pdf_inspector::extractor::group_into_lines;
use pdf_inspector::types::TextLine;
use pdf_inspector::{
detect_pdf_type, extract_pages_markdown, extract_pages_markdown_mem,
extract_tables_in_regions_mem, extract_text, extract_text_in_regions_mem,
extract_text_with_positions, process_pdf_mem, process_pdf_with_options, to_markdown,
MarkdownOptions, PdfError, PdfOptions, PdfType, TextItem,
detect_pdf_type, detect_vector_grid_in_region_mem, extract_pages_markdown,
extract_pages_markdown_mem, extract_tables_in_regions_mem, extract_text,
extract_text_in_regions_mem, extract_text_with_positions, process_pdf_mem,
process_pdf_with_options, to_markdown, MarkdownOptions, PdfError, PdfOptions, PdfType,
TextItem,
};
use std::collections::HashSet;
@@ -1704,6 +1705,205 @@ fn synthetic_dense_table_pdf() -> Vec<u8> {
bytes
}
fn synthetic_vector_grid_pdf(two_tables: bool) -> Vec<u8> {
use lopdf::content::{Content, Operation};
use lopdf::{dictionary, Document, Object, Stream};
fn push_grid(
operations: &mut Vec<Operation>,
x_left: i64,
x_mid: i64,
x_right: i64,
y_top: i64,
y_mid: i64,
y_bottom: i64,
) {
for y in [y_top, y_mid, y_bottom] {
operations.push(Operation::new("m", vec![x_left.into(), y.into()]));
operations.push(Operation::new("l", vec![x_right.into(), y.into()]));
}
for x in [x_left, x_mid, x_right] {
operations.push(Operation::new("m", vec![x.into(), y_bottom.into()]));
operations.push(Operation::new("l", vec![x.into(), y_top.into()]));
}
operations.push(Operation::new("S", vec![]));
}
fn push_text(operations: &mut Vec<Operation>, x: i64, y: i64, text: &str) {
operations.push(Operation::new(
"Tm",
vec![1.into(), 0.into(), 0.into(), 1.into(), x.into(), y.into()],
));
operations.push(Operation::new("Tj", vec![Object::string_literal(text)]));
}
let mut doc = Document::with_version("1.5");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
let font_id = doc.new_object_id();
let content_id = doc.new_object_id();
doc.objects.insert(
font_id,
dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
}
.into(),
);
let mut operations = Vec::new();
push_grid(&mut operations, 50, 130, 210, 740, 710, 670);
if two_tables {
push_grid(&mut operations, 50, 130, 210, 560, 530, 490);
}
operations.push(Operation::new("BT", vec![]));
operations.push(Operation::new("Tf", vec!["F1".into(), 10.into()]));
push_text(&mut operations, 70, 724, "A1");
push_text(&mut operations, 150, 724, "B1");
push_text(&mut operations, 70, 688, "A2");
push_text(&mut operations, 150, 688, "B2");
if two_tables {
push_text(&mut operations, 70, 544, "C1");
push_text(&mut operations, 150, 544, "D1");
push_text(&mut operations, 70, 508, "C2");
push_text(&mut operations, 150, 508, "D2");
}
operations.push(Operation::new("ET", vec![]));
let content = Content { operations }.encode().unwrap();
doc.objects
.insert(content_id, Stream::new(dictionary! {}, content).into());
doc.objects.insert(
page_id,
dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"MediaBox" => vec![0.into(), 0.into(), 300.into(), 800.into()],
"Resources" => dictionary! {
"Font" => dictionary! {
"F1" => font_id,
},
},
"Contents" => content_id,
}
.into(),
);
doc.objects.insert(
pages_id,
dictionary! {
"Type" => "Pages",
"Kids" => vec![page_id.into()],
"Count" => 1,
}
.into(),
);
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"Pages" => pages_id,
});
doc.trailer.set("Root", catalog_id);
let mut bytes = Vec::new();
doc.save_to(&mut bytes).unwrap();
bytes
}
fn assert_close(actual: f32, expected: f32) {
assert!(
(actual - expected).abs() < 0.75,
"expected {actual} to be close to {expected}"
);
}
#[test]
fn test_detect_vector_grid_in_region_line_pdf() {
use pdf_inspector::{extract_tables_with_structure_mem, TsrTableInput};
let buf = synthetic_vector_grid_pdf(false);
let crop = [50.0_f32, 60.0, 210.0, 130.0];
let detected = detect_vector_grid_in_region_mem(&buf, 0, crop, 72.0)
.unwrap()
.expect("ruled vector table should be detected");
assert_eq!(detected.cell_bboxes.len(), 4);
assert_eq!(
detected
.structure_tokens
.iter()
.filter(|tok| tok.as_str() == "<td></td>")
.count(),
4
);
assert_eq!(detected.structure_tokens.first().unwrap(), "<table>");
assert_eq!(detected.structure_tokens.last().unwrap(), "</table>");
let first = &detected.cell_bboxes[0];
assert_close(first[0], 0.0);
assert_close(first[1], 0.0);
assert_close(first[2], 80.0);
assert_close(first[3], 30.0);
let markdown = extract_tables_with_structure_mem(
&buf,
&[TsrTableInput {
page: 0,
crop_pdf_pt_bbox: crop,
render_dpi: 72.0,
structure_tokens: detected.structure_tokens,
cell_bboxes: detected.cell_bboxes,
}],
)
.unwrap()
.remove(0);
assert!(markdown.contains("A1"));
assert!(markdown.contains("B1"));
assert!(markdown.contains("A2"));
assert!(markdown.contains("B2"));
}
#[test]
fn test_detect_vector_grid_in_region_text_pdf_returns_none() {
let buf = make_minimal_text_pdf();
let detected =
detect_vector_grid_in_region_mem(&buf, 0, [0.0, 0.0, 300.0, 800.0], 72.0).unwrap();
assert!(detected.is_none());
}
#[test]
fn test_detect_vector_grid_in_region_filters_to_requested_table() {
use pdf_inspector::{extract_tables_with_structure_mem, TsrTableInput};
let buf = synthetic_vector_grid_pdf(true);
let second_table_crop = [50.0_f32, 240.0, 210.0, 310.0];
let detected = detect_vector_grid_in_region_mem(&buf, 0, second_table_crop, 72.0)
.unwrap()
.expect("second ruled table should be detected");
assert_eq!(detected.cell_bboxes.len(), 4);
let markdown = extract_tables_with_structure_mem(
&buf,
&[TsrTableInput {
page: 0,
crop_pdf_pt_bbox: second_table_crop,
render_dpi: 72.0,
structure_tokens: detected.structure_tokens,
cell_bboxes: detected.cell_bboxes,
}],
)
.unwrap()
.remove(0);
assert!(markdown.contains("C1"));
assert!(markdown.contains("D2"));
assert!(!markdown.contains("A1"));
assert!(!markdown.contains("B2"));
}
#[test]
fn test_extract_tables_with_structure_real_pdf_bits_pilani() {
use pdf_inspector::{extract_tables_with_structure_mem, TsrTableInput};
@@ -2190,6 +2390,219 @@ fn test_auto_returns_empty_inputs() {
assert!(results.is_empty());
}
#[test]
fn test_auto_does_not_fire_on_legit_rowspan_cell() {
use pdf_inspector::{extract_tables_with_structure_auto_mem, TsrTableInput};
let buf = synthetic_dense_table_pdf();
// 2 columns, 3 rows in the visible PDF. SLANet emits a 2-row table
// where the LEFT cell of row 1 is a rowspan=2 cell that legitimately
// covers Oak Street + Boardwalk on two visual lines. The right
// column has two normal rows. multi_row_in_cell must NOT fire on
// the rowspan=2 cell.
let tokens: Vec<String> = [
"<table>",
"<thead>",
"<tr>",
"<th></th>",
"<th></th>",
"</tr>",
"</thead>",
"<tbody>",
"<tr>",
// First data cell explicitly declares rowspan=2.
"<td",
" rowspan=\"2\"",
">",
"</td>",
"<td></td>",
"</tr>",
"<tr>",
"<td></td>",
"</tr>",
"</tbody>",
"</table>",
]
.into_iter()
.map(String::from)
.collect();
// Header row, then a tall left cell covering both data lines, plus
// two narrow right cells (one per line).
let cell_bboxes = vec![
poly(10.0, 88.0, 100.0, 105.0),
poly(90.0, 88.0, 180.0, 105.0),
poly(10.0, 105.0, 100.0, 145.0), // rowspan=2 — covers both lines
poly(90.0, 105.0, 180.0, 122.0), // row 1 only
poly(90.0, 122.0, 180.0, 145.0), // row 2 only
];
let results = extract_tables_with_structure_auto_mem(
&buf,
&[TsrTableInput {
page: 0,
crop_pdf_pt_bbox: [0.0, 0.0, 200.0, 800.0],
render_dpi: 72.0,
structure_tokens: tokens,
cell_bboxes,
}],
)
.unwrap();
assert_eq!(results.len(), 1);
assert!(
results[0].fallback_reason.is_none(),
"rowspan=2 cell containing 2 visual lines should not trip multi_row_in_cell, got reason={:?}",
results[0].fallback_reason,
);
}
#[test]
fn test_auto_keeps_tsr_markdown_when_heuristic_returns_empty() {
use pdf_inspector::{extract_tables_with_structure_auto_mem, TsrTableInput};
let buf = synthetic_dense_table_pdf();
// Same shape as the multi_row_in_cell regression — a tall data cell
// that catches Oak Street + Boardwalk. But the crop bbox we pass
// points at a strip of the page that has NO text items, so the
// heuristic's region will be empty when it tries to extract there.
// The auto wrapper must keep the TSR markdown rather than ship "".
let tokens: Vec<String> = [
"<table>",
"<thead>",
"<tr>",
"<th></th>",
"<th></th>",
"</tr>",
"</thead>",
"<tbody>",
"<tr>",
"<td></td>",
"<td></td>",
"</tr>",
"</tbody>",
"</table>",
]
.into_iter()
.map(String::from)
.collect();
// Cell bboxes overlap the actual PDF text (so multi_row_in_cell
// fires) — but the crop_pdf_pt_bbox we hand to the heuristic is a
// wholly-empty region of the page. The heuristic should return "".
let cell_bboxes = vec![
poly(10.0, 88.0, 100.0, 105.0),
poly(90.0, 88.0, 180.0, 105.0),
poly(10.0, 105.0, 100.0, 145.0),
poly(90.0, 105.0, 180.0, 145.0),
];
let results = extract_tables_with_structure_auto_mem(
&buf,
&[TsrTableInput {
page: 0,
// Crop is at the BOTTOM of the page where there's no text.
crop_pdf_pt_bbox: [0.0, 0.0, 200.0, 50.0],
render_dpi: 72.0,
structure_tokens: tokens,
cell_bboxes,
}],
)
.unwrap();
assert_eq!(results.len(), 1);
let r = &results[0];
assert_eq!(
r.fallback_reason.as_deref(),
Some("multi_row_in_cell_heuristic_empty"),
"expected _heuristic_empty suffix, got {:?}",
r.fallback_reason,
);
// TSR markdown should be preserved — non-empty, contains the cell
// text we know was assigned by the TSR path.
assert!(
!r.markdown.trim().is_empty(),
"expected TSR markdown to be preserved, got empty",
);
assert!(
r.markdown.contains("Oak Street") || r.markdown.contains("Boardwalk"),
"expected TSR markdown to contain at least one row, got: {}",
r.markdown,
);
}
#[test]
fn test_auto_isolates_per_input_failures() {
use pdf_inspector::{extract_tables_with_structure_auto_mem, TsrTableInput};
let buf = synthetic_dense_table_pdf();
let good_tokens: Vec<String> = [
"<table>",
"<thead>",
"<tr>",
"<th></th>",
"<th></th>",
"</tr>",
"</thead>",
"<tbody>",
"<tr>",
"<td></td>",
"<td></td>",
"</tr>",
"<tr>",
"<td></td>",
"<td></td>",
"</tr>",
"</tbody>",
"</table>",
]
.into_iter()
.map(String::from)
.collect();
// A clean input that should pass through with no fallback.
let good_input = TsrTableInput {
page: 0,
crop_pdf_pt_bbox: [0.0, 0.0, 200.0, 800.0],
render_dpi: 72.0,
structure_tokens: good_tokens,
cell_bboxes: vec![
poly(10.0, 72.0, 100.0, 112.0),
poly(90.0, 72.0, 180.0, 112.0),
poly(10.0, 88.8, 100.0, 128.8),
poly(90.0, 88.8, 180.0, 128.8),
poly(10.0, 105.6, 100.0, 145.6),
poly(90.0, 105.6, 180.0, 145.6),
],
};
// A bad input that targets a non-existent page. The detection
// helper short-circuits on missing pages with Ok(None), so this
// shouldn't itself crash, but pairing it with a flagged input
// exercises the per-input control flow regardless. The point of
// this test is that one input's outcome doesn't poison the other.
let bad_input = TsrTableInput {
page: 9999,
crop_pdf_pt_bbox: [0.0, 0.0, 100.0, 100.0],
render_dpi: 72.0,
structure_tokens: vec![
"<table>".into(),
"<tr>".into(),
"<td></td>".into(),
"</tr>".into(),
"</table>".into(),
],
cell_bboxes: vec![poly(0.0, 0.0, 50.0, 50.0)],
};
let results = extract_tables_with_structure_auto_mem(&buf, &[good_input, bad_input]).unwrap();
assert_eq!(results.len(), 2);
// Good input still produces non-empty TSR markdown with no fallback.
assert!(
results[0].fallback_reason.is_none(),
"good input should pass through, got reason={:?}",
results[0].fallback_reason,
);
assert!(results[0].markdown.contains("Oak Street"));
// Bad input collapses to empty markdown but doesn't take the
// batch down with it.
assert_eq!(results[1].markdown, "");
}
// =========================================================================
// extract_pages_markdown_mem tests
// =========================================================================