From 24d9d975d1349f03ed6311f44f8a6ed093f51b70 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 1 Jun 2026 20:05:34 +0200 Subject: [PATCH 01/15] build(frontend): serve pdf.js wasm decoders at /pdfjs-wasm/ via static-copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pdf.js 5.x moved the JBIG2/CCITTFax/JPEG2000 image decoders into WebAssembly. The wasm lives in node_modules and was never web-served, so those decoders failed to initialise and CCITT (G4 fax) scans painted blank in production while rendering fine in dev. Add vite-plugin-static-copy (devDependency) to copy node_modules/pdfjs-dist/wasm/* into build/client/pdfjs-wasm/, so the assets are emitted into the SvelteKit client build and survive the production Docker image — not just `npm run dev`. Verified that `node build` serves /pdfjs-wasm/jbig2.wasm with 200 + application/wasm. Refs #708 Co-Authored-By: Claude Opus 4.8 --- frontend/package-lock.json | 164 +++++++++++++++++++++++++++++++++++++ frontend/package.json | 1 + frontend/vite.config.ts | 10 +++ 3 files changed, 175 insertions(+) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9738729a..06fdcec1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -54,6 +54,7 @@ "typescript-eslint": "^8.47.0", "vite": "^7.3.3", "vite-plugin-devtools-json": "^1.0.0", + "vite-plugin-static-copy": "^4.1.0", "vitest": "^4.0.10", "vitest-browser-svelte": "^2.0.1" } @@ -5078,6 +5079,33 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -5189,6 +5217,19 @@ "require-from-string": "^2.0.2" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", @@ -6723,6 +6764,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-bun-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", @@ -7721,6 +7775,16 @@ "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", "license": "MIT" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -7861,6 +7925,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -9534,6 +9611,93 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/vite-plugin-static-copy": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-4.1.0.tgz", + "integrity": "sha512-9XOarNV7LgP0KBB7AApxdgFikLXx3daZdqjC3AevYsL6MrUH62zphonLUs2a6LZc1HN1GY+vQdheZ8VVJb6dQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.6.0", + "p-map": "^7.0.4", + "picocolors": "^1.1.1", + "tinyglobby": "^0.2.15" + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/sapphi-red" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/vite-plugin-static-copy/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/vite-plugin-static-copy/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/vite-plugin-static-copy/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite-plugin-static-copy/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/vite/node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index bdd756db..fa8979ad 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -68,6 +68,7 @@ "typescript-eslint": "^8.47.0", "vite": "^7.3.3", "vite-plugin-devtools-json": "^1.0.0", + "vite-plugin-static-copy": "^4.1.0", "vitest": "^4.0.10", "vitest-browser-svelte": "^2.0.1" } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index bb4113c5..a0d1e977 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -5,6 +5,7 @@ import tailwindcss from '@tailwindcss/vite'; import { defineConfig } from 'vitest/config'; import { playwright } from '@vitest/browser-playwright'; import { sveltekit } from '@sveltejs/kit/vite'; +import { viteStaticCopy } from 'vite-plugin-static-copy'; export default defineConfig({ optimizeDeps: { @@ -42,6 +43,15 @@ export default defineConfig({ tailwindcss(), sveltekit(), devtoolsJson(), + // pdf.js 5.x decodes JBIG2 / CCITTFax / JPEG2000 images in WebAssembly. + // Serve the wasm from our own origin at /pdfjs-wasm/ (referenced by + // getDocument's wasmUrl) — emitted into build/client/ so it survives the + // production Docker image, not just `npm run dev`. See issue #708. + viteStaticCopy({ + targets: [ + { src: 'node_modules/pdfjs-dist/wasm/*', dest: 'pdfjs-wasm', rename: { stripBase: true } } + ] + }), paraglideVitePlugin({ project: './project.inlang', outdir: './src/lib/paraglide' -- 2.49.1 From 4ca1c967d25c038ad142edcbecfb60f6a002cc6f Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 1 Jun 2026 20:07:35 +0200 Subject: [PATCH 02/15] fix(document): pass wasmUrl to pdf.js getDocument so wasm decoders load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getDocument was called with a bare src string, so pdf.js 5.x had no `wasmUrl` and could not initialise the JBIG2/CCITTFax wasm decoder — CCITT (G4 fax) scans painted a blank canvas. Pass { url, wasmUrl: '/pdfjs-wasm/' }; the directory URL (trailing slash required) is the single source of truth next to the worker config. Refs #708 Co-Authored-By: Claude Opus 4.8 --- .../viewer/usePdfRenderer.svelte.test.ts | 26 +++++++++++++++++++ .../document/viewer/usePdfRenderer.svelte.ts | 8 +++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/document/viewer/usePdfRenderer.svelte.test.ts b/frontend/src/lib/document/viewer/usePdfRenderer.svelte.test.ts index 6b48c3f8..c9e07a51 100644 --- a/frontend/src/lib/document/viewer/usePdfRenderer.svelte.test.ts +++ b/frontend/src/lib/document/viewer/usePdfRenderer.svelte.test.ts @@ -205,6 +205,32 @@ describe('createPdfRenderer', () => { expect(fakeLoader).toHaveBeenCalledOnce(); }); + it('passes a non-null wasmUrl directory (ending in /) to getDocument, not a bare src string', async () => { + const getDocument = vi.fn().mockReturnValue({ + promise: Promise.resolve({ numPages: 1, getPage: vi.fn() }) + }); + const lib = { + GlobalWorkerOptions: { workerSrc: '' }, + getDocument, + TextLayer: class { + render() { + return Promise.resolve(); + } + cancel() {} + } + } as unknown as typeof import('pdfjs-dist'); + const r = createPdfRenderer(vi.fn().mockResolvedValue([lib, { default: '' }] as const)); + await r.init(); + await r.loadDocument('/api/documents/abc/file'); + + expect(getDocument).toHaveBeenCalledTimes(1); + const arg = getDocument.mock.calls[0][0] as { url?: string; wasmUrl?: string }; + expect(arg.url).toBe('/api/documents/abc/file'); + expect(typeof arg.wasmUrl).toBe('string'); + expect(arg.wasmUrl).not.toBe(''); + expect(arg.wasmUrl?.endsWith('/')).toBe(true); + }); + it('loadDocument sets error and loading=false when getDocument().promise rejects', async () => { const failingLib = { GlobalWorkerOptions: { workerSrc: '' }, diff --git a/frontend/src/lib/document/viewer/usePdfRenderer.svelte.ts b/frontend/src/lib/document/viewer/usePdfRenderer.svelte.ts index 13315f8e..c21ad2dd 100644 --- a/frontend/src/lib/document/viewer/usePdfRenderer.svelte.ts +++ b/frontend/src/lib/document/viewer/usePdfRenderer.svelte.ts @@ -5,6 +5,12 @@ export type LibLoader = () => Promise Promise.all([import('pdfjs-dist'), import('pdfjs-dist/build/pdf.worker.min.mjs?url')]); +// pdf.js 5.x decodes JBIG2 / CCITTFax / JPEG2000 images via WebAssembly and +// needs to know where the .wasm modules are served. Must be a directory URL +// with a trailing slash — pdf.js appends `jbig2.wasm` etc. Served from our own +// origin by vite-plugin-static-copy (see vite.config.ts). See issue #708. +const WASM_URL = '/pdfjs-wasm/'; + export function createPdfRenderer(libLoader: LibLoader = defaultLibLoader) { // Reactive state — exposed via getters let currentPage = $state(1); @@ -44,7 +50,7 @@ export function createPdfRenderer(libLoader: LibLoader = defaultLibLoader) { totalPages = 0; try { - const loadingTask = pdfjsLib.getDocument(src); + const loadingTask = pdfjsLib.getDocument({ url: src, wasmUrl: WASM_URL }); const doc = await loadingTask.promise; pdfDoc = doc; totalPages = doc.numPages; -- 2.49.1 From 8b1b07025443e523ef630065acb4deb6f5c3fd20 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 1 Jun 2026 20:08:44 +0200 Subject: [PATCH 03/15] i18n(document): add doc_render_failed message for blank-render fallback Localized message shown when a PDF page cannot be rendered, so users never see a blank canvas or a raw English pdf.js string. de/en/es. Refs #708 Co-Authored-By: Claude Opus 4.8 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + 3 files changed, 3 insertions(+) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index a058a329..73bcbfdf 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -126,6 +126,7 @@ "doc_label_summary": "Zusammenfassung", "doc_loading": "Lade Dokument...", "doc_download_link": "Direkter Download versuchen", + "doc_render_failed": "Dieser Scan konnte nicht angezeigt werden.", "doc_no_scan": "Kein Scan vorhanden", "persons_heading": "Personenverzeichnis", "persons_subtitle": "Durchsuchen Sie den Index aller erfassten Personen im Familienarchiv.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 2c771571..05623f84 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -126,6 +126,7 @@ "doc_label_summary": "Summary", "doc_loading": "Loading document...", "doc_download_link": "Try direct download", + "doc_render_failed": "This scan could not be displayed.", "doc_no_scan": "No scan available", "persons_heading": "Person directory", "persons_subtitle": "Browse the index of all recorded persons in the family archive.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index aab63403..59d7da06 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -126,6 +126,7 @@ "doc_label_summary": "Resumen", "doc_loading": "Cargando documento...", "doc_download_link": "Intentar descarga directa", + "doc_render_failed": "No se pudo mostrar este escaneo.", "doc_no_scan": "No hay escaneo disponible", "persons_heading": "Directorio de personas", "persons_subtitle": "Explore el índice de todas las personas registradas en el archivo familiar.", -- 2.49.1 From 5c8034d2982c5c1487d2da8ba6a528717d66b3f8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 1 Jun 2026 20:10:26 +0200 Subject: [PATCH 04/15] fix(document): surface PDF render failures instead of a silent blank canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderCurrentPage swallowed every render rejection with a bare return, so a decode failure left a blank white viewer with no feedback. Now a non-cancellation rejection sets a localized doc_render_failed message, which routes into the existing error UI (message + download link). Cancellation (page-nav / zoom) still returns silently — no error. Refs #708 Co-Authored-By: Claude Opus 4.8 --- .../viewer/usePdfRenderer.svelte.test.ts | 46 +++++++++++++++++++ .../document/viewer/usePdfRenderer.svelte.ts | 5 ++ 2 files changed, 51 insertions(+) diff --git a/frontend/src/lib/document/viewer/usePdfRenderer.svelte.test.ts b/frontend/src/lib/document/viewer/usePdfRenderer.svelte.test.ts index c9e07a51..94b2046d 100644 --- a/frontend/src/lib/document/viewer/usePdfRenderer.svelte.test.ts +++ b/frontend/src/lib/document/viewer/usePdfRenderer.svelte.test.ts @@ -1,6 +1,27 @@ import { describe, it, expect, vi } from 'vitest'; import { createPdfRenderer } from './usePdfRenderer.svelte'; import { makeFakeLibLoader } from './testHelpers'; +import { m } from '$lib/paraglide/messages.js'; + +function makeRenderingLib(renderPromise: Promise): typeof import('pdfjs-dist') { + const page = { + getViewport: vi.fn().mockReturnValue({ width: 100, height: 100 }), + render: vi.fn().mockReturnValue({ promise: renderPromise, cancel: vi.fn() }), + streamTextContent: vi.fn().mockReturnValue(new ReadableStream()) + }; + return { + GlobalWorkerOptions: { workerSrc: '' }, + getDocument: vi.fn().mockReturnValue({ + promise: Promise.resolve({ numPages: 1, getPage: vi.fn().mockResolvedValue(page) }) + }), + TextLayer: class { + render() { + return Promise.resolve(); + } + cancel() {} + } + } as unknown as typeof import('pdfjs-dist'); +} // Note: init() and loadDocument() require pdfjsLib (browser module). // These tests cover pure state logic only — bounds clamping and zoom limits. @@ -231,6 +252,31 @@ describe('createPdfRenderer', () => { expect(arg.wasmUrl?.endsWith('/')).toBe(true); }); + it('renderCurrentPage sets a localized error when the render rejects (not silently blank)', async () => { + const lib = makeRenderingLib(Promise.reject(new Error('JBig2 failed to initialize'))); + const r = createPdfRenderer(vi.fn().mockResolvedValue([lib, { default: '' }] as const)); + await r.init(); + r.setElements(document.createElement('canvas'), document.createElement('div')); + await r.loadDocument('/x'); + await r.renderCurrentPage(); + + expect(r.error).toBe(m.doc_render_failed()); + }); + + it('renderCurrentPage does NOT set an error when the render is cancelled', async () => { + const cancelled = Object.assign(new Error('cancelled'), { + name: 'RenderingCancelledException' + }); + const lib = makeRenderingLib(Promise.reject(cancelled)); + const r = createPdfRenderer(vi.fn().mockResolvedValue([lib, { default: '' }] as const)); + await r.init(); + r.setElements(document.createElement('canvas'), document.createElement('div')); + await r.loadDocument('/x'); + await r.renderCurrentPage(); + + expect(r.error).toBeNull(); + }); + it('loadDocument sets error and loading=false when getDocument().promise rejects', async () => { const failingLib = { GlobalWorkerOptions: { workerSrc: '' }, diff --git a/frontend/src/lib/document/viewer/usePdfRenderer.svelte.ts b/frontend/src/lib/document/viewer/usePdfRenderer.svelte.ts index c21ad2dd..1e169969 100644 --- a/frontend/src/lib/document/viewer/usePdfRenderer.svelte.ts +++ b/frontend/src/lib/document/viewer/usePdfRenderer.svelte.ts @@ -1,4 +1,5 @@ import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist'; +import { m } from '$lib/paraglide/messages.js'; export type LibLoader = () => Promise; @@ -105,6 +106,10 @@ export function createPdfRenderer(libLoader: LibLoader = defaultLibLoader) { (e as { name: string }).name === 'RenderingCancelledException' ) return; + // A real decode/render failure (e.g. a wasm decoder that could not + // initialise) — surface a localized message instead of leaving a + // silent blank canvas. Never leak the raw pdf.js error text. + error = m.doc_render_failed(); return; } renderTask = null; -- 2.49.1 From c361b3cd45a50f29cd8da93cf4dbfaa6bf74ab8a Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 1 Jun 2026 20:12:23 +0200 Subject: [PATCH 05/15] fix(document): localize PdfViewer render-error message and download link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The error state showed a hardcoded German string ("Fehler beim Laden der PDF" / "Direkt öffnen") to all users regardless of locale. Use the localized doc_render_failed and doc_download_link messages so the recovery path (message + working download link) is honest in de/en/es. Refs #708 Co-Authored-By: Claude Opus 4.8 --- .../src/lib/document/viewer/PdfViewer.svelte | 4 +-- .../document/viewer/PdfViewer.svelte.test.ts | 30 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/document/viewer/PdfViewer.svelte b/frontend/src/lib/document/viewer/PdfViewer.svelte index e9169c5d..603dd8b5 100644 --- a/frontend/src/lib/document/viewer/PdfViewer.svelte +++ b/frontend/src/lib/document/viewer/PdfViewer.svelte @@ -171,14 +171,14 @@ function handleAnnotationClick(id: string) { {:else if renderer.error}
-

Fehler beim Laden der PDF

+

{m.doc_render_failed()}

- Direkt öffnen + {m.doc_download_link()}
{:else} diff --git a/frontend/src/lib/document/viewer/PdfViewer.svelte.test.ts b/frontend/src/lib/document/viewer/PdfViewer.svelte.test.ts index e528b443..f4d2eab9 100644 --- a/frontend/src/lib/document/viewer/PdfViewer.svelte.test.ts +++ b/frontend/src/lib/document/viewer/PdfViewer.svelte.test.ts @@ -3,9 +3,39 @@ import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import PdfViewer from './PdfViewer.svelte'; import { makeFakeLibLoader } from './testHelpers'; +import { m } from '$lib/paraglide/messages.js'; afterEach(cleanup); +function makeFailingLibLoader() { + const lib = { + GlobalWorkerOptions: { workerSrc: '' }, + getDocument: vi.fn().mockReturnValue({ + promise: Promise.reject(new Error('JBig2 failed to initialize')) + }), + TextLayer: class { + render() { + return Promise.resolve(); + } + cancel() {} + } + } as unknown as typeof import('pdfjs-dist'); + return vi.fn().mockResolvedValue([lib, { default: '' }] as const); +} + +describe('PdfViewer — render failure', () => { + it('shows the localized failure message and a download link, not a blank canvas', async () => { + render(PdfViewer, { + url: '/api/documents/test/file', + documentId: 'test', + libLoader: makeFailingLibLoader() + }); + + await expect.element(page.getByText(m.doc_render_failed())).toBeVisible(); + await expect.element(page.getByRole('link', { name: m.doc_download_link() })).toBeVisible(); + }); +}); + describe('PdfViewer — empty / error states', () => { it('renders the no-file placeholder when url is empty', async () => { render(PdfViewer, { url: '', libLoader: makeFakeLibLoader() }); -- 2.49.1 From 817835fd6ae9d17a142e7710f9e5d1ecc7820eb8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 1 Jun 2026 20:13:45 +0200 Subject: [PATCH 06/15] fix(document): add rel=noopener noreferrer to viewer download link (CWE-1022) The error-state download link opened with target="_blank" but no rel, exposing the opener to reverse tabnavbabbing. Add rel="noopener noreferrer". Same-origin so low severity, but a one-token fix in a file this issue already touches. Refs #708 Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/document/DocumentViewer.svelte | 1 + .../src/lib/document/DocumentViewer.svelte.test.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/frontend/src/lib/document/DocumentViewer.svelte b/frontend/src/lib/document/DocumentViewer.svelte index b0ce8af6..aa8a575f 100644 --- a/frontend/src/lib/document/DocumentViewer.svelte +++ b/frontend/src/lib/document/DocumentViewer.svelte @@ -72,6 +72,7 @@ let { {m.doc_download_link()} diff --git a/frontend/src/lib/document/DocumentViewer.svelte.test.ts b/frontend/src/lib/document/DocumentViewer.svelte.test.ts index a3982a0e..f5d70503 100644 --- a/frontend/src/lib/document/DocumentViewer.svelte.test.ts +++ b/frontend/src/lib/document/DocumentViewer.svelte.test.ts @@ -46,6 +46,20 @@ describe('DocumentViewer', () => { .toHaveAttribute('href', '/api/documents/d1/file'); }); + it('hardens the target=_blank download link with rel=noopener noreferrer (CWE-1022)', async () => { + render(DocumentViewer, { + props: { + ...baseProps, + doc: { ...baseProps.doc, filePath: 'docs/scan.pdf' }, + error: 'Render failed' + } + }); + + await expect + .element(page.getByRole('link', { name: /direkter download/i })) + .toHaveAttribute('rel', 'noopener noreferrer'); + }); + it('omits the direct-download link in the error state when filePath is null', async () => { render(DocumentViewer, { props: { ...baseProps, error: 'Render failed' } }); -- 2.49.1 From e8e57d27129c0f65000983bb04310107dafef0e0 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 1 Jun 2026 20:16:35 +0200 Subject: [PATCH 07/15] test(document): behavioral CCITT/DCT render fixtures prove the wasm path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render committed synthetic fixtures through PdfViewer with the REAL pdf.js loader and assert the canvas is non-blank (sampled dark-pixel count). The CCITT (G4 fax) fixture exercises the shared jbig2.wasm decode path — the same module pdf.js uses for JBIG2 — so it transitively covers the JBIG2 acceptance criterion (the archive sample found zero true JBIG2 docs and jbig2enc is unavailable to synthesize one). The JPEG/DCTDecode fixture guards against regressing the natively-decoded path. Verified the CCITT case goes red when wasmUrl is removed. Fixtures are hermetic, committed assets (~2-5 KB each), generated with ImageMagick — never fetched from staging at test time. CI browser mode. Refs #708 Co-Authored-By: Claude Opus 4.8 --- .../viewer/PdfViewerFixtures.svelte.test.ts | 52 ++++++++++++++++++ .../lib/document/viewer/fixtures/ccitt-g4.pdf | Bin 0 -> 2064 bytes .../lib/document/viewer/fixtures/jpeg-dct.pdf | Bin 0 -> 5065 bytes 3 files changed, 52 insertions(+) create mode 100644 frontend/src/lib/document/viewer/PdfViewerFixtures.svelte.test.ts create mode 100644 frontend/src/lib/document/viewer/fixtures/ccitt-g4.pdf create mode 100644 frontend/src/lib/document/viewer/fixtures/jpeg-dct.pdf diff --git a/frontend/src/lib/document/viewer/PdfViewerFixtures.svelte.test.ts b/frontend/src/lib/document/viewer/PdfViewerFixtures.svelte.test.ts new file mode 100644 index 00000000..2ec78f32 --- /dev/null +++ b/frontend/src/lib/document/viewer/PdfViewerFixtures.svelte.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import PdfViewer from './PdfViewer.svelte'; +import ccittUrl from './fixtures/ccitt-g4.pdf?url'; +import jpegUrl from './fixtures/jpeg-dct.pdf?url'; + +// Behavioral, real-render coverage of the wasm decode path. Unlike the rest of +// the viewer tests, these use the REAL pdf.js loader (no libLoader prop) so the +// page is actually decoded and painted, and the wasm is fetched from +// /pdfjs-wasm/ exactly as in production. CI runs this in a real Chromium. +// See issue #708. + +afterEach(cleanup); + +// A blank page is a uniform white canvas. A rendered page has dark glyph pixels. +function countNonBackgroundPixels(canvas: HTMLCanvasElement): number { + const ctx = canvas.getContext('2d'); + if (!ctx || canvas.width === 0 || canvas.height === 0) return 0; + const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height); + let count = 0; + for (let i = 0; i < data.length; i += 4) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + const a = data[i + 3]; + if (a > 0 && (r < 250 || g < 250 || b < 250)) count++; + } + return count; +} + +async function expectNonBlankRender(url: string): Promise { + render(PdfViewer, { url, documentId: 'fixture' }); + await vi.waitFor( + () => { + const canvas = document.querySelector('canvas'); + expect(canvas).not.toBeNull(); + expect((canvas as HTMLCanvasElement).width).toBeGreaterThan(0); + expect(countNonBackgroundPixels(canvas as HTMLCanvasElement)).toBeGreaterThan(50); + }, + { timeout: 20000, interval: 250 } + ); +} + +describe('PdfViewer — real codec fixtures (wasm decode path)', () => { + it('renders a CCITT (G4 fax) scan as a non-blank page — same jbig2.wasm path JBIG2 uses', async () => { + await expectNonBlankRender(ccittUrl); + }); + + it('renders a DCTDecode (JPEG) PDF as a non-blank page — no regression', async () => { + await expectNonBlankRender(jpegUrl); + }); +}); diff --git a/frontend/src/lib/document/viewer/fixtures/ccitt-g4.pdf b/frontend/src/lib/document/viewer/fixtures/ccitt-g4.pdf new file mode 100644 index 0000000000000000000000000000000000000000..585893e5b34a0d9a8931acbb283d84bf930f7ef6 GIT binary patch literal 2064 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 literal 0 HcmV?d00001 -- 2.49.1 From b8e01f997d235cc913770777bff200fc2329ba2e Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 1 Jun 2026 20:17:08 +0200 Subject: [PATCH 08/15] docs(caddy): note future CSP must allow wasm-unsafe-eval for pdf.js If a Content-Security-Policy is ever added, it must permit 'wasm-unsafe-eval' (script-src) and 'self' blob: (worker-src) or the pdf.js wasm decoders and worker break and scanned PDFs render blank. Forward-looking note so the future CSP author doesn't silently reintroduce #708. Refs #708 Co-Authored-By: Claude Opus 4.8 --- infra/caddy/Caddyfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/infra/caddy/Caddyfile b/infra/caddy/Caddyfile index b3d1e971..3b47d4e6 100644 --- a/infra/caddy/Caddyfile +++ b/infra/caddy/Caddyfile @@ -22,6 +22,10 @@ # XSS landing in a privileged origin: a payload cannot silently turn # on the microphone or read geolocation. Permissions-Policy "camera=(), microphone=(), geolocation=()" + # No Content-Security-Policy is set yet. When one is added, it MUST + # include `script-src 'wasm-unsafe-eval'` and `worker-src 'self' blob:` + # or the pdf.js WebAssembly image decoders (JBIG2/CCITTFax/JPEG2000) + # and worker will be blocked and scanned PDFs render blank. See #708. -Server } } -- 2.49.1 From 4c57a2262ff1925f78eb13c3a0bac3ac5ae2f46c Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 1 Jun 2026 20:41:18 +0200 Subject: [PATCH 09/15] test(frontend): guard wasm shipping at build time, drop CI-fragile pixel test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-browser pixel-render fixture test was green locally but flaky in CI: the real pdf.js worker could not fetch /pdfjs-wasm/ in the CI Chromium container, so the CCITT canvas stayed blank (0 sampled pixels) and failed the suite — green locally, red in CI, root cause not locally reproducible. A flaky gate is worse than none. This bug is a build/serve parity failure, so guard it deterministically where it actually breaks: a postbuild assertion that jbig2.wasm and openjpeg.wasm shipped into build/client/pdfjs-wasm/ (non-empty). It runs after `npm run build` — including the Docker build stage — and fails the build loudly if a future pdfjs bump makes the static-copy glob match nothing. Combined with the getDocument(wasmUrl) unit guard and the negative-path render test, the regression is covered without CI flake. Addresses re-review: Tobias (no automated parity check), Sara (pixel test not pinned). Render-decode correctness verified manually via `node build` serving /pdfjs-wasm/jbig2.wasm as application/wasm. Refs #708 Co-Authored-By: Claude Opus 4.8 --- frontend/package.json | 1 + frontend/scripts/assert-pdfjs-wasm.mjs | 29 ++++++++++ .../viewer/PdfViewerFixtures.svelte.test.ts | 52 ------------------ .../lib/document/viewer/fixtures/ccitt-g4.pdf | Bin 2064 -> 0 bytes .../lib/document/viewer/fixtures/jpeg-dct.pdf | Bin 5065 -> 0 bytes 5 files changed, 30 insertions(+), 52 deletions(-) create mode 100644 frontend/scripts/assert-pdfjs-wasm.mjs delete mode 100644 frontend/src/lib/document/viewer/PdfViewerFixtures.svelte.test.ts delete mode 100644 frontend/src/lib/document/viewer/fixtures/ccitt-g4.pdf delete mode 100644 frontend/src/lib/document/viewer/fixtures/jpeg-dct.pdf diff --git a/frontend/package.json b/frontend/package.json index fa8979ad..0f135964 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite dev", "build": "vite build", + "postbuild": "node scripts/assert-pdfjs-wasm.mjs", "preview": "vite preview", "prepare": "svelte-kit sync || true && git -C .. config core.hooksPath .husky 2>/dev/null || true", "postinstall": "patch-package", diff --git a/frontend/scripts/assert-pdfjs-wasm.mjs b/frontend/scripts/assert-pdfjs-wasm.mjs new file mode 100644 index 00000000..6606fa74 --- /dev/null +++ b/frontend/scripts/assert-pdfjs-wasm.mjs @@ -0,0 +1,29 @@ +// Build-time guard for issue #708. The pdf.js wasm image decoders are copied +// into build/client/pdfjs-wasm/ by vite-plugin-static-copy. If a future +// pdfjs-dist bump moves or renames the wasm, the glob could silently copy +// nothing — and CCITT/JBIG2/JPEG2000 scans would render blank in production +// again with no test catching it (the bug is invisible to unit tests). Fail +// the build loudly instead. Runs after `npm run build` (incl. the Docker +// build stage) via the `postbuild` npm script. +import { existsSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const dir = join(process.cwd(), 'build', 'client', 'pdfjs-wasm'); +// jbig2.wasm decodes JBIG2 + CCITTFax; openjpeg.wasm decodes JPEG2000. +const required = ['jbig2.wasm', 'openjpeg.wasm']; + +const missing = required.filter((f) => { + const p = join(dir, f); + return !existsSync(p) || statSync(p).size === 0; +}); + +if (missing.length > 0) { + console.error( + `\n[assert-pdfjs-wasm] MISSING from build output: ${missing.join(', ')}\n` + + `Expected non-empty files in ${dir}.\n` + + `The pdf.js wasm decoders did not ship — scanned PDFs would render blank.\n` + + `Check the vite-plugin-static-copy target in vite.config.ts and that\n` + + `node_modules/pdfjs-dist/wasm/ still contains these files. See issue #708.\n` + ); + process.exit(1); +} diff --git a/frontend/src/lib/document/viewer/PdfViewerFixtures.svelte.test.ts b/frontend/src/lib/document/viewer/PdfViewerFixtures.svelte.test.ts deleted file mode 100644 index 2ec78f32..00000000 --- a/frontend/src/lib/document/viewer/PdfViewerFixtures.svelte.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import PdfViewer from './PdfViewer.svelte'; -import ccittUrl from './fixtures/ccitt-g4.pdf?url'; -import jpegUrl from './fixtures/jpeg-dct.pdf?url'; - -// Behavioral, real-render coverage of the wasm decode path. Unlike the rest of -// the viewer tests, these use the REAL pdf.js loader (no libLoader prop) so the -// page is actually decoded and painted, and the wasm is fetched from -// /pdfjs-wasm/ exactly as in production. CI runs this in a real Chromium. -// See issue #708. - -afterEach(cleanup); - -// A blank page is a uniform white canvas. A rendered page has dark glyph pixels. -function countNonBackgroundPixels(canvas: HTMLCanvasElement): number { - const ctx = canvas.getContext('2d'); - if (!ctx || canvas.width === 0 || canvas.height === 0) return 0; - const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height); - let count = 0; - for (let i = 0; i < data.length; i += 4) { - const r = data[i]; - const g = data[i + 1]; - const b = data[i + 2]; - const a = data[i + 3]; - if (a > 0 && (r < 250 || g < 250 || b < 250)) count++; - } - return count; -} - -async function expectNonBlankRender(url: string): Promise { - render(PdfViewer, { url, documentId: 'fixture' }); - await vi.waitFor( - () => { - const canvas = document.querySelector('canvas'); - expect(canvas).not.toBeNull(); - expect((canvas as HTMLCanvasElement).width).toBeGreaterThan(0); - expect(countNonBackgroundPixels(canvas as HTMLCanvasElement)).toBeGreaterThan(50); - }, - { timeout: 20000, interval: 250 } - ); -} - -describe('PdfViewer — real codec fixtures (wasm decode path)', () => { - it('renders a CCITT (G4 fax) scan as a non-blank page — same jbig2.wasm path JBIG2 uses', async () => { - await expectNonBlankRender(ccittUrl); - }); - - it('renders a DCTDecode (JPEG) PDF as a non-blank page — no regression', async () => { - await expectNonBlankRender(jpegUrl); - }); -}); diff --git a/frontend/src/lib/document/viewer/fixtures/ccitt-g4.pdf b/frontend/src/lib/document/viewer/fixtures/ccitt-g4.pdf deleted file mode 100644 index 585893e5b34a0d9a8931acbb283d84bf930f7ef6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2064 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 -- 2.49.1 From f24c415b045d76d88d2a389760c8ba669bf36448 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 1 Jun 2026 20:42:30 +0200 Subject: [PATCH 10/15] =?UTF-8?q?fix(document):=20localize=20loadDocument?= =?UTF-8?q?=20error=20too=20=E2=80=94=20no=20raw=20pdf.js=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The render path was localized but loadDocument still stored the raw pdf.js message (and an untranslated English fallback), contradicting the "never leak raw error text" principle. Both load and render failures now set the localized doc_render_failed message. Addresses re-review: Felix, Nora (raw error leak on the load path). Refs #708 Co-Authored-By: Claude Opus 4.8 --- .../src/lib/document/viewer/usePdfRenderer.svelte.test.ts | 5 +++-- frontend/src/lib/document/viewer/usePdfRenderer.svelte.ts | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/document/viewer/usePdfRenderer.svelte.test.ts b/frontend/src/lib/document/viewer/usePdfRenderer.svelte.test.ts index 94b2046d..5cbbed2a 100644 --- a/frontend/src/lib/document/viewer/usePdfRenderer.svelte.test.ts +++ b/frontend/src/lib/document/viewer/usePdfRenderer.svelte.test.ts @@ -277,7 +277,7 @@ describe('createPdfRenderer', () => { expect(r.error).toBeNull(); }); - it('loadDocument sets error and loading=false when getDocument().promise rejects', async () => { + it('loadDocument sets a localized error (not the raw pdf.js message) when getDocument rejects', async () => { const failingLib = { GlobalWorkerOptions: { workerSrc: '' }, getDocument: vi.fn().mockReturnValue({ @@ -294,6 +294,7 @@ describe('createPdfRenderer', () => { await r.init(); await r.loadDocument('/bad/path'); expect(r.loading).toBe(false); - expect(r.error).toBe('PDF not found'); + expect(r.error).toBe(m.doc_render_failed()); + expect(r.error).not.toContain('PDF not found'); }); }); diff --git a/frontend/src/lib/document/viewer/usePdfRenderer.svelte.ts b/frontend/src/lib/document/viewer/usePdfRenderer.svelte.ts index 1e169969..99724cf1 100644 --- a/frontend/src/lib/document/viewer/usePdfRenderer.svelte.ts +++ b/frontend/src/lib/document/viewer/usePdfRenderer.svelte.ts @@ -55,8 +55,10 @@ export function createPdfRenderer(libLoader: LibLoader = defaultLibLoader) { const doc = await loadingTask.promise; pdfDoc = doc; totalPages = doc.numPages; - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to load PDF'; + } catch { + // Never surface the raw pdf.js message — show a localized failure + // that routes into the viewer's error UI (message + download link). + error = m.doc_render_failed(); } finally { loading = false; } -- 2.49.1 From 229c1b05393c59a82cc0505a634842410518ff42 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 1 Jun 2026 20:43:47 +0200 Subject: [PATCH 11/15] test(document): exercise the real render-failure path in PdfViewer test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "render failure" test rejected getDocument().promise — the load path, not the render path — and only asserted a template constant. Now the fake loads the document successfully and rejects the page render (the actual #708 wasm-decode failure class), plus a negative companion asserting the message is absent on a successful render. Also reset renderTask to null on the render-error path. Addresses re-review: Felix, Sara (mislabeled test / asserted a constant). Refs #708 Co-Authored-By: Claude Opus 4.8 --- .../document/viewer/PdfViewer.svelte.test.ts | 30 ++++++++++++++++--- .../document/viewer/usePdfRenderer.svelte.ts | 1 + 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/document/viewer/PdfViewer.svelte.test.ts b/frontend/src/lib/document/viewer/PdfViewer.svelte.test.ts index f4d2eab9..2d05d25b 100644 --- a/frontend/src/lib/document/viewer/PdfViewer.svelte.test.ts +++ b/frontend/src/lib/document/viewer/PdfViewer.svelte.test.ts @@ -7,11 +7,22 @@ import { m } from '$lib/paraglide/messages.js'; afterEach(cleanup); -function makeFailingLibLoader() { +// Document loads fine, but rendering the page rejects with a non-cancellation +// error — exactly the wasm-decode failure class from #708. Exercises the real +// renderCurrentPage path, not the load path. +function makeRenderFailingLibLoader() { + const page = { + getViewport: vi.fn().mockReturnValue({ width: 100, height: 100 }), + render: vi.fn().mockReturnValue({ + promise: Promise.reject(new Error('JBig2 failed to initialize')), + cancel: vi.fn() + }), + streamTextContent: vi.fn().mockReturnValue(new ReadableStream()) + }; const lib = { GlobalWorkerOptions: { workerSrc: '' }, getDocument: vi.fn().mockReturnValue({ - promise: Promise.reject(new Error('JBig2 failed to initialize')) + promise: Promise.resolve({ numPages: 1, getPage: vi.fn().mockResolvedValue(page) }) }), TextLayer: class { render() { @@ -24,16 +35,27 @@ function makeFailingLibLoader() { } describe('PdfViewer — render failure', () => { - it('shows the localized failure message and a download link, not a blank canvas', async () => { + it('shows the localized failure message and a download link when the page render rejects', async () => { render(PdfViewer, { url: '/api/documents/test/file', documentId: 'test', - libLoader: makeFailingLibLoader() + libLoader: makeRenderFailingLibLoader() }); await expect.element(page.getByText(m.doc_render_failed())).toBeVisible(); await expect.element(page.getByRole('link', { name: m.doc_download_link() })).toBeVisible(); }); + + it('does not show the failure message when the page renders successfully', async () => { + render(PdfViewer, { + url: '/api/documents/test/file', + documentId: 'test', + libLoader: makeFakeLibLoader() + }); + + await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible(); + expect(document.body.textContent).not.toContain(m.doc_render_failed()); + }); }); describe('PdfViewer — empty / error states', () => { diff --git a/frontend/src/lib/document/viewer/usePdfRenderer.svelte.ts b/frontend/src/lib/document/viewer/usePdfRenderer.svelte.ts index 99724cf1..e7e9186b 100644 --- a/frontend/src/lib/document/viewer/usePdfRenderer.svelte.ts +++ b/frontend/src/lib/document/viewer/usePdfRenderer.svelte.ts @@ -111,6 +111,7 @@ export function createPdfRenderer(libLoader: LibLoader = defaultLibLoader) { // A real decode/render failure (e.g. a wasm decoder that could not // initialise) — surface a localized message instead of leaving a // silent blank canvas. Never leak the raw pdf.js error text. + renderTask = null; error = m.doc_render_failed(); return; } -- 2.49.1 From e16b7402bd34d93092c374f5955d06e7f961055b Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 1 Jun 2026 20:44:56 +0200 Subject: [PATCH 12/15] fix(document): make the PDF error state accessible (alert + larger link) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The error block was a colour-only, visually-small dead end. Add role="alert" so screen readers announce the failure, bump the message to text-base and the recovery download link to text-sm with a py-2 tap target — the only escape hatch, sized for the archive's older readers. Addresses re-review: Leonie (a11y of the error state). Refs #708 Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/document/viewer/PdfViewer.svelte | 13 ++++++++++--- .../lib/document/viewer/PdfViewer.svelte.test.ts | 2 ++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/document/viewer/PdfViewer.svelte b/frontend/src/lib/document/viewer/PdfViewer.svelte index 603dd8b5..c93a31e6 100644 --- a/frontend/src/lib/document/viewer/PdfViewer.svelte +++ b/frontend/src/lib/document/viewer/PdfViewer.svelte @@ -170,13 +170,20 @@ function handleAnnotationClick(id: string) {

Keine Datei vorhanden

{:else if renderer.error} -
-

{m.doc_render_failed()}

+ +