From ea21fc000f0e41448cfd556023a011f5d0cbb8f5 Mon Sep 17 00:00:00 2001 From: rehlert Date: Fri, 28 Aug 2026 08:02:10 +0200 Subject: [PATCH] batch + stamping --- agents.md | 32 +- src/lib/batch.ts | 71 +++ src/lib/components/BatchFileList.svelte | 176 ++++++ src/lib/pdf-compression.ts | 96 ++++ src/lib/pdf-overlay.ts | 52 ++ src/lib/tools.ts | 6 + src/routes/+page.svelte | 4 +- src/routes/tools/compress/+page.svelte | 305 +++++----- src/routes/tools/decrypt/+page.svelte | 271 +++++---- src/routes/tools/encrypt/+page.svelte | 260 +++++---- src/routes/tools/stamp/+page.svelte | 713 ++++++++++++++++++++++++ src/routes/tools/watermark/+page.svelte | 506 +++++++++-------- tests/e2e/toolbox.spec.ts | 393 ++++++++++++- 13 files changed, 2237 insertions(+), 648 deletions(-) create mode 100644 src/lib/batch.ts create mode 100644 src/lib/components/BatchFileList.svelte create mode 100644 src/lib/pdf-compression.ts create mode 100644 src/lib/pdf-overlay.ts create mode 100644 src/routes/tools/stamp/+page.svelte diff --git a/agents.md b/agents.md index dfb7a25..f308666 100644 --- a/agents.md +++ b/agents.md @@ -67,6 +67,7 @@ src/ │ ├── assets/ # Favicon and local Nunito fonts │ ├── components/ │ │ ├── AttachmentPreview.svelte # Browser-rendered PDF/image thumbnails +│ │ ├── BatchFileList.svelte # Shared batch rows: status chips, downloads, retry │ │ ├── BeaArchiveProcessing.svelte │ │ ├── BeaWorkspaceControls.svelte # beA workspace toolbar, settings popover, dialogs │ │ ├── ProcessedZipArchiveEditor.svelte @@ -81,6 +82,9 @@ src/ │ ├── services/ │ │ ├── xml-reading.service.ts # Namespace-tolerant XJustiz parsing │ │ └── 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-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 │ ├── utils.ts # cn() and shared component utility types @@ -92,7 +96,7 @@ src/ ├── tools/ │ ├── +layout.svelte # Shared back-navigation shell │ ├── bea/+page.svelte # Existing ZIP archive workflow - │ └── {merge,separate,watermark,encrypt,decrypt,compress,convert}/ + │ └── {merge,separate,stamp,watermark,encrypt,decrypt,compress,convert}/ ├── datenschutz/+page.svelte # Privacy policy └── impressum/+page.svelte # Imprint static/ # Public logo, footer art, and robots.txt @@ -174,6 +178,32 @@ localStorage, and `crypto.randomUUID()`. Do not invoke browser-only work during initialization. Keep it in event handlers, `onMount`, effects guarded by hydration, or functions only called in the browser. +## Batch processing pattern for single-PDF tools + +Tools that transform one PDF per run (compress, watermark, encrypt, …) are being retrofitted to +accept multiple files. Follow the established compress retrofit when adding the pattern to another +tool; see `plan/batch-processing-plan.md` for the full design. + +- `src/lib/batch.ts` owns the engine: `createBatchItem(file)`, `runBatch(items, process, update, +options?)` and `formatBytes`. `runBatch` processes strictly sequentially (one PDF in flight for + memory safety), never throws — per-item failures are caught and written to the item as a German + error message — and consults the optional `shouldProcess` predicate so rows removed mid-run are + skipped. +- `src/lib/components/BatchFileList.svelte` renders the rows: name, size, status chip + (Wartet / Verarbeite… / Fertig ✓ / Fehler), per-row download, remove, and retry buttons with + accessible names including the file name, a progress line (“Datei 3 von 7 wird verarbeitet…”), a + total result-size summary, a memory warning (>20 files or any file >25 MB), and the bulk + “Alle herunterladen” ZIP action (via fflate, named `{toolname}_{date}.zip`) shown once ≥2 results + exist. Duplicate result names inside the ZIP are suffixed instead of overwriting. +- Page wiring: keep `items = $state([])`, append newly dropped files (do not replace; + deduplicate by name + size), pass a `process(file)` callback that reads the current option state + so option changes mid-run affect only subsequent files, and gate the run button on pending items. + Files too large to rasterize (>100 MB) are marked as error rows immediately on selection instead + of blocking the batch. +- Per-tool ZIP base names: compress uses `komprimiert`, watermark `wasserzeichen`, encrypt + `geschuetzt`, decrypt `entsperrt`. Choose an analogous German name per tool and cover the batch + flow with Playwright tests (multi-file ZIP contents, failure continuation, retry, removal). + ## Svelte conventions - Use Svelte 5 runes and current event syntax: `$props()`, `$state`, `$derived`, `$effect`, snippets, diff --git a/src/lib/batch.ts b/src/lib/batch.ts new file mode 100644 index 0000000..3a98c85 --- /dev/null +++ b/src/lib/batch.ts @@ -0,0 +1,71 @@ +export type BatchItemStatus = 'pending' | 'processing' | 'done' | 'error'; + +export type BatchItem = { + id: string; + file: File; + status: BatchItemStatus; + resultBytes?: Uint8Array; + resultName?: string; + errorMessage?: string | null; +}; + +export type BatchProcessResult = { bytes: Uint8Array; name: string }; + +export type BatchRunOptions = { + /** Error message written to a row when `process` throws for it. */ + errorMessage?: string; + /** + * Optional predicate consulted before each item; when it returns `false` + * (for example because the row was removed mid-run), the item is skipped. + */ + shouldProcess?: (id: string) => boolean; +}; + +export const createBatchItem = (file: File): BatchItem => ({ + id: crypto.randomUUID(), + file, + status: 'pending' +}); + +/** + * Processes the given items strictly one after another so only one PDF is in + * flight at a time. Never throws: a failing item is marked as an error row and + * the queue continues with the remaining files. + */ +export const runBatch = async ( + items: BatchItem[], + process: (file: File) => Promise, + update: (id: string, patch: Partial) => void, + options: BatchRunOptions = {} +): Promise => { + const { errorMessage = 'Die Datei konnte nicht verarbeitet werden.', shouldProcess } = options; + + for (const item of items) { + if (shouldProcess && !shouldProcess(item.id)) continue; + + update(item.id, { status: 'processing', errorMessage: null }); + try { + const result = await process(item.file); + update(item.id, { + status: 'done', + resultBytes: result.bytes, + resultName: result.name, + errorMessage: null + }); + } catch (error) { + console.error(`Batch processing failed for ${item.file.name}:`, error); + update(item.id, { + status: 'error', + resultBytes: undefined, + resultName: undefined, + errorMessage + }); + } + } +}; + +export const formatBytes = (bytes: number) => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +}; diff --git a/src/lib/components/BatchFileList.svelte b/src/lib/components/BatchFileList.svelte new file mode 100644 index 0000000..3562f6b --- /dev/null +++ b/src/lib/components/BatchFileList.svelte @@ -0,0 +1,176 @@ + + +
+
+

Dateien ({items.length})

+ {#if doneItems.length > 0} +

+ Ergebnisse gesamt: {formatBytes(totalResultBytes)} +

+ {/if} +
+ + {#if processingIndex !== -1} +

+ Datei {processingIndex + 1} von {items.length} wird verarbeitet… +

+ {/if} + + {#if showMemoryWarning} +
+ Bei sehr vielen oder sehr großen Dateien kann der Arbeitsspeicher knapp werden. Laden Sie + fertige Ergebnisse früh herunter und entfernen Sie sie aus der Liste. +
+ {/if} + +
    + {#each items as item (item.id)} +
  • +
    +

    {item.file.name}

    +

    + {formatBytes(item.file.size)} + {#if item.status === 'done' && item.resultBytes} + → {formatBytes(item.resultBytes.length)} + {/if} +

    + {#if item.status === 'error' && item.errorMessage} + + {/if} +
    + + + {statusLabel(item.status)} + + +
    + {#if item.status === 'done' && item.resultBytes && item.resultName} + + {/if} + {#if item.status === 'error'} + + {/if} + +
    +
  • + {/each} +
+ + {#if doneItems.length >= 2} +
+ +
+ {/if} +
diff --git a/src/lib/pdf-compression.ts b/src/lib/pdf-compression.ts new file mode 100644 index 0000000..e68a96c --- /dev/null +++ b/src/lib/pdf-compression.ts @@ -0,0 +1,96 @@ +import { PDFDocument } from 'pdf-lib'; + +export type CompressionPreset = { + id: string; + label: string; + description: string; + scale: number; + quality: number; +}; + +export const compressionPresets: CompressionPreset[] = [ + { + id: 'strong', + label: 'Starke Komprimierung', + description: '~72 DPI, JPEG 50%', + scale: 1, + quality: 0.5 + }, + { + id: 'balanced', + label: 'Ausgewogen', + description: '~108 DPI, JPEG 70%', + scale: 1.5, + quality: 0.7 + }, + { id: 'light', label: 'Leicht', description: '~144 DPI, JPEG 85%', scale: 2, quality: 0.85 } +]; + +export const defaultCompressionPreset = compressionPresets[1]; + +const loadPdfjs = async () => { + 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 + ); + return { getDocument }; +}; + +/** + * Rasterizes every page of `file` with the given preset and rebuilds a + * JPEG-compressed PDF from the rendered pages. Throws when the file cannot be + * processed; callers are responsible for user-facing error messages. + */ +export const compressWithPreset = async ( + file: File, + preset: CompressionPreset +): Promise => { + const { getDocument } = await loadPdfjs(); + + const bytes = new Uint8Array(await file.arrayBuffer()); + const loadingTask = getDocument({ data: bytes }); + + try { + const pdfjsDoc = await loadingTask.promise; + const outputPdf = await PDFDocument.create(); + + for (let pageNumber = 1; pageNumber <= pdfjsDoc.numPages; pageNumber += 1) { + const page = await pdfjsDoc.getPage(pageNumber); + const viewport = page.getViewport({ scale: preset.scale }); + const canvas = document.createElement('canvas'); + canvas.width = Math.ceil(viewport.width); + canvas.height = Math.ceil(viewport.height); + const canvasContext = canvas.getContext('2d'); + if (!canvasContext) throw new Error('Could not create canvas context'); + + await page.render({ + canvas, + canvasContext, + viewport, + background: '#ffffff' + }).promise; + + const jpegBlob = await new Promise((resolve, reject) => { + canvas.toBlob( + (result) => (result ? resolve(result) : reject(new Error('JPEG encode failed'))), + 'image/jpeg', + preset.quality + ); + }); + const jpegImage = await outputPdf.embedJpg(new Uint8Array(await jpegBlob.arrayBuffer())); + const originalViewport = page.getViewport({ scale: 1 }); + const pdfPage = outputPdf.addPage([originalViewport.width, originalViewport.height]); + pdfPage.drawImage(jpegImage, { + x: 0, + y: 0, + width: pdfPage.getWidth(), + height: pdfPage.getHeight() + }); + } + + return await outputPdf.save(); + } finally { + await loadingTask.destroy(); + } +}; diff --git a/src/lib/pdf-overlay.ts b/src/lib/pdf-overlay.ts new file mode 100644 index 0000000..70e16c1 --- /dev/null +++ b/src/lib/pdf-overlay.ts @@ -0,0 +1,52 @@ +import { rgb, type RGB } from 'pdf-lib'; + +export type OverlayPosition = + | 'center' + | 'top-left' + | 'top-center' + | 'top-right' + | 'bottom-left' + | 'bottom-center' + | 'bottom-right'; + +export type WatermarkPosition = OverlayPosition | 'diagonal'; + +export const getPositionCoords = ( + pageWidth: number, + pageHeight: number, + markWidth: number, + markHeight: number, + position: WatermarkPosition, + margin = 20 +): { x: number; y: number } => { + const horizontallyCentered = (pageWidth - markWidth) / 2; + + switch (position) { + case 'center': + case 'diagonal': + return { x: horizontallyCentered, y: (pageHeight - markHeight) / 2 }; + case 'top-left': + return { x: margin, y: pageHeight - markHeight - margin }; + case 'top-center': + return { x: horizontallyCentered, y: pageHeight - markHeight - margin }; + case 'top-right': + return { x: pageWidth - markWidth - margin, y: pageHeight - markHeight - margin }; + case 'bottom-left': + return { x: margin, y: margin }; + case 'bottom-center': + return { x: horizontallyCentered, y: margin }; + case 'bottom-right': + return { x: pageWidth - markWidth - margin, y: margin }; + } +}; + +export const parseHexColor = (hexColor: string): RGB => { + const match = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hexColor); + if (!match) return rgb(0.8, 0.1, 0.1); + + return rgb( + Number.parseInt(match[1], 16) / 255, + Number.parseInt(match[2], 16) / 255, + Number.parseInt(match[3], 16) / 255 + ); +}; diff --git a/src/lib/tools.ts b/src/lib/tools.ts index 287d51f..a0e9aeb 100644 --- a/src/lib/tools.ts +++ b/src/lib/tools.ts @@ -6,6 +6,12 @@ export type Tool = { }; export const tools: Tool[] = [ + { + slug: 'stamp', + title: 'Stempeln', + description: 'Versehen Sie Seiten mit Textstempeln wie „Beglaubigt“ oder „Eilt“.', + icon: 'stamp' + }, { slug: 'watermark', title: 'Wasserzeichen', diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 9b841e9..18f5ee4 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -8,6 +8,7 @@ Minimize2, Scissors, Merge, + Stamp, FileText } from '@lucide/svelte'; import { goto } from '$app/navigation'; @@ -23,7 +24,8 @@ image: Image, minimize: Minimize2, scissors: Scissors, - merge: Merge + merge: Merge, + stamp: Stamp }; let isDraggingFiles = $state(false); diff --git a/src/routes/tools/compress/+page.svelte b/src/routes/tools/compress/+page.svelte index 35b2ffc..f1998a2 100644 --- a/src/routes/tools/compress/+page.svelte +++ b/src/routes/tools/compress/+page.svelte @@ -1,140 +1,99 @@
-

Komprimieren

-

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

Komprimieren

+

+ Reduzieren Sie die Dateigröße Ihrer PDFs durch Komprimierung – einzeln oder mehrere in einem + Durchlauf.

@@ -149,70 +108,64 @@
- {#if !pdfFile} - - {:else} -
- - -
-

{pdfFile.name}

-

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

+ -
-

Komprimierungsstufe

- {#each presets as preset} - - {/each} -
+ {#if items.length > 0} +
+

Komprimierungsstufe

+

Die Einstellung gilt für alle Dateien der Liste.

- {#if compressedBytes} -

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

- {/if} +
+ {#each compressionPresets as preset (preset.id)} + + {/each}
- {#if error} -
- {error} -
+ {#if isRunning} +

+ Änderungen an der Komprimierungsstufe gelten für die noch ausstehenden Dateien. +

{/if} +
-
- - {#if compressedBytes} - - {/if} -
+ {#if items.length > 0} + + {/if} + +
+
{/if}
diff --git a/src/routes/tools/decrypt/+page.svelte b/src/routes/tools/decrypt/+page.svelte index 014b35b..ee0a9cd 100644 --- a/src/routes/tools/decrypt/+page.svelte +++ b/src/routes/tools/decrypt/+page.svelte @@ -1,96 +1,144 @@
-

Passwort entfernen

-

- Entfernen Sie den Passwortschutz von einem PDF-Dokument. +

Passwort entfernen

+

+ Entfernen Sie den Passwortschutz von PDF-Dokumenten – einzeln oder mehrere in einem Durchlauf.

@@ -105,48 +153,63 @@
- {#if !pdfFile} - - {:else} -
- - -
-
- -

{pdfFile.name}

-
+ -
- - -
+ {#if items.length > 0} +
+
+ +

Passwort

+
+

Das Passwort gilt für alle Dateien der Liste.

+ +
+ +
- {#if error} -
- {error} -
+ {#if isRunning} +

+ Änderungen am Passwort gelten für die noch ausstehenden Dateien. +

{/if} +
-
- -
+ + {/if} + + {#if error} +
+ {error} +
+ {/if} + + {#if items.length > 0} +
+
{/if}
diff --git a/src/routes/tools/encrypt/+page.svelte b/src/routes/tools/encrypt/+page.svelte index 271d009..328c99d 100644 --- a/src/routes/tools/encrypt/+page.svelte +++ b/src/routes/tools/encrypt/+page.svelte @@ -1,150 +1,192 @@
-

Passwort setzen

-

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

Passwort setzen

+

+ Verschlüsseln Sie Ihre PDFs mit einem Passwort, um sie vor unbefugtem Zugriff zu schützen – + einzeln oder mehrere in einem Durchlauf.

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

{pdfFile.name}

-
+ -
- - -
+ {#if items.length > 0} +
+
+ +

Passwort

+
+

Das Passwort gilt für alle Dateien der Liste.

-
- - -
- -
-

Berechtigungen

-
- - -
-
- - -
-
+
+ +
- {#if error} -
- {error} -
- {/if} - -
- + Passwort bestätigen + +
+ +
+

Berechtigungen

+
+ + +
+
+ + +
+
+ + {#if isRunning} +

+ Änderungen am Passwort gelten für die noch ausstehenden Dateien. +

+ {/if} +
+ + + {/if} + + {#if error} +
+ {error} +
+ {/if} + + {#if items.length > 0} +
+
{/if}
diff --git a/src/routes/tools/stamp/+page.svelte b/src/routes/tools/stamp/+page.svelte new file mode 100644 index 0000000..ad313e0 --- /dev/null +++ b/src/routes/tools/stamp/+page.svelte @@ -0,0 +1,713 @@ + + + + PDF stempeln — beA-Edit + + +
+
+
+
+

Stempeln

+

+ Setzen Sie einen Text- oder Bildstempel auf alle oder ausgewählte PDF-Seiten. +

+
+ + + + {#if pdfHandle} +
+
+
+

+ Stempel einstellen +

+

+ Lange Texte werden für kleine Seiten automatisch verkleinert. +

+
+ +
+ + + {#if stampImage} +
+ {stampImage.name} + +
+ {/if} +
+ + {#if stampImage} + +
+ + +
+ {:else} +
+ Vorlage +
+ {#each PRESETS as preset} + + {/each} +
+
+ +
+ + +
+ + + +
+ + +
+ {/if} + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+

Vorschau

+

Annäherung an die Ausgabe im PDF

+
+ {currentPage} / {pdfHandle.pageCount} +
+ + {#if pdfHandle.thumbnails[currentPage - 1]} + {@const currentThumbnail = pdfHandle.thumbnails[currentPage - 1]} +
+ Vorschau von Seite {currentPage} +
+ {#if stampImage} +
+ Stempelbild +
+ {:else} +
+ {previewText} +
+ {/if} +
+
+ {/if} + + {#if pdfHandle.pageCount > 1} +
+ + +
+ {/if} +
+
+ + {#if !applyToAll} +
+
+

+ Seiten auswählen +

+

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

+
+ +
+ {/if} + +
+ {#if error} + + {/if} + +
+ {:else if error} + + {/if} +
diff --git a/src/routes/tools/watermark/+page.svelte b/src/routes/tools/watermark/+page.svelte index 8690555..b19703c 100644 --- a/src/routes/tools/watermark/+page.svelte +++ b/src/routes/tools/watermark/+page.svelte @@ -1,23 +1,18 @@