Compare commits

...
Author SHA1 Message Date
Abimael MartellandClaude Opus 4.7 468d5dc99f tables: lift detection on shaded-header + alt-row tables (#wired-grids)
Production telemetry on `wired_high_confidence`-classified table regions
showed `detect_vector_grid_in_region_mem` returning a usable grid only
~27% of the time, with the rest falling through to GLM-OCR. Three
surgical fixes target the dominant production shapes:

* Path-fill cell backgrounds: when the page has no `re` rects but draws
  cell backgrounds via `m`/`l`/`h`/`f*` sequences, prefer the fill-derived
  rects over the few section-level `W*` clip paths that previously won
  the priority gate. Activated when fill rects outnumber clip rects ≥3×.

* Dedup-induced cluster splits: page-background rects could pose as
  containers in the sub-rect dedup and evict a slightly smaller
  table-frame rect, breaking adjacency between column-cell groups so each
  column became its own cluster. Origin-anchored containers are now
  disqualified from sub-rect dedup. A separate exact-duplicate pass
  collapses the cell-padding/text-bg/cell-border triple emissions some
  PDFs produce, preserving original order to avoid reshuffling table
  output on multi-table pages.

* Prose-words rejection: the `cell-rect` fallback's whole-grid prose
  threshold also rejected real tables that include a description column.
  Now relaxed when content is well-distributed (≥75% of cols filled),
  while keeping the original strictness for prose-in-a-frame layouts.

Two regression fixtures from the opendataloader-bench corpus, covering
the dominant production failure categories:

* `greencomp_competence.pdf` — 2-col shaded-header + plain-body glossary.
  Mirrors production crops #1 (Contractions glossary) and #6 (BIO 350
  course header).
* `upstage_key_functions.pdf` — 4-col shaded-header + alt-row backgrounds
  + merged left column. Mirrors production crops #2 (Parameter/Value
  alt-row), #7 (Spanish XML schema), and #8 (Córdoba multi-row header).

Existing fixtures stay green (doc 51 wrapped-label, doc 128 forecast
six-cols, td9264 snapshot). 133 unit + integration tests pass; clippy
clean.

Bumps napi/package.json 1.8.4 → 1.8.5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:43:44 -07:00
Abimael Martell 88844d18be Bump version from 1.8.3 to 1.8.4 2026-05-02 21:29:51 -07:00
Abimael MartellandClaude Opus 4.7 cfc080f79a extractor: drop Latin-1 mojibake on Type0/CID fonts; tokenize wide TSR items (#75)
* extractor: drop Latin-1 mojibake on Type0/CID fonts; tokenize wide TSR items

Two text-extraction failure modes surfaced by table-candidate shadow
data; both also affect the existing TableFormer / vector-grid paths
since they share `extract_tables_with_structure_*_mem`'s downstream
cell-fill.

1. CJK / multi-byte mojibake. The bottom Latin-1 fallback in
   `extract_text_from_operand` ran unconditionally. For a Type0/CID
   (Identity-H) font whose ToUnicode CMap fails to parse, the bytes
   are CIDs (font-internal indices), not character codes — per-byte
   Latin-1 produces mojibake (e.g. 2-byte CID 0xCDD9 surfaces as "ÍÙ").
   Gate that fallback on `FontWidthInfo.is_cid` (set by
   `parse_type0_widths` for `/Subtype /Type0`). For Type0 fonts with
   any non-ASCII byte, emit one U+FFFD per CID instead so
   `detect_encoding_issues` still trips and the page is flagged for
   OCR — preserving the existing OCR-routing path that the
   high-Latin-1 garbage used to satisfy by accident. Type1 / TrueType
   simple fonts retain the per-byte Latin-1 round-trip (it IS the
   canonical interpretation for them; verified against an existing
   pdf-evals fixture where bytes like 0xB6 are legitimate Latin-1).

   Threaded `font_widths: &PageFontWidths` through
   `extract_text_from_operand` and its 5 call sites in
   `content_stream.rs` / `xobjects.rs`.

2. Dense-cell text collapse in `extract_tables_with_structure_cells_mem`.
   Stage-1 routing did per-item assignment — each TextItem went into the
   single cell whose bbox contained its center. When a row's text is
   rendered as one wide Tj (e.g. "Marshall Islands 0.9 0.9 0.9"), the
   whole row parks in one cell and the rest of the row stays empty.

   New `split_item_into_token_subitems` helper splits each item into
   per-token virtual sub-items with x positions estimated from
   `effective_width / char_count` and the token's character offset.
   Stage 1 then routes per-token. Single-token items collapse to a
   one-element vector (no behavior change). Multi-token items spanning
   multiple cells distribute correctly. Stage-2 orphan recovery now
   operates on token-grain orphans rather than re-trying whole items.

Tests:
 - `cid_font_with_unparseable_cmap_does_not_emit_latin1_mojibake` (unit)
   exercises the Type0/CID + unparseable-CMap fallback path.
 - `simple_font_latin1_fallback_passes_high_bytes_through` (unit)
   guards the false-positive case where a Type1 font's `/ToUnicode`
   reference is set but bytes are legitimate Latin-1 character codes.
 - `test_extract_tables_with_structure_distributes_wide_item_across_cells`
   (integration) builds a synthetic PDF with one wide Tj and asserts
   each token lands in its own cell.

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

* tests: pin CID mojibake fix mechanism with FFFD assertions

Two complementary tests for the Type0/CID Latin-1-fallback guard:

1. Tighten `test_identity_h_no_tounicode_suppresses_garbage` on the
   existing real-PDF fixture `shinagawa_identity_h.pdf` to also assert
   the pre-suppression text contains U+FFFD and contains no high-Latin-1
   chars. Pins down WHICH mechanism is suppressing the garbage so a
   future regression that re-enables Latin-1 mojibake fails loudly here
   instead of silently switching the suppression chain back to
   `is_cid_garbage` + high-Latin-1 detection.

2. Add `test_synthetic_type0_broken_tounicode_emits_fffd_not_latin1_mojibake`
   with a fully-synthetic Type0 / Identity-H PDF built in process. We
   control the malformed ToUnicode contents, the descendant CIDFontType2
   shape (just enough for `parse_type0_widths` to set `is_cid=true`,
   which is what the new guard keys off of), and the Tj byte stream.
   No fixture file or external license needed. Reproduces the exact
   "Type0 + non-ASCII bytes + unparseable ToUnicode" code path that
   produced the production mojibake samples.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 21:28:39 -07:00
Abimael Martell 1f28e523fd tables: keep wrapped labels in TSR output (#73) 2026-04-30 10:56:05 -07:00
10 changed files with 874 additions and 63 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@firecrawl/pdf-inspector",
"version": "1.8.2",
"version": "1.8.5",
"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",
+12 -1
View File
@@ -385,6 +385,7 @@ pub(crate) fn extract_page_text_items(
&font_encodings,
&encoding_cache,
&mut cmap_decisions,
&font_widths,
) {
let combined = multiply_matrices(&text_matrix, &ctm);
let rendered_size = effective_font_size(current_font_size, &combined);
@@ -533,6 +534,7 @@ pub(crate) fn extract_page_text_items(
&font_encodings,
&encoding_cache,
&mut cmap_decisions,
&font_widths,
) {
current_text.push_str(&text);
}
@@ -620,6 +622,7 @@ pub(crate) fn extract_page_text_items(
&font_encodings,
&encoding_cache,
&mut cmap_decisions,
&font_widths,
) {
if !text.trim().is_empty() {
let combined = multiply_matrices(&text_matrix, &ctm);
@@ -1012,9 +1015,17 @@ pub(crate) fn extract_page_text_items(
// producing thousands of identical rects that yield a degenerate grid.
// After dedup, if too few unique clip rects remain we fall through to
// fill rects (explicitly drawn visible rectangles).
//
// When fill rects substantially outnumber clip rects, the clips are
// typically section-level wrappers and the fills are the actual table
// cell backgrounds (e.g. shaded-header tables drawn with `m`/`l`/`h`/`f*`
// sequences). In that case, prefer fills.
if rects.is_empty() {
dedup_rects(&mut clip_rects);
if clip_rects.len() >= 4 {
let prefer_fills = !fill_rects.is_empty() && fill_rects.len() >= clip_rects.len() * 3;
if prefer_fills {
rects = fill_rects;
} else if clip_rects.len() >= 4 {
rects = clip_rects;
} else if !fill_rects.is_empty() {
rects = fill_rects;
+122 -1
View File
@@ -720,7 +720,11 @@ pub(crate) fn extract_text_from_operand(
font_encodings: &PageFontEncodings,
encoding_cache: &HashMap<String, Encoding<'_>>,
cmap_decisions: &mut CMapDecisionCache,
font_widths: &PageFontWidths,
) -> Option<String> {
let is_type0_cid_font = font_widths
.get(current_font)
.is_some_and(|info| info.is_cid);
let result = (|| -> Option<String> {
if let Object::String(bytes, _) = obj {
let mut decode_with_entry = |entry: &crate::tounicode::CMapEntry| -> Option<String> {
@@ -962,7 +966,31 @@ pub(crate) fn extract_text_from_operand(
return Some(symbol_text);
}
// Latin-1 fallback
// Latin-1 fallback. Safe ONLY for fonts that use single-byte
// encodings — for these, an unmapped byte is a valid character
// code in Latin-1/WinAnsi space. CID fonts (Type0 / Identity-H)
// emit multi-byte CIDs that aren't characters; per-byte Latin-1
// produces mojibake (e.g. 2-byte CID 0xCDD9 → "ÍÙ" for the
// production scrape_id 019de78c-... samples).
//
// For a CID font (has_cmap is set OR a /ToUnicode reference
// exists) with any non-ASCII bytes, emit a single U+FFFD per
// CID instead. This both replaces the mojibake with a proper
// "decode failed" marker AND keeps `detect_encoding_issues`
// tripping so the page is flagged for OCR — the existing
// garbage-detection path that the high-Latin-1 mojibake used
// to satisfy by accident.
if is_type0_cid_font && bytes.iter().any(|&b| b > 0x7F) {
// 2-byte CIDs (Identity-H) are by far the common case; for
// an odd byte count we still emit at least one marker so
// detection downstream fires.
let cid_count = (bytes.len() / 2).max(1);
return Some("\u{FFFD}".repeat(cid_count));
}
// Pure ASCII bytes round-trip safely (Latin-1 == ASCII for
// 0x00..=0x7F), and non-CID (Type1 / TrueType / Type3) fonts
// use single-byte encodings where Latin-1 fallback is the
// canonical interpretation.
Some(bytes.iter().map(|&b| b as char).collect())
} else {
None
@@ -1213,4 +1241,97 @@ mod tests {
let bad = "###!!!@@@$$$";
assert!(score_text(good) > score_text(bad));
}
#[test]
fn cid_font_with_unparseable_cmap_does_not_emit_latin1_mojibake() {
// Type0/CID font (font_widths reports `is_cid=true`) where the
// ToUnicode CMap couldn't be parsed (FontCMaps doesn't have the
// obj_num). Bytes are a 2-byte CID stream containing high bytes
// that aren't valid UTF-8 — exactly the case in the production
// samples (Identity-H text where the ToUnicode CMap was missing
// or malformed, scrape_id 019de78c-..., e.g. "Í Ù Z)¿").
//
// Without the guard, the function falls through to the byte-by-byte
// Latin-1 fallback and produces "ÍÙ" (U+00CD U+00D9). The correct
// behavior is to emit U+FFFD per CID so downstream
// `detect_encoding_issues` flags the page for OCR.
let bytes = vec![0xCD_u8, 0xD9, 0xCD, 0xD9];
let obj = Object::String(bytes, lopdf::StringFormat::Hexadecimal);
let font_cmaps = FontCMaps::default();
let mut font_tounicode_refs: HashMap<String, u32> = HashMap::new();
font_tounicode_refs.insert("F0".to_string(), 999);
let inline_cmaps = HashMap::new();
let font_encodings: PageFontEncodings = HashMap::new();
let encoding_cache: HashMap<String, Encoding<'_>> = HashMap::new();
let mut decisions = CMapDecisionCache::new();
let mut font_widths: PageFontWidths = HashMap::new();
font_widths.insert("F0".to_string(), make_font_info(&[], 1000, true));
let result = extract_text_from_operand(
&obj,
"F0",
None,
&font_cmaps,
&font_tounicode_refs,
&inline_cmaps,
&font_encodings,
&encoding_cache,
&mut decisions,
&font_widths,
);
let text = result.expect("CID font fallback should still emit a marker");
assert!(
!text.contains('\u{00CD}') && !text.contains('\u{00D9}'),
"CID font with unparseable CMap leaked Latin-1 mojibake: {text:?}"
);
assert!(
text.contains('\u{FFFD}'),
"CID font with unparseable CMap should emit U+FFFD so detect_encoding_issues fires: {text:?}"
);
}
#[test]
fn simple_font_latin1_fallback_passes_high_bytes_through() {
// A Type1/TrueType simple font (is_cid=false) with a `/ToUnicode`
// reference but no usable CMap and no `/Differences` map.
// Per-byte Latin-1 IS the canonical interpretation here — these
// bytes are character codes, not CIDs. The CID guard must NOT
// strip them. Reproduces the false positive that an earlier
// version of the guard introduced for fonts in PDFs like
// pdf-evals/Navigating-Artificial-Intelligence-..., where bytes
// like 0xB6 are legitimate Latin-1 character codes.
let bytes = vec![0x24_u8, 0x47, 0xB6, 0x56]; // "$G¶V"
let obj = Object::String(bytes, lopdf::StringFormat::Hexadecimal);
let font_cmaps = FontCMaps::default();
let mut font_tounicode_refs: HashMap<String, u32> = HashMap::new();
font_tounicode_refs.insert("F1".to_string(), 999);
let inline_cmaps = HashMap::new();
let font_encodings: PageFontEncodings = HashMap::new();
let encoding_cache: HashMap<String, Encoding<'_>> = HashMap::new();
let mut decisions = CMapDecisionCache::new();
let mut font_widths: PageFontWidths = HashMap::new();
font_widths.insert("F1".to_string(), make_font_info(&[], 1000, false));
let text = extract_text_from_operand(
&obj,
"F1",
None,
&font_cmaps,
&font_tounicode_refs,
&inline_cmaps,
&font_encodings,
&encoding_cache,
&mut decisions,
&font_widths,
)
.expect("simple font should round-trip Latin-1 bytes");
assert_eq!(text, "$G\u{00B6}V");
assert!(
!text.contains('\u{FFFD}'),
"simple font fallback must not stamp FFFD over legitimate bytes: {text:?}"
);
}
}
+2
View File
@@ -373,6 +373,7 @@ fn extract_form_xobject_text_inner(
&font_encodings,
&encoding_cache,
cmap_decisions,
&font_widths,
) {
let combined = multiply_matrices(&text_matrix, &ctm);
let rendered_size = effective_font_size(current_font_size, &combined);
@@ -517,6 +518,7 @@ fn extract_form_xobject_text_inner(
&font_encodings,
&encoding_cache,
cmap_decisions,
&font_widths,
) {
current_text.push_str(&text);
}
+240 -51
View File
@@ -1035,6 +1035,90 @@ mod vector_grid_tests {
);
}
/// Helper: load a fixture PDF and run the rect-based table detector.
fn detect_rect_tables_in_fixture(path: &str) -> Vec<crate::tables::Table> {
use crate::extractor::content_stream::extract_page_text_items;
use crate::tables::detect_tables_from_rects;
use crate::tounicode::FontCMaps;
use lopdf::Document;
use std::collections::HashSet;
use std::fs;
let buf = fs::read(path).unwrap();
let doc = Document::load_mem(&buf).unwrap();
let pages = doc.get_pages();
let &page_id = pages.get(&1).unwrap();
let needed: HashSet<u32> = HashSet::from([1]);
let cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed));
let ((items, rects, _lines), _has_gid, _rotated) =
extract_page_text_items(&doc, page_id, 1, &cmaps, false).unwrap();
let (rect_tables, _) = detect_tables_from_rects(&items, &rects, 1);
rect_tables
}
/// Regression for `greencomp_competence.pdf` — a 2-column "Area / Competence"
/// glossary with a green-shaded header row and plain (line-drawn) body cells.
/// Mirrors the production failure cohort #1 (Contractions glossary) and #6
/// (BIO 350 course header): a few colored header rects sit in a horizontal
/// strip while body rows are drawn with `m`/`l` operators, so the rect
/// cluster has only 2 Y-edges and `try_build_grid` rejects.
#[test]
fn greencomp_competence_two_cols() {
let tables = detect_rect_tables_in_fixture("tests/fixtures/greencomp_competence.pdf");
assert!(
!tables.is_empty(),
"expected at least one rect-detected table for shaded-header + plain-body shape"
);
let t = tables
.iter()
.max_by_key(|t| t.rows.len() * t.columns.len())
.unwrap();
assert_eq!(
t.columns.len(),
2,
"GreenComp competence is a 2-column table; got {}: {:?}",
t.columns.len(),
t.columns
);
assert!(
t.rows.len() >= 6,
"expected at least 6 rows of competences; got {}",
t.rows.len()
);
}
/// Regression for `upstage_key_functions.pdf` — a 4-column "Service Stage /
/// Function Name / Explanation / Expected Benefit" table with a blue-shaded
/// header band plus alternating row backgrounds. Mirrors production crops
/// #2 (Parameter / Value with alternating blue rows) and #7 (Spanish XML
/// schema with shaded header). Currently `pdf2md` returns zero markdown
/// table rows.
#[test]
fn upstage_key_functions_four_cols() {
let tables = detect_rect_tables_in_fixture("tests/fixtures/upstage_key_functions.pdf");
assert!(
!tables.is_empty(),
"expected at least one rect-detected table for shaded-header + alt-row shape"
);
let t = tables
.iter()
.max_by_key(|t| t.rows.len() * t.columns.len())
.unwrap();
assert_eq!(
t.columns.len(),
4,
"Service Flow is a 4-column table; got {}: {:?}",
t.columns.len(),
t.columns
);
assert!(
t.rows.len() >= 8,
"expected at least 8 visible body rows; got {}",
t.rows.len()
);
}
#[test]
fn test_crop_px_bbox_is_plausible_bounds() {
let crop = [10.0, 20.0, 110.0, 220.0];
@@ -1556,26 +1640,32 @@ 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.
// Stage 1: exclusive per-token assignment. Each PDF text item is
// first split into whitespace-separated tokens with estimated x
// positions (see `split_item_into_token_subitems`). For each token
// we 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, the closest-center wins. Tokens that don't land in any
// cell are eligible for stage-2 orphan recovery.
//
// The exclusivity (one item → one cell) prevents the cell-overlap
// bug where SLANet emits cells whose y-extents overlap between
// rows: under the previous "for each cell, gather items" approach,
// an item whose center fell in two cells' overlap got duplicated
// into both. Closest-center disambiguation routes it to the
// correct row.
let mut claimed: std::collections::HashSet<usize> = std::collections::HashSet::new();
let mut item_to_cell: std::collections::HashMap<usize, usize> =
std::collections::HashMap::new();
// Per-token (rather than per-item) routing is what prevents the
// dense-grid collapse bug: a row rendered as one wide Tj
// ("Marshall Islands 0.9 0.9 0.9") produces one TextItem whose
// CENTER lies in only one cell, so per-item routing parks the
// whole row in that single cell and leaves the rest of the row
// empty. Per-token routing distributes the words to whichever
// cells their (estimated) positions fall into. Single-token items
// collapse to a one-element token list and behave exactly as
// before.
//
// The token-level exclusivity (one token → one cell) still
// prevents the cell-overlap bug where SLANet emits cells whose
// y-extents overlap between rows. Closest-center disambiguation
// routes each token to the correct row.
// Pre-compute each cell's bounds + center (in PDF-pt-flipped space)
// so we don't redo the work per item.
// so we don't redo the work per token.
let cell_meta: Vec<Option<(RegionBounds, f32, f32)>> = cells
.iter()
.map(|cell| {
@@ -1590,44 +1680,53 @@ pub fn extract_tables_with_structure_cells_mem(
})
.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 mut per_cell_items: Vec<Vec<TextItem>> = vec![Vec::new(); cells.len()];
// Tokens of an item that did NOT land in any cell during stage 1.
// These are the orphan candidates handed to `tsr_assign_orphan_items`.
// Using token-grain orphan candidates (rather than the original wide
// item) lets stage 2 recover individual words that fell just outside
// their cell's clamped band, without re-attributing already-claimed
// tokens.
let mut orphan_token_subitems: Vec<TextItem> = Vec::new();
for item in items.iter() {
let token_subitems = split_item_into_token_subitems(item);
for token_item in token_subitems {
let token_w = text_utils::effective_width(&token_item);
let token_cx = token_item.x + token_w * 0.5;
let token_cy = token_item.y + token_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(&token_item, *bounds) {
continue;
}
let dx = token_cx - ccx;
let dy = token_cy - ccy;
let dist_sq = dx * dx + dy * dy;
if best.is_none_or(|(_, d)| dist_sq < d) {
best = Some((cell_idx, dist_sq));
}
}
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));
if let Some((ci, _)) = best {
per_cell_items[ci].push(token_item);
} else {
orphan_token_subitems.push(token_item);
}
}
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
// Build per-cell text from the assigned tokens. Markdown cells must
// be one line — collapse line breaks from the line-grouping pass.
let mut per_cell_items: Vec<Vec<TextItem>> = vec![Vec::new(); cells.len()];
for (&item_idx, &cell_idx) in &item_to_cell {
per_cell_items[cell_idx].push(items[item_idx].clone());
}
for (cell_idx, matched) in per_cell_items.into_iter().enumerate() {
cells[cell_idx].text = collect_text_from_matched_items(matched, adaptive_threshold)
.replace(['\n', '\r'], " ");
}
// Stage 2: orphan assignment — text items that didn't land in any
// cell during stage 1 get assigned to their nearest *empty* cell,
// Stage 2: orphan assignment — tokens 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`:
@@ -1639,7 +1738,13 @@ pub fn extract_tables_with_structure_cells_mem(
// 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);
tsr_assign_orphan_items(
&orphan_token_subitems,
&mut cells,
&std::collections::HashSet::new(),
page_h,
coords,
);
results.push(cells);
}
@@ -1647,6 +1752,70 @@ pub fn extract_tables_with_structure_cells_mem(
Ok(results)
}
/// Split a `TextItem` into one virtual sub-item per whitespace-separated
/// token, with each token's `x` / `width` estimated from the original item's
/// effective width and the token's character offset.
///
/// PDFs often render an entire row's content as a single Tj — e.g.
/// "Marshall Islands 0.9 0.9 0.9" — producing one wide TextItem whose
/// center sits in only one of the model-emitted cells. Per-item routing
/// then parks the whole row in that one cell. Splitting on whitespace
/// gives each word its own approximate position so per-cell routing can
/// distribute the words to whichever cells their estimated centers fall
/// into.
///
/// The character-width estimate is `effective_width / char_count`.
/// `effective_width` returns the explicit `item.width` when known and
/// otherwise falls back to `char_count * font_size * 0.5`. Either way the
/// estimate is uniform across the item — fine for routing, since we only
/// need to know which cell each token's center lands in, not its exact
/// position. Single-token items collapse to a one-element vector
/// equivalent to the input item, making this a no-op for the common case.
fn split_item_into_token_subitems(item: &TextItem) -> Vec<TextItem> {
let total_chars = item.text.chars().count();
if total_chars == 0 {
return Vec::new();
}
let item_w = text_utils::effective_width(item);
let char_w = item_w / total_chars as f32;
let mut tokens: Vec<TextItem> = Vec::new();
let mut current_token = String::new();
let mut current_start_idx: Option<usize> = None;
let push_token =
|tokens: &mut Vec<TextItem>, text: String, start_idx: usize, end_idx: usize| {
if text.is_empty() {
return;
}
let mut sub = item.clone();
sub.text = text;
sub.x = item.x + start_idx as f32 * char_w;
sub.width = (end_idx - start_idx) as f32 * char_w;
tokens.push(sub);
};
for (idx, ch) in item.text.chars().enumerate() {
if ch.is_whitespace() {
if let Some(start_idx) = current_start_idx.take() {
let text = std::mem::take(&mut current_token);
push_token(&mut tokens, text, start_idx, idx);
}
} else {
if current_start_idx.is_none() {
current_start_idx = Some(idx);
}
current_token.push(ch);
}
}
if let Some(start_idx) = current_start_idx {
let text = std::mem::take(&mut current_token);
push_token(&mut tokens, text, start_idx, total_chars);
}
tokens
}
/// 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.
@@ -2152,13 +2321,15 @@ fn try_expand_multi_row_cells(
/// * `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
/// * `multi_row_in_cell` — at least one non-label `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).
/// to span multiple lines. First-row/first-column wraps are ignored
/// unless the in-place row expansion has enough support to repair them,
/// because those are often legitimate wrapped headers or row labels.
/// 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,
@@ -2216,6 +2387,8 @@ fn detect_tsr_quality_issue(
};
let expanded_cells =
try_expand_multi_row_cells(cells, &items, page_h, coords, adaptive_threshold);
let first_row = cells.iter().map(|cell| cell.row).min().unwrap_or(0);
let first_col = cells.iter().map(|cell| cell.col).min().unwrap_or(0);
for cell in cells {
// rowspan>1 cells are intentionally multi-line — skip them.
@@ -2229,14 +2402,30 @@ fn detect_tsr_quality_issue(
if cell_items.len() < 2 {
continue;
}
if cluster_tsr_cell_text_lines(cell_items).len() >= 2 {
if cluster_tsr_cell_text_lines(cell_items).len() < 2 {
continue;
}
if expanded_cells.is_some() {
return Ok(Some(TsrQualityIssue::MultiRowInCell { expanded_cells }));
}
if !is_wrapped_tsr_label_cell(cell, first_row, first_col) {
return Ok(Some(TsrQualityIssue::MultiRowInCell {
expanded_cells: None,
}));
}
}
Ok(None)
}
fn is_wrapped_tsr_label_cell(
cell: &tables::StructuredCell,
first_row: usize,
first_col: usize,
) -> bool {
cell.is_header || cell.row == first_row || cell.col == first_col
}
/// 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),
+76 -7
View File
@@ -277,6 +277,39 @@ pub fn detect_tables_from_rects(
);
}
// Drop exact / near-exact duplicates first. Many PDFs draw the
// same cell rectangle multiple times — once for the cell border,
// again for an inner padding fill, plus a per-text-run background
// wrapper. Without this dedup, the contained-sub-rect pass below
// can't help (it requires container area to strictly exceed the
// sub-rect by 20%), and the duplicated edges over-segment the grid
// into spurious thin rows / columns that collapse content density.
//
// Preserve original order (no sort) — cluster output is keyed by
// first-seen index, and a sort here would shuffle the table-emission
// order on multi-table pages.
if page_rects.len() < MAX_CLUSTER_RECTS {
let before = page_rects.len();
let mut seen: std::collections::HashSet<(i32, i32, i32, i32)> =
std::collections::HashSet::new();
page_rects.retain(|&(x, y, w, h)| {
let key = (
x.round() as i32,
y.round() as i32,
w.round() as i32,
h.round() as i32,
);
seen.insert(key)
});
if page_rects.len() < before {
debug!(
"page {}: removed {} duplicate rects",
page,
before - page_rects.len(),
);
}
}
// Deduplicate sub-rects: when a rect is fully contained within a
// slightly larger rect (same column, interior Y range), the smaller
// one is a cell-internal decoration (e.g. content-area shading
@@ -285,7 +318,11 @@ pub fn detect_tables_from_rects(
//
// Only remove when the container is a similarly-sized cell (height
// ratio < 4×), NOT when the container is a table-wide background
// that dwarfs the sub-rect.
// that dwarfs the sub-rect. Origin-anchored page-background rects
// also disqualify as containers — they normally exceed the 4× ratio,
// but when the sub-rect is itself a tall table-frame the ratio can
// fall under the gate, and dropping the frame collapses cluster
// adjacency between adjacent column-cell groups.
//
// Skip this O(n²) dedup when there are too many rects — pages with
// thousands of vector-drawing rects won't benefit from cell dedup.
@@ -295,9 +332,11 @@ pub fn detect_tables_from_rects(
page_rects.retain(|&(ax, ay, aw, ah)| {
let tol = 2.0;
!snapshot.iter().any(|&(bx, by, bw, bh)| {
let container_is_page_bg = bx < 5.0 && by < 5.0;
// b must strictly contain a (b is larger in area)
bw * bh > aw * ah * 1.2
&& bh < ah * 4.0 // container must be similarly sized, not a table background
&& !container_is_page_bg
&& bx <= ax + tol
&& (bx + bw) >= (ax + aw) - tol
&& by <= ay + tol
@@ -1728,9 +1767,17 @@ fn detect_row_stripe_table_from_cell_rects(
// inside a bounding-box rect (e.g. chat-transcript figures) the
// word-boundary gaps cluster into many spurious columns, and the
// resulting cells hold sentence fragments riddled with common English
// function words. Count cells with any such word and reject when
// 20%+ of non-empty cells match — real tabular data (labels, units,
// numbers) rarely contains these words.
// function words.
//
// The 20%-of-cells threshold catches both shapes — a prose paragraph
// chunked across cols where every cell carries prose, and a single
// prose column flanked by empty cols where the prose dominates the
// small population of non-empty cells. To avoid rejecting real data
// tables that happen to include one description column, relax only
// when content is well-distributed: at least 75% of columns must hold
// ≥2 non-empty cells. That excludes the prose-in-a-frame case (one
// filled col, the rest empty) while admitting "label / value /
// explanation / benefit"-style tables.
if num_cols >= 4 {
const PROSE_WORDS: &[&str] = &[
"a", "an", "the", "of", "to", "is", "was", "are", "were", "be", "been", "in", "on",
@@ -1758,11 +1805,33 @@ fn detect_row_stripe_table_from_cell_rects(
}
}
if counted > 0 && prose_cells * 5 >= counted {
let filled_cols = (0..num_cols)
.filter(|&c| {
cells
.iter()
.filter(|row| {
!row.get(c)
.map(String::as_str)
.unwrap_or("")
.trim()
.is_empty()
})
.count()
>= 2
})
.count();
let well_distributed = filled_cols * 4 >= num_cols * 3;
if !well_distributed {
debug!(
" cell-rect rejected: {}/{} cells contain prose function words — likely prose ({}/{} cols filled)",
prose_cells, counted, filled_cols, num_cols
);
return None;
}
debug!(
" cell-rect rejected: {}/{} cells contain prose function words — likely prose",
prose_cells, counted
" cell-rect prose check relaxed: {}/{} cols filled — table-with-description-col",
filled_cols, num_cols
);
return None;
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+421 -2
View File
@@ -1189,9 +1189,38 @@ fn test_firecrawl_tagged_pdf_struct_tree() {
#[test]
fn test_identity_h_no_tounicode_suppresses_garbage() {
// shinagawa_identity_h.pdf uses YuGothic with Identity-H encoding and no
// ToUnicode CMap. The raw CID values look like random Latin characters.
// We should suppress the garbage and flag the page for OCR.
// usable ToUnicode CMap. The raw CID bytes (e.g. 0x08 0x37, 0x0E 0x0F)
// contain non-ASCII high bytes and previously fell through to the
// per-byte Latin-1 fallback, producing high-Latin-1 mojibake that
// `is_cid_garbage` flagged. The Type0/CID guard in
// `extract_text_from_operand` now emits one U+FFFD per CID instead of
// mojibake; `detect_encoding_issues` trips on that and suppresses the
// markdown / flags the page for OCR — so we still pass this test, but
// via the deliberate marker path rather than by accident.
let buf = std::fs::read("tests/fixtures/shinagawa_identity_h.pdf").unwrap();
// Pre-suppression check: the raw text items must contain the U+FFFD
// markers that prove the Type0/CID fallback fired. This pins the
// mechanism so a future regression that re-enables Latin-1 mojibake
// would fail loudly here, not just silently change the suppression
// chain to one that depends on `is_cid_garbage` + high-Latin-1 chars.
let items = pdf_inspector::extractor::extract_text_with_positions_mem(&buf).unwrap();
let combined: String = items.iter().map(|i| i.text.as_str()).collect();
assert!(
combined.contains('\u{FFFD}'),
"Type0/CID font with unparseable ToUnicode CMap should emit U+FFFD per CID; \
got {} chars: {:?}",
combined.len(),
&combined[..combined.len().min(100)]
);
assert!(
!combined
.chars()
.any(|c| ('\u{0080}'..='\u{00FF}').contains(&c)),
"Latin-1 mojibake (high bytes) must not leak from Type0/CID fallback; got: {:?}",
&combined[..combined.len().min(100)]
);
let result = pdf_inspector::process_pdf_mem(&buf).unwrap();
// Page 1 should be flagged for OCR
@@ -2532,6 +2561,61 @@ fn test_auto_expands_under_counted_vector_grid_rows() {
);
}
#[test]
fn test_auto_keeps_wrapped_header_vector_grid_doc51() {
use pdf_inspector::{extract_tables_with_structure_auto_mem, TsrTableInput};
let buf = std::fs::read("tests/fixtures/government_positions_women.pdf").unwrap();
let crop = [0.0, 0.0, 612.0, 792.0];
let grid = detect_vector_grid_in_region_mem(&buf, 0, crop, 200.0)
.unwrap()
.expect("expected doc 51 vector grid");
assert_eq!(
grid.cell_bboxes.len(),
36,
"doc 51 should have a 9x4 vector grid"
);
let results = extract_tables_with_structure_auto_mem(
&buf,
&[TsrTableInput {
page: 0,
crop_pdf_pt_bbox: crop,
render_dpi: 200.0,
structure_tokens: grid.structure_tokens,
cell_bboxes: grid.cell_bboxes,
}],
)
.unwrap();
assert_eq!(results.len(), 1);
let r = &results[0];
assert!(
r.fallback_reason.is_none(),
"wrapped header/label text should not trigger heuristic fallback: {:?}\n{}",
r.fallback_reason,
r.markdown
);
let md = &r.markdown;
assert!(md.contains("Government Position"), "missing header: {md}");
assert!(
md.contains("Aquino Administration"),
"missing Aquino header: {md}"
);
assert!(
md.contains("Ramos Administration"),
"missing Ramos header: {md}"
);
assert!(
md.contains("City Municipal Councilor"),
"row label was truncated: {md}"
);
assert!(
!md.contains("|Position||Administration"),
"heuristic fallback split the header row: {md}"
);
}
#[test]
fn test_auto_returns_empty_inputs() {
use pdf_inspector::extract_tables_with_structure_auto_mem;
@@ -2945,3 +3029,338 @@ fn test_extract_pages_markdown_path_none_returns_all_pages() {
let result = extract_pages_markdown(path, None).unwrap();
assert_eq!(result.pages.len() as u32, page_count);
}
// ============================================================================
// PROBE: investigate dense-cell text-assignment failure mode (failure mode 2)
// ============================================================================
fn synthetic_wide_row_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()]),
// A single Tj that visually spans multiple cells. This mirrors PDFs
// where a row's address/role/email columns are emitted as one literal
// string with embedded spaces, producing one wide TextItem.
Operation::new(
"Tj",
vec![Object::string_literal("Name JobTitle Email Phone")],
),
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_distributes_wide_item_across_cells() {
use pdf_inspector::{extract_tables_with_structure_cells_mem, TsrTableInput};
// Reproduces failure mode 2: a row of multi-token text rendered as one
// Tj produces a single wide TextItem that visually spans multiple cells.
// The current first-match-by-center routing parks the entire item in
// whichever cell holds the item's center, leaving the other cells empty.
// See production samples in scrape_id 019de788-ff41-... where 10-column
// grids ended up with row text packed into one cell.
let buf = synthetic_wide_row_pdf();
// Helvetica 10pt with width=0 falls back to char_count*font_size*0.5.
// "Name JobTitle Email Phone" is 25 chars → effective_width 125pt,
// text starts at PDF (20, 700), top-down y=[90, 100], char_w≈5pt.
// Tokens land at:
// "Name" chars 0-3 center≈x=30
// "JobTitle" chars 5-12 center≈x=65
// "Email" chars 14-18 center≈x=100
// "Phone" chars 20-24 center≈x=130
let cell_bboxes = vec![
poly(15.0, 88.0, 50.0, 102.0),
poly(50.0, 88.0, 85.0, 102.0),
poly(85.0, 88.0, 120.0, 102.0),
poly(120.0, 88.0, 155.0, 102.0),
];
let tokens: Vec<String> = [
"<table>",
"<tbody>",
"<tr>",
"<td></td>",
"<td></td>",
"<td></td>",
"<td></td>",
"</tr>",
"</tbody>",
"</table>",
]
.into_iter()
.map(String::from)
.collect();
let cells_lists = extract_tables_with_structure_cells_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 cells = &cells_lists[0];
assert_eq!(cells.len(), 4);
assert_eq!(
cells[0].text, "Name",
"cell 0 should hold 'Name', got {:?}",
cells[0].text
);
assert_eq!(
cells[1].text, "JobTitle",
"cell 1 should hold 'JobTitle', got {:?}",
cells[1].text
);
assert_eq!(
cells[2].text, "Email",
"cell 2 should hold 'Email', got {:?}",
cells[2].text
);
assert_eq!(
cells[3].text, "Phone",
"cell 3 should hold 'Phone', got {:?}",
cells[3].text
);
}
// ============================================================================
// PROPER TEST: synthetic Type0/Identity-H PDF with malformed ToUnicode CMap
// ============================================================================
//
// Complements the existing real-PDF fixture `shinagawa_identity_h.pdf` by
// building a minimal Type0 / Identity-H font in process. We control:
// * the byte stream emitted by Tj (a 2-byte CID containing one high byte),
// * the malformed ToUnicode contents (junk bytes that won't parse), and
// * the DescendantFonts shape (just enough for `parse_type0_widths` to set
// `is_cid=true`, which is what the new guard in `extract_text_from_operand`
// keys off of).
// No fixture file or external license to worry about.
fn synthetic_type0_broken_tounicode_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 cid_font_id = doc.new_object_id();
let descriptor_id = doc.new_object_id();
let tounicode_id = doc.new_object_id();
let cid_system_info_id = doc.new_object_id();
let content_id = doc.new_object_id();
// Type0 font with Identity-H encoding and a broken ToUnicode reference.
doc.objects.insert(
font_id,
dictionary! {
"Type" => "Font",
"Subtype" => "Type0",
"BaseFont" => "AAAAAA+SyntheticCID",
"Encoding" => "Identity-H",
"DescendantFonts" => vec![cid_font_id.into()],
"ToUnicode" => tounicode_id,
}
.into(),
);
// CIDSystemInfo and a minimal CIDFontType2 descendant. parse_type0_widths
// walks DescendantFonts → returns FontWidthInfo with is_cid=true. That's
// the only thing the new Latin-1 guard needs to see.
doc.objects.insert(
cid_system_info_id,
dictionary! {
"Registry" => Object::string_literal("Adobe"),
"Ordering" => Object::string_literal("Identity"),
"Supplement" => 0,
}
.into(),
);
doc.objects.insert(
cid_font_id,
dictionary! {
"Type" => "Font",
"Subtype" => "CIDFontType2",
"BaseFont" => "AAAAAA+SyntheticCID",
"CIDSystemInfo" => cid_system_info_id,
"FontDescriptor" => descriptor_id,
"DW" => 1000,
}
.into(),
);
doc.objects.insert(
descriptor_id,
dictionary! {
"Type" => "FontDescriptor",
"FontName" => "AAAAAA+SyntheticCID",
"Flags" => 4,
"FontBBox" => vec![Object::Integer(-100), Object::Integer(-100), 1000.into(), 1000.into()],
"ItalicAngle" => 0,
"Ascent" => 800,
"Descent" => Object::Integer(-200),
"CapHeight" => 700,
"StemV" => 80,
}
.into(),
);
// Intentionally malformed ToUnicode stream — just junk bytes. ToUnicode
// CMap parsing will fail, so `font_cmaps.get_by_obj` returns None and
// `has_cmap` stays false. The reference still exists in the font dict,
// so `font_tounicode_refs` contains the entry — but the new guard now
// routes off `is_cid` from font_widths instead, which is robust to a
// failed CMap parse.
doc.objects.insert(
tounicode_id,
Stream::new(dictionary! {}, b"this is not a valid CMap stream".to_vec()).into(),
);
// Tj with a 2-byte CID stream containing a non-ASCII high byte.
// Pre-fix this would have decoded as Latin-1 to "\u{00CD}\u{00D9}" ("ÍÙ").
// Post-fix it should produce U+FFFD per CID.
let cid_bytes = vec![0xCD_u8, 0xD9, 0xCD, 0xD9];
let operations = vec![
Operation::new("BT", vec![]),
Operation::new("Tf", vec!["F0".into(), 12.into()]),
Operation::new("Td", vec![50.into(), 100.into()]),
Operation::new(
"Tj",
vec![Object::String(cid_bytes, lopdf::StringFormat::Hexadecimal)],
),
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(), 200.into()],
"Resources" => dictionary! {
"Font" => dictionary! {
"F0" => 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_synthetic_type0_broken_tounicode_emits_fffd_not_latin1_mojibake() {
let buf = synthetic_type0_broken_tounicode_pdf();
let items = pdf_inspector::extractor::extract_text_with_positions_mem(&buf).unwrap();
let combined: String = items.iter().map(|i| i.text.as_str()).collect();
// Mojibake leak check: 2-byte CID 0xCDD9 must NOT come out as "ÍÙ"
// (U+00CD U+00D9). That was the production scrape symptom.
assert!(
!combined.contains('\u{00CD}'),
"Latin-1 mojibake leaked from Type0 font: {combined:?}"
);
assert!(
!combined.contains('\u{00D9}'),
"Latin-1 mojibake leaked from Type0 font: {combined:?}"
);
// Marker presence: Type0/CID + non-ASCII bytes must produce U+FFFD so
// `detect_encoding_issues` can flag the page for OCR downstream.
assert!(
combined.contains('\u{FFFD}'),
"Type0 font with malformed ToUnicode CMap should emit U+FFFD per CID; got: {combined:?}"
);
// End-to-end check: the page is correctly routed to OCR.
let result = pdf_inspector::process_pdf_mem(&buf).unwrap();
assert!(
result.pages_needing_ocr.contains(&1),
"Type0 page with broken ToUnicode + non-ASCII bytes must be flagged for OCR; \
pages_needing_ocr={:?}",
result.pages_needing_ocr
);
}