feat: TSR auto-fallback to heuristic on quality issues, v1.7.1 (#67)
Adds extract_tables_with_structure_auto_mem (Rust) /
extractTablesWithStructureAuto (napi). Returns
TableExtractionResult { markdown, fallback_reason } per input.
The wrapper runs the existing TSR-hybrid path then checks the
resulting cells for two known SLANet detection pathologies:
* phantom_empty_row: empty row sandwiched between non-empty rows
(cheap, cell-metadata only).
* multi_row_in_cell: re-reads PDF text items, flags any cell whose
contained items span >1.3× either the smallest cell height or
the tallest contained item height. Catches the FNBO failure mode
where a tall TSR cell absorbs two adjacent PDF rows.
When either fires, extract_tables_in_regions_mem runs over the same
crop bbox and its markdown replaces the TSR markdown.
fallback_reason carries the diagnostic label so callers can emit
metrics and watch each pathology independently.
Validated on FNBO branches PDF page 1 (Kansas region):
- TSR-only: merges Shawnee into BVP, wrong census tract on Sonoma.
- Auto fallback (phantom_empty_row): each row separate, correct tracts.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
bdea4f345a
commit
fbab84fc20
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@firecrawl/pdf-inspector",
|
"name": "@firecrawl/pdf-inspector",
|
||||||
"version": "1.7.0",
|
"version": "1.7.1",
|
||||||
"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.",
|
"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",
|
"main": "index.js",
|
||||||
"types": "index.d.ts",
|
"types": "index.d.ts",
|
||||||
|
|||||||
@@ -422,6 +422,52 @@ pub fn extract_tables_with_structure_cells(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One result from `extractTablesWithStructureAuto` — markdown plus a
|
||||||
|
/// diagnostic flag identifying which path produced it.
|
||||||
|
///
|
||||||
|
/// `fallbackReason` is `null` when the TSR-hybrid path produced the
|
||||||
|
/// markdown directly. When stage 1's quality check fires (the cells
|
||||||
|
/// look like a SLANet detection pathology — phantom rows or multi-row
|
||||||
|
/// content in a single cell), the heuristic table extractor is run on
|
||||||
|
/// the same region instead, and `fallbackReason` carries the diagnostic
|
||||||
|
/// label (`"phantom_empty_row"`, `"multi_row_in_cell"`).
|
||||||
|
#[napi(object)]
|
||||||
|
pub struct TableExtractionResultJs {
|
||||||
|
pub markdown: String,
|
||||||
|
pub fallback_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auto-fallback variant of [`extractTablesWithStructure`].
|
||||||
|
///
|
||||||
|
/// Runs the TSR-hybrid path, checks the resulting cells for known
|
||||||
|
/// SLANet detection pathologies, and falls back to the heuristic
|
||||||
|
/// `extractTablesInRegions` for any input where the TSR path looks
|
||||||
|
/// compromised.
|
||||||
|
///
|
||||||
|
/// On clean inputs this returns identical markdown to
|
||||||
|
/// `extractTablesWithStructure`; on flagged inputs the heuristic
|
||||||
|
/// markdown replaces the TSR markdown and `fallbackReason` is set.
|
||||||
|
#[napi]
|
||||||
|
pub fn extract_tables_with_structure_auto(
|
||||||
|
buffer: Buffer,
|
||||||
|
inputs: Vec<TsrTableInputJs>,
|
||||||
|
) -> Result<Vec<TableExtractionResultJs>> {
|
||||||
|
let bytes: Vec<u8> = buffer.to_vec();
|
||||||
|
let parsed = parse_tsr_inputs(&inputs);
|
||||||
|
|
||||||
|
catch_panic("extract_tables_with_structure_auto", move || {
|
||||||
|
let result = pdf_inspector::extract_tables_with_structure_auto_mem(&bytes, &parsed)
|
||||||
|
.map_err(|e| to_napi_err(e, "extract_tables_with_structure_auto"))?;
|
||||||
|
Ok(result
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| TableExtractionResultJs {
|
||||||
|
markdown: r.markdown,
|
||||||
|
fallback_reason: r.fallback_reason,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_tsr_inputs(inputs: &[TsrTableInputJs]) -> Vec<pdf_inspector::TsrTableInput> {
|
fn parse_tsr_inputs(inputs: &[TsrTableInputJs]) -> Vec<pdf_inspector::TsrTableInput> {
|
||||||
inputs
|
inputs
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
+194
@@ -1185,6 +1185,200 @@ pub fn extract_tables_with_structure_mem(
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Markdown for one extracted table plus a diagnostic flag describing
|
||||||
|
/// which path produced it.
|
||||||
|
///
|
||||||
|
/// `fallback_reason` is `None` when the TSR-hybrid path produced the
|
||||||
|
/// markdown directly; `Some(<short identifier>)` when stage 1's quality
|
||||||
|
/// check fired and the heuristic `extract_tables_in_regions_mem` was
|
||||||
|
/// substituted instead. The reason string is stable enough to use as a
|
||||||
|
/// metric label (e.g. `phantom_empty_row`, `multi_row_in_cell`).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TableExtractionResult {
|
||||||
|
pub markdown: String,
|
||||||
|
pub fallback_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect quality issues in the TSR-hybrid output for a single input.
|
||||||
|
///
|
||||||
|
/// Returns `Some(reason)` if the cells look like they reflect a known
|
||||||
|
/// SLANet detection pathology that the heuristic table extractor would
|
||||||
|
/// likely handle better. Reasons (also used as metric labels):
|
||||||
|
///
|
||||||
|
/// * `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.
|
||||||
|
fn detect_tsr_quality_issue(
|
||||||
|
buffer: &[u8],
|
||||||
|
input: &TsrTableInput,
|
||||||
|
cells: &[tables::StructuredCell],
|
||||||
|
) -> Result<Option<String>, PdfError> {
|
||||||
|
if cells.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phantom row: cheap, computed from cell metadata alone.
|
||||||
|
let max_row = cells.iter().map(|c| c.row).max().unwrap_or(0);
|
||||||
|
if max_row >= 2 {
|
||||||
|
let mut row_has_content = vec![false; max_row + 1];
|
||||||
|
for cell in cells {
|
||||||
|
if !cell.text.trim().is_empty() {
|
||||||
|
row_has_content[cell.row] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for r in 1..max_row {
|
||||||
|
if !row_has_content[r] && row_has_content[r - 1] && row_has_content[r + 1] {
|
||||||
|
return Ok(Some("phantom_empty_row".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
let (doc, _page_count) = load_document_from_mem(buffer)?;
|
||||||
|
let pages = doc.get_pages();
|
||||||
|
let page_1idx = input.page + 1;
|
||||||
|
let Some(&page_id) = pages.get(&page_1idx) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let page_h = get_page_height(&doc, page_id).unwrap_or(792.0);
|
||||||
|
let mut needed: HashSet<u32> = HashSet::new();
|
||||||
|
needed.insert(page_1idx);
|
||||||
|
let font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed));
|
||||||
|
let ((mut items, _rects, _lines), _has_gid, coords_rotated) =
|
||||||
|
extractor::content_stream::extract_page_text_items(
|
||||||
|
&doc,
|
||||||
|
page_id,
|
||||||
|
page_1idx,
|
||||||
|
&font_cmaps,
|
||||||
|
false,
|
||||||
|
)?;
|
||||||
|
let _ = text_utils::fix_letterspaced_items(&mut items);
|
||||||
|
let coords = if coords_rotated {
|
||||||
|
RegionCoordSpace::Rotated90Ccw
|
||||||
|
} else {
|
||||||
|
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 {
|
||||||
|
if cell.text.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let [x1, y1, x2, y2] = cell.page_pt_bbox;
|
||||||
|
if x1 >= x2 || y1 >= y2 {
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count < 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 {
|
||||||
|
return Ok(Some("multi_row_in_cell".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auto-fallback variant of [`extract_tables_with_structure_mem`]:
|
||||||
|
/// runs the TSR-hybrid path, checks the resulting cells for known
|
||||||
|
/// SLANet detection pathologies (phantom rows, multi-row-in-cell text),
|
||||||
|
/// and falls back to the heuristic [`extract_tables_in_regions_mem`]
|
||||||
|
/// for any input where the TSR path looks compromised.
|
||||||
|
///
|
||||||
|
/// On clean inputs this is identical to the markdown variant.
|
||||||
|
/// On flagged inputs the heuristic markdown replaces the TSR markdown
|
||||||
|
/// and the result's `fallback_reason` is set to the diagnostic label.
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
/// two paths).
|
||||||
|
pub fn extract_tables_with_structure_auto_mem(
|
||||||
|
buffer: &[u8],
|
||||||
|
inputs: &[TsrTableInput],
|
||||||
|
) -> Result<Vec<TableExtractionResult>, PdfError> {
|
||||||
|
let tsr_cells = extract_tables_with_structure_cells_mem(buffer, inputs)?;
|
||||||
|
let mut results = Vec::with_capacity(inputs.len());
|
||||||
|
|
||||||
|
for (i, input) in inputs.iter().enumerate() {
|
||||||
|
let cells = &tsr_cells[i];
|
||||||
|
let issue = detect_tsr_quality_issue(buffer, input, cells)?;
|
||||||
|
|
||||||
|
let result = match issue {
|
||||||
|
None => TableExtractionResult {
|
||||||
|
markdown: if cells.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
tables::cells_to_markdown(cells)
|
||||||
|
},
|
||||||
|
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(
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
/// Get page height in points from MediaBox.
|
/// Get page height in points from MediaBox.
|
||||||
fn get_page_height(doc: &Document, page_id: lopdf::ObjectId) -> Option<f32> {
|
fn get_page_height(doc: &Document, page_id: lopdf::ObjectId) -> Option<f32> {
|
||||||
let page_dict = doc.get_dictionary(page_id).ok()?;
|
let page_dict = doc.get_dictionary(page_id).ok()?;
|
||||||
|
|||||||
@@ -2052,6 +2052,144 @@ fn test_extract_tables_with_structure_separator_after_thead() {
|
|||||||
assert_eq!(mds[0], "|Department|Core Courses|\n|---|---|\n|BIO|8.23|\n");
|
assert_eq!(mds[0], "|Department|Core Courses|\n|---|---|\n|BIO|8.23|\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// extract_tables_with_structure_auto_mem tests (TSR + heuristic fallback)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auto_passes_through_clean_tsr_output() {
|
||||||
|
use pdf_inspector::{extract_tables_with_structure_auto_mem, TsrTableInput};
|
||||||
|
|
||||||
|
let buf = synthetic_dense_table_pdf();
|
||||||
|
let 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();
|
||||||
|
// Cells fit each visible row cleanly. Same shape as the existing
|
||||||
|
// dense-overlap regression test — TSR should produce clean output
|
||||||
|
// and the auto wrapper should pass through with no fallback.
|
||||||
|
let 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),
|
||||||
|
];
|
||||||
|
|
||||||
|
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(),
|
||||||
|
"expected no fallback, got {:?}",
|
||||||
|
results[0].fallback_reason
|
||||||
|
);
|
||||||
|
assert!(results[0].markdown.contains("Oak Street"));
|
||||||
|
assert!(results[0].markdown.contains("Boardwalk"));
|
||||||
|
assert!(!results[0].markdown.contains("Oak Street Boardwalk"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auto_falls_back_on_multi_row_in_cell() {
|
||||||
|
use pdf_inspector::{extract_tables_with_structure_auto_mem, TsrTableInput};
|
||||||
|
|
||||||
|
let buf = synthetic_dense_table_pdf();
|
||||||
|
// TSR returns only 2 rows for what's actually 3 visible PDF rows.
|
||||||
|
// Row 1's cells are tall enough to encompass both Oak Street and
|
||||||
|
// Boardwalk text — the FNBO row-undercount pattern.
|
||||||
|
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();
|
||||||
|
// Header row at top-left y=[88, 105] (covers "Branch Name"/"Deposits"
|
||||||
|
// at native y=700, top-left y≈92-103). The "data" row at top-left
|
||||||
|
// y=[105, 145] is intentionally tall — covers BOTH the Oak Street
|
||||||
|
// line (top-left y≈108-119) AND the Boardwalk line (y≈124-135).
|
||||||
|
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_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_eq!(
|
||||||
|
results[0].fallback_reason.as_deref(),
|
||||||
|
Some("multi_row_in_cell"),
|
||||||
|
"expected multi_row_in_cell fallback, got {:?}",
|
||||||
|
results[0].fallback_reason
|
||||||
|
);
|
||||||
|
// The heuristic-fallback markdown should preserve all three PDF rows.
|
||||||
|
let md = &results[0].markdown;
|
||||||
|
assert!(md.contains("Oak Street"), "missing Oak Street: {md}");
|
||||||
|
assert!(md.contains("Boardwalk"), "missing Boardwalk: {md}");
|
||||||
|
assert!(md.contains("100"), "missing 100: {md}");
|
||||||
|
assert!(md.contains("200"), "missing 200: {md}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auto_returns_empty_inputs() {
|
||||||
|
use pdf_inspector::extract_tables_with_structure_auto_mem;
|
||||||
|
let buf = synthetic_dense_table_pdf();
|
||||||
|
let results = extract_tables_with_structure_auto_mem(&buf, &[]).unwrap();
|
||||||
|
assert!(results.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// extract_pages_markdown_mem tests
|
// extract_pages_markdown_mem tests
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user