Compare commits

..
Author SHA1 Message Date
Abimael Martell ff1c6c8d03 repair malformed PDF containers 2026-04-27 08:53:22 -07:00
5 changed files with 20 additions and 1612 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@firecrawl/pdf-inspector",
"version": "1.7.2",
"version": "1.6.3",
"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,13 +99,6 @@ 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
// ---------------------------------------------------------------------------
@@ -324,53 +317,6 @@ 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`.
///
@@ -476,52 +422,6 @@ 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,7 +7,6 @@ import {
extractText,
extractTextWithPositions,
extractTextInRegions,
detectVectorGridInRegion,
extractPagesMarkdown,
} from './index.js';
@@ -91,17 +90,6 @@ 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...');
+15 -944
View File
@@ -784,591 +784,6 @@ 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)
// =========================================================================
@@ -1516,73 +931,24 @@ pub fn extract_tables_with_structure_cells_mem(
normalize_cell_bands(&mut cells);
// 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.
// 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.
let mut claimed: std::collections::HashSet<usize> = std::collections::HashSet::new();
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));
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());
}
}
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)
// 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)
.replace(['\n', '\r'], " ");
}
@@ -1770,262 +1136,6 @@ 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()?;
@@ -2238,45 +1348,6 @@ 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);
+4 -555
View File
@@ -4,11 +4,10 @@ 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, 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,
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,
};
use std::collections::HashSet;
@@ -1705,205 +1704,6 @@ 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};
@@ -2252,357 +2052,6 @@ 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
// =========================================================================