This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,6 +30,7 @@
|
|||||||
>
|
>
|
||||||
{#each thumbnails as thumb}
|
{#each thumbnails as thumb}
|
||||||
{@const isSelected = selectedPages.has(thumb.pageNumber)}
|
{@const isSelected = selectedPages.has(thumb.pageNumber)}
|
||||||
|
{#if onPageClick}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={cn(
|
class={cn(
|
||||||
@@ -33,14 +39,29 @@
|
|||||||
? 'border-primary bg-primary/5'
|
? 'border-primary bg-primary/5'
|
||||||
: 'border-transparent hover:border-primary-200 hover:bg-primary-50/50'
|
: 'border-transparent hover:border-primary-200 hover:bg-primary-50/50'
|
||||||
)}
|
)}
|
||||||
onclick={() => onPageClick?.(thumb.pageNumber)}
|
onclick={() => onPageClick(thumb.pageNumber)}
|
||||||
>
|
>
|
||||||
|
{#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
|
<img
|
||||||
src={thumb.imageUrl}
|
src={thumb.imageUrl}
|
||||||
alt="Seite {thumb.pageNumber}"
|
alt="Seite {thumb.pageNumber}"
|
||||||
class="w-full rounded border border-primary-100 bg-white object-contain"
|
class="w-full rounded border border-primary-100 bg-white object-contain"
|
||||||
style="aspect-ratio: {thumb.width} / {thumb.height};"
|
style="aspect-ratio: {thumb.width} / {thumb.height};"
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
<span class="text-[12px] font-medium text-primary-700">Seite {thumb.pageNumber}</span>
|
<span class="text-[12px] font-medium text-primary-700">Seite {thumb.pageNumber}</span>
|
||||||
{#if isSelected}
|
{#if isSelected}
|
||||||
<div
|
<div
|
||||||
@@ -50,5 +71,39 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</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>
|
||||||
|
|||||||
@@ -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
@@ -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 -->
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user