Author SHA1 Message Date
rehlert ca06ca2050 subdocument-splitting-plan 2026-10-06 22:52:35 +02:00
rehlert f09384d2e6 pwa / offline mode
- @vite-pwa/sveltekit: workbox precache (incl. pdfjs worker), generated manifest, prompt-style updates via PwaUpdatePrompt toast
- prerendered routes for offline navigation, PWA icons, theme-color + apple-touch-icon
- pwa offline/update e2e suite (pnpm test:pwa, playwright.pwa.config.ts)
- datenschutz: offline/install section; prettier cleanup of shadcn components
2026-10-04 12:55:46 +02:00
rehlert b7969b4c9e metadata
Build and Push Docker Image / build (push) Successful in 45s
2026-08-29 12:31:06 +02:00
rehlert d71b25f834 splitting tests 2026-08-29 12:10:26 +02:00
98 changed files with 9518 additions and 1218 deletions

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
# PWA / Offline-Fähigkeit (plan/pwa-plan.md)
- [x] Add `@vite-pwa/sveltekit` dev dependency (pnpm, plus explicit `vite-plugin-pwa`/`workbox-window` for pnpm strict resolution)
- [x] Generate PWA icons (192 / 512 / maskable / apple-touch) from `static/favicon.svg`
- [x] Configure `SvelteKitPWA` in `vite.config.ts` (manifest, workbox glob patterns incl. pdfjs `.mjs` worker, navigateFallback)
- [x] Enable prerendering so route documents are precached (`src/routes/+layout.ts`)
- [x] Add PWA client type references (`src/app.d.ts`)
- [x] Update toast component + `virtual:pwa-info` manifest link in root layout
- [x] Add `theme-color` meta + apple-touch-icon
- [x] Production-build verification: SW registers, offline document/tool navigation, offline ZIP + merge chain, update toast (defer + activate) — `pnpm test:pwa`
- [x] Run `pnpm check` (0 errors), `pnpm build`
- [x] Update `agents.md`
- [x] `pnpm lint`: 19 formatting failures fixed via `pnpm format` (shadcn-generated
`src/lib/components/ui/` and `src/lib/components/icons/`, plus `AttachmentPreview.svelte`;
pure reformatting, no logic changes)
- [ ] `datenschutz` wording: draft added to section 4 (“Offline-Nutzung als App (Progressive Web
App)”) in `src/routes/datenschutz/+page.svelte` — pending user review/approval (D3)
+79 -16
View File
@@ -3,8 +3,9 @@
## Project overview
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.
**beA-Edit**. Users add one or more beA ZIP archives or PDF files in the browser, inspect and
reorder supported attachments, group attachments into named sub-documents (including page-level
PDF splitting), 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.
@@ -22,6 +23,7 @@ The application also contains German imprint and privacy-policy routes.
- 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`
- PWA/offline support through `@vite-pwa/sveltekit` (Workbox precache, generated web manifest)
- pnpm with a committed lockfile
- Prettier with Svelte and Tailwind plugins
@@ -37,6 +39,7 @@ pnpm preview # Preview the production build
pnpm check # Run Svelte and TypeScript diagnostics
pnpm check:watch # Run diagnostics in watch mode
pnpm test:e2e # Run the Playwright toolbox tests in Chromium
pnpm test:pwa # PWA offline/update tests against the production build (requires pnpm build)
pnpm lint # Check repository formatting with Prettier
pnpm format # Format the repository with Prettier
```
@@ -67,14 +70,17 @@ src/
│ ├── assets/ # Favicon and local Nunito fonts
│ ├── components/
│ │ ├── AttachmentPreview.svelte # Browser-rendered PDF/image thumbnails
│ │ ├── AttachmentMenu.svelte # Per-attachment dropdown menu (move, split, remove)
│ │ ├── BatchFileList.svelte # Shared batch rows: status chips, downloads, retry
│ │ ├── BeaArchiveProcessing.svelte
│ │ ├── BeaWorkspaceControls.svelte # beA workspace toolbar, settings popover, dialogs
│ │ ├── ProcessedZipArchiveEditor.svelte
│ │ ├── PdfSplitDialog.svelte # Page-level PDF splitting into Teildokumente
│ │ ├── SubDocumentEditor.svelte
│ │ ├── FileDropzone.svelte # Shared PDF/image/ZIP file picker and drop target
│ │ ├── ZipFilePicker.svelte # Compact ZIP picker button with hidden resettable input
│ │ ├── PdfPageGrid.svelte # Accessible PDF thumbnail selection grid
│ │ ├── PwaUpdatePrompt.svelte # Service-worker registration and update/offline toast
│ │ ├── RasterizationWarning.svelte
│ │ ├── ZipDropzone.svelte # beA-specific wrapper around FileDropzone
│ │ ├── icons/ # Project-specific SVG components
@@ -84,6 +90,7 @@ src/
│ │ └── zip-inflating.service.ts # Async fflate wrapper
│ ├── batch.ts # Sequential batch engine for multi-file tools
│ ├── pdf-compression.ts # Shared compression presets and rasterize pipeline
│ ├── pdf-metadata.ts # PDF metadata inspection and cleaning (pdf-lib only)
│ ├── pdf-overlay.ts # Shared PDF overlay positions and color parsing
│ ├── pdf-processing.ts # Reusable PDF merge helper
│ ├── pdf-thumbnails.ts # Managed pdfjs thumbnail loading and cleanup
@@ -91,66 +98,119 @@ src/
│ └── zip-processing.ts # Archive model, transforms, and PDF export
└── routes/
├── +layout.svelte # Shared logo header and branded footer
├── +layout.ts # `prerender = true` for all routes (PWA offline documents)
├── +page.svelte # Toolbox hub and global beA ZIP drop target
├── layout.css # Tailwind, shadcn, theme, fonts, global CSS
├── tools/
│ ├── +layout.svelte # Shared back-navigation shell
│ ├── bea/+page.svelte # Existing ZIP archive workflow
│ └── {merge,separate,stamp,watermark,encrypt,decrypt,compress,convert,rotate}/
│ └── {merge,separate,stamp,watermark,encrypt,decrypt,compress,convert,rotate,metadata}/
├── datenschutz/+page.svelte # Privacy policy
└── impressum/+page.svelte # Imprint
static/ # Public logo, footer art, and robots.txt
static/ # Public logo, footer art, PWA icons, and robots.txt
components.json # shadcn-svelte registry configuration
Dockerfile # Node adapter production image
playwright.pwa.config.ts # Playwright config for `pnpm test:pwa`
plan/ # Design and implementation notes
```
Generated directories such as `.svelte-kit/`, `build/`, and `node_modules/` must not be edited or
committed.
## PWA / offline mode
- `SvelteKitPWA` is configured in `vite.config.ts` with `registerType: 'prompt'`: a waiting service
worker never activates by itself. `PwaUpdatePrompt.svelte` registers the worker in the browser and
shows a toast (“Neue Version verfügbar” with “Neu laden”/“Später”, plus a one-time hint when the
app is ready offline). Never force a reload — an in-progress archive must survive a declined
update.
- All routes are prerendered (`src/routes/+layout.ts`), so route documents land in the precache and
offline navigation works for every page, including a direct deep link. `navigateFallback: '/'`
covers unknown paths.
- Precache contents come from `workbox.globPatterns`: the plugin's client/prerendered defaults plus
`mjs` (the pdfjs worker chunk, required for offline rendering) and the local fonts; the unused
`static/Nunito/**` font drop is excluded via `globIgnores`. Precache size is ~4.5 MB.
- PWA icons in `static/icons/` (192/512, maskable, apple-touch) were generated once from
`static/favicon.svg`; regenerate them with ImageMagick/rsvg if the logo changes. The manifest link
is injected client-side from `virtual:pwa-info` in the root layout, `theme-color` and the
apple-touch-icon live in `app.html`.
- `devOptions.enabled` stays `false`: `pnpm dev` never registers a service worker, so development
caches stay clean. Verify PWA behaviour only against a production build (`pnpm build` and
`pnpm preview`, or `pnpm test:pwa`, which also covers the offline processing chain and the update
toast).
- Simulating a deployment in tests requires restarting `node build/index.js`: adapter-node's static
handler (sirv, non-dev mode) walks the build directory once at startup and caches the ETags, so
swapped files are invisible to a running process. Browsers revalidate `/sw.js` via its ETag on
navigation and `registration.update()`, so the normal container restart on deploy is sufficient.
## 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.
- `/` is the toolbox hub. A beA ZIP or PDF dropped anywhere on that route is queued in memory and
forwarded to `/tools/bea`; the file is not uploaded or persisted.
- `/tools/metadata` is a single-PDF inspection tool. `src/lib/pdf-metadata.ts` reads document
properties with pdf-lib only (no rasterization), returns a discriminated `{ status: 'encrypted' }`
result for password-protected files, and detects embedded-file markers by scanning raw bytes.
Cleaning neutralizes the standard Info dictionary fields and re-saves through pdf-lib; embedded
files and XMP streams are out of scope and are disclosed in the UI instead.
- `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.
- The loaded workspace renders `BeaWorkspaceControls.svelte`, a top-aligned toolbar with the
workspace status and two archive lifecycle actions:
- **“Neue ZIP bearbeiten”** (replace) opens a ZIP picker and, after the user confirms the
- **“Neue Datei bearbeiten”** (replace) opens a file picker and, after the user confirms the
replacement dialog, calls `replaceWithZipFiles(files)` which resets the workspace and enqueues the
new files against the incremented generation. Files picked for replacement stay in temporary
component state until confirmation; cancelling the picker or the dialog must never mutate the
current workspace.
- **“Weitere ZIP hinzufügen”** (append) lives in the settings popover, uses the plain append path
- **“Weitere Datei hinzufügen”** (append) lives in the settings popover, uses the plain append path
(`handleFilesSelected`), and never clears current archives. Keep the two labels distinct:
replace is “Neue ZIP bearbeiten”, append is “Weitere ZIP hinzufügen”.
replace is “Neue Datei bearbeiten”, append is “Weitere Datei hinzufügen”.
- `BeaWorkspaceControls.svelte` owns only ephemeral UI state (popover/dialog open state and pending
replacement files). The route remains the source of truth for files, jobs, archives, and the
persisted thumbnail width.
- `resetWorkspace()` is the single teardown helper: it increments `archiveGeneration` and clears
selected files, processed archives, and jobs. Never clear those arrays without going through it.
- Accepted ZIP types and the file filter for the dropzone and both compact pickers are centralized in
`src/lib/zip-selection.ts`; `ZipFilePicker.svelte` resets its input after every selection so the
- Accepted file types for the workspace (beA ZIP **and** PDF) are centralized in
`src/lib/zip-selection.ts` and shared by the dropzone, the start-page drop target, and both
compact pickers; `ZipFilePicker.svelte` resets its input after every selection so the
same file can be chosen again.
- ZIP files are processed concurrently. `archiveGeneration` prevents results from an old batch from
reappearing after the user deletes all archives **or** confirms a replacement.
- Workspace files are processed concurrently. PDF files become single-attachment archives via
`pdfFileToArchive`; ZIP files go through `extractZipArchives`. `archiveGeneration` prevents
results from an old batch from reappearing after the user deletes all archives **or** confirms a
replacement.
- `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.
- **Gruppieren** (grouping mode) offers archive-wide multi-select over loose and sub-document
attachments: create a new Teildokument from the selection, move the selection into an existing
Teildokument, select all/none. Exiting with **Fertig** or Escape clears the pending selection.
New Teildokumente are numbered via `nextSubDocumentNumber` (max existing trailing number, not
list length), and the created block's name input is focused and scrolled into view.
- Each attachment card has an **“⋯” menu** (`AttachmentMenu.svelte`) as the keyboard/touch path:
move into an existing or new Teildokument, move back to the loose list, split PDFs, and remove
(with confirmation). It must never become the only way to perform an action that drag-and-drop
also covers.
- **PDF splitting**: `PdfSplitDialog.svelte` opens per PDF attachment, renders pages through
`loadPdfDocument` + `PdfPageGrid`, and assigns page sets to named groups (manual selection,
“Jede Seite einzeln”, or “Alle N Seiten”). Cut is the default; “Seiten im Original behalten”
switches to copy. `applyPdfSplit` replaces the source with the remaining pages (or removes it),
inserts the new Teildokumente after the source's parent block, and the synthetic attachments
get unique `#seiten-…` path suffixes because `path` is the identity for keyed lists, drag
payloads, and 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.
- An attachment move must not duplicate or discard data. Splitting cuts pages by default and only
duplicates them when the user explicitly chooses copy mode.
- 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.
- Splitting inserts the new Teildokumente directly after the source's parent block (loose source:
appended at the end) so the reading order is preserved.
- State helpers in `zip-processing.ts` should return new archive/list objects rather than mutating
component-owned state.
@@ -168,6 +228,9 @@ All case-file processing is intentionally local to the browser.
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.
- Page-set copies (separate/extract tools, PDF splitting) share
`copyPagesToNewPdf(sourceBytes, pageIndexes)` in `src/lib/pdf-processing.ts` so the copy
semantics cannot drift apart.
- `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.
+6 -2
View File
@@ -12,7 +12,8 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test:e2e": "playwright test",
"lint": "prettier --check .",
"format": "prettier --write ."
"format": "prettier --write .",
"test:pwa": "playwright test -c playwright.pwa.config.ts"
},
"devDependencies": {
"@fontsource-variable/geist": "^5.2.9",
@@ -25,6 +26,7 @@
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^26.3.0",
"@vite-pwa/sveltekit": "^1.1.0",
"bits-ui": "2.18.1",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
@@ -39,7 +41,9 @@
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.3",
"vite": "^8.0.16"
"vite": "^8.0.16",
"vite-plugin-pwa": "^1.3.0",
"workbox-window": "^7.4.1"
},
"dependencies": {
"@cantoo/pdf-lib": "^2.9.1",
+143
View File
@@ -0,0 +1,143 @@
# Plan: Batch-Verarbeitung in den Einzel-PDF-Tools
## Goal
Several tools process exactly one PDF at a time, but real legal work is repetitive: compress ten
scans, watermark five drafts, encrypt three exhibits. This plan generalizes the single-file
tools to accept **multiple files**, process them sequentially with per-file status, and download
results individually or as one ZIP.
---
## Open decisions (defaults marked ✅)
### D1 — Rollout scope
- ✅ **A. Phased rollout, starting with compress.** Compress is the highest-repetition tool and
already has the cleanest input→output shape. Phase 2: watermark (needs option application per
file), stamp/page-numbers (same pattern). Phase 3: encrypt/decrypt (decrypt needs a password
for all files — one shared password input).
- B. All tools at once. Too much surface; the shared pattern should prove itself first.
### D2 — UX model
- ✅ **A. Always multi-file.** The dropzone accepts multiple PDFs from day one; a single dropped
file simply renders a one-row list. No mode switch. Download: one button per finished row
plus "Alle als ZIP", which appears once ≥2 files are done.
- B. Explicit "Batch-Modus" toggle. Extra clicks and duplicated code paths.
### D3 — Failure behavior
- ✅ **Continue on error.** A failing file is marked with a German error message in its row; the
queue keeps processing the rest. "Erneut versuchen" per failed row. This matches how lawyers
will use it (one broken scan must not kill the run).
---
## UX
Per retrofitted tool page:
1. `PdfDropzone` with `multiple` enabled; dropping more files appends to the current list
(does not replace it) — matches how the start page queues ZIPs.
2. **File list** below the options card: one row per file —
name, size, status chip (Wartet / Verarbeite… / Fertig ✓ mit neuer Größe / Fehler),
per-row download button (enabled when done), per-row remove, "Erneut versuchen" on error.
3. Progress line during a run: "Datei 3 von 7 wird verarbeitet…".
4. Footer actions: **"Alle herunterladen"** (ZIP via fflate, named
`{toolname}_{date}.zip`, e.g. `komprimiert_2026-02-12.zip`) visible when ≥2 results exist.
5. Options (preset, watermark settings, password…) apply to **all** files in the run; changing
options while a run is active affects only subsequent files (documented in helper text).
## Technical approach
### Shared batch engine (`src/lib/components/BatchFileList.svelte` + `src/lib/batch.ts`)
```ts
// src/lib/batch.ts
export type BatchItemStatus = 'pending' | 'processing' | 'done' | 'error';
export type BatchItem = {
id: string; // crypto.randomUUID()
file: File;
status: BatchItemStatus;
resultBytes?: Uint8Array;
resultName?: string;
errorMessage?: string | null;
};
export const runBatch = async (
items: BatchItem[],
process: (file: File) => Promise<{ bytes: Uint8Array; name: string }>,
update: (id: string, patch: Partial<BatchItem>) => void
): Promise<void>;
```
- `runBatch` iterates **sequentially** (memory protection: only one PDF in flight), calls
`update` per lifecycle step so the component re-renders status chips, and never throws —
per-item errors are caught and written to the item.
- `BatchFileList.svelte` renders rows, emits callbacks (`onDownload(item)`, `onRemove(id)`,
`onRetry(id)`); row buttons are real buttons with accessible labels including the file name.
- Result bytes stay in memory until downloaded or removed; revocation concerns don't apply
(downloads use the existing `downloadBlob` object-URL pattern with timeout revocation). A
summary line shows total result size so users notice runaway memory situations.
- Very large runs: warn (>20 files or >25 MB per file in rasterizing tools) with a German note;
do not hard-block.
### Retrofit pilot: compress
- Extract per-file compression into `compressWithPreset` (already planned in
`bea-size-limit-plan.md` — coordinate: whichever ships first creates `pdf-compression.ts`).
- Page state becomes `items = $state<BatchItem[]>([])` + options; the old single-result UI
(`compressedBytes` etc.) is replaced by the list. Zielgröße mode + batch: each file runs the
preset loop independently.
- Rasterization guards apply per file: files >100 MB are immediately marked as error rows with
the rasterization message instead of blocking the whole batch.
### Rollout pattern for later tools
- Watermark/stamp/page-numbers: identical wiring; the pdf-lib overlay loop is already per-page
and per-file load is isolated.
- Encrypt: one password for all files (password inputs stay at the top); per-file failure shows
which file broke.
- Decrypt: one shared password input + rasterization caveat once, per-file status as above.
## Files changed
- `src/lib/batch.ts` — new batch engine.
- `src/lib/components/BatchFileList.svelte` — new shared list component.
- `src/routes/tools/compress/+page.svelte` — pilot retrofit.
- (Phase 2/3) watermark/stamp/page-numbers/encrypt/decrypt pages.
- `tests/e2e/toolbox.spec.ts` — spec blocks per retrofitted tool.
- `agents.md` — document the batch pattern for future tools.
## Implementation phases
1. `batch.ts` + `BatchFileList.svelte` (build against a mock process function first).
2. Compress pilot retrofit (depends on `pdf-compression.ts` extraction).
3. E2E for compress batch: 2 PDFs → both done, ZIP contains 2 entries; one corrupt file →
error row, other still succeeds; retry works.
4. Phase 2 tools, Phase 3 tools (separate PRs).
5. Polish: copy, memory summary line, warning thresholds.
## Verification
- Single file dropped → identical outcome to today's single-file flow (same output bytes for the
same preset).
- Mixed success/failure batch: statuses correct, ZIP includes only successful results.
- Remove a finished row → its bytes freed (no download offered afterwards).
- Option change mid-run affects only following files.
- `pnpm check`, `pnpm lint`, `pnpm build`, `pnpm test:e2e` pass.
## Risks
- **Memory with many results held simultaneously**: 30 compressed PDFs × several MB can strain
low-end machines. Mitigations: per-row remove, total-size summary, and a follow-up "download
and clear" convenience. Do not stream to disk (browser constraint).
- **Sequential processing is slow for many large files** — inherent to memory safety; progress
line sets expectations.
## Out of scope
- Parallel processing.
- Cross-tool pipelines (compress → watermark in one run) — separate roadmap item.
- Per-file option overrides (v1 applies one option set to the whole batch).
@@ -0,0 +1,315 @@
# Plan: beA settings menu and “next ZIP” workflow
**Status: Done**
## Current review
The loaded-archive state in `src/routes/tools/bea/+page.svelte` uses a native `<details>` menu fixed
in the lower-right corner. I reviewed it at 1440 × 900 and 390 × 844 with a real one-PDF ZIP.
### What is not working
- The floating gear is detached from the archive it controls and competes with the footer/content
edge. On mobile, the open panel overlaps the archive card.
- Two long actions are forced side by side into a 288 px panel. Their labels visibly clip to
“Archive als PDF” and “Archive”, so the difference between export and delete is unclear.
- The emoji gear does not match the app's Lucide icon language. Its accessible name is the English
word “Settings” in an otherwise German interface.
- View configuration, bulk export, and destructive session deletion are mixed without hierarchy.
“Alle ZIP-Archive löschen” receives nearly the same visual weight as export and has no
confirmation.
- “Thumbnail Breite” is an implementation-oriented, partly English label. The control should be
described as the user-visible result: “Vorschaugröße”.
- Native `<details>` does not provide the expected menu behavior here: no reliable outside-click
dismissal, weak focus management, and no deliberate focus return.
- Once any ZIP has loaded, the dropzone disappears. Although the route can append files internally,
the user has no control to open another ZIP or begin a clean next task.
- The loaded archive is vertically centered in a viewport-height container. This creates a large
gap below “beA-Edit” on both desktop and mobile and makes the fixed settings control feel even more
disconnected.
## Product decision
Use two explicit archive lifecycle actions:
1. **“Neue ZIP bearbeiten”** is the prominent next-task action. It replaces the current workspace
only after the user has selected a valid ZIP and confirmed the replacement. This directly serves
the requested “I am done; give me the next ZIP” workflow.
2. **“Weitere ZIP hinzufügen”** remains available inside the actions panel for users intentionally
working with multiple ZIPs at once. It appends to the current workspace, preserving the behavior
already supported by `handleFilesSelected`.
The replacement confirmation is shown _after_ file selection. Cancelling either the system picker or
confirmation must leave the current work untouched. The confirmation says that the current view and
unexported changes will be removed, while files already downloaded remain on the device.
Do not try to infer that a user is “done” from a download click. A download does not prove that every
wanted output was saved, and the browser cannot reliably verify what happened to the file afterward.
## UX direction
### Desktop
```text
← Zurück
beA-Edit 1 ZIP · 1 Archiv geöffnet [+ Neue ZIP bearbeiten] [⚙ Einstellungen]
──────────────────────────────────────────────────────────────────────────────────────────
┌ Current archive editor ────────────────────────────────────────────────────────────────┐
│ archive name [Auswählen] [PDF herunterladen] │
│ attachment previews … │
└────────────────────────────────────────────────────────────────────────────────────────┘
```
Settings popover, aligned to the right edge of the toolbar:
```text
┌ Einstellungen ─────────────────────────────┐
│ ANSICHT │
│ Vorschaugröße 200 px │
│ Klein ─────────●────────────── Groß │
│ │
│ ARCHIV-AKTIONEN │
│ [+ Weitere ZIP hinzufügen] │
│ [⇩ Alle Archive als PDF herunterladen] │ shown only when useful
│ ───────────────────────────────────────── │
│ [🗑 Aktuelle Bearbeitung leeren] │ quiet destructive action
└───────────────────────────────────────────┘
```
### Mobile
The toolbar becomes a two-row block immediately below the title and remains in normal document flow.
The status occupies the first row; the two buttons share the second row. The popover uses
`width: min(22rem, calc(100vw - 2rem))`, stacks every action vertically, and relies on collision-aware
positioning rather than fixed bottom/right coordinates. No control or panel may cover the archive
header.
The loaded workspace becomes top-aligned (`justify-start`) instead of viewport-centered, removing the
large empty band between the page heading and first archive.
## Visual system
Preserve the existing Nominandum identity rather than introducing a new theme:
- **Ink** `#122F62`: headings, primary actions, and active controls.
- **Workspace** `#EEF4F6`: page background.
- **Paper** `#FFFFFF`: editor and popover surfaces.
- **Handoff coral** `#EA544B`: restrained accent only; do not use it to make deletion look primary.
- **Success** `#4E8A72`: completed processing state.
- Typography remains **Nunito** for interface copy; use the existing Geist utility face only where
numeric/file metadata already uses it.
- Use existing radii and semantic CSS variables from `src/routes/layout.css`; no theme-level CSS
changes should be necessary.
The distinctive element is the **archive handoff row**: current-work status on the left and “Neue ZIP
bearbeiten” on the right. It expresses the actual legal-document rhythm—finish one beA export, hand
it off, open the next—rather than presenting a generic floating settings cog.
Self-critique: a card full of settings tabs would be a generic dashboard solution and is unnecessary
for one view preference. The revised direction removes the floating action, keeps the next-case task
visible, and spends visual emphasis only on the archive handoff.
## Interaction details
### Toolbar
- Status copy derives from state:
- `1 ZIP · 1 Archiv geöffnet`
- `2 ZIPs · 3 Archive geöffnet`
- while work is pending, append `· 1 wird geöffnet` in an `aria-live="polite"` status.
- “Neue ZIP bearbeiten” uses Lucide `FilePlus2` and remains available after the first archive or an
error. It opens a ZIP-only file picker.
- “Einstellungen” uses Lucide `Settings2`, has a visible German label at desktop sizes, and retains
an explicit accessible name at all widths.
- Do not make the toolbar fixed or sticky in v1. Normal flow avoids covering previews and footer
content; sticky behavior can be reconsidered after testing long archives.
### New ZIP replacement
1. User activates “Neue ZIP bearbeiten”.
2. The system file picker opens and accepts the same ZIP types as `ZipDropzone`.
3. If the picker is cancelled, nothing changes.
4. If valid files are selected, show an `AlertDialog`:
- title: **“Aktuelle Bearbeitung ersetzen?”**
- description: **“Archive und nicht heruntergeladene Änderungen in dieser Ansicht werden
entfernt. Bereits heruntergeladene PDFs bleiben erhalten.”**
- cancel: **“Abbrechen”**
- confirm: **“Neue ZIP öffnen”**
5. On confirm, increment `archiveGeneration`, clear selected files, processed archives, and jobs, then
enqueue the newly selected files against the new generation.
6. If extraction fails, show the existing processing error state for the new ZIP and allow retry or
removal; do not restore the old workspace implicitly.
Keep the selected replacement files in temporary component state until confirmation. This prevents a
cancelled dialog from mutating or clearing the current workspace. Reset the underlying file input
after every selection so choosing the same ZIP again still fires `change`.
### Append, export, and clear
- “Weitere ZIP hinzufügen” uses the existing append path and never clears current archives.
- Show bulk export only when there are at least two processed archives; one archive already has its
own clearer “PDF herunterladen” action.
- Rename bulk export to **“Alle Archive als PDF herunterladen”**.
- Rename deletion to **“Aktuelle Bearbeitung leeren”** and place it after a separator as a quiet
destructive menu item with `Trash2`.
- Clearing requires its own `AlertDialog`; confirmation calls the existing reset path and returns the
user to the initial ZIP dropzone.
- Disable replacement, append, export, and clear while a destructive transition is being committed,
but allow a new selection while archive extraction is in progress; generation guarding prevents
stale results from returning.
### View setting
- Replace the raw range with the shadcn `Slider` labeled **“Vorschaugröße”**.
- Keep the current `100–400 px` range, `1 px` step, clamp function, and `thumbnailWidth`
localStorage key for backward compatibility.
- Display the current value as tabular numerals and include “Klein”/“Groß” endpoint hints.
- The label and current value must be associated with the slider for assistive technology. Arrow-key
adjustment remains available.
## Component and state design
### New `BeaWorkspaceControls.svelte`
Create `src/lib/components/BeaWorkspaceControls.svelte` to keep the already state-heavy route from
absorbing menu markup. Suggested props:
```ts
type Props = {
selectedZipCount: number;
processedArchiveCount: number;
pendingZipCount: number;
thumbnailWidth: number;
onThumbnailWidthChange: (value: number) => void;
onRequestReplacement: () => void;
onAppendFiles: (files: File[]) => void;
onExportAll: () => void;
onClear: () => void;
};
```
The component owns only ephemeral UI state (popover/dialog open state and temporarily selected
replacement files). The route remains the source of truth for files, jobs, archives, and the
persisted thumbnail width.
Centralize the ZIP accept string and validation used by the initial dropzone and both compact pickers.
Prefer extracting a small reusable ZIP picker/input rather than duplicating MIME and extension checks
inside the controls component.
### Route changes
In `src/routes/tools/bea/+page.svelte`:
- Replace `deleteAllZipArchives` with a single `resetWorkspace()` helper that increments
`archiveGeneration` and clears all three state arrays.
- Add `replaceWithZipFiles(files)` that calls `resetWorkspace()` and then
`handleFilesSelected(files)`; the new jobs must capture the incremented generation.
- Keep `handleFilesSelected` as the append operation.
- Render `BeaWorkspaceControls` whenever at least one selected file/job/archive exists.
- Remove the fixed `<details>` block.
- Top-align the loaded-workspace container and render status/controls before processing and editor
cards.
- Preserve `thumbnailWidth` hydration and persistence exactly as today.
### shadcn-svelte pieces
Add only the required official components with the pnpm CLI, reviewing generated files and imports:
- `popover`
- `slider`
- `separator`
- `alert-dialog`
Do not overwrite the customized button component. The project currently imports
`$lib/components/ui/button.svelte` directly, so adapt generated imports to the local export shape if
the registry assumes a barrel. Use Lucide Svelte icons (`FilePlus2`, `Settings2`, `Download`,
`Trash2`) with `data-icon` inside buttons.
## Files
- `src/routes/tools/bea/+page.svelte` — workspace lifecycle, reset/replace handlers, and layout.
- `src/lib/components/BeaWorkspaceControls.svelte` — new toolbar, popover, dialogs, and picker
coordination.
- `src/lib/components/ZipDropzone.svelte` and/or a new small ZIP picker helper — shared accept and
selection behavior.
- `src/lib/components/ui/{popover,slider,separator,alert-dialog}/` — official shadcn-svelte source.
- `tests/e2e/toolbox.spec.ts` — archive handoff, menu, keyboard, and responsive coverage.
- `agents.md` — document append versus replace semantics and the shared reset invariant.
No changes are expected in `zip-processing.ts`, archive transforms, export ordering, or server code.
## Implementation phases
1. **Shared ZIP selection and state helpers**
- Centralize accepted ZIP types.
- Introduce `resetWorkspace` and `replaceWithZipFiles`.
- Verify generation guarding when replacement occurs during extraction.
2. **Workspace toolbar and layout**
- Add the top-aligned status/action row.
- Implement the replacement picker and confirmation without removing current work on cancel.
- Remove the fixed gear and viewport-centering.
3. **Settings popover**
- Add the responsive popover, slider, append, conditional bulk export, and confirmed clear.
- Verify keyboard focus, Escape/outside-click dismissal, and focus return.
4. **Tests and polish**
- Add e2e coverage below.
- Run desktop/mobile browser review and remove any clipped or overlapping labels.
- Update `agents.md` and run the full validation set.
## Verification
### Automated
Add Playwright cases for:
- Load ZIP A → “Neue ZIP bearbeiten” appears → select ZIP B → cancel confirmation → ZIP A remains.
- Repeat replacement → confirm → only ZIP B is shown and no late result from ZIP A can reappear.
- Cancel the system file picker → workspace is unchanged.
- Select the same ZIP twice through replacement → the second `change` is processed.
- “Weitere ZIP hinzufügen” appends ZIP B and both archive editors remain visible.
- Bulk export is absent for one processed archive and available for two or more.
- “Aktuelle Bearbeitung leeren” cancel preserves state; confirm returns to the initial dropzone.
- Slider changes preview width, works with arrow keys, and survives a reload through localStorage.
- Settings opens with a German accessible name, closes on Escape/outside click, and returns focus to
its trigger.
- At 390 × 844, toolbar actions and every popover label fit without horizontal clipping; the panel
stays inside the viewport and does not cover the archive header.
Then run:
```sh
pnpm check
pnpm test:e2e
pnpm lint
pnpm build
```
### Manual
- Review 1440 × 900 and 390 × 844 with one archive, multiple archives, an extraction in progress,
and an extraction error.
- Confirm file processing remains entirely local and no case data is persisted.
- Confirm tab order is title/status → new ZIP → settings → popover controls → archive controls.
- Confirm reduced-motion mode introduces no menu animation requirement.
## Risks and mitigations
- **Accidental loss during replacement:** current state is not cleared until valid files are selected
and the user confirms.
- **Stale async extraction:** every reset increments `archiveGeneration`; old jobs cannot append
results afterward.
- **Duplicate picker logic:** centralize accept/filter/reset behavior before adding compact actions.
- **Component bloat for a small menu:** use one popover and shared alert-dialog primitives; do not add
tabs, a permanent sidebar, or a settings route.
- **Ambiguous “new” versus “additional”:** keep the labels distinct and stable everywhere:
“Neue ZIP bearbeiten” replaces; “Weitere ZIP hinzufügen” appends.
## Out of scope
- Persisting archives or edits across reloads.
- Detecting whether the operating system completed a download.
- Undoing a confirmed workspace replacement.
- Changing PDF composition, attachment ordering, or sub-document behavior.
- A global application settings page.
+139
View File
@@ -0,0 +1,139 @@
# Plan: PDF-Metadaten — anzeigen und bereinigen
## Goal
Add a new tool **"Metadaten"** (`/tools/metadata`) with two jobs:
1. **Inspect** a PDF: show document metadata (title, author, subject, keywords, creator,
producer, creation/modification dates), page count, file size, and encryption state —
lawyers need to know what a document reveals before forwarding it.
2. **Clean** a PDF: strip the metadata fields and re-save, so the cleaned file can be shared
without leaking software, author names, or edit history.
No rasterization is involved — this tool works directly with pdf-lib and preserves the document
fully (vector text stays selectable). No new dependencies.
---
## Open decisions (defaults marked ✅)
### D1 — Cleaning strategy
- ✅ **A. Neutralize fields + re-save with pdf-lib.** Set title/author/subject/keywords to empty
strings, creator/producer to neutral values, reset creation/modification dates to the cleaning
moment. Saving through pdf-lib rebuilds the file without unreferenced objects. Simple and
dependency-free.
- B. Also attempt to remove embedded files/attachments. pdf-lib has no reliable public API to
enumerate or remove all embedded-file entries; defer to D2.
### D2 — Embedded-file detection
- ✅ **A. Best-effort heuristic warning.** Scan the raw bytes for `/EmbeddedFile` and
`/EmbeddedFiles` markers; if found, show "Die Datei enthält möglicherweise eingebettete
Anlagen. Diese werden beim Bereinigen nicht entfernt." No removal in v1.
- B. No detection at all. Rejected: silent ignorance is worse than an honest warning.
### D3 — Encrypted PDFs
- ✅ **A. Friendly rejection.** pdf-lib cannot load encrypted files; show a German hint pointing
to `tools/decrypt` first. No silent failure.
---
## UX
- Route `/tools/metadata`, new entry in `src/lib/tools.ts`:
`{ slug: 'metadata', title: 'Metadaten', description: 'Zeigen Sie Metadaten eines PDFs an und entfernen Sie sie.', icon: 'info' }`
- icon mapping on the start page.
- Flow: `PdfDropzone` (single PDF) → results card with:
- **Info table** (label/value rows, empty values shown as "–"): Titel, Autor, Betreff,
Schlüsselwörter, Erstellt mit (creator), PDF-Erzeuger (producer), Erstellt am, Geändert am,
Seiten, Größe, Verschlüsselung (Ja/Nein — from load success/failure is insufficient; use
pdf-lib's load error for encrypted detection and show it as a state, or report "Unbekannt"
when not determinable).
- **Embedded-file warning block** when the heuristic fires (amber styling consistent with
existing warning patterns).
- **Quality note**: "Beim Bereinigen bleibt der Inhalt vollständig erhalten (kein Qualitätsverlust)."
- Actions:
- **"Bereinigt herunterladen"** (primary, disabled for encrypted files) → cleaned PDF named
`{baseName}_bereinigt.pdf`.
- Secondary: "Original herunterladen" is _not_ offered (the user already has the original).
- German inline error area for corrupt/encrypted files, matching existing tool pages.
## Technical approach
New route `src/routes/tools/metadata/+page.svelte` plus a small lib module
`src/lib/pdf-metadata.ts`:
```ts
export type PdfMetadataInfo = {
title: string;
author: string;
subject: string;
keywords: string[];
creator: string;
producer: string;
creationDate: Date | null;
modificationDate: Date | null;
pageCount: number;
embeddedFileSuspected: boolean;
};
export const readPdfMetadata = async (bytes: Uint8Array): Promise<PdfMetadataInfo>;
export const stripPdfMetadata = async (bytes: Uint8Array): Promise<Uint8Array>;
```
- `readPdfMetadata`: `PDFDocument.load(bytes)` (with `{ ignoreEncryption: false }` default so
encrypted files throw), then the `getTitle()`/`getAuthor()`/`getSubject()`/`getKeywords()`/
`getCreator()`/`getProducer()`/`getCreationDate()`/`getModificationDate()`/`getPageCount()`
accessors. Encryption detection: catch the pdf-lib `EncryptedPDFError` and return a distinct
`{ encrypted: true }` result shape (discriminated union) so the UI can branch.
- `stripPdfMetadata`: load, apply setters (`setTitle('')`, `setAuthor('')`, `setSubject('')`,
`setKeywords([])`, `setCreator('')`, `setProducer('beA-Edit')`, `setCreationDate(new Date())`,
`setModificationDate(new Date())`), `save()`.
- Embedded-file heuristic: decode bytes with `TextDecoder` once and search for the two marker
strings; cheap, runs before pdf-lib load.
- Reading metadata does **not** need pdfjs or thumbnails — no rasterization limits apply; large
files are fine because pdf-lib parses lazily enough for this purpose.
## Files changed
- `src/lib/pdf-metadata.ts` — new.
- `src/routes/tools/metadata/+page.svelte` — new route.
- `src/lib/tools.ts`, `src/routes/+page.svelte` — registry entry + icon map.
- `tests/e2e/toolbox.spec.ts` — new spec block.
- `agents.md` — structure update.
## Implementation phases
1. `pdf-metadata.ts` with read + strip + heuristic.
2. Route UI: info table, warning block, clean action, encrypted-file branch.
3. Registry entry + icon.
4. Playwright tests: PDF created with `createPdf` + set metadata fields → fields displayed;
clean download loads with pdf-lib and has empty author/title; encrypted-PDF rejection hint
(reuse the encrypt tool to produce a fixture inline).
5. Polish, German copy review, `pnpm check`/`lint`/`build`/`test:e2e`.
## Verification
- Metadata of a known PDF renders completely; empty fields show "–".
- Cleaned PDF: open in viewer, metadata fields empty; content and page count identical; file
still selectable/searchable (no rasterization happened).
- Encrypted input shows the decrypt hint instead of crashing.
- `/EmbeddedFile`-containing fixture triggers the amber warning.
- Standard command gauntlet passes.
## Risks
- **XMP metadata**: pdf-lib's setters touch the Info dict; a separate XMP metadata stream can
survive. Mitigation: note in the UI copy that cleaning covers standard document properties.
(Full XMP removal could be a follow-up by deleting the metadata stream object.)
- **Producer fingerprint**: setting `beA-Edit` as producer is itself a small fingerprint; neutral
but acceptable — alternative is an empty string; pick empty string if reviewers prefer.
## Out of scope
- Removing embedded files/attachments (see D2).
- XMP stream removal (follow-up).
- Metadata editing (rename title, etc.) — inspection + removal only.
- Hooking metadata cleaning into the beA export pipeline.
+140
View File
@@ -0,0 +1,140 @@
# Plan: Seiten drehen
## Goal
Add a new tool **"Seiten drehen"** (`/tools/rotate`) that fixes page orientation — the classic
case being scanned court mail with individual pages rotated 90°/180°. Users rotate pages
individually or all at once; the result is saved via the standard PDF `/Rotate` mechanism, so the
operation is **lossless and instant** (no rasterization, no re-encoding).
No new dependencies.
---
## Open decisions (defaults marked ✅)
### D1 — How rotation is applied
- ✅ **A. pdf-lib `setRotation` (document-level `/Rotate`).** `page.setRotation(degrees(angle))`
writes the page's rotation entry; every viewer honors it. Lossless, fast, file size unchanged.
Existing rotation of a page (from scanners that already set `/Rotate`) is read via
`page.getRotation().angle` and added to, so the user's delta is always applied on top of the
current state.
- B. Rasterize-and-rotate pixels. Unnecessary quality loss and memory cost; rejected.
### D2 — Grid capabilities
- ✅ **Extend `PdfPageGrid` with an optional action snippet.** The current grid renders fixed
cards. Add an optional snippet prop (e.g. `pageActions`) rendered below each thumbnail; existing
call sites (separate, watermark, extract) pass nothing and look unchanged. The rotate page uses
it for per-page rotate buttons and a rotation badge.
- B. Fork the grid inside the rotate page. Rejected: duplicates the accessible grid work.
---
## UX
- Route `/tools/rotate`, new `src/lib/tools.ts` entry:
`{ slug: 'rotate', title: 'Seiten drehen', description: 'Drehen Sie einzelne oder alle Seiten eines PDFs.', icon: 'rotate-cw' }`
- icon map entry.
- After load (`loadPdfDocument` + thumbnails like separate):
- Quick-action bar: **"Alle 90° rechts"**, **"Alle 90° links"**, **"Alle 180°"**,
**"Zurücksetzen"** (clears all deltas).
- Page grid: each card shows the thumbnail with a CSS rotation preview
(`transform: rotate({total}deg)` on the `<img>`, contained in a square-ish frame so rotated
pages still fit), a small badge when the total rotation is non-zero (e.g. "90°"), and two
icon buttons (rotate left / rotate right) provided through the new `pageActions` snippet.
Buttons are real `<button>` elements with `aria-label="Seite 3 um 90° nach rechts drehen"`.
- Count label: "4 von 12 Seiten gedreht".
- Primary button **"Drehen & herunterladen"** → `{baseName}_gedreht.pdf`. Disabled when no page
has a non-zero total rotation change.
- Keyboard operability is inherent (all buttons); no drag interactions required at all.
## Technical approach
### State
```ts
let rotations = $state(new Map<number, number>()); // pageNumber → delta degrees (0/90/180/270)
```
- `rotatePage(pageNumber, delta)`: `next = ((current + delta) % 360 + 360) % 360`; store; delete
entry when back to 0 (keeps "gedreht" count honest).
- Preview angle: `originalRotation(page) + delta` — the thumbnail renderer cannot know the
original `/Rotate` without pdf-lib; simplest correct-enough approach: apply only the delta to
the preview (pdfjs already renders pages honoring `/Rotate`, so the preview matches what the
user sees plus their delta). No need to read pdf-lib during preview.
### Export
```ts
const pdfDoc = await PDFDocument.load(bytes);
for (const page of pdfDoc.getPages()) {
const delta = rotations.get(pageIndexPlusOne);
if (!delta) continue;
const current = page.getRotation().angle; // respects existing /Rotate
page.setRotation(degrees((((current + delta) % 360) + 360) % 360));
}
downloadBlob(await pdfDoc.save(), `${baseName}_gedreht.pdf`);
```
- Instant for any file size — no rasterization limits needed, but keep the shared load error
handling (encrypted files: pdf-lib load fails → German error; optionally hint to decrypt like
the metadata plan does).
### `PdfPageGrid` extension (backward compatible)
```ts
type Props = {
thumbnails: PdfThumbnail[];
selectedPages?: Set<number>;
onPageClick?: (pageNumber: number) => void;
thumbnailWidth?: number;
class?: string;
pageActions?: Snippet<[pageNumber: number]>; // NEW, optional
cardClass?: (pageNumber: number) => string; // NEW, optional — rotate preview transform hook
};
```
- When `pageActions` is provided, render `{@render pageActions(thumb.pageNumber)}` inside the
card. When `cardClass` is provided, merge it into the image wrapper via `cn()`. Existing pages
need no changes.
## Files changed
- `src/lib/components/PdfPageGrid.svelte` — optional snippet/class props.
- `src/routes/tools/rotate/+page.svelte` — new route.
- `src/lib/tools.ts`, `src/routes/+page.svelte` — registry + icon map.
- `tests/e2e/toolbox.spec.ts` — spec block.
- `agents.md` — structure update.
## Implementation phases
1. `PdfPageGrid` extension; run existing e2e to prove separate/watermark visuals unchanged.
2. Rotate route: state, quick actions, grid with badges/buttons, CSS preview rotation.
3. Export via `setRotation`.
4. Registry + icon.
5. Playwright: rotate page 2 by 90° twice via its buttons, download, load output with pdf-lib,
assert `getPage(1).getRotation().angle === 180`; "Zurücksetzen" disables the download button.
6. Polish + `pnpm check`/`lint`/`build`/`test:e2e`.
## Verification
- Single page rotate, all-page rotate, reset flows.
- A PDF whose pages already carry `/Rotate=90` gains a correct cumulative rotation (assert in e2e
by creating such a fixture with pdf-lib).
- Output opens with correct orientation in a viewer; file size ~unchanged.
- Grid cards stay within layout bounds when rotated (object-contain + fixed aspect frame).
## Risks
- **CSS preview vs. real rotation mismatch** for pages that already carry `/Rotate`: pdfjs
thumbnails render the page as displayed; the preview adds the delta visually — acceptable.
- Rotated landscape images inside the grid may look small in portrait cards; use `object-contain`
and a square min-height frame.
## Out of scope
- Auto-detection of upside-down scans (needs heuristics/OCR).
- Rotating arbitrary selections only via a separate tool (quick actions + per-page buttons cover
it).
+139
View File
@@ -0,0 +1,139 @@
# Plan: Stempeln — Textstempel auf PDFs
## Goal
Add a new tool **"Stempeln"** (`/tools/stamp`) that places opaque, framed text stamps on PDF
pages — "BEGLAUBIGT", "EILT", "ENTWURF", "KOPIE", "AUSFERTIGUNG", plus free text with an optional
date, or an image. This is a staple of German legal/case-file work and is deliberately separate from the
watermark tool:
- Watermark = semi-transparent, decorative/repetitive, many positions.
- Stamp = fully opaque, framed, authoritative marking, usually one per page.
Pure pdf-lib drawing (text + rectangle border): lossless, instant. No new dependencies.
---
## Open decisions (defaults marked ✅)
### D1 — Stamp look
- ✅ **A. Uppercase bold text inside a plain rectangle border** (2 pt border, padding 8 pt,
default color red `#cc1a1a`, matching the existing brand red used by the watermark default).
pdf-lib has no rounded-rectangle primitive; plain rectangle is the honest option.
- B. Borderless colored text. Possible but reads as a watermark, not a stamp.
### D2 — Presets
- ✅ **Editable preset chips + free text.** Chips: BEGLAUBIGT, AUSFERTIGUNG, KOPIE, EILT,
ENTWURF, ERLEDIGT. Selecting a chip fills the text input; the input stays editable. Optional
checkbox "Datum anhängen" appends `dd.mm.yyyy` (German format).
- B. Fixed presets only. Rejected: courts/firms love their own wording.
### D3 — Relation to the watermark tool
- ✅ **Separate tool**, sharing extracted helpers from `src/lib/pdf-overlay.ts`
(position coordinate computation + hex color parsing — see page-numbers-plan; whichever plan is
implemented first creates the module). Keeps discovery simple (one tile per job) and avoids
overloading the watermark UI with a mode switch.
---
## UX
- Route `/tools/stamp`, new `src/lib/tools.ts` entry:
`{ slug: 'stamp', title: 'Stempeln', description: 'Versehen Sie Seiten mit Textstempeln wie „Beglaubigt“ oder „Eilt“.', icon: 'stamp' }`
(`stamp` is a real Lucide icon name) + icon map entry.
- After load (`loadPdfDocument` for thumbnails):
- **Options card**:
- Preset chip row (buttons; selected chip highlighted).
- Text input (bound, default "BEGLAUBIGT"), "Datum anhängen" checkbox.
- Schriftgröße slider (12–48 pt, default 24).
- Farbe color input (default `#cc1a1a`).
- Position select — same six positions + center as watermark
(reuses shared position helper; no "diagonal" for stamps).
- Rotation slider (−45°…45°, default 0) — stamps are often applied slightly tilted.
- Apply-to: "Auf alle Seiten anwenden" checkbox + `PdfPageGrid` selection when off
(watermark pattern).
- **Live preview**: rendered current page (pdfjs) with an absolutely-positioned overlay div
styled like the stamp (border, uppercase, color, rotation) at the chosen position; page
navigation. Approximation is fine, same approach as page-numbers-plan.
- Primary button **"Stempel anwenden & herunterladen"** → `{baseName}_stempel.pdf`.
## Technical approach
### Export (pdf-lib)
```ts
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const stampText = `${textInput.trim().toUpperCase()}${withDate ? ` ${formatGermanDate(new Date())}` : ''}`;
const textWidth = font.widthOfTextAtSize(stampText, size);
const boxWidth = textWidth + 2 * padding;
const boxHeight = font.heightAtSize(size) + 2 * padding;
for (const pageIndex of targetPages) {
const { width, height } = pages[pageIndex].getSize();
const { x, y } = getPositionCoords(width, height, boxWidth, boxHeight, position); // shared helper
pages[pageIndex].drawRectangle({
x, y, width: boxWidth, height: boxHeight,
borderColor: parseHexColor(colorValue), borderWidth: 2
});
pages[pageIndex].drawText(stampText, {
x: x + padding, y: y + padding + /* baseline adjust */, size, font,
color: parseHexColor(colorValue), rotate: degrees(rotationValue)
});
}
```
Details:
- Rotation is applied to the text; the border rectangle stays axis-aligned for v1 (rotated
rectangles need manual path drawing — follow-up if users care).
- Baseline adjustment: use `font.heightAtSize(size) * 0.2` descent approximation; verify visually.
- Uppercasing happens in JS (`toUpperCase()` — German umlauts are fine in WinAnsi: ä/ö/ü/ß are
supported except ẞ; uppercase ß → SS via `toLocaleUpperCase('de')` acceptable).
- Encrypted input → German error + decrypt hint (shared pattern).
### Shared helpers
Depends on `src/lib/pdf-overlay.ts` (position presets + `parseHexColor`). If page-numbers or
stamp ships first, it creates the module and rewires watermark; the second consumer just imports.
## Files changed
- `src/routes/tools/stamp/+page.svelte` — new route.
- `src/lib/pdf-overlay.ts` — shared helpers (created here if not already present).
- `src/lib/tools.ts`, `src/routes/+page.svelte` — registry + icon map.
- `tests/e2e/toolbox.spec.ts` — spec block.
- `agents.md` — structure update.
## Implementation phases
1. Shared overlay helpers (or import if they exist) — watermark regression must stay green.
2. Route UI: presets, inputs, preview overlay, page selection.
3. Export + download.
4. Registry + icon.
5. Playwright: preset "EILT" on all pages of a 3-page PDF → output valid, 3 pages; custom text
- date checkbox adds text; selection-only mode leaves other pages unmodified (compare page
content-stream sizes via pdf-lib if practical, else file-level validity + count).
6. Polish + command gauntlet.
## Verification
- Every preset chip fills the input; the stamp renders uppercase in preview and output.
- Date uses German `dd.mm.yyyy` and updates on the export day (not cached).
- Rotation visually matches preview within tolerance.
- Watermark tool unchanged (regression).
## Risks
- **Text width overflow** on small pages with long custom text + huge font: clamp computed box to
page size minus margins and shrink font if needed (simple guard loop), with a German hint.
- Preview overlay approximation may differ a few points from export — acceptable, labeled.
## Out of scope
- Image stamps (logo stamps) — users can route through the watermark image mode for that.
- Rotated border rectangles, multi-stamps per page in one run (run the tool twice; output can be
re-loaded).
- Stamp "libraries" persisted across sessions.
+126
View File
@@ -0,0 +1,126 @@
# Plan: Start page — make the beA context obvious
**Status: Done**
## Problem
The start page (`src/routes/+page.svelte`) presents itself as a generic "PDF-Werkzeuge" site:
- Hero headline: **"PDF-Werkzeuge"**
- ZIP dropzone: label **"ZIP-Archiv bearbeiten"**, sublabel "ZIP-Datei hier ablegen oder zum
Auswählen klicken", aria label "ZIP-Archiv bearbeiten"
- Drag overlay: **"ZIP-Archiv hier ablegen"**
- `<title>`: `juri-merger — PDF-Werkzeuge`
The ZIP the user is supposed to drop here is, in reality, an archive exported from the
**besondere elektronische Anwaltspostfach (beA)** — that's what `/tools/bea` processes and what
the whole product ("beA-Edit", see `agents.md`) exists for. Nothing on the start page says so.
A user seeing any random ZIP has no hint which one belongs in this slot.
Goal: keep the toolbox-hub structure (hero + beA entry point + tool tiles), but reframe the copy
so that (a) the beA origin of the expected ZIP is unmistakable and (b) the overall positioning
shifts from generic "PDF tools" to "workbench for beA exports (+ extra PDF tools)".
## Target copy (proposal)
### 1. Hero / page identity
- `<title>`: from `juri-merger — PDF-Werkzeuge` to
`beA-Edit — Werkzeugkasten für das besondere elektronische Anwaltspostfach` (long form) or
`… für beA-Archive` (short form, see D1).
- H1: from `PDF-Werkzeuge` to **`Werkzeugkasten für beA`**.
- Subline: currently only the generic privacy line. Replace with a lead sentence explaining beA:
„Bereiten Sie ZIP-Archive aus dem besonderen elektronischen Anwaltspostfach (beA) auf — plus
praktische PDF-Werkzeuge.“ — then keep the existing privacy line as a second sentence.
This keeps "PDF-Werkzeuge" discoverable for anyone who knew the old wording but leads with beA.
### 2. ZIP entry point (the core fix)
- Label: **„beA-ZIP öffnen"** instead of "ZIP-Archiv bearbeiten".
- Sublabel: „ZIP aus dem besonderen elektronischen Anwaltspostfach (beA) hier ablegen oder zum
Auswählen klicken".
- Add a small example/hint line inside or under the dropzone:
„Tipp: In beA exportierte Archive enden meist auf `.zip` und enthalten Anlagen als PDF."
(exact wording see D2 — including whether to show typical archive contents)
- Icon inside the dropzone next to the "+" (e.g. Lucide `Mail`/`Postbox`-like or `Archive`)
to visually distinguish it from the plain-PDF tool tiles below.
- Dropzone keeps `accept=".zip,…"`. No functional change to filtering — dropping any ZIP still
routes to `/tools/bea`.
### 3. Full-window drag overlay
- Text: **„beA-ZIP hier ablegen"** (currently "ZIP-Archiv hier ablegen"), optionally with the
subline „Archive aus dem besonderen elektronischen Anwaltspostfach".
### 4. Tool grid section heading
- Currently: „Werkzeuge". Change to **„Weitere PDF-Werkzeuge"** — this is where the words
"PDF-Werkzeuge" live on after the hero shift, clarifying these tiles are the _secondary_,
general-purpose part while beA sits above.
## Code touchpoints
All copy lives in exactly three files; no component API changes required:
1. `src/routes/+page.svelte`
- `<title>`, H1 + sublines in the Hero block
- props passed to `FileDropzone`: `ariaLabel`, `label`, `sublabel` (add a hint element below
the dropzone if D2 decides for it)
- drag overlay text
- "Werkzeuge" → "Weitere PDF-Werkzeuge"
1. `src/routes/+layout.svelte` — only if global header/footer copy mentions the product role
(check during implementation; likely unchanged).
2. `tests/e2e/toolbox.spec.ts`
- Line ~88 asserts the heading name `'PDF-Werkzeuge'` → update to the new H1
(`'Werkzeugkasten für beA'`).
- Any other selectors keyed on the old labels must be swept (search tests for
"ZIP-Archiv" / "PDF-Werkzeuge").
Accessibility notes:
- The new sublabel contains the full term + abbreviation once per page; use `abbr`-free plain
text (screen readers handle "beA" fine as-is). Keep `aria-label` identical to the visible
label to avoid divergence.
## Open decisions (defaults marked ✅)
### D1 — How far to push the rebrand?
- ✅ **A. Lead with beA, keep toolbox**: H1 "Werkzeugkasten für beA", tool grid becomes
"Weitere PDF-Werkzeuge". Minimal risk, preserves both audiences.
- B. Full rename to "beA-Editor": hero only about beA, tools demoted into an opt-in section
further down. Stronger positioning, hides the newer tools behind scroll/click.
### D2 — Where does the explainer text live?
- ✅ **A. Short hint line inside/below the dropzone** (one sentence naming "besonderes
elektronisches Anwaltspostfach"). Cheap, always visible where it matters.
- B. Info popover/question-mark icon expanding details (typical file names, how to export from
beA). More room, more moving parts.
- C. Both A and B if users actually need export instructions (needs user feedback).
### D3 — Also update other beA-named strings for consistency?
- ✅ **A. Yes, sweep for consistency**: e.g. documents/README references and `ZipDropzone.svelte`
sublabels inside `/tools/bea` should also say "beA-ZIP" so terminology matches across pages.
Copy-only sweep, no behavior change.
- B. Only the start page; leave inner pages untouched.
## Implementation phases
Single phase — this is copy + one test file.
1. Apply copy changes in `src/routes/+page.svelte` (+ optional consistency sweep per D3).
2. Update `tests/e2e/toolbox.spec.ts` expectations.
3. Run `pnpm check`, `pnpm lint`, `pnpm build`, and the e2e suite.
4. Manual browser pass: hero, dropzone, drag-overlay, keyboard focus order unchanged;
verify window-wide drag of a ZIP still lands on `/tools/bea`.
5. Update `agents.md` / README wording if D3 = A touches those files.
## Risks
- Very low. Pure copy change; only test breakage possible (heading/label assertions), covered
by running the spec suite. SSR-safe: no new logic, all changes static markup.
+259
View File
@@ -0,0 +1,259 @@
# Plan: Start-page redesign — PDF toolbox
## Goal
Today the start page (`src/routes/+page.svelte`) offers exactly one entry point: the ZIP dropzone
that starts the beA archive workflow. This redesign turns the start page into a **toolbox hub**:
1. The existing **ZIP upload** (beA workflow) stays available — visually prominent as the
"hero" action of the product.
2. Eight additional tools become reachable as tiles from the start page:
- **Wasserzeichen** — add a watermark to a PDF
- **Signieren** — sign a PDF
- **Passwort setzen** — encrypt a PDF with a password
- **Passwort entfernen** — unlock a password-protected PDF
- **Konvertieren** — convert a PDF
- **Komprimieren** — compress a PDF
- **Seiten entfernen** — remove pages from (separate) a PDF
- **PDFs zusammenfügen** — merge PDFs
All processing remains **100 % client-side** (no uploads, no network requests, no persistence),
in line with the project's privacy constraints.
---
## Target UX
### Start page (`/`)
- The Nominandum header/footer and overall visual language stay untouched.
- A compact hero area with the product name and a one-line value proposition.
- A **large primary tile** for the beA workflow ("ZIP-Archiv bearbeiten") — it keeps the
full-height dropzone feel: dragging a ZIP anywhere on the start page still starts the workflow.
- A **grid of secondary tool tiles** (2–3 columns responsive, `grid` + `gap-*`, white cards on the
pale blue-gray background, Lucide icon + German title + one-line description, visible focus and
keyboard operability — tiles are real links/buttons, not divs).
- German copy throughout (project constraint).
### Tool pages
- Each tool gets its **own route** under `src/routes/tools/…` (see D1):
`tools/bea`, `tools/merge`, `tools/separate`, `tools/watermark`, `tools/sign`,
`tools/encrypt`, `tools/decrypt`, `tools/convert`, `tools/compress`.
- A shared layout (`tools/+layout.svelte`) provides a consistent shell: back-link to `/`,
tool title, and the file-pick area.
- The existing ZIP workflow component tree (`ZipDropzone`, `ProcessedZipArchiveEditor`,
`SubDocumentEditor`, `AttachmentPreview`) moves to `tools/bea` essentially unchanged.
---
## Technical approach per tool
| Tool | Route | Input | Processing | Output |
| ----------------------- | ----------------- | ------------------------ | ------------------------------------ | ---------------------- |
| beA workflow | `tools/bea` | `.zip` (multiple) | unchanged existing pipeline | merged/separate PDFs |
| Merge | `tools/merge` | multiple PDFs | pdf-lib copy of pages in order | one PDF |
| Separate / remove pages | `tools/separate` | one PDF | pdf-lib copy with page selection | one PDF |
| Watermark | `tools/watermark` | one PDF | pdf-lib overlay on every page | one PDF |
| Sign | `tools/sign` | one PDF | pdf-lib places signature image | one PDF |
| Encrypt | `tools/encrypt` | one PDF | encryption library (see D5) | password-protected PDF |
| Unlock | `tools/decrypt` | protected PDF + password | pdfjs (password) → rebuild (see D6) | unprotected PDF |
| Compress | `tools/compress` | one PDF | pdfjs rasterize + re-encode (see D7) | smaller PDF |
| Convert | `tools/convert` | one PDF | pdfjs render pages (see D4) | images or PDF variant |
### 1. Merge PDFs
- Load each selected PDF with pdf-lib, copy pages in the (reorderable) list order into a new
document. This is a generalization of the existing `mergeProcessedZipArchive` logic — extract a
reusable `mergePdfFiles(files: File[]): Promise<Uint8Array>` helper.
### 2. Separate / remove pages
- Render page thumbnails (reuse the pdfjs pattern from `AttachmentPreview.svelte`), show a page
grid with selection; the user deselects/removes pages and downloads the remaining document.
- Page selection must also work with keyboard focus (project accessibility rule).
### 3. Watermark
- Options: text (or uploaded PNG/JPG image), position (center/diagonal/corners), opacity, font
size, page range (all / selected). Drawn with pdf-lib `drawText`/`drawImage` on each selected page.
- No new dependency.
### 4. Sign
- Default proposal (see D3): **visual signature**. The user draws a signature in a canvas element
(mouse/touch/pen), or types a name in a handwriting-like font, or uploads a signature image.
The signature is exported as a transparent PNG and placed by clicking/tapping the desired
position on a rendered page (pdfjs preview + pdf-lib placement).
- PAdES/cryptographic signing is **not** part of the default scope.
### 5. Set password (encrypt)
- pdf-lib does **not** support encryption → a new dependency is unavoidable (see D5).
Primary candidate: `@cantoo/pdf-lib` (drop-in fork of pdf-lib with AES encryption support),
alternative: `qpdf-wasm`.
- UI: password + confirmation field, optional permissions flags (print/copy allowed?).
### 6. Unlock (remove password)
- pdfjs-dist can open password-protected PDFs when given the password; pdf-lib cannot load them.
- Default approach (see D6): open with pdfjs using the password, then **rasterize** each page to a
canvas and rebuild a PDF via pdf-lib. Known tradeoff: text becomes images (no selectable text,
larger file, no vector quality). This is documented to the user in the UI.
- Failing passwords must surface a clear German error message, not a console-only error.
### 7. Compress
- Browser-only compression is realistically rasterization-based: render each page with pdfjs at a
chosen DPI/scale, encode as JPEG at a chosen quality, rebuild the PDF.
- UI presents target presets (e.g. "Starke Komprimierung" / "Ausgewogen" / "Leicht") and shows the
resulting size estimate before download. Same vector→raster caveat as unlock, shown in the UI.
### 8. Convert
- Default scope (see D4): **PDF → images** (PNG or JPEG, one file per page, downloaded as separate
files or a ZIP via fflate — fflate is already a dependency).
- Other directions (images/Office → PDF) already exist in the beA flow for images; extending
convert beyond PDF→images is out of scope for v1.
---
## Shared infrastructure (new `src/lib` pieces)
- **`PdfDropzone` component** — generalization of `ZipDropzone` with configurable `accept` filter
(`.pdf` vs `.zip`) and drag state. `ZipDropzone` is refactored on top of it (or replaced).
- **`usePdfThumbnails` helper / `PdfPageGrid` component** — shared page-thumbnail rendering for
separate/sign/watermark/convert/compress, modeled on `AttachmentPreview.svelte`'s memory rules
(dynamic pdfjs import, byte copies before render, revoked object URLs, destroyed loading tasks).
- **`downloadBlob()` helper** — the current inline `downloadPdfBytes` in `+page.svelte` moves to a
shared `src/lib/download.ts` and gains a generic filename/MIME signature.
- **Tool registry** (`src/lib/tools.ts`) — typed list of `{ slug, title, description, icon }`
consumed by the start page tile grid, so adding a tool later means one entry + one route.
- All browser-only work stays in event handlers / `onMount` (SSR-safe, per `agents.md`).
- File-size/memory guard: cap or warn on very large PDFs before rasterization-heavy tools
(unlock/compress/convert).
## Dependencies
- **New runtime dependency** (only for encrypt): `@cantoo/pdf-lib` **or** `qpdf-wasm` — decision D5.
All other tools run on the existing stack (pdf-lib, pdfjs-dist, fflate).
---
## Implementation phases
Each phase ends with `pnpm check`, `pnpm lint`, `pnpm build` and a manual browser pass
(prettier formatting on changed files).
### Phase 0 — Groundwork
1. Create `tools/+layout.svelte` shell and `src/lib/tools.ts` registry.
2. Move the existing ZIP workflow from `+page.svelte` to `tools/bea/+page.svelte` unchanged
(keep the settings popover, thumbnail width localStorage, bulk export).
3. Build the new start page: hero + beA tile + (initially) tool tiles that link to stub routes
with a "coming soon" state? — no: see D8; default is to ship phases together and leave
tiles hidden until their tool exists.
4. Update `agents.md` repository structure and flow sections.
### Phase 1 — No-new-dependency tools
1. `PdfDropzone`, `download.ts`, `PdfPageGrid`/thumbnail helper.
2. `tools/merge` (reusable `mergePdfFiles` helper).
3. `tools/separate` (page grid, selection, export).
4. `tools/watermark`.
### Phase 2 — Signature
1. `tools/sign`: canvas signature capture (draw/type/upload), placement preview, export.
### Phase 3 — Password tools
1. Add the chosen encryption dependency; `tools/encrypt`.
2. `tools/decrypt` (pdfjs password + rasterized rebuild) with explicit quality warning in UI.
### Phase 4 — Rasterization tools
1. `tools/compress` (DPI/quality presets, size preview).
2. `tools/convert` (PDF → PNG/JPEG, separate files or ZIP).
### Phase 5 — Polish
1. Start-page tile copy and icons review (German, Nominandum visual language).
2. Final `check`/`lint`/`build`, update `agents.md`, README feature list.
---
## Open decisions (please confirm before implementation)
Defaults are marked ✅.
### D1 — One page per tool vs. single-page app
- ✅ **A. Separate routes** (`/tools/<slug>`). Better deep-linking/bookmarking, per-tool state is
isolated, back-navigation is natural, and SvelteKit gives us the structure for free.
- B. Single start page where each tile opens an in-place panel/modal. Keeps everything on `/` but
concentrates a lot of state in one component and hurts URL shareability.
### D2 — Where does the ZIP workflow live?
- ✅ **A. `/tools/bea`, start page shows it as the large primary tile.** Drag-a-ZIP-anywhere on the
start page still routes into the workflow, preserving today's fastest path.
- B. ZIP workflow stays on `/` and tools branch off it. Rejected: makes `/` a mixed
dropzone+dashboard and complicates the page.
### D3 — What does "sign" mean?
- ✅ **A. Visual signature** (drawn/typed/uploaded image placed on the page). No crypto, no
certificates, no PAdES.
- B. Cryptographic/PAdES signing. Large scope (certificate handling, new heavy dependencies,
legal implications) — recommend a separate project if needed.
### D4 — What does "convert" mean (v1)?
- ✅ **A. PDF → PNG/JPEG** (per-page images, optionally bundled into a ZIP with fflate).
- B. PDF → PDF/A. There is no solid browser-only PDF/A converter; would need a new heavy
dependency or a server.
- C. Images/Office → PDF. Image→PDF already exists inside the beA flow; Office formats are
out of scope.
### D5 — Encryption dependency
- ✅ **A. `@cantoo/pdf-lib`** — API-compatible fork of pdf-lib with encryption; smallest migration
risk (drop-in swap of the pdf-lib import in the encrypt tool only).
- B. `qpdf-wasm` — battle-tested engine, but WASM bundle size and wiring cost are significant.
- C. Server-side encryption — violates the no-upload constraint; rejected.
### D6 — Unlock strategy
- ✅ **A. pdfjs (with password) → rasterize pages → rebuild with pdf-lib.** Works fully in the
browser; document the vector→raster quality loss in the UI before the user commits.
- B. Await/patch pdf-lib fork decryption support — currently not available for browser use.
- C. Server-side qpdf — violates the no-upload constraint; rejected.
### D7 — Compress strategy
- ✅ **Rasterize-and-re-encode** (pdfjs render + JPEG at chosen quality). Predictable, browser-only;
shows the same quality caveat as unlock.
- B. "Structure-only" optimization (remove metadata, deduplicate objects). pdf-lib offers little
here; savings are usually marginal. Could be added as a light pre-step within the same tool.
### D8 — Release strategy for tiles
- ✅ **A. Ship it whole / hide unfinished tiles.** Only tiles whose route works are rendered
(driven by the tool registry), so phases can be merged without dead links.
- B. Show all tiles immediately with "in Kürze verfügbar" badges — acceptable only if the
redesign itself must go live before all tools are done.
---
## Risks and mitigations
- **Rasterization quality/memory** (unlock, compress, convert): large PDFs can exhaust browser
memory when rendering many pages at high DPI. Mitigate with sequential page processing, DPI
caps, and an upfront file-size warning.
- **`@cantoo/pdf-lib` maturity**: it is a fork; keep the pdf-lib import isolated to the encrypt
tool so a swap back (or to qpdf-wasm) stays local.
- **Scope creep on "sign"/"convert"**: the open decisions above pin v1 scope deliberately; crypto
signing and PDF/A conversion are explicitly out.
- **Regression risk for the existing workflow**: Phase 0 is a pure move of the current page;
verify sub-document editing, XJustiz naming, and bulk export still work after relocation.
+188
View File
@@ -0,0 +1,188 @@
# Plan: Download an individual sub-document
**Status: Done**
## Goal
Each sub-document displayed inside a processed ZIP archive shall have its own download action. The
action creates and downloads one PDF containing only that sub-document's attachments, in their
current order.
This is an additional interaction. The existing archive-level export modes remain available:
- **Eine PDF** continues to flatten all sub-documents and loose attachments into one PDF.
- **Getrennt** continues to download every non-empty sub-document plus the loose-attachment PDF.
- The new sub-document action downloads only the selected sub-document and does not include loose
attachments or sibling sub-documents.
## Current implementation
- `ProcessedZipArchiveEditor.svelte` owns archive-level download state and the browser download helper.
- `SubDocumentEditor.svelte` renders each sub-document but currently exposes only edit, drag/drop, and
dissolve callbacks.
- `mergeAttachments()` in `src/lib/zip-processing.ts` already creates a PDF from an ordered attachment
list.
- `buildArchiveExport(archive, 'separate')` already defines the desired sub-document naming convention:
`{archiveName} - {subDocumentName}.pdf`. It currently creates exports for every sub-document, so it
should not be called for an individual download.
## UX and behavior
Add a **PDF herunterladen** button to each sub-document header, before **Auflösen**.
- Clicking it downloads one PDF for that sub-document.
- The PDF page order matches `subDocument.attachments` exactly.
- The filename remains consistent with separate archive export:
`{archiveName without .pdf} - {subDocumentName without .pdf}.pdf`.
- The action is disabled when the sub-document has no attachments.
- While an individual PDF is being generated, its button reads **PDF wird erstellt** and cannot be
triggered again.
- Only one PDF-generation operation may run in an archive editor at a time. While an individual
download is active, archive-level and sibling download actions are disabled; while archive-level
export is active, individual actions are disabled.
- A failure leaves the archive and sub-document unchanged and shows the existing inline error area
with a sub-document-specific German message.
- Existing drag/drop, rename, selection, dissolve, and archive export behavior must remain unchanged.
## Implementation
### 1. Add a focused export helper
Update `src/lib/zip-processing.ts` with an exported helper:
```ts
export const buildSubDocumentExport = async (
archiveName: string,
subDocument: SubDocument
): Promise<ExportUnit>
```
Behavior:
1. Reject an empty attachment list with the same explicit empty-export behavior used by
`mergeAttachments()`.
2. Merge `subDocument.attachments` by calling `mergeAttachments()`.
3. Return one `ExportUnit` using the existing archive/sub-document filename convention.
4. Keep `.pdf` normalization centralized through the existing `stripPdfExtension()` helper.
Refactor the `mode === 'separate'` branch of `buildArchiveExport()` to call
`buildSubDocumentExport()` for each non-empty sub-document. This ensures bulk-separate and
individual exports cannot drift in PDF contents or naming. Preserve sequential generation and the
existing loose-attachment export behavior.
Do not construct a temporary `ProcessedZipArchive` and do not invoke `buildArchiveExport(...,
'separate')` for one item: both approaches encode the wrong scope and risk exporting unrelated
attachments later.
### 2. Add individual download orchestration to the archive editor
Update `src/lib/components/ProcessedZipArchiveEditor.svelte`:
- Import `buildSubDocumentExport`.
- Add `downloadingSubDocumentId: string | null` state alongside `isMerging` and `mergeError`.
- Derive whether any export is active from `isMerging || downloadingSubDocumentId !== null`.
- Add `downloadSubDocument(subDocument: SubDocument)` that:
1. Returns when another export is active or the sub-document is empty.
2. Stores the selected sub-document ID and clears `mergeError`.
3. Calls `buildSubDocumentExport(archiveName, subDocument)`.
4. Passes the returned bytes and name to the existing `downloadPdfBytes()` helper.
5. On failure, logs context including the archive and sub-document and sets
`Teildokument „{name}“ konnte nicht als PDF erstellt werden.`.
6. Clears the active ID in `finally`.
- Update `downloadArchive()` to guard against any active export, not only `isMerging`.
- Disable the archive-level download button and export-mode controls while an individual export is
active. The mode controls should remain visible and retain their selected value.
- Pass these props to every `SubDocumentEditor`:
- `onDownload={() => downloadSubDocument(subDocument)}`
- `isDownloading={downloadingSubDocumentId === subDocument.id}`
- `downloadDisabled={isMerging || downloadingSubDocumentId !== null}`
The browser download implementation stays in the parent so Blob/object-URL creation, filename
normalization, loading coordination, and errors remain centralized.
### 3. Add the sub-document header action
Update `src/lib/components/SubDocumentEditor.svelte`:
- Import the existing local `Button` component.
- Extend `Props` with:
- `onDownload?: () => void`
- `isDownloading?: boolean`
- `downloadDisabled?: boolean`
- Give all new props safe defaults.
- Render a bordered button before **Auflösen**:
- Normal label: **PDF herunterladen**
- Active label: **PDF wird erstellt**
- Disabled when `downloadDisabled`, `isDownloading`, or `attachments.length === 0`
- Keep the control as a native button through the shared `Button` component, with a clear accessible
label that includes the sub-document name if the visible label alone is ambiguous in assistive
technology.
- Prevent the button from participating in drag initiation in the same way as the existing name and
dissolve controls.
- Keep **Auflösen** visually distinct as the destructive action; do not change its behavior.
Use existing button variants and semantic theme utilities. Do not introduce a new UI dependency or a
second download utility in the child component.
## Files changed
- `src/lib/zip-processing.ts`
- Add `buildSubDocumentExport()`.
- Reuse it from separate archive export.
- `src/lib/components/ProcessedZipArchiveEditor.svelte`
- Coordinate individual export state, errors, and browser download.
- `src/lib/components/SubDocumentEditor.svelte`
- Add the individual download button and callback props.
- `agents.md`
- Clarify that per-archive separate export and direct per-sub-document export are both supported.
No route, server endpoint, persistence, package, lockfile, or Docker changes are required.
## Verification
### Automated checks
Run:
```sh
pnpm exec prettier --write \
src/lib/zip-processing.ts \
src/lib/components/ProcessedZipArchiveEditor.svelte \
src/lib/components/SubDocumentEditor.svelte \
agents.md
pnpm check
pnpm lint
pnpm build
```
There is no test framework in the repository. Do not add one solely for this feature. If a test
framework is introduced separately, cover `buildSubDocumentExport()` with non-empty, empty, ordering,
and filename cases.
### Manual scenarios
1. Create an archive with two sub-documents and at least one loose attachment.
2. Download the first sub-document and verify exactly one PDF is triggered.
3. Confirm the PDF contains only the first sub-document's attachments and preserves their displayed
order.
4. Confirm the filename is `{archive} - {sub-document}.pdf` without duplicate `.pdf` suffixes.
5. Download the second sub-document and verify it has independent contents and naming.
6. Confirm an empty sub-document's download action is disabled.
7. Start an individual export and verify duplicate, sibling, and archive-level export actions cannot
start until it finishes.
8. Force an invalid PDF/image merge and verify a German inline error appears, loading state clears,
and editor state is unchanged.
9. Verify **Eine PDF** still exports all sub-documents followed by loose attachments.
10. Verify **Getrennt** still exports every non-empty sub-document and the loose-attachment PDF.
11. Verify rename, reorder, move, selection, Escape, and **Auflösen** interactions still work.
12. Verify the new control remains usable on a narrow viewport and by keyboard.
## Acceptance criteria
- Every non-empty sub-document has a directly accessible download action.
- The action downloads one PDF containing only that sub-document's ordered attachments.
- Individual and archive-level exports use the same filename convention and merge implementation.
- Empty sub-documents cannot be exported.
- Loading and error states are visible, duplicate operations are prevented, and state always resets.
- Existing archive-level export behavior and all editor interactions are preserved.
- `pnpm check`, `pnpm lint`, and `pnpm build` pass without new issues.
+330
View File
@@ -0,0 +1,330 @@
# Plan: Teildokumente besser aufteilen (PDF & beA-ZIP)
## Goal
Splitting an archive's content into multiple Teildokumente is a core workflow of the beA editor,
but the current UX handles only the easy half of it. This plan closes two gaps:
1. **PDFs split into sub-documents.** A single PDF attachment (e.g. one scan containing several
documents) cannot be divided at page level today. The user has to leave the app, split the PDF
with an external tool, and re-zip the results — the beA route only accepts ZIPs.
2. **Grouping UX.** Creating and re-arranging Teildokumente relies on a modal-ish selection mode
and HTML5 drag-and-drop only. It is hard to discover, hard to use on touch devices (relevant
since the app is an installable PWA), and offers no path for regrouping attachments that are
already inside a Teildokument except dissolving the whole block.
All features stay 100 % client-side, German UI copy, no new dependencies.
---
## Current-state analysis (UX pain points)
| # | Pain point | Where |
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| P1 | A multi-document PDF attachment cannot be split into several Teildokumente at all. | `ProcessedZipArchiveEditor.svelte` — attachments are atomic |
| P2 | PDFs cannot enter the beA workspace; only ZIPs are accepted. Splitting a PDF means zipping it first. | `zip-selection.ts`, `ZipDropzone.svelte` |
| P3 | Grouping is hidden behind an unlabeled-purpose "Auswählen" mode toggle; the action bar with "Teildokument erstellen" only appears after the first checkbox, so the goal of the mode is unclear. | `ProcessedZipArchiveEditor.svelte` |
| P4 | Selection mode only covers loose attachments. To regroup attachments that already sit in a Teildokument, the user must dissolve the whole block (all-or-nothing) or drag each file individually. | `ProcessedZipArchiveEditor.svelte`, `SubDocumentEditor.svelte` |
| P5 | Moving attachments relies exclusively on HTML5 drag-and-drop, which does not work with touch input and has no keyboard alternative. | both editors |
| P6 | New Teildokumente get the name `Teildokument ${subDocuments.length + 1}` — dissolving and re-creating produces duplicate names — and the name input is not focused, so the rename step is easy to miss. | `createSubDocumentFromSelection` |
| P7 | The newly created Teildokument renders far below the loose list; there is no scroll-into-view or other confirmation. | `ProcessedZipArchiveEditor.svelte` |
| P8 | No quick actions for the most common grouping patterns: "every attachment becomes its own Teildokument", "create empty Teildokument and fill it". | — |
| P9 | `tests/e2e/bea.spec.ts` has zero coverage of the sub-document flow (no grouping, no drag, no export-mode tests). | `tests/e2e/bea.spec.ts` |
---
## Open decisions (defaults marked ✅)
### D1 — Entry point for PDFs
- ✅ **A. Accept PDFs in the beA workspace.** Extend the beA dropzone/pickers to also accept PDF
files. A PDF is turned into a `ProcessedZipArchive` with `name: {fileName}.pdf`,
`attachments: [ {kind: 'pdf', data, path: fileName, name: fileName} ]`, `subDocuments: []`.
The archive editor then offers the full splitting workflow on it.
- B. Keep beA ZIP-only; route PDF splitting to the planned standalone `/tools/extract`
(extract-split-plan.md). Rejected: its output is a ZIP of PDFs, which the user would then have
to re-import — a disconnected round trip.
> Naming note: the route heading and dropzone copy should mention PDFs
> ("beA-ZIP oder PDF auswählen") so the entry point is discoverable.
### D2 — Page-level split interaction
- ✅ **A. Split dialog per PDF attachment.** A "Teilen" action on a PDF attachment opens a dialog
that reuses `loadPdfDocument` + `PdfPageGrid` (same pattern as `tools/separate`). The user
selects a page set, clicks "Als Teildokument übernehmen", the dialog stays open with the
remaining pages and a fresh empty selection for the next Teildokument, until "Fertig".
This guided loop mirrors how documents are actually separated (page range by page range).
- B. Inline page expansion in the archive editor (expand thumbnails for every attachment).
Rejected: doubles memory (pdfjs documents for every PDF stay alive) and clutters the editor.
### D3 — What happens to the source PDF
- ✅ **A. "Ausschneiden" (cut) by default.** The selected pages move into the new Teildokument;
the source attachment keeps the remaining pages. If all pages are consumed, the source
attachment is removed. A checkbox "Seiten im Original behalten" (copy mode) is available for
the rare case where pages should appear in two Teildokumente.
- B. Copy-only. Rejected: the feature's purpose is _splitting_; copy-only leaves the user with
manual cleanup every time.
### D4 — Grouping mode redesign
- ✅ **A. Keep a temporary grouping mode, but make it self-explanatory and archive-wide.**
Rename the toggle to **"Gruppieren"** (with a layers/group icon). While active:
- the action bar is always visible (disabled at 0 selected), showing the count and the
"Teildokument erstellen" button,
- checkboxes appear on loose **and** sub-document attachments, so regrouping no longer
requires dissolving,
- the bar offers **"In Teildokument verschieben"** (a select of existing Teildokumente) next
to "Teildokument erstellen", covering both create-new and move-into-existing in one place,
- "Alle auswählen" / "Auswahl aufheben" buttons are added (parity with the separate tool).
- B. Remove the mode entirely in favour of per-attachment menus only. Rejected: multi-select is
the fastest way to group many attachments; menus are one-file-at-a-time.
### D5 — Touch/keyboard-friendly moves
- ✅ **A. Per-attachment action menu.** Each attachment card gets a small "⋯" menu (visible on
hover/focus, always rendered for screen readers and touch) with:
- "In Teildokument verschieben →" (existing Teildokumente + "Neues Teildokument"),
- "Teilen…" (PDFs only, opens the D2 dialog),
- "Entfernen" (remove attachment from the archive, with confirm).
Drag-and-drop stays as the fast path for pointer users.
- B. Up/down move buttons on every card. Rejected: six buttons per card is visual noise, and it
cannot express "into another Teildokument" without a target chooser anyway.
### D6 — Bulk split modes in the dialog
- ✅ **A. Offer "Jede Seite einzeln" and "Alle N Seiten"** alongside the manual page selection
(same modes as extract-split-plan.md D2). Each chunk becomes one Teildokument named
`Seiten {first}–{last}`; the source attachment is removed (D3 cut semantics).
- B. Manual ranges only for v1. Kept as the fallback if the chunking logic proves fiddly.
### D7 — Naming of new Teildokumente
- ✅ **A.** Keep the editable inline name, but number with `Math.max` of existing numbers + 1
(parse trailing digits of names starting with "Teildokument") so dissolve/re-create cycles no
longer produce duplicates, and **focus + select the name input** of the freshly created block,
then scroll it into view. The split dialog (D2) pre-fills the name from the page range
("Seiten 1–3") and lets the user edit it inline per iteration.
---
## UX concept
### Workstream A — Grouping UX (beA-ZIP → Teildokumente)
1. **"Gruppieren" toggle** in the archive header (replaces "Auswählen"/"Fertig"). Archive-wide
multi-select with an always-visible action bar:
`N Dateien ausgewählt` · "Teildokument erstellen" · "In Teildokument verschieben ▾" ·
"Alle auswählen" / "Auswahl aufheben". Escape/"Fertig" exits and clears.
2. **Per-attachment "⋯" menu** (D5) for single-file moves and splitting without entering any mode.
3. **Empty Teildokument**: new header button "Neues Teildokument" creates an empty block (the
existing empty-drop-zone state in `SubDocumentEditor` is already the right affordance).
4. **Quick action**: "Jede Datei als Teildokument" in the header menu converts all loose
attachments into one Teildokument each, in current order.
5. **Feedback**: newly created blocks are scrolled into view; their name input is focused.
### Workstream B — Page-level splitting (PDF → Teildokumente)
1. **PDF acceptance** in the beA workspace (D1): dropzone, file picker, and the start-page
drop-forward accept `.pdf` alongside ZIPs; a dropped PDF opens the same workspace.
2. **"Teilen…" dialog** (D6/D3): page grid via `PdfPageGrid`, selection via click, live label
("3 von 12 Seiten ausgewählt"), "Als Teildokument übernehmen" applies the cut and re-opens
for the next range; bulk radio "Jede Seite einzeln" / "Alle N Seiten" with a file-count preview
("Es werden 4 Teildokumente erstellt."). "Fertig" closes.
3. The generated chunks are regular PDF attachments (kind `'pdf'`), so export, reordering, and
previews need no special cases.
---
## Technical approach
### Data model (`src/lib/zip-processing.ts`)
`SubDocument` and `ProcessedZipArchive` stay unchanged. Splitting produces **synthetic
attachments** (implemented helpers; page _sets_ instead of plain ranges so arbitrary grid
selections work — a range is the special case used by the bulk modes):
```ts
export type PdfSplitResult = {
groups: Array<{ name: string; attachment: ZipAttachment }>;
source: ZipAttachment | null; // remaining pages; null removes the source
};
// New helpers (pure, immutable — same style as moveAttachmentIntoSubDocument):
createAttachmentFromPages(source, pages: number[], name?) // copy a 1-based page set into a new attachment
removePagesFromAttachment(source, pages: number[]) // complement; null when empty
applyPdfSplit(archive, sourcePath, result) // replace source, insert new sub-documents after its parent block
pdfFileToArchive(file) // D1: PDF → single-attachment archive
nextSubDocumentNumber(subDocuments) // max trailing "Teildokument N" number + 1
createSubDocumentFromPaths(archive, paths) // group any selection into a new Teildokument
moveAttachmentsByPathsToSubDocument(archive, paths, id) // move selection into an existing Teildokument
groupEachAttachmentAsSubDocument(archive) // quick action: one Teildokument per loose file
removeAttachmentFromArchive(archive, path) // menu "Entfernen"
```
- **Unique `path`s are mandatory**: `path` is the `{#each}` key and the drag/selection identity
(`selectedPaths`). The `#seiten-…-uuid` suffix guarantees uniqueness even when the same range
is split twice (copy mode).
- Splitting an attachment **inside a Teildokument** inserts the new Teildokument **directly
after the source's parent block** and applies the cut to the copy inside that block, so the
user's reading order is preserved.
### Shared page extraction
Implement `copyPagesToNewPdf(sourceBytes, pageNumberList): Promise<Uint8Array>` in
`src/lib/pdf-processing.ts` exactly as specified in extract-split-plan.md D3, and rewire
`tools/separate` to use it. If `/tools/extract` is built first, reuse the helper — the two plans
must not fork the code.
### Components
- **New `src/lib/components/PdfSplitDialog.svelte`**: props `{ attachment, onConfirm }`.
Loads thumbnails through `loadPdfDocument` and destroys the handle on close (same memory
safeguards as `AttachmentPreview`); rendering happens only while open. Uses the existing
shadcn dialog primitives.
- **New `src/lib/components/AttachmentMenu.svelte`**: the "⋯" menu (D5), built on the existing
shadcn dropdown-menu primitives; used by both editors.
- **`ProcessedZipArchiveEditor.svelte`**: grouping-mode rework (D4), quick actions, "Neues
Teildokument" button, split-dialog wiring, scroll-into-view + name focus after creation.
- **`SubDocumentEditor.svelte`**: render the "⋯" menu per attachment; grouping-mode checkboxes
forward selection to the parent (selection stays owned by the archive editor).
### PDF acceptance (D1)
- `zip-selection.ts`: add `PDF_ACCEPT_TYPES`/`isAcceptedPdfFile` and a combined
`isAcceptedWorkspaceFile`.
- `ZipDropzone.svelte`, both compact pickers, and the start-page drop-forward: accept PDFs; the
beA page routes PDF files to a `pdfFileToArchive(file)` wrapper that produces the synthetic
single-attachment archive.
- Jobs list: a PDF job shows the same processing row; it cannot fail like a nested-ZIP extraction
can (only a pdf-lib load error, surfaced with the existing German error copy).
### Invariants (from agents.md, restated as acceptance criteria)
- No move/split operation may duplicate or discard attachment data (cut moves bytes, copy
duplicates them deliberately and only on explicit user request).
- Teildokument IDs stay stable across edits and reordering.
- Single-PDF export order stays: Teildokumente in order, each attachment in order, loose
attachments last.
- Dissolving a Teildokument appends its attachments to the loose list (unchanged).
- All state helpers return new archive objects; the route remains the source of truth.
---
## Files changed
| File | Change |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| `src/lib/pdf-processing.ts` | `copyPagesToNewPdf` (shared with extract-split-plan) |
| `src/lib/zip-processing.ts` | page-range helpers, `createSubDocumentPerAttachment`, `nextSubDocumentNumber`, `pdfFileToArchive` |
| `src/lib/components/PdfSplitDialog.svelte` | new — page-selection dialog with manual + bulk modes |
| `src/lib/components/AttachmentMenu.svelte` | new — per-attachment actions |
| `src/lib/components/ProcessedZipArchiveEditor.svelte` | grouping-mode rework, quick actions, split wiring |
| `src/lib/components/SubDocumentEditor.svelte` | attachment menu, grouping-mode checkboxes |
| `src/lib/zip-selection.ts`, `ZipDropzone.svelte`, `ZipFilePicker.svelte` | PDF acceptance |
| `src/routes/tools/bea/+page.svelte`, `src/routes/+page.svelte` | PDF → archive wrapper, drop-forward |
| `tests/e2e/bea.spec.ts` (+ fixtures) | sub-document, grouping, splitting coverage |
| `agents.md` | flow + invariants updates |
---
## Implementation phases
### Phase 1 — Shared extraction helper + pure helpers
`copyPagesToNewPdf`, page-range/splitting helpers, `nextSubDocumentNumber`,
`pdfFileToArchive`. Unit-verifiable pure functions, no UI. Rewire `tools/separate` to the shared
helper.
### Phase 2 — Grouping UX rework
"Gruppieren" mode with always-visible action bar, archive-wide selection, move-into-existing
select, select all/none, naming/numbering fix, focus + scroll-into-view feedback.
### Phase 3 — Attachment menu + quick actions
`AttachmentMenu.svelte` in both editors (touch/keyboard path), "Neues Teildokument",
"Jede Datei als Teildokument".
### Phase 4 — Split dialog
`PdfSplitDialog.svelte` with manual cut/copy loop and bulk modes; wire "Teilen…" from the
attachment menu; insertion after the source block.
### Phase 5 — PDF acceptance in the beA workspace
Selection types, dropzone/picker/start-page plumbing, synthetic archive, job error copy.
### Phase 6 — Polish, agents.md, tests
Copy pass, empty/edge states, `agents.md` update, full e2e suite.
Phases 2–5 are independent enough to land separately (each behind the Phase 1 helpers where it
needs them).
---
## Verification
- `pnpm check`, `pnpm lint`, `pnpm build` — no new errors.
- `pnpm test:e2e` with new cases in `bea.spec.ts` (current suite has no sub-document coverage):
- group loose attachments via "Gruppieren" → names/counts correct, order preserved;
- select attachments inside existing Teildokumente and move them into another Teildokument
without dissolving;
- regroup without drag-and-drop (menu path — this is also the touch-equivalent path);
- split a PDF attachment: cut three pages into a new Teildokument, remainder stays as source,
both export correctly via "Eine PDF" and "Getrennt";
- bulk mode "Jede Seite einzeln" produces one Teildokument per page, source removed;
- drop a bare PDF onto the beA dropzone and onto the start page → workspace with one
attachment; split and export;
- duplicate-name regression: dissolve + re-create → "Teildokument 2", not a second
"Teildokument 1".
- Manual: run the split dialog on a 100+ page scan (thumbnail memory, dialog close cleanup);
touch device pass for menu-based moves.
- `pnpm test:pwa` still passes (PDF acceptance changes the dropzone precached flow inputs only).
---
## Risks
- **Memory**: the split dialog keeps a pdfjs document + thumbnails alive while open. Mitigated by
the dialog lifecycle (load on open, `destroy()` on close/discard) and the existing managed
thumbnail service.
- **Path collisions**: synthetic paths must be globally unique per archive; the uuid suffix
makes this safe, but the grouping/selection code must never assume `path` is a real file-system
path (it already only uses it as identity).
- **Forking with extract-split-plan**: both plans need `copyPagesToNewPdf`. Whichever lands first
owns the helper; the other must reuse it.
- **Copy-mode confusion**: cut vs copy must be visually explicit in the dialog (default checked
= cut), otherwise users may assume data loss.
## Out of scope
- Undo/redo for archive edits (separate plan; this plan's helpers are immutable, which is the
prerequisite for it).
- ZIP export of the edited structure (export-zip-plan.md).
- Free-form range input ("1-3, 5, 8-10") in the split dialog — follow-up after v1.
- Splitting image attachments into pages (images are single-page by definition).
- XJustiz-aware auto-grouping (e.g. pre-filling Teildokument names from document names) —
candidate for a later plan once the metadata panel (xjustiz-metadata-panel-plan.md) exists.
---
## Status: Implemented
Deviations from the original text, made during implementation:
- Page helpers work on **1-based page sets** (`createAttachmentFromPages`,
`removePagesFromAttachment`) instead of `PageRange` only, because the manual dialog mode selects
arbitrary grid pages; the bulk modes pass contiguous sets.
- The split dialog applies the cut **once on "Teilen"** (accumulating groups with "Als Teildokument
übernehmen" first) rather than re-encoding the source after every assignment; the semantics are
identical and the grid shows assigned pages with a "✓ Zugewiesen" badge.
- Split-chunk attachments use the source-based default names (`vertrag_Seiten_1-2.pdf`); the
user-facing names live on the Teildokumente.
- Toolbar labels became file-neutral ("Neue Datei bearbeiten", "Weitere Datei hinzufügen") because
the same pickers now accept PDFs (D1); `agents.md` and the e2e helpers were updated accordingly.
- `PdfPageGrid.svelte` renders the `pageActions` snippet in its clickable branch too (previously
only the static branch), needed for the assigned-page badges.
- The workspace status counts files ("1 Datei · 1 Archiv geöffnet") instead of ZIPs.
+283
View File
@@ -0,0 +1,283 @@
# Plan: Sub-documents within a ZIP archive
**Status: Done**
## Goal
For a processed ZIP archive the user can already **reorder** attachments and **export** them as a single merged PDF. This feature adds the ability to:
1. **Select** some of the archive's attachments and **group them into a "sub-document"** (a named, ordered subset of attachments).
2. **Reorder** attachments _within_ a sub-document — and **reorder** the sub-documents themselves — exactly like the parent archive today.
3. **Choose an export mode** for the sub-documents:
- **Separate files** → each sub-document becomes its own downloaded PDF.
- **One PDF** → all sub-documents are merged into a single downloaded PDF (the existing top-level "PDF herunterladen" behaviour, now scoped to sub-documents when they exist).
The existing flow (no sub-documents created → one merged PDF for the whole archive) stays intact as a fallback.
---
## Open decisions (please confirm before implementation)
These affect the data model and UI. Default proposals are marked ✅.
### D1 — Partition model
- ✅ **A. Optional grouping.** Attachments can be "loose" (not part of any sub-document) _or_ members of a sub-document. Sub-documents are created on demand by selecting attachments.
- B. **Exclusive partition.** Once the user starts creating sub-documents, every attachment must belong to exactly one.
- C. **Shared/multi-membership.** An attachment can appear in multiple sub-documents (references, not moves).
> Recommendation: **A**. It is the least disruptive, matches the current "flat list" mental model, and degrades gracefully when the user does nothing.
### D2 — Selection lifecycle
- ✅ **Selection mode toggle.** A small "Auswählen" button in the archive header switches the editor into selection mode. Only then are checkboxes rendered on each thumbnail; in normal mode the editor looks exactly like today (no permanent checkbox clutter). While in selection mode the user toggles attachments, then clicks "Teildokument erstellen" to consume the selection (which clears the selection but leaves selection mode on, so they can immediately create another sub-document). Exiting selection mode via a "Fertig" button (or Esc) clears any pending selection.
- A sub-document is created from the _current_ selection. Afterwards each attachment can be moved into / out of / between sub-documents via drag-drop or a context action.
- Alternative considered: persistent always-visible checkboxes — rejected as visually noisy for the common case where the user only wants to reorder.
### D3 — Download scope when sub-documents exist
The existing single archive-level "PDF herunterladen" button behaviour needs clarification. Proposed:
- ✅ When **no** sub-documents exist → today's behaviour (merge all attachments into one PDF).
- When sub-documents **do** exist → show a **download mode toggle** offered to the user at export time:
- **Getrennt** (separate): one PDF per sub-document (loose attachments are downloaded individually, matching their kind — PDF passthrough, image as-is or as a tiny PDF — TBD in D4).
- **Eine PDF** (one): all sub-documents (and loose attachments, optionally — see D5) merged in sub-document order, with attachment order preserved inside each.
- ✅ "Separate" downloads are triggered sequentially (same pattern as today's `exportAllZipArchivesAsPdf`).
### D4 — Format of "separate" downloads
- ✅ Each sub-document is downloaded as a **PDF** (merge its attachments with the existing `mergeProcessedZipArchive` logic, scoped to the sub-document's attachments). Naming: `{archiveName} - {subDocumentName}.pdf`.
- B. Each sub-document is downloaded as a **ZIP** containing its original files.
- C. Mixed: keep each attachment's original format (PDFs passthrough, images as image files).
> Recommendation: **A** for consistency with the existing PDF-first export. We can revisit if the actual need is "preserve original files."
### D5 — Treatment of loose (ungrouped) attachments at export
- ✅ When exporting **separate**: loose attachments are downloaded each as their own PDF (or skipped — needs a quick confirm).
- ✅ When exporting **one PDF**: loose attachments are appended at the end (in their existing order) — or skipped.
- B. Ignore loose attachments entirely during export.
> Recommendation: **include loose attachments** at the end of both modes, so nothing is silently lost. Confirm with user.
### D6 — Naming of sub-documents
- ✅ A sub-document has a user-editable name (default e.g. `Teildokument 1`, `Teildokument 2`, …) shown next to its attachments, mirroring the archive name input.
### D7 — Persistence
- ✅ Sub-document structure lives only in component state (same as today's reordering) — no localStorage persistence required for v1. (We already do not persist reordering.)
---
## Data model (`src/lib/zip-processing.ts`)
Introduce a `SubDocument` type alongside `ZipAttachment` and extend `ProcessedZipArchive`.
```ts
export type SubDocument = {
id: string; // stable id for keyed {#each} and drag state
name: string; // user-editable
attachments: ZipAttachment[];
};
export type ProcessedZipArchive = {
name: string;
attachments: ZipAttachment[]; // loose attachments (unchanged shape)
subDocuments: SubDocument[]; // NEW — defaults to [] for backward compat
};
```
Notes:
- Attachments inside `SubDocument` reuse the existing `ZipAttachment` type unchanged.
- `extractZipEntries` populates `subDocuments: []`. All existing call sites continue to work.
- A sub-document's `id` is generated with `crypto.randomUUID()` (available in browser + modern Node) — never re-derived from index, so reordering stays stable.
### New pure helpers (unit-test-friendly, no DOM)
```ts
// Create a sub-document from a list of attachments.
export const createSubDocument = (name: string, attachments: ZipAttachment[]): SubDocument => ({
id: crypto.randomUUID(),
name,
attachments
});
// Move an attachment from the loose list (or another sub-document) into a sub-document.
export const moveAttachmentIntoSubDocument = (
archive: ProcessedZipArchive,
attachmentPath: string,
source: { kind: 'loose' } | { kind: 'subDocument'; id: string },
target: { kind: 'loose'; index: number } | { kind: 'subDocument'; id: string; index: number }
): ProcessedZipArchive => {
/* immutable update */
};
// Reorder sub-documents (same shape as the existing moveAttachment helper).
export const moveSubDocument = (
archive: ProcessedZipArchive,
fromIndex: number,
toIndex: number
): ProcessedZipArchive => {
/* ... */
};
// Flatten the archive into an ordered list of attachments for "one PDF" export.
// sub-document order is preserved; loose attachments appended at the end (per D5).
export const flattenArchiveForMerge = (archive: ProcessedZipArchive): ZipAttachment[] => {
const ordered: ZipAttachment[] = [];
for (const sub of archive.subDocuments) ordered.push(...sub.attachments);
ordered.push(...archive.attachments);
return ordered;
};
// Build one merged-PDF byte array per export unit.
// - mode 'single' → returns [ { name, bytes } ] from the whole archive (existing behaviour)
// - mode 'separate' → returns one entry per sub-document (+ loose attachments per D5)
export type ExportUnit = { name: string; bytes: Uint8Array };
export const buildArchiveExport = async (
archive: ProcessedZipArchive,
mode: 'single' | 'separate'
): Promise<ExportUnit[]> => {
/* ... */
};
```
`mergeProcessedZipArchive` is refactored to delegate to a new `mergeAttachments(attachments): Promise<Uint8Array>` (a thin rename of the existing inner loop) so both "single" and "separate" share the same merging code.
---
## UI
### `ProcessedZipArchiveEditor.svelte` (main work)
State additions:
- `subDocuments = $state<SubDocument[]>([])` synced from `archive.subDocuments` in the existing `$effect`.
- `selectionMode = $state<boolean>(false)` — whether selection checkboxes are currently shown on thumbnails.
- `selectedPaths = $state<Set<string>>([])` — currently selected attachment paths (keyed by `attachment.path`, which is unique within an archive after extraction). Only meaningful while `selectionMode` is on.
- `downloadMode = $state<'single' | 'separate'>('single')` (only shown when sub-documents exist).
- Existing drag handlers generalised to handle three drag sources/targets:
- loose attachment ↔ loose attachment (today's behaviour),
- loose attachment → sub-document,
- sub-document attachment ↔ sub-document attachment,
- sub-document ↔ sub-document (reorder).
- Drag-and-drop is disabled while in selection mode (so a click toggles selection rather than starting a drag).
Layout:
- Keep the existing horizontal `<ul>` for **loose** attachments.
- In the header, add a small toggle button labelled **"Auswählen"** (icon: `IconMenu` or a checkmark lucide icon). When active it reads **"Fertig"** (and selection mode is on). In normal mode thumbnails have no checkbox at all — the editor looks identical to today.
- While in selection mode, each thumbnail card shows a **checkbox** in the top-left corner and the whole card becomes a click target that toggles its membership in `selectedPaths`. Drag-to-reorder is suspended in this mode to avoid click/drag ambiguity.
- A new **inline action bar** appears above the loose-attachment list when `selectionMode && selectedPaths.size > 0`:
- Button **"Teildokument erstellen"** → calls `createSubDocument` with the selected attachments, removes them from the loose list, clears `selectedPaths`, and keeps `selectionMode` on so the user can immediately build the next sub-document.
- `Esc` exits selection mode (clearing `selectedPaths`); the "Fertig" button does the same.
- Below the loose list, render `subDocuments` as a **vertical stack of "sub-document blocks"**. Each block is itself a horizontal reorderable `<ul>` of attachments (same `AttachmentPreview`, same drag handle `::`, same thumbnail width). Each block has:
- an editable name input (mirrors archive name input styling),
- a count label,
- a "remove" action that returns its attachments back to the loose list,
- a draggable header (`::`) to reorder the block among its siblings.
- Sub-document blocks are draggable as a whole via their header (HTML5 DnD, mirroring the existing pattern in `handleDragStart/Drop`).
Export controls (header area):
- If `subDocuments.length === 0` → existing **"PDF herunterladen"** button + behaviour (unchanged).
- If `subDocuments.length > 0` → replace with a **segmented control** (`Eine PDF` / `Getrennt`) plus the download button labeled accordingly:
- `Eine PDF` → `buildArchiveExport(archive, 'single')`, one `downloadPdfBytes` call.
- `Getrennt` → `buildArchiveExport(archive, 'separate')`, sequential `downloadPdfBytes` per unit (matches today's `exportAllZipArchivesAsPdf` loop).
All state changes flow through `onArchiveChange` (already plumbed to `updateProcessedZipFile` in `+page.svelte`) so the top-level array stays the source of truth.
### `+page.svelte`
- `exportAllZipArchivesAsPdf` needs to honour per-archive export mode. Two options:
- ✅ Keep it simple: bulk export uses the existing **"single"** mode for every archive (sub-documents flattened). Add a note in the tooltip.
- B. Read each archive's last-chosen mode (requires propagating `downloadMode` up). Defer to a follow-up if desired.
- The settings-drawer bulk export button label stays; behaviour unchanged for v1.
### No changes required
- `ZipDropzone`, `AttachmentPreview`, services, `xml-reading.service`, `zip-inflating.service`, layout.
---
## Implementation phases
### Phase 1 — Data model + pure helpers
- Edit `src/lib/zip-processing.ts`:
- Add `SubDocument` type and extend `ProcessedZipArchive` with `subDocuments: SubDocument[]` (default `[]`).
- Set `subDocuments: []` in `extractZipEntries`.
- Extract `mergeAttachments(attachments: ZipAttachment[]): Promise<Uint8Array>` from `mergeProcessedZipArchive`.
- Add the pure helpers: `createSubDocument`, `moveAttachmentIntoSubDocument`, `moveSubDocument`, `removeAttachmentFromSubDocument` (returns attachments to the loose list), `flattenArchiveForMerge`, `buildArchiveExport`.
- Verification: TypeScript compiles (`pnpm check` / `svelte-check`); existing tests (if any) still pass; existing UI still renders unchanged.
### Phase 2 — Sub-document block component
- New `src/lib/components/SubDocumentEditor.svelte`:
- Props: `subDocument`, `thumbnailWidth`, `onSubDocumentChange`, `onRemove`, `drag handlers` (or pass a generic reorder callback).
- Renders the editable name, count, remove button, draggable header, horizontal attachment list (reusing `AttachmentPreview` + the same DnD styling).
- Internally mirrors `ProcessedZipArchiveEditor`'s drag logic, scoped to the block.
- Verify: can be unit-rendered in isolation (storybook-equivalent or a throwaway page snippet).
### Phase 3 — Selection mode + sub-document creation in `ProcessedZipArchiveEditor`
- Add `selectionMode` and `selectedPaths` state and the "Auswählen" / "Fertig" header toggle.
- While `selectionMode` is on, render a checkbox on each loose thumbnail and make the whole card a click-to-toggle target; suspend drag-to-reorder to avoid click/drag ambiguity.
- Add the inline action bar with **"Teildokument erstellen"**, visible when a selection exists.
- On create: call `createSubDocument`, remove the selected attachments from the loose list, clear `selectedPaths`, keep `selectionMode` on, and emit `onArchiveChange`.
- `Esc` / "Fertig" exits selection mode and clears the selection.
- On sub-document removal: push its attachments back into the loose list (appended at the end by default).
- Verify: toggling selection mode shows/hides checkboxes; in normal mode the editor looks identical to today; creating, populating, and removing sub-documents works end-to-end with reordering still working for loose attachments.
### Phase 4 — Sub-document drag interactions
- Generalise the existing HTML5 DnD handlers to support the three move scenarios listed above. Use the `dataTransfer` payload to encode `{ source: 'loose' |`sub:${id}`, index }`.
- Reorder sub-document blocks via header drag.
- Verify: items can be moved loose↔sub and sub↔sub; sub-document order can be changed; nothing is duplicated or lost (count invariant: `loose.length + Σ sub.attachments.length === total attachments`).
### Phase 5 — Export modes
- Replace header download button with the segmented control + button when sub-documents exist.
- Wire `buildArchiveExport(archive, mode)` to `downloadPdfBytes` (single) and the sequential loop (separate).
- `+page.svelte`: keep `exportAllZipArchivesAsPdf` in single mode; optionally surface a small hint when an archive has sub-documents.
- Verify:
- No sub-documents → existing single-PDF download unchanged.
- With sub-documents, "Eine PDF" flattens and downloads one PDF.
- With sub-documents, "Getrennt" downloads N files named `{archiveName} - {subName}.pdf`.
### Phase 6 — Polish
- Empty states (e.g. empty sub-document block, no loose attachments).
- Disable the create-sub-document button when selection is empty.
- Accessible labels + keyboard operability for checkboxes / segmented control (`bits-ui` SegmentedControl if available, otherwise native radio group).
- Update `agents.md` "ZIP/PDF processing details" + "UI/state details" sections to mention sub-documents.
- Manual smoke test on a real `xjustiz` ZIP with nested archives.
---
## Verification checklist (definition of done)
- [x] `pnpm check` (or `svelte-check`) passes with no new errors.
- [x] `pnpm build` succeeds.
- [x] Loading a ZIP without creating sub-documents produces the exact same merged PDF as today.
- [x] Toggling "Auswählen" shows checkboxes on thumbnails; toggling "Fertig" (or pressing Esc) hides them and clears the selection. In normal mode the editor looks identical to today (no checkboxes).
- [x] Selecting ≥1 attachments and clicking "Teildokument erstellen" removes them from the loose list and adds a named sub-document block, then clears the selection while staying in selection mode for the next group.
- [x] Drag-reordering works for: loose attachments, attachments inside a sub-document, sub-document blocks themselves, and moving an attachment loose↔sub.
- [x] With sub-documents present:
- "Eine PDF" downloads a single PDF containing every attachment (sub-documents first, then loose).
- "Getrennt" downloads one PDF per sub-document, each correctly named.
- [x] Removing a sub-document returns its attachments to the loose list; total attachment count is invariant.
- [x] Bulk "Alle ZIP-Archive als PDF exportieren" still works (single mode).
- [x] `agents.md` updated.
---
## Out of scope (follow-ups)
- Persisting sub-document structure to `localStorage`.
- Renaming attachments themselves (only sub-document names are editable in v1).
- Exporting "separate" as ZIPs or original-format files (see D4-C).
- Per-archive export mode remembered by the bulk exporter (D3 follow-up).
- Drag-and-drop between _different archives_ in `+page.svelte` (today each editor is self-contained).
+146
View File
@@ -0,0 +1,146 @@
# Plan: PWA / Offline-Fähigkeit
## Goal
The app already processes everything locally — but the browser still has to fetch the app shell
from the server on every load. Making **beA-Edit an installable, offline-capable PWA** reinforces
the privacy story ("works without internet after first load"), enables home-screen/desktop
installation, and is essentially free given there are no network calls to cache-around.
---
## Open decisions (defaults marked ✅)
### D1 — Implementation approach
- ✅ **A. `@vite-pwa/sveltekit`** (new **dev** dependency). Mature integration: precaching of the
built assets via Workbox, generated manifest, virtual `RegisterSW` module, works with
`adapter-node` builds. The dependency is justified (agents.md allows deps when the task cannot
reasonably be done without) — hand-rolling a correct service worker + update flow is
error-prone.
- B. Hand-written minimal `sw.js` in `static/` + manual manifest. Zero deps but custom update
logic, manual asset hashing, more long-term maintenance.
### D2 — Update UX
- ✅ **A. Auto-update with a toast.** New versions activate on next navigation; a small toast
"Neue Version verfügbar – Seite neu laden" appears when an update is waiting (Workbox
`onNeedRefresh`/`onOfflineReady` hooks). Never force-reload mid-work (users may have unsaved
archive state).
- B. Silent auto-reload. Rejected: could interrupt an in-progress merge.
### D3 — Legal pages
- ✅ **Review required, not auto-edited.** A service worker + install prompt is client-side
storage/behavior; `datenschutz/+page.svelte` should likely gain a sentence. Flag for the user;
do not rewrite legal copy unilaterally (project constraint).
---
## Technical approach
### Vite config
```ts
// vite.config.ts
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
export default defineConfig({
plugins: [
sveltekit(),
tailwindcss(),
SvelteKitPWA({
registerType: 'prompt',
manifest: {
name: 'beA-Edit',
short_name: 'beA-Edit',
description: 'PDF-Werkzeuge für die beA-Kanzlei – 100 % lokal im Browser.',
lang: 'de',
start_url: '/',
display: 'standalone',
background_color: '#f2f5f8', // align with theme background token
theme_color: '#…', // align with primary token
icons: [
/* 192 + 512 + maskable, derived from static logo */
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,woff2}'],
navigateFallback: '/' // SPA-style offline fallback for app routes
},
devOptions: { enabled: false }
})
]
});
```
Notes:
- pdfjs worker chunk (`pdf.worker.mjs?url`) is part of the built assets → precached by glob.
- Legal routes (`/impressum`, `/datenschutz`) precache like any route; offline navigation works
via the fallback.
- No runtime caching strategies needed: the app makes **no** network requests by design.
### Registration UI
- Root `+layout.svelte`: import the virtual `virtual:pwa-register` module (SSR-guarded), wire
`onNeedRefresh` → toast state; render a small dismissible toast component (build with existing
card/button styling; no new UI dep) with "Neu laden" / "Später".
- `onOfflineReady` → optional one-time hint "beA-Edit funktioniert jetzt offline." (keep subtle).
- Install prompt: rely on the browser's native install affordance (manifest makes it appear);
optionally an "Installieren" hint on the start page later — out of scope for v1.
### Icons
- Generate 192/512/maskable PNGs from the existing logo (`static/` logo asset) once, committed
to `static/icons/`. Use an offline tool/imagemagick during implementation; no runtime dep.
### Docker / adapter-node
- Service worker + manifest are static build output → served by `node build/index.js` without
changes. Verify `Cache-Control` headers don't block SW updates (adapter-node defaults are fine;
the SW itself must not be cached aggressively — Vite PWA emits the right filenames).
## Files changed
- `vite.config.ts` — SvelteKitPWA plugin + manifest.
- `package.json` / lockfile — `@vite-pwa/sveltekit` dev dependency (via pnpm only).
- `src/routes/+layout.svelte` — registration + update toast.
- `static/icons/*` — generated icons; `static/manifest.webmanifest` handled by plugin.
- `src/app.html` — `theme-color` meta if plugin doesn't inject.
- `agents.md` — document PWA setup + update behavior.
- `datenschutz/+page.svelte` — only after explicit user approval (D3).
## Implementation phases
1. Add dependency, configure plugin + manifest + icons.
2. Registration + update toast in root layout.
3. Build + `pnpm preview`: verify `/` loads, then devtools → Application → service worker
registered; toggle "Offline" → reload → app still works.
4. Production image smoke test (Docker build, offline reload).
5. Update `agents.md`; flag datenschutz review.
## Verification
- Fresh load registers the SW (devtools), `onOfflineReady` path reachable.
- Offline reload after precache: start page + all tool routes render; dropping a ZIP still works
fully (entire processing chain offline).
- New build deployed → toast offers reload; choosing it loads the new version; declining does
not interrupt current state.
- No console errors on iOS Safari (manifest parsing quirks) — best-effort check.
- `pnpm check`, `pnpm lint`, `pnpm build` pass.
## Risks
- **Stale cache confusion** during development: keep `devOptions.enabled: false`; document in
agents.md that preview/production test the SW, not dev mode.
- **Large precache manifest**: the pdfjs worker + fonts add a few MB to the first precache —
acceptable for an installable tool; monitor build output stats.
- **SW + SvelteKit hydration edge cases** on first offline visit are known upstream; pin a
current stable plugin version and test the exact offline-reload sequence.
## Out of scope
- Background sync (nothing to sync — no network by design).
- App-store packaging.
- Custom install button UI beyond the native affordance.
+29
View File
@@ -0,0 +1,29 @@
/// <reference types="node" />
// TEMPORARY: verification harness for the PWA offline/update behaviour.
// The spec starts the production `node build/index.js` server itself, because a "deployment"
// has to restart the process (adapter-node caches build-file stats at startup).
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',
testMatch: 'pwa.spec.ts',
fullyParallel: false,
workers: 1,
retries: 0,
reporter: 'list',
timeout: 420_000,
use: {
baseURL: 'http://127.0.0.1:4179',
trace: 'retain-on-failure',
launchOptions: {
executablePath: chromiumExecutable,
args: ['--no-sandbox']
}
}
});
+3087 -26
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -1,3 +1,6 @@
/// <reference types="vite-plugin-pwa/client" />
/// <reference types="vite-plugin-pwa/info" />
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
+2
View File
@@ -3,8 +3,10 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#122f62" />
<meta name="text-scale" content="scale" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/icons/apple-touch-icon.png" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
+100
View File
@@ -0,0 +1,100 @@
<script lang="ts">
import { File, Folder, FolderPlus, MoreHorizontal, Scissors, Trash2 } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import type { ZipAttachment } from '$lib/zip-processing';
export type AttachmentMoveTarget = {
id: string;
name: string;
};
type Props = {
attachment: ZipAttachment;
/** Existing sub-document move targets (excluding the attachment's own). */
moveTargets: AttachmentMoveTarget[];
canSplit?: boolean;
onMoveToTarget: (targetId: string) => void;
onMoveToLoose?: () => void;
onSplit?: () => void;
onRemove?: () => void;
};
let {
attachment,
moveTargets,
canSplit = false,
onMoveToTarget,
onMoveToLoose,
onSplit,
onRemove
}: Props = $props();
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger aria-label={`Aktionen für ${attachment.name}`}>
{#snippet child({ props })}
<button
{...props}
type="button"
class="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-md border border-primary-200 bg-white text-primary-700 transition hover:border-primary-400 hover:bg-primary-50 focus-visible:ring-2 focus-visible:ring-primary-300"
aria-label={`Aktionen für ${attachment.name}`}
data-attachment-menu={attachment.path}
draggable="false"
ondragstart={(event) => event.preventDefault()}
>
<MoreHorizontal class="size-3.5" aria-hidden="true" />
</button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" side="top" sideOffset={6} class="w-56">
<DropdownMenu.Group>
<DropdownMenu.GroupHeading>Verschieben</DropdownMenu.GroupHeading>
{#each moveTargets as target (target.id)}
<DropdownMenu.Item
aria-label={`Nach „${target.name}“ verschieben`}
onclick={() => onMoveToTarget(target.id)}
>
<Folder aria-hidden="true" />
<span class="truncate">{target.name}</span>
</DropdownMenu.Item>
{/each}
<DropdownMenu.Item
aria-label="In neues Teildokument verschieben"
onclick={() => onMoveToTarget('new')}
>
<FolderPlus aria-hidden="true" />
<span class="truncate">Neues Teildokument</span>
</DropdownMenu.Item>
{#if onMoveToLoose}
<DropdownMenu.Item aria-label="Zu den losen Dateien verschieben" onclick={onMoveToLoose}>
<File aria-hidden="true" />
<span class="truncate">Zu den losen Dateien</span>
</DropdownMenu.Item>
{/if}
</DropdownMenu.Group>
{#if canSplit && onSplit}
<DropdownMenu.Separator />
<DropdownMenu.Item aria-label={`Teilen: ${attachment.name}`} onclick={onSplit}>
<Scissors aria-hidden="true" />
<span class="truncate">Teilen…</span>
</DropdownMenu.Item>
{/if}
{#if onRemove}
<DropdownMenu.Separator />
<DropdownMenu.Item
variant="destructive"
onclick={onRemove}
aria-label={`Entfernen: ${attachment.name}`}
>
<Trash2 aria-hidden="true" />
<span class="truncate">Entfernen</span>
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
+21 -15
View File
@@ -1,14 +1,14 @@
<script lang="ts">
import { onMount } from "svelte";
import { onMount } from 'svelte';
import type {ZipAttachment} from "$lib/zip-processing";
import type { ZipAttachment } from '$lib/zip-processing';
type Props = {
attachment: ZipAttachment;
class?: string;
};
let { attachment, class: className = "" }: Props = $props();
let { attachment, class: className = '' }: Props = $props();
let imageUrl = $state<string | null>(null);
let previewError = $state<string | null>(null);
@@ -31,19 +31,21 @@
};
(async () => {
const pdfjsLib = await import("pdfjs-dist");
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);
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
(m) => m.default
);
const loadPreview = async () => {
previewError = null;
imageUrl = null;
if (attachment.kind === "image") {
if (attachment.kind === 'image') {
const imageBuffer = attachment.data.buffer.slice(
attachment.data.byteOffset,
attachment.data.byteOffset + attachment.data.byteLength,
attachment.data.byteOffset + attachment.data.byteLength
) as ArrayBuffer;
setBlobImage(new Blob([imageBuffer]));
return;
@@ -62,15 +64,15 @@
}
const viewport = page.getViewport({ scale: 1 });
const canvasElement = document.createElement("canvas");
const canvasElement = document.createElement('canvas');
canvasElement.width = viewport.width;
canvasElement.height = viewport.height;
const canvasContext = canvasElement.getContext("2d");
const canvasContext = canvasElement.getContext('2d');
if (!canvasContext) {
throw new Error("Could not create thumbnail canvas context.");
throw new Error('Could not create thumbnail canvas context.');
}
await page.render({ canvas: canvasElement, canvasContext, viewport }).promise;
@@ -87,8 +89,8 @@
return;
}
reject(new Error("Could not create thumbnail blob."));
}, "image/png");
reject(new Error('Could not create thumbnail blob.'));
}, 'image/png');
});
setBlobImage(blob);
@@ -96,7 +98,7 @@
await loadingTask.destroy();
} catch (error) {
console.error(`Failed to render preview for ${attachment.path}`, error);
previewError = "PDF-Vorschau konnte nicht geladen werden.";
previewError = 'PDF-Vorschau konnte nicht geladen werden.';
}
};
@@ -112,7 +114,9 @@
<div class={className}>
{#if previewError}
<div class="flex h-full min-h-0 items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-3 py-4 text-center text-[12px] text-primary-800">
<div
class="flex h-full min-h-0 items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-3 py-4 text-center text-[12px] text-primary-800"
>
{previewError}
</div>
{:else if imageUrl}
@@ -122,7 +126,9 @@
src={imageUrl}
/>
{:else}
<div class="flex items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-3 py-4 text-center text-[12px] text-primary-800">
<div
class="flex items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-3 py-4 text-center text-[12px] text-primary-800"
>
Vorschau wird erstellt
</div>
{/if}
@@ -41,7 +41,7 @@
let isExporting = $state(false);
const statusText = $derived.by(() => {
const zipText = `${selectedZipCount} ${selectedZipCount === 1 ? 'ZIP' : 'ZIPs'}`;
const fileText = `${selectedZipCount} ${selectedZipCount === 1 ? 'Datei' : 'Dateien'}`;
const archiveText = `${processedArchiveCount} ${
processedArchiveCount === 1 ? 'Archiv' : 'Archive'
} geöffnet`;
@@ -49,7 +49,7 @@
pendingZipCount > 0
? ` · ${pendingZipCount} ${pendingZipCount === 1 ? 'wird' : 'werden'} geöffnet`
: '';
return `${zipText} · ${archiveText}${pendingText}`;
return `${fileText} · ${archiveText}${pendingText}`;
});
const commit = async (action: () => void | Promise<void>) => {
@@ -93,12 +93,12 @@
<div class="flex flex-wrap items-center gap-2">
<ZipFilePicker
pickerId="replace-zip"
ariaLabel="Neue ZIP bearbeiten"
ariaLabel="Neue Datei bearbeiten"
disabled={isCommitting}
onFilesSelected={(files) => (pendingReplacementFiles = files)}
>
<FilePlus2 class="size-4" aria-hidden="true" />
Neue ZIP bearbeiten
Neue Datei bearbeiten
</ZipFilePicker>
<Popover.Root>
@@ -153,14 +153,14 @@
<ZipFilePicker
pickerId="append-zip"
ariaLabel="Weitere ZIP hinzufügen"
ariaLabel="Weitere Datei hinzufügen"
variant="bordered"
disabled={isCommitting}
class="w-full justify-start"
onFilesSelected={onAppendFiles}
>
<Plus class="size-4" aria-hidden="true" />
Weitere ZIP hinzufügen
Weitere Datei hinzufügen
</ZipFilePicker>
{#if processedArchiveCount >= 2}
@@ -208,7 +208,7 @@
</AlertDialog.Description>
<AlertDialog.Footer>
<AlertDialog.Cancel>Abbrechen</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmReplacement}>Neue ZIP öffnen</AlertDialog.Action>
<AlertDialog.Action onclick={confirmReplacement}>Neue Datei öffnen</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
+3
View File
@@ -70,6 +70,9 @@
✓
</div>
{/if}
{#if pageActions}
{@render pageActions(thumb.pageNumber)}
{/if}
</button>
{:else}
<div
+438
View File
@@ -0,0 +1,438 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { X } from '@lucide/svelte';
import Button from '$lib/components/ui/button.svelte';
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
import {
createAttachmentFromPages,
removePagesFromAttachment,
type PdfSplitResult,
type ZipAttachment
} from '$lib/zip-processing';
import { cn } from '$lib/utils';
type SplitMode = 'manual' | 'perPage' | 'everyN';
type AssignedGroup = {
name: string;
pages: number[];
};
type Props = {
source: ZipAttachment;
onApply: (result: PdfSplitResult) => void;
onClose: () => void;
};
let { source, onApply, onClose }: Props = $props();
let panel = $state<HTMLDivElement | null>(null);
let pdfHandle = $state<PdfDocumentHandle | null>(null);
let loadError = $state<string | null>(null);
let splitError = $state<string | null>(null);
let isProcessing = $state(false);
let mode = $state<SplitMode>('manual');
let selectedPages = $state(new Set<number>());
let assignedGroups = $state<AssignedGroup[]>([]);
let groupName = $state('');
let keepSource = $state(false);
let chunkSize = $state(2);
let cancelled = false;
const pageCount = $derived(pdfHandle?.pageCount ?? 0);
const assignedPages = $derived(new Set(assignedGroups.flatMap((group) => group.pages)));
const modes: Array<{ value: SplitMode; label: string }> = [
{ value: 'manual', label: 'Seiten auswählen' },
{ value: 'perPage', label: 'Jede Seite einzeln' },
{ value: 'everyN', label: 'Alle N Seiten' }
];
const isContiguous = (pages: number[]) => {
const sorted = [...pages].sort((a, b) => a - b);
return sorted.every((page, index) => index === 0 || page === sorted[index - 1] + 1);
};
const describePages = (pages: number[]) => {
const sorted = [...pages].sort((a, b) => a - b);
if (sorted.length === 1) {
return `Seite ${sorted[0]}`;
}
if (isContiguous(sorted)) {
return `Seiten ${sorted[0]}–${sorted[sorted.length - 1]}`;
}
return `Seiten ${sorted.join(', ')}`;
};
const defaultGroupName = (pages: number[]) =>
pages.length === 1 ? `Seite ${pages[0]}` : describePages(pages);
const setMode = (nextMode: SplitMode) => {
mode = nextMode;
selectedPages = new Set();
};
const togglePage = (pageNumber: number) => {
if (assignedPages.has(pageNumber)) {
return;
}
const next = new Set(selectedPages);
if (next.has(pageNumber)) {
next.delete(pageNumber);
} else {
next.add(pageNumber);
}
selectedPages = next;
groupName = defaultGroupName([...next]);
};
const assignSelection = () => {
if (selectedPages.size === 0) {
return;
}
const pages = [...selectedPages].sort((a, b) => a - b);
const name = groupName.trim() || defaultGroupName(pages);
assignedGroups = [...assignedGroups, { name, pages }];
selectedPages = new Set();
groupName = '';
};
const removeGroup = (index: number) => {
assignedGroups = assignedGroups.filter((_, groupIndex) => groupIndex !== index);
};
const bulkChunks = $derived.by(() => {
if (mode !== 'perPage' && mode !== 'everyN') {
return [];
}
const chunks: number[][] = [];
if (mode === 'perPage') {
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) {
chunks.push([pageNumber]);
}
return chunks;
}
const size = Math.max(2, Number.isFinite(chunkSize) ? Math.floor(chunkSize) : 2);
for (let first = 1; first <= pageCount; first += size) {
const chunk: number[] = [];
for (let page = first; page < Math.min(first + size, pageCount + 1); page += 1) {
chunk.push(page);
}
if (chunk.length > 0) {
chunks.push(chunk);
}
}
return chunks;
});
const buildResult = async (groups: AssignedGroup[]): Promise<PdfSplitResult> => {
const splitGroups = [];
for (const group of groups) {
const attachment = await createAttachmentFromPages(source, group.pages);
splitGroups.push({ name: group.name, attachment });
}
const assignedAllPages = groups.flatMap((group) => group.pages);
const remainingSource = keepSource
? source
: await removePagesFromAttachment(source, assignedAllPages);
return { groups: splitGroups, source: remainingSource };
};
const applyGroups = async (groups: AssignedGroup[]) => {
if (groups.length === 0 || isProcessing) {
return;
}
isProcessing = true;
splitError = null;
try {
onApply(await buildResult(groups));
} catch (error) {
console.error(`Failed to split PDF attachment ${source.path}`, error);
splitError = 'Das PDF konnte nicht geteilt werden.';
} finally {
isProcessing = false;
}
};
const applyBulk = () => {
void applyGroups(bulkChunks.map((pages) => ({ name: defaultGroupName(pages), pages })));
};
const applyManual = () => {
void applyGroups(assignedGroups);
};
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
onMount(() => {
panel?.focus();
(async () => {
try {
const loaded = await loadPdfDocument(new Uint8Array(source.data));
if (cancelled) {
loaded.destroy();
return;
}
pdfHandle = loaded;
} catch (error) {
console.error(`Failed to load PDF attachment ${source.path}`, error);
if (!cancelled) {
loadError = 'PDF konnte nicht geladen werden.';
}
}
})();
});
onDestroy(() => {
cancelled = true;
pdfHandle?.destroy();
pdfHandle = null;
});
</script>
<svelte:window onkeydown={handleKeydown} />
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="presentation"
>
<div
bind:this={panel}
class="flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl bg-white shadow-xl outline-none"
role="dialog"
aria-modal="true"
aria-label="PDF teilen"
data-split-dialog
tabindex="-1"
>
<header class="flex items-center justify-between gap-3 border-b border-primary-100 px-5 py-4">
<div class="min-w-0">
<h2 class="text-[16px] font-bold text-primary-900">PDF teilen</h2>
<p class="truncate text-[13px] text-primary-700" title={source.name}>
{source.name} — {pageCount} Seite{pageCount === 1 ? '' : 'n'}
</p>
</div>
<button
type="button"
class="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border border-primary-200 bg-white text-primary-700 transition hover:border-primary-400 hover:bg-primary-50"
aria-label="Teilen abbrechen"
onclick={onClose}
>
<X class="size-4" aria-hidden="true" />
</button>
</header>
<div class="flex-1 space-y-4 overflow-y-auto px-5 py-4">
<div
class="inline-flex flex-wrap items-center rounded-[16px] border border-primary-200 bg-white p-0.5"
role="radiogroup"
aria-label="Art des Teilens"
>
{#each modes as modeOption (modeOption.value)}
<label
class={cn(
'cursor-pointer rounded-[14px] px-3 py-1 text-[13px] font-semibold transition',
mode === modeOption.value
? 'bg-primary text-primary-foreground'
: 'text-primary-700 hover:bg-primary-50'
)}
>
<input
class="sr-only"
type="radio"
name="split-mode"
value={modeOption.value}
checked={mode === modeOption.value}
onchange={() => setMode(modeOption.value)}
/>
{modeOption.label}
</label>
{/each}
</div>
{#if mode === 'everyN'}
<label class="flex flex-wrap items-center gap-2 text-[13px] font-semibold text-primary-900">
Seiten pro Teildokument
<input
class="w-20 rounded-md border border-primary-200 bg-white px-2 py-1 text-[13px] font-medium text-primary-900 outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-200"
type="number"
min={2}
value={chunkSize}
oninput={(event) => {
chunkSize = Number((event.currentTarget as HTMLInputElement).value);
}}
/>
</label>
{/if}
{#if mode !== 'manual' && pageCount > 0}
<p class="text-[13px] font-medium text-primary-700" aria-live="polite">
Es werden {bulkChunks.length}
Teildokument{bulkChunks.length === 1 ? '' : 'e'} erstellt.
</p>
{/if}
{#if loadError}
<div class="rounded-container bg-red-50 px-4 py-3 text-[14px] font-medium text-red-900">
{loadError}
</div>
{:else if pdfHandle}
<PdfPageGrid
thumbnails={pdfHandle.thumbnails}
{selectedPages}
onPageClick={mode === 'manual' ? togglePage : undefined}
class={mode === 'manual' ? '' : 'pointer-events-none'}
cardClass={(pageNumber) =>
assignedPages.has(pageNumber) ? 'bg-primary/10 rounded-md p-1' : ''}
>
{#snippet pageActions(pageNumber)}
{#if assignedPages.has(pageNumber)}
<span
class="mt-1 rounded-full bg-primary px-2 py-0.5 text-[11px] font-bold text-primary-foreground"
>
✓ Zugewiesen
</span>
{/if}
{/snippet}
</PdfPageGrid>
{:else}
<div
class="flex items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-4 py-10 text-[13px] text-primary-700"
>
Vorschau wird erstellt
</div>
{/if}
{#if mode === 'manual' && pdfHandle}
<div class="flex flex-wrap items-center gap-2">
<label class="flex min-w-0 flex-1 items-center gap-2">
<span class="sr-only">Name des Teildokuments</span>
<input
class="w-full min-w-40 rounded-md border border-primary-200 bg-white px-3 py-1.5 text-[13px] font-semibold text-primary-900 outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-200"
type="text"
placeholder={selectedPages.size > 0
? defaultGroupName([...selectedPages])
: 'Name des Teildokuments'}
value={groupName}
oninput={(event) => {
groupName = (event.currentTarget as HTMLInputElement).value;
}}
/>
</label>
<Button
type="button"
disabled={selectedPages.size === 0 || isProcessing}
onclick={assignSelection}
>
Als Teildokument übernehmen
</Button>
</div>
<p class="text-[13px] text-primary-700" aria-live="polite">
{selectedPages.size} von {pageCount} Seiten ausgewählt
</p>
{/if}
{#if assignedGroups.length > 0}
<ul class="flex flex-col gap-2">
{#each assignedGroups as group, index (index)}
<li
class="flex flex-wrap items-center gap-2 rounded-xl border border-primary-200 bg-primary-50 px-3 py-1.5"
>
<span class="min-w-0 flex-1 truncate text-[13px] font-semibold text-primary-900">
{group.name}
</span>
<span class="text-[12px] font-medium text-primary-700">
{describePages(group.pages)}
</span>
<button
type="button"
class="flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-primary-600 transition hover:bg-white hover:text-red-700"
aria-label={`Zuordnung „${group.name}“ aufheben`}
onclick={() => removeGroup(index)}
>
<X class="size-3.5" aria-hidden="true" />
</button>
</li>
{/each}
</ul>
{/if}
{#if splitError}
<div class="rounded-container bg-red-50 px-4 py-3 text-[14px] font-medium text-red-900">
{splitError}
</div>
{/if}
<label class="flex items-center gap-2 text-[13px] font-medium text-primary-900">
<input
class="h-4 w-4 accent-primary-700"
type="checkbox"
checked={keepSource}
onchange={(event) => {
keepSource = (event.currentTarget as HTMLInputElement).checked;
}}
/>
Seiten im Original behalten (kopieren statt ausschneiden)
</label>
</div>
<footer
class="flex flex-wrap items-center justify-end gap-2 border-t border-primary-100 px-5 py-4"
>
<Button type="button" variant="bordered" disabled={isProcessing} onclick={onClose}>
Abbrechen
</Button>
{#if mode === 'manual'}
<Button
type="button"
disabled={assignedGroups.length === 0 || isProcessing}
onclick={applyManual}
data-split-apply
>
{isProcessing ? 'Teildokumente werden erstellt' : 'Teilen'}
</Button>
{:else}
<Button
type="button"
disabled={bulkChunks.length === 0 || isProcessing}
onclick={applyBulk}
data-split-apply
>
{isProcessing ? 'Teildokumente werden erstellt' : 'Teilen'}
</Button>
{/if}
</footer>
</div>
</div>
@@ -1,16 +1,28 @@
<script lang="ts">
import { FilePlus2, Layers } from '@lucide/svelte';
import AttachmentMenu from '$lib/components/AttachmentMenu.svelte';
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
import Button from '$lib/components/ui/button.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import PdfSplitDialog from '$lib/components/PdfSplitDialog.svelte';
import SubDocumentEditor from '$lib/components/SubDocumentEditor.svelte';
import {
applyPdfSplit,
buildArchiveExport,
buildSubDocumentExport,
createSubDocument,
createSubDocumentFromPaths,
dissolveSubDocument,
groupEachAttachmentAsSubDocument,
moveAttachmentIntoSubDocument,
moveAttachmentsByPathsToSubDocument,
moveSubDocument,
nextSubDocumentNumber,
removeAttachmentFromArchive,
removeAttachmentFromSubDocument,
type AttachmentLocation,
type ExportMode,
type PdfSplitResult,
type ProcessedZipArchive,
type SubDocument,
type ZipAttachment
@@ -31,8 +43,11 @@
let looseAttachments = $state<ZipAttachment[]>([]);
let subDocuments = $state<SubDocument[]>([]);
let selectionMode = $state(false);
let groupingMode = $state(false);
let selectedPaths = $state<Set<string>>(new Set());
let focusSubDocumentId = $state<string | null>(null);
let splitSource = $state<ZipAttachment | null>(null);
let removeCandidate = $state<ZipAttachment | null>(null);
let dragIndex = $state<number | null>(null);
let dropIndex = $state<number | null>(null);
@@ -46,6 +61,31 @@
let mergeError = $state<string | null>(null);
const isExportActive = $derived(isMerging || downloadingSubDocumentId !== null);
const hasSubDocuments = $derived(subDocuments.length > 0);
const subDocumentTargets = $derived(
subDocuments.map((subDocument) => ({ id: subDocument.id, name: subDocument.name }))
);
const allAttachmentPaths = $derived.by(() => {
const paths = new Set<string>();
for (const subDocument of subDocuments) {
for (const attachment of subDocument.attachments) {
paths.add(attachment.path);
}
}
for (const attachment of looseAttachments) {
paths.add(attachment.path);
}
return paths;
});
const selectedCount = $derived(
[...selectedPaths].filter((path) => allAttachmentPaths.has(path)).length
);
$effect(() => {
archiveName = archive.name;
@@ -94,8 +134,6 @@
onArchiveChange(composedArchive());
};
const hasSubDocuments = $derived(subDocuments.length > 0);
const toggleSelection = (path: string) => {
const next = new Set(selectedPaths);
@@ -108,48 +146,145 @@
selectedPaths = next;
};
const toggleSelectionMode = () => {
selectionMode = !selectionMode;
const toggleGroupingMode = () => {
groupingMode = !groupingMode;
selectedPaths = new Set();
};
const exitSelectionMode = () => {
selectionMode = false;
const exitGroupingMode = () => {
groupingMode = false;
selectedPaths = new Set();
};
const selectAllAttachments = () => {
selectedPaths = new Set(allAttachmentPaths);
};
const selectNoAttachments = () => {
selectedPaths = new Set();
};
const handleWindowKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && selectionMode) {
exitSelectionMode();
if (event.key === 'Escape' && groupingMode && !splitSource && !removeCandidate) {
exitGroupingMode();
}
};
const createSubDocumentFromSelection = () => {
if (selectedPaths.size === 0) {
const focusLastSubDocument = (nextArchive: ProcessedZipArchive) => {
focusSubDocumentId = nextArchive.subDocuments[nextArchive.subDocuments.length - 1]?.id ?? null;
};
const createGroupFromSelection = () => {
if (selectedCount === 0) {
return;
}
const selectedAttachments = looseAttachments.filter((attachment) =>
selectedPaths.has(attachment.path)
);
const remainingAttachments = looseAttachments.filter(
(attachment) => !selectedPaths.has(attachment.path)
);
const nextArchive = createSubDocumentFromPaths(composedArchive(), selectedPaths);
if (selectedAttachments.length === 0) {
return;
}
const subDocumentNumber = subDocuments.length + 1;
const nextSubDocument = createSubDocument(
`Teildokument ${subDocumentNumber}`,
selectedAttachments
);
looseAttachments = remainingAttachments;
subDocuments = [...subDocuments, nextSubDocument];
emitArchiveChange(nextArchive);
focusLastSubDocument(nextArchive);
selectedPaths = new Set();
commit();
};
const moveSelectionToTarget = (targetId: string) => {
if (targetId === '' || selectedCount === 0) {
return;
}
emitArchiveChange(
moveAttachmentsByPathsToSubDocument(composedArchive(), selectedPaths, targetId)
);
selectedPaths = new Set();
};
const createEmptySubDocument = () => {
const nextSubDocument = createSubDocument(
`Teildokument ${nextSubDocumentNumber(subDocuments)}`,
[]
);
const nextArchive = {
...composedArchive(),
subDocuments: [...subDocuments, nextSubDocument]
};
emitArchiveChange(nextArchive);
focusSubDocumentId = nextSubDocument.id;
};
const groupEachAttachment = () => {
if (looseAttachments.length === 0) {
return;
}
emitArchiveChange(groupEachAttachmentAsSubDocument(composedArchive()));
focusSubDocumentId = null;
};
const locateAttachment = (
sourceArchive: ProcessedZipArchive,
path: string
): AttachmentLocation | null => {
const looseIndex = sourceArchive.attachments.findIndex(
(attachment) => attachment.path === path
);
if (looseIndex !== -1) {
return { kind: 'loose', index: looseIndex };
}
for (const subDocument of sourceArchive.subDocuments) {
const index = subDocument.attachments.findIndex((attachment) => attachment.path === path);
if (index !== -1) {
return { kind: 'subDocument', id: subDocument.id, index };
}
}
return null;
};
const moveAttachmentToTarget = (path: string, targetId: string) => {
if (targetId === 'new') {
const nextArchive = createSubDocumentFromPaths(composedArchive(), new Set([path]));
emitArchiveChange(nextArchive);
focusLastSubDocument(nextArchive);
return;
}
emitArchiveChange(
moveAttachmentsByPathsToSubDocument(composedArchive(), new Set([path]), targetId)
);
};
const moveAttachmentToLoose = (path: string) => {
const location = locateAttachment(composedArchive(), path);
if (!location || location.kind !== 'subDocument') {
return;
}
emitArchiveChange(
removeAttachmentFromSubDocument(composedArchive(), location.id, location.index)
);
};
const confirmRemoveAttachment = () => {
if (!removeCandidate) {
return;
}
emitArchiveChange(removeAttachmentFromArchive(composedArchive(), removeCandidate.path));
removeCandidate = null;
};
const handleSplitApply = (result: PdfSplitResult) => {
if (!splitSource) {
return;
}
emitArchiveChange(applyPdfSplit(composedArchive(), splitSource.path, result));
splitSource = null;
};
const dissolveSubDocumentBlock = (subDocumentId: string) => {
@@ -219,7 +354,7 @@
};
const handleLooseDragStart = (index: number, event: DragEvent) => {
if (selectionMode) {
if (groupingMode) {
event.preventDefault();
return;
}
@@ -371,12 +506,27 @@
</div>
<div class="flex flex-wrap items-center gap-2">
{#if !groupingMode}
<Button type="button" variant="bordered" onclick={createEmptySubDocument}>
<FilePlus2 class="size-4" aria-hidden="true" />
Neues Teildokument
</Button>
{#if looseAttachments.length >= 2}
<Button type="button" variant="bordered" onclick={groupEachAttachment}>
<Layers class="size-4" aria-hidden="true" />
Jede Datei als Teildokument
</Button>
{/if}
{/if}
<Button
type="button"
variant={selectionMode ? 'secondary' : 'bordered'}
onclick={toggleSelectionMode}
variant={groupingMode ? 'secondary' : 'bordered'}
onclick={toggleGroupingMode}
>
{selectionMode ? 'Fertig' : 'Auswählen'}
<Layers class="size-4" aria-hidden="true" />
{groupingMode ? 'Fertig' : 'Gruppieren'}
</Button>
{#if hasSubDocuments}
@@ -432,12 +582,51 @@
</div>
{/if}
{#if selectionMode && selectedPaths.size > 0}
{#if groupingMode}
<div
class="flex flex-wrap items-center gap-3 rounded-xl border border-primary-200 bg-primary-50 px-4 py-2 text-[13px] font-medium text-primary-900"
>
<span>{selectedPaths.size} Datei{selectedPaths.size === 1 ? '' : 'en'} ausgewählt</span>
<Button type="button" onclick={createSubDocumentFromSelection}>Teildokument erstellen</Button>
<span aria-live="polite">
{selectedCount} Datei{selectedCount === 1 ? '' : 'en'} ausgewählt
</span>
<Button type="button" disabled={selectedCount === 0} onclick={createGroupFromSelection}>
Teildokument erstellen
</Button>
{#if hasSubDocuments}
<select
class="h-8 cursor-pointer rounded-[16px] border-2 border-[#E6E7EA] bg-white px-3 text-[13px] font-bold text-primary disabled:cursor-not-allowed disabled:text-[#0B1F4366]"
aria-label="Auswahl in Teildokument verschieben"
disabled={selectedCount === 0}
onchange={(event) => {
const select = event.currentTarget as HTMLSelectElement;
const targetId = select.value;
select.value = '';
moveSelectionToTarget(targetId);
}}
>
<option value="">In Teildokument verschieben…</option>
{#each subDocuments as subDocument (subDocument.id)}
<option value={subDocument.id}>{subDocument.name}</option>
{/each}
</select>
{/if}
<div class="ml-auto flex items-center gap-2">
<Button type="button" variant="tertiary" onclick={selectAllAttachments}>
Alle auswählen
</Button>
<Button
type="button"
variant="tertiary"
disabled={selectedCount === 0}
onclick={selectNoAttachments}
>
Auswahl aufheben
</Button>
</div>
</div>
{/if}
@@ -451,13 +640,13 @@
selectedPaths.has(attachment.path) ? 'border-primary-500 ring-2 ring-primary-300' : ''
)}
style={`width: ${thumbnailWidth}px;`}
draggable={selectionMode ? 'false' : 'true'}
draggable={groupingMode ? 'false' : 'true'}
ondragstart={(event) => handleLooseDragStart(index, event)}
ondragover={(event) => handleLooseDragOver(index, event)}
ondrop={(event) => handleLooseDrop(index, event)}
ondragend={handleLooseDragEnd}
>
{#if selectionMode}
{#if groupingMode}
<label class="flex cursor-pointer flex-col gap-2">
<div class="flex items-center gap-2 px-1 text-[13px] font-medium text-primary-900">
<input
@@ -480,7 +669,7 @@
<AttachmentPreview {attachment} />
</label>
{:else}
<div class="flex min-w-0 flex-1 flex-row items-baseline gap-2 py-1">
<div class="flex min-w-0 flex-1 flex-row items-center gap-1 py-1">
<button
class="w-fit cursor-grab rounded-md border border-primary-200 bg-white px-2 py-1 text-sm font-semibold leading-none text-primary-700 active:cursor-grabbing"
draggable="false"
@@ -491,11 +680,20 @@
</button>
<div
class="truncate text-[13px] font-medium leading-tight text-primary-900"
class="min-w-0 flex-1 truncate text-[13px] font-medium leading-tight text-primary-900"
title={attachment.name}
>
{attachment.name}
</div>
<AttachmentMenu
{attachment}
moveTargets={subDocumentTargets}
canSplit={attachment.kind === 'pdf'}
onMoveToTarget={(targetId) => moveAttachmentToTarget(attachment.path, targetId)}
onSplit={() => (splitSource = attachment)}
onRemove={() => (removeCandidate = attachment)}
/>
</div>
<AttachmentPreview {attachment} />
{/if}
@@ -520,6 +718,15 @@
{thumbnailWidth}
isBlockDragSource={blockDragIndex === blockIndex}
isBlockDropTarget={blockDropIndex === blockIndex && blockDragIndex !== blockIndex}
{groupingMode}
{selectedPaths}
onToggleSelection={toggleSelection}
autoFocusName={subDocument.id === focusSubDocumentId}
moveTargets={subDocumentTargets.filter((target) => target.id !== subDocument.id)}
onMoveToTarget={(path, targetId) => moveAttachmentToTarget(path, targetId)}
onMoveToLoose={moveAttachmentToLoose}
onSplit={(attachment) => (splitSource = attachment)}
onRemoveAttachment={(attachment) => (removeCandidate = attachment)}
onSubDocumentChange={handleSubDocumentChange}
onRemove={() => dissolveSubDocumentBlock(subDocument.id)}
onAttachmentMove={handleAttachmentMove}
@@ -535,3 +742,32 @@
</div>
{/if}
</section>
{#if splitSource}
<PdfSplitDialog
source={splitSource}
onApply={handleSplitApply}
onClose={() => (splitSource = null)}
/>
{/if}
<AlertDialog.Root
open={removeCandidate !== null}
onOpenChange={(open) => {
if (!open) removeCandidate = null;
}}
>
<AlertDialog.Content class="max-w-sm">
<AlertDialog.Title class="text-base font-bold text-primary-900">
Datei entfernen?
</AlertDialog.Title>
<AlertDialog.Description>
{removeCandidate?.name ?? ''} wird aus dem Archiv entfernt. Bereits heruntergeladene PDFs bleiben
erhalten.
</AlertDialog.Description>
<AlertDialog.Footer>
<AlertDialog.Cancel>Abbrechen</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmRemoveAttachment}>Entfernen</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
+92
View File
@@ -0,0 +1,92 @@
<script lang="ts">
import { pwaInfo } from 'virtual:pwa-info';
import Button from '$lib/components/ui/button.svelte';
/**
* Service-worker registration and the update hint for the PWA.
*
* Rendered only in the browser: the plugin's virtual modules are no-ops during SSR and in
* `pnpm dev` (PWA `devOptions` are disabled), so `pwaInfo` is `undefined` there and nothing is
* registered.
*
* Updates are never applied automatically (registerType `prompt`): a waiting worker is only
* activated when the user confirms, so an in-progress archive is never interrupted.
*/
let needRefresh = $state(false);
let offlineReady = $state(false);
let updateServiceWorker = $state<(reloadPage?: boolean) => Promise<void>>(async () => {});
let offlineReadyTimer: ReturnType<typeof setTimeout> | undefined;
$effect(() => {
if (!pwaInfo) return;
let cancelled = false;
(async () => {
const { registerSW } = await import('virtual:pwa-register');
if (cancelled) return;
updateServiceWorker = registerSW({
immediate: true,
onNeedRefresh() {
offlineReady = false;
needRefresh = true;
},
onOfflineReady() {
offlineReady = true;
offlineReadyTimer = setTimeout(close, 8000);
},
onRegisterError(error: unknown) {
console.error('Service Worker konnte nicht registriert werden.', error);
}
});
})();
return () => {
cancelled = true;
if (offlineReadyTimer) clearTimeout(offlineReadyTimer);
};
});
function close() {
needRefresh = false;
offlineReady = false;
if (offlineReadyTimer) {
clearTimeout(offlineReadyTimer);
offlineReadyTimer = undefined;
}
}
async function reload() {
await updateServiceWorker(true);
}
</script>
{#if needRefresh || offlineReady}
<div
role="status"
aria-live="polite"
class="fixed inset-x-4 bottom-4 z-50 flex flex-col gap-3 rounded-2xl border border-border bg-white p-4 shadow-lg sm:left-auto sm:w-[min(24rem,calc(100vw-2rem))]"
>
<p class="text-sm font-bold text-primary">
{needRefresh
? 'Neue Version von beA-Edit verfügbar.'
: 'beA-Edit funktioniert jetzt offline.'}
</p>
{#if needRefresh}
<p class="text-sm text-muted-foreground">
Die neue Version wird geladen, sobald Sie neu laden. Ein laufender Vorgang wird dabei nicht
unterbrochen.
</p>
{/if}
<div class="flex flex-row flex-wrap gap-3">
{#if needRefresh}
<Button variant="primary" size="standard" onclick={reload}>Neu laden</Button>
{/if}
<Button variant="bordered" size="standard" onclick={close}>Später</Button>
</div>
</div>
{/if}
+75 -4
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import AttachmentMenu, { type AttachmentMoveTarget } from '$lib/components/AttachmentMenu.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';
@@ -10,6 +11,15 @@
thumbnailWidth: number;
isBlockDragSource?: boolean;
isBlockDropTarget?: boolean;
groupingMode?: boolean;
selectedPaths?: Set<string>;
onToggleSelection?: (path: string) => void;
autoFocusName?: boolean;
moveTargets?: AttachmentMoveTarget[];
onMoveToTarget?: (path: string, targetId: string) => void;
onMoveToLoose?: (path: string) => void;
onSplit?: (attachment: ZipAttachment) => void;
onRemoveAttachment?: (attachment: ZipAttachment) => void;
onSubDocumentChange?: (next: SubDocument) => void;
onRemove?: () => void;
onAttachmentMove?: (source: AttachmentLocation, target: AttachmentLocation) => void;
@@ -28,6 +38,15 @@
thumbnailWidth = 200,
isBlockDragSource = false,
isBlockDropTarget = false,
groupingMode = false,
selectedPaths = new Set<string>(),
onToggleSelection = () => {},
autoFocusName = false,
moveTargets = [],
onMoveToTarget = () => {},
onMoveToLoose = () => {},
onSplit = () => {},
onRemoveAttachment = () => {},
onSubDocumentChange = () => {},
onRemove = () => {},
onAttachmentMove = () => {},
@@ -40,6 +59,7 @@
downloadDisabled = false
}: Props = $props();
let nameInput = $state<HTMLInputElement | null>(null);
let subDocumentName = $state('');
let dragIndex = $state<number | null>(null);
let dropIndex = $state<number | null>(null);
@@ -48,6 +68,14 @@
subDocumentName = subDocument.name;
});
$effect(() => {
if (autoFocusName && nameInput) {
nameInput.focus();
nameInput.select();
nameInput.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
});
const emitChange = (overrides: Partial<SubDocument> = {}) => {
onSubDocumentChange({
...subDocument,
@@ -81,6 +109,11 @@
};
const handleCardDragStart = (index: number, event: DragEvent) => {
if (groupingMode) {
event.preventDefault();
return;
}
dragIndex = index;
dropIndex = index;
@@ -195,6 +228,7 @@
<label class="block min-w-0 flex-1">
<span class="sr-only">Teildokumentname</span>
<input
bind:this={nameInput}
class="w-full max-w-md rounded-md border border-primary-200 bg-white px-3 py-1.5 text-[15px] font-semibold text-primary-900 outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-200"
type="text"
value={subDocumentName}
@@ -272,16 +306,42 @@
dropIndex === index
? 'border-primary-400 ring-1 ring-primary-200'
: 'border-primary-100',
dragIndex === index ? 'opacity-60' : ''
dragIndex === index ? 'opacity-60' : '',
groupingMode && selectedPaths.has(attachment.path)
? 'border-primary-500 ring-2 ring-primary-300'
: ''
)}
style={`width: ${thumbnailWidth}px;`}
draggable="true"
draggable={groupingMode ? 'false' : 'true'}
ondragstart={(event) => handleCardDragStart(index, event)}
ondragover={(event) => handleCardDragOver(index, event)}
ondrop={(event) => handleCardDrop(index, event)}
ondragend={handleCardDragEnd}
>
<div class="flex min-w-0 flex-1 flex-row items-baseline gap-2 py-1">
{#if groupingMode}
<label class="flex cursor-pointer flex-col gap-2">
<div class="flex items-center gap-2 px-1 text-[13px] font-medium text-primary-900">
<input
class="h-4 w-4 accent-primary-700"
type="checkbox"
checked={selectedPaths.has(attachment.path)}
onchange={() => onToggleSelection(attachment.path)}
/>
<span class="truncate"
>{selectedPaths.has(attachment.path) ? 'Ausgewählt' : 'Auswählen'}</span
>
</div>
<div
class="truncate text-[13px] font-medium leading-tight text-primary-900"
title={attachment.name}
>
{attachment.name}
</div>
<AttachmentPreview {attachment} />
</label>
{:else}
<div class="flex min-w-0 flex-1 flex-row items-center gap-1 py-1">
<button
class="w-fit cursor-grab rounded-md border border-primary-200 bg-white px-2 py-1 text-sm font-semibold leading-none text-primary-700 active:cursor-grabbing"
draggable="false"
@@ -292,13 +352,24 @@
</button>
<div
class="truncate text-[13px] font-medium leading-tight text-primary-900"
class="min-w-0 flex-1 truncate text-[13px] font-medium leading-tight text-primary-900"
title={attachment.name}
>
{attachment.name}
</div>
<AttachmentMenu
{attachment}
{moveTargets}
canSplit={attachment.kind === 'pdf'}
onMoveToTarget={(targetId) => onMoveToTarget(attachment.path, targetId)}
onMoveToLoose={() => onMoveToLoose(attachment.path)}
onSplit={() => onSplit(attachment)}
onRemove={() => onRemoveAttachment(attachment)}
/>
</div>
<AttachmentPreview {attachment} />
{/if}
</li>
{/each}
</ul>
+4 -4
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import FileDropzone from '$lib/components/FileDropzone.svelte';
import { ZIP_ACCEPT_TYPES } from '$lib/zip-selection';
import { WORKSPACE_ACCEPT_TYPES } from '$lib/zip-selection';
type Props = {
onFilesSelected: (files: File[]) => void;
@@ -12,10 +12,10 @@
<FileDropzone
{onFilesSelected}
accept={ZIP_ACCEPT_TYPES}
accept={WORKSPACE_ACCEPT_TYPES}
multiple={true}
ariaLabel="beA-ZIP-Dateien hinzufügen"
ariaLabel="beA-ZIP- oder PDF-Dateien hinzufügen"
label="Dateien hinzufügen"
sublabel="beA-ZIP-Dateien hier ablegen oder zum Auswählen klicken"
sublabel="beA-ZIP oder PDF hier ablegen oder zum Auswählen klicken"
class="min-h-[calc(-180px+100vh)] border-0 bg-transparent {className}"
/>
+3 -3
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import Button from '$lib/components/ui/button.svelte';
import type { ButtonVariant } from '$lib/components/ui/button.svelte';
import { isAcceptedZipFile, ZIP_ACCEPT_TYPES } from '$lib/zip-selection';
import { isAcceptedWorkspaceFile, WORKSPACE_ACCEPT_TYPES } from '$lib/zip-selection';
import type { Snippet } from 'svelte';
type Props = {
@@ -31,7 +31,7 @@
const handleFileSelection = (event: Event) => {
const input = event.currentTarget as HTMLInputElement;
const files = Array.from(input.files ?? []).filter(isAcceptedZipFile);
const files = Array.from(input.files ?? []).filter(isAcceptedWorkspaceFile);
// Reset first so selecting the same file again still fires `change`.
input.value = '';
@@ -45,7 +45,7 @@
bind:this={fileInput}
class="hidden"
type="file"
accept={ZIP_ACCEPT_TYPES}
accept={WORKSPACE_ACCEPT_TYPES}
{multiple}
data-zip-picker={pickerId}
onchange={handleFileSelection}
+13 -3
View File
@@ -2,7 +2,17 @@
</script>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.5618 6.94745L14.1001 3.31245C15.0133 2.37424 16.5206 2.37424 17.4338 3.31245L19.0544 4.97737C19.9334 5.88045 19.9334 7.31929 19.0544 8.22237L14.3368 13.069C13.4235 14.0072 11.9162 14.0072 11.003 13.069L10.5537 12.6075" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.8717 16.4126L7.66659 21.76L4.37911 18.3826C3.50007 17.4796 3.50008 16.0407 4.37911 15.1376L9.09675 10.291C10.01 9.35276 11.5173 9.35276 12.4305 10.291L12.8798 10.7525" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/>
<path
d="M10.5618 6.94745L14.1001 3.31245C15.0133 2.37424 16.5206 2.37424 17.4338 3.31245L19.0544 4.97737C19.9334 5.88045 19.9334 7.31929 19.0544 8.22237L14.3368 13.069C13.4235 14.0072 11.9162 14.0072 11.003 13.069L10.5537 12.6075"
stroke="currentColor"
stroke-width="1.9"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12.8717 16.4126L7.66659 21.76L4.37911 18.3826C3.50007 17.4796 3.50008 16.0407 4.37911 15.1376L9.09675 10.291C10.01 9.35276 11.5173 9.35276 12.4305 10.291L12.8798 10.7525"
stroke="currentColor"
stroke-width="1.9"
stroke-linecap="round"
/>
</svg>
+25 -5
View File
@@ -2,9 +2,29 @@
</script>
<svg width="23" height="24" viewBox="0 0 23 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.6875 19.7575C18.6875 21.0807 17.6148 22.1533 16.2917 22.1533H4.3125V5.99287C4.3125 5.39212 4.54755 4.81522 4.96738 4.38552L7.17097 2.13015C7.60374 1.68721 8.19683 1.4375 8.81609 1.4375H16.3875C17.6578 1.4375 18.6875 2.46725 18.6875 3.7375V19.7575Z" stroke="currentColor" stroke-width="1.86875"/>
<path d="M7.06787 12.5889H15.6929" stroke="currentColor" stroke-width="1.725" stroke-linecap="round"/>
<path d="M7.06787 8.71875H15.6929" stroke="currentColor" stroke-width="1.725" stroke-linecap="round"/>
<path d="M7.06787 16.464H12.8179" stroke="currentColor" stroke-width="1.725" stroke-linecap="round"/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M18.6875 19.7575C18.6875 21.0807 17.6148 22.1533 16.2917 22.1533H4.3125V5.99287C4.3125 5.39212 4.54755 4.81522 4.96738 4.38552L7.17097 2.13015C7.60374 1.68721 8.19683 1.4375 8.81609 1.4375H16.3875C17.6578 1.4375 18.6875 2.46725 18.6875 3.7375V19.7575Z"
stroke="currentColor"
stroke-width="1.86875"
/>
<path
d="M7.06787 12.5889H15.6929"
stroke="currentColor"
stroke-width="1.725"
stroke-linecap="round"
/>
<path
d="M7.06787 8.71875H15.6929"
stroke="currentColor"
stroke-width="1.725"
stroke-linecap="round"
/>
<path
d="M7.06787 16.464H12.8179"
stroke="currentColor"
stroke-width="1.725"
stroke-linecap="round"
/>
</svg>
+18 -5
View File
@@ -2,9 +2,22 @@
</script>
<svg width="21" height="15" viewBox="0 0 21 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 13.2188C0 12.5457 0.545653 12 1.21875 12H16.8256C17.339 12 17.6536 12.5629 17.3845 13.0002L16.5 14.4375H1.21875C0.545653 14.4375 0 13.8918 0 13.2188Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 7.21875C0 6.54565 0.545653 6 1.21875 6H18.2738C18.7979 6 19.1106 6.58416 18.8198 7.02027L17.875 8.4375H1.21875C0.545653 8.4375 0 7.89185 0 7.21875Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 1.21875C0 0.545653 0.545653 0 1.21875 0H19.721C20.2555 0 20.5658 0.60479 20.2541 1.03898L19.25 2.4375H1.21875C0.545653 2.4375 0 1.89185 0 1.21875Z" fill="currentColor"/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0 13.2188C0 12.5457 0.545653 12 1.21875 12H16.8256C17.339 12 17.6536 12.5629 17.3845 13.0002L16.5 14.4375H1.21875C0.545653 14.4375 0 13.8918 0 13.2188Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0 7.21875C0 6.54565 0.545653 6 1.21875 6H18.2738C18.7979 6 19.1106 6.58416 18.8198 7.02027L17.875 8.4375H1.21875C0.545653 8.4375 0 7.89185 0 7.21875Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0 1.21875C0 0.545653 0.545653 0 1.21875 0H19.721C20.2555 0 20.5658 0.60479 20.2541 1.03898L19.25 2.4375H1.21875C0.545653 2.4375 0 1.89185 0 1.21875Z"
fill="currentColor"
/>
</svg>
+10 -4
View File
@@ -2,8 +2,14 @@
</script>
<svg width="23" height="24" viewBox="0 0 23 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.4129 7.29267L14.1203 1C14.1203 1 11.8437 8.76535 4.14746 10.9744L11.7731 18.6C13.9805 10.9022 21.7475 8.62562 21.7475 8.62562L15.4548 2.33295" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
<path d="M7.28204 14.2833L8.8 15.8656L1.51796 23.0833L1.36219 22.921C0.606308 22.1331 0.622023 20.8845 1.3975 20.1159L7.28204 14.2833Z" fill="currentColor"/>
<path
d="M20.4129 7.29267L14.1203 1C14.1203 1 11.8437 8.76535 4.14746 10.9744L11.7731 18.6C13.9805 10.9022 21.7475 8.62562 21.7475 8.62562L15.4548 2.33295"
stroke="currentColor"
stroke-width="2"
stroke-linejoin="round"
/>
<path
d="M7.28204 14.2833L8.8 15.8656L1.51796 23.0833L1.36219 22.921C0.606308 22.1331 0.622023 20.8845 1.3975 20.1159L7.28204 14.2833Z"
fill="currentColor"
/>
</svg>
+14 -5
View File
@@ -2,14 +2,23 @@
</script>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.9914 12.6763V15.4458V20.4H7.98481H3.6001V16.046V4.27311C3.6001 3.32208 4.49438 2.6246 5.41678 2.85624L11.8863 4.48089C12.5359 4.64402 12.9914 5.228 12.9914 5.89777V12.6763Z" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<path d="M13.6491 8.40002L20.5221 10.2682C21.1583 10.4411 21.5998 11.0186 21.5998 11.6779V12.6424V15.424V18.9392C21.5998 19.746 20.9457 20.4 20.1389 20.4H17.4409H12.2085" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12.9914 12.6763V15.4458V20.4H7.98481H3.6001V16.046V4.27311C3.6001 3.32208 4.49438 2.6246 5.41678 2.85624L11.8863 4.48089C12.5359 4.64402 12.9914 5.228 12.9914 5.89777V12.6763Z"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
/>
<path
d="M13.6491 8.40002L20.5221 10.2682C21.1583 10.4411 21.5998 11.0186 21.5998 11.6779V12.6424V15.424V18.9392C21.5998 19.746 20.9457 20.4 20.1389 20.4H17.4409H12.2085"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
/>
<path d="M4.5 8.3H7.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M4.5 12.1H7.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M4.5 15.9H7.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M14 13.1H16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M14 16.9H16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
+6 -1
View File
@@ -1,4 +1,9 @@
<svg width="16" height="22" viewBox="0 0 16 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.27193 9.97498L13.4818 11.6926C14.3848 12.0611 14.9751 12.9394 14.9751 13.9148V15.4118V18.475C14.9751 19.8557 13.8558 20.975 12.4751 20.975H7.51156H0.975098V16.0858V13.8946C0.975098 12.9294 1.55327 12.0581 2.44261 11.6831L6.49358 9.97498" stroke="currentColor" stroke-width="1.95" stroke-linecap="round"/>
<path
d="M9.27193 9.97498L13.4818 11.6926C14.3848 12.0611 14.9751 12.9394 14.9751 13.9148V15.4118V18.475C14.9751 19.8557 13.8558 20.975 12.4751 20.975H7.51156H0.975098V16.0858V13.8946C0.975098 12.9294 1.55327 12.0581 2.44261 11.6831L6.49358 9.97498"
stroke="currentColor"
stroke-width="1.95"
stroke-linecap="round"
/>
<circle cx="7.4751" cy="5.47498" r="4.5" stroke="currentColor" stroke-width="1.95" />
</svg>

Before

Width:  |  Height:  |  Size: 513 B

After

Width:  |  Height:  |  Size: 518 B

+12 -4
View File
@@ -2,11 +2,19 @@
</script>
<svg width="22" height="23" viewBox="0 0 22 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 17.5834C21.5523 17.5834 22 18.0311 22 18.5834V19.5834H1C0.447716 19.5834 0 19.1357 0 18.5834C0 18.0311 0.447715 17.5834 1 17.5834H21Z" fill="#122F62"/>
<path d="M21 10.5834C21.5523 10.5834 22 11.0311 22 11.5834V12.5834H1C0.447716 12.5834 0 12.1357 0 11.5834C0 11.0311 0.447715 10.5834 1 10.5834H21Z" fill="#122F62"/>
<path d="M21 3.08337C21.5523 3.08337 22 3.53109 22 4.08337V5.08337H1C0.447716 5.08337 0 4.63566 0 4.08337C0 3.53109 0.447715 3.08337 1 3.08337H21Z" fill="#122F62"/>
<path
d="M21 17.5834C21.5523 17.5834 22 18.0311 22 18.5834V19.5834H1C0.447716 19.5834 0 19.1357 0 18.5834C0 18.0311 0.447715 17.5834 1 17.5834H21Z"
fill="#122F62"
/>
<path
d="M21 10.5834C21.5523 10.5834 22 11.0311 22 11.5834V12.5834H1C0.447716 12.5834 0 12.1357 0 11.5834C0 11.0311 0.447715 10.5834 1 10.5834H21Z"
fill="#122F62"
/>
<path
d="M21 3.08337C21.5523 3.08337 22 3.53109 22 4.08337V5.08337H1C0.447716 5.08337 0 4.63566 0 4.08337C0 3.53109 0.447715 3.08337 1 3.08337H21Z"
fill="#122F62"
/>
<circle cx="6.5" cy="3.75" r="2.75" fill="white" stroke="#122F62" stroke-width="2" />
<circle cx="15.5835" cy="11.0834" r="2.75" fill="white" stroke="#122F62" stroke-width="2" />
<circle cx="6.5" cy="18.4167" r="2.75" fill="white" stroke="#122F62" stroke-width="2" />
</svg>
+7 -1
View File
@@ -1,4 +1,10 @@
<svg width="24" height="23" viewBox="0 0 24 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.4142 5.48509C10.7066 5.19265 11.101 5.0248 11.5145 5.01676L17.3775 4.90268C18.2946 4.88483 19.039 5.64016 19.0077 6.55694L18.8111 12.3183C18.7973 12.7235 18.6301 13.1084 18.3434 13.3951L10.5219 21.2166C9.89704 21.8414 8.88398 21.8414 8.25914 21.2166L1.47091 14.4284L10.4142 5.48509Z" stroke="currentColor" stroke-width="2.08"/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.4142 5.48509C10.7066 5.19265 11.101 5.0248 11.5145 5.01676L17.3775 4.90268C18.2946 4.88483 19.039 5.64016 19.0077 6.55694L18.8111 12.3183C18.7973 12.7235 18.6301 13.1084 18.3434 13.3951L10.5219 21.2166C9.89704 21.8414 8.88398 21.8414 8.25914 21.2166L1.47091 14.4284L10.4142 5.48509Z"
stroke="currentColor"
stroke-width="2.08"
/>
<circle cx="13.7192" cy="9.90002" r="1.5" fill="currentColor" />
</svg>

Before

Width:  |  Height:  |  Size: 556 B

After

Width:  |  Height:  |  Size: 563 B

+5 -3
View File
@@ -3,8 +3,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 7V19C18 20.1046 17.1046 21 16 21H6V7" stroke="currentColor" stroke-width="2" />
<path d="M8 4C8 3.44772 8.44772 3 9 3H15C15.5523 3 16 3.44772 16 4V7H8V4Z" stroke="currentColor" stroke-width="1.8"/>
<path
d="M8 4C8 3.44772 8.44772 3 9 3H15C15.5523 3 16 3.44772 16 4V7H8V4Z"
stroke="currentColor"
stroke-width="1.8"
/>
<path d="M3 7.5H21" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
+5 -1
View File
@@ -2,6 +2,10 @@
</script>
<svg width="19" height="19" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.49023 1H12.6572C13.5748 1 14.375 1.62449 14.5977 2.51465L17.0977 12.5146C17.4132 13.7769 16.4584 15 15.1572 15H1.20508L3.52441 2.63184C3.70178 1.68589 4.5278 1 5.49023 1Z" stroke="currentColor" stroke-width="2"/>
<path
d="M5.49023 1H12.6572C13.5748 1 14.375 1.62449 14.5977 2.51465L17.0977 12.5146C17.4132 13.7769 16.4584 15 15.1572 15H1.20508L3.52441 2.63184C3.70178 1.68589 4.5278 1 5.49023 1Z"
stroke="currentColor"
stroke-width="2"
/>
<rect x="5" y="17" width="8" height="2" rx="1" fill="currentColor" />
</svg>
+29 -25
View File
@@ -1,33 +1,33 @@
<script lang="ts" module>
import {ripple} from "$lib/actions/ripple";
import {cn, type WithElementRef} from "$lib/utils.js";
import type {HTMLAnchorAttributes, HTMLButtonAttributes} from "svelte/elements";
import {type VariantProps, tv} from "tailwind-variants";
import type {Component} from "svelte";
import { ripple } from '$lib/actions/ripple';
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants';
import type { Component } from 'svelte';
export const buttonVariants = tv({
base: "relative overflow-hidden font-bold w-fit min-w-28 hover:not-disabled:cursor-pointer disabled:bg-[#E6E7EA] disabled:text-[#0B1F4366] inline-flex items-center justify-center gap-4",
base: 'relative overflow-hidden font-bold w-fit min-w-28 hover:not-disabled:cursor-pointer disabled:bg-[#E6E7EA] disabled:text-[#0B1F4366] inline-flex items-center justify-center gap-4',
variants: {
variant: {
primary: "bg-primary text-primary-foreground",
secondary: "bg-linear-to-r from-secondary to-[#FF6F6D] text-primary-foreground",
bordered: "text-primary border-[#E6E7EA] border-2 bg-white hover:bg-[#122F621A]",
tertiary: "text-primary hover:bg-[#122F621A]",
success: "text-white bg-[#0FAD80]"
primary: 'bg-primary text-primary-foreground',
secondary: 'bg-linear-to-r from-secondary to-[#FF6F6D] text-primary-foreground',
bordered: 'text-primary border-[#E6E7EA] border-2 bg-white hover:bg-[#122F621A]',
tertiary: 'text-primary hover:bg-[#122F621A]',
success: 'text-white bg-[#0FAD80]'
},
size: {
standard: "px-6 h-8 rounded-[16px]",
large: "px-8 h-12 rounded-[24px]"
},
standard: 'px-6 h-8 rounded-[16px]',
large: 'px-8 h-12 rounded-[24px]'
}
},
defaultVariants: {
variant: "primary",
size: "standard",
},
variant: 'primary',
size: 'standard'
}
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
@@ -40,12 +40,12 @@
<script lang="ts">
let {
class: className,
variant = "primary",
size = "standard",
variant = 'primary',
size = 'standard',
icon,
ref = $bindable(null),
href = undefined,
type = "button",
type = 'button',
disabled,
children,
...restProps
@@ -57,10 +57,10 @@
use:ripple
bind:this={ref}
data-slot="button"
class={cn("", buttonVariants({ variant, size }), className)}
class={cn('', buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
role={disabled ? 'link' : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
@@ -75,7 +75,11 @@
use:ripple
bind:this={ref}
data-slot="button"
class={cn("inline-flex items-center justify-center gap-4", buttonVariants({ variant, size }), className)}
class={cn(
'inline-flex items-center justify-center gap-4',
buttonVariants({ variant, size }),
className
)}
{type}
{disabled}
{...restProps}
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
@@ -14,7 +14,7 @@
bind:this={ref}
data-slot="card-action"
class={cn(
"cn-card-action col-start-2 row-span-2 row-start-1 self-start justify-self-end",
'cn-card-action col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className
)}
{...restProps}
@@ -1,6 +1,6 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
<div
bind:this={ref}
data-slot="card-content"
class={cn("px-4 group-data-[size=sm]/card:px-3", className)}
class={cn('px-4 group-data-[size=sm]/card:px-3', className)}
{...restProps}
>
{@render children?.()}
@@ -1,6 +1,6 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
class={cn('text-muted-foreground text-sm', className)}
{...restProps}
>
{@render children?.()}
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
@@ -13,7 +13,10 @@
<div
bind:this={ref}
data-slot="card-footer"
class={cn("bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", className)}
class={cn(
'bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center',
className
)}
{...restProps}
>
{@render children?.()}
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
@@ -14,7 +14,7 @@
bind:this={ref}
data-slot="card-header"
class={cn(
"gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
'gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]',
className
)}
{...restProps}
+3 -3
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
<div
bind:this={ref}
data-slot="card-title"
class={cn("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
class={cn('text-base leading-snug font-medium group-data-[size=sm]/card:text-sm', className)}
{...restProps}
>
{@render children?.()}
+8 -5
View File
@@ -1,21 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
size = "default",
size = 'default',
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props();
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: 'default' | 'sm' } = $props();
</script>
<div
bind:this={ref}
data-slot="card"
data-size={size}
class={cn("ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)}
class={cn(
'ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col',
className
)}
{...restProps}
>
{@render children?.()}
+8 -8
View File
@@ -1,10 +1,10 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
import Root from './card.svelte';
import Content from './card-content.svelte';
import Description from './card-description.svelte';
import Footer from './card-footer.svelte';
import Header from './card-header.svelte';
import Title from './card-title.svelte';
import Action from './card-action.svelte';
export {
Root,
@@ -21,5 +21,5 @@ export {
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
Action as CardAction
};
@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
value = $bindable([]),
...restProps
}: DropdownMenuPrimitive.CheckboxGroupProps = $props();
</script>
<DropdownMenuPrimitive.CheckboxGroup
bind:ref
bind:value
data-slot="dropdown-menu-checkbox-group"
{...restProps}
/>
@@ -0,0 +1,46 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import MinusIcon from '@lucide/svelte/icons/minus';
import CheckIcon from '@lucide/svelte/icons/check';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import type { Snippet } from 'svelte';
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
children: childrenProp,
closeOnSelect = false,
...restProps
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<DropdownMenuPrimitive.CheckboxItem
bind:ref
{closeOnSelect}
bind:checked
bind:indeterminate
data-slot="dropdown-menu-checkbox-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-checkbox-item-indicator"
>
{#if indeterminate}
<MinusIcon />
{:else if checked}
<CheckIcon />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</DropdownMenuPrimitive.CheckboxItem>
@@ -0,0 +1,31 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import DropdownMenuPortal from './dropdown-menu-portal.svelte';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
sideOffset = 4,
align = 'start',
portalProps,
class: className,
...restProps
}: DropdownMenuPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DropdownMenuPortal>>;
} = $props();
</script>
<DropdownMenuPortal {...portalProps}>
<DropdownMenuPrimitive.Content
bind:ref
data-slot="dropdown-menu-content"
{sideOffset}
{align}
class={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 z-50 max-h-(--bits-dropdown-menu-content-available-height) w-(--bits-dropdown-menu-anchor-width) origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-closed:overflow-hidden',
className
)}
{...restProps}
/>
</DropdownMenuPortal>
@@ -0,0 +1,22 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.GroupHeading
bind:ref
data-slot="dropdown-menu-group-heading"
data-inset={inset}
class={cn('text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
</script>
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />
@@ -0,0 +1,27 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
inset,
variant = 'default',
...restProps
}: DropdownMenuPrimitive.ItemProps & {
inset?: boolean;
variant?: 'default' | 'destructive';
} = $props();
</script>
<DropdownMenuPrimitive.Item
bind:ref
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
class={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>
@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dropdown-menu-label"
data-inset={inset}
class={cn('text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let { ...restProps }: DropdownMenuPrimitive.PortalProps = $props();
</script>
<DropdownMenuPrimitive.Portal {...restProps} />
@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: DropdownMenuPrimitive.RadioGroupProps = $props();
</script>
<DropdownMenuPrimitive.RadioGroup
bind:ref
bind:value
data-slot="dropdown-menu-radio-group"
{...restProps}
/>
@@ -0,0 +1,36 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import CheckIcon from '@lucide/svelte/icons/check';
import { cn, type WithoutChild } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children: childrenProp,
closeOnSelect = false,
...restProps
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
</script>
<DropdownMenuPrimitive.RadioItem
bind:ref
{closeOnSelect}
data-slot="dropdown-menu-radio-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-radio-item-indicator"
>
{#if checked}
<CheckIcon />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</DropdownMenuPrimitive.RadioItem>
@@ -0,0 +1,17 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SeparatorProps = $props();
</script>
<DropdownMenuPrimitive.Separator
bind:ref
data-slot="dropdown-menu-separator"
class={cn('bg-border -mx-1 my-1 h-px', className)}
{...restProps}
/>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="dropdown-menu-shortcut"
class={cn(
'text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest',
className
)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,31 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import DropdownMenuPortal from './dropdown-menu-portal.svelte';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
align = 'start',
alignOffset = -3,
portalProps,
...restProps
}: DropdownMenuPrimitive.SubContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DropdownMenuPortal>>;
} = $props();
</script>
<DropdownMenuPortal {...portalProps}>
<DropdownMenuPrimitive.SubContent
bind:ref
data-slot="dropdown-menu-sub-content"
{align}
{alignOffset}
class={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-lg p-1 shadow-lg ring-1 duration-100 z-50 origin-(--bits-dropdown-menu-content-transform-origin) overflow-hidden',
className
)}
{...restProps}
/>
</DropdownMenuPortal>
@@ -0,0 +1,29 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.SubTrigger
bind:ref
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
class={cn(
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRightIcon class="cn-rtl-flip ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.SubProps = $props();
</script>
<DropdownMenuPrimitive.Sub bind:open {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.TriggerProps = $props();
</script>
<DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.RootProps = $props();
</script>
<DropdownMenuPrimitive.Root bind:open {...restProps} />
@@ -0,0 +1,54 @@
import CheckboxGroup from './dropdown-menu-checkbox-group.svelte';
import CheckboxItem from './dropdown-menu-checkbox-item.svelte';
import Content from './dropdown-menu-content.svelte';
import GroupHeading from './dropdown-menu-group-heading.svelte';
import Group from './dropdown-menu-group.svelte';
import Item from './dropdown-menu-item.svelte';
import Label from './dropdown-menu-label.svelte';
import Portal from './dropdown-menu-portal.svelte';
import RadioGroup from './dropdown-menu-radio-group.svelte';
import RadioItem from './dropdown-menu-radio-item.svelte';
import Separator from './dropdown-menu-separator.svelte';
import Shortcut from './dropdown-menu-shortcut.svelte';
import SubContent from './dropdown-menu-sub-content.svelte';
import SubTrigger from './dropdown-menu-sub-trigger.svelte';
import Sub from './dropdown-menu-sub.svelte';
import Trigger from './dropdown-menu-trigger.svelte';
import Root from './dropdown-menu.svelte';
export {
CheckboxGroup,
CheckboxItem,
Content,
Portal,
Root as DropdownMenu,
CheckboxGroup as DropdownMenuCheckboxGroup,
CheckboxItem as DropdownMenuCheckboxItem,
Content as DropdownMenuContent,
Portal as DropdownMenuPortal,
Group as DropdownMenuGroup,
Item as DropdownMenuItem,
Label as DropdownMenuLabel,
RadioGroup as DropdownMenuRadioGroup,
RadioItem as DropdownMenuRadioItem,
Separator as DropdownMenuSeparator,
Shortcut as DropdownMenuShortcut,
Sub as DropdownMenuSub,
SubContent as DropdownMenuSubContent,
SubTrigger as DropdownMenuSubTrigger,
Trigger as DropdownMenuTrigger,
GroupHeading as DropdownMenuGroupHeading,
Group,
GroupHeading,
Item,
Label,
RadioGroup,
RadioItem,
Root,
Separator,
Shortcut,
Sub,
SubContent,
SubTrigger,
Trigger
};
+108
View File
@@ -0,0 +1,108 @@
import { PDFDocument } from 'pdf-lib';
export type PdfMetadataInfo = {
title: string;
author: string;
subject: string;
keywords: string[];
creator: string;
producer: string;
creationDate: Date | null;
modificationDate: Date | null;
pageCount: number;
embeddedFileSuspected: boolean;
};
export type PdfMetadataResult =
| { status: 'ok'; metadata: PdfMetadataInfo }
| { status: 'encrypted' };
// The pdf-lib error class for encrypted documents is not exported from the
// package root, so we identify it by its message instead.
const isEncryptedPdfError = (error: unknown) =>
error instanceof Error && error.message.includes('is encrypted');
const safeRead = <T>(read: () => T | undefined): T | undefined => {
try {
return read();
} catch {
return undefined;
}
};
const toDateOrNull = (value: Date | undefined): Date | null =>
value && !Number.isNaN(value.getTime()) ? value : null;
// Finds an ASCII marker in raw bytes without decoding the whole file. The
// search for '/EmbeddedFile' also matches '/EmbeddedFiles' as a prefix.
const containsAsciiMarker = (bytes: Uint8Array, marker: string): boolean => {
const firstByte = marker.charCodeAt(0);
for (let index = 0; index <= bytes.length - marker.length; index += 1) {
if (bytes[index] !== firstByte) continue;
let matched = true;
for (let offset = 1; offset < marker.length; offset += 1) {
if (bytes[index + offset] !== marker.charCodeAt(offset)) {
matched = false;
break;
}
}
if (matched) return true;
}
return false;
};
export const hasEmbeddedFileMarkers = (bytes: Uint8Array): boolean =>
containsAsciiMarker(bytes, '/EmbeddedFile');
export const readPdfMetadata = async (bytes: Uint8Array): Promise<PdfMetadataResult> => {
const embeddedFileSuspected = hasEmbeddedFileMarkers(bytes);
let document: PDFDocument;
try {
document = await PDFDocument.load(bytes, { updateMetadata: false });
} catch (error) {
if (isEncryptedPdfError(error)) return { status: 'encrypted' };
throw error;
}
const keywords = safeRead(() => document.getKeywords());
return {
status: 'ok',
metadata: {
title: safeRead(() => document.getTitle()) ?? '',
author: safeRead(() => document.getAuthor()) ?? '',
subject: safeRead(() => document.getSubject()) ?? '',
// PDF keyword lists have no canonical separator; split only on
// explicit punctuation so multi-word keywords survive.
keywords: keywords
? keywords
.split(/[;,]/)
.map((keyword) => keyword.trim())
.filter(Boolean)
: [],
creator: safeRead(() => document.getCreator()) ?? '',
producer: safeRead(() => document.getProducer()) ?? '',
creationDate: toDateOrNull(safeRead(() => document.getCreationDate())),
modificationDate: toDateOrNull(safeRead(() => document.getModificationDate())),
pageCount: document.getPageCount(),
embeddedFileSuspected
}
};
};
export const stripPdfMetadata = async (bytes: Uint8Array): Promise<Uint8Array> => {
const document = await PDFDocument.load(bytes, { updateMetadata: false });
const now = new Date();
document.setTitle('');
document.setAuthor('');
document.setSubject('');
document.setKeywords([]);
document.setCreator('');
document.setProducer('beA-Edit');
document.setCreationDate(now);
document.setModificationDate(now);
return document.save();
};
+24
View File
@@ -15,3 +15,27 @@ export const mergePdfFiles = async (files: File[]): Promise<Uint8Array> => {
return mergedDocument.save();
};
/**
* Copy a set of pages (0-based indices) from `sourceBytes` into a fresh PDF.
* Shared by the separate/extract tools and the archive editor's page splitting
* so the page-copy semantics can never drift apart.
*/
export const copyPagesToNewPdf = async (
sourceBytes: Uint8Array,
pageIndexes: number[]
): Promise<Uint8Array> => {
if (pageIndexes.length === 0) {
throw new Error('Cannot create a PDF from an empty page selection.');
}
const sourceDocument = await PDFDocument.load(sourceBytes);
const outputDocument = await PDFDocument.create();
const copiedPages = await outputDocument.copyPages(sourceDocument, pageIndexes);
for (const page of copiedPages) {
outputDocument.addPage(page);
}
return outputDocument.save();
};
+6
View File
@@ -59,5 +59,11 @@ export const tools: Tool[] = [
title: 'PDFs zusammenfügen',
description: 'Fügen Sie mehrere PDFs zu einer Datei zusammen.',
icon: 'merge'
},
{
slug: 'metadata',
title: 'Metadaten',
description: 'Zeigen Sie Metadaten eines PDFs an und entfernen Sie sie.',
icon: 'info'
}
];
+355
View File
@@ -1,5 +1,6 @@
import { PDFDocument } from 'pdf-lib';
import { copyPagesToNewPdf } from './pdf-processing';
import { parseXJustizMetadata, type XJustizMetadata } from './services/xml-reading.service';
import { unzipArchive } from './services/zip-inflating.service';
@@ -642,3 +643,357 @@ export const buildArchiveExport = async (
return separateUnits;
};
/**
* One sub-document produced by splitting a PDF attachment into page groups.
*/
export type PdfSplitGroup = {
name: string;
attachment: ZipAttachment;
};
/**
* Result of a page-level PDF split. `source` is the remaining source
* attachment after cutting out the assigned pages; `null` removes the
* source from the archive entirely (all pages assigned or copy mode off).
*/
export type PdfSplitResult = {
groups: PdfSplitGroup[];
source: ZipAttachment | null;
};
/**
* Turn a PDF file dropped into the beA workspace into a single-attachment
* archive so the editor's splitting/grouping workflow can operate on it.
*/
export const pdfFileToArchive = async (file: File): Promise<ProcessedZipArchive> => {
const bytes = new Uint8Array(await file.arrayBuffer());
const sourcePdf = await PDFDocument.load(bytes);
if (sourcePdf.getPageCount() === 0) {
throw new Error('The PDF does not contain any pages.');
}
const name = file.name.toLowerCase().endsWith('.pdf') ? file.name : `${file.name}.pdf`;
return {
name,
attachments: [
{
name,
path: file.name,
kind: 'pdf',
data: bytes
}
],
subDocuments: []
};
};
/**
* Copy a set of pages (1-based) of a PDF attachment into a new synthetic
* attachment. The generated `path` carries a unique suffix because `path` is
* the identity used for keyed lists, drag payloads, and selection state.
*/
export const createAttachmentFromPages = async (
source: ZipAttachment,
pages: number[],
name?: string
): Promise<ZipAttachment> => {
if (pages.length === 0) {
throw new Error('Cannot create an attachment from an empty page selection.');
}
const minPage = Math.min(...pages);
const maxPage = Math.max(...pages);
const baseName = stripPdfExtension(source.name);
const attachmentName =
name ??
(pages.length === 1
? `${baseName}_Seite_${pages[0]}.pdf`
: `${baseName}_Seiten_${minPage}-${maxPage}.pdf`);
const bytes = await copyPagesToNewPdf(
source.data,
pages.map((page) => page - 1)
);
return {
name: attachmentName,
path: `${source.path}#seiten-${crypto.randomUUID()}`,
kind: 'pdf',
data: bytes
};
};
/**
* Remove a set of pages (1-based) from a PDF attachment. Returns the remaining
* attachment, `null` when no pages are left, or the unchanged source when the
* page set removes nothing.
*/
export const removePagesFromAttachment = async (
source: ZipAttachment,
pages: number[]
): Promise<ZipAttachment | null> => {
const sourcePdf = await PDFDocument.load(source.data);
const pageCount = sourcePdf.getPageCount();
const pagesToRemove = new Set(pages);
const keepIndexes: number[] = [];
for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) {
if (!pagesToRemove.has(pageIndex + 1)) {
keepIndexes.push(pageIndex);
}
}
if (keepIndexes.length === 0) {
return null;
}
if (keepIndexes.length === pageCount) {
return { ...source };
}
return {
...source,
data: await copyPagesToNewPdf(source.data, keepIndexes)
};
};
/**
* Apply a completed PDF split to the archive: the source attachment (located
* by its unique `path`) is replaced by the remaining pages (or removed), and
* the new sub-documents are inserted directly after the source's parent
* sub-document so the reading order is preserved. A loose source appends the
* new sub-documents at the end.
*/
export const applyPdfSplit = (
archive: ProcessedZipArchive,
sourcePath: string,
result: PdfSplitResult
): ProcessedZipArchive => {
let sourceSubDocumentId: string | null = null;
let insertIndex = -1;
const nextSubDocuments: SubDocument[] = [];
for (const subDocument of archive.subDocuments) {
const sourceIndex = subDocument.attachments.findIndex(
(attachment) => attachment.path === sourcePath
);
if (sourceIndex === -1) {
nextSubDocuments.push(subDocument);
continue;
}
const remainingAttachments = subDocument.attachments.filter(
(attachment) => attachment.path !== sourcePath
);
if (result.source) {
remainingAttachments.splice(sourceIndex, 0, { ...result.source });
}
sourceSubDocumentId = subDocument.id;
insertIndex = nextSubDocuments.length;
nextSubDocuments.push({ ...subDocument, attachments: remainingAttachments });
}
const looseSourceIndex = archive.attachments.findIndex(
(attachment) => attachment.path === sourcePath
);
if (looseSourceIndex === -1 && sourceSubDocumentId === null) {
return archive;
}
const newSubDocuments = result.groups.map((group) =>
createSubDocument(group.name, [group.attachment])
);
const nextLooseAttachments = archive.attachments.filter(
(attachment) => attachment.path !== sourcePath
);
if (sourceSubDocumentId === null) {
if (result.source) {
nextLooseAttachments.splice(looseSourceIndex, 0, { ...result.source });
}
return {
...archive,
attachments: nextLooseAttachments,
subDocuments: [...nextSubDocuments, ...newSubDocuments]
};
}
nextSubDocuments.splice(insertIndex + 1, 0, ...newSubDocuments);
return {
...archive,
attachments: nextLooseAttachments,
subDocuments: nextSubDocuments
};
};
/**
* Next free "Teildokument N" number, derived from the maximum existing
* trailing number rather than the list length, so dissolve/re-create cycles
* cannot produce duplicate names.
*/
export const nextSubDocumentNumber = (subDocuments: SubDocument[]): number => {
const namePattern = /^Teildokument (\d+)$/;
let maxNumber = 0;
for (const subDocument of subDocuments) {
const match = namePattern.exec(subDocument.name.trim());
if (match) {
maxNumber = Math.max(maxNumber, Number.parseInt(match[1], 10));
}
}
return maxNumber + 1;
};
const detachAttachmentsByPaths = (
archive: ProcessedZipArchive,
paths: Set<string>
): { extracted: ZipAttachment[]; archive: ProcessedZipArchive } => {
const extracted: ZipAttachment[] = [];
const subDocuments = archive.subDocuments.map((subDocument) => ({
...subDocument,
attachments: subDocument.attachments.filter((attachment) => {
if (paths.has(attachment.path)) {
extracted.push(attachment);
return false;
}
return true;
})
}));
const attachments = archive.attachments.filter((attachment) => {
if (paths.has(attachment.path)) {
extracted.push(attachment);
return false;
}
return true;
});
return { extracted, archive: { ...archive, attachments, subDocuments } };
};
/**
* Group the attachments identified by `paths` (loose or inside any
* sub-document) into a new sub-document appended at the end. Returns the
* input archive unchanged when no selected attachment exists.
*/
export const createSubDocumentFromPaths = (
archive: ProcessedZipArchive,
paths: Set<string>
): ProcessedZipArchive => {
const { extracted, archive: strippedArchive } = detachAttachmentsByPaths(archive, paths);
if (extracted.length === 0) {
return archive;
}
const name = `Teildokument ${nextSubDocumentNumber(archive.subDocuments)}`;
return {
...strippedArchive,
subDocuments: [...strippedArchive.subDocuments, createSubDocument(name, extracted)]
};
};
/**
* Move the attachments identified by `paths` (loose or inside any
* sub-document) to the end of an existing sub-document's attachment list.
*/
export const moveAttachmentsByPathsToSubDocument = (
archive: ProcessedZipArchive,
paths: Set<string>,
targetSubDocumentId: string
): ProcessedZipArchive => {
const { extracted, archive: strippedArchive } = detachAttachmentsByPaths(archive, paths);
if (extracted.length === 0) {
return archive;
}
let targetFound = false;
const subDocuments = strippedArchive.subDocuments.map((subDocument) => {
if (subDocument.id !== targetSubDocumentId) {
return subDocument;
}
targetFound = true;
return {
...subDocument,
attachments: [
...subDocument.attachments,
...extracted.map((attachment) => ({ ...attachment }))
]
};
});
if (!targetFound) {
return archive;
}
return { ...strippedArchive, subDocuments };
};
/**
* Remove a single attachment from the archive, wherever it currently lives.
* Located by its unique `path`; returns the input archive unchanged when no
* attachment matches.
*/
export const removeAttachmentFromArchive = (
archive: ProcessedZipArchive,
path: string
): ProcessedZipArchive => {
const attachments = archive.attachments.filter((attachment) => attachment.path !== path);
let removed = attachments.length !== archive.attachments.length;
const subDocuments = archive.subDocuments.map((subDocument) => {
const nextAttachments = subDocument.attachments.filter(
(attachment) => attachment.path !== path
);
if (nextAttachments.length !== subDocument.attachments.length) {
removed = true;
}
return nextAttachments.length === subDocument.attachments.length
? subDocument
: { ...subDocument, attachments: nextAttachments };
});
return removed ? { ...archive, attachments, subDocuments } : archive;
};
/**
* Move every loose attachment into its own sub-document named after the
* attachment. The sub-document order matches the loose attachment order.
*/
export const groupEachAttachmentAsSubDocument = (
archive: ProcessedZipArchive
): ProcessedZipArchive => {
if (archive.attachments.length === 0) {
return archive;
}
const newSubDocuments = archive.attachments.map((attachment) =>
createSubDocument(stripPdfExtension(attachment.name), [attachment])
);
return {
...archive,
attachments: [],
subDocuments: [...archive.subDocuments, ...newSubDocuments]
};
};
+10
View File
@@ -1,6 +1,16 @@
export const ZIP_ACCEPT_TYPES = '.zip,application/zip,application/x-zip-compressed';
export const PDF_ACCEPT_TYPES = '.pdf,application/pdf';
export const WORKSPACE_ACCEPT_TYPES = `${ZIP_ACCEPT_TYPES},${PDF_ACCEPT_TYPES}`;
export const isAcceptedZipFile = (file: File): boolean => {
if (file.name.toLowerCase().endsWith('.zip')) return true;
return ['application/zip', 'application/x-zip-compressed'].includes(file.type.toLowerCase());
};
export const isAcceptedPdfFile = (file: File): boolean => {
if (file.name.toLowerCase().endsWith('.pdf')) return true;
return file.type.toLowerCase() === 'application/pdf';
};
export const isAcceptedWorkspaceFile = (file: File): boolean =>
isAcceptedZipFile(file) || isAcceptedPdfFile(file);
+8
View File
@@ -1,12 +1,18 @@
<script lang="ts">
import './layout.css';
import { pwaInfo } from 'virtual:pwa-info';
import favicon from '$lib/assets/favicon.svg';
import PwaUpdatePrompt from '$lib/components/PwaUpdatePrompt.svelte';
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
{#if pwaInfo}
<!-- rendered on the client only: the manifest is part of the production build output -->
{@html pwaInfo.webManifest.linkTag}
{/if}
</svelte:head>
<div class="flex min-h-dvh flex-col">
<header style="height:72px" class="grid shrink-0 place-content-center bg-white shadow">
@@ -19,6 +25,8 @@
{@render children()}
</div>
<PwaUpdatePrompt />
<footer
class="mt-8.75 flex h-96 min-h-78.5 shrink-0 flex-col justify-end gap-15 px-4 py-8.75 text-white"
style="background-image: url('/footer.svg');background-position: top right; background-size: cover; background-repeat: no-repeat"
+5
View File
@@ -0,0 +1,5 @@
// Every route is rendered from static markup and processes data purely in the browser, so all
// pages are prerendered. That is what makes the PWA work offline: `@vite-pwa/sveltekit` precaches
// `prerendered/**/*.{html,json}` (see vite.config.ts), so the service worker can answer document
// navigations without a server.
export const prerender = true;
+14 -15
View File
@@ -10,12 +10,14 @@
Merge,
Stamp,
RotateCw,
Info,
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 { isAcceptedWorkspaceFile, WORKSPACE_ACCEPT_TYPES } from '$lib/zip-selection';
import { tools } from '$lib/tools';
const iconMap: Record<string, typeof FileText> = {
@@ -27,19 +29,15 @@
scissors: Scissors,
merge: Merge,
stamp: Stamp,
'rotate-cw': RotateCw
'rotate-cw': RotateCw,
info: Info
};
const isZipFile = (file: File) =>
file.name.toLowerCase().endsWith('.zip') ||
file.type === 'application/zip' ||
file.type === 'application/x-zip-compressed';
const openBeaFiles = (files: File[]) => {
const zipFiles = files.filter(isZipFile);
if (zipFiles.length === 0) return;
const workspaceFiles = files.filter(isAcceptedWorkspaceFile);
if (workspaceFiles.length === 0) return;
queueZipFiles(zipFiles);
queueZipFiles(workspaceFiles);
void goto('/tools/bea');
};
@@ -74,20 +72,21 @@
</p>
</div>
<!-- ZIP entry point -->
<!-- Workspace entry point -->
<div class="mx-auto w-full max-w-2xl">
<FileDropzone
onFilesSelected={openBeaFiles}
accept=".zip,application/zip,application/x-zip-compressed"
accept={WORKSPACE_ACCEPT_TYPES}
multiple={true}
ariaLabel="beA-ZIP öffnen"
label="beA-ZIP öffnen"
sublabel="ZIP aus dem besonderen elektronischen Anwaltspostfach (beA) hier ablegen oder zum Auswählen klicken"
ariaLabel="beA-ZIP oder PDF öffnen"
label="beA-ZIP oder PDF öffnen"
sublabel="ZIP aus dem besonderen elektronischen Anwaltspostfach (beA) oder eine PDF-Datei hier ablegen oder zum Auswählen klicken"
icon={Archive}
class="py-10"
/>
<p class="mt-2 text-center text-[12px] text-primary-600">
Tipp: In beA exportierte Archive enden auf .zip und enthalten Ihre Anlagen als PDF.
Tipp: In beA exportierte Archive enden auf .zip und enthalten Ihre Anlagen als PDF. PDFs
können direkt in Teildokumente aufgeteilt werden.
</p>
</div>
+18
View File
@@ -402,6 +402,24 @@
Darstellung und der Optimierung seiner Website – hierzu müssen die Server-Log-Files
erfasst werden.
</p>
<h3>Offline-Nutzung als App (Progressive Web App)</h3>
<p>
Diese Website ist technisch so aufgebaut, dass sie sich als sogenannte Progressive Web
App (PWA) in Ihrem Browser installieren und auch ohne bestehende Internetverbindung
nutzen lässt. Zu diesem Zweck speichert Ihr Browser die für den Betrieb der Website
benötigten Dateien (z. B. Grafiken und Programmdateien) in einem lokalen Cache.
</p>
<p>
In diesem Cache werden ausschließlich diese Anwendungsdateien abgelegt. Die von Ihnen
verarbeiteten Dokumente werden nicht gespeichert, nicht übertragen und verlassen Ihr
Gerät nicht: Sämtliche Verarbeitungsschritte erfolgen vollständig lokal in Ihrem
Browser. Durch die Offline-Nutzung werden keine personenbezogenen Daten erhoben.
</p>
<p>
Sie können die gespeicherten Anwendungsdateien jederzeit löschen, indem Sie die
Website-Daten in den Einstellungen Ihres Browsers entfernen.
</p>
</section>
<p class="source">
+8 -2
View File
@@ -9,8 +9,10 @@
import {
extractZipArchives,
mergeProcessedZipArchive,
pdfFileToArchive,
type ProcessedZipArchive
} from '$lib/zip-processing';
import { isAcceptedPdfFile } from '$lib/zip-selection';
import { downloadBlob } from '$lib/download';
import { takeQueuedZipFiles } from '$lib/pending-files';
import * as Card from '$lib/components/ui/card/index';
@@ -76,7 +78,9 @@
updateZipJob(jobId, { status: 'processing', error: undefined, archiveCount: undefined });
try {
const processedZipArchives = await extractZipArchives(file);
const processedZipArchives = isAcceptedPdfFile(file)
? [await pdfFileToArchive(file)]
: await extractZipArchives(file);
if (generation !== archiveGeneration || !zipJobs.some((job) => job.id === jobId)) {
return;
@@ -93,7 +97,9 @@
if (generation === archiveGeneration && zipJobs.some((job) => job.id === jobId)) {
updateZipJob(jobId, {
status: 'error',
error: 'Prüfen Sie, ob die Datei ein gültiges beA-ZIP-Archiv ist.'
error: isAcceptedPdfFile(file)
? 'Prüfen Sie, ob die Datei ein gültiges PDF ist.'
: 'Prüfen Sie, ob die Datei ein gültiges beA-ZIP-Archiv ist.'
});
}
}
+156
View File
@@ -0,0 +1,156 @@
<script lang="ts">
import FileDropzone from '$lib/components/FileDropzone.svelte';
import Button from '$lib/components/ui/button.svelte';
import { formatBytes } from '$lib/batch';
import { downloadBlob } from '$lib/download';
import { readPdfMetadata, stripPdfMetadata, type PdfMetadataResult } from '$lib/pdf-metadata';
import { AlertTriangle, Info, Lock } from '@lucide/svelte';
let file = $state<File | null>(null);
let result = $state<PdfMetadataResult | null>(null);
let isReading = $state(false);
let isCleaning = $state(false);
let error = $state<string | null>(null);
const cleanedFileName = $derived(file ? `${file.name.replace(/\.pdf$/i, '')}_bereinigt.pdf` : '');
const handleFilesSelected = async (files: File[]) => {
const selected = files[0];
if (!selected) return;
file = selected;
result = null;
error = null;
isReading = true;
try {
const bytes = new Uint8Array(await selected.arrayBuffer());
result = await readPdfMetadata(bytes);
} catch {
file = null;
error = 'Diese Datei konnte nicht als PDF gelesen werden. Bitte prüfen Sie die Datei.';
} finally {
isReading = false;
}
};
const downloadCleaned = async () => {
if (!file || isCleaning) return;
isCleaning = true;
error = null;
try {
const bytes = new Uint8Array(await file.arrayBuffer());
const cleaned = await stripPdfMetadata(bytes);
downloadBlob(cleaned, cleanedFileName);
} catch {
error = 'Die Metadaten konnten nicht entfernt werden. Bitte versuchen Sie es erneut.';
} finally {
isCleaning = false;
}
};
const formatDate = (value: Date | null) =>
value ? value.toLocaleString('de-DE', { dateStyle: 'long', timeStyle: 'short' }) : '';
const infoRows = $derived.by(() => {
if (!result || result.status !== 'ok') return [];
const metadata = result.metadata;
return [
{ label: 'Titel', value: metadata.title },
{ label: 'Autor', value: metadata.author },
{ label: 'Betreff', value: metadata.subject },
{ label: 'Schlüsselwörter', value: metadata.keywords.join(', ') },
{ label: 'Erstellt mit', value: metadata.creator },
{ label: 'PDF-Erzeuger', value: metadata.producer },
{ label: 'Erstellt am', value: formatDate(metadata.creationDate) },
{ label: 'Geändert am', value: formatDate(metadata.modificationDate) },
{ label: 'Seiten', value: String(metadata.pageCount) },
{ label: 'Größe', value: file ? formatBytes(file.size) : '' },
{ label: 'Verschlüsselung', value: 'Nein' }
];
});
</script>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-foreground">Metadaten</h1>
<p class="mt-2 text-sm text-primary">
Prüfen Sie, welche Informationen Ihr PDF enthält, und entfernen Sie sie vor dem Weitergeben.
</p>
</div>
<FileDropzone
onFilesSelected={handleFilesSelected}
label={file ? 'Anderes PDF auswählen' : 'PDF auswählen'}
sublabel="PDF hier ablegen oder zum Auswählen klicken"
ariaLabel="PDF-Dateien hinzufügen"
/>
{#if isReading}
<p class="text-sm text-primary" role="status">Metadaten werden gelesen …</p>
{:else if error}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800" role="alert">
{error}
</div>
{:else if result}
{#if result.status === 'encrypted'}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 flex gap-3" role="alert">
<Lock class="h-5 w-5 shrink-0 text-red-600 mt-0.5" />
<div class="text-sm text-red-800">
<p class="font-medium">Dieses PDF ist verschlüsselt</p>
<p class="mt-1">
Verschlüsselte Dokumente können nicht gelesen werden. Bitte entfernen Sie zuerst das
Passwort mit dem Werkzeug
<a href="/tools/decrypt" class="font-medium underline">Passwort entfernen</a>.
</p>
</div>
</div>
{:else}
<div class="rounded-2xl border border-border bg-card p-6 space-y-5" data-metadata-card>
<div class="flex items-center gap-2 text-foreground">
<Info class="h-5 w-5" />
<h2 class="text-lg font-semibold">Dokument-Informationen</h2>
</div>
<p class="text-xs text-primary truncate" title={file?.name}>{file?.name}</p>
<dl class="grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-[12rem_1fr]">
{#each infoRows as row (row.label)}
<dt class="text-sm font-medium text-foreground">{row.label}</dt>
<dd class="text-sm text-primary wrap-break-word" data-metadata-value={row.label}>
{row.value || '–'}
</dd>
{/each}
</dl>
{#if result.metadata.embeddedFileSuspected}
<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" />
<p class="text-sm text-amber-800">
Die Datei enthält möglicherweise eingebettete Anlagen. Diese werden beim Bereinigen
nicht entfernt.
</p>
</div>
{/if}
<div class="rounded-lg border border-border bg-muted/50 p-4 text-sm text-primary">
<p>
Beim Bereinigen bleibt der Inhalt vollständig erhalten (kein Qualitätsverlust). Entfernt
werden die Standard-Dokumenteigenschaften (Titel, Autor, Betreff usw.). Nicht entfernt
werden sogenannte XMP-Metadaten (Extensible Metadata Platform): Diese stehen nicht im
Bereich „Dokumenteigenschaften“, sondern können direkt ins PDF-Dokument oder in
eingebettete Bilder eingebettet sein.
</p>
</div>
</div>
{/if}
<div class="flex justify-end">
<Button
onclick={downloadCleaned}
disabled={result.status !== 'ok' || isCleaning}
data-clean-download
>
{isCleaning ? 'Wird bereinigt …' : 'Bereinigt herunterladen'}
</Button>
</div>
{/if}
</div>
+3 -8
View File
@@ -1,10 +1,10 @@
<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 { copyPagesToNewPdf } from '$lib/pdf-processing';
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
let pdfFile: File | null = $state(null);
@@ -64,11 +64,9 @@
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++) {
for (let i = 0; i < pdfHandle.pageCount; i++) {
if (!selectedPages.has(i + 1)) {
pagesToKeep.push(i);
}
@@ -80,10 +78,7 @@
return;
}
const copiedPages = await outputPdf.copyPages(sourcePdf, pagesToKeep);
copiedPages.forEach((page) => outputPdf.addPage(page));
const resultBytes = await outputPdf.save();
const resultBytes = await copyPagesToNewPdf(bytes, pagesToKeep);
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
downloadBlob(resultBytes, `${baseName}_ohne_seiten.pdf`);
} catch (err) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+491
View File
@@ -0,0 +1,491 @@
import { expect, test } from '@playwright/test';
import { zipSync } from 'fflate';
import {
appendZipInput,
createMultiFileZip,
createPdf,
createZip,
dropFiles,
expectPdfDownload,
newZipButton,
replaceThroughDialog,
replaceZipInput,
settingsButton,
trackPageErrors,
waitForLoadedWorkspace,
workspaceStatus,
zipDropzone
} from './helpers';
test('beA reports an invalid ZIP and lets the user remove it', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{
name: 'defektes-archiv.zip',
mimeType: 'application/zip',
buffer: Buffer.from('kein gueltiges ZIP-Archiv')
}
]);
await expect(page.getByText('defektes-archiv.zip', { exact: true })).toBeVisible();
await expect(page.getByText('Konnte nicht geöffnet werden.')).toBeVisible();
await expect(page.getByRole('status')).toContainText('1 fehlgeschlagen');
await expect(page.getByRole('button', { name: 'Erneut versuchen' })).toBeVisible();
await page.getByRole('button', { name: 'Entfernen' }).click();
await expect(zipDropzone(page)).toBeVisible();
expect(pageErrors).toEqual([]);
});
test('cancelling the replacement confirmation keeps the current archive', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const zipA = await createZip('archiv-a');
const zipB = await createZip('archiv-b');
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await replaceThroughDialog(page, zipB, false);
await expect(workspaceStatus(page, /1 Datei · 1 Archiv geöffnet/)).toBeVisible();
await expect(page.getByText('2 Dateien')).toHaveCount(0);
expect(pageErrors).toEqual([]);
});
test('confirming the replacement opens only the new ZIP and drops stale results', async ({
page
}) => {
const pageErrors = trackPageErrors(page);
const zipA = await createZip('archiv-a', 3);
const zipB = await createZip('archiv-b');
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
// Replace while the first ZIP may still be extracting; generation guarding
// must ensure no late result from archiv-a reappears.
await replaceThroughDialog(page, zipB, true);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await expect(page.getByText('1 lose Datei')).toHaveCount(1);
expect(pageErrors).toEqual([]);
});
test('cancelling the system file picker leaves the workspace unchanged', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const zipA = await createZip('archiv-a');
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await newZipButton(page).click();
await replaceZipInput(page).setInputFiles([]);
await expect(page.getByText('Aktuelle Bearbeitung ersetzen?')).toHaveCount(0);
await expect(workspaceStatus(page, /1 Datei · 1 Archiv geöffnet/)).toBeVisible();
expect(pageErrors).toEqual([]);
});
test('the replacement input accepts the same ZIP twice', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const zipA = await createZip('archiv-a');
const zipB = await createZip('archiv-b');
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await replaceThroughDialog(page, zipB, true);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await replaceThroughDialog(page, zipB, true);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await expect(page.getByText('1 lose Datei')).toHaveCount(1);
expect(pageErrors).toEqual([]);
});
test('appending a ZIP keeps both archive editors visible', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const zipA = await createZip('archiv-a');
const zipB = await createZip('archiv-b');
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await settingsButton(page).click();
await appendZipInput(page).setInputFiles([
{ name: 'archiv-b.zip', mimeType: 'application/zip', buffer: zipB }
]);
await waitForLoadedWorkspace(page, /2 Dateien · 2 Archive geöffnet/);
await expect(page.getByText('1 lose Datei')).toHaveCount(2);
expect(pageErrors).toEqual([]);
});
test('bulk export only appears for two or more processed archives', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const zipA = await createZip('archiv-a');
const zipB = await createZip('archiv-b');
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await settingsButton(page).click();
await expect(
page.getByRole('button', { name: 'Alle Archive als PDF herunterladen' })
).toHaveCount(0);
await appendZipInput(page).setInputFiles([
{ name: 'archiv-b.zip', mimeType: 'application/zip', buffer: zipB }
]);
await waitForLoadedWorkspace(page, /2 Dateien · 2 Archive geöffnet/);
// The popover stays open, so the new bulk export action appears in place.
const exportButton = page.getByRole('button', { name: 'Alle Archive als PDF herunterladen' });
await expect(exportButton).toBeVisible();
const downloadPromise = page.waitForEvent('download');
await exportButton.click();
await expectPdfDownload(await downloadPromise, /\.pdf$/);
expect(pageErrors).toEqual([]);
});
test('clearing the workspace requires confirmation and returns to the dropzone', async ({
page
}) => {
const pageErrors = trackPageErrors(page);
const zipA = await createZip('archiv-a');
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await settingsButton(page).click();
await page.getByRole('button', { name: 'Aktuelle Bearbeitung leeren' }).click();
await expect(page.getByText('Aktuelle Bearbeitung leeren?')).toBeVisible();
await page.getByRole('button', { name: 'Abbrechen' }).click();
await expect(workspaceStatus(page, /1 Datei · 1 Archiv geöffnet/)).toBeVisible();
await page.getByRole('button', { name: 'Aktuelle Bearbeitung leeren' }).click();
await page.getByRole('button', { name: 'Bearbeitung leeren', exact: true }).click();
await expect(zipDropzone(page)).toBeVisible();
await expect(newZipButton(page)).toHaveCount(0);
expect(pageErrors).toEqual([]);
});
test('the preview-size slider works with arrow keys and survives a reload', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const zipA = await createZip('archiv-a');
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await expect(page.locator('li[style*="width: 200px"]').first()).toBeVisible();
await settingsButton(page).click();
const slider = page.getByRole('slider', { name: 'Vorschaugröße' });
await expect(page.getByText('200 px')).toBeVisible();
await slider.focus();
await page.keyboard.press('ArrowRight');
await expect(page.getByText('201 px')).toBeVisible();
await expect(page.locator('li[style*="width: 201px"]').first()).toBeVisible();
await page.reload();
await page.locator('[data-dropzone-ready="true"]').first().waitFor();
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await settingsButton(page).click();
await expect(page.getByText('201 px')).toBeVisible();
await expect(await page.evaluate(() => localStorage.getItem('thumbnailWidth'))).toBe('201');
expect(pageErrors).toEqual([]);
});
test('settings closes on Escape and outside click and returns focus to its trigger', async ({
page
}) => {
const pageErrors = trackPageErrors(page);
const zipA = await createZip('archiv-a');
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await expect(settingsButton(page)).toHaveAccessibleName('Einstellungen');
await settingsButton(page).click();
await expect(page.getByText('Vorschaugröße')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByText('Vorschaugröße')).toHaveCount(0);
await expect(settingsButton(page)).toBeFocused();
await settingsButton(page).click();
await expect(page.getByText('Vorschaugröße')).toBeVisible();
await page.getByRole('heading', { name: 'beA-Edit' }).click();
await expect(page.getByText('Vorschaugröße')).toHaveCount(0);
expect(pageErrors).toEqual([]);
});
test('mobile toolbar and settings popover stay inside the viewport', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const zipA = await createZip('archiv-a');
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await settingsButton(page).click();
const popover = page.locator('[data-slot="popover-content"]');
await expect(popover).toBeVisible();
await expect(page.getByText('Weitere Datei hinzufügen')).toBeVisible();
const popoverBox = await popover.boundingBox();
expect(popoverBox).not.toBeNull();
expect(popoverBox!.x).toBeGreaterThanOrEqual(0);
expect(popoverBox!.x + popoverBox!.width).toBeLessThanOrEqual(390);
expect(popoverBox!.y).toBeGreaterThanOrEqual(0);
expect(popoverBox!.y + popoverBox!.height).toBeLessThanOrEqual(844);
const popoverMetrics = await popover.evaluate((element) => ({
scrollWidth: element.scrollWidth,
clientWidth: element.clientWidth
}));
expect(popoverMetrics.scrollWidth).toBeLessThanOrEqual(popoverMetrics.clientWidth);
// The toolbar sits in normal flow, so it must not cover the archive header.
const toolbarBox = await workspaceStatus(page, /1 Datei · 1 Archiv geöffnet/).boundingBox();
const archiveHeaderBox = await page.getByText('1 lose Datei').first().boundingBox();
expect(toolbarBox).not.toBeNull();
expect(archiveHeaderBox).not.toBeNull();
expect(toolbarBox!.y + toolbarBox!.height).toBeLessThanOrEqual(archiveHeaderBox!.y);
expect(pageErrors).toEqual([]);
});
test('grouping mode creates Teildokumente and never duplicates their names', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const zip = await createMultiFileZip(['antrag', 'klage', 'gutachten']);
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv.zip', mimeType: 'application/zip', buffer: zip }
]);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await page.getByRole('button', { name: 'Gruppieren' }).click();
const createButton = page.getByRole('button', { name: 'Teildokument erstellen' });
await expect(page.getByText('0 Dateien ausgewählt')).toBeVisible();
await expect(createButton).toBeDisabled();
const checkboxes = page.locator('li input[type="checkbox"]');
await checkboxes.nth(0).check();
await expect(page.getByText('1 Datei ausgewählt')).toBeVisible();
await createButton.click();
// the new block appears, is named "Teildokument 1", and its name input is focused
await expect(page.getByText('2 lose Dateien · 1 Teildokument')).toBeVisible();
const nameInput = page.getByLabel('Teildokumentname');
await expect(nameInput).toHaveValue('Teildokument 1');
await expect(nameInput).toBeFocused();
// create a second block, dissolve the first, then create again:
// the next number must not collide with the surviving "Teildokument 2"
await checkboxes.nth(0).check();
await createButton.click();
await expect(page.getByLabel('Teildokumentname').nth(1)).toHaveValue('Teildokument 2');
await page.getByRole('button', { name: 'Auflösen' }).first().click();
await expect(page.getByText('2 lose Dateien')).toBeVisible();
await checkboxes.nth(0).check();
await createButton.click();
await expect(page.getByLabel('Teildokumentname').nth(1)).toHaveValue('Teildokument 3');
await page.keyboard.press('Escape');
await expect(page.getByRole('button', { name: 'Fertig' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Gruppieren' })).toBeVisible();
expect(pageErrors).toEqual([]);
});
test('grouping mode moves a selection into an existing Teildokument', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const zip = await createMultiFileZip(['antrag', 'klage', 'gutachten']);
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv.zip', mimeType: 'application/zip', buffer: zip }
]);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await page.getByRole('button', { name: 'Neues Teildokument' }).click();
await expect(page.getByLabel('Teildokumentname')).toHaveValue('Teildokument 1');
await page.getByRole('button', { name: 'Gruppieren' }).click();
const checkboxes = page.locator('li input[type="checkbox"]');
await checkboxes.nth(0).check();
await checkboxes.nth(1).check();
await expect(page.getByText('2 Dateien ausgewählt')).toBeVisible();
const moveSelect = page.getByLabel('Auswahl in Teildokument verschieben');
await moveSelect.selectOption({ label: 'Teildokument 1' });
await expect(page.getByText('1 lose Datei · 1 Teildokument')).toBeVisible();
await expect(page.getByText('2 Dateien').first()).toBeVisible();
expect(pageErrors).toEqual([]);
});
test('attachment menu moves files between sub-documents and the loose list', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const zip = await createMultiFileZip(['antrag', 'klage', 'gutachten']);
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv.zip', mimeType: 'application/zip', buffer: zip }
]);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
// create a sub-document from a loose file without drag-and-drop (touch path)
await page.getByRole('button', { name: 'Aktionen für antrag.pdf' }).click();
await page.getByRole('menuitem', { name: 'In neues Teildokument verschieben' }).click();
await expect(page.getByText('2 lose Dateien · 1 Teildokument')).toBeVisible();
// move a second loose file into the existing sub-document
await page.getByRole('button', { name: 'Aktionen für klage.pdf' }).click();
await page.getByRole('menuitem', { name: 'Nach „Teildokument 1“ verschieben' }).click();
await expect(page.getByText('1 lose Datei · 1 Teildokument')).toBeVisible();
// move it back out to the loose list
await page.getByRole('button', { name: 'Aktionen für klage.pdf' }).click();
await page.getByRole('menuitem', { name: 'Zu den losen Dateien verschieben' }).click();
await expect(page.getByText('2 lose Dateien · 1 Teildokument')).toBeVisible();
// remove a loose file with confirmation
await page.getByRole('button', { name: 'Aktionen für gutachten.pdf' }).click();
await page.getByRole('menuitem', { name: 'Entfernen: gutachten.pdf' }).click();
await page.getByRole('button', { name: 'Entfernen', exact: true }).click();
await expect(page.getByText('1 lose Datei · 1 Teildokument')).toBeVisible();
expect(pageErrors).toEqual([]);
});
test('quick action groups every loose file as its own Teildokument', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const zip = await createMultiFileZip(['antrag', 'klage']);
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv.zip', mimeType: 'application/zip', buffer: zip }
]);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await page.getByRole('button', { name: 'Jede Datei als Teildokument' }).click();
await expect(page.getByText('0 lose Dateien · 2 Teildokumente')).toBeVisible();
await expect(page.getByLabel('Teildokumentname').nth(0)).toHaveValue('antrag');
await expect(page.getByLabel('Teildokumentname').nth(1)).toHaveValue('klage');
expect(pageErrors).toEqual([]);
});
test('splitting a PDF attachment cuts the selected pages into a new Teildokument', async ({
page
}) => {
const pageErrors = trackPageErrors(page);
const zip = Buffer.from(zipSync({ 'vertrag.pdf': await createPdf(3, 'Vertrag') }));
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv.zip', mimeType: 'application/zip', buffer: zip }
]);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await page.getByRole('button', { name: 'Aktionen für vertrag.pdf' }).click();
await page.getByRole('menuitem', { name: 'Teilen: vertrag.pdf' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await page.getByRole('button', { name: 'Seite 1' }).click();
await page.getByRole('button', { name: 'Seite 2' }).click();
await expect(page.getByText('2 von 3 Seiten ausgewählt')).toBeVisible();
await page.getByRole('button', { name: 'Als Teildokument übernehmen' }).click();
await expect(page.getByText('✓ Zugewiesen')).toHaveCount(2);
await page.locator('[data-split-apply]').click();
await expect(dialog).toHaveCount(0);
// the remaining page 3 stays loose, the cut pages form a new Teildokument
await expect(page.getByText('1 lose Datei · 1 Teildokument')).toBeVisible();
await expect(page.getByLabel('Teildokumentname')).toHaveValue('Seiten 1–2');
await expect(page.getByText('vertrag_Seiten_1-2.pdf')).toBeVisible();
expect(pageErrors).toEqual([]);
});
test('bulk split creates one Teildokument per page and removes the source', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const zip = Buffer.from(zipSync({ 'vertrag.pdf': await createPdf(3, 'Vertrag') }));
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv.zip', mimeType: 'application/zip', buffer: zip }
]);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await page.getByRole('button', { name: 'Aktionen für vertrag.pdf' }).click();
await page.getByRole('menuitem', { name: 'Teilen: vertrag.pdf' }).click();
await page.getByText('Jede Seite einzeln').click();
await expect(page.getByText('Es werden 3 Teildokumente erstellt.')).toBeVisible();
await page.locator('[data-split-apply]').click();
await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(page.getByText('0 lose Dateien · 3 Teildokumente')).toBeVisible();
await expect(page.getByLabel('Teildokumentname').nth(0)).toHaveValue('Seite 1');
await expect(page.getByLabel('Teildokumentname').nth(2)).toHaveValue('Seite 3');
expect(pageErrors).toEqual([]);
});
test('a dropped PDF opens as a workspace archive and can be split', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const pdf = await createPdf(2, 'Antrag');
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'antrag.pdf', mimeType: 'application/pdf', buffer: pdf }
]);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/);
await expect(page.getByText('1 lose Datei')).toBeVisible();
await page.getByRole('button', { name: 'Aktionen für antrag.pdf' }).click();
await page.getByRole('menuitem', { name: 'Teilen: antrag.pdf' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Seite 1' }).click();
await page.getByRole('button', { name: 'Als Teildokument übernehmen' }).click();
await page.locator('[data-split-apply]').click();
await expect(page.getByText('1 Teildokument')).toBeVisible();
expect(pageErrors).toEqual([]);
});
+83
View File
@@ -0,0 +1,83 @@
import { expect, test } from '@playwright/test';
import { unzipSync } from 'fflate';
import { readFile } from 'node:fs/promises';
import { createPdf, dropFiles, expectPdfDownload, pdfDropzone, trackPageErrors } from './helpers';
test('compress processes a single dropped file and offers its download per row', 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 expect(page.getByText('Dateien (1)')).toBeVisible();
await page.getByRole('button', { name: 'Komprimieren', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'gross.pdf herunterladen' }).click();
await expectPdfDownload(await downloadPromise, /^gross_komprimiert\.pdf$/);
expect(pageErrors).toEqual([]);
});
test('compress appends further files and bundles all results in one ZIP', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/compress');
await dropFiles(page, pdfDropzone(page), [
{ name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') }
]);
await dropFiles(page, pdfDropzone(page), [
{ name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Zwei') }
]);
await expect(page.getByText('Dateien (2)')).toBeVisible();
await page.getByRole('button', { name: 'Komprimieren', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 30_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^komprimiert_\d{4}-\d{2}-\d{2}\.zip$/);
const zipPath = await download.path();
expect(zipPath).not.toBeNull();
const entries = Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!))));
expect(entries.sort()).toEqual(['eins_komprimiert.pdf', 'zwei_komprimiert.pdf']);
expect(pageErrors).toEqual([]);
});
test('compress batch keeps processing after a failing file and supports retry and removal', async ({
page
}) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/compress');
await dropFiles(page, pdfDropzone(page), [
{ name: 'gut.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Gut') },
{
name: 'kaputt.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('kein gueltiges PDF')
}
]);
await page.getByRole('button', { name: 'Komprimieren', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(1, { timeout: 30_000 });
await expect(page.getByText('Fehler')).toBeVisible();
// Only one result exists, so the bulk ZIP action stays hidden.
await expect(page.getByRole('button', { name: 'Alle herunterladen' })).toHaveCount(0);
// Retrying the broken file fails again without disturbing the finished row.
await page.getByRole('button', { name: 'kaputt.pdf erneut versuchen' }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(1);
await expect(page.getByText('Fehler')).toBeVisible();
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'gut.pdf herunterladen' }).click();
await expectPdfDownload(await downloadPromise, /^gut_komprimiert\.pdf$/);
await page.getByRole('button', { name: 'kaputt.pdf entfernen' }).click();
await expect(page.getByText('kaputt.pdf')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'gut.pdf herunterladen' })).toBeVisible();
expect(pageErrors).toEqual([]);
});
+17
View File
@@ -0,0 +1,17 @@
import { expect, test } from '@playwright/test';
import { createPdf, dropFiles, pdfDropzone, trackPageErrors } from './helpers';
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([]);
});
+45
View File
@@ -0,0 +1,45 @@
import { expect, test } from '@playwright/test';
import { unzipSync } from 'fflate';
import { readFile } from 'node:fs/promises';
import { createEncryptedPdf, dropFiles, pdfDropzone, trackPageErrors } from './helpers';
test('decrypt batch unlocks every file and bundles results in one ZIP', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/decrypt');
await dropFiles(page, pdfDropzone(page), [
{
name: 'eins_geschuetzt.pdf',
mimeType: 'application/pdf',
buffer: await createEncryptedPdf(1, 'Eins', 'test-passwort')
},
{
name: 'zwei_geschuetzt.pdf',
mimeType: 'application/pdf',
buffer: await createEncryptedPdf(2, 'Zwei', 'test-passwort')
},
{
name: 'kaputt.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('kein gueltiges PDF')
}
]);
await expect(page.getByText('Dateien (3)')).toBeVisible();
await page.locator('#decrypt-password').fill('test-passwort');
await page.getByRole('button', { name: 'Passwort entfernen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 20_000 });
await expect(page.getByText(/Fehler beim Entsperren/)).toBeVisible();
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^entsperrt_\d{4}-\d{2}-\d{2}\.zip$/);
const zipPath = await download.path();
expect(zipPath).not.toBeNull();
const entries = Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!))));
expect(entries.sort()).toEqual([
'eins_geschuetzt_entsperrt.pdf',
'zwei_geschuetzt_entsperrt.pdf'
]);
expect(pageErrors).toEqual([]);
});
+66
View File
@@ -0,0 +1,66 @@
import { expect, test } from '@playwright/test';
import { unzipSync } from 'fflate';
import { readFile } from 'node:fs/promises';
import { createPdf, dropFiles, expectPdfDownload, pdfDropzone, trackPageErrors } from './helpers';
test('encrypt protects a dropped 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');
await page.getByRole('button', { name: 'Passwort setzen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 15_000 });
const encryptedDownloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'geheim.pdf herunterladen' }).click();
const encryptedBytes = await expectPdfDownload(
await encryptedDownloadPromise,
/^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');
await page.getByRole('button', { name: 'Passwort entfernen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
const decryptedDownloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'geheim_geschuetzt.pdf herunterladen' }).click();
await expectPdfDownload(await decryptedDownloadPromise, /^geheim_geschuetzt_entsperrt\.pdf$/);
expect(pageErrors).toEqual([]);
});
test('encrypt batch protects every file and bundles results in one ZIP', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/encrypt');
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('Dateien (2)')).toBeVisible();
await page.locator('#encrypt-password').fill('test-passwort');
await page.locator('#encrypt-password-confirmation').fill('test-passwort');
await page.getByRole('button', { name: 'Passwort setzen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 15_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^geschuetzt_\d{4}-\d{2}-\d{2}\.zip$/);
const zipPath = await download.path();
expect(zipPath).not.toBeNull();
const entries = Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!))));
expect(entries.sort()).toEqual(['eins_geschuetzt.pdf', 'zwei_geschuetzt.pdf']);
expect(pageErrors).toEqual([]);
});
+206
View File
@@ -0,0 +1,206 @@
import { expect, type Download, type Locator, type Page } from '@playwright/test';
import {
decodePDFRawStream,
degrees,
PDFArray,
PDFDocument,
PDFRawStream,
StandardFonts,
rgb
} from 'pdf-lib';
import { PDFDocument as EncryptablePdfDocument } from '@cantoo/pdf-lib';
import { zipSync } from 'fflate';
import { readFile } from 'node:fs/promises';
export 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());
};
export const createStampPng = (page: Page) =>
page.evaluate(
async () =>
new Promise<string>((resolve, reject) => {
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 32;
const context = canvas.getContext('2d');
if (!context) {
reject(new Error('Kein 2D-Kontext'));
return;
}
context.fillStyle = '#cc1a1a';
context.fillRect(0, 0, 32, 32);
canvas.toBlob(async (blob) => {
if (!blob) {
reject(new Error('PNG konnte nicht erzeugt werden'));
return;
}
const bytes = new Uint8Array(await blob.arrayBuffer());
let binary = '';
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
resolve(btoa(binary));
}, 'image/png');
})
);
export 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();
};
export 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;
};
export const getPageContentStreamCount = (document: PDFDocument, pageIndex: number) => {
const contents = document.getPage(pageIndex).node.Contents();
if (!contents) return 0;
return contents instanceof PDFArray ? contents.size() : 1;
};
export const getPageContentText = (document: PDFDocument, pageIndex: number) => {
const contents = document.getPage(pageIndex).node.Contents();
const refs = contents instanceof PDFArray ? contents.asArray() : contents ? [contents] : [];
return refs
.map((ref) => document.context.lookup(ref))
.filter((stream): stream is PDFRawStream => stream instanceof PDFRawStream)
.map((stream) => Buffer.from(decodePDFRawStream(stream).decode()).toString('latin1'))
.join('\n');
};
export const createEncryptedPdf = async (pageCount: number, label: string, password: string) => {
const document = await EncryptablePdfDocument.load(await createPdf(pageCount, label));
document.encrypt({
userPassword: password,
ownerPassword: 'owner-passwort',
permissions: { printing: true, copying: true }
});
return Buffer.from(await document.save());
};
export const createPreRotatedPdf = 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)
});
if (pageNumber === 1) page.setRotation(degrees(90));
}
return Buffer.from(await document.save());
};
export const trackPageErrors = (page: Page) => {
const errors: Error[] = [];
page.on('pageerror', (error) => errors.push(error));
return errors;
};
export const pdfDropzone = (page: Page) =>
page.getByRole('button', { name: 'PDF-Dateien hinzufügen' });
export const zipDropzone = (page: Page) =>
page.getByRole('button', { name: 'beA-ZIP- oder PDF-Dateien hinzufügen' });
export const createZip = async (label: string, pageCount = 1) =>
Buffer.from(zipSync({ [`${label}.pdf`]: await createPdf(pageCount, label) }));
/** ZIP with multiple named single-page PDF attachments. */
export const createMultiFileZip = async (labels: string[]) =>
Buffer.from(
zipSync(
Object.fromEntries(
await Promise.all(labels.map(async (label) => [`${label}.pdf`, await createPdf(1, label)]))
)
)
);
export const newZipButton = (page: Page) =>
page.getByRole('button', { name: 'Neue Datei bearbeiten' });
export const settingsButton = (page: Page) =>
page.getByRole('button', { name: 'Einstellungen', exact: true });
export const replaceZipInput = (page: Page) => page.locator('input[data-zip-picker="replace-zip"]');
export const appendZipInput = (page: Page) => page.locator('input[data-zip-picker="append-zip"]');
export const workspaceStatus = (page: Page, pattern: RegExp) => page.getByText(pattern);
export const waitForLoadedWorkspace = async (
page: Page,
status: RegExp,
options?: { timeout?: number }
) => {
await expect(newZipButton(page)).toBeVisible();
await expect(workspaceStatus(page, status)).toBeVisible({ timeout: options?.timeout ?? 15_000 });
};
export const replaceThroughDialog = async (page: Page, zip: Buffer, confirm: boolean) => {
await newZipButton(page).click();
await replaceZipInput(page).setInputFiles([
{ name: 'archiv-b.zip', mimeType: 'application/zip', buffer: zip }
]);
await expect(page.getByText('Aktuelle Bearbeitung ersetzen?')).toBeVisible();
if (confirm) {
await page.getByRole('button', { name: 'Neue Datei öffnen' }).click();
} else {
await page.getByRole('button', { name: 'Abbrechen' }).click();
}
await expect(page.getByText('Aktuelle Bearbeitung ersetzen?')).toBeHidden();
};
+17
View File
@@ -0,0 +1,17 @@
import { expect, test } from '@playwright/test';
test('short tool pages keep the footer flush with the viewport bottom', async ({ page }) => {
const expectFooterAtViewportBottom = async (viewport: { width: number; height: number }) => {
await page.setViewportSize(viewport);
await page.goto('/tools/watermark');
const footerBottom = await page.locator('footer').evaluate((footer) => {
return footer.getBoundingClientRect().bottom;
});
expect(Math.abs(footerBottom - viewport.height)).toBeLessThanOrEqual(1);
};
await expectFooterAtViewportBottom({ width: 1440, height: 900 });
await expectFooterAtViewportBottom({ width: 390, height: 844 });
});
+17
View File
@@ -0,0 +1,17 @@
import { expect, test } from '@playwright/test';
import { createPdf, dropFiles, expectPdfDownload, pdfDropzone, trackPageErrors } from './helpers';
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([]);
});
+138
View File
@@ -0,0 +1,138 @@
import { expect, test, type Page } from '@playwright/test';
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
import {
createEncryptedPdf,
createPdf,
dropFiles,
expectPdfDownload,
pdfDropzone,
trackPageErrors
} from './helpers';
const createPdfWithMetadata = async () => {
const document = await PDFDocument.create();
const font = await document.embedFont(StandardFonts.Helvetica);
for (let pageNumber = 1; pageNumber <= 2; pageNumber += 1) {
const page = document.addPage([420, 594]);
page.drawText(`Vertraulich – Seite ${pageNumber}`, {
x: 40,
y: 520,
size: 20,
font,
color: rgb(0.1, 0.2, 0.4)
});
}
document.setTitle('Schriftsatz 12. Kammer');
document.setAuthor('Dr. Echte Anwältin');
document.setSubject('Klageerwiderung');
document.setKeywords(['vertraulich, Mandat 4711']);
document.setCreator('Anwaltssoftware Pro');
document.setProducer('PDF-Fabrik 3.2');
document.setCreationDate(new Date('2024-05-06T09:12:00Z'));
document.setModificationDate(new Date('2024-05-07T16:40:00Z'));
return Buffer.from(await document.save());
};
const metadataValue = (page: Page, label: string) =>
page.locator(`[data-metadata-value="${label}"]`);
test('metadata tool shows document properties of a dropped PDF', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/metadata');
await dropFiles(page, pdfDropzone(page), [
{
name: 'schriftsatz.pdf',
mimeType: 'application/pdf',
buffer: await createPdfWithMetadata()
}
]);
await expect(metadataValue(page, 'Titel')).toHaveText('Schriftsatz 12. Kammer');
await expect(metadataValue(page, 'Autor')).toHaveText('Dr. Echte Anwältin');
await expect(metadataValue(page, 'Betreff')).toHaveText('Klageerwiderung');
await expect(metadataValue(page, 'Schlüsselwörter')).toHaveText('vertraulich, Mandat 4711');
await expect(metadataValue(page, 'Erstellt mit')).toHaveText('Anwaltssoftware Pro');
await expect(metadataValue(page, 'PDF-Erzeuger')).toHaveText('PDF-Fabrik 3.2');
await expect(metadataValue(page, 'Erstellt am')).toContainText('2024');
await expect(metadataValue(page, 'Geändert am')).toContainText('2024');
await expect(metadataValue(page, 'Seiten')).toHaveText('2');
await expect(metadataValue(page, 'Verschlüsselung')).toHaveText('Nein');
expect(pageErrors).toEqual([]);
});
test('metadata tool shows a dash for empty fields', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/metadata');
await dropFiles(page, pdfDropzone(page), [
{ name: 'nackt.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Nackt') }
]);
await expect(metadataValue(page, 'Titel')).toHaveText('–');
await expect(metadataValue(page, 'Autor')).toHaveText('–');
await expect(metadataValue(page, 'Schlüsselwörter')).toHaveText('–');
expect(pageErrors).toEqual([]);
});
test('metadata tool downloads a cleaned PDF with empty document properties', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/metadata');
await dropFiles(page, pdfDropzone(page), [
{
name: 'schriftsatz.pdf',
mimeType: 'application/pdf',
buffer: await createPdfWithMetadata()
}
]);
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Bereinigt herunterladen' }).click();
const bytes = await expectPdfDownload(await downloadPromise, /^schriftsatz_bereinigt\.pdf$/);
const document = await PDFDocument.load(bytes, { updateMetadata: false });
expect((document.getTitle() ?? '').trim()).toBe('');
expect((document.getAuthor() ?? '').trim()).toBe('');
expect((document.getSubject() ?? '').trim()).toBe('');
expect((document.getKeywords() ?? '').trim()).toBe('');
expect(document.getProducer()).toBe('beA-Edit');
expect(document.getPageCount()).toBe(2);
expect(pageErrors).toEqual([]);
});
test('metadata tool rejects encrypted PDFs with a decrypt hint', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/metadata');
await dropFiles(page, pdfDropzone(page), [
{
name: 'geheim.pdf',
mimeType: 'application/pdf',
buffer: await createEncryptedPdf(1, 'Geheim', 'test-passwort')
}
]);
await expect(page.getByText('Dieses PDF ist verschlüsselt')).toBeVisible();
await expect(page.getByRole('link', { name: 'Passwort entfernen' })).toHaveAttribute(
'href',
'/tools/decrypt'
);
await expect(page.getByRole('button', { name: 'Bereinigt herunterladen' })).toBeDisabled();
expect(pageErrors).toEqual([]);
});
test('metadata tool warns when embedded files are suspected', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const pdf = await createPdf(1, 'Anlage');
const withMarker = Buffer.concat([pdf, Buffer.from('\n% /EmbeddedFile\n')]);
await page.goto('/tools/metadata');
await dropFiles(page, pdfDropzone(page), [
{ name: 'mit-anlage.pdf', mimeType: 'application/pdf', buffer: withMarker }
]);
await expect(
page.getByText(
'Die Datei enthält möglicherweise eingebettete Anlagen. Diese werden beim Bereinigen nicht entfernt.'
)
).toBeVisible();
await expect(page.getByRole('button', { name: 'Bereinigt herunterladen' })).toBeEnabled();
expect(pageErrors).toEqual([]);
});
+296
View File
@@ -0,0 +1,296 @@
/// <reference types="node" />
// TEMPORARY verification harness for the PWA (not part of the committed suite).
// Run against a production build:
// pnpm build && pnpm exec playwright test -c playwright.pwa.config.ts
//
// Note: a "deployment" has to restart the node server, because adapter-node's static
// handler (sirv, non-dev mode) stats the build directory once at startup and caches the
// ETags forever.
import { expect, test, type Page } from '@playwright/test';
import { spawn, execSync, type ChildProcess } from 'node:child_process';
import { readdirSync, rmSync, writeFileSync } from 'node:fs';
import {
createPdf,
createZip,
dropFiles,
expectPdfDownload,
pdfDropzone,
trackPageErrors,
waitForLoadedWorkspace,
zipDropzone
} from './helpers';
const PORT = 4179;
const BASE = `http://127.0.0.1:${PORT}`;
// unique per test-file run: leftovers from an aborted run stay inert in old precache versions
const PROBE = `static/pwa-update-probe-${process.pid}.svg`;
const PROBE_URL = `/${PROBE.split('/').pop()}`;
let server: ChildProcess | undefined;
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
async function stopServer() {
const running = server;
server = undefined;
if (!running) return;
await new Promise<void>((resolve) => {
if (running.exitCode !== null) return resolve();
running.once('exit', () => resolve());
running.kill();
});
// give the listener time to release the port
await wait(250);
}
async function startServer() {
await stopServer();
const child = spawn('node', ['build/index.js'], {
env: { ...process.env, PORT: String(PORT), ORIGIN: BASE }
});
server = child;
if (child.stderr) {
child.stderr.on('data', (chunk: Buffer) => process.stderr.write(chunk));
}
for (let attempt = 0; attempt < 200; attempt += 1) {
try {
const response = await fetch(`${BASE}/`);
if (response.ok) return;
} catch {
/* not listening yet */
}
await wait(200);
}
throw new Error('node server did not start');
}
async function rebuild(label: string) {
console.log(`building (${label})…`);
try {
execSync('pnpm build', { stdio: 'ignore' });
} catch (error) {
throw new Error(`build failed for ${label}: ${String(error)}`);
}
}
const swReady = (page: Page) =>
page.evaluate(() => navigator.serviceWorker.ready.then((r) => r.active?.scriptURL ?? null));
const precachedPaths = (page: Page) =>
page.evaluate(async () => {
const names = await caches.keys();
const precache = names.find((name) => name.includes('precache'));
if (!precache) return null;
const cache = await caches.open(precache);
return (await cache.keys()).map((request) => new URL(request.url).pathname).sort();
});
const waitForPrecache = (page: Page, minimum = 50) =>
page.waitForFunction(
(min) =>
caches.keys().then(async (names) => {
const precache = names.find((name) => name.includes('precache'));
if (!precache) return false;
const cache = await caches.open(precache);
return (await cache.keys()).length >= min;
}),
minimum
);
const removeProbeFiles = () => {
for (const file of readdirSync('static')) {
if (file.startsWith('pwa-update-probe-')) rmSync(`static/${file}`, { force: true });
}
};
test.beforeEach(async () => {
rmSync(PROBE, { force: true });
await startServer();
});
test.afterAll(async () => {
await stopServer();
const hadLeftovers = readdirSync('static').some((file) => file.startsWith('pwa-update-probe-'));
removeProbeFiles();
// purge leftover probes from an aborted run out of the precache
if (hadLeftovers) await rebuild('afterAll');
});
test('production build serves the manifest, icons and service worker', async ({
page,
request
}) => {
const manifestResponse = await request.get('/manifest.webmanifest');
expect(manifestResponse.status()).toBe(200);
expect(manifestResponse.headers()['content-type']).toContain('application/manifest+json');
const manifest = await manifestResponse.json();
expect(manifest.name).toBe('beA-Edit');
expect(manifest.start_url).toBe('/');
expect(manifest.display).toBe('standalone');
const iconPaths = [
'/icons/icon-192.png',
'/icons/icon-512.png',
'/icons/icon-maskable-512.png',
'/icons/apple-touch-icon.png'
];
for (const path of iconPaths) {
const response = await request.get(path);
expect(response.status(), path).toBe(200);
}
const swResponse = await request.get('/sw.js');
expect(swResponse.status()).toBe(200);
// the service worker script must never be cached aggressively by the origin
expect(swResponse.headers()['cache-control'] ?? '').not.toMatch(/max-age=[1-9]/);
await page.goto(BASE);
await expect(swReady(page)).resolves.toBe(`${BASE}/sw.js`);
await expect(page.locator('link[rel="manifest"]')).toHaveAttribute(
'href',
'/manifest.webmanifest'
);
await expect(page.locator('meta[name="theme-color"]')).toHaveAttribute('content', '#122f62');
});
test('all documents, assets and the pdfjs worker are precached', async ({ page }) => {
await page.goto(BASE);
await swReady(page);
await waitForPrecache(page);
const paths = await precachedPaths(page);
expect(paths).not.toBeNull();
for (const route of [
'/',
'/tools/bea',
'/tools/merge',
'/tools/metadata',
'/impressum',
'/datenschutz',
'/manifest.webmanifest',
'/icons/icon-192.png',
'/icons/icon-512.png',
'/icons/icon-maskable-512.png',
'/logo.svg'
]) {
expect(paths, route).toContain(route);
}
// pdfjs worker chunk: required to render PDF thumbnails offline
expect(paths!.some((path) => path.endsWith('.mjs'))).toBe(true);
// the unused font drop from static/ must not bloat the precache
expect(paths!.some((path) => path.startsWith('/Nunito/'))).toBe(false);
});
test('offline: documents, ZIP processing and PDF merge work without a network', async ({
page,
context
}) => {
const pageErrors = trackPageErrors(page);
await page.goto(BASE);
await swReady(page);
await waitForPrecache(page);
await context.setOffline(true);
// direct document navigation is answered by the service worker
await page.goto(`${BASE}/tools/bea`);
await expect(page.getByRole('heading', { name: 'beA-Edit' })).toBeVisible();
// the whole processing chain runs offline, including pdfjs thumbnails
const zip = await createZip('offline-archiv', 2);
await dropFiles(page, zipDropzone(page), [
{ name: 'offline-archiv.zip', mimeType: 'application/zip', buffer: zip }
]);
await waitForLoadedWorkspace(page, /1 Datei · 1 Archiv geöffnet/, { timeout: 45_000 });
// a second tool route, still offline
await page.goto(`${BASE}/tools/merge`);
await expect(page.getByRole('button', { name: 'PDF-Dateien hinzufügen' })).toBeVisible();
await dropFiles(page, pdfDropzone(page), [
{ name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Offline-Eins') },
{ name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Offline-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$/);
// legal routes and the start page reload
await page.goto(`${BASE}/datenschutz`);
await expect(page.locator('h1, h2').first()).toBeVisible();
await page.goto(BASE);
await expect(page.getByRole('banner')).toBeVisible();
expect(pageErrors).toEqual([]);
});
test('update: toast defers without interrupting, reload activates the new version', async ({
page
}) => {
await page.goto(BASE);
await swReady(page);
await waitForPrecache(page);
expect(await precachedPaths(page)).not.toContain(PROBE_URL);
// deploy: new build + new server process
writeFileSync(
PROBE,
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><desc>probe</desc></svg>'
);
await rebuild('with probe');
await startServer();
const toast = page.getByText('Neue Version von beA-Edit verfügbar.');
await page.reload();
await expect(toast).toBeVisible({ timeout: 30_000 });
// "Später" must not reload the page: in-progress state survives
await page.evaluate(() => {
(window as unknown as { __pwaMarker?: string }).__pwaMarker = 'intakt';
});
await page.getByRole('button', { name: 'Später' }).click();
await expect(toast).toBeHidden();
expect(
await page.evaluate(() => (window as unknown as { __pwaMarker?: string }).__pwaMarker)
).toBe('intakt');
// the offer returns on the next visit
await page.reload();
await expect(toast).toBeVisible({ timeout: 30_000 });
// "Neu laden" activates the waiting worker and reloads the page
await page.evaluate(() => {
(window as unknown as { __pwaMarker?: string }).__pwaMarker = 'verloren';
});
await page.getByRole('button', { name: 'Neu laden' }).click();
await expect
.poll(
async () => {
try {
return await page.evaluate(
() => (window as unknown as { __pwaMarker?: string }).__pwaMarker
);
} catch {
// the activation reload destroys the execution context: exactly the expected outcome
return undefined;
}
},
{ timeout: 30_000 }
)
.toBeUndefined();
await page.waitForLoadState('load');
await expect(swReady(page)).resolves.toBe(`${BASE}/sw.js`);
await waitForPrecache(page, 50);
await expect
.poll(async () => (await precachedPaths(page))?.includes(PROBE_URL), {
timeout: 30_000
})
.toBe(true);
rmSync(PROBE, { force: true });
await rebuild('cleanup');
await startServer();
});
+72
View File
@@ -0,0 +1,72 @@
import { expect, test } from '@playwright/test';
import { PDFDocument } from 'pdf-lib';
import {
createPdf,
createPreRotatedPdf,
dropFiles,
expectPdfDownload,
pdfDropzone,
trackPageErrors
} from './helpers';
test('rotate turns pages losslessly via /Rotate and reset clears deltas', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/rotate');
await dropFiles(page, pdfDropzone(page), [
{ name: 'drehung.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Drehen') }
]);
const downloadButton = page.getByRole('button', { name: 'Drehen & herunterladen' });
await expect(page.getByRole('button', { name: 'Seite 2 um 90° nach rechts drehen' })).toBeVisible(
{
timeout: 15_000
}
);
await expect(downloadButton).toBeDisabled();
const rotateRightPage2 = page.getByRole('button', { name: 'Seite 2 um 90° nach rechts drehen' });
await rotateRightPage2.click();
await rotateRightPage2.click();
await expect(page.getByText('1 von 3 Seiten gedreht')).toBeVisible();
await expect(page.getByText('180°', { exact: true })).toBeVisible();
const downloadPromise = page.waitForEvent('download');
await downloadButton.click();
const bytes = await expectPdfDownload(await downloadPromise, /^drehung_gedreht\.pdf$/);
const output = await PDFDocument.load(bytes);
expect(output.getPage(1).getRotation().angle).toBe(180);
expect(output.getPage(0).getRotation().angle).toBe(0);
await page.getByRole('button', { name: 'Zurücksetzen' }).click();
await expect(page.getByText('0 von 3 Seiten gedreht')).toBeVisible();
await expect(downloadButton).toBeDisabled();
expect(pageErrors).toEqual([]);
});
test('rotate adds the delta on top of an existing /Rotate value', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/rotate');
await dropFiles(page, pdfDropzone(page), [
{
name: 'vorgedreht.pdf',
mimeType: 'application/pdf',
buffer: await createPreRotatedPdf(2, 'Vorgedreht')
}
]);
await expect(page.getByRole('button', { name: 'Seite 1 um 90° nach rechts drehen' })).toBeVisible(
{
timeout: 15_000
}
);
await page.getByRole('button', { name: 'Seite 1 um 90° nach rechts drehen' }).click();
await expect(page.getByText('1 von 2 Seiten gedreht')).toBeVisible();
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Drehen & herunterladen' }).click();
const bytes = await expectPdfDownload(await downloadPromise, /^vorgedreht_gedreht\.pdf$/);
const output = await PDFDocument.load(bytes);
expect(output.getPage(0).getRotation().angle).toBe(180);
expect(output.getPage(1).getRotation().angle).toBe(0);
expect(pageErrors).toEqual([]);
});
+17
View File
@@ -0,0 +1,17 @@
import { expect, test } from '@playwright/test';
import { createPdf, dropFiles, expectPdfDownload, pdfDropzone, trackPageErrors } from './helpers';
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([]);
});
+137
View File
@@ -0,0 +1,137 @@
import { expect, test } from '@playwright/test';
import { PDFDocument } from 'pdf-lib';
import {
createEncryptedPdf,
createPdf,
createStampPng,
dropFiles,
expectPdfDownload,
getPageContentStreamCount,
getPageContentText,
pdfDropzone,
trackPageErrors
} from './helpers';
test('stamp presets fill the editable text and EILT is applied to every page', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/stamp');
await dropFiles(page, pdfDropzone(page), [
{ name: 'akte.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Akte') }
]);
const stampInput = page.locator('#stamp-text');
for (const preset of ['BEGLAUBIGT', 'AUSFERTIGUNG', 'KOPIE', 'EILT', 'ENTWURF', 'ERLEDIGT']) {
await page.getByRole('button', { name: preset, exact: true }).click();
await expect(stampInput).toHaveValue(preset);
}
await page.getByRole('button', { name: 'EILT', exact: true }).click();
await page.locator('#stamp-rotation').fill('20');
await expect(page.getByTestId('stamp-preview')).toContainText('EILT');
await expect(page.getByTestId('stamp-preview')).not.toHaveCSS('transform', 'none');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
const outputBytes = await expectPdfDownload(await downloadPromise, /^akte_stempel\.pdf$/);
const output = await PDFDocument.load(outputBytes);
expect(output.getPageCount()).toBe(3);
for (let pageIndex = 0; pageIndex < output.getPageCount(); pageIndex += 1) {
expect(getPageContentStreamCount(output, pageIndex)).toBeGreaterThan(1);
}
expect(pageErrors).toEqual([]);
});
test('stamp custom text adds a German date and only changes selected pages', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const inputBytes = await createPdf(3, 'Auswahl');
const input = await PDFDocument.load(inputBytes);
const originalStreamCounts = input
.getPages()
.map((_, pageIndex) => getPageContentStreamCount(input, pageIndex));
await page.goto('/tools/stamp');
await dropFiles(page, pdfDropzone(page), [
{ name: 'auswahl.pdf', mimeType: 'application/pdf', buffer: inputBytes }
]);
await page.locator('#stamp-text').fill('nur hier');
await page.getByRole('checkbox', { name: 'Datum anhängen' }).check();
await expect(page.getByTestId('stamp-preview')).toHaveText(/NUR HIER \d{2}\.\d{2}\.\d{4}/);
await page.getByRole('checkbox', { name: 'Auf alle Seiten anwenden' }).uncheck();
await page.getByRole('button', { name: /Seite 2/ }).click();
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
const outputBytes = await expectPdfDownload(await downloadPromise, /^auswahl_stempel\.pdf$/);
const output = await PDFDocument.load(outputBytes);
expect(output.getPageCount()).toBe(3);
expect(getPageContentStreamCount(output, 0)).toBe(originalStreamCounts[0]);
expect(getPageContentStreamCount(output, 1)).toBeGreaterThan(originalStreamCounts[1]);
expect(getPageContentStreamCount(output, 2)).toBe(originalStreamCounts[2]);
expect(pageErrors).toEqual([]);
});
test('stamp uses an image instead of text and makes the border optional', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/stamp');
await dropFiles(page, pdfDropzone(page), [
{ name: 'bildakte.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Bild') }
]);
await page.locator('#stamp-image').setInputFiles({
name: 'stempel.png',
mimeType: 'image/png',
buffer: Buffer.from(await createStampPng(page), 'base64')
});
await expect(page.getByText('stempel.png')).toBeVisible();
await expect(page.getByTestId('stamp-preview').locator('img')).toBeVisible();
await expect(page.locator('#stamp-text')).toHaveCount(0);
await expect(page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' })).toBeVisible();
await expect(
page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' })
).not.toBeChecked();
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
const outputBytes = await expectPdfDownload(await downloadPromise, /^bildakte_stempel\.pdf$/);
const output = await PDFDocument.load(outputBytes);
expect(output.getPageCount()).toBe(2);
// Image without frame: the image XObject is drawn, no stroked rectangle is present.
for (const pageIndex of [0, 1]) {
const content = getPageContentText(output, pageIndex);
expect(content).toContain(' Do\n');
expect(content).not.toContain(' RG');
}
await page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' }).check();
await expect(page.getByTestId('stamp-preview')).toHaveCSS('border-style', 'solid');
const borderedDownloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
const borderedBytes = await expectPdfDownload(
await borderedDownloadPromise,
/^bildakte_stempel\.pdf$/
);
const bordered = await PDFDocument.load(borderedBytes);
// The optional frame adds a stroked rectangle around the drawn image.
for (const pageIndex of [0, 1]) {
const content = getPageContentText(bordered, pageIndex);
expect(content).toContain(' Do\n');
expect(content).toContain(' RG');
expect(content).toMatch(/\bh\nS\n/);
}
expect(pageErrors).toEqual([]);
});
test('stamp explains how to handle a password-protected PDF', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/stamp');
await dropFiles(page, pdfDropzone(page), [
{
name: 'geschuetzt.pdf',
mimeType: 'application/pdf',
buffer: await createEncryptedPdf(1, 'Geschützt', 'test-passwort')
}
]);
await expect(page.getByRole('alert')).toContainText('passwortgeschützt');
await expect(page.getByRole('alert')).toContainText('Passwort entfernen');
expect(pageErrors).toEqual([]);
});
+38
View File
@@ -0,0 +1,38 @@
import { expect, test } from '@playwright/test';
import { zipSync } from 'fflate';
import { createPdf, dropFiles, trackPageErrors } from './helpers';
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: 'Werkzeugkasten für beA' })).toBeVisible();
await expect(page.getByRole('link', { name: /Stempeln/ })).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('start page forwards a dropped PDF to beA', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const pdf = await createPdf(2, 'Antrag');
await page.goto('/');
await dropFiles(page, 'window', [
{ name: 'antrag.pdf', mimeType: 'application/pdf', buffer: pdf }
]);
await expect(page).toHaveURL(/\/tools\/bea$/);
await expect(page.getByText('1 lose Datei')).toBeVisible({ timeout: 15_000 });
expect(pageErrors).toEqual([]);
});
-987
View File
@@ -1,987 +0,0 @@
import { expect, test, type Download, type Locator, type Page } from '@playwright/test';
import {
decodePDFRawStream,
degrees,
PDFArray,
PDFDocument,
PDFRawStream,
StandardFonts,
rgb
} from 'pdf-lib';
import { PDFDocument as EncryptablePdfDocument } from '@cantoo/pdf-lib';
import { unzipSync, 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 createStampPng = (page: Page) =>
page.evaluate(
async () =>
new Promise<string>((resolve, reject) => {
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 32;
const context = canvas.getContext('2d');
if (!context) {
reject(new Error('Kein 2D-Kontext'));
return;
}
context.fillStyle = '#cc1a1a';
context.fillRect(0, 0, 32, 32);
canvas.toBlob(async (blob) => {
if (!blob) {
reject(new Error('PNG konnte nicht erzeugt werden'));
return;
}
const bytes = new Uint8Array(await blob.arrayBuffer());
let binary = '';
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
resolve(btoa(binary));
}, 'image/png');
})
);
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 getPageContentStreamCount = (document: PDFDocument, pageIndex: number) => {
const contents = document.getPage(pageIndex).node.Contents();
if (!contents) return 0;
return contents instanceof PDFArray ? contents.size() : 1;
};
const getPageContentText = (document: PDFDocument, pageIndex: number) => {
const contents = document.getPage(pageIndex).node.Contents();
const refs = contents instanceof PDFArray ? contents.asArray() : contents ? [contents] : [];
return refs
.map((ref) => document.context.lookup(ref))
.filter((stream): stream is PDFRawStream => stream instanceof PDFRawStream)
.map((stream) => Buffer.from(decodePDFRawStream(stream).decode()).toString('latin1'))
.join('\n');
};
const createEncryptedPdf = async (pageCount: number, label: string, password: string) => {
const document = await EncryptablePdfDocument.load(await createPdf(pageCount, label));
document.encrypt({
userPassword: password,
ownerPassword: 'owner-passwort',
permissions: { printing: true, copying: true }
});
return Buffer.from(await document.save());
};
const createPreRotatedPdf = 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)
});
if (pageNumber === 1) page.setRotation(degrees(90));
}
return Buffer.from(await document.save());
};
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' });
const zipDropzone = (page: Page) =>
page.getByRole('button', { name: 'beA-ZIP-Dateien hinzufügen' });
const createZip = async (label: string, pageCount = 1) =>
Buffer.from(zipSync({ [`${label}.pdf`]: await createPdf(pageCount, label) }));
const newZipButton = (page: Page) => page.getByRole('button', { name: 'Neue ZIP bearbeiten' });
const settingsButton = (page: Page) =>
page.getByRole('button', { name: 'Einstellungen', exact: true });
const replaceZipInput = (page: Page) => page.locator('input[data-zip-picker="replace-zip"]');
const appendZipInput = (page: Page) => page.locator('input[data-zip-picker="append-zip"]');
const workspaceStatus = (page: Page, pattern: RegExp) => page.getByText(pattern);
const waitForLoadedWorkspace = async (
page: Page,
status: RegExp,
options?: { timeout?: number }
) => {
await expect(newZipButton(page)).toBeVisible();
await expect(workspaceStatus(page, status)).toBeVisible({ timeout: options?.timeout ?? 15_000 });
};
const replaceThroughDialog = async (page: Page, zip: Buffer, confirm: boolean) => {
await newZipButton(page).click();
await replaceZipInput(page).setInputFiles([
{ name: 'archiv-b.zip', mimeType: 'application/zip', buffer: zip }
]);
await expect(page.getByText('Aktuelle Bearbeitung ersetzen?')).toBeVisible();
if (confirm) {
await page.getByRole('button', { name: 'Neue ZIP öffnen' }).click();
} else {
await page.getByRole('button', { name: 'Abbrechen' }).click();
}
await expect(page.getByText('Aktuelle Bearbeitung ersetzen?')).toBeHidden();
};
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: 'Werkzeugkasten für beA' })).toBeVisible();
await expect(page.getByRole('link', { name: /Stempeln/ })).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('beA reports an invalid ZIP and lets the user remove it', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{
name: 'defektes-archiv.zip',
mimeType: 'application/zip',
buffer: Buffer.from('kein gueltiges ZIP-Archiv')
}
]);
await expect(page.getByText('defektes-archiv.zip', { exact: true })).toBeVisible();
await expect(page.getByText('Konnte nicht geöffnet werden.')).toBeVisible();
await expect(page.getByRole('status')).toContainText('1 fehlgeschlagen');
await expect(page.getByRole('button', { name: 'Erneut versuchen' })).toBeVisible();
await page.getByRole('button', { name: 'Entfernen' }).click();
await expect(zipDropzone(page)).toBeVisible();
expect(pageErrors).toEqual([]);
});
test('short tool pages keep the footer flush with the viewport bottom', async ({ page }) => {
const expectFooterAtViewportBottom = async (viewport: { width: number; height: number }) => {
await page.setViewportSize(viewport);
await page.goto('/tools/watermark');
const footerBottom = await page.locator('footer').evaluate((footer) => {
return footer.getBoundingClientRect().bottom;
});
expect(Math.abs(footerBottom - viewport.height)).toBeLessThanOrEqual(1);
};
await expectFooterAtViewportBottom({ width: 1440, height: 900 });
await expectFooterAtViewportBottom({ width: 390, height: 844 });
});
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('rotate turns pages losslessly via /Rotate and reset clears deltas', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/rotate');
await dropFiles(page, pdfDropzone(page), [
{ name: 'drehung.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Drehen') }
]);
const downloadButton = page.getByRole('button', { name: 'Drehen & herunterladen' });
await expect(page.getByRole('button', { name: 'Seite 2 um 90° nach rechts drehen' })).toBeVisible(
{
timeout: 15_000
}
);
await expect(downloadButton).toBeDisabled();
const rotateRightPage2 = page.getByRole('button', { name: 'Seite 2 um 90° nach rechts drehen' });
await rotateRightPage2.click();
await rotateRightPage2.click();
await expect(page.getByText('1 von 3 Seiten gedreht')).toBeVisible();
await expect(page.getByText('180°', { exact: true })).toBeVisible();
const downloadPromise = page.waitForEvent('download');
await downloadButton.click();
const bytes = await expectPdfDownload(await downloadPromise, /^drehung_gedreht\.pdf$/);
const output = await PDFDocument.load(bytes);
expect(output.getPage(1).getRotation().angle).toBe(180);
expect(output.getPage(0).getRotation().angle).toBe(0);
await page.getByRole('button', { name: 'Zurücksetzen' }).click();
await expect(page.getByText('0 von 3 Seiten gedreht')).toBeVisible();
await expect(downloadButton).toBeDisabled();
expect(pageErrors).toEqual([]);
});
test('rotate adds the delta on top of an existing /Rotate value', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/rotate');
await dropFiles(page, pdfDropzone(page), [
{
name: 'vorgedreht.pdf',
mimeType: 'application/pdf',
buffer: await createPreRotatedPdf(2, 'Vorgedreht')
}
]);
await expect(page.getByRole('button', { name: 'Seite 1 um 90° nach rechts drehen' })).toBeVisible(
{
timeout: 15_000
}
);
await page.getByRole('button', { name: 'Seite 1 um 90° nach rechts drehen' }).click();
await expect(page.getByText('1 von 2 Seiten gedreht')).toBeVisible();
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Drehen & herunterladen' }).click();
const bytes = await expectPdfDownload(await downloadPromise, /^vorgedreht_gedreht\.pdf$/);
const output = await PDFDocument.load(bytes);
expect(output.getPage(0).getRotation().angle).toBe(180);
expect(output.getPage(1).getRotation().angle).toBe(0);
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();
await page.locator('#watermark-color').fill('#00ff00');
await page.locator('#watermark-opacity').fill('0.45');
await page.getByRole('button', { name: 'Wasserzeichen anwenden', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 15_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'marke.pdf 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'
)
}
]);
// The file is already done, so reset it to pending by removing and re-adding it.
await page.getByRole('button', { name: 'marke.pdf entfernen' }).click();
await dropFiles(page, pdfDropzone(page), [
{ name: 'marke.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Marke') }
]);
await page.getByRole('button', { name: 'Wasserzeichen anwenden', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 15_000 });
const imageDownloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'marke.pdf herunterladen' }).click();
await expectPdfDownload(await imageDownloadPromise, /^marke_wasserzeichen\.pdf$/);
expect(pageErrors).toEqual([]);
});
test('stamp presets fill the editable text and EILT is applied to every page', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/stamp');
await dropFiles(page, pdfDropzone(page), [
{ name: 'akte.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Akte') }
]);
const stampInput = page.locator('#stamp-text');
for (const preset of ['BEGLAUBIGT', 'AUSFERTIGUNG', 'KOPIE', 'EILT', 'ENTWURF', 'ERLEDIGT']) {
await page.getByRole('button', { name: preset, exact: true }).click();
await expect(stampInput).toHaveValue(preset);
}
await page.getByRole('button', { name: 'EILT', exact: true }).click();
await page.locator('#stamp-rotation').fill('20');
await expect(page.getByTestId('stamp-preview')).toContainText('EILT');
await expect(page.getByTestId('stamp-preview')).not.toHaveCSS('transform', 'none');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
const outputBytes = await expectPdfDownload(await downloadPromise, /^akte_stempel\.pdf$/);
const output = await PDFDocument.load(outputBytes);
expect(output.getPageCount()).toBe(3);
for (let pageIndex = 0; pageIndex < output.getPageCount(); pageIndex += 1) {
expect(getPageContentStreamCount(output, pageIndex)).toBeGreaterThan(1);
}
expect(pageErrors).toEqual([]);
});
test('stamp custom text adds a German date and only changes selected pages', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const inputBytes = await createPdf(3, 'Auswahl');
const input = await PDFDocument.load(inputBytes);
const originalStreamCounts = input
.getPages()
.map((_, pageIndex) => getPageContentStreamCount(input, pageIndex));
await page.goto('/tools/stamp');
await dropFiles(page, pdfDropzone(page), [
{ name: 'auswahl.pdf', mimeType: 'application/pdf', buffer: inputBytes }
]);
await page.locator('#stamp-text').fill('nur hier');
await page.getByRole('checkbox', { name: 'Datum anhängen' }).check();
await expect(page.getByTestId('stamp-preview')).toHaveText(/NUR HIER \d{2}\.\d{2}\.\d{4}/);
await page.getByRole('checkbox', { name: 'Auf alle Seiten anwenden' }).uncheck();
await page.getByRole('button', { name: /Seite 2/ }).click();
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
const outputBytes = await expectPdfDownload(await downloadPromise, /^auswahl_stempel\.pdf$/);
const output = await PDFDocument.load(outputBytes);
expect(output.getPageCount()).toBe(3);
expect(getPageContentStreamCount(output, 0)).toBe(originalStreamCounts[0]);
expect(getPageContentStreamCount(output, 1)).toBeGreaterThan(originalStreamCounts[1]);
expect(getPageContentStreamCount(output, 2)).toBe(originalStreamCounts[2]);
expect(pageErrors).toEqual([]);
});
test('stamp uses an image instead of text and makes the border optional', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/stamp');
await dropFiles(page, pdfDropzone(page), [
{ name: 'bildakte.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Bild') }
]);
await page.locator('#stamp-image').setInputFiles({
name: 'stempel.png',
mimeType: 'image/png',
buffer: Buffer.from(await createStampPng(page), 'base64')
});
await expect(page.getByText('stempel.png')).toBeVisible();
await expect(page.getByTestId('stamp-preview').locator('img')).toBeVisible();
await expect(page.locator('#stamp-text')).toHaveCount(0);
await expect(page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' })).toBeVisible();
await expect(
page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' })
).not.toBeChecked();
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
const outputBytes = await expectPdfDownload(await downloadPromise, /^bildakte_stempel\.pdf$/);
const output = await PDFDocument.load(outputBytes);
expect(output.getPageCount()).toBe(2);
// Image without frame: the image XObject is drawn, no stroked rectangle is present.
for (const pageIndex of [0, 1]) {
const content = getPageContentText(output, pageIndex);
expect(content).toContain(' Do\n');
expect(content).not.toContain(' RG');
}
await page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' }).check();
await expect(page.getByTestId('stamp-preview')).toHaveCSS('border-style', 'solid');
const borderedDownloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
const borderedBytes = await expectPdfDownload(
await borderedDownloadPromise,
/^bildakte_stempel\.pdf$/
);
const bordered = await PDFDocument.load(borderedBytes);
// The optional frame adds a stroked rectangle around the drawn image.
for (const pageIndex of [0, 1]) {
const content = getPageContentText(bordered, pageIndex);
expect(content).toContain(' Do\n');
expect(content).toContain(' RG');
expect(content).toMatch(/\bh\nS\n/);
}
expect(pageErrors).toEqual([]);
});
test('stamp explains how to handle a password-protected PDF', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/stamp');
await dropFiles(page, pdfDropzone(page), [
{
name: 'geschuetzt.pdf',
mimeType: 'application/pdf',
buffer: await createEncryptedPdf(1, 'Geschützt', 'test-passwort')
}
]);
await expect(page.getByRole('alert')).toContainText('passwortgeschützt');
await expect(page.getByRole('alert')).toContainText('Passwort entfernen');
expect(pageErrors).toEqual([]);
});
test('watermark batch applies to every file and bundles results in one ZIP', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/watermark');
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') },
{
name: 'kaputt.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('kein gueltiges PDF')
}
]);
await expect(page.getByText('Dateien (3)')).toBeVisible();
await page.getByRole('button', { name: 'Wasserzeichen anwenden', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 15_000 });
await expect(page.getByText('Fehler')).toBeVisible();
// With several files the per-file page selection is replaced by all pages.
await expect(page.getByRole('checkbox', { name: 'Auf alle Seiten anwenden' })).toHaveCount(0);
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^wasserzeichen_\d{4}-\d{2}-\d{2}\.zip$/);
const zipPath = await download.path();
expect(zipPath).not.toBeNull();
const entries = Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!))));
expect(entries.sort()).toEqual(['eins_wasserzeichen.pdf', 'zwei_wasserzeichen.pdf']);
expect(pageErrors).toEqual([]);
});
test('encrypt protects a dropped 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');
await page.getByRole('button', { name: 'Passwort setzen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 15_000 });
const encryptedDownloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'geheim.pdf herunterladen' }).click();
const encryptedBytes = await expectPdfDownload(
await encryptedDownloadPromise,
/^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');
await page.getByRole('button', { name: 'Passwort entfernen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
const decryptedDownloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'geheim_geschuetzt.pdf herunterladen' }).click();
await expectPdfDownload(await decryptedDownloadPromise, /^geheim_geschuetzt_entsperrt\.pdf$/);
expect(pageErrors).toEqual([]);
});
test('encrypt batch protects every file and bundles results in one ZIP', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/encrypt');
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('Dateien (2)')).toBeVisible();
await page.locator('#encrypt-password').fill('test-passwort');
await page.locator('#encrypt-password-confirmation').fill('test-passwort');
await page.getByRole('button', { name: 'Passwort setzen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 15_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^geschuetzt_\d{4}-\d{2}-\d{2}\.zip$/);
const zipPath = await download.path();
expect(zipPath).not.toBeNull();
const entries = Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!))));
expect(entries.sort()).toEqual(['eins_geschuetzt.pdf', 'zwei_geschuetzt.pdf']);
expect(pageErrors).toEqual([]);
});
test('decrypt batch unlocks every file and bundles results in one ZIP', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/decrypt');
await dropFiles(page, pdfDropzone(page), [
{
name: 'eins_geschuetzt.pdf',
mimeType: 'application/pdf',
buffer: await createEncryptedPdf(1, 'Eins', 'test-passwort')
},
{
name: 'zwei_geschuetzt.pdf',
mimeType: 'application/pdf',
buffer: await createEncryptedPdf(2, 'Zwei', 'test-passwort')
},
{
name: 'kaputt.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('kein gueltiges PDF')
}
]);
await expect(page.getByText('Dateien (3)')).toBeVisible();
await page.locator('#decrypt-password').fill('test-passwort');
await page.getByRole('button', { name: 'Passwort entfernen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 20_000 });
await expect(page.getByText(/Fehler beim Entsperren/)).toBeVisible();
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^entsperrt_\d{4}-\d{2}-\d{2}\.zip$/);
const zipPath = await download.path();
expect(zipPath).not.toBeNull();
const entries = Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!))));
expect(entries.sort()).toEqual([
'eins_geschuetzt_entsperrt.pdf',
'zwei_geschuetzt_entsperrt.pdf'
]);
expect(pageErrors).toEqual([]);
});
test('compress processes a single dropped file and offers its download per row', 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 expect(page.getByText('Dateien (1)')).toBeVisible();
await page.getByRole('button', { name: 'Komprimieren', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'gross.pdf herunterladen' }).click();
await expectPdfDownload(await downloadPromise, /^gross_komprimiert\.pdf$/);
expect(pageErrors).toEqual([]);
});
test('compress appends further files and bundles all results in one ZIP', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/compress');
await dropFiles(page, pdfDropzone(page), [
{ name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') }
]);
await dropFiles(page, pdfDropzone(page), [
{ name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Zwei') }
]);
await expect(page.getByText('Dateien (2)')).toBeVisible();
await page.getByRole('button', { name: 'Komprimieren', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 30_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^komprimiert_\d{4}-\d{2}-\d{2}\.zip$/);
const zipPath = await download.path();
expect(zipPath).not.toBeNull();
const entries = Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!))));
expect(entries.sort()).toEqual(['eins_komprimiert.pdf', 'zwei_komprimiert.pdf']);
expect(pageErrors).toEqual([]);
});
test('compress batch keeps processing after a failing file and supports retry and removal', async ({
page
}) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/compress');
await dropFiles(page, pdfDropzone(page), [
{ name: 'gut.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Gut') },
{
name: 'kaputt.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('kein gueltiges PDF')
}
]);
await page.getByRole('button', { name: 'Komprimieren', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(1, { timeout: 30_000 });
await expect(page.getByText('Fehler')).toBeVisible();
// Only one result exists, so the bulk ZIP action stays hidden.
await expect(page.getByRole('button', { name: 'Alle herunterladen' })).toHaveCount(0);
// Retrying the broken file fails again without disturbing the finished row.
await page.getByRole('button', { name: 'kaputt.pdf erneut versuchen' }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(1);
await expect(page.getByText('Fehler')).toBeVisible();
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'gut.pdf herunterladen' }).click();
await expectPdfDownload(await downloadPromise, /^gut_komprimiert\.pdf$/);
await page.getByRole('button', { name: 'kaputt.pdf entfernen' }).click();
await expect(page.getByText('kaputt.pdf')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'gut.pdf herunterladen' })).toBeVisible();
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([]);
});
test('cancelling the replacement confirmation keeps the current archive', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const zipA = await createZip('archiv-a');
const zipB = await createZip('archiv-b');
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await replaceThroughDialog(page, zipB, false);
await expect(workspaceStatus(page, /1 ZIP · 1 Archiv geöffnet/)).toBeVisible();
await expect(page.getByText('2 ZIPs')).toHaveCount(0);
expect(pageErrors).toEqual([]);
});
test('confirming the replacement opens only the new ZIP and drops stale results', async ({
page
}) => {
const pageErrors = trackPageErrors(page);
const zipA = await createZip('archiv-a', 3);
const zipB = await createZip('archiv-b');
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
// Replace while the first ZIP may still be extracting; generation guarding
// must ensure no late result from archiv-a reappears.
await replaceThroughDialog(page, zipB, true);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await expect(page.getByText('1 lose Datei')).toHaveCount(1);
expect(pageErrors).toEqual([]);
});
test('cancelling the system file picker leaves the workspace unchanged', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const zipA = await createZip('archiv-a');
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await newZipButton(page).click();
await replaceZipInput(page).setInputFiles([]);
await expect(page.getByText('Aktuelle Bearbeitung ersetzen?')).toHaveCount(0);
await expect(workspaceStatus(page, /1 ZIP · 1 Archiv geöffnet/)).toBeVisible();
expect(pageErrors).toEqual([]);
});
test('the replacement input accepts the same ZIP twice', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const zipA = await createZip('archiv-a');
const zipB = await createZip('archiv-b');
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await replaceThroughDialog(page, zipB, true);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await replaceThroughDialog(page, zipB, true);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await expect(page.getByText('1 lose Datei')).toHaveCount(1);
expect(pageErrors).toEqual([]);
});
test('appending a ZIP keeps both archive editors visible', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const zipA = await createZip('archiv-a');
const zipB = await createZip('archiv-b');
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await settingsButton(page).click();
await appendZipInput(page).setInputFiles([
{ name: 'archiv-b.zip', mimeType: 'application/zip', buffer: zipB }
]);
await waitForLoadedWorkspace(page, /2 ZIPs · 2 Archive geöffnet/);
await expect(page.getByText('1 lose Datei')).toHaveCount(2);
expect(pageErrors).toEqual([]);
});
test('bulk export only appears for two or more processed archives', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const zipA = await createZip('archiv-a');
const zipB = await createZip('archiv-b');
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await settingsButton(page).click();
await expect(
page.getByRole('button', { name: 'Alle Archive als PDF herunterladen' })
).toHaveCount(0);
await appendZipInput(page).setInputFiles([
{ name: 'archiv-b.zip', mimeType: 'application/zip', buffer: zipB }
]);
await waitForLoadedWorkspace(page, /2 ZIPs · 2 Archive geöffnet/);
// The popover stays open, so the new bulk export action appears in place.
const exportButton = page.getByRole('button', { name: 'Alle Archive als PDF herunterladen' });
await expect(exportButton).toBeVisible();
const downloadPromise = page.waitForEvent('download');
await exportButton.click();
await expectPdfDownload(await downloadPromise, /\.pdf$/);
expect(pageErrors).toEqual([]);
});
test('clearing the workspace requires confirmation and returns to the dropzone', async ({
page
}) => {
const pageErrors = trackPageErrors(page);
const zipA = await createZip('archiv-a');
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await settingsButton(page).click();
await page.getByRole('button', { name: 'Aktuelle Bearbeitung leeren' }).click();
await expect(page.getByText('Aktuelle Bearbeitung leeren?')).toBeVisible();
await page.getByRole('button', { name: 'Abbrechen' }).click();
await expect(workspaceStatus(page, /1 ZIP · 1 Archiv geöffnet/)).toBeVisible();
await page.getByRole('button', { name: 'Aktuelle Bearbeitung leeren' }).click();
await page.getByRole('button', { name: 'Bearbeitung leeren', exact: true }).click();
await expect(zipDropzone(page)).toBeVisible();
await expect(newZipButton(page)).toHaveCount(0);
expect(pageErrors).toEqual([]);
});
test('the preview-size slider works with arrow keys and survives a reload', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const zipA = await createZip('archiv-a');
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await expect(page.locator('li[style*="width: 200px"]').first()).toBeVisible();
await settingsButton(page).click();
const slider = page.getByRole('slider', { name: 'Vorschaugröße' });
await expect(page.getByText('200 px')).toBeVisible();
await slider.focus();
await page.keyboard.press('ArrowRight');
await expect(page.getByText('201 px')).toBeVisible();
await expect(page.locator('li[style*="width: 201px"]').first()).toBeVisible();
await page.reload();
await page.locator('[data-dropzone-ready="true"]').first().waitFor();
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await settingsButton(page).click();
await expect(page.getByText('201 px')).toBeVisible();
await expect(await page.evaluate(() => localStorage.getItem('thumbnailWidth'))).toBe('201');
expect(pageErrors).toEqual([]);
});
test('settings closes on Escape and outside click and returns focus to its trigger', async ({
page
}) => {
const pageErrors = trackPageErrors(page);
const zipA = await createZip('archiv-a');
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await expect(settingsButton(page)).toHaveAccessibleName('Einstellungen');
await settingsButton(page).click();
await expect(page.getByText('Vorschaugröße')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByText('Vorschaugröße')).toHaveCount(0);
await expect(settingsButton(page)).toBeFocused();
await settingsButton(page).click();
await expect(page.getByText('Vorschaugröße')).toBeVisible();
await page.getByRole('heading', { name: 'beA-Edit' }).click();
await expect(page.getByText('Vorschaugröße')).toHaveCount(0);
expect(pageErrors).toEqual([]);
});
test('mobile toolbar and settings popover stay inside the viewport', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const zipA = await createZip('archiv-a');
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/tools/bea');
await dropFiles(page, zipDropzone(page), [
{ name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA }
]);
await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/);
await settingsButton(page).click();
const popover = page.locator('[data-slot="popover-content"]');
await expect(popover).toBeVisible();
await expect(page.getByText('Weitere ZIP hinzufügen')).toBeVisible();
const popoverBox = await popover.boundingBox();
expect(popoverBox).not.toBeNull();
expect(popoverBox!.x).toBeGreaterThanOrEqual(0);
expect(popoverBox!.x + popoverBox!.width).toBeLessThanOrEqual(390);
expect(popoverBox!.y).toBeGreaterThanOrEqual(0);
expect(popoverBox!.y + popoverBox!.height).toBeLessThanOrEqual(844);
const popoverMetrics = await popover.evaluate((element) => ({
scrollWidth: element.scrollWidth,
clientWidth: element.clientWidth
}));
expect(popoverMetrics.scrollWidth).toBeLessThanOrEqual(popoverMetrics.clientWidth);
// The toolbar sits in normal flow, so it must not cover the archive header.
const toolbarBox = await workspaceStatus(page, /1 ZIP · 1 Archiv geöffnet/).boundingBox();
const archiveHeaderBox = await page.getByText('1 lose Datei').first().boundingBox();
expect(toolbarBox).not.toBeNull();
expect(archiveHeaderBox).not.toBeNull();
expect(toolbarBox!.y + toolbarBox!.height).toBeLessThanOrEqual(archiveHeaderBox!.y);
expect(pageErrors).toEqual([]);
});
+78
View File
@@ -0,0 +1,78 @@
import { expect, test } from '@playwright/test';
import { unzipSync } from 'fflate';
import { readFile } from 'node:fs/promises';
import { createPdf, dropFiles, expectPdfDownload, pdfDropzone, trackPageErrors } from './helpers';
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();
await page.locator('#watermark-color').fill('#00ff00');
await page.locator('#watermark-opacity').fill('0.45');
await page.getByRole('button', { name: 'Wasserzeichen anwenden', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 15_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'marke.pdf 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'
)
}
]);
// The file is already done, so reset it to pending by removing and re-adding it.
await page.getByRole('button', { name: 'marke.pdf entfernen' }).click();
await dropFiles(page, pdfDropzone(page), [
{ name: 'marke.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Marke') }
]);
await page.getByRole('button', { name: 'Wasserzeichen anwenden', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 15_000 });
const imageDownloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'marke.pdf herunterladen' }).click();
await expectPdfDownload(await imageDownloadPromise, /^marke_wasserzeichen\.pdf$/);
expect(pageErrors).toEqual([]);
});
test('watermark batch applies to every file and bundles results in one ZIP', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/watermark');
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') },
{
name: 'kaputt.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('kein gueltiges PDF')
}
]);
await expect(page.getByText('Dateien (3)')).toBeVisible();
await page.getByRole('button', { name: 'Wasserzeichen anwenden', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 15_000 });
await expect(page.getByText('Fehler')).toBeVisible();
// With several files the per-file page selection is replaced by all pages.
await expect(page.getByRole('checkbox', { name: 'Auf alle Seiten anwenden' })).toHaveCount(0);
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^wasserzeichen_\d{4}-\d{2}-\d{2}\.zip$/);
const zipPath = await download.path();
expect(zipPath).not.toBeNull();
const entries = Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!))));
expect(entries.sort()).toEqual(['eins_wasserzeichen.pdf', 'zwei_wasserzeichen.pdf']);
expect(pageErrors).toEqual([]);
});
+46
View File
@@ -1,5 +1,6 @@
import tailwindcss from '@tailwindcss/vite';
import adapter from '@sveltejs/adapter-node';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
@@ -14,6 +15,51 @@ export default defineConfig({
},
adapter: adapter()
}),
SvelteKitPWA({
registerType: 'prompt',
injectRegister: false,
manifest: {
name: 'beA-Edit',
short_name: 'beA-Edit',
description:
'PDF-Werkzeuge für die beA-Kanzlei – PDFs werden vollständig lokal im Browser verarbeitet.',
display: 'standalone',
orientation: 'any',
lang: 'de',
dir: 'ltr',
start_url: '/',
scope: '/',
categories: ['productivity', 'utilities'],
background_color: '#eef4f6',
theme_color: '#122f62',
icons: [
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
{
src: '/icons/icon-maskable-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable'
}
]
},
workbox: {
// The plugin prepends `client/` and `prerendered/` paths itself; extending the
// default list only adds the local font files and the pdfjs worker chunk (`*.mjs`,
// 2.1 MB, required to render PDFs offline). `client/Nunito/**` is the unused font
// drop from `static/` and would add 2.6 MB to the precache.
globPatterns: ['client/**/*.{js,mjs,css,ico,png,svg,webp,webmanifest,woff2,ttf}'],
globIgnores: ['client/Nunito/**'],
// Prerendered route documents (see src/routes/+layout.ts) are precached, so an
// offline navigation to any visited route — and any unknown path via the
// fallback — is served from the cache.
navigateFallback: '/',
maximumFileSizeToCacheInBytes: 8 * 1024 * 1024
},
// The service worker is only produced for production builds; `pnpm dev` never
// registers one, which keeps development caches clean.
devOptions: { enabled: false }
})
]
});