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
+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[] = [
{
slug: 'stamp',
title: 'Stempeln',
description: 'Versehen Sie Seiten mit Textstempeln wie „Beglaubigt“ oder „Eilt“.',
icon: 'stamp'
},
{
slug: 'watermark',
title: 'Wasserzeichen',
+3 -1
View File
@@ -8,6 +8,7 @@
Minimize2,
Scissors,
Merge,
Stamp,
FileText
} from '@lucide/svelte';
import { goto } from '$app/navigation';
@@ -23,7 +24,8 @@
image: Image,
minimize: Minimize2,
scissors: Scissors,
merge: Merge
merge: Merge,
stamp: Stamp
};
let isDraggingFiles = $state(false);
+129 -176
View File
@@ -1,140 +1,99 @@
<script lang="ts">
import { PDFDocument } from 'pdf-lib';
import BatchFileList from '$lib/components/BatchFileList.svelte';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import RasterizationWarning from '$lib/components/RasterizationWarning.svelte';
import Button from '$lib/components/ui/button.svelte';
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
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';
type CompressionPreset = {
id: string;
label: string;
description: string;
scale: number;
quality: number;
};
let items = $state<BatchItem[]>([]);
let selectedPreset = $state(defaultCompressionPreset.id);
let isRunning = $state(false);
const presets: 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 }
];
const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
const canStart = $derived(pendingCount > 0 && !isRunning);
let pdfFile: File | null = $state(null);
let selectedPreset = $state('balanced');
let isProcessing = $state(false);
let error = $state<string | null>(null);
let compressedBytes = $state<Uint8Array | null>(null);
let compressedFileName = $state('');
const rasterizationBlocked = $derived(isRasterizationTooLarge(pdfFile));
const handleFileSelected = (files: File[]) => {
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()
});
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];
};
compressedBytes = await outputPdf.save();
compressedFileName = `${pdfFile.name.replace(/\.pdf$/i, '')}_komprimiert.pdf`;
} finally {
await loadingTask.destroy();
}
} catch (err) {
console.error('Compression failed:', err);
error = 'Fehler beim Komprimieren des PDFs.';
const updateItem = (id: string, patch: Partial<BatchItem>) => {
items = items.map((item) => (item.id === id ? { ...item, ...patch } : item));
};
const processFile = async (file: File) => {
// The preset is read per file so an option change during a run only
// affects the still pending files.
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 {
isProcessing = false;
isRunning = false;
}
};
const downloadCompressedPdf = () => {
if (compressedBytes) downloadBlob(compressedBytes, compressedFileName);
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 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>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-primary-900">Komprimieren</h1>
<p class="mt-2 text-sm text-primary-700">
Reduzieren Sie die Dateigröße Ihres PDFs durch Komprimierung.
<h1 class="text-2xl font-bold text-foreground">Komprimieren</h1>
<p class="mt-2 text-sm text-primary">
Reduzieren Sie die Dateigröße Ihrer PDFs durch Komprimierung einzeln oder mehrere in einem
Durchlauf.
</p>
</div>
@@ -149,70 +108,64 @@
</div>
</div>
{#if !pdfFile}
<FileDropzone onFilesSelected={handleFileSelected} />
{:else}
<div class="space-y-6">
<FileDropzone
onFilesSelected={handleFileSelected}
label="Anderes PDF ablegen"
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>
<FileDropzone
onFilesSelected={handleFilesSelected}
multiple
label={items.length === 0 ? 'Dateien hinzufügen' : 'Weitere Dateien ablegen'}
sublabel={items.length === 0
? 'PDFs hier ablegen oder zum Auswählen klicken'
: 'Weitere PDFs hinzufügen bestehende Dateien bleiben erhalten'}
/>
<div class="space-y-2">
<p class="text-sm font-medium text-primary-900">Komprimierungsstufe</p>
{#each presets as preset}
<label
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 items.length > 0}
<div class="rounded-2xl border border-border bg-card p-4 space-y-4">
<h2 class="text-lg font-semibold text-foreground">Komprimierungsstufe</h2>
<p class="text-xs text-primary">Die Einstellung gilt für alle Dateien der Liste.</p>
{#if compressedBytes}
<p class="text-sm text-primary-700">
Ergebnisgröße: <span class="font-semibold">{formatBytes(compressedBytes.length)}</span>
</p>
{/if}
<div class="space-y-2">
{#each compressionPresets as preset (preset.id)}
<label
class="flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition {selectedPreset ===
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>
{#if error}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
{error}
</div>
{#if isRunning}
<p class="text-xs text-primary" role="note">
Änderungen an der Komprimierungsstufe gelten für die noch ausstehenden Dateien.
</p>
{/if}
</div>
<div class="flex justify-end gap-2">
<Button onclick={compressPdf} disabled={isProcessing || rasterizationBlocked}>
{isProcessing ? 'Wird komprimiert...' : 'Komprimieren'}
</Button>
{#if compressedBytes}
<Button onclick={downloadCompressedPdf}>PDF herunterladen</Button>
{/if}
</div>
{#if items.length > 0}
<BatchFileList
{items}
zipBaseName="komprimiert"
onDownload={downloadItem}
onRemove={removeItem}
onRetry={retryItem}
/>
{/if}
<div class="flex justify-end">
<Button onclick={startBatch} disabled={!canStart}>
{isRunning ? 'Wird komprimiert...' : 'Komprimieren'}
</Button>
</div>
{/if}
</div>
+167 -104
View File
@@ -1,96 +1,144 @@
<script lang="ts">
import { PDFDocument } from 'pdf-lib';
import BatchFileList from '$lib/components/BatchFileList.svelte';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import RasterizationWarning from '$lib/components/RasterizationWarning.svelte';
import Button from '$lib/components/ui/button.svelte';
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
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';
let pdfFile: File | null = $state(null);
let items = $state<BatchItem[]>([]);
let password = $state('');
let isProcessing = $state(false);
let isRunning = $state(false);
let error = $state<string | null>(null);
const rasterizationBlocked = $derived(isRasterizationTooLarge(pdfFile));
const handleFileSelected = (files: File[]) => {
pdfFile = files[0] ?? null;
const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
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;
};
const decryptPdf = async () => {
if (!pdfFile) return;
const updateItem = (id: string, patch: Partial<BatchItem>) => {
items = items.map((item) => (item.id === id ? { ...item, ...patch } : item));
};
isProcessing = true;
error = null;
const processFile = async (file: File) => {
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 {
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 pdfDocument = await loadingTask.promise;
const outputPdf = await PDFDocument.create();
const loadingTask = getDocument({
data: new Uint8Array(await pdfFile.arrayBuffer()),
password
});
for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) {
const page = await pdfDocument.getPage(pageNumber);
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 {
const pdfDocument = await loadingTask.promise;
const outputPdf = await PDFDocument.create();
for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) {
const page = await pdfDocument.getPage(pageNumber);
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');
await page.render({
canvas,
canvasContext,
viewport,
background: '#ffffff'
}).promise;
const pngBlob = await new Promise<Blob>((resolve, reject) => {
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();
await page.render({
canvas,
canvasContext,
viewport,
background: '#ffffff'
}).promise;
const pngBlob = await new Promise<Blob>((resolve, reject) => {
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()
});
}
} catch (decryptionError) {
console.error('Decryption failed:', decryptionError);
error = 'Fehler beim Entsperren. Bitte prüfen Sie das Passwort und versuchen Sie es erneut.';
const bytes = await outputPdf.save();
return { bytes, name: `${file.name.replace(/\.pdf$/i, '')}_entsperrt.pdf` };
} 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>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-primary-900">Passwort entfernen</h1>
<p class="mt-2 text-sm text-primary-700">
Entfernen Sie den Passwortschutz von einem PDF-Dokument.
<h1 class="text-2xl font-bold text-foreground">Passwort entfernen</h1>
<p class="mt-2 text-sm text-primary">
Entfernen Sie den Passwortschutz von PDF-Dokumenten einzeln oder mehrere in einem Durchlauf.
</p>
</div>
@@ -105,48 +153,63 @@
</div>
</div>
{#if !pdfFile}
<FileDropzone onFilesSelected={handleFileSelected} />
{:else}
<div class="space-y-6">
<FileDropzone
onFilesSelected={handleFileSelected}
label="Anderes PDF ablegen"
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>
<FileDropzone
onFilesSelected={handleFilesSelected}
multiple
label={items.length === 0 ? 'Dateien hinzufügen' : 'Weitere Dateien ablegen'}
sublabel={items.length === 0
? 'PDFs hier ablegen oder zum Auswählen klicken'
: 'Weitere PDFs hinzufügen bestehende Dateien bleiben erhalten'}
/>
<div>
<label class="block text-sm font-medium text-primary-900 mb-1" for="decrypt-password">
Passwort
</label>
<input
id="decrypt-password"
type="password"
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 des PDFs eingeben"
/>
</div>
{#if items.length > 0}
<div class="rounded-2xl border border-border bg-card p-4 space-y-4">
<div class="flex items-center gap-2 text-foreground">
<Unlock class="h-5 w-5" />
<h2 class="text-lg font-semibold">Passwort</h2>
</div>
<p class="text-xs text-primary">Das Passwort gilt für alle Dateien der Liste.</p>
<div>
<label class="block text-sm font-medium text-foreground mb-1" for="decrypt-password">
Passwort
</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>
{#if error}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
{error}
</div>
{#if isRunning}
<p class="text-xs text-primary" role="note">
Änderungen am Passwort gelten für die noch ausstehenden Dateien.
</p>
{/if}
</div>
<div class="flex justify-end gap-2">
<Button onclick={decryptPdf} disabled={isProcessing || !password || rasterizationBlocked}>
{isProcessing ? 'Wird entsperrt...' : 'Passwort entfernen & herunterladen'}
</Button>
</div>
<BatchFileList
{items}
zipBaseName="entsperrt"
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 entsperrt...' : 'Passwort entfernen'}
</Button>
</div>
{/if}
</div>
+151 -109
View File
@@ -1,150 +1,192 @@
<script lang="ts">
import { PDFDocument } from '@cantoo/pdf-lib';
import BatchFileList from '$lib/components/BatchFileList.svelte';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import Button from '$lib/components/ui/button.svelte';
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
import { downloadBlob } from '$lib/download';
import { Lock } from '@lucide/svelte';
let pdfFile: File | null = $state(null);
let items = $state<BatchItem[]>([]);
let password = $state('');
let confirmPassword = $state('');
let allowPrinting = $state(true);
let allowCopying = $state(true);
let isProcessing = $state(false);
let isRunning = $state(false);
let error = $state<string | null>(null);
const handleFileSelected = (files: File[]) => {
pdfFile = files[0] ?? null;
const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
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;
};
const encryptPdf = async () => {
if (!pdfFile || !password) return;
const updateItem = (id: string, patch: Partial<BatchItem>) => {
items = items.map((item) => (item.id === id ? { ...item, ...patch } : item));
};
if (password !== confirmPassword) {
error = 'Passwörter stimmen nicht überein.';
return;
}
const processFile = async (file: File) => {
// The password and permissions are read per file so a change during a
// run only affects the still pending files.
const bytes = new Uint8Array(await file.arrayBuffer());
const pdfDoc = await PDFDocument.load(bytes);
if (password.length < 1) {
error = 'Bitte geben Sie ein Passwort ein.';
return;
}
pdfDoc.encrypt({
userPassword: password,
ownerPassword: crypto.randomUUID(),
permissions: {
printing: allowPrinting,
copying: allowCopying
}
});
isProcessing = true;
error = null;
const encryptedBytes = await pdfDoc.save();
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 {
const bytes = new Uint8Array(await pdfFile.arrayBuffer());
const pdfDoc = await PDFDocument.load(bytes);
pdfDoc.encrypt({
userPassword: password,
ownerPassword: crypto.randomUUID(),
permissions: {
printing: allowPrinting,
copying: allowCopying
}
await runBatch(queue, processFile, updateItem, {
errorMessage: 'Diese PDF konnte nicht verschlüsselt werden.',
// Skip rows removed while they were still waiting in the queue.
shouldProcess: (id) => items.some((item) => item.id === id)
});
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 {
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>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-primary-900">Passwort setzen</h1>
<p class="mt-2 text-sm text-primary-700">
Verschlüsseln Sie Ihr PDF mit einem Passwort, um es vor unbefugtem Zugriff zu schützen.
<h1 class="text-2xl font-bold text-foreground">Passwort setzen</h1>
<p class="mt-2 text-sm text-primary">
Verschlüsseln Sie Ihre PDFs mit einem Passwort, um sie vor unbefugtem Zugriff zu schützen
einzeln oder mehrere in einem Durchlauf.
</p>
</div>
{#if !pdfFile}
<FileDropzone onFilesSelected={handleFileSelected} />
{:else}
<div class="space-y-6">
<FileDropzone
onFilesSelected={handleFileSelected}
label="Anderes PDF ablegen"
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>
<FileDropzone
onFilesSelected={handleFilesSelected}
multiple
label={items.length === 0 ? 'Dateien hinzufügen' : 'Weitere Dateien ablegen'}
sublabel={items.length === 0
? 'PDFs hier ablegen oder zum Auswählen klicken'
: 'Weitere PDFs hinzufügen bestehende Dateien bleiben erhalten'}
/>
<div>
<label class="block text-sm font-medium text-primary-900 mb-1" for="encrypt-password">
Passwort
</label>
<input
id="encrypt-password"
type="password"
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>
{#if items.length > 0}
<div class="rounded-2xl border border-border bg-card p-4 space-y-4">
<div class="flex items-center gap-2 text-foreground">
<Lock class="h-5 w-5" />
<h2 class="text-lg font-semibold">Passwort</h2>
</div>
<p class="text-xs text-primary">Das Passwort gilt für alle Dateien der Liste.</p>
<div>
<label
class="block text-sm font-medium text-primary-900 mb-1"
for="encrypt-password-confirmation"
>
Passwort bestätigen
</label>
<input
id="encrypt-password-confirmation"
type="password"
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>
<label class="block text-sm font-medium text-foreground mb-1" for="encrypt-password">
Passwort
</label>
<input
id="encrypt-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 eingeben"
/>
</div>
{#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 gap-2">
<Button
onclick={encryptPdf}
disabled={isProcessing || !password || password !== confirmPassword}
<div>
<label
class="block text-sm font-medium text-foreground mb-1"
for="encrypt-password-confirmation"
>
{isProcessing ? 'Wird verschlüsselt...' : 'Passwort setzen & herunterladen'}
</Button>
Passwort bestätigen
</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 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>
{/if}
</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">
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 PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
import Button from '$lib/components/ui/button.svelte';
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
import { downloadBlob } from '$lib/download';
import { getPositionCoords, parseHexColor, type WatermarkPosition } from '$lib/pdf-overlay';
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
type WatermarkKind = 'text' | 'image';
type WatermarkPosition =
| 'center'
| 'diagonal'
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right';
let pdfFile: File | null = $state(null);
let pdfHandle: PdfDocumentHandle | null = $state(null);
let items = $state<BatchItem[]>([]);
let watermarkKind = $state<WatermarkKind>('text');
let watermarkText = $state('VERTRAULICH');
let watermarkImage: File | null = $state(null);
@@ -29,25 +24,47 @@
let watermarkColor = $state('#cc1a1a');
let applyToAll = $state(true);
let selectedPages = $state(new Set<number>());
let isProcessing = $state(false);
let isRunning = $state(false);
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(
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 file = files[0];
if (!file) return;
const handleFilesSelected = async (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));
if (additions.length === 0) return;
pdfHandle?.destroy();
pdfHandle = null;
pdfFile = file;
items = [...items, ...additions];
selectedPages = new Set();
error = null;
pdfHandle?.destroy();
pdfHandle = null;
if (items.length !== 1) return;
const generation = ++handleGeneration;
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) {
console.error('Failed to load PDF for watermarking:', loadError);
error = 'PDF konnte nicht geladen werden.';
@@ -66,111 +83,110 @@
selectedPages = next;
};
const getPositionCoords = (
pageWidth: number,
pageHeight: number,
markWidth: number,
markHeight: number,
markPosition: WatermarkPosition
) => {
const margin = 20;
switch (markPosition) {
case 'center':
case 'diagonal':
return { x: (pageWidth - markWidth) / 2, y: (pageHeight - markHeight) / 2 };
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 processFile = async (file: File) => {
const pdfDoc = await PDFDocument.load(new Uint8Array(await file.arrayBuffer()));
const pages = pdfDoc.getPages();
// Options (including the page selection) are read per file so a change
// during a run only affects the still pending files. With multiple files
// the watermark is applied to all pages of every file.
const useAllPages = items.length > 1 || applyToAll;
const pageIndexes = useAllPages
? pages.map((_, index) => index)
: Array.from(selectedPages, (pageNumber) => pageNumber - 1);
const numericOpacity = Number(opacity);
const numericRotation = position === 'diagonal' ? 45 : Number(rotation);
const parseHexColor = (hexColor: string) => {
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
);
};
if (watermarkKind === 'text') {
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const numericFontSize = Number(fontSize);
const applyWatermark = async () => {
if (!pdfFile || !watermarkReady) return;
if (!applyToAll && selectedPages.size === 0) {
error = 'Bitte wählen Sie mindestens eine Seite aus.';
return;
}
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);
isProcessing = true;
error = null;
try {
const pdfDoc = await PDFDocument.load(new Uint8Array(await pdfFile.arrayBuffer()));
const pages = pdfDoc.getPages();
const pageIndexes = applyToAll
? 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)
});
}
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);
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
downloadBlob(await pdfDoc.save(), `${baseName}_wasserzeichen.pdf`);
} catch (watermarkError) {
console.error('Watermark failed:', watermarkError);
error = 'Fehler beim Anwenden des Wasserzeichens.';
} finally {
isProcessing = false;
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)
});
}
}
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());
@@ -178,152 +194,160 @@
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-primary-900">Wasserzeichen</h1>
<p class="mt-2 text-sm text-primary-700">
Fügen Sie allen oder ausgewählten Seiten ein Text- oder Bildwasserzeichen hinzu.
<h1 class="text-2xl font-bold text-foreground">Wasserzeichen</h1>
<p class="mt-2 text-sm text-primary">
Fügen Sie ein Text- oder Bildwasserzeichen hinzu einzeln oder für mehrere PDFs in einem
Durchlauf.
</p>
</div>
{#if !pdfHandle}
<FileDropzone onFilesSelected={handleFileSelected} />
{:else}
<div class="space-y-6">
<FileDropzone
onFilesSelected={handleFileSelected}
label="Anderes PDF ablegen"
sublabel={pdfFile?.name ?? ''}
class="py-4"
/>
<FileDropzone
onFilesSelected={handleFilesSelected}
multiple
label={items.length === 0 ? 'Dateien hinzufügen' : 'Weitere Dateien ablegen'}
sublabel={items.length === 0
? 'PDFs hier ablegen oder zum Auswählen klicken'
: 'Weitere PDFs hinzufügen bestehende Dateien bleiben erhalten'}
/>
<div class="space-y-4 rounded-xl border border-primary-200 bg-white p-4">
<h2 class="text-lg font-semibold text-primary-900">Wasserzeichen-Einstellungen</h2>
{#if items.length > 0}
<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">
<legend class="mb-2 text-sm font-medium text-primary-900">Art</legend>
<label class="flex items-center gap-2 text-sm">
<input type="radio" bind:group={watermarkKind} value="text" /> Text
</label>
<label class="flex items-center gap-2 text-sm">
<input type="radio" bind:group={watermarkKind} value="image" /> Bild
</label>
</fieldset>
<fieldset class="flex gap-4">
<legend class="mb-2 text-sm font-medium text-foreground">Art</legend>
<label class="flex items-center gap-2 text-sm">
<input type="radio" bind:group={watermarkKind} value="text" /> Text
</label>
<label class="flex items-center gap-2 text-sm">
<input type="radio" bind:group={watermarkKind} value="image" /> Bild
</label>
</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'}
<label class="block text-sm font-medium text-primary-900" for="watermark-text">
Text
<label class="text-sm font-medium text-foreground" for="watermark-font-size">
Schriftgröße: {fontSize}
</label>
<input
id="watermark-text"
type="text"
bind:value={watermarkText}
class="w-full rounded-lg border border-primary-200 px-3 py-2 text-sm"
/>
<input id="watermark-font-size" type="range" min="10" max="120" bind:value={fontSize} />
{: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"
<label class="text-sm font-medium text-foreground" for="watermark-image-width">
Bildbreite: {imageWidth}
</label>
<input
id="watermark-image-width"
type="range"
min="10"
max="400"
bind:value={imageWidth}
/>
{/if}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
{#if watermarkKind === 'text'}
<label class="text-sm font-medium text-primary-900" for="watermark-font-size">
Schriftgröße: {fontSize}
</label>
<input id="watermark-font-size" type="range" min="10" max="120" bind:value={fontSize} />
{:else}
<label class="text-sm font-medium text-primary-900" for="watermark-image-width">
Bildbreite: {imageWidth}
</label>
<input
id="watermark-image-width"
type="range"
min="10"
max="400"
bind:value={imageWidth}
/>
{/if}
<label class="text-sm font-medium text-foreground" for="watermark-opacity">
Deckkraft: {Math.round(Number(opacity) * 100)}%
</label>
<input
id="watermark-opacity"
type="range"
min="0.05"
max="1"
step="0.05"
bind:value={opacity}
/>
<label class="text-sm font-medium text-primary-900" for="watermark-opacity">
Deckkraft: {Math.round(Number(opacity) * 100)}%
</label>
<input
id="watermark-opacity"
type="range"
min="0.05"
max="1"
step="0.05"
bind:value={opacity}
/>
<label class="text-sm font-medium text-foreground" for="watermark-rotation">
Drehung: {rotation}°
</label>
<input
id="watermark-rotation"
type="range"
min="0"
max="360"
bind:value={rotation}
disabled={position === 'diagonal'}
/>
<label class="text-sm font-medium text-primary-900" for="watermark-rotation">
Drehung: {rotation}°
</label>
<input
id="watermark-rotation"
type="range"
min="0"
max="360"
bind:value={rotation}
disabled={position === 'diagonal'}
/>
<label class="text-sm font-medium text-foreground" for="watermark-position">Position</label>
<select id="watermark-position" bind:value={position} class="rounded-lg border px-3 py-2">
<option value="center">Zentriert</option>
<option value="diagonal">Diagonal</option>
<option value="top-left">Oben links</option>
<option value="top-right">Oben rechts</option>
<option value="bottom-left">Unten links</option>
<option value="bottom-right">Unten rechts</option>
</select>
</div>
<label class="text-sm font-medium text-primary-900" for="watermark-position">
Position
</label>
<select id="watermark-position" bind:value={position} class="rounded-lg border px-3 py-2">
<option value="center">Zentriert</option>
<option value="diagonal">Diagonal</option>
<option value="top-left">Oben links</option>
<option value="top-right">Oben rechts</option>
<option value="bottom-left">Unten links</option>
<option value="bottom-right">Unten rechts</option>
</select>
</div>
{#if watermarkKind === 'text'}
<label class="block text-sm font-medium text-foreground" for="watermark-color">Farbe</label>
<input
id="watermark-color"
type="color"
bind:value={watermarkColor}
class="h-10 w-14 cursor-pointer"
/>
{/if}
{#if watermarkKind === 'text'}
<label class="block text-sm font-medium text-primary-900" for="watermark-color">
Farbe
</label>
<input
id="watermark-color"
type="color"
bind:value={watermarkColor}
class="h-10 w-14 cursor-pointer"
/>
{/if}
<label class="flex items-center gap-2 text-sm text-primary-900" for="all-pages">
{#if items.length > 1}
<p class="text-xs text-primary" role="note">
Die Einstellungen gelten für alle Dateien. Das Wasserzeichen wird auf allen Seiten jeder
Datei angewendet.
{#if isRunning}Änderungen wirken auf die noch ausstehenden Dateien.{/if}
</p>
{:else}
<label class="flex items-center gap-2 text-sm text-foreground" for="all-pages">
<input type="checkbox" id="all-pages" bind:checked={applyToAll} />
Auf alle Seiten anwenden
</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}
</div>
{#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={applyWatermark}
disabled={isProcessing || !watermarkReady || (!applyToAll && selectedPages.size === 0)}
>
{isProcessing ? 'Wird verarbeitet...' : 'Wasserzeichen anwenden & herunterladen'}
</Button>
{#if pageSelectionAvailable && !applyToAll}
<div>
<p class="mb-3 text-sm font-medium">Seiten auswählen:</p>
<PdfPageGrid thumbnails={pdfHandle!.thumbnails} {selectedPages} onPageClick={togglePage} />
</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>
{/if}
</div>