Compare commits

..
Author SHA1 Message Date
Abimael MartellandClaude Opus 4.6 cc85057a0e feat: add extractFormulasInRegions for native formula text extraction
Add a new region extraction endpoint that uses formula-specific quality
checks instead of the generic text garbage detector. Formula text is
legitimately symbol-heavy (Greek letters, math operators, subscripts),
so the standard is_garbage_text check — which requires >50% alphanumeric
characters — would false-positive on valid formula regions.

The new is_formula_garbage validator catches actual decode failures:
PUA characters from undecoded TeX extensible delimiters (>10%) and
control characters from broken font encodings (>30%).

Also refactors the shared page-extraction boilerplate into
prepare_region_extraction, eliminating duplication across
extract_text_in_regions_mem and extract_tables_in_regions_mem.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:51:43 -07:00
Abimael MartellandClaude Opus 4.6 2f23f07f6e fix: use AND logic for looks_like_scan heuristic in detector (#39)
The looks_like_scan check incorrectly used OR logic, causing any single
condition (image_count <= 1, text_ops < 50, alphanum < 10) to flag a page
as a scan. A real scan has ALL three: single full-page image AND low text
AND low alphanum. Text pages with one figure were falsely flagged for OCR.

Bump napi to 0.7.3.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:05:22 -07:00
Abimael MartellandClaude Opus 4.6 0a9c120a6b fix: reduce false OCR recommendations for text PDFs with figures (#38)
* fix: reduce false OCR recommendations for text PDFs with figure images

Two fixes in the detector:

1. Fix Tf operator parsing: some PDFs concatenate Tf directly with the
   next operator (e.g. "25 Tf[<01>...") without whitespace. The scanner
   now accepts [, (, <, / as valid followers, fixing font_changes being
   reported as 0.

2. Distinguish text-with-figures from scanned-with-OCR: pages with
   multiple images (image_count > 1) and strong text signals (text_ops
   >= 50, alphanum >= 10) are recognized as text pages with figures,
   not scanned templates. Scanned PDFs have exactly 1 full-page image.

This prevents academic papers, reports with charts, and similar PDFs
from being incorrectly classified as Mixed/OCR-needed when their text
is perfectly extractable.

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

* chore: bump napi version to 0.7.2

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

* fix: remove template image influence from page classification

Template images (large background/figure images) no longer affect
pages_needing_ocr. In the region-based pipeline, text regions are
extracted independently from image regions, and per-region needs_ocr
quality checks handle scanned-with-OCR garbage text.

Also makes the invisible text retry (for OCR text layers) trigger on
text quality rather than PDF type, so it works regardless of
classification.

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

* Revert "fix: remove template image influence from page classification"

This reverts commit 100cbe5453.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 21:42:19 -07:00
Abimael MartellandClaude Opus 4.6 7c8b09be67 fix: improve table detection for numeric columns and multi-line headers (#35)
* fix: improve heuristic table detection for numeric columns and multi-line headers

Two fixes for tables that have clean extractable text but fail heuristic
structure detection:

1. Numeric column merge pass (grid.rs): After initial X-position
   clustering, adjacent clusters are merged when one is sparse (header
   text) and the other is dense with >50% numeric items (data column).
   Multi-line wrapped headers often land slightly offset from their
   data column — the merge closes gaps within 1.5× the clustering
   threshold. New is_numeric_text() helper matches decimals, percentages,
   negative numbers, and comma-separated thousands.

2. Duplicate-header skip (detect_heuristic.rs): Spanning super-headers
   like "First Degree | First Degree | Higher Degree" contain duplicate
   cells that trigger looks_like_partial_table_ex rejection. Now skips
   rows with duplicate cells when a better header candidate exists
   within the next 3 rows (higher fill ratio or numeric cells).

Tested on BITS Pilani university report (430 pages, 314 table pages).
Page 4 (multi-line header + numeric data) previously returned
needs_ocr=true; now correctly detects the table structure.

Eval: 197 PDFs, zero regressions, all 104+ tests pass, zero clippy.

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

* bump version to 0.7.1

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:27:59 -07:00
Abimael MartellandClaude Opus 4.6 35445c3208 Auto-publish npm package when version changes in package.json (#33)
Replace tag-based trigger with push-to-main trigger that detects
version changes in napi/package.json, removing the need for manual
git tags to publish new npm releases.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:12:55 -07:00
5 changed files with 421 additions and 121 deletions
+29 -2
View File
@@ -2,14 +2,41 @@ name: Publish npm package
on:
push:
tags: ['v*']
branches: [main]
paths: ['napi/package.json']
permissions:
contents: read
id-token: write
jobs:
check-version:
name: Check version change
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.check.outputs.changed }}
version: ${{ steps.check.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Check if version changed
id: check
run: |
NEW_VERSION=$(node -p "require('./napi/package.json').version")
OLD_VERSION=$(git show HEAD~1:napi/package.json | node -p "JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')).version")
echo "old=$OLD_VERSION new=$NEW_VERSION"
if [ "$NEW_VERSION" != "$OLD_VERSION" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
build:
needs: check-version
if: needs.check-version.outputs.changed == 'true'
name: Build ${{ matrix.target }}
runs-on: ${{ matrix.os }}
strategy:
@@ -68,7 +95,7 @@ jobs:
publish:
name: Publish to npm
needs: build
needs: [check-version, build]
runs-on: ubuntu-latest
permissions:
contents: read
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "firecrawl-pdf-inspector",
"version": "0.7.1",
"version": "0.7.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",
+23
View File
@@ -317,6 +317,29 @@ pub fn extract_tables_in_regions(
})
}
/// Extract formula text within bounding-box regions from a PDF.
///
/// Like `extractTextInRegions` but uses formula-specific quality checks.
/// Formula text is legitimately symbol-heavy (Greek letters, math operators)
/// so the generic garbage-text check is relaxed. When the text decodes
/// cleanly, `needsOcr` is `false` — the caller can skip GPU OCR.
///
/// Coordinates are PDF points with top-left origin.
#[napi]
pub fn extract_formulas_in_regions(
buffer: Buffer,
page_regions: Vec<PageRegions>,
) -> Result<Vec<PageRegionTexts>> {
let bytes: Vec<u8> = buffer.to_vec();
let regions = parse_page_regions(&page_regions);
catch_panic("extract_formulas_in_regions", move || {
let results = pdf_inspector::extract_formulas_in_regions_mem(&bytes, &regions)
.map_err(|e| to_napi_err(e, "extract_formulas_in_regions"))?;
Ok(to_page_region_texts(results))
})
}
/// Per-page markdown extraction result.
#[napi(object)]
pub struct PageMarkdownResult {
+109 -2
View File
@@ -223,7 +223,15 @@ pub(crate) fn detect_from_document(
if analysis.has_images {
pages_with_images += 1;
}
if analysis.has_template_image {
// Only count as a template-image page if it looks like a scan
// (single full-page image) rather than a text page with figures.
// Scanned-with-OCR PDFs have 1 large image per page + OCR text overlay;
// text PDFs with figures have multiple smaller images alongside real text.
if analysis.has_template_image
&& (analysis.image_count <= 1
&& analysis.text_operator_count < 50
&& analysis.unique_alphanum_chars < 10)
{
pages_with_template_images += 1;
}
if analysis.has_vector_text {
@@ -350,7 +358,12 @@ pub(crate) fn detect_from_document(
} else {
continue;
};
if analysis.has_template_image
// Template images only need OCR when it looks like a scan
// (single full-page image) rather than figures alongside text.
let looks_like_scan = analysis.image_count <= 1
&& analysis.text_operator_count < 50
&& analysis.unique_alphanum_chars < 10;
if (analysis.has_template_image && looks_like_scan)
|| analysis.has_vector_text
|| (analysis.text_operator_count < config.min_text_ops_per_page
&& analysis.has_images)
@@ -865,10 +878,17 @@ fn scan_content_for_text_operators(
}
} else if next == b'f' {
// Tf = set font operator
// Some PDFs concatenate Tf with the next operator without
// whitespace (e.g. "25 Tf[<01>..." or "25 Tf(<text>..."),
// so also accept '[', '(', '<', '/' as valid followers.
if i + 2 >= content.len()
|| content[i + 2].is_ascii_whitespace()
|| content[i + 2] == b'\n'
|| content[i + 2] == b'\r'
|| content[i + 2] == b'['
|| content[i + 2] == b'('
|| content[i + 2] == b'<'
|| content[i + 2] == b'/'
{
font_changes += 1;
}
@@ -1613,6 +1633,39 @@ mod tests {
assert_eq!(fonts, 2);
}
#[test]
fn test_tf_without_trailing_whitespace() {
// Some PDFs concatenate Tf directly with the next operator's operand,
// e.g. "25 Tf[<01>..." or "25 Tf(<text>..."
let mut uchars = HashSet::new();
// Tf followed by '[' (TJ array start)
let content = b"BT /F1 25 Tf[<01>1<02>-1] TJ ET";
let (ops, _, _, fonts) = scan_content_for_text_operators(content, &mut uchars);
assert_eq!(fonts, 1, "Tf followed by '[' should be counted");
assert_eq!(ops, 1);
// Tf followed by '(' (literal string)
uchars.clear();
let content2 = b"BT /F1 12 Tf(Hello) Tj ET";
let (ops2, _, _, fonts2) = scan_content_for_text_operators(content2, &mut uchars);
assert_eq!(fonts2, 1, "Tf followed by '(' should be counted");
assert_eq!(ops2, 1);
// Tf followed by '<' (hex string)
uchars.clear();
let content3 = b"BT /F1 12 Tf<0102> Tj ET";
let (ops3, _, _, fonts3) = scan_content_for_text_operators(content3, &mut uchars);
assert_eq!(fonts3, 1, "Tf followed by '<' should be counted");
assert_eq!(ops3, 1);
// Tf followed by '/' (next font name)
uchars.clear();
let content4 = b"BT /F1 12 Tf/F2 10 Tf (x) Tj ET";
let (_, _, _, fonts4) = scan_content_for_text_operators(content4, &mut uchars);
assert_eq!(fonts4, 2, "Tf followed by '/' should be counted");
}
#[test]
fn test_newspaper_heuristic_thresholds() {
// Newspaper page: high text ops, moderate font changes, low ratio
@@ -1636,4 +1689,58 @@ mod tests {
let font_changes = 50u32;
assert!(text_ops < 1500);
}
#[test]
fn test_looks_like_scan_requires_all_conditions() {
// The looks_like_scan heuristic requires ALL three conditions (AND):
// 1. image_count <= 1
// 2. text_operator_count < 50
// 3. unique_alphanum_chars < 10
// A text page with one figure: has text ops and alphanum chars
// Should NOT look like a scan
let image_count = 1u32;
let text_operator_count = 135u32;
let unique_alphanum_chars = 58u32;
let looks_like_scan =
image_count <= 1 && text_operator_count < 50 && unique_alphanum_chars < 10;
assert!(
!looks_like_scan,
"text page with one figure should not be flagged as scan"
);
// A genuine scan: single image, no real text
let image_count = 1u32;
let text_operator_count = 3u32;
let unique_alphanum_chars = 2u32;
let looks_like_scan =
image_count <= 1 && text_operator_count < 50 && unique_alphanum_chars < 10;
assert!(
looks_like_scan,
"single image with no real text should be flagged as scan"
);
// OCR overlay page: single image but has OCR text operators and chars
// Should NOT look like a scan (OCR text is sufficient)
let image_count = 1u32;
let text_operator_count = 200u32;
let unique_alphanum_chars = 40u32;
let looks_like_scan =
image_count <= 1 && text_operator_count < 50 && unique_alphanum_chars < 10;
assert!(
!looks_like_scan,
"OCR overlay page should not be flagged as scan"
);
// Multiple images but low text: still not a scan (multiple figures page)
let image_count = 4u32;
let text_operator_count = 25u32;
let unique_alphanum_chars = 1u32;
let looks_like_scan =
image_count <= 1 && text_operator_count < 50 && unique_alphanum_chars < 10;
assert!(
!looks_like_scan,
"multiple images page should not match single-image scan pattern"
);
}
}
+259 -116
View File
@@ -462,6 +462,98 @@ pub struct PageRegionResult {
pub regions: Vec<RegionText>,
}
/// Shared page extraction state for region-based extraction functions.
struct RegionExtractionData {
items_by_page: HashMap<u32, Vec<TextItem>>,
page_heights: HashMap<u32, f32>,
#[allow(dead_code)]
gid_pages: HashSet<u32>,
page_thresholds: HashMap<u32, f32>,
rotated_pages: HashSet<u32>,
}
/// Extract text items, page heights, and metadata for the pages needed by region queries.
///
/// This is the shared boilerplate for `extract_text_in_regions_mem`,
/// `extract_tables_in_regions_mem`, and `extract_formulas_in_regions_mem`.
fn prepare_region_extraction(
buffer: &[u8],
page_regions: &[(u32, Vec<[f32; 4]>)],
) -> Result<RegionExtractionData, PdfError> {
validate_pdf_bytes(buffer)?;
let (doc, _page_count) = load_document_from_mem(buffer)?;
let pages = doc.get_pages();
let needed_pages: HashSet<u32> = page_regions.iter().map(|(p, _)| p + 1).collect();
// Fast mode: skip expensive TrueType font fallback parsing.
// Fonts that can't be decoded from ToUnicode alone will produce empty/garbage
// text, triggering needs_ocr=true → GPU OCR fallback in the pipeline.
let font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed_pages));
let mut items_by_page: HashMap<u32, Vec<TextItem>> = HashMap::new();
let mut page_heights: HashMap<u32, f32> = HashMap::new();
let mut gid_pages: HashSet<u32> = HashSet::new();
let mut page_thresholds: HashMap<u32, f32> = HashMap::new();
let mut rotated_pages: HashSet<u32> = HashSet::new();
for (page_num, &page_id) in pages.iter() {
if !needed_pages.contains(page_num) {
continue;
}
let height = get_page_height(&doc, page_id).unwrap_or(792.0);
page_heights.insert(*page_num, height);
let ((mut items, _rects, _lines), has_gid, coords_rotated) =
extractor::content_stream::extract_page_text_items(
&doc,
page_id,
*page_num,
&font_cmaps,
false,
)?;
let threshold = text_utils::fix_letterspaced_items(&mut items);
if threshold > 0.10 {
page_thresholds.insert(*page_num, threshold);
}
if has_gid {
gid_pages.insert(*page_num);
}
if coords_rotated {
rotated_pages.insert(*page_num);
}
items_by_page.insert(*page_num, items);
}
Ok(RegionExtractionData {
items_by_page,
page_heights,
gid_pages,
page_thresholds,
rotated_pages,
})
}
/// Resolve per-page coord space and adaptive threshold for a given page.
fn page_region_context(
data: &RegionExtractionData,
page_1idx: u32,
) -> (f32, f32, RegionCoordSpace) {
let page_h = data.page_heights.get(&page_1idx).copied().unwrap_or(792.0);
let adaptive_threshold = data
.page_thresholds
.get(&page_1idx)
.copied()
.unwrap_or(0.10);
let coords = if data.rotated_pages.contains(&page_1idx) {
RegionCoordSpace::Rotated90Ccw
} else {
RegionCoordSpace::Standard
};
(page_h, adaptive_threshold, coords)
}
/// Extract text within bounding-box regions from a PDF in memory.
///
/// This is designed for hybrid OCR pipelines: a layout model detects regions
@@ -485,70 +577,14 @@ pub fn extract_text_in_regions_mem(
buffer: &[u8],
page_regions: &[(u32, Vec<[f32; 4]>)],
) -> Result<Vec<PageRegionResult>, PdfError> {
validate_pdf_bytes(buffer)?;
let (doc, _page_count) = load_document_from_mem(buffer)?;
let pages = doc.get_pages();
let data = prepare_region_extraction(buffer, page_regions)?;
// Build a set of pages we need to extract (1-indexed for lopdf)
let needed_pages: HashSet<u32> = page_regions.iter().map(|(p, _)| p + 1).collect();
// Fast mode: skip expensive TrueType font fallback parsing.
// Fonts that can't be decoded from ToUnicode alone will produce empty/garbage
// text, triggering needs_ocr=true → GPU OCR fallback in the pipeline.
let font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed_pages));
// Extract text items for needed pages only
let mut items_by_page: HashMap<u32, Vec<TextItem>> = HashMap::new();
let mut page_heights: HashMap<u32, f32> = HashMap::new();
let mut gid_pages: HashSet<u32> = HashSet::new();
let mut page_thresholds: HashMap<u32, f32> = HashMap::new();
let mut rotated_pages: HashSet<u32> = HashSet::new();
for (page_num, &page_id) in pages.iter() {
if !needed_pages.contains(page_num) {
continue;
}
// Get page height from MediaBox for coordinate flip
let height = get_page_height(&doc, page_id).unwrap_or(792.0);
page_heights.insert(*page_num, height);
// Extract text items for this page
let ((mut items, _rects, _lines), has_gid, coords_rotated) =
extractor::content_stream::extract_page_text_items(
&doc,
page_id,
*page_num,
&font_cmaps,
false,
)?;
let threshold = text_utils::fix_letterspaced_items(&mut items);
if threshold > 0.10 {
page_thresholds.insert(*page_num, threshold);
}
if has_gid {
gid_pages.insert(*page_num);
}
if coords_rotated {
rotated_pages.insert(*page_num);
}
items_by_page.insert(*page_num, items);
}
// For each page's regions, filter and assemble text
let mut results = Vec::with_capacity(page_regions.len());
for (page_0idx, regions) in page_regions {
let page_1idx = page_0idx + 1;
let items = items_by_page.get(&page_1idx);
let page_h = page_heights.get(&page_1idx).copied().unwrap_or(792.0);
let _page_has_gid = gid_pages.contains(&page_1idx);
let adaptive_threshold = page_thresholds.get(&page_1idx).copied().unwrap_or(0.10);
let coords = if rotated_pages.contains(&page_1idx) {
RegionCoordSpace::Rotated90Ccw
} else {
RegionCoordSpace::Standard
};
let items = data.items_by_page.get(&page_1idx);
let (page_h, adaptive_threshold, coords) = page_region_context(&data, page_1idx);
let mut page_results = Vec::with_capacity(regions.len());
@@ -602,74 +638,20 @@ pub fn extract_tables_in_regions_mem(
buffer: &[u8],
page_regions: &[(u32, Vec<[f32; 4]>)],
) -> Result<Vec<PageRegionResult>, PdfError> {
validate_pdf_bytes(buffer)?;
let (doc, _page_count) = load_document_from_mem(buffer)?;
let pages = doc.get_pages();
let needed_pages: HashSet<u32> = page_regions.iter().map(|(p, _)| p + 1).collect();
let font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed_pages));
let mut items_by_page: HashMap<u32, Vec<TextItem>> = HashMap::new();
let mut page_heights: HashMap<u32, f32> = HashMap::new();
let mut gid_pages: HashSet<u32> = HashSet::new();
let mut page_thresholds: HashMap<u32, f32> = HashMap::new();
let mut rotated_pages: HashSet<u32> = HashSet::new();
for (page_num, &page_id) in pages.iter() {
if !needed_pages.contains(page_num) {
continue;
}
let height = get_page_height(&doc, page_id).unwrap_or(792.0);
page_heights.insert(*page_num, height);
let ((mut items, _rects, _lines), has_gid, coords_rotated) =
extractor::content_stream::extract_page_text_items(
&doc,
page_id,
*page_num,
&font_cmaps,
false,
)?;
let threshold = text_utils::fix_letterspaced_items(&mut items);
if threshold > 0.10 {
page_thresholds.insert(*page_num, threshold);
}
if has_gid {
gid_pages.insert(*page_num);
}
if coords_rotated {
rotated_pages.insert(*page_num);
}
items_by_page.insert(*page_num, items);
}
let data = prepare_region_extraction(buffer, page_regions)?;
let mut results = Vec::with_capacity(page_regions.len());
for (page_0idx, regions) in page_regions {
let page_1idx = page_0idx + 1;
let items = items_by_page.get(&page_1idx);
let page_h = page_heights.get(&page_1idx).copied().unwrap_or(792.0);
let _page_has_gid = gid_pages.contains(&page_1idx);
let coords = if rotated_pages.contains(&page_1idx) {
RegionCoordSpace::Rotated90Ccw
} else {
RegionCoordSpace::Standard
};
let items = data.items_by_page.get(&page_1idx);
let (page_h, _adaptive_threshold, coords) = page_region_context(&data, page_1idx);
let mut page_results = Vec::with_capacity(regions.len());
for rect in regions {
let [rx1, ry1, rx2, ry2] = *rect;
// Note: we intentionally DO NOT bail on page_has_gid here.
// The GID flag means some font on the page uses unresolvable
// glyph IDs, but that font may only appear in a logo or
// header — not in the table region. Instead we let the
// per-region text quality checks (is_garbage_text, is_cid_garbage,
// detect_encoding_issues) reject based on the actual extracted
// content. This avoids rejecting clean tables just because an
// unrelated decorative font on the same page is GID-encoded.
let matched: Vec<TextItem> = match items {
Some(items) => {
let bounds = region_bounds(rx1, ry1, rx2, ry2, page_h, coords);
@@ -750,6 +732,70 @@ pub fn extract_tables_in_regions_mem(
Ok(results)
}
/// Extract formula text within bounding-box regions from a PDF in memory.
///
/// Similar to [`extract_text_in_regions_mem`] but uses formula-specific quality
/// checks. Formula text is legitimately symbol-heavy (Greek letters, math
/// operators, subscripts) so the generic `is_garbage_text` check — which rejects
/// text with <50% alphanumeric characters — would false-positive on valid
/// formula regions.
///
/// When the extracted text decodes cleanly, `needs_ocr` is `false` and the
/// caller can skip GPU OCR. When extraction fails (empty, PUA-heavy, encoding
/// issues), `needs_ocr` is `true` for OCR fallback.
pub fn extract_formulas_in_regions_mem(
buffer: &[u8],
page_regions: &[(u32, Vec<[f32; 4]>)],
) -> Result<Vec<PageRegionResult>, PdfError> {
let data = prepare_region_extraction(buffer, page_regions)?;
let mut results = Vec::with_capacity(page_regions.len());
for (page_0idx, regions) in page_regions {
let page_1idx = page_0idx + 1;
let items = data.items_by_page.get(&page_1idx);
let (page_h, adaptive_threshold, coords) = page_region_context(&data, page_1idx);
let mut page_results = Vec::with_capacity(regions.len());
for rect in regions {
let [rx1, ry1, rx2, ry2] = *rect;
let text = match items {
Some(items) => collect_text_in_region_with_options(
items,
rx1,
ry1,
rx2,
ry2,
page_h,
coords,
adaptive_threshold,
),
None => String::new(),
};
// Formula-specific quality checks:
// - Skip is_garbage_text (formulas are legitimately symbol-heavy)
// - Keep CID/encoding checks (broken font decode is still broken)
// - Add PUA check (extensible delimiters that didn't decode)
let needs_ocr = text.trim().is_empty()
|| is_cid_garbage(&text)
|| detect_encoding_issues(&text)
|| is_formula_garbage(&text);
page_results.push(RegionText { text, needs_ocr });
}
results.push(PageRegionResult {
page: *page_0idx,
regions: page_results,
});
}
Ok(results)
}
/// Get page height in points from MediaBox.
fn get_page_height(doc: &Document, page_id: lopdf::ObjectId) -> Option<f32> {
let page_dict = doc.get_dictionary(page_id).ok()?;
@@ -1347,6 +1393,49 @@ fn is_cid_garbage(text: &str) -> bool {
high_latin * 5 >= total * 2 && ascii_letters * 3 < total
}
/// Detect formula text that is unlikely to be usable despite passing generic checks.
///
/// Formula text (Greek letters, math operators, variables) is legitimately
/// symbol-heavy, so `is_garbage_text` would false-positive. This check instead
/// catches:
///
/// 1. **Private Use Area (PUA) characters** — TeX extensible delimiter glyphs
/// (large brackets from CMEX fonts) often map to PUA U+E000F8FF when the
/// ToUnicode CMap is missing. >10% PUA means significant undecoded content.
///
/// 2. **Control characters** — C0 controls (U+0000001F excluding whitespace)
/// indicate broken font encoding, not formula content. >30% is rejected.
fn is_formula_garbage(text: &str) -> bool {
let mut total = 0usize;
let mut pua = 0usize;
let mut control = 0usize;
for ch in text.chars() {
if ch.is_whitespace() {
continue;
}
total += 1;
if ('\u{E000}'..='\u{F8FF}').contains(&ch) {
pua += 1;
}
let cp = ch as u32;
if cp < 0x20 {
control += 1;
}
}
if total < 3 {
return false;
}
// >10% PUA — significant undecoded extensible delimiters
if pua * 10 > total {
return true;
}
// >30% control chars — broken encoding
if control * 10 > total * 3 {
return true;
}
false
}
/// Detect markdown tables with suspicious structure that suggest the heuristic
/// missed/mangled rows or columns. Returns true when the caller should treat
/// the result as `needs_ocr` and fall back to GPU OCR.
@@ -2064,4 +2153,58 @@ mod tests {
"Valid Japanese text should not be flagged as garbage"
);
}
#[test]
fn test_is_formula_garbage_accepts_math_text() {
// Greek letters, math operators, variables — typical formula text
let formula = "Φ(ν) = ∫ ∞ dze it r1 iνr2 sinh z";
assert!(
!is_formula_garbage(formula),
"Valid formula text should not be flagged as garbage"
);
// Dense operator text
let operators = "α + β − γ × δ ÷ ε ≤ ζ ≥ η ≈ θ ≠ ι ± κ";
assert!(
!is_formula_garbage(operators),
"Math operator text should not be flagged as garbage"
);
// Short formula (e.g. single equation variable)
let short = "αβ";
assert!(
!is_formula_garbage(short),
"Short formula text should not be flagged"
);
}
#[test]
fn test_is_formula_garbage_rejects_pua_heavy() {
// Simulates extensible delimiters from CMEX fonts mapping to PUA
let pua_heavy = "x \u{F8EB} \u{F8EC} \u{F8ED} \u{F8F6} \u{F8F7} \u{F8F8} y";
assert!(
is_formula_garbage(pua_heavy),
"PUA-heavy text should be flagged as formula garbage"
);
}
#[test]
fn test_is_formula_garbage_rejects_control_chars() {
// Control characters indicate broken encoding
let control_heavy = "a\x01b\x02c\x03d\x04e\x05f\x06g\x07h\x08i";
assert!(
is_formula_garbage(control_heavy),
"Control-char-heavy text should be flagged as formula garbage"
);
}
#[test]
fn test_is_formula_garbage_accepts_few_pua() {
// A few PUA chars among many valid chars is fine (<10% threshold)
let mostly_good = "Φ(ν) = ∫ dze r1 iνr2 sinh z α β γ δ ε ζ η θ \u{F8EB}";
assert!(
!is_formula_garbage(mostly_good),
"Mostly-good text with rare PUA should pass"
);
}
}