Skip to content

fix(pdf): abort early on scanned PDFs with an OCR hint - #130

Merged
virgiliojr94 merged 1 commit into
masterfrom
fix/scanned-pdf-early-abort
Aug 10, 2026
Merged

fix(pdf): abort early on scanned PDFs with an OCR hint#130
virgiliojr94 merged 1 commit into
masterfrom
fix/scanned-pdf-early-abort

Conversation

@virgiliojr94

Copy link
Copy Markdown
Owner

Summary (Required)

A scanned (image-only) PDF used to fail only at the very end of the pipeline — after Docling had processed the entire book in technical mode — and the error blamed "Unicode sanitization" instead of saying "this is a scan, run OCR". This probes the first 5 pages up front and fails in ~0.01s with an actionable ocrmypdf hint.

Type of change (Required)

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ Feature (non-breaking change that adds capability)
  • ⚡ Performance (faster / cheaper, no behavior change)
  • ♻️ Refactor (no behavior change)
  • 📝 Docs only
  • 🔒 Security
  • 🧹 Chore / CI / tooling
  • 💥 Breaking change (changes existing behavior or a public interface)

What it does (Required)

  • Fixes: an image-only PDF ran the whole extraction chain (Docling on every page in --mode technical, then pdftotext → pypdf → pdfminer) before the final if not text.strip() guard in extract_single_file raised — with a message about Unicode sanitization that never mentions OCR. Root cause: there was no pre-flight check, and the guard sat at the end of the function.
  • Fixes: the PDF chain tested if text: at four call sites. extract_with_pypdf joins per-page strings, so an image-only PDF yields "\n\n\n…" — truthy — and extraction was marked successful (method="pypdf") with no real text. Now if text and text.strip():.
  • Improves: time-to-error on a scanned PDF from a full Docling pass over the book down to a measured 0.01s, and the message now names the actual problem and the fix command.

Motivation & context (Required)

Reported directly by a non-technical user who ran book-to-skill on a scanned PDF: it "spent ages trying to identify it and only then said it couldn't read it", burning agent time and tokens on a file that was never going to work. Her suggestion was exactly this — bail on the first pages.

Closes #n/a — reported in a live conversation, not via an issue.

How it works (Required for anything non-trivial)

looks_image_only() shells out to pdftotext -f 1 -l 5 and returns True when those pages produce no non-whitespace text. extract_single_file calls it as the first thing in the .pdf branch, before the extractor is chosen, so Docling never starts on a scan.

Deliberately conservative:

  • No OCR here. Keeping OCR out of the tool is an existing product decision; this PR only makes the failure fast and legible, and points at ocrmypdf.
  • Degrades to a no-op. Without pdftotext on PATH the probe returns False and the normal chain (plus the unchanged final empty-text guard) runs exactly as before. Same for a timeout or any subprocess error.
  • 5 pages, not 1. A single scanned cover page in an otherwise text PDF must not trip the guard.

Known limit, accepted: a book whose first 5 pages are images but whose body is real text would now be rejected. I could not construct a realistic instance of that, and the trade is a fast honest failure against minutes of work ending in a misleading message.

Evidence (Required)

  • Tests: new TestLooksImageOnly — 4 cases: whitespace-only first pages ⇒ True and the probe is capped at -l 5; real text ⇒ False; no pdftotext on PATH ⇒ False (probe skipped); and an end-to-end extract_single_file on a .pdf raising ExtractionError whose message contains both "scanned" and "ocrmypdf".
  • Before / after: built two real PDFs with reportlab — text.pdf (8 pages of text) and scan.pdf (8 pages of drawn rectangles, zero text operators) — and ran the real extraction path on both.
$ python -c "... extract_single_file(scan.pdf) ..."
Extracting PDF: /tmp/.../scan.pdf
-> ExtractionError after 0.01s:
scan.pdf looks like a scanned (image-only) PDF: its first pages contain no extractable text, only images.
Run OCR on it first, then retry:
  ocrmypdf input.pdf output.pdf

$ python -c "... extract_single_file(text.pdf) ..."
Extracting PDF: /tmp/.../text.pdf
Mode: text — using pdftotext...
Trying pdftotext... OK
-> OK text.pdf: method=pdftotext chars=343 (0.03s)

$ pytest -q       ->  274 passed, 1 skipped
$ ruff check .    ->  All checks passed!

Before this change, the same scan.pdf reached the end of the chain and raised Extracted text from scan.pdf contained no visible content after Unicode sanitization. — and in --mode technical only after Docling had walked every page.

Checklist (Required — all must be checked)

  • One focused change (one feature/fix per PR; not a stack of unrelated work)
  • Branch is rebased on the latest master and has no merge conflicts
  • Tests added/updated for behavior changes
  • pytest -q is green on a clean checkout
  • ruff check . is clean
  • python3 tools/validate_skill.py SKILL.md passes (if SKILL.md changed) — n/a, SKILL.md untouched
  • PR title follows Conventional Commits (fix:, feat:, docs:… — it becomes the changelog entry; do NOT edit CHANGELOG.md by hand)
  • No raw book text shipped; no net SKILL.md bloat without justification

Notes for reviewers (Optional)

The text.strip() tightening at the four call sites is strictly a second line of defense — with the probe in place it should be unreachable for scans — but if text: on a whitespace string was wrong on its own terms, so I fixed it rather than leaving a latent silent-success path for any future extractor that returns blank pages.

A scanned (image-only) PDF only failed at the very end of the pipeline,
after Docling had processed the whole book in technical mode, and the
error blamed "Unicode sanitization" instead of telling the user to run
OCR. Real user report: minutes of waiting, then an unactionable message.

Probe the first 5 pages with pdftotext before picking an extractor and
fail immediately with an ocrmypdf hint. Without pdftotext on PATH the
probe is skipped, so nothing regresses.

Also tighten the PDF chain's `if text:` checks to `text.strip()`: pypdf
returns "\n\n\n..." for image-only pages, which is truthy, so extraction
was silently marked successful with no real text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@virgiliojr94
virgiliojr94 merged commit bd8a522 into master Aug 10, 2026
14 checks passed
@virgiliojr94
virgiliojr94 deleted the fix/scanned-pdf-early-abort branch August 10, 2026 14:06
virgiliojr94 added a commit that referenced this pull request Aug 10, 2026
Nothing in the README, SKILL.md or docs/ mentioned scanned PDFs or OCR,
so the one case that cannot work was the one case with no documentation.
A user hit it in practice, waited through a full extraction pass, and had
to guess what to do next.

The extractor now stops on the first pages (#130), but the failure should
not be where people learn the limit. Adds a note under the PDF extractor
table and an FAQ entry, both pointing at ocrmypdf, and states that not
running OCR is a deliberate choice rather than a gap.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
virgiliojr94 added a commit that referenced this pull request Aug 12, 2026
The use-case link invited accounts without stating that two already
exist. The strongest evidence this project has — a book that became a
survey of 300+ engineers, and a stalled run that turned into #130,
reported by someone who does not write software — was one click away
and unnamed.

The README line now leads with what happened and keeps the invitation
second. docs/usage.md gains the same, since the site serves that page to
people looking for how to run it and never mentioned use cases at all.

No names or companies here: crediting the fact is evidence, crediting
the author is README real estate, which stays reserved for sponsors.
Full credits live in the index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jimewu added a commit to jimewu/pi-doc-to-skill that referenced this pull request Aug 17, 2026
…ack node_modules

Ports the code-level fixes from virgiliojr94/book-to-skill commits
b4b3733..903d102 that the fork core was missing (local core files were
identical to the fork point):

- extractor: expand ~ in input paths (virgiliojr94#118); skip unreadable source
  instead of aborting the batch (virgiliojr94#120); detect ToC in any source, not
  just the first (virgiliojr94#114); whitespace-separated CJK ToC headers (virgiliojr94#112)
- extract: count supplementary-plane CJK in token estimate (virgiliojr94#136);
  stop unbalanced code fence swallowing later headings (virgiliojr94#135); count
  numbered headings as chapters when they carry a chapter's weight
  (virgiliojr94#149); report which method produced the chapter count (virgiliojr94#150)
- security: harden extraction workdir against symlink/ownership (virgiliojr94#141);
  DOCX leaf parsers self-defend against XXE (virgiliojr94#99); scan nested chapters
  and report markdown left out of scope (virgiliojr94#115)
- pdf: precise Roman page-number pattern, strip running headers only at
  page edges, apply pdftotext cleanup to pypdf/pdfminer paths too,
  abort early on scanned PDFs with an OCR hint (virgiliojr94#113/virgiliojr94#119/virgiliojr94#101/virgiliojr94#130)
- discovery_tax: reuse the extractor's multilingual ToC detection (virgiliojr94#138)
- scan: match tool-call control tokens, not the words 'tool call' (virgiliojr94#152)
- deps: diagnose modules installed in an isolated environment (pipx) (virgiliojr94#151)

Excludes upstream branding/output changes (banner→intro+support note,
GitHub publishing feature) pending review. Keeps the fork's notes/ and
indexes/ scan coverage in tools/scan_generated_skill.py.

Also:
- untrack node_modules (14,794 files accidentally committed in
  af2c1fb); add to .gitignore
- fix pre-existing ruff E9/F nits (f-strings, unused pytest imports)
- +16 upstream test files; suite: 499 passed
jimewu added a commit to jimewu/pi-doc-to-skill that referenced this pull request Aug 17, 2026
…ack node_modules

Ports the code-level fixes from virgiliojr94/book-to-skill commits
9c95f41..903d102 that the fork core was missing (local core files were
identical to the fork point):

- extractor: expand ~ in input paths (virgiliojr94#118); skip unreadable source
  instead of aborting the batch (virgiliojr94#120); detect ToC in any source, not
  just the first (virgiliojr94#114); whitespace-separated CJK ToC headers (virgiliojr94#112)
- extract: count supplementary-plane CJK in token estimate (virgiliojr94#136);
  stop unbalanced code fence swallowing later headings (virgiliojr94#135); count
  numbered headings as chapters when they carry a chapter's weight
  (virgiliojr94#149); report which method produced the chapter count (virgiliojr94#150)
- security: harden extraction workdir against symlink/ownership (virgiliojr94#141);
  DOCX leaf parsers self-defend against XXE (virgiliojr94#99); scan nested chapters
  and report markdown left out of scope (virgiliojr94#115)
- pdf: precise Roman page-number pattern, strip running headers only at
  page edges, apply pdftotext cleanup to pypdf/pdfminer paths too,
  abort early on scanned PDFs with an OCR hint (virgiliojr94#113/virgiliojr94#119/virgiliojr94#101/virgiliojr94#130)
- discovery_tax: reuse the extractor's multilingual ToC detection (virgiliojr94#138)
- scan: match tool-call control tokens, not the words 'tool call' (virgiliojr94#152)
- deps: diagnose modules installed in an isolated environment (pipx) (virgiliojr94#151)

Excludes upstream branding/output changes (banner→intro+support note,
GitHub publishing feature) pending review. Keeps the fork's notes/ and
indexes/ scan coverage in tools/scan_generated_skill.py.

Also:
- untrack node_modules (14,794 files accidentally committed in
  abc75a8); add to .gitignore
- fix pre-existing ruff E9/F nits (f-strings, unused pytest imports)
- +16 upstream test files; suite: 499 passed
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