This commit is contained in:
@@ -7,6 +7,8 @@ node_modules
|
|||||||
.wrangler
|
.wrangler
|
||||||
/.svelte-kit
|
/.svelte-kit
|
||||||
/build
|
/build
|
||||||
|
/playwright-report
|
||||||
|
/test-results
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -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
|
||||||
|
├── tools/
|
||||||
|
│ ├── +layout.svelte # Shared back-navigation shell
|
||||||
|
│ ├── bea/+page.svelte # Existing ZIP archive workflow
|
||||||
|
│ └── {merge,separate,watermark,encrypt,decrypt,compress,convert}/
|
||||||
├── datenschutz/+page.svelte # Privacy policy
|
├── datenschutz/+page.svelte # Privacy policy
|
||||||
└── impressum/+page.svelte # Imprint
|
└── impressum/+page.svelte # Imprint
|
||||||
static/ # Files served unchanged from the site root
|
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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
Generated
+157
-26
@@ -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: {}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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);
|
||||||
@@ -11,21 +11,20 @@ export function ripple(node: HTMLElement) {
|
|||||||
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,76 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onFilesSelected: (files: File[]) => void;
|
onFilesSelected: (files: File[]) => void;
|
||||||
class?: string;
|
class?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { onFilesSelected, class: className = "" }: Props = $props();
|
let { onFilesSelected, class: className = '' }: Props = $props();
|
||||||
|
|
||||||
let fileInput: HTMLInputElement | null = null;
|
|
||||||
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"
|
|
||||||
type="file"
|
|
||||||
accept=".zip,application/zip,application/x-zip-compressed"
|
accept=".zip,application/zip,application/x-zip-compressed"
|
||||||
multiple
|
multiple={true}
|
||||||
onchange={handleFileSelection}
|
ariaLabel="ZIP-Dateien hinzufügen"
|
||||||
|
label="Dateien hinzufügen"
|
||||||
|
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>
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
let pendingZipFiles: File[] = [];
|
||||||
|
|
||||||
|
export const queueZipFiles = (files: File[]) => {
|
||||||
|
pendingZipFiles = [...pendingZipFiles, ...files];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const takeQueuedZipFiles = () => {
|
||||||
|
const files = pendingZipFiles;
|
||||||
|
pendingZipFiles = [];
|
||||||
|
return files;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -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 };
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
+125
-187
@@ -1,211 +1,149 @@
|
|||||||
<script lang="ts">
|
<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 {
|
import {
|
||||||
extractZipArchives,
|
Archive,
|
||||||
mergeProcessedZipArchive,
|
Droplets,
|
||||||
type ProcessedZipArchive
|
Lock,
|
||||||
} from '$lib/zip-processing';
|
Unlock,
|
||||||
import * as Card from "$lib/components/ui/card/index";
|
Image,
|
||||||
|
Minimize2,
|
||||||
|
Scissors,
|
||||||
|
Merge,
|
||||||
|
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,
|
||||||
let selectedZipFiles: File[] = $state([]);
|
lock: Lock,
|
||||||
let processedZipFiles: ProcessedZipArchive[] = $state([]);
|
unlock: Unlock,
|
||||||
let pendingZipCount = $state(0);
|
image: Image,
|
||||||
let thumbnailWidth = $state(200);
|
minimize: Minimize2,
|
||||||
let thumbnailWidthHydrated = $state(false);
|
scissors: Scissors,
|
||||||
let archiveGeneration = 0;
|
merge: Merge
|
||||||
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!thumbnailWidthHydrated) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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) => {
|
let isDraggingFiles = $state(false);
|
||||||
const pdfBlob = new Blob([toArrayBuffer(bytes)], {type: 'application/pdf'});
|
|
||||||
const objectUrl = URL.createObjectURL(pdfBlob);
|
|
||||||
const downloadLink = document.createElement('a');
|
|
||||||
|
|
||||||
downloadLink.href = objectUrl;
|
const isZipFile = (file: File) =>
|
||||||
downloadLink.download = fileName.toLowerCase().endsWith('.pdf') ? fileName : `${fileName}.pdf`;
|
file.name.toLowerCase().endsWith('.zip') ||
|
||||||
downloadLink.rel = 'noopener';
|
file.type === 'application/zip' ||
|
||||||
downloadLink.click();
|
file.type === 'application/x-zip-compressed';
|
||||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
|
||||||
|
const openBeaFiles = (files: File[]) => {
|
||||||
|
const zipFiles = files.filter(isZipFile);
|
||||||
|
if (zipFiles.length === 0) return;
|
||||||
|
|
||||||
|
queueZipFiles(zipFiles);
|
||||||
|
void goto('/tools/bea');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFilesSelected = (files: File[]) => {
|
const handleWindowDragOver = (event: DragEvent) => {
|
||||||
selectedZipFiles = [...selectedZipFiles, ...files];
|
if (!event.dataTransfer?.types.includes('Files')) return;
|
||||||
|
event.preventDefault();
|
||||||
for (const file of files) {
|
isDraggingFiles = true;
|
||||||
void processZipFile(file, archiveGeneration);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const processZipFile = async (file: File, generation: number) => {
|
const handleWindowDragLeave = (event: DragEvent) => {
|
||||||
pendingZipCount += 1;
|
if (event.relatedTarget === null) isDraggingFiles = false;
|
||||||
|
|
||||||
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) => {
|
const handleWindowDrop = (event: DragEvent) => {
|
||||||
processedZipFiles = processedZipFiles.map((existingArchive, existingIndex) =>
|
if (!event.dataTransfer?.files.length) return;
|
||||||
existingIndex === index ? archive : existingArchive
|
event.preventDefault();
|
||||||
);
|
isDraggingFiles = false;
|
||||||
};
|
openBeaFiles(Array.from(event.dataTransfer.files));
|
||||||
|
|
||||||
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}/>
|
<svelte:window
|
||||||
|
ondragover={handleWindowDragOver}
|
||||||
|
ondragleave={handleWindowDragLeave}
|
||||||
|
ondrop={handleWindowDrop}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if isDraggingFiles}
|
||||||
|
<div
|
||||||
|
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"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Archive class="mx-auto h-12 w-12" />
|
||||||
|
<p class="mt-4 text-xl font-bold">ZIP-Archiv hier ablegen</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-10 px-4 py-10">
|
||||||
|
<!-- Hero -->
|
||||||
|
<div class="flex flex-col items-center gap-4 text-center">
|
||||||
|
<h1 class="text-[28px] font-bold text-primary-900">PDF-Werkzeuge</h1>
|
||||||
|
<p class="max-w-lg text-[15px] text-primary-700">
|
||||||
|
Alle Werkzeuge laufen zu 100 % in Ihrem Browser — keine Dateien werden hochgeladen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hero tile: beA workflow -->
|
||||||
|
<a href="/tools/bea" class="block w-full max-w-2xl mx-auto">
|
||||||
|
<Card.Root class="transition hover:shadow-md cursor-pointer">
|
||||||
|
<Card.Content class="flex flex-col items-center gap-4 py-10">
|
||||||
|
<div
|
||||||
|
class="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary text-primary-foreground"
|
||||||
|
>
|
||||||
|
<Archive class="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<h2 class="text-[18px] font-bold text-primary-900">ZIP-Archiv bearbeiten</h2>
|
||||||
|
<p class="mt-1 text-[14px] text-primary-700">
|
||||||
|
beA-Archive verarbeiten, Teildokumente erstellen und als PDF exportieren.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
{:else}
|
</a>
|
||||||
<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
|
<!-- ZIP drop entry point -->
|
||||||
class="absolute bottom-14 right-0 w-72 rounded-xl border border-primary-100 bg-white p-4 shadow-lg"
|
<div class="mx-auto w-full max-w-2xl">
|
||||||
>
|
<FileDropzone
|
||||||
<div class="flex flex-col gap-3">
|
onFilesSelected={openBeaFiles}
|
||||||
<div class="flex gap-2">
|
accept=".zip,application/zip,application/x-zip-compressed"
|
||||||
<Button
|
multiple={true}
|
||||||
class="flex-1"
|
ariaLabel="ZIP-Dateien hinzufügen"
|
||||||
type="button"
|
label="ZIP-Datei hier ablegen"
|
||||||
disabled={processedZipFiles.length === 0}
|
sublabel="oder zum Auswählen klicken"
|
||||||
onclick={exportAllZipArchivesAsPdf}
|
class="py-6"
|
||||||
>
|
|
||||||
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>
|
||||||
</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}
|
<!-- Tool grid -->
|
||||||
<div class="flex flex-col gap-4">
|
<div class="mx-auto w-full max-w-4xl">
|
||||||
{#each processedZipFiles as file, index}
|
<h2 class="mb-4 text-[18px] font-bold text-primary-900">Werkzeuge</h2>
|
||||||
<ProcessedZipArchiveEditor
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
archive={file}
|
{#each tools as tool}
|
||||||
{thumbnailWidth}
|
{@const Icon = iconMap[tool.icon] ?? FileText}
|
||||||
onArchiveChange={(updatedArchive) => updateProcessedZipFile(index, updatedArchive)}
|
<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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<section class="px-4">
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3 py-6">
|
|
||||||
<h1 class="h1 text-[20px] font-bold text-primary-900">beA-Edit</h1>
|
|
||||||
</div>
|
|
||||||
{@render content()}
|
|
||||||
</section>
|
|
||||||
|
|||||||
+9
-11
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user