Compare commits

...
Author SHA1 Message Date
Abimael Martell ff1c6c8d03 repair malformed PDF containers 2026-04-27 08:53:22 -07:00
Abimael MartellandClaude Opus 4.7 9cce4dd161 TSR stage 2: reject cross-line orphan stacking, v1.6.3 (#64)
Closes the residual run-on-cell pattern observed on FNBO branch list
after 1.6.2 deployed:

  |Shawnee Blue Valley Parkway|Kansas|6301 Pflumm, ...|0523.04|
  |Sonoma Plaza|Kansas Kansas|<addr1> <addr2>|0531.05|
  |Mitchell Woonsocket|South Dakota|<addr1>|9628.01|

Cause: when col-N cells across multiple consecutive rows are y-shifted
the same way (a local SLANet drift), the stage 2 orphan pass sees
multiple orphans qualifying for the same nearest empty cell. The cell
gets all of them appended in order, producing "RowA-text RowB-text".

Fix: track the y-coordinate of the first orphan that lands in each
cell. Subsequent orphans only join that cell if their y is within
half-a-row-height of the first orphan's y (same line). Cross-line
orphans skip that cell and look for the next-nearest empty cell on
their own line.

Same-line slack preserves multi-token branch names like
"Blue Valley Parkway" (3 PDF text items at the same y) — all three
stack into the same cell. Cross-row stacking is what gets rejected.

Two new tests:

  - stage2_rejects_cross_line_stacking_into_same_cell
    Two orphans on different rows, both equidistant to the same empty
    cell. First wins; second routes to its own row's cell.

  - stage2_allows_same_line_orphans_to_stack_into_one_cell
    Three same-line orphans (multi-token branch name) all land in the
    same empty cell, joined by spaces.

The existing four stage-2 tests + the cell-bleed regression test from
PR #62 + #63 all pass: 416 lib + 115 integration + 2 doctests.
clippy + fmt clean.

Bump @firecrawl/pdf-inspector to 1.6.3 (patch — refines 1.6.2; no API
changes).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 08:11:17 -07:00
Abimael MartellandClaude Opus 4.7 f61d139710 Recover orphan text after band normalization (TSR stage 2), v1.6.2 (#63)
PR #62 (1.6.1) closed the cell-bleed regression by clamping SLANet's
loose cell bboxes into non-overlapping row/column bands and tightening
text membership to center-containment OR >=60% overlap. That worked,
but exposed the opposite failure: legitimate native PDF text whose
center fell just outside the *clamped* cell bbox now had nowhere to go.

Two distinct failure modes observed against the FNBO branch-list PDF
after 1.6.1 deployed:

  Symptom A — header text positioned at the LEFT of a column whose band
  was derived from data-cell centers farther right. Header "Address"
  PDF text at x=331..375 fell outside the clamped col band starting
  at x=410. Strict membership rejected it (0% x-overlap, center
  outside).

  Symptom B — local SLANet row drift in col 0 over a 5-row stretch.
  Cell bboxes sat just above the actual branch-name text items
  (x-overlap 100% but y-overlap ~30-43%, below the 60% threshold).

Both share one root: post-normalization bboxes are too tight, and the
strict rule has no escape valve for legitimate edge text.

Fix: add a stage-2 orphan-recovery pass after the strict fill. Items
that NO cell claimed in stage 1 get re-assigned to their nearest
*empty* cell, distance-capped by `(median_col_width, median_row_height)`
so a far-orphan figure title can't get pulled into a faraway empty
cell. Stage 2 only fills empties — never overwrites stage 1 — so the
cell-bleed case PR #62 closed cannot regress.

Three new lib tests cover the bug shapes:
  - stage2_recovers_left_aligned_header_text_outside_data_band
    (Symptom A: header text left-of-band, data cells already filled,
     stage 2 fills only the header)
  - stage2_recovers_y_shifted_col0_in_consecutive_rows
    (Symptom B: 3 col-0 cells shifted vs text, all 3 recovered)
  - stage2_does_not_overwrite_filled_cells_or_admit_far_orphans
    (cap rejects far figure titles; pre-filled cells untouched)

Plus tests for the cap-derivation helper:
  - tsr_assignment_caps_uses_median_geometry
  - tsr_assignment_caps_floor_protects_degenerate_input

The existing dense-overlapping-rows regression test (added in #62) still
passes, confirming no regression on the cell-bleed case.

Test results: 414 lib + 115 integration + 2 doctests pass. clippy + fmt
clean.

Bump @firecrawl/pdf-inspector to 1.6.2 (patch — fixes a regression
introduced by 1.6.1; no API changes).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 23:36:43 -07:00
Abimael MartellandClaude Opus 4.7 3f8fb645c9 Fix TSR cell assignment for overlapping table boxes (#62)
* feat: TSR-aware table extraction (extract_tables_with_structure_mem)

New public function that consumes raw structure-recovery output (HTML
structure tokens + per-cell bboxes from a model like SLANet) and assembles
markdown tables by pulling cell text from the native PDF — no OCR, no
geometry inference.

Why: the existing extract_tables_in_regions_mem infers grid geometry from
text positions only and can't distinguish merged cells from multiple narrow
columns. Pairing structure recovery from a layout/TSR model with native
PDF text gets perfect text quality with proper row/col/span structure.

- New module src/tables/structured.rs: token state machine, polygon→AABB,
  crop-px→page-pt, rowspan/colspan-aware cell layout, markdown emitter.
  Accepts both 4-element rects and 8-element 4-corner polygons.
- New public extract_tables_with_structure_mem in src/lib.rs that reuses
  extract_page_text_items, region_overlaps_item, and the shared region
  text-collection helper. No existing public function modified.
- napi binding extractTablesWithStructure mirroring the existing
  extractTablesInRegions shape (f64 in JS → f32 internally).
- 14 unit tests + 5 integration tests, including a real-PDF gold-standard
  match against bits_pilani_feedback.pdf.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* TSR follow-ups: header-aware separator, cells API, v1.6.0

- cells_to_markdown emits the separator after the LAST row that contains
  is_header=true cells, falling back to "after row 0" when no header is
  flagged. Multi-row theads now render correctly. Three new unit tests
  cover: multi-row header, header not on row 0, no headers (fallback).
- New public extract_tables_with_structure_cells_mem returning
  Vec<Vec<StructuredCell>> so callers can drive their own rendering or
  debug overlays without re-doing the parse + extraction. The markdown
  variant now wraps it. The previously-unused page_pt_bbox field is
  surfaced through this API.
- New napi binding extractTablesWithStructureCells + StructuredCellJs.
- Bump @firecrawl/pdf-inspector to 1.6.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix TSR cell text assignment for overlapping bboxes

Made-with: Cursor

* bump npm package version to 1.6.1

Made-with: Cursor

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:35:19 -07:00
7 changed files with 1444 additions and 105 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@firecrawl/pdf-inspector",
"version": "1.6.0",
"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",
+33 -11
View File
@@ -1,8 +1,12 @@
//! CLI tool for detecting PDF type (text-based vs scanned)
use pdf_inspector::{detect_pdf_type, process_pdf_with_options, PdfOptions, PdfType, ProcessMode};
use pdf_inspector::{
detect_pdf_type, detector::estimate_page_count_from_bytes, process_pdf_with_options,
PdfOptions, PdfType, ProcessMode,
};
use std::env;
use std::fmt::Write;
use std::fs;
use std::process;
use std::time::Instant;
@@ -64,6 +68,32 @@ fn pdf_type_str(pdf_type: &PdfType) -> &'static str {
}
}
fn page_count_hint(pdf_path: &str) -> Option<u32> {
fs::read(pdf_path)
.ok()
.map(|bytes| estimate_page_count_from_bytes(&bytes))
.filter(|&count| count > 0)
}
fn print_error(e: &pdf_inspector::PdfError, pdf_path: &str, json_output: bool) {
if json_output {
if let Some(count) = page_count_hint(pdf_path) {
println!(
r#"{{"error":"{}","page_count_hint":{}}}"#,
json_escape(&e.to_string()),
count
);
} else {
println!(r#"{{"error":"{}"}}"#, json_escape(&e.to_string()));
}
} else {
eprintln!("Error: {}", e);
if let Some(count) = page_count_hint(pdf_path) {
eprintln!("Page count hint: {}", count);
}
}
}
fn run_analyze(pdf_path: &str, json_output: bool, start: Instant) {
match process_pdf_with_options(pdf_path, PdfOptions::new().mode(ProcessMode::Analyze)) {
Ok(result) => {
@@ -135,11 +165,7 @@ fn run_analyze(pdf_path: &str, json_output: bool, start: Instant) {
}
}
Err(e) => {
if json_output {
println!(r#"{{"error":"{}"}}"#, e);
} else {
eprintln!("Error: {}", e);
}
print_error(&e, pdf_path, json_output);
process::exit(1);
}
}
@@ -236,11 +262,7 @@ fn run_detect_only(pdf_path: &str, json_output: bool, start: Instant) {
}
}
Err(e) => {
if json_output {
println!(r#"{{"error":"{}"}}"#, e);
} else {
eprintln!("Error: {}", e);
}
print_error(&e, pdf_path, json_output);
process::exit(1);
}
}
+58 -36
View File
@@ -97,26 +97,9 @@ pub fn detect_pdf_type_with_config<P: AsRef<Path>>(
) -> Result<PdfTypeResult, PdfError> {
crate::validate_pdf_file(&path)?;
// First, load metadata only (fast operation)
let metadata = match Document::load_metadata(&path) {
Ok(m) => m,
Err(ref e) if crate::is_encrypted_lopdf_error(e) => {
Document::load_metadata_with_password(&path, "")?
}
Err(e) => return Err(e.into()),
};
let (doc, page_count) = crate::load_document_from_path(&path)?;
// Then load the full document for content inspection
// We use filtered loading to skip heavy objects we don't need
let doc = match Document::load(&path) {
Ok(d) => d,
Err(ref e) if crate::is_encrypted_lopdf_error(e) => {
Document::load_with_password(&path, "")?
}
Err(e) => return Err(e.into()),
};
detect_from_document(&doc, metadata.page_count, &config)
detect_from_document(&doc, page_count, &config)
}
/// Detect PDF type from memory buffer
@@ -131,25 +114,64 @@ pub fn detect_pdf_type_mem_with_config(
) -> Result<PdfTypeResult, PdfError> {
crate::validate_pdf_bytes(buffer)?;
// Load metadata first (fast)
let metadata = match Document::load_metadata_mem(buffer) {
Ok(m) => m,
Err(ref e) if crate::is_encrypted_lopdf_error(e) => {
Document::load_metadata_mem_with_password(buffer, "")?
}
Err(e) => return Err(e.into()),
};
let (doc, page_count) = crate::load_document_from_mem(buffer)?;
// Load document for inspection
let doc = match Document::load_mem(buffer) {
Ok(d) => d,
Err(ref e) if crate::is_encrypted_lopdf_error(e) => {
Document::load_mem_with_options(buffer, lopdf::LoadOptions::with_password(""))?
}
Err(e) => return Err(e.into()),
};
detect_from_document(&doc, page_count, &config)
}
detect_from_document(&doc, metadata.page_count, &config)
/// Heuristic page-count fallback for malformed PDFs that cannot be parsed.
///
/// This scans raw bytes for page dictionaries (`/Type /Page`) while excluding
/// the page tree node (`/Type /Pages`). It is intended as a low-confidence hint
/// for diagnostics; parsed page-tree counts remain authoritative.
pub fn estimate_page_count_from_bytes(buffer: &[u8]) -> u32 {
let mut count = 0u32;
let mut pos = 0usize;
while let Some(rel_idx) = find_bytes(&buffer[pos..], b"/Type") {
let mut value_pos = pos + rel_idx + b"/Type".len();
value_pos = skip_pdf_whitespace(buffer, value_pos);
if buffer.get(value_pos) == Some(&b'/') {
let name_start = value_pos + 1;
let name_end = name_start + b"Page".len();
if name_end <= buffer.len()
&& &buffer[name_start..name_end] == b"Page"
&& buffer
.get(name_end)
.is_none_or(|b| is_pdf_name_delimiter(*b))
{
count += 1;
}
}
pos += rel_idx + b"/Type".len();
}
count
}
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
fn skip_pdf_whitespace(buffer: &[u8], mut pos: usize) -> usize {
while pos < buffer.len() && is_pdf_whitespace(buffer[pos]) {
pos += 1;
}
pos
}
fn is_pdf_whitespace(byte: u8) -> bool {
matches!(byte, b'\0' | b'\t' | b'\n' | 0x0C | b'\r' | b' ')
}
fn is_pdf_name_delimiter(byte: u8) -> bool {
is_pdf_whitespace(byte)
|| matches!(
byte,
b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'
)
}
/// Detection logic on a pre-loaded document.
+4 -28
View File
@@ -36,26 +36,14 @@ pub(crate) use layout::ColumnRegion;
/// Extract text from PDF file as plain string
pub fn extract_text<P: AsRef<Path>>(path: P) -> Result<String, PdfError> {
crate::validate_pdf_file(&path)?;
let doc = match Document::load(&path) {
Ok(d) => d,
Err(ref e) if crate::is_encrypted_lopdf_error(e) => {
Document::load_with_password(&path, "")?
}
Err(e) => return Err(e.into()),
};
let (doc, _) = crate::load_document_from_path(&path)?;
extract_text_from_doc(&doc)
}
/// Extract text from PDF memory buffer
pub fn extract_text_mem(buffer: &[u8]) -> Result<String, PdfError> {
crate::validate_pdf_bytes(buffer)?;
let doc = match Document::load_mem(buffer) {
Ok(d) => d,
Err(ref e) if crate::is_encrypted_lopdf_error(e) => {
Document::load_mem_with_options(buffer, lopdf::LoadOptions::with_password(""))?
}
Err(e) => return Err(e.into()),
};
let (doc, _) = crate::load_document_from_mem(buffer)?;
extract_text_from_doc(&doc)
}
@@ -91,13 +79,7 @@ pub(crate) fn extract_text_with_positions_and_rects<P: AsRef<Path>>(
page_filter: Option<&HashSet<u32>>,
) -> Result<PageExtraction, PdfError> {
crate::validate_pdf_file(&path)?;
let doc = match Document::load(&path) {
Ok(d) => d,
Err(ref e) if crate::is_encrypted_lopdf_error(e) => {
Document::load_with_password(&path, "")?
}
Err(e) => return Err(e.into()),
};
let (doc, _) = crate::load_document_from_path(&path)?;
let font_cmaps = FontCMaps::from_doc(&doc);
let (extraction, _thresholds, _gid_pages) =
extract_positioned_text_from_doc(&doc, &font_cmaps, page_filter)?;
@@ -124,13 +106,7 @@ pub(crate) fn extract_text_with_positions_mem_and_rects(
page_filter: Option<&HashSet<u32>>,
) -> Result<PageExtraction, PdfError> {
crate::validate_pdf_bytes(buffer)?;
let doc = match Document::load_mem(buffer) {
Ok(d) => d,
Err(ref e) if crate::is_encrypted_lopdf_error(e) => {
Document::load_mem_with_options(buffer, lopdf::LoadOptions::with_password(""))?
}
Err(e) => return Err(e.into()),
};
let (doc, _) = crate::load_document_from_mem(buffer)?;
let font_cmaps = FontCMaps::from_doc(&doc);
let (extraction, _thresholds, _gid_pages) =
extract_positioned_text_from_doc(&doc, &font_cmaps, page_filter)?;
+834 -27
View File
@@ -839,7 +839,7 @@ pub fn extract_tables_with_structure_cells_mem(
inputs: &[TsrTableInput],
) -> Result<Vec<Vec<tables::StructuredCell>>, PdfError> {
use tables::structured::{
cell_px_to_page_pt, parse_structure, polygon_to_aabb, StructuredCell,
cell_px_to_page_pt, normalize_cell_bands, parse_structure, polygon_to_aabb, StructuredCell,
};
validate_pdf_bytes(buffer)?;
@@ -906,32 +906,15 @@ pub fn extract_tables_with_structure_cells_mem(
let mut cells: Vec<StructuredCell> = Vec::with_capacity(slots.len());
for slot in &slots {
let cell_text;
let page_pt_bbox;
if let Some(coords_arr) = input.cell_bboxes.get(slot.bbox_idx) {
if let Some(aabb_px) = polygon_to_aabb(coords_arr) {
let aabb_pt = cell_px_to_page_pt(aabb_px, input.render_dpi, crop_origin);
let raw = collect_text_in_region_with_options(
items,
aabb_pt[0],
aabb_pt[1],
aabb_pt[2],
aabb_pt[3],
page_h,
coords,
adaptive_threshold,
);
// Markdown cells must be one line — collapse line breaks
// produced by the line-grouping pass.
cell_text = raw.replace(['\n', '\r'], " ");
page_pt_bbox = aabb_pt;
page_pt_bbox = cell_px_to_page_pt(aabb_px, input.render_dpi, crop_origin);
} else {
cell_text = String::new();
page_pt_bbox = [0.0, 0.0, 0.0, 0.0];
}
} else {
cell_text = String::new();
page_pt_bbox = [0.0, 0.0, 0.0, 0.0];
}
@@ -941,17 +924,194 @@ pub fn extract_tables_with_structure_cells_mem(
rowspan: slot.rowspan,
colspan: slot.colspan,
is_header: slot.is_header,
text: cell_text,
text: String::new(),
page_pt_bbox,
});
}
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.
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());
}
}
// 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'], " ");
}
// Stage 2: orphan assignment — text items that didn't land in any
// cell during stage 1 get assigned to their nearest *empty* cell,
// clamped by a plausibility cap derived from cell geometry.
//
// This recovers two failure modes left by `normalize_cell_bands`:
// (a) header text positioned to the LEFT of a column whose band
// was derived from data cells centered farther right, so the
// header text falls outside the clamped band; and
// (b) local SLANet row drift where a cell's bbox sits slightly
// above/below its target text item, so the strict rules miss.
// Empty-cell-only is the safety net: a cell already filled by stage 1
// is never overwritten or augmented, so the cell-bleed case PR #62
// closed cannot regress.
tsr_assign_orphan_items(items, &mut cells, &claimed, page_h, coords);
results.push(cells);
}
Ok(results)
}
/// Compute plausibility caps for the orphan-assignment pass. Returns
/// `(cap_x, cap_y)` — the maximum x/y distance from a text item's center
/// to a candidate empty cell's bbox before the candidate is rejected.
///
/// Caps are derived from cell geometry so they scale with the table:
/// dense small-row tables get a tight cap, looser tables get more slack.
/// Floor values guard against degenerate single-cell tables collapsing
/// the cap to zero.
fn tsr_assignment_caps(cells: &[tables::StructuredCell]) -> (f32, f32) {
let mut widths: Vec<f32> = Vec::with_capacity(cells.len());
let mut heights: Vec<f32> = Vec::with_capacity(cells.len());
for cell in cells {
let [x1, y1, x2, y2] = cell.page_pt_bbox;
let w = (x2 - x1).abs();
let h = (y2 - y1).abs();
if w > 0.0 && h > 0.0 {
widths.push(w);
heights.push(h);
}
}
if widths.is_empty() {
return (0.0, 0.0);
}
widths.sort_by(|a, b| a.total_cmp(b));
heights.sort_by(|a, b| a.total_cmp(b));
let median_w = widths[widths.len() / 2];
let median_h = heights[heights.len() / 2];
// Floor values: even on a dense table, a 5pt floor handles small
// pixel-level bbox jitter without being so loose that we'd cross
// into a neighboring row/column. Symmetric in both axes.
let cap_x = median_w.max(5.0);
let cap_y = median_h.max(5.0);
(cap_x, cap_y)
}
/// For each text item that wasn't claimed by any cell during stage 1,
/// find the nearest *empty* cell within `(cap_x, cap_y)` of the item's
/// center and append the item's text to that cell. Cells that already
/// have content are skipped — stage 2 only fills, never augments.
///
/// Distance is point-to-rect: 0 if the item center is inside the cell's
/// bbox, else the axis-aligned gap to the nearest edge. Both x-gap and
/// y-gap must be within their respective caps for a candidate to qualify;
/// among qualifying candidates, the smallest combined euclidean distance
/// wins.
fn tsr_assign_orphan_items(
items: &[TextItem],
cells: &mut [tables::StructuredCell],
claimed: &std::collections::HashSet<usize>,
page_height: f32,
coord_space: RegionCoordSpace,
) {
if cells.is_empty() {
return;
}
let (cap_x, cap_y) = tsr_assignment_caps(cells);
if cap_x <= 0.0 || cap_y <= 0.0 {
return;
}
// Y-tolerance for "same line as a previous orphan" — multi-token branch
// names like "Blue Valley Parkway" are 3 separate text items and should
// all stack into the same cell. But two orphans on different rows of
// the PDF (different y values) targeting the same empty cell should
// NOT merge — that produces the "Mitchell Woonsocket" / "Shawnee Blue
// Valley Parkway" run-on cells. Half a row of slack is conservative.
let y_tolerance = (cap_y * 0.5).max(3.0);
// Pre-compute each empty cell's region bounds so we don't re-flip
// page coordinates per orphan-candidate pair.
let cell_bounds: Vec<Option<RegionBounds>> = cells
.iter()
.map(|cell| {
if !cell.text.is_empty() {
return None;
}
let [x1, y1, x2, y2] = cell.page_pt_bbox;
if x1 >= x2 || y1 >= y2 {
return None;
}
Some(region_bounds(x1, y1, x2, y2, page_height, coord_space))
})
.collect();
// Track the y-center of the FIRST orphan that landed in each cell so
// subsequent orphans only stack if they're on the same line.
let mut stage2_first_y: std::collections::HashMap<usize, f32> =
std::collections::HashMap::new();
for (i, item) in items.iter().enumerate() {
if claimed.contains(&i) {
continue;
}
let item_w = text_utils::effective_width(item);
if item.text.trim().is_empty() {
continue;
}
let cx = item.x + item_w * 0.5;
let cy = item.y + item.height * 0.5;
let mut best: Option<(usize, f32)> = None;
for (ci, bounds_opt) in cell_bounds.iter().enumerate() {
let Some(bounds) = bounds_opt else {
continue;
};
// If a previous orphan already landed in this cell, only let a
// new orphan join if it's on the same line. Cross-line orphans
// need to look elsewhere (next-nearest empty cell).
if let Some(&first_y) = stage2_first_y.get(&ci) {
if (first_y - cy).abs() > y_tolerance {
continue;
}
}
let dx = (bounds.x_min - cx).max(0.0).max(cx - bounds.x_max);
let dy = (bounds.y_min - cy).max(0.0).max(cy - bounds.y_max);
if dx > cap_x || dy > cap_y {
continue;
}
let dist_sq = dx * dx + dy * dy;
if best.is_none_or(|(_, d)| dist_sq < d) {
best = Some((ci, dist_sq));
}
}
if let Some((ci, _)) = best {
// Append, preserving stage 1's content. Same-line orphans
// stack to support multi-token text (e.g. "Blue Valley
// Parkway"); cross-line orphans are filtered out above.
let trimmed = item.text.trim();
if cells[ci].text.is_empty() {
cells[ci].text = trimmed.to_string();
} else {
cells[ci].text.push(' ');
cells[ci].text.push_str(trimmed);
}
stage2_first_y.entry(ci).or_insert(cy);
}
}
}
/// Extract markdown tables using externally-supplied structure recovery.
///
/// Convenience wrapper around [`extract_tables_with_structure_cells_mem`]
@@ -1062,6 +1222,31 @@ fn collect_text_in_region_with_options(
.filter(|item| region_overlaps_item(item, bounds))
.cloned()
.collect();
collect_text_from_matched_items(matched, adaptive_threshold)
}
#[cfg(test)]
#[allow(clippy::too_many_arguments)]
fn collect_text_in_tsr_cell(
items: &[TextItem],
rx1: f32,
ry1: f32,
rx2: f32,
ry2: f32,
page_height: f32,
coord_space: RegionCoordSpace,
adaptive_threshold: f32,
) -> String {
let bounds = region_bounds(rx1, ry1, rx2, ry2, page_height, coord_space);
let matched: Vec<TextItem> = items
.iter()
.filter(|item| tsr_region_contains_item(item, bounds))
.cloned()
.collect();
collect_text_from_matched_items(matched, adaptive_threshold)
}
fn collect_text_from_matched_items(matched: Vec<TextItem>, adaptive_threshold: f32) -> String {
if matched.is_empty() {
return String::new();
}
@@ -1163,6 +1348,30 @@ fn region_overlaps_item(item: &TextItem, bounds: RegionBounds) -> bool {
x_overlap > 0.0 && y_overlap > 0.0
}
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);
let item_y_min = item.y;
let item_y_max = item.y + item.height;
let center_x = (item_x_min + item_x_max) * 0.5;
let center_y = (item_y_min + item_y_max) * 0.5;
if center_x >= bounds.x_min
&& center_x <= bounds.x_max
&& center_y >= bounds.y_min
&& center_y <= bounds.y_max
{
return true;
}
let x_overlap = (item_x_max.min(bounds.x_max) - item_x_min.max(bounds.x_min)).max(0.0);
let y_overlap = (item_y_max.min(bounds.y_max) - item_y_min.max(bounds.y_min)).max(0.0);
let item_width = (item_x_max - item_x_min).max(0.1);
let item_height = (item_y_max - item_y_min).max(0.1);
x_overlap / item_width >= 0.6 && y_overlap / item_height >= 0.6
}
// =========================================================================
// Internal: single-load document pipeline
// =========================================================================
@@ -1172,30 +1381,133 @@ fn region_overlaps_item(item: &TextItem, bounds: RegionBounds) -> bool {
/// `Document::load_metadata` for page count + `Document::load` for content
/// are combined here, but lopdf loads the full doc in `load()` so we extract
/// page count from it directly to avoid the metadata-only round-trip.
fn load_document_from_path<P: AsRef<Path>>(path: P) -> Result<(Document, u32), PdfError> {
pub(crate) fn load_document_from_path<P: AsRef<Path>>(
path: P,
) -> Result<(Document, u32), PdfError> {
let buffer = std::fs::read(&path)?;
load_document_from_mem(&buffer)
}
/// Load a PDF from a memory buffer.
fn load_document_from_mem(buffer: &[u8]) -> Result<(Document, u32), PdfError> {
pub(crate) fn load_document_from_mem(buffer: &[u8]) -> Result<(Document, u32), PdfError> {
// Fix malformed struct element names before parsing. Some PDF generators
// write bare names (/S Code) instead of proper PDF names (/S /Code), which
// causes lopdf to silently drop the entire object.
let fixed = structure_tree::fix_bare_struct_names(buffer);
let buf = fixed.as_ref();
let doc = match Document::load_mem(buf) {
Ok(d) => d,
Err(ref e) if is_encrypted_lopdf_error(e) => {
Document::load_mem_with_options(buf, lopdf::LoadOptions::with_password(""))?
let doc = match load_document_bytes(buf) {
Ok(doc) => doc,
Err(first_err) => {
for repaired in repair_pdf_container_candidates(buf) {
match load_document_bytes(&repaired) {
Ok(doc) => {
log::debug!("loaded PDF after repairing malformed container bytes");
let page_count = doc.get_pages().len() as u32;
return Ok((doc, page_count));
}
Err(e) => {
if is_encrypted_lopdf_error(&e) {
return Err(e.into());
}
}
}
}
return Err(first_err.into());
}
Err(e) => return Err(e.into()),
};
let page_count = doc.get_pages().len() as u32;
Ok((doc, page_count))
}
fn load_document_bytes(buf: &[u8]) -> Result<Document, lopdf::Error> {
match Document::load_mem(buf) {
Ok(doc) => Ok(doc),
Err(ref e) if is_encrypted_lopdf_error(e) => {
Document::load_mem_with_options(buf, lopdf::LoadOptions::with_password(""))
}
Err(e) => Err(e),
}
}
fn repair_pdf_container_candidates(buf: &[u8]) -> Vec<Vec<u8>> {
let mut candidates = Vec::new();
add_repair_candidate(&mut candidates, append_missing_eof_marker(buf), buf);
let stripped = strip_leading_pdf_container_bytes(buf);
if let Some(stripped_buf) = stripped.as_deref() {
add_repair_candidate(&mut candidates, Some(stripped_buf.to_vec()), buf);
add_repair_candidate(
&mut candidates,
append_missing_eof_marker(stripped_buf),
buf,
);
}
candidates
}
fn add_repair_candidate(
candidates: &mut Vec<Vec<u8>>,
candidate: Option<Vec<u8>>,
original: &[u8],
) {
let Some(candidate) = candidate else {
return;
};
if candidate.as_slice() == original {
return;
}
if candidates.iter().any(|existing| existing == &candidate) {
return;
}
candidates.push(candidate);
}
fn append_missing_eof_marker(buf: &[u8]) -> Option<Vec<u8>> {
if contains_recent_eof_marker(buf) {
return None;
}
let mut end = buf.len();
while end > 0 && buf[end - 1].is_ascii_whitespace() {
end -= 1;
}
if !buf[..end].ends_with(b"%%EO") {
return None;
}
let mut repaired = Vec::with_capacity(end + 2);
repaired.extend_from_slice(&buf[..end]);
repaired.extend_from_slice(b"F\n");
Some(repaired)
}
fn contains_recent_eof_marker(buf: &[u8]) -> bool {
let start = buf.len().saturating_sub(1024);
buf[start..].windows(b"%%EOF".len()).any(|w| w == b"%%EOF")
}
fn strip_leading_pdf_container_bytes(buf: &[u8]) -> Option<Vec<u8>> {
let mut start = if buf.starts_with(&[0xEF, 0xBB, 0xBF]) {
3
} else {
0
};
while start < buf.len() && buf[start].is_ascii_whitespace() {
start += 1;
}
if start > 0 && buf[start..].starts_with(b"%PDF-") {
Some(buf[start..].to_vec())
} else {
None
}
}
/// Core processing pipeline operating on a pre-loaded document.
fn process_document(
doc: Document,
@@ -2217,6 +2529,24 @@ pub(crate) fn validate_pdf_file<P: AsRef<Path>>(path: P) -> Result<(), PdfError>
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ItemType;
fn test_item(text: &str, x: f32, y: f32, width: f32, height: f32) -> TextItem {
TextItem {
text: text.to_string(),
x,
y,
width,
height,
font: "Helvetica".to_string(),
font_size: height,
page: 1,
is_bold: false,
is_italic: false,
item_type: ItemType::Text,
mcid: None,
}
}
#[test]
fn test_detect_encoding_issues_fffd() {
@@ -2297,4 +2627,481 @@ mod tests {
"Valid Japanese text should not be flagged as garbage"
);
}
#[test]
fn tsr_text_fill_does_not_pull_neighboring_overlapping_rows() {
use crate::tables::structured::normalize_cell_bands;
use crate::tables::StructuredCell;
let items = vec![
test_item("Branch Name", 12.0, 88.0, 55.0, 8.0),
test_item("Deposits", 112.0, 88.0, 36.0, 8.0),
test_item("Oak Street", 12.0, 72.0, 48.0, 8.0),
test_item("100", 112.0, 72.0, 18.0, 8.0),
test_item("Boardwalk", 12.0, 55.2, 46.0, 8.0),
test_item("200", 112.0, 55.2, 18.0, 8.0),
];
let mut cells = vec![
StructuredCell {
row: 0,
col: 0,
rowspan: 1,
colspan: 1,
is_header: true,
text: String::new(),
page_pt_bbox: [10.0, 100.0, 100.0, 125.0],
},
StructuredCell {
row: 0,
col: 1,
rowspan: 1,
colspan: 1,
is_header: true,
text: String::new(),
page_pt_bbox: [100.0, 100.0, 170.0, 125.0],
},
StructuredCell {
row: 1,
col: 0,
rowspan: 1,
colspan: 1,
is_header: false,
text: String::new(),
page_pt_bbox: [10.0, 116.0, 100.0, 141.0],
},
StructuredCell {
row: 1,
col: 1,
rowspan: 1,
colspan: 1,
is_header: false,
text: String::new(),
page_pt_bbox: [100.0, 116.0, 170.0, 141.0],
},
StructuredCell {
row: 2,
col: 0,
rowspan: 1,
colspan: 1,
is_header: false,
text: String::new(),
page_pt_bbox: [10.0, 132.8, 100.0, 157.8],
},
StructuredCell {
row: 2,
col: 1,
rowspan: 1,
colspan: 1,
is_header: false,
text: String::new(),
page_pt_bbox: [100.0, 132.8, 170.0, 157.8],
},
];
normalize_cell_bands(&mut cells);
for cell in &mut cells {
let [x1, y1, x2, y2] = cell.page_pt_bbox;
cell.text = collect_text_in_tsr_cell(
&items,
x1,
y1,
x2,
y2,
200.0,
RegionCoordSpace::Standard,
0.10,
);
}
assert_eq!(cells[0].text, "Branch Name");
assert_eq!(cells[2].text, "Oak Street");
assert_eq!(cells[4].text, "Boardwalk");
assert!(!cells[0].text.contains("Oak Street"));
assert!(!cells[2].text.contains("Branch Name"));
assert!(!cells[2].text.contains("Boardwalk"));
}
#[test]
fn tsr_assignment_caps_uses_median_geometry() {
use crate::tables::StructuredCell;
let cells = vec![
StructuredCell {
row: 0,
col: 0,
rowspan: 1,
colspan: 1,
is_header: false,
text: String::new(),
page_pt_bbox: [0.0, 0.0, 100.0, 20.0], // 100x20
},
StructuredCell {
row: 0,
col: 1,
rowspan: 1,
colspan: 1,
is_header: false,
text: String::new(),
page_pt_bbox: [100.0, 0.0, 200.0, 20.0],
},
StructuredCell {
row: 1,
col: 0,
rowspan: 1,
colspan: 1,
is_header: false,
text: String::new(),
page_pt_bbox: [0.0, 20.0, 100.0, 40.0],
},
];
let (cap_x, cap_y) = tsr_assignment_caps(&cells);
assert_eq!(cap_x, 100.0);
assert_eq!(cap_y, 20.0);
}
#[test]
fn tsr_assignment_caps_floor_protects_degenerate_input() {
use crate::tables::StructuredCell;
let cells = vec![StructuredCell {
row: 0,
col: 0,
rowspan: 1,
colspan: 1,
is_header: false,
text: String::new(),
page_pt_bbox: [0.0, 0.0, 1.0, 1.0],
}];
let (cap_x, cap_y) = tsr_assignment_caps(&cells);
assert_eq!(cap_x, 5.0);
assert_eq!(cap_y, 5.0);
}
#[test]
fn stage2_recovers_left_aligned_header_text_outside_data_band() {
// Symptom A reproduction: the column band derived from data-cell
// centers ends up too far right, so header text positioned at the
// left of the column falls outside the band and stage 1's strict
// membership rejects it. Stage 2 should re-attach by proximity.
//
// Item coords are bottom-left native; cell page_pt_bbox is top-left.
// page_height=200 so a top-left bbox y=[88, 100] flips to native y
// bounds [100, 112]; an item at native y=104 (center 108) lands in.
use crate::tables::StructuredCell;
let items = vec![
// Header text — centered in row 0 (native y=104, center 108) but
// at the LEFT of the column (x=175, far left of the [410, 700]
// data-derived band).
test_item("Address", 175.0, 104.0, 50.0, 8.0),
// Data row 1 — fits its cell.
test_item("205 W Oak St", 420.0, 84.0, 100.0, 8.0),
// Data row 2 — fits its cell.
test_item("155 E Boardwalk Dr", 420.0, 64.0, 100.0, 8.0),
];
// Cells AFTER normalize_cell_bands would have run — col 0 band
// shifted right by data-cell centers, header cell now excludes
// the "Address" text at center x=200.
let mut cells = vec![
StructuredCell {
row: 0,
col: 0,
rowspan: 1,
colspan: 1,
is_header: true,
text: String::new(),
page_pt_bbox: [410.0, 88.0, 700.0, 100.0],
},
StructuredCell {
row: 1,
col: 0,
rowspan: 1,
colspan: 1,
is_header: false,
text: String::new(),
page_pt_bbox: [410.0, 108.0, 700.0, 116.0],
},
StructuredCell {
row: 2,
col: 0,
rowspan: 1,
colspan: 1,
is_header: false,
text: String::new(),
page_pt_bbox: [410.0, 128.0, 700.0, 136.0],
},
];
let page_h = 200.0;
// Stage 1 mimic — fill cells via the strict rule, track claimed.
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, RegionCoordSpace::Standard);
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());
}
}
cell.text = collect_text_from_matched_items(matched, 0.10).replace(['\n', '\r'], " ");
}
// Header is empty after stage 1 (Address fell outside col 0 band).
assert_eq!(cells[0].text, "", "header should be empty after stage 1");
// Data rows already populated.
assert!(
cells[1].text.contains("Oak"),
"data row 1 should contain Oak: got {:?}",
cells[1].text
);
assert!(
cells[2].text.contains("Boardwalk"),
"data row 2 should contain Boardwalk: got {:?}",
cells[2].text
);
// Stage 2 should fill the orphan "Address" into the empty header.
tsr_assign_orphan_items(
&items,
&mut cells,
&claimed,
page_h,
RegionCoordSpace::Standard,
);
assert_eq!(cells[0].text, "Address");
// Data rows must NOT have been augmented (already filled by stage 1).
assert!(!cells[1].text.contains("Address"));
assert!(!cells[2].text.contains("Address"));
}
#[test]
fn stage2_recovers_y_shifted_col0_in_consecutive_rows() {
// Symptom B reproduction: a stretch of rows where col 0 cell bboxes
// sit just above the actual branch-name text. After stage 1 those
// cells are empty; stage 2 should pull the orphan items in by
// y-proximity.
//
// page_height=800. Cells are 14pt tall in top-left; flipped native
// bounds are [240,254], [220,234], [200,214]. Items sit ~1pt below
// each cell's native y range (still within ~1pt of the edge), so
// both center-containment and 60% overlap fail in stage 1.
use crate::tables::StructuredCell;
let items = vec![
// Bellevue: native y=235, center 239 — just below row 0's
// cell native bottom (240). Closer to row 0 than row 1.
test_item("Bellevue", 30.0, 235.0, 45.0, 8.0),
// Glenwood: native y=215, center 219 — just below row 1.
test_item("Glenwood", 30.0, 215.0, 45.0, 8.0),
// Metro Crossing: native y=195, center 199 — just below row 2.
test_item("Metro Crossing", 30.0, 195.0, 70.0, 8.0),
];
let mut cells = vec![
StructuredCell {
row: 0,
col: 0,
rowspan: 1,
colspan: 1,
is_header: false,
text: String::new(),
page_pt_bbox: [10.0, 546.0, 200.0, 560.0],
},
StructuredCell {
row: 1,
col: 0,
rowspan: 1,
colspan: 1,
is_header: false,
text: String::new(),
page_pt_bbox: [10.0, 566.0, 200.0, 580.0],
},
StructuredCell {
row: 2,
col: 0,
rowspan: 1,
colspan: 1,
is_header: false,
text: String::new(),
page_pt_bbox: [10.0, 586.0, 200.0, 600.0],
},
];
let page_h = 800.0;
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, RegionCoordSpace::Standard);
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());
}
}
cell.text = collect_text_from_matched_items(matched, 0.10).replace(['\n', '\r'], " ");
}
// All three cells empty after stage 1 (text falls just below each).
for c in &cells {
assert!(
c.text.is_empty(),
"stage 1 should leave all cells empty: {:?}",
c
);
}
tsr_assign_orphan_items(
&items,
&mut cells,
&claimed,
page_h,
RegionCoordSpace::Standard,
);
assert_eq!(cells[0].text, "Bellevue");
assert_eq!(cells[1].text, "Glenwood");
assert_eq!(cells[2].text, "Metro Crossing");
}
#[test]
fn stage2_rejects_cross_line_stacking_into_same_cell() {
// Two orphans on different rows of the PDF, both equidistant from
// the same empty cell. Without the same-line guard they'd both stack
// into that cell ("Shawnee Blue Valley Parkway" run-on); the guard
// keeps the first orphan and routes the second to the next-nearest
// empty cell on its own line.
use crate::tables::StructuredCell;
// page_h=200. Two empty cells:
// cell X (row 0): top-left y=[100, 110], native [90, 100]
// cell Y (row 1): top-left y=[112, 122], native [78, 88]
let mut cells = vec![
StructuredCell {
row: 0,
col: 0,
rowspan: 1,
colspan: 1,
is_header: false,
text: String::new(),
page_pt_bbox: [10.0, 100.0, 100.0, 110.0],
},
StructuredCell {
row: 1,
col: 0,
rowspan: 1,
colspan: 1,
is_header: false,
text: String::new(),
page_pt_bbox: [10.0, 112.0, 100.0, 122.0],
},
];
// Two orphans, different rows of the PDF (y differs by 14pt = a
// full row), both 2pt outside their target cell — both within
// cap_y, both equidistant-ish to cell X. Without the same-line
// guard they'd both land in X.
// "Shawnee" should belong to cell X (row 0) — center y=98 is
// 2pt below X's native min=100.
// "BlueValley" should belong to cell Y (row 1) — center y=84
// is 4pt above Y's native max=88.
let items = vec![
// Shawnee orphan — closer to X (dy=2) than Y (dy=6 from native min=78).
test_item("Shawnee", 30.0, 94.0, 50.0, 8.0),
// BlueValley orphan — closer to Y (dy=4) than X (dy=8 from native max=100).
test_item("BlueValley", 30.0, 80.0, 60.0, 8.0),
];
let claimed: std::collections::HashSet<usize> = std::collections::HashSet::new();
tsr_assign_orphan_items(
&items,
&mut cells,
&claimed,
200.0,
RegionCoordSpace::Standard,
);
assert_eq!(cells[0].text, "Shawnee");
assert_eq!(cells[1].text, "BlueValley");
assert!(!cells[0].text.contains("BlueValley"));
assert!(!cells[1].text.contains("Shawnee"));
}
#[test]
fn stage2_allows_same_line_orphans_to_stack_into_one_cell() {
// Multi-token branch names like "Blue Valley Parkway" are 3 PDF
// text items at the SAME y-coordinate. They should all stack into
// the cell their row's branch-name belongs to, not get split
// across rows by the cross-line guard.
use crate::tables::StructuredCell;
let mut cells = vec![StructuredCell {
row: 0,
col: 0,
rowspan: 1,
colspan: 1,
is_header: false,
text: String::new(),
page_pt_bbox: [10.0, 100.0, 200.0, 110.0],
}];
// Three same-line items, all 2pt below the cell's native bottom.
let items = vec![
test_item("Blue", 30.0, 94.0, 25.0, 8.0),
test_item("Valley", 60.0, 94.0, 35.0, 8.0),
test_item("Parkway", 100.0, 94.0, 45.0, 8.0),
];
let claimed: std::collections::HashSet<usize> = std::collections::HashSet::new();
tsr_assign_orphan_items(
&items,
&mut cells,
&claimed,
200.0,
RegionCoordSpace::Standard,
);
// All three same-line orphans stacked into the single empty cell.
assert_eq!(cells[0].text, "Blue Valley Parkway");
}
#[test]
fn stage2_does_not_overwrite_filled_cells_or_admit_far_orphans() {
// Stage 2 must only fill EMPTY cells (preserves stage 1's strict
// behavior on bleed cases) and must reject orphans that fall far
// outside any cell (prevents pulling a figure title into a table).
use crate::tables::StructuredCell;
let items = vec![
test_item("Real", 50.0, 100.0, 30.0, 8.0),
// Far orphan — at native y=20 (page bottom edge) on a page where
// the table sits around native y=92..104 (top-left y=96..108).
// y-distance to nearest cell is ~70pt, far exceeding the ~12pt
// cap from median row height.
test_item("FigureTitle", 50.0, 20.0, 60.0, 8.0),
];
let mut cells = vec![
StructuredCell {
row: 0,
col: 0,
rowspan: 1,
colspan: 1,
is_header: false,
text: String::new(),
page_pt_bbox: [40.0, 96.0, 100.0, 108.0],
},
StructuredCell {
row: 1,
col: 0,
rowspan: 1,
colspan: 1,
is_header: false,
text: "Pre-filled".to_string(),
page_pt_bbox: [40.0, 116.0, 100.0, 128.0],
},
];
let mut claimed: std::collections::HashSet<usize> = std::collections::HashSet::new();
// Pretend "Real" got claimed by a different cell (won't be re-assigned).
// Don't claim "FigureTitle" — it's the far orphan.
claimed.insert(0);
tsr_assign_orphan_items(
&items,
&mut cells,
&claimed,
200.0,
RegionCoordSpace::Standard,
);
// Empty cell stayed empty (orphan was too far).
assert_eq!(cells[0].text, "");
// Pre-filled cell was not touched.
assert_eq!(cells[1].text, "Pre-filled");
}
}
+235 -1
View File
@@ -12,7 +12,7 @@
//! Cell text is supplied separately by the caller (typically by overlap-
//! testing PDF text items against each cell's page-PDF-pt bbox).
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
/// A single resolved cell, with both structural metadata and its bbox in
/// page PDF-points (top-left origin).
@@ -219,6 +219,144 @@ pub(crate) fn cell_px_to_page_pt(
]
}
/// Refine TSR cell bboxes into non-overlapping row/column bands.
///
/// SLANet-style bboxes are often plausible but too tall on dense borderless
/// tables. Native PDF text assignment is more reliable when each parsed row
/// owns the band between neighboring row centers instead of the full model box.
pub(crate) fn normalize_cell_bands(cells: &mut [StructuredCell]) {
if cells.len() < 2 {
return;
}
let row_bands = derive_axis_bands(cells, Axis::Y);
let col_bands = derive_axis_bands(cells, Axis::X);
for cell in cells {
let row_end = cell.row + cell.rowspan.max(1).saturating_sub(1);
if let (Some(&(y1, _)), Some(&(_, y2))) =
(row_bands.get(&cell.row), row_bands.get(&row_end))
{
let clamped_y1 = cell.page_pt_bbox[1].max(y1);
let clamped_y2 = cell.page_pt_bbox[3].min(y2);
if clamped_y1 < clamped_y2 {
cell.page_pt_bbox[1] = clamped_y1;
cell.page_pt_bbox[3] = clamped_y2;
}
}
let col_end = cell.col + cell.colspan.max(1).saturating_sub(1);
if let (Some(&(x1, _)), Some(&(_, x2))) =
(col_bands.get(&cell.col), col_bands.get(&col_end))
{
let clamped_x1 = cell.page_pt_bbox[0].max(x1);
let clamped_x2 = cell.page_pt_bbox[2].min(x2);
if clamped_x1 < clamped_x2 {
cell.page_pt_bbox[0] = clamped_x1;
cell.page_pt_bbox[2] = clamped_x2;
}
}
}
}
#[derive(Clone, Copy)]
enum Axis {
X,
Y,
}
fn derive_axis_bands(cells: &[StructuredCell], axis: Axis) -> HashMap<usize, (f32, f32)> {
let mut by_index: HashMap<usize, Vec<(f32, f32)>> = HashMap::new();
// Prefer non-spanning cells so colspan/rowspan boxes do not skew a single
// column/row center. If an axis has no non-spanning examples for an index,
// fall back to anchored cells below.
for cell in cells {
let span = match axis {
Axis::X => cell.colspan.max(1),
Axis::Y => cell.rowspan.max(1),
};
if span == 1 {
let idx = match axis {
Axis::X => cell.col,
Axis::Y => cell.row,
};
by_index
.entry(idx)
.or_default()
.push(axis_bounds(cell.page_pt_bbox, axis));
}
}
for cell in cells {
let idx = match axis {
Axis::X => cell.col,
Axis::Y => cell.row,
};
if !by_index.contains_key(&idx) {
by_index
.entry(idx)
.or_default()
.push(axis_bounds(cell.page_pt_bbox, axis));
}
}
let mut rows: Vec<(usize, f32, f32, f32)> = by_index
.into_iter()
.filter_map(|(idx, bounds)| {
let mut min_edge = f32::INFINITY;
let mut max_edge = f32::NEG_INFINITY;
let mut center_sum = 0.0;
let mut count = 0usize;
for (lo, hi) in bounds {
if lo.is_finite() && hi.is_finite() && lo < hi {
min_edge = min_edge.min(lo);
max_edge = max_edge.max(hi);
center_sum += (lo + hi) * 0.5;
count += 1;
}
}
(count > 0).then_some((idx, center_sum / count as f32, min_edge, max_edge))
})
.collect();
if rows.len() < 2 {
return rows
.into_iter()
.map(|(idx, _center, lo, hi)| (idx, (lo, hi)))
.collect();
}
rows.sort_by_key(|(idx, _, _, _)| *idx);
let mut bands = HashMap::new();
for i in 0..rows.len() {
let (idx, _center, min_edge, max_edge) = rows[i];
let lo = if i == 0 {
min_edge
} else {
(rows[i - 1].1 + rows[i].1) * 0.5
};
let hi = if i + 1 == rows.len() {
max_edge
} else {
(rows[i].1 + rows[i + 1].1) * 0.5
};
if lo.is_finite() && hi.is_finite() && lo < hi {
bands.insert(idx, (lo, hi));
}
}
bands
}
fn axis_bounds(bbox: [f32; 4], axis: Axis) -> (f32, f32) {
match axis {
Axis::X => (bbox[0].min(bbox[2]), bbox[0].max(bbox[2])),
Axis::Y => (bbox[1].min(bbox[3]), bbox[1].max(bbox[3])),
}
}
/// Sanitize cell text for inclusion in a markdown pipe-table cell:
/// collapse whitespace runs, drop newlines/tabs (cells must be one line),
/// and escape pipes that would otherwise break the table.
@@ -448,6 +586,102 @@ mod tests {
}
}
#[test]
fn normalize_cell_bands_splits_overlapping_slanet_rows() {
let mut cells = vec![
StructuredCell {
row: 0,
col: 0,
rowspan: 1,
colspan: 1,
is_header: true,
text: String::new(),
page_pt_bbox: [10.0, 100.0, 90.0, 120.0],
},
StructuredCell {
row: 0,
col: 1,
rowspan: 1,
colspan: 1,
is_header: true,
text: String::new(),
page_pt_bbox: [90.0, 100.0, 170.0, 120.0],
},
StructuredCell {
row: 1,
col: 0,
rowspan: 1,
colspan: 1,
is_header: false,
text: String::new(),
page_pt_bbox: [10.0, 116.0, 90.0, 136.0],
},
StructuredCell {
row: 1,
col: 1,
rowspan: 1,
colspan: 1,
is_header: false,
text: String::new(),
page_pt_bbox: [90.0, 116.0, 170.0, 136.0],
},
];
normalize_cell_bands(&mut cells);
assert_eq!(cells[0].page_pt_bbox[3], cells[2].page_pt_bbox[1]);
assert_eq!(cells[1].page_pt_bbox[3], cells[3].page_pt_bbox[1]);
assert!(
(cells[0].page_pt_bbox[3] - 118.0).abs() < 0.01,
"row separator should be midpoint between row centers: {:?}",
cells
);
}
#[test]
fn normalize_cell_bands_preserves_colspan_extent() {
let mut cells = vec![
StructuredCell {
row: 0,
col: 0,
rowspan: 1,
colspan: 2,
is_header: true,
text: String::new(),
page_pt_bbox: [8.0, 80.0, 172.0, 98.0],
},
StructuredCell {
row: 1,
col: 0,
rowspan: 1,
colspan: 1,
is_header: false,
text: String::new(),
page_pt_bbox: [10.0, 96.0, 90.0, 114.0],
},
StructuredCell {
row: 1,
col: 1,
rowspan: 1,
colspan: 1,
is_header: false,
text: String::new(),
page_pt_bbox: [88.0, 96.0, 170.0, 114.0],
},
];
normalize_cell_bands(&mut cells);
assert!(
cells[0].page_pt_bbox[0] <= cells[1].page_pt_bbox[0],
"spanning cell should retain the first column's left edge"
);
assert!(
cells[0].page_pt_bbox[2] >= cells[2].page_pt_bbox[2],
"spanning cell should retain the last column's right edge"
);
}
#[test]
fn parse_int_attr_basic() {
assert_eq!(parse_int_attr(" colspan=\"4\"", "colspan"), Some(4));
+279 -1
View File
@@ -1,6 +1,6 @@
//! Integration tests for pdf-to-markdown library
use pdf_inspector::detector::{DetectionConfig, ScanStrategy};
use pdf_inspector::detector::{estimate_page_count_from_bytes, DetectionConfig, ScanStrategy};
use pdf_inspector::extractor::group_into_lines;
use pdf_inspector::types::TextLine;
use pdf_inspector::{
@@ -11,6 +11,83 @@ use pdf_inspector::{
};
use std::collections::HashSet;
fn make_minimal_text_pdf() -> Vec<u8> {
let mut pdf = b"%PDF-1.4\n".to_vec();
let mut offsets = vec![0usize];
fn add_object(pdf: &mut Vec<u8>, offsets: &mut Vec<usize>, id: usize, body: &str) {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
pdf.extend_from_slice(body.as_bytes());
pdf.extend_from_slice(b"\nendobj\n");
}
add_object(
&mut pdf,
&mut offsets,
1,
"<< /Type /Catalog /Pages 2 0 R >>",
);
add_object(
&mut pdf,
&mut offsets,
2,
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
);
add_object(
&mut pdf,
&mut offsets,
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
);
let content = "BT /F1 12 Tf 100 700 Td (Hello World) Tj 0 -14 Td (Second Line) Tj 0 -14 Td (Third Line) Tj ET";
add_object(
&mut pdf,
&mut offsets,
4,
&format!(
"<< /Length {} >>\nstream\n{}\nendstream",
content.len(),
content
),
);
add_object(
&mut pdf,
&mut offsets,
5,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
);
let xref_start = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n", offsets.len()).as_bytes());
pdf.extend_from_slice(b"0000000000 65535 f \n");
for offset in offsets.iter().skip(1) {
pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(
format!(
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF",
offsets.len(),
xref_start
)
.as_bytes(),
);
pdf
}
fn truncate_eof_marker(mut pdf: Vec<u8>) -> Vec<u8> {
assert!(pdf.ends_with(b"%%EOF"));
pdf.pop();
pdf
}
fn add_leading_tab(mut pdf: Vec<u8>) -> Vec<u8> {
pdf.insert(0, b'\t');
pdf
}
// Helper to create test TextItems
fn make_text_item(text: &str, x: f32, y: f32, font_size: f32, page: u32) -> TextItem {
use pdf_inspector::types::ItemType;
@@ -826,6 +903,73 @@ fn test_bom_prefixed_pdf_header_not_rejected() {
}
}
#[test]
fn test_process_pdf_mem_repairs_truncated_eof_marker() {
let pdf = truncate_eof_marker(make_minimal_text_pdf());
let result = process_pdf_mem(&pdf).expect("truncated %%EO marker should be repaired");
assert_eq!(result.pdf_type, PdfType::TextBased);
assert_eq!(result.page_count, 1);
assert!(
result
.markdown
.as_deref()
.unwrap_or_default()
.contains("Hello World"),
"repaired PDF should still extract text"
);
}
#[test]
fn test_process_pdf_mem_repairs_leading_tab_and_truncated_eof() {
let pdf = add_leading_tab(truncate_eof_marker(make_minimal_text_pdf()));
let result = process_pdf_mem(&pdf).expect("leading whitespace + %%EO should be repaired");
assert_eq!(result.pdf_type, PdfType::TextBased);
assert_eq!(result.page_count, 1);
assert!(
result
.markdown
.as_deref()
.unwrap_or_default()
.contains("Hello World"),
"repaired PDF should still extract text"
);
}
#[test]
fn test_detect_pdf_type_repairs_container_from_path() {
let pdf = add_leading_tab(truncate_eof_marker(make_minimal_text_pdf()));
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("broken-container.pdf");
std::fs::write(&path, pdf).unwrap();
let result = detect_pdf_type(&path).expect("detector should use shared repair loader");
assert_eq!(result.pdf_type, PdfType::TextBased);
assert_eq!(result.page_count, 1);
assert_eq!(result.pages_with_text, 1);
}
#[test]
fn test_extract_text_mem_uses_container_repair() {
let pdf = truncate_eof_marker(make_minimal_text_pdf());
let text = pdf_inspector::extractor::extract_text_mem(&pdf)
.expect("plain text extraction should use shared repair loader");
assert!(text.contains("Hello World"));
}
#[test]
fn test_estimate_page_count_from_bytes_excludes_pages_tree() {
let pdf = add_leading_tab(truncate_eof_marker(make_minimal_text_pdf()));
assert_eq!(estimate_page_count_from_bytes(&pdf), 1);
}
#[test]
fn test_not_a_pdf_detect_pdf_type_mem() {
// Verify detect_pdf_type_mem is also guarded
@@ -1484,6 +1628,82 @@ fn poly(x1: f32, y1: f32, x2: f32, y2: f32) -> Vec<f32> {
vec![x1, y1, x2, y1, x2, y2, x1, y2]
}
fn synthetic_dense_table_pdf() -> Vec<u8> {
use lopdf::content::{Content, Operation};
use lopdf::{dictionary, Document, Object, Stream};
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 operations = vec![
Operation::new("BT", vec![]),
Operation::new("Tf", vec!["F1".into(), 10.into()]),
Operation::new("Td", vec![20.into(), 700.into()]),
Operation::new("Tj", vec![Object::string_literal("Branch Name")]),
Operation::new("Td", vec![100.into(), 0.into()]),
Operation::new("Tj", vec![Object::string_literal("Deposits")]),
Operation::new("Td", vec![Object::Integer(-100), Object::Real(-16.8)]),
Operation::new("Tj", vec![Object::string_literal("Oak Street")]),
Operation::new("Td", vec![100.into(), 0.into()]),
Operation::new("Tj", vec![Object::string_literal("100")]),
Operation::new("Td", vec![Object::Integer(-100), Object::Real(-16.8)]),
Operation::new("Tj", vec![Object::string_literal("Boardwalk")]),
Operation::new("Td", vec![100.into(), 0.into()]),
Operation::new("Tj", vec![Object::string_literal("200")]),
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(), 200.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
}
#[test]
fn test_extract_tables_with_structure_real_pdf_bits_pilani() {
use pdf_inspector::{extract_tables_with_structure_mem, TsrTableInput};
@@ -1564,6 +1784,64 @@ fn test_extract_tables_with_structure_real_pdf_bits_pilani() {
);
}
#[test]
fn test_extract_tables_with_structure_dense_overlapping_slanet_boxes() {
use pdf_inspector::{extract_tables_with_structure_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();
// Rows are spaced 16.8pt apart, while the SLANet-style boxes are 40pt
// tall and overlap adjacent rows. Text must still land in only one row.
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 mds = extract_tables_with_structure_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();
let expected = "|Branch Name|Deposits|\n|---|---|\n|Oak Street|100|\n|Boardwalk|200|\n";
assert_eq!(mds[0], expected);
assert!(!mds[0].contains("Branch Name Oak Street"));
assert!(!mds[0].contains("Oak Street Boardwalk"));
}
#[test]
fn test_extract_tables_with_structure_input_order_preserved() {
use pdf_inspector::{extract_tables_with_structure_mem, TsrTableInput};