Skip to content

fix(detector): add character-volume floor so dense text in few operators is not read as a scan - #445

Open
mathurshubham wants to merge 6 commits into
firecrawl:mainfrom
mathurshubham:fix/arabic-classification-sampling
Open

fix(detector): add character-volume floor so dense text in few operators is not read as a scan#445
mathurshubham wants to merge 6 commits into
firecrawl:mainfrom
mathurshubham:fix/arabic-classification-sampling

Conversation

@mathurshubham

@mathurshubham mathurshubham commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Addresses the misclassification half of #213.

Problem

A 25-page Arabic journal article with a complete, extractable text layer is classified Mixed (confidence 0.7) with 24 of 25 pages flagged as needing OCR, while pdftotext reads the same file perfectly. #213's first hypothesis — that the per-page needs-OCR verdict is made below any sensible minimum evidence — turns out to be exactly right, with a specific mechanism:

The detector used text_operator_count as its only proxy for a page's text volume. Every sparse-text scan heuristic routes a page below the per-page operator floor (min_text_ops_per_page.max(10)) toward OCR when the page also carries a template/background image — the pages_with_text gate, the alphanum_ok template-page counting, the Phase 2 Mixed routing branches (alphanum_low, sparse_text_over_scan, text_ops < min && has_images), and page_ocr_signals.

That proxy collapses for typesetters that emit a whole line or paragraph as a single TJ array — the norm for RTL/Arabic runs. A page dense with genuine, decodable text draws only a handful of operators and becomes indistinguishable from a near-empty scan. The alphanum_low check cannot rescue it, because raw CID operand bytes have little ASCII-alphanumeric diversity, and the existing decodable-font guard only applied once text_operator_count >= 10.

Change (all in src/detector.rs)

  • PageAnalysis gains text_char_count: total non-whitespace characters drawn by text-show operators, tallied during the existing content scan (including recursive XObject scanning). The old scan_content_for_text_operators signature is preserved as a test wrapper, so no existing unit tests changed.
  • New minimum-evidence predicate: a page with decodable fonts that draws at least MIN_DECODABLE_TEXT_CHARS = 200 characters counts as text-bearing, applied at all six sparse-text routing sites above.
  • The floor requires decodable fonts, so it never rescues an image-only scan or an undecodable-font page. 200 characters sits well above incidental scan chrome (a masthead or date line is a few dozen characters) and below a body paragraph, so masthead-over-scan detection is preserved — all existing detector tests still pass.

Tests

  • test_dense_text_in_one_op_over_scan_stays_native — page-level: template image plus a ~250-char body in one Tj; asserts text_operator_count < 10, text_char_count >= 200, and needs_ocr = false.
  • test_dense_text_journal_not_routed_to_ocr — document-level: 5-page journal (full-page background image + one-operator text layer per page); asserts TextBased, empty pages_needing_ocr, ocr_recommended = false.

Both fail on the pre-fix logic and pass now. Full cargo test suite green (1017 lib + 165 integration + 2 doc-tests, 0 failures); cargo fmt and cargo clippy -- -D warnings clean.

The latency half of #213 — documented, not fixed here

The ~15s classifyPdf is a separate cost and this PR does not change it. The hot paths: analyze_page_content runs for nearly every page of a Mixed/large document (Phase 2 + Phase 3b loops), each doing full content-stream decompression, recursive XObject scanning, per-page font-map construction with dict clones, and — for Identity-H fonts without ToUnicode — up to two embedded-font cmap parses per page via embedded_font_has_cmap, with no cross-page cache and no early return. Confirming which of these dominates for the reporter's file needs the actual file; fixing it speculatively risked regressions, so it is left for a follow-up.

Fixes the misclassification reported in #213.


Summary by cubic

Prevents dense RTL/Arabic pages packed into few operators from being flagged as scans by adding a character‑volume floor and judging only fonts that actually draw text. Old: operator‑count heuristics sent low‑op pages with images to OCR; new: pages with decodable fonts and ≥200 rendered characters count as text, bypass sparse‑text scan paths, and EarlyExit doesn’t break on them.

  • Adds text_char_count and gate page_has_dense_decodable_text (≥200 chars, has decodable fonts, no undecodable font that draws, no unattributable text). Applied to the pages‑with‑text gate, alphanum_low, looks_like_scan, sparse_text_over_scan, page_ocr_signals, and the EarlyExit break.
  • Attributes text shows by drawn volume: record the current font only when a Tj/TJ renders characters; empty () and numeric‑only TJ don’t add vetoing fonts. Carry Tf across a page’s /Contents array; treat unattributable shows as undecodable for the gate. Share a single FontDecodability classifier; track selected vs showing fonts separately.
  • Decodes literal‑string escapes when counting so escapes don’t inflate volume; counts across Form XObjects. Adds a regression test pinning font persistence across BT/ET per spec. No config or API changes; performance unchanged.

Written for commit 0d6d56c. Summary will update on new commits.

Review in cubic

…ors is not read as a scan

A 25-page Arabic journal with a complete, extractable text layer was
classified Mixed (0.7) with 24 of 25 pages flagged as needing OCR, even
though pdftotext recovers the full text. The classifier used
`text_operator_count` as its only proxy for text volume, and the
sparse-text scan heuristics (`sparse_text_over_scan`, `looks_like_scan`,
and `page_ocr_signals`'s `insufficient_text`) route any page below the
per-page operator floor (min_text_ops_per_page.max(10)) to OCR when it
also carries a template image.

That proxy collapses to near zero for typesetters that emit a whole line
or paragraph as a single TJ array — the norm for right-to-left / Arabic
runs — so a page dense with genuine, decodable text drew only a handful
of operators and looked identical to a near-empty scan. `alphanum_low`
could not save it: raw CID operand bytes carry little ASCII
alphanumeric diversity, and the existing decodable-font guard only
applied when text_operator_count was already >= 10.

This adds a minimum-evidence floor. `analyze_page_content` now tallies
the total non-whitespace characters drawn by text-show operators
(`text_char_count`), independent of operator count. A page clears the
floor when it has decodable fonts and draws at least
MIN_DECODABLE_TEXT_CHARS (200) characters; such pages are counted as
text-bearing and are exempted from every sparse-text scan signal, so a
real text layer packed into few operators stays native. The floor
requires decodable fonts, so it never rescues an image-only scan or an
undecodable-font page, and at 200 characters it sits well above
incidental scan chrome (a masthead or date line is a few dozen chars)
and comfortably below a body page of prose — the masthead-over-scan
detection is preserved.

Character counting is threaded through a new
`scan_content_for_text_operators_counted`; the former
`scan_content_for_text_operators` is retained as a test-only wrapper so
the existing unit tests are unchanged.

Perf: the reported ~14.7s classifyPdf latency is not addressed here. It
is a separate hot path — `analyze_page_content` runs for nearly every
page of a Mixed/large document (Phase 2/3 loops), each doing full
content-stream decompression, recursive XObject scanning, per-page font
map construction, and, for Identity-H fonts without ToUnicode, up to two
embedded-font cmap parses per page via `embedded_font_has_cmap` with no
cross-page cache. The reporter's file has a working text layer, so the
dominant cost is most likely repeated decompression of large content
streams or of an embedded CID font consulted through the cmap fallback.
Confirming and caching that path needs the file and is left out of scope
per the sampling-fix focus.

Tests: adds test_dense_text_in_one_op_over_scan_stays_native (page-level
page_ocr_signals) and test_dense_text_journal_not_routed_to_ocr
(document-level detect_from_document → TextBased, no OCR pages).

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 1 file

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/detector.rs">

<violation number="1" location="src/detector.rs:247">
P1: When an image-only page retains an unused dense text Form XObject in `/Resources`, this predicate can classify the page as native text even though the Form is never drawn. Count dense evidence only from XObjects actually invoked by the page, or otherwise exclude unrendered resource Forms from this signal.</violation>

<violation number="2" location="src/detector.rs:1665">
P2: The new floor counts encoded bytes rather than displayed characters, so short UTF-16/CID or escaped text can cross the 200-character threshold and suppress OCR incorrectly. Count decoded code points or glyph/code units consistently before applying `MIN_DECODABLE_TEXT_CHARS`.</violation>
</file>

Shadow auto-approve: would not auto-approve because issues were found.

Fix all with cubic | Re-trigger cubic

Comment thread src/detector.rs
// into few operators (#213 minimum-evidence floor). The remaining
// guards still exclude vector-outlined and undecodable pages.
if (analysis.text_operator_count >= effective_min_ops
|| page_has_dense_decodable_text(&analysis))

@cubic-dev-ai cubic-dev-ai Bot Aug 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When an image-only page retains an unused dense text Form XObject in /Resources, this predicate can classify the page as native text even though the Form is never drawn. Count dense evidence only from XObjects actually invoked by the page, or otherwise exclude unrendered resource Forms from this signal.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/detector.rs, line 247:

<comment>When an image-only page retains an unused dense text Form XObject in `/Resources`, this predicate can classify the page as native text even though the Form is never drawn. Count dense evidence only from XObjects actually invoked by the page, or otherwise exclude unrendered resource Forms from this signal.</comment>

<file context>
@@ -239,7 +239,12 @@ pub(crate) fn detect_from_document(
+            // into few operators (#213 minimum-evidence floor). The remaining
+            // guards still exclude vector-outlined and undecodable pages.
+            if (analysis.text_operator_count >= effective_min_ops
+                || page_has_dense_decodable_text(&analysis))
                 && !is_image_dominated
                 && analysis.unique_text_chars >= 5
</file context>
Fix with cubic

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a regression of this PR — this is pre-existing behavior on main. scan_xobjects_in_resources has always walked every Form XObject in the resource dictionary regardless of invocation, and its text operators already feed text_operator_count, so an image-only page carrying an uninvoked dense-text Form already passed the operator gate (text_operator_count >= effective_min_ops) before this PR existed. The new character floor threads its tally through the exact same walk, so it neither widens nor narrows that behavior. Restricting the scan to Do-invoked forms would be a behavioral change to the established detector semantics (which earlier reviews deliberately shaped — see the P1/P2 scoped-resolution comments in that function) and belongs in its own change if wanted.

Comment thread src/detector.rs
Comment thread src/detector.rs Outdated
for &ch in &content[k + 1..j] {
if !ch.is_ascii_whitespace() {
unique_chars.insert(ch);
*total_chars += 1;

@cubic-dev-ai cubic-dev-ai Bot Aug 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The new floor counts encoded bytes rather than displayed characters, so short UTF-16/CID or escaped text can cross the 200-character threshold and suppress OCR incorrectly. Count decoded code points or glyph/code units consistently before applying MIN_DECODABLE_TEXT_CHARS.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/detector.rs, line 1665:

<comment>The new floor counts encoded bytes rather than displayed characters, so short UTF-16/CID or escaped text can cross the 200-character threshold and suppress OCR incorrectly. Count decoded code points or glyph/code units consistently before applying `MIN_DECODABLE_TEXT_CHARS`.</comment>

<file context>
@@ -1582,6 +1662,7 @@ fn collect_text_chars_before(
             for &ch in &content[k + 1..j] {
                 if !ch.is_ascii_whitespace() {
                     unique_chars.insert(ch);
+                    *total_chars += 1;
                 }
             }
</file context>
Fix with cubic

… text

Address review feedback on the character-volume floor:

- EarlyExit sampling no longer breaks on a page that clears the dense-text
  floor: such a page is text-bearing despite its low operator count, and
  breaking there ended sampling with text_ratio 1.0, misclassifying a
  dense-text-then-scan document as TextBased with no OCR pages.

- Literal-string character counting now decodes escape sequences per the
  spec instead of counting raw bytes, so escapes with no visible glyphs
  (runs of \n, octal codes) cannot clear the floor and suppress OCR on a
  scanned page. Hex strings already decoded pairs and were unaffected.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 existing issue remains and no new issues found across 1 file (changes from recent commits).

Shadow auto-approve: would not auto-approve because issues were found.

Fix all with cubic | Re-trigger cubic

Comment thread src/detector.rs
The floor's character tally is page-wide with no per-font attribution, so a
page whose volume is drawn by an undecodable Identity-H font could qualify
via a small decodable header font, letting garbled volume suppress OCR.
The exemption now also requires that no used font is undecodable
(Identity-H/V without ToUnicode or fallback, Type3 without ToUnicode, or an
unresolvable font definition). Pages that fail the check simply fall back
to the pre-floor routing.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/detector.rs">

<violation number="1" location="src/detector.rs:1359">
P2: The new `used_fonts_include_undecodable_text` duplicates the full traversal and classification logic of `used_fonts_have_decodable_text` (including the `identity_h_font_has_fallback` document lookups). Both are called inline in `analyze_page_content` when `text_ops > 0`, so every page pays the classification cost twice, and because the doc explicitly notes this is not the negation of the other, the two must be kept in lockstep or `page_has_dense_decodable_text` silently changes behavior. Merge them into a single pass that returns `(has_decodable, has_undecodable)` to remove the duplicate lookups and make the two checks structurally unable to diverge.</violation>
</file>

Shadow auto-approve: would not auto-approve because issues were found.
Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread src/detector.rs Outdated
Comment thread src/detector.rs Outdated
/// ([`page_has_dense_decodable_text`]) needs this distinction because its
/// character tally is page-wide — with any undecodable font in play, part of
/// that volume may be garbage, so the floor must not vouch for it.
fn used_fonts_include_undecodable_text(

@cubic-dev-ai cubic-dev-ai Bot Aug 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The new used_fonts_include_undecodable_text duplicates the full traversal and classification logic of used_fonts_have_decodable_text (including the identity_h_font_has_fallback document lookups). Both are called inline in analyze_page_content when text_ops > 0, so every page pays the classification cost twice, and because the doc explicitly notes this is not the negation of the other, the two must be kept in lockstep or page_has_dense_decodable_text silently changes behavior. Merge them into a single pass that returns (has_decodable, has_undecodable) to remove the duplicate lookups and make the two checks structurally unable to diverge.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/detector.rs, line 1359:

<comment>The new `used_fonts_include_undecodable_text` duplicates the full traversal and classification logic of `used_fonts_have_decodable_text` (including the `identity_h_font_has_fallback` document lookups). Both are called inline in `analyze_page_content` when `text_ops > 0`, so every page pays the classification cost twice, and because the doc explicitly notes this is not the negation of the other, the two must be kept in lockstep or `page_has_dense_decodable_text` silently changes behavior. Merge them into a single pass that returns `(has_decodable, has_undecodable)` to remove the duplicate lookups and make the two checks structurally unable to diverge.</comment>

<file context>
@@ -1327,6 +1345,48 @@ fn used_fonts_have_decodable_text(
+/// ([`page_has_dense_decodable_text`]) needs this distinction because its
+/// character tally is page-wide — with any undecodable font in play, part of
+/// that volume may be garbage, so the floor must not vouch for it.
+fn used_fonts_include_undecodable_text(
+    used_font_ids: &HashSet<ObjectId>,
+    font_map: &HashMap<ObjectId, FontInfo>,
</file context>
Fix with cubic

Address two review findings on the undecodable-font gate:

- The gate previously judged every Tf-selected font, so an undecodable font
  that never draws text vetoed the dense-text rescue despite contributing no
  character volume. The content scanner now tracks the font current at each
  text-show operator, and the gate judges only those, treating text that
  cannot be attributed to a resolvable font as unvouchable.

- Both font predicates now share a single per-font FontDecodability
  classification, so the any-decodable check and the no-undecodable gate can
  no longer drift apart, and the duplicated traversal logic is gone. Font
  accumulators are bundled into a FontUsage struct.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 1 file (changes from recent commits).

Shadow auto-approve: would not auto-approve because issues were found.
Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread src/detector.rs Outdated
Comment thread src/detector.rs Outdated
…treams

The dense-text OCR rescue (firecrawl#213) judges a page by the fonts that actually
draw characters. Two gaps in that attribution routed valid text pages to OCR.

First, the scanner recorded the current font (or, absent a Tf, the
unattributable-text flag) on every Tj/TJ regardless of whether the operand
drew anything. An empty `()` Tj or a numeric-only `TJ` spacer under an
undecodable font therefore added that font to the veto set even though it
contributed no characters, so a page whose real volume came from a decodable
font lost its rescue. Attribution now happens only when the show draws at
least one counted non-whitespace character, measured as the delta in the
character tally around collect_text_chars_before.

Second, a page whose content is split across several streams (a /Contents
array) reset the current-font and attribution state per stream, so a Tf in an
earlier stream was forgotten before a later stream showed text. The show was
then counted as font-less and set shows_text_without_font, denying the rescue.
The PDF spec treats a page's content streams as one logical stream, so the
current font now persists across them: current_font moves into
TextShowAttribution (seeded and written back per scan), and analyze_page_content
carries one attribution and one Tf-selected name set across the content-stream
loop, resolving both once afterward against the shared page resource dict. Form
XObject scans keep their own fresh state, so a Form's inherited-font case stays
conservatively unattributable.

Adds regression tests for both: an undecodable font showing only empty/numeric
operands, and a two-stream page with the Tf in stream 1 and the dense body in
stream 2.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/detector.rs">

<violation number="1" location="src/detector.rs:1625">
P2: When a later content stream starts a new `BT`, this carries the prior text object's font into the new object and can mark unattributed text as decodable, causing the dense-text floor to skip OCR. Track text-object boundaries and clear the carried font when `BT` begins, while preserving it only when the same text object continues across streams.</violation>
</file>

Shadow auto-approve: would not auto-approve because issues were found.
Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread src/detector.rs
// Seed from the attribution so a `Tf` in an earlier content stream of the
// same page still governs shows in this one (see `TextShowAttribution`);
// written back before returning. A fresh attribution starts with no font.
let mut current_font: Option<Vec<u8>> = attribution.current_font.take();

@cubic-dev-ai cubic-dev-ai Bot Aug 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a later content stream starts a new BT, this carries the prior text object's font into the new object and can mark unattributed text as decodable, causing the dense-text floor to skip OCR. Track text-object boundaries and clear the carried font when BT begins, while preserving it only when the same text object continues across streams.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/detector.rs, line 1625:

<comment>When a later content stream starts a new `BT`, this carries the prior text object's font into the new object and can mark unattributed text as decodable, causing the dense-text floor to skip OCR. Track text-object boundaries and clear the carried font when `BT` begins, while preserving it only when the same text object continues across streams.</comment>

<file context>
@@ -1602,7 +1619,10 @@ fn scan_content_for_text_operators_counted(
+    // Seed from the attribution so a `Tf` in an earlier content stream of the
+    // same page still governs shows in this one (see `TextShowAttribution`);
+    // written back before returning. A fresh attribution starts with no font.
+    let mut current_font: Option<Vec<u8>> = attribution.current_font.take();
     let mut text_ops = 0u32;
     let image_count = 0u32;
</file context>
Fix with cubic

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refuting this one — the premise contradicts the PDF specification. Per ISO 32000-1 §9.3.1, the font (Tf) is one of the text state parameters that live in the graphics state, and text state persists across text objects: BT resets only the text matrix and line matrix, and only a Q restoring an earlier q resets the font. BT /F1 12 Tf (a) Tj ET BT (b) Tj ET is legal PDF and (b) renders in F1.

This repo's own extractor agrees: the BT arm of the operator state machine in src/extractor/content_stream.rs resets only text_matrix, line_matrix, and text_rendering_modecurrent_font is saved/restored exclusively by the q/Q arms. The scanner's carry models the same rule.

Clearing the carried font at BT would therefore introduce a regression, not fix one: a legitimate page that selects its font once and draws dense text across several BT blocks would have its later blocks marked unattributable, setting has_undecodable_text_fonts and denying the dense-text rescue — the exact failure class the last several commits eliminated.

Instead of changing the scanner I've pinned the correct behavior with a regression test in 0d6d56c: test_font_persists_across_text_objects_per_spec (font selected only in the first text object, dense body split across two BT/ET blocks, asserts the page keeps the rescue and stays native). Full suite green, clippy clean.

cubic flagged the current_font carry across a page's content streams,
claiming a new BT must clear it or a stale font can wrongly mark text
as decodable. Per ISO 32000-1 9.3.1/9.4.2 the font set by Tf is a text
state parameter that lives in the graphics state and persists across
BT/ET — only q/Q resets it; BT only resets the text and line matrices.
The repo's own extractor (extractor/content_stream.rs) already agrees:
its "BT" arm never touches current_font. Clearing the font at BT would
wrongly veto the dense-text rescue for legitimate multi-text-object
pages, so no production change is made. Add a regression test that
pins the correct behavior instead.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 1 file (changes from recent commits).

Shadow auto-approve: would not auto-approve. Auto-approval blocked by 4 unresolved issues from previous reviews.

Re-trigger cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant