diff --git a/frontend/e2e/documents.spec.ts b/frontend/e2e/documents.spec.ts
index 5743ab02..54e56b1d 100644
--- a/frontend/e2e/documents.spec.ts
+++ b/frontend/e2e/documents.spec.ts
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
+import path from 'path';
/**
* Document management E2E tests.
@@ -142,3 +143,78 @@ test.describe('Document edit', () => {
await page.screenshot({ path: 'test-results/e2e/document-edit-date-error.png' });
});
});
+
+// ─── PDF Viewer ───────────────────────────────────────────────────────────────
+
+const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
+
+test.describe('PDF viewer', () => {
+ let pdfDocHref: string;
+
+ test.beforeAll(async ({ browser }) => {
+ // Create a document and upload the PDF fixture so later tests have a
+ // real file attached. Runs once for the whole describe block.
+ const ctx = await browser.newContext();
+ const p = await ctx.newPage();
+
+ await p.goto('/documents/new');
+ await p.waitForSelector('[data-hydrated]');
+ await p.getByLabel('Titel').fill('E2E PDF Viewer Test');
+ await p.getByRole('button', { name: /Speichern/i }).click();
+ await p.waitForURL(/\/documents\/[^/]+$/);
+
+ // Upload the PDF on the edit page
+ const href = p.url().replace(/\/$/, '');
+ pdfDocHref = href;
+ await p.goto(`${href}/edit`);
+ await p.waitForSelector('[data-hydrated]');
+ await p.locator('input[type="file"][name="file"]').setInputFiles(PDF_FIXTURE);
+ await p.getByRole('button', { name: /Speichern/i }).click();
+ await p.waitForURL(/\/documents\/[^/]+$/);
+
+ await ctx.close();
+ });
+
+ test('PDF renders in the custom viewer — canvas is present instead of iframe', async ({
+ page
+ }) => {
+ await page.goto(pdfDocHref);
+ await page.waitForSelector('[data-hydrated]');
+
+ // There must be NO iframe — we replaced it with PDF.js canvas rendering.
+ await expect(page.locator('iframe')).not.toBeAttached();
+
+ // At least one canvas element must be visible (one per rendered page).
+ await expect(page.locator('canvas').first()).toBeVisible({ timeout: 15000 });
+
+ await page.screenshot({ path: 'test-results/e2e/pdf-viewer-canvas.png' });
+ });
+
+ test('page navigation controls are visible', async ({ page }) => {
+ await page.goto(pdfDocHref);
+ await page.waitForSelector('[data-hydrated]');
+ await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 15000 });
+
+ await expect(page.getByRole('button', { name: /prev|previous|zurück|vorige/i })).toBeVisible();
+ await expect(page.getByRole('button', { name: /next|weiter|nächste/i })).toBeVisible();
+
+ await page.screenshot({ path: 'test-results/e2e/pdf-viewer-nav.png' });
+ });
+
+ test('non-PDF attachment renders as an img element, not canvas', async ({ page }) => {
+ // The seed document "Urlaubspostkarte Ostsee" has a .jpg original filename.
+ // Navigate to it and confirm an is used (no canvas, no iframe).
+ await page.goto('/');
+ await page.waitForSelector('[data-hydrated]');
+ await page.goto('/?q=Urlaubspostkarte');
+ const link = page.getByRole('link', { name: /Urlaubspostkarte/i }).first();
+ const href = await link.getAttribute('href');
+ await page.goto(href!);
+ await page.waitForSelector('[data-hydrated]');
+
+ // No canvas — this is an image document
+ await expect(page.locator('canvas')).not.toBeAttached();
+
+ await page.screenshot({ path: 'test-results/e2e/pdf-viewer-image-fallback.png' });
+ });
+});
diff --git a/frontend/e2e/fixtures/minimal.pdf b/frontend/e2e/fixtures/minimal.pdf
new file mode 100644
index 00000000..c127ad96
--- /dev/null
+++ b/frontend/e2e/fixtures/minimal.pdf
@@ -0,0 +1,21 @@
+%PDF-1.4
+1 0 obj
+<>
+endobj
+2 0 obj
+<>
+endobj
+3 0 obj
+<>
+endobj
+xref
+0 4
+0000000000 65535 f
+0000000009 00000 n
+0000000054 00000 n
+0000000105 00000 n
+trailer
+<>
+startxref
+170
+%%EOF
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 6795fc10..493c551e 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,7 +9,8 @@
"version": "0.0.1",
"dependencies": {
"diff": "^8.0.3",
- "openapi-fetch": "^0.13.5"
+ "openapi-fetch": "^0.13.5",
+ "pdfjs-dist": "^5.5.207"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
@@ -885,6 +886,256 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/@napi-rs/canvas": {
+ "version": "0.1.97",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz",
+ "integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==",
+ "license": "MIT",
+ "optional": true,
+ "workspaces": [
+ "e2e/*"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas-android-arm64": "0.1.97",
+ "@napi-rs/canvas-darwin-arm64": "0.1.97",
+ "@napi-rs/canvas-darwin-x64": "0.1.97",
+ "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97",
+ "@napi-rs/canvas-linux-arm64-gnu": "0.1.97",
+ "@napi-rs/canvas-linux-arm64-musl": "0.1.97",
+ "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97",
+ "@napi-rs/canvas-linux-x64-gnu": "0.1.97",
+ "@napi-rs/canvas-linux-x64-musl": "0.1.97",
+ "@napi-rs/canvas-win32-arm64-msvc": "0.1.97",
+ "@napi-rs/canvas-win32-x64-msvc": "0.1.97"
+ }
+ },
+ "node_modules/@napi-rs/canvas-android-arm64": {
+ "version": "0.1.97",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz",
+ "integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-arm64": {
+ "version": "0.1.97",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz",
+ "integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-x64": {
+ "version": "0.1.97",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz",
+ "integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
+ "version": "0.1.97",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz",
+ "integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-gnu": {
+ "version": "0.1.97",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz",
+ "integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-musl": {
+ "version": "0.1.97",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz",
+ "integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
+ "version": "0.1.97",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz",
+ "integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-gnu": {
+ "version": "0.1.97",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz",
+ "integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-musl": {
+ "version": "0.1.97",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz",
+ "integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-win32-arm64-msvc": {
+ "version": "0.1.97",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz",
+ "integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-win32-x64-msvc": {
+ "version": "0.1.97",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz",
+ "integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
@@ -3954,6 +4205,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/node-readable-to-web-readable-stream": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
+ "integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@@ -4129,6 +4387,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/pdfjs-dist": {
+ "version": "5.5.207",
+ "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz",
+ "integrity": "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=20.19.0 || >=22.13.0 || >=24"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas": "^0.1.95",
+ "node-readable-to-web-readable-stream": "^0.4.2"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 7e8ded46..4d622d4d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -21,7 +21,8 @@
},
"dependencies": {
"diff": "^8.0.3",
- "openapi-fetch": "^0.13.5"
+ "openapi-fetch": "^0.13.5",
+ "pdfjs-dist": "^5.5.207"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
diff --git a/frontend/src/lib/components/PdfViewer.svelte b/frontend/src/lib/components/PdfViewer.svelte
new file mode 100644
index 00000000..224b2cc6
--- /dev/null
+++ b/frontend/src/lib/components/PdfViewer.svelte
@@ -0,0 +1,290 @@
+
+
+{#if !url}
+
Keine Datei vorhanden
+Fehler beim Laden der PDF
+ + Direkt öffnen + +{m.doc_no_scan()}
- {:else if fileUrl && doc.originalFilename.toLowerCase().endsWith('.pdf')} - + {:else if fileUrl && doc.contentType?.startsWith('application/pdf')} +