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>
This commit is contained in:
Marcel
2026-06-01 20:10:26 +02:00
committed by marcel
parent 8b1b070254
commit 5c8034d298
2 changed files with 51 additions and 0 deletions

View File

@@ -1,6 +1,27 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { createPdfRenderer } from './usePdfRenderer.svelte'; import { createPdfRenderer } from './usePdfRenderer.svelte';
import { makeFakeLibLoader } from './testHelpers'; 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). // Note: init() and loadDocument() require pdfjsLib (browser module).
// These tests cover pure state logic only — bounds clamping and zoom limits. // These tests cover pure state logic only — bounds clamping and zoom limits.
@@ -231,6 +252,31 @@ describe('createPdfRenderer', () => {
expect(arg.wasmUrl?.endsWith('/')).toBe(true); 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 () => { it('loadDocument sets error and loading=false when getDocument().promise rejects', async () => {
const failingLib = { const failingLib = {
GlobalWorkerOptions: { workerSrc: '' }, GlobalWorkerOptions: { workerSrc: '' },

View File

@@ -1,4 +1,5 @@
import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist'; 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 }]>; export type LibLoader = () => Promise<readonly [typeof import('pdfjs-dist'), { default: string }]>;
@@ -105,6 +106,10 @@ export function createPdfRenderer(libLoader: LibLoader = defaultLibLoader) {
(e as { name: string }).name === 'RenderingCancelledException' (e as { name: string }).name === 'RenderingCancelledException'
) )
return; 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; return;
} }
renderTask = null; renderTask = null;