adds features
Build and Push Docker Image / build (push) Successful in 1m28s

This commit is contained in:
2026-08-27 07:38:35 +02:00
parent 891026ccbb
commit adaae41e44
33 changed files with 2771 additions and 396 deletions
+18
View File
@@ -0,0 +1,18 @@
<script lang="ts">
import { ArrowLeft } from '@lucide/svelte';
let { children } = $props();
</script>
<section class="px-4">
<div class="flex flex-wrap items-center gap-3 py-6">
<a
href="/"
class="flex items-center gap-2 text-[14px] font-medium text-primary-700 transition hover:text-primary-900"
>
<ArrowLeft class="h-4 w-4" />
Zurück
</a>
</div>
{@render children()}
</section>
+196
View File
@@ -0,0 +1,196 @@
<script lang="ts">
import { onMount } from 'svelte';
import Button from '$lib/components/ui/button.svelte';
import ProcessedZipArchiveEditor from '$lib/components/ProcessedZipArchiveEditor.svelte';
import ZipDropzone from '$lib/components/ZipDropzone.svelte';
import {
extractZipArchives,
mergeProcessedZipArchive,
type ProcessedZipArchive
} from '$lib/zip-processing';
import { downloadBlob } from '$lib/download';
import { takeQueuedZipFiles } from '$lib/pending-files';
import * as Card from '$lib/components/ui/card/index';
const THUMBNAIL_WIDTH_STORAGE_KEY = 'thumbnailWidth';
let selectedZipFiles: File[] = $state([]);
let processedZipFiles: ProcessedZipArchive[] = $state([]);
let pendingZipCount = $state(0);
let thumbnailWidth = $state(200);
let thumbnailWidthHydrated = $state(false);
let archiveGeneration = 0;
const clampThumbnailWidth = (value: number) => Math.min(400, Math.max(100, value));
onMount(() => {
const storedValue = localStorage.getItem(THUMBNAIL_WIDTH_STORAGE_KEY);
if (storedValue !== null) {
const parsedValue = Number(storedValue);
if (Number.isFinite(parsedValue)) {
thumbnailWidth = clampThumbnailWidth(parsedValue);
}
}
thumbnailWidthHydrated = true;
const queuedFiles = takeQueuedZipFiles();
if (queuedFiles.length > 0) handleFilesSelected(queuedFiles);
});
$effect(() => {
if (!thumbnailWidthHydrated) {
return;
}
localStorage.setItem(THUMBNAIL_WIDTH_STORAGE_KEY, String(thumbnailWidth));
});
const handleFilesSelected = (files: File[]) => {
selectedZipFiles = [...selectedZipFiles, ...files];
for (const file of files) {
void processZipFile(file, archiveGeneration);
}
};
const processZipFile = async (file: File, generation: number) => {
pendingZipCount += 1;
try {
const processedZipArchives = await extractZipArchives(file);
if (generation !== archiveGeneration) {
return;
}
processedZipFiles = [...processedZipFiles, ...processedZipArchives];
} catch (error) {
console.error(`Failed to process ${file.name}`, error);
} finally {
if (generation === archiveGeneration) {
pendingZipCount -= 1;
}
}
};
const updateProcessedZipFile = (index: number, archive: ProcessedZipArchive) => {
processedZipFiles = processedZipFiles.map((existingArchive, existingIndex) =>
existingIndex === index ? archive : existingArchive
);
};
const exportAllZipArchivesAsPdf = async () => {
for (const archive of processedZipFiles) {
try {
const mergedBytes = await mergeProcessedZipArchive(archive);
downloadBlob(mergedBytes, archive.name);
} catch (error) {
console.error(`Failed to export archive ${archive.name}`, error);
}
}
};
const deleteAllZipArchives = () => {
archiveGeneration += 1;
selectedZipFiles = [];
processedZipFiles = [];
pendingZipCount = 0;
};
</script>
{#snippet content()}
{#if selectedZipFiles.length === 0}
<Card.Root>
<Card.Content>
<ZipDropzone onFilesSelected={handleFilesSelected} />
</Card.Content>
</Card.Root>
{:else}
<details class="fixed bottom-4 right-4 z-20">
<summary
class="flex h-12 w-12 cursor-pointer list-none items-center justify-center rounded-full border border-primary-200 bg-white text-lg font-semibold text-primary-800 shadow-lg transition hover:border-primary-300 hover:bg-primary-50"
>
<span class="sr-only">Settings</span>
<span>⚙️</span>
</summary>
<div
class="absolute bottom-14 right-0 w-72 rounded-xl border border-primary-100 bg-white p-4 shadow-lg"
>
<div class="flex flex-col gap-3">
<div class="flex gap-2">
<Button
class="flex-1"
type="button"
disabled={processedZipFiles.length === 0}
onclick={exportAllZipArchivesAsPdf}
>
Alle ZIP-Archive als PDF exportieren
</Button>
<Button
class="flex-1"
type="button"
disabled={selectedZipFiles.length === 0}
variant="secondary"
onclick={deleteAllZipArchives}
>
Alle ZIP-Archive löschen
</Button>
</div>
<label class="flex flex-col gap-2">
<div
class="flex items-center justify-between gap-3 text-sm font-medium text-primary-900"
>
<span>Thumbnail Breite</span>
<span class="tabular-nums text-primary-700">{thumbnailWidth}px</span>
</div>
<input
class="w-full accent-primary-700"
type="range"
min="100"
max="400"
step="1"
bind:value={thumbnailWidth}
/>
</label>
</div>
</div>
</details>
<div
class="flex min-h-[calc(-180px+100vh)] w-full grow flex-col justify-center gap-4 px-4 py-6"
>
<div class="mx-auto flex w-full flex-col gap-3">
{#if pendingZipCount > 0}
<div
class="rounded-container bg-primary-50 px-4 py-3 text-[14px] font-medium text-primary-900"
>
Noch {pendingZipCount} ZIP-Datei{pendingZipCount === 1 ? '' : 'en'} in Bearbeitung.
</div>
{/if}
{#if processedZipFiles.length > 0}
<div class="flex flex-col gap-4">
{#each processedZipFiles as file, index}
<ProcessedZipArchiveEditor
archive={file}
{thumbnailWidth}
onArchiveChange={(updatedArchive) => updateProcessedZipFile(index, updatedArchive)}
/>
{/each}
</div>
{/if}
</div>
</div>
{/if}
{/snippet}
<div>
<h1 class="h1 text-[20px] font-bold text-primary-900">beA-Edit</h1>
{@render content()}
</div>
+218
View File
@@ -0,0 +1,218 @@
<script lang="ts">
import { PDFDocument } from 'pdf-lib';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import RasterizationWarning from '$lib/components/RasterizationWarning.svelte';
import Button from '$lib/components/ui/button.svelte';
import { downloadBlob } from '$lib/download';
import { isRasterizationTooLarge } from '$lib/rasterization-limits';
import { AlertTriangle } from '@lucide/svelte';
type CompressionPreset = {
id: string;
label: string;
description: string;
scale: number;
quality: number;
};
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 }
];
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()
});
}
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.';
} finally {
isProcessing = false;
}
};
const downloadCompressedPdf = () => {
if (compressedBytes) downloadBlob(compressedBytes, compressedFileName);
};
</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.
</p>
</div>
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex gap-3">
<AlertTriangle class="h-5 w-5 shrink-0 text-amber-600 mt-0.5" />
<div class="text-sm text-amber-800">
<p class="font-medium">Hinweis zur Qualität</p>
<p class="mt-1">
Das PDF wird neu gerendert, wodurch Text zu Bildern wird. Ausgewählter Text,
Durchsuchbarkeit und Vektorgrafiken gehen verloren.
</p>
</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>
<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 compressedBytes}
<p class="text-sm text-primary-700">
Ergebnisgröße: <span class="font-semibold">{formatBytes(compressedBytes.length)}</span>
</p>
{/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 gap-2">
<Button onclick={compressPdf} disabled={isProcessing || rasterizationBlocked}>
{isProcessing ? 'Wird komprimiert...' : 'Komprimieren'}
</Button>
{#if compressedBytes}
<Button onclick={downloadCompressedPdf}>PDF herunterladen</Button>
{/if}
</div>
</div>
{/if}
</div>
+192
View File
@@ -0,0 +1,192 @@
<script lang="ts">
import FileDropzone from '$lib/components/FileDropzone.svelte';
import RasterizationWarning from '$lib/components/RasterizationWarning.svelte';
import Button from '$lib/components/ui/button.svelte';
import { downloadBlob, downloadBlobUrl } from '$lib/download';
import { isRasterizationTooLarge } from '$lib/rasterization-limits';
import { zipSync } from 'fflate';
import { Image } from '@lucide/svelte';
type OutputFormat = 'png' | 'jpeg';
let pdfFile: File | null = $state(null);
let outputFormat = $state<OutputFormat>('png');
let scale = $state(2);
let isProcessing = $state(false);
let error = $state<string | null>(null);
let progress = $state(0);
let totalPages = $state(0);
const rasterizationBlocked = $derived(isRasterizationTooLarge(pdfFile));
const handleFileSelected = (files: File[]) => {
pdfFile = files[0] ?? null;
error = null;
progress = 0;
totalPages = 0;
};
const convertPdf = async () => {
if (!pdfFile) return;
isProcessing = true;
error = null;
progress = 0;
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 loadingTask = getDocument({
data: new Uint8Array(await pdfFile.arrayBuffer())
});
try {
const pdfDocument = await loadingTask.promise;
const renderScale = Number(scale);
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
const mimeType = outputFormat === 'png' ? 'image/png' : 'image/jpeg';
const extension = outputFormat === 'png' ? 'png' : 'jpg';
totalPages = pdfDocument.numPages;
const renderPage = async (pageNumber: number) => {
const page = await pdfDocument.getPage(pageNumber);
const viewport = page.getViewport({ scale: renderScale });
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;
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(result) => (result ? resolve(result) : reject(new Error('Image encode failed'))),
mimeType,
outputFormat === 'jpeg' ? 0.9 : undefined
);
});
};
if (pdfDocument.numPages === 1) {
const blob = await renderPage(1);
const objectUrl = URL.createObjectURL(blob);
downloadBlobUrl(objectUrl, `${baseName}.${extension}`);
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
progress = 1;
} else {
const images: Record<string, Uint8Array> = {};
for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) {
const blob = await renderPage(pageNumber);
images[`seite_${String(pageNumber).padStart(3, '0')}.${extension}`] = new Uint8Array(
await blob.arrayBuffer()
);
progress = pageNumber;
}
downloadBlob(zipSync(images), `${baseName}_bilder.zip`, 'application/zip');
}
} finally {
await loadingTask.destroy();
}
} catch (conversionError) {
console.error('Conversion failed:', conversionError);
error = 'Fehler beim Konvertieren des PDFs.';
} finally {
isProcessing = false;
}
};
</script>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-primary-900">Konvertieren</h1>
<p class="mt-2 text-sm text-primary-700">Konvertieren Sie ein PDF in Bilder (PNG oder JPEG).</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"
/>
<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">
<Image class="h-5 w-5" />
<h2 class="text-lg font-semibold">{pdfFile.name}</h2>
</div>
<div>
<p class="text-sm font-medium text-primary-900 mb-2">Bildformat</p>
<div class="flex gap-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" bind:group={outputFormat} value="png" class="h-4 w-4" />
<span class="text-sm text-primary-700">PNG (verlustfrei)</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" bind:group={outputFormat} value="jpeg" class="h-4 w-4" />
<span class="text-sm text-primary-700">JPEG (kleiner)</span>
</label>
</div>
</div>
<div>
<label class="block text-sm font-medium text-primary-900 mb-1" for="render-scale">
Auflösung: {scale}x
</label>
<input
id="render-scale"
type="range"
min="1"
max="4"
step="0.5"
bind:value={scale}
class="w-full"
/>
<p class="text-xs text-primary-600 mt-1">
{scale === 1
? '~72 DPI'
: scale === 2
? '~150 DPI'
: scale === 3
? '~216 DPI'
: '~288 DPI'}
</p>
</div>
{#if isProcessing && totalPages > 0}
<div class="space-y-1">
<div class="flex justify-between text-xs text-primary-700">
<span>Wird konvertiert...</span>
<span>{progress} / {totalPages}</span>
</div>
<div class="h-2 w-full rounded-full bg-primary-100">
<div
class="h-2 rounded-full bg-primary transition-all"
style="width: {(progress / totalPages) * 100}%"
></div>
</div>
</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 gap-2">
<Button onclick={convertPdf} disabled={isProcessing || rasterizationBlocked}>
{isProcessing ? 'Wird konvertiert...' : 'Konvertieren & herunterladen'}
</Button>
</div>
</div>
{/if}
</div>
+152
View File
@@ -0,0 +1,152 @@
<script lang="ts">
import { PDFDocument } from 'pdf-lib';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import RasterizationWarning from '$lib/components/RasterizationWarning.svelte';
import Button from '$lib/components/ui/button.svelte';
import { downloadBlob } from '$lib/download';
import { isRasterizationTooLarge } from '$lib/rasterization-limits';
import { Unlock, AlertTriangle } from '@lucide/svelte';
let pdfFile: File | null = $state(null);
let password = $state('');
let isProcessing = $state(false);
let error = $state<string | null>(null);
const rasterizationBlocked = $derived(isRasterizationTooLarge(pdfFile));
const handleFileSelected = (files: File[]) => {
pdfFile = files[0] ?? null;
error = null;
};
const decryptPdf = async () => {
if (!pdfFile) return;
isProcessing = true;
error = null;
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 loadingTask = getDocument({
data: new Uint8Array(await pdfFile.arrayBuffer()),
password
});
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();
}
} catch (decryptionError) {
console.error('Decryption failed:', decryptionError);
error = 'Fehler beim Entsperren. Bitte prüfen Sie das Passwort und versuchen Sie es erneut.';
} finally {
isProcessing = false;
}
};
</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.
</p>
</div>
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex gap-3">
<AlertTriangle class="h-5 w-5 shrink-0 text-amber-600 mt-0.5" />
<div class="text-sm text-amber-800">
<p class="font-medium">Hinweis zur Qualität</p>
<p class="mt-1">
Das PDF wird neu gerendert, wodurch Text zu Bildern wird. Ausgewählter Text,
Durchsuchbarkeit und Vektorgrafiken gehen verloren. Die Dateigröße kann größer werden.
</p>
</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>
<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>
</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={decryptPdf} disabled={isProcessing || !password || rasterizationBlocked}>
{isProcessing ? 'Wird entsperrt...' : 'Passwort entfernen & herunterladen'}
</Button>
</div>
</div>
{/if}
</div>
+150
View File
@@ -0,0 +1,150 @@
<script lang="ts">
import { PDFDocument } from '@cantoo/pdf-lib';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import Button from '$lib/components/ui/button.svelte';
import { downloadBlob } from '$lib/download';
import { Lock } from '@lucide/svelte';
let pdfFile: File | null = $state(null);
let password = $state('');
let confirmPassword = $state('');
let allowPrinting = $state(true);
let allowCopying = $state(true);
let isProcessing = $state(false);
let error = $state<string | null>(null);
const handleFileSelected = (files: File[]) => {
pdfFile = files[0] ?? null;
error = null;
};
const encryptPdf = async () => {
if (!pdfFile || !password) return;
if (password !== confirmPassword) {
error = 'Passwörter stimmen nicht überein.';
return;
}
if (password.length < 1) {
error = 'Bitte geben Sie ein Passwort ein.';
return;
}
isProcessing = true;
error = null;
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
}
});
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;
}
};
</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.
</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>
<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>
<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>
{#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}
>
{isProcessing ? 'Wird verschlüsselt...' : 'Passwort setzen & herunterladen'}
</Button>
</div>
</div>
{/if}
</div>
+108
View File
@@ -0,0 +1,108 @@
<script lang="ts">
import FileDropzone from '$lib/components/FileDropzone.svelte';
import Button from '$lib/components/ui/button.svelte';
import { downloadBlob } from '$lib/download';
import { mergePdfFiles } from '$lib/pdf-processing';
import { Trash2 } from '@lucide/svelte';
let pdfFiles: File[] = $state([]);
let isProcessing = $state(false);
let error = $state<string | null>(null);
const handleFilesSelected = (files: File[]) => {
pdfFiles = [...pdfFiles, ...files];
error = null;
};
const removeFile = (index: number) => {
pdfFiles = pdfFiles.filter((_, i) => i !== index);
};
const moveFile = (fromIndex: number, toIndex: number) => {
if (toIndex < 0 || toIndex >= pdfFiles.length) return;
const newFiles = [...pdfFiles];
const [moved] = newFiles.splice(fromIndex, 1);
newFiles.splice(toIndex, 0, moved);
pdfFiles = newFiles;
};
const mergePdfs = async () => {
if (pdfFiles.length < 2) return;
isProcessing = true;
error = null;
try {
downloadBlob(await mergePdfFiles(pdfFiles), 'zusammengefuegt.pdf');
} catch (err) {
console.error('Merge failed:', err);
error = 'Fehler beim Zusammenfügen der PDFs.';
} finally {
isProcessing = false;
}
};
</script>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-primary-900">PDFs zusammenfügen</h1>
<p class="mt-2 text-sm text-primary-700">
Fügen Sie mehrere PDF-Dateien zu einem einzigen Dokument zusammen.
</p>
</div>
<FileDropzone onFilesSelected={handleFilesSelected} multiple={true} />
{#if pdfFiles.length > 0}
<div class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-primary-900">
{pdfFiles.length} Datei{pdfFiles.length === 1 ? '' : 'en'} ausgewählt
</h2>
<Button onclick={mergePdfs} disabled={isProcessing || pdfFiles.length < 2}>
{isProcessing ? 'Wird zusammengefügt...' : 'PDFs zusammenfügen'}
</Button>
</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="space-y-2">
{#each pdfFiles as file, index}
<div class="flex items-center gap-3 rounded-lg border border-primary-200 bg-white p-3">
<span
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary"
>
{index + 1}
</span>
<span class="flex-1 truncate text-sm text-primary-900">{file.name}</span>
<div class="flex gap-1">
<Button
variant="tertiary"
class="min-w-0 px-3"
onclick={() => moveFile(index, index - 1)}
disabled={index === 0}
>
</Button>
<Button
variant="tertiary"
class="min-w-0 px-3"
onclick={() => moveFile(index, index + 1)}
disabled={index === pdfFiles.length - 1}
>
</Button>
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => removeFile(index)}>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
+162
View File
@@ -0,0 +1,162 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { PDFDocument } 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 { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
let pdfFile: File | null = $state(null);
let pdfHandle: PdfDocumentHandle | null = $state(null);
let selectedPages = $state(new Set<number>());
let isProcessing = $state(false);
let error = $state<string | null>(null);
let loadingError = $state<string | null>(null);
const handleFileSelected = async (files: File[]) => {
const file = files[0];
if (!file) return;
pdfHandle?.destroy();
pdfHandle = null;
pdfFile = file;
selectedPages = new Set();
loadingError = null;
error = null;
try {
const bytes = new Uint8Array(await file.arrayBuffer());
pdfHandle = await loadPdfDocument(bytes);
} catch (err) {
console.error('Failed to load PDF:', err);
loadingError = 'PDF konnte nicht geladen werden.';
pdfHandle = null;
}
};
const togglePage = (pageNumber: number) => {
const next = new Set(selectedPages);
if (next.has(pageNumber)) {
next.delete(pageNumber);
} else {
next.add(pageNumber);
}
selectedPages = next;
};
const selectAll = () => {
if (!pdfHandle) return;
const all = new Set<number>();
for (let i = 1; i <= pdfHandle.pageCount; i++) all.add(i);
selectedPages = all;
};
const selectNone = () => {
selectedPages = new Set();
};
const removePages = async () => {
if (!pdfFile || !pdfHandle || selectedPages.size === 0) return;
isProcessing = true;
error = null;
try {
const bytes = new Uint8Array(await pdfFile.arrayBuffer());
const sourcePdf = await PDFDocument.load(bytes);
const outputPdf = await PDFDocument.create();
const pagesToKeep: number[] = [];
for (let i = 0; i < sourcePdf.getPageCount(); i++) {
if (!selectedPages.has(i + 1)) {
pagesToKeep.push(i);
}
}
if (pagesToKeep.length === 0) {
error = 'Es muss mindestens eine Seite übrig bleiben.';
isProcessing = false;
return;
}
const copiedPages = await outputPdf.copyPages(sourcePdf, pagesToKeep);
copiedPages.forEach((page) => outputPdf.addPage(page));
const resultBytes = await outputPdf.save();
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
downloadBlob(resultBytes, `${baseName}_ohne_seiten.pdf`);
} catch (err) {
console.error('Failed to remove pages:', err);
error = 'Fehler beim Entfernen der Seiten.';
} finally {
isProcessing = false;
}
};
onDestroy(() => {
pdfHandle?.destroy();
});
</script>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-primary-900">Seiten entfernen</h1>
<p class="mt-2 text-sm text-primary-700">
Wählen Sie Seiten aus, die Sie aus dem PDF entfernen möchten.
</p>
</div>
{#if !pdfHandle}
<FileDropzone onFilesSelected={handleFileSelected} />
{#if loadingError}
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
{loadingError}
</div>
{/if}
{:else}
<div class="space-y-4">
<FileDropzone
onFilesSelected={handleFileSelected}
label="Anderes PDF ablegen"
sublabel={pdfFile?.name ?? ''}
class="py-4"
/>
<div class="flex flex-wrap items-center justify-between gap-3">
<h2 class="text-lg font-semibold text-primary-900">
{pdfFile?.name}{pdfHandle.pageCount} Seite{pdfHandle.pageCount === 1 ? '' : 'n'}
</h2>
<div class="flex gap-2">
<Button variant="tertiary" class="min-w-0 px-3" onclick={selectAll}>
Alle auswählen
</Button>
<Button variant="tertiary" class="min-w-0 px-3" onclick={selectNone}>
Auswahl aufheben
</Button>
</div>
</div>
<PdfPageGrid thumbnails={pdfHandle.thumbnails} {selectedPages} onPageClick={togglePage} />
{#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 items-center justify-between">
<p class="text-sm text-primary-700">
{selectedPages.size} Seite{selectedPages.size === 1 ? '' : 'n'} zum Entfernen ausgewählt
</p>
<Button
onclick={removePages}
disabled={isProcessing ||
selectedPages.size === 0 ||
selectedPages.size >= pdfHandle.pageCount}
>
{isProcessing ? 'Wird verarbeitet...' : 'Seiten entfernen & herunterladen'}
</Button>
</div>
</div>
{/if}
</div>
+329
View File
@@ -0,0 +1,329 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { degrees, PDFDocument, rgb, StandardFonts } 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 { 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 watermarkKind = $state<WatermarkKind>('text');
let watermarkText = $state('VERTRAULICH');
let watermarkImage: File | null = $state(null);
let fontSize = $state(50);
let imageWidth = $state(160);
let opacity = $state(0.3);
let rotation = $state(0);
let position = $state<WatermarkPosition>('diagonal');
let watermarkColor = $state('#cc1a1a');
let applyToAll = $state(true);
let selectedPages = $state(new Set<number>());
let isProcessing = $state(false);
let error = $state<string | null>(null);
const watermarkReady = $derived(
watermarkKind === 'text' ? watermarkText.trim().length > 0 : watermarkImage !== null
);
const handleFileSelected = async (files: File[]) => {
const file = files[0];
if (!file) return;
pdfHandle?.destroy();
pdfHandle = null;
pdfFile = file;
selectedPages = new Set();
error = null;
try {
pdfHandle = await loadPdfDocument(new Uint8Array(await file.arrayBuffer()));
} catch (loadError) {
console.error('Failed to load PDF for watermarking:', loadError);
error = 'PDF konnte nicht geladen werden.';
}
};
const handleImageSelected = (files: File[]) => {
watermarkImage = files[0] ?? null;
error = null;
};
const togglePage = (pageNumber: number) => {
const next = new Set(selectedPages);
if (next.has(pageNumber)) next.delete(pageNumber);
else next.add(pageNumber);
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 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
);
};
const applyWatermark = async () => {
if (!pdfFile || !watermarkReady) return;
if (!applyToAll && selectedPages.size === 0) {
error = 'Bitte wählen Sie mindestens eine Seite aus.';
return;
}
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)
});
}
}
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;
}
};
onDestroy(() => pdfHandle?.destroy());
</script>
<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.
</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"
/>
<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>
<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>
{#if watermarkKind === 'text'}
<label class="block text-sm font-medium text-primary-900" for="watermark-text">
Text
</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"
/>
{: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="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-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-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-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-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">
<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}
{#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>
</div>
</div>
{/if}
</div>