From a89e5113591c906be7d27f848544bd4413c2a5ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 9 Jul 2026 16:22:00 +0200 Subject: [PATCH] fix(documents): run pdfjs + canvas un-bundled so scanned PDFs actually rasterize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled (Turbopack) copy of pdfjs-dist failed its render path in the standalone image with a bare Error, so a scanned PDF on a non-Anthropic provider fell back to 'PDF scanning needs a Claude vision provider' — while the identical un-bundled module rasterizes the same document fine (verified in the container end to end on the real document). Externalize pdfjs-dist and @napi-rs/canvas so the server loads the real modules, and hoist pdfjs-dist to the top level in the image so the runtime import resolves. Also log the render error message (not just its name) and drop the redundant 'not saved' note from the document summary panel. --- CHANGELOG.md | 10 ++++++++++ Dockerfile | 12 ++++++++++++ docs/api/openapi.yaml | 2 +- messages/de.json | 1 - messages/en.json | 1 - messages/es.json | 1 - messages/fr.json | 1 - messages/it.json | 1 - messages/pl.json | 1 - next.config.ts | 12 ++++++++++++ package.json | 2 +- public/sw.js | 2 +- .../documents/__tests__/document-ai-panels.test.tsx | 8 ++------ .../documents/__tests__/document-ai-section.test.tsx | 3 +-- src/components/documents/document-ai-panels.tsx | 4 ---- src/lib/documents/rasterize-pdf.ts | 8 +++++++- 16 files changed, 47 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90e9a3921..05b515660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +## [1.28.7] — 2026-07-09 — Scanned-PDF reading actually works + +The prior fix made the PDF image library loadable, but the page renderer was +still a bundled copy that failed on a real document with a bare error, so reading +a scanned PDF on a non-Anthropic provider still fell back to "PDF scanning needs +a Claude vision provider". The renderer now runs the real, un-bundled module — +the same one that renders these documents correctly in isolation — so scanned +PDFs are read on a subscription or local vision provider. Also drops a redundant +per-view note from the document summary panel. + ## [1.28.6] — 2026-07-09 — Fix scanned-PDF reading in the container Reading a scanned PDF with a non-Anthropic AI provider (a signed-in subscription diff --git a/Dockerfile b/Dockerfile index 3e2d2c45f..aad0d0b4c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -106,6 +106,18 @@ RUN set -e; \ echo "canvas hoisted for pdfjs: $CANVAS_DIR (musl=$MUSL_DIR)"; \ else echo "WARN: @napi-rs/canvas not found; PDF rasterization will degrade to local text"; fi +# pdfjs-dist is `serverExternalPackages` (NOT bundled — the bundled copy's render +# path breaks in the standalone image), so the server does a runtime bare +# `import('pdfjs-dist/legacy/build/pdf.mjs')`. Node resolves that up to +# /app/node_modules, so hoist the real pnpm-store copy to the top level too. +RUN set -e; \ + PDFJS_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path '*pdfjs-dist@*/node_modules/pdfjs-dist' 2>/dev/null | head -1)"; \ + if [ -n "$PDFJS_DIR" ]; then \ + ln -sfn "$PDFJS_DIR" /app/node_modules/pdfjs-dist; \ + chown -h nextjs:nodejs /app/node_modules/pdfjs-dist; \ + echo "pdfjs-dist hoisted: $PDFJS_DIR"; \ + else echo "WARN: pdfjs-dist not found; PDF rasterization will degrade to local text"; fi + # Copy Prisma for migrations (schema, migration SQL, config, engines) COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma.config.ts ./prisma.config.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e38b7d504..904c2db1f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.28.6 + version: 1.28.7 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/messages/de.json b/messages/de.json index 77e50b4eb..9702f9964 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8257,7 +8257,6 @@ "showText": "Erkannten Text anzeigen", "summaryTitle": "Zusammenfassung", "textTitle": "Erkannter Text", - "notSaved": "Nicht gespeichert · keine Diagnose. Nur einmalig für diese Ansicht angezeigt.", "close": "Ausblenden" }, "ai": { diff --git a/messages/en.json b/messages/en.json index ab2ead205..4744fc5c8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8257,7 +8257,6 @@ "showText": "Show extracted text", "summaryTitle": "Summary", "textTitle": "Extracted text", - "notSaved": "Not saved · not a diagnosis. Shown once for this view only.", "close": "Hide" }, "ai": { diff --git a/messages/es.json b/messages/es.json index 349d0c9ed..e2056fac8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8257,7 +8257,6 @@ "showText": "Mostrar texto extraído", "summaryTitle": "Resumen", "textTitle": "Texto extraído", - "notSaved": "No se guarda · no es un diagnóstico. Se muestra solo una vez en esta vista.", "close": "Ocultar" }, "ai": { diff --git a/messages/fr.json b/messages/fr.json index b6e907177..6501d839a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8257,7 +8257,6 @@ "showText": "Afficher le texte extrait", "summaryTitle": "Résumé", "textTitle": "Texte extrait", - "notSaved": "Non enregistré · pas un diagnostic. Affiché une seule fois pour cette vue.", "close": "Masquer" }, "ai": { diff --git a/messages/it.json b/messages/it.json index b3e75070d..821af3453 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8257,7 +8257,6 @@ "showText": "Mostra testo estratto", "summaryTitle": "Riepilogo", "textTitle": "Testo estratto", - "notSaved": "Non salvato · non è una diagnosi. Mostrato una sola volta per questa vista.", "close": "Nascondi" }, "ai": { diff --git a/messages/pl.json b/messages/pl.json index d08590f4e..ebdeee94a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8257,7 +8257,6 @@ "showText": "Pokaż wyodrębniony tekst", "summaryTitle": "Podsumowanie", "textTitle": "Wyodrębniony tekst", - "notSaved": "Nie zapisano · to nie diagnoza. Wyświetlane tylko raz w tym widoku.", "close": "Ukryj" }, "ai": { diff --git a/next.config.ts b/next.config.ts index fc2280969..9613aa34b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -233,6 +233,18 @@ const nextConfig: NextConfig = { "pg-boss", "@prisma/adapter-pg", "pg", + // Document PDF handling MUST run the real, un-bundled modules. When + // Turbopack bundles pdfjs-dist into the server chunks, its runtime + // `require('@napi-rs/canvas')` and its NodeCanvasFactory render path break + // in the standalone image (a scanned PDF fails to rasterize with a bare + // `Error`, so the read falls back to "PDF scanning needs a Claude vision + // provider"), while the identical un-bundled module rasterizes the same + // document fine. Externalise both so the server loads the real files from + // node_modules; the Dockerfile hoists them to the top level so the runtime + // `import()` resolves in the standalone tree, and outputFileTracingIncludes + // still ships pdfjs's wasm decoders. + "pdfjs-dist", + "@napi-rs/canvas", ], // v1.4.25 Fix-G — `src/lib/ai/prompts/safety-contracts.ts` reads its // sibling YAML files at runtime via `__dirname + readFileSync`. The diff --git a/package.json b/package.json index 6baebe067..6db93a609 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.28.6", + "version": "1.28.7", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 48d81e877..3b91811a7 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.28.6"; + /* @sw-version-fallback */ "v1.28.7"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/components/documents/__tests__/document-ai-panels.test.tsx b/src/components/documents/__tests__/document-ai-panels.test.tsx index 3ee93d715..6e6874adf 100644 --- a/src/components/documents/__tests__/document-ai-panels.test.tsx +++ b/src/components/documents/__tests__/document-ai-panels.test.tsx @@ -126,11 +126,9 @@ describe("", () => { ); expect(html).toContain('data-slot="document-summary-panel"'); expect(html).toContain('data-slot="document-summary-loading"'); - expect(html).toContain("Not saved"); - expect(html).toContain("not a diagnosis"); }); - it("renders the summary body and the persistent note", () => { + it("renders the summary body", () => { const html = render( ", () => { />, ); expect(html).toContain("A lipid panel dated 1 March 2026."); - expect(html).toContain("Not saved"); }); it("renders extracted text in a monospace block", () => { @@ -159,7 +156,7 @@ describe("", () => { expect(html).toContain("font-mono"); }); - it("surfaces an error via role=alert but keeps the note", () => { + it("surfaces an error via role=alert", () => { const html = render( ", () => { ); expect(html).toContain('role="alert"'); expect(html).toContain("read the document"); - expect(html).toContain("Not saved"); }); }); diff --git a/src/components/documents/__tests__/document-ai-section.test.tsx b/src/components/documents/__tests__/document-ai-section.test.tsx index 0a9a307ce..78e963ba9 100644 --- a/src/components/documents/__tests__/document-ai-section.test.tsx +++ b/src/components/documents/__tests__/document-ai-section.test.tsx @@ -130,7 +130,7 @@ describe("", () => { expect(html).toContain("review before saving"); }); - it("renders the transient summary panel with the session-only note", () => { + it("renders the transient summary panel", () => { const html = render( base({ summaryOutput: "summary", @@ -139,7 +139,6 @@ describe("", () => { ); expect(html).toContain('data-slot="document-summary-panel"'); expect(html).toContain("An MRI report of the left knee."); - expect(html).toContain("Not saved"); }); it("renders no per-document egress notice — the settings confirm covers it", () => { diff --git a/src/components/documents/document-ai-panels.tsx b/src/components/documents/document-ai-panels.tsx index 88760bedb..a0080ffac 100644 --- a/src/components/documents/document-ai-panels.tsx +++ b/src/components/documents/document-ai-panels.tsx @@ -281,10 +281,6 @@ export function DocumentSummaryPanel({ {body}

) : null} - -

- {t("documents.summary.notSaved")} -

); } diff --git a/src/lib/documents/rasterize-pdf.ts b/src/lib/documents/rasterize-pdf.ts index b880441c9..b1ae43765 100644 --- a/src/lib/documents/rasterize-pdf.ts +++ b/src/lib/documents/rasterize-pdf.ts @@ -176,7 +176,13 @@ export async function rasterizePdf(buffer: Buffer): Promise { } catch (err) { annotate({ action: { name: "documents.rasterize.failed" }, - meta: { reason: err instanceof Error ? err.name : "unknown" }, + meta: { + reason: err instanceof Error ? err.name : "unknown", + // The message pins WHY a render failed (bundled-module mismatch, an + // encrypted/odd PDF, a font issue) — the name alone is opaque. Bounded + // so a pathological message can't bloat the wide event. + message: err instanceof Error ? err.message.slice(0, 300) : String(err), + }, }); return { ok: false }; } finally {