Compare commits

...

12 Commits

Author SHA1 Message Date
Marcel
2c96752330 fix(document): make the PDF error state accessible (alert + larger link)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m45s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m35s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
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 <noreply@anthropic.com>
2026-06-01 20:44:56 +02:00
Marcel
1d2c529436 test(document): exercise the real render-failure path in PdfViewer test
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 <noreply@anthropic.com>
2026-06-01 20:43:47 +02:00
Marcel
2a44bc33fe fix(document): localize loadDocument error too — no raw pdf.js text
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 <noreply@anthropic.com>
2026-06-01 20:42:30 +02:00
Marcel
23a635e0fb test(frontend): guard wasm shipping at build time, drop CI-fragile pixel test
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 <noreply@anthropic.com>
2026-06-01 20:41:18 +02:00
Marcel
688d38120a docs(caddy): note future CSP must allow wasm-unsafe-eval for pdf.js
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m57s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m31s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
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 <noreply@anthropic.com>
2026-06-01 20:17:08 +02:00
Marcel
cf86019337 test(document): behavioral CCITT/DCT render fixtures prove the wasm path
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 <noreply@anthropic.com>
2026-06-01 20:16:35 +02:00
Marcel
6690e1374d 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 <noreply@anthropic.com>
2026-06-01 20:13:45 +02:00
Marcel
e0eedc70f9 fix(document): localize PdfViewer render-error message and download link
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 <noreply@anthropic.com>
2026-06-01 20:12:23 +02:00
Marcel
aa1e89c290 fix(document): surface PDF render failures instead of a silent blank canvas
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 <noreply@anthropic.com>
2026-06-01 20:10:26 +02:00
Marcel
5a4b55e366 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 <noreply@anthropic.com>
2026-06-01 20:08:44 +02:00
Marcel
be42e1f01f fix(document): pass wasmUrl to pdf.js getDocument so wasm decoders load
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 <noreply@anthropic.com>
2026-06-01 20:07:35 +02:00
Marcel
8d2ef97fe2 build(frontend): serve pdf.js wasm decoders at /pdfjs-wasm/ via static-copy
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 <noreply@anthropic.com>
2026-06-01 20:05:34 +02:00
14 changed files with 384 additions and 9 deletions

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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",

View File

@@ -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",
@@ -68,6 +69,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"
}

View File

@@ -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);
}

View File

@@ -72,6 +72,7 @@ let {
<a
href="/api/documents/{doc.id}/file"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline hover:text-white"
>
{m.doc_download_link()}

View File

@@ -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' } });

View File

@@ -170,15 +170,22 @@ function handleAnnotationClick(id: string) {
<p class="font-sans text-sm">Keine Datei vorhanden</p>
</div>
{:else if renderer.error}
<div class="flex h-full w-full flex-col items-center justify-center gap-3 bg-pdf-bg text-ink-3">
<p class="font-sans text-sm text-red-400">Fehler beim Laden der PDF</p>
<!-- role="alert" announces the failure to screen readers; the message text
(not colour alone) carries the meaning. The download link is the only
recovery action, so it is sized as a comfortable tap/focus target for
the archive's older readers. -->
<div
role="alert"
class="flex h-full w-full flex-col items-center justify-center gap-3 bg-pdf-bg px-4 text-center text-ink-3"
>
<p class="font-sans text-base text-red-400">{m.doc_render_failed()}</p>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
class="font-sans text-xs text-primary underline hover:text-ink-2"
class="inline-block py-2 font-sans text-sm text-primary underline hover:text-ink-2"
>
Direkt öffnen
{m.doc_download_link()}
</a>
</div>
{:else}

View File

@@ -3,9 +3,63 @@ 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);
// 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.resolve({ numPages: 1, getPage: vi.fn().mockResolvedValue(page) })
}),
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 when the page render rejects', async () => {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
libLoader: makeRenderFailingLibLoader()
});
await expect.element(page.getByText(m.doc_render_failed())).toBeVisible();
await expect.element(page.getByRole('link', { name: m.doc_download_link() })).toBeVisible();
// Announced to assistive tech, not a silent visual-only failure.
await expect.element(page.getByRole('alert')).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', () => {
it('renders the no-file placeholder when url is empty', async () => {
render(PdfViewer, { url: '', libLoader: makeFakeLibLoader() });

View File

@@ -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<void>): 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.
@@ -205,7 +226,58 @@ describe('createPdfRenderer', () => {
expect(fakeLoader).toHaveBeenCalledOnce();
});
it('loadDocument sets error and loading=false when getDocument().promise rejects', async () => {
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('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 a localized error (not the raw pdf.js message) when getDocument rejects', async () => {
const failingLib = {
GlobalWorkerOptions: { workerSrc: '' },
getDocument: vi.fn().mockReturnValue({
@@ -222,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');
});
});

View File

@@ -1,10 +1,17 @@
import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist';
import { m } from '$lib/paraglide/messages.js';
export type LibLoader = () => Promise<readonly [typeof import('pdfjs-dist'), { default: string }]>;
const defaultLibLoader: LibLoader = () =>
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,12 +51,14 @@ 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;
} 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;
}
@@ -99,6 +108,11 @@ 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.
renderTask = null;
error = m.doc_render_failed();
return;
}
renderTask = null;

View File

@@ -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'

View File

@@ -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
}
}