Compare commits

...

4 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
9 changed files with 76 additions and 63 deletions

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

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

@@ -170,13 +170,20 @@ 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">{m.doc_render_failed()}</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"
>
{m.doc_download_link()}
</a>

View File

@@ -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,15 +35,28 @@ 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();
// 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());
});
});

View File

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

View File

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

View File

@@ -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;
}
@@ -109,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;
}