From adaae41e4477ccbbc09faede12f50552d9985d0d Mon Sep 17 00:00:00 2001 From: rehlert Date: Thu, 27 Aug 2026 07:38:35 +0200 Subject: [PATCH] adds features --- .gitea/workflows/uat.yml | 2 +- .gitignore | 2 + agents.md | 239 ++++++++++--- package.json | 4 + playwright.config.ts | 30 ++ pnpm-lock.yaml | 183 ++++++++-- src/lib/actions/ripple.ts | 43 ++- src/lib/components/FileDropzone.svelte | 124 +++++++ src/lib/components/PdfPageGrid.svelte | 54 +++ .../ProcessedZipArchiveEditor.svelte | 45 ++- .../components/RasterizationWarning.svelte | 23 ++ src/lib/components/SubDocumentEditor.svelte | 22 +- src/lib/components/ZipDropzone.svelte | 84 +---- src/lib/download.ts | 25 ++ src/lib/pdf-processing.ts | 17 + src/lib/pdf-thumbnails.ts | 71 ++++ src/lib/pending-files.ts | 11 + src/lib/rasterization-limits.ts | 8 + src/lib/tools.ts | 51 +++ src/lib/utils.ts | 8 +- src/lib/zip-processing.ts | 24 +- src/routes/+page.svelte | 326 +++++++---------- src/routes/layout.css | 20 +- src/routes/tools/+layout.svelte | 18 + src/routes/tools/bea/+page.svelte | 196 +++++++++++ src/routes/tools/compress/+page.svelte | 218 ++++++++++++ src/routes/tools/convert/+page.svelte | 192 ++++++++++ src/routes/tools/decrypt/+page.svelte | 152 ++++++++ src/routes/tools/encrypt/+page.svelte | 150 ++++++++ src/routes/tools/merge/+page.svelte | 108 ++++++ src/routes/tools/separate/+page.svelte | 162 +++++++++ src/routes/tools/watermark/+page.svelte | 329 ++++++++++++++++++ tests/e2e/toolbox.spec.ts | 226 ++++++++++++ 33 files changed, 2771 insertions(+), 396 deletions(-) create mode 100644 playwright.config.ts create mode 100644 src/lib/components/FileDropzone.svelte create mode 100644 src/lib/components/PdfPageGrid.svelte create mode 100644 src/lib/components/RasterizationWarning.svelte create mode 100644 src/lib/download.ts create mode 100644 src/lib/pdf-processing.ts create mode 100644 src/lib/pdf-thumbnails.ts create mode 100644 src/lib/pending-files.ts create mode 100644 src/lib/rasterization-limits.ts create mode 100644 src/lib/tools.ts create mode 100644 src/routes/tools/+layout.svelte create mode 100644 src/routes/tools/bea/+page.svelte create mode 100644 src/routes/tools/compress/+page.svelte create mode 100644 src/routes/tools/convert/+page.svelte create mode 100644 src/routes/tools/decrypt/+page.svelte create mode 100644 src/routes/tools/encrypt/+page.svelte create mode 100644 src/routes/tools/merge/+page.svelte create mode 100644 src/routes/tools/separate/+page.svelte create mode 100644 src/routes/tools/watermark/+page.svelte create mode 100644 tests/e2e/toolbox.spec.ts 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} + +{/if} diff --git a/src/lib/components/PdfPageGrid.svelte b/src/lib/components/PdfPageGrid.svelte new file mode 100644 index 0000000..6ab3804 --- /dev/null +++ b/src/lib/components/PdfPageGrid.svelte @@ -0,0 +1,54 @@ + + +
+ {#each thumbnails as thumb} + {@const isSelected = selectedPages.has(thumb.pageNumber)} + + {/each} +
diff --git a/src/lib/components/ProcessedZipArchiveEditor.svelte b/src/lib/components/ProcessedZipArchiveEditor.svelte index 086f9d6..e420991 100644 --- a/src/lib/components/ProcessedZipArchiveEditor.svelte +++ b/src/lib/components/ProcessedZipArchiveEditor.svelte @@ -4,6 +4,7 @@ import SubDocumentEditor from '$lib/components/SubDocumentEditor.svelte'; import { buildArchiveExport, + buildSubDocumentExport, createSubDocument, dissolveSubDocument, moveAttachmentIntoSubDocument, @@ -41,8 +42,11 @@ let downloadMode = $state('single'); let isMerging = $state(false); + let downloadingSubDocumentId = $state(null); let mergeError = $state(null); + const isExportActive = $derived(isMerging || downloadingSubDocumentId !== null); + $effect(() => { archiveName = archive.name; looseAttachments = archive.attachments.map((attachment) => ({ ...attachment })); @@ -293,7 +297,7 @@ return; } - if (isMerging) { + if (isExportActive) { return; } @@ -313,6 +317,29 @@ isMerging = false; } }; + + const downloadSubDocument = async (subDocument: SubDocument) => { + if (isExportActive || subDocument.attachments.length === 0) { + return; + } + + downloadingSubDocumentId = subDocument.id; + mergeError = null; + + try { + const exportUnit = await buildSubDocumentExport(archiveName, subDocument); + + downloadPdfBytes(exportUnit.bytes, exportUnit.name); + } catch (error) { + console.error( + `Failed to export sub-document "${subDocument.name}" from archive ${archive.name}`, + error + ); + mergeError = `Teildokument „${subDocument.name}“ konnte nicht als PDF erstellt werden.`; + } finally { + downloadingSubDocumentId = null; + } + }; @@ -354,17 +381,21 @@ {#if hasSubDocuments}
{#each [{ value: 'single', label: 'Eine PDF' }, { value: 'separate', label: 'Getrennt' }] as option (option.value)}
diff --git a/src/lib/components/RasterizationWarning.svelte b/src/lib/components/RasterizationWarning.svelte new file mode 100644 index 0000000..365e00b --- /dev/null +++ b/src/lib/components/RasterizationWarning.svelte @@ -0,0 +1,23 @@ + + +{#if shouldWarnAboutRasterization(file)} +
+ +

+ {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} + +{/if} -
-
-
-
- {#if pendingZipCount > 0} -
- Noch {pendingZipCount} ZIP-Datei{pendingZipCount === 1 ? '' : 'en'} in Bearbeitung. -
- {/if} + + + + +
+ +
+
+

ZIP-Archiv bearbeiten

+

+ beA-Archive verarbeiten, Teildokumente erstellen und als PDF exportieren. +

+
+
+
+
- {#if processedZipFiles.length > 0} -
- {#each processedZipFiles as file, index} - updateProcessedZipFile(index, updatedArchive)} - /> - {/each} -
- {/if} -
-
- {/if} -{/snippet} + +
+ +
-
-
-

beA-Edit

-
- {@render content()} -
+ +
+

Werkzeuge

+
+ {#each tools as tool} + {@const Icon = iconMap[tool.icon] ?? FileText} + + + +
+ +
+
+

{tool.title}

+

{tool.description}

+
+
+
+
+ {/each} +
+
+ diff --git a/src/routes/layout.css b/src/routes/layout.css index e2394e6..1af2ab8 100644 --- a/src/routes/layout.css +++ b/src/routes/layout.css @@ -1,8 +1,8 @@ @import 'tailwindcss'; /*@import '../globals.css';*/ -@import "tw-animate-css"; -@import "shadcn-svelte/tailwind.css"; -@import "@fontsource-variable/geist"; +@import 'tw-animate-css'; +@import 'shadcn-svelte/tailwind.css'; +@import '@fontsource-variable/geist'; @custom-variant dark (&:is(.dark *)); @@ -17,16 +17,16 @@ } :root { - --background: #EEF4F6; - --foreground: #122F62; + --background: #eef4f6; + --foreground: #122f62; --card: oklch(1 0 0); --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); --popover-foreground: oklch(0.145 0 0); --primary: #122f62; --primary-foreground: oklch(0.985 0 0); - --secondary: #EA544B; - --secondary-end: #FF6F6D; + --secondary: #ea544b; + --secondary-end: #ff6f6d; --secondary-foreground: oklch(0.205 0 0); --muted: oklch(0.97 0 0); --muted-foreground: oklch(0.556 0 0); @@ -54,14 +54,14 @@ .dark { --background: #212121; - --foreground: #FFFFFF; + --foreground: #ffffff; --card: oklch(0.205 0 0); --card-foreground: oklch(0.985 0 0); --popover: oklch(0.205 0 0); --popover-foreground: oklch(0.985 0 0); --primary: #92b2eb; --primary-foreground: oklch(0.205 0 0); - --secondary: linear-gradient(90deg, #EA544B 0%, #FF6F6D 100%); + --secondary: linear-gradient(90deg, #ea544b 0%, #ff6f6d 100%); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.269 0 0); --muted-foreground: oklch(0.708 0 0); @@ -140,7 +140,6 @@ } } - /* ripple */ @keyframes ripple { @@ -158,4 +157,3 @@ .animate-ripple { animation: ripple 600ms ease-out; } - \ No newline at end of file diff --git a/src/routes/tools/+layout.svelte b/src/routes/tools/+layout.svelte new file mode 100644 index 0000000..bca80db --- /dev/null +++ b/src/routes/tools/+layout.svelte @@ -0,0 +1,18 @@ + + +
+ + {@render children()} +
diff --git a/src/routes/tools/bea/+page.svelte b/src/routes/tools/bea/+page.svelte new file mode 100644 index 0000000..3bb9133 --- /dev/null +++ b/src/routes/tools/bea/+page.svelte @@ -0,0 +1,196 @@ + + +{#snippet content()} + {#if selectedZipFiles.length === 0} + + + + + + {:else} +
+ + Settings + ⚙️ + + +
+
+
+ + + +
+ + +
+
+
+
+
+ {#if pendingZipCount > 0} +
+ Noch {pendingZipCount} ZIP-Datei{pendingZipCount === 1 ? '' : 'en'} in Bearbeitung. +
+ {/if} + + {#if processedZipFiles.length > 0} +
+ {#each processedZipFiles as file, index} + updateProcessedZipFile(index, updatedArchive)} + /> + {/each} +
+ {/if} +
+
+ {/if} +{/snippet} + +
+

beA-Edit

+ {@render content()} +
diff --git a/src/routes/tools/compress/+page.svelte b/src/routes/tools/compress/+page.svelte new file mode 100644 index 0000000..35b2ffc --- /dev/null +++ b/src/routes/tools/compress/+page.svelte @@ -0,0 +1,218 @@ + + +
+
+

Komprimieren

+

+ Reduzieren Sie die Dateigröße Ihres PDFs durch Komprimierung. +

+
+ +
+ +
+

Hinweis zur Qualität

+

+ Das PDF wird neu gerendert, wodurch Text zu Bildern wird. Ausgewählter Text, + Durchsuchbarkeit und Vektorgrafiken gehen verloren. +

+
+
+ + {#if !pdfFile} + + {:else} +
+ + +
+

{pdfFile.name}

+

Dateigröße: {formatBytes(pdfFile.size)}

+ +
+

Komprimierungsstufe

+ {#each presets as preset} + + {/each} +
+ + {#if compressedBytes} +

+ Ergebnisgröße: {formatBytes(compressedBytes.length)} +

+ {/if} +
+ + {#if error} +
+ {error} +
+ {/if} + +
+ + {#if compressedBytes} + + {/if} +
+
+ {/if} +
diff --git a/src/routes/tools/convert/+page.svelte b/src/routes/tools/convert/+page.svelte new file mode 100644 index 0000000..b7e8ee2 --- /dev/null +++ b/src/routes/tools/convert/+page.svelte @@ -0,0 +1,192 @@ + + +
+
+

Konvertieren

+

Konvertieren Sie ein PDF in Bilder (PNG oder JPEG).

+
+ + {#if !pdfFile} + + {:else} +
+ + +
+
+ +

{pdfFile.name}

+
+ +
+

Bildformat

+
+ + +
+
+ +
+ + +

+ {scale === 1 + ? '~72 DPI' + : scale === 2 + ? '~150 DPI' + : scale === 3 + ? '~216 DPI' + : '~288 DPI'} +

+
+ + {#if isProcessing && totalPages > 0} +
+
+ Wird konvertiert... + {progress} / {totalPages} +
+
+
+
+
+ {/if} +
+ + {#if error} +
+ {error} +
+ {/if} + +
+ +
+
+ {/if} +
diff --git a/src/routes/tools/decrypt/+page.svelte b/src/routes/tools/decrypt/+page.svelte new file mode 100644 index 0000000..014b35b --- /dev/null +++ b/src/routes/tools/decrypt/+page.svelte @@ -0,0 +1,152 @@ + + +
+
+

Passwort entfernen

+

+ 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. +

+
+
+ + {#if !pdfFile} + + {:else} +
+ + +
+
+ +

{pdfFile.name}

+
+ +
+ + +
+
+ + {#if error} +
+ {error} +
+ {/if} + +
+ +
+
+ {/if} +
diff --git a/src/routes/tools/encrypt/+page.svelte b/src/routes/tools/encrypt/+page.svelte new file mode 100644 index 0000000..271d009 --- /dev/null +++ b/src/routes/tools/encrypt/+page.svelte @@ -0,0 +1,150 @@ + + +
+
+

Passwort setzen

+

+ Verschlüsseln Sie Ihr PDF mit einem Passwort, um es vor unbefugtem Zugriff zu schützen. +

+
+ + {#if !pdfFile} + + {:else} +
+ +
+
+ +

{pdfFile.name}

+
+ +
+ + +
+ +
+ + +
+ +
+

Berechtigungen

+
+ + +
+
+ + +
+
+
+ + {#if error} +
+ {error} +
+ {/if} + +
+ +
+
+ {/if} +
diff --git a/src/routes/tools/merge/+page.svelte b/src/routes/tools/merge/+page.svelte new file mode 100644 index 0000000..43482b3 --- /dev/null +++ b/src/routes/tools/merge/+page.svelte @@ -0,0 +1,108 @@ + + +
+
+

PDFs zusammenfügen

+

+ Fügen Sie mehrere PDF-Dateien zu einem einzigen Dokument zusammen. +

+
+ + + + {#if pdfFiles.length > 0} +
+
+

+ {pdfFiles.length} Datei{pdfFiles.length === 1 ? '' : 'en'} ausgewählt +

+ +
+ + {#if error} +
+ {error} +
+ {/if} + +
+ {#each pdfFiles as file, index} +
+ + {index + 1} + + {file.name} +
+ + + +
+
+ {/each} +
+
+ {/if} +
diff --git a/src/routes/tools/separate/+page.svelte b/src/routes/tools/separate/+page.svelte new file mode 100644 index 0000000..b6a7f23 --- /dev/null +++ b/src/routes/tools/separate/+page.svelte @@ -0,0 +1,162 @@ + + +
+
+

Seiten entfernen

+

+ Wählen Sie Seiten aus, die Sie aus dem PDF entfernen möchten. +

+
+ + {#if !pdfHandle} + + {#if loadingError} +
+ {loadingError} +
+ {/if} + {:else} +
+ +
+

+ {pdfFile?.name} — {pdfHandle.pageCount} Seite{pdfHandle.pageCount === 1 ? '' : 'n'} +

+
+ + +
+
+ + + + {#if error} +
+ {error} +
+ {/if} + +
+

+ {selectedPages.size} Seite{selectedPages.size === 1 ? '' : 'n'} zum Entfernen ausgewählt +

+ +
+
+ {/if} +
diff --git a/src/routes/tools/watermark/+page.svelte b/src/routes/tools/watermark/+page.svelte new file mode 100644 index 0000000..8690555 --- /dev/null +++ b/src/routes/tools/watermark/+page.svelte @@ -0,0 +1,329 @@ + + +
+
+

Wasserzeichen

+

+ Fügen Sie allen oder ausgewählten Seiten ein Text- oder Bildwasserzeichen hinzu. +

+
+ + {#if !pdfHandle} + + {:else} +
+ + +
+

Wasserzeichen-Einstellungen

+ +
+ Art + + +
+ + {#if watermarkKind === 'text'} + + + {:else} + + {/if} + +
+ {#if watermarkKind === 'text'} + + + {:else} + + + {/if} + + + + + + + + + +
+ + {#if watermarkKind === 'text'} + + + {/if} + + +
+ + {#if !applyToAll} +
+

Seiten auswählen:

+ +
+ {/if} + + {#if error} +
+ {error} +
+ {/if} + +
+ +
+
+ {/if} +
diff --git a/tests/e2e/toolbox.spec.ts b/tests/e2e/toolbox.spec.ts new file mode 100644 index 0000000..773fd33 --- /dev/null +++ b/tests/e2e/toolbox.spec.ts @@ -0,0 +1,226 @@ +import { expect, test, type Download, type Locator, type Page } from '@playwright/test'; +import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'; +import { zipSync } from 'fflate'; +import { readFile } from 'node:fs/promises'; + +const createPdf = async (pageCount: number, label: string) => { + const document = await PDFDocument.create(); + const font = await document.embedFont(StandardFonts.Helvetica); + for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) { + const page = document.addPage([420, 594]); + page.drawText(`${label} – Seite ${pageNumber}`, { + x: 40, + y: 520, + size: 20, + font, + color: rgb(0.1, 0.2, 0.4) + }); + } + return Buffer.from(await document.save()); +}; + +const dropFiles = async ( + page: Page, + target: Locator | 'window', + files: Array<{ name: string; mimeType: string; buffer: Buffer }> +) => { + await page.locator('[data-dropzone-ready="true"]').first().waitFor(); + const dataTransfer = await page.evaluateHandle( + (droppedFiles) => { + const transfer = new DataTransfer(); + for (const droppedFile of droppedFiles) { + const bytes = Uint8Array.from(atob(droppedFile.base64), (character) => + character.charCodeAt(0) + ); + transfer.items.add( + new File([bytes], droppedFile.name, { + type: droppedFile.mimeType, + lastModified: Date.now() + }) + ); + } + return transfer; + }, + files.map((file) => ({ + name: file.name, + mimeType: file.mimeType, + base64: file.buffer.toString('base64') + })) + ); + + if (target === 'window') { + await page.evaluate((transfer) => { + window.dispatchEvent( + new DragEvent('drop', { dataTransfer: transfer, bubbles: true, cancelable: true }) + ); + }, dataTransfer); + } else { + await target.dispatchEvent('drop', { dataTransfer }); + } + await dataTransfer.dispose(); +}; + +const expectPdfDownload = async (download: Download, name: RegExp) => { + expect(download.suggestedFilename()).toMatch(name); + const path = await download.path(); + expect(path).not.toBeNull(); + const bytes = await readFile(path!); + expect(bytes.subarray(0, 5).toString()).toBe('%PDF-'); + return bytes; +}; + +const trackPageErrors = (page: Page) => { + const errors: Error[] = []; + page.on('pageerror', (error) => errors.push(error)); + return errors; +}; + +const pdfDropzone = (page: Page) => page.getByRole('button', { name: 'PDF-Dateien hinzufügen' }); + +test('start page exposes only working tools and forwards a dropped ZIP to beA', async ({ + page +}) => { + const pageErrors = trackPageErrors(page); + const pdf = await createPdf(1, 'beA'); + const zip = Buffer.from(zipSync({ 'anlage.pdf': pdf })); + + await page.goto('/'); + await expect(page.getByRole('heading', { name: 'PDF-Werkzeuge' })).toBeVisible(); + await expect(page.getByRole('link', { name: /Signieren/ })).toHaveCount(0); + + await dropFiles(page, 'window', [ + { name: 'bea-archiv.zip', mimeType: 'application/zip', buffer: zip } + ]); + + await expect(page).toHaveURL(/\/tools\/bea$/); + await expect(page.getByText('1 lose Datei')).toBeVisible({ timeout: 15_000 }); + expect(pageErrors).toEqual([]); +}); + +test('merge accepts dropped PDFs and downloads a merged PDF', async ({ page }) => { + const pageErrors = trackPageErrors(page); + await page.goto('/tools/merge'); + await dropFiles(page, pdfDropzone(page), [ + { name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') }, + { name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Zwei') } + ]); + + await expect(page.getByText('2 Dateien ausgewählt')).toBeVisible(); + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'PDFs zusammenfügen' }).click(); + await expectPdfDownload(await downloadPromise, /^zusammengefuegt\.pdf$/); + expect(pageErrors).toEqual([]); +}); + +test('separate renders dropped PDF pages and removes a selected page', async ({ page }) => { + const pageErrors = trackPageErrors(page); + await page.goto('/tools/separate'); + await dropFiles(page, pdfDropzone(page), [ + { name: 'seiten.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Trennen') } + ]); + + await expect(page.getByRole('button', { name: /Seite 1/ })).toBeVisible({ timeout: 15_000 }); + await page.getByRole('button', { name: /Seite 2/ }).click(); + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Seiten entfernen & herunterladen' }).click(); + await expectPdfDownload(await downloadPromise, /^seiten_ohne_seiten\.pdf$/); + expect(pageErrors).toEqual([]); +}); + +test('watermark accepts numeric controls and downloads text and image watermarks', async ({ + page +}) => { + const pageErrors = trackPageErrors(page); + await page.goto('/tools/watermark'); + await dropFiles(page, pdfDropzone(page), [ + { name: 'marke.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Marke') } + ]); + + await expect(page.getByRole('heading', { name: 'Wasserzeichen-Einstellungen' })).toBeVisible({ + timeout: 15_000 + }); + await page.locator('#watermark-color').fill('#00ff00'); + await page.locator('#watermark-opacity').fill('0.45'); + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Wasserzeichen anwenden & herunterladen' }).click(); + await expectPdfDownload(await downloadPromise, /^marke_wasserzeichen\.pdf$/); + + await page.getByLabel('Bild', { exact: true }).check(); + await dropFiles(page, page.getByRole('button', { name: 'Wasserzeichenbild hinzufügen' }), [ + { + name: 'logo.png', + mimeType: 'image/png', + buffer: Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Z5rAAAAAASUVORK5CYII=', + 'base64' + ) + } + ]); + const imageDownloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Wasserzeichen anwenden & herunterladen' }).click(); + await expectPdfDownload(await imageDownloadPromise, /^marke_wasserzeichen\.pdf$/); + + await expect(page.getByText('Fehler beim Anwenden')).toHaveCount(0); + expect(pageErrors).toEqual([]); +}); + +test('encrypt creates a protected PDF and decrypt rebuilds it with the password', async ({ + page +}) => { + const pageErrors = trackPageErrors(page); + await page.goto('/tools/encrypt'); + await dropFiles(page, pdfDropzone(page), [ + { name: 'geheim.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Geheim') } + ]); + + await page.locator('#encrypt-password').fill('test-passwort'); + await page.locator('#encrypt-password-confirmation').fill('test-passwort'); + const encryptedDownloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Passwort setzen & herunterladen' }).click(); + const encryptedDownload = await encryptedDownloadPromise; + const encryptedBytes = await expectPdfDownload(encryptedDownload, /^geheim_geschuetzt\.pdf$/); + + await page.goto('/tools/decrypt'); + await dropFiles(page, pdfDropzone(page), [ + { + name: 'geheim_geschuetzt.pdf', + mimeType: 'application/pdf', + buffer: encryptedBytes + } + ]); + await page.locator('#decrypt-password').fill('test-passwort'); + const decryptedDownloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Passwort entfernen & herunterladen' }).click(); + await expectPdfDownload(await decryptedDownloadPromise, /^geheim_geschuetzt_entsperrt\.pdf$/); + expect(pageErrors).toEqual([]); +}); + +test('compress previews result size before downloading', async ({ page }) => { + const pageErrors = trackPageErrors(page); + await page.goto('/tools/compress'); + await dropFiles(page, pdfDropzone(page), [ + { name: 'gross.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Komprimieren') } + ]); + + await page.getByRole('button', { name: 'Komprimieren', exact: true }).click(); + await expect(page.getByText(/Ergebnisgröße:/)).toBeVisible({ timeout: 20_000 }); + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'PDF herunterladen' }).click(); + await expectPdfDownload(await downloadPromise, /^gross_komprimiert\.pdf$/); + expect(pageErrors).toEqual([]); +}); + +test('convert bundles a multi-page PDF as a correctly named ZIP', async ({ page }) => { + const pageErrors = trackPageErrors(page); + await page.goto('/tools/convert'); + await dropFiles(page, pdfDropzone(page), [ + { name: 'bilder.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Bilder') } + ]); + + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Konvertieren & herunterladen' }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe('bilder_bilder.zip'); + expect(download.suggestedFilename()).not.toContain('.pdf'); + expect(pageErrors).toEqual([]); +});