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 {