diff --git a/.gitea/workflows/uat.yml b/.gitea/workflows/uat.yml
index fac4fb8..1c7fe27 100644
--- a/.gitea/workflows/uat.yml
+++ b/.gitea/workflows/uat.yml
@@ -31,7 +31,7 @@ jobs:
run: |
docker push "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:dev"
docker push "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:${{ gitea.sha }}"
-
+
- name: Deploy
run: |
curl https://dokploy.robosoft-solutions.de/api/deploy/compose/Miw8GJXF_v4Ebavr3mJwa
diff --git a/.gitignore b/.gitignore
index 3b462cb..da4b604 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,8 @@ node_modules
.wrangler
/.svelte-kit
/build
+/playwright-report
+/test-results
# OS
.DS_Store
diff --git a/agents.md b/agents.md
index e51cec4..f4cdd2d 100644
--- a/agents.md
+++ b/agents.md
@@ -2,99 +2,214 @@
## Project overview
-This repository contains **juri-merger**, a German-language SvelteKit application whose current UI is branded as **beA-Edit**. The main route lets users select or drag ZIP files in the browser. It currently displays the selected filenames; no server-side upload or archive-processing flow is present in this version.
+This repository contains **juri-merger**, a German-language SvelteKit application branded as
+**beA-Edit**. Users add one or more ZIP archives in the browser, inspect and reorder supported
+attachments, optionally group attachments into named sub-documents, and export merged PDFs.
+Nested ZIP archives and XJustiz metadata are handled client-side; there is no upload, server-side
+archive processing, database, analytics, or persistence of case data.
-The application also contains German legal pages for the imprint and privacy policy.
+The application also contains German imprint and privacy-policy routes.
## Technology stack
-- SvelteKit 2
-- Svelte 5 with runes mode forced for project source files
-- TypeScript with strict mode
+- SvelteKit 2 and Svelte 5, with runes mode forced for project source files
+- TypeScript in strict mode
- Vite 8
- Tailwind CSS 4 through `@tailwindcss/vite`
-- Skeleton 4 with a custom `nominandum` theme
-- pnpm and `pnpm-lock.yaml`
-- `@sveltejs/adapter-auto`
-- Prettier with the Svelte and Tailwind plugins
+- shadcn-svelte 1 with the `nova` style and CSS-variable theme tokens
+- bits-ui, tailwind-variants, clsx, and tailwind-merge
+- Lucide Svelte icons plus project-specific SVG icon components
+- fflate for ZIP extraction and image-export ZIP creation
+- pdf-lib for PDF assembly, `@cantoo/pdf-lib` for encryption, and pdfjs-dist for rendering
+- `@sveltejs/adapter-node`
+- pnpm with a committed lockfile
+- Prettier with Svelte and Tailwind plugins
-Use **pnpm**, not npm or yarn, when installing packages or running project scripts.
+Use **pnpm**, not npm or yarn. The production container uses Node 24 and pnpm 11.15.1.
## Commands
```sh
pnpm install # Install dependencies
pnpm dev # Start the development server
-pnpm build # Create a production build
+pnpm build # Create the adapter-node production build
pnpm preview # Preview the production build
pnpm check # Run Svelte and TypeScript diagnostics
pnpm check:watch # Run diagnostics in watch mode
-pnpm lint # Check formatting with Prettier
+pnpm test:e2e # Run the Playwright toolbox tests in Chromium
+pnpm lint # Check repository formatting with Prettier
pnpm format # Format the repository with Prettier
```
-There is currently no automated unit or end-to-end test suite. For code changes, run at least:
+For code changes, run at least:
```sh
pnpm check
+pnpm test:e2e
pnpm lint
pnpm build
```
-If pre-existing warnings remain outside the files being changed, report them rather than making unrelated edits.
+For documentation-only changes, a targeted Prettier check is sufficient. If pre-existing warnings
+or formatting failures remain outside changed files, report them instead of making unrelated edits.
+
+The Docker image can be built from the repository root. It installs with `--frozen-lockfile`, builds
+and prunes dependencies, then runs `node build/index.js` as a non-root user on port 3000.
## Repository structure
```text
src/
-├── app.html # German document shell and Skeleton theme selection
-├── app.d.ts # SvelteKit application type declarations
+├── app.html # German HTML shell
+├── app.d.ts # SvelteKit application declarations
├── lib/
-│ ├── assets/ # Bundled favicon and Nunito font files
-│ ├── components/ # Reusable Svelte components
-│ │ ├── Button.svelte
-│ │ ├── Card.svelte
-│ │ └── ZipDropzone.svelte
-│ ├── index.ts # Public `$lib` exports
-│ └── nom-theme.css # Custom Skeleton theme tokens
+│ ├── actions/ripple.ts # Button ripple Svelte action
+│ ├── assets/ # Favicon and local Nunito fonts
+│ ├── components/
+│ │ ├── AttachmentPreview.svelte # Browser-rendered PDF/image thumbnails
+│ │ ├── ProcessedZipArchiveEditor.svelte
+│ │ ├── SubDocumentEditor.svelte
+│ │ ├── PdfDropzone.svelte # Shared PDF/image/ZIP file picker and drop target
+│ │ ├── PdfPageGrid.svelte # Accessible PDF thumbnail selection grid
+│ │ ├── ZipDropzone.svelte # beA-specific wrapper around PdfDropzone
+│ │ ├── icons/ # Project-specific SVG components
+│ │ └── ui/ # Local shadcn-svelte component source
+│ ├── services/
+│ │ ├── xml-reading.service.ts # Namespace-tolerant XJustiz parsing
+│ │ └── zip-inflating.service.ts # Async fflate wrapper
+│ ├── pdf-processing.ts # Reusable PDF merge helper
+│ ├── pdf-thumbnails.ts # Managed pdfjs thumbnail loading and cleanup
+│ ├── utils.ts # cn() and shared component utility types
+│ └── zip-processing.ts # Archive model, transforms, and PDF export
└── routes/
- ├── +layout.svelte # Shared logo header and branded footer
- ├── +page.svelte # Main ZIP-selection page
- ├── layout.css # Tailwind/Skeleton imports and global font setup
- ├── datenschutz/+page.svelte # Privacy policy
- └── impressum/+page.svelte # Imprint
-static/ # Files served unchanged from the site root
+ ├── +layout.svelte # Shared logo header and branded footer
+ ├── +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,watermark,encrypt,decrypt,compress,convert}/
+ ├── datenschutz/+page.svelte # Privacy policy
+ └── impressum/+page.svelte # Imprint
+static/ # Public logo, footer art, and robots.txt
+components.json # shadcn-svelte registry configuration
+Dockerfile # Node adapter production image
+plan/ # Design and implementation notes
```
-Generated directories such as `.svelte-kit/`, `build/`, and `node_modules/` must not be edited or committed.
+Generated directories such as `.svelte-kit/`, `build/`, and `node_modules/` must not be edited or
+committed.
+
+## Application flow and state
+
+- `/` is the toolbox hub. A ZIP dropped anywhere on that route is queued in memory and forwarded to
+ `/tools/bea`; the file is not uploaded or persisted.
+- `tools/bea/+page.svelte` owns selected files, processed archives, pending work, and the
+ thumbnail-width preference. Thumbnail width is the only persisted UI setting and uses localStorage.
+- ZIP files are processed concurrently. `archiveGeneration` prevents results from an old batch from
+ reappearing after the user deletes all archives.
+- `ProcessedZipArchiveEditor.svelte` edits one archive and emits immutable replacements through
+ `onArchiveChange`; the route-level archive array remains the source of truth.
+- Attachments may remain loose or belong to exactly one sub-document. Drag-and-drop supports
+ reordering and moving attachments between lists, while sub-document headers can be reordered.
+- Selection mode creates sub-documents from loose attachments. Exiting with **Fertig** or Escape
+ clears the pending selection.
+- Per-archive export supports one flattened PDF or separate PDFs for sub-documents. Each
+ sub-document also has its own direct download action that exports only that sub-document's
+ attachments. Bulk export from the page always creates one flattened PDF per archive.
+
+When changing archive transforms, preserve these invariants:
+
+- An attachment move must not duplicate or discard data.
+- Sub-document IDs remain stable across edits and reordering.
+- Single-PDF order is all sub-documents in order, each attachment in order, followed by loose
+ attachments in order.
+- Dissolving a sub-document appends its attachments to the loose list.
+- State helpers in `zip-processing.ts` should return new archive/list objects rather than mutating
+ component-owned state.
+
+## ZIP, XML, and PDF processing
+
+All case-file processing is intentionally local to the browser.
+
+- `extractZipArchives` recursively inflates nested ZIP files.
+- Ignore macOS metadata (`__MACOSX`, `.DS_Store`, and `._*`) and unsupported entries.
+- `xjustiz_nachricht.xml` is metadata, not an export attachment. Its sender, receiver, and declared
+ document names determine the generated PDF name and preferred attachment order.
+- XJustiz element matching must remain namespace tolerant. Malformed or absent metadata falls back
+ safely instead of preventing extraction.
+- Supported attachments are PDFs and the image extensions listed in `zip-processing.ts`. Update the
+ extension classification and downstream preview/export behavior together when adding formats.
+- PDF attachments are copied with pdf-lib. JPEG and PNG files can be embedded directly; other image
+ formats are decoded through browser image/canvas APIs and converted to PNG first.
+- `AttachmentPreview.svelte` dynamically loads pdfjs-dist in `onMount`, copies PDF bytes before
+ rendering, and revokes object URLs during cleanup. Preserve those memory and data-integrity
+ safeguards.
+- Empty archives/export groups should continue to fail explicitly rather than creating invalid PDFs.
+
+Browser-only APIs used by this flow include `File`, `DOMParser`, `Image`, canvas, `Blob`, `URL`,
+localStorage, and `crypto.randomUUID()`. Do not invoke browser-only work during SSR or at module
+initialization. Keep it in event handlers, `onMount`, effects guarded by hydration, or functions only
+called in the browser.
## Svelte conventions
-- Use Svelte 5 runes and current event syntax. Prefer `$props()`, `$state`, `$derived`, snippets, `{@render ...}`, and handlers such as `onclick` over legacy APIs.
+- Use Svelte 5 runes and current event syntax: `$props()`, `$state`, `$derived`, `$effect`, snippets,
+ `{@render ...}`, and handlers such as `onclick` rather than legacy APIs.
- Keep component scripts typed with `
+
+
+
+
+
+{#if rejectionMessage}
+
+ {isRasterizationTooLarge(file)
+ ? 'Diese Datei ist größer als 100 MB und kann aus Speichergründen nicht im Browser gerastert werden.'
+ : 'Diese große Datei benötigt beim Rasterisieren viel Arbeitsspeicher. Schließen Sie andere speicherintensive Tabs.'}
+
+
+{/if}
diff --git a/src/lib/components/SubDocumentEditor.svelte b/src/lib/components/SubDocumentEditor.svelte
index 81164fe..8d673e7 100644
--- a/src/lib/components/SubDocumentEditor.svelte
+++ b/src/lib/components/SubDocumentEditor.svelte
@@ -1,5 +1,6 @@
-
-
-
diff --git a/src/lib/download.ts b/src/lib/download.ts
new file mode 100644
index 0000000..f5770dd
--- /dev/null
+++ b/src/lib/download.ts
@@ -0,0 +1,25 @@
+export const downloadBlob = (
+ bytes: Uint8Array,
+ fileName: string,
+ mimeType: string = 'application/pdf'
+) => {
+ const buffer = new Uint8Array(bytes.byteLength);
+ buffer.set(bytes);
+ const blob = new Blob([buffer.buffer as ArrayBuffer], { type: mimeType });
+ const objectUrl = URL.createObjectURL(blob);
+ const downloadLink = document.createElement('a');
+
+ downloadLink.href = objectUrl;
+ downloadLink.download = fileName;
+ downloadLink.rel = 'noopener';
+ downloadLink.click();
+ window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
+};
+
+export const downloadBlobUrl = (blobUrl: string, fileName: string) => {
+ const downloadLink = document.createElement('a');
+ downloadLink.href = blobUrl;
+ downloadLink.download = fileName;
+ downloadLink.rel = 'noopener';
+ downloadLink.click();
+};
diff --git a/src/lib/pdf-processing.ts b/src/lib/pdf-processing.ts
new file mode 100644
index 0000000..c8faf75
--- /dev/null
+++ b/src/lib/pdf-processing.ts
@@ -0,0 +1,17 @@
+import { PDFDocument } from 'pdf-lib';
+
+export const mergePdfFiles = async (files: File[]): Promise => {
+ if (files.length < 2) throw new Error('At least two PDF files are required.');
+
+ const mergedDocument = await PDFDocument.create();
+ for (const file of files) {
+ const sourceDocument = await PDFDocument.load(new Uint8Array(await file.arrayBuffer()));
+ const copiedPages = await mergedDocument.copyPages(
+ sourceDocument,
+ sourceDocument.getPageIndices()
+ );
+ for (const page of copiedPages) mergedDocument.addPage(page);
+ }
+
+ return mergedDocument.save();
+};
diff --git a/src/lib/pdf-thumbnails.ts b/src/lib/pdf-thumbnails.ts
new file mode 100644
index 0000000..b020f79
--- /dev/null
+++ b/src/lib/pdf-thumbnails.ts
@@ -0,0 +1,71 @@
+export type PdfThumbnail = {
+ pageNumber: number;
+ imageUrl: string;
+ width: number;
+ height: number;
+};
+
+export type PdfDocumentHandle = {
+ pageCount: number;
+ thumbnails: PdfThumbnail[];
+ destroy: () => void;
+};
+
+export async function loadPdfDocument(data: Uint8Array): Promise {
+ const pdfjsLib = await import('pdfjs-dist');
+ const { getDocument, GlobalWorkerOptions } = pdfjsLib;
+
+ GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
+ (module) => module.default
+ );
+
+ const loadingTask = getDocument({ data: new Uint8Array(data) });
+ const thumbnails: PdfThumbnail[] = [];
+
+ try {
+ const pdfDocument = await loadingTask.promise;
+
+ for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) {
+ const page = await pdfDocument.getPage(pageNumber);
+ const viewport = page.getViewport({ scale: 0.5 });
+ const canvas = document.createElement('canvas');
+ canvas.width = Math.ceil(viewport.width);
+ canvas.height = Math.ceil(viewport.height);
+ const canvasContext = canvas.getContext('2d');
+
+ if (!canvasContext) throw new Error('Could not create thumbnail canvas context.');
+
+ await page.render({ canvas, canvasContext, viewport }).promise;
+ const blob = await new Promise((resolve, reject) => {
+ canvas.toBlob(
+ (result) =>
+ result ? resolve(result) : reject(new Error('Could not encode PDF thumbnail.')),
+ 'image/png'
+ );
+ });
+
+ thumbnails.push({
+ pageNumber,
+ imageUrl: URL.createObjectURL(blob),
+ width: viewport.width,
+ height: viewport.height
+ });
+ }
+
+ let destroyed = false;
+ return {
+ pageCount: pdfDocument.numPages,
+ thumbnails,
+ destroy: () => {
+ if (destroyed) return;
+ destroyed = true;
+ for (const thumbnail of thumbnails) URL.revokeObjectURL(thumbnail.imageUrl);
+ void loadingTask.destroy();
+ }
+ };
+ } catch (error) {
+ for (const thumbnail of thumbnails) URL.revokeObjectURL(thumbnail.imageUrl);
+ await loadingTask.destroy();
+ throw error;
+ }
+}
diff --git a/src/lib/pending-files.ts b/src/lib/pending-files.ts
new file mode 100644
index 0000000..6d8c0c0
--- /dev/null
+++ b/src/lib/pending-files.ts
@@ -0,0 +1,11 @@
+let pendingZipFiles: File[] = [];
+
+export const queueZipFiles = (files: File[]) => {
+ pendingZipFiles = [...pendingZipFiles, ...files];
+};
+
+export const takeQueuedZipFiles = () => {
+ const files = pendingZipFiles;
+ pendingZipFiles = [];
+ return files;
+};
diff --git a/src/lib/rasterization-limits.ts b/src/lib/rasterization-limits.ts
new file mode 100644
index 0000000..cedc7f1
--- /dev/null
+++ b/src/lib/rasterization-limits.ts
@@ -0,0 +1,8 @@
+export const RASTERIZATION_WARNING_BYTES = 25 * 1024 * 1024;
+export const RASTERIZATION_MAX_BYTES = 100 * 1024 * 1024;
+
+export const isRasterizationTooLarge = (file: File | null) =>
+ file !== null && file.size > RASTERIZATION_MAX_BYTES;
+
+export const shouldWarnAboutRasterization = (file: File | null) =>
+ file !== null && file.size > RASTERIZATION_WARNING_BYTES;
diff --git a/src/lib/tools.ts b/src/lib/tools.ts
new file mode 100644
index 0000000..287d51f
--- /dev/null
+++ b/src/lib/tools.ts
@@ -0,0 +1,51 @@
+export type Tool = {
+ slug: string;
+ title: string;
+ description: string;
+ icon: string;
+};
+
+export const tools: Tool[] = [
+ {
+ slug: 'watermark',
+ title: 'Wasserzeichen',
+ description: 'Fügen Sie ein Wasserzeichen zu Ihrem PDF hinzu.',
+ icon: 'droplets'
+ },
+ {
+ slug: 'encrypt',
+ title: 'Passwort setzen',
+ description: 'Verschlüsseln Sie Ihr PDF mit einem Passwort.',
+ icon: 'lock'
+ },
+ {
+ slug: 'decrypt',
+ title: 'Passwort entfernen',
+ description: 'Entsperren Sie ein passwortgeschütztes PDF.',
+ icon: 'unlock'
+ },
+ {
+ slug: 'convert',
+ title: 'Konvertieren',
+ description: 'Konvertieren Sie ein PDF in Bilder.',
+ icon: 'image'
+ },
+ {
+ slug: 'compress',
+ title: 'Komprimieren',
+ description: 'Komprimieren Sie ein PDF für kleinere Dateigröße.',
+ icon: 'minimize'
+ },
+ {
+ slug: 'separate',
+ title: 'Seiten entfernen',
+ description: 'Entfernen Sie Seiten aus einem PDF.',
+ icon: 'scissors'
+ },
+ {
+ slug: 'merge',
+ title: 'PDFs zusammenfügen',
+ description: 'Fügen Sie mehrere PDFs zu einer Datei zusammen.',
+ icon: 'merge'
+ }
+];
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index 55b3a91..f92bfcb 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -1,13 +1,13 @@
-import { clsx, type ClassValue } from "clsx";
-import { twMerge } from "tailwind-merge";
+import { clsx, type ClassValue } from 'clsx';
+import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export type WithoutChild = T extends { child?: any } ? Omit : T;
+export type WithoutChild = T extends { child?: any } ? Omit : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export type WithoutChildren = T extends { children?: any } ? Omit : T;
+export type WithoutChildren = T extends { children?: any } ? Omit : T;
export type WithoutChildrenOrChild = WithoutChildren>;
export type WithElementRef = T & { ref?: U | null };
diff --git a/src/lib/zip-processing.ts b/src/lib/zip-processing.ts
index 2137660..b6b65a3 100644
--- a/src/lib/zip-processing.ts
+++ b/src/lib/zip-processing.ts
@@ -572,6 +572,25 @@ export const dissolveSubDocument = (
const stripPdfExtension = (name: string) =>
name.toLowerCase().endsWith('.pdf') ? name.slice(0, -'.pdf'.length) : name;
+/**
+ * Build a single PDF export unit for one sub-document. The resulting filename
+ * follows the same convention as the separate archive export:
+ * `{archiveName} - {subDocumentName}` (without duplicate `.pdf` suffixes).
+ */
+export const buildSubDocumentExport = async (
+ archiveName: string,
+ subDocument: SubDocument
+): Promise => {
+ if (subDocument.attachments.length === 0) {
+ throw new Error('Cannot create a PDF from an empty sub-document.');
+ }
+
+ return {
+ name: `${stripPdfExtension(archiveName)} - ${stripPdfExtension(subDocument.name)}`,
+ bytes: await mergeAttachments(subDocument.attachments)
+ };
+};
+
/**
* Build one or more PDF export units from the archive.
*
@@ -607,10 +626,7 @@ export const buildArchiveExport = async (
continue;
}
- separateUnits.push({
- name: `${stripPdfExtension(archive.name)} - ${stripPdfExtension(subDocument.name)}`,
- bytes: await mergeAttachments(subDocument.attachments)
- });
+ separateUnits.push(await buildSubDocumentExport(archive.name, subDocument));
}
if (archive.attachments.length > 0) {
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 52e8ac8..dd25c8e 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -1,211 +1,149 @@
-{#snippet content()}
- {#if selectedZipFiles.length === 0}
-
-
-
-
-
- {:else}
-
-
- Settings
- ⚙️
-
+
+ juri-merger — PDF-Werkzeuge
+
-
-
-
-
+
-
-
+{#if isDraggingFiles}
+
+
+
+
ZIP-Archiv hier ablegen
+
+
+{/if}
-
-
-
-
- {#if pendingZipCount > 0}
-
- Noch {pendingZipCount} ZIP-Datei{pendingZipCount === 1 ? '' : 'en'} in Bearbeitung.
-
+ Entfernen Sie den Passwortschutz von einem PDF-Dokument.
+
+
+
+
+
+
+
Hinweis zur Qualität
+
+ Das PDF wird neu gerendert, wodurch Text zu Bildern wird. Ausgewählter Text,
+ Durchsuchbarkeit und Vektorgrafiken gehen verloren. Die Dateigröße kann größer werden.
+