feat(frontend): replace iframe with PDF.js viewer (#39)
- Install pdfjs-dist v5 and add optimizeDeps pre-bundle config - New PdfViewer.svelte component: renders each page on a <canvas> with correct device-pixel-ratio scaling, overlays a text layer (enables text selection; foundation for annotations in #40), prev/next navigation, zoom controls, and lazy page rendering (only current ±1 pre-fetched — avoids freezing on multi-page documents) - Replace the <iframe> in documents/[id]/+page.svelte with PdfViewer; image attachments continue to use <img>; detection now uses doc.contentType instead of filename extension - Unit tests for navigation controls and page counter (pdfjs mocked) - E2E tests: PDF renders as canvas (not iframe), nav controls visible, image fallback stays as <img>; minimal.pdf fixture for upload tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Document management E2E tests.
|
* Document management E2E tests.
|
||||||
@@ -142,3 +143,78 @@ test.describe('Document edit', () => {
|
|||||||
await page.screenshot({ path: 'test-results/e2e/document-edit-date-error.png' });
|
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 <img> 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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
21
frontend/e2e/fixtures/minimal.pdf
Normal file
21
frontend/e2e/fixtures/minimal.pdf
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
%PDF-1.4
|
||||||
|
1 0 obj
|
||||||
|
<</Type/Catalog/Pages 2 0 R>>
|
||||||
|
endobj
|
||||||
|
2 0 obj
|
||||||
|
<</Type/Pages/Kids[3 0 R]/Count 1>>
|
||||||
|
endobj
|
||||||
|
3 0 obj
|
||||||
|
<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R>>
|
||||||
|
endobj
|
||||||
|
xref
|
||||||
|
0 4
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000009 00000 n
|
||||||
|
0000000054 00000 n
|
||||||
|
0000000105 00000 n
|
||||||
|
trailer
|
||||||
|
<</Size 4/Root 1 0 R>>
|
||||||
|
startxref
|
||||||
|
170
|
||||||
|
%%EOF
|
||||||
273
frontend/package-lock.json
generated
273
frontend/package-lock.json
generated
@@ -9,7 +9,8 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"openapi-fetch": "^0.13.5"
|
"openapi-fetch": "^0.13.5",
|
||||||
|
"pdfjs-dist": "^5.5.207"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.4.0",
|
"@eslint/compat": "^1.4.0",
|
||||||
@@ -885,6 +886,256 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.58.2",
|
"version": "1.58.2",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||||
@@ -3954,6 +4205,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/obug": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||||
@@ -4129,6 +4387,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"openapi-fetch": "^0.13.5"
|
"openapi-fetch": "^0.13.5",
|
||||||
|
"pdfjs-dist": "^5.5.207"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.4.0",
|
"@eslint/compat": "^1.4.0",
|
||||||
|
|||||||
290
frontend/src/lib/components/PdfViewer.svelte
Normal file
290
frontend/src/lib/components/PdfViewer.svelte
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getDocument, GlobalWorkerOptions, TextLayer } from 'pdfjs-dist';
|
||||||
|
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
||||||
|
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
|
||||||
|
|
||||||
|
GlobalWorkerOptions.workerSrc = workerUrl;
|
||||||
|
|
||||||
|
let { url }: { url: string } = $props();
|
||||||
|
|
||||||
|
let pdfDoc = $state<PDFDocumentProxy | null>(null);
|
||||||
|
let currentPage = $state(1);
|
||||||
|
let totalPages = $state(0);
|
||||||
|
let scale = $state(1.5);
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Canvas and text layer container refs — bound via bind:this, not reactive state
|
||||||
|
let canvasEl = $state<HTMLCanvasElement | null>(null);
|
||||||
|
let textLayerEl = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// Internal mutable refs for in-flight tasks — NOT $state to avoid reactive loops
|
||||||
|
let renderTask: RenderTask | null = null;
|
||||||
|
let textLayerInstance: { cancel: () => void } | null = null;
|
||||||
|
|
||||||
|
async function loadDocument(src: string) {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
pdfDoc = null;
|
||||||
|
currentPage = 1;
|
||||||
|
totalPages = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loadingTask = getDocument(src);
|
||||||
|
const doc = await loadingTask.promise;
|
||||||
|
pdfDoc = doc;
|
||||||
|
totalPages = doc.numPages;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to load PDF';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPage(doc: PDFDocumentProxy, pageNum: number) {
|
||||||
|
if (!canvasEl || !textLayerEl) return;
|
||||||
|
|
||||||
|
// Cancel any in-flight render
|
||||||
|
if (renderTask) {
|
||||||
|
renderTask.cancel();
|
||||||
|
renderTask = null;
|
||||||
|
}
|
||||||
|
if (textLayerInstance) {
|
||||||
|
textLayerInstance.cancel();
|
||||||
|
textLayerInstance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let page: PDFPageProxy;
|
||||||
|
try {
|
||||||
|
page = await doc.getPage(pageNum);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const viewport = page.getViewport({ scale: scale * dpr });
|
||||||
|
|
||||||
|
const canvas = canvasEl;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
canvas.style.width = `${viewport.width / dpr}px`;
|
||||||
|
canvas.style.height = `${viewport.height / dpr}px`;
|
||||||
|
|
||||||
|
const task = page.render({ canvas, canvasContext: ctx, viewport });
|
||||||
|
renderTask = task;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await task.promise;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
// Cancelled renders throw a specific error — ignore them
|
||||||
|
if (e instanceof Error && e.message === 'Rendering cancelled') return;
|
||||||
|
if (
|
||||||
|
typeof e === 'object' &&
|
||||||
|
e !== null &&
|
||||||
|
'name' in e &&
|
||||||
|
(e as { name: string }).name === 'RenderingCancelledException'
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderTask = null;
|
||||||
|
|
||||||
|
// Text layer
|
||||||
|
const textDiv = textLayerEl;
|
||||||
|
textDiv.innerHTML = '';
|
||||||
|
textDiv.style.width = `${viewport.width / dpr}px`;
|
||||||
|
textDiv.style.height = `${viewport.height / dpr}px`;
|
||||||
|
|
||||||
|
const tl = new TextLayer({
|
||||||
|
textContentSource: page.streamTextContent(),
|
||||||
|
container: textDiv,
|
||||||
|
viewport
|
||||||
|
});
|
||||||
|
textLayerInstance = tl;
|
||||||
|
try {
|
||||||
|
await tl.render();
|
||||||
|
} catch {
|
||||||
|
// cancelled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prerender(doc: PDFDocumentProxy, pageNum: number) {
|
||||||
|
const neighbors = [pageNum - 1, pageNum + 1].filter((n) => n >= 1 && n <= doc.numPages);
|
||||||
|
for (const n of neighbors) {
|
||||||
|
try {
|
||||||
|
await doc.getPage(n);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (url) {
|
||||||
|
loadDocument(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (pdfDoc && currentPage) {
|
||||||
|
renderPage(pdfDoc, currentPage).then(() => {
|
||||||
|
if (pdfDoc) prerender(pdfDoc, currentPage);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function prevPage() {
|
||||||
|
if (currentPage > 1) currentPage -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPage() {
|
||||||
|
if (pdfDoc && currentPage < pdfDoc.numPages) currentPage += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomIn() {
|
||||||
|
scale += 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOut() {
|
||||||
|
if (scale > 0.5) scale -= 0.25;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !url}
|
||||||
|
<div class="flex h-full w-full items-center justify-center bg-[#2A2A2A] text-gray-400">
|
||||||
|
<p class="font-sans text-sm">Keine Datei vorhanden</p>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div
|
||||||
|
class="flex h-full w-full flex-col items-center justify-center gap-3 bg-[#2A2A2A] text-gray-300"
|
||||||
|
>
|
||||||
|
<p class="font-sans text-sm text-red-400">Fehler beim Laden der PDF</p>
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="font-sans text-xs text-brand-mint underline hover:opacity-80"
|
||||||
|
>
|
||||||
|
Direkt öffnen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-full w-full flex-col bg-[#2A2A2A]">
|
||||||
|
<!-- Controls -->
|
||||||
|
<div
|
||||||
|
class="flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-2"
|
||||||
|
>
|
||||||
|
<!-- Page navigation -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onclick={prevPage}
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
aria-label="Zurück"
|
||||||
|
class="rounded p-1 text-gray-300 transition hover:bg-white/10 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if totalPages > 0}
|
||||||
|
<span class="font-sans text-xs text-gray-300 tabular-nums">
|
||||||
|
{currentPage} / {totalPages}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={nextPage}
|
||||||
|
disabled={!pdfDoc || currentPage >= totalPages}
|
||||||
|
aria-label="Weiter"
|
||||||
|
class="rounded p-1 text-gray-300 transition hover:bg-white/10 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zoom controls -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onclick={zoomOut}
|
||||||
|
aria-label="Verkleinern"
|
||||||
|
class="rounded p-1 text-gray-300 transition hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" /><path
|
||||||
|
stroke-linecap="round"
|
||||||
|
d="M21 21l-4.35-4.35M8 11h6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={zoomIn}
|
||||||
|
aria-label="Vergrößern"
|
||||||
|
class="rounded p-1 text-gray-300 transition hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" /><path
|
||||||
|
stroke-linecap="round"
|
||||||
|
d="M21 21l-4.35-4.35M11 8v6M8 11h6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PDF canvas area -->
|
||||||
|
<div class="relative flex-1 overflow-auto">
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex h-full items-center justify-center">
|
||||||
|
<div
|
||||||
|
class="h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex min-h-full items-start justify-center p-4">
|
||||||
|
<div
|
||||||
|
class="pdf-page relative shadow-xl"
|
||||||
|
data-page-number={currentPage}
|
||||||
|
style="position: relative"
|
||||||
|
>
|
||||||
|
<canvas bind:this={canvasEl}></canvas>
|
||||||
|
<div
|
||||||
|
bind:this={textLayerEl}
|
||||||
|
class="textLayer"
|
||||||
|
style="position: absolute; top: 0; left: 0; overflow: hidden; pointer-events: none; line-height: 1;"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
53
frontend/src/lib/components/PdfViewer.svelte.spec.ts
Normal file
53
frontend/src/lib/components/PdfViewer.svelte.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { vi, describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
|
// pdfjs-dist is a rendering dependency — we mock it so unit tests don't need
|
||||||
|
// a real browser PDF engine. The interesting behaviour under test here is the
|
||||||
|
// component's own UI logic (controls, page counter), not pdfjs internals.
|
||||||
|
vi.mock('pdfjs-dist', () => {
|
||||||
|
function TextLayerMock() {}
|
||||||
|
TextLayerMock.prototype.render = () => Promise.resolve();
|
||||||
|
TextLayerMock.prototype.cancel = () => {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
GlobalWorkerOptions: { workerSrc: '' },
|
||||||
|
getDocument: vi.fn().mockReturnValue({
|
||||||
|
promise: Promise.resolve({
|
||||||
|
numPages: 2,
|
||||||
|
getPage: vi.fn().mockResolvedValue({
|
||||||
|
getViewport: vi.fn().mockReturnValue({ width: 595, height: 842 }),
|
||||||
|
render: vi.fn().mockReturnValue({ promise: Promise.resolve() }),
|
||||||
|
streamTextContent: vi.fn().mockReturnValue(new ReadableStream())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
TextLayer: TextLayerMock
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('pdfjs-dist/build/pdf.worker.min.mjs?url', () => ({ default: '' }));
|
||||||
|
|
||||||
|
import PdfViewer from './PdfViewer.svelte';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe('PdfViewer', () => {
|
||||||
|
it('shows previous and next page navigation buttons', async () => {
|
||||||
|
render(PdfViewer, { url: '/api/documents/test-id/file' });
|
||||||
|
await expect.element(page.getByRole('button', { name: /zurück/i })).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByRole('button', { name: /weiter/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows zoom controls', async () => {
|
||||||
|
render(PdfViewer, { url: '/api/documents/test-id/file' });
|
||||||
|
await expect.element(page.getByRole('button', { name: /vergrößern/i })).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByRole('button', { name: /verkleinern/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays the page counter once the PDF has loaded', async () => {
|
||||||
|
render(PdfViewer, { url: '/api/documents/test-id/file' });
|
||||||
|
// Mock resolves synchronously, so "1 / 2" should appear quickly
|
||||||
|
await expect.element(page.getByText(/1\s*\/\s*2/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { m } from '$lib/paraglide/messages.js';
|
|||||||
import { formatDate } from '$lib/utils/date';
|
import { formatDate } from '$lib/utils/date';
|
||||||
import { diffWords } from 'diff';
|
import { diffWords } from 'diff';
|
||||||
import ExpandableText from '$lib/components/ExpandableText.svelte';
|
import ExpandableText from '$lib/components/ExpandableText.svelte';
|
||||||
|
import PdfViewer from '$lib/components/PdfViewer.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -873,16 +874,12 @@ function versionLabel(v: VersionSummary, index: number): string {
|
|||||||
</div>
|
</div>
|
||||||
<p class="font-sans text-sm tracking-wide uppercase">{m.doc_no_scan()}</p>
|
<p class="font-sans text-sm tracking-wide uppercase">{m.doc_no_scan()}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if fileUrl && doc.originalFilename.toLowerCase().endsWith('.pdf')}
|
{:else if fileUrl && doc.contentType?.startsWith('application/pdf')}
|
||||||
<iframe
|
<PdfViewer url={fileUrl} />
|
||||||
src="{fileUrl}#zoom=page-width"
|
|
||||||
title={m.doc_preview_iframe_title()}
|
|
||||||
class="h-full w-full border-none bg-white"
|
|
||||||
></iframe>
|
|
||||||
{:else if fileUrl}
|
{:else if fileUrl}
|
||||||
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
|
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
|
||||||
<img
|
<img
|
||||||
src="{fileUrl}#zoom=page-width"
|
src={fileUrl}
|
||||||
alt={m.doc_image_alt()}
|
alt={m.doc_image_alt()}
|
||||||
class="max-h-full max-w-full object-contain shadow-2xl"
|
class="max-h-full max-w-full object-contain shadow-2xl"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { playwright } from '@vitest/browser-playwright';
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['pdfjs-dist']
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0', // Erlaubt Zugriff von außen
|
host: '0.0.0.0', // Erlaubt Zugriff von außen
|
||||||
port: 5173, // Standard SvelteKit Port
|
port: 5173, // Standard SvelteKit Port
|
||||||
|
|||||||
@@ -207,6 +207,28 @@
|
|||||||
resolved "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz"
|
resolved "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz"
|
||||||
integrity sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==
|
integrity sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==
|
||||||
|
|
||||||
|
"@napi-rs/canvas-linux-x64-gnu@0.1.97":
|
||||||
|
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==
|
||||||
|
|
||||||
|
"@napi-rs/canvas@^0.1.95":
|
||||||
|
version "0.1.97"
|
||||||
|
resolved "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz"
|
||||||
|
integrity sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==
|
||||||
|
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"
|
||||||
|
|
||||||
"@playwright/test@^1.58.2":
|
"@playwright/test@^1.58.2":
|
||||||
version "1.58.2"
|
version "1.58.2"
|
||||||
resolved "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz"
|
resolved "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz"
|
||||||
@@ -1462,6 +1484,11 @@ natural-compare@^1.4.0:
|
|||||||
resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz"
|
resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz"
|
||||||
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
|
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
|
||||||
|
|
||||||
|
node-readable-to-web-readable-stream@^0.4.2:
|
||||||
|
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==
|
||||||
|
|
||||||
obug@^2.1.0, obug@^2.1.1:
|
obug@^2.1.0, obug@^2.1.1:
|
||||||
version "2.1.1"
|
version "2.1.1"
|
||||||
resolved "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz"
|
resolved "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz"
|
||||||
@@ -1553,6 +1580,14 @@ pathe@^2.0.3:
|
|||||||
resolved "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz"
|
resolved "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz"
|
||||||
integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
|
integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
|
||||||
|
|
||||||
|
pdfjs-dist@^5.5.207:
|
||||||
|
version "5.5.207"
|
||||||
|
resolved "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz"
|
||||||
|
integrity sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==
|
||||||
|
optionalDependencies:
|
||||||
|
"@napi-rs/canvas" "^0.1.95"
|
||||||
|
node-readable-to-web-readable-stream "^0.4.2"
|
||||||
|
|
||||||
picocolors@^1.0.0, picocolors@^1.1.1:
|
picocolors@^1.0.0, picocolors@^1.1.1:
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz"
|
resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user