fix: render PDF pages with self-contained fonts so text survives fontless containers - #421
Conversation
…less containers unpdf ships a serverless pdfjs build and resolves its Node defaults (disableFontFace, standardFontDataUrl, cMaps) from an installed pdfjs-dist package inside a silent catch — without the package those defaults vanish, leaving useSystemFonts: true as the effective config. Glyphs then render only when the host has system fonts; in the fontless production container every PDF page image came back with vector graphics but no text at all. The edge build also cannot read font/cMap data from disk (no node:fs) and calls Math.sumPrecise, which Node 24 lacks. New utils/pdf-engine.ts swaps in the pdfjs-dist legacy Node build via definePDFJSModule — injecting @napi-rs/canvas constructors first, so pdfjs adopts a canvas-compatible Path2D instead of its own polyfill — and creates every document proxy with disableFontFace + bundled standard fonts + cMaps as plain paths (pdfjs's Node fetch treats file:// strings as literal paths). Both the text-extraction and page-render paths now share one configured proxy, which also drops the per-page document re-parse. Verified end-to-end in a defonted environment: embedded-font and non-embedded base-14 PDFs both render full text through the built output. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CHrV3mahZ7P7Hc3DdU5QJq
…importer Review findings on the pdf-engine change: (1) the shared proxy was released with cleanup() only — destroy() is the disposal call, and the render path previously got proper disposal from unpdf's per-page proxies; (2) a failed engine init was memoized forever, poisoning every later PDF read after one transient failure; (3) the @napi-rs/canvas importer thunk was duplicated at three sites — pdf-engine now exports the shared one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CHrV3mahZ7P7Hc3DdU5QJq
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
umm-actually re-reviewed at 1 new finding(s) posted (13 tracked finding(s) across all runs). umm-actually · deepseek/deepseek-v4-pro |
|
@CodeRabbit review |
✅ Action performedReview finished.
|
📝 WalkthroughWalkthroughThe PR adds a shared PDF.js Node engine with bundled font and cMap support. PDF asset extraction and rendering now use a shared document proxy and loading-task destruction. Tests cover rendering, extraction, initialization retries, configuration, cleanup, and asset resolution. ChangesPDF handling
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 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: 2
🧹 Nitpick comments (1)
src/vault-mcp/vault-operations/__tests__/asset-operations.test.ts (1)
40-43: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAssert the shared canvas importer identity.
expect.any(Function)accepts an unrelated importer. Export a hoistedmockCanvasImportand assert thatrenderPageAsImagereceives that exact mock. This verifies the shared canvas integration contract.As per coding guidelines, tests must use exact assertions.
Proposed test update
+ const mockCanvasImport = vi.fn() return { + mockCanvasImport, mockDestroy, mockCreatePdfDocumentProxy: vi.fn(() => ({ loadingTask: { destroy: mockDestroy }, numPages: 1, })), } }) vi.mock("../../../utils/pdf-engine.js", () => ({ createPdfDocumentProxy: mockCreatePdfDocumentProxy, - canvasImport: vi.fn(), + canvasImport: mockCanvasImport, })) expect(mockRenderPageAsImage).toHaveBeenCalledWith(configuredProxy, 1, { - canvasImport: expect.any(Function), + canvasImport: mockCanvasImport, scale: 2, })Also applies to: 725-730
🤖 Prompt for 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. In `@src/vault-mcp/vault-operations/__tests__/asset-operations.test.ts` around lines 40 - 43, Update the PDF engine mock to export a hoisted mockCanvasImport, then revise the renderPageAsImage assertions to verify it receives that exact mock instead of expect.any(Function). Apply the same identity assertion to the additional covered case.Source: Coding guidelines
🤖 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/utils/__tests__/pdf-engine.test.ts`:
- Around line 105-107: Update the createPdfDocumentProxy rejection assertion to
match the complete deterministic error message rather than using the partial
string matcher. Preserve the existing “transient init failure” contract while
ensuring wrapped prefixes or suffixes do not satisfy the assertion.
In `@src/utils/pdf-engine.ts`:
- Around line 104-107: Update the initialization flow around pdfEnginePromise so
the catch handler that clears the memoized promise also re-throws the original
initialization error. Memoize that re-throwing catch result instead of attaching
a detached observer to initAttempt, preserving retry behavior while keeping the
rejection observable.
---
Nitpick comments:
In `@src/vault-mcp/vault-operations/__tests__/asset-operations.test.ts`:
- Around line 40-43: Update the PDF engine mock to export a hoisted
mockCanvasImport, then revise the renderPageAsImage assertions to verify it
receives that exact mock instead of expect.any(Function). Apply the same
identity assertion to the additional covered case.
🪄 Autofix
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: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: fc84d622-ea3c-4dfb-a6dc-69227d3db042
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (8)
.devin/wiki.jsonAGENTS.mdARCHITECTURE.mdpackage.jsonsrc/utils/__tests__/pdf-engine.test.tssrc/utils/pdf-engine.tssrc/vault-mcp/vault-operations/__tests__/asset-operations.test.tssrc/vault-mcp/vault-operations/asset-operations.ts
CodeRabbit review: the engine-init retry now memoizes the re-throwing catch chain (clears the memo, then re-throws) instead of a detached observer, satisfying the every-catch-logs-or-re-throws convention with identical retry semantics. The retry test's rejection assertion is anchored to the full error message, and the proxy-flow test asserts the shared canvasImport by identity instead of any-function. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CHrV3mahZ7P7Hc3DdU5QJq
|
Log the raw error object instead of String(error) to preserve stack traces
Failure scenario: A PDF with an unusual font encoding causes Suggested fixUse `describeError(error)` (the project's own utility for extracting messages from unknown throws) and also log `error` as a structured field so the logger's own error serialization preserves the stack: `logger.warn("pdf_page_render_failed", { page, error })`. The logger should handle Error objects natively — passing `error` directly lets it capture the stack. |
Aligns the one String(error) outlier with the codebase-wide describeError idiom (review finding at c0e723a). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CHrV3mahZ7P7Hc3DdU5QJq
|
Re the PR-level finding on Generated by Claude Code |
aliasunder
left a comment
There was a problem hiding this comment.
Phase 1: PR Review
2 findings across 2 files.
The core implementation is sound: the pdf-engine bootstrap correctly injects canvas constructors before the pdfjs import, swaps in the legacy Node build, configures font-independent document proxies, and handles init failure with a retry-safe single-flight pattern. The asset-operations changes — proxy reuse, shared canvasImport, proper destroy() cleanup, describeError — are all correct and consistent. Module placement in utils/ satisfies both admission gates (zero domain knowledge, complete standalone primitive). AGENTS.md structure tree, wiki.json, and package.json changes are accurate.
Findings are both docstring/doc precision issues — no code bugs found.
Verdict: ship-with-minor-fixes
🔍 ship-check · pr-review · opus
Ship-check findings: the proxy docstring no longer offers cleanup() as a disposal path (destroy() is the disposal call), and ARCHITECTURE.md's fontless-degradation sentence now states the precise symptom — text glyphs dropped while vector graphics still draw. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CHrV3mahZ7P7Hc3DdU5QJq
aliasunder
left a comment
There was a problem hiding this comment.
Phase 2: Code Quality
1 finding across 5 files.
🔍 ship-check · code-quality · opus
…itle Ship-check code-quality finding: the meta.info?.Title ?? undefined expression was duplicated across the raw-mode return and the markdown reconstruction, with the normalization comment on only one of them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CHrV3mahZ7P7Hc3DdU5QJq
|
Clamp maxPdfRenderPages to at least 1 at the data layer
The Failure scenario: Programmatic caller passes Suggested fixAdd `Math.max(1, params.maxPdfRenderPages)` to guard against negative or zero caps at the data layer — the tool schema handles the normal path, but the data layer should be resilient to programmatic misuse. |
|
Re the PR-level finding on clamping Generated by Claude Code |
aliasunder
left a comment
There was a problem hiding this comment.
Phase 3: Test Audit
1 finding across 2 files.
pdf-engine.test.ts (NEW, 5 tests): 4 of 5 tests pass all audit dimensions cleanly. The integration tests are well-designed — spy mode keeps the real unpdf implementation while verifying configuration, the fixture uses non-embedded base-14 Helvetica to prove font-independent rendering, and the retry test uses vi.resetModules() for a clean memo. One mild assertion quality finding on the text extraction test.
asset-operations.test.ts (modified): All changes are mechanical and correct — mock renames (cleanup → destroy, getDocumentProxy → createPdfDocumentProxy) match the production API change, test names updated to reflect the new behavior, and the new "renders through the configured document proxy" test is a strict improvement over the removed "passes canvasImport and scale" test (exact reference assertions replace expect.any(Uint8Array) and expect.objectContaining). No coverage regression from the removed test.
Coverage gaps: None. Both production files have adequate test coverage for all changed and new code paths. The canvasImport export is trivially thin (one-line delegation) and tested via mock in asset-operations. The describeError swap in logging is a format detail exercised by existing catch-branch tests.
🔍 ship-check · test-audit · opus
Ship-check test-audit finding: the extraction test asserted totalPages and text with decomposed expects; a single toMatchObject locks the relevant subset of the library-owned return shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CHrV3mahZ7P7Hc3DdU5QJq
aliasunder
left a comment
There was a problem hiding this comment.
Phase 4: Bug Check
1 finding across 5 files.
🔍 ship-check · bug-check · opus
…the mock call log Reworks the on-disk asset test per review discussion: the config test now pins the exact standardFontDataUrl/cMapUrl (test-owned derivation via createRequire), and the layout test checks those same paths directly — no positional mock.calls lookup anywhere. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CHrV3mahZ7P7Hc3DdU5QJq
…low runners The 2000px noise fixture pushed four WebP encodes of incompressible noise past the 5s test timeout on slow CI runners (and reproduced identically on untouched main). 800px keeps the source PNG far over budget — recompression still triggers — while only the format choice is under test; dimension descent and the 1568px clamp have their own tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CHrV3mahZ7P7Hc3DdU5QJq
Review discussion: "ensure" reads as a side-effect idiom, but the function's point at its call site is the returned engine. getPdfEngine states what the caller receives; the memoize-once behavior stays an internal detail carried by the comment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CHrV3mahZ7P7Hc3DdU5QJq
What does this PR do?
Fixes
vault_read_fileraw: truereturning PDF page images with all text silently missing — vector graphics (rules, underlines, bullets) rendered while every glyph was dropped. Reported against a real vault PDF (a Chrome print-to-PDF cover letter); reproduced byte-for-byte against production.Root cause (three stacked failures):
disableFontFace: true,standardFontDataUrl, cMaps) viaimport.meta.resolve("pdfjs-dist/package.json")inside a silent catch.pdfjs-distis only unpdf's devDependency, so in this repo's install the resolve throws and every Node default vanishes.useSystemFonts: true+ CSS-font-path rendering, which draws text via canvasfillTextwith host fonts instead of the PDF's own glyph outlines. The production container (node:24-trixie-slim) ships zero fonts, so Skia draws nothing. Verified both directions: the identical code renders text on a host with fonts and a blank page after defonting the host.node:fs, and pdfjs's Node fetch treatsfile://strings as literal paths), and it callsMath.sumPrecise, which Node 24 lacks.The fix — new
src/utils/pdf-engine.ts, the single entry point for every PDF read:@napi-rs/canvasconstructors intoglobalThis(overwriting — pdfjs's ownpath2dpolyfill objects are rejected by napi-rs contexts, and unpdf's set-if-undefined injection loses that race), then swaps inpdfjs-dist/legacy/build/pdf.mjsvia unpdf'sdefinePDFJSModule(the Node-targeted build with runtime polyfills).disableFontFace: true+useSystemFonts: false+ bundled standard fonts and cMaps as plain paths — glyphs always render from font data (embedded, or pdfjs-dist's bundled base-14 set), never from the host font stack.loadingTask.destroy()rather thancleanup().Also fixes the pre-existing
fit-image-to-byte-budgetalpha-WebP test flake (fixture shrunk 2000px → 800px; the test timed out at its 5s limit on slow runners, including on untouchedmain).Verification: end-to-end through the built output in a defonted environment — an embedded-font Chrome print-to-PDF and a worst-case non-embedded base-14 Helvetica PDF both render full text and still extract text correctly. The new integration test renders the repo's non-embedded-Helvetica fixture and asserts glyph pixels — that test fails without this fix even on font-rich CI (with
useSystemFonts: false, standard-font loading is the only way base-14 text renders), and mutation runs confirmed it fails whenstandardFontDataUrlis dropped, when the init-retry reset is removed, and when the engine points at a wrong font directory.Type of change
Checklist
npm testpassesnpm run lintpassesnpm run prettier:checkpassesnpm run buildsucceeds.devin/wiki.jsonpage purpose)🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Documentation