Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
468d5dc99f |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@firecrawl/pdf-inspector",
|
"name": "@firecrawl/pdf-inspector",
|
||||||
"version": "1.8.4",
|
"version": "1.8.5",
|
||||||
"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",
|
||||||
|
|||||||
@@ -1015,9 +1015,17 @@ pub(crate) fn extract_page_text_items(
|
|||||||
// producing thousands of identical rects that yield a degenerate grid.
|
// producing thousands of identical rects that yield a degenerate grid.
|
||||||
// After dedup, if too few unique clip rects remain we fall through to
|
// After dedup, if too few unique clip rects remain we fall through to
|
||||||
// fill rects (explicitly drawn visible rectangles).
|
// fill rects (explicitly drawn visible rectangles).
|
||||||
|
//
|
||||||
|
// When fill rects substantially outnumber clip rects, the clips are
|
||||||
|
// typically section-level wrappers and the fills are the actual table
|
||||||
|
// cell backgrounds (e.g. shaded-header tables drawn with `m`/`l`/`h`/`f*`
|
||||||
|
// sequences). In that case, prefer fills.
|
||||||
if rects.is_empty() {
|
if rects.is_empty() {
|
||||||
dedup_rects(&mut clip_rects);
|
dedup_rects(&mut clip_rects);
|
||||||
if clip_rects.len() >= 4 {
|
let prefer_fills = !fill_rects.is_empty() && fill_rects.len() >= clip_rects.len() * 3;
|
||||||
|
if prefer_fills {
|
||||||
|
rects = fill_rects;
|
||||||
|
} else if clip_rects.len() >= 4 {
|
||||||
rects = clip_rects;
|
rects = clip_rects;
|
||||||
} else if !fill_rects.is_empty() {
|
} else if !fill_rects.is_empty() {
|
||||||
rects = fill_rects;
|
rects = fill_rects;
|
||||||
|
|||||||
+84
@@ -1035,6 +1035,90 @@ mod vector_grid_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Helper: load a fixture PDF and run the rect-based table detector.
|
||||||
|
fn detect_rect_tables_in_fixture(path: &str) -> Vec<crate::tables::Table> {
|
||||||
|
use crate::extractor::content_stream::extract_page_text_items;
|
||||||
|
use crate::tables::detect_tables_from_rects;
|
||||||
|
use crate::tounicode::FontCMaps;
|
||||||
|
use lopdf::Document;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let buf = fs::read(path).unwrap();
|
||||||
|
let doc = Document::load_mem(&buf).unwrap();
|
||||||
|
let pages = doc.get_pages();
|
||||||
|
let &page_id = pages.get(&1).unwrap();
|
||||||
|
let needed: HashSet<u32> = HashSet::from([1]);
|
||||||
|
let cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed));
|
||||||
|
let ((items, rects, _lines), _has_gid, _rotated) =
|
||||||
|
extract_page_text_items(&doc, page_id, 1, &cmaps, false).unwrap();
|
||||||
|
|
||||||
|
let (rect_tables, _) = detect_tables_from_rects(&items, &rects, 1);
|
||||||
|
rect_tables
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression for `greencomp_competence.pdf` — a 2-column "Area / Competence"
|
||||||
|
/// glossary with a green-shaded header row and plain (line-drawn) body cells.
|
||||||
|
/// Mirrors the production failure cohort #1 (Contractions glossary) and #6
|
||||||
|
/// (BIO 350 course header): a few colored header rects sit in a horizontal
|
||||||
|
/// strip while body rows are drawn with `m`/`l` operators, so the rect
|
||||||
|
/// cluster has only 2 Y-edges and `try_build_grid` rejects.
|
||||||
|
#[test]
|
||||||
|
fn greencomp_competence_two_cols() {
|
||||||
|
let tables = detect_rect_tables_in_fixture("tests/fixtures/greencomp_competence.pdf");
|
||||||
|
assert!(
|
||||||
|
!tables.is_empty(),
|
||||||
|
"expected at least one rect-detected table for shaded-header + plain-body shape"
|
||||||
|
);
|
||||||
|
let t = tables
|
||||||
|
.iter()
|
||||||
|
.max_by_key(|t| t.rows.len() * t.columns.len())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
t.columns.len(),
|
||||||
|
2,
|
||||||
|
"GreenComp competence is a 2-column table; got {}: {:?}",
|
||||||
|
t.columns.len(),
|
||||||
|
t.columns
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
t.rows.len() >= 6,
|
||||||
|
"expected at least 6 rows of competences; got {}",
|
||||||
|
t.rows.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression for `upstage_key_functions.pdf` — a 4-column "Service Stage /
|
||||||
|
/// Function Name / Explanation / Expected Benefit" table with a blue-shaded
|
||||||
|
/// header band plus alternating row backgrounds. Mirrors production crops
|
||||||
|
/// #2 (Parameter / Value with alternating blue rows) and #7 (Spanish XML
|
||||||
|
/// schema with shaded header). Currently `pdf2md` returns zero markdown
|
||||||
|
/// table rows.
|
||||||
|
#[test]
|
||||||
|
fn upstage_key_functions_four_cols() {
|
||||||
|
let tables = detect_rect_tables_in_fixture("tests/fixtures/upstage_key_functions.pdf");
|
||||||
|
assert!(
|
||||||
|
!tables.is_empty(),
|
||||||
|
"expected at least one rect-detected table for shaded-header + alt-row shape"
|
||||||
|
);
|
||||||
|
let t = tables
|
||||||
|
.iter()
|
||||||
|
.max_by_key(|t| t.rows.len() * t.columns.len())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
t.columns.len(),
|
||||||
|
4,
|
||||||
|
"Service Flow is a 4-column table; got {}: {:?}",
|
||||||
|
t.columns.len(),
|
||||||
|
t.columns
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
t.rows.len() >= 8,
|
||||||
|
"expected at least 8 visible body rows; got {}",
|
||||||
|
t.rows.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_crop_px_bbox_is_plausible_bounds() {
|
fn test_crop_px_bbox_is_plausible_bounds() {
|
||||||
let crop = [10.0, 20.0, 110.0, 220.0];
|
let crop = [10.0, 20.0, 110.0, 220.0];
|
||||||
|
|||||||
@@ -277,6 +277,39 @@ pub fn detect_tables_from_rects(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drop exact / near-exact duplicates first. Many PDFs draw the
|
||||||
|
// same cell rectangle multiple times — once for the cell border,
|
||||||
|
// again for an inner padding fill, plus a per-text-run background
|
||||||
|
// wrapper. Without this dedup, the contained-sub-rect pass below
|
||||||
|
// can't help (it requires container area to strictly exceed the
|
||||||
|
// sub-rect by 20%), and the duplicated edges over-segment the grid
|
||||||
|
// into spurious thin rows / columns that collapse content density.
|
||||||
|
//
|
||||||
|
// Preserve original order (no sort) — cluster output is keyed by
|
||||||
|
// first-seen index, and a sort here would shuffle the table-emission
|
||||||
|
// order on multi-table pages.
|
||||||
|
if page_rects.len() < MAX_CLUSTER_RECTS {
|
||||||
|
let before = page_rects.len();
|
||||||
|
let mut seen: std::collections::HashSet<(i32, i32, i32, i32)> =
|
||||||
|
std::collections::HashSet::new();
|
||||||
|
page_rects.retain(|&(x, y, w, h)| {
|
||||||
|
let key = (
|
||||||
|
x.round() as i32,
|
||||||
|
y.round() as i32,
|
||||||
|
w.round() as i32,
|
||||||
|
h.round() as i32,
|
||||||
|
);
|
||||||
|
seen.insert(key)
|
||||||
|
});
|
||||||
|
if page_rects.len() < before {
|
||||||
|
debug!(
|
||||||
|
"page {}: removed {} duplicate rects",
|
||||||
|
page,
|
||||||
|
before - page_rects.len(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Deduplicate sub-rects: when a rect is fully contained within a
|
// Deduplicate sub-rects: when a rect is fully contained within a
|
||||||
// slightly larger rect (same column, interior Y range), the smaller
|
// slightly larger rect (same column, interior Y range), the smaller
|
||||||
// one is a cell-internal decoration (e.g. content-area shading
|
// one is a cell-internal decoration (e.g. content-area shading
|
||||||
@@ -285,7 +318,11 @@ pub fn detect_tables_from_rects(
|
|||||||
//
|
//
|
||||||
// Only remove when the container is a similarly-sized cell (height
|
// Only remove when the container is a similarly-sized cell (height
|
||||||
// ratio < 4×), NOT when the container is a table-wide background
|
// ratio < 4×), NOT when the container is a table-wide background
|
||||||
// that dwarfs the sub-rect.
|
// that dwarfs the sub-rect. Origin-anchored page-background rects
|
||||||
|
// also disqualify as containers — they normally exceed the 4× ratio,
|
||||||
|
// but when the sub-rect is itself a tall table-frame the ratio can
|
||||||
|
// fall under the gate, and dropping the frame collapses cluster
|
||||||
|
// adjacency between adjacent column-cell groups.
|
||||||
//
|
//
|
||||||
// Skip this O(n²) dedup when there are too many rects — pages with
|
// Skip this O(n²) dedup when there are too many rects — pages with
|
||||||
// thousands of vector-drawing rects won't benefit from cell dedup.
|
// thousands of vector-drawing rects won't benefit from cell dedup.
|
||||||
@@ -295,9 +332,11 @@ pub fn detect_tables_from_rects(
|
|||||||
page_rects.retain(|&(ax, ay, aw, ah)| {
|
page_rects.retain(|&(ax, ay, aw, ah)| {
|
||||||
let tol = 2.0;
|
let tol = 2.0;
|
||||||
!snapshot.iter().any(|&(bx, by, bw, bh)| {
|
!snapshot.iter().any(|&(bx, by, bw, bh)| {
|
||||||
|
let container_is_page_bg = bx < 5.0 && by < 5.0;
|
||||||
// b must strictly contain a (b is larger in area)
|
// b must strictly contain a (b is larger in area)
|
||||||
bw * bh > aw * ah * 1.2
|
bw * bh > aw * ah * 1.2
|
||||||
&& bh < ah * 4.0 // container must be similarly sized, not a table background
|
&& bh < ah * 4.0 // container must be similarly sized, not a table background
|
||||||
|
&& !container_is_page_bg
|
||||||
&& bx <= ax + tol
|
&& bx <= ax + tol
|
||||||
&& (bx + bw) >= (ax + aw) - tol
|
&& (bx + bw) >= (ax + aw) - tol
|
||||||
&& by <= ay + tol
|
&& by <= ay + tol
|
||||||
@@ -1728,9 +1767,17 @@ fn detect_row_stripe_table_from_cell_rects(
|
|||||||
// inside a bounding-box rect (e.g. chat-transcript figures) the
|
// inside a bounding-box rect (e.g. chat-transcript figures) the
|
||||||
// word-boundary gaps cluster into many spurious columns, and the
|
// word-boundary gaps cluster into many spurious columns, and the
|
||||||
// resulting cells hold sentence fragments riddled with common English
|
// resulting cells hold sentence fragments riddled with common English
|
||||||
// function words. Count cells with any such word and reject when
|
// function words.
|
||||||
// 20%+ of non-empty cells match — real tabular data (labels, units,
|
//
|
||||||
// numbers) rarely contains these words.
|
// The 20%-of-cells threshold catches both shapes — a prose paragraph
|
||||||
|
// chunked across cols where every cell carries prose, and a single
|
||||||
|
// prose column flanked by empty cols where the prose dominates the
|
||||||
|
// small population of non-empty cells. To avoid rejecting real data
|
||||||
|
// tables that happen to include one description column, relax only
|
||||||
|
// when content is well-distributed: at least 75% of columns must hold
|
||||||
|
// ≥2 non-empty cells. That excludes the prose-in-a-frame case (one
|
||||||
|
// filled col, the rest empty) while admitting "label / value /
|
||||||
|
// explanation / benefit"-style tables.
|
||||||
if num_cols >= 4 {
|
if num_cols >= 4 {
|
||||||
const PROSE_WORDS: &[&str] = &[
|
const PROSE_WORDS: &[&str] = &[
|
||||||
"a", "an", "the", "of", "to", "is", "was", "are", "were", "be", "been", "in", "on",
|
"a", "an", "the", "of", "to", "is", "was", "are", "were", "be", "been", "in", "on",
|
||||||
@@ -1758,12 +1805,34 @@ fn detect_row_stripe_table_from_cell_rects(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if counted > 0 && prose_cells * 5 >= counted {
|
if counted > 0 && prose_cells * 5 >= counted {
|
||||||
|
let filled_cols = (0..num_cols)
|
||||||
|
.filter(|&c| {
|
||||||
|
cells
|
||||||
|
.iter()
|
||||||
|
.filter(|row| {
|
||||||
|
!row.get(c)
|
||||||
|
.map(String::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.is_empty()
|
||||||
|
})
|
||||||
|
.count()
|
||||||
|
>= 2
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
let well_distributed = filled_cols * 4 >= num_cols * 3;
|
||||||
|
if !well_distributed {
|
||||||
debug!(
|
debug!(
|
||||||
" cell-rect rejected: {}/{} cells contain prose function words — likely prose",
|
" cell-rect rejected: {}/{} cells contain prose function words — likely prose ({}/{} cols filled)",
|
||||||
prose_cells, counted
|
prose_cells, counted, filled_cols, num_cols
|
||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
debug!(
|
||||||
|
" cell-rect prose check relaxed: {}/{} cols filled — table-with-description-col",
|
||||||
|
filled_cols, num_cols
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let column_centers: Vec<f32> = (0..num_cols)
|
let column_centers: Vec<f32> = (0..num_cols)
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Reference in New Issue
Block a user