Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc85057a0e | ||
|
|
2f23f07f6e | ||
|
|
0a9c120a6b | ||
|
|
7c8b09be67 | ||
|
|
35445c3208 |
@@ -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
@@ -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",
|
||||
|
||||
@@ -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, ®ions)
|
||||
.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
@@ -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
@@ -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+E000–F8FF when the
|
||||
/// ToUnicode CMap is missing. >10% PUA means significant undecoded content.
|
||||
///
|
||||
/// 2. **Control characters** — C0 controls (U+0000–001F 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user