Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0de11413ea |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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.",
|
||||
"main": "index.js",
|
||||
"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> {
|
||||
inputs
|
||||
.iter()
|
||||
|
||||
+194
@@ -1185,6 +1185,200 @@ pub fn extract_tables_with_structure_mem(
|
||||
.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.
|
||||
fn get_page_height(doc: &Document, page_id: lopdf::ObjectId) -> Option<f32> {
|
||||
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");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 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
|
||||
// =========================================================================
|
||||
|
||||
Reference in New Issue
Block a user