714 lines
22 KiB
Svelte
714 lines
22 KiB
Svelte
<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>
|