Compare commits

..
Author SHA1 Message Date
Abimael Martell 1424ff8d00 chore: drop dev-only probe binaries and serde dep from PR
The probe-formulas and probe-formulas-latex binaries were used to
validate the LaTeX recovery during development but are not needed
by consumers of the library. Removing them also drops the serde
runtime dependency, which was only required by the probes.

The probes can live as standalone scripts outside the published
crate.

No production behavior change. All 393 lib + 104 integration + 2 doc
tests still pass; clippy clean.
2026-04-15 16:06:13 -07:00
Abimael Martell 4dd0164d81 feat: heuristic LaTeX recovery for formula regions (0.7.5)
Adds extract_formulas_in_regions_as_latex (and NAPI export
extractFormulasInRegionsAsLatex) — converts the linearized text from
formula bboxes into LaTeX using positioned text data, with a
calibrated confidence score so callers can gate on quality.

Pipeline (per formula bbox):
  1. Pull positioned text items inside the bbox via the existing
     positioned-text extractor
  2. Apply unicode → LaTeX char mapping (160+ symbols: Greek,
     operators, relations) from a dedicated unicode_map module
  3. Detect simple structure from item geometry — sub/superscripts
     by font-size + y-baseline, basic two-row fractions
  4. Score the result with positive points for clean conversion,
     plus penalties for failure modes that produce broken output

Confidence is honest, not optimistic. Penalties applied:
  - many items (>15) and very many (>25)
  - 3+ distinct y-bands (multi-row display equations)
  - fraction fired but denominator x-range much wider than
    numerator (cross-equation false positive)
  - fraction fired but denominator starts well to the left
    (likely separate expression below)
  - large operator (∫ ∑ ∏ √ etc) bigger than 1.3× median —
    these need bounded-operator structure detection (Phase 2)
  - mixed font sizes within a single y-band

The high-confidence band (>0.85) on a formula-heavy academic test
PDF dropped from 81% → 38% after recalibration. Manual inspection
confirms the new high-confidence band contains structurally-correct
LaTeX only — no false positives. Mid (0.5-0.85) and low (<0.5)
bands hold the cases where structural reconstruction is uncertain
or broken; callers should fall back to OCR for those.

Also includes:
  - probe-formulas-latex eval binary (compares raw text vs LaTeX
    side-by-side with confidence breakdown for quality inspection)
  - probe-formulas eval binary (validates raw extract API)
  - 8 new unit tests for penalty calculation
  - 15 + 10 unit tests for reconstruction and unicode mapping

Bumps NAPI package to 0.7.5. Builds on extractFormulasInRegions
from the previous formula-extraction feature.
2026-04-15 16:01:47 -07:00
Abimael Martell 0b3b0379e6 Merge remote-tracking branch 'origin/abimaelmartell/formula-extraction' into feat/formula-latex-recovery 2026-04-15 15:33:59 -07:00
Abimael MartellandClaude Opus 4.6 cc85057a0e feat: add extractFormulasInRegions for native formula text extraction
Add a new region extraction endpoint that uses formula-specific quality
checks instead of the generic text garbage detector. Formula text is
legitimately symbol-heavy (Greek letters, math operators, subscripts),
so the standard is_garbage_text check — which requires >50% alphanumeric
characters — would false-positive on valid formula regions.

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:51:43 -07:00
14 changed files with 2149 additions and 1326 deletions
-2
View File
@@ -46,8 +46,6 @@ jobs:
target: x86_64-unknown-linux-gnu
- os: macos-14
target: aarch64-apple-darwin
- os: windows-latest
target: x86_64-pc-windows-msvc
steps:
- uses: actions/checkout@v4
-79
View File
@@ -1,79 +0,0 @@
# pdf-inspector
Fast PDF text extraction to structured Markdown. CLI binary: `pdf2md`. Detection binary: `detect-pdf`.
## Build & Test
```bash
cargo fmt # format
cargo clippy -- -D warnings # lint (enforced, zero warnings)
cargo test # unit + integration tests (267+ unit, 73+ integration)
cargo build --release # release binary for benchmarks
```
All three must pass before committing.
## Binaries
- `pdf2md` — extract PDF → Markdown. Supports `--json` for structured output.
- `detect-pdf` — classify PDF type (TextBased/Scanned/Mixed/ImageBased). Supports `--analyze --json`.
## Architecture
```
src/
lib.rs public API, process_pdf_with_options, encoding issue detection
detector.rs PDF type classification, tiled-scan detection, page sampling
types.rs TextItem, TextLine, PdfRect, PdfLine
tounicode.rs CMap/ToUnicode parsing, CID decoding
text_utils.rs CJK/RTL handling, Otsu threshold, ligature expansion, NFKC
extractor/
mod.rs top-level extraction orchestrator
content_stream.rs PDF operator state machine (Tj/TJ/Td/Tm/q/Q)
fonts.rs font width/encoding, CMapDecisionCache, TrueType cmap fallback
layout.rs column detection (histogram), newspaper/tabular classification,
spanning-line pre-masking, sidebar detection
tables/
detect_rects.rs rect-based table detection (union-find clustering)
detect_heuristic.rs heuristic table detection (gap-histogram, body-font tables)
detect_lines.rs line-based table detection (H/V line grids)
grid.rs column/row boundaries, cell assignment
format.rs table→Markdown formatting, continuation row merging
markdown/
convert.rs core line→Markdown loop, struct-tree role support
analysis.rs font stats, heading tiers, paragraph thresholds
classify.rs line classification (header, list, code, caption)
preprocess.rs drop cap merging, heading line merging
postprocess.rs dot leaders, hyphenation, page numbers, URL formatting
```
## Key design decisions
- **Primary audience is AI agents.** Output optimized for token efficiency and semantic quality, not visual formatting. No cosmetic padding.
- **Three table detection strategies** run in priority order: rect-based → line-based → heuristic. First valid result wins.
- **Column detection** uses horizontal projection histograms with valley detection. Multi-item spanning lines (titles, headers) are pre-masked using column-aware thresholds before column assignment.
- **Newspaper vs tabular** classification determines reading order: newspaper reads columns sequentially, tabular Y-interleaves them.
- **Tiled-scan detection** catches scanned PDFs with JBIG2/strip images where no single tile exceeds the template threshold but aggregate area does (≥2M pixels).
- **Garbage text upgrade** reclassifies Mixed PDFs as Scanned when extracted text is <50% alphanumeric.
- **Tagged PDF support** uses structure tree roles (H1-H6, P, L, Code, BlockQuote) when available, falling back to font-size heuristics.
## Testing
- **Unit tests**: inline `#[cfg(test)] mod tests` in each module with synthetic data.
- **Integration tests**: `tests/integration_tests.rs` with fixture PDFs in `tests/fixtures/`.
- **Regression suite**: sibling repo `pdf-evals` with 179+ snapshot PDFs. Run `cargo build --release` then `bench.py test` in that repo before committing.
## Debugging
```bash
RUST_LOG=pdf_inspector::extractor::layout=debug cargo run --bin pdf2md -- file.pdf
RUST_LOG=pdf_inspector::tables=debug cargo run --bin pdf2md -- file.pdf
RUST_LOG=pdf_inspector::detector=debug cargo run --release --bin detect-pdf -- file.pdf
```
## Conventions
- Clippy: use `is_some_and(...)` not `map_or(false, ...)`
- lopdf quirk: `ParseError` is private — match by string for `InvalidFileHeader`
- Column limit for tables: 25 (wide statistical tables)
- `propagate_merged_cells` skipped for >10 columns (spanning rects = background fills)
+2 -2
View File
@@ -55,12 +55,12 @@ print(result.markdown) # Markdown string or None
### Node.js
```bash
npm install @firecrawl/pdf-inspector
npm install @firecrawl/pdf-inspector-js
```
```javascript
import { readFileSync } from 'fs';
import { processPdf, classifyPdf } from '@firecrawl/pdf-inspector';
import { processPdf, classifyPdf } from '@firecrawl/pdf-inspector-js';
const result = processPdf(readFileSync('document.pdf'));
console.log(result.pdfType); // "TextBased", "Scanned", "ImageBased", "Mixed"
+5 -5
View File
@@ -1,4 +1,4 @@
# PDF Inspector
# firecrawl-pdf-inspector
Fast PDF classification and region-based text extraction for Node.js/Bun. Native Rust performance via [napi-rs](https://napi.rs).
@@ -7,9 +7,9 @@ Built by [Firecrawl](https://firecrawl.dev) for hybrid OCR pipelines — extract
## Install
```bash
npm install @firecrawl/pdf-inspector
npm install firecrawl-pdf-inspector
# or
bun add @firecrawl/pdf-inspector
bun add firecrawl-pdf-inspector
```
Prebuilt binaries included for **linux-x64** and **macOS ARM64**. No Rust toolchain needed.
@@ -21,7 +21,7 @@ Prebuilt binaries included for **linux-x64** and **macOS ARM64**. No Rust toolch
Classify a PDF as TextBased, Scanned, Mixed, or ImageBased (~10-50ms). Returns which pages need OCR.
```typescript
import { classifyPdf } from '@firecrawl/pdf-inspector'
import { classifyPdf } from 'firecrawl-pdf-inspector'
import { readFileSync } from 'fs'
const pdf = readFileSync('document.pdf')
@@ -40,7 +40,7 @@ Extract text within bounding-box regions from a PDF. Designed for hybrid OCR pip
Each region result includes a `needsOcr` flag that signals unreliable extraction (empty text, GID-encoded fonts, garbage text, encoding issues).
```typescript
import { extractTextInRegions } from '@firecrawl/pdf-inspector'
import { extractTextInRegions } from 'firecrawl-pdf-inspector'
const result = extractTextInRegions(pdf, [
{
-131
View File
@@ -1,131 +0,0 @@
#!/usr/bin/env node
import { readFileSync, writeFileSync } from "fs";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { version } = require("../package.json");
const HELP = `pdf-inspector v${version} — Fast PDF text extraction to Markdown
Usage:
pdf-inspector <file> Extract markdown (default)
pdf-inspector detect <file> Classify PDF type
Options:
--json Output as JSON
--pages <pages> Comma-separated page numbers (e.g. 1,3,5)
-o, --output <file> Write output to file instead of stdout
-h, --help Show this help
-v, --version Show version
Examples:
pdf-inspector document.pdf
pdf-inspector document.pdf --json
pdf-inspector document.pdf --pages 1,2,3
pdf-inspector detect document.pdf --json
cat document.pdf | pdf-inspector -`;
function die(msg) {
process.stderr.write(`error: ${msg}\n`);
process.exit(1);
}
function parseArgs(argv) {
const opts = { json: false, pages: null, output: null, file: null, command: "extract" };
let i = 0;
// Check for subcommand
if (argv[0] === "detect") {
opts.command = "detect";
i = 1;
}
while (i < argv.length) {
const arg = argv[i];
if (arg === "-h" || arg === "--help") {
process.stdout.write(HELP + "\n");
process.exit(0);
} else if (arg === "-v" || arg === "--version") {
process.stdout.write(`${version}\n`);
process.exit(0);
} else if (arg === "--json") {
opts.json = true;
} else if (arg === "--pages") {
i++;
if (!argv[i]) die("--pages requires a value (e.g. 1,3,5)");
opts.pages = argv[i].split(",").map((p) => {
const n = parseInt(p.trim(), 10);
if (Number.isNaN(n) || n < 1) die(`invalid page number: ${p}`);
return n;
});
} else if (arg === "-o" || arg === "--output") {
i++;
if (!argv[i]) die("-o requires a filename");
opts.output = argv[i];
} else if (arg === "-" || !arg.startsWith("-")) {
if (opts.file) die(`unexpected argument: ${arg}`);
opts.file = arg;
} else {
die(`unknown option: ${arg}`);
}
i++;
}
return opts;
}
function readInput(file) {
if (file === "-") {
return readFileSync(0); // stdin fd
}
try {
return readFileSync(file);
} catch (err) {
if (err.code === "ENOENT") die(`file not found: ${file}`);
die(err.message);
}
}
function output(text, outputPath) {
if (outputPath) {
writeFileSync(outputPath, text);
} else {
process.stdout.write(text);
}
}
// ---- main ----
const opts = parseArgs(process.argv.slice(2));
if (!opts.file) {
// Check if stdin is piped
if (process.stdin.isTTY !== false) {
process.stderr.write(HELP + "\n");
process.exit(1);
}
opts.file = "-";
}
const { processPdf, classifyPdf } = await import("../index.js");
const buffer = readInput(opts.file);
if (opts.command === "detect") {
const result = classifyPdf(buffer);
if (opts.json) {
output(JSON.stringify(result, null, 2) + "\n", opts.output);
} else {
const ocr = result.pagesNeedingOcr.length > 0
? `, ${result.pagesNeedingOcr.length} pages need OCR`
: "";
output(`${result.pdfType} (${result.pageCount} pages, confidence: ${result.confidence.toFixed(2)}${ocr})\n`, opts.output);
}
} else {
const result = processPdf(buffer, opts.pages ?? undefined);
if (opts.json) {
output(JSON.stringify(result, null, 2) + "\n", opts.output);
} else {
output((result.markdown ?? "") + "\n", opts.output);
}
}
+3 -8
View File
@@ -1,12 +1,9 @@
{
"name": "@firecrawl/pdf-inspector",
"version": "1.3.0",
"name": "firecrawl-pdf-inspector",
"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.",
"main": "index.js",
"types": "index.d.ts",
"bin": {
"pdf-inspector": "bin/pdf-inspector.mjs"
},
"license": "MIT",
"keywords": [
"pdf",
@@ -23,7 +20,6 @@
"index.js",
"index.d.ts",
"*.node",
"bin/",
"README.md"
],
"repository": {
@@ -38,8 +34,7 @@
"binaryName": "pdf-inspector",
"targets": [
"x86_64-unknown-linux-gnu",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc"
"aarch64-apple-darwin"
],
"package": {
"name": "@firecrawl/pdf-inspector-js"
+84
View File
@@ -99,6 +99,26 @@ pub struct PageRegionTexts {
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.01.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
// ---------------------------------------------------------------------------
@@ -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, &regions)
.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.01.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, &regions)
.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.
#[napi(object)]
pub struct PageMarkdownResult {
+3 -21
View File
@@ -216,7 +216,6 @@ pub(crate) fn extract_page_text_items(
let mut marked_content_stack: Vec<MarkedContentEntry> = Vec::new();
let mut suppress_glyph_extraction = false;
let mut actual_text_start_tm: Option<[f32; 6]> = None; // text matrix at BDC entry
let mut actual_text_glyph_tm: Option<[f32; 6]> = None; // text matrix at first glyph inside BDC
/// Get the innermost MCID from the marked content stack.
fn current_mcid(stack: &[MarkedContentEntry]) -> Option<i64> {
stack.iter().rev().find_map(|e| e.mcid)
@@ -350,15 +349,8 @@ pub(crate) fn extract_page_text_items(
)
})
});
// ActualText: suppress glyph extraction, just advance text matrix.
// Capture the FIRST glyph's text matrix as the rendering position
// for the ActualText item. Td ops between BDC and the first Tj
// may have moved the position to the correct line — the BDC-entry
// position (actual_text_start_tm) can be on the previous line.
// ActualText: suppress glyph extraction, just advance text matrix
if suppress_glyph_extraction {
if actual_text_glyph_tm.is_none() {
actual_text_glyph_tm = Some(text_matrix);
}
if let Some(w_ts) = w_ts_opt {
text_matrix[4] += w_ts * text_matrix[0];
text_matrix[5] += w_ts * text_matrix[1];
@@ -433,10 +425,6 @@ pub(crate) fn extract_page_text_items(
let font_info = font_widths.get(&current_font);
let is_invisible = (text_rendering_mode == 3 && !include_invisible)
|| suppress_glyph_extraction;
// Capture first-glyph position for ActualText
if suppress_glyph_extraction && actual_text_glyph_tm.is_none() {
actual_text_glyph_tm = Some(text_matrix);
}
// Compute space threshold based on font metrics when available
let space_threshold = if let Some(font_info) = font_info {
@@ -712,7 +700,6 @@ pub(crate) fn extract_page_text_items(
if actual_text.is_some() {
suppress_glyph_extraction = true;
actual_text_start_tm = Some(text_matrix);
actual_text_glyph_tm = None; // reset — will be captured at first Tj/TJ
}
marked_content_stack.push(MarkedContentEntry { actual_text, mcid });
}
@@ -720,13 +707,8 @@ pub(crate) fn extract_page_text_items(
// End Marked Content — emit ActualText item with correct width
if let Some(entry) = marked_content_stack.pop() {
if let Some(at) = entry.actual_text {
// Use the first-glyph position (if available) instead of the
// BDC-entry position. Td operators between BDC and the first
// Tj may have moved the text position to the correct line —
// the BDC-entry position can be on the previous line.
let glyph_tm = actual_text_glyph_tm.take();
let entry_tm = actual_text_start_tm.take();
if let Some(start_tm) = glyph_tm.or(entry_tm) {
// Compute width from text matrix advancement during BDC..EMC
if let Some(start_tm) = actual_text_start_tm.take() {
let combined = multiply_matrices(&start_tm, &ctm);
if combined[0].abs() >= combined[1].abs() {
rotation_votes.horizontal += 1;
File diff suppressed because it is too large Load Diff
+418
View File
@@ -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 -122
View File
@@ -1,9 +1,3 @@
// Rust 1.95 introduced collapsible_match for `if` inside match arms.
// The content-stream parsers use this pattern extensively (match on operator
// name, then check `in_text_block && !op.operands.is_empty()`). Collapsing
// these into match guards would hurt readability. Allow crate-wide.
#![allow(clippy::collapsible_match)]
//! Smart PDF detection and text extraction using lopdf
//!
//! # Quick start
@@ -34,6 +28,7 @@ pub mod python;
pub mod adobe_korea1;
pub mod detector;
pub mod extractor;
pub mod formula_latex;
pub mod glyph_names;
pub mod markdown;
pub mod process_mode;
@@ -468,6 +463,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
@@ -491,70 +578,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());
@@ -608,74 +639,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);
@@ -756,6 +733,164 @@ 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)
}
/// 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.01.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.
fn get_page_height(doc: &Document, page_id: lopdf::ObjectId) -> Option<f32> {
let page_dict = doc.get_dictionary(page_id).ok()?;
@@ -1353,6 +1488,49 @@ fn is_cid_garbage(text: &str) -> bool {
high_latin * 5 >= total * 2 && ascii_letters * 3 < total
}
/// Detect formula text that is unlikely to be usable despite passing generic checks.
///
/// Formula text (Greek letters, math operators, variables) is legitimately
/// symbol-heavy, so `is_garbage_text` would false-positive. This check instead
/// catches:
///
/// 1. **Private Use Area (PUA) characters** — TeX extensible delimiter glyphs
/// (large brackets from CMEX fonts) often map to PUA U+E000F8FF when the
/// ToUnicode CMap is missing. >10% PUA means significant undecoded content.
///
/// 2. **Control characters** — C0 controls (U+0000001F excluding whitespace)
/// indicate broken font encoding, not formula content. >30% is rejected.
fn is_formula_garbage(text: &str) -> bool {
let mut total = 0usize;
let mut pua = 0usize;
let mut control = 0usize;
for ch in text.chars() {
if ch.is_whitespace() {
continue;
}
total += 1;
if ('\u{E000}'..='\u{F8FF}').contains(&ch) {
pua += 1;
}
let cp = ch as u32;
if cp < 0x20 {
control += 1;
}
}
if total < 3 {
return false;
}
// >10% PUA — significant undecoded extensible delimiters
if pua * 10 > total {
return true;
}
// >30% control chars — broken encoding
if control * 10 > total * 3 {
return true;
}
false
}
/// Detect markdown tables with suspicious structure that suggest the heuristic
/// missed/mangled rows or columns. Returns true when the caller should treat
/// the result as `needs_ocr` and fall back to GPU OCR.
@@ -2070,4 +2248,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"
);
}
}
+58 -522
View File
@@ -581,14 +581,8 @@ fn detect_table_in_region(items: &[(usize, &TextItem)], mode: TableDetectionMode
// Validation 1: some rows should have content in first column.
// Use a lower threshold (25%) for tables with wrapped cells where
// continuation lines leave the first column empty.
// Skip when cells form a narrow TOC pattern: hierarchical entries indented
// across multiple X levels leave the leftmost column sparse (only top-level
// chapters land there) but the structure is still a valid TOC. Narrow only
// (<=5 cols) — wide multi-column TOCs (e.g. 2-up indices) would render
// poorly through format_toc_as_list, which assumes one entry per row.
let rows_with_first_col = cells.iter().filter(|row| !row[0].is_empty()).count();
let is_narrow_toc = columns.len() <= 5 && is_table_of_contents(&cells);
if rows_with_first_col < rows.len() / 4 && !is_narrow_toc {
if rows_with_first_col < rows.len() / 4 {
log::debug!(
" validation 1 fail: {}/{} rows have first col",
rows_with_first_col,
@@ -659,24 +653,15 @@ fn detect_table_in_region(items: &[(usize, &TextItem)], mode: TableDetectionMode
return None;
}
// Validation 8: Reject paragraph-like content falsely detected as tables.
// TOC pages with deep indentation (top-level chapters in col 0, subsections
// in cols 1-3, page numbers in last col) leave most cells empty and trip
// the paragraph heuristic; TOC shape is a safer signal here. Narrow only
// — see narrow-TOC rationale at validation 1.
if is_paragraph_content(&cells) && !is_narrow_toc {
log::debug!(" validation 9 fail: paragraph content");
// Validation 8: Check for Table of Contents pattern
if is_table_of_contents(&cells) {
log::debug!(" validation 8 fail: table of contents");
return None;
}
// Validation 9: Reject wide "index" layouts where every cell carries a
// full "label ... page" fragment (back-of-book IRS-style indices).
// These render poorly in any structured form; text flow is the best
// fallback. Narrow dot-leader TOCs (2-3 cols) are kept so format.rs
// can emit them as a per-row flat list with titles tab-joined to page
// numbers.
if is_inline_leader_index(&cells) {
log::debug!(" validation 9 fail: inline-leader index");
// Validation 9: Reject paragraph-like content falsely detected as tables
if is_paragraph_content(&cells) {
log::debug!(" validation 9 fail: paragraph content");
return None;
}
@@ -913,247 +898,77 @@ fn looks_like_number(s: &str) -> bool {
&& s.chars().any(|c| c.is_ascii_digit())
}
/// Check if this looks like a Table of Contents (either style).
///
/// Used by format.rs to render TOCs as flat lists instead of markdown tables.
pub(super) fn is_table_of_contents(cells: &[Vec<String>]) -> bool {
is_dot_leader_toc(cells) || is_tabular_toc(cells)
}
/// Dot-leader TOC: any "Chapter 1 ........ 42" style with explicit leader
/// dots. Covers both narrow 2-3 col TOCs (where the leader is a dedicated
/// cell) and wide indices (where each cell encodes a full "label ... page"
/// fragment). Used by format.rs to render as a flat list.
pub(super) fn is_dot_leader_toc(cells: &[Vec<String>]) -> bool {
has_structural_dot_leader(cells) || is_inline_leader_index(cells)
}
/// Rows with a dedicated dots-only cell flanked by label + number (2-3 col
/// TOC layout). Format.rs handles these well via per-row flat-list
/// rendering; they should NOT be rejected at detect time.
fn has_structural_dot_leader(cells: &[Vec<String>]) -> bool {
/// Check if this looks like a Table of Contents
/// TOCs have characteristic patterns: leader dots, page numbers, section names
fn is_table_of_contents(cells: &[Vec<String>]) -> bool {
if cells.is_empty() {
return false;
}
let structural_rows = cells.iter().filter(|row| row_has_dot_leader(row)).count();
structural_rows as f32 / cells.len() as f32 >= 0.3
}
/// Wide index layout: each cell holds a full "label ... page" fragment
/// because the column detector kept multi-column indices as single cells.
/// These render poorly both as markdown tables (column boundaries are
/// arbitrary) and as flat lists (each row holds 3+ separate index
/// entries). Reject these at detect time so they fall back to the page's
/// normal text flow.
pub(super) fn is_inline_leader_index(cells: &[Vec<String>]) -> bool {
let mut inline_cells = 0;
let mut total_nonempty = 0;
let num_cols = cells[0].len();
let mut dot_cells = 0;
let mut page_number_cells = 0;
let mut total_cells = 0;
// Track which columns contain dots vs numbers to distinguish
// TOC (dots span middle, page number at end) from data tables
// (dots only in label column, many number columns).
let mut dot_cols = vec![0u32; num_cols];
let mut numeric_cols = vec![0u32; num_cols];
for row in cells {
for cell in row {
for (ci, cell) in row.iter().enumerate() {
let trimmed = cell.trim();
if trimmed.is_empty() {
continue;
}
total_nonempty += 1;
if cell_is_inline_leader(trimmed) {
inline_cells += 1;
total_cells += 1;
// Check for leader dots (sequences of periods)
// TOCs often have "........" or ". . . ." patterns
let dot_count = trimmed.chars().filter(|&c| c == '.').count();
let is_mostly_dots = dot_count > trimmed.len() / 2 && dot_count >= 3;
if is_mostly_dots {
dot_cells += 1;
if ci < num_cols {
dot_cols[ci] += 1;
}
}
// Check for standalone page numbers (1-4 digits, possibly with spaces)
let digits_only: String = trimmed.chars().filter(|c| !c.is_whitespace()).collect();
if digits_only.len() <= 4
&& !digits_only.is_empty()
&& digits_only.chars().all(|c| c.is_ascii_digit())
{
page_number_cells += 1;
if ci < num_cols {
numeric_cols[ci] += 1;
}
}
}
}
total_nonempty >= 4 && inline_cells as f32 / total_nonempty as f32 >= 0.25
}
/// A row with a dot-leader. Accepts two layouts:
/// 1. A dedicated dots-only cell ("....") with a text label somewhere
/// to its left and a page number somewhere to its right.
/// 2. A "title ... " cell (trailing leader dots glued to the title)
/// with a page number elsewhere in the same row.
fn row_has_dot_leader(row: &[String]) -> bool {
let has_page_number = row.iter().any(|c| row_cell_is_page_number(c));
for (ci, cell) in row.iter().enumerate() {
let trimmed = cell.trim();
// Pattern 1: dedicated dots-only cell.
let dot_count = trimmed.chars().filter(|&c| c == '.').count();
let is_mostly_dots = dot_count >= 3
&& dot_count > trimmed.len() / 2
&& trimmed.chars().all(|c| c == '.' || c.is_whitespace());
if is_mostly_dots {
let has_label_left = row[..ci].iter().any(|c| {
let t = c.trim();
!t.is_empty() && t.chars().any(|ch| ch.is_alphabetic())
});
if has_label_left && has_page_number {
return true;
}
continue;
}
// Pattern 2: cell ends with a trailing " ... " run after a label.
if has_page_number && cell_has_trailing_leader(trimmed) {
return true;
}
}
false
}
/// Cell ends with a run of ≥3 dots preceded by alphabetic text and a
/// space — the "Title ... " layout where the leader is glued to the name.
/// Alphabetic (not alphanumeric) so that data-table row labels like
/// "1973 ... " do not register as titles.
fn cell_has_trailing_leader(cell: &str) -> bool {
let trimmed = cell.trim_end();
if !trimmed.ends_with('.') {
return false;
}
let without_dots = trimmed.trim_end_matches('.');
let dot_run = trimmed.len() - without_dots.len();
if dot_run < 3 {
return false;
}
// Require a space before the dot run (rules out "etc..." / "Mr...") and
// at least one alphabetic char (rules out "1973 ... " data-row labels).
without_dots.ends_with(' ') && without_dots.trim().chars().any(|c| c.is_alphabetic())
}
/// Page-number shape: single ≤4-digit integer, a ", "-separated list of
/// ≤4-digit integers ("18, 36, 107"), or a dashed section-page ID
/// ("A-1", "5-21"). Rejects decimal cells ("4. 0"), thousands-separated
/// values ("189,164"), and other long numeric data that appears in
/// statistical tables.
fn row_cell_is_page_number(cell: &str) -> bool {
let t = cell.trim();
if t.is_empty() {
return false;
}
if looks_like_section_page_id(t) {
return true;
}
// Page list: ", " separator (with space) distinguishes real page lists
// from thousands-separated numbers like "189,164".
let parts: Vec<&str> = t.split(", ").collect();
parts
.iter()
.all(|p| !p.is_empty() && p.len() <= 4 && p.chars().all(|c| c.is_ascii_digit()))
}
/// A cell shaped like an index leader fragment. Accepts two forms:
/// - "text ... number" — label + dots + page number in one cell
/// - "... number" — bare leader + number (row where the label
/// landed in a separate column)
///
/// Both only count if followed by pure numeric content (optionally
/// comma-separated page lists like "127, 213").
fn cell_is_inline_leader(cell: &str) -> bool {
let cell = cell.trim();
// Find the first "..." run. Surrounding-whitespace checks below
// reject intra-word ellipses ("etc...").
let idx = match cell.match_indices("...").next() {
Some((i, _)) => i,
None => return false,
};
let before = &cell[..idx];
let after_dots = &cell[idx + 3..];
// Allow extra dots (e.g. "....") by skipping any additional '.'
let after = after_dots.trim_start_matches('.');
// Require space (or start-of-cell) before the dots and space/digit
// after — blocks intra-word ellipses.
let before_ok = before.is_empty() || before.ends_with(' ');
let after_ok = after.starts_with(' ') || after.is_empty();
if !before_ok || !after_ok {
if total_cells == 0 {
return false;
}
let after_trim = after.trim();
if after_trim.is_empty() {
return false;
}
// Tail must be purely numeric/page-list content.
let tail_numeric = after_trim
.chars()
.all(|c| c.is_ascii_digit() || matches!(c, ',' | ' ' | '.' | '-' | '$'))
&& after_trim.chars().any(|c| c.is_ascii_digit());
if !tail_numeric {
// Data tables with dot leaders (e.g. "1973....") have dots concentrated
// in one column (the label column) while many other columns contain numbers.
// True TOCs have dots spanning the middle and one page-number column at the end.
// If dots are confined to ≤1 column AND there are ≥3 columns with numbers,
// this is a data table, not a TOC.
let cols_with_dots = dot_cols.iter().filter(|&&c| c >= 2).count();
let cols_with_numbers = numeric_cols.iter().filter(|&&c| c >= 2).count();
if cols_with_dots <= 1 && cols_with_numbers >= 3 {
return false;
}
// Either we have a label before, or the leader is bare (starts the cell)
// — both are legitimate index fragments.
before.chars().any(|c| c.is_alphabetic()) || before.trim().is_empty()
}
// If a significant portion of cells are dots or page numbers, it's likely a TOC
let dot_ratio = dot_cells as f32 / total_cells as f32;
let page_num_ratio = page_number_cells as f32 / total_cells as f32;
/// Dot-less tabular TOC: tagged PDFs emit entries as rows where the first
/// column starts with a dotted section number (e.g. "4.3.1 Something") and
/// the last column is one or more page numbers. These have no leader dots
/// and benefit from flat-list formatting (page numbers aligned to titles).
pub(super) fn is_tabular_toc(cells: &[Vec<String>]) -> bool {
if cells.is_empty() {
return false;
}
let num_cols = cells[0].len();
if num_cols < 2 || cells.len() < 4 {
return false;
}
let section_rows = cells
.iter()
.filter(|row| {
row.iter()
.find(|c| !c.trim().is_empty())
.is_some_and(|c| starts_with_section_number(c.trim()))
})
.count();
let last_col = num_cols - 1;
let (last_filled, last_page_num) = cells.iter().fold((0u32, 0u32), |(f, n), row| {
let cell = row.get(last_col).map(|s| s.trim()).unwrap_or("");
if cell.is_empty() {
return (f, n);
}
let is_page_nums = cell
.split_whitespace()
.all(|tok| !tok.is_empty() && tok.chars().all(|c| c.is_ascii_digit()));
(f + 1, n + if is_page_nums { 1 } else { 0 })
});
let section_ratio = section_rows as f32 / cells.len() as f32;
let page_num_last_ratio = if last_filled > 0 {
last_page_num as f32 / last_filled as f32
} else {
0.0
};
section_ratio >= 0.6 && last_filled >= 3 && page_num_last_ratio >= 0.7
}
/// Matches dashed section-page identifiers used in technical manuals:
/// "5-21", "A-1", "B--3", "TC-2". At least one ASCII digit is required.
fn looks_like_section_page_id(s: &str) -> bool {
let ok = s
.chars()
.all(|c| c.is_ascii_digit() || c.is_ascii_uppercase() || c == '-');
ok && s.chars().any(|c| c.is_ascii_digit())
}
/// Returns true when the leading token looks like a dotted section number:
/// "1", "1.2", "1.2.3", "4.3.1.2" — integer components joined by dots,
/// with at least one dot (single-number prefixes are too ambiguous).
fn starts_with_section_number(s: &str) -> bool {
let Some(first) = s.split_whitespace().next() else {
return false;
};
let first = first.trim_end_matches('.');
let parts: Vec<&str> = first.split('.').collect();
if parts.len() < 2 || parts.len() > 6 {
return false;
}
parts
.iter()
.all(|p| !p.is_empty() && p.len() <= 3 && p.chars().all(|c| c.is_ascii_digit()))
// TOC typically has >15% dot cells and >10% page number cells
dot_ratio > 0.15 || (dot_ratio > 0.05 && page_num_ratio > 0.15)
}
/// Check if detected "table" cells are actually paragraph text fragments.
@@ -1610,283 +1425,4 @@ mod tests {
"data table with dot-leader labels should not be rejected as TOC"
);
}
#[test]
fn is_table_of_contents_accepts_hierarchical_indented_toc() {
// Mythos system card pages 4-5: top-level chapters indent at col 0,
// subsections at cols 1-2, leaving col 0 mostly empty (only ~10% of
// rows). Validation 1 was rejecting these even though the structure
// is unambiguously a TOC.
let cells = vec![
vec!["Abstract".to_string(), String::new(), "3".to_string()],
vec![
"1 Introduction".to_string(),
String::new(),
"10".to_string(),
],
vec![
String::new(),
"1.1 Model training".to_string(),
"11".to_string(),
],
vec![
String::new(),
"1.1.1 Training data".to_string(),
"11".to_string(),
],
vec![
String::new(),
"1.1.2 Crowd workers".to_string(),
"12".to_string(),
],
vec![
String::new(),
"1.2 Release decision".to_string(),
"13".to_string(),
],
vec![
"2 RSP evaluations".to_string(),
String::new(),
"16".to_string(),
],
vec![
String::new(),
"2.1 RSP risk assessment".to_string(),
"16".to_string(),
],
vec![String::new(), "2.1.1 Context".to_string(), "16".to_string()],
vec![
String::new(),
"2.2 CB evaluations".to_string(),
"20".to_string(),
],
];
assert!(
is_table_of_contents(&cells),
"hierarchical TOC with sparse col 0 should still be detected"
);
}
#[test]
fn is_table_of_contents_rejects_dotless_toc() {
// Tabular TOC without leader dots: first column starts with dotted
// section numbers, last column is page numbers. Pattern from
// Mythos system card pages 6-8.
let cells = vec![
vec![
"4.3 Case studies and targeted evaluations".to_string(),
String::new(),
"86".to_string(),
],
vec![
"4.3.1 Destructive or reckless actions".to_string(),
"4.3.1.1 Synthetic-backend evaluation".to_string(),
"86 86".to_string(),
],
vec![
"4.3.2 Adherence to constitution".to_string(),
"4.3.2.1 Overview".to_string(),
"89 89".to_string(),
],
vec![
"4.3.3 Honesty and hallucinations".to_string(),
"4.3.3.1 Factual hallucinations".to_string(),
"93 94".to_string(),
],
vec![
"4.4 Capability evaluations".to_string(),
String::new(),
"101".to_string(),
],
];
assert!(
is_table_of_contents(&cells),
"dot-less TOC with section numbers + page numbers should be rejected"
);
}
#[test]
fn dot_leader_toc_accepts_short_inline_leaders() {
// Index-style cells where the full "label ... number" pattern is
// preserved in a single cell (IRS Publication 17 back-of-book index).
let cells = vec![
vec!["Child tax credit ... 235".to_string(), String::new()],
vec!["Church employee ... 252".to_string(), String::new()],
vec!["Citizens outside the U.S ... 6".to_string(), String::new()],
vec![
"Claim for refund ... 18, 36, 107".to_string(),
String::new(),
],
vec!["Clergy ... 7, 52".to_string(), String::new()],
];
assert!(is_dot_leader_toc(&cells));
}
#[test]
fn dot_leader_toc_allows_ellipsis_data_table() {
// Data tables using "..." as a row-omission marker must not be
// mistaken for dot-leader TOCs. Based on MCF5235RM QSPI RAM layout.
let cells = vec![
vec![
"0x00".to_string(),
"QTR0".to_string(),
"Transmit RAM".to_string(),
],
vec!["0x01".to_string(), "QTR1".to_string(), String::new()],
vec![
"...".to_string(),
"...".to_string(),
"16 bits wide".to_string(),
],
vec!["0x0F".to_string(), "QTR15".to_string(), String::new()],
vec![
"0x10".to_string(),
"QRR0".to_string(),
"Receive RAM".to_string(),
],
vec!["0x11".to_string(), "QRR1".to_string(), String::new()],
vec![
"...".to_string(),
"...".to_string(),
"16 bits wide".to_string(),
],
vec!["0x1F".to_string(), "QRR15".to_string(), String::new()],
];
assert!(
!is_dot_leader_toc(&cells),
"ellipsis markers in a data table should not match TOC detection"
);
}
#[test]
fn dot_leader_toc_rejects_year_row_data_table() {
// ERP-2025 economic data tables: year labels with trailing " ... ",
// a final " ... " column, and decimal-looking numeric cells. The
// detection previously classified these as dot-leader TOCs and
// routed them through flat-list formatting, destroying the grid.
let cells = vec![
vec![
"1973 ... ".to_string(),
"4. 0".to_string(),
"1. 8".to_string(),
"0. 4".to_string(),
"3. 2".to_string(),
" ... ".to_string(),
],
vec![
"1974 ... ".to_string(),
"1. 9".to_string(),
"1. 6".to_string(),
"5. 6".to_string(),
"2. 4".to_string(),
" ... ".to_string(),
],
vec![
"1975 ... ".to_string(),
"2. 6".to_string(),
"5. 1".to_string(),
"6. 1".to_string(),
"4. 1".to_string(),
" ... ".to_string(),
],
vec![
"1976 ... ".to_string(),
"4. 3".to_string(),
"5. 4".to_string(),
"6. 4".to_string(),
"4. 5".to_string(),
" ... ".to_string(),
],
];
assert!(
!is_dot_leader_toc(&cells),
"year-indexed data tables with decimal cells must not match TOC detection"
);
}
#[test]
fn dot_leader_toc_rejects_monthly_data_table() {
// ERP-2025 Table B-22: monthly labor-force rows with "Jan ... ",
// "Feb ... " labels and thousands-separated cells ("189,164").
// Previously matched TOC detection because "Jan ..." has alphabetic
// text and "189,164" passed the page-number shape check.
let cells = vec![
vec![
"2023: Jan ... ".to_string(),
"265,962".to_string(),
"165,871".to_string(),
"160,152".to_string(),
"62. 4".to_string(),
],
vec![
"Feb ... ".to_string(),
"266,112".to_string(),
"166,263".to_string(),
"160,301".to_string(),
"62. 5".to_string(),
],
vec![
"Mar ... ".to_string(),
"266,272".to_string(),
"166,690".to_string(),
"160,824".to_string(),
"62. 6".to_string(),
],
vec![
"Apr ... ".to_string(),
"266,443".to_string(),
"166,678".to_string(),
"160,962".to_string(),
"62. 6".to_string(),
],
];
assert!(
!is_dot_leader_toc(&cells),
"monthly labor-force rows with thousands-separated data must not match TOC detection"
);
}
#[test]
fn tabular_toc_requires_section_numbers_and_pages() {
// Dot-less tabular TOC matches is_tabular_toc but not dot-leader.
let cells = vec![
vec![
"4.3 Case studies".to_string(),
String::new(),
"86".to_string(),
],
vec![
"4.3.1 Destructive actions".to_string(),
String::new(),
"86".to_string(),
],
vec![
"4.3.2 Adherence".to_string(),
String::new(),
"89".to_string(),
],
vec!["4.3.3 Honesty".to_string(), String::new(), "93".to_string()],
];
assert!(is_tabular_toc(&cells));
assert!(!is_dot_leader_toc(&cells));
}
#[test]
fn starts_with_section_number_matches_dotted() {
assert!(starts_with_section_number("1.2"));
assert!(starts_with_section_number("4.3.1"));
assert!(starts_with_section_number("4.3.1.2"));
assert!(starts_with_section_number("4.3 Case studies"));
assert!(starts_with_section_number("2.2.5.1 Expert red teaming"));
}
#[test]
fn starts_with_section_number_rejects_non_sections() {
assert!(!starts_with_section_number("Chapter 1"));
assert!(!starts_with_section_number("1973"));
assert!(!starts_with_section_number("1.5M"));
assert!(!starts_with_section_number("10.0%"));
assert!(!starts_with_section_number(""));
assert!(!starts_with_section_number("Hello world"));
}
}
-150
View File
@@ -1,6 +1,5 @@
//! Table-to-markdown formatting and cell cleanup.
use super::detect_heuristic::is_table_of_contents;
use super::Table;
pub fn table_to_markdown(table: &Table) -> String {
@@ -8,22 +7,6 @@ pub fn table_to_markdown(table: &Table) -> String {
return String::new();
}
// Detect TOC on the raw cells: clean_table_cells merges rows in ways
// that can make genuine data tables superficially resemble a TOC
// (short numeric cells, few columns) — but the raw detection here
// preserves the original multi-column structure and only matches the
// true TOC pattern.
//
// Tables of contents render poorly as markdown tables — emit a flat
// per-row text list instead so the page numbers stay aligned with
// their section titles rather than drifting to a separate column.
// Format from raw cells: continuation-row merging collapses separate
// TOC entries (e.g. "6.2 Contamination" + "6.2.1 SWE-bench") into a
// single line because sub-entries leave column 0 empty.
if is_table_of_contents(&table.cells) {
return format_toc_as_list(&table.cells, &[]);
}
// Clean up the table: merge continuation rows, extract footnotes, remove empty rows
let (cleaned_cells, footnotes) = clean_table_cells(&table.cells);
@@ -66,101 +49,6 @@ pub fn table_to_markdown(table: &Table) -> String {
output
}
/// Render a table-of-contents as a flat per-row text block.
///
/// Each row becomes one line: non-empty cells joined with spaces, and the
/// last cell (typically a page number) is separated by a tab so the page
/// numbers stay aligned with their titles instead of being pulled into a
/// separate column by the column-aware reader.
fn format_toc_as_list(cells: &[Vec<String>], footnotes: &[String]) -> String {
let mut output = String::new();
for row in cells {
let trimmed: Vec<&str> = row.iter().map(|c| c.trim()).collect();
let last_idx = trimmed.iter().rposition(|c| !c.is_empty());
let Some(last_idx) = last_idx else {
continue;
};
let last_cell = trimmed[last_idx];
let last_is_page = is_page_number_cell(last_cell);
let (title_cells, trailing) = if last_is_page && last_idx > 0 {
(&trimmed[..last_idx], Some(last_cell))
} else {
(&trimmed[..=last_idx], None)
};
// Skip dots-only cells when joining the title — in a detected TOC
// layout, a "...." cell is a leader separator, not part of the
// entry name.
let title = title_cells
.iter()
.filter(|c| !c.is_empty() && !is_dots_only(c))
.copied()
.collect::<Vec<_>>()
.join(" ");
if title.is_empty() && trailing.is_none() {
continue;
}
if !title.is_empty() {
output.push_str(&title);
}
if let Some(page) = trailing {
if !title.is_empty() {
output.push('\t');
}
output.push_str(page);
}
output.push('\n');
}
if !footnotes.is_empty() {
output.push('\n');
for footnote in footnotes {
output.push_str(footnote);
output.push('\n');
}
}
output
}
/// True when the cell looks like a page number. Accepts:
/// - plain digit tokens: "42", "86 86"
/// - dashed section-page IDs: "5-21", "A-1", "B--3", "TC-2" (common in
/// technical manuals)
fn is_page_number_cell(cell: &str) -> bool {
let tokens: Vec<&str> = cell.split_whitespace().collect();
if tokens.is_empty() {
return false;
}
tokens.iter().all(|t| {
if t.is_empty() || t.len() > 8 {
return false;
}
let all_digits = t.chars().all(|c| c.is_ascii_digit());
if all_digits {
return t.len() <= 4;
}
// Section-page form: uppercase letters, digits, dashes; at least
// one digit present.
t.chars()
.all(|c| c.is_ascii_digit() || c.is_ascii_uppercase() || c == '-')
&& t.chars().any(|c| c.is_ascii_digit())
})
}
/// True when the cell is purely leader dots (any length ≥ 3) with optional
/// whitespace.
fn is_dots_only(cell: &str) -> bool {
let t = cell.trim();
let dots = t.chars().filter(|&c| c == '.').count();
dots >= 3 && t.chars().all(|c| c == '.' || c.is_whitespace())
}
/// Clean up table cells: merge continuation rows, extract footnotes, remove empty rows
fn clean_table_cells(cells: &[Vec<String>]) -> (Vec<Vec<String>>, Vec<String>) {
let mut cleaned: Vec<Vec<String>> = Vec::new();
@@ -544,42 +432,4 @@ mod tests {
};
assert_eq!(table_to_markdown(&table), "");
}
#[test]
fn test_table_to_markdown_toc_renders_as_flat_list() {
// A TOC-shaped table with section numbers in col 0 and page numbers
// in the last column should render as a flat list, not a markdown
// table, so the page numbers stay on the same line as their titles.
let table = Table {
columns: vec![50.0, 80.0, 300.0],
rows: vec![500.0; 5],
cells: vec![
vec![
"4.3".into(),
"Case studies and targeted evaluations".into(),
"86".into(),
],
vec![
"4.3.1".into(),
"Destructive or reckless actions".into(),
"86".into(),
],
vec![
"4.3.2".into(),
"Adherence to its constitution".into(),
"89".into(),
],
vec!["4.4".into(), "Capability evaluations".into(), "101".into()],
vec!["4.5".into(), "White-box analyses".into(), "113".into()],
],
item_indices: vec![],
};
let md = table_to_markdown(&table);
assert!(
!md.contains("|---|"),
"TOC should not render as a markdown table: {md}"
);
assert!(md.contains("4.3 Case studies and targeted evaluations\t86"));
assert!(md.contains("4.5 White-box analyses\t113"));
}
}
-284
View File
@@ -520,18 +520,6 @@ impl ToUnicodeCMap {
}
}
/// Get the maximum source CID across all mappings (char_map + ranges).
fn max_source_cid(&self) -> Option<u16> {
let char_max = self.char_map.keys().copied().max();
let range_max = self.ranges.iter().map(|&(_, end, _)| end).max();
match (char_max, range_max) {
(Some(a), Some(b)) => Some(a.max(b)),
(a @ Some(_), None) => a,
(None, b @ Some(_)) => b,
(None, None) => None,
}
}
/// Remap a CMap that references pre-subsetting GIDs to sequential post-subsetting GIDs.
/// Collects all source CIDs, sorts them, and reassigns to 1, 2, 3, ...
pub fn remap_to_sequential(&self) -> ToUnicodeCMap {
@@ -669,81 +657,6 @@ fn get_w_array_start_cid(cid_font_dict: &lopdf::Dictionary, doc: &Document) -> O
}
}
/// Return true if the CIDFont's W (widths) array explicitly covers the given CID.
///
/// The W array uses two formats (PDF 32000-1:2008, §9.7.4.3):
/// 1. `c [w1 w2 ... wn]` — widths for CIDs c, c+1, ..., c+n-1
/// 2. `c_first c_last w` — CIDs c_first..c_last all have width w
fn w_array_covers_cid(cid_font_dict: &lopdf::Dictionary, doc: &Document, target: u16) -> bool {
let Ok(w_obj) = cid_font_dict.get(b"W") else {
return false;
};
let arr = match w_obj {
Object::Array(arr) => arr,
Object::Reference(r) => match doc.get_object(*r) {
Ok(Object::Array(arr)) => arr,
_ => return false,
},
_ => return false,
};
let resolve_int = |o: &Object| -> Option<i64> {
match o {
Object::Integer(n) => Some(*n),
Object::Reference(r) => match doc.get_object(*r) {
Ok(Object::Integer(n)) => Some(*n),
_ => None,
},
_ => None,
}
};
let resolve_arr = |o: &Object| -> Option<Vec<Object>> {
match o {
Object::Array(a) => Some(a.clone()),
Object::Reference(r) => match doc.get_object(*r) {
Ok(Object::Array(a)) => Some(a.clone()),
_ => None,
},
_ => None,
}
};
let target = target as i64;
let mut i = 0usize;
while i < arr.len() {
let Some(first) = resolve_int(&arr[i]) else {
break;
};
i += 1;
if i >= arr.len() {
break;
}
// Peek at arr[i] to decide format.
if let Some(widths) = resolve_arr(&arr[i]) {
// Format 1: c [w1 ... wn]
let last = first + widths.len() as i64 - 1;
if target >= first && target <= last {
return true;
}
i += 1;
} else if let Some(last) = resolve_int(&arr[i]) {
// Format 2: c_first c_last w
i += 1;
if i < arr.len() {
i += 1; // skip the width value
}
if target >= first && target <= last {
return true;
}
} else {
// Unknown token — abort parsing safely
break;
}
}
false
}
/// Extract CIDToGIDMap as a vector of GIDs (u16) indexed by CID.
fn get_cid_to_gid_map(cid_font_dict: &lopdf::Dictionary, doc: &Document) -> Option<Vec<u16>> {
let obj = cid_font_dict.get(b"CIDToGIDMap").ok()?;
@@ -839,20 +752,6 @@ fn try_remap_subset_cmap(
_ => return (cmap, None),
};
// If the W array actually covers the CMap's max source CID, the CMap is
// aligned with the font — no sequential renumbering happened. A sparse W
// array starting at CID 0 (for .notdef) with additional high-CID entries
// matching the CMap is the normal subset layout, not a mismatch.
if let Some(max_cid) = cmap.max_source_cid() {
if w_array_covers_cid(cid_font_dict, doc, max_cid) {
debug!(
"Subset remap skipped for obj={}: W array covers CMap max CID {}",
obj_num, max_cid
);
return (cmap, None);
}
}
debug!(
"Subset GID mismatch detected for obj={}: W starts at CID {}, CMap min CID {}. Remapping to sequential.",
obj_num, w_start, min_cid
@@ -2818,187 +2717,4 @@ endbfchar
assert_eq!(remapped.unwrap().char_map.len(), 50);
assert_eq!(fallback.unwrap().char_map.len(), 10);
}
#[test]
fn test_max_source_cid() {
let cmap_content = r#"
1 begincodespacerange
<0000><FFFF>
endcodespacerange
2 beginbfchar
<0003> <0020>
<0031> <004E>
endbfchar
1 beginbfrange
<0208> <0227> <0430>
endbfrange
"#;
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
assert_eq!(cmap.min_source_cid(), Some(0x0003));
assert_eq!(cmap.max_source_cid(), Some(0x0227));
}
/// Helper: build a minimal CIDFont dict with a W array and check coverage.
fn cid_font_dict_with_w(w_items: Vec<lopdf::Object>) -> lopdf::Dictionary {
let mut d = lopdf::Dictionary::new();
d.set("W", lopdf::Object::Array(w_items));
d
}
#[test]
fn test_w_array_covers_cid_format1() {
// Format 1: `c [w1 w2 ... wn]` — widths for CIDs c..c+n-1.
// Mimics the 16.pdf Tahoma W array: 0[1000] 3[313] 5[401] 11[383 383] 16[363 303 382]
let doc = Document::new();
let d = cid_font_dict_with_w(vec![
lopdf::Object::Integer(0),
lopdf::Object::Array(vec![lopdf::Object::Integer(1000)]),
lopdf::Object::Integer(3),
lopdf::Object::Array(vec![lopdf::Object::Integer(313)]),
lopdf::Object::Integer(5),
lopdf::Object::Array(vec![lopdf::Object::Integer(401)]),
lopdf::Object::Integer(11),
lopdf::Object::Array(vec![
lopdf::Object::Integer(383),
lopdf::Object::Integer(383),
]),
lopdf::Object::Integer(16),
lopdf::Object::Array(vec![
lopdf::Object::Integer(363),
lopdf::Object::Integer(303),
lopdf::Object::Integer(382),
]),
lopdf::Object::Integer(570),
lopdf::Object::Array(vec![lopdf::Object::Integer(667); 26]),
]);
assert!(w_array_covers_cid(&d, &doc, 0));
assert!(w_array_covers_cid(&d, &doc, 3));
assert!(w_array_covers_cid(&d, &doc, 5));
assert!(w_array_covers_cid(&d, &doc, 11));
assert!(w_array_covers_cid(&d, &doc, 12));
assert!(w_array_covers_cid(&d, &doc, 16));
assert!(w_array_covers_cid(&d, &doc, 18));
assert!(w_array_covers_cid(&d, &doc, 570));
assert!(w_array_covers_cid(&d, &doc, 595));
// Gaps are NOT covered
assert!(!w_array_covers_cid(&d, &doc, 1));
assert!(!w_array_covers_cid(&d, &doc, 4));
assert!(!w_array_covers_cid(&d, &doc, 19));
assert!(!w_array_covers_cid(&d, &doc, 596));
}
#[test]
fn test_w_array_covers_cid_format2() {
// Format 2: `c_first c_last w` — CIDs c_first..c_last all have width w.
let doc = Document::new();
let d = cid_font_dict_with_w(vec![
lopdf::Object::Integer(100),
lopdf::Object::Integer(120),
lopdf::Object::Integer(500),
]);
assert!(w_array_covers_cid(&d, &doc, 100));
assert!(w_array_covers_cid(&d, &doc, 110));
assert!(w_array_covers_cid(&d, &doc, 120));
assert!(!w_array_covers_cid(&d, &doc, 99));
assert!(!w_array_covers_cid(&d, &doc, 121));
}
#[test]
fn test_w_array_covers_cid_missing_w() {
let doc = Document::new();
let d = lopdf::Dictionary::new();
assert!(!w_array_covers_cid(&d, &doc, 3));
}
#[test]
fn test_try_remap_skipped_when_w_covers_cmap() {
// Simulates 16.pdf: CMap's max source CID (0x0279 = 633) is explicitly
// in the W array, so no subset-renumbering happened — remap must NOT fire.
let cmap_content = r#"
1 begincodespacerange
<0000><FFFF>
endcodespacerange
2 beginbfchar
<0003> <0020>
<0031> <004E>
endbfchar
2 beginbfrange
<023A> <0253> <0410>
<0255> <0279> <042B>
endbfrange
"#;
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
let mut doc = Document::new();
// Build a CIDFont dict with Identity CIDToGIDMap and a W array that
// covers CID 633 via `597 [widths...]`.
let mut cid_font = lopdf::Dictionary::new();
cid_font.set("CIDToGIDMap", lopdf::Object::Name(b"Identity".to_vec()));
cid_font.set(
"W",
lopdf::Object::Array(vec![
lopdf::Object::Integer(0),
lopdf::Object::Array(vec![lopdf::Object::Integer(750)]),
lopdf::Object::Integer(597),
lopdf::Object::Array(vec![lopdf::Object::Integer(500); 37]), // 597..633
]),
);
let cid_font_id = doc.add_object(cid_font);
// Build the Type0 font dict with Identity-H + DescendantFonts ref.
let mut font_dict = lopdf::Dictionary::new();
font_dict.set("Encoding", lopdf::Object::Name(b"Identity-H".to_vec()));
font_dict.set(
"DescendantFonts",
lopdf::Object::Array(vec![lopdf::Object::Reference(cid_font_id)]),
);
let (primary, remapped) = try_remap_subset_cmap(cmap, &font_dict, &doc, 123);
assert!(
remapped.is_none(),
"Remap must be skipped when W covers CMap max CID (this is 16.pdf)"
);
assert_eq!(primary.lookup(0x0003), Some(" ".to_string()));
}
#[test]
fn test_try_remap_fires_for_true_subset_mismatch() {
// True mismatch: CMap has high CIDs (512-544) but W only lists low sequential CIDs.
let cmap_content = r#"
1 begincodespacerange
<0000><FFFF>
endcodespacerange
1 beginbfrange
<0200> <0220> <0410>
endbfrange
"#;
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
let mut doc = Document::new();
let mut cid_font = lopdf::Dictionary::new();
cid_font.set("CIDToGIDMap", lopdf::Object::Name(b"Identity".to_vec()));
cid_font.set(
"W",
lopdf::Object::Array(vec![
lopdf::Object::Integer(0),
lopdf::Object::Array(vec![lopdf::Object::Integer(500); 34]), // 0..33
]),
);
let cid_font_id = doc.add_object(cid_font);
let mut font_dict = lopdf::Dictionary::new();
font_dict.set("Encoding", lopdf::Object::Name(b"Identity-H".to_vec()));
font_dict.set(
"DescendantFonts",
lopdf::Object::Array(vec![lopdf::Object::Reference(cid_font_id)]),
);
let (_primary, remapped) = try_remap_subset_cmap(cmap, &font_dict, &doc, 456);
assert!(
remapped.is_some(),
"Remap must fire when CMap's CIDs are outside W array coverage"
);
}
}