Compare commits

..
Author SHA1 Message Date
Abimael Martell 82c0a758cd Bump version from 1.8.15 to 1.9.0 2026-05-20 13:35:48 -07:00
Abimael Martell 1f6497197a emit ItemType::Image bboxes for Image XObjects (was: silently dropped)
Background. ItemType::Image, MarkdownOptions::include_images, and the
markdown emitter's image-collection path have all been in the tree
for a while, but no producer ever populated them — content_stream.rs
explicitly `// Skip images — text extraction only` at the Do
operator, and the nested Form-XObject walker in xobjects.rs only
matched XObjectType::Form, silently dropping Image entries. The
declared types were dead code.

This PR lights them up. At every Do that resolves to an Image
XObject (both top-level and nested inside Form XObjects), we now
compute the page-space bbox from the current CTM via a new
`image_bbox_from_ctm` helper — handling both axis-aligned and
rotated/sheared placements via 4-corner AABB — and emit a TextItem
with `item_type: ItemType::Image` and the legacy `[Image: <name>]`
text payload that the markdown emitter already knows how to render.

Callers can now find raster figures via `extract_text_with_positions`
(and the `_mem` variant, newly re-exported at the crate root) without
needing to re-parse the PDF or run a vision/layout model. The intended
consumer is layout-aware text pipelines that want to crop figures and
caption them out-of-band.

Two backstops to avoid silent breakage for existing callers:

  1. `MarkdownOptions::include_images` default flipped `true → false`.
     If it stayed at `true`, every existing user of
     `extract_pages_markdown` would suddenly see `![Image: Im0](image)`
     placeholders inserted throughout their output the moment they
     upgraded. Image data is still available structurally via
     `extract_text_with_positions`; rendering it into markdown is now
     an opt-in. New regression test asserts `extract_pages_markdown`
     output is unchanged for the image-bearing fixture.

  2. Image items now also skip the layout heuristics
     (`detect_columns`, `detect_tables_from_rects`) via a new
     `is_text_layout_item` predicate. Without this filter, an image's
     left edge would land in the column-projection profile and skew
     table column detection — surfaced by
     `vector_grid_tests::upstage_key_functions_four_cols` going from 4
     detected columns to 5 in CI before the filter was added.

Re-exporting `extract_text_with_positions_mem` at the crate root —
strictly additive; mirrors how `extract_pages_markdown_mem` is already
available there.

Tests:

  - test_extract_text_with_positions_emits_image_bboxes — minimal PDF
    with one 200×100 image at (50, 600); asserts one Image item with
    correct bbox + page + text.
  - test_image_xobject_bbox_handles_rotated_ctm — 90° rotated image
    via shear-component CTM; asserts AABB is correct (handles non-
    axis-aligned placements via 4-corner clamp).
  - test_image_emission_does_not_change_default_markdown — asserts no
    `Image:` token leaks into default markdown output, regression
    guard for the include_images flip.
  - test_markdown_options_default_has_include_images_false — explicit
    sentinel so anyone flipping it back catches it in CI.
2026-05-20 13:22:00 -07:00
2 changed files with 12 additions and 151 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@firecrawl/pdf-inspector",
"version": "1.9.1",
"version": "1.9.0",
"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",
+11 -150
View File
@@ -823,9 +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 region_text_density_too_low(region_text_chars, region_area)
&& !markdown_table_body_is_dense(&md)
{
if region_text_density_too_low(region_text_chars, region_area) {
return None;
}
let shape = markdown_table_shape(&md);
@@ -836,9 +834,7 @@ pub fn extract_tables_in_regions_mem(
Some(TableCandidateIssue::LineRowUndercount)
} else if wide_table_sparse_prefix_undercount(&md) {
Some(TableCandidateIssue::SparseWideUndercount)
} else if source != TableCandidateSource::Line
&& text_cluster_column_undercount(&matched, shape)
{
} else if text_cluster_column_undercount(&matched, shape) {
Some(TableCandidateIssue::TextColumnUndercount)
} else if prose_grid_fragment_needs_ocr(&md) {
Some(TableCandidateIssue::ProseGridFragment)
@@ -4257,21 +4253,12 @@ fn looks_like_partial_table_ex(markdown: &str, layout_assisted: bool) -> bool {
}
// Failure mode 2: header has empty cells in a multi-column table.
// When layout-assisted, tolerate merged/spanning header gaps if the
// body is dense. Region bboxes from a layout model often start at a
// visual table whose header cannot be represented faithfully in a
// flat pipe table, while the body rows are still complete enough to use.
let header_empty_indices: Vec<usize> = header_cells
.iter()
.enumerate()
.filter_map(|(idx, cell)| cell.is_empty().then_some(idx))
.collect();
let empty_count = header_empty_indices.len();
// When layout-assisted, allow up to 1 empty header cell (common in
// tables with merged/spanning header cells that we can't represent).
let empty_count = header_cells.iter().filter(|c| c.is_empty()).count();
if layout_assisted {
if n_cols >= 3
&& empty_count >= 2
&& !layout_assisted_empty_header_has_dense_body(markdown, n_cols)
{
// Reject only if >1 empty header cell (2+ means serious boundary issue)
if n_cols >= 3 && empty_count >= 2 {
return true;
}
} else if n_cols >= 3 && empty_count >= 1 {
@@ -4309,14 +4296,7 @@ fn looks_like_partial_table_ex(markdown: &str, layout_assisted: bool) -> bool {
// (totals, subtotals) are common.
let threshold = if layout_assisted { 2 } else { 3 };
if n_cols >= 3 && empty_data * threshold >= n_cols {
let sparse_row_shares_header_spacer = layout_assisted
&& data_inner.iter().enumerate().any(|(idx, cell)| {
cell.trim().is_empty() && header_empty_indices.contains(&idx)
})
&& layout_assisted_empty_header_has_dense_body(markdown, n_cols);
if !sparse_row_shares_header_spacer {
return true;
}
return true;
}
}
}
@@ -4374,68 +4354,6 @@ fn looks_like_partial_table_ex(markdown: &str, layout_assisted: bool) -> bool {
false
}
fn layout_assisted_empty_header_has_dense_body(markdown: &str, n_cols: usize) -> bool {
let rows = markdown_pipe_rows(markdown);
let data_rows: Vec<&Vec<&str>> = rows
.iter()
.skip(1)
.filter(|row| row.iter().any(|cell| !cell.trim().is_empty()))
.collect();
if data_rows.len() < 2 || n_cols < 3 {
return false;
}
let total_cells = data_rows.len() * n_cols;
let mut filled_cells = 0usize;
let mut rows_with_multiple_cells = 0usize;
let mut max_filled_in_row = 0usize;
for row in &data_rows {
let filled = row.iter().filter(|cell| !cell.trim().is_empty()).count();
filled_cells += filled;
max_filled_in_row = max_filled_in_row.max(filled);
if filled >= 2 {
rows_with_multiple_cells += 1;
}
}
// Dense enough to be a useful extraction despite lossy merged headers.
// The row-count gate avoids accepting a single tidy row under a broken
// header, and the density gate keeps sparse fragments on the OCR path.
rows_with_multiple_cells * 2 >= data_rows.len()
&& max_filled_in_row >= n_cols.min(3)
&& filled_cells * 100 >= total_cells * 45
}
fn markdown_table_body_is_dense(markdown: &str) -> bool {
let rows = markdown_pipe_rows(markdown);
let data_rows: Vec<&Vec<&str>> = rows
.iter()
.skip(1)
.filter(|row| row.iter().any(|cell| !cell.trim().is_empty()))
.collect();
if data_rows.len() < 3 {
return false;
}
let cols = rows.iter().map(|row| row.len()).max().unwrap_or_default();
if cols < 3 {
return false;
}
let mut filled_cells = 0usize;
let mut rows_with_multiple_cells = 0usize;
for row in &data_rows {
let filled = row.iter().filter(|cell| !cell.trim().is_empty()).count();
filled_cells += filled;
if filled >= cols.min(3) {
rows_with_multiple_cells += 1;
}
}
let total_cells = data_rows.len() * cols;
rows_with_multiple_cells * 2 >= data_rows.len() && filled_cells * 100 >= total_cells * 45
}
/// Original strict validation (no layout assistance). Used by tests and
/// full-page extraction paths that don't have layout model assistance.
#[cfg(test)]
@@ -4891,9 +4809,7 @@ mod table_candidate_selection_tests {
#[cfg(test)]
mod looks_like_partial_table_tests {
use super::{
looks_like_partial_table, looks_like_partial_table_ex, markdown_table_body_is_dense,
};
use super::{looks_like_partial_table, looks_like_partial_table_ex};
#[test]
fn good_table_passes() {
@@ -5028,29 +4944,11 @@ mod looks_like_partial_table_tests {
#[test]
fn two_empty_headers_still_rejected_when_layout_assisted() {
// A single tidy row is not enough evidence to trust a badly gapped header.
// 2+ empty headers is still bad even with layout assistance.
let md = "|A|||D|\n|---|---|---|---|\n|x|y|z|w|";
assert!(
looks_like_partial_table_ex(md, true),
"2 empty headers with only one body row are rejected even layout-assisted"
);
}
#[test]
fn dense_body_with_empty_merged_header_passes_when_layout_assisted() {
let md = "|Year||Unadjusted Basis|||\n\
|---|---|---|---|---|\n\
|1|.1667|$100,000|$16,670|$16,670|\n\
|2|.3333|$100,000|$33,330|$50,000|\n\
|3|.3333|$100,000|$33,330|$88,330|\n\
|4|.1667|$100,000|$16,670|$100,000|";
assert!(
looks_like_partial_table(md),
"strict mode still rejects merged-header gaps"
);
assert!(
!looks_like_partial_table_ex(md, true),
"layout-assisted should trust a dense body under a merged header"
"2 empty headers rejected even layout-assisted"
);
}
@@ -5075,22 +4973,6 @@ mod looks_like_partial_table_tests {
);
}
#[test]
fn sparse_first_row_with_header_spacer_passes_when_layout_assisted() {
let md = "|Properties|Instruction||Training Datasets Alignment|\n\
|---|---|---|---|\n\
||Alpaca-GPT4 OpenOrca Synth. Math-Instruct||Orca DPO Pairs Ultrafeedback Cleaned|\n\
|Total # Samples|52K 2.91M 126K||12.9K 60.8K 126K|";
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 sparse rows that share a header spacer column"
);
}
#[test]
fn paragraph_still_rejected_when_layout_assisted() {
// Paragraph detection is not relaxed — it's a genuine extraction issue.
@@ -5144,27 +5026,6 @@ mod looks_like_partial_table_tests {
"duplicate headers rejected even layout-assisted"
);
}
#[test]
fn dense_numeric_table_body_is_structurally_trusted() {
let md = "|Year|3-Year|5-Year|7-Year|\n\
|---|---|---|---|\n\
|1|33.0%|20.00%|14.29%|\n\
|2|44.45%|32.00%|24.49%|\n\
|3|14.81%|19.20%|17.49%|\n\
|4|7.41%|11.52%|12.49%|";
assert!(markdown_table_body_is_dense(md));
}
#[test]
fn sparse_markdown_fragment_is_not_structurally_trusted() {
let md = "|A|B|C|D|\n\
|---|---|---|---|\n\
|x||||\n\
|||y||\n\
||||z|";
assert!(!markdown_table_body_is_dense(md));
}
}
/// Analyse extracted items and rects for layout complexity.