batch + stamping
Build and Push Docker Image / build (push) Successful in 1m35s

This commit is contained in:
2026-08-28 08:02:10 +02:00
parent 57b38fe2c7
commit ea21fc000f
13 changed files with 2237 additions and 648 deletions
+31 -1
View File
@@ -67,6 +67,7 @@ src/
│ ├── assets/ # Favicon and local Nunito fonts │ ├── assets/ # Favicon and local Nunito fonts
│ ├── components/ │ ├── components/
│ │ ├── AttachmentPreview.svelte # Browser-rendered PDF/image thumbnails │ │ ├── AttachmentPreview.svelte # Browser-rendered PDF/image thumbnails
│ │ ├── BatchFileList.svelte # Shared batch rows: status chips, downloads, retry
│ │ ├── BeaArchiveProcessing.svelte │ │ ├── BeaArchiveProcessing.svelte
│ │ ├── BeaWorkspaceControls.svelte # beA workspace toolbar, settings popover, dialogs │ │ ├── BeaWorkspaceControls.svelte # beA workspace toolbar, settings popover, dialogs
│ │ ├── ProcessedZipArchiveEditor.svelte │ │ ├── ProcessedZipArchiveEditor.svelte
@@ -81,6 +82,9 @@ src/
│ ├── services/ │ ├── services/
│ │ ├── xml-reading.service.ts # Namespace-tolerant XJustiz parsing │ │ ├── xml-reading.service.ts # Namespace-tolerant XJustiz parsing
│ │ └── zip-inflating.service.ts # Async fflate wrapper │ │ └── 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-processing.ts # Reusable PDF merge helper
│ ├── pdf-thumbnails.ts # Managed pdfjs thumbnail loading and cleanup │ ├── pdf-thumbnails.ts # Managed pdfjs thumbnail loading and cleanup
│ ├── utils.ts # cn() and shared component utility types │ ├── utils.ts # cn() and shared component utility types
@@ -92,7 +96,7 @@ src/
├── tools/ ├── tools/
│ ├── +layout.svelte # Shared back-navigation shell │ ├── +layout.svelte # Shared back-navigation shell
│ ├── bea/+page.svelte # Existing ZIP archive workflow │ ├── 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 ├── datenschutz/+page.svelte # Privacy policy
└── impressum/+page.svelte # Imprint └── impressum/+page.svelte # Imprint
static/ # Public logo, footer art, and robots.txt 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 initialization. Keep it in event handlers, `onMount`, effects guarded by hydration, or functions only
called in the browser. 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<BatchItem[]>([])`, 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 ## Svelte conventions
- Use Svelte 5 runes and current event syntax: `$props()`, `$state`, `$derived`, `$effect`, snippets, - Use Svelte 5 runes and current event syntax: `$props()`, `$state`, `$derived`, `$effect`, snippets,
+71
View File
@@ -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<BatchProcessResult>,
update: (id: string, patch: Partial<BatchItem>) => void,
options: BatchRunOptions = {}
): Promise<void> => {
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`;
};
+176
View File
@@ -0,0 +1,176 @@
<script lang="ts">
import { Download, RotateCcw, Trash2 } from '@lucide/svelte';
import { zipSync } from 'fflate';
import Button from '$lib/components/ui/button.svelte';
import { downloadBlob } from '$lib/download';
import { formatBytes, type BatchItem } from '$lib/batch';
import { RASTERIZATION_WARNING_BYTES } from '$lib/rasterization-limits';
type Props = {
items: BatchItem[];
/** Base file name of the bulk ZIP, e.g. `komprimiert` → `komprimiert_2026-02-12.zip`. */
zipBaseName: string;
onDownload: (item: BatchItem) => void;
onRemove: (id: string) => void;
onRetry: (id: string) => void;
};
let { items, zipBaseName, onDownload, onRemove, onRetry }: Props = $props();
const doneItems = $derived(items.filter((item) => item.status === 'done'));
const totalResultBytes = $derived(
doneItems.reduce((sum, item) => sum + (item.resultBytes?.byteLength ?? 0), 0)
);
const processingIndex = $derived(items.findIndex((item) => item.status === 'processing'));
const showMemoryWarning = $derived(
items.length > 20 || items.some((item) => item.file.size > RASTERIZATION_WARNING_BYTES)
);
const uniqueEntryName = (name: string, usedNames: Set<string>) => {
if (!usedNames.has(name)) {
usedNames.add(name);
return name;
}
const dotIndex = name.lastIndexOf('.');
const base = dotIndex > 0 ? name.slice(0, dotIndex) : name;
const extension = dotIndex > 0 ? name.slice(dotIndex) : '';
let counter = 2;
let candidate = `${base}_2${extension}`;
while (usedNames.has(candidate)) {
counter += 1;
candidate = `${base}_${counter}${extension}`;
}
usedNames.add(candidate);
return candidate;
};
const downloadZip = () => {
const entries: Record<string, Uint8Array> = {};
const usedNames = new Set<string>();
for (const item of doneItems) {
if (!item.resultBytes || !item.resultName) continue;
entries[uniqueEntryName(item.resultName, usedNames)] = item.resultBytes;
}
if (Object.keys(entries).length === 0) return;
const dateStamp = new Date().toISOString().slice(0, 10);
downloadBlob(zipSync(entries), `${zipBaseName}_${dateStamp}.zip`, 'application/zip');
};
const statusChipClass = (status: BatchItem['status']) => {
switch (status) {
case 'processing':
return 'bg-amber-100 text-amber-800';
case 'done':
return 'bg-emerald-100 text-emerald-800';
case 'error':
return 'bg-red-100 text-red-800';
default:
return 'bg-accent text-primary';
}
};
const statusLabel = (status: BatchItem['status']) => {
switch (status) {
case 'processing':
return 'Verarbeite…';
case 'done':
return 'Fertig ✓';
case 'error':
return 'Fehler';
default:
return 'Wartet';
}
};
</script>
<div class="rounded-2xl border border-border bg-card p-4 space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-lg font-semibold text-foreground">Dateien ({items.length})</h2>
{#if doneItems.length > 0}
<p class="text-sm text-primary">
Ergebnisse gesamt: <span class="font-semibold">{formatBytes(totalResultBytes)}</span>
</p>
{/if}
</div>
{#if processingIndex !== -1}
<p class="text-sm text-primary" role="status">
Datei {processingIndex + 1} von {items.length} wird verarbeitet…
</p>
{/if}
{#if showMemoryWarning}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
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.
</div>
{/if}
<ul class="divide-y divide-border">
{#each items as item (item.id)}
<li class="flex flex-wrap items-center gap-3 py-3" data-batch-item={item.file.name}>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-foreground">{item.file.name}</p>
<p class="text-xs text-primary">
{formatBytes(item.file.size)}
{#if item.status === 'done' && item.resultBytes}
{formatBytes(item.resultBytes.length)}
{/if}
</p>
{#if item.status === 'error' && item.errorMessage}
<p class="mt-0.5 text-xs text-red-700" role="alert">{item.errorMessage}</p>
{/if}
</div>
<span
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold {statusChipClass(
item.status
)}"
>
{statusLabel(item.status)}
</span>
<div class="flex shrink-0 items-center gap-1">
{#if item.status === 'done' && item.resultBytes && item.resultName}
<button
type="button"
class="rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
aria-label="{item.file.name} herunterladen"
title="{item.file.name} herunterladen"
onclick={() => onDownload(item)}
>
<Download class="h-4 w-4" />
</button>
{/if}
{#if item.status === 'error'}
<button
type="button"
class="rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
aria-label="{item.file.name} erneut versuchen"
title="{item.file.name} erneut versuchen"
onclick={() => onRetry(item.id)}
>
<RotateCcw class="h-4 w-4" />
</button>
{/if}
<button
type="button"
class="rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="{item.file.name} entfernen"
title="{item.file.name} entfernen"
disabled={item.status === 'processing'}
onclick={() => onRemove(item.id)}
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</li>
{/each}
</ul>
{#if doneItems.length >= 2}
<div class="flex justify-end pt-1">
<Button onclick={downloadZip} icon={Download}>Alle herunterladen</Button>
</div>
{/if}
</div>
+96
View File
@@ -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<Uint8Array> => {
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<Blob>((resolve, reject) => {
canvas.toBlob(
(result) => (result ? resolve(result) : reject(new Error('JPEG encode failed'))),
'image/jpeg',
preset.quality
);
});
const jpegImage = await outputPdf.embedJpg(new Uint8Array(await jpegBlob.arrayBuffer()));
const originalViewport = page.getViewport({ scale: 1 });
const pdfPage = outputPdf.addPage([originalViewport.width, originalViewport.height]);
pdfPage.drawImage(jpegImage, {
x: 0,
y: 0,
width: pdfPage.getWidth(),
height: pdfPage.getHeight()
});
}
return await outputPdf.save();
} finally {
await loadingTask.destroy();
}
};
+52
View File
@@ -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
);
};
+6
View File
@@ -6,6 +6,12 @@ export type Tool = {
}; };
export const tools: Tool[] = [ export const tools: Tool[] = [
{
slug: 'stamp',
title: 'Stempeln',
description: 'Versehen Sie Seiten mit Textstempeln wie „Beglaubigt“ oder „Eilt“.',
icon: 'stamp'
},
{ {
slug: 'watermark', slug: 'watermark',
title: 'Wasserzeichen', title: 'Wasserzeichen',
+3 -1
View File
@@ -8,6 +8,7 @@
Minimize2, Minimize2,
Scissors, Scissors,
Merge, Merge,
Stamp,
FileText FileText
} from '@lucide/svelte'; } from '@lucide/svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -23,7 +24,8 @@
image: Image, image: Image,
minimize: Minimize2, minimize: Minimize2,
scissors: Scissors, scissors: Scissors,
merge: Merge merge: Merge,
stamp: Stamp
}; };
let isDraggingFiles = $state(false); let isDraggingFiles = $state(false);
+129 -176
View File
@@ -1,140 +1,99 @@
<script lang="ts"> <script lang="ts">
import { PDFDocument } from 'pdf-lib'; import BatchFileList from '$lib/components/BatchFileList.svelte';
import FileDropzone from '$lib/components/FileDropzone.svelte'; import FileDropzone from '$lib/components/FileDropzone.svelte';
import RasterizationWarning from '$lib/components/RasterizationWarning.svelte';
import Button from '$lib/components/ui/button.svelte'; import Button from '$lib/components/ui/button.svelte';
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
import { downloadBlob } from '$lib/download'; import { downloadBlob } from '$lib/download';
import { isRasterizationTooLarge } from '$lib/rasterization-limits'; import {
compressWithPreset,
compressionPresets,
defaultCompressionPreset
} from '$lib/pdf-compression';
import { isRasterizationTooLarge, RASTERIZATION_MAX_BYTES } from '$lib/rasterization-limits';
import { AlertTriangle } from '@lucide/svelte'; import { AlertTriangle } from '@lucide/svelte';
type CompressionPreset = { let items = $state<BatchItem[]>([]);
id: string; let selectedPreset = $state(defaultCompressionPreset.id);
label: string; let isRunning = $state(false);
description: string;
scale: number;
quality: number;
};
const presets: CompressionPreset[] = [ const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
{ const canStart = $derived(pendingCount > 0 && !isRunning);
id: 'strong',
label: 'Starke Komprimierung',
description: '~72 DPI, JPEG 50%',
scale: 1,
quality: 0.5
},
{
id: 'balanced',
label: 'Ausgewogen',
description: '~108 DPI, JPEG 70%',
scale: 1.5,
quality: 0.7
},
{ id: 'light', label: 'Leicht', description: '~144 DPI, JPEG 85%', scale: 2, quality: 0.85 }
];
let pdfFile: File | null = $state(null); const handleFilesSelected = (files: File[]) => {
let selectedPreset = $state('balanced'); const knownKeys = new Set(items.map((item) => `${item.file.name}:${item.file.size}`));
let isProcessing = $state(false); const additions = files
let error = $state<string | null>(null); .filter((file) => !knownKeys.has(`${file.name}:${file.size}`))
let compressedBytes = $state<Uint8Array | null>(null); .map((file) => {
let compressedFileName = $state(''); const item = createBatchItem(file);
const rasterizationBlocked = $derived(isRasterizationTooLarge(pdfFile)); if (isRasterizationTooLarge(file)) {
item.status = 'error';
const handleFileSelected = (files: File[]) => { item.errorMessage = `Diese Datei ist größer als ${RASTERIZATION_MAX_BYTES / (1024 * 1024)} MB und kann aus Speichergründen nicht im Browser gerastert werden.`;
pdfFile = files[0] ?? null;
error = null;
compressedBytes = null;
compressedFileName = '';
};
const formatBytes = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const compressPdf = async () => {
if (!pdfFile) return;
const preset = presets.find((p) => p.id === selectedPreset) ?? presets[1];
isProcessing = true;
error = null;
compressedBytes = null;
try {
const pdfjsLib = await import('pdfjs-dist');
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
(m) => m.default
);
const bytes = new Uint8Array(await pdfFile.arrayBuffer());
const loadingTask = getDocument({ data: bytes });
try {
const pdfjsDoc = await loadingTask.promise;
const outputPdf = await PDFDocument.create();
for (let pageNumber = 1; pageNumber <= pdfjsDoc.numPages; pageNumber += 1) {
const page = await pdfjsDoc.getPage(pageNumber);
const viewport = page.getViewport({ scale: preset.scale });
const canvas = document.createElement('canvas');
canvas.width = Math.ceil(viewport.width);
canvas.height = Math.ceil(viewport.height);
const canvasContext = canvas.getContext('2d');
if (!canvasContext) throw new Error('Could not create canvas context');
await page.render({
canvas,
canvasContext,
viewport,
background: '#ffffff'
}).promise;
const jpegBlob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(result) => (result ? resolve(result) : reject(new Error('JPEG encode failed'))),
'image/jpeg',
preset.quality
);
});
const jpegImage = await outputPdf.embedJpg(new Uint8Array(await jpegBlob.arrayBuffer()));
const originalViewport = page.getViewport({ scale: 1 });
const pdfPage = outputPdf.addPage([originalViewport.width, originalViewport.height]);
pdfPage.drawImage(jpegImage, {
x: 0,
y: 0,
width: pdfPage.getWidth(),
height: pdfPage.getHeight()
});
} }
return item;
});
items = [...items, ...additions];
};
compressedBytes = await outputPdf.save(); const updateItem = (id: string, patch: Partial<BatchItem>) => {
compressedFileName = `${pdfFile.name.replace(/\.pdf$/i, '')}_komprimiert.pdf`; items = items.map((item) => (item.id === id ? { ...item, ...patch } : item));
} finally { };
await loadingTask.destroy();
} const processFile = async (file: File) => {
} catch (err) { // The preset is read per file so an option change during a run only
console.error('Compression failed:', err); // affects the still pending files.
error = 'Fehler beim Komprimieren des PDFs.'; const preset =
compressionPresets.find((entry) => entry.id === selectedPreset) ?? defaultCompressionPreset;
const bytes = await compressWithPreset(file, preset);
return { bytes, name: `${file.name.replace(/\.pdf$/i, '')}_komprimiert.pdf` };
};
const startBatch = async () => {
if (isRunning) return;
const queue = items.filter((item) => item.status === 'pending');
if (queue.length === 0) return;
isRunning = true;
try {
await runBatch(queue, processFile, updateItem, {
errorMessage: 'Diese PDF konnte nicht komprimiert werden.',
// Skip rows removed while they were still waiting in the queue.
shouldProcess: (id) => items.some((item) => item.id === id)
});
} finally { } finally {
isProcessing = false; isRunning = false;
} }
}; };
const downloadCompressedPdf = () => { const retryItem = async (id: string) => {
if (compressedBytes) downloadBlob(compressedBytes, compressedFileName); if (isRunning) return;
const item = items.find((entry) => entry.id === id);
if (!item) return;
isRunning = true;
try {
await runBatch([item], processFile, updateItem, {
errorMessage: 'Diese PDF konnte nicht komprimiert werden.'
});
} finally {
isRunning = false;
}
};
const removeItem = (id: string) => {
items = items.filter((item) => item.id !== id);
};
const downloadItem = (item: BatchItem) => {
if (item.resultBytes && item.resultName) downloadBlob(item.resultBytes, item.resultName);
}; };
</script> </script>
<div class="mx-auto max-w-4xl space-y-6"> <div class="mx-auto max-w-4xl space-y-6">
<div> <div>
<h1 class="text-2xl font-bold text-primary-900">Komprimieren</h1> <h1 class="text-2xl font-bold text-foreground">Komprimieren</h1>
<p class="mt-2 text-sm text-primary-700"> <p class="mt-2 text-sm text-primary">
Reduzieren Sie die Dateigröße Ihres PDFs durch Komprimierung. Reduzieren Sie die Dateigröße Ihrer PDFs durch Komprimierung einzeln oder mehrere in einem
Durchlauf.
</p> </p>
</div> </div>
@@ -149,70 +108,64 @@
</div> </div>
</div> </div>
{#if !pdfFile} <FileDropzone
<FileDropzone onFilesSelected={handleFileSelected} /> onFilesSelected={handleFilesSelected}
{:else} multiple
<div class="space-y-6"> label={items.length === 0 ? 'Dateien hinzufügen' : 'Weitere Dateien ablegen'}
<FileDropzone sublabel={items.length === 0
onFilesSelected={handleFileSelected} ? 'PDFs hier ablegen oder zum Auswählen klicken'
label="Anderes PDF ablegen" : 'Weitere PDFs hinzufügen bestehende Dateien bleiben erhalten'}
sublabel={pdfFile.name} />
class="py-4"
/>
<RasterizationWarning file={pdfFile} />
<div class="rounded-xl border border-primary-200 bg-white p-4 space-y-4">
<h2 class="text-lg font-semibold text-primary-900">{pdfFile.name}</h2>
<p class="text-sm text-primary-700">Dateigröße: {formatBytes(pdfFile.size)}</p>
<div class="space-y-2"> {#if items.length > 0}
<p class="text-sm font-medium text-primary-900">Komprimierungsstufe</p> <div class="rounded-2xl border border-border bg-card p-4 space-y-4">
{#each presets as preset} <h2 class="text-lg font-semibold text-foreground">Komprimierungsstufe</h2>
<label <p class="text-xs text-primary">Die Einstellung gilt für alle Dateien der Liste.</p>
class="flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition {selectedPreset ===
preset.id
? 'border-primary bg-primary/5'
: 'border-primary-200 hover:border-primary-300'}"
>
<input
type="radio"
name="compression-preset"
value={preset.id}
bind:group={selectedPreset}
onchange={() => {
compressedBytes = null;
compressedFileName = '';
}}
class="mt-0.5 h-4 w-4"
/>
<div>
<p class="text-sm font-medium text-primary-900">{preset.label}</p>
<p class="text-xs text-primary-700">{preset.description}</p>
</div>
</label>
{/each}
</div>
{#if compressedBytes} <div class="space-y-2">
<p class="text-sm text-primary-700"> {#each compressionPresets as preset (preset.id)}
Ergebnisgröße: <span class="font-semibold">{formatBytes(compressedBytes.length)}</span> <label
</p> class="flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition {selectedPreset ===
{/if} preset.id
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary-300'}"
>
<input
type="radio"
name="compression-preset"
value={preset.id}
bind:group={selectedPreset}
class="mt-0.5 h-4 w-4"
/>
<div>
<p class="text-sm font-medium text-foreground">{preset.label}</p>
<p class="text-xs text-primary">{preset.description}</p>
</div>
</label>
{/each}
</div> </div>
{#if error} {#if isRunning}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800"> <p class="text-xs text-primary" role="note">
{error} Änderungen an der Komprimierungsstufe gelten für die noch ausstehenden Dateien.
</div> </p>
{/if} {/if}
</div>
<div class="flex justify-end gap-2"> {#if items.length > 0}
<Button onclick={compressPdf} disabled={isProcessing || rasterizationBlocked}> <BatchFileList
{isProcessing ? 'Wird komprimiert...' : 'Komprimieren'} {items}
</Button> zipBaseName="komprimiert"
{#if compressedBytes} onDownload={downloadItem}
<Button onclick={downloadCompressedPdf}>PDF herunterladen</Button> onRemove={removeItem}
{/if} onRetry={retryItem}
</div> />
{/if}
<div class="flex justify-end">
<Button onclick={startBatch} disabled={!canStart}>
{isRunning ? 'Wird komprimiert...' : 'Komprimieren'}
</Button>
</div> </div>
{/if} {/if}
</div> </div>
+167 -104
View File
@@ -1,96 +1,144 @@
<script lang="ts"> <script lang="ts">
import { PDFDocument } from 'pdf-lib'; import { PDFDocument } from 'pdf-lib';
import BatchFileList from '$lib/components/BatchFileList.svelte';
import FileDropzone from '$lib/components/FileDropzone.svelte'; import FileDropzone from '$lib/components/FileDropzone.svelte';
import RasterizationWarning from '$lib/components/RasterizationWarning.svelte';
import Button from '$lib/components/ui/button.svelte'; import Button from '$lib/components/ui/button.svelte';
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
import { downloadBlob } from '$lib/download'; import { downloadBlob } from '$lib/download';
import { isRasterizationTooLarge } from '$lib/rasterization-limits'; import { isRasterizationTooLarge, RASTERIZATION_MAX_BYTES } from '$lib/rasterization-limits';
import { Unlock, AlertTriangle } from '@lucide/svelte'; import { Unlock, AlertTriangle } from '@lucide/svelte';
let pdfFile: File | null = $state(null); let items = $state<BatchItem[]>([]);
let password = $state(''); let password = $state('');
let isProcessing = $state(false); let isRunning = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
const rasterizationBlocked = $derived(isRasterizationTooLarge(pdfFile));
const handleFileSelected = (files: File[]) => { const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
pdfFile = files[0] ?? null; const canRun = $derived(!isRunning && pendingCount > 0 && password.length > 0);
const handleFilesSelected = (files: File[]) => {
const knownKeys = new Set(items.map((item) => `${item.file.name}:${item.file.size}`));
const additions = files
.filter((file) => !knownKeys.has(`${file.name}:${file.size}`))
.map((file) => {
const item = createBatchItem(file);
if (isRasterizationTooLarge(file)) {
item.status = 'error';
item.errorMessage = `Diese Datei ist größer als ${RASTERIZATION_MAX_BYTES / (1024 * 1024)} MB und kann aus Speichergründen nicht im Browser gerastert werden.`;
}
return item;
});
items = [...items, ...additions];
error = null; error = null;
}; };
const decryptPdf = async () => { const updateItem = (id: string, patch: Partial<BatchItem>) => {
if (!pdfFile) return; items = items.map((item) => (item.id === id ? { ...item, ...patch } : item));
};
isProcessing = true; const processFile = async (file: File) => {
error = null; const pdfjsLib = await import('pdfjs-dist');
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
(module) => module.default
);
const loadingTask = getDocument({
data: new Uint8Array(await file.arrayBuffer()),
password
});
try { try {
const pdfjsLib = await import('pdfjs-dist'); const pdfDocument = await loadingTask.promise;
const { getDocument, GlobalWorkerOptions } = pdfjsLib; const outputPdf = await PDFDocument.create();
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
(module) => module.default
);
const loadingTask = getDocument({ for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) {
data: new Uint8Array(await pdfFile.arrayBuffer()), const page = await pdfDocument.getPage(pageNumber);
password const viewport = page.getViewport({ scale: 2 });
}); const canvas = document.createElement('canvas');
canvas.width = Math.ceil(viewport.width);
canvas.height = Math.ceil(viewport.height);
const canvasContext = canvas.getContext('2d');
if (!canvasContext) throw new Error('Could not create canvas context');
try { await page.render({
const pdfDocument = await loadingTask.promise; canvas,
const outputPdf = await PDFDocument.create(); canvasContext,
viewport,
for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) { background: '#ffffff'
const page = await pdfDocument.getPage(pageNumber); }).promise;
const viewport = page.getViewport({ scale: 2 }); const pngBlob = await new Promise<Blob>((resolve, reject) => {
const canvas = document.createElement('canvas'); canvas.toBlob(
canvas.width = Math.ceil(viewport.width); (result) => (result ? resolve(result) : reject(new Error('PNG encode failed'))),
canvas.height = Math.ceil(viewport.height); 'image/png'
const canvasContext = canvas.getContext('2d'); );
if (!canvasContext) throw new Error('Could not create canvas context'); });
const pngImage = await outputPdf.embedPng(new Uint8Array(await pngBlob.arrayBuffer()));
await page.render({ const originalViewport = page.getViewport({ scale: 1 });
canvas, const pdfPage = outputPdf.addPage([originalViewport.width, originalViewport.height]);
canvasContext, pdfPage.drawImage(pngImage, {
viewport, x: 0,
background: '#ffffff' y: 0,
}).promise; width: pdfPage.getWidth(),
const pngBlob = await new Promise<Blob>((resolve, reject) => { height: pdfPage.getHeight()
canvas.toBlob( });
(result) => (result ? resolve(result) : reject(new Error('PNG encode failed'))),
'image/png'
);
});
const pngImage = await outputPdf.embedPng(new Uint8Array(await pngBlob.arrayBuffer()));
const originalViewport = page.getViewport({ scale: 1 });
const pdfPage = outputPdf.addPage([originalViewport.width, originalViewport.height]);
pdfPage.drawImage(pngImage, {
x: 0,
y: 0,
width: pdfPage.getWidth(),
height: pdfPage.getHeight()
});
}
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
downloadBlob(await outputPdf.save(), `${baseName}_entsperrt.pdf`);
} finally {
await loadingTask.destroy();
} }
} catch (decryptionError) {
console.error('Decryption failed:', decryptionError); const bytes = await outputPdf.save();
error = 'Fehler beim Entsperren. Bitte prüfen Sie das Passwort und versuchen Sie es erneut.'; return { bytes, name: `${file.name.replace(/\.pdf$/i, '')}_entsperrt.pdf` };
} finally { } finally {
isProcessing = false; await loadingTask.destroy();
} }
}; };
const startBatch = async () => {
if (isRunning) return;
const queue = items.filter((item) => item.status === 'pending');
if (queue.length === 0) return;
isRunning = true;
try {
await runBatch(queue, processFile, updateItem, {
errorMessage:
'Fehler beim Entsperren. Bitte prüfen Sie das Passwort und versuchen Sie es erneut.',
// Skip rows removed while they were still waiting in the queue.
shouldProcess: (id) => items.some((item) => item.id === id)
});
} finally {
isRunning = false;
}
};
const retryItem = async (id: string) => {
if (isRunning) return;
const item = items.find((entry) => entry.id === id);
if (!item) return;
isRunning = true;
try {
await runBatch([item], processFile, updateItem, {
errorMessage:
'Fehler beim Entsperren. Bitte prüfen Sie das Passwort und versuchen Sie es erneut.'
});
} finally {
isRunning = false;
}
};
const removeItem = (id: string) => {
items = items.filter((item) => item.id !== id);
};
const downloadItem = (item: BatchItem) => {
if (item.resultBytes && item.resultName) downloadBlob(item.resultBytes, item.resultName);
};
</script> </script>
<div class="mx-auto max-w-4xl space-y-6"> <div class="mx-auto max-w-4xl space-y-6">
<div> <div>
<h1 class="text-2xl font-bold text-primary-900">Passwort entfernen</h1> <h1 class="text-2xl font-bold text-foreground">Passwort entfernen</h1>
<p class="mt-2 text-sm text-primary-700"> <p class="mt-2 text-sm text-primary">
Entfernen Sie den Passwortschutz von einem PDF-Dokument. Entfernen Sie den Passwortschutz von PDF-Dokumenten einzeln oder mehrere in einem Durchlauf.
</p> </p>
</div> </div>
@@ -105,48 +153,63 @@
</div> </div>
</div> </div>
{#if !pdfFile} <FileDropzone
<FileDropzone onFilesSelected={handleFileSelected} /> onFilesSelected={handleFilesSelected}
{:else} multiple
<div class="space-y-6"> label={items.length === 0 ? 'Dateien hinzufügen' : 'Weitere Dateien ablegen'}
<FileDropzone sublabel={items.length === 0
onFilesSelected={handleFileSelected} ? 'PDFs hier ablegen oder zum Auswählen klicken'
label="Anderes PDF ablegen" : 'Weitere PDFs hinzufügen bestehende Dateien bleiben erhalten'}
sublabel={pdfFile.name} />
class="py-4"
/>
<RasterizationWarning file={pdfFile} />
<div class="rounded-xl border border-primary-200 bg-white p-4 space-y-4">
<div class="flex items-center gap-2 text-primary-900">
<Unlock class="h-5 w-5" />
<h2 class="text-lg font-semibold">{pdfFile.name}</h2>
</div>
<div> {#if items.length > 0}
<label class="block text-sm font-medium text-primary-900 mb-1" for="decrypt-password"> <div class="rounded-2xl border border-border bg-card p-4 space-y-4">
Passwort <div class="flex items-center gap-2 text-foreground">
</label> <Unlock class="h-5 w-5" />
<input <h2 class="text-lg font-semibold">Passwort</h2>
id="decrypt-password" </div>
type="password" <p class="text-xs text-primary">Das Passwort gilt für alle Dateien der Liste.</p>
bind:value={password}
class="w-full rounded-lg border border-primary-200 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" <div>
placeholder="Passwort des PDFs eingeben" <label class="block text-sm font-medium text-foreground mb-1" for="decrypt-password">
/> Passwort
</div> </label>
<input
id="decrypt-password"
type="password"
bind:value={password}
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
placeholder="Passwort der PDFs eingeben"
/>
</div> </div>
{#if error} {#if isRunning}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800"> <p class="text-xs text-primary" role="note">
{error} Änderungen am Passwort gelten für die noch ausstehenden Dateien.
</div> </p>
{/if} {/if}
</div>
<div class="flex justify-end gap-2"> <BatchFileList
<Button onclick={decryptPdf} disabled={isProcessing || !password || rasterizationBlocked}> {items}
{isProcessing ? 'Wird entsperrt...' : 'Passwort entfernen & herunterladen'} zipBaseName="entsperrt"
</Button> onDownload={downloadItem}
</div> onRemove={removeItem}
onRetry={retryItem}
/>
{/if}
{#if error}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
{error}
</div>
{/if}
{#if items.length > 0}
<div class="flex justify-end">
<Button onclick={startBatch} disabled={!canRun}>
{isRunning ? 'Wird entsperrt...' : 'Passwort entfernen'}
</Button>
</div> </div>
{/if} {/if}
</div> </div>
+151 -109
View File
@@ -1,150 +1,192 @@
<script lang="ts"> <script lang="ts">
import { PDFDocument } from '@cantoo/pdf-lib'; import { PDFDocument } from '@cantoo/pdf-lib';
import BatchFileList from '$lib/components/BatchFileList.svelte';
import FileDropzone from '$lib/components/FileDropzone.svelte'; import FileDropzone from '$lib/components/FileDropzone.svelte';
import Button from '$lib/components/ui/button.svelte'; import Button from '$lib/components/ui/button.svelte';
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
import { downloadBlob } from '$lib/download'; import { downloadBlob } from '$lib/download';
import { Lock } from '@lucide/svelte'; import { Lock } from '@lucide/svelte';
let pdfFile: File | null = $state(null); let items = $state<BatchItem[]>([]);
let password = $state(''); let password = $state('');
let confirmPassword = $state(''); let confirmPassword = $state('');
let allowPrinting = $state(true); let allowPrinting = $state(true);
let allowCopying = $state(true); let allowCopying = $state(true);
let isProcessing = $state(false); let isRunning = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
const handleFileSelected = (files: File[]) => { const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
pdfFile = files[0] ?? null; const canRun = $derived(
!isRunning && pendingCount > 0 && password.length > 0 && password === confirmPassword
);
const handleFilesSelected = (files: File[]) => {
const knownKeys = new Set(items.map((item) => `${item.file.name}:${item.file.size}`));
const additions = files
.filter((file) => !knownKeys.has(`${file.name}:${file.size}`))
.map((file) => createBatchItem(file));
items = [...items, ...additions];
error = null; error = null;
}; };
const encryptPdf = async () => { const updateItem = (id: string, patch: Partial<BatchItem>) => {
if (!pdfFile || !password) return; items = items.map((item) => (item.id === id ? { ...item, ...patch } : item));
};
if (password !== confirmPassword) { const processFile = async (file: File) => {
error = 'Passwörter stimmen nicht überein.'; // The password and permissions are read per file so a change during a
return; // run only affects the still pending files.
} const bytes = new Uint8Array(await file.arrayBuffer());
const pdfDoc = await PDFDocument.load(bytes);
if (password.length < 1) { pdfDoc.encrypt({
error = 'Bitte geben Sie ein Passwort ein.'; userPassword: password,
return; ownerPassword: crypto.randomUUID(),
} permissions: {
printing: allowPrinting,
copying: allowCopying
}
});
isProcessing = true; const encryptedBytes = await pdfDoc.save();
error = null; return { bytes: encryptedBytes, name: `${file.name.replace(/\.pdf$/i, '')}_geschuetzt.pdf` };
};
const startBatch = async () => {
if (isRunning) return;
const queue = items.filter((item) => item.status === 'pending');
if (queue.length === 0) return;
isRunning = true;
try { try {
const bytes = new Uint8Array(await pdfFile.arrayBuffer()); await runBatch(queue, processFile, updateItem, {
const pdfDoc = await PDFDocument.load(bytes); errorMessage: 'Diese PDF konnte nicht verschlüsselt werden.',
// Skip rows removed while they were still waiting in the queue.
pdfDoc.encrypt({ shouldProcess: (id) => items.some((item) => item.id === id)
userPassword: password,
ownerPassword: crypto.randomUUID(),
permissions: {
printing: allowPrinting,
copying: allowCopying
}
}); });
const encryptedBytes = await pdfDoc.save();
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
downloadBlob(encryptedBytes, `${baseName}_geschuetzt.pdf`);
} catch (err) {
console.error('Encryption failed:', err);
error = 'Fehler beim Verschlüsseln des PDFs.';
} finally { } finally {
isProcessing = false; isRunning = false;
} }
}; };
const retryItem = async (id: string) => {
if (isRunning) return;
const item = items.find((entry) => entry.id === id);
if (!item) return;
isRunning = true;
try {
await runBatch([item], processFile, updateItem, {
errorMessage: 'Diese PDF konnte nicht verschlüsselt werden.'
});
} finally {
isRunning = false;
}
};
const removeItem = (id: string) => {
items = items.filter((item) => item.id !== id);
};
const downloadItem = (item: BatchItem) => {
if (item.resultBytes && item.resultName) downloadBlob(item.resultBytes, item.resultName);
};
</script> </script>
<div class="mx-auto max-w-4xl space-y-6"> <div class="mx-auto max-w-4xl space-y-6">
<div> <div>
<h1 class="text-2xl font-bold text-primary-900">Passwort setzen</h1> <h1 class="text-2xl font-bold text-foreground">Passwort setzen</h1>
<p class="mt-2 text-sm text-primary-700"> <p class="mt-2 text-sm text-primary">
Verschlüsseln Sie Ihr PDF mit einem Passwort, um es vor unbefugtem Zugriff zu schützen. Verschlüsseln Sie Ihre PDFs mit einem Passwort, um sie vor unbefugtem Zugriff zu schützen
einzeln oder mehrere in einem Durchlauf.
</p> </p>
</div> </div>
{#if !pdfFile} <FileDropzone
<FileDropzone onFilesSelected={handleFileSelected} /> onFilesSelected={handleFilesSelected}
{:else} multiple
<div class="space-y-6"> label={items.length === 0 ? 'Dateien hinzufügen' : 'Weitere Dateien ablegen'}
<FileDropzone sublabel={items.length === 0
onFilesSelected={handleFileSelected} ? 'PDFs hier ablegen oder zum Auswählen klicken'
label="Anderes PDF ablegen" : 'Weitere PDFs hinzufügen bestehende Dateien bleiben erhalten'}
sublabel={pdfFile.name} />
class="py-4"
/>
<div class="rounded-xl border border-primary-200 bg-white p-4 space-y-4">
<div class="flex items-center gap-2 text-primary-900">
<Lock class="h-5 w-5" />
<h2 class="text-lg font-semibold">{pdfFile.name}</h2>
</div>
<div> {#if items.length > 0}
<label class="block text-sm font-medium text-primary-900 mb-1" for="encrypt-password"> <div class="rounded-2xl border border-border bg-card p-4 space-y-4">
Passwort <div class="flex items-center gap-2 text-foreground">
</label> <Lock class="h-5 w-5" />
<input <h2 class="text-lg font-semibold">Passwort</h2>
id="encrypt-password" </div>
type="password" <p class="text-xs text-primary">Das Passwort gilt für alle Dateien der Liste.</p>
bind:value={password}
class="w-full rounded-lg border border-primary-200 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
placeholder="Passwort eingeben"
/>
</div>
<div> <div>
<label <label class="block text-sm font-medium text-foreground mb-1" for="encrypt-password">
class="block text-sm font-medium text-primary-900 mb-1" Passwort
for="encrypt-password-confirmation" </label>
> <input
Passwort bestätigen id="encrypt-password"
</label> type="password"
<input bind:value={password}
id="encrypt-password-confirmation" class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
type="password" placeholder="Passwort eingeben"
bind:value={confirmPassword} />
class="w-full rounded-lg border border-primary-200 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
placeholder="Passwort wiederholen"
/>
</div>
<div class="space-y-2">
<p class="text-sm font-medium text-primary-900">Berechtigungen</p>
<div class="flex items-center gap-2">
<input
type="checkbox"
id="allow-printing"
bind:checked={allowPrinting}
class="h-4 w-4"
/>
<label for="allow-printing" class="text-sm text-primary-700">Drucken erlauben</label>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="allow-copying" bind:checked={allowCopying} class="h-4 w-4" />
<label for="allow-copying" class="text-sm text-primary-700">Kopieren erlauben</label>
</div>
</div>
</div> </div>
{#if error} <div>
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800"> <label
{error} class="block text-sm font-medium text-foreground mb-1"
</div> for="encrypt-password-confirmation"
{/if}
<div class="flex justify-end gap-2">
<Button
onclick={encryptPdf}
disabled={isProcessing || !password || password !== confirmPassword}
> >
{isProcessing ? 'Wird verschlüsselt...' : 'Passwort setzen & herunterladen'} Passwort bestätigen
</Button> </label>
<input
id="encrypt-password-confirmation"
type="password"
bind:value={confirmPassword}
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
placeholder="Passwort wiederholen"
/>
</div> </div>
<div class="space-y-2">
<p class="text-sm font-medium text-foreground">Berechtigungen</p>
<div class="flex items-center gap-2">
<input type="checkbox" id="allow-printing" bind:checked={allowPrinting} class="h-4 w-4" />
<label for="allow-printing" class="text-sm text-primary">Drucken erlauben</label>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="allow-copying" bind:checked={allowCopying} class="h-4 w-4" />
<label for="allow-copying" class="text-sm text-primary">Kopieren erlauben</label>
</div>
</div>
{#if isRunning}
<p class="text-xs text-primary" role="note">
Änderungen am Passwort gelten für die noch ausstehenden Dateien.
</p>
{/if}
</div>
<BatchFileList
{items}
zipBaseName="geschuetzt"
onDownload={downloadItem}
onRemove={removeItem}
onRetry={retryItem}
/>
{/if}
{#if error}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
{error}
</div>
{/if}
{#if items.length > 0}
<div class="flex justify-end">
<Button onclick={startBatch} disabled={!canRun}>
{isRunning ? 'Wird verschlüsselt...' : 'Passwort setzen'}
</Button>
</div> </div>
{/if} {/if}
</div> </div>
+713
View File
@@ -0,0 +1,713 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { ChevronLeft, ChevronRight, Stamp } from '@lucide/svelte';
import { degrees, PDFDocument, StandardFonts, type PDFFont } 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 { getPositionCoords, parseHexColor, type OverlayPosition } from '$lib/pdf-overlay';
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
const PRESETS = ['BEGLAUBIGT', 'AUSFERTIGUNG', 'KOPIE', 'EILT', 'ENTWURF', 'ERLEDIGT'] as const;
const PADDING = 8;
const PAGE_MARGIN = 20;
const MIN_EXPORT_FONT_SIZE = 4;
const MIN_EXPORT_IMAGE_SIZE = 20;
type StampImage = {
name: string;
bytes: Uint8Array;
mimeType: 'image/png' | 'image/jpeg';
objectUrl: string;
width: number;
height: number;
};
let pdfFile: File | null = $state(null);
let pdfHandle: PdfDocumentHandle | null = $state(null);
let stampInput = $state('BEGLAUBIGT');
let withDate = $state(false);
let fontSize = $state(24);
let stampImage = $state<StampImage | null>(null);
let isLoadingImage = $state(false);
let withBorder = $state(false);
let imageSize = $state(120);
let stampColor = $state('#cc1a1a');
let position = $state<OverlayPosition>('top-right');
let rotation = $state(0);
let applyToAll = $state(true);
let selectedPages = $state(new Set<number>());
let currentPage = $state(1);
let isLoading = $state(false);
let isProcessing = $state(false);
let error = $state<string | null>(null);
let handleGeneration = 0;
const formatGermanDate = (date: Date) =>
[date.getDate(), date.getMonth() + 1, date.getFullYear()]
.map((part, index) => (index < 2 ? String(part).padStart(2, '0') : String(part)))
.join('.');
const buildStampText = (input: string, appendDate: boolean, date: Date) => {
const uppercaseText = input.trim().toLocaleUpperCase('de-DE');
return `${uppercaseText}${appendDate ? ` ${formatGermanDate(date)}` : ''}`;
};
const previewText = $derived(buildStampText(stampInput, withDate, new Date()));
const selectedPreset = $derived(
PRESETS.find((preset) => preset === stampInput.trim().toLocaleUpperCase('de-DE'))
);
const canProcess = $derived(
pdfFile !== null &&
pdfHandle !== null &&
(stampImage !== null || previewText.length > 0) &&
!isLoading &&
!isLoadingImage &&
!isProcessing &&
(applyToAll || selectedPages.size > 0)
);
const previewFontSize = $derived(
Math.max(6, Math.min(Number(fontSize) * 0.5, 240 / Math.max(8, previewText.length * 0.62)))
);
const isEncryptedPdfError = (loadError: unknown) => {
const message =
loadError instanceof Error ? `${loadError.name} ${loadError.message}` : String(loadError);
return /password|encrypted|encryption|verschl/i.test(message);
};
const loadErrorMessage = (loadError: unknown) =>
isEncryptedPdfError(loadError)
? 'Dieses PDF ist passwortgeschützt. Entfernen Sie zuerst unter „Passwort entfernen“ den Schutz.'
: 'PDF konnte nicht geladen werden. Prüfen Sie, ob die Datei gültig ist.';
const handleFileSelected = async (files: File[]) => {
const file = files[0];
if (!file) return;
const generation = ++handleGeneration;
pdfHandle?.destroy();
pdfHandle = null;
pdfFile = file;
selectedPages = new Set();
currentPage = 1;
error = null;
isLoading = true;
try {
const handle = await loadPdfDocument(new Uint8Array(await file.arrayBuffer()));
if (generation !== handleGeneration) {
handle.destroy();
return;
}
pdfHandle = handle;
} catch (loadError) {
console.error('Failed to load PDF for stamping:', loadError);
if (generation === handleGeneration) error = loadErrorMessage(loadError);
} finally {
if (generation === handleGeneration) isLoading = false;
}
};
const selectPreset = (preset: (typeof PRESETS)[number]) => {
stampInput = preset;
};
const clearStampImage = () => {
if (stampImage) URL.revokeObjectURL(stampImage.objectUrl);
stampImage = null;
withBorder = false;
};
const detectImageMimeType = (bytes: Uint8Array): 'image/png' | 'image/jpeg' | null => {
if (
bytes.length > 8 &&
bytes[0] === 0x89 &&
bytes[1] === 0x50 &&
bytes[2] === 0x4e &&
bytes[3] === 0x47
) {
return 'image/png';
}
if (bytes.length > 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return 'image/jpeg';
}
return null;
};
const handleImageSelected = async (event: Event) => {
const fileInput = event.target as HTMLInputElement;
const file = fileInput.files?.[0];
if (!file) return;
isLoadingImage = true;
error = null;
try {
const bytes = new Uint8Array(await file.arrayBuffer());
const mimeType = detectImageMimeType(bytes);
if (!mimeType) {
fileInput.value = '';
error = 'Das Stempelbild muss eine PNG- oder JPG-Datei sein.';
return;
}
const bitmap = await createImageBitmap(new Blob([bytes], { type: mimeType }));
const dimensions = { width: bitmap.width, height: bitmap.height };
bitmap.close();
if (stampImage) URL.revokeObjectURL(stampImage.objectUrl);
stampImage = {
name: file.name,
bytes,
mimeType,
objectUrl: URL.createObjectURL(file),
...dimensions
};
withBorder = false;
} catch {
fileInput.value = '';
error =
'Das Stempelbild konnte nicht gelesen werden. Verwenden Sie eine gültige PNG- oder JPG-Datei.';
} finally {
isLoadingImage = false;
}
};
const togglePage = (pageNumber: number) => {
const next = new Set(selectedPages);
if (next.has(pageNumber)) next.delete(pageNumber);
else next.add(pageNumber);
selectedPages = next;
};
const showPreviousPage = () => {
currentPage = Math.max(1, currentPage - 1);
};
const showNextPage = () => {
if (!pdfHandle) return;
currentPage = Math.min(pdfHandle.pageCount, currentPage + 1);
};
const getPreviewPositionStyle = (previewPosition: OverlayPosition) => {
switch (previewPosition) {
case 'center':
return 'left: 50%; top: 50%; transform: translate(-50%, -50%);';
case 'top-left':
return 'left: 5%; top: 5%;';
case 'top-center':
return 'left: 50%; top: 5%; transform: translateX(-50%);';
case 'top-right':
return 'right: 5%; top: 5%;';
case 'bottom-left':
return 'bottom: 5%; left: 5%;';
case 'bottom-center':
return 'bottom: 5%; left: 50%; transform: translateX(-50%);';
case 'bottom-right':
return 'bottom: 5%; right: 5%;';
}
};
const getRotatedStampPlacement = (
pageWidth: number,
pageHeight: number,
boxWidth: number,
boxHeight: number,
stampPosition: OverlayPosition,
angle: number
) => {
const radians = (angle * Math.PI) / 180;
const cosine = Math.cos(radians);
const sine = Math.sin(radians);
const rotatedCorners = [
{ x: 0, y: 0 },
{ x: boxWidth * cosine, y: boxWidth * sine },
{ x: -boxHeight * sine, y: boxHeight * cosine },
{
x: boxWidth * cosine - boxHeight * sine,
y: boxWidth * sine + boxHeight * cosine
}
];
const minX = Math.min(...rotatedCorners.map((corner) => corner.x));
const maxX = Math.max(...rotatedCorners.map((corner) => corner.x));
const minY = Math.min(...rotatedCorners.map((corner) => corner.y));
const maxY = Math.max(...rotatedCorners.map((corner) => corner.y));
const boundingCoordinates = getPositionCoords(
pageWidth,
pageHeight,
maxX - minX,
maxY - minY,
stampPosition,
PAGE_MARGIN
);
return {
x: boundingCoordinates.x - minX,
y: boundingCoordinates.y - minY,
cosine,
sine
};
};
const fitStampToPage = (
font: PDFFont,
text: string,
requestedSize: number,
pageWidth: number,
pageHeight: number
) => {
const availableWidth = pageWidth - PAGE_MARGIN * 2;
const availableHeight = pageHeight - PAGE_MARGIN * 2;
let size = requestedSize;
while (size > MIN_EXPORT_FONT_SIZE) {
const width = font.widthOfTextAtSize(text, size) + PADDING * 2;
const height = font.heightAtSize(size) + PADDING * 2;
if (width <= availableWidth && height <= availableHeight) break;
size = Math.max(MIN_EXPORT_FONT_SIZE, size - 0.5);
}
const textWidth = font.widthOfTextAtSize(text, size);
const textHeight = font.heightAtSize(size);
if (textWidth + PADDING * 2 > availableWidth || textHeight + PADDING * 2 > availableHeight) {
throw new Error(
'Der Stempeltext ist für mindestens eine Seite zu lang. Kürzen Sie den Text.'
);
}
return {
size,
textHeight,
boxWidth: textWidth + PADDING * 2,
boxHeight: textHeight + PADDING * 2
};
};
const getImageStampBox = (
image: { width: number; height: number },
pageWidth: number,
pageHeight: number
) => {
const padding = withBorder ? PADDING : 0;
const aspectRatio = image.width / image.height;
let drawHeight = Number(imageSize);
let drawWidth = drawHeight * aspectRatio;
const availableWidth = pageWidth - PAGE_MARGIN * 2 - padding * 2;
const availableHeight = pageHeight - PAGE_MARGIN * 2 - padding * 2;
if (drawWidth > availableWidth || drawHeight > availableHeight) {
const scale = Math.min(availableWidth / drawWidth, availableHeight / drawHeight);
drawWidth *= scale;
drawHeight *= scale;
}
if (drawHeight < MIN_EXPORT_IMAGE_SIZE) {
drawHeight = MIN_EXPORT_IMAGE_SIZE;
drawWidth = drawHeight * aspectRatio;
}
return {
drawWidth,
drawHeight,
boxWidth: drawWidth + padding * 2,
boxHeight: drawHeight + padding * 2,
padding
};
};
const applyStamp = async () => {
if (!pdfFile || !pdfHandle || !canProcess) return;
isProcessing = true;
error = null;
try {
const stampText = buildStampText(stampInput, withDate, new Date());
const pdfDoc = await PDFDocument.load(new Uint8Array(await pdfFile.arrayBuffer()));
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const embeddedImage = stampImage
? stampImage.mimeType === 'image/png'
? await pdfDoc.embedPng(stampImage.bytes)
: await pdfDoc.embedJpg(stampImage.bytes)
: null;
const pages = pdfDoc.getPages();
const pageIndexes = applyToAll
? pages.map((_, index) => index)
: Array.from(selectedPages, (pageNumber) => pageNumber - 1);
const color = parseHexColor(stampColor);
for (const pageIndex of pageIndexes) {
const page = pages[pageIndex];
if (!page) continue;
const { width, height } = page.getSize();
const angle = Number(rotation);
const box = embeddedImage
? getImageStampBox(embeddedImage, width, height)
: fitStampToPage(font, stampText, Number(fontSize), width, height);
const placement = getRotatedStampPlacement(
width,
height,
box.boxWidth,
box.boxHeight,
position,
angle
);
if (embeddedImage ? withBorder : true) {
page.drawRectangle({
x: placement.x,
y: placement.y,
width: box.boxWidth,
height: box.boxHeight,
borderColor: color,
borderWidth: 2,
rotate: degrees(angle)
});
}
if (embeddedImage) {
const imageBox = box as { drawWidth: number; drawHeight: number; padding: number };
const offsetX = imageBox.padding;
const offsetY = imageBox.padding;
page.drawImage(embeddedImage, {
x: placement.x + offsetX * placement.cosine - offsetY * placement.sine,
y: placement.y + offsetX * placement.sine + offsetY * placement.cosine,
width: imageBox.drawWidth,
height: imageBox.drawHeight,
rotate: degrees(angle)
});
} else {
const textOffsetX = PADDING;
const textOffsetY = PADDING + (box as { textHeight: number }).textHeight * 0.2;
page.drawText(stampText, {
x: placement.x + textOffsetX * placement.cosine - textOffsetY * placement.sine,
y: placement.y + textOffsetX * placement.sine + textOffsetY * placement.cosine,
size: (box as { size: number }).size,
font,
color,
rotate: degrees(angle)
});
}
}
const resultBytes = await pdfDoc.save();
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
downloadBlob(resultBytes, `${baseName}_stempel.pdf`);
} catch (processingError) {
console.error('Failed to stamp PDF:', processingError);
error = isEncryptedPdfError(processingError)
? loadErrorMessage(processingError)
: processingError instanceof Error && processingError.message.startsWith('Der Stempeltext')
? processingError.message
: 'Der Stempel konnte nicht angewendet werden. Prüfen Sie die PDF-Datei.';
} finally {
isProcessing = false;
}
};
onDestroy(() => {
handleGeneration += 1;
pdfHandle?.destroy();
if (stampImage) URL.revokeObjectURL(stampImage.objectUrl);
});
</script>
<svelte:head>
<title>PDF stempeln — beA-Edit</title>
</svelte:head>
<div class="mx-auto flex max-w-5xl flex-col gap-6 pb-10">
<div>
<div class="mb-2 flex items-center gap-2 text-secondary">
<Stamp class="size-5" aria-hidden="true" />
<span class="text-xs font-extrabold tracking-[0.18em] uppercase">Dokumentenvermerk</span>
</div>
<h1 class="text-2xl font-bold text-foreground">Stempeln</h1>
<p class="mt-2 max-w-2xl text-sm text-primary">
Setzen Sie einen Text- oder Bildstempel auf alle oder ausgewählte PDF-Seiten.
</p>
</div>
<FileDropzone
onFilesSelected={handleFileSelected}
label={pdfHandle ? 'Anderes PDF ablegen' : 'PDF zum Stempeln hinzufügen'}
sublabel={isLoading
? 'Vorschau wird geladen …'
: (pdfFile?.name ?? 'PDF hier ablegen oder auswählen')}
/>
{#if pdfHandle}
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]">
<section
class="flex flex-col gap-5 rounded-2xl border border-border bg-card p-5"
aria-labelledby="stamp-options-title"
>
<div>
<h2 id="stamp-options-title" class="text-lg font-semibold text-foreground">
Stempel einstellen
</h2>
<p class="mt-1 text-xs text-muted-foreground">
Lange Texte werden für kleine Seiten automatisch verkleinert.
</p>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground" for="stamp-image">
Stempelbild (optional)
</label>
<input
id="stamp-image"
type="file"
accept="image/png,image/jpeg"
onchange={handleImageSelected}
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm file:mr-3 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-sm file:font-semibold file:text-primary-foreground"
/>
{#if stampImage}
<div
class="flex items-center justify-between gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm"
>
<span class="min-w-0 truncate" title={stampImage.name}>{stampImage.name}</span>
<button
type="button"
class="text-xs font-semibold text-primary underline hover:no-underline"
onclick={clearStampImage}
>
Bild entfernen
</button>
</div>
{/if}
</div>
{#if stampImage}
<label class="flex items-center gap-2 text-sm text-foreground" for="stamp-border">
<input id="stamp-border" type="checkbox" bind:checked={withBorder} />
Rahmen um das Bild anzeigen
</label>
<div class="grid gap-x-4 gap-y-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<label class="text-sm font-medium text-foreground" for="stamp-image-size">
Bildgröße: {imageSize} pt
</label>
<input
id="stamp-image-size"
type="range"
min="40"
max="300"
step="5"
bind:value={imageSize}
class="sm:col-span-2"
/>
</div>
{:else}
<fieldset class="flex flex-col gap-2">
<legend class="text-sm font-medium text-foreground">Vorlage</legend>
<div class="flex flex-wrap gap-2">
{#each PRESETS as preset}
<button
type="button"
class="rounded-full border px-3 py-1.5 text-xs font-bold tracking-wide transition focus:outline-none focus-visible:ring-2 focus-visible:ring-ring {selectedPreset ===
preset
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-background text-foreground hover:border-primary/40'}"
aria-pressed={selectedPreset === preset}
onclick={() => selectPreset(preset)}
>
{preset}
</button>
{/each}
</div>
</fieldset>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground" for="stamp-text">Stempeltext</label>
<input
id="stamp-text"
type="text"
bind:value={stampInput}
maxlength="120"
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm uppercase"
/>
</div>
<label class="flex items-center gap-2 text-sm text-foreground" for="stamp-date">
<input id="stamp-date" type="checkbox" bind:checked={withDate} />
Datum anhängen
</label>
<div class="grid gap-x-4 gap-y-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<label class="text-sm font-medium text-foreground" for="stamp-font-size">
Schriftgröße: {fontSize} pt
</label>
<input
id="stamp-font-size"
type="range"
min="12"
max="48"
bind:value={fontSize}
class="sm:col-span-2"
/>
</div>
{/if}
<div class="grid gap-x-4 gap-y-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<label class="text-sm font-medium text-foreground" for="stamp-rotation">
Drehung: {rotation}°
</label>
<input
id="stamp-rotation"
type="range"
min="-45"
max="45"
bind:value={rotation}
class="sm:col-span-2"
/>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground" for="stamp-position">Position</label>
<select
id="stamp-position"
bind:value={position}
class="rounded-lg border border-input bg-background px-3 py-2 text-sm"
>
<option value="top-left">Oben links</option>
<option value="top-center">Oben mittig</option>
<option value="top-right">Oben rechts</option>
<option value="center">Zentriert</option>
<option value="bottom-left">Unten links</option>
<option value="bottom-center">Unten mittig</option>
<option value="bottom-right">Unten rechts</option>
</select>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground" for="stamp-color">Farbe</label>
<input
id="stamp-color"
type="color"
bind:value={stampColor}
class="h-10 w-full cursor-pointer rounded-lg border border-input bg-background p-1"
/>
</div>
</div>
<label class="flex items-center gap-2 text-sm text-foreground" for="stamp-all-pages">
<input id="stamp-all-pages" type="checkbox" bind:checked={applyToAll} />
Auf alle Seiten anwenden
</label>
</section>
<section
class="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5"
aria-labelledby="stamp-preview-title"
>
<div class="flex items-center justify-between gap-3">
<div>
<h2 id="stamp-preview-title" class="text-lg font-semibold text-foreground">Vorschau</h2>
<p class="mt-1 text-xs text-muted-foreground">Annäherung an die Ausgabe im PDF</p>
</div>
<span class="text-xs font-semibold text-primary"
>{currentPage} / {pdfHandle.pageCount}</span
>
</div>
{#if pdfHandle.thumbnails[currentPage - 1]}
{@const currentThumbnail = pdfHandle.thumbnails[currentPage - 1]}
<div
class="relative mx-auto w-full max-w-lg overflow-hidden rounded-lg border border-border bg-white shadow-sm"
>
<img
src={currentThumbnail.imageUrl}
alt="Vorschau von Seite {currentPage}"
class="block w-full"
style="aspect-ratio: {currentThumbnail.width} / {currentThumbnail.height};"
/>
<div class="absolute max-w-[90%]" style={getPreviewPositionStyle(position)}>
{#if stampImage}
<div
data-testid="stamp-preview"
class={withBorder ? 'border-2' : ''}
style="border-color: {stampColor}; padding: {withBorder
? '2px'
: '0'}; transform: rotate({rotation}deg);"
>
<img
src={stampImage.objectUrl}
alt="Stempelbild"
class="block"
style="width: {imageSize * 0.5}px; max-width: 100%;"
/>
</div>
{:else}
<div
data-testid="stamp-preview"
class="border-2 px-2 py-1 font-sans font-extrabold leading-tight whitespace-nowrap"
style="border-color: {stampColor}; color: {stampColor}; font-size: {previewFontSize}px; transform: rotate({rotation}deg);"
>
{previewText}
</div>
{/if}
</div>
</div>
{/if}
{#if pdfHandle.pageCount > 1}
<div class="flex items-center justify-center gap-3">
<Button
variant="tertiary"
class="min-w-0 px-3"
onclick={showPreviousPage}
disabled={currentPage === 1}
aria-label="Vorherige Seite"
>
<ChevronLeft aria-hidden="true" />
</Button>
<Button
variant="tertiary"
class="min-w-0 px-3"
onclick={showNextPage}
disabled={currentPage === pdfHandle.pageCount}
aria-label="Nächste Seite"
>
<ChevronRight aria-hidden="true" />
</Button>
</div>
{/if}
</section>
</div>
{#if !applyToAll}
<section class="flex flex-col gap-3" aria-labelledby="stamp-pages-title">
<div>
<h2 id="stamp-pages-title" class="text-lg font-semibold text-foreground">
Seiten auswählen
</h2>
<p class="mt-1 text-sm text-primary">
{selectedPages.size} Seite{selectedPages.size === 1 ? '' : 'n'} ausgewählt
</p>
</div>
<PdfPageGrid thumbnails={pdfHandle.thumbnails} {selectedPages} onPageClick={togglePage} />
</section>
{/if}
<div class="flex flex-col items-end gap-3">
{#if error}
<p
class="w-full rounded-lg border border-destructive/25 bg-card p-4 text-sm text-destructive"
role="alert"
>
{error}
</p>
{/if}
<Button size="large" onclick={applyStamp} disabled={!canProcess}>
{isProcessing ? 'Stempel wird angewendet …' : 'Stempel anwenden & herunterladen'}
</Button>
</div>
{:else if error}
<p
class="rounded-lg border border-destructive/25 bg-card p-4 text-sm text-destructive"
role="alert"
>
{error}
</p>
{/if}
</div>
+265 -241
View File
@@ -1,23 +1,18 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { degrees, PDFDocument, rgb, StandardFonts } from 'pdf-lib'; import { degrees, PDFDocument, StandardFonts } from 'pdf-lib';
import BatchFileList from '$lib/components/BatchFileList.svelte';
import FileDropzone from '$lib/components/FileDropzone.svelte'; import FileDropzone from '$lib/components/FileDropzone.svelte';
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte'; import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
import Button from '$lib/components/ui/button.svelte'; import Button from '$lib/components/ui/button.svelte';
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
import { downloadBlob } from '$lib/download'; import { downloadBlob } from '$lib/download';
import { getPositionCoords, parseHexColor, type WatermarkPosition } from '$lib/pdf-overlay';
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails'; import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
type WatermarkKind = 'text' | 'image'; type WatermarkKind = 'text' | 'image';
type WatermarkPosition =
| 'center'
| 'diagonal'
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right';
let pdfFile: File | null = $state(null); let items = $state<BatchItem[]>([]);
let pdfHandle: PdfDocumentHandle | null = $state(null);
let watermarkKind = $state<WatermarkKind>('text'); let watermarkKind = $state<WatermarkKind>('text');
let watermarkText = $state('VERTRAULICH'); let watermarkText = $state('VERTRAULICH');
let watermarkImage: File | null = $state(null); let watermarkImage: File | null = $state(null);
@@ -29,25 +24,47 @@
let watermarkColor = $state('#cc1a1a'); let watermarkColor = $state('#cc1a1a');
let applyToAll = $state(true); let applyToAll = $state(true);
let selectedPages = $state(new Set<number>()); let selectedPages = $state(new Set<number>());
let isProcessing = $state(false); let isRunning = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
// Thumbnail handle for the single-file page selection grid only; multiple
// files skip preview loading to keep memory flat.
let pdfHandle: PdfDocumentHandle | null = $state(null);
let handleGeneration = 0;
const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
const watermarkReady = $derived( const watermarkReady = $derived(
watermarkKind === 'text' ? watermarkText.trim().length > 0 : watermarkImage !== null watermarkKind === 'text' ? watermarkText.trim().length > 0 : watermarkImage !== null
); );
const pageSelectionAvailable = $derived(items.length === 1 && pdfHandle !== null);
const requiresPageSelection = $derived(pageSelectionAvailable && !applyToAll);
const canRun = $derived(
!isRunning && pendingCount > 0 && watermarkReady && !requiresPageSelection
);
const handleFileSelected = async (files: File[]) => { const handleFilesSelected = async (files: File[]) => {
const file = files[0]; const knownKeys = new Set(items.map((item) => `${item.file.name}:${item.file.size}`));
if (!file) return; const additions = files
.filter((file) => !knownKeys.has(`${file.name}:${file.size}`))
.map((file) => createBatchItem(file));
if (additions.length === 0) return;
pdfHandle?.destroy(); items = [...items, ...additions];
pdfHandle = null;
pdfFile = file;
selectedPages = new Set(); selectedPages = new Set();
error = null; error = null;
pdfHandle?.destroy();
pdfHandle = null;
if (items.length !== 1) return;
const generation = ++handleGeneration;
try { try {
pdfHandle = await loadPdfDocument(new Uint8Array(await file.arrayBuffer())); const handle = await loadPdfDocument(new Uint8Array(await items[0].file.arrayBuffer()));
if (generation !== handleGeneration || items.length !== 1) {
handle.destroy();
return;
}
pdfHandle = handle;
} catch (loadError) { } catch (loadError) {
console.error('Failed to load PDF for watermarking:', loadError); console.error('Failed to load PDF for watermarking:', loadError);
error = 'PDF konnte nicht geladen werden.'; error = 'PDF konnte nicht geladen werden.';
@@ -66,111 +83,110 @@
selectedPages = next; selectedPages = next;
}; };
const getPositionCoords = ( const processFile = async (file: File) => {
pageWidth: number, const pdfDoc = await PDFDocument.load(new Uint8Array(await file.arrayBuffer()));
pageHeight: number, const pages = pdfDoc.getPages();
markWidth: number, // Options (including the page selection) are read per file so a change
markHeight: number, // during a run only affects the still pending files. With multiple files
markPosition: WatermarkPosition // the watermark is applied to all pages of every file.
) => { const useAllPages = items.length > 1 || applyToAll;
const margin = 20; const pageIndexes = useAllPages
switch (markPosition) { ? pages.map((_, index) => index)
case 'center': : Array.from(selectedPages, (pageNumber) => pageNumber - 1);
case 'diagonal': const numericOpacity = Number(opacity);
return { x: (pageWidth - markWidth) / 2, y: (pageHeight - markHeight) / 2 }; const numericRotation = position === 'diagonal' ? 45 : Number(rotation);
case 'top-left':
return { x: margin, y: pageHeight - markHeight - margin };
case 'top-right':
return { x: pageWidth - markWidth - margin, y: pageHeight - markHeight - margin };
case 'bottom-left':
return { x: margin, y: margin };
case 'bottom-right':
return { x: pageWidth - markWidth - margin, y: margin };
}
};
const parseHexColor = (hexColor: string) => { if (watermarkKind === 'text') {
const match = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hexColor); const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
if (!match) return rgb(0.8, 0.1, 0.1); const numericFontSize = Number(fontSize);
return rgb(
Number.parseInt(match[1], 16) / 255,
Number.parseInt(match[2], 16) / 255,
Number.parseInt(match[3], 16) / 255
);
};
const applyWatermark = async () => { for (const pageIndex of pageIndexes) {
if (!pdfFile || !watermarkReady) return; const page = pages[pageIndex];
if (!applyToAll && selectedPages.size === 0) { if (!page) continue;
error = 'Bitte wählen Sie mindestens eine Seite aus.'; const { width, height } = page.getSize();
return; const textWidth = font.widthOfTextAtSize(watermarkText, numericFontSize);
} const textHeight = font.heightAtSize(numericFontSize);
const coordinates = getPositionCoords(width, height, textWidth, textHeight, position);
isProcessing = true; page.drawText(watermarkText, {
error = null; ...coordinates,
size: numericFontSize,
try { font,
const pdfDoc = await PDFDocument.load(new Uint8Array(await pdfFile.arrayBuffer())); color: parseHexColor(watermarkColor),
const pages = pdfDoc.getPages(); opacity: numericOpacity,
const pageIndexes = applyToAll rotate: degrees(numericRotation)
? pages.map((_, index) => index) });
: Array.from(selectedPages, (pageNumber) => pageNumber - 1);
const numericOpacity = Number(opacity);
const numericRotation = position === 'diagonal' ? 45 : Number(rotation);
if (watermarkKind === 'text') {
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const numericFontSize = Number(fontSize);
for (const pageIndex of pageIndexes) {
const page = pages[pageIndex];
if (!page) continue;
const { width, height } = page.getSize();
const textWidth = font.widthOfTextAtSize(watermarkText, numericFontSize);
const textHeight = font.heightAtSize(numericFontSize);
const coordinates = getPositionCoords(width, height, textWidth, textHeight, position);
page.drawText(watermarkText, {
...coordinates,
size: numericFontSize,
font,
color: parseHexColor(watermarkColor),
opacity: numericOpacity,
rotate: degrees(numericRotation)
});
}
} else if (watermarkImage) {
const imageBytes = new Uint8Array(await watermarkImage.arrayBuffer());
const embeddedImage =
watermarkImage.type === 'image/jpeg' || /\.jpe?g$/i.test(watermarkImage.name)
? await pdfDoc.embedJpg(imageBytes)
: await pdfDoc.embedPng(imageBytes);
const markWidth = Number(imageWidth);
const markHeight = markWidth * (embeddedImage.height / embeddedImage.width);
for (const pageIndex of pageIndexes) {
const page = pages[pageIndex];
if (!page) continue;
const { width, height } = page.getSize();
const coordinates = getPositionCoords(width, height, markWidth, markHeight, position);
page.drawImage(embeddedImage, {
...coordinates,
width: markWidth,
height: markHeight,
opacity: numericOpacity,
rotate: degrees(numericRotation)
});
}
} }
} else if (watermarkImage) {
const imageBytes = new Uint8Array(await watermarkImage.arrayBuffer());
const embeddedImage =
watermarkImage.type === 'image/jpeg' || /\.jpe?g$/i.test(watermarkImage.name)
? await pdfDoc.embedJpg(imageBytes)
: await pdfDoc.embedPng(imageBytes);
const markWidth = Number(imageWidth);
const markHeight = markWidth * (embeddedImage.height / embeddedImage.width);
const baseName = pdfFile.name.replace(/\.pdf$/i, ''); for (const pageIndex of pageIndexes) {
downloadBlob(await pdfDoc.save(), `${baseName}_wasserzeichen.pdf`); const page = pages[pageIndex];
} catch (watermarkError) { if (!page) continue;
console.error('Watermark failed:', watermarkError); const { width, height } = page.getSize();
error = 'Fehler beim Anwenden des Wasserzeichens.'; const coordinates = getPositionCoords(width, height, markWidth, markHeight, position);
} finally { page.drawImage(embeddedImage, {
isProcessing = false; ...coordinates,
width: markWidth,
height: markHeight,
opacity: numericOpacity,
rotate: degrees(numericRotation)
});
}
} }
const bytes = await pdfDoc.save();
return { bytes, name: `${file.name.replace(/\.pdf$/i, '')}_wasserzeichen.pdf` };
};
const updateItem = (id: string, patch: Partial<BatchItem>) => {
items = items.map((item) => (item.id === id ? { ...item, ...patch } : item));
};
const startBatch = async () => {
if (isRunning) return;
const queue = items.filter((item) => item.status === 'pending');
if (queue.length === 0) return;
isRunning = true;
try {
await runBatch(queue, processFile, updateItem, {
errorMessage: 'Diese PDF konnte nicht mit einem Wasserzeichen versehen werden.',
// Skip rows removed while they were still waiting in the queue.
shouldProcess: (id) => items.some((item) => item.id === id)
});
} finally {
isRunning = false;
}
};
const retryItem = async (id: string) => {
if (isRunning) return;
const item = items.find((entry) => entry.id === id);
if (!item) return;
isRunning = true;
try {
await runBatch([item], processFile, updateItem, {
errorMessage: 'Diese PDF konnte nicht mit einem Wasserzeichen versehen werden.'
});
} finally {
isRunning = false;
}
};
const removeItem = (id: string) => {
items = items.filter((item) => item.id !== id);
};
const downloadItem = (item: BatchItem) => {
if (item.resultBytes && item.resultName) downloadBlob(item.resultBytes, item.resultName);
}; };
onDestroy(() => pdfHandle?.destroy()); onDestroy(() => pdfHandle?.destroy());
@@ -178,152 +194,160 @@
<div class="mx-auto max-w-4xl space-y-6"> <div class="mx-auto max-w-4xl space-y-6">
<div> <div>
<h1 class="text-2xl font-bold text-primary-900">Wasserzeichen</h1> <h1 class="text-2xl font-bold text-foreground">Wasserzeichen</h1>
<p class="mt-2 text-sm text-primary-700"> <p class="mt-2 text-sm text-primary">
Fügen Sie allen oder ausgewählten Seiten ein Text- oder Bildwasserzeichen hinzu. Fügen Sie ein Text- oder Bildwasserzeichen hinzu einzeln oder für mehrere PDFs in einem
Durchlauf.
</p> </p>
</div> </div>
{#if !pdfHandle} <FileDropzone
<FileDropzone onFilesSelected={handleFileSelected} /> onFilesSelected={handleFilesSelected}
{:else} multiple
<div class="space-y-6"> label={items.length === 0 ? 'Dateien hinzufügen' : 'Weitere Dateien ablegen'}
<FileDropzone sublabel={items.length === 0
onFilesSelected={handleFileSelected} ? 'PDFs hier ablegen oder zum Auswählen klicken'
label="Anderes PDF ablegen" : 'Weitere PDFs hinzufügen bestehende Dateien bleiben erhalten'}
sublabel={pdfFile?.name ?? ''} />
class="py-4"
/>
<div class="space-y-4 rounded-xl border border-primary-200 bg-white p-4"> {#if items.length > 0}
<h2 class="text-lg font-semibold text-primary-900">Wasserzeichen-Einstellungen</h2> <div class="space-y-4 rounded-2xl border border-border bg-card p-4">
<h2 class="text-lg font-semibold text-foreground">Wasserzeichen-Einstellungen</h2>
<fieldset class="flex gap-4"> <fieldset class="flex gap-4">
<legend class="mb-2 text-sm font-medium text-primary-900">Art</legend> <legend class="mb-2 text-sm font-medium text-foreground">Art</legend>
<label class="flex items-center gap-2 text-sm"> <label class="flex items-center gap-2 text-sm">
<input type="radio" bind:group={watermarkKind} value="text" /> Text <input type="radio" bind:group={watermarkKind} value="text" /> Text
</label> </label>
<label class="flex items-center gap-2 text-sm"> <label class="flex items-center gap-2 text-sm">
<input type="radio" bind:group={watermarkKind} value="image" /> Bild <input type="radio" bind:group={watermarkKind} value="image" /> Bild
</label> </label>
</fieldset> </fieldset>
{#if watermarkKind === 'text'}
<label class="block text-sm font-medium text-foreground" for="watermark-text">Text</label>
<input
id="watermark-text"
type="text"
bind:value={watermarkText}
class="w-full rounded-lg border border-border px-3 py-2 text-sm"
/>
{:else}
<FileDropzone
onFilesSelected={handleImageSelected}
accept=".png,.jpg,.jpeg,image/png,image/jpeg"
ariaLabel="Wasserzeichenbild hinzufügen"
label="Wasserzeichenbild ablegen"
sublabel={watermarkImage?.name ?? 'PNG oder JPG'}
class="py-4"
/>
{/if}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
{#if watermarkKind === 'text'} {#if watermarkKind === 'text'}
<label class="block text-sm font-medium text-primary-900" for="watermark-text"> <label class="text-sm font-medium text-foreground" for="watermark-font-size">
Text Schriftgröße: {fontSize}
</label> </label>
<input <input id="watermark-font-size" type="range" min="10" max="120" bind:value={fontSize} />
id="watermark-text"
type="text"
bind:value={watermarkText}
class="w-full rounded-lg border border-primary-200 px-3 py-2 text-sm"
/>
{:else} {:else}
<FileDropzone <label class="text-sm font-medium text-foreground" for="watermark-image-width">
onFilesSelected={handleImageSelected} Bildbreite: {imageWidth}
accept=".png,.jpg,.jpeg,image/png,image/jpeg" </label>
ariaLabel="Wasserzeichenbild hinzufügen" <input
label="Wasserzeichenbild ablegen" id="watermark-image-width"
sublabel={watermarkImage?.name ?? 'PNG oder JPG'} type="range"
class="py-4" min="10"
max="400"
bind:value={imageWidth}
/> />
{/if} {/if}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <label class="text-sm font-medium text-foreground" for="watermark-opacity">
{#if watermarkKind === 'text'} Deckkraft: {Math.round(Number(opacity) * 100)}%
<label class="text-sm font-medium text-primary-900" for="watermark-font-size"> </label>
Schriftgröße: {fontSize} <input
</label> id="watermark-opacity"
<input id="watermark-font-size" type="range" min="10" max="120" bind:value={fontSize} /> type="range"
{:else} min="0.05"
<label class="text-sm font-medium text-primary-900" for="watermark-image-width"> max="1"
Bildbreite: {imageWidth} step="0.05"
</label> bind:value={opacity}
<input />
id="watermark-image-width"
type="range"
min="10"
max="400"
bind:value={imageWidth}
/>
{/if}
<label class="text-sm font-medium text-primary-900" for="watermark-opacity"> <label class="text-sm font-medium text-foreground" for="watermark-rotation">
Deckkraft: {Math.round(Number(opacity) * 100)}% Drehung: {rotation}°
</label> </label>
<input <input
id="watermark-opacity" id="watermark-rotation"
type="range" type="range"
min="0.05" min="0"
max="1" max="360"
step="0.05" bind:value={rotation}
bind:value={opacity} disabled={position === 'diagonal'}
/> />
<label class="text-sm font-medium text-primary-900" for="watermark-rotation"> <label class="text-sm font-medium text-foreground" for="watermark-position">Position</label>
Drehung: {rotation}° <select id="watermark-position" bind:value={position} class="rounded-lg border px-3 py-2">
</label> <option value="center">Zentriert</option>
<input <option value="diagonal">Diagonal</option>
id="watermark-rotation" <option value="top-left">Oben links</option>
type="range" <option value="top-right">Oben rechts</option>
min="0" <option value="bottom-left">Unten links</option>
max="360" <option value="bottom-right">Unten rechts</option>
bind:value={rotation} </select>
disabled={position === 'diagonal'} </div>
/>
<label class="text-sm font-medium text-primary-900" for="watermark-position"> {#if watermarkKind === 'text'}
Position <label class="block text-sm font-medium text-foreground" for="watermark-color">Farbe</label>
</label> <input
<select id="watermark-position" bind:value={position} class="rounded-lg border px-3 py-2"> id="watermark-color"
<option value="center">Zentriert</option> type="color"
<option value="diagonal">Diagonal</option> bind:value={watermarkColor}
<option value="top-left">Oben links</option> class="h-10 w-14 cursor-pointer"
<option value="top-right">Oben rechts</option> />
<option value="bottom-left">Unten links</option> {/if}
<option value="bottom-right">Unten rechts</option>
</select>
</div>
{#if watermarkKind === 'text'} {#if items.length > 1}
<label class="block text-sm font-medium text-primary-900" for="watermark-color"> <p class="text-xs text-primary" role="note">
Farbe Die Einstellungen gelten für alle Dateien. Das Wasserzeichen wird auf allen Seiten jeder
</label> Datei angewendet.
<input {#if isRunning}Änderungen wirken auf die noch ausstehenden Dateien.{/if}
id="watermark-color" </p>
type="color" {:else}
bind:value={watermarkColor} <label class="flex items-center gap-2 text-sm text-foreground" for="all-pages">
class="h-10 w-14 cursor-pointer"
/>
{/if}
<label class="flex items-center gap-2 text-sm text-primary-900" for="all-pages">
<input type="checkbox" id="all-pages" bind:checked={applyToAll} /> <input type="checkbox" id="all-pages" bind:checked={applyToAll} />
Auf alle Seiten anwenden Auf alle Seiten anwenden
</label> </label>
</div>
{#if !applyToAll}
<div>
<p class="mb-3 text-sm font-medium">Seiten auswählen:</p>
<PdfPageGrid thumbnails={pdfHandle.thumbnails} {selectedPages} onPageClick={togglePage} />
</div>
{/if} {/if}
</div>
{#if error} {#if pageSelectionAvailable && !applyToAll}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800"> <div>
{error} <p class="mb-3 text-sm font-medium">Seiten auswählen:</p>
</div> <PdfPageGrid thumbnails={pdfHandle!.thumbnails} {selectedPages} onPageClick={togglePage} />
{/if}
<div class="flex justify-end">
<Button
onclick={applyWatermark}
disabled={isProcessing || !watermarkReady || (!applyToAll && selectedPages.size === 0)}
>
{isProcessing ? 'Wird verarbeitet...' : 'Wasserzeichen anwenden & herunterladen'}
</Button>
</div> </div>
{/if}
{#if items.length > 0}
<BatchFileList
{items}
zipBaseName="wasserzeichen"
onDownload={downloadItem}
onRemove={removeItem}
onRetry={retryItem}
/>
{/if}
{#if error}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
{error}
</div>
{/if}
<div class="flex justify-end">
<Button onclick={startBatch} disabled={!canRun}>
{isRunning ? 'Wird verarbeitet...' : 'Wasserzeichen anwenden'}
</Button>
</div> </div>
{/if} {/if}
</div> </div>
+377 -16
View File
@@ -1,6 +1,14 @@
import { expect, test, type Download, type Locator, type Page } from '@playwright/test'; import { expect, test, type Download, type Locator, type Page } from '@playwright/test';
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'; import {
import { zipSync } from 'fflate'; decodePDFRawStream,
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'; import { readFile } from 'node:fs/promises';
const createPdf = async (pageCount: number, label: string) => { const createPdf = async (pageCount: number, label: string) => {
@@ -19,6 +27,35 @@ const createPdf = async (pageCount: number, label: string) => {
return Buffer.from(await document.save()); 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 ( const dropFiles = async (
page: Page, page: Page,
target: Locator | 'window', target: Locator | 'window',
@@ -69,6 +106,32 @@ const expectPdfDownload = async (download: Download, name: RegExp) => {
return bytes; 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 trackPageErrors = (page: Page) => { const trackPageErrors = (page: Page) => {
const errors: Error[] = []; const errors: Error[] = [];
page.on('pageerror', (error) => errors.push(error)); page.on('pageerror', (error) => errors.push(error));
@@ -121,6 +184,7 @@ test('start page exposes only working tools and forwards a dropped ZIP to beA',
await page.goto('/'); await page.goto('/');
await expect(page.getByRole('heading', { name: 'Werkzeugkasten für beA' })).toBeVisible(); 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 expect(page.getByRole('link', { name: /Signieren/ })).toHaveCount(0);
await dropFiles(page, 'window', [ await dropFiles(page, 'window', [
@@ -209,13 +273,13 @@ test('watermark accepts numeric controls and downloads text and image watermarks
{ name: 'marke.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Marke') } { name: 'marke.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Marke') }
]); ]);
await expect(page.getByRole('heading', { name: 'Wasserzeichen-Einstellungen' })).toBeVisible({ await expect(page.getByRole('heading', { name: 'Wasserzeichen-Einstellungen' })).toBeVisible();
timeout: 15_000
});
await page.locator('#watermark-color').fill('#00ff00'); await page.locator('#watermark-color').fill('#00ff00');
await page.locator('#watermark-opacity').fill('0.45'); 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'); const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Wasserzeichen anwenden & herunterladen' }).click(); await page.getByRole('button', { name: 'marke.pdf herunterladen' }).click();
await expectPdfDownload(await downloadPromise, /^marke_wasserzeichen\.pdf$/); await expectPdfDownload(await downloadPromise, /^marke_wasserzeichen\.pdf$/);
await page.getByLabel('Bild', { exact: true }).check(); await page.getByLabel('Bild', { exact: true }).check();
@@ -229,15 +293,176 @@ test('watermark accepts numeric controls and downloads text and image watermarks
) )
} }
]); ]);
// 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'); const imageDownloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Wasserzeichen anwenden & herunterladen' }).click(); await page.getByRole('button', { name: 'marke.pdf herunterladen' }).click();
await expectPdfDownload(await imageDownloadPromise, /^marke_wasserzeichen\.pdf$/); await expectPdfDownload(await imageDownloadPromise, /^marke_wasserzeichen\.pdf$/);
await expect(page.getByText('Fehler beim Anwenden')).toHaveCount(0);
expect(pageErrors).toEqual([]); expect(pageErrors).toEqual([]);
}); });
test('encrypt creates a protected PDF and decrypt rebuilds it with the password', async ({ 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 page
}) => { }) => {
const pageErrors = trackPageErrors(page); const pageErrors = trackPageErrors(page);
@@ -248,10 +473,14 @@ test('encrypt creates a protected PDF and decrypt rebuilds it with the password'
await page.locator('#encrypt-password').fill('test-passwort'); await page.locator('#encrypt-password').fill('test-passwort');
await page.locator('#encrypt-password-confirmation').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'); const encryptedDownloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Passwort setzen & herunterladen' }).click(); await page.getByRole('button', { name: 'geheim.pdf herunterladen' }).click();
const encryptedDownload = await encryptedDownloadPromise; const encryptedBytes = await expectPdfDownload(
const encryptedBytes = await expectPdfDownload(encryptedDownload, /^geheim_geschuetzt\.pdf$/); await encryptedDownloadPromise,
/^geheim_geschuetzt\.pdf$/
);
await page.goto('/tools/decrypt'); await page.goto('/tools/decrypt');
await dropFiles(page, pdfDropzone(page), [ await dropFiles(page, pdfDropzone(page), [
@@ -262,27 +491,159 @@ test('encrypt creates a protected PDF and decrypt rebuilds it with the password'
} }
]); ]);
await page.locator('#decrypt-password').fill('test-passwort'); 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'); const decryptedDownloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Passwort entfernen & herunterladen' }).click(); await page.getByRole('button', { name: 'geheim_geschuetzt.pdf herunterladen' }).click();
await expectPdfDownload(await decryptedDownloadPromise, /^geheim_geschuetzt_entsperrt\.pdf$/); await expectPdfDownload(await decryptedDownloadPromise, /^geheim_geschuetzt_entsperrt\.pdf$/);
expect(pageErrors).toEqual([]); expect(pageErrors).toEqual([]);
}); });
test('compress previews result size before downloading', async ({ page }) => { 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); const pageErrors = trackPageErrors(page);
await page.goto('/tools/compress'); await page.goto('/tools/compress');
await dropFiles(page, pdfDropzone(page), [ await dropFiles(page, pdfDropzone(page), [
{ name: 'gross.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Komprimieren') } { 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 page.getByRole('button', { name: 'Komprimieren', exact: true }).click();
await expect(page.getByText(/Ergebnisgröße:/)).toBeVisible({ timeout: 20_000 }); await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
const downloadPromise = page.waitForEvent('download'); const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'PDF herunterladen' }).click(); await page.getByRole('button', { name: 'gross.pdf herunterladen' }).click();
await expectPdfDownload(await downloadPromise, /^gross_komprimiert\.pdf$/); await expectPdfDownload(await downloadPromise, /^gross_komprimiert\.pdf$/);
expect(pageErrors).toEqual([]); 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 }) => { test('convert bundles a multi-page PDF as a correctly named ZIP', async ({ page }) => {
const pageErrors = trackPageErrors(page); const pageErrors = trackPageErrors(page);
await page.goto('/tools/convert'); await page.goto('/tools/convert');