Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddaf412a3b |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@firecrawl/pdf-inspector",
|
"name": "@firecrawl/pdf-inspector",
|
||||||
"version": "1.7.1",
|
"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.",
|
"description": "Fast PDF classification and text extraction. Detect text-based vs scanned PDFs, extract text by region with quality checks. Native Rust performance via napi-rs.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"types": "index.d.ts",
|
"types": "index.d.ts",
|
||||||
|
|||||||
+114
-52
@@ -1208,11 +1208,13 @@ pub struct TableExtractionResult {
|
|||||||
/// * `phantom_empty_row` — a row whose every cell is empty, surrounded
|
/// * `phantom_empty_row` — a row whose every cell is empty, surrounded
|
||||||
/// above and below by rows with content. SLANet sometimes emits an
|
/// above and below by rows with content. SLANet sometimes emits an
|
||||||
/// extra row that doesn't correspond to any visible PDF row.
|
/// extra row that doesn't correspond to any visible PDF row.
|
||||||
/// * `multi_row_in_cell` — at least one cell's matched PDF text items
|
/// * `multi_row_in_cell` — at least one `rowspan==1` cell encloses
|
||||||
/// span more than 1.3× either the smallest cell height or the tallest
|
/// PDF text items that cluster into two distinct visual lines
|
||||||
/// contained item's own height, meaning the cell has absorbed text
|
/// separated by a whitespace gap larger than the line height. Cells
|
||||||
/// from two adjacent visual rows. SLANet's row under-detection on
|
/// declared as `rowspan>1` are excluded since they are *expected*
|
||||||
/// tightly-packed tables produces this.
|
/// 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(
|
fn detect_tsr_quality_issue(
|
||||||
buffer: &[u8],
|
buffer: &[u8],
|
||||||
input: &TsrTableInput,
|
input: &TsrTableInput,
|
||||||
@@ -1238,10 +1240,12 @@ fn detect_tsr_quality_issue(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multi-row-in-cell: re-extract PDF text items in the page and check
|
// Multi-row-in-cell: re-extract PDF text items in the page and look
|
||||||
// whether any non-empty cell's bbox encloses items whose y-centers
|
// for `rowspan==1` cells that contain items grouped into ≥2 visual
|
||||||
// span across multiple visual lines. This is the FNBO failure mode —
|
// lines separated by a real whitespace gap. This is the FNBO mode:
|
||||||
// a tall TSR cell catches text from two adjacent PDF rows.
|
// 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 (doc, _page_count) = load_document_from_mem(buffer)?;
|
||||||
let pages = doc.get_pages();
|
let pages = doc.get_pages();
|
||||||
let page_1idx = input.page + 1;
|
let page_1idx = input.page + 1;
|
||||||
@@ -1267,20 +1271,11 @@ fn detect_tsr_quality_issue(
|
|||||||
RegionCoordSpace::Standard
|
RegionCoordSpace::Standard
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use the minimum non-empty cell height as the typical-row baseline.
|
|
||||||
// The pathology is that some cells are abnormally tall (multi-row),
|
|
||||||
// so taking the median or mean would scale with the bad cells. The
|
|
||||||
// smallest cell is likely a tightly-bound single-row cell, which is
|
|
||||||
// a better proxy for a real row's height.
|
|
||||||
let mut heights: Vec<f32> = cells
|
|
||||||
.iter()
|
|
||||||
.map(|c| (c.page_pt_bbox[3] - c.page_pt_bbox[1]).abs())
|
|
||||||
.filter(|h| *h > 0.0)
|
|
||||||
.collect();
|
|
||||||
heights.sort_by(|a, b| a.total_cmp(b));
|
|
||||||
let typical_row_h = heights.first().copied().unwrap_or(15.0).max(5.0);
|
|
||||||
|
|
||||||
for cell in cells {
|
for cell in cells {
|
||||||
|
// rowspan>1 cells are intentionally multi-line — skip them.
|
||||||
|
if cell.rowspan > 1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if cell.text.trim().is_empty() {
|
if cell.text.trim().is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1289,31 +1284,51 @@ fn detect_tsr_quality_issue(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let bounds = region_bounds(x1, y1, x2, y2, page_h, coords);
|
let bounds = region_bounds(x1, y1, x2, y2, page_h, coords);
|
||||||
let mut min_y = f32::INFINITY;
|
|
||||||
let mut max_y = f32::NEG_INFINITY;
|
// Collect the items inside this cell, with their y-centers and
|
||||||
let mut max_item_h = 0f32;
|
// half-heights so we can cluster them into visual lines.
|
||||||
let mut count = 0u32;
|
let mut cell_items: Vec<(f32, f32)> = Vec::new();
|
||||||
for item in &items {
|
for item in &items {
|
||||||
if tsr_region_contains_item(item, bounds) {
|
if tsr_region_contains_item(item, bounds) {
|
||||||
let cy = item.y + item.height * 0.5;
|
let cy = item.y + item.height * 0.5;
|
||||||
min_y = min_y.min(cy);
|
let half_h = (item.height * 0.5).max(2.5);
|
||||||
max_y = max_y.max(cy);
|
cell_items.push((cy, half_h));
|
||||||
max_item_h = max_item_h.max(item.height);
|
|
||||||
count += 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if count < 2 {
|
if cell_items.len() < 2 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Items on the same visual line have y-centers within ~one
|
// Sort by y-center descending (top-of-page first in PDF native
|
||||||
// line-height. Flag a cell whose items span > 1.3× both the
|
// coords where y grows upward) — direction doesn't matter, we
|
||||||
// typical row height AND the largest item's own height —
|
// just need consecutive items to be neighbors in the sort.
|
||||||
// either signal alone is a strong indicator of multi-line text
|
cell_items.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||||
// inside a cell that should be a single row.
|
|
||||||
let span = max_y - min_y;
|
// Walk pairs and see if there's a real whitespace gap between
|
||||||
let row_threshold = typical_row_h * 1.3;
|
// any two adjacent items — defined as their bounding-box edges
|
||||||
let item_threshold = max_item_h.max(5.0) * 1.3;
|
// separated by more than half a line height. This rules out
|
||||||
if span > row_threshold || span > item_threshold {
|
// 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()));
|
return Ok(Some("multi_row_in_cell".to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1331,6 +1346,20 @@ fn detect_tsr_quality_issue(
|
|||||||
/// On flagged inputs the heuristic markdown replaces the TSR markdown
|
/// On flagged inputs the heuristic markdown replaces the TSR markdown
|
||||||
/// and the result's `fallback_reason` is set to the diagnostic label.
|
/// 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 this from production callers that want self-healing output.
|
||||||
/// Use [`extract_tables_with_structure_mem`] when you want raw TSR
|
/// Use [`extract_tables_with_structure_mem`] when you want raw TSR
|
||||||
/// output regardless of quality (e.g. eval harnesses comparing the
|
/// output regardless of quality (e.g. eval harnesses comparing the
|
||||||
@@ -1344,34 +1373,67 @@ pub fn extract_tables_with_structure_auto_mem(
|
|||||||
|
|
||||||
for (i, input) in inputs.iter().enumerate() {
|
for (i, input) in inputs.iter().enumerate() {
|
||||||
let cells = &tsr_cells[i];
|
let cells = &tsr_cells[i];
|
||||||
let issue = detect_tsr_quality_issue(buffer, input, cells)?;
|
let tsr_md = if cells.is_empty() {
|
||||||
|
|
||||||
let result = match issue {
|
|
||||||
None => TableExtractionResult {
|
|
||||||
markdown: if cells.is_empty() {
|
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
tables::cells_to_markdown(cells)
|
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,
|
fallback_reason: None,
|
||||||
},
|
},
|
||||||
Some(reason) => {
|
Some(reason) => {
|
||||||
// Fall back to heuristic on the input's table region.
|
// Fall back to heuristic on the input's table region.
|
||||||
// The crop's PDF-pt bbox IS the table region.
|
// The crop's PDF-pt bbox IS the table region.
|
||||||
let heuristic = extract_tables_in_regions_mem(
|
let heuristic_md = match extract_tables_in_regions_mem(
|
||||||
buffer,
|
buffer,
|
||||||
&[(input.page, vec![input.crop_pdf_pt_bbox])],
|
&[(input.page, vec![input.crop_pdf_pt_bbox])],
|
||||||
)?;
|
) {
|
||||||
let md = heuristic
|
Ok(pages) => pages
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.next()
|
.next()
|
||||||
.and_then(|page_result| page_result.regions.into_iter().next().map(|r| r.text))
|
.and_then(|p| p.regions.into_iter().next().map(|r| r.text))
|
||||||
.unwrap_or_default();
|
.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 {
|
TableExtractionResult {
|
||||||
markdown: md,
|
markdown: tsr_md,
|
||||||
|
fallback_reason: Some(format!("{reason}_heuristic_empty")),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TableExtractionResult {
|
||||||
|
markdown: heuristic_md,
|
||||||
fallback_reason: Some(reason),
|
fallback_reason: Some(reason),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
results.push(result);
|
results.push(result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2190,6 +2190,219 @@ fn test_auto_returns_empty_inputs() {
|
|||||||
assert!(results.is_empty());
|
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
|
// extract_pages_markdown_mem tests
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user