Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1424ff8d00 | ||
|
|
4dd0164d81 | ||
|
|
0b3b0379e6 | ||
|
|
cc85057a0e |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "firecrawl-pdf-inspector",
|
"name": "firecrawl-pdf-inspector",
|
||||||
"version": "0.7.4",
|
"version": "0.7.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.",
|
"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",
|
"main": "index.js",
|
||||||
"types": "index.d.ts",
|
"types": "index.d.ts",
|
||||||
|
|||||||
@@ -99,6 +99,26 @@ pub struct PageRegionTexts {
|
|||||||
pub regions: Vec<RegionText>,
|
pub regions: Vec<RegionText>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// LaTeX reconstruction result for a single formula region.
|
||||||
|
#[napi(object)]
|
||||||
|
pub struct FormulaLatexResult {
|
||||||
|
/// Reconstructed LaTeX string.
|
||||||
|
pub latex: String,
|
||||||
|
/// The linearized raw text (before LaTeX reconstruction).
|
||||||
|
pub raw_text: String,
|
||||||
|
/// Heuristic confidence in the LaTeX output (0.0–1.0).
|
||||||
|
pub confidence: f64,
|
||||||
|
/// `true` when extraction failed entirely and GPU OCR is needed.
|
||||||
|
pub needs_ocr: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// LaTeX reconstruction results for one page's formula regions.
|
||||||
|
#[napi(object)]
|
||||||
|
pub struct PageFormulaLatexResults {
|
||||||
|
pub page: u32,
|
||||||
|
pub regions: Vec<FormulaLatexResult>,
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -317,6 +337,70 @@ 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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract formula text within bounding-box regions and reconstruct LaTeX.
|
||||||
|
///
|
||||||
|
/// Like `extractFormulasInRegions` but additionally reconstructs LaTeX from
|
||||||
|
/// the positioned text items. Each result includes the raw text, reconstructed
|
||||||
|
/// LaTeX, a confidence score, and the `needsOcr` flag.
|
||||||
|
///
|
||||||
|
/// The confidence score (0.0–1.0) indicates how reliable the heuristic LaTeX
|
||||||
|
/// reconstruction is. The caller should use this to decide whether to trust
|
||||||
|
/// the LaTeX or fall back to GPU OCR.
|
||||||
|
///
|
||||||
|
/// Coordinates are PDF points with top-left origin.
|
||||||
|
#[napi]
|
||||||
|
pub fn extract_formulas_in_regions_as_latex(
|
||||||
|
buffer: Buffer,
|
||||||
|
page_regions: Vec<PageRegions>,
|
||||||
|
) -> Result<Vec<PageFormulaLatexResults>> {
|
||||||
|
let bytes: Vec<u8> = buffer.to_vec();
|
||||||
|
let regions = parse_page_regions(&page_regions);
|
||||||
|
|
||||||
|
catch_panic("extract_formulas_in_regions_as_latex", move || {
|
||||||
|
let results = pdf_inspector::extract_formulas_in_regions_as_latex(&bytes, ®ions)
|
||||||
|
.map_err(|e| to_napi_err(e, "extract_formulas_in_regions_as_latex"))?;
|
||||||
|
Ok(results
|
||||||
|
.into_iter()
|
||||||
|
.map(|page_result| PageFormulaLatexResults {
|
||||||
|
page: page_result.page,
|
||||||
|
regions: page_result
|
||||||
|
.regions
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| FormulaLatexResult {
|
||||||
|
latex: r.latex,
|
||||||
|
raw_text: r.raw_text,
|
||||||
|
confidence: r.confidence as f64,
|
||||||
|
needs_ocr: r.needs_ocr,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Per-page markdown extraction result.
|
/// Per-page markdown extraction result.
|
||||||
#[napi(object)]
|
#[napi(object)]
|
||||||
pub struct PageMarkdownResult {
|
pub struct PageMarkdownResult {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,418 @@
|
|||||||
|
//! Unicode to LaTeX character mapping.
|
||||||
|
//!
|
||||||
|
//! Maps Unicode math symbols, Greek letters, operators, and relations to their
|
||||||
|
//! LaTeX command equivalents. Only includes characters that commonly appear in
|
||||||
|
//! PDF text extraction of mathematical formulas.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
/// Returns a reference to the global Unicode → LaTeX mapping table.
|
||||||
|
pub fn unicode_to_latex_map() -> &'static HashMap<char, &'static str> {
|
||||||
|
static MAP: OnceLock<HashMap<char, &'static str>> = OnceLock::new();
|
||||||
|
MAP.get_or_init(build_map)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_map() -> HashMap<char, &'static str> {
|
||||||
|
let entries: &[(char, &str)] = &[
|
||||||
|
// ── Greek lowercase ─────────────────────────────────────────
|
||||||
|
('\u{03B1}', r"\alpha"),
|
||||||
|
('\u{03B2}', r"\beta"),
|
||||||
|
('\u{03B3}', r"\gamma"),
|
||||||
|
('\u{03B4}', r"\delta"),
|
||||||
|
('\u{03B5}', r"\varepsilon"),
|
||||||
|
('\u{03F5}', r"\epsilon"),
|
||||||
|
('\u{03B6}', r"\zeta"),
|
||||||
|
('\u{03B7}', r"\eta"),
|
||||||
|
('\u{03B8}', r"\theta"),
|
||||||
|
('\u{03D1}', r"\vartheta"),
|
||||||
|
('\u{03B9}', r"\iota"),
|
||||||
|
('\u{03BA}', r"\kappa"),
|
||||||
|
('\u{03BB}', r"\lambda"),
|
||||||
|
('\u{03BC}', r"\mu"),
|
||||||
|
('\u{03BD}', r"\nu"),
|
||||||
|
('\u{03BE}', r"\xi"),
|
||||||
|
('\u{03C0}', r"\pi"),
|
||||||
|
('\u{03D6}', r"\varpi"),
|
||||||
|
('\u{03C1}', r"\rho"),
|
||||||
|
('\u{03C2}', r"\varsigma"),
|
||||||
|
('\u{03C3}', r"\sigma"),
|
||||||
|
('\u{03C4}', r"\tau"),
|
||||||
|
('\u{03C5}', r"\upsilon"),
|
||||||
|
('\u{03C6}', r"\varphi"),
|
||||||
|
('\u{03D5}', r"\phi"),
|
||||||
|
('\u{03C7}', r"\chi"),
|
||||||
|
('\u{03C8}', r"\psi"),
|
||||||
|
('\u{03C9}', r"\omega"),
|
||||||
|
// ── Greek uppercase ─────────────────────────────────────────
|
||||||
|
('\u{0393}', r"\Gamma"),
|
||||||
|
('\u{0394}', r"\Delta"),
|
||||||
|
('\u{0398}', r"\Theta"),
|
||||||
|
('\u{039B}', r"\Lambda"),
|
||||||
|
('\u{039E}', r"\Xi"),
|
||||||
|
('\u{03A0}', r"\Pi"),
|
||||||
|
('\u{03A3}', r"\Sigma"),
|
||||||
|
('\u{03A5}', r"\Upsilon"),
|
||||||
|
('\u{03A6}', r"\Phi"),
|
||||||
|
('\u{03A8}', r"\Psi"),
|
||||||
|
('\u{03A9}', r"\Omega"),
|
||||||
|
// ── Large operators ─────────────────────────────────────────
|
||||||
|
('\u{222B}', r"\int"),
|
||||||
|
('\u{222C}', r"\iint"),
|
||||||
|
('\u{222D}', r"\iiint"),
|
||||||
|
('\u{222E}', r"\oint"),
|
||||||
|
('\u{2211}', r"\sum"),
|
||||||
|
('\u{220F}', r"\prod"),
|
||||||
|
('\u{2210}', r"\coprod"),
|
||||||
|
// ── Roots / radicals ────────────────────────────────────────
|
||||||
|
('\u{221A}', r"\sqrt"),
|
||||||
|
// ── Calculus / differential ─────────────────────────────────
|
||||||
|
('\u{2202}', r"\partial"),
|
||||||
|
('\u{2207}', r"\nabla"),
|
||||||
|
// ── Binary operators ────────────────────────────────────────
|
||||||
|
('\u{00B1}', r"\pm"),
|
||||||
|
('\u{2213}', r"\mp"),
|
||||||
|
('\u{00D7}', r"\times"),
|
||||||
|
('\u{00F7}', r"\div"),
|
||||||
|
('\u{2217}', r"\ast"),
|
||||||
|
('\u{22C6}', r"\star"),
|
||||||
|
('\u{00B7}', r"\cdot"),
|
||||||
|
('\u{2219}', r"\bullet"),
|
||||||
|
('\u{2218}', r"\circ"),
|
||||||
|
('\u{2020}', r"\dagger"),
|
||||||
|
('\u{2021}', r"\ddagger"),
|
||||||
|
('\u{2295}', r"\oplus"),
|
||||||
|
('\u{2297}', r"\otimes"),
|
||||||
|
('\u{2227}', r"\wedge"),
|
||||||
|
('\u{2228}', r"\vee"),
|
||||||
|
('\u{2229}', r"\cap"),
|
||||||
|
('\u{222A}', r"\cup"),
|
||||||
|
// ── Relations ───────────────────────────────────────────────
|
||||||
|
('\u{2264}', r"\leq"),
|
||||||
|
('\u{2265}', r"\geq"),
|
||||||
|
('\u{2260}', r"\neq"),
|
||||||
|
('\u{2248}', r"\approx"),
|
||||||
|
('\u{223C}', r"\sim"),
|
||||||
|
('\u{2243}', r"\simeq"),
|
||||||
|
('\u{2261}', r"\equiv"),
|
||||||
|
('\u{226A}', r"\ll"),
|
||||||
|
('\u{226B}', r"\gg"),
|
||||||
|
('\u{221D}', r"\propto"),
|
||||||
|
('\u{2208}', r"\in"),
|
||||||
|
('\u{2209}', r"\notin"),
|
||||||
|
('\u{220B}', r"\ni"),
|
||||||
|
('\u{2282}', r"\subset"),
|
||||||
|
('\u{2283}', r"\supset"),
|
||||||
|
('\u{2286}', r"\subseteq"),
|
||||||
|
('\u{2287}', r"\supseteq"),
|
||||||
|
('\u{22A2}', r"\vdash"),
|
||||||
|
('\u{22A3}', r"\dashv"),
|
||||||
|
('\u{22A4}', r"\top"),
|
||||||
|
('\u{22A5}', r"\bot"),
|
||||||
|
('\u{2225}', r"\parallel"),
|
||||||
|
('\u{22A5}', r"\perp"),
|
||||||
|
// ── Arrows ──────────────────────────────────────────────────
|
||||||
|
('\u{2190}', r"\leftarrow"),
|
||||||
|
('\u{2192}', r"\to"),
|
||||||
|
('\u{2191}', r"\uparrow"),
|
||||||
|
('\u{2193}', r"\downarrow"),
|
||||||
|
('\u{2194}', r"\leftrightarrow"),
|
||||||
|
('\u{21D0}', r"\Leftarrow"),
|
||||||
|
('\u{21D2}', r"\Rightarrow"),
|
||||||
|
('\u{21D4}', r"\Leftrightarrow"),
|
||||||
|
('\u{21A6}', r"\mapsto"),
|
||||||
|
('\u{2197}', r"\nearrow"),
|
||||||
|
('\u{2198}', r"\searrow"),
|
||||||
|
// ── Miscellaneous symbols ───────────────────────────────────
|
||||||
|
('\u{221E}', r"\infty"),
|
||||||
|
('\u{2200}', r"\forall"),
|
||||||
|
('\u{2203}', r"\exists"),
|
||||||
|
('\u{2204}', r"\nexists"),
|
||||||
|
('\u{2205}', r"\emptyset"),
|
||||||
|
('\u{00AC}', r"\neg"),
|
||||||
|
('\u{00B0}', r"^\circ"),
|
||||||
|
('\u{2032}', r"'"), // prime (common in physics: x')
|
||||||
|
('\u{2033}', r"''"), // double prime
|
||||||
|
('\u{210F}', r"\hbar"),
|
||||||
|
('\u{2113}', r"\ell"),
|
||||||
|
('\u{211C}', r"\Re"),
|
||||||
|
('\u{2111}', r"\Im"),
|
||||||
|
('\u{2118}', r"\wp"),
|
||||||
|
('\u{2135}', r"\aleph"),
|
||||||
|
// ── Dots ────────────────────────────────────────────────────
|
||||||
|
('\u{22EF}', r"\cdots"),
|
||||||
|
('\u{22EE}', r"\vdots"),
|
||||||
|
('\u{22F1}', r"\ddots"),
|
||||||
|
('\u{2026}', r"\ldots"),
|
||||||
|
// ── Delimiters / brackets ───────────────────────────────────
|
||||||
|
('\u{27E8}', r"\langle"),
|
||||||
|
('\u{27E9}', r"\rangle"),
|
||||||
|
('\u{2308}', r"\lceil"),
|
||||||
|
('\u{2309}', r"\rceil"),
|
||||||
|
('\u{230A}', r"\lfloor"),
|
||||||
|
('\u{230B}', r"\rfloor"),
|
||||||
|
('\u{2016}', r"\|"),
|
||||||
|
// ── Accents / decorations (as standalone chars) ─────────────
|
||||||
|
('\u{0302}', r"\hat{}"),
|
||||||
|
('\u{0303}', r"\tilde{}"),
|
||||||
|
('\u{0304}', r"\bar{}"),
|
||||||
|
('\u{0307}', r"\dot{}"),
|
||||||
|
('\u{0308}', r"\ddot{}"),
|
||||||
|
('\u{20D7}', r"\vec{}"),
|
||||||
|
// Hat/tilde as standalone characters (sometimes extracted separately)
|
||||||
|
('\u{02C6}', r"\hat{}"),
|
||||||
|
('\u{02DC}', r"\tilde{}"),
|
||||||
|
// ── Subscript/superscript digits (Unicode) ──────────────────
|
||||||
|
('\u{2070}', "^{0}"),
|
||||||
|
('\u{00B9}', "^{1}"),
|
||||||
|
('\u{00B2}', "^{2}"),
|
||||||
|
('\u{00B3}', "^{3}"),
|
||||||
|
('\u{2074}', "^{4}"),
|
||||||
|
('\u{2075}', "^{5}"),
|
||||||
|
('\u{2076}', "^{6}"),
|
||||||
|
('\u{2077}', "^{7}"),
|
||||||
|
('\u{2078}', "^{8}"),
|
||||||
|
('\u{2079}', "^{9}"),
|
||||||
|
('\u{207A}', "^{+}"),
|
||||||
|
('\u{207B}', "^{-}"),
|
||||||
|
('\u{2080}', "_{0}"),
|
||||||
|
('\u{2081}', "_{1}"),
|
||||||
|
('\u{2082}', "_{2}"),
|
||||||
|
('\u{2083}', "_{3}"),
|
||||||
|
('\u{2084}', "_{4}"),
|
||||||
|
('\u{2085}', "_{5}"),
|
||||||
|
('\u{2086}', "_{6}"),
|
||||||
|
('\u{2087}', "_{7}"),
|
||||||
|
('\u{2088}', "_{8}"),
|
||||||
|
('\u{2089}', "_{9}"),
|
||||||
|
('\u{208A}', "_{+}"),
|
||||||
|
('\u{208B}', "_{-}"),
|
||||||
|
// ── Math italic letters (sometimes used in PDF fonts) ───────
|
||||||
|
// These map back to plain ASCII in LaTeX (math mode handles italics)
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut map = HashMap::with_capacity(entries.len());
|
||||||
|
for &(ch, latex) in entries {
|
||||||
|
map.insert(ch, latex);
|
||||||
|
}
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a single character to its LaTeX representation.
|
||||||
|
///
|
||||||
|
/// Returns `Some(latex_str)` if the character has a known mapping,
|
||||||
|
/// or `None` if it should be kept as-is.
|
||||||
|
pub fn char_to_latex(ch: char) -> Option<&'static str> {
|
||||||
|
unicode_to_latex_map().get(&ch).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if a character is a "known math character" — either ASCII
|
||||||
|
/// alphanumeric, basic punctuation used in math, or a mapped Unicode symbol.
|
||||||
|
pub fn is_known_math_char(ch: char) -> bool {
|
||||||
|
if ch.is_ascii_alphanumeric() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Common ASCII math characters
|
||||||
|
matches!(
|
||||||
|
ch,
|
||||||
|
'+' | '-'
|
||||||
|
| '*'
|
||||||
|
| '/'
|
||||||
|
| '='
|
||||||
|
| '<'
|
||||||
|
| '>'
|
||||||
|
| '('
|
||||||
|
| ')'
|
||||||
|
| '['
|
||||||
|
| ']'
|
||||||
|
| '{'
|
||||||
|
| '}'
|
||||||
|
| ','
|
||||||
|
| '.'
|
||||||
|
| ':'
|
||||||
|
| ';'
|
||||||
|
| '!'
|
||||||
|
| '|'
|
||||||
|
| '\''
|
||||||
|
| '"'
|
||||||
|
| '^'
|
||||||
|
| '_'
|
||||||
|
| '~'
|
||||||
|
| ' '
|
||||||
|
| '\n'
|
||||||
|
| '\t'
|
||||||
|
) || unicode_to_latex_map().contains_key(&ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a string of text to LaTeX, applying per-character mappings.
|
||||||
|
/// Characters without mappings are left as-is.
|
||||||
|
///
|
||||||
|
/// Returns `(latex_string, fraction_of_chars_that_were_known)`.
|
||||||
|
pub fn text_to_latex_chars(text: &str) -> (String, f32) {
|
||||||
|
let map = unicode_to_latex_map();
|
||||||
|
let mut result = String::with_capacity(text.len() * 2);
|
||||||
|
let mut total_nonws = 0usize;
|
||||||
|
let mut known = 0usize;
|
||||||
|
|
||||||
|
for ch in text.chars() {
|
||||||
|
if ch.is_whitespace() {
|
||||||
|
result.push(ch);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
total_nonws += 1;
|
||||||
|
|
||||||
|
if let Some(latex) = map.get(&ch) {
|
||||||
|
// Add space before LaTeX commands that start with backslash
|
||||||
|
// to prevent them from merging with preceding text
|
||||||
|
if latex.starts_with('\\') && !result.is_empty() && !result.ends_with(' ') {
|
||||||
|
// Only add space if the last char is alphanumeric (to avoid "x \alpha" but allow "( \alpha")
|
||||||
|
let last = result.chars().last().unwrap();
|
||||||
|
if last.is_alphanumeric() || last == '}' {
|
||||||
|
result.push(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push_str(latex);
|
||||||
|
// Add trailing space after LaTeX commands so next char doesn't merge
|
||||||
|
if latex.starts_with('\\') && !latex.ends_with('}') && !latex.ends_with('\'') {
|
||||||
|
result.push(' ');
|
||||||
|
}
|
||||||
|
known += 1;
|
||||||
|
} else if is_known_math_char(ch) {
|
||||||
|
result.push(ch);
|
||||||
|
known += 1;
|
||||||
|
} else {
|
||||||
|
// Unknown character — keep it but it lowers confidence
|
||||||
|
result.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let frac = if total_nonws == 0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
known as f32 / total_nonws as f32
|
||||||
|
};
|
||||||
|
(result, frac)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_greek_lowercase() {
|
||||||
|
assert_eq!(char_to_latex('\u{03B1}'), Some(r"\alpha"));
|
||||||
|
assert_eq!(char_to_latex('\u{03B2}'), Some(r"\beta"));
|
||||||
|
assert_eq!(char_to_latex('\u{03B3}'), Some(r"\gamma"));
|
||||||
|
assert_eq!(char_to_latex('\u{03B4}'), Some(r"\delta"));
|
||||||
|
assert_eq!(char_to_latex('\u{03B5}'), Some(r"\varepsilon"));
|
||||||
|
assert_eq!(char_to_latex('\u{03B6}'), Some(r"\zeta"));
|
||||||
|
assert_eq!(char_to_latex('\u{03B7}'), Some(r"\eta"));
|
||||||
|
assert_eq!(char_to_latex('\u{03B8}'), Some(r"\theta"));
|
||||||
|
assert_eq!(char_to_latex('\u{03D1}'), Some(r"\vartheta"));
|
||||||
|
assert_eq!(char_to_latex('\u{03B9}'), Some(r"\iota"));
|
||||||
|
assert_eq!(char_to_latex('\u{03BA}'), Some(r"\kappa"));
|
||||||
|
assert_eq!(char_to_latex('\u{03BB}'), Some(r"\lambda"));
|
||||||
|
assert_eq!(char_to_latex('\u{03BC}'), Some(r"\mu"));
|
||||||
|
assert_eq!(char_to_latex('\u{03BD}'), Some(r"\nu"));
|
||||||
|
assert_eq!(char_to_latex('\u{03BE}'), Some(r"\xi"));
|
||||||
|
assert_eq!(char_to_latex('\u{03C0}'), Some(r"\pi"));
|
||||||
|
assert_eq!(char_to_latex('\u{03C1}'), Some(r"\rho"));
|
||||||
|
assert_eq!(char_to_latex('\u{03C3}'), Some(r"\sigma"));
|
||||||
|
assert_eq!(char_to_latex('\u{03C4}'), Some(r"\tau"));
|
||||||
|
assert_eq!(char_to_latex('\u{03C9}'), Some(r"\omega"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_greek_uppercase() {
|
||||||
|
assert_eq!(char_to_latex('\u{0393}'), Some(r"\Gamma"));
|
||||||
|
assert_eq!(char_to_latex('\u{0394}'), Some(r"\Delta"));
|
||||||
|
assert_eq!(char_to_latex('\u{0398}'), Some(r"\Theta"));
|
||||||
|
assert_eq!(char_to_latex('\u{039B}'), Some(r"\Lambda"));
|
||||||
|
assert_eq!(char_to_latex('\u{03A3}'), Some(r"\Sigma"));
|
||||||
|
assert_eq!(char_to_latex('\u{03A6}'), Some(r"\Phi"));
|
||||||
|
assert_eq!(char_to_latex('\u{03A9}'), Some(r"\Omega"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_operators() {
|
||||||
|
assert_eq!(char_to_latex('\u{222B}'), Some(r"\int"));
|
||||||
|
assert_eq!(char_to_latex('\u{2211}'), Some(r"\sum"));
|
||||||
|
assert_eq!(char_to_latex('\u{220F}'), Some(r"\prod"));
|
||||||
|
assert_eq!(char_to_latex('\u{221A}'), Some(r"\sqrt"));
|
||||||
|
assert_eq!(char_to_latex('\u{2202}'), Some(r"\partial"));
|
||||||
|
assert_eq!(char_to_latex('\u{2207}'), Some(r"\nabla"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_relations() {
|
||||||
|
assert_eq!(char_to_latex('\u{2264}'), Some(r"\leq"));
|
||||||
|
assert_eq!(char_to_latex('\u{2265}'), Some(r"\geq"));
|
||||||
|
assert_eq!(char_to_latex('\u{2260}'), Some(r"\neq"));
|
||||||
|
assert_eq!(char_to_latex('\u{2248}'), Some(r"\approx"));
|
||||||
|
assert_eq!(char_to_latex('\u{223C}'), Some(r"\sim"));
|
||||||
|
assert_eq!(char_to_latex('\u{226A}'), Some(r"\ll"));
|
||||||
|
assert_eq!(char_to_latex('\u{226B}'), Some(r"\gg"));
|
||||||
|
assert_eq!(char_to_latex('\u{221E}'), Some(r"\infty"));
|
||||||
|
assert_eq!(char_to_latex('\u{2208}'), Some(r"\in"));
|
||||||
|
assert_eq!(char_to_latex('\u{2209}'), Some(r"\notin"));
|
||||||
|
assert_eq!(char_to_latex('\u{2282}'), Some(r"\subset"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_misc_symbols() {
|
||||||
|
assert_eq!(char_to_latex('\u{00B1}'), Some(r"\pm"));
|
||||||
|
assert_eq!(char_to_latex('\u{00D7}'), Some(r"\times"));
|
||||||
|
assert_eq!(char_to_latex('\u{00B7}'), Some(r"\cdot"));
|
||||||
|
assert_eq!(char_to_latex('\u{00B0}'), Some(r"^\circ"));
|
||||||
|
assert_eq!(char_to_latex('\u{2192}'), Some(r"\to"));
|
||||||
|
assert_eq!(char_to_latex('\u{21D2}'), Some(r"\Rightarrow"));
|
||||||
|
assert_eq!(char_to_latex('\u{210F}'), Some(r"\hbar"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unicode_super_sub_digits() {
|
||||||
|
assert_eq!(char_to_latex('\u{00B2}'), Some("^{2}"));
|
||||||
|
assert_eq!(char_to_latex('\u{00B3}'), Some("^{3}"));
|
||||||
|
assert_eq!(char_to_latex('\u{2082}'), Some("_{2}"));
|
||||||
|
assert_eq!(char_to_latex('\u{2083}'), Some("_{3}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_known_math_char() {
|
||||||
|
// ASCII math
|
||||||
|
assert!(is_known_math_char('+'));
|
||||||
|
assert!(is_known_math_char('='));
|
||||||
|
assert!(is_known_math_char('('));
|
||||||
|
assert!(is_known_math_char('x'));
|
||||||
|
assert!(is_known_math_char('0'));
|
||||||
|
// Mapped Unicode
|
||||||
|
assert!(is_known_math_char('\u{03B1}')); // alpha
|
||||||
|
assert!(is_known_math_char('\u{2264}')); // leq
|
||||||
|
// Unknown
|
||||||
|
assert!(!is_known_math_char('\u{E000}')); // PUA
|
||||||
|
assert!(!is_known_math_char('\u{4E00}')); // CJK
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_text_to_latex_simple() {
|
||||||
|
let (latex, frac) = text_to_latex_chars("x + y");
|
||||||
|
assert_eq!(latex, "x + y");
|
||||||
|
assert!((frac - 1.0).abs() < 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_text_to_latex_greek() {
|
||||||
|
let (latex, frac) = text_to_latex_chars("αβγ");
|
||||||
|
assert!(latex.contains(r"\alpha"));
|
||||||
|
assert!(latex.contains(r"\beta"));
|
||||||
|
assert!(latex.contains(r"\gamma"));
|
||||||
|
assert!((frac - 1.0).abs() < 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_text_to_latex_mixed() {
|
||||||
|
let (latex, frac) = text_to_latex_chars("x ≤ y");
|
||||||
|
assert!(latex.contains(r"\leq"));
|
||||||
|
assert!((frac - 1.0).abs() < 0.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
+354
-116
@@ -28,6 +28,7 @@ pub mod python;
|
|||||||
pub mod adobe_korea1;
|
pub mod adobe_korea1;
|
||||||
pub mod detector;
|
pub mod detector;
|
||||||
pub mod extractor;
|
pub mod extractor;
|
||||||
|
pub mod formula_latex;
|
||||||
pub mod glyph_names;
|
pub mod glyph_names;
|
||||||
pub mod markdown;
|
pub mod markdown;
|
||||||
pub mod process_mode;
|
pub mod process_mode;
|
||||||
@@ -462,6 +463,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 +578,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 +639,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 +733,164 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract formula text within bounding-box regions and reconstruct LaTeX.
|
||||||
|
///
|
||||||
|
/// For each formula bbox, this function:
|
||||||
|
/// 1. Gets positioned text items inside the bbox
|
||||||
|
/// 2. Analyzes font sizes and vertical positions to detect sub/superscripts
|
||||||
|
/// 3. Detects simple fractions from vertically stacked text
|
||||||
|
/// 4. Converts Unicode math symbols to LaTeX commands
|
||||||
|
/// 5. Reconstructs structured LaTeX from the positioned items
|
||||||
|
///
|
||||||
|
/// Each result includes a `confidence` score (0.0–1.0) indicating how reliable
|
||||||
|
/// the LaTeX reconstruction is. Low-confidence results should be sent to GPU OCR.
|
||||||
|
///
|
||||||
|
/// The `needs_ocr` flag is set only when extraction fails entirely (empty text,
|
||||||
|
/// encoding garbage), NOT based on confidence — the caller decides the threshold.
|
||||||
|
pub fn extract_formulas_in_regions_as_latex(
|
||||||
|
buffer: &[u8],
|
||||||
|
page_regions: &[(u32, Vec<[f32; 4]>)],
|
||||||
|
) -> Result<Vec<formula_latex::PageFormulaResult>, 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 result = match items {
|
||||||
|
Some(items) => {
|
||||||
|
// Convert the bbox from top-left origin to the item coordinate space
|
||||||
|
let bounds = region_bounds(rx1, ry1, rx2, ry2, page_h, coords);
|
||||||
|
|
||||||
|
// Filter items whose center falls inside the bbox
|
||||||
|
let matched = formula_latex::filter_items_in_bbox(
|
||||||
|
items,
|
||||||
|
bounds.x_min,
|
||||||
|
bounds.y_min,
|
||||||
|
bounds.x_max,
|
||||||
|
bounds.y_max,
|
||||||
|
);
|
||||||
|
|
||||||
|
if matched.is_empty() {
|
||||||
|
formula_latex::FormulaResult {
|
||||||
|
latex: String::new(),
|
||||||
|
raw_text: String::new(),
|
||||||
|
confidence: 0.0,
|
||||||
|
needs_ocr: true,
|
||||||
|
confidence_breakdown: Vec::new(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let (latex, raw_text, confidence, confidence_breakdown) =
|
||||||
|
formula_latex::reconstruct_latex(&matched);
|
||||||
|
|
||||||
|
// Same formula-specific quality checks as extract_formulas_in_regions_mem
|
||||||
|
let needs_ocr = raw_text.trim().is_empty()
|
||||||
|
|| is_cid_garbage(&raw_text)
|
||||||
|
|| detect_encoding_issues(&raw_text)
|
||||||
|
|| is_formula_garbage(&raw_text);
|
||||||
|
|
||||||
|
formula_latex::FormulaResult {
|
||||||
|
latex,
|
||||||
|
raw_text,
|
||||||
|
confidence,
|
||||||
|
needs_ocr,
|
||||||
|
confidence_breakdown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => formula_latex::FormulaResult {
|
||||||
|
latex: String::new(),
|
||||||
|
raw_text: String::new(),
|
||||||
|
confidence: 0.0,
|
||||||
|
needs_ocr: true,
|
||||||
|
confidence_breakdown: Vec::new(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
page_results.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(formula_latex::PageFormulaResult {
|
||||||
|
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 +1488,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 +2248,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