Compare commits

..
Author SHA1 Message Date
Abimael Martell cabc108d5c test: add crop bbox plausibility coverage
Cover in-crop, out-of-crop, slack-boundary, and non-positive DPI behavior for vector grid cell bbox validation.

Made-with: Cursor
2026-04-28 17:14:17 -07:00
Abimael Martell 860328bd74 fix: address vector grid review feedback
Return null for rotated vector grids until the coordinate transform has coverage and reject out-of-crop cell boxes surfaced by real-PDF smoke testing.

Made-with: Cursor
2026-04-28 16:56:10 -07:00
Abimael Martell 0674ba5155 feat: add vector grid region detector napi export
Expose region-scoped vector PDF grid detection so TSR callers can reuse native geometry before model fallback.

Made-with: Cursor
2026-04-28 16:38:11 -07:00
Abimael MartellandClaude Opus 4.7 d196d435d1 fix: TSR auto-fallback bugs found in review, v1.7.2 (#68)
Three fixes to extract_tables_with_structure_auto_mem (added in
1.7.1) caught by external review:

1. multi_row_in_cell over-triggered on legitimate multi-line cells.
   The previous threshold (item span > 1.3× either smallest cell or
   tallest item height) fires on any cell with 2+ y-separated text
   items — including rowspan>1 cells, wrapped descriptions, and
   superscript/subscript runs. Replaced with two gates:
   - skip cells whose declared rowspan > 1 (intentional multi-line)
   - require an actual whitespace gap (>~half a line height)
     between the bottom of one item and the top of the next, in
     PDF-native y-coordinates. Same-line items with tall glyphs or
     superscripts have negative or near-zero gap; truly separate
     visual rows have gap ≈ leading − line-height.
   FNBO regression test still passes; new test covers a rowspan=2
   cell with two visible text lines and verifies no fallback fires.

2. Heuristic returning empty silently replaced TSR markdown with
   "". The auto wrapper now keeps the TSR markdown when the
   heuristic markdown is empty/whitespace and tags fallback_reason
   with `_heuristic_empty` suffix (e.g.
   `multi_row_in_cell_heuristic_empty`). Worst case we ship the
   same wrong-but-non-empty TSR output we'd have shipped before
   1.7.1; we never replace useful output with literally nothing.

3. One bad input blanked the whole batch. Errors from
   detect_tsr_quality_issue or extract_tables_in_regions_mem now
   stay scoped to the single input — that input falls through to
   raw TSR markdown with a `_error` reason label so callers can
   metric on it. Other inputs in the batch are unaffected.

3 new integration tests:
- test_auto_does_not_fire_on_legit_rowspan_cell
- test_auto_keeps_tsr_markdown_when_heuristic_returns_empty
- test_auto_isolates_per_input_failures

All 6 auto tests + full 123-test suite pass. FNBO local replay
still triggers fallback (phantom_empty_row signal in this run) and
emits correct Shawnee/BVP/Sonoma rows with correct census tracts.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 11:23:03 -07:00
Abimael MartellandClaude Opus 4.7 fbab84fc20 feat: TSR auto-fallback to heuristic on quality issues, v1.7.1 (#67)
Adds extract_tables_with_structure_auto_mem (Rust) /
extractTablesWithStructureAuto (napi). Returns
TableExtractionResult { markdown, fallback_reason } per input.

The wrapper runs the existing TSR-hybrid path then checks the
resulting cells for two known SLANet detection pathologies:

* phantom_empty_row: empty row sandwiched between non-empty rows
  (cheap, cell-metadata only).
* multi_row_in_cell: re-reads PDF text items, flags any cell whose
  contained items span >1.3× either the smallest cell height or
  the tallest contained item height. Catches the FNBO failure mode
  where a tall TSR cell absorbs two adjacent PDF rows.

When either fires, extract_tables_in_regions_mem runs over the same
crop bbox and its markdown replaces the TSR markdown.
fallback_reason carries the diagnostic label so callers can emit
metrics and watch each pathology independently.

Validated on FNBO branches PDF page 1 (Kansas region):
- TSR-only: merges Shawnee into BVP, wrong census tract on Sonoma.
- Auto fallback (phantom_empty_row): each row separate, correct tracts.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 10:13:25 -07:00
Abimael Martell bdea4f345a Bump version from 1.6.4 to 1.7.0 2026-04-27 10:01:30 -07:00
Abimael Martell 8a0f98dee7 repair malformed PDF containers (#65) 2026-04-27 09:38:25 -07:00
Abimael MartellandClaude Opus 4.7 d8894326e8 Stage 1 exclusive item-to-cell assignment, v1.6.4 (#66)
Closes the row-merge pattern observed against FNBO branch list at the
College/Fairway boundary: when SLANet emits cells whose y-extents
overlap between consecutive rows, an item whose center fell in the
overlap region got pulled into BOTH cells, producing run-on cells
(e.g. "Kansas Kansas" + concatenated addresses).

Cause: stage 1's "for each cell, gather items inside" loop allowed an
item to match multiple cells. `normalize_cell_bands` reduces overlap
but is biased when cell-mean-center is offset from actual text baseline
(SLANet bboxes are typically taller than their text content), so the
midpoint-clamp can land on the wrong side of the row boundary, and
items at the boundary still match two cells.

Fix: invert the matching. For each PDF text item, find candidate cells
(those whose bbox satisfies tsr_region_contains_item — center inside
OR >=60% overlap on both axes), and assign to the cell whose CENTER
is geometrically closest. Build per-cell text from the assigned items.
Stage 2 (orphan recovery) is unchanged.

Exclusivity prevents item duplication across cells. The closest-center
rule disambiguates the cell-overlap case naturally without aggressive
band clamping. normalize_cell_bands stays — it tightens cells before
matching (smaller overlap → fewer ambiguous candidates) but is no
longer load-bearing for correctness of the overlap case.

Local replay against FNBO via api/scripts/local-tsr-replay.ts (which
exercises the full layout-pod → table-pod → pdf-inspector chain
in-process):

  Pre-1.6.4 (deployed 1.6.3):
    |LITH West|Illinois Illinois|11700 S. IL Route 47, Huntley IL...
    |College|||0534.03|
    |Fairway|Kansas Kansas|4650 College Blvd... 2828 Shawnee Mission...

  Post-1.6.4 (this change):
    |Huntley|Illinois|11700 S. IL Route 47, Huntley IL...|8711.15|
    |LITH West|Illinois|4520 W Algonquin Rd, Lake in the Hills...
    |College|Kansas|4650 College Blvd, Overland Park KS...|0532.01|
    |Fairway|Kansas|2828 Shawnee Mission Pkwy, Fairway KS...|0500.00|

One row (Shawnee in the Kansas section) can still get dropped under
SLANet detection variance — the model occasionally under-detects rows
and emits N structure rows for N+1 PDF rows. The squeezed row's text
gets routed to the structurally-nearest existing cell. This is a
SLANet limitation, not addressable in pdf-inspector without
synthesizing rows from PDF text geometry; deferred.

Tests: 416 lib + 120 integration + 2 doctests pass. clippy + fmt clean.

Bump @firecrawl/pdf-inspector to 1.6.4 (patch — refines stage 1's
matching strategy; no API changes).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 09:22:58 -07:00
5 changed files with 1612 additions and 20 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@firecrawl/pdf-inspector",
"version": "1.6.3",
"version": "1.7.2",
"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",
+100
View File
@@ -99,6 +99,13 @@ pub struct PageRegionTexts {
pub regions: Vec<RegionText>,
}
/// Vector-grid detection result compatible with `extractTablesWithStructure*`.
#[napi(object)]
pub struct VectorGridDetectionJs {
pub structure_tokens: Vec<String>,
pub cell_bboxes: Vec<Vec<f64>>,
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -317,6 +324,53 @@ pub fn extract_tables_in_regions(
})
}
/// Detect a vector ruled-line / rectangle grid inside one page region.
///
/// Returns TSR-compatible structure tokens plus crop-pixel cell bboxes, or
/// `null` when the region does not contain a valid vector grid.
///
/// `pageIdx` is 0-indexed. `regionPdfPtBbox` is `[x1,y1,x2,y2]` in PDF
/// points with top-left origin. `renderDpi` is the DPI of the crop image that
/// will consume the returned cell bboxes.
#[napi]
pub fn detect_vector_grid_in_region(
buffer: Buffer,
page_idx: u32,
region_pdf_pt_bbox: Vec<f64>,
render_dpi: f64,
) -> Result<Option<VectorGridDetectionJs>> {
let bytes: Vec<u8> = buffer.to_vec();
let region = if region_pdf_pt_bbox.len() == 4 {
[
region_pdf_pt_bbox[0] as f32,
region_pdf_pt_bbox[1] as f32,
region_pdf_pt_bbox[2] as f32,
region_pdf_pt_bbox[3] as f32,
]
} else {
[0.0, 0.0, 0.0, 0.0]
};
catch_panic("detect_vector_grid_in_region", move || {
let result = pdf_inspector::detect_vector_grid_in_region_mem(
&bytes,
page_idx,
region,
render_dpi as f32,
)
.map_err(|e| to_napi_err(e, "detect_vector_grid_in_region"))?;
Ok(result.map(|r| VectorGridDetectionJs {
structure_tokens: r.structure_tokens,
cell_bboxes: r
.cell_bboxes
.into_iter()
.map(|bbox| bbox.into_iter().map(|v| v as f64).collect())
.collect(),
}))
})
}
/// One cropped table region plus its raw structure-recovery output, for
/// `extractTablesWithStructure`.
///
@@ -422,6 +476,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()
+12
View File
@@ -7,6 +7,7 @@ import {
extractText,
extractTextWithPositions,
extractTextInRegions,
detectVectorGridInRegion,
extractPagesMarkdown,
} from './index.js';
@@ -90,6 +91,17 @@ assert.equal(typeof regionResults[0].regions[0].text, 'string');
assert.equal(typeof regionResults[0].regions[0].needsOcr, 'boolean');
console.log(' extractTextInRegions: OK');
// --- detectVectorGridInRegion ---
console.log('Testing detectVectorGridInRegion...');
const vectorGrid = detectVectorGridInRegion(fixture, 0, [0, 0, 600, 800], 72);
assert.ok(vectorGrid === null || typeof vectorGrid === 'object');
if (vectorGrid) {
assert.ok(Array.isArray(vectorGrid.structureTokens));
assert.ok(Array.isArray(vectorGrid.cellBboxes));
assert.ok(vectorGrid.cellBboxes.every(bbox => Array.isArray(bbox) && bbox.length === 4));
}
console.log(' detectVectorGridInRegion: OK');
// --- extractPagesMarkdown ---
console.log('Testing extractPagesMarkdown...');
+944 -15
View File
@@ -784,6 +784,591 @@ pub fn extract_tables_in_regions_mem(
Ok(results)
}
/// Region-scoped vector grid detection result for TSR-compatible callers.
#[derive(Debug, Clone)]
pub struct VectorGridDetection {
/// HTML-like structure tokens consumed by the TSR path.
pub structure_tokens: Vec<String>,
/// One crop-pixel bbox per `<td>` token, in document order.
pub cell_bboxes: Vec<Vec<f32>>,
}
#[derive(Clone, Copy)]
enum VectorGridSource {
Rects,
Lines,
}
/// Detect a vector ruled-line / rectangle grid inside one page region.
///
/// The returned shape intentionally matches [`TsrTableInput`]'s structure
/// fields so callers can hand it to `extract_tables_with_structure_*` and let
/// the existing PDF-text cell fill path populate contents.
pub fn detect_vector_grid_in_region_mem(
buffer: &[u8],
page_idx: u32,
region_pdf_pt_bbox: [f32; 4],
render_dpi: f32,
) -> Result<Option<VectorGridDetection>, PdfError> {
validate_pdf_bytes(buffer)?;
let (doc, _page_count) = load_document_from_mem(buffer)?;
let pages = doc.get_pages();
let page_1idx = page_idx + 1;
let Some(&page_id) = pages.get(&page_1idx) else {
return Ok(None);
};
let needed_pages = HashSet::from([page_1idx]);
let font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed_pages));
let page_h = get_page_height(&doc, page_id).unwrap_or(792.0);
let ((mut items, rects, lines), _has_gid, coords_rotated) =
extractor::content_stream::extract_page_text_items(
&doc,
page_id,
page_1idx,
&font_cmaps,
false,
)?;
text_utils::fix_letterspaced_items(&mut items);
let coords = if coords_rotated {
RegionCoordSpace::Rotated90Ccw
} else {
RegionCoordSpace::Standard
};
if matches!(coords, RegionCoordSpace::Rotated90Ccw) {
// TODO: add a rotated-page vector-grid fixture before enabling this.
// The TSR crop contract is top-left page coordinates, while rotated
// extraction normalizes vector geometry into a synthetic coordinate
// space. Returning None is safer than emitting misleading bboxes.
return Ok(None);
}
let [rx1, ry1, rx2, ry2] = region_pdf_pt_bbox;
let bounds = region_bounds(rx1, ry1, rx2, ry2, page_h, coords);
let items_in_region: Vec<TextItem> = items
.iter()
.filter(|item| region_overlaps_item(item, bounds))
.cloned()
.collect();
if items_in_region.is_empty() {
return Ok(None);
}
let rects_in_region: Vec<PdfRect> = rects
.iter()
.filter(|rect| region_overlaps_rect(rect, bounds))
.cloned()
.collect();
let lines_in_region: Vec<PdfLine> = lines
.iter()
.filter(|line| region_overlaps_line(line, bounds))
.cloned()
.collect();
// Match the existing geometry pipeline priority: rect-backed grids first,
// then line-backed grids. The detector output is only the validity gate;
// bboxes below are rebuilt from the filtered vector geometry because Table
// stores centers for some rect paths and row starts for line paths.
let (rect_tables, _) =
tables::detect_tables_from_rects(&items_in_region, &rects_in_region, page_1idx);
for table in rect_tables {
if let Some(result) = vector_grid_result_from_table(
&table,
VectorGridSource::Rects,
&rects_in_region,
&lines_in_region,
region_pdf_pt_bbox,
render_dpi,
page_h,
coords,
) {
return Ok(Some(result));
}
}
let line_tables =
tables::detect_tables_from_lines(&items_in_region, &lines_in_region, page_1idx);
for table in line_tables {
if let Some(result) = vector_grid_result_from_table(
&table,
VectorGridSource::Lines,
&rects_in_region,
&lines_in_region,
region_pdf_pt_bbox,
render_dpi,
page_h,
coords,
) {
return Ok(Some(result));
}
}
Ok(None)
}
#[allow(clippy::too_many_arguments)]
fn vector_grid_result_from_table(
table: &tables::Table,
source: VectorGridSource,
rects: &[PdfRect],
lines: &[PdfLine],
crop_pdf_pt_bbox: [f32; 4],
render_dpi: f32,
page_height: f32,
coord_space: RegionCoordSpace,
) -> Option<VectorGridDetection> {
let num_rows = table.cells.len();
let num_cols = table.cells.first().map_or(0, Vec::len);
if num_rows == 0 || num_cols == 0 || table.cells.iter().any(|row| row.len() != num_cols) {
return None;
}
let (x_edges, y_edges) = match source {
VectorGridSource::Rects => rect_grid_edges(rects, num_cols, num_rows)
.or_else(|| inferred_grid_edges(table, rects, lines, num_cols, num_rows))?,
VectorGridSource::Lines => line_grid_edges(table, lines, num_cols, num_rows)
.or_else(|| inferred_grid_edges(table, rects, lines, num_cols, num_rows))?,
};
if x_edges.len() != num_cols + 1 || y_edges.len() != num_rows + 1 {
return None;
}
let mut structure_tokens = Vec::with_capacity(num_rows * (num_cols + 2) + 2);
let mut cell_bboxes = Vec::with_capacity(num_rows * num_cols);
structure_tokens.push("<table>".to_string());
// TODO: refactor vector detectors to return normalized `(x_edges, y_edges)`.
// Today `Table.columns` / `Table.rows` have detector-specific semantics,
// so this export reconstructs edges from the validated table plus geometry.
// Keep v1 structural output uniform: the downstream TSR text-fill path
// does not require header semantics, and reliable header detection can be
// layered later without changing the geometry contract.
for r in 0..num_rows {
structure_tokens.push("<tr>".to_string());
for c in 0..num_cols {
structure_tokens.push("<td></td>".to_string());
let bbox_px = extracted_cell_to_crop_px(
[x_edges[c], y_edges[r + 1], x_edges[c + 1], y_edges[r]],
crop_pdf_pt_bbox,
render_dpi,
page_height,
coord_space,
)?;
if !crop_px_bbox_is_plausible(bbox_px, crop_pdf_pt_bbox, render_dpi) {
return None;
}
cell_bboxes.push(bbox_px.to_vec());
}
structure_tokens.push("</tr>".to_string());
}
structure_tokens.push("</table>".to_string());
Some(VectorGridDetection {
structure_tokens,
cell_bboxes,
})
}
fn crop_px_bbox_is_plausible(
bbox_px: [f32; 4],
crop_pdf_pt_bbox: [f32; 4],
render_dpi: f32,
) -> bool {
let ppi = if render_dpi > 0.0 {
render_dpi / 72.0
} else {
1.0
};
let crop_w = (crop_pdf_pt_bbox[2] - crop_pdf_pt_bbox[0]).abs() * ppi;
let crop_h = (crop_pdf_pt_bbox[3] - crop_pdf_pt_bbox[1]).abs() * ppi;
let slack = 1.0;
bbox_px[0] >= -slack
&& bbox_px[1] >= -slack
&& bbox_px[2] <= crop_w + slack
&& bbox_px[3] <= crop_h + slack
}
#[cfg(test)]
mod vector_grid_tests {
use super::crop_px_bbox_is_plausible;
#[test]
fn test_crop_px_bbox_is_plausible_bounds() {
let crop = [10.0, 20.0, 110.0, 220.0];
assert!(crop_px_bbox_is_plausible(
[0.0, 0.0, 100.0, 200.0],
crop,
72.0
));
assert!(crop_px_bbox_is_plausible(
[0.0, 0.0, 200.0, 400.0],
crop,
144.0
));
assert!(!crop_px_bbox_is_plausible(
[-2.0, 0.0, 50.0, 100.0],
crop,
72.0
));
assert!(!crop_px_bbox_is_plausible(
[0.0, -2.0, 50.0, 100.0],
crop,
72.0
));
assert!(!crop_px_bbox_is_plausible(
[0.0, 0.0, 102.0, 200.0],
crop,
72.0
));
assert!(!crop_px_bbox_is_plausible(
[0.0, 0.0, 100.0, 202.0],
crop,
72.0
));
// Boundary values at the existing 1px slack should remain valid.
assert!(crop_px_bbox_is_plausible(
[-1.0, -1.0, 101.0, 201.0],
crop,
72.0
));
// Non-positive DPI falls back to 1.0 ppi, so crop points equal pixels.
assert!(crop_px_bbox_is_plausible(
[0.0, 0.0, 100.0, 200.0],
crop,
0.0
));
assert!(crop_px_bbox_is_plausible(
[0.0, 0.0, 100.0, 200.0],
crop,
-144.0
));
assert!(!crop_px_bbox_is_plausible(
[0.0, 0.0, 102.0, 200.0],
crop,
-144.0
));
}
}
fn line_grid_edges(
table: &tables::Table,
lines: &[PdfLine],
num_cols: usize,
num_rows: usize,
) -> Option<(Vec<f32>, Vec<f32>)> {
if lines.is_empty() || table.columns.len() != num_cols + 1 || table.rows.len() != num_rows {
return None;
}
let angle_tolerance = 2.0_f32.to_radians().tan();
let mut ys = Vec::new();
for line in lines {
let dx = (line.x2 - line.x1).abs();
let dy = (line.y2 - line.y1).abs();
let length = (dx * dx + dy * dy).sqrt();
if length < 20.0 {
continue;
}
if dx > 0.01 && dy / dx <= angle_tolerance {
ys.push((line.y1 + line.y2) * 0.5);
}
}
let mut x_edges = table.columns.clone();
x_edges.sort_by(|a, b| a.total_cmp(b));
let snapped_y = snap_vector_edges(ys, true);
let mut y_edges = Vec::with_capacity(num_rows + 1);
for &row_top in &table.rows {
let matched = snapped_y
.iter()
.copied()
.find(|y| (*y - row_top).abs() <= 3.0)
.unwrap_or(row_top);
y_edges.push(matched);
}
let last_top = *y_edges.last()?;
let bottom = snapped_y
.iter()
.copied()
.filter(|y| *y < last_top - 3.0)
.max_by(|a, b| a.total_cmp(b))?;
y_edges.push(bottom);
if x_edges.len() == num_cols + 1 && y_edges.len() == num_rows + 1 {
Some((x_edges, y_edges))
} else {
None
}
}
fn rect_grid_edges(
rects: &[PdfRect],
num_cols: usize,
num_rows: usize,
) -> Option<(Vec<f32>, Vec<f32>)> {
if rects.is_empty() {
return None;
}
let mut xs = Vec::new();
let mut ys = Vec::new();
for rect in rects {
let (x1, y1, x2, y2) = normalized_rect_edges(rect);
if (x2 - x1) < 5.0 || (y2 - y1) < 5.0 {
continue;
}
xs.push(x1);
xs.push(x2);
ys.push(y1);
ys.push(y2);
}
let x_edges = snap_vector_edges(xs, false);
let y_edges = snap_vector_edges(ys, true);
if x_edges.len() == num_cols + 1 && y_edges.len() == num_rows + 1 {
Some((x_edges, y_edges))
} else {
None
}
}
fn inferred_grid_edges(
table: &tables::Table,
rects: &[PdfRect],
lines: &[PdfLine],
num_cols: usize,
num_rows: usize,
) -> Option<(Vec<f32>, Vec<f32>)> {
let bounds = vector_geometry_bounds(rects, lines);
let x_edges = if table.columns.len() == num_cols + 1 {
let mut edges = table.columns.clone();
edges.sort_by(|a, b| a.total_cmp(b));
Some(edges)
} else {
infer_ascending_edges(&table.columns, num_cols, bounds.map(|b| (b.x_min, b.x_max)))
}?;
let y_edges = if table.rows.len() == num_rows + 1 {
let mut edges = table.rows.clone();
edges.sort_by(|a, b| b.total_cmp(a));
Some(edges)
} else {
infer_descending_edges(&table.rows, num_rows, bounds.map(|b| (b.y_min, b.y_max)))
}?;
Some((x_edges, y_edges))
}
fn infer_ascending_edges(
positions: &[f32],
expected_centers: usize,
bounds: Option<(f32, f32)>,
) -> Option<Vec<f32>> {
if positions.len() != expected_centers || positions.is_empty() {
return None;
}
let mut centers = positions.to_vec();
centers.sort_by(|a, b| a.total_cmp(b));
if centers.len() == 1 {
return None;
}
let mut edges = Vec::with_capacity(centers.len() + 1);
let first_gap = centers[1] - centers[0];
let last_gap = centers[centers.len() - 1] - centers[centers.len() - 2];
let left = bounds
.map(|(min, _)| min)
.filter(|min| min.is_finite() && *min < centers[0])
.unwrap_or(centers[0] - first_gap * 0.5);
let right = bounds
.map(|(_, max)| max)
.filter(|max| max.is_finite() && *max > *centers.last().unwrap())
.unwrap_or(*centers.last().unwrap() + last_gap * 0.5);
edges.push(left);
for pair in centers.windows(2) {
edges.push((pair[0] + pair[1]) * 0.5);
}
edges.push(right);
strictly_ordered(&edges, false).then_some(edges)
}
fn infer_descending_edges(
positions: &[f32],
expected_centers: usize,
bounds: Option<(f32, f32)>,
) -> Option<Vec<f32>> {
if positions.len() != expected_centers || positions.is_empty() {
return None;
}
let mut centers = positions.to_vec();
centers.sort_by(|a, b| b.total_cmp(a));
if centers.len() == 1 {
return None;
}
let mut edges = Vec::with_capacity(centers.len() + 1);
let first_gap = centers[0] - centers[1];
let last_gap = centers[centers.len() - 2] - centers[centers.len() - 1];
let top = bounds
.map(|(_, max)| max)
.filter(|max| max.is_finite() && *max > centers[0])
.unwrap_or(centers[0] + first_gap * 0.5);
let bottom = bounds
.map(|(min, _)| min)
.filter(|min| min.is_finite() && *min < *centers.last().unwrap())
.unwrap_or(*centers.last().unwrap() - last_gap * 0.5);
edges.push(top);
for pair in centers.windows(2) {
edges.push((pair[0] + pair[1]) * 0.5);
}
edges.push(bottom);
strictly_ordered(&edges, true).then_some(edges)
}
fn snap_vector_edges(mut values: Vec<f32>, descending: bool) -> Vec<f32> {
values.retain(|v| v.is_finite());
values.sort_by(|a, b| a.total_cmp(b));
let mut snapped: Vec<f32> = Vec::new();
let mut cluster: Vec<f32> = Vec::new();
for value in values {
if cluster
.last()
.is_some_and(|last| (value - *last).abs() <= 3.0)
{
cluster.push(value);
} else {
if !cluster.is_empty() {
snapped.push(cluster.iter().sum::<f32>() / cluster.len() as f32);
}
cluster = vec![value];
}
}
if !cluster.is_empty() {
snapped.push(cluster.iter().sum::<f32>() / cluster.len() as f32);
}
if descending {
snapped.sort_by(|a, b| b.total_cmp(a));
}
snapped
}
fn strictly_ordered(values: &[f32], descending: bool) -> bool {
values.windows(2).all(|pair| {
pair[0].is_finite()
&& pair[1].is_finite()
&& if descending {
pair[0] > pair[1]
} else {
pair[0] < pair[1]
}
})
}
fn normalized_rect_edges(rect: &PdfRect) -> (f32, f32, f32, f32) {
let x2 = rect.x + rect.width;
let y2 = rect.y + rect.height;
(
rect.x.min(x2),
rect.y.min(y2),
rect.x.max(x2),
rect.y.max(y2),
)
}
fn vector_geometry_bounds(rects: &[PdfRect], lines: &[PdfLine]) -> Option<RegionBounds> {
let mut bounds: Option<RegionBounds> = None;
let mut include = |x1: f32, y1: f32, x2: f32, y2: f32| {
let next = RegionBounds {
x_min: x1.min(x2),
y_min: y1.min(y2),
x_max: x1.max(x2),
y_max: y1.max(y2),
};
bounds = Some(if let Some(prev) = bounds {
RegionBounds {
x_min: prev.x_min.min(next.x_min),
y_min: prev.y_min.min(next.y_min),
x_max: prev.x_max.max(next.x_max),
y_max: prev.y_max.max(next.y_max),
}
} else {
next
});
};
for rect in rects {
let (x1, y1, x2, y2) = normalized_rect_edges(rect);
include(x1, y1, x2, y2);
}
for line in lines {
include(line.x1, line.y1, line.x2, line.y2);
}
bounds
}
fn extracted_cell_to_crop_px(
bbox: [f32; 4],
crop_pdf_pt_bbox: [f32; 4],
render_dpi: f32,
page_height: f32,
coord_space: RegionCoordSpace,
) -> Option<[f32; 4]> {
let [x1, y1, x2, y2] = extracted_bbox_to_page_top_left(bbox, page_height, coord_space);
if !(x1.is_finite() && y1.is_finite() && x2.is_finite() && y2.is_finite()) {
return None;
}
if x1 >= x2 || y1 >= y2 {
return None;
}
let ppi = if render_dpi > 0.0 {
render_dpi / 72.0
} else {
1.0
};
let [crop_x1, crop_y1, _, _] = crop_pdf_pt_bbox;
Some([
(x1 - crop_x1) * ppi,
(y1 - crop_y1) * ppi,
(x2 - crop_x1) * ppi,
(y2 - crop_y1) * ppi,
])
}
fn extracted_bbox_to_page_top_left(
bbox: [f32; 4],
page_height: f32,
coord_space: RegionCoordSpace,
) -> [f32; 4] {
let [x1, y1, x2, y2] = bbox;
let x_min = x1.min(x2);
let x_max = x1.max(x2);
let y_min = y1.min(y2);
let y_max = y1.max(y2);
match coord_space {
RegionCoordSpace::Standard => [x_min, page_height - y_max, x_max, page_height - y_min],
RegionCoordSpace::Rotated90Ccw => {
[-y_max, page_height - x_max, -y_min, page_height - x_min]
}
}
}
// =========================================================================
// Region-based table extraction with external structure recovery (TSR)
// =========================================================================
@@ -931,24 +1516,73 @@ pub fn extract_tables_with_structure_cells_mem(
normalize_cell_bands(&mut cells);
// Stage 1: strict text fill — each cell gets the items whose centers
// fall inside its (normalized) bbox or whose >=60% overlap rule fires.
// Track which item indices any cell claimed so the orphan pass below
// doesn't double-assign.
// Stage 1: exclusive per-item assignment. For each PDF text item,
// find the cell(s) whose (band-clamped) bbox satisfies the strict
// membership rule (`tsr_region_contains_item`: center inside OR
// >=60% overlap on both axes). If multiple cells qualify, assign
// the item to the cell whose center is geometrically closest. If
// exactly one qualifies, assign to that. If none, the item is an
// orphan and stage 2 below tries to recover it.
//
// The exclusivity (one item → one cell) prevents the cell-overlap
// bug where SLANet emits cells whose y-extents overlap between
// rows: under the previous "for each cell, gather items" approach,
// an item whose center fell in two cells' overlap got duplicated
// into both. Closest-center disambiguation routes it to the
// correct row.
let mut claimed: std::collections::HashSet<usize> = std::collections::HashSet::new();
for cell in &mut cells {
let [x1, y1, x2, y2] = cell.page_pt_bbox;
let bounds = region_bounds(x1, y1, x2, y2, page_h, coords);
let mut matched: Vec<TextItem> = Vec::new();
for (i, item) in items.iter().enumerate() {
if tsr_region_contains_item(item, bounds) {
claimed.insert(i);
matched.push(item.clone());
let mut item_to_cell: std::collections::HashMap<usize, usize> =
std::collections::HashMap::new();
// Pre-compute each cell's bounds + center (in PDF-pt-flipped space)
// so we don't redo the work per item.
let cell_meta: Vec<Option<(RegionBounds, f32, f32)>> = cells
.iter()
.map(|cell| {
let [x1, y1, x2, y2] = cell.page_pt_bbox;
if x1 >= x2 || y1 >= y2 {
return None;
}
let bounds = region_bounds(x1, y1, x2, y2, page_h, coords);
let cx = (bounds.x_min + bounds.x_max) * 0.5;
let cy = (bounds.y_min + bounds.y_max) * 0.5;
Some((bounds, cx, cy))
})
.collect();
for (item_idx, item) in items.iter().enumerate() {
let item_w = text_utils::effective_width(item);
let item_cx = item.x + item_w * 0.5;
let item_cy = item.y + item.height * 0.5;
let mut best: Option<(usize, f32)> = None;
for (cell_idx, meta) in cell_meta.iter().enumerate() {
let Some((bounds, ccx, ccy)) = meta else {
continue;
};
if !tsr_region_contains_item(item, *bounds) {
continue;
}
let dx = item_cx - ccx;
let dy = item_cy - ccy;
let dist_sq = dx * dx + dy * dy;
if best.is_none_or(|(_, d)| dist_sq < d) {
best = Some((cell_idx, dist_sq));
}
}
// Markdown cells must be one line — collapse line breaks produced
// by the line-grouping pass.
cell.text = collect_text_from_matched_items(matched, adaptive_threshold)
if let Some((ci, _)) = best {
claimed.insert(item_idx);
item_to_cell.insert(item_idx, ci);
}
}
// Build per-cell text from the assigned items. Markdown cells must
// be one line — collapse line breaks from the line-grouping pass.
let mut per_cell_items: Vec<Vec<TextItem>> = vec![Vec::new(); cells.len()];
for (&item_idx, &cell_idx) in &item_to_cell {
per_cell_items[cell_idx].push(items[item_idx].clone());
}
for (cell_idx, matched) in per_cell_items.into_iter().enumerate() {
cells[cell_idx].text = collect_text_from_matched_items(matched, adaptive_threshold)
.replace(['\n', '\r'], " ");
}
@@ -1136,6 +1770,262 @@ 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 `rowspan==1` cell encloses
/// PDF text items that cluster into two distinct visual lines
/// separated by a whitespace gap larger than the line height. Cells
/// declared as `rowspan>1` are excluded since they are *expected*
/// to span multiple lines. SLANet's row under-detection on
/// tightly-packed tables produces the rowspan==1-but-multi-line
/// pattern (the FNBO failure mode).
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 look
// for `rowspan==1` cells that contain items grouped into ≥2 visual
// lines separated by a real whitespace gap. This is the FNBO mode:
// a tall TSR cell catches text from two adjacent PDF rows that
// SLANet failed to separate. Cells declared `rowspan>1` are
// expected to be multi-line and are excluded.
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
};
for cell in cells {
// rowspan>1 cells are intentionally multi-line — skip them.
if cell.rowspan > 1 {
continue;
}
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);
// Collect the items inside this cell, with their y-centers and
// half-heights so we can cluster them into visual lines.
let mut cell_items: Vec<(f32, f32)> = Vec::new();
for item in &items {
if tsr_region_contains_item(item, bounds) {
let cy = item.y + item.height * 0.5;
let half_h = (item.height * 0.5).max(2.5);
cell_items.push((cy, half_h));
}
}
if cell_items.len() < 2 {
continue;
}
// Sort by y-center descending (top-of-page first in PDF native
// coords where y grows upward) — direction doesn't matter, we
// just need consecutive items to be neighbors in the sort.
cell_items.sort_by(|a, b| b.0.total_cmp(&a.0));
// Walk pairs and see if there's a real whitespace gap between
// any two adjacent items — defined as their bounding-box edges
// separated by more than half a line height. This rules out
// tall glyphs / superscripts / accents on a single visual line.
let max_half_h = cell_items
.iter()
.map(|(_, h)| *h)
.fold(0f32, f32::max)
.max(2.5);
let gap_threshold = max_half_h; // ≈ half a line height
let mut found_gap = false;
for w in cell_items.windows(2) {
let (cy_a, h_a) = w[0];
let (cy_b, h_b) = w[1];
// Gap = distance between the bottom of the upper item and
// the top of the lower item, measured in PDF-native coords
// (y grows upward, so the upper item has the larger cy).
let upper_bottom = cy_a - h_a;
let lower_top = cy_b + h_b;
let gap = upper_bottom - lower_top;
if gap > gap_threshold {
found_gap = true;
break;
}
}
if found_gap {
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.
///
/// Two failure modes are guarded against per-input:
///
/// * **Empty heuristic**: if the heuristic returns empty/whitespace
/// markdown for a flagged region, the original TSR markdown is
/// preserved and `fallback_reason` is suffixed with
/// `_heuristic_empty` (e.g. `multi_row_in_cell_heuristic_empty`).
/// This avoids replacing a usable wrong-but-non-empty TSR output
/// with literally nothing.
/// * **Per-input errors**: any failure in detection or heuristic
/// extraction for a single input is contained — that input
/// returns the raw TSR markdown with `fallback_reason` set to
/// an `_error` label so callers can metric on it. Other inputs
/// in the same batch are unaffected.
///
/// 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 tsr_md = if cells.is_empty() {
String::new()
} else {
tables::cells_to_markdown(cells)
};
let issue = match detect_tsr_quality_issue(buffer, input, cells) {
Ok(opt) => opt,
Err(_) => {
// Detection failed for this input — fall through with
// the raw TSR markdown so the rest of the batch is
// unaffected. Tag the reason for caller metrics.
results.push(TableExtractionResult {
markdown: tsr_md,
fallback_reason: Some("detection_error".to_string()),
});
continue;
}
};
let result = match issue {
None => TableExtractionResult {
markdown: tsr_md,
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_md = match extract_tables_in_regions_mem(
buffer,
&[(input.page, vec![input.crop_pdf_pt_bbox])],
) {
Ok(pages) => pages
.into_iter()
.next()
.and_then(|p| p.regions.into_iter().next().map(|r| r.text))
.unwrap_or_default(),
Err(_) => {
// Heuristic threw — keep raw TSR markdown.
results.push(TableExtractionResult {
markdown: tsr_md,
fallback_reason: Some(format!("{reason}_heuristic_error")),
});
continue;
}
};
if heuristic_md.trim().is_empty() {
// Heuristic produced nothing useful — keep TSR
// markdown rather than ship empty. The reason
// suffix lets callers count this case.
TableExtractionResult {
markdown: tsr_md,
fallback_reason: Some(format!("{reason}_heuristic_empty")),
}
} else {
TableExtractionResult {
markdown: heuristic_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()?;
@@ -1348,6 +2238,45 @@ fn region_overlaps_item(item: &TextItem, bounds: RegionBounds) -> bool {
x_overlap > 0.0 && y_overlap > 0.0
}
fn region_overlaps_rect(rect: &PdfRect, bounds: RegionBounds) -> bool {
const REGION_MARGIN: f32 = 1.5;
let (x_min, y_min, x_max, y_max) = normalized_rect_edges(rect);
ranges_overlap(
x_min,
x_max,
bounds.x_min - REGION_MARGIN,
bounds.x_max + REGION_MARGIN,
) && ranges_overlap(
y_min,
y_max,
bounds.y_min - REGION_MARGIN,
bounds.y_max + REGION_MARGIN,
)
}
fn region_overlaps_line(line: &PdfLine, bounds: RegionBounds) -> bool {
const REGION_MARGIN: f32 = 1.5;
let x_min = line.x1.min(line.x2);
let x_max = line.x1.max(line.x2);
let y_min = line.y1.min(line.y2);
let y_max = line.y1.max(line.y2);
ranges_overlap(
x_min,
x_max,
bounds.x_min - REGION_MARGIN,
bounds.x_max + REGION_MARGIN,
) && ranges_overlap(
y_min,
y_max,
bounds.y_min - REGION_MARGIN,
bounds.y_max + REGION_MARGIN,
)
}
fn ranges_overlap(a_min: f32, a_max: f32, b_min: f32, b_max: f32) -> bool {
a_max >= b_min && b_max >= a_min
}
fn tsr_region_contains_item(item: &TextItem, bounds: RegionBounds) -> bool {
let item_x_min = item.x;
let item_x_max = item.x + text_utils::effective_width(item);
+555 -4
View File
@@ -4,10 +4,11 @@ use pdf_inspector::detector::{estimate_page_count_from_bytes, DetectionConfig, S
use pdf_inspector::extractor::group_into_lines;
use pdf_inspector::types::TextLine;
use pdf_inspector::{
detect_pdf_type, extract_pages_markdown, extract_pages_markdown_mem,
extract_tables_in_regions_mem, extract_text, extract_text_in_regions_mem,
extract_text_with_positions, process_pdf_mem, process_pdf_with_options, to_markdown,
MarkdownOptions, PdfError, PdfOptions, PdfType, TextItem,
detect_pdf_type, detect_vector_grid_in_region_mem, extract_pages_markdown,
extract_pages_markdown_mem, extract_tables_in_regions_mem, extract_text,
extract_text_in_regions_mem, extract_text_with_positions, process_pdf_mem,
process_pdf_with_options, to_markdown, MarkdownOptions, PdfError, PdfOptions, PdfType,
TextItem,
};
use std::collections::HashSet;
@@ -1704,6 +1705,205 @@ fn synthetic_dense_table_pdf() -> Vec<u8> {
bytes
}
fn synthetic_vector_grid_pdf(two_tables: bool) -> Vec<u8> {
use lopdf::content::{Content, Operation};
use lopdf::{dictionary, Document, Object, Stream};
fn push_grid(
operations: &mut Vec<Operation>,
x_left: i64,
x_mid: i64,
x_right: i64,
y_top: i64,
y_mid: i64,
y_bottom: i64,
) {
for y in [y_top, y_mid, y_bottom] {
operations.push(Operation::new("m", vec![x_left.into(), y.into()]));
operations.push(Operation::new("l", vec![x_right.into(), y.into()]));
}
for x in [x_left, x_mid, x_right] {
operations.push(Operation::new("m", vec![x.into(), y_bottom.into()]));
operations.push(Operation::new("l", vec![x.into(), y_top.into()]));
}
operations.push(Operation::new("S", vec![]));
}
fn push_text(operations: &mut Vec<Operation>, x: i64, y: i64, text: &str) {
operations.push(Operation::new(
"Tm",
vec![1.into(), 0.into(), 0.into(), 1.into(), x.into(), y.into()],
));
operations.push(Operation::new("Tj", vec![Object::string_literal(text)]));
}
let mut doc = Document::with_version("1.5");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
let font_id = doc.new_object_id();
let content_id = doc.new_object_id();
doc.objects.insert(
font_id,
dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
}
.into(),
);
let mut operations = Vec::new();
push_grid(&mut operations, 50, 130, 210, 740, 710, 670);
if two_tables {
push_grid(&mut operations, 50, 130, 210, 560, 530, 490);
}
operations.push(Operation::new("BT", vec![]));
operations.push(Operation::new("Tf", vec!["F1".into(), 10.into()]));
push_text(&mut operations, 70, 724, "A1");
push_text(&mut operations, 150, 724, "B1");
push_text(&mut operations, 70, 688, "A2");
push_text(&mut operations, 150, 688, "B2");
if two_tables {
push_text(&mut operations, 70, 544, "C1");
push_text(&mut operations, 150, 544, "D1");
push_text(&mut operations, 70, 508, "C2");
push_text(&mut operations, 150, 508, "D2");
}
operations.push(Operation::new("ET", vec![]));
let content = Content { operations }.encode().unwrap();
doc.objects
.insert(content_id, Stream::new(dictionary! {}, content).into());
doc.objects.insert(
page_id,
dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"MediaBox" => vec![0.into(), 0.into(), 300.into(), 800.into()],
"Resources" => dictionary! {
"Font" => dictionary! {
"F1" => font_id,
},
},
"Contents" => content_id,
}
.into(),
);
doc.objects.insert(
pages_id,
dictionary! {
"Type" => "Pages",
"Kids" => vec![page_id.into()],
"Count" => 1,
}
.into(),
);
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"Pages" => pages_id,
});
doc.trailer.set("Root", catalog_id);
let mut bytes = Vec::new();
doc.save_to(&mut bytes).unwrap();
bytes
}
fn assert_close(actual: f32, expected: f32) {
assert!(
(actual - expected).abs() < 0.75,
"expected {actual} to be close to {expected}"
);
}
#[test]
fn test_detect_vector_grid_in_region_line_pdf() {
use pdf_inspector::{extract_tables_with_structure_mem, TsrTableInput};
let buf = synthetic_vector_grid_pdf(false);
let crop = [50.0_f32, 60.0, 210.0, 130.0];
let detected = detect_vector_grid_in_region_mem(&buf, 0, crop, 72.0)
.unwrap()
.expect("ruled vector table should be detected");
assert_eq!(detected.cell_bboxes.len(), 4);
assert_eq!(
detected
.structure_tokens
.iter()
.filter(|tok| tok.as_str() == "<td></td>")
.count(),
4
);
assert_eq!(detected.structure_tokens.first().unwrap(), "<table>");
assert_eq!(detected.structure_tokens.last().unwrap(), "</table>");
let first = &detected.cell_bboxes[0];
assert_close(first[0], 0.0);
assert_close(first[1], 0.0);
assert_close(first[2], 80.0);
assert_close(first[3], 30.0);
let markdown = extract_tables_with_structure_mem(
&buf,
&[TsrTableInput {
page: 0,
crop_pdf_pt_bbox: crop,
render_dpi: 72.0,
structure_tokens: detected.structure_tokens,
cell_bboxes: detected.cell_bboxes,
}],
)
.unwrap()
.remove(0);
assert!(markdown.contains("A1"));
assert!(markdown.contains("B1"));
assert!(markdown.contains("A2"));
assert!(markdown.contains("B2"));
}
#[test]
fn test_detect_vector_grid_in_region_text_pdf_returns_none() {
let buf = make_minimal_text_pdf();
let detected =
detect_vector_grid_in_region_mem(&buf, 0, [0.0, 0.0, 300.0, 800.0], 72.0).unwrap();
assert!(detected.is_none());
}
#[test]
fn test_detect_vector_grid_in_region_filters_to_requested_table() {
use pdf_inspector::{extract_tables_with_structure_mem, TsrTableInput};
let buf = synthetic_vector_grid_pdf(true);
let second_table_crop = [50.0_f32, 240.0, 210.0, 310.0];
let detected = detect_vector_grid_in_region_mem(&buf, 0, second_table_crop, 72.0)
.unwrap()
.expect("second ruled table should be detected");
assert_eq!(detected.cell_bboxes.len(), 4);
let markdown = extract_tables_with_structure_mem(
&buf,
&[TsrTableInput {
page: 0,
crop_pdf_pt_bbox: second_table_crop,
render_dpi: 72.0,
structure_tokens: detected.structure_tokens,
cell_bboxes: detected.cell_bboxes,
}],
)
.unwrap()
.remove(0);
assert!(markdown.contains("C1"));
assert!(markdown.contains("D2"));
assert!(!markdown.contains("A1"));
assert!(!markdown.contains("B2"));
}
#[test]
fn test_extract_tables_with_structure_real_pdf_bits_pilani() {
use pdf_inspector::{extract_tables_with_structure_mem, TsrTableInput};
@@ -2052,6 +2252,357 @@ 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());
}
#[test]
fn test_auto_does_not_fire_on_legit_rowspan_cell() {
use pdf_inspector::{extract_tables_with_structure_auto_mem, TsrTableInput};
let buf = synthetic_dense_table_pdf();
// 2 columns, 3 rows in the visible PDF. SLANet emits a 2-row table
// where the LEFT cell of row 1 is a rowspan=2 cell that legitimately
// covers Oak Street + Boardwalk on two visual lines. The right
// column has two normal rows. multi_row_in_cell must NOT fire on
// the rowspan=2 cell.
let tokens: Vec<String> = [
"<table>",
"<thead>",
"<tr>",
"<th></th>",
"<th></th>",
"</tr>",
"</thead>",
"<tbody>",
"<tr>",
// First data cell explicitly declares rowspan=2.
"<td",
" rowspan=\"2\"",
">",
"</td>",
"<td></td>",
"</tr>",
"<tr>",
"<td></td>",
"</tr>",
"</tbody>",
"</table>",
]
.into_iter()
.map(String::from)
.collect();
// Header row, then a tall left cell covering both data lines, plus
// two narrow right cells (one per line).
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), // rowspan=2 — covers both lines
poly(90.0, 105.0, 180.0, 122.0), // row 1 only
poly(90.0, 122.0, 180.0, 145.0), // row 2 only
];
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(),
"rowspan=2 cell containing 2 visual lines should not trip multi_row_in_cell, got reason={:?}",
results[0].fallback_reason,
);
}
#[test]
fn test_auto_keeps_tsr_markdown_when_heuristic_returns_empty() {
use pdf_inspector::{extract_tables_with_structure_auto_mem, TsrTableInput};
let buf = synthetic_dense_table_pdf();
// Same shape as the multi_row_in_cell regression — a tall data cell
// that catches Oak Street + Boardwalk. But the crop bbox we pass
// points at a strip of the page that has NO text items, so the
// heuristic's region will be empty when it tries to extract there.
// The auto wrapper must keep the TSR markdown rather than ship "".
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();
// Cell bboxes overlap the actual PDF text (so multi_row_in_cell
// fires) — but the crop_pdf_pt_bbox we hand to the heuristic is a
// wholly-empty region of the page. The heuristic should return "".
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 is at the BOTTOM of the page where there's no text.
crop_pdf_pt_bbox: [0.0, 0.0, 200.0, 50.0],
render_dpi: 72.0,
structure_tokens: tokens,
cell_bboxes,
}],
)
.unwrap();
assert_eq!(results.len(), 1);
let r = &results[0];
assert_eq!(
r.fallback_reason.as_deref(),
Some("multi_row_in_cell_heuristic_empty"),
"expected _heuristic_empty suffix, got {:?}",
r.fallback_reason,
);
// TSR markdown should be preserved — non-empty, contains the cell
// text we know was assigned by the TSR path.
assert!(
!r.markdown.trim().is_empty(),
"expected TSR markdown to be preserved, got empty",
);
assert!(
r.markdown.contains("Oak Street") || r.markdown.contains("Boardwalk"),
"expected TSR markdown to contain at least one row, got: {}",
r.markdown,
);
}
#[test]
fn test_auto_isolates_per_input_failures() {
use pdf_inspector::{extract_tables_with_structure_auto_mem, TsrTableInput};
let buf = synthetic_dense_table_pdf();
let good_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();
// A clean input that should pass through with no fallback.
let good_input = TsrTableInput {
page: 0,
crop_pdf_pt_bbox: [0.0, 0.0, 200.0, 800.0],
render_dpi: 72.0,
structure_tokens: good_tokens,
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),
],
};
// A bad input that targets a non-existent page. The detection
// helper short-circuits on missing pages with Ok(None), so this
// shouldn't itself crash, but pairing it with a flagged input
// exercises the per-input control flow regardless. The point of
// this test is that one input's outcome doesn't poison the other.
let bad_input = TsrTableInput {
page: 9999,
crop_pdf_pt_bbox: [0.0, 0.0, 100.0, 100.0],
render_dpi: 72.0,
structure_tokens: vec![
"<table>".into(),
"<tr>".into(),
"<td></td>".into(),
"</tr>".into(),
"</table>".into(),
],
cell_bboxes: vec![poly(0.0, 0.0, 50.0, 50.0)],
};
let results = extract_tables_with_structure_auto_mem(&buf, &[good_input, bad_input]).unwrap();
assert_eq!(results.len(), 2);
// Good input still produces non-empty TSR markdown with no fallback.
assert!(
results[0].fallback_reason.is_none(),
"good input should pass through, got reason={:?}",
results[0].fallback_reason,
);
assert!(results[0].markdown.contains("Oak Street"));
// Bad input collapses to empty markdown but doesn't take the
// batch down with it.
assert_eq!(results[1].markdown, "");
}
// =========================================================================
// extract_pages_markdown_mem tests
// =========================================================================