mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
8246d9251c
commit
a89e511359
16 files changed
+47
-22
No files matched your search
@@ -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
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user