feat(parser): PDF OCR fallback + font-encoding recovery (#246) - #290
Conversation
…246) OCR engine (tesseract.js 7.0.0, Apache-2.0, WASM, offline-capable) plus the rasterizer (@napi-rs/canvas 1.0.1, MIT, prebuilt binary) that unpdf's renderPageAsImage needs — per ADR-034 §3, isolated to the scanned-PDF branch. pnpm audit clean: identical 5-advisory baseline as main, zero new advisories. tesseract's opencollective postinstall left unapproved (funding nag, not needed). The eng.traineddata model is NOT vendored — loaded from a gitignored cache; tests inject a fake OCR engine so CI needs no model/network. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Codex <noreply@openai.com>
|
Warning Review limit reached
More reviews will be available in 11 minutes and 31 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more credits in the billing tab to continue. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits. 🚦 How do rate limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (6)
📝 WalkthroughWalkthroughThe PR adds PDF OCR fallback and font-encoding recovery, expands ParseWarning codes and suggestions, wires new OCR dependencies and config, and updates parser and API tests for the new warning flow. ChangesPDF OCR fallback and font-encoding recovery
Sequence Diagram(s)sequenceDiagram
participant parse
participant parsePdf
participant recoverPdfFontEncoding
participant applyOcrIfNeeded
participant recognizePdfPages
parse->>parsePdf: parse(buffer, options)
parsePdf->>recoverPdfFontEncoding: recoverPdfFontEncoding(pages)
parsePdf->>applyOcrIfNeeded: applyOcrIfNeeded(need, pages, ocr)
applyOcrIfNeeded->>recognizePdfPages: recognizePdfPages(buffer, pageNumbers, ocr)
recognizePdfPages-->>applyOcrIfNeeded: OCR text and confidence
applyOcrIfNeeded-->>parsePdf: updated pages and OCR warnings
parsePdf-->>parse: ParseResult with merged warnings
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related issues
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/api/parse.integration.test.ts`:
- Around line 230-243: The regression test for blank PDF handling is currently
gated behind the OCR E2E flag, so the default suite no longer covers the crash
symptom. Move the `completes no-text PDF with an OCR warning instead of
crashing` case in `parse.integration.test.ts` back into the always-on
API-boundary suite, and make it deterministic by stubbing OCR or injecting a
fake recognizer instead of relying on `OCR_E2E_ENABLED`. Keep the real OCR
end-to-end coverage opt-in, but ensure this test remains unskipped in normal CI
and continues asserting the warning-based completion path through `postPdf` and
`blankPdf`.
In `@src/lib/env.ts`:
- Around line 9-12: The OCR_RENDER_SCALE schema in env validation is too
permissive because positive() allows unbounded values, which can later blow up
rasterization. Update the Zod definition in env validation to enforce a sane
maximum for OCR_RENDER_SCALE, keeping the existing default and failing fast on
invalid config so the process exits during boot. Use the OCR_RENDER_SCALE field
in src/lib/env.ts as the place to tighten the bound.
In `@src/parser/pdf/font-encoding.ts`:
- Around line 192-214: The aggregated pdf-font-encoding-remapped warning in
recoverPdfFontEncoding is using a single remapDecision for all recovered pages,
so the hint can become inaccurate when pages recover differently. Update
recoverPdfFontEncoding to keep the decision associated with each remapped page
(or avoid embedding a per-page decision in the aggregated warning), and make
sure the recoveryLineHint call reflects the correct page-specific decision from
remapPage/decideRecovery rather than the last one seen.
In `@src/parser/pdf/index.ts`:
- Around line 152-154: The OCR warning path is exposing raw exception text
through `errorMessage` and `lineHint`, which can leak internal filesystem/config
details to callers. Update the `errorMessage` helper and the related
parse-warning flow so callers only receive a generic `pdf-ocr-unusable` hint,
and make sure the full error is logged server-side via the pino logger from
`src/lib/logger.ts` rather than using any console output. Keep the public
surface in the `src/parser/pdf/index.ts` flow from returning `err.message`
directly, including the later OCR-init handling code referenced by the same
helper.
In `@src/parser/pdf/ocr.ts`:
- Around line 81-143: The OCR module is leaking raw filesystem/render/Tesseract
errors instead of a module-owned typed error. Add a `SpecrError` subclass for
this parser OCR module and catch failures in `createManagedRecognizer`,
`withRecognizer`, and `recognizePage` so you can rethrow with added stage/page
context using `cause`. Keep `recognizePdfPages` as the public surface that only
emits the typed OCR error, preserving the original error details while enriching
the message with the relevant page or OCR step.
- Around line 57-59: The pdfData helper in ocr.ts is unnecessarily cloning the
PDF buffer before OCR, which adds memory overhead on this hot path. Update
pdfData to return the incoming Buffer directly since it already satisfies
Uint8Array, and keep the change localized to the pdfData function used by the
OCR flow.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 5e5f4103-123e-40ef-b9e3-4cc9658a9545
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (16)
openapi.yamlpackage.jsonpnpm-workspace.yamlsrc/api/parse.integration.test.tssrc/ast/schemas.tssrc/ast/types.tssrc/lib/env.tssrc/lib/parse-worker.tssrc/parser/index.tssrc/parser/pdf/font-encoding.test.tssrc/parser/pdf/font-encoding.tssrc/parser/pdf/index.test.tssrc/parser/pdf/index.tssrc/parser/pdf/ocr.integration.test.tssrc/parser/pdf/ocr.tssrc/parser/text/index.ts
Address CodeRabbit findings on the PDF OCR fallback + font-encoding PR: - pdf/index: stop leaking raw OCR exception text (Tesseract cache/lang filesystem paths) into the pdf-ocr-unusable warning returned to API callers; surface a generic hint instead (stack traces never leave the process). The parser stays env/logger-free by design, so the cause is preserved via the typed ParserError below rather than the pino logger. - pdf/ocr: wrap OCR worker init and per-page recognition failures in the module's typed ParserError with cause, per the module-boundary error convention; return the PDF Buffer directly instead of cloning the whole file on the OCR hot path (Buffer already is a Uint8Array). - font-encoding: report the distinct set of source encodings/fingerprints across remapped pages instead of attributing every page to the last decision; drop the mutable single-decision accumulator. Pin with a multi-page regression test. - lib/env: cap OCR_RENDER_SCALE at 10 so a bad env value cannot explode rasterization size and OOM the worker. - .env.example: document the OCR knobs, including OCR_LANG_PATH for offline/network-restricted deployments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex (GPT-5.5, xhigh) adversarial review — additional eyesRan alongside CodeRabbit as a second reviewer. One confirmed finding: [P1] OCR worker init can hang parse jobs offline when traineddata is uncached —
|
Invariant 2 (a rejecting worker init must keep degrading to a pdf-ocr-unusable warning, never throw) was only pinned at the recognizePdfPages surface (-> ParserError). Add the parsePdf-boundary assertion mirroring the offline `TypeError: fetch failed` case, so all three #298 invariants are pinned at the parser boundary, not internals. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ever hang (#298) (#311) * fix(parser): bound OCR worker init so offline scanned PDFs degrade, never hang When eng.traineddata is uncached AND OCR_LANG_PATH is unset, tesseract.js fetches the model from a CDN on first use. #290 already handles the fetch *rejection* (fail-fast -> pdf-ocr-unusable). The unhandled case was a connection accepted but never answered: createWorker never settles and the whole parse job hangs. Race worker init against a configurable timer (OCR_INIT_TIMEOUT_MS, default 30s). On timeout, reject with a typed ParserError -- which the existing applyOcrIfNeeded catch degrades to a pdf-ocr-unusable warning -- and terminate any late-resolving worker so no Tesseract process leaks. Wrapping is centralized at the init boundary, preserving the #290 fail-fast. A createWorker DI seam makes the stall unit-testable with no network or real traineddata. ADR-039 records the offline-OCR provisioning strategy: vendored traineddata via OCR_LANG_PATH, never a production CDN dependency. Closes #298 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(parser): pin #290 fail-fast degradation at the parsePdf boundary Invariant 2 (a rejecting worker init must keep degrading to a pdf-ocr-unusable warning, never throw) was only pinned at the recognizePdfPages surface (-> ParserError). Add the parsePdf-boundary assertion mirroring the offline `TypeError: fetch failed` case, so all three #298 invariants are pinned at the parser boundary, not internals. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(parser): pre-flight local traineddata in strict offline mode to stop OCR worker leak PR #311's bounded init timeout stops the offline hang but not a worker LEAK that a Codex (GPT-5.5) adversarial review surfaced: tesseract.js v7 spawns the worker thread synchronously BEFORE fetching eng.traineddata, so in the black-hole stall (connection accepted, never answered) createWorker never settles, terminateLater() never fires, and the already-spawned worker leaks. The timeout can't terminate a worker it has no handle to. Timeout and pre-flight are therefore complementary, not substitutes. Add an opt-in strict mode (OCR_REQUIRE_LOCAL_TRAINEDDATA, default false) that pre-flights local traineddata BEFORE spawning: if eng.traineddata is absent (probing langPath + cache for .traineddata and .traineddata.gz, matching tesseract.js v7's resolution), refuse via ParserError -> pdf-ocr-unusable WITHOUT invoking the worker factory. Default false preserves the convenient networked-dev behavior (CDN fetch on first run, bounded by the timeout); production sets it true and provisions local data. New DI seams (requireLocalTraineddata, hasLocalTraineddata) keep it fully offline-testable. Plumbed config -> ParseOptions -> PdfOcrOptions; env via z.stringbool. ADR-039 corrected: pre-flight is adopted as complementary to the timeout, with the filename contract pinned by a test; credits the PR #311 review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#325) * docs(readme): sync capabilities to last month of merged PRs Reflect shipped work in the README's "Included Today", "API Surface", and MCP tool table, validated against the merged diffs and current main: - PDF ingest (text-layer + OCR + font-encoding recovery) accepted by POST /parse (#287, #290, #311) - coordination / E&O report + submittal register (#241, #269, #277, #282, #283, #284) and article-role tagging (#273) - onboarding pipeline: library import, editability review/override, reclassify, finalize/reopen, open-comments (#243, #247, #248, #249, #272) - spec/project soft-delete + restore (#257, #313), document concurrency (#197), revision/addendum manual rendering (#221), numbering profiles (#317, #322) - add missing MCP tools get_numbering_profile, submittal_register, open_comments_report; document GET /docs (Scalar) (#213, #285) - add Example Client pointer to examples/web_ui_demo (#225) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(roadmap): move shipped work to done; re-date to 2026-07-01 Reconcile the roadmap with merged reality (was stamped 2026-06-17). Moved from planned/in-progress to Included, each validated against the diff: - PDF ingest (#287, #290, #311) — remove from "Later" - deep paragraph nesting pr6/pr7 (#215) - revision nomenclature (#216) + revision/addendum manual rendering (#221) — the two "Near Term" Phase 2e items are done - coordination / E&O report, required-sections, article-role, submittal register (#239, #241, #269, #273, #277, #282, #283, #284) — new "Coordination and Semantics" section; removed "coordination report" from planned Phase 4 - onboarding APIs (#243, #247, #248, #249, #272) — API done; UI remains planned - soft-delete/withdraw (#257, #313), section-number format (#266, #271), external-content associations (#242), structural numbering profiles (#317) Kept as planned (foundation only): header/footer composition (#222, #314) and keynote surfacing (#315) — DB/AST exist, no resolution/render/export yet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(architecture): reflect merged structural changes Update the architecture spec for shipped work, validated against the diffs and current schema/routes: - Tech Stack + Data Flow: Parse — PDF text-layer (unpdf/pdfjs-dist) + OCR (tesseract.js/@napi-rs/canvas) path and numberingProfileId override (#287, #290, #311, #317; ADR-034, ADR-039) - DB schema — specs.onboarding_status/withdrawn_at, projects.section_number_format /deleted_at/deleted_by, paragraphs.source_facts/classification/ editability_override; "Additional tables" summary for editing_conventions, paragraph_associations, required_sections, keynotes, header_footer_configs, numbering_profiles, revision_nomenclature_profiles (foundation-only tables flagged) (ADR-021/022/023/028/031/032; #187, #242) - new Coordination Report / E&O section (finding vocabulary) and Document Concurrency section (locks/optimistic/lifecycle) (#197, #241, #269, #277, #282, #283, #284; ADR-018, ADR-033/035/036/037) - AST meta.articleRole (#273, ADR-033); API-surface note pointing at the CI-enforced openapi.yaml + GET /docs; refreshed MCP tool list Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Why
Completes PDF spec ingest (#65, slice 3/3): scanned/image PDFs and font-corrupted text layers. After #245
(text-layer PDFs), a scanned PDF dropped into the demo's dropzone only emitted a "needs OCR" deferral — this makes
it actually OCR into the same AST + coordination panels, and repairs mojibake-corrupted text layers.
What
src/parser/pdf/ocr.ts): when feat(parser): PDF text-layer adapter — extract + normalize → text inference (slice 2/3 of #65) #245'sdetectPdfOcrNeedflags a scanned/mixedPDF,rasterize the affected pages (unpdf
renderPageAsImage+@napi-rs/canvas) →tesseract.js→ splice OCR textback per page → existing feat(parser): plaintext spec ingest — hierarchy inference from indent + numbering patterns #64 hierarchy inference.
mixedkeeps good text-layer pages and OCRs only the emptyones. The recognizer + renderer are dependency-injected.
src/parser/pdf/font-encoding.ts): detects mojibake (char-frequency +chardetfingerprint), remaps via
iconv-lite. Decodes items in coherent adjacent groups and rejects any remap thatintroduces a new
�, so valid non-ASCII (70°F,±) is never destroyed and sequences split across PDF itemsaren't corrupted. Genuinely unrecoverable corruption is flagged, never silently passed.
pdf-ocr-applied,pdf-ocr-low-confidence,pdf-ocr-unusable,pdf-font-encoding-remapped,pdf-font-encoding-unrecoverable.warnings[]→typed hard error. Never a silent empty/garbled parse.
tesseract.js7 (Apache-2.0, WASM, offline) +@napi-rs/canvas(MIT, prebuilt).pnpm auditclean — 0 new advisories vsmain. Model loaded from a gitignored cache (NOT vendored in git);tesseract's build script left unapproved.
Testing
tsc) cleanSPECR_OCR_E2E=1): an image-only PDF → genuine tesseract modeldownload + recognize → recovers
PART 1 - GENERAL+pdf-ocr-applied. CI skips it (no model);skipIf-gated.redocly lint openapi.yamlvalid (9 pre-existing warnings)Review notes
orchestration: dependency vetting + install, orchestrator-run integration + the real-OCR e2e, and an adversarial
Codex review gate that caught 2 P2 font-encoding corruption bugs (valid non-ASCII destroyed; split-item
mojibake) — both fixed here.
eng.traineddatamodel downloads on first OCR to a gitignored cache (offline after first fetch). Forair-gapped deploys, pre-seed the cache or set
OCR_LANG_PATH.🤖 Co-authored by Codex (gpt-5.5 xhigh — implementation) under Claude Opus 4.8 (orchestration + review + deps).
Closes #246.
Summary by CodeRabbit
New Features
Bug Fixes