firecrawl / firecrawl/pdf-inspector

Mixed healthy and garbled fonts bypass the page-level CipherGarbleStats OCR signal

Open
#352 2 comments 0 reactions 0 assignees View on GitHub
Dominant language
Rust
Stars
19.1k
Forks
1.3k
Avg merge
9h 21m
Merged PRs (30d)
51

Description

## Summary

The substitution-cipher detector added for #118 can identify a fully garbled Latin text layer, but the same corrupted font is no longer flagged when healthy text from another font appears on the page.

The self-contained fictional reproduction below creates the same dense inventory table twice with a deliberately shifted but syntactically valid `/ToUnicode` map on its subset TrueType font:

- Table-only page: `extract_pages_markdown_bytes()` reports `needs_ocr=True` with `suspected_garbled_text`.
- Mixed page: adding healthy Helvetica paragraphs above the unchanged corrupted table changes the result to `needs_ocr=False` with no reason.

The healthy text dilutes the page-wide letter histogram, so the corrupted table is returned as plausible-looking wrong words and numbers.

## Impact

Real PDFs often use different fonts for declarations, headings, schedules, tables, and form bodies. A single broken font can therefore corrupt audit- or extraction-critical values while other fonts make the overall page look statistically healthy.

This failure is silent: the page is reported as text-based, confidence remains `1.0`, and the corrupted region is returned as ordinary Markdown.

## Environment

- `pdf-inspector==1.14.0` from PyPI
- Python `3.11.15`
- macOS `26.6.1`, arm64
- `reportlab==5.0.0` and `pypdf==6.15.0` only generate the synthetic fixtures

## Reproduction

Install the pinned dependencies:

```bash
pip install pdf-inspector==1.14.0 reportlab==5.0.0 pypdf==6.15.0
```

Save and run this script in an empty directory:

Self-contained fictional PDF generator and pdf-inspector probe

```python
from pathlib import Path
import re

import pdf_inspector
import reportlab
from pypdf import PdfReader, PdfWriter
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen import canvas

MIXED_CLEAN = Path("mixed-font-clean.pdf")
MIXED_BROKEN = Path("mixed-font-wrong-tounicode.pdf")
TABLE_CLEAN = Path("table-only-clean.pdf")
TABLE_BROKEN = Path("table-only-wrong-tounicode.pdf")

def build_clean_pdf(path: Path, include_healthy_text: bool) -> None:
font_path = Path(reportlab.__file__).parent / "fonts" / "Vera.ttf"
pdfmetrics.registerFont(TTFont("FixtureFont", str(font_path)))
page = canvas.Canvas(str(path), pagesize=(612, 792))
if include_healthy_text:
page.setFont("Helvetica-Bold", 16)
page.drawString(54, 744, "Quarterly Inventory Review")
page.setFont("Helvetica", 10)
standard_text = [
"This fictional report is a public parser-routing fixture.",
"The upper section uses a standard font and extracts normally.",
"All names, identifiers, quantities, and dates are fabricated.",
"The lower table uses an embedded font with a deliberately wrong Unicode map.",
"A PDF viewer still renders both sections as readable text.",
"A native extractor that trusts the map returns incorrect lower-table text.",
"OCR should recover the visible values from the rendered page.",
"Coverage remains high because both the correct and incorrect text layers are dense.",
"The problem is semantic trustworthiness, not whether characters can be extracted.",
]
y = 716
for line in standard_text:
page.drawString(54, y, line)
y -= 16
y -= 18
else:
y = 744

page.setFont("FixtureFont", 10)
page.drawString(54, y, "Item Code Description Quantity Review Date")
y -= 18
descriptions = [
"Replacement Filter Assembly", "Portable Sensor Housing",
"Calibration Bracket", "Weatherproof Cable Set",
"Inspection Label Roll", "Storage Tray Divider",
"Protective Terminal Cover", "Mounting Hardware Kit",
"Maintenance Reference Card", "Shipping Seal Packet",
"Reinforced Packing Insert", "Diagnostic Connector Cap",
"Reusable Transit Container", "Pressure Gauge Protector",
"Documentation Sleeve", "Component Identification Tag",
"Adjustable Retaining Clip", "Equipment Cleaning Pad",
"Verification Checklist Pack", "Spare Fastener Envelope",
"Inventory Control Marker", "Compact Tool Organizer",
]
for i, description in enumerate(descriptions, start=1):
page.drawString(
54,
y,
f"ZX-{i:03d} {description:<35} {10 + i:>3} 2026-07-{i:02d}",
)
y -= 18
page.save()

def shift(codepoint: int) -> int:
if 65 <= codepoint <= 90:
return 65 + ((codepoint - 65 + 8) % 26)
if 97 <= codepoint <= 122:
return 97 + ((codepoint - 97 + 8) % 26)
if 48 <= codepoint <= 57:
return 48 + ((codepoint - 48 + 3) % 10)
return codepoint

def build_broken_pdf(clean: Path, broken: Path) -> None:
reader = PdfReader(clean)
writer = PdfWriter()
writer.clone_document_from_reader(reader)
for page in writer.pages:
for font_ref in page["/Resources"]["/Font"].values():
font = font_ref.get_object()
if "Vera" not in str(font.get("/BaseFont", "")) or "/ToUnicode" not in font:
continue
stream = font["/ToUnicode"].get_object()
text = stream.get_data().decode("latin-1")

def replace(match: re.Match[str]) -> str:
return f"{match.group(1)}<{shift(int(match.group(2), 16)):04X}>"

stream.set_data(
re.sub(
r"(<[0-9A-Fa-f]{2}>\s+)<([0-9A-Fa-f]{4})>",
replace,
text,
).encode("latin-1")
)
with broken.open("wb") as output:
writer.write(output)

build_clean_pdf(MIXED_CLEAN, include_healthy_text=True)
build_broken_pdf(MIXED_CLEAN, MIXED_BROKEN)
build_clean_pdf(TABLE_CLEAN, include_healthy_text=False)
build_broken_pdf(TABLE_CLEAN, TABLE_BROKEN)
for path in (TABLE_BROKEN, MIXED_BROKEN):
data = path.read_bytes()
classification = pdf_inspector.classify_pdf_bytes(data)
detection = pdf_inspector.detect_pdf_bytes(data)
extraction = pdf_inspector.extract_pages_markdown_bytes(data)
print("\n", path.name)
print("classify:", classification.pdf_type, classification.confidence, classification.pages_needing_ocr)
print(
"detect:",
detection.pdf_type,
detection.confidence,
detection.has_encoding_issues,
detection.pages_needing_ocr,
detection.ocr_reasons_by_page,
)
print(
"extract:",
extraction.pages[0].needs_ocr,
extraction.pages_needing_ocr,
extraction.ocr_reasons_by_page,
)
print(extraction.pages[0].markdown[:240])
```

## Actual output

```text
table-only-wrong-tounicode.pdf
classify: text_based 1.0 []
detect: text_based 1.0 False [] []
extract: True [1] [PageOcrReasons(page=1, reasons=["suspected_garbled_text"])]

mixed-font-wrong-tounicode.pdf
classify: text_based 1.0 []
detect: text_based 1.0 False [] []
extract: False [] []
# Quarterly Inventory Review

This fictional report is a public parser-routing fixture. ...
```

The visible table begins with `Item Code Description Quantity Review Date`, while the extracted mixed-page table begins with `Qbmu Kwlm Lmakzqxbqwv Ycivbqbg Zmdqme Libm` and similarly shifts every identifier, quantity, and date.

Rendering each clean/broken pair with Poppler at 150 DPI produced identical PNG hashes:

- Table-only pair: `5f4dae6c8cb532d34a19832e6f678d0e1fafa6d82312b6b11f51562529538662`
- Mixed-font pair: `d23a4eac25319ccff34fb3565ffb21604cd182e6fa306305484809d1794911f8`

## Why the result changes

The current implementation stores one `CipherGarbleStats` accumulator per page, adds every text item to it, and calls `looks_garbled()` only on that combined page-wide distribution ([`text_quality.rs`](https://github.com/firecrawl/pdf-inspector/blob/9947485a9201f8aa1c307a214259bd6213cc9d1d/src/text_quality.rs#L235-L300)).

Measured with the same current thresholds:

| case | ASCII letters | vowel ratio | English cosine | shape cosine | extraction `needs_ocr` |
|---|---:|---:|---:|---:|---|
| corrupted table alone | 553 | 0.165 | 0.459 | 0.993 | `True` |
| same corrupted table + healthy text | 1,089 | 0.266 | 0.857 | 0.960 | `False` |

The healthy text raises the combined English cosine above the detector's `<0.60` condition even though the corrupted font and every table value are unchanged.

## Expected behavior

Adding healthy text in a different font should not turn a known-corrupted font region into trusted output.

A possible direction is to retain cipher statistics per font resource or per sufficiently large run group and then roll suspicious groups up to the page. I do not know whether that is the right false-positive trade-off; the A/B fixture is intended to make the regression boundary testable without prescribing the implementation.

The lightweight `classify_pdf_bytes()` and `detect_pdf_bytes()` paths also do not surface the table-only signal that extraction already finds. If that separation is intentional for performance, documenting it would help callers understand that `has_encoding_issues=False` from `detect_pdf_bytes()` is not equivalent to the extraction path's text-quality verdict.

## Relationship to prior issues

- #118 and its fix in #120 added `CipherGarbleStats` for a fully garbled Type0/`beginbfrange` page. This report is the mixed-font/localized variant that the page-wide aggregation still misses.
- #122 asks for per-font `ToUnicode` presence/validity metadata. This fixture's subset TrueType font has a present, syntactically valid `beginbfchar` map, so presence alone cannot establish trust, although a per-font grouping seam could help.
- #214 proposes a broader, language-aware confidence signal, primarily motivated by Arabic. This report is narrower: an English, deterministic A/B failure in the existing Latin substitution-cipher detector.

## Scope and data handling

The intentionally wrong map may not be structurally distinguishable from an arbitrary valid mapping. The verified defect here is that a corruption signal already detected in isolation disappears when unrelated healthy text is added.

All PDFs and output are generated from fictional inventory text. No customer document, customer text, identifier, or derived artifact is included.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start with src/text_quality.rs around lines 235-300, then run the supplied self-contained reproduction to compare table-only and mixed-font results. Add a regression test for the mixed-font case and verify that adding healthy text does not suppress the existing suspected_garbled_text signal, while checking the lightweight classification paths separately.

Written by the indexing model from the issue text.

Assessment

Tech stack
python, rust
Domain
backend, testing-qa
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.