adds features
Build and Push Docker Image / build (push) Successful in 1m28s

This commit is contained in:
2026-08-27 07:38:35 +02:00
parent 891026ccbb
commit adaae41e44
33 changed files with 2771 additions and 396 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
run: | run: |
docker push "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:dev" docker push "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:dev"
docker push "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:${{ gitea.sha }}" docker push "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:${{ gitea.sha }}"
- name: Deploy - name: Deploy
run: | run: |
curl https://dokploy.robosoft-solutions.de/api/deploy/compose/Miw8GJXF_v4Ebavr3mJwa curl https://dokploy.robosoft-solutions.de/api/deploy/compose/Miw8GJXF_v4Ebavr3mJwa
+2
View File
@@ -7,6 +7,8 @@ node_modules
.wrangler .wrangler
/.svelte-kit /.svelte-kit
/build /build
/playwright-report
/test-results
# OS # OS
.DS_Store .DS_Store
+181 -58
View File
@@ -2,99 +2,214 @@
## Project overview ## Project overview
This repository contains **juri-merger**, a German-language SvelteKit application whose current UI is branded as **beA-Edit**. The main route lets users select or drag ZIP files in the browser. It currently displays the selected filenames; no server-side upload or archive-processing flow is present in this version. This repository contains **juri-merger**, a German-language SvelteKit application branded as
**beA-Edit**. Users add one or more ZIP archives in the browser, inspect and reorder supported
attachments, optionally group attachments into named sub-documents, and export merged PDFs.
Nested ZIP archives and XJustiz metadata are handled client-side; there is no upload, server-side
archive processing, database, analytics, or persistence of case data.
The application also contains German legal pages for the imprint and privacy policy. The application also contains German imprint and privacy-policy routes.
## Technology stack ## Technology stack
- SvelteKit 2 - SvelteKit 2 and Svelte 5, with runes mode forced for project source files
- Svelte 5 with runes mode forced for project source files - TypeScript in strict mode
- TypeScript with strict mode
- Vite 8 - Vite 8
- Tailwind CSS 4 through `@tailwindcss/vite` - Tailwind CSS 4 through `@tailwindcss/vite`
- Skeleton 4 with a custom `nominandum` theme - shadcn-svelte 1 with the `nova` style and CSS-variable theme tokens
- pnpm and `pnpm-lock.yaml` - bits-ui, tailwind-variants, clsx, and tailwind-merge
- `@sveltejs/adapter-auto` - Lucide Svelte icons plus project-specific SVG icon components
- Prettier with the Svelte and Tailwind plugins - fflate for ZIP extraction and image-export ZIP creation
- pdf-lib for PDF assembly, `@cantoo/pdf-lib` for encryption, and pdfjs-dist for rendering
- `@sveltejs/adapter-node`
- pnpm with a committed lockfile
- Prettier with Svelte and Tailwind plugins
Use **pnpm**, not npm or yarn, when installing packages or running project scripts. Use **pnpm**, not npm or yarn. The production container uses Node 24 and pnpm 11.15.1.
## Commands ## Commands
```sh ```sh
pnpm install # Install dependencies pnpm install # Install dependencies
pnpm dev # Start the development server pnpm dev # Start the development server
pnpm build # Create a production build pnpm build # Create the adapter-node production build
pnpm preview # Preview the production build pnpm preview # Preview the production build
pnpm check # Run Svelte and TypeScript diagnostics pnpm check # Run Svelte and TypeScript diagnostics
pnpm check:watch # Run diagnostics in watch mode pnpm check:watch # Run diagnostics in watch mode
pnpm lint # Check formatting with Prettier pnpm test:e2e # Run the Playwright toolbox tests in Chromium
pnpm lint # Check repository formatting with Prettier
pnpm format # Format the repository with Prettier pnpm format # Format the repository with Prettier
``` ```
There is currently no automated unit or end-to-end test suite. For code changes, run at least: For code changes, run at least:
```sh ```sh
pnpm check pnpm check
pnpm test:e2e
pnpm lint pnpm lint
pnpm build pnpm build
``` ```
If pre-existing warnings remain outside the files being changed, report them rather than making unrelated edits. For documentation-only changes, a targeted Prettier check is sufficient. If pre-existing warnings
or formatting failures remain outside changed files, report them instead of making unrelated edits.
The Docker image can be built from the repository root. It installs with `--frozen-lockfile`, builds
and prunes dependencies, then runs `node build/index.js` as a non-root user on port 3000.
## Repository structure ## Repository structure
```text ```text
src/ src/
├── app.html # German document shell and Skeleton theme selection ├── app.html # German HTML shell
├── app.d.ts # SvelteKit application type declarations ├── app.d.ts # SvelteKit application declarations
├── lib/ ├── lib/
│ ├── assets/ # Bundled favicon and Nunito font files │ ├── actions/ripple.ts # Button ripple Svelte action
│ ├── components/ # Reusable Svelte components │ ├── assets/ # Favicon and local Nunito fonts
│ ├── Button.svelte │ ├── components/
│ │ ├── Card.svelte │ │ ├── AttachmentPreview.svelte # Browser-rendered PDF/image thumbnails
│ │ ── ZipDropzone.svelte │ │ ── ProcessedZipArchiveEditor.svelte
├── index.ts # Public `$lib` exports │ ├── SubDocumentEditor.svelte
└── nom-theme.css # Custom Skeleton theme tokens │ ├── PdfDropzone.svelte # Shared PDF/image/ZIP file picker and drop target
│ │ ├── PdfPageGrid.svelte # Accessible PDF thumbnail selection grid
│ │ ├── ZipDropzone.svelte # beA-specific wrapper around PdfDropzone
│ │ ├── icons/ # Project-specific SVG components
│ │ └── ui/ # Local shadcn-svelte component source
│ ├── services/
│ │ ├── xml-reading.service.ts # Namespace-tolerant XJustiz parsing
│ │ └── zip-inflating.service.ts # Async fflate wrapper
│ ├── pdf-processing.ts # Reusable PDF merge helper
│ ├── pdf-thumbnails.ts # Managed pdfjs thumbnail loading and cleanup
│ ├── utils.ts # cn() and shared component utility types
│ └── zip-processing.ts # Archive model, transforms, and PDF export
└── routes/ └── routes/
├── +layout.svelte # Shared logo header and branded footer ├── +layout.svelte # Shared logo header and branded footer
├── +page.svelte # Main ZIP-selection page ├── +page.svelte # Toolbox hub and global beA ZIP drop target
├── layout.css # Tailwind/Skeleton imports and global font setup ├── layout.css # Tailwind, shadcn, theme, fonts, global CSS
├── datenschutz/+page.svelte # Privacy policy ├── tools/
└── impressum/+page.svelte # Imprint │ ├── +layout.svelte # Shared back-navigation shell
static/ # Files served unchanged from the site root │ ├── bea/+page.svelte # Existing ZIP archive workflow
│ └── {merge,separate,watermark,encrypt,decrypt,compress,convert}/
├── datenschutz/+page.svelte # Privacy policy
└── impressum/+page.svelte # Imprint
static/ # Public logo, footer art, and robots.txt
components.json # shadcn-svelte registry configuration
Dockerfile # Node adapter production image
plan/ # Design and implementation notes
``` ```
Generated directories such as `.svelte-kit/`, `build/`, and `node_modules/` must not be edited or committed. Generated directories such as `.svelte-kit/`, `build/`, and `node_modules/` must not be edited or
committed.
## Application flow and state
- `/` is the toolbox hub. A ZIP dropped anywhere on that route is queued in memory and forwarded to
`/tools/bea`; the file is not uploaded or persisted.
- `tools/bea/+page.svelte` owns selected files, processed archives, pending work, and the
thumbnail-width preference. Thumbnail width is the only persisted UI setting and uses localStorage.
- ZIP files are processed concurrently. `archiveGeneration` prevents results from an old batch from
reappearing after the user deletes all archives.
- `ProcessedZipArchiveEditor.svelte` edits one archive and emits immutable replacements through
`onArchiveChange`; the route-level archive array remains the source of truth.
- Attachments may remain loose or belong to exactly one sub-document. Drag-and-drop supports
reordering and moving attachments between lists, while sub-document headers can be reordered.
- Selection mode creates sub-documents from loose attachments. Exiting with **Fertig** or Escape
clears the pending selection.
- Per-archive export supports one flattened PDF or separate PDFs for sub-documents. Each
sub-document also has its own direct download action that exports only that sub-document's
attachments. Bulk export from the page always creates one flattened PDF per archive.
When changing archive transforms, preserve these invariants:
- An attachment move must not duplicate or discard data.
- Sub-document IDs remain stable across edits and reordering.
- Single-PDF order is all sub-documents in order, each attachment in order, followed by loose
attachments in order.
- Dissolving a sub-document appends its attachments to the loose list.
- State helpers in `zip-processing.ts` should return new archive/list objects rather than mutating
component-owned state.
## ZIP, XML, and PDF processing
All case-file processing is intentionally local to the browser.
- `extractZipArchives` recursively inflates nested ZIP files.
- Ignore macOS metadata (`__MACOSX`, `.DS_Store`, and `._*`) and unsupported entries.
- `xjustiz_nachricht.xml` is metadata, not an export attachment. Its sender, receiver, and declared
document names determine the generated PDF name and preferred attachment order.
- XJustiz element matching must remain namespace tolerant. Malformed or absent metadata falls back
safely instead of preventing extraction.
- Supported attachments are PDFs and the image extensions listed in `zip-processing.ts`. Update the
extension classification and downstream preview/export behavior together when adding formats.
- PDF attachments are copied with pdf-lib. JPEG and PNG files can be embedded directly; other image
formats are decoded through browser image/canvas APIs and converted to PNG first.
- `AttachmentPreview.svelte` dynamically loads pdfjs-dist in `onMount`, copies PDF bytes before
rendering, and revokes object URLs during cleanup. Preserve those memory and data-integrity
safeguards.
- Empty archives/export groups should continue to fail explicitly rather than creating invalid PDFs.
Browser-only APIs used by this flow include `File`, `DOMParser`, `Image`, canvas, `Blob`, `URL`,
localStorage, and `crypto.randomUUID()`. Do not invoke browser-only work during SSR or at module
initialization. Keep it in event handlers, `onMount`, effects guarded by hydration, or functions only
called in the browser.
## Svelte conventions ## Svelte conventions
- Use Svelte 5 runes and current event syntax. Prefer `$props()`, `$state`, `$derived`, snippets, `{@render ...}`, and handlers such as `onclick` over legacy APIs. - Use Svelte 5 runes and current event syntax: `$props()`, `$state`, `$derived`, `$effect`, snippets,
`{@render ...}`, and handlers such as `onclick` rather than legacy APIs.
- Keep component scripts typed with `<script lang="ts">`. - Keep component scripts typed with `<script lang="ts">`.
- Define a local `Props` type for non-trivial component props. - Define a local `Props` type for non-trivial component props.
- Use callback props for child-to-parent communication, as `ZipDropzone.svelte` does with `onFilesSelected`. - Use callback props for child-to-parent updates; do not mutate prop objects in place.
- Use `$lib/...` for imports from `src/lib` rather than long relative paths. - Use `$lib/...` aliases for application imports instead of long relative paths.
- Name reusable component files in PascalCase and route files according to SvelteKit conventions. - Name reusable components in PascalCase and route files according to SvelteKit conventions.
- Keep browser-only APIs such as `File`, `FileList`, and drag-and-drop handling in components that execute in the browser. Do not introduce server-side upload behavior unless requested. - Preserve semantic HTML, accessible names, visible focus states, and keyboard alternatives. Do not
- Preserve semantic HTML. Interactive drop zones must remain keyboard-operable buttons with an accessible name. make drag-and-drop the only way to perform a critical action.
- Clean up global listeners, object URLs, PDF loading tasks, and other browser resources.
The Vite configuration forces runes mode for application files, so do not add legacy Svelte component patterns. The Vite configuration forces runes mode for project files, so do not introduce legacy Svelte
component patterns.
## shadcn-svelte and component conventions
`components.json` configures the official shadcn-svelte registry, the `nova` style, Lucide icons, and
these aliases:
- UI components: `$lib/components/ui`
- General components: `$lib/components`
- Utilities: `$lib/utils`
Use existing local UI components before creating replacements. Follow the exports actually present
on disk: the card is composed through `import * as Card from '$lib/components/ui/card'`, while the
current button is a default import from `$lib/components/ui/button.svelte`.
When adding a registry component, use the project runner, inspect every generated file, and preserve
local customizations:
```sh
pnpm dlx shadcn-svelte@latest add <component>
```
Do not overwrite or bulk-update customized components without explicit approval. Use `cn()` for
conditional class merging and existing variants before adding one-off component styling.
## Styling conventions ## Styling conventions
Use **Tailwind utility classes in Svelte markup** for component and route styling. Orient new work around the existing classes in `+layout.svelte`, `+page.svelte`, `Card.svelte`, and `ZipDropzone.svelte`. Use Tailwind utilities in Svelte markup and semantic theme tokens from `src/routes/layout.css`.
- Prefer existing theme utilities such as `bg-primary-50`, `text-primary-900`, `border-primary-500`, and `rounded-container`. - Prefer `bg-background`, `text-foreground`, `bg-primary`, `text-muted-foreground`, `border-border`,
- Use the custom `primary`, `secondary`, `tertiary`, and `surface` scales defined in `src/lib/nom-theme.css` instead of adding arbitrary brand colors. and other configured semantic utilities over raw brand colors.
- Keep the established visual language: cool blue-gray page background, white cards, dark blue headings, subtle shadows, rounded containers, and the Nunito typeface. - Preserve the established Nominandum visual language: pale blue-gray background, white cards, dark
- Use responsive Tailwind variants and ensure layouts work on narrow screens. blue primary text, red/coral secondary actions, rounded containers, and Nunito typography.
- Prefer utilities over new component-level `<style>` blocks or inline styles. Add global CSS only when defining shared theme primitives, imports, font faces, or behavior that cannot reasonably be expressed with utilities. - Use `flex`/`grid` with `gap-*`, responsive variants, and `size-*` when width and height are equal.
- Tailwind 4 is configured through Vite and `src/routes/layout.css`; there is no `tailwind.config` file. - Use `cn()` rather than constructing partial Tailwind class names. Keep complete class names visible
- Keep complete utility class names visible to Tailwind. For variants, map each state to complete class strings rather than constructing fragments such as `text-${color}`. to Tailwind's scanner.
- Preserve clear focus states, adequate contrast, and visible drag-and-drop feedback. - Prefer component variants and theme variables over overriding UI-component colors in call sites.
- Add global CSS only for imports, tokens, font faces, animations, or behavior that utilities cannot
reasonably express.
- Tailwind 4 is configured through Vite and `layout.css`; there is no `tailwind.config` file.
- Maintain contrast, responsive layouts, drag-state feedback, and visible focus treatment.
`src/app.html` applies `data-theme="nominandum"`. Theme-level changes belong in `src/lib/nom-theme.css`; global Tailwind and Skeleton imports belong in `src/routes/layout.css`. Theme-level changes belong in `src/routes/layout.css`. Do not reintroduce the removed Skeleton theme
or refer to the old `nom-theme.css` architecture.
## Formatting ## Formatting
@@ -106,21 +221,29 @@ Follow the checked-in Prettier configuration:
- 100-character print width - 100-character print width
- Svelte-aware and Tailwind-aware formatting - Svelte-aware and Tailwind-aware formatting
Use `pnpm exec prettier --write <changed-files>` for targeted formatting. Avoid formatting unrelated files solely to clean up existing differences. Use `pnpm exec prettier --write <changed-files>` for targeted formatting. Avoid formatting unrelated
files solely to clean up existing differences.
## Product and content constraints ## Product and content constraints
- User-facing copy is German unless a task explicitly requests another language. - User-facing copy is German unless a task explicitly requests another language.
- Preserve legal text in `datenschutz/+page.svelte` and `impressum/+page.svelte` unless the request explicitly asks for content changes. - Preserve legal text in `datenschutz/+page.svelte` and `impressum/+page.svelte` unless explicitly
- Keep the shared Nominandum logo, footer artwork, and theme consistent across routes. asked to change it.
- ZIP selection is client-side and accepts `.zip`, `application/zip`, and `application/x-zip-compressed` files. - Keep the Nominandum logo, footer artwork, and theme consistent across routes.
- Resetting the file input after selection is intentional so the same archive can be selected twice. - ZIP selection accepts multiple files matching `.zip`, `application/zip`, or
- Do not add network requests, file uploads, analytics, or persistence without an explicit requirement. `application/x-zip-compressed`.
- Resetting the file input after selection is intentional so the same archive can be selected again.
- Do not add uploads, network requests, server processing, analytics, or case-data persistence without
an explicit requirement and privacy review.
## Change discipline ## Change discipline
- Keep changes scoped to the request and preserve unrelated work, including untracked files. - Keep changes scoped and preserve unrelated work, including untracked files.
- Inspect existing components and theme tokens before introducing new abstractions. - Inspect existing components, archive helpers, and theme tokens before adding abstractions.
- Do not hand-edit generated output or lockfile contents. Update the lockfile only through pnpm when dependency changes are required. - Keep browser processing behavior and archive-order invariants covered when editing shared helpers.
- Avoid adding dependencies when the task can be completed with Svelte, browser APIs, Tailwind, or existing Skeleton packages. - Do not hand-edit generated output or lockfile contents. Update the lockfile only through pnpm when
- After implementation, review the changed files, run the relevant checks, and mention any remaining warnings or limitations. dependency changes are required.
- Avoid dependencies when the task can be completed with Svelte, browser APIs, Tailwind, or existing
packages.
- After implementation, review changed files, run relevant checks, and mention remaining warnings or
limitations.
+4
View File
@@ -10,18 +10,21 @@
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test:e2e": "playwright test",
"lint": "prettier --check .", "lint": "prettier --check .",
"format": "prettier --write ." "format": "prettier --write ."
}, },
"devDependencies": { "devDependencies": {
"@fontsource-variable/geist": "^5.2.9", "@fontsource-variable/geist": "^5.2.9",
"@lucide/svelte": "^1.18.0", "@lucide/svelte": "^1.18.0",
"@playwright/test": "^1.62.1",
"@sveltejs/adapter-node": "^5.5.7", "@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.63.0", "@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"@types/node": "^26.3.0",
"bits-ui": "2.18.1", "bits-ui": "2.18.1",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
@@ -39,6 +42,7 @@
"vite": "^8.0.16" "vite": "^8.0.16"
}, },
"dependencies": { "dependencies": {
"@cantoo/pdf-lib": "^2.9.1",
"fflate": "^0.8.3", "fflate": "^0.8.3",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdfjs-dist": "^6.0.227" "pdfjs-dist": "^6.0.227"
+30
View File
@@ -0,0 +1,30 @@
/// <reference types="node" />
import { defineConfig } from '@playwright/test';
import { existsSync } from 'node:fs';
const chromiumExecutable =
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ??
(existsSync('/usr/bin/chromium') ? '/usr/bin/chromium' : undefined);
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: false,
retries: 0,
reporter: 'line',
use: {
baseURL: 'http://127.0.0.1:4174',
trace: 'retain-on-failure',
video: 'retain-on-failure',
launchOptions: {
executablePath: chromiumExecutable,
args: ['--no-sandbox']
}
},
webServer: {
command: 'pnpm dev --host 127.0.0.1 --port 4174 --strictPort',
url: 'http://127.0.0.1:4174',
reuseExistingServer: true,
timeout: 120_000
}
});
+157 -26
View File
@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@cantoo/pdf-lib':
specifier: ^2.9.1
version: 2.9.1(html-entities@2.6.0)
fflate: fflate:
specifier: ^0.8.3 specifier: ^0.8.3
version: 0.8.3 version: 0.8.3
@@ -24,15 +27,18 @@ importers:
'@lucide/svelte': '@lucide/svelte':
specifier: ^1.18.0 specifier: ^1.18.0
version: 1.18.0(svelte@5.56.3) version: 1.18.0(svelte@5.56.3)
'@playwright/test':
specifier: ^1.62.1
version: 1.62.1
'@sveltejs/adapter-node': '@sveltejs/adapter-node':
specifier: ^5.5.7 specifier: ^5.5.7
version: 5.5.7(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0))) version: 5.5.7(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))
'@sveltejs/kit': '@sveltejs/kit':
specifier: ^2.63.0 specifier: ^2.63.0
version: 2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0)) version: 2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0))
'@sveltejs/vite-plugin-svelte': '@sveltejs/vite-plugin-svelte':
specifier: ^7.1.2 specifier: ^7.1.2
version: 7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)) version: 7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0))
'@tailwindcss/forms': '@tailwindcss/forms':
specifier: ^0.5.11 specifier: ^0.5.11
version: 0.5.11(tailwindcss@4.3.0) version: 0.5.11(tailwindcss@4.3.0)
@@ -41,10 +47,13 @@ importers:
version: 0.5.20(tailwindcss@4.3.0) version: 0.5.20(tailwindcss@4.3.0)
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.3.0 specifier: ^4.3.0
version: 4.3.0(vite@8.0.16(jiti@2.7.0)) version: 4.3.0(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0))
'@types/node':
specifier: ^26.3.0
version: 26.3.0
bits-ui: bits-ui:
specifier: 2.18.1 specifier: 2.18.1
version: 2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3) version: 2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)
class-variance-authority: class-variance-authority:
specifier: 0.7.1 specifier: 0.7.1
version: 0.7.1 version: 0.7.1
@@ -86,10 +95,15 @@ importers:
version: 6.0.3 version: 6.0.3
vite: vite:
specifier: ^8.0.16 specifier: ^8.0.16
version: 8.0.16(jiti@2.7.0) version: 8.0.16(@types/node@26.3.0)(jiti@2.7.0)
packages: packages:
'@cantoo/pdf-lib@2.9.1':
resolution: {integrity: sha512-wAKXXDjp5lO0wS6d+k0FRpmWsULb95WhHKYx2fonTbv4zIfuIdCBz2nyTVVDGJpNDLl6o4w9aSTxQisBvvd60A==}
peerDependencies:
html-entities: ^2.3.2
'@emnapi/core@1.10.0': '@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@@ -225,6 +239,11 @@ packages:
'@pdf-lib/upng@1.0.1': '@pdf-lib/upng@1.0.1':
resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==} resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==}
'@playwright/test@1.62.1':
resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==}
engines: {node: '>=20'}
hasBin: true
'@polka/url@1.0.0-next.29': '@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -665,6 +684,9 @@ packages:
'@types/estree@1.0.9': '@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/node@26.3.0':
resolution: {integrity: sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==}
'@types/resolve@1.20.2': '@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
@@ -702,6 +724,20 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'} engines: {node: '>=6'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-string@1.9.1:
resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
color@4.2.3:
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
engines: {node: '>=12.5.0'}
commander@14.0.3: commander@14.0.3:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -767,6 +803,11 @@ packages:
fflate@0.8.3: fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -782,9 +823,15 @@ packages:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
html-entities@2.6.0:
resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==}
inline-style-parser@0.2.7: inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
is-arrayish@0.3.4:
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
is-core-module@2.16.2: is-core-module@2.16.2:
resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -910,6 +957,9 @@ packages:
node-fetch-native@1.6.7: node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
node-html-better-parser@1.5.9:
resolution: {integrity: sha512-z1I5UINMezJXYL9cH3h0a9KBth2G978gSLlfkpQ+CQzzVHVQy9gpARgm9eDsz1O4gn1HtgUqjdAIYxKFZm6uHQ==}
obug@2.1.2: obug@2.1.2:
resolution: {integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==} resolution: {integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==}
engines: {node: '>=12.20.0'} engines: {node: '>=12.20.0'}
@@ -917,6 +967,9 @@ packages:
pako@1.0.11: pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
pako@2.2.0:
resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==}
path-parse@1.0.7: path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
@@ -934,6 +987,16 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'} engines: {node: '>=12'}
playwright-core@1.62.1:
resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==}
engines: {node: '>=20'}
hasBin: true
playwright@1.62.1:
resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==}
engines: {node: '>=20'}
hasBin: true
postcss-selector-parser@6.0.10: postcss-selector-parser@6.0.10:
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -1050,6 +1113,9 @@ packages:
peerDependencies: peerDependencies:
svelte: ^5.0.0 svelte: ^5.0.0
simple-swizzle@0.2.4:
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
sirv@3.0.2: sirv@3.0.2:
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1128,6 +1194,9 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -1187,6 +1256,16 @@ packages:
snapshots: snapshots:
'@cantoo/pdf-lib@2.9.1(html-entities@2.6.0)':
dependencies:
'@pdf-lib/standard-fonts': 1.0.0
'@pdf-lib/upng': 1.0.1
color: 4.2.3
html-entities: 2.6.0
node-html-better-parser: 1.5.9
pako: 2.2.0
tslib: 2.8.1
'@emnapi/core@1.10.0': '@emnapi/core@1.10.0':
dependencies: dependencies:
'@emnapi/wasi-threads': 1.2.1 '@emnapi/wasi-threads': 1.2.1
@@ -1308,6 +1387,10 @@ snapshots:
dependencies: dependencies:
pako: 1.0.11 pako: 1.0.11
'@playwright/test@1.62.1':
dependencies:
playwright: 1.62.1
'@polka/url@1.0.0-next.29': {} '@polka/url@1.0.0-next.29': {}
'@rolldown/binding-android-arm64@1.0.3': '@rolldown/binding-android-arm64@1.0.3':
@@ -1485,20 +1568,20 @@ snapshots:
dependencies: dependencies:
acorn: 8.16.0 acorn: 8.16.0
'@sveltejs/adapter-node@5.5.7(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0)))': '@sveltejs/adapter-node@5.5.7(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))':
dependencies: dependencies:
'@rollup/plugin-commonjs': 29.0.3(rollup@4.62.2) '@rollup/plugin-commonjs': 29.0.3(rollup@4.62.2)
'@rollup/plugin-json': 6.1.0(rollup@4.62.2) '@rollup/plugin-json': 6.1.0(rollup@4.62.2)
'@rollup/plugin-node-resolve': 16.0.3(rollup@4.62.2) '@rollup/plugin-node-resolve': 16.0.3(rollup@4.62.2)
'@rollup/plugin-replace': 6.0.3(rollup@4.62.2) '@rollup/plugin-replace': 6.0.3(rollup@4.62.2)
'@sveltejs/kit': 2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0)) '@sveltejs/kit': 2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0))
rollup: 4.62.2 rollup: 4.62.2
'@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0))': '@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0))':
dependencies: dependencies:
'@standard-schema/spec': 1.1.0 '@standard-schema/spec': 1.1.0
'@sveltejs/acorn-typescript': 1.0.10(acorn@8.16.0) '@sveltejs/acorn-typescript': 1.0.10(acorn@8.16.0)
'@sveltejs/vite-plugin-svelte': 7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)) '@sveltejs/vite-plugin-svelte': 7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0))
'@types/cookie': 0.6.0 '@types/cookie': 0.6.0
acorn: 8.16.0 acorn: 8.16.0
cookie: 0.6.0 cookie: 0.6.0
@@ -1510,20 +1593,20 @@ snapshots:
set-cookie-parser: 3.1.0 set-cookie-parser: 3.1.0
sirv: 3.0.2 sirv: 3.0.2
svelte: 5.56.3 svelte: 5.56.3
vite: 8.0.16(jiti@2.7.0) vite: 8.0.16(@types/node@26.3.0)(jiti@2.7.0)
optionalDependencies: optionalDependencies:
typescript: 6.0.3 typescript: 6.0.3
'@sveltejs/load-config@0.1.1': {} '@sveltejs/load-config@0.1.1': {}
'@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0))': '@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0))':
dependencies: dependencies:
deepmerge: 4.3.1 deepmerge: 4.3.1
magic-string: 0.30.21 magic-string: 0.30.21
obug: 2.1.2 obug: 2.1.2
svelte: 5.56.3 svelte: 5.56.3
vite: 8.0.16(jiti@2.7.0) vite: 8.0.16(@types/node@26.3.0)(jiti@2.7.0)
vitefu: 1.1.3(vite@8.0.16(jiti@2.7.0)) vitefu: 1.1.3(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0))
'@swc/helpers@0.5.23': '@swc/helpers@0.5.23':
dependencies: dependencies:
@@ -1600,12 +1683,12 @@ snapshots:
postcss-selector-parser: 6.0.10 postcss-selector-parser: 6.0.10
tailwindcss: 4.3.0 tailwindcss: 4.3.0
'@tailwindcss/vite@4.3.0(vite@8.0.16(jiti@2.7.0))': '@tailwindcss/vite@4.3.0(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0))':
dependencies: dependencies:
'@tailwindcss/node': 4.3.0 '@tailwindcss/node': 4.3.0
'@tailwindcss/oxide': 4.3.0 '@tailwindcss/oxide': 4.3.0
tailwindcss: 4.3.0 tailwindcss: 4.3.0
vite: 8.0.16(jiti@2.7.0) vite: 8.0.16(@types/node@26.3.0)(jiti@2.7.0)
'@tybys/wasm-util@0.10.2': '@tybys/wasm-util@0.10.2':
dependencies: dependencies:
@@ -1616,6 +1699,10 @@ snapshots:
'@types/estree@1.0.9': {} '@types/estree@1.0.9': {}
'@types/node@26.3.0':
dependencies:
undici-types: 8.3.0
'@types/resolve@1.20.2': {} '@types/resolve@1.20.2': {}
'@types/trusted-types@2.0.7': {} '@types/trusted-types@2.0.7': {}
@@ -1626,15 +1713,15 @@ snapshots:
axobject-query@4.1.0: {} axobject-query@4.1.0: {}
bits-ui@2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3): bits-ui@2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3):
dependencies: dependencies:
'@floating-ui/core': 1.7.5 '@floating-ui/core': 1.7.5
'@floating-ui/dom': 1.7.6 '@floating-ui/dom': 1.7.6
'@internationalized/date': 3.12.2 '@internationalized/date': 3.12.2
esm-env: 1.2.2 esm-env: 1.2.2
runed: 0.35.1(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3) runed: 0.35.1(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)
svelte: 5.56.3 svelte: 5.56.3
svelte-toolbelt: 0.10.6(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3) svelte-toolbelt: 0.10.6(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)
tabbable: 6.4.0 tabbable: 6.4.0
transitivePeerDependencies: transitivePeerDependencies:
- '@sveltejs/kit' - '@sveltejs/kit'
@@ -1649,6 +1736,22 @@ snapshots:
clsx@2.1.1: {} clsx@2.1.1: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
color-string@1.9.1:
dependencies:
color-name: 1.1.4
simple-swizzle: 0.2.4
color@4.2.3:
dependencies:
color-convert: 2.0.1
color-string: 1.9.1
commander@14.0.3: {} commander@14.0.3: {}
commondir@1.0.1: {} commondir@1.0.1: {}
@@ -1686,6 +1789,9 @@ snapshots:
fflate@0.8.3: {} fflate@0.8.3: {}
fsevents@2.3.2:
optional: true
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
@@ -1697,8 +1803,12 @@ snapshots:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
html-entities@2.6.0: {}
inline-style-parser@0.2.7: {} inline-style-parser@0.2.7: {}
is-arrayish@0.3.4: {}
is-core-module@2.16.2: is-core-module@2.16.2:
dependencies: dependencies:
hasown: 2.0.4 hasown: 2.0.4
@@ -1784,10 +1894,16 @@ snapshots:
node-fetch-native@1.6.7: {} node-fetch-native@1.6.7: {}
node-html-better-parser@1.5.9:
dependencies:
html-entities: 2.6.0
obug@2.1.2: {} obug@2.1.2: {}
pako@1.0.11: {} pako@1.0.11: {}
pako@2.2.0: {}
path-parse@1.0.7: {} path-parse@1.0.7: {}
pdf-lib@1.17.1: pdf-lib@1.17.1:
@@ -1805,6 +1921,14 @@ snapshots:
picomatch@4.0.4: {} picomatch@4.0.4: {}
playwright-core@1.62.1: {}
playwright@1.62.1:
dependencies:
playwright-core: 1.62.1
optionalDependencies:
fsevents: 2.3.2
postcss-selector-parser@6.0.10: postcss-selector-parser@6.0.10:
dependencies: dependencies:
cssesc: 3.0.0 cssesc: 3.0.0
@@ -1890,14 +2014,14 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.62.2 '@rollup/rollup-win32-x64-msvc': 4.62.2
fsevents: 2.3.3 fsevents: 2.3.3
runed@0.35.1(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3): runed@0.35.1(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3):
dependencies: dependencies:
dequal: 2.0.3 dequal: 2.0.3
esm-env: 1.2.2 esm-env: 1.2.2
lz-string: 1.5.0 lz-string: 1.5.0
svelte: 5.56.3 svelte: 5.56.3
optionalDependencies: optionalDependencies:
'@sveltejs/kit': 2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0)) '@sveltejs/kit': 2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0))
sade@1.8.1: sade@1.8.1:
dependencies: dependencies:
@@ -1913,6 +2037,10 @@ snapshots:
svelte: 5.56.3 svelte: 5.56.3
tailwind-merge: 3.6.0 tailwind-merge: 3.6.0
simple-swizzle@0.2.4:
dependencies:
is-arrayish: 0.3.4
sirv@3.0.2: sirv@3.0.2:
dependencies: dependencies:
'@polka/url': 1.0.0-next.29 '@polka/url': 1.0.0-next.29
@@ -1940,10 +2068,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- picomatch - picomatch
svelte-toolbelt@0.10.6(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3): svelte-toolbelt@0.10.6(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3):
dependencies: dependencies:
clsx: 2.1.1 clsx: 2.1.1
runed: 0.35.1(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(jiti@2.7.0)))(svelte@5.56.3) runed: 0.35.1(@sveltejs/kit@2.64.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)(typescript@6.0.3)(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)))(svelte@5.56.3)
style-to-object: 1.0.14 style-to-object: 1.0.14
svelte: 5.56.3 svelte: 5.56.3
transitivePeerDependencies: transitivePeerDependencies:
@@ -1999,9 +2127,11 @@ snapshots:
typescript@6.0.3: {} typescript@6.0.3: {}
undici-types@8.3.0: {}
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
vite@8.0.16(jiti@2.7.0): vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0):
dependencies: dependencies:
lightningcss: 1.32.0 lightningcss: 1.32.0
picomatch: 4.0.4 picomatch: 4.0.4
@@ -2009,11 +2139,12 @@ snapshots:
rolldown: 1.0.3 rolldown: 1.0.3
tinyglobby: 0.2.17 tinyglobby: 0.2.17
optionalDependencies: optionalDependencies:
'@types/node': 26.3.0
fsevents: 2.3.3 fsevents: 2.3.3
jiti: 2.7.0 jiti: 2.7.0
vitefu@1.1.3(vite@8.0.16(jiti@2.7.0)): vitefu@1.1.3(vite@8.0.16(@types/node@26.3.0)(jiti@2.7.0)):
optionalDependencies: optionalDependencies:
vite: 8.0.16(jiti@2.7.0) vite: 8.0.16(@types/node@26.3.0)(jiti@2.7.0)
zimmerframe@1.1.4: {} zimmerframe@1.1.4: {}
+21 -22
View File
@@ -1,31 +1,30 @@
export function ripple(node: HTMLElement) { export function ripple(node: HTMLElement) {
function handleClick(event: MouseEvent) { function handleClick(event: MouseEvent) {
const circle = document.createElement("span"); const circle = document.createElement('span');
const rect = node.getBoundingClientRect(); const rect = node.getBoundingClientRect();
const size = Math.max(rect.width, rect.height); const size = Math.max(rect.width, rect.height);
circle.style.width = `${size}px`; circle.style.width = `${size}px`;
circle.style.height = `${size}px`; circle.style.height = `${size}px`;
circle.style.left = `${event.clientX - rect.left - size / 2}px`; circle.style.left = `${event.clientX - rect.left - size / 2}px`;
circle.style.top = `${event.clientY - rect.top - size / 2}px`; circle.style.top = `${event.clientY - rect.top - size / 2}px`;
circle.className = circle.className = 'absolute rounded-full bg-white/30 animate-ripple pointer-events-none';
"absolute rounded-full bg-white/30 animate-ripple pointer-events-none";
node.appendChild(circle); node.appendChild(circle);
circle.addEventListener("animationend", () => { circle.addEventListener('animationend', () => {
circle.remove(); circle.remove();
}); });
} }
node.addEventListener("click", handleClick); node.addEventListener('click', handleClick);
return { return {
destroy() { destroy() {
node.removeEventListener("click", handleClick); node.removeEventListener('click', handleClick);
} }
}; };
} }
+124
View File
@@ -0,0 +1,124 @@
<script lang="ts">
type Props = {
onFilesSelected: (files: File[]) => void;
onFilesRejected?: (files: File[]) => void;
accept?: string;
multiple?: boolean;
ariaLabel?: string;
label?: string;
sublabel?: string;
class?: string;
};
let {
onFilesSelected,
onFilesRejected,
accept = '.pdf,application/pdf',
multiple = false,
ariaLabel = 'PDF-Dateien hinzufügen',
label = 'Dateien hinzufügen',
sublabel = 'PDF hier ablegen oder zum Auswählen klicken',
class: className = ''
}: Props = $props();
let fileInput = $state<HTMLInputElement | null>(null);
let isDragging = $state(false);
let rejectionMessage = $state<string | null>(null);
let dragDepth = 0;
const matchesAccept = (file: File) =>
accept
.split(',')
.map((value) => value.trim().toLowerCase())
.filter(Boolean)
.some((filter) => {
if (filter.startsWith('.')) return file.name.toLowerCase().endsWith(filter);
if (filter.endsWith('/*')) return file.type.toLowerCase().startsWith(filter.slice(0, -1));
return file.type.toLowerCase() === filter;
});
const processFiles = (files: FileList | null | undefined) => {
if (!files) return;
const received = Array.from(files);
const accepted = received.filter(matchesAccept);
const rejected = received.filter((file) => !matchesAccept(file));
if (rejected.length > 0) {
rejectionMessage = `${rejected.map((file) => file.name).join(', ')}: Dateityp nicht unterstützt.`;
onFilesRejected?.(rejected);
}
if (accepted.length > 0) {
rejectionMessage = null;
onFilesSelected(multiple ? accepted : accepted.slice(0, 1));
}
};
const openFileDialog = () => fileInput?.click();
const handleFileSelection = (event: Event) => {
const input = event.currentTarget as HTMLInputElement;
processFiles(input.files);
input.value = '';
};
const handleDragEnter = (event: DragEvent) => {
event.preventDefault();
dragDepth += 1;
isDragging = true;
};
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
};
const handleDragLeave = (event: DragEvent) => {
event.preventDefault();
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) isDragging = false;
};
const handleDrop = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
dragDepth = 0;
isDragging = false;
processFiles(event.dataTransfer?.files);
};
</script>
<input
bind:this={fileInput}
class="hidden"
type="file"
{accept}
{multiple}
onchange={handleFileSelection}
/>
<button
type="button"
class="flex w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-primary-200 bg-primary-50/50 px-6 py-10 text-center transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 {isDragging
? 'border-primary-400 bg-primary-100/60'
: ''} {className}"
aria-label={ariaLabel}
data-dropzone-ready={fileInput ? 'true' : 'false'}
onclick={openFileDialog}
ondragenter={handleDragEnter}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
>
<div class="flex flex-row items-baseline justify-center gap-2">
<span class="text-[18px] font-bold text-primary-900">{label}</span>
<span class="text-[20px] font-extrabold text-primary">+</span>
</div>
{#if sublabel}
<p class="text-[13px] text-primary-700">{sublabel}</p>
{/if}
</button>
{#if rejectionMessage}
<p class="mt-2 text-sm font-medium text-red-700" role="alert">{rejectionMessage}</p>
{/if}
+54
View File
@@ -0,0 +1,54 @@
<script lang="ts">
import type { PdfThumbnail } from '$lib/pdf-thumbnails';
import { cn } from '$lib/utils';
type Props = {
thumbnails: PdfThumbnail[];
selectedPages?: Set<number>;
onPageClick?: (pageNumber: number) => void;
thumbnailWidth?: number;
class?: string;
};
let {
thumbnails,
selectedPages = new Set<number>(),
onPageClick,
thumbnailWidth = 120,
class: className = ''
}: Props = $props();
</script>
<div
class={cn('grid gap-3', className)}
style="grid-template-columns: repeat(auto-fill, minmax({thumbnailWidth}px, 1fr));"
>
{#each thumbnails as thumb}
{@const isSelected = selectedPages.has(thumb.pageNumber)}
<button
type="button"
class={cn(
'group relative flex flex-col items-center gap-2 rounded-xl border-2 p-2 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400',
isSelected
? 'border-primary bg-primary/5'
: 'border-transparent hover:border-primary-200 hover:bg-primary-50/50'
)}
onclick={() => onPageClick?.(thumb.pageNumber)}
>
<img
src={thumb.imageUrl}
alt="Seite {thumb.pageNumber}"
class="w-full rounded border border-primary-100 bg-white object-contain"
style="aspect-ratio: {thumb.width} / {thumb.height};"
/>
<span class="text-[12px] font-medium text-primary-700">Seite {thumb.pageNumber}</span>
{#if isSelected}
<div
class="absolute top-1 right-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-white"
>
</div>
{/if}
</button>
{/each}
</div>
@@ -4,6 +4,7 @@
import SubDocumentEditor from '$lib/components/SubDocumentEditor.svelte'; import SubDocumentEditor from '$lib/components/SubDocumentEditor.svelte';
import { import {
buildArchiveExport, buildArchiveExport,
buildSubDocumentExport,
createSubDocument, createSubDocument,
dissolveSubDocument, dissolveSubDocument,
moveAttachmentIntoSubDocument, moveAttachmentIntoSubDocument,
@@ -41,8 +42,11 @@
let downloadMode = $state<ExportMode>('single'); let downloadMode = $state<ExportMode>('single');
let isMerging = $state(false); let isMerging = $state(false);
let downloadingSubDocumentId = $state<string | null>(null);
let mergeError = $state<string | null>(null); let mergeError = $state<string | null>(null);
const isExportActive = $derived(isMerging || downloadingSubDocumentId !== null);
$effect(() => { $effect(() => {
archiveName = archive.name; archiveName = archive.name;
looseAttachments = archive.attachments.map((attachment) => ({ ...attachment })); looseAttachments = archive.attachments.map((attachment) => ({ ...attachment }));
@@ -293,7 +297,7 @@
return; return;
} }
if (isMerging) { if (isExportActive) {
return; return;
} }
@@ -313,6 +317,29 @@
isMerging = false; isMerging = false;
} }
}; };
const downloadSubDocument = async (subDocument: SubDocument) => {
if (isExportActive || subDocument.attachments.length === 0) {
return;
}
downloadingSubDocumentId = subDocument.id;
mergeError = null;
try {
const exportUnit = await buildSubDocumentExport(archiveName, subDocument);
downloadPdfBytes(exportUnit.bytes, exportUnit.name);
} catch (error) {
console.error(
`Failed to export sub-document "${subDocument.name}" from archive ${archive.name}`,
error
);
mergeError = `Teildokument „${subDocument.name}“ konnte nicht als PDF erstellt werden.`;
} finally {
downloadingSubDocumentId = null;
}
};
</script> </script>
<svelte:window onkeydown={handleWindowKeydown} /> <svelte:window onkeydown={handleWindowKeydown} />
@@ -354,17 +381,21 @@
{#if hasSubDocuments} {#if hasSubDocuments}
<div <div
class="inline-flex items-center rounded-[16px] border border-primary-200 bg-white p-0.5" class={cn(
'inline-flex items-center rounded-[16px] border border-primary-200 bg-white p-0.5',
downloadingSubDocumentId !== null ? 'opacity-60' : ''
)}
role="radiogroup" role="radiogroup"
aria-label="Export-Modus" aria-label="Export-Modus"
> >
{#each [{ value: 'single', label: 'Eine PDF' }, { value: 'separate', label: 'Getrennt' }] as option (option.value)} {#each [{ value: 'single', label: 'Eine PDF' }, { value: 'separate', label: 'Getrennt' }] as option (option.value)}
<label <label
class={cn( class={cn(
'cursor-pointer rounded-[14px] px-3 py-1 text-[13px] font-semibold transition', 'rounded-[14px] px-3 py-1 text-[13px] font-semibold transition',
downloadMode === option.value downloadMode === option.value
? 'bg-primary text-primary-foreground' ? 'bg-primary text-primary-foreground'
: 'text-primary-700 hover:bg-primary-50' : 'text-primary-700 hover:bg-primary-50',
downloadingSubDocumentId !== null ? 'cursor-not-allowed' : 'cursor-pointer'
)} )}
> >
<input <input
@@ -373,6 +404,7 @@
name={`download-mode-${archive.name}`} name={`download-mode-${archive.name}`}
value={option.value} value={option.value}
checked={downloadMode === option.value} checked={downloadMode === option.value}
disabled={downloadingSubDocumentId !== null}
onchange={() => { onchange={() => {
downloadMode = option.value as ExportMode; downloadMode = option.value as ExportMode;
}} }}
@@ -386,7 +418,7 @@
<Button <Button
class="bg-primary" class="bg-primary"
type="button" type="button"
disabled={(looseAttachments.length === 0 && subDocuments.length === 0) || isMerging} disabled={(looseAttachments.length === 0 && subDocuments.length === 0) || isExportActive}
onclick={downloadArchive} onclick={downloadArchive}
> >
{isMerging ? 'PDF wird erstellt' : 'PDF herunterladen'} {isMerging ? 'PDF wird erstellt' : 'PDF herunterladen'}
@@ -495,6 +527,9 @@
onBlockDragOver={handleBlockDragOver} onBlockDragOver={handleBlockDragOver}
onBlockDrop={handleBlockDrop} onBlockDrop={handleBlockDrop}
onBlockDragEnd={handleBlockDragEnd} onBlockDragEnd={handleBlockDragEnd}
onDownload={() => downloadSubDocument(subDocument)}
isDownloading={downloadingSubDocumentId === subDocument.id}
downloadDisabled={isExportActive}
/> />
{/each} {/each}
</div> </div>
@@ -0,0 +1,23 @@
<script lang="ts">
import { AlertTriangle } from '@lucide/svelte';
import { isRasterizationTooLarge, shouldWarnAboutRasterization } from '$lib/rasterization-limits';
type Props = { file: File | null };
let { file }: Props = $props();
</script>
{#if shouldWarnAboutRasterization(file)}
<div
class="flex gap-3 rounded-lg border p-4 text-sm {isRasterizationTooLarge(file)
? 'border-red-200 bg-red-50 text-red-800'
: 'border-amber-200 bg-amber-50 text-amber-800'}"
role={isRasterizationTooLarge(file) ? 'alert' : undefined}
>
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0" />
<p>
{isRasterizationTooLarge(file)
? 'Diese Datei ist größer als 100 MB und kann aus Speichergründen nicht im Browser gerastert werden.'
: 'Diese große Datei benötigt beim Rasterisieren viel Arbeitsspeicher. Schließen Sie andere speicherintensive Tabs.'}
</p>
</div>
{/if}
+21 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte'; import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
import Button from '$lib/components/ui/button.svelte';
import type { AttachmentLocation, SubDocument, ZipAttachment } from '$lib/zip-processing'; import type { AttachmentLocation, SubDocument, ZipAttachment } from '$lib/zip-processing';
import { cn } from '$lib/utils'; import { cn } from '$lib/utils';
@@ -16,6 +17,9 @@
onBlockDragOver?: (blockIndex: number, event: DragEvent) => void; onBlockDragOver?: (blockIndex: number, event: DragEvent) => void;
onBlockDrop?: (blockIndex: number, event: DragEvent) => void; onBlockDrop?: (blockIndex: number, event: DragEvent) => void;
onBlockDragEnd?: () => void; onBlockDragEnd?: () => void;
onDownload?: () => void;
isDownloading?: boolean;
downloadDisabled?: boolean;
}; };
let { let {
@@ -30,7 +34,10 @@
onBlockDragStart = () => {}, onBlockDragStart = () => {},
onBlockDragOver = () => {}, onBlockDragOver = () => {},
onBlockDrop = () => {}, onBlockDrop = () => {},
onBlockDragEnd = () => {} onBlockDragEnd = () => {},
onDownload,
isDownloading = false,
downloadDisabled = false
}: Props = $props(); }: Props = $props();
let subDocumentName = $state(''); let subDocumentName = $state('');
@@ -200,6 +207,19 @@
{attachments.length} Datei{attachments.length === 1 ? '' : 'en'} {attachments.length} Datei{attachments.length === 1 ? '' : 'en'}
</span> </span>
{#if onDownload}
<Button
type="button"
variant="bordered"
disabled={downloadDisabled || isDownloading || attachments.length === 0}
onclick={onDownload}
draggable="false"
aria-label={`PDF für ${subDocumentName} herunterladen`}
>
{isDownloading ? 'PDF wird erstellt' : 'PDF herunterladen'}
</Button>
{/if}
<button <button
class="rounded-md border border-primary-200 bg-white px-2 py-1 text-[13px] font-medium text-primary-700 transition hover:border-red-300 hover:bg-red-50 hover:text-red-700" class="rounded-md border border-primary-200 bg-white px-2 py-1 text-[13px] font-medium text-primary-700 transition hover:border-red-300 hover:bg-red-50 hover:text-red-700"
type="button" type="button"
+14 -70
View File
@@ -1,76 +1,20 @@
<script lang="ts"> <script lang="ts">
type Props = { import FileDropzone from '$lib/components/FileDropzone.svelte';
onFilesSelected: (files: File[]) => void;
class?: string;
};
let { onFilesSelected, class: className = "" }: Props = $props(); type Props = {
onFilesSelected: (files: File[]) => void;
class?: string;
};
let fileInput: HTMLInputElement | null = null; let { onFilesSelected, class: className = '' }: Props = $props();
let isDragging = $state(false);
const processFiles = (files: FileList | null | undefined) => {
if (!files) return;
const filtered = Array.from(files).filter((file) => {
const name = file.name.toLowerCase();
return (
name.endsWith('.zip') ||
file.type === 'application/zip' ||
file.type === 'application/x-zip-compressed'
);
});
if (filtered.length > 0) {
onFilesSelected(filtered);
}
};
const openFileDialog = () => fileInput?.click();
const handleFileSelection = (event: Event) => {
const input = event.currentTarget as HTMLInputElement;
processFiles(input.files);
input.value = ''; // Reset so same file can be uploaded twice
};
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
isDragging = true;
};
const handleDragLeave = (event: DragEvent) => {
event.preventDefault();
isDragging = false;
};
const handleDrop = (event: DragEvent) => {
event.preventDefault();
isDragging = false;
processFiles(event.dataTransfer?.files);
};
</script> </script>
<input <FileDropzone
bind:this={fileInput} {onFilesSelected}
class="hidden" accept=".zip,application/zip,application/x-zip-compressed"
type="file" multiple={true}
accept=".zip,application/zip,application/x-zip-compressed" ariaLabel="ZIP-Dateien hinzufügen"
multiple label="Dateien hinzufügen"
onchange={handleFileSelection} sublabel="ZIP-Dateien hier ablegen oder zum Auswählen klicken"
class="min-h-[calc(-180px+100vh)] border-0 bg-transparent {className}"
/> />
<button
type="button"
class="flex min-h-[calc(-180px+100vh)] w-full grow cursor-pointer flex-col justify-center border-0 text-primary bg-transparent p-0 text-center appearance-none focus:outline-none transition-colors rounded-2xl {isDragging ? 'bg-gray-100 border-2 border-dashed border-primary-500' : ''} {className}"
aria-label="ZIP-Dateien hinzufügen"
onclick={openFileDialog}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
>
<div class="flex flex-row items-baseline justify-center gap-2">
<span class="text-[20px] font-bold">Dateien hinzufügen</span>
<span class="text-[20px] font-extrabold">+</span>
</div>
</button>
+25
View File
@@ -0,0 +1,25 @@
export const downloadBlob = (
bytes: Uint8Array,
fileName: string,
mimeType: string = 'application/pdf'
) => {
const buffer = new Uint8Array(bytes.byteLength);
buffer.set(bytes);
const blob = new Blob([buffer.buffer as ArrayBuffer], { type: mimeType });
const objectUrl = URL.createObjectURL(blob);
const downloadLink = document.createElement('a');
downloadLink.href = objectUrl;
downloadLink.download = fileName;
downloadLink.rel = 'noopener';
downloadLink.click();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
};
export const downloadBlobUrl = (blobUrl: string, fileName: string) => {
const downloadLink = document.createElement('a');
downloadLink.href = blobUrl;
downloadLink.download = fileName;
downloadLink.rel = 'noopener';
downloadLink.click();
};
+17
View File
@@ -0,0 +1,17 @@
import { PDFDocument } from 'pdf-lib';
export const mergePdfFiles = async (files: File[]): Promise<Uint8Array> => {
if (files.length < 2) throw new Error('At least two PDF files are required.');
const mergedDocument = await PDFDocument.create();
for (const file of files) {
const sourceDocument = await PDFDocument.load(new Uint8Array(await file.arrayBuffer()));
const copiedPages = await mergedDocument.copyPages(
sourceDocument,
sourceDocument.getPageIndices()
);
for (const page of copiedPages) mergedDocument.addPage(page);
}
return mergedDocument.save();
};
+71
View File
@@ -0,0 +1,71 @@
export type PdfThumbnail = {
pageNumber: number;
imageUrl: string;
width: number;
height: number;
};
export type PdfDocumentHandle = {
pageCount: number;
thumbnails: PdfThumbnail[];
destroy: () => void;
};
export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHandle> {
const pdfjsLib = await import('pdfjs-dist');
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
(module) => module.default
);
const loadingTask = getDocument({ data: new Uint8Array(data) });
const thumbnails: PdfThumbnail[] = [];
try {
const pdfDocument = await loadingTask.promise;
for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) {
const page = await pdfDocument.getPage(pageNumber);
const viewport = page.getViewport({ scale: 0.5 });
const canvas = document.createElement('canvas');
canvas.width = Math.ceil(viewport.width);
canvas.height = Math.ceil(viewport.height);
const canvasContext = canvas.getContext('2d');
if (!canvasContext) throw new Error('Could not create thumbnail canvas context.');
await page.render({ canvas, canvasContext, viewport }).promise;
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(result) =>
result ? resolve(result) : reject(new Error('Could not encode PDF thumbnail.')),
'image/png'
);
});
thumbnails.push({
pageNumber,
imageUrl: URL.createObjectURL(blob),
width: viewport.width,
height: viewport.height
});
}
let destroyed = false;
return {
pageCount: pdfDocument.numPages,
thumbnails,
destroy: () => {
if (destroyed) return;
destroyed = true;
for (const thumbnail of thumbnails) URL.revokeObjectURL(thumbnail.imageUrl);
void loadingTask.destroy();
}
};
} catch (error) {
for (const thumbnail of thumbnails) URL.revokeObjectURL(thumbnail.imageUrl);
await loadingTask.destroy();
throw error;
}
}
+11
View File
@@ -0,0 +1,11 @@
let pendingZipFiles: File[] = [];
export const queueZipFiles = (files: File[]) => {
pendingZipFiles = [...pendingZipFiles, ...files];
};
export const takeQueuedZipFiles = () => {
const files = pendingZipFiles;
pendingZipFiles = [];
return files;
};
+8
View File
@@ -0,0 +1,8 @@
export const RASTERIZATION_WARNING_BYTES = 25 * 1024 * 1024;
export const RASTERIZATION_MAX_BYTES = 100 * 1024 * 1024;
export const isRasterizationTooLarge = (file: File | null) =>
file !== null && file.size > RASTERIZATION_MAX_BYTES;
export const shouldWarnAboutRasterization = (file: File | null) =>
file !== null && file.size > RASTERIZATION_WARNING_BYTES;
+51
View File
@@ -0,0 +1,51 @@
export type Tool = {
slug: string;
title: string;
description: string;
icon: string;
};
export const tools: Tool[] = [
{
slug: 'watermark',
title: 'Wasserzeichen',
description: 'Fügen Sie ein Wasserzeichen zu Ihrem PDF hinzu.',
icon: 'droplets'
},
{
slug: 'encrypt',
title: 'Passwort setzen',
description: 'Verschlüsseln Sie Ihr PDF mit einem Passwort.',
icon: 'lock'
},
{
slug: 'decrypt',
title: 'Passwort entfernen',
description: 'Entsperren Sie ein passwortgeschütztes PDF.',
icon: 'unlock'
},
{
slug: 'convert',
title: 'Konvertieren',
description: 'Konvertieren Sie ein PDF in Bilder.',
icon: 'image'
},
{
slug: 'compress',
title: 'Komprimieren',
description: 'Komprimieren Sie ein PDF für kleinere Dateigröße.',
icon: 'minimize'
},
{
slug: 'separate',
title: 'Seiten entfernen',
description: 'Entfernen Sie Seiten aus einem PDF.',
icon: 'scissors'
},
{
slug: 'merge',
title: 'PDFs zusammenfügen',
description: 'Fügen Sie mehrere PDFs zu einer Datei zusammen.',
icon: 'merge'
}
];
+4 -4
View File
@@ -1,13 +1,13 @@
import { clsx, type ClassValue } from "clsx"; import { clsx, type ClassValue } from 'clsx';
import { twMerge } from "tailwind-merge"; import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T; export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T; export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>; export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null }; export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
+20 -4
View File
@@ -572,6 +572,25 @@ export const dissolveSubDocument = (
const stripPdfExtension = (name: string) => const stripPdfExtension = (name: string) =>
name.toLowerCase().endsWith('.pdf') ? name.slice(0, -'.pdf'.length) : name; name.toLowerCase().endsWith('.pdf') ? name.slice(0, -'.pdf'.length) : name;
/**
* Build a single PDF export unit for one sub-document. The resulting filename
* follows the same convention as the separate archive export:
* `{archiveName} - {subDocumentName}` (without duplicate `.pdf` suffixes).
*/
export const buildSubDocumentExport = async (
archiveName: string,
subDocument: SubDocument
): Promise<ExportUnit> => {
if (subDocument.attachments.length === 0) {
throw new Error('Cannot create a PDF from an empty sub-document.');
}
return {
name: `${stripPdfExtension(archiveName)} - ${stripPdfExtension(subDocument.name)}`,
bytes: await mergeAttachments(subDocument.attachments)
};
};
/** /**
* Build one or more PDF export units from the archive. * Build one or more PDF export units from the archive.
* *
@@ -607,10 +626,7 @@ export const buildArchiveExport = async (
continue; continue;
} }
separateUnits.push({ separateUnits.push(await buildSubDocumentExport(archive.name, subDocument));
name: `${stripPdfExtension(archive.name)} - ${stripPdfExtension(subDocument.name)}`,
bytes: await mergeAttachments(subDocument.attachments)
});
} }
if (archive.attachments.length > 0) { if (archive.attachments.length > 0) {
+132 -194
View File
@@ -1,211 +1,149 @@
<script lang="ts"> <script lang="ts">
import {onMount} from 'svelte'; import {
import Button from '$lib/components/ui/button.svelte'; Archive,
import ProcessedZipArchiveEditor from '$lib/components/ProcessedZipArchiveEditor.svelte'; Droplets,
import ZipDropzone from '$lib/components/ZipDropzone.svelte'; Lock,
import { Unlock,
extractZipArchives, Image,
mergeProcessedZipArchive, Minimize2,
type ProcessedZipArchive Scissors,
} from '$lib/zip-processing'; Merge,
import * as Card from "$lib/components/ui/card/index"; FileText
} from '@lucide/svelte';
import { goto } from '$app/navigation';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import * as Card from '$lib/components/ui/card/index';
import { queueZipFiles } from '$lib/pending-files';
import { tools } from '$lib/tools';
const THUMBNAIL_WIDTH_STORAGE_KEY = 'thumbnailWidth'; const iconMap: Record<string, typeof FileText> = {
droplets: Droplets,
lock: Lock,
unlock: Unlock,
image: Image,
minimize: Minimize2,
scissors: Scissors,
merge: Merge
};
let selectedZipFiles: File[] = $state([]); let isDraggingFiles = $state(false);
let processedZipFiles: ProcessedZipArchive[] = $state([]);
let pendingZipCount = $state(0);
let thumbnailWidth = $state(200);
let thumbnailWidthHydrated = $state(false);
let archiveGeneration = 0;
const clampThumbnailWidth = (value: number) => Math.min(400, Math.max(100, value)); const isZipFile = (file: File) =>
file.name.toLowerCase().endsWith('.zip') ||
file.type === 'application/zip' ||
file.type === 'application/x-zip-compressed';
onMount(() => { const openBeaFiles = (files: File[]) => {
const storedValue = localStorage.getItem(THUMBNAIL_WIDTH_STORAGE_KEY); const zipFiles = files.filter(isZipFile);
if (zipFiles.length === 0) return;
if (storedValue !== null) { queueZipFiles(zipFiles);
const parsedValue = Number(storedValue); void goto('/tools/bea');
};
if (Number.isFinite(parsedValue)) { const handleWindowDragOver = (event: DragEvent) => {
thumbnailWidth = clampThumbnailWidth(parsedValue); if (!event.dataTransfer?.types.includes('Files')) return;
} event.preventDefault();
} isDraggingFiles = true;
};
thumbnailWidthHydrated = true; const handleWindowDragLeave = (event: DragEvent) => {
}); if (event.relatedTarget === null) isDraggingFiles = false;
};
$effect(() => { const handleWindowDrop = (event: DragEvent) => {
if (!thumbnailWidthHydrated) { if (!event.dataTransfer?.files.length) return;
return; event.preventDefault();
} isDraggingFiles = false;
openBeaFiles(Array.from(event.dataTransfer.files));
localStorage.setItem(THUMBNAIL_WIDTH_STORAGE_KEY, String(thumbnailWidth)); };
});
const toArrayBuffer = (bytes: Uint8Array) => {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy.buffer;
};
const downloadPdfBytes = (bytes: Uint8Array, fileName: string) => {
const pdfBlob = new Blob([toArrayBuffer(bytes)], {type: 'application/pdf'});
const objectUrl = URL.createObjectURL(pdfBlob);
const downloadLink = document.createElement('a');
downloadLink.href = objectUrl;
downloadLink.download = fileName.toLowerCase().endsWith('.pdf') ? fileName : `${fileName}.pdf`;
downloadLink.rel = 'noopener';
downloadLink.click();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
};
const handleFilesSelected = (files: File[]) => {
selectedZipFiles = [...selectedZipFiles, ...files];
for (const file of files) {
void processZipFile(file, archiveGeneration);
}
};
const processZipFile = async (file: File, generation: number) => {
pendingZipCount += 1;
try {
const processedZipArchives = await extractZipArchives(file);
if (generation !== archiveGeneration) {
return;
}
processedZipFiles = [...processedZipFiles, ...processedZipArchives];
} catch (error) {
console.error(`Failed to process ${file.name}`, error);
} finally {
if (generation === archiveGeneration) {
pendingZipCount -= 1;
}
}
};
const updateProcessedZipFile = (index: number, archive: ProcessedZipArchive) => {
processedZipFiles = processedZipFiles.map((existingArchive, existingIndex) =>
existingIndex === index ? archive : existingArchive
);
};
const exportAllZipArchivesAsPdf = async () => {
for (const archive of processedZipFiles) {
try {
const mergedBytes = await mergeProcessedZipArchive(archive);
downloadPdfBytes(mergedBytes, archive.name);
} catch (error) {
console.error(`Failed to export archive ${archive.name}`, error);
}
}
};
const deleteAllZipArchives = () => {
archiveGeneration += 1;
selectedZipFiles = [];
processedZipFiles = [];
pendingZipCount = 0;
};
</script> </script>
{#snippet content()} <svelte:head>
{#if selectedZipFiles.length === 0} <title>juri-merger — PDF-Werkzeuge</title>
<Card.Root> </svelte:head>
<Card.Content>
<ZipDropzone onFilesSelected={handleFilesSelected}/>
</Card.Content>
</Card.Root>
{:else}
<details class="fixed bottom-4 right-4 z-20">
<summary
class="flex h-12 w-12 cursor-pointer list-none items-center justify-center rounded-full border border-primary-200 bg-white text-lg font-semibold text-primary-800 shadow-lg transition hover:border-primary-300 hover:bg-primary-50"
>
<span class="sr-only">Settings</span>
<span>⚙️</span>
</summary>
<div <svelte:window
class="absolute bottom-14 right-0 w-72 rounded-xl border border-primary-100 bg-white p-4 shadow-lg" ondragover={handleWindowDragOver}
> ondragleave={handleWindowDragLeave}
<div class="flex flex-col gap-3"> ondrop={handleWindowDrop}
<div class="flex gap-2"> />
<Button
class="flex-1"
type="button"
disabled={processedZipFiles.length === 0}
onclick={exportAllZipArchivesAsPdf}
>
Alle ZIP-Archive als PDF exportieren
</Button>
<Button {#if isDraggingFiles}
class="flex-1" <div
type="button" class="pointer-events-none fixed inset-3 z-50 grid place-items-center rounded-3xl border-4 border-dashed border-primary bg-background/95 text-center shadow-2xl"
disabled={selectedZipFiles.length === 0} aria-hidden="true"
variant="secondary" >
onclick={deleteAllZipArchives} <div>
> <Archive class="mx-auto h-12 w-12" />
Alle ZIP-Archive löschen <p class="mt-4 text-xl font-bold">ZIP-Archiv hier ablegen</p>
</Button> </div>
</div> </div>
{/if}
<label class="flex flex-col gap-2"> <div class="flex flex-col gap-10 px-4 py-10">
<div <!-- Hero -->
class="flex items-center justify-between gap-3 text-sm font-medium text-primary-900" <div class="flex flex-col items-center gap-4 text-center">
> <h1 class="text-[28px] font-bold text-primary-900">PDF-Werkzeuge</h1>
<span>Thumbnail Breite</span> <p class="max-w-lg text-[15px] text-primary-700">
<span class="tabular-nums text-primary-700">{thumbnailWidth}px</span> Alle Werkzeuge laufen zu 100 % in Ihrem Browser — keine Dateien werden hochgeladen.
</div> </p>
</div>
<input <!-- Hero tile: beA workflow -->
class="w-full accent-primary-700" <a href="/tools/bea" class="block w-full max-w-2xl mx-auto">
type="range" <Card.Root class="transition hover:shadow-md cursor-pointer">
min="100" <Card.Content class="flex flex-col items-center gap-4 py-10">
max="400" <div
step="1" class="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary text-primary-foreground"
bind:value={thumbnailWidth} >
/> <Archive class="h-8 w-8" />
</label> </div>
</div> <div class="text-center">
</div> <h2 class="text-[18px] font-bold text-primary-900">ZIP-Archiv bearbeiten</h2>
</details> <p class="mt-1 text-[14px] text-primary-700">
<div beA-Archive verarbeiten, Teildokumente erstellen und als PDF exportieren.
class="flex min-h-[calc(-180px+100vh)] w-full grow flex-col justify-center gap-4 px-4 py-6" </p>
> </div>
<div class="mx-auto flex w-full flex-col gap-3"> </Card.Content>
{#if pendingZipCount > 0} </Card.Root>
<div </a>
class="rounded-container bg-primary-50 px-4 py-3 text-[14px] font-medium text-primary-900"
>
Noch {pendingZipCount} ZIP-Datei{pendingZipCount === 1 ? '' : 'en'} in Bearbeitung.
</div>
{/if}
{#if processedZipFiles.length > 0} <!-- ZIP drop entry point -->
<div class="flex flex-col gap-4"> <div class="mx-auto w-full max-w-2xl">
{#each processedZipFiles as file, index} <FileDropzone
<ProcessedZipArchiveEditor onFilesSelected={openBeaFiles}
archive={file} accept=".zip,application/zip,application/x-zip-compressed"
{thumbnailWidth} multiple={true}
onArchiveChange={(updatedArchive) => updateProcessedZipFile(index, updatedArchive)} ariaLabel="ZIP-Dateien hinzufügen"
/> label="ZIP-Datei hier ablegen"
{/each} sublabel="oder zum Auswählen klicken"
</div> class="py-6"
{/if} />
</div> </div>
</div>
{/if}
{/snippet}
<section class="px-4"> <!-- Tool grid -->
<div class="flex flex-wrap items-center justify-between gap-3 py-6"> <div class="mx-auto w-full max-w-4xl">
<h1 class="h1 text-[20px] font-bold text-primary-900">beA-Edit</h1> <h2 class="mb-4 text-[18px] font-bold text-primary-900">Werkzeuge</h2>
</div> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{@render content()} {#each tools as tool}
</section> {@const Icon = iconMap[tool.icon] ?? FileText}
<a href="/tools/{tool.slug}" class="block">
<Card.Root class="h-full transition hover:shadow-md cursor-pointer">
<Card.Content class="flex flex-col gap-3 py-6">
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 text-primary"
>
<Icon class="h-5 w-5" />
</div>
<div>
<h3 class="text-[15px] font-bold text-primary-900">{tool.title}</h3>
<p class="mt-1 text-[13px] leading-snug text-primary-700">{tool.description}</p>
</div>
</Card.Content>
</Card.Root>
</a>
{/each}
</div>
</div>
</div>
+9 -11
View File
@@ -1,8 +1,8 @@
@import 'tailwindcss'; @import 'tailwindcss';
/*@import '../globals.css';*/ /*@import '../globals.css';*/
@import "tw-animate-css"; @import 'tw-animate-css';
@import "shadcn-svelte/tailwind.css"; @import 'shadcn-svelte/tailwind.css';
@import "@fontsource-variable/geist"; @import '@fontsource-variable/geist';
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@@ -17,16 +17,16 @@
} }
:root { :root {
--background: #EEF4F6; --background: #eef4f6;
--foreground: #122F62; --foreground: #122f62;
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.145 0 0);
--primary: #122f62; --primary: #122f62;
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: #EA544B; --secondary: #ea544b;
--secondary-end: #FF6F6D; --secondary-end: #ff6f6d;
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0); --muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.556 0 0);
@@ -54,14 +54,14 @@
.dark { .dark {
--background: #212121; --background: #212121;
--foreground: #FFFFFF; --foreground: #ffffff;
--card: oklch(0.205 0 0); --card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0); --popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: #92b2eb; --primary: #92b2eb;
--primary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.205 0 0);
--secondary: linear-gradient(90deg, #EA544B 0%, #FF6F6D 100%); --secondary: linear-gradient(90deg, #ea544b 0%, #ff6f6d 100%);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0); --muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.708 0 0);
@@ -140,7 +140,6 @@
} }
} }
/* ripple */ /* ripple */
@keyframes ripple { @keyframes ripple {
@@ -158,4 +157,3 @@
.animate-ripple { .animate-ripple {
animation: ripple 600ms ease-out; animation: ripple 600ms ease-out;
} }
+18
View File
@@ -0,0 +1,18 @@
<script lang="ts">
import { ArrowLeft } from '@lucide/svelte';
let { children } = $props();
</script>
<section class="px-4">
<div class="flex flex-wrap items-center gap-3 py-6">
<a
href="/"
class="flex items-center gap-2 text-[14px] font-medium text-primary-700 transition hover:text-primary-900"
>
<ArrowLeft class="h-4 w-4" />
Zurück
</a>
</div>
{@render children()}
</section>
+196
View File
@@ -0,0 +1,196 @@
<script lang="ts">
import { onMount } from 'svelte';
import Button from '$lib/components/ui/button.svelte';
import ProcessedZipArchiveEditor from '$lib/components/ProcessedZipArchiveEditor.svelte';
import ZipDropzone from '$lib/components/ZipDropzone.svelte';
import {
extractZipArchives,
mergeProcessedZipArchive,
type ProcessedZipArchive
} from '$lib/zip-processing';
import { downloadBlob } from '$lib/download';
import { takeQueuedZipFiles } from '$lib/pending-files';
import * as Card from '$lib/components/ui/card/index';
const THUMBNAIL_WIDTH_STORAGE_KEY = 'thumbnailWidth';
let selectedZipFiles: File[] = $state([]);
let processedZipFiles: ProcessedZipArchive[] = $state([]);
let pendingZipCount = $state(0);
let thumbnailWidth = $state(200);
let thumbnailWidthHydrated = $state(false);
let archiveGeneration = 0;
const clampThumbnailWidth = (value: number) => Math.min(400, Math.max(100, value));
onMount(() => {
const storedValue = localStorage.getItem(THUMBNAIL_WIDTH_STORAGE_KEY);
if (storedValue !== null) {
const parsedValue = Number(storedValue);
if (Number.isFinite(parsedValue)) {
thumbnailWidth = clampThumbnailWidth(parsedValue);
}
}
thumbnailWidthHydrated = true;
const queuedFiles = takeQueuedZipFiles();
if (queuedFiles.length > 0) handleFilesSelected(queuedFiles);
});
$effect(() => {
if (!thumbnailWidthHydrated) {
return;
}
localStorage.setItem(THUMBNAIL_WIDTH_STORAGE_KEY, String(thumbnailWidth));
});
const handleFilesSelected = (files: File[]) => {
selectedZipFiles = [...selectedZipFiles, ...files];
for (const file of files) {
void processZipFile(file, archiveGeneration);
}
};
const processZipFile = async (file: File, generation: number) => {
pendingZipCount += 1;
try {
const processedZipArchives = await extractZipArchives(file);
if (generation !== archiveGeneration) {
return;
}
processedZipFiles = [...processedZipFiles, ...processedZipArchives];
} catch (error) {
console.error(`Failed to process ${file.name}`, error);
} finally {
if (generation === archiveGeneration) {
pendingZipCount -= 1;
}
}
};
const updateProcessedZipFile = (index: number, archive: ProcessedZipArchive) => {
processedZipFiles = processedZipFiles.map((existingArchive, existingIndex) =>
existingIndex === index ? archive : existingArchive
);
};
const exportAllZipArchivesAsPdf = async () => {
for (const archive of processedZipFiles) {
try {
const mergedBytes = await mergeProcessedZipArchive(archive);
downloadBlob(mergedBytes, archive.name);
} catch (error) {
console.error(`Failed to export archive ${archive.name}`, error);
}
}
};
const deleteAllZipArchives = () => {
archiveGeneration += 1;
selectedZipFiles = [];
processedZipFiles = [];
pendingZipCount = 0;
};
</script>
{#snippet content()}
{#if selectedZipFiles.length === 0}
<Card.Root>
<Card.Content>
<ZipDropzone onFilesSelected={handleFilesSelected} />
</Card.Content>
</Card.Root>
{:else}
<details class="fixed bottom-4 right-4 z-20">
<summary
class="flex h-12 w-12 cursor-pointer list-none items-center justify-center rounded-full border border-primary-200 bg-white text-lg font-semibold text-primary-800 shadow-lg transition hover:border-primary-300 hover:bg-primary-50"
>
<span class="sr-only">Settings</span>
<span>⚙️</span>
</summary>
<div
class="absolute bottom-14 right-0 w-72 rounded-xl border border-primary-100 bg-white p-4 shadow-lg"
>
<div class="flex flex-col gap-3">
<div class="flex gap-2">
<Button
class="flex-1"
type="button"
disabled={processedZipFiles.length === 0}
onclick={exportAllZipArchivesAsPdf}
>
Alle ZIP-Archive als PDF exportieren
</Button>
<Button
class="flex-1"
type="button"
disabled={selectedZipFiles.length === 0}
variant="secondary"
onclick={deleteAllZipArchives}
>
Alle ZIP-Archive löschen
</Button>
</div>
<label class="flex flex-col gap-2">
<div
class="flex items-center justify-between gap-3 text-sm font-medium text-primary-900"
>
<span>Thumbnail Breite</span>
<span class="tabular-nums text-primary-700">{thumbnailWidth}px</span>
</div>
<input
class="w-full accent-primary-700"
type="range"
min="100"
max="400"
step="1"
bind:value={thumbnailWidth}
/>
</label>
</div>
</div>
</details>
<div
class="flex min-h-[calc(-180px+100vh)] w-full grow flex-col justify-center gap-4 px-4 py-6"
>
<div class="mx-auto flex w-full flex-col gap-3">
{#if pendingZipCount > 0}
<div
class="rounded-container bg-primary-50 px-4 py-3 text-[14px] font-medium text-primary-900"
>
Noch {pendingZipCount} ZIP-Datei{pendingZipCount === 1 ? '' : 'en'} in Bearbeitung.
</div>
{/if}
{#if processedZipFiles.length > 0}
<div class="flex flex-col gap-4">
{#each processedZipFiles as file, index}
<ProcessedZipArchiveEditor
archive={file}
{thumbnailWidth}
onArchiveChange={(updatedArchive) => updateProcessedZipFile(index, updatedArchive)}
/>
{/each}
</div>
{/if}
</div>
</div>
{/if}
{/snippet}
<div>
<h1 class="h1 text-[20px] font-bold text-primary-900">beA-Edit</h1>
{@render content()}
</div>
+218
View File
@@ -0,0 +1,218 @@
<script lang="ts">
import { PDFDocument } from 'pdf-lib';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import RasterizationWarning from '$lib/components/RasterizationWarning.svelte';
import Button from '$lib/components/ui/button.svelte';
import { downloadBlob } from '$lib/download';
import { isRasterizationTooLarge } from '$lib/rasterization-limits';
import { AlertTriangle } from '@lucide/svelte';
type CompressionPreset = {
id: string;
label: string;
description: string;
scale: number;
quality: number;
};
const presets: CompressionPreset[] = [
{
id: 'strong',
label: 'Starke Komprimierung',
description: '~72 DPI, JPEG 50%',
scale: 1,
quality: 0.5
},
{
id: 'balanced',
label: 'Ausgewogen',
description: '~108 DPI, JPEG 70%',
scale: 1.5,
quality: 0.7
},
{ id: 'light', label: 'Leicht', description: '~144 DPI, JPEG 85%', scale: 2, quality: 0.85 }
];
let pdfFile: File | null = $state(null);
let selectedPreset = $state('balanced');
let isProcessing = $state(false);
let error = $state<string | null>(null);
let compressedBytes = $state<Uint8Array | null>(null);
let compressedFileName = $state('');
const rasterizationBlocked = $derived(isRasterizationTooLarge(pdfFile));
const handleFileSelected = (files: File[]) => {
pdfFile = files[0] ?? null;
error = null;
compressedBytes = null;
compressedFileName = '';
};
const formatBytes = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const compressPdf = async () => {
if (!pdfFile) return;
const preset = presets.find((p) => p.id === selectedPreset) ?? presets[1];
isProcessing = true;
error = null;
compressedBytes = null;
try {
const pdfjsLib = await import('pdfjs-dist');
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
(m) => m.default
);
const bytes = new Uint8Array(await pdfFile.arrayBuffer());
const loadingTask = getDocument({ data: bytes });
try {
const pdfjsDoc = await loadingTask.promise;
const outputPdf = await PDFDocument.create();
for (let pageNumber = 1; pageNumber <= pdfjsDoc.numPages; pageNumber += 1) {
const page = await pdfjsDoc.getPage(pageNumber);
const viewport = page.getViewport({ scale: preset.scale });
const canvas = document.createElement('canvas');
canvas.width = Math.ceil(viewport.width);
canvas.height = Math.ceil(viewport.height);
const canvasContext = canvas.getContext('2d');
if (!canvasContext) throw new Error('Could not create canvas context');
await page.render({
canvas,
canvasContext,
viewport,
background: '#ffffff'
}).promise;
const jpegBlob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(result) => (result ? resolve(result) : reject(new Error('JPEG encode failed'))),
'image/jpeg',
preset.quality
);
});
const jpegImage = await outputPdf.embedJpg(new Uint8Array(await jpegBlob.arrayBuffer()));
const originalViewport = page.getViewport({ scale: 1 });
const pdfPage = outputPdf.addPage([originalViewport.width, originalViewport.height]);
pdfPage.drawImage(jpegImage, {
x: 0,
y: 0,
width: pdfPage.getWidth(),
height: pdfPage.getHeight()
});
}
compressedBytes = await outputPdf.save();
compressedFileName = `${pdfFile.name.replace(/\.pdf$/i, '')}_komprimiert.pdf`;
} finally {
await loadingTask.destroy();
}
} catch (err) {
console.error('Compression failed:', err);
error = 'Fehler beim Komprimieren des PDFs.';
} finally {
isProcessing = false;
}
};
const downloadCompressedPdf = () => {
if (compressedBytes) downloadBlob(compressedBytes, compressedFileName);
};
</script>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-primary-900">Komprimieren</h1>
<p class="mt-2 text-sm text-primary-700">
Reduzieren Sie die Dateigröße Ihres PDFs durch Komprimierung.
</p>
</div>
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex gap-3">
<AlertTriangle class="h-5 w-5 shrink-0 text-amber-600 mt-0.5" />
<div class="text-sm text-amber-800">
<p class="font-medium">Hinweis zur Qualität</p>
<p class="mt-1">
Das PDF wird neu gerendert, wodurch Text zu Bildern wird. Ausgewählter Text,
Durchsuchbarkeit und Vektorgrafiken gehen verloren.
</p>
</div>
</div>
{#if !pdfFile}
<FileDropzone onFilesSelected={handleFileSelected} />
{:else}
<div class="space-y-6">
<FileDropzone
onFilesSelected={handleFileSelected}
label="Anderes PDF ablegen"
sublabel={pdfFile.name}
class="py-4"
/>
<RasterizationWarning file={pdfFile} />
<div class="rounded-xl border border-primary-200 bg-white p-4 space-y-4">
<h2 class="text-lg font-semibold text-primary-900">{pdfFile.name}</h2>
<p class="text-sm text-primary-700">Dateigröße: {formatBytes(pdfFile.size)}</p>
<div class="space-y-2">
<p class="text-sm font-medium text-primary-900">Komprimierungsstufe</p>
{#each presets as preset}
<label
class="flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition {selectedPreset ===
preset.id
? 'border-primary bg-primary/5'
: 'border-primary-200 hover:border-primary-300'}"
>
<input
type="radio"
name="compression-preset"
value={preset.id}
bind:group={selectedPreset}
onchange={() => {
compressedBytes = null;
compressedFileName = '';
}}
class="mt-0.5 h-4 w-4"
/>
<div>
<p class="text-sm font-medium text-primary-900">{preset.label}</p>
<p class="text-xs text-primary-700">{preset.description}</p>
</div>
</label>
{/each}
</div>
{#if compressedBytes}
<p class="text-sm text-primary-700">
Ergebnisgröße: <span class="font-semibold">{formatBytes(compressedBytes.length)}</span>
</p>
{/if}
</div>
{#if error}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
{error}
</div>
{/if}
<div class="flex justify-end gap-2">
<Button onclick={compressPdf} disabled={isProcessing || rasterizationBlocked}>
{isProcessing ? 'Wird komprimiert...' : 'Komprimieren'}
</Button>
{#if compressedBytes}
<Button onclick={downloadCompressedPdf}>PDF herunterladen</Button>
{/if}
</div>
</div>
{/if}
</div>
+192
View File
@@ -0,0 +1,192 @@
<script lang="ts">
import FileDropzone from '$lib/components/FileDropzone.svelte';
import RasterizationWarning from '$lib/components/RasterizationWarning.svelte';
import Button from '$lib/components/ui/button.svelte';
import { downloadBlob, downloadBlobUrl } from '$lib/download';
import { isRasterizationTooLarge } from '$lib/rasterization-limits';
import { zipSync } from 'fflate';
import { Image } from '@lucide/svelte';
type OutputFormat = 'png' | 'jpeg';
let pdfFile: File | null = $state(null);
let outputFormat = $state<OutputFormat>('png');
let scale = $state(2);
let isProcessing = $state(false);
let error = $state<string | null>(null);
let progress = $state(0);
let totalPages = $state(0);
const rasterizationBlocked = $derived(isRasterizationTooLarge(pdfFile));
const handleFileSelected = (files: File[]) => {
pdfFile = files[0] ?? null;
error = null;
progress = 0;
totalPages = 0;
};
const convertPdf = async () => {
if (!pdfFile) return;
isProcessing = true;
error = null;
progress = 0;
try {
const pdfjsLib = await import('pdfjs-dist');
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
(module) => module.default
);
const loadingTask = getDocument({
data: new Uint8Array(await pdfFile.arrayBuffer())
});
try {
const pdfDocument = await loadingTask.promise;
const renderScale = Number(scale);
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
const mimeType = outputFormat === 'png' ? 'image/png' : 'image/jpeg';
const extension = outputFormat === 'png' ? 'png' : 'jpg';
totalPages = pdfDocument.numPages;
const renderPage = async (pageNumber: number) => {
const page = await pdfDocument.getPage(pageNumber);
const viewport = page.getViewport({ scale: renderScale });
const canvas = document.createElement('canvas');
canvas.width = Math.ceil(viewport.width);
canvas.height = Math.ceil(viewport.height);
const canvasContext = canvas.getContext('2d');
if (!canvasContext) throw new Error('Could not create canvas context');
await page.render({ canvas, canvasContext, viewport, background: '#ffffff' }).promise;
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(result) => (result ? resolve(result) : reject(new Error('Image encode failed'))),
mimeType,
outputFormat === 'jpeg' ? 0.9 : undefined
);
});
};
if (pdfDocument.numPages === 1) {
const blob = await renderPage(1);
const objectUrl = URL.createObjectURL(blob);
downloadBlobUrl(objectUrl, `${baseName}.${extension}`);
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
progress = 1;
} else {
const images: Record<string, Uint8Array> = {};
for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) {
const blob = await renderPage(pageNumber);
images[`seite_${String(pageNumber).padStart(3, '0')}.${extension}`] = new Uint8Array(
await blob.arrayBuffer()
);
progress = pageNumber;
}
downloadBlob(zipSync(images), `${baseName}_bilder.zip`, 'application/zip');
}
} finally {
await loadingTask.destroy();
}
} catch (conversionError) {
console.error('Conversion failed:', conversionError);
error = 'Fehler beim Konvertieren des PDFs.';
} finally {
isProcessing = false;
}
};
</script>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-primary-900">Konvertieren</h1>
<p class="mt-2 text-sm text-primary-700">Konvertieren Sie ein PDF in Bilder (PNG oder JPEG).</p>
</div>
{#if !pdfFile}
<FileDropzone onFilesSelected={handleFileSelected} />
{:else}
<div class="space-y-6">
<FileDropzone
onFilesSelected={handleFileSelected}
label="Anderes PDF ablegen"
sublabel={pdfFile.name}
class="py-4"
/>
<RasterizationWarning file={pdfFile} />
<div class="rounded-xl border border-primary-200 bg-white p-4 space-y-4">
<div class="flex items-center gap-2 text-primary-900">
<Image class="h-5 w-5" />
<h2 class="text-lg font-semibold">{pdfFile.name}</h2>
</div>
<div>
<p class="text-sm font-medium text-primary-900 mb-2">Bildformat</p>
<div class="flex gap-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" bind:group={outputFormat} value="png" class="h-4 w-4" />
<span class="text-sm text-primary-700">PNG (verlustfrei)</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" bind:group={outputFormat} value="jpeg" class="h-4 w-4" />
<span class="text-sm text-primary-700">JPEG (kleiner)</span>
</label>
</div>
</div>
<div>
<label class="block text-sm font-medium text-primary-900 mb-1" for="render-scale">
Auflösung: {scale}x
</label>
<input
id="render-scale"
type="range"
min="1"
max="4"
step="0.5"
bind:value={scale}
class="w-full"
/>
<p class="text-xs text-primary-600 mt-1">
{scale === 1
? '~72 DPI'
: scale === 2
? '~150 DPI'
: scale === 3
? '~216 DPI'
: '~288 DPI'}
</p>
</div>
{#if isProcessing && totalPages > 0}
<div class="space-y-1">
<div class="flex justify-between text-xs text-primary-700">
<span>Wird konvertiert...</span>
<span>{progress} / {totalPages}</span>
</div>
<div class="h-2 w-full rounded-full bg-primary-100">
<div
class="h-2 rounded-full bg-primary transition-all"
style="width: {(progress / totalPages) * 100}%"
></div>
</div>
</div>
{/if}
</div>
{#if error}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
{error}
</div>
{/if}
<div class="flex justify-end gap-2">
<Button onclick={convertPdf} disabled={isProcessing || rasterizationBlocked}>
{isProcessing ? 'Wird konvertiert...' : 'Konvertieren & herunterladen'}
</Button>
</div>
</div>
{/if}
</div>
+152
View File
@@ -0,0 +1,152 @@
<script lang="ts">
import { PDFDocument } from 'pdf-lib';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import RasterizationWarning from '$lib/components/RasterizationWarning.svelte';
import Button from '$lib/components/ui/button.svelte';
import { downloadBlob } from '$lib/download';
import { isRasterizationTooLarge } from '$lib/rasterization-limits';
import { Unlock, AlertTriangle } from '@lucide/svelte';
let pdfFile: File | null = $state(null);
let password = $state('');
let isProcessing = $state(false);
let error = $state<string | null>(null);
const rasterizationBlocked = $derived(isRasterizationTooLarge(pdfFile));
const handleFileSelected = (files: File[]) => {
pdfFile = files[0] ?? null;
error = null;
};
const decryptPdf = async () => {
if (!pdfFile) return;
isProcessing = true;
error = null;
try {
const pdfjsLib = await import('pdfjs-dist');
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
(module) => module.default
);
const loadingTask = getDocument({
data: new Uint8Array(await pdfFile.arrayBuffer()),
password
});
try {
const pdfDocument = await loadingTask.promise;
const outputPdf = await PDFDocument.create();
for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) {
const page = await pdfDocument.getPage(pageNumber);
const viewport = page.getViewport({ scale: 2 });
const canvas = document.createElement('canvas');
canvas.width = Math.ceil(viewport.width);
canvas.height = Math.ceil(viewport.height);
const canvasContext = canvas.getContext('2d');
if (!canvasContext) throw new Error('Could not create canvas context');
await page.render({
canvas,
canvasContext,
viewport,
background: '#ffffff'
}).promise;
const pngBlob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(result) => (result ? resolve(result) : reject(new Error('PNG encode failed'))),
'image/png'
);
});
const pngImage = await outputPdf.embedPng(new Uint8Array(await pngBlob.arrayBuffer()));
const originalViewport = page.getViewport({ scale: 1 });
const pdfPage = outputPdf.addPage([originalViewport.width, originalViewport.height]);
pdfPage.drawImage(pngImage, {
x: 0,
y: 0,
width: pdfPage.getWidth(),
height: pdfPage.getHeight()
});
}
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
downloadBlob(await outputPdf.save(), `${baseName}_entsperrt.pdf`);
} finally {
await loadingTask.destroy();
}
} catch (decryptionError) {
console.error('Decryption failed:', decryptionError);
error = 'Fehler beim Entsperren. Bitte prüfen Sie das Passwort und versuchen Sie es erneut.';
} finally {
isProcessing = false;
}
};
</script>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-primary-900">Passwort entfernen</h1>
<p class="mt-2 text-sm text-primary-700">
Entfernen Sie den Passwortschutz von einem PDF-Dokument.
</p>
</div>
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex gap-3">
<AlertTriangle class="h-5 w-5 shrink-0 text-amber-600 mt-0.5" />
<div class="text-sm text-amber-800">
<p class="font-medium">Hinweis zur Qualität</p>
<p class="mt-1">
Das PDF wird neu gerendert, wodurch Text zu Bildern wird. Ausgewählter Text,
Durchsuchbarkeit und Vektorgrafiken gehen verloren. Die Dateigröße kann größer werden.
</p>
</div>
</div>
{#if !pdfFile}
<FileDropzone onFilesSelected={handleFileSelected} />
{:else}
<div class="space-y-6">
<FileDropzone
onFilesSelected={handleFileSelected}
label="Anderes PDF ablegen"
sublabel={pdfFile.name}
class="py-4"
/>
<RasterizationWarning file={pdfFile} />
<div class="rounded-xl border border-primary-200 bg-white p-4 space-y-4">
<div class="flex items-center gap-2 text-primary-900">
<Unlock class="h-5 w-5" />
<h2 class="text-lg font-semibold">{pdfFile.name}</h2>
</div>
<div>
<label class="block text-sm font-medium text-primary-900 mb-1" for="decrypt-password">
Passwort
</label>
<input
id="decrypt-password"
type="password"
bind:value={password}
class="w-full rounded-lg border border-primary-200 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
placeholder="Passwort des PDFs eingeben"
/>
</div>
</div>
{#if error}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
{error}
</div>
{/if}
<div class="flex justify-end gap-2">
<Button onclick={decryptPdf} disabled={isProcessing || !password || rasterizationBlocked}>
{isProcessing ? 'Wird entsperrt...' : 'Passwort entfernen & herunterladen'}
</Button>
</div>
</div>
{/if}
</div>
+150
View File
@@ -0,0 +1,150 @@
<script lang="ts">
import { PDFDocument } from '@cantoo/pdf-lib';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import Button from '$lib/components/ui/button.svelte';
import { downloadBlob } from '$lib/download';
import { Lock } from '@lucide/svelte';
let pdfFile: File | null = $state(null);
let password = $state('');
let confirmPassword = $state('');
let allowPrinting = $state(true);
let allowCopying = $state(true);
let isProcessing = $state(false);
let error = $state<string | null>(null);
const handleFileSelected = (files: File[]) => {
pdfFile = files[0] ?? null;
error = null;
};
const encryptPdf = async () => {
if (!pdfFile || !password) return;
if (password !== confirmPassword) {
error = 'Passwörter stimmen nicht überein.';
return;
}
if (password.length < 1) {
error = 'Bitte geben Sie ein Passwort ein.';
return;
}
isProcessing = true;
error = null;
try {
const bytes = new Uint8Array(await pdfFile.arrayBuffer());
const pdfDoc = await PDFDocument.load(bytes);
pdfDoc.encrypt({
userPassword: password,
ownerPassword: crypto.randomUUID(),
permissions: {
printing: allowPrinting,
copying: allowCopying
}
});
const encryptedBytes = await pdfDoc.save();
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
downloadBlob(encryptedBytes, `${baseName}_geschuetzt.pdf`);
} catch (err) {
console.error('Encryption failed:', err);
error = 'Fehler beim Verschlüsseln des PDFs.';
} finally {
isProcessing = false;
}
};
</script>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-primary-900">Passwort setzen</h1>
<p class="mt-2 text-sm text-primary-700">
Verschlüsseln Sie Ihr PDF mit einem Passwort, um es vor unbefugtem Zugriff zu schützen.
</p>
</div>
{#if !pdfFile}
<FileDropzone onFilesSelected={handleFileSelected} />
{:else}
<div class="space-y-6">
<FileDropzone
onFilesSelected={handleFileSelected}
label="Anderes PDF ablegen"
sublabel={pdfFile.name}
class="py-4"
/>
<div class="rounded-xl border border-primary-200 bg-white p-4 space-y-4">
<div class="flex items-center gap-2 text-primary-900">
<Lock class="h-5 w-5" />
<h2 class="text-lg font-semibold">{pdfFile.name}</h2>
</div>
<div>
<label class="block text-sm font-medium text-primary-900 mb-1" for="encrypt-password">
Passwort
</label>
<input
id="encrypt-password"
type="password"
bind:value={password}
class="w-full rounded-lg border border-primary-200 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
placeholder="Passwort eingeben"
/>
</div>
<div>
<label
class="block text-sm font-medium text-primary-900 mb-1"
for="encrypt-password-confirmation"
>
Passwort bestätigen
</label>
<input
id="encrypt-password-confirmation"
type="password"
bind:value={confirmPassword}
class="w-full rounded-lg border border-primary-200 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
placeholder="Passwort wiederholen"
/>
</div>
<div class="space-y-2">
<p class="text-sm font-medium text-primary-900">Berechtigungen</p>
<div class="flex items-center gap-2">
<input
type="checkbox"
id="allow-printing"
bind:checked={allowPrinting}
class="h-4 w-4"
/>
<label for="allow-printing" class="text-sm text-primary-700">Drucken erlauben</label>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="allow-copying" bind:checked={allowCopying} class="h-4 w-4" />
<label for="allow-copying" class="text-sm text-primary-700">Kopieren erlauben</label>
</div>
</div>
</div>
{#if error}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
{error}
</div>
{/if}
<div class="flex justify-end gap-2">
<Button
onclick={encryptPdf}
disabled={isProcessing || !password || password !== confirmPassword}
>
{isProcessing ? 'Wird verschlüsselt...' : 'Passwort setzen & herunterladen'}
</Button>
</div>
</div>
{/if}
</div>
+108
View File
@@ -0,0 +1,108 @@
<script lang="ts">
import FileDropzone from '$lib/components/FileDropzone.svelte';
import Button from '$lib/components/ui/button.svelte';
import { downloadBlob } from '$lib/download';
import { mergePdfFiles } from '$lib/pdf-processing';
import { Trash2 } from '@lucide/svelte';
let pdfFiles: File[] = $state([]);
let isProcessing = $state(false);
let error = $state<string | null>(null);
const handleFilesSelected = (files: File[]) => {
pdfFiles = [...pdfFiles, ...files];
error = null;
};
const removeFile = (index: number) => {
pdfFiles = pdfFiles.filter((_, i) => i !== index);
};
const moveFile = (fromIndex: number, toIndex: number) => {
if (toIndex < 0 || toIndex >= pdfFiles.length) return;
const newFiles = [...pdfFiles];
const [moved] = newFiles.splice(fromIndex, 1);
newFiles.splice(toIndex, 0, moved);
pdfFiles = newFiles;
};
const mergePdfs = async () => {
if (pdfFiles.length < 2) return;
isProcessing = true;
error = null;
try {
downloadBlob(await mergePdfFiles(pdfFiles), 'zusammengefuegt.pdf');
} catch (err) {
console.error('Merge failed:', err);
error = 'Fehler beim Zusammenfügen der PDFs.';
} finally {
isProcessing = false;
}
};
</script>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-primary-900">PDFs zusammenfügen</h1>
<p class="mt-2 text-sm text-primary-700">
Fügen Sie mehrere PDF-Dateien zu einem einzigen Dokument zusammen.
</p>
</div>
<FileDropzone onFilesSelected={handleFilesSelected} multiple={true} />
{#if pdfFiles.length > 0}
<div class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-primary-900">
{pdfFiles.length} Datei{pdfFiles.length === 1 ? '' : 'en'} ausgewählt
</h2>
<Button onclick={mergePdfs} disabled={isProcessing || pdfFiles.length < 2}>
{isProcessing ? 'Wird zusammengefügt...' : 'PDFs zusammenfügen'}
</Button>
</div>
{#if error}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
{error}
</div>
{/if}
<div class="space-y-2">
{#each pdfFiles as file, index}
<div class="flex items-center gap-3 rounded-lg border border-primary-200 bg-white p-3">
<span
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary"
>
{index + 1}
</span>
<span class="flex-1 truncate text-sm text-primary-900">{file.name}</span>
<div class="flex gap-1">
<Button
variant="tertiary"
class="min-w-0 px-3"
onclick={() => moveFile(index, index - 1)}
disabled={index === 0}
>
</Button>
<Button
variant="tertiary"
class="min-w-0 px-3"
onclick={() => moveFile(index, index + 1)}
disabled={index === pdfFiles.length - 1}
>
</Button>
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => removeFile(index)}>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
+162
View File
@@ -0,0 +1,162 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { PDFDocument } from 'pdf-lib';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
import Button from '$lib/components/ui/button.svelte';
import { downloadBlob } from '$lib/download';
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
let pdfFile: File | null = $state(null);
let pdfHandle: PdfDocumentHandle | null = $state(null);
let selectedPages = $state(new Set<number>());
let isProcessing = $state(false);
let error = $state<string | null>(null);
let loadingError = $state<string | null>(null);
const handleFileSelected = async (files: File[]) => {
const file = files[0];
if (!file) return;
pdfHandle?.destroy();
pdfHandle = null;
pdfFile = file;
selectedPages = new Set();
loadingError = null;
error = null;
try {
const bytes = new Uint8Array(await file.arrayBuffer());
pdfHandle = await loadPdfDocument(bytes);
} catch (err) {
console.error('Failed to load PDF:', err);
loadingError = 'PDF konnte nicht geladen werden.';
pdfHandle = null;
}
};
const togglePage = (pageNumber: number) => {
const next = new Set(selectedPages);
if (next.has(pageNumber)) {
next.delete(pageNumber);
} else {
next.add(pageNumber);
}
selectedPages = next;
};
const selectAll = () => {
if (!pdfHandle) return;
const all = new Set<number>();
for (let i = 1; i <= pdfHandle.pageCount; i++) all.add(i);
selectedPages = all;
};
const selectNone = () => {
selectedPages = new Set();
};
const removePages = async () => {
if (!pdfFile || !pdfHandle || selectedPages.size === 0) return;
isProcessing = true;
error = null;
try {
const bytes = new Uint8Array(await pdfFile.arrayBuffer());
const sourcePdf = await PDFDocument.load(bytes);
const outputPdf = await PDFDocument.create();
const pagesToKeep: number[] = [];
for (let i = 0; i < sourcePdf.getPageCount(); i++) {
if (!selectedPages.has(i + 1)) {
pagesToKeep.push(i);
}
}
if (pagesToKeep.length === 0) {
error = 'Es muss mindestens eine Seite übrig bleiben.';
isProcessing = false;
return;
}
const copiedPages = await outputPdf.copyPages(sourcePdf, pagesToKeep);
copiedPages.forEach((page) => outputPdf.addPage(page));
const resultBytes = await outputPdf.save();
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
downloadBlob(resultBytes, `${baseName}_ohne_seiten.pdf`);
} catch (err) {
console.error('Failed to remove pages:', err);
error = 'Fehler beim Entfernen der Seiten.';
} finally {
isProcessing = false;
}
};
onDestroy(() => {
pdfHandle?.destroy();
});
</script>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-primary-900">Seiten entfernen</h1>
<p class="mt-2 text-sm text-primary-700">
Wählen Sie Seiten aus, die Sie aus dem PDF entfernen möchten.
</p>
</div>
{#if !pdfHandle}
<FileDropzone onFilesSelected={handleFileSelected} />
{#if loadingError}
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
{loadingError}
</div>
{/if}
{:else}
<div class="space-y-4">
<FileDropzone
onFilesSelected={handleFileSelected}
label="Anderes PDF ablegen"
sublabel={pdfFile?.name ?? ''}
class="py-4"
/>
<div class="flex flex-wrap items-center justify-between gap-3">
<h2 class="text-lg font-semibold text-primary-900">
{pdfFile?.name}{pdfHandle.pageCount} Seite{pdfHandle.pageCount === 1 ? '' : 'n'}
</h2>
<div class="flex gap-2">
<Button variant="tertiary" class="min-w-0 px-3" onclick={selectAll}>
Alle auswählen
</Button>
<Button variant="tertiary" class="min-w-0 px-3" onclick={selectNone}>
Auswahl aufheben
</Button>
</div>
</div>
<PdfPageGrid thumbnails={pdfHandle.thumbnails} {selectedPages} onPageClick={togglePage} />
{#if error}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
{error}
</div>
{/if}
<div class="flex items-center justify-between">
<p class="text-sm text-primary-700">
{selectedPages.size} Seite{selectedPages.size === 1 ? '' : 'n'} zum Entfernen ausgewählt
</p>
<Button
onclick={removePages}
disabled={isProcessing ||
selectedPages.size === 0 ||
selectedPages.size >= pdfHandle.pageCount}
>
{isProcessing ? 'Wird verarbeitet...' : 'Seiten entfernen & herunterladen'}
</Button>
</div>
</div>
{/if}
</div>
+329
View File
@@ -0,0 +1,329 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { degrees, PDFDocument, rgb, StandardFonts } from 'pdf-lib';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
import Button from '$lib/components/ui/button.svelte';
import { downloadBlob } from '$lib/download';
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
type WatermarkKind = 'text' | 'image';
type WatermarkPosition =
| 'center'
| 'diagonal'
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right';
let pdfFile: File | null = $state(null);
let pdfHandle: PdfDocumentHandle | null = $state(null);
let watermarkKind = $state<WatermarkKind>('text');
let watermarkText = $state('VERTRAULICH');
let watermarkImage: File | null = $state(null);
let fontSize = $state(50);
let imageWidth = $state(160);
let opacity = $state(0.3);
let rotation = $state(0);
let position = $state<WatermarkPosition>('diagonal');
let watermarkColor = $state('#cc1a1a');
let applyToAll = $state(true);
let selectedPages = $state(new Set<number>());
let isProcessing = $state(false);
let error = $state<string | null>(null);
const watermarkReady = $derived(
watermarkKind === 'text' ? watermarkText.trim().length > 0 : watermarkImage !== null
);
const handleFileSelected = async (files: File[]) => {
const file = files[0];
if (!file) return;
pdfHandle?.destroy();
pdfHandle = null;
pdfFile = file;
selectedPages = new Set();
error = null;
try {
pdfHandle = await loadPdfDocument(new Uint8Array(await file.arrayBuffer()));
} catch (loadError) {
console.error('Failed to load PDF for watermarking:', loadError);
error = 'PDF konnte nicht geladen werden.';
}
};
const handleImageSelected = (files: File[]) => {
watermarkImage = files[0] ?? null;
error = null;
};
const togglePage = (pageNumber: number) => {
const next = new Set(selectedPages);
if (next.has(pageNumber)) next.delete(pageNumber);
else next.add(pageNumber);
selectedPages = next;
};
const getPositionCoords = (
pageWidth: number,
pageHeight: number,
markWidth: number,
markHeight: number,
markPosition: WatermarkPosition
) => {
const margin = 20;
switch (markPosition) {
case 'center':
case 'diagonal':
return { x: (pageWidth - markWidth) / 2, y: (pageHeight - markHeight) / 2 };
case 'top-left':
return { x: margin, y: pageHeight - markHeight - margin };
case 'top-right':
return { x: pageWidth - markWidth - margin, y: pageHeight - markHeight - margin };
case 'bottom-left':
return { x: margin, y: margin };
case 'bottom-right':
return { x: pageWidth - markWidth - margin, y: margin };
}
};
const parseHexColor = (hexColor: string) => {
const match = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hexColor);
if (!match) return rgb(0.8, 0.1, 0.1);
return rgb(
Number.parseInt(match[1], 16) / 255,
Number.parseInt(match[2], 16) / 255,
Number.parseInt(match[3], 16) / 255
);
};
const applyWatermark = async () => {
if (!pdfFile || !watermarkReady) return;
if (!applyToAll && selectedPages.size === 0) {
error = 'Bitte wählen Sie mindestens eine Seite aus.';
return;
}
isProcessing = true;
error = null;
try {
const pdfDoc = await PDFDocument.load(new Uint8Array(await pdfFile.arrayBuffer()));
const pages = pdfDoc.getPages();
const pageIndexes = applyToAll
? pages.map((_, index) => index)
: Array.from(selectedPages, (pageNumber) => pageNumber - 1);
const numericOpacity = Number(opacity);
const numericRotation = position === 'diagonal' ? 45 : Number(rotation);
if (watermarkKind === 'text') {
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const numericFontSize = Number(fontSize);
for (const pageIndex of pageIndexes) {
const page = pages[pageIndex];
if (!page) continue;
const { width, height } = page.getSize();
const textWidth = font.widthOfTextAtSize(watermarkText, numericFontSize);
const textHeight = font.heightAtSize(numericFontSize);
const coordinates = getPositionCoords(width, height, textWidth, textHeight, position);
page.drawText(watermarkText, {
...coordinates,
size: numericFontSize,
font,
color: parseHexColor(watermarkColor),
opacity: numericOpacity,
rotate: degrees(numericRotation)
});
}
} else if (watermarkImage) {
const imageBytes = new Uint8Array(await watermarkImage.arrayBuffer());
const embeddedImage =
watermarkImage.type === 'image/jpeg' || /\.jpe?g$/i.test(watermarkImage.name)
? await pdfDoc.embedJpg(imageBytes)
: await pdfDoc.embedPng(imageBytes);
const markWidth = Number(imageWidth);
const markHeight = markWidth * (embeddedImage.height / embeddedImage.width);
for (const pageIndex of pageIndexes) {
const page = pages[pageIndex];
if (!page) continue;
const { width, height } = page.getSize();
const coordinates = getPositionCoords(width, height, markWidth, markHeight, position);
page.drawImage(embeddedImage, {
...coordinates,
width: markWidth,
height: markHeight,
opacity: numericOpacity,
rotate: degrees(numericRotation)
});
}
}
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
downloadBlob(await pdfDoc.save(), `${baseName}_wasserzeichen.pdf`);
} catch (watermarkError) {
console.error('Watermark failed:', watermarkError);
error = 'Fehler beim Anwenden des Wasserzeichens.';
} finally {
isProcessing = false;
}
};
onDestroy(() => pdfHandle?.destroy());
</script>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-primary-900">Wasserzeichen</h1>
<p class="mt-2 text-sm text-primary-700">
Fügen Sie allen oder ausgewählten Seiten ein Text- oder Bildwasserzeichen hinzu.
</p>
</div>
{#if !pdfHandle}
<FileDropzone onFilesSelected={handleFileSelected} />
{:else}
<div class="space-y-6">
<FileDropzone
onFilesSelected={handleFileSelected}
label="Anderes PDF ablegen"
sublabel={pdfFile?.name ?? ''}
class="py-4"
/>
<div class="space-y-4 rounded-xl border border-primary-200 bg-white p-4">
<h2 class="text-lg font-semibold text-primary-900">Wasserzeichen-Einstellungen</h2>
<fieldset class="flex gap-4">
<legend class="mb-2 text-sm font-medium text-primary-900">Art</legend>
<label class="flex items-center gap-2 text-sm">
<input type="radio" bind:group={watermarkKind} value="text" /> Text
</label>
<label class="flex items-center gap-2 text-sm">
<input type="radio" bind:group={watermarkKind} value="image" /> Bild
</label>
</fieldset>
{#if watermarkKind === 'text'}
<label class="block text-sm font-medium text-primary-900" for="watermark-text">
Text
</label>
<input
id="watermark-text"
type="text"
bind:value={watermarkText}
class="w-full rounded-lg border border-primary-200 px-3 py-2 text-sm"
/>
{:else}
<FileDropzone
onFilesSelected={handleImageSelected}
accept=".png,.jpg,.jpeg,image/png,image/jpeg"
ariaLabel="Wasserzeichenbild hinzufügen"
label="Wasserzeichenbild ablegen"
sublabel={watermarkImage?.name ?? 'PNG oder JPG'}
class="py-4"
/>
{/if}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
{#if watermarkKind === 'text'}
<label class="text-sm font-medium text-primary-900" for="watermark-font-size">
Schriftgröße: {fontSize}
</label>
<input id="watermark-font-size" type="range" min="10" max="120" bind:value={fontSize} />
{:else}
<label class="text-sm font-medium text-primary-900" for="watermark-image-width">
Bildbreite: {imageWidth}
</label>
<input
id="watermark-image-width"
type="range"
min="10"
max="400"
bind:value={imageWidth}
/>
{/if}
<label class="text-sm font-medium text-primary-900" for="watermark-opacity">
Deckkraft: {Math.round(Number(opacity) * 100)}%
</label>
<input
id="watermark-opacity"
type="range"
min="0.05"
max="1"
step="0.05"
bind:value={opacity}
/>
<label class="text-sm font-medium text-primary-900" for="watermark-rotation">
Drehung: {rotation}°
</label>
<input
id="watermark-rotation"
type="range"
min="0"
max="360"
bind:value={rotation}
disabled={position === 'diagonal'}
/>
<label class="text-sm font-medium text-primary-900" for="watermark-position">
Position
</label>
<select id="watermark-position" bind:value={position} class="rounded-lg border px-3 py-2">
<option value="center">Zentriert</option>
<option value="diagonal">Diagonal</option>
<option value="top-left">Oben links</option>
<option value="top-right">Oben rechts</option>
<option value="bottom-left">Unten links</option>
<option value="bottom-right">Unten rechts</option>
</select>
</div>
{#if watermarkKind === 'text'}
<label class="block text-sm font-medium text-primary-900" for="watermark-color">
Farbe
</label>
<input
id="watermark-color"
type="color"
bind:value={watermarkColor}
class="h-10 w-14 cursor-pointer"
/>
{/if}
<label class="flex items-center gap-2 text-sm text-primary-900" for="all-pages">
<input type="checkbox" id="all-pages" bind:checked={applyToAll} />
Auf alle Seiten anwenden
</label>
</div>
{#if !applyToAll}
<div>
<p class="mb-3 text-sm font-medium">Seiten auswählen:</p>
<PdfPageGrid thumbnails={pdfHandle.thumbnails} {selectedPages} onPageClick={togglePage} />
</div>
{/if}
{#if error}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
{error}
</div>
{/if}
<div class="flex justify-end">
<Button
onclick={applyWatermark}
disabled={isProcessing || !watermarkReady || (!applyToAll && selectedPages.size === 0)}
>
{isProcessing ? 'Wird verarbeitet...' : 'Wasserzeichen anwenden & herunterladen'}
</Button>
</div>
</div>
{/if}
</div>
+226
View File
@@ -0,0 +1,226 @@
import { expect, test, type Download, type Locator, type Page } from '@playwright/test';
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
import { zipSync } from 'fflate';
import { readFile } from 'node:fs/promises';
const createPdf = async (pageCount: number, label: string) => {
const document = await PDFDocument.create();
const font = await document.embedFont(StandardFonts.Helvetica);
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) {
const page = document.addPage([420, 594]);
page.drawText(`${label} Seite ${pageNumber}`, {
x: 40,
y: 520,
size: 20,
font,
color: rgb(0.1, 0.2, 0.4)
});
}
return Buffer.from(await document.save());
};
const dropFiles = async (
page: Page,
target: Locator | 'window',
files: Array<{ name: string; mimeType: string; buffer: Buffer }>
) => {
await page.locator('[data-dropzone-ready="true"]').first().waitFor();
const dataTransfer = await page.evaluateHandle(
(droppedFiles) => {
const transfer = new DataTransfer();
for (const droppedFile of droppedFiles) {
const bytes = Uint8Array.from(atob(droppedFile.base64), (character) =>
character.charCodeAt(0)
);
transfer.items.add(
new File([bytes], droppedFile.name, {
type: droppedFile.mimeType,
lastModified: Date.now()
})
);
}
return transfer;
},
files.map((file) => ({
name: file.name,
mimeType: file.mimeType,
base64: file.buffer.toString('base64')
}))
);
if (target === 'window') {
await page.evaluate((transfer) => {
window.dispatchEvent(
new DragEvent('drop', { dataTransfer: transfer, bubbles: true, cancelable: true })
);
}, dataTransfer);
} else {
await target.dispatchEvent('drop', { dataTransfer });
}
await dataTransfer.dispose();
};
const expectPdfDownload = async (download: Download, name: RegExp) => {
expect(download.suggestedFilename()).toMatch(name);
const path = await download.path();
expect(path).not.toBeNull();
const bytes = await readFile(path!);
expect(bytes.subarray(0, 5).toString()).toBe('%PDF-');
return bytes;
};
const trackPageErrors = (page: Page) => {
const errors: Error[] = [];
page.on('pageerror', (error) => errors.push(error));
return errors;
};
const pdfDropzone = (page: Page) => page.getByRole('button', { name: 'PDF-Dateien hinzufügen' });
test('start page exposes only working tools and forwards a dropped ZIP to beA', async ({
page
}) => {
const pageErrors = trackPageErrors(page);
const pdf = await createPdf(1, 'beA');
const zip = Buffer.from(zipSync({ 'anlage.pdf': pdf }));
await page.goto('/');
await expect(page.getByRole('heading', { name: 'PDF-Werkzeuge' })).toBeVisible();
await expect(page.getByRole('link', { name: /Signieren/ })).toHaveCount(0);
await dropFiles(page, 'window', [
{ name: 'bea-archiv.zip', mimeType: 'application/zip', buffer: zip }
]);
await expect(page).toHaveURL(/\/tools\/bea$/);
await expect(page.getByText('1 lose Datei')).toBeVisible({ timeout: 15_000 });
expect(pageErrors).toEqual([]);
});
test('merge accepts dropped PDFs and downloads a merged PDF', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/merge');
await dropFiles(page, pdfDropzone(page), [
{ name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') },
{ name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Zwei') }
]);
await expect(page.getByText('2 Dateien ausgewählt')).toBeVisible();
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'PDFs zusammenfügen' }).click();
await expectPdfDownload(await downloadPromise, /^zusammengefuegt\.pdf$/);
expect(pageErrors).toEqual([]);
});
test('separate renders dropped PDF pages and removes a selected page', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/separate');
await dropFiles(page, pdfDropzone(page), [
{ name: 'seiten.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Trennen') }
]);
await expect(page.getByRole('button', { name: /Seite 1/ })).toBeVisible({ timeout: 15_000 });
await page.getByRole('button', { name: /Seite 2/ }).click();
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Seiten entfernen & herunterladen' }).click();
await expectPdfDownload(await downloadPromise, /^seiten_ohne_seiten\.pdf$/);
expect(pageErrors).toEqual([]);
});
test('watermark accepts numeric controls and downloads text and image watermarks', async ({
page
}) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/watermark');
await dropFiles(page, pdfDropzone(page), [
{ name: 'marke.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Marke') }
]);
await expect(page.getByRole('heading', { name: 'Wasserzeichen-Einstellungen' })).toBeVisible({
timeout: 15_000
});
await page.locator('#watermark-color').fill('#00ff00');
await page.locator('#watermark-opacity').fill('0.45');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Wasserzeichen anwenden & herunterladen' }).click();
await expectPdfDownload(await downloadPromise, /^marke_wasserzeichen\.pdf$/);
await page.getByLabel('Bild', { exact: true }).check();
await dropFiles(page, page.getByRole('button', { name: 'Wasserzeichenbild hinzufügen' }), [
{
name: 'logo.png',
mimeType: 'image/png',
buffer: Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Z5rAAAAAASUVORK5CYII=',
'base64'
)
}
]);
const imageDownloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Wasserzeichen anwenden & herunterladen' }).click();
await expectPdfDownload(await imageDownloadPromise, /^marke_wasserzeichen\.pdf$/);
await expect(page.getByText('Fehler beim Anwenden')).toHaveCount(0);
expect(pageErrors).toEqual([]);
});
test('encrypt creates a protected PDF and decrypt rebuilds it with the password', async ({
page
}) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/encrypt');
await dropFiles(page, pdfDropzone(page), [
{ name: 'geheim.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Geheim') }
]);
await page.locator('#encrypt-password').fill('test-passwort');
await page.locator('#encrypt-password-confirmation').fill('test-passwort');
const encryptedDownloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Passwort setzen & herunterladen' }).click();
const encryptedDownload = await encryptedDownloadPromise;
const encryptedBytes = await expectPdfDownload(encryptedDownload, /^geheim_geschuetzt\.pdf$/);
await page.goto('/tools/decrypt');
await dropFiles(page, pdfDropzone(page), [
{
name: 'geheim_geschuetzt.pdf',
mimeType: 'application/pdf',
buffer: encryptedBytes
}
]);
await page.locator('#decrypt-password').fill('test-passwort');
const decryptedDownloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Passwort entfernen & herunterladen' }).click();
await expectPdfDownload(await decryptedDownloadPromise, /^geheim_geschuetzt_entsperrt\.pdf$/);
expect(pageErrors).toEqual([]);
});
test('compress previews result size before downloading', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/compress');
await dropFiles(page, pdfDropzone(page), [
{ name: 'gross.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Komprimieren') }
]);
await page.getByRole('button', { name: 'Komprimieren', exact: true }).click();
await expect(page.getByText(/Ergebnisgröße:/)).toBeVisible({ timeout: 20_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'PDF herunterladen' }).click();
await expectPdfDownload(await downloadPromise, /^gross_komprimiert\.pdf$/);
expect(pageErrors).toEqual([]);
});
test('convert bundles a multi-page PDF as a correctly named ZIP', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/convert');
await dropFiles(page, pdfDropzone(page), [
{ name: 'bilder.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Bilder') }
]);
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Konvertieren & herunterladen' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('bilder_bilder.zip');
expect(download.suggestedFilename()).not.toContain('.pdf');
expect(pageErrors).toEqual([]);
});