Compare commits

..
Author SHA1 Message Date
Abimael MartellandClaude Opus 4.7 468d5dc99f tables: lift detection on shaded-header + alt-row tables (#wired-grids)
Production telemetry on `wired_high_confidence`-classified table regions
showed `detect_vector_grid_in_region_mem` returning a usable grid only
~27% of the time, with the rest falling through to GLM-OCR. Three
surgical fixes target the dominant production shapes:

* Path-fill cell backgrounds: when the page has no `re` rects but draws
  cell backgrounds via `m`/`l`/`h`/`f*` sequences, prefer the fill-derived
  rects over the few section-level `W*` clip paths that previously won
  the priority gate. Activated when fill rects outnumber clip rects ≥3×.

* Dedup-induced cluster splits: page-background rects could pose as
  containers in the sub-rect dedup and evict a slightly smaller
  table-frame rect, breaking adjacency between column-cell groups so each
  column became its own cluster. Origin-anchored containers are now
  disqualified from sub-rect dedup. A separate exact-duplicate pass
  collapses the cell-padding/text-bg/cell-border triple emissions some
  PDFs produce, preserving original order to avoid reshuffling table
  output on multi-table pages.

* Prose-words rejection: the `cell-rect` fallback's whole-grid prose
  threshold also rejected real tables that include a description column.
  Now relaxed when content is well-distributed (≥75% of cols filled),
  while keeping the original strictness for prose-in-a-frame layouts.

Two regression fixtures from the opendataloader-bench corpus, covering
the dominant production failure categories:

* `greencomp_competence.pdf` — 2-col shaded-header + plain-body glossary.
  Mirrors production crops #1 (Contractions glossary) and #6 (BIO 350
  course header).
* `upstage_key_functions.pdf` — 4-col shaded-header + alt-row backgrounds
  + merged left column. Mirrors production crops #2 (Parameter/Value
  alt-row), #7 (Spanish XML schema), and #8 (Córdoba multi-row header).

Existing fixtures stay green (doc 51 wrapped-label, doc 128 forecast
six-cols, td9264 snapshot). 133 unit + integration tests pass; clippy
clean.

Bumps napi/package.json 1.8.4 → 1.8.5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:43:44 -07:00
7 changed files with 59 additions and 245 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@firecrawl/pdf-inspector",
"version": "1.8.6",
"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.",
"main": "index.js",
"types": "index.d.ts",
-113
View File
@@ -1057,126 +1057,13 @@ mod vector_grid_tests {
rect_tables
}
/// Regression for the prose-in-a-frame failure mode introduced by the
/// shaded-header detection lift (PR #76). The accessory_building permit
/// form has a paragraph of legal text laid out in a 2-column justified
/// block; the new fill-priority + dedup changes start producing rects
/// for it, and the rect detector then admits a 10×2 fake table where
/// every cell holds a sentence fragment ("I agree to comply...", "I",
/// "It is the property owner's responsibility..."). This test asserts
/// the detector REJECTS that fake table — only the real 5×3 form data
/// table (TYPE / SIZE / SETBACKS) should survive. See pdf-evals PR #30
/// for the original score regression that surfaced this.
#[test]
fn accessory_building_rejects_prose_in_frame() {
let tables = detect_rect_tables_in_fixture(
"tests/fixtures/accessory_building_permit_prose_frame.pdf",
);
// Real form data table (TYPE / SIZE / SETBACKS) must still be detected.
let data_table = tables.iter().find(|t| t.columns.len() == 3);
assert!(
data_table.is_some(),
"expected to keep the 5×3 TYPE/SIZE/SETBACKS data table; got {:?}",
tables
.iter()
.map(|t| (t.rows.len(), t.columns.len()))
.collect::<Vec<_>>()
);
// Prose paragraph laid out in 2 cols must NOT be detected as a table.
// If the rejection regresses, the 10×2 fake table reappears and
// produces fragmented markdown like "I agree to comply..." | "I"
// that fragments mid-sentence.
let prose_table = tables.iter().find(|t| t.columns.len() == 2);
assert!(
prose_table.is_none(),
"expected the 10×2 prose-in-frame block to be rejected; got rows×cols = {:?}",
prose_table.map(|t| (t.rows.len(), t.columns.len()))
);
}
/// Wireless table regression: decorative/text-region rects may provide row
/// bands, but without a real rect-derived column scaffold they must not be
/// accepted as a vector grid.
#[test]
fn wireless_two_col_rejects_rect_grid() {
let tables = detect_rect_tables_in_fixture("tests/fixtures/wireless_two_col_no_rects.pdf");
assert!(
tables.is_empty(),
"expected no rect-detected tables for wireless content; got {:?}",
tables
.iter()
.map(|t| (t.rows.len(), t.columns.len()))
.collect::<Vec<_>>()
);
}
#[test]
fn wireless_two_col_region_rejects_vector_grid() {
let buf = std::fs::read("tests/fixtures/wireless_two_col_no_rects.pdf").unwrap();
let crops = [
[49.32_f32, 52.92, 558.72, 214.2],
[49.32_f32, 288.72, 556.56, 378.0],
[51.48_f32, 478.44, 558.36, 567.36],
];
for crop in crops {
let detected = crate::detect_vector_grid_in_region_mem(&buf, 0, crop, 200.0).unwrap();
assert!(
detected.is_none(),
"expected no vector grid for wireless crop {crop:?}; got {} cells",
detected.map(|grid| grid.cell_bboxes.len()).unwrap_or(0)
);
}
}
/// Wireless dense table regression: text-position columns alone are not
/// enough evidence for a rect-derived grid.
#[test]
fn wireless_dense_rejects_rect_grid() {
let tables = detect_rect_tables_in_fixture("tests/fixtures/wireless_dense_no_rects.pdf");
assert!(
tables.is_empty(),
"expected no rect-detected tables for wireless content; got {:?}",
tables
.iter()
.map(|t| (t.rows.len(), t.columns.len()))
.collect::<Vec<_>>()
);
}
#[test]
fn wireless_dense_region_rejects_vector_grid() {
let buf = std::fs::read("tests/fixtures/wireless_dense_no_rects.pdf").unwrap();
let crops = [
[72.36_f32, 177.48, 243.72, 333.36],
[72.0_f32, 390.24, 286.92, 417.6],
];
for crop in crops {
let detected = crate::detect_vector_grid_in_region_mem(&buf, 0, crop, 200.0).unwrap();
assert!(
detected.is_none(),
"expected no vector grid for wireless crop {crop:?}; got {} cells",
detected.map(|grid| grid.cell_bboxes.len()).unwrap_or(0)
);
}
}
/// 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.
///
/// IGNORED: lifting this shape required the exact-duplicate early-dedup
/// (PR #76 first iteration), which had broad collateral damage on
/// SEC 10-K TOCs and similar docs that draw rule-rects above + below
/// section dividers (production diff: 0001104659-25-093871 lost its
/// TOC structure, perf-graph data table, and qualifications matrix).
/// Re-enable once a more surgical lift exists in `try_build_grid` or
/// `snap_edges` that handles cell-border + inner-fill + text-bg rect
/// triplets without page-wide dedup.
#[test]
#[ignore]
fn greencomp_competence_two_cols() {
let tables = detect_rect_tables_in_fixture("tests/fixtures/greencomp_competence.pdf");
assert!(
+55 -122
View File
@@ -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
// slightly larger rect (same column, interior Y range), the smaller
// one is a cell-internal decoration (e.g. content-area shading
@@ -1632,17 +1665,17 @@ fn detect_row_stripe_table_from_cell_rects(
}
};
let (col_edges, columns_from_text) = match (rect_col_edges, text_col_edges) {
let col_edges = match (rect_col_edges, text_col_edges) {
(Some(rect_edges), Some(text_edges)) if rect_edges.len() <= text_edges.len() => {
debug!(
" cell-rect using {} rect-derived columns over {} text clusters",
rect_edges.len() - 1,
text_edges.len() - 1
);
(rect_edges, false)
rect_edges
}
(_, Some(text_edges)) => (text_edges, true),
(Some(rect_edges), None) => (rect_edges, false),
(_, Some(text_edges)) => text_edges,
(Some(rect_edges), None) => rect_edges,
(None, None) => {
debug!(
" cell-rect rejected: only {} columns from text clustering",
@@ -1731,36 +1764,21 @@ fn detect_row_stripe_table_from_cell_rects(
// Reject "tables" that are actually prose in a framed region.
// Columns here come from text X-position clustering; when prose wraps
// inside a bounding-box rect (e.g. chat-transcript figures, two-column
// legal-text blocks in forms) the word-boundary gaps cluster into
// spurious columns, and the resulting cells hold sentence fragments
// riddled with common English function words.
// inside a bounding-box rect (e.g. chat-transcript figures) the
// word-boundary gaps cluster into many spurious columns, and the
// resulting cells hold sentence fragments riddled with common English
// function words.
//
// Apply at any column count >= 2. The 2-col case is the bite — a
// paragraph wrapped into 2 justified columns produces the same
// surface signal as a real "label / value" table in the
// well-distributed-cols check (both cols populated), so we need a
// content-based signal to tell them apart.
//
// Layered checks combine after the 20%-of-cells prose-word
// trigger fires:
// (a) Long-cell content: prose-in-a-frame averages ~70-100 chars
// per non-empty cell (sentence fragments); real data tables
// are typically <30 chars, occasionally up to ~55 for
// descriptive 4-col tables. The 65-char threshold cleanly
// separates them on observed fixtures (accessory_building
// prose=74 chars, upstage data=53, greencomp=20). This
// overrides the well-distributed relaxation — long cells
// are the strongest prose signal even when both cols are
// populated.
// (b) Two-column text-only scaffold: when both columns were inferred
// from text starts rather than rect edges, prose fragments can look
// perfectly balanced. Require rect evidence for this relaxed shape.
// (c) Well-distributed columns: ≥75% of cols hold ≥2 non-empty
// cells. Catches the prose-paragraph-as-many-cols shape
// while admitting real "label / value / description /
// benefit"-style tables.
if num_cols >= 2 {
// 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 {
const PROSE_WORDS: &[&str] = &[
"a", "an", "the", "of", "to", "is", "was", "are", "were", "be", "been", "in", "on",
"at", "with", "for", "by", "as", "and", "or", "but", "this", "that", "these", "those",
@@ -1770,7 +1788,6 @@ fn detect_row_stripe_table_from_cell_rects(
];
let mut prose_cells = 0usize;
let mut counted = 0usize;
let mut total_chars = 0usize;
for row in &cells {
for cell in row {
let t = cell.trim();
@@ -1778,7 +1795,6 @@ fn detect_row_stripe_table_from_cell_rects(
continue;
}
counted += 1;
total_chars += t.chars().count();
let lower = t.to_ascii_lowercase();
let has_prose_word = lower
.split(|c: char| !c.is_ascii_alphabetic() && c != '\'')
@@ -1789,33 +1805,6 @@ fn detect_row_stripe_table_from_cell_rects(
}
}
if counted > 0 && prose_cells * 5 >= counted {
// (a) Long-cell content: overrides the well-distributed
// relaxation. The 2-col prose-in-a-frame case populates
// both cols (passes well-distributed) but every cell
// holds a sentence fragment, so mean cell length is the
// discriminator.
const PROSE_MEAN_CHAR_THRESHOLD: usize = 65;
let mean_chars = total_chars / counted;
if mean_chars > PROSE_MEAN_CHAR_THRESHOLD {
debug!(
" cell-rect rejected: prose-in-frame, mean non-empty cell {} chars > {} (prose words {}/{})",
mean_chars, PROSE_MEAN_CHAR_THRESHOLD, prose_cells, counted
);
return None;
}
// (b) Two text-derived columns are not enough vector evidence once
// the content looks prose-like. Real 2-col rect tables still pass
// when the column scaffold comes from drawn cell geometry.
if columns_from_text && num_cols == 2 {
debug!(
" cell-rect rejected: prose-in-frame with text-derived 2-col scaffold (mean {} chars, prose words {}/{})",
mean_chars, prose_cells, counted
);
return None;
}
// (c) Well-distributed columns.
let filled_cols = (0..num_cols)
.filter(|&c| {
cells
@@ -1834,14 +1823,14 @@ fn detect_row_stripe_table_from_cell_rects(
let well_distributed = filled_cols * 4 >= num_cols * 3;
if !well_distributed {
debug!(
" cell-rect rejected: {}/{} cells contain prose function words — likely prose ({}/{} cols filled, mean {} chars)",
prose_cells, counted, filled_cols, num_cols, mean_chars
" cell-rect rejected: {}/{} cells contain prose function words — likely prose ({}/{} cols filled)",
prose_cells, counted, filled_cols, num_cols
);
return None;
}
debug!(
" cell-rect prose check relaxed: {}/{} cols filled, mean {} chars — table-with-description-col",
filled_cols, num_cols, mean_chars
" cell-rect prose check relaxed: {}/{} cols filled — table-with-description-col",
filled_cols, num_cols
);
}
}
@@ -3053,62 +3042,6 @@ mod tests {
// If tables were detected, that's also acceptable
}
#[test]
fn text_derived_two_col_prose_is_not_cell_rect_table() {
let page = 1;
let mut rects = Vec::new();
for row in 0..8 {
rects.push(PdfRect {
x: 50.0,
y: 100.0 + row as f32 * 20.0,
width: 180.0,
height: 18.0,
page,
});
}
let mut items = Vec::new();
let left = [
"the annual plan was revised",
"and the team noted changes",
"this section explains limits",
"with additional notes below",
"the policy was reviewed",
"and results are summarized",
"this appendix describes scope",
"with examples for reference",
];
let right = [
"for each area in the review",
"as part of the assessment",
"that were applied in context",
"to support the conclusion",
"for use by the committee",
"as shown in the narrative",
"that remain under discussion",
"to clarify the method",
];
for row in 0..8 {
let y = 104.0 + row as f32 * 20.0;
let mut left_item = make_item(left[row], 60.0, y, 9.0);
left_item.width = 50.0;
items.push(left_item);
let mut right_item = make_item(right[row], 150.0, y, 9.0);
right_item.width = 50.0;
items.push(right_item);
}
let (tables, _hints) = detect_tables_from_rects(&items, &rects, page);
assert!(
tables.is_empty(),
"text-derived two-column prose must not be accepted as a rect table; got {:?}",
tables
.iter()
.map(|t| (t.rows.len(), t.columns.len()))
.collect::<Vec<_>>()
);
}
#[test]
fn failed_cluster_no_hint_without_items() {
// Rects with no text items inside → no failed-cluster hint generated.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -9
View File
@@ -54,14 +54,9 @@ company other than a life insurance company shall make a return on Form 1120PC.
annual statement (or a pro forma annual statement), including the underwriting and investment exhibit for the year covered by such return.
(3) Foreign insurance companies. The provisions of paragraphs (c)(1) and
(c)(2) of this section concerning the returns and statements of insurance companies subject to tax under section 801 or section 831 also apply to foreign insurance companies subject to tax under those sections, except that the copy of the annual statement required to be submitted with the return shall, in the case of a foreign insurance company that is not required to file an annual statement, be a copy of the pro forma annual statement relating to the United States business of such company.
(4) Exception for insurance companies filing their Federal income tax returns
electronically. If an insurance company described in paragraph (c)(1), (c)(2), or
(c)(3) of this section files its Federal income tax return electronically, it should not include on or with such return its annual statement (or pro forma annual statement), or any portion thereof. Such statement must be available at all times for inspection by authorized Internal Revenue Service officers or employees and retained for so long as such statements may be material in the administration of any internal revenue law. See §1.6001-1(e).
(5) Definition. For purposes of this section, the term annual statement means
the annual statement, the form of which is approved by the National Association of Insurance Commissioners (NAIC), which is filed by an insurance company for the year with the insurance departments of States, Territories, and the District of
||(3) Foreign insurance companies. The provisions of paragraphs (c)(1) and|
|---|---|
||(c)(2) of this section concerning the returns and statements of insurance companies subject to tax under section 801 or section 831 also apply to foreign insurance companies subject to tax under those sections, except that the copy of the annual statement required to be submitted with the return shall, in the case of a foreign insurance company that is not required to file an annual statement, be a copy of the pro forma annual statement relating to the United States business of such company. (4) Exception for insurance companies filing their Federal income tax returns electronically. If an insurance company described in paragraph (c)(1), (c)(2), or (c)(3) of this section files its Federal income tax return electronically, it should not include on or with such return its annual statement (or pro forma annual statement), or any portion thereof. Such statement must be available at all times for inspection by authorized Internal Revenue Service officers or employees and retained for so long as such statements may be material in the administration of any internal revenue law. See §1.6001-1(e). (5) Definition. For purposes of this section, the term annual statement means the annual statement, the form of which is approved by the National Association of Insurance Commissioners (NAIC), which is filed by an insurance company for the year with the insurance departments of States, Territories, and the District of|
Columbia. The term annual statement also includes a pro forma annual statement if the insurance company is not required to file the NAIC annual statement.
@@ -206,4 +201,3 @@ CFR part or section where Current OMB identified or described control No.
Deputy Commissioner for Services and Enforcement.
Approved: May 19, 2006 Eric Solomon Acting Deputy Assistant Secretary of the Treasury (Tax Policy).