Compare commits
4 Commits
688d38120a
...
2c96752330
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c96752330 | ||
|
|
1d2c529436 | ||
|
|
2a44bc33fe | ||
|
|
23a635e0fb |
@@ -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",
|
||||
|
||||
29
frontend/scripts/assert-pdfjs-wasm.mjs
Normal file
29
frontend/scripts/assert-pdfjs-wasm.mjs
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user