firecrawl / firecrawl/pdf-inspector
Scanned/ImageBased early return skips extraction entirely — dense Tr 3 OCR layers behind full-page rasters are never read (measured: +66/96 on 4,114 PDFs, 0 regressions)
- Dominant language
- Rust
- Stars
- 19.2k
- Forks
- 1.3k
- Avg merge
- 9h 21m
- Merged PRs (30d)
- 51
Description
## Summary
`process_document` returns early for `PdfType::Scanned | PdfType::ImageBased` ("nothing to extract", currently `src/lib.rs` ~4144) *before* the extraction step and its invisible-text retry. But the classifier regularly assigns `ImageBased` (and sometimes `Scanned`) to re-OCR'ed scans whose full text is present as a dense invisible (Tr 3) layer — full-page rasters dominate the classification even when hundreds of text operators are present. For those documents the text layer is never read at all: the retry that exists specifically to recover Tr 3 OCR layers is unreachable.
This is the classification-side sibling of #385: there the layer is missed because a `TextBased` document skips the Mixed-only retry; here it is missed because extraction never runs.
## Proposal (measured)
1. Drop the early return; let extraction (with the invisible retry) run for `Scanned | ImageBased` too.
2. Make extraction failure non-fatal for `Scanned | ImageBased` (same treatment as `Mixed`), so malformed genuine scans still produce the current `markdown: None` result instead of an error.
3. Add a result-quality gate so genuine scans keep today's behavior: for `Scanned | ImageBased`, suppress the markdown when it carries fewer than `OCR_LAYER_MIN_ALNUM` (40) alphanumeric characters per page — reusing the existing crate norm for "a real OCR layer carries far more". `pages_needing_ocr` / `ocr_reasons_by_page` keep their detection values, so consumers still route those documents to OCR.
We evaluated a threshold-on-result gate against an operator pre-scan ("only extract when Tr 3 operators were seen") and found the result gate strictly simpler: one code path, no second detection mechanism that can disagree with the extractor, and it also catches layers that exist but are effectively empty.
## Measurements (A/B on 4,114 real-world PDFs, v0.2.0 baseline vs patched)
| | baseline | patched |
|---|---|---|
| successful extractions | 3,991 | 4,057 (+66) |
| "no extractable text" | 96 | 30 |
| broken regressions | — | 0 |
| output diffs among both-ok | — | 8, all strictly richer (e.g. a book cover page previously missed) |
| latency median / p95 | 6 ms / 69 ms | 5 ms / 69 ms |
The remaining 30 refusals are genuine: near-empty OCR layers (<50 alphanumerics per document), one double-text-layer document (broken PUA layer from a pre-OCR watermark — an upstream-of-us pipeline problem), and CJK-noise layers correctly rejected by `is_garbage_text`.
The false-positive guard held over the whole corpus: no genuine scan without a usable layer produced markdown (the gate at 40 alnum/page suppressed every junk recovery; without it there was exactly 1 false positive in 4,114).
We run this as a vendored patch in production; happy to turn it into a PR if the direction is acceptable.
Related: #385 (dense Tr 3 layers classified TextBased), and #466 — the three combine to the +66 figure above.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start in src/lib.rs around process_document (~4144), reading the early return for Scanned and ImageBased, the extraction path, and its invisible-text retry. Trace the existing OCR_LAYER_MIN_ALNUM norm and the pages_needing_ocr/ocr_reasons_by_page values before changing behavior. Done means dense OCR layers are recovered, extraction failures remain non-fatal for these types, genuine scans still return markdown: None, and the reported measurements and zero-regression behavior are preserved.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 64/100