Compare commits

..
Author SHA1 Message Date
Abimael Martell e301688f4b fix(pdf-inspector): relax vector table confidence gates 2026-05-27 11:35:24 -07:00
4 changed files with 13 additions and 1393 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@firecrawl/pdf-inspector",
"version": "1.9.4",
"version": "1.9.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",
+8 -94
View File
@@ -823,8 +823,7 @@ pub fn extract_tables_in_regions_mem(
// symmetrically low under font-decode failure — this
// guard breaks that symmetry by comparing against
// bbox area, which is independent of extraction.
if source != TableCandidateSource::KeyValue
&& region_text_density_too_low(region_text_chars, region_area)
if region_text_density_too_low(region_text_chars, region_area)
&& !markdown_table_body_is_dense(&md)
{
return None;
@@ -837,10 +836,8 @@ pub fn extract_tables_in_regions_mem(
Some(TableCandidateIssue::LineRowUndercount)
} else if wide_table_sparse_prefix_undercount(&md) {
Some(TableCandidateIssue::SparseWideUndercount)
} else if !matches!(
source,
TableCandidateSource::Line | TableCandidateSource::KeyValue
) && text_cluster_column_undercount(&matched, shape)
} else if source != TableCandidateSource::Line
&& text_cluster_column_undercount(&matched, shape)
{
Some(TableCandidateIssue::TextColumnUndercount)
} else if prose_grid_fragment_needs_ocr(&md) {
@@ -884,16 +881,6 @@ pub fn extract_tables_in_regions_mem(
{
candidates.push(candidate);
}
if let Some(table) = tables::try_build_table_from_columns(&matched, page_1idx) {
if let Some(candidate) = evaluate(TableCandidateSource::Column, &table) {
candidates.push(candidate);
}
}
if let Some(table) = tables::try_build_key_value_table_from_rows(&matched, page_1idx) {
if let Some(candidate) = evaluate(TableCandidateSource::KeyValue, &table) {
candidates.push(candidate);
}
}
match select_table_candidate(&candidates) {
Some(candidate) => page_results.push(RegionText {
@@ -3765,8 +3752,6 @@ enum TableCandidateSource {
Rect,
Line,
Heuristic,
Column,
KeyValue,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -3801,12 +3786,8 @@ fn select_table_candidate(candidates: &[TableCandidate]) -> Option<&TableCandida
// serving a tidy-looking fragment.
if first.issue == Some(TableCandidateIssue::LineRowUndercount) {
return candidates.iter().find(|candidate| {
matches!(
candidate.source,
TableCandidateSource::Heuristic
| TableCandidateSource::Column
| TableCandidateSource::KeyValue
) && candidate.issue.is_none()
candidate.source == TableCandidateSource::Heuristic
&& candidate.issue.is_none()
&& candidate.shape.cols * 10 >= first.shape.cols * 13
});
}
@@ -3824,31 +3805,14 @@ fn select_table_candidate(candidates: &[TableCandidate]) -> Option<&TableCandida
TableCandidateSource::Rect | TableCandidateSource::Line
) {
if let Some(heuristic) = candidates.iter().find(|candidate| {
matches!(
candidate.source,
TableCandidateSource::Heuristic
| TableCandidateSource::Column
| TableCandidateSource::KeyValue
) && candidate.issue.is_none()
candidate.source == TableCandidateSource::Heuristic
&& candidate.issue.is_none()
&& heuristic_substantially_better(candidate.shape, accepted.shape)
}) {
accepted = heuristic;
}
}
if accepted.source == TableCandidateSource::Heuristic {
if let Some(layout_candidate) = candidates.iter().find(|candidate| {
matches!(
candidate.source,
TableCandidateSource::Column | TableCandidateSource::KeyValue
) && candidate.issue.is_none()
&& candidate.shape.cols >= accepted.shape.cols
&& candidate.shape.rows > accepted.shape.rows
}) {
accepted = layout_candidate;
}
}
Some(accepted)
}
@@ -4350,9 +4314,7 @@ fn looks_like_partial_table_ex(markdown: &str, layout_assisted: bool) -> bool {
cell.trim().is_empty() && header_empty_indices.contains(&idx)
})
&& layout_assisted_empty_header_has_dense_body(markdown, n_cols);
let sparse_row_is_section_label = layout_assisted
&& layout_assisted_sparse_section_row_is_ok(data_inner, markdown, n_cols);
if !sparse_row_shares_header_spacer && !sparse_row_is_section_label {
if !sparse_row_shares_header_spacer {
return true;
}
}
@@ -4444,26 +4406,6 @@ fn layout_assisted_empty_header_has_dense_body(markdown: &str, n_cols: usize) ->
&& filled_cells * 100 >= total_cells * 45
}
fn layout_assisted_sparse_section_row_is_ok(row: &[&str], markdown: &str, n_cols: usize) -> bool {
let labels: Vec<&str> = row
.iter()
.map(|cell| cell.trim())
.filter(|cell| !cell.is_empty())
.collect();
if labels.len() != 1 {
return false;
}
let label = labels[0];
if label.len() > 40 || !label.chars().any(|ch| ch.is_alphabetic()) {
return false;
}
if label.ends_with('.') || label.ends_with('!') || label.ends_with('?') || label.ends_with(':')
{
return false;
}
layout_assisted_empty_header_has_dense_body(markdown, n_cols)
}
fn markdown_table_body_is_dense(markdown: &str) -> bool {
let rows = markdown_pipe_rows(markdown);
let data_rows: Vec<&Vec<&str>> = rows
@@ -4845,16 +4787,6 @@ mod table_candidate_selection_tests {
assert_eq!(selected.source, TableCandidateSource::Heuristic);
}
#[test]
fn prefers_clean_column_fallback_when_it_recovers_more_rows() {
let candidates = vec![
candidate(TableCandidateSource::Heuristic, 6, 5, None),
candidate(TableCandidateSource::Column, 7, 5, None),
];
let selected = select_table_candidate(&candidates).unwrap();
assert_eq!(selected.source, TableCandidateSource::Column);
}
#[test]
fn line_candidate_collapsing_captured_y_clusters_is_suspicious() {
let long = "value value value value value value value value value value value value";
@@ -5159,24 +5091,6 @@ mod looks_like_partial_table_tests {
);
}
#[test]
fn sparse_section_row_passes_when_layout_assisted_body_is_dense() {
let md = "|Properties|Conditions|Method|Typical values|Units|\n\
|---|---|---|---|---|\n\
|Rheology|||||\n\
|Melt Flow Rate|230 C/2.16 kg|ASTM D1238|3.0|g/10 min|\n\
|Tensile Stress at Yield|50 mm/min|ASTM D638|31|MPa|\n\
|Elongation at Yield|50 mm/min|ASTM D638|8|%|";
assert!(
looks_like_partial_table(md),
"strict mode rejects the sparse first row"
);
assert!(
!looks_like_partial_table_ex(md, true),
"layout-assisted should allow a short section label above dense table rows"
);
}
#[test]
fn paragraph_still_rejected_when_layout_assisted() {
// Paragraph detection is not relaxed — it's a genuine extraction issue.
+4 -65
View File
@@ -208,24 +208,6 @@ fn looks_like_compact_entry_label(cell: &str) -> bool {
(1..=6).contains(&words)
}
fn looks_like_plain_section_label(cell: &str) -> bool {
let trimmed = cell.trim();
if trimmed.len() < 4 || trimmed.len() > 40 {
return false;
}
if trimmed.ends_with(['.', ',', ';', ':']) || trimmed.contains(|ch: char| ch.is_ascii_digit()) {
return false;
}
if trimmed.len() <= 4 && trimmed.chars().all(|ch| !ch.is_lowercase()) {
return false;
}
trimmed
.chars()
.all(|ch| ch.is_alphabetic() || ch.is_whitespace() || matches!(ch, '&' | '/' | '-'))
&& starts_with_uppercase_alpha(trimmed)
&& (1..=4).contains(&alpha_word_count(trimmed))
}
fn ends_like_incomplete_phrase(cell: &str) -> bool {
let lower = cell.trim_end().to_ascii_lowercase();
lower.ends_with(" and")
@@ -323,10 +305,6 @@ fn clean_table_cells(cells: &[Vec<String>]) -> (Vec<Vec<String>>, Vec<String>) {
.and_then(|r| r.first())
.map(|c| c.trim())
.unwrap_or("");
let header_filled = cleaned
.first()
.map(|r| r.iter().filter(|c| !c.trim().is_empty()).count())
.unwrap_or(num_cols);
let looks_like_spanning_first_column_row = first_cell.is_empty()
&& row.len() >= 4
&& non_first_cells.len() == row.len().saturating_sub(1)
@@ -350,10 +328,6 @@ fn clean_table_cells(cells: &[Vec<String>]) -> (Vec<Vec<String>>, Vec<String>) {
&& non_first_cells
.iter()
.any(|cell| looks_like_compact_entry_label(cell));
let looks_like_section_label_row = !first_cell.is_empty()
&& filled_cells == 1
&& header_filled >= 3
&& looks_like_plain_section_label(first_cell);
// Classic continuation: first cell empty, content in other cells
let is_classic_continuation = first_cell.is_empty()
&& !non_first_cells.is_empty()
@@ -370,6 +344,10 @@ fn clean_table_cells(cells: &[Vec<String>]) -> (Vec<Vec<String>>, Vec<String>) {
.last()
.map(|r| r.iter().filter(|c| !c.trim().is_empty()).count())
.unwrap_or(0);
let header_filled = cleaned
.first()
.map(|r| r.iter().filter(|c| !c.trim().is_empty()).count())
.unwrap_or(num_cols);
// Merge when the row has significantly fewer filled cells than header.
// For wide tables (5+ cols), require ≤50% of header cells.
// For narrow tables (2-4 cols), require fewer than header cells.
@@ -391,7 +369,6 @@ fn clean_table_cells(cells: &[Vec<String>]) -> (Vec<Vec<String>>, Vec<String>) {
&& !looks_like_spanning_first_column_row
&& !looks_like_hierarchical_subrow
&& !looks_like_new_first_column_entry
&& !looks_like_section_label_row
&& !is_short_subheader;
let is_continuation = is_classic_continuation || is_wrapped_continuation;
@@ -537,44 +514,6 @@ mod tests {
assert!(cleaned[1][1].contains("continued text here"));
}
#[test]
fn test_clean_table_cells_first_column_section_label_not_merged() {
let cells = vec![
vec![
"Properties".into(),
"Conditions".into(),
"Method".into(),
"Typical values".into(),
"Units".into(),
],
vec![
"Melt Flow Rate".into(),
"230 C/2.16 kg".into(),
"ASTM D1238".into(),
"3.0".into(),
"g/10 min".into(),
],
vec![
"Mechanical".into(),
"".into(),
"".into(),
"".into(),
"".into(),
],
vec![
"Tensile Stress at Yield".into(),
"50 mm/min".into(),
"ASTM D638".into(),
"31".into(),
"MPa".into(),
],
];
let (cleaned, _) = clean_table_cells(&cells);
assert_eq!(cleaned.len(), 4);
assert_eq!(cleaned[2][0], "Mechanical");
}
#[test]
fn test_clean_table_cells_short_subheader_not_merged() {
let cells = vec![
-1233
View File
File diff suppressed because it is too large Load Diff