fix(documents): run pdfjs + canvas un-bundled so scanned PDFs actually rasterize

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-09 16:22:00 +02:00
1 parent 8246d9251c
commit a89e511359
16 files changed
+47 -22

No files matched your search

+10
View File
@@ -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
+12
View File
@@ -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
+1 -1
View File
@@ -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.
-1
View File
@@ -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": {
-1
View File
@@ -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": {
-1
View File
@@ -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": {
-1
View File
@@ -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": {
-1
View File
@@ -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": {
-1
View File
@@ -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": {
+12
View File
@@ -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
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -126,11 +126,9 @@ describe("<DocumentSummaryPanel>", () => {
);
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(
<DocumentSummaryPanel
output="summary"
@@ -141,7 +139,6 @@ describe("<DocumentSummaryPanel>", () => {
/>,
);
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("<DocumentSummaryPanel>", () => {
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(
<DocumentSummaryPanel
output="summary"
@@ -171,7 +168,6 @@ describe("<DocumentSummaryPanel>", () => {
);
expect(html).toContain('role="alert"');
expect(html).toContain("read the document");
expect(html).toContain("Not saved");
});
});
@@ -130,7 +130,7 @@ describe("<DocumentAiSection>", () => {
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("<DocumentAiSection>", () => {
);
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", () => {
@@ -281,10 +281,6 @@ export function DocumentSummaryPanel({
{body}
</p>
) : null}
<p className="text-muted-foreground border-border/60 border-t pt-2 text-xs">
{t("documents.summary.notSaved")}
</p>
</div>
);
}
+7 -1
View File
@@ -176,7 +176,13 @@ export async function rasterizePdf(buffer: Buffer): Promise<RasterResult> {
} 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 {