Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc85057a0e |
@@ -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.
|
/// Per-page markdown extraction result.
|
||||||
#[napi(object)]
|
#[napi(object)]
|
||||||
pub struct PageMarkdownResult {
|
pub struct PageMarkdownResult {
|
||||||
|
|||||||
+259
-116
@@ -462,6 +462,98 @@ pub struct PageRegionResult {
|
|||||||
pub regions: Vec<RegionText>,
|
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.
|
/// Extract text within bounding-box regions from a PDF in memory.
|
||||||
///
|
///
|
||||||
/// This is designed for hybrid OCR pipelines: a layout model detects regions
|
/// 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],
|
buffer: &[u8],
|
||||||
page_regions: &[(u32, Vec<[f32; 4]>)],
|
page_regions: &[(u32, Vec<[f32; 4]>)],
|
||||||
) -> Result<Vec<PageRegionResult>, PdfError> {
|
) -> Result<Vec<PageRegionResult>, PdfError> {
|
||||||
validate_pdf_bytes(buffer)?;
|
let data = prepare_region_extraction(buffer, page_regions)?;
|
||||||
let (doc, _page_count) = load_document_from_mem(buffer)?;
|
|
||||||
let pages = doc.get_pages();
|
|
||||||
|
|
||||||
// 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());
|
let mut results = Vec::with_capacity(page_regions.len());
|
||||||
|
|
||||||
for (page_0idx, regions) in page_regions {
|
for (page_0idx, regions) in page_regions {
|
||||||
let page_1idx = page_0idx + 1;
|
let page_1idx = page_0idx + 1;
|
||||||
let items = items_by_page.get(&page_1idx);
|
let items = data.items_by_page.get(&page_1idx);
|
||||||
let page_h = page_heights.get(&page_1idx).copied().unwrap_or(792.0);
|
let (page_h, adaptive_threshold, coords) = page_region_context(&data, page_1idx);
|
||||||
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 mut page_results = Vec::with_capacity(regions.len());
|
let mut page_results = Vec::with_capacity(regions.len());
|
||||||
|
|
||||||
@@ -602,74 +638,20 @@ pub fn extract_tables_in_regions_mem(
|
|||||||
buffer: &[u8],
|
buffer: &[u8],
|
||||||
page_regions: &[(u32, Vec<[f32; 4]>)],
|
page_regions: &[(u32, Vec<[f32; 4]>)],
|
||||||
) -> Result<Vec<PageRegionResult>, PdfError> {
|
) -> Result<Vec<PageRegionResult>, PdfError> {
|
||||||
validate_pdf_bytes(buffer)?;
|
let data = prepare_region_extraction(buffer, page_regions)?;
|
||||||
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 mut results = Vec::with_capacity(page_regions.len());
|
let mut results = Vec::with_capacity(page_regions.len());
|
||||||
|
|
||||||
for (page_0idx, regions) in page_regions {
|
for (page_0idx, regions) in page_regions {
|
||||||
let page_1idx = page_0idx + 1;
|
let page_1idx = page_0idx + 1;
|
||||||
let items = items_by_page.get(&page_1idx);
|
let items = data.items_by_page.get(&page_1idx);
|
||||||
let page_h = page_heights.get(&page_1idx).copied().unwrap_or(792.0);
|
let (page_h, _adaptive_threshold, coords) = page_region_context(&data, page_1idx);
|
||||||
let _page_has_gid = gid_pages.contains(&page_1idx);
|
|
||||||
let coords = if rotated_pages.contains(&page_1idx) {
|
|
||||||
RegionCoordSpace::Rotated90Ccw
|
|
||||||
} else {
|
|
||||||
RegionCoordSpace::Standard
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut page_results = Vec::with_capacity(regions.len());
|
let mut page_results = Vec::with_capacity(regions.len());
|
||||||
|
|
||||||
for rect in regions {
|
for rect in regions {
|
||||||
let [rx1, ry1, rx2, ry2] = *rect;
|
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 {
|
let matched: Vec<TextItem> = match items {
|
||||||
Some(items) => {
|
Some(items) => {
|
||||||
let bounds = region_bounds(rx1, ry1, rx2, ry2, page_h, coords);
|
let bounds = region_bounds(rx1, ry1, rx2, ry2, page_h, coords);
|
||||||
@@ -750,6 +732,70 @@ pub fn extract_tables_in_regions_mem(
|
|||||||
Ok(results)
|
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.
|
/// Get page height in points from MediaBox.
|
||||||
fn get_page_height(doc: &Document, page_id: lopdf::ObjectId) -> Option<f32> {
|
fn get_page_height(doc: &Document, page_id: lopdf::ObjectId) -> Option<f32> {
|
||||||
let page_dict = doc.get_dictionary(page_id).ok()?;
|
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
|
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
|
/// Detect markdown tables with suspicious structure that suggest the heuristic
|
||||||
/// missed/mangled rows or columns. Returns true when the caller should treat
|
/// missed/mangled rows or columns. Returns true when the caller should treat
|
||||||
/// the result as `needs_ocr` and fall back to GPU OCR.
|
/// 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"
|
"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