rotate + hover on start-page
Build and Push Docker Image / build (push) Successful in 1m26s

This commit is contained in:
2026-08-29 12:04:29 +02:00
parent ea21fc000f
commit 09cc5c06fb
7 changed files with 402 additions and 58 deletions
+1 -1
View File
@@ -96,7 +96,7 @@ src/
├── tools/ ├── tools/
│ ├── +layout.svelte # Shared back-navigation shell │ ├── +layout.svelte # Shared back-navigation shell
│ ├── bea/+page.svelte # Existing ZIP archive workflow │ ├── bea/+page.svelte # Existing ZIP archive workflow
│ └── {merge,separate,stamp,watermark,encrypt,decrypt,compress,convert}/ │ └── {merge,separate,stamp,watermark,encrypt,decrypt,compress,convert,rotate}/
├── datenschutz/+page.svelte # Privacy policy ├── datenschutz/+page.svelte # Privacy policy
└── impressum/+page.svelte # Imprint └── impressum/+page.svelte # Imprint
static/ # Public logo, footer art, and robots.txt static/ # Public logo, footer art, and robots.txt
+11 -4
View File
@@ -103,9 +103,9 @@
<button <button
type="button" type="button"
class="flex w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-primary-300 bg-white px-6 py-10 text-center transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 {isDragging class="flex w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed px-6 py-10 text-center transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 {isDragging
? 'border-primary-400 bg-primary-100/60' ? 'scale-[1.02] border-primary bg-primary-100/70 shadow-lg ring-4 ring-primary/25'
: ''} {className}" : 'border-primary-300 bg-white hover:border-primary-400 hover:bg-primary-50/60'} {className}"
aria-label={ariaLabel} aria-label={ariaLabel}
data-dropzone-ready={fileInput ? 'true' : 'false'} data-dropzone-ready={fileInput ? 'true' : 'false'}
onclick={openFileDialog} onclick={openFileDialog}
@@ -116,7 +116,11 @@
> >
{#if icon} {#if icon}
{@const IconComponent = icon} {@const IconComponent = icon}
<IconComponent class="h-10 w-10 text-primary" /> <IconComponent
class="h-10 w-10 text-primary transition-transform duration-150 {isDragging
? 'scale-125'
: ''}"
/>
{/if} {/if}
<div class="flex flex-row items-baseline justify-center gap-2"> <div class="flex flex-row items-baseline justify-center gap-2">
<span class="text-[18px] font-bold text-primary-900">{label}</span> <span class="text-[18px] font-bold text-primary-900">{label}</span>
@@ -125,6 +129,9 @@
{#if sublabel} {#if sublabel}
<p class="text-[13px] text-primary-700">{sublabel}</p> <p class="text-[13px] text-primary-700">{sublabel}</p>
{/if} {/if}
{#if isDragging}
<p class="text-[13px] font-bold text-primary">Jetzt loslassen zum Hinzufügen</p>
{/if}
</button> </button>
{#if rejectionMessage} {#if rejectionMessage}
+81 -26
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte';
import type { PdfThumbnail } from '$lib/pdf-thumbnails'; import type { PdfThumbnail } from '$lib/pdf-thumbnails';
import { cn } from '$lib/utils'; import { cn } from '$lib/utils';
@@ -8,6 +9,8 @@
onPageClick?: (pageNumber: number) => void; onPageClick?: (pageNumber: number) => void;
thumbnailWidth?: number; thumbnailWidth?: number;
class?: string; class?: string;
pageActions?: Snippet<[pageNumber: number]>;
cardClass?: (pageNumber: number) => string;
}; };
let { let {
@@ -15,7 +18,9 @@
selectedPages = new Set<number>(), selectedPages = new Set<number>(),
onPageClick, onPageClick,
thumbnailWidth = 120, thumbnailWidth = 120,
class: className = '' class: className = '',
pageActions,
cardClass
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -25,30 +30,80 @@
> >
{#each thumbnails as thumb} {#each thumbnails as thumb}
{@const isSelected = selectedPages.has(thumb.pageNumber)} {@const isSelected = selectedPages.has(thumb.pageNumber)}
<button {#if onPageClick}
type="button" <button
class={cn( type="button"
'group relative flex flex-col items-center gap-2 rounded-xl border-2 p-2 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400', class={cn(
isSelected 'group relative flex flex-col items-center gap-2 rounded-xl border-2 p-2 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400',
? 'border-primary bg-primary/5' isSelected
: 'border-transparent hover:border-primary-200 hover:bg-primary-50/50' ? 'border-primary bg-primary/5'
)} : 'border-transparent hover:border-primary-200 hover:bg-primary-50/50'
onclick={() => onPageClick?.(thumb.pageNumber)} )}
> onclick={() => onPageClick(thumb.pageNumber)}
<img >
src={thumb.imageUrl} {#if cardClass}
alt="Seite {thumb.pageNumber}" <div
class="w-full rounded border border-primary-100 bg-white object-contain" class={cn(
style="aspect-ratio: {thumb.width} / {thumb.height};" 'flex aspect-square w-full items-center justify-center',
/> cardClass(thumb.pageNumber)
<span class="text-[12px] font-medium text-primary-700">Seite {thumb.pageNumber}</span> )}
{#if isSelected} >
<div <img
class="absolute top-1 right-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-white" src={thumb.imageUrl}
> alt="Seite {thumb.pageNumber}"
class="max-h-full max-w-full rounded border border-primary-100 bg-white object-contain"
</div> />
{/if} </div>
</button> {:else}
<img
src={thumb.imageUrl}
alt="Seite {thumb.pageNumber}"
class="w-full rounded border border-primary-100 bg-white object-contain"
style="aspect-ratio: {thumb.width} / {thumb.height};"
/>
{/if}
<span class="text-[12px] font-medium text-primary-700">Seite {thumb.pageNumber}</span>
{#if isSelected}
<div
class="absolute top-1 right-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-white"
>
</div>
{/if}
</button>
{:else}
<div
class={cn(
'group relative flex flex-col items-center gap-2 rounded-xl border-2 border-transparent p-2',
isSelected && 'border-primary bg-primary/5'
)}
>
{#if cardClass}
<div
class={cn(
'flex aspect-square w-full items-center justify-center',
cardClass(thumb.pageNumber)
)}
>
<img
src={thumb.imageUrl}
alt="Seite {thumb.pageNumber}"
class="max-h-full max-w-full rounded border border-primary-100 bg-white object-contain"
/>
</div>
{:else}
<img
src={thumb.imageUrl}
alt="Seite {thumb.pageNumber}"
class="w-full rounded border border-primary-100 bg-white object-contain"
style="aspect-ratio: {thumb.width} / {thumb.height};"
/>
{/if}
<span class="text-[12px] font-medium text-primary-700">Seite {thumb.pageNumber}</span>
{#if pageActions}
{@render pageActions(thumb.pageNumber)}
{/if}
</div>
{/if}
{/each} {/each}
</div> </div>
+6
View File
@@ -48,6 +48,12 @@ export const tools: Tool[] = [
description: 'Entfernen Sie Seiten aus einem PDF.', description: 'Entfernen Sie Seiten aus einem PDF.',
icon: 'scissors' icon: 'scissors'
}, },
{
slug: 'rotate',
title: 'Seiten drehen',
description: 'Drehen Sie einzelne oder alle Seiten eines PDFs.',
icon: 'rotate-cw'
},
{ {
slug: 'merge', slug: 'merge',
title: 'PDFs zusammenfügen', title: 'PDFs zusammenfügen',
+4 -27
View File
@@ -9,6 +9,7 @@
Scissors, Scissors,
Merge, Merge,
Stamp, Stamp,
RotateCw,
FileText FileText
} from '@lucide/svelte'; } from '@lucide/svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -25,11 +26,10 @@
minimize: Minimize2, minimize: Minimize2,
scissors: Scissors, scissors: Scissors,
merge: Merge, merge: Merge,
stamp: Stamp stamp: Stamp,
'rotate-cw': RotateCw
}; };
let isDraggingFiles = $state(false);
const isZipFile = (file: File) => const isZipFile = (file: File) =>
file.name.toLowerCase().endsWith('.zip') || file.name.toLowerCase().endsWith('.zip') ||
file.type === 'application/zip' || file.type === 'application/zip' ||
@@ -46,17 +46,11 @@
const handleWindowDragOver = (event: DragEvent) => { const handleWindowDragOver = (event: DragEvent) => {
if (!event.dataTransfer?.types.includes('Files')) return; if (!event.dataTransfer?.types.includes('Files')) return;
event.preventDefault(); event.preventDefault();
isDraggingFiles = true;
};
const handleWindowDragLeave = (event: DragEvent) => {
if (event.relatedTarget === null) isDraggingFiles = false;
}; };
const handleWindowDrop = (event: DragEvent) => { const handleWindowDrop = (event: DragEvent) => {
if (!event.dataTransfer?.files.length) return; if (!event.dataTransfer?.files.length) return;
event.preventDefault(); event.preventDefault();
isDraggingFiles = false;
openBeaFiles(Array.from(event.dataTransfer.files)); openBeaFiles(Array.from(event.dataTransfer.files));
}; };
</script> </script>
@@ -65,24 +59,7 @@
<title>beA-Edit — Werkzeugkasten für das besondere elektronische Anwaltspostfach</title> <title>beA-Edit — Werkzeugkasten für das besondere elektronische Anwaltspostfach</title>
</svelte:head> </svelte:head>
<svelte:window <svelte:window ondragover={handleWindowDragOver} ondrop={handleWindowDrop} />
ondragover={handleWindowDragOver}
ondragleave={handleWindowDragLeave}
ondrop={handleWindowDrop}
/>
{#if isDraggingFiles}
<div
class="pointer-events-none fixed inset-3 z-50 grid place-items-center rounded-3xl border-4 border-dashed border-primary bg-background/95 text-center shadow-2xl"
aria-hidden="true"
>
<div class="px-4">
<Archive class="mx-auto h-12 w-12" />
<p class="mt-4 text-xl font-bold">beA-ZIP hier ablegen</p>
<p class="mt-1 text-sm">Archiv aus dem besonderen elektronischen Anwaltspostfach</p>
</div>
</div>
{/if}
<div class="flex flex-col gap-10 px-4 py-10"> <div class="flex flex-col gap-10 px-4 py-10">
<!-- Hero --> <!-- Hero -->
+219
View File
@@ -0,0 +1,219 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { degrees, PDFDocument } from 'pdf-lib';
import { RotateCcw, RotateCw } from '@lucide/svelte';
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 rotations = $state(new Map<number, number>());
let isProcessing = $state(false);
let error = $state<string | null>(null);
let loadingError = $state<string | null>(null);
const rotatedCount = $derived(rotations.size);
const hasRotations = $derived(rotatedCount > 0);
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;
pdfHandle?.destroy();
pdfHandle = null;
pdfFile = file;
rotations = new Map();
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 = loadErrorMessage(err);
pdfHandle = null;
}
};
const rotatePage = (pageNumber: number, delta: number) => {
const next = new Map(rotations);
const current = next.get(pageNumber) ?? 0;
const value = (((current + delta) % 360) + 360) % 360;
if (value === 0) {
next.delete(pageNumber);
} else {
next.set(pageNumber, value);
}
rotations = next;
};
const rotateAll = (delta: number) => {
if (!pdfHandle) return;
for (let pageNumber = 1; pageNumber <= pdfHandle.pageCount; pageNumber += 1) {
rotatePage(pageNumber, delta);
}
};
const resetRotations = () => {
rotations = new Map();
};
const previewClass = (pageNumber: number) => {
const delta = rotations.get(pageNumber) ?? 0;
switch (delta) {
case 90:
return 'rotate-90';
case 180:
return 'rotate-180';
case 270:
return 'rotate-[270deg]';
default:
return '';
}
};
const rotateAndDownload = async () => {
if (!pdfFile || !pdfHandle || !hasRotations) return;
isProcessing = true;
error = null;
try {
const bytes = new Uint8Array(await pdfFile.arrayBuffer());
const pdfDoc = await PDFDocument.load(bytes);
for (const [index, page] of pdfDoc.getPages().entries()) {
const delta = rotations.get(index + 1);
if (!delta) continue;
const current = page.getRotation().angle;
page.setRotation(degrees((((current + delta) % 360) + 360) % 360));
}
const resultBytes = await pdfDoc.save();
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
downloadBlob(resultBytes, `${baseName}_gedreht.pdf`);
} catch (err) {
console.error('Failed to rotate pages:', err);
error = isEncryptedPdfError(err)
? 'Dieses PDF ist passwortgeschützt. Entfernen Sie zuerst unter „Passwort entfernen“ den Schutz.'
: 'Fehler beim Drehen 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 drehen</h1>
<p class="mt-2 text-sm text-primary-700">
Drehen Sie einzelne oder alle Seiten eines PDFs — verlustfrei und ohne Qualitätsverlust.
</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 flex-wrap gap-2">
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => rotateAll(90)}>
Alle 90° rechts
</Button>
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => rotateAll(270)}>
Alle 90° links
</Button>
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => rotateAll(180)}>
Alle 180°
</Button>
<Button
variant="tertiary"
class="min-w-0 px-3"
onclick={resetRotations}
disabled={!hasRotations}
>
Zurücksetzen
</Button>
</div>
</div>
<PdfPageGrid thumbnails={pdfHandle.thumbnails} thumbnailWidth={160} cardClass={previewClass}>
{#snippet pageActions(pageNumber)}
{#if (rotations.get(pageNumber) ?? 0) !== 0}
<span
class="absolute top-1 right-1 rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-bold text-white"
>
{rotations.get(pageNumber)}°
</span>
{/if}
<div class="flex gap-1">
<button
type="button"
class="flex h-7 w-7 items-center justify-center rounded-lg border border-primary-200 bg-white text-primary transition hover:bg-primary-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
aria-label="Seite {pageNumber} um 90° nach links drehen"
onclick={() => rotatePage(pageNumber, 270)}
>
<RotateCcw class="h-4 w-4" />
</button>
<button
type="button"
class="flex h-7 w-7 items-center justify-center rounded-lg border border-primary-200 bg-white text-primary transition hover:bg-primary-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
aria-label="Seite {pageNumber} um 90° nach rechts drehen"
onclick={() => rotatePage(pageNumber, 90)}
>
<RotateCw class="h-4 w-4" />
</button>
</div>
{/snippet}
</PdfPageGrid>
{#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">
{rotatedCount} von {pdfHandle.pageCount} Seite{pdfHandle.pageCount === 1 ? '' : 'n'}
gedreht
</p>
<Button onclick={rotateAndDownload} disabled={isProcessing || !hasRotations}>
{isProcessing ? 'Wird verarbeitet...' : 'Drehen & herunterladen'}
</Button>
</div>
</div>
{/if}
</div>
+80
View File
@@ -1,6 +1,7 @@
import { expect, test, type Download, type Locator, type Page } from '@playwright/test'; import { expect, test, type Download, type Locator, type Page } from '@playwright/test';
import { import {
decodePDFRawStream, decodePDFRawStream,
degrees,
PDFArray, PDFArray,
PDFDocument, PDFDocument,
PDFRawStream, PDFRawStream,
@@ -132,6 +133,23 @@ const createEncryptedPdf = async (pageCount: number, label: string, password: st
return Buffer.from(await document.save()); return Buffer.from(await document.save());
}; };
const createPreRotatedPdf = async (pageCount: number, label: string) => {
const document = await PDFDocument.create();
const font = await document.embedFont(StandardFonts.Helvetica);
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) {
const page = document.addPage([420, 594]);
page.drawText(`${label} Seite ${pageNumber}`, {
x: 40,
y: 520,
size: 20,
font,
color: rgb(0.1, 0.2, 0.4)
});
if (pageNumber === 1) page.setRotation(degrees(90));
}
return Buffer.from(await document.save());
};
const trackPageErrors = (page: Page) => { const trackPageErrors = (page: Page) => {
const errors: Error[] = []; const errors: Error[] = [];
page.on('pageerror', (error) => errors.push(error)); page.on('pageerror', (error) => errors.push(error));
@@ -264,6 +282,68 @@ test('separate renders dropped PDF pages and removes a selected page', async ({
expect(pageErrors).toEqual([]); expect(pageErrors).toEqual([]);
}); });
test('rotate turns pages losslessly via /Rotate and reset clears deltas', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/rotate');
await dropFiles(page, pdfDropzone(page), [
{ name: 'drehung.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Drehen') }
]);
const downloadButton = page.getByRole('button', { name: 'Drehen & herunterladen' });
await expect(page.getByRole('button', { name: 'Seite 2 um 90° nach rechts drehen' })).toBeVisible(
{
timeout: 15_000
}
);
await expect(downloadButton).toBeDisabled();
const rotateRightPage2 = page.getByRole('button', { name: 'Seite 2 um 90° nach rechts drehen' });
await rotateRightPage2.click();
await rotateRightPage2.click();
await expect(page.getByText('1 von 3 Seiten gedreht')).toBeVisible();
await expect(page.getByText('180°', { exact: true })).toBeVisible();
const downloadPromise = page.waitForEvent('download');
await downloadButton.click();
const bytes = await expectPdfDownload(await downloadPromise, /^drehung_gedreht\.pdf$/);
const output = await PDFDocument.load(bytes);
expect(output.getPage(1).getRotation().angle).toBe(180);
expect(output.getPage(0).getRotation().angle).toBe(0);
await page.getByRole('button', { name: 'Zurücksetzen' }).click();
await expect(page.getByText('0 von 3 Seiten gedreht')).toBeVisible();
await expect(downloadButton).toBeDisabled();
expect(pageErrors).toEqual([]);
});
test('rotate adds the delta on top of an existing /Rotate value', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/rotate');
await dropFiles(page, pdfDropzone(page), [
{
name: 'vorgedreht.pdf',
mimeType: 'application/pdf',
buffer: await createPreRotatedPdf(2, 'Vorgedreht')
}
]);
await expect(page.getByRole('button', { name: 'Seite 1 um 90° nach rechts drehen' })).toBeVisible(
{
timeout: 15_000
}
);
await page.getByRole('button', { name: 'Seite 1 um 90° nach rechts drehen' }).click();
await expect(page.getByText('1 von 2 Seiten gedreht')).toBeVisible();
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Drehen & herunterladen' }).click();
const bytes = await expectPdfDownload(await downloadPromise, /^vorgedreht_gedreht\.pdf$/);
const output = await PDFDocument.load(bytes);
expect(output.getPage(0).getRotation().angle).toBe(180);
expect(output.getPage(1).getRotation().angle).toBe(0);
expect(pageErrors).toEqual([]);
});
test('watermark accepts numeric controls and downloads text and image watermarks', async ({ test('watermark accepts numeric controls and downloads text and image watermarks', async ({
page page
}) => { }) => {