This commit is contained in:
@@ -67,6 +67,7 @@ src/
|
|||||||
│ ├── assets/ # Favicon and local Nunito fonts
|
│ ├── assets/ # Favicon and local Nunito fonts
|
||||||
│ ├── components/
|
│ ├── components/
|
||||||
│ │ ├── AttachmentPreview.svelte # Browser-rendered PDF/image thumbnails
|
│ │ ├── AttachmentPreview.svelte # Browser-rendered PDF/image thumbnails
|
||||||
|
│ │ ├── BatchFileList.svelte # Shared batch rows: status chips, downloads, retry
|
||||||
│ │ ├── BeaArchiveProcessing.svelte
|
│ │ ├── BeaArchiveProcessing.svelte
|
||||||
│ │ ├── BeaWorkspaceControls.svelte # beA workspace toolbar, settings popover, dialogs
|
│ │ ├── BeaWorkspaceControls.svelte # beA workspace toolbar, settings popover, dialogs
|
||||||
│ │ ├── ProcessedZipArchiveEditor.svelte
|
│ │ ├── ProcessedZipArchiveEditor.svelte
|
||||||
@@ -81,6 +82,9 @@ src/
|
|||||||
│ ├── services/
|
│ ├── services/
|
||||||
│ │ ├── xml-reading.service.ts # Namespace-tolerant XJustiz parsing
|
│ │ ├── xml-reading.service.ts # Namespace-tolerant XJustiz parsing
|
||||||
│ │ └── zip-inflating.service.ts # Async fflate wrapper
|
│ │ └── zip-inflating.service.ts # Async fflate wrapper
|
||||||
|
│ ├── batch.ts # Sequential batch engine for multi-file tools
|
||||||
|
│ ├── pdf-compression.ts # Shared compression presets and rasterize pipeline
|
||||||
|
│ ├── pdf-overlay.ts # Shared PDF overlay positions and color parsing
|
||||||
│ ├── pdf-processing.ts # Reusable PDF merge helper
|
│ ├── pdf-processing.ts # Reusable PDF merge helper
|
||||||
│ ├── pdf-thumbnails.ts # Managed pdfjs thumbnail loading and cleanup
|
│ ├── pdf-thumbnails.ts # Managed pdfjs thumbnail loading and cleanup
|
||||||
│ ├── utils.ts # cn() and shared component utility types
|
│ ├── utils.ts # cn() and shared component utility types
|
||||||
@@ -92,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,watermark,encrypt,decrypt,compress,convert}/
|
│ └── {merge,separate,stamp,watermark,encrypt,decrypt,compress,convert}/
|
||||||
├── 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
|
||||||
@@ -174,6 +178,32 @@ localStorage, and `crypto.randomUUID()`. Do not invoke browser-only work during
|
|||||||
initialization. Keep it in event handlers, `onMount`, effects guarded by hydration, or functions only
|
initialization. Keep it in event handlers, `onMount`, effects guarded by hydration, or functions only
|
||||||
called in the browser.
|
called in the browser.
|
||||||
|
|
||||||
|
## Batch processing pattern for single-PDF tools
|
||||||
|
|
||||||
|
Tools that transform one PDF per run (compress, watermark, encrypt, …) are being retrofitted to
|
||||||
|
accept multiple files. Follow the established compress retrofit when adding the pattern to another
|
||||||
|
tool; see `plan/batch-processing-plan.md` for the full design.
|
||||||
|
|
||||||
|
- `src/lib/batch.ts` owns the engine: `createBatchItem(file)`, `runBatch(items, process, update,
|
||||||
|
options?)` and `formatBytes`. `runBatch` processes strictly sequentially (one PDF in flight for
|
||||||
|
memory safety), never throws — per-item failures are caught and written to the item as a German
|
||||||
|
error message — and consults the optional `shouldProcess` predicate so rows removed mid-run are
|
||||||
|
skipped.
|
||||||
|
- `src/lib/components/BatchFileList.svelte` renders the rows: name, size, status chip
|
||||||
|
(Wartet / Verarbeite… / Fertig ✓ / Fehler), per-row download, remove, and retry buttons with
|
||||||
|
accessible names including the file name, a progress line (“Datei 3 von 7 wird verarbeitet…”), a
|
||||||
|
total result-size summary, a memory warning (>20 files or any file >25 MB), and the bulk
|
||||||
|
“Alle herunterladen” ZIP action (via fflate, named `{toolname}_{date}.zip`) shown once ≥2 results
|
||||||
|
exist. Duplicate result names inside the ZIP are suffixed instead of overwriting.
|
||||||
|
- Page wiring: keep `items = $state<BatchItem[]>([])`, append newly dropped files (do not replace;
|
||||||
|
deduplicate by name + size), pass a `process(file)` callback that reads the current option state
|
||||||
|
so option changes mid-run affect only subsequent files, and gate the run button on pending items.
|
||||||
|
Files too large to rasterize (>100 MB) are marked as error rows immediately on selection instead
|
||||||
|
of blocking the batch.
|
||||||
|
- Per-tool ZIP base names: compress uses `komprimiert`, watermark `wasserzeichen`, encrypt
|
||||||
|
`geschuetzt`, decrypt `entsperrt`. Choose an analogous German name per tool and cover the batch
|
||||||
|
flow with Playwright tests (multi-file ZIP contents, failure continuation, retry, removal).
|
||||||
|
|
||||||
## Svelte conventions
|
## Svelte conventions
|
||||||
|
|
||||||
- Use Svelte 5 runes and current event syntax: `$props()`, `$state`, `$derived`, `$effect`, snippets,
|
- Use Svelte 5 runes and current event syntax: `$props()`, `$state`, `$derived`, `$effect`, snippets,
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
export type BatchItemStatus = 'pending' | 'processing' | 'done' | 'error';
|
||||||
|
|
||||||
|
export type BatchItem = {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
status: BatchItemStatus;
|
||||||
|
resultBytes?: Uint8Array;
|
||||||
|
resultName?: string;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BatchProcessResult = { bytes: Uint8Array; name: string };
|
||||||
|
|
||||||
|
export type BatchRunOptions = {
|
||||||
|
/** Error message written to a row when `process` throws for it. */
|
||||||
|
errorMessage?: string;
|
||||||
|
/**
|
||||||
|
* Optional predicate consulted before each item; when it returns `false`
|
||||||
|
* (for example because the row was removed mid-run), the item is skipped.
|
||||||
|
*/
|
||||||
|
shouldProcess?: (id: string) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createBatchItem = (file: File): BatchItem => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
file,
|
||||||
|
status: 'pending'
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes the given items strictly one after another so only one PDF is in
|
||||||
|
* flight at a time. Never throws: a failing item is marked as an error row and
|
||||||
|
* the queue continues with the remaining files.
|
||||||
|
*/
|
||||||
|
export const runBatch = async (
|
||||||
|
items: BatchItem[],
|
||||||
|
process: (file: File) => Promise<BatchProcessResult>,
|
||||||
|
update: (id: string, patch: Partial<BatchItem>) => void,
|
||||||
|
options: BatchRunOptions = {}
|
||||||
|
): Promise<void> => {
|
||||||
|
const { errorMessage = 'Die Datei konnte nicht verarbeitet werden.', shouldProcess } = options;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (shouldProcess && !shouldProcess(item.id)) continue;
|
||||||
|
|
||||||
|
update(item.id, { status: 'processing', errorMessage: null });
|
||||||
|
try {
|
||||||
|
const result = await process(item.file);
|
||||||
|
update(item.id, {
|
||||||
|
status: 'done',
|
||||||
|
resultBytes: result.bytes,
|
||||||
|
resultName: result.name,
|
||||||
|
errorMessage: null
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Batch processing failed for ${item.file.name}:`, error);
|
||||||
|
update(item.id, {
|
||||||
|
status: 'error',
|
||||||
|
resultBytes: undefined,
|
||||||
|
resultName: undefined,
|
||||||
|
errorMessage
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatBytes = (bytes: number) => {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Download, RotateCcw, Trash2 } from '@lucide/svelte';
|
||||||
|
import { zipSync } from 'fflate';
|
||||||
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import { downloadBlob } from '$lib/download';
|
||||||
|
import { formatBytes, type BatchItem } from '$lib/batch';
|
||||||
|
import { RASTERIZATION_WARNING_BYTES } from '$lib/rasterization-limits';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
items: BatchItem[];
|
||||||
|
/** Base file name of the bulk ZIP, e.g. `komprimiert` → `komprimiert_2026-02-12.zip`. */
|
||||||
|
zipBaseName: string;
|
||||||
|
onDownload: (item: BatchItem) => void;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
onRetry: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { items, zipBaseName, onDownload, onRemove, onRetry }: Props = $props();
|
||||||
|
|
||||||
|
const doneItems = $derived(items.filter((item) => item.status === 'done'));
|
||||||
|
const totalResultBytes = $derived(
|
||||||
|
doneItems.reduce((sum, item) => sum + (item.resultBytes?.byteLength ?? 0), 0)
|
||||||
|
);
|
||||||
|
const processingIndex = $derived(items.findIndex((item) => item.status === 'processing'));
|
||||||
|
const showMemoryWarning = $derived(
|
||||||
|
items.length > 20 || items.some((item) => item.file.size > RASTERIZATION_WARNING_BYTES)
|
||||||
|
);
|
||||||
|
|
||||||
|
const uniqueEntryName = (name: string, usedNames: Set<string>) => {
|
||||||
|
if (!usedNames.has(name)) {
|
||||||
|
usedNames.add(name);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
const dotIndex = name.lastIndexOf('.');
|
||||||
|
const base = dotIndex > 0 ? name.slice(0, dotIndex) : name;
|
||||||
|
const extension = dotIndex > 0 ? name.slice(dotIndex) : '';
|
||||||
|
let counter = 2;
|
||||||
|
let candidate = `${base}_2${extension}`;
|
||||||
|
while (usedNames.has(candidate)) {
|
||||||
|
counter += 1;
|
||||||
|
candidate = `${base}_${counter}${extension}`;
|
||||||
|
}
|
||||||
|
usedNames.add(candidate);
|
||||||
|
return candidate;
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadZip = () => {
|
||||||
|
const entries: Record<string, Uint8Array> = {};
|
||||||
|
const usedNames = new Set<string>();
|
||||||
|
for (const item of doneItems) {
|
||||||
|
if (!item.resultBytes || !item.resultName) continue;
|
||||||
|
entries[uniqueEntryName(item.resultName, usedNames)] = item.resultBytes;
|
||||||
|
}
|
||||||
|
if (Object.keys(entries).length === 0) return;
|
||||||
|
const dateStamp = new Date().toISOString().slice(0, 10);
|
||||||
|
downloadBlob(zipSync(entries), `${zipBaseName}_${dateStamp}.zip`, 'application/zip');
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusChipClass = (status: BatchItem['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'processing':
|
||||||
|
return 'bg-amber-100 text-amber-800';
|
||||||
|
case 'done':
|
||||||
|
return 'bg-emerald-100 text-emerald-800';
|
||||||
|
case 'error':
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
default:
|
||||||
|
return 'bg-accent text-primary';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabel = (status: BatchItem['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'processing':
|
||||||
|
return 'Verarbeite…';
|
||||||
|
case 'done':
|
||||||
|
return 'Fertig ✓';
|
||||||
|
case 'error':
|
||||||
|
return 'Fehler';
|
||||||
|
default:
|
||||||
|
return 'Wartet';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-border bg-card p-4 space-y-3">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h2 class="text-lg font-semibold text-foreground">Dateien ({items.length})</h2>
|
||||||
|
{#if doneItems.length > 0}
|
||||||
|
<p class="text-sm text-primary">
|
||||||
|
Ergebnisse gesamt: <span class="font-semibold">{formatBytes(totalResultBytes)}</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if processingIndex !== -1}
|
||||||
|
<p class="text-sm text-primary" role="status">
|
||||||
|
Datei {processingIndex + 1} von {items.length} wird verarbeitet…
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showMemoryWarning}
|
||||||
|
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||||
|
Bei sehr vielen oder sehr großen Dateien kann der Arbeitsspeicher knapp werden. Laden Sie
|
||||||
|
fertige Ergebnisse früh herunter und entfernen Sie sie aus der Liste.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ul class="divide-y divide-border">
|
||||||
|
{#each items as item (item.id)}
|
||||||
|
<li class="flex flex-wrap items-center gap-3 py-3" data-batch-item={item.file.name}>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="truncate text-sm font-medium text-foreground">{item.file.name}</p>
|
||||||
|
<p class="text-xs text-primary">
|
||||||
|
{formatBytes(item.file.size)}
|
||||||
|
{#if item.status === 'done' && item.resultBytes}
|
||||||
|
→ {formatBytes(item.resultBytes.length)}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{#if item.status === 'error' && item.errorMessage}
|
||||||
|
<p class="mt-0.5 text-xs text-red-700" role="alert">{item.errorMessage}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold {statusChipClass(
|
||||||
|
item.status
|
||||||
|
)}"
|
||||||
|
>
|
||||||
|
{statusLabel(item.status)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
|
{#if item.status === 'done' && item.resultBytes && item.resultName}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
|
||||||
|
aria-label="{item.file.name} herunterladen"
|
||||||
|
title="{item.file.name} herunterladen"
|
||||||
|
onclick={() => onDownload(item)}
|
||||||
|
>
|
||||||
|
<Download class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if item.status === 'error'}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
|
||||||
|
aria-label="{item.file.name} erneut versuchen"
|
||||||
|
title="{item.file.name} erneut versuchen"
|
||||||
|
onclick={() => onRetry(item.id)}
|
||||||
|
>
|
||||||
|
<RotateCcw class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
aria-label="{item.file.name} entfernen"
|
||||||
|
title="{item.file.name} entfernen"
|
||||||
|
disabled={item.status === 'processing'}
|
||||||
|
onclick={() => onRemove(item.id)}
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{#if doneItems.length >= 2}
|
||||||
|
<div class="flex justify-end pt-1">
|
||||||
|
<Button onclick={downloadZip} icon={Download}>Alle herunterladen</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
|
||||||
|
export type CompressionPreset = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
scale: number;
|
||||||
|
quality: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const compressionPresets: CompressionPreset[] = [
|
||||||
|
{
|
||||||
|
id: 'strong',
|
||||||
|
label: 'Starke Komprimierung',
|
||||||
|
description: '~72 DPI, JPEG 50%',
|
||||||
|
scale: 1,
|
||||||
|
quality: 0.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'balanced',
|
||||||
|
label: 'Ausgewogen',
|
||||||
|
description: '~108 DPI, JPEG 70%',
|
||||||
|
scale: 1.5,
|
||||||
|
quality: 0.7
|
||||||
|
},
|
||||||
|
{ id: 'light', label: 'Leicht', description: '~144 DPI, JPEG 85%', scale: 2, quality: 0.85 }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const defaultCompressionPreset = compressionPresets[1];
|
||||||
|
|
||||||
|
const loadPdfjs = async () => {
|
||||||
|
const pdfjsLib = await import('pdfjs-dist');
|
||||||
|
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
|
||||||
|
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
|
||||||
|
(module) => module.default
|
||||||
|
);
|
||||||
|
return { getDocument };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rasterizes every page of `file` with the given preset and rebuilds a
|
||||||
|
* JPEG-compressed PDF from the rendered pages. Throws when the file cannot be
|
||||||
|
* processed; callers are responsible for user-facing error messages.
|
||||||
|
*/
|
||||||
|
export const compressWithPreset = async (
|
||||||
|
file: File,
|
||||||
|
preset: CompressionPreset
|
||||||
|
): Promise<Uint8Array> => {
|
||||||
|
const { getDocument } = await loadPdfjs();
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const loadingTask = getDocument({ data: bytes });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pdfjsDoc = await loadingTask.promise;
|
||||||
|
const outputPdf = await PDFDocument.create();
|
||||||
|
|
||||||
|
for (let pageNumber = 1; pageNumber <= pdfjsDoc.numPages; pageNumber += 1) {
|
||||||
|
const page = await pdfjsDoc.getPage(pageNumber);
|
||||||
|
const viewport = page.getViewport({ scale: preset.scale });
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.ceil(viewport.width);
|
||||||
|
canvas.height = Math.ceil(viewport.height);
|
||||||
|
const canvasContext = canvas.getContext('2d');
|
||||||
|
if (!canvasContext) throw new Error('Could not create canvas context');
|
||||||
|
|
||||||
|
await page.render({
|
||||||
|
canvas,
|
||||||
|
canvasContext,
|
||||||
|
viewport,
|
||||||
|
background: '#ffffff'
|
||||||
|
}).promise;
|
||||||
|
|
||||||
|
const jpegBlob = await new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(result) => (result ? resolve(result) : reject(new Error('JPEG encode failed'))),
|
||||||
|
'image/jpeg',
|
||||||
|
preset.quality
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const jpegImage = await outputPdf.embedJpg(new Uint8Array(await jpegBlob.arrayBuffer()));
|
||||||
|
const originalViewport = page.getViewport({ scale: 1 });
|
||||||
|
const pdfPage = outputPdf.addPage([originalViewport.width, originalViewport.height]);
|
||||||
|
pdfPage.drawImage(jpegImage, {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: pdfPage.getWidth(),
|
||||||
|
height: pdfPage.getHeight()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await outputPdf.save();
|
||||||
|
} finally {
|
||||||
|
await loadingTask.destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { rgb, type RGB } from 'pdf-lib';
|
||||||
|
|
||||||
|
export type OverlayPosition =
|
||||||
|
| 'center'
|
||||||
|
| 'top-left'
|
||||||
|
| 'top-center'
|
||||||
|
| 'top-right'
|
||||||
|
| 'bottom-left'
|
||||||
|
| 'bottom-center'
|
||||||
|
| 'bottom-right';
|
||||||
|
|
||||||
|
export type WatermarkPosition = OverlayPosition | 'diagonal';
|
||||||
|
|
||||||
|
export const getPositionCoords = (
|
||||||
|
pageWidth: number,
|
||||||
|
pageHeight: number,
|
||||||
|
markWidth: number,
|
||||||
|
markHeight: number,
|
||||||
|
position: WatermarkPosition,
|
||||||
|
margin = 20
|
||||||
|
): { x: number; y: number } => {
|
||||||
|
const horizontallyCentered = (pageWidth - markWidth) / 2;
|
||||||
|
|
||||||
|
switch (position) {
|
||||||
|
case 'center':
|
||||||
|
case 'diagonal':
|
||||||
|
return { x: horizontallyCentered, y: (pageHeight - markHeight) / 2 };
|
||||||
|
case 'top-left':
|
||||||
|
return { x: margin, y: pageHeight - markHeight - margin };
|
||||||
|
case 'top-center':
|
||||||
|
return { x: horizontallyCentered, y: pageHeight - markHeight - margin };
|
||||||
|
case 'top-right':
|
||||||
|
return { x: pageWidth - markWidth - margin, y: pageHeight - markHeight - margin };
|
||||||
|
case 'bottom-left':
|
||||||
|
return { x: margin, y: margin };
|
||||||
|
case 'bottom-center':
|
||||||
|
return { x: horizontallyCentered, y: margin };
|
||||||
|
case 'bottom-right':
|
||||||
|
return { x: pageWidth - markWidth - margin, y: margin };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseHexColor = (hexColor: string): RGB => {
|
||||||
|
const match = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hexColor);
|
||||||
|
if (!match) return rgb(0.8, 0.1, 0.1);
|
||||||
|
|
||||||
|
return rgb(
|
||||||
|
Number.parseInt(match[1], 16) / 255,
|
||||||
|
Number.parseInt(match[2], 16) / 255,
|
||||||
|
Number.parseInt(match[3], 16) / 255
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,6 +6,12 @@ export type Tool = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const tools: Tool[] = [
|
export const tools: Tool[] = [
|
||||||
|
{
|
||||||
|
slug: 'stamp',
|
||||||
|
title: 'Stempeln',
|
||||||
|
description: 'Versehen Sie Seiten mit Textstempeln wie „Beglaubigt“ oder „Eilt“.',
|
||||||
|
icon: 'stamp'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
slug: 'watermark',
|
slug: 'watermark',
|
||||||
title: 'Wasserzeichen',
|
title: 'Wasserzeichen',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
Minimize2,
|
Minimize2,
|
||||||
Scissors,
|
Scissors,
|
||||||
Merge,
|
Merge,
|
||||||
|
Stamp,
|
||||||
FileText
|
FileText
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
@@ -23,7 +24,8 @@
|
|||||||
image: Image,
|
image: Image,
|
||||||
minimize: Minimize2,
|
minimize: Minimize2,
|
||||||
scissors: Scissors,
|
scissors: Scissors,
|
||||||
merge: Merge
|
merge: Merge,
|
||||||
|
stamp: Stamp
|
||||||
};
|
};
|
||||||
|
|
||||||
let isDraggingFiles = $state(false);
|
let isDraggingFiles = $state(false);
|
||||||
|
|||||||
@@ -1,140 +1,99 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { PDFDocument } from 'pdf-lib';
|
import BatchFileList from '$lib/components/BatchFileList.svelte';
|
||||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
import RasterizationWarning from '$lib/components/RasterizationWarning.svelte';
|
|
||||||
import Button from '$lib/components/ui/button.svelte';
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
|
||||||
import { downloadBlob } from '$lib/download';
|
import { downloadBlob } from '$lib/download';
|
||||||
import { isRasterizationTooLarge } from '$lib/rasterization-limits';
|
import {
|
||||||
|
compressWithPreset,
|
||||||
|
compressionPresets,
|
||||||
|
defaultCompressionPreset
|
||||||
|
} from '$lib/pdf-compression';
|
||||||
|
import { isRasterizationTooLarge, RASTERIZATION_MAX_BYTES } from '$lib/rasterization-limits';
|
||||||
import { AlertTriangle } from '@lucide/svelte';
|
import { AlertTriangle } from '@lucide/svelte';
|
||||||
|
|
||||||
type CompressionPreset = {
|
let items = $state<BatchItem[]>([]);
|
||||||
id: string;
|
let selectedPreset = $state(defaultCompressionPreset.id);
|
||||||
label: string;
|
let isRunning = $state(false);
|
||||||
description: string;
|
|
||||||
scale: number;
|
|
||||||
quality: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const presets: CompressionPreset[] = [
|
const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
|
||||||
{
|
const canStart = $derived(pendingCount > 0 && !isRunning);
|
||||||
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);
|
const handleFilesSelected = (files: File[]) => {
|
||||||
let selectedPreset = $state('balanced');
|
const knownKeys = new Set(items.map((item) => `${item.file.name}:${item.file.size}`));
|
||||||
let isProcessing = $state(false);
|
const additions = files
|
||||||
let error = $state<string | null>(null);
|
.filter((file) => !knownKeys.has(`${file.name}:${file.size}`))
|
||||||
let compressedBytes = $state<Uint8Array | null>(null);
|
.map((file) => {
|
||||||
let compressedFileName = $state('');
|
const item = createBatchItem(file);
|
||||||
const rasterizationBlocked = $derived(isRasterizationTooLarge(pdfFile));
|
if (isRasterizationTooLarge(file)) {
|
||||||
|
item.status = 'error';
|
||||||
const handleFileSelected = (files: File[]) => {
|
item.errorMessage = `Diese Datei ist größer als ${RASTERIZATION_MAX_BYTES / (1024 * 1024)} MB und kann aus Speichergründen nicht im Browser gerastert werden.`;
|
||||||
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()
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
items = [...items, ...additions];
|
||||||
|
};
|
||||||
|
|
||||||
compressedBytes = await outputPdf.save();
|
const updateItem = (id: string, patch: Partial<BatchItem>) => {
|
||||||
compressedFileName = `${pdfFile.name.replace(/\.pdf$/i, '')}_komprimiert.pdf`;
|
items = items.map((item) => (item.id === id ? { ...item, ...patch } : item));
|
||||||
} finally {
|
};
|
||||||
await loadingTask.destroy();
|
|
||||||
}
|
const processFile = async (file: File) => {
|
||||||
} catch (err) {
|
// The preset is read per file so an option change during a run only
|
||||||
console.error('Compression failed:', err);
|
// affects the still pending files.
|
||||||
error = 'Fehler beim Komprimieren des PDFs.';
|
const preset =
|
||||||
|
compressionPresets.find((entry) => entry.id === selectedPreset) ?? defaultCompressionPreset;
|
||||||
|
const bytes = await compressWithPreset(file, preset);
|
||||||
|
return { bytes, name: `${file.name.replace(/\.pdf$/i, '')}_komprimiert.pdf` };
|
||||||
|
};
|
||||||
|
|
||||||
|
const startBatch = async () => {
|
||||||
|
if (isRunning) return;
|
||||||
|
const queue = items.filter((item) => item.status === 'pending');
|
||||||
|
if (queue.length === 0) return;
|
||||||
|
|
||||||
|
isRunning = true;
|
||||||
|
try {
|
||||||
|
await runBatch(queue, processFile, updateItem, {
|
||||||
|
errorMessage: 'Diese PDF konnte nicht komprimiert werden.',
|
||||||
|
// Skip rows removed while they were still waiting in the queue.
|
||||||
|
shouldProcess: (id) => items.some((item) => item.id === id)
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
isProcessing = false;
|
isRunning = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadCompressedPdf = () => {
|
const retryItem = async (id: string) => {
|
||||||
if (compressedBytes) downloadBlob(compressedBytes, compressedFileName);
|
if (isRunning) return;
|
||||||
|
const item = items.find((entry) => entry.id === id);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
isRunning = true;
|
||||||
|
try {
|
||||||
|
await runBatch([item], processFile, updateItem, {
|
||||||
|
errorMessage: 'Diese PDF konnte nicht komprimiert werden.'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isRunning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (id: string) => {
|
||||||
|
items = items.filter((item) => item.id !== id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadItem = (item: BatchItem) => {
|
||||||
|
if (item.resultBytes && item.resultName) downloadBlob(item.resultBytes, item.resultName);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-4xl space-y-6">
|
<div class="mx-auto max-w-4xl space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-primary-900">Komprimieren</h1>
|
<h1 class="text-2xl font-bold text-foreground">Komprimieren</h1>
|
||||||
<p class="mt-2 text-sm text-primary-700">
|
<p class="mt-2 text-sm text-primary">
|
||||||
Reduzieren Sie die Dateigröße Ihres PDFs durch Komprimierung.
|
Reduzieren Sie die Dateigröße Ihrer PDFs durch Komprimierung – einzeln oder mehrere in einem
|
||||||
|
Durchlauf.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -149,70 +108,64 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !pdfFile}
|
<FileDropzone
|
||||||
<FileDropzone onFilesSelected={handleFileSelected} />
|
onFilesSelected={handleFilesSelected}
|
||||||
{:else}
|
multiple
|
||||||
<div class="space-y-6">
|
label={items.length === 0 ? 'Dateien hinzufügen' : 'Weitere Dateien ablegen'}
|
||||||
<FileDropzone
|
sublabel={items.length === 0
|
||||||
onFilesSelected={handleFileSelected}
|
? 'PDFs hier ablegen oder zum Auswählen klicken'
|
||||||
label="Anderes PDF ablegen"
|
: 'Weitere PDFs hinzufügen – bestehende Dateien bleiben erhalten'}
|
||||||
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">
|
{#if items.length > 0}
|
||||||
<p class="text-sm font-medium text-primary-900">Komprimierungsstufe</p>
|
<div class="rounded-2xl border border-border bg-card p-4 space-y-4">
|
||||||
{#each presets as preset}
|
<h2 class="text-lg font-semibold text-foreground">Komprimierungsstufe</h2>
|
||||||
<label
|
<p class="text-xs text-primary">Die Einstellung gilt für alle Dateien der Liste.</p>
|
||||||
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}
|
<div class="space-y-2">
|
||||||
<p class="text-sm text-primary-700">
|
{#each compressionPresets as preset (preset.id)}
|
||||||
Ergebnisgröße: <span class="font-semibold">{formatBytes(compressedBytes.length)}</span>
|
<label
|
||||||
</p>
|
class="flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition {selectedPreset ===
|
||||||
{/if}
|
preset.id
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border hover:border-primary-300'}"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="compression-preset"
|
||||||
|
value={preset.id}
|
||||||
|
bind:group={selectedPreset}
|
||||||
|
class="mt-0.5 h-4 w-4"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-foreground">{preset.label}</p>
|
||||||
|
<p class="text-xs text-primary">{preset.description}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if isRunning}
|
||||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
<p class="text-xs text-primary" role="note">
|
||||||
{error}
|
Änderungen an der Komprimierungsstufe gelten für die noch ausstehenden Dateien.
|
||||||
</div>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-2">
|
{#if items.length > 0}
|
||||||
<Button onclick={compressPdf} disabled={isProcessing || rasterizationBlocked}>
|
<BatchFileList
|
||||||
{isProcessing ? 'Wird komprimiert...' : 'Komprimieren'}
|
{items}
|
||||||
</Button>
|
zipBaseName="komprimiert"
|
||||||
{#if compressedBytes}
|
onDownload={downloadItem}
|
||||||
<Button onclick={downloadCompressedPdf}>PDF herunterladen</Button>
|
onRemove={removeItem}
|
||||||
{/if}
|
onRetry={retryItem}
|
||||||
</div>
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button onclick={startBatch} disabled={!canStart}>
|
||||||
|
{isRunning ? 'Wird komprimiert...' : 'Komprimieren'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,96 +1,144 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { PDFDocument } from 'pdf-lib';
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
import BatchFileList from '$lib/components/BatchFileList.svelte';
|
||||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
import RasterizationWarning from '$lib/components/RasterizationWarning.svelte';
|
|
||||||
import Button from '$lib/components/ui/button.svelte';
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
|
||||||
import { downloadBlob } from '$lib/download';
|
import { downloadBlob } from '$lib/download';
|
||||||
import { isRasterizationTooLarge } from '$lib/rasterization-limits';
|
import { isRasterizationTooLarge, RASTERIZATION_MAX_BYTES } from '$lib/rasterization-limits';
|
||||||
import { Unlock, AlertTriangle } from '@lucide/svelte';
|
import { Unlock, AlertTriangle } from '@lucide/svelte';
|
||||||
|
|
||||||
let pdfFile: File | null = $state(null);
|
let items = $state<BatchItem[]>([]);
|
||||||
let password = $state('');
|
let password = $state('');
|
||||||
let isProcessing = $state(false);
|
let isRunning = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
const rasterizationBlocked = $derived(isRasterizationTooLarge(pdfFile));
|
|
||||||
|
|
||||||
const handleFileSelected = (files: File[]) => {
|
const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
|
||||||
pdfFile = files[0] ?? null;
|
const canRun = $derived(!isRunning && pendingCount > 0 && password.length > 0);
|
||||||
|
|
||||||
|
const handleFilesSelected = (files: File[]) => {
|
||||||
|
const knownKeys = new Set(items.map((item) => `${item.file.name}:${item.file.size}`));
|
||||||
|
const additions = files
|
||||||
|
.filter((file) => !knownKeys.has(`${file.name}:${file.size}`))
|
||||||
|
.map((file) => {
|
||||||
|
const item = createBatchItem(file);
|
||||||
|
if (isRasterizationTooLarge(file)) {
|
||||||
|
item.status = 'error';
|
||||||
|
item.errorMessage = `Diese Datei ist größer als ${RASTERIZATION_MAX_BYTES / (1024 * 1024)} MB und kann aus Speichergründen nicht im Browser gerastert werden.`;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
items = [...items, ...additions];
|
||||||
error = null;
|
error = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const decryptPdf = async () => {
|
const updateItem = (id: string, patch: Partial<BatchItem>) => {
|
||||||
if (!pdfFile) return;
|
items = items.map((item) => (item.id === id ? { ...item, ...patch } : item));
|
||||||
|
};
|
||||||
|
|
||||||
isProcessing = true;
|
const processFile = async (file: File) => {
|
||||||
error = null;
|
const pdfjsLib = await import('pdfjs-dist');
|
||||||
|
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
|
||||||
|
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
|
||||||
|
(module) => module.default
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadingTask = getDocument({
|
||||||
|
data: new Uint8Array(await file.arrayBuffer()),
|
||||||
|
password
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pdfjsLib = await import('pdfjs-dist');
|
const pdfDocument = await loadingTask.promise;
|
||||||
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
|
const outputPdf = await PDFDocument.create();
|
||||||
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
|
|
||||||
(module) => module.default
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadingTask = getDocument({
|
for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) {
|
||||||
data: new Uint8Array(await pdfFile.arrayBuffer()),
|
const page = await pdfDocument.getPage(pageNumber);
|
||||||
password
|
const viewport = page.getViewport({ scale: 2 });
|
||||||
});
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.ceil(viewport.width);
|
||||||
|
canvas.height = Math.ceil(viewport.height);
|
||||||
|
const canvasContext = canvas.getContext('2d');
|
||||||
|
if (!canvasContext) throw new Error('Could not create canvas context');
|
||||||
|
|
||||||
try {
|
await page.render({
|
||||||
const pdfDocument = await loadingTask.promise;
|
canvas,
|
||||||
const outputPdf = await PDFDocument.create();
|
canvasContext,
|
||||||
|
viewport,
|
||||||
for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) {
|
background: '#ffffff'
|
||||||
const page = await pdfDocument.getPage(pageNumber);
|
}).promise;
|
||||||
const viewport = page.getViewport({ scale: 2 });
|
const pngBlob = await new Promise<Blob>((resolve, reject) => {
|
||||||
const canvas = document.createElement('canvas');
|
canvas.toBlob(
|
||||||
canvas.width = Math.ceil(viewport.width);
|
(result) => (result ? resolve(result) : reject(new Error('PNG encode failed'))),
|
||||||
canvas.height = Math.ceil(viewport.height);
|
'image/png'
|
||||||
const canvasContext = canvas.getContext('2d');
|
);
|
||||||
if (!canvasContext) throw new Error('Could not create canvas context');
|
});
|
||||||
|
const pngImage = await outputPdf.embedPng(new Uint8Array(await pngBlob.arrayBuffer()));
|
||||||
await page.render({
|
const originalViewport = page.getViewport({ scale: 1 });
|
||||||
canvas,
|
const pdfPage = outputPdf.addPage([originalViewport.width, originalViewport.height]);
|
||||||
canvasContext,
|
pdfPage.drawImage(pngImage, {
|
||||||
viewport,
|
x: 0,
|
||||||
background: '#ffffff'
|
y: 0,
|
||||||
}).promise;
|
width: pdfPage.getWidth(),
|
||||||
const pngBlob = await new Promise<Blob>((resolve, reject) => {
|
height: pdfPage.getHeight()
|
||||||
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);
|
const bytes = await outputPdf.save();
|
||||||
error = 'Fehler beim Entsperren. Bitte prüfen Sie das Passwort und versuchen Sie es erneut.';
|
return { bytes, name: `${file.name.replace(/\.pdf$/i, '')}_entsperrt.pdf` };
|
||||||
} finally {
|
} finally {
|
||||||
isProcessing = false;
|
await loadingTask.destroy();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startBatch = async () => {
|
||||||
|
if (isRunning) return;
|
||||||
|
const queue = items.filter((item) => item.status === 'pending');
|
||||||
|
if (queue.length === 0) return;
|
||||||
|
|
||||||
|
isRunning = true;
|
||||||
|
try {
|
||||||
|
await runBatch(queue, processFile, updateItem, {
|
||||||
|
errorMessage:
|
||||||
|
'Fehler beim Entsperren. Bitte prüfen Sie das Passwort und versuchen Sie es erneut.',
|
||||||
|
// Skip rows removed while they were still waiting in the queue.
|
||||||
|
shouldProcess: (id) => items.some((item) => item.id === id)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isRunning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryItem = async (id: string) => {
|
||||||
|
if (isRunning) return;
|
||||||
|
const item = items.find((entry) => entry.id === id);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
isRunning = true;
|
||||||
|
try {
|
||||||
|
await runBatch([item], processFile, updateItem, {
|
||||||
|
errorMessage:
|
||||||
|
'Fehler beim Entsperren. Bitte prüfen Sie das Passwort und versuchen Sie es erneut.'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isRunning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (id: string) => {
|
||||||
|
items = items.filter((item) => item.id !== id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadItem = (item: BatchItem) => {
|
||||||
|
if (item.resultBytes && item.resultName) downloadBlob(item.resultBytes, item.resultName);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-4xl space-y-6">
|
<div class="mx-auto max-w-4xl space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-primary-900">Passwort entfernen</h1>
|
<h1 class="text-2xl font-bold text-foreground">Passwort entfernen</h1>
|
||||||
<p class="mt-2 text-sm text-primary-700">
|
<p class="mt-2 text-sm text-primary">
|
||||||
Entfernen Sie den Passwortschutz von einem PDF-Dokument.
|
Entfernen Sie den Passwortschutz von PDF-Dokumenten – einzeln oder mehrere in einem Durchlauf.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -105,48 +153,63 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !pdfFile}
|
<FileDropzone
|
||||||
<FileDropzone onFilesSelected={handleFileSelected} />
|
onFilesSelected={handleFilesSelected}
|
||||||
{:else}
|
multiple
|
||||||
<div class="space-y-6">
|
label={items.length === 0 ? 'Dateien hinzufügen' : 'Weitere Dateien ablegen'}
|
||||||
<FileDropzone
|
sublabel={items.length === 0
|
||||||
onFilesSelected={handleFileSelected}
|
? 'PDFs hier ablegen oder zum Auswählen klicken'
|
||||||
label="Anderes PDF ablegen"
|
: 'Weitere PDFs hinzufügen – bestehende Dateien bleiben erhalten'}
|
||||||
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>
|
{#if items.length > 0}
|
||||||
<label class="block text-sm font-medium text-primary-900 mb-1" for="decrypt-password">
|
<div class="rounded-2xl border border-border bg-card p-4 space-y-4">
|
||||||
Passwort
|
<div class="flex items-center gap-2 text-foreground">
|
||||||
</label>
|
<Unlock class="h-5 w-5" />
|
||||||
<input
|
<h2 class="text-lg font-semibold">Passwort</h2>
|
||||||
id="decrypt-password"
|
</div>
|
||||||
type="password"
|
<p class="text-xs text-primary">Das Passwort gilt für alle Dateien der Liste.</p>
|
||||||
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"
|
<div>
|
||||||
placeholder="Passwort des PDFs eingeben"
|
<label class="block text-sm font-medium text-foreground mb-1" for="decrypt-password">
|
||||||
/>
|
Passwort
|
||||||
</div>
|
</label>
|
||||||
|
<input
|
||||||
|
id="decrypt-password"
|
||||||
|
type="password"
|
||||||
|
bind:value={password}
|
||||||
|
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
placeholder="Passwort der PDFs eingeben"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if isRunning}
|
||||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
<p class="text-xs text-primary" role="note">
|
||||||
{error}
|
Änderungen am Passwort gelten für die noch ausstehenden Dateien.
|
||||||
</div>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-2">
|
<BatchFileList
|
||||||
<Button onclick={decryptPdf} disabled={isProcessing || !password || rasterizationBlocked}>
|
{items}
|
||||||
{isProcessing ? 'Wird entsperrt...' : 'Passwort entfernen & herunterladen'}
|
zipBaseName="entsperrt"
|
||||||
</Button>
|
onDownload={downloadItem}
|
||||||
</div>
|
onRemove={removeItem}
|
||||||
|
onRetry={retryItem}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if items.length > 0}
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button onclick={startBatch} disabled={!canRun}>
|
||||||
|
{isRunning ? 'Wird entsperrt...' : 'Passwort entfernen'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,150 +1,192 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { PDFDocument } from '@cantoo/pdf-lib';
|
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||||
|
import BatchFileList from '$lib/components/BatchFileList.svelte';
|
||||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
import Button from '$lib/components/ui/button.svelte';
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
|
||||||
import { downloadBlob } from '$lib/download';
|
import { downloadBlob } from '$lib/download';
|
||||||
import { Lock } from '@lucide/svelte';
|
import { Lock } from '@lucide/svelte';
|
||||||
|
|
||||||
let pdfFile: File | null = $state(null);
|
let items = $state<BatchItem[]>([]);
|
||||||
let password = $state('');
|
let password = $state('');
|
||||||
let confirmPassword = $state('');
|
let confirmPassword = $state('');
|
||||||
let allowPrinting = $state(true);
|
let allowPrinting = $state(true);
|
||||||
let allowCopying = $state(true);
|
let allowCopying = $state(true);
|
||||||
let isProcessing = $state(false);
|
let isRunning = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
const handleFileSelected = (files: File[]) => {
|
const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
|
||||||
pdfFile = files[0] ?? null;
|
const canRun = $derived(
|
||||||
|
!isRunning && pendingCount > 0 && password.length > 0 && password === confirmPassword
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFilesSelected = (files: File[]) => {
|
||||||
|
const knownKeys = new Set(items.map((item) => `${item.file.name}:${item.file.size}`));
|
||||||
|
const additions = files
|
||||||
|
.filter((file) => !knownKeys.has(`${file.name}:${file.size}`))
|
||||||
|
.map((file) => createBatchItem(file));
|
||||||
|
items = [...items, ...additions];
|
||||||
error = null;
|
error = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const encryptPdf = async () => {
|
const updateItem = (id: string, patch: Partial<BatchItem>) => {
|
||||||
if (!pdfFile || !password) return;
|
items = items.map((item) => (item.id === id ? { ...item, ...patch } : item));
|
||||||
|
};
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
const processFile = async (file: File) => {
|
||||||
error = 'Passwörter stimmen nicht überein.';
|
// The password and permissions are read per file so a change during a
|
||||||
return;
|
// run only affects the still pending files.
|
||||||
}
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const pdfDoc = await PDFDocument.load(bytes);
|
||||||
|
|
||||||
if (password.length < 1) {
|
pdfDoc.encrypt({
|
||||||
error = 'Bitte geben Sie ein Passwort ein.';
|
userPassword: password,
|
||||||
return;
|
ownerPassword: crypto.randomUUID(),
|
||||||
}
|
permissions: {
|
||||||
|
printing: allowPrinting,
|
||||||
|
copying: allowCopying
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
isProcessing = true;
|
const encryptedBytes = await pdfDoc.save();
|
||||||
error = null;
|
return { bytes: encryptedBytes, name: `${file.name.replace(/\.pdf$/i, '')}_geschuetzt.pdf` };
|
||||||
|
};
|
||||||
|
|
||||||
|
const startBatch = async () => {
|
||||||
|
if (isRunning) return;
|
||||||
|
const queue = items.filter((item) => item.status === 'pending');
|
||||||
|
if (queue.length === 0) return;
|
||||||
|
|
||||||
|
isRunning = true;
|
||||||
try {
|
try {
|
||||||
const bytes = new Uint8Array(await pdfFile.arrayBuffer());
|
await runBatch(queue, processFile, updateItem, {
|
||||||
const pdfDoc = await PDFDocument.load(bytes);
|
errorMessage: 'Diese PDF konnte nicht verschlüsselt werden.',
|
||||||
|
// Skip rows removed while they were still waiting in the queue.
|
||||||
pdfDoc.encrypt({
|
shouldProcess: (id) => items.some((item) => item.id === id)
|
||||||
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 {
|
} finally {
|
||||||
isProcessing = false;
|
isRunning = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const retryItem = async (id: string) => {
|
||||||
|
if (isRunning) return;
|
||||||
|
const item = items.find((entry) => entry.id === id);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
isRunning = true;
|
||||||
|
try {
|
||||||
|
await runBatch([item], processFile, updateItem, {
|
||||||
|
errorMessage: 'Diese PDF konnte nicht verschlüsselt werden.'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isRunning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (id: string) => {
|
||||||
|
items = items.filter((item) => item.id !== id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadItem = (item: BatchItem) => {
|
||||||
|
if (item.resultBytes && item.resultName) downloadBlob(item.resultBytes, item.resultName);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-4xl space-y-6">
|
<div class="mx-auto max-w-4xl space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-primary-900">Passwort setzen</h1>
|
<h1 class="text-2xl font-bold text-foreground">Passwort setzen</h1>
|
||||||
<p class="mt-2 text-sm text-primary-700">
|
<p class="mt-2 text-sm text-primary">
|
||||||
Verschlüsseln Sie Ihr PDF mit einem Passwort, um es vor unbefugtem Zugriff zu schützen.
|
Verschlüsseln Sie Ihre PDFs mit einem Passwort, um sie vor unbefugtem Zugriff zu schützen –
|
||||||
|
einzeln oder mehrere in einem Durchlauf.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !pdfFile}
|
<FileDropzone
|
||||||
<FileDropzone onFilesSelected={handleFileSelected} />
|
onFilesSelected={handleFilesSelected}
|
||||||
{:else}
|
multiple
|
||||||
<div class="space-y-6">
|
label={items.length === 0 ? 'Dateien hinzufügen' : 'Weitere Dateien ablegen'}
|
||||||
<FileDropzone
|
sublabel={items.length === 0
|
||||||
onFilesSelected={handleFileSelected}
|
? 'PDFs hier ablegen oder zum Auswählen klicken'
|
||||||
label="Anderes PDF ablegen"
|
: 'Weitere PDFs hinzufügen – bestehende Dateien bleiben erhalten'}
|
||||||
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>
|
{#if items.length > 0}
|
||||||
<label class="block text-sm font-medium text-primary-900 mb-1" for="encrypt-password">
|
<div class="rounded-2xl border border-border bg-card p-4 space-y-4">
|
||||||
Passwort
|
<div class="flex items-center gap-2 text-foreground">
|
||||||
</label>
|
<Lock class="h-5 w-5" />
|
||||||
<input
|
<h2 class="text-lg font-semibold">Passwort</h2>
|
||||||
id="encrypt-password"
|
</div>
|
||||||
type="password"
|
<p class="text-xs text-primary">Das Passwort gilt für alle Dateien der Liste.</p>
|
||||||
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>
|
<div>
|
||||||
<label
|
<label class="block text-sm font-medium text-foreground mb-1" for="encrypt-password">
|
||||||
class="block text-sm font-medium text-primary-900 mb-1"
|
Passwort
|
||||||
for="encrypt-password-confirmation"
|
</label>
|
||||||
>
|
<input
|
||||||
Passwort bestätigen
|
id="encrypt-password"
|
||||||
</label>
|
type="password"
|
||||||
<input
|
bind:value={password}
|
||||||
id="encrypt-password-confirmation"
|
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
type="password"
|
placeholder="Passwort eingeben"
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
<div>
|
||||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
<label
|
||||||
{error}
|
class="block text-sm font-medium text-foreground mb-1"
|
||||||
</div>
|
for="encrypt-password-confirmation"
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
onclick={encryptPdf}
|
|
||||||
disabled={isProcessing || !password || password !== confirmPassword}
|
|
||||||
>
|
>
|
||||||
{isProcessing ? 'Wird verschlüsselt...' : 'Passwort setzen & herunterladen'}
|
Passwort bestätigen
|
||||||
</Button>
|
</label>
|
||||||
|
<input
|
||||||
|
id="encrypt-password-confirmation"
|
||||||
|
type="password"
|
||||||
|
bind:value={confirmPassword}
|
||||||
|
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
placeholder="Passwort wiederholen"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-sm font-medium text-foreground">Berechtigungen</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" id="allow-printing" bind:checked={allowPrinting} class="h-4 w-4" />
|
||||||
|
<label for="allow-printing" class="text-sm text-primary">Drucken erlauben</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" id="allow-copying" bind:checked={allowCopying} class="h-4 w-4" />
|
||||||
|
<label for="allow-copying" class="text-sm text-primary">Kopieren erlauben</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isRunning}
|
||||||
|
<p class="text-xs text-primary" role="note">
|
||||||
|
Änderungen am Passwort gelten für die noch ausstehenden Dateien.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BatchFileList
|
||||||
|
{items}
|
||||||
|
zipBaseName="geschuetzt"
|
||||||
|
onDownload={downloadItem}
|
||||||
|
onRemove={removeItem}
|
||||||
|
onRetry={retryItem}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if items.length > 0}
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button onclick={startBatch} disabled={!canRun}>
|
||||||
|
{isRunning ? 'Wird verschlüsselt...' : 'Passwort setzen'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,713 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import { ChevronLeft, ChevronRight, Stamp } from '@lucide/svelte';
|
||||||
|
import { degrees, PDFDocument, StandardFonts, type PDFFont } from 'pdf-lib';
|
||||||
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
|
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
||||||
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import { downloadBlob } from '$lib/download';
|
||||||
|
import { getPositionCoords, parseHexColor, type OverlayPosition } from '$lib/pdf-overlay';
|
||||||
|
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
|
||||||
|
|
||||||
|
const PRESETS = ['BEGLAUBIGT', 'AUSFERTIGUNG', 'KOPIE', 'EILT', 'ENTWURF', 'ERLEDIGT'] as const;
|
||||||
|
const PADDING = 8;
|
||||||
|
const PAGE_MARGIN = 20;
|
||||||
|
const MIN_EXPORT_FONT_SIZE = 4;
|
||||||
|
const MIN_EXPORT_IMAGE_SIZE = 20;
|
||||||
|
|
||||||
|
type StampImage = {
|
||||||
|
name: string;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
mimeType: 'image/png' | 'image/jpeg';
|
||||||
|
objectUrl: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let pdfFile: File | null = $state(null);
|
||||||
|
let pdfHandle: PdfDocumentHandle | null = $state(null);
|
||||||
|
let stampInput = $state('BEGLAUBIGT');
|
||||||
|
let withDate = $state(false);
|
||||||
|
let fontSize = $state(24);
|
||||||
|
let stampImage = $state<StampImage | null>(null);
|
||||||
|
let isLoadingImage = $state(false);
|
||||||
|
let withBorder = $state(false);
|
||||||
|
let imageSize = $state(120);
|
||||||
|
let stampColor = $state('#cc1a1a');
|
||||||
|
let position = $state<OverlayPosition>('top-right');
|
||||||
|
let rotation = $state(0);
|
||||||
|
let applyToAll = $state(true);
|
||||||
|
let selectedPages = $state(new Set<number>());
|
||||||
|
let currentPage = $state(1);
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let isProcessing = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let handleGeneration = 0;
|
||||||
|
|
||||||
|
const formatGermanDate = (date: Date) =>
|
||||||
|
[date.getDate(), date.getMonth() + 1, date.getFullYear()]
|
||||||
|
.map((part, index) => (index < 2 ? String(part).padStart(2, '0') : String(part)))
|
||||||
|
.join('.');
|
||||||
|
|
||||||
|
const buildStampText = (input: string, appendDate: boolean, date: Date) => {
|
||||||
|
const uppercaseText = input.trim().toLocaleUpperCase('de-DE');
|
||||||
|
return `${uppercaseText}${appendDate ? ` ${formatGermanDate(date)}` : ''}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewText = $derived(buildStampText(stampInput, withDate, new Date()));
|
||||||
|
const selectedPreset = $derived(
|
||||||
|
PRESETS.find((preset) => preset === stampInput.trim().toLocaleUpperCase('de-DE'))
|
||||||
|
);
|
||||||
|
const canProcess = $derived(
|
||||||
|
pdfFile !== null &&
|
||||||
|
pdfHandle !== null &&
|
||||||
|
(stampImage !== null || previewText.length > 0) &&
|
||||||
|
!isLoading &&
|
||||||
|
!isLoadingImage &&
|
||||||
|
!isProcessing &&
|
||||||
|
(applyToAll || selectedPages.size > 0)
|
||||||
|
);
|
||||||
|
const previewFontSize = $derived(
|
||||||
|
Math.max(6, Math.min(Number(fontSize) * 0.5, 240 / Math.max(8, previewText.length * 0.62)))
|
||||||
|
);
|
||||||
|
|
||||||
|
const isEncryptedPdfError = (loadError: unknown) => {
|
||||||
|
const message =
|
||||||
|
loadError instanceof Error ? `${loadError.name} ${loadError.message}` : String(loadError);
|
||||||
|
return /password|encrypted|encryption|verschl/i.test(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadErrorMessage = (loadError: unknown) =>
|
||||||
|
isEncryptedPdfError(loadError)
|
||||||
|
? 'Dieses PDF ist passwortgeschützt. Entfernen Sie zuerst unter „Passwort entfernen“ den Schutz.'
|
||||||
|
: 'PDF konnte nicht geladen werden. Prüfen Sie, ob die Datei gültig ist.';
|
||||||
|
|
||||||
|
const handleFileSelected = async (files: File[]) => {
|
||||||
|
const file = files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const generation = ++handleGeneration;
|
||||||
|
pdfHandle?.destroy();
|
||||||
|
pdfHandle = null;
|
||||||
|
pdfFile = file;
|
||||||
|
selectedPages = new Set();
|
||||||
|
currentPage = 1;
|
||||||
|
error = null;
|
||||||
|
isLoading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handle = await loadPdfDocument(new Uint8Array(await file.arrayBuffer()));
|
||||||
|
if (generation !== handleGeneration) {
|
||||||
|
handle.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pdfHandle = handle;
|
||||||
|
} catch (loadError) {
|
||||||
|
console.error('Failed to load PDF for stamping:', loadError);
|
||||||
|
if (generation === handleGeneration) error = loadErrorMessage(loadError);
|
||||||
|
} finally {
|
||||||
|
if (generation === handleGeneration) isLoading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectPreset = (preset: (typeof PRESETS)[number]) => {
|
||||||
|
stampInput = preset;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearStampImage = () => {
|
||||||
|
if (stampImage) URL.revokeObjectURL(stampImage.objectUrl);
|
||||||
|
stampImage = null;
|
||||||
|
withBorder = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const detectImageMimeType = (bytes: Uint8Array): 'image/png' | 'image/jpeg' | null => {
|
||||||
|
if (
|
||||||
|
bytes.length > 8 &&
|
||||||
|
bytes[0] === 0x89 &&
|
||||||
|
bytes[1] === 0x50 &&
|
||||||
|
bytes[2] === 0x4e &&
|
||||||
|
bytes[3] === 0x47
|
||||||
|
) {
|
||||||
|
return 'image/png';
|
||||||
|
}
|
||||||
|
if (bytes.length > 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
||||||
|
return 'image/jpeg';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageSelected = async (event: Event) => {
|
||||||
|
const fileInput = event.target as HTMLInputElement;
|
||||||
|
const file = fileInput.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
isLoadingImage = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const mimeType = detectImageMimeType(bytes);
|
||||||
|
if (!mimeType) {
|
||||||
|
fileInput.value = '';
|
||||||
|
error = 'Das Stempelbild muss eine PNG- oder JPG-Datei sein.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bitmap = await createImageBitmap(new Blob([bytes], { type: mimeType }));
|
||||||
|
const dimensions = { width: bitmap.width, height: bitmap.height };
|
||||||
|
bitmap.close();
|
||||||
|
if (stampImage) URL.revokeObjectURL(stampImage.objectUrl);
|
||||||
|
stampImage = {
|
||||||
|
name: file.name,
|
||||||
|
bytes,
|
||||||
|
mimeType,
|
||||||
|
objectUrl: URL.createObjectURL(file),
|
||||||
|
...dimensions
|
||||||
|
};
|
||||||
|
withBorder = false;
|
||||||
|
} catch {
|
||||||
|
fileInput.value = '';
|
||||||
|
error =
|
||||||
|
'Das Stempelbild konnte nicht gelesen werden. Verwenden Sie eine gültige PNG- oder JPG-Datei.';
|
||||||
|
} finally {
|
||||||
|
isLoadingImage = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePage = (pageNumber: number) => {
|
||||||
|
const next = new Set(selectedPages);
|
||||||
|
if (next.has(pageNumber)) next.delete(pageNumber);
|
||||||
|
else next.add(pageNumber);
|
||||||
|
selectedPages = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showPreviousPage = () => {
|
||||||
|
currentPage = Math.max(1, currentPage - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showNextPage = () => {
|
||||||
|
if (!pdfHandle) return;
|
||||||
|
currentPage = Math.min(pdfHandle.pageCount, currentPage + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPreviewPositionStyle = (previewPosition: OverlayPosition) => {
|
||||||
|
switch (previewPosition) {
|
||||||
|
case 'center':
|
||||||
|
return 'left: 50%; top: 50%; transform: translate(-50%, -50%);';
|
||||||
|
case 'top-left':
|
||||||
|
return 'left: 5%; top: 5%;';
|
||||||
|
case 'top-center':
|
||||||
|
return 'left: 50%; top: 5%; transform: translateX(-50%);';
|
||||||
|
case 'top-right':
|
||||||
|
return 'right: 5%; top: 5%;';
|
||||||
|
case 'bottom-left':
|
||||||
|
return 'bottom: 5%; left: 5%;';
|
||||||
|
case 'bottom-center':
|
||||||
|
return 'bottom: 5%; left: 50%; transform: translateX(-50%);';
|
||||||
|
case 'bottom-right':
|
||||||
|
return 'bottom: 5%; right: 5%;';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRotatedStampPlacement = (
|
||||||
|
pageWidth: number,
|
||||||
|
pageHeight: number,
|
||||||
|
boxWidth: number,
|
||||||
|
boxHeight: number,
|
||||||
|
stampPosition: OverlayPosition,
|
||||||
|
angle: number
|
||||||
|
) => {
|
||||||
|
const radians = (angle * Math.PI) / 180;
|
||||||
|
const cosine = Math.cos(radians);
|
||||||
|
const sine = Math.sin(radians);
|
||||||
|
const rotatedCorners = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: boxWidth * cosine, y: boxWidth * sine },
|
||||||
|
{ x: -boxHeight * sine, y: boxHeight * cosine },
|
||||||
|
{
|
||||||
|
x: boxWidth * cosine - boxHeight * sine,
|
||||||
|
y: boxWidth * sine + boxHeight * cosine
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const minX = Math.min(...rotatedCorners.map((corner) => corner.x));
|
||||||
|
const maxX = Math.max(...rotatedCorners.map((corner) => corner.x));
|
||||||
|
const minY = Math.min(...rotatedCorners.map((corner) => corner.y));
|
||||||
|
const maxY = Math.max(...rotatedCorners.map((corner) => corner.y));
|
||||||
|
const boundingCoordinates = getPositionCoords(
|
||||||
|
pageWidth,
|
||||||
|
pageHeight,
|
||||||
|
maxX - minX,
|
||||||
|
maxY - minY,
|
||||||
|
stampPosition,
|
||||||
|
PAGE_MARGIN
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: boundingCoordinates.x - minX,
|
||||||
|
y: boundingCoordinates.y - minY,
|
||||||
|
cosine,
|
||||||
|
sine
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const fitStampToPage = (
|
||||||
|
font: PDFFont,
|
||||||
|
text: string,
|
||||||
|
requestedSize: number,
|
||||||
|
pageWidth: number,
|
||||||
|
pageHeight: number
|
||||||
|
) => {
|
||||||
|
const availableWidth = pageWidth - PAGE_MARGIN * 2;
|
||||||
|
const availableHeight = pageHeight - PAGE_MARGIN * 2;
|
||||||
|
let size = requestedSize;
|
||||||
|
|
||||||
|
while (size > MIN_EXPORT_FONT_SIZE) {
|
||||||
|
const width = font.widthOfTextAtSize(text, size) + PADDING * 2;
|
||||||
|
const height = font.heightAtSize(size) + PADDING * 2;
|
||||||
|
if (width <= availableWidth && height <= availableHeight) break;
|
||||||
|
size = Math.max(MIN_EXPORT_FONT_SIZE, size - 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textWidth = font.widthOfTextAtSize(text, size);
|
||||||
|
const textHeight = font.heightAtSize(size);
|
||||||
|
if (textWidth + PADDING * 2 > availableWidth || textHeight + PADDING * 2 > availableHeight) {
|
||||||
|
throw new Error(
|
||||||
|
'Der Stempeltext ist für mindestens eine Seite zu lang. Kürzen Sie den Text.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
size,
|
||||||
|
textHeight,
|
||||||
|
boxWidth: textWidth + PADDING * 2,
|
||||||
|
boxHeight: textHeight + PADDING * 2
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getImageStampBox = (
|
||||||
|
image: { width: number; height: number },
|
||||||
|
pageWidth: number,
|
||||||
|
pageHeight: number
|
||||||
|
) => {
|
||||||
|
const padding = withBorder ? PADDING : 0;
|
||||||
|
const aspectRatio = image.width / image.height;
|
||||||
|
let drawHeight = Number(imageSize);
|
||||||
|
let drawWidth = drawHeight * aspectRatio;
|
||||||
|
|
||||||
|
const availableWidth = pageWidth - PAGE_MARGIN * 2 - padding * 2;
|
||||||
|
const availableHeight = pageHeight - PAGE_MARGIN * 2 - padding * 2;
|
||||||
|
if (drawWidth > availableWidth || drawHeight > availableHeight) {
|
||||||
|
const scale = Math.min(availableWidth / drawWidth, availableHeight / drawHeight);
|
||||||
|
drawWidth *= scale;
|
||||||
|
drawHeight *= scale;
|
||||||
|
}
|
||||||
|
if (drawHeight < MIN_EXPORT_IMAGE_SIZE) {
|
||||||
|
drawHeight = MIN_EXPORT_IMAGE_SIZE;
|
||||||
|
drawWidth = drawHeight * aspectRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
drawWidth,
|
||||||
|
drawHeight,
|
||||||
|
boxWidth: drawWidth + padding * 2,
|
||||||
|
boxHeight: drawHeight + padding * 2,
|
||||||
|
padding
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyStamp = async () => {
|
||||||
|
if (!pdfFile || !pdfHandle || !canProcess) return;
|
||||||
|
|
||||||
|
isProcessing = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stampText = buildStampText(stampInput, withDate, new Date());
|
||||||
|
const pdfDoc = await PDFDocument.load(new Uint8Array(await pdfFile.arrayBuffer()));
|
||||||
|
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||||
|
const embeddedImage = stampImage
|
||||||
|
? stampImage.mimeType === 'image/png'
|
||||||
|
? await pdfDoc.embedPng(stampImage.bytes)
|
||||||
|
: await pdfDoc.embedJpg(stampImage.bytes)
|
||||||
|
: null;
|
||||||
|
const pages = pdfDoc.getPages();
|
||||||
|
const pageIndexes = applyToAll
|
||||||
|
? pages.map((_, index) => index)
|
||||||
|
: Array.from(selectedPages, (pageNumber) => pageNumber - 1);
|
||||||
|
const color = parseHexColor(stampColor);
|
||||||
|
|
||||||
|
for (const pageIndex of pageIndexes) {
|
||||||
|
const page = pages[pageIndex];
|
||||||
|
if (!page) continue;
|
||||||
|
|
||||||
|
const { width, height } = page.getSize();
|
||||||
|
const angle = Number(rotation);
|
||||||
|
const box = embeddedImage
|
||||||
|
? getImageStampBox(embeddedImage, width, height)
|
||||||
|
: fitStampToPage(font, stampText, Number(fontSize), width, height);
|
||||||
|
const placement = getRotatedStampPlacement(
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
box.boxWidth,
|
||||||
|
box.boxHeight,
|
||||||
|
position,
|
||||||
|
angle
|
||||||
|
);
|
||||||
|
|
||||||
|
if (embeddedImage ? withBorder : true) {
|
||||||
|
page.drawRectangle({
|
||||||
|
x: placement.x,
|
||||||
|
y: placement.y,
|
||||||
|
width: box.boxWidth,
|
||||||
|
height: box.boxHeight,
|
||||||
|
borderColor: color,
|
||||||
|
borderWidth: 2,
|
||||||
|
rotate: degrees(angle)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embeddedImage) {
|
||||||
|
const imageBox = box as { drawWidth: number; drawHeight: number; padding: number };
|
||||||
|
const offsetX = imageBox.padding;
|
||||||
|
const offsetY = imageBox.padding;
|
||||||
|
page.drawImage(embeddedImage, {
|
||||||
|
x: placement.x + offsetX * placement.cosine - offsetY * placement.sine,
|
||||||
|
y: placement.y + offsetX * placement.sine + offsetY * placement.cosine,
|
||||||
|
width: imageBox.drawWidth,
|
||||||
|
height: imageBox.drawHeight,
|
||||||
|
rotate: degrees(angle)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const textOffsetX = PADDING;
|
||||||
|
const textOffsetY = PADDING + (box as { textHeight: number }).textHeight * 0.2;
|
||||||
|
page.drawText(stampText, {
|
||||||
|
x: placement.x + textOffsetX * placement.cosine - textOffsetY * placement.sine,
|
||||||
|
y: placement.y + textOffsetX * placement.sine + textOffsetY * placement.cosine,
|
||||||
|
size: (box as { size: number }).size,
|
||||||
|
font,
|
||||||
|
color,
|
||||||
|
rotate: degrees(angle)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultBytes = await pdfDoc.save();
|
||||||
|
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
|
||||||
|
downloadBlob(resultBytes, `${baseName}_stempel.pdf`);
|
||||||
|
} catch (processingError) {
|
||||||
|
console.error('Failed to stamp PDF:', processingError);
|
||||||
|
error = isEncryptedPdfError(processingError)
|
||||||
|
? loadErrorMessage(processingError)
|
||||||
|
: processingError instanceof Error && processingError.message.startsWith('Der Stempeltext')
|
||||||
|
? processingError.message
|
||||||
|
: 'Der Stempel konnte nicht angewendet werden. Prüfen Sie die PDF-Datei.';
|
||||||
|
} finally {
|
||||||
|
isProcessing = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
handleGeneration += 1;
|
||||||
|
pdfHandle?.destroy();
|
||||||
|
if (stampImage) URL.revokeObjectURL(stampImage.objectUrl);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>PDF stempeln — beA-Edit</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto flex max-w-5xl flex-col gap-6 pb-10">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 flex items-center gap-2 text-secondary">
|
||||||
|
<Stamp class="size-5" aria-hidden="true" />
|
||||||
|
<span class="text-xs font-extrabold tracking-[0.18em] uppercase">Dokumentenvermerk</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold text-foreground">Stempeln</h1>
|
||||||
|
<p class="mt-2 max-w-2xl text-sm text-primary">
|
||||||
|
Setzen Sie einen Text- oder Bildstempel auf alle oder ausgewählte PDF-Seiten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FileDropzone
|
||||||
|
onFilesSelected={handleFileSelected}
|
||||||
|
label={pdfHandle ? 'Anderes PDF ablegen' : 'PDF zum Stempeln hinzufügen'}
|
||||||
|
sublabel={isLoading
|
||||||
|
? 'Vorschau wird geladen …'
|
||||||
|
: (pdfFile?.name ?? 'PDF hier ablegen oder auswählen')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if pdfHandle}
|
||||||
|
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]">
|
||||||
|
<section
|
||||||
|
class="flex flex-col gap-5 rounded-2xl border border-border bg-card p-5"
|
||||||
|
aria-labelledby="stamp-options-title"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 id="stamp-options-title" class="text-lg font-semibold text-foreground">
|
||||||
|
Stempel einstellen
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
Lange Texte werden für kleine Seiten automatisch verkleinert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="stamp-image">
|
||||||
|
Stempelbild (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="stamp-image"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg"
|
||||||
|
onchange={handleImageSelected}
|
||||||
|
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm file:mr-3 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-sm file:font-semibold file:text-primary-foreground"
|
||||||
|
/>
|
||||||
|
{#if stampImage}
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 truncate" title={stampImage.name}>{stampImage.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs font-semibold text-primary underline hover:no-underline"
|
||||||
|
onclick={clearStampImage}
|
||||||
|
>
|
||||||
|
Bild entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if stampImage}
|
||||||
|
<label class="flex items-center gap-2 text-sm text-foreground" for="stamp-border">
|
||||||
|
<input id="stamp-border" type="checkbox" bind:checked={withBorder} />
|
||||||
|
Rahmen um das Bild anzeigen
|
||||||
|
</label>
|
||||||
|
<div class="grid gap-x-4 gap-y-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="stamp-image-size">
|
||||||
|
Bildgröße: {imageSize} pt
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="stamp-image-size"
|
||||||
|
type="range"
|
||||||
|
min="40"
|
||||||
|
max="300"
|
||||||
|
step="5"
|
||||||
|
bind:value={imageSize}
|
||||||
|
class="sm:col-span-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<fieldset class="flex flex-col gap-2">
|
||||||
|
<legend class="text-sm font-medium text-foreground">Vorlage</legend>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each PRESETS as preset}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border px-3 py-1.5 text-xs font-bold tracking-wide transition focus:outline-none focus-visible:ring-2 focus-visible:ring-ring {selectedPreset ===
|
||||||
|
preset
|
||||||
|
? 'border-primary bg-primary text-primary-foreground'
|
||||||
|
: 'border-border bg-background text-foreground hover:border-primary/40'}"
|
||||||
|
aria-pressed={selectedPreset === preset}
|
||||||
|
onclick={() => selectPreset(preset)}
|
||||||
|
>
|
||||||
|
{preset}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="stamp-text">Stempeltext</label>
|
||||||
|
<input
|
||||||
|
id="stamp-text"
|
||||||
|
type="text"
|
||||||
|
bind:value={stampInput}
|
||||||
|
maxlength="120"
|
||||||
|
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm uppercase"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm text-foreground" for="stamp-date">
|
||||||
|
<input id="stamp-date" type="checkbox" bind:checked={withDate} />
|
||||||
|
Datum anhängen
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="grid gap-x-4 gap-y-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="stamp-font-size">
|
||||||
|
Schriftgröße: {fontSize} pt
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="stamp-font-size"
|
||||||
|
type="range"
|
||||||
|
min="12"
|
||||||
|
max="48"
|
||||||
|
bind:value={fontSize}
|
||||||
|
class="sm:col-span-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid gap-x-4 gap-y-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="stamp-rotation">
|
||||||
|
Drehung: {rotation}°
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="stamp-rotation"
|
||||||
|
type="range"
|
||||||
|
min="-45"
|
||||||
|
max="45"
|
||||||
|
bind:value={rotation}
|
||||||
|
class="sm:col-span-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="stamp-position">Position</label>
|
||||||
|
<select
|
||||||
|
id="stamp-position"
|
||||||
|
bind:value={position}
|
||||||
|
class="rounded-lg border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="top-left">Oben links</option>
|
||||||
|
<option value="top-center">Oben mittig</option>
|
||||||
|
<option value="top-right">Oben rechts</option>
|
||||||
|
<option value="center">Zentriert</option>
|
||||||
|
<option value="bottom-left">Unten links</option>
|
||||||
|
<option value="bottom-center">Unten mittig</option>
|
||||||
|
<option value="bottom-right">Unten rechts</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-foreground" for="stamp-color">Farbe</label>
|
||||||
|
<input
|
||||||
|
id="stamp-color"
|
||||||
|
type="color"
|
||||||
|
bind:value={stampColor}
|
||||||
|
class="h-10 w-full cursor-pointer rounded-lg border border-input bg-background p-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm text-foreground" for="stamp-all-pages">
|
||||||
|
<input id="stamp-all-pages" type="checkbox" bind:checked={applyToAll} />
|
||||||
|
Auf alle Seiten anwenden
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5"
|
||||||
|
aria-labelledby="stamp-preview-title"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 id="stamp-preview-title" class="text-lg font-semibold text-foreground">Vorschau</h2>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">Annäherung an die Ausgabe im PDF</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-semibold text-primary"
|
||||||
|
>{currentPage} / {pdfHandle.pageCount}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if pdfHandle.thumbnails[currentPage - 1]}
|
||||||
|
{@const currentThumbnail = pdfHandle.thumbnails[currentPage - 1]}
|
||||||
|
<div
|
||||||
|
class="relative mx-auto w-full max-w-lg overflow-hidden rounded-lg border border-border bg-white shadow-sm"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={currentThumbnail.imageUrl}
|
||||||
|
alt="Vorschau von Seite {currentPage}"
|
||||||
|
class="block w-full"
|
||||||
|
style="aspect-ratio: {currentThumbnail.width} / {currentThumbnail.height};"
|
||||||
|
/>
|
||||||
|
<div class="absolute max-w-[90%]" style={getPreviewPositionStyle(position)}>
|
||||||
|
{#if stampImage}
|
||||||
|
<div
|
||||||
|
data-testid="stamp-preview"
|
||||||
|
class={withBorder ? 'border-2' : ''}
|
||||||
|
style="border-color: {stampColor}; padding: {withBorder
|
||||||
|
? '2px'
|
||||||
|
: '0'}; transform: rotate({rotation}deg);"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={stampImage.objectUrl}
|
||||||
|
alt="Stempelbild"
|
||||||
|
class="block"
|
||||||
|
style="width: {imageSize * 0.5}px; max-width: 100%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
data-testid="stamp-preview"
|
||||||
|
class="border-2 px-2 py-1 font-sans font-extrabold leading-tight whitespace-nowrap"
|
||||||
|
style="border-color: {stampColor}; color: {stampColor}; font-size: {previewFontSize}px; transform: rotate({rotation}deg);"
|
||||||
|
>
|
||||||
|
{previewText}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pdfHandle.pageCount > 1}
|
||||||
|
<div class="flex items-center justify-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
class="min-w-0 px-3"
|
||||||
|
onclick={showPreviousPage}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
aria-label="Vorherige Seite"
|
||||||
|
>
|
||||||
|
<ChevronLeft aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
class="min-w-0 px-3"
|
||||||
|
onclick={showNextPage}
|
||||||
|
disabled={currentPage === pdfHandle.pageCount}
|
||||||
|
aria-label="Nächste Seite"
|
||||||
|
>
|
||||||
|
<ChevronRight aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !applyToAll}
|
||||||
|
<section class="flex flex-col gap-3" aria-labelledby="stamp-pages-title">
|
||||||
|
<div>
|
||||||
|
<h2 id="stamp-pages-title" class="text-lg font-semibold text-foreground">
|
||||||
|
Seiten auswählen
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-sm text-primary">
|
||||||
|
{selectedPages.size} Seite{selectedPages.size === 1 ? '' : 'n'} ausgewählt
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<PdfPageGrid thumbnails={pdfHandle.thumbnails} {selectedPages} onPageClick={togglePage} />
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-col items-end gap-3">
|
||||||
|
{#if error}
|
||||||
|
<p
|
||||||
|
class="w-full rounded-lg border border-destructive/25 bg-card p-4 text-sm text-destructive"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<Button size="large" onclick={applyStamp} disabled={!canProcess}>
|
||||||
|
{isProcessing ? 'Stempel wird angewendet …' : 'Stempel anwenden & herunterladen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<p
|
||||||
|
class="rounded-lg border border-destructive/25 bg-card p-4 text-sm text-destructive"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,23 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { degrees, PDFDocument, rgb, StandardFonts } from 'pdf-lib';
|
import { degrees, PDFDocument, StandardFonts } from 'pdf-lib';
|
||||||
|
import BatchFileList from '$lib/components/BatchFileList.svelte';
|
||||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
||||||
import Button from '$lib/components/ui/button.svelte';
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
|
||||||
import { downloadBlob } from '$lib/download';
|
import { downloadBlob } from '$lib/download';
|
||||||
|
import { getPositionCoords, parseHexColor, type WatermarkPosition } from '$lib/pdf-overlay';
|
||||||
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
|
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
|
||||||
|
|
||||||
type WatermarkKind = 'text' | 'image';
|
type WatermarkKind = 'text' | 'image';
|
||||||
type WatermarkPosition =
|
|
||||||
| 'center'
|
|
||||||
| 'diagonal'
|
|
||||||
| 'top-left'
|
|
||||||
| 'top-right'
|
|
||||||
| 'bottom-left'
|
|
||||||
| 'bottom-right';
|
|
||||||
|
|
||||||
let pdfFile: File | null = $state(null);
|
let items = $state<BatchItem[]>([]);
|
||||||
let pdfHandle: PdfDocumentHandle | null = $state(null);
|
|
||||||
let watermarkKind = $state<WatermarkKind>('text');
|
let watermarkKind = $state<WatermarkKind>('text');
|
||||||
let watermarkText = $state('VERTRAULICH');
|
let watermarkText = $state('VERTRAULICH');
|
||||||
let watermarkImage: File | null = $state(null);
|
let watermarkImage: File | null = $state(null);
|
||||||
@@ -29,25 +24,47 @@
|
|||||||
let watermarkColor = $state('#cc1a1a');
|
let watermarkColor = $state('#cc1a1a');
|
||||||
let applyToAll = $state(true);
|
let applyToAll = $state(true);
|
||||||
let selectedPages = $state(new Set<number>());
|
let selectedPages = $state(new Set<number>());
|
||||||
let isProcessing = $state(false);
|
let isRunning = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Thumbnail handle for the single-file page selection grid only; multiple
|
||||||
|
// files skip preview loading to keep memory flat.
|
||||||
|
let pdfHandle: PdfDocumentHandle | null = $state(null);
|
||||||
|
let handleGeneration = 0;
|
||||||
|
|
||||||
|
const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
|
||||||
const watermarkReady = $derived(
|
const watermarkReady = $derived(
|
||||||
watermarkKind === 'text' ? watermarkText.trim().length > 0 : watermarkImage !== null
|
watermarkKind === 'text' ? watermarkText.trim().length > 0 : watermarkImage !== null
|
||||||
);
|
);
|
||||||
|
const pageSelectionAvailable = $derived(items.length === 1 && pdfHandle !== null);
|
||||||
|
const requiresPageSelection = $derived(pageSelectionAvailable && !applyToAll);
|
||||||
|
const canRun = $derived(
|
||||||
|
!isRunning && pendingCount > 0 && watermarkReady && !requiresPageSelection
|
||||||
|
);
|
||||||
|
|
||||||
const handleFileSelected = async (files: File[]) => {
|
const handleFilesSelected = async (files: File[]) => {
|
||||||
const file = files[0];
|
const knownKeys = new Set(items.map((item) => `${item.file.name}:${item.file.size}`));
|
||||||
if (!file) return;
|
const additions = files
|
||||||
|
.filter((file) => !knownKeys.has(`${file.name}:${file.size}`))
|
||||||
|
.map((file) => createBatchItem(file));
|
||||||
|
if (additions.length === 0) return;
|
||||||
|
|
||||||
pdfHandle?.destroy();
|
items = [...items, ...additions];
|
||||||
pdfHandle = null;
|
|
||||||
pdfFile = file;
|
|
||||||
selectedPages = new Set();
|
selectedPages = new Set();
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
|
pdfHandle?.destroy();
|
||||||
|
pdfHandle = null;
|
||||||
|
if (items.length !== 1) return;
|
||||||
|
|
||||||
|
const generation = ++handleGeneration;
|
||||||
try {
|
try {
|
||||||
pdfHandle = await loadPdfDocument(new Uint8Array(await file.arrayBuffer()));
|
const handle = await loadPdfDocument(new Uint8Array(await items[0].file.arrayBuffer()));
|
||||||
|
if (generation !== handleGeneration || items.length !== 1) {
|
||||||
|
handle.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pdfHandle = handle;
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
console.error('Failed to load PDF for watermarking:', loadError);
|
console.error('Failed to load PDF for watermarking:', loadError);
|
||||||
error = 'PDF konnte nicht geladen werden.';
|
error = 'PDF konnte nicht geladen werden.';
|
||||||
@@ -66,111 +83,110 @@
|
|||||||
selectedPages = next;
|
selectedPages = next;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPositionCoords = (
|
const processFile = async (file: File) => {
|
||||||
pageWidth: number,
|
const pdfDoc = await PDFDocument.load(new Uint8Array(await file.arrayBuffer()));
|
||||||
pageHeight: number,
|
const pages = pdfDoc.getPages();
|
||||||
markWidth: number,
|
// Options (including the page selection) are read per file so a change
|
||||||
markHeight: number,
|
// during a run only affects the still pending files. With multiple files
|
||||||
markPosition: WatermarkPosition
|
// the watermark is applied to all pages of every file.
|
||||||
) => {
|
const useAllPages = items.length > 1 || applyToAll;
|
||||||
const margin = 20;
|
const pageIndexes = useAllPages
|
||||||
switch (markPosition) {
|
? pages.map((_, index) => index)
|
||||||
case 'center':
|
: Array.from(selectedPages, (pageNumber) => pageNumber - 1);
|
||||||
case 'diagonal':
|
const numericOpacity = Number(opacity);
|
||||||
return { x: (pageWidth - markWidth) / 2, y: (pageHeight - markHeight) / 2 };
|
const numericRotation = position === 'diagonal' ? 45 : Number(rotation);
|
||||||
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) => {
|
if (watermarkKind === 'text') {
|
||||||
const match = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hexColor);
|
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||||
if (!match) return rgb(0.8, 0.1, 0.1);
|
const numericFontSize = Number(fontSize);
|
||||||
return rgb(
|
|
||||||
Number.parseInt(match[1], 16) / 255,
|
|
||||||
Number.parseInt(match[2], 16) / 255,
|
|
||||||
Number.parseInt(match[3], 16) / 255
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyWatermark = async () => {
|
for (const pageIndex of pageIndexes) {
|
||||||
if (!pdfFile || !watermarkReady) return;
|
const page = pages[pageIndex];
|
||||||
if (!applyToAll && selectedPages.size === 0) {
|
if (!page) continue;
|
||||||
error = 'Bitte wählen Sie mindestens eine Seite aus.';
|
const { width, height } = page.getSize();
|
||||||
return;
|
const textWidth = font.widthOfTextAtSize(watermarkText, numericFontSize);
|
||||||
}
|
const textHeight = font.heightAtSize(numericFontSize);
|
||||||
|
const coordinates = getPositionCoords(width, height, textWidth, textHeight, position);
|
||||||
|
|
||||||
isProcessing = true;
|
page.drawText(watermarkText, {
|
||||||
error = null;
|
...coordinates,
|
||||||
|
size: numericFontSize,
|
||||||
try {
|
font,
|
||||||
const pdfDoc = await PDFDocument.load(new Uint8Array(await pdfFile.arrayBuffer()));
|
color: parseHexColor(watermarkColor),
|
||||||
const pages = pdfDoc.getPages();
|
opacity: numericOpacity,
|
||||||
const pageIndexes = applyToAll
|
rotate: degrees(numericRotation)
|
||||||
? 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)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else if (watermarkImage) {
|
||||||
|
const imageBytes = new Uint8Array(await watermarkImage.arrayBuffer());
|
||||||
|
const embeddedImage =
|
||||||
|
watermarkImage.type === 'image/jpeg' || /\.jpe?g$/i.test(watermarkImage.name)
|
||||||
|
? await pdfDoc.embedJpg(imageBytes)
|
||||||
|
: await pdfDoc.embedPng(imageBytes);
|
||||||
|
const markWidth = Number(imageWidth);
|
||||||
|
const markHeight = markWidth * (embeddedImage.height / embeddedImage.width);
|
||||||
|
|
||||||
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
|
for (const pageIndex of pageIndexes) {
|
||||||
downloadBlob(await pdfDoc.save(), `${baseName}_wasserzeichen.pdf`);
|
const page = pages[pageIndex];
|
||||||
} catch (watermarkError) {
|
if (!page) continue;
|
||||||
console.error('Watermark failed:', watermarkError);
|
const { width, height } = page.getSize();
|
||||||
error = 'Fehler beim Anwenden des Wasserzeichens.';
|
const coordinates = getPositionCoords(width, height, markWidth, markHeight, position);
|
||||||
} finally {
|
page.drawImage(embeddedImage, {
|
||||||
isProcessing = false;
|
...coordinates,
|
||||||
|
width: markWidth,
|
||||||
|
height: markHeight,
|
||||||
|
opacity: numericOpacity,
|
||||||
|
rotate: degrees(numericRotation)
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bytes = await pdfDoc.save();
|
||||||
|
return { bytes, name: `${file.name.replace(/\.pdf$/i, '')}_wasserzeichen.pdf` };
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItem = (id: string, patch: Partial<BatchItem>) => {
|
||||||
|
items = items.map((item) => (item.id === id ? { ...item, ...patch } : item));
|
||||||
|
};
|
||||||
|
|
||||||
|
const startBatch = async () => {
|
||||||
|
if (isRunning) return;
|
||||||
|
const queue = items.filter((item) => item.status === 'pending');
|
||||||
|
if (queue.length === 0) return;
|
||||||
|
|
||||||
|
isRunning = true;
|
||||||
|
try {
|
||||||
|
await runBatch(queue, processFile, updateItem, {
|
||||||
|
errorMessage: 'Diese PDF konnte nicht mit einem Wasserzeichen versehen werden.',
|
||||||
|
// Skip rows removed while they were still waiting in the queue.
|
||||||
|
shouldProcess: (id) => items.some((item) => item.id === id)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isRunning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryItem = async (id: string) => {
|
||||||
|
if (isRunning) return;
|
||||||
|
const item = items.find((entry) => entry.id === id);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
isRunning = true;
|
||||||
|
try {
|
||||||
|
await runBatch([item], processFile, updateItem, {
|
||||||
|
errorMessage: 'Diese PDF konnte nicht mit einem Wasserzeichen versehen werden.'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isRunning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (id: string) => {
|
||||||
|
items = items.filter((item) => item.id !== id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadItem = (item: BatchItem) => {
|
||||||
|
if (item.resultBytes && item.resultName) downloadBlob(item.resultBytes, item.resultName);
|
||||||
};
|
};
|
||||||
|
|
||||||
onDestroy(() => pdfHandle?.destroy());
|
onDestroy(() => pdfHandle?.destroy());
|
||||||
@@ -178,152 +194,160 @@
|
|||||||
|
|
||||||
<div class="mx-auto max-w-4xl space-y-6">
|
<div class="mx-auto max-w-4xl space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-primary-900">Wasserzeichen</h1>
|
<h1 class="text-2xl font-bold text-foreground">Wasserzeichen</h1>
|
||||||
<p class="mt-2 text-sm text-primary-700">
|
<p class="mt-2 text-sm text-primary">
|
||||||
Fügen Sie allen oder ausgewählten Seiten ein Text- oder Bildwasserzeichen hinzu.
|
Fügen Sie ein Text- oder Bildwasserzeichen hinzu – einzeln oder für mehrere PDFs in einem
|
||||||
|
Durchlauf.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !pdfHandle}
|
<FileDropzone
|
||||||
<FileDropzone onFilesSelected={handleFileSelected} />
|
onFilesSelected={handleFilesSelected}
|
||||||
{:else}
|
multiple
|
||||||
<div class="space-y-6">
|
label={items.length === 0 ? 'Dateien hinzufügen' : 'Weitere Dateien ablegen'}
|
||||||
<FileDropzone
|
sublabel={items.length === 0
|
||||||
onFilesSelected={handleFileSelected}
|
? 'PDFs hier ablegen oder zum Auswählen klicken'
|
||||||
label="Anderes PDF ablegen"
|
: 'Weitere PDFs hinzufügen – bestehende Dateien bleiben erhalten'}
|
||||||
sublabel={pdfFile?.name ?? ''}
|
/>
|
||||||
class="py-4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="space-y-4 rounded-xl border border-primary-200 bg-white p-4">
|
{#if items.length > 0}
|
||||||
<h2 class="text-lg font-semibold text-primary-900">Wasserzeichen-Einstellungen</h2>
|
<div class="space-y-4 rounded-2xl border border-border bg-card p-4">
|
||||||
|
<h2 class="text-lg font-semibold text-foreground">Wasserzeichen-Einstellungen</h2>
|
||||||
|
|
||||||
<fieldset class="flex gap-4">
|
<fieldset class="flex gap-4">
|
||||||
<legend class="mb-2 text-sm font-medium text-primary-900">Art</legend>
|
<legend class="mb-2 text-sm font-medium text-foreground">Art</legend>
|
||||||
<label class="flex items-center gap-2 text-sm">
|
<label class="flex items-center gap-2 text-sm">
|
||||||
<input type="radio" bind:group={watermarkKind} value="text" /> Text
|
<input type="radio" bind:group={watermarkKind} value="text" /> Text
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2 text-sm">
|
<label class="flex items-center gap-2 text-sm">
|
||||||
<input type="radio" bind:group={watermarkKind} value="image" /> Bild
|
<input type="radio" bind:group={watermarkKind} value="image" /> Bild
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
{#if watermarkKind === 'text'}
|
||||||
|
<label class="block text-sm font-medium text-foreground" for="watermark-text">Text</label>
|
||||||
|
<input
|
||||||
|
id="watermark-text"
|
||||||
|
type="text"
|
||||||
|
bind:value={watermarkText}
|
||||||
|
class="w-full rounded-lg border border-border px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<FileDropzone
|
||||||
|
onFilesSelected={handleImageSelected}
|
||||||
|
accept=".png,.jpg,.jpeg,image/png,image/jpeg"
|
||||||
|
ariaLabel="Wasserzeichenbild hinzufügen"
|
||||||
|
label="Wasserzeichenbild ablegen"
|
||||||
|
sublabel={watermarkImage?.name ?? 'PNG oder JPG'}
|
||||||
|
class="py-4"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
{#if watermarkKind === 'text'}
|
{#if watermarkKind === 'text'}
|
||||||
<label class="block text-sm font-medium text-primary-900" for="watermark-text">
|
<label class="text-sm font-medium text-foreground" for="watermark-font-size">
|
||||||
Text
|
Schriftgröße: {fontSize}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input id="watermark-font-size" type="range" min="10" max="120" bind:value={fontSize} />
|
||||||
id="watermark-text"
|
|
||||||
type="text"
|
|
||||||
bind:value={watermarkText}
|
|
||||||
class="w-full rounded-lg border border-primary-200 px-3 py-2 text-sm"
|
|
||||||
/>
|
|
||||||
{:else}
|
{:else}
|
||||||
<FileDropzone
|
<label class="text-sm font-medium text-foreground" for="watermark-image-width">
|
||||||
onFilesSelected={handleImageSelected}
|
Bildbreite: {imageWidth}
|
||||||
accept=".png,.jpg,.jpeg,image/png,image/jpeg"
|
</label>
|
||||||
ariaLabel="Wasserzeichenbild hinzufügen"
|
<input
|
||||||
label="Wasserzeichenbild ablegen"
|
id="watermark-image-width"
|
||||||
sublabel={watermarkImage?.name ?? 'PNG oder JPG'}
|
type="range"
|
||||||
class="py-4"
|
min="10"
|
||||||
|
max="400"
|
||||||
|
bind:value={imageWidth}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<label class="text-sm font-medium text-foreground" for="watermark-opacity">
|
||||||
{#if watermarkKind === 'text'}
|
Deckkraft: {Math.round(Number(opacity) * 100)}%
|
||||||
<label class="text-sm font-medium text-primary-900" for="watermark-font-size">
|
</label>
|
||||||
Schriftgröße: {fontSize}
|
<input
|
||||||
</label>
|
id="watermark-opacity"
|
||||||
<input id="watermark-font-size" type="range" min="10" max="120" bind:value={fontSize} />
|
type="range"
|
||||||
{:else}
|
min="0.05"
|
||||||
<label class="text-sm font-medium text-primary-900" for="watermark-image-width">
|
max="1"
|
||||||
Bildbreite: {imageWidth}
|
step="0.05"
|
||||||
</label>
|
bind:value={opacity}
|
||||||
<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">
|
<label class="text-sm font-medium text-foreground" for="watermark-rotation">
|
||||||
Deckkraft: {Math.round(Number(opacity) * 100)}%
|
Drehung: {rotation}°
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="watermark-opacity"
|
id="watermark-rotation"
|
||||||
type="range"
|
type="range"
|
||||||
min="0.05"
|
min="0"
|
||||||
max="1"
|
max="360"
|
||||||
step="0.05"
|
bind:value={rotation}
|
||||||
bind:value={opacity}
|
disabled={position === 'diagonal'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label class="text-sm font-medium text-primary-900" for="watermark-rotation">
|
<label class="text-sm font-medium text-foreground" for="watermark-position">Position</label>
|
||||||
Drehung: {rotation}°
|
<select id="watermark-position" bind:value={position} class="rounded-lg border px-3 py-2">
|
||||||
</label>
|
<option value="center">Zentriert</option>
|
||||||
<input
|
<option value="diagonal">Diagonal</option>
|
||||||
id="watermark-rotation"
|
<option value="top-left">Oben links</option>
|
||||||
type="range"
|
<option value="top-right">Oben rechts</option>
|
||||||
min="0"
|
<option value="bottom-left">Unten links</option>
|
||||||
max="360"
|
<option value="bottom-right">Unten rechts</option>
|
||||||
bind:value={rotation}
|
</select>
|
||||||
disabled={position === 'diagonal'}
|
</div>
|
||||||
/>
|
|
||||||
|
|
||||||
<label class="text-sm font-medium text-primary-900" for="watermark-position">
|
{#if watermarkKind === 'text'}
|
||||||
Position
|
<label class="block text-sm font-medium text-foreground" for="watermark-color">Farbe</label>
|
||||||
</label>
|
<input
|
||||||
<select id="watermark-position" bind:value={position} class="rounded-lg border px-3 py-2">
|
id="watermark-color"
|
||||||
<option value="center">Zentriert</option>
|
type="color"
|
||||||
<option value="diagonal">Diagonal</option>
|
bind:value={watermarkColor}
|
||||||
<option value="top-left">Oben links</option>
|
class="h-10 w-14 cursor-pointer"
|
||||||
<option value="top-right">Oben rechts</option>
|
/>
|
||||||
<option value="bottom-left">Unten links</option>
|
{/if}
|
||||||
<option value="bottom-right">Unten rechts</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if watermarkKind === 'text'}
|
{#if items.length > 1}
|
||||||
<label class="block text-sm font-medium text-primary-900" for="watermark-color">
|
<p class="text-xs text-primary" role="note">
|
||||||
Farbe
|
Die Einstellungen gelten für alle Dateien. Das Wasserzeichen wird auf allen Seiten jeder
|
||||||
</label>
|
Datei angewendet.
|
||||||
<input
|
{#if isRunning}Änderungen wirken auf die noch ausstehenden Dateien.{/if}
|
||||||
id="watermark-color"
|
</p>
|
||||||
type="color"
|
{:else}
|
||||||
bind:value={watermarkColor}
|
<label class="flex items-center gap-2 text-sm text-foreground" for="all-pages">
|
||||||
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} />
|
<input type="checkbox" id="all-pages" bind:checked={applyToAll} />
|
||||||
Auf alle Seiten anwenden
|
Auf alle Seiten anwenden
|
||||||
</label>
|
</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}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if pageSelectionAvailable && !applyToAll}
|
||||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
<div>
|
||||||
{error}
|
<p class="mb-3 text-sm font-medium">Seiten auswählen:</p>
|
||||||
</div>
|
<PdfPageGrid thumbnails={pdfHandle!.thumbnails} {selectedPages} onPageClick={togglePage} />
|
||||||
{/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}
|
||||||
|
|
||||||
|
{#if items.length > 0}
|
||||||
|
<BatchFileList
|
||||||
|
{items}
|
||||||
|
zipBaseName="wasserzeichen"
|
||||||
|
onDownload={downloadItem}
|
||||||
|
onRemove={removeItem}
|
||||||
|
onRetry={retryItem}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button onclick={startBatch} disabled={!canRun}>
|
||||||
|
{isRunning ? 'Wird verarbeitet...' : 'Wasserzeichen anwenden'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+377
-16
@@ -1,6 +1,14 @@
|
|||||||
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 { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
|
import {
|
||||||
import { zipSync } from 'fflate';
|
decodePDFRawStream,
|
||||||
|
PDFArray,
|
||||||
|
PDFDocument,
|
||||||
|
PDFRawStream,
|
||||||
|
StandardFonts,
|
||||||
|
rgb
|
||||||
|
} from 'pdf-lib';
|
||||||
|
import { PDFDocument as EncryptablePdfDocument } from '@cantoo/pdf-lib';
|
||||||
|
import { unzipSync, zipSync } from 'fflate';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
const createPdf = async (pageCount: number, label: string) => {
|
const createPdf = async (pageCount: number, label: string) => {
|
||||||
@@ -19,6 +27,35 @@ const createPdf = async (pageCount: number, label: string) => {
|
|||||||
return Buffer.from(await document.save());
|
return Buffer.from(await document.save());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createStampPng = (page: Page) =>
|
||||||
|
page.evaluate(
|
||||||
|
async () =>
|
||||||
|
new Promise<string>((resolve, reject) => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 32;
|
||||||
|
canvas.height = 32;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) {
|
||||||
|
reject(new Error('Kein 2D-Kontext'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.fillStyle = '#cc1a1a';
|
||||||
|
context.fillRect(0, 0, 32, 32);
|
||||||
|
canvas.toBlob(async (blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
reject(new Error('PNG konnte nicht erzeugt werden'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||||
|
let binary = '';
|
||||||
|
bytes.forEach((byte) => {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
});
|
||||||
|
resolve(btoa(binary));
|
||||||
|
}, 'image/png');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const dropFiles = async (
|
const dropFiles = async (
|
||||||
page: Page,
|
page: Page,
|
||||||
target: Locator | 'window',
|
target: Locator | 'window',
|
||||||
@@ -69,6 +106,32 @@ const expectPdfDownload = async (download: Download, name: RegExp) => {
|
|||||||
return bytes;
|
return bytes;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getPageContentStreamCount = (document: PDFDocument, pageIndex: number) => {
|
||||||
|
const contents = document.getPage(pageIndex).node.Contents();
|
||||||
|
if (!contents) return 0;
|
||||||
|
return contents instanceof PDFArray ? contents.size() : 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPageContentText = (document: PDFDocument, pageIndex: number) => {
|
||||||
|
const contents = document.getPage(pageIndex).node.Contents();
|
||||||
|
const refs = contents instanceof PDFArray ? contents.asArray() : contents ? [contents] : [];
|
||||||
|
return refs
|
||||||
|
.map((ref) => document.context.lookup(ref))
|
||||||
|
.filter((stream): stream is PDFRawStream => stream instanceof PDFRawStream)
|
||||||
|
.map((stream) => Buffer.from(decodePDFRawStream(stream).decode()).toString('latin1'))
|
||||||
|
.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const createEncryptedPdf = async (pageCount: number, label: string, password: string) => {
|
||||||
|
const document = await EncryptablePdfDocument.load(await createPdf(pageCount, label));
|
||||||
|
document.encrypt({
|
||||||
|
userPassword: password,
|
||||||
|
ownerPassword: 'owner-passwort',
|
||||||
|
permissions: { printing: true, copying: true }
|
||||||
|
});
|
||||||
|
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));
|
||||||
@@ -121,6 +184,7 @@ test('start page exposes only working tools and forwards a dropped ZIP to beA',
|
|||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.getByRole('heading', { name: 'Werkzeugkasten für beA' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Werkzeugkasten für beA' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: /Stempeln/ })).toBeVisible();
|
||||||
await expect(page.getByRole('link', { name: /Signieren/ })).toHaveCount(0);
|
await expect(page.getByRole('link', { name: /Signieren/ })).toHaveCount(0);
|
||||||
|
|
||||||
await dropFiles(page, 'window', [
|
await dropFiles(page, 'window', [
|
||||||
@@ -209,13 +273,13 @@ test('watermark accepts numeric controls and downloads text and image watermarks
|
|||||||
{ name: 'marke.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Marke') }
|
{ name: 'marke.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Marke') }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Wasserzeichen-Einstellungen' })).toBeVisible({
|
await expect(page.getByRole('heading', { name: 'Wasserzeichen-Einstellungen' })).toBeVisible();
|
||||||
timeout: 15_000
|
|
||||||
});
|
|
||||||
await page.locator('#watermark-color').fill('#00ff00');
|
await page.locator('#watermark-color').fill('#00ff00');
|
||||||
await page.locator('#watermark-opacity').fill('0.45');
|
await page.locator('#watermark-opacity').fill('0.45');
|
||||||
|
await page.getByRole('button', { name: 'Wasserzeichen anwenden', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 15_000 });
|
||||||
const downloadPromise = page.waitForEvent('download');
|
const downloadPromise = page.waitForEvent('download');
|
||||||
await page.getByRole('button', { name: 'Wasserzeichen anwenden & herunterladen' }).click();
|
await page.getByRole('button', { name: 'marke.pdf herunterladen' }).click();
|
||||||
await expectPdfDownload(await downloadPromise, /^marke_wasserzeichen\.pdf$/);
|
await expectPdfDownload(await downloadPromise, /^marke_wasserzeichen\.pdf$/);
|
||||||
|
|
||||||
await page.getByLabel('Bild', { exact: true }).check();
|
await page.getByLabel('Bild', { exact: true }).check();
|
||||||
@@ -229,15 +293,176 @@ test('watermark accepts numeric controls and downloads text and image watermarks
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
// The file is already done, so reset it to pending by removing and re-adding it.
|
||||||
|
await page.getByRole('button', { name: 'marke.pdf entfernen' }).click();
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'marke.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Marke') }
|
||||||
|
]);
|
||||||
|
await page.getByRole('button', { name: 'Wasserzeichen anwenden', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 15_000 });
|
||||||
const imageDownloadPromise = page.waitForEvent('download');
|
const imageDownloadPromise = page.waitForEvent('download');
|
||||||
await page.getByRole('button', { name: 'Wasserzeichen anwenden & herunterladen' }).click();
|
await page.getByRole('button', { name: 'marke.pdf herunterladen' }).click();
|
||||||
await expectPdfDownload(await imageDownloadPromise, /^marke_wasserzeichen\.pdf$/);
|
await expectPdfDownload(await imageDownloadPromise, /^marke_wasserzeichen\.pdf$/);
|
||||||
|
|
||||||
await expect(page.getByText('Fehler beim Anwenden')).toHaveCount(0);
|
|
||||||
expect(pageErrors).toEqual([]);
|
expect(pageErrors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('encrypt creates a protected PDF and decrypt rebuilds it with the password', async ({
|
test('stamp presets fill the editable text and EILT is applied to every page', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/stamp');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'akte.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Akte') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stampInput = page.locator('#stamp-text');
|
||||||
|
for (const preset of ['BEGLAUBIGT', 'AUSFERTIGUNG', 'KOPIE', 'EILT', 'ENTWURF', 'ERLEDIGT']) {
|
||||||
|
await page.getByRole('button', { name: preset, exact: true }).click();
|
||||||
|
await expect(stampInput).toHaveValue(preset);
|
||||||
|
}
|
||||||
|
await page.getByRole('button', { name: 'EILT', exact: true }).click();
|
||||||
|
await page.locator('#stamp-rotation').fill('20');
|
||||||
|
await expect(page.getByTestId('stamp-preview')).toContainText('EILT');
|
||||||
|
await expect(page.getByTestId('stamp-preview')).not.toHaveCSS('transform', 'none');
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
|
||||||
|
const outputBytes = await expectPdfDownload(await downloadPromise, /^akte_stempel\.pdf$/);
|
||||||
|
const output = await PDFDocument.load(outputBytes);
|
||||||
|
expect(output.getPageCount()).toBe(3);
|
||||||
|
for (let pageIndex = 0; pageIndex < output.getPageCount(); pageIndex += 1) {
|
||||||
|
expect(getPageContentStreamCount(output, pageIndex)).toBeGreaterThan(1);
|
||||||
|
}
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stamp custom text adds a German date and only changes selected pages', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
const inputBytes = await createPdf(3, 'Auswahl');
|
||||||
|
const input = await PDFDocument.load(inputBytes);
|
||||||
|
const originalStreamCounts = input
|
||||||
|
.getPages()
|
||||||
|
.map((_, pageIndex) => getPageContentStreamCount(input, pageIndex));
|
||||||
|
|
||||||
|
await page.goto('/tools/stamp');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'auswahl.pdf', mimeType: 'application/pdf', buffer: inputBytes }
|
||||||
|
]);
|
||||||
|
await page.locator('#stamp-text').fill('nur hier');
|
||||||
|
await page.getByRole('checkbox', { name: 'Datum anhängen' }).check();
|
||||||
|
await expect(page.getByTestId('stamp-preview')).toHaveText(/NUR HIER \d{2}\.\d{2}\.\d{4}/);
|
||||||
|
await page.getByRole('checkbox', { name: 'Auf alle Seiten anwenden' }).uncheck();
|
||||||
|
await page.getByRole('button', { name: /Seite 2/ }).click();
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
|
||||||
|
const outputBytes = await expectPdfDownload(await downloadPromise, /^auswahl_stempel\.pdf$/);
|
||||||
|
const output = await PDFDocument.load(outputBytes);
|
||||||
|
expect(output.getPageCount()).toBe(3);
|
||||||
|
expect(getPageContentStreamCount(output, 0)).toBe(originalStreamCounts[0]);
|
||||||
|
expect(getPageContentStreamCount(output, 1)).toBeGreaterThan(originalStreamCounts[1]);
|
||||||
|
expect(getPageContentStreamCount(output, 2)).toBe(originalStreamCounts[2]);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stamp uses an image instead of text and makes the border optional', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/stamp');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'bildakte.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Bild') }
|
||||||
|
]);
|
||||||
|
await page.locator('#stamp-image').setInputFiles({
|
||||||
|
name: 'stempel.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
buffer: Buffer.from(await createStampPng(page), 'base64')
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.getByText('stempel.png')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('stamp-preview').locator('img')).toBeVisible();
|
||||||
|
await expect(page.locator('#stamp-text')).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' })
|
||||||
|
).not.toBeChecked();
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
|
||||||
|
const outputBytes = await expectPdfDownload(await downloadPromise, /^bildakte_stempel\.pdf$/);
|
||||||
|
const output = await PDFDocument.load(outputBytes);
|
||||||
|
expect(output.getPageCount()).toBe(2);
|
||||||
|
// Image without frame: the image XObject is drawn, no stroked rectangle is present.
|
||||||
|
for (const pageIndex of [0, 1]) {
|
||||||
|
const content = getPageContentText(output, pageIndex);
|
||||||
|
expect(content).toContain(' Do\n');
|
||||||
|
expect(content).not.toContain(' RG');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' }).check();
|
||||||
|
await expect(page.getByTestId('stamp-preview')).toHaveCSS('border-style', 'solid');
|
||||||
|
const borderedDownloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
|
||||||
|
const borderedBytes = await expectPdfDownload(
|
||||||
|
await borderedDownloadPromise,
|
||||||
|
/^bildakte_stempel\.pdf$/
|
||||||
|
);
|
||||||
|
const bordered = await PDFDocument.load(borderedBytes);
|
||||||
|
// The optional frame adds a stroked rectangle around the drawn image.
|
||||||
|
for (const pageIndex of [0, 1]) {
|
||||||
|
const content = getPageContentText(bordered, pageIndex);
|
||||||
|
expect(content).toContain(' Do\n');
|
||||||
|
expect(content).toContain(' RG');
|
||||||
|
expect(content).toMatch(/\bh\nS\n/);
|
||||||
|
}
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stamp explains how to handle a password-protected PDF', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/stamp');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{
|
||||||
|
name: 'geschuetzt.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: await createEncryptedPdf(1, 'Geschützt', 'test-passwort')
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByRole('alert')).toContainText('passwortgeschützt');
|
||||||
|
await expect(page.getByRole('alert')).toContainText('Passwort entfernen');
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('watermark batch applies to every file and bundles results in one ZIP', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/watermark');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') },
|
||||||
|
{ name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Zwei') },
|
||||||
|
{
|
||||||
|
name: 'kaputt.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: Buffer.from('kein gueltiges PDF')
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByText('Dateien (3)')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Wasserzeichen anwenden', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 15_000 });
|
||||||
|
await expect(page.getByText('Fehler')).toBeVisible();
|
||||||
|
// With several files the per-file page selection is replaced by all pages.
|
||||||
|
await expect(page.getByRole('checkbox', { name: 'Auf alle Seiten anwenden' })).toHaveCount(0);
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
expect(download.suggestedFilename()).toMatch(/^wasserzeichen_\d{4}-\d{2}-\d{2}\.zip$/);
|
||||||
|
const zipPath = await download.path();
|
||||||
|
expect(zipPath).not.toBeNull();
|
||||||
|
const entries = Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!))));
|
||||||
|
expect(entries.sort()).toEqual(['eins_wasserzeichen.pdf', 'zwei_wasserzeichen.pdf']);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('encrypt protects a dropped PDF and decrypt rebuilds it with the password', async ({
|
||||||
page
|
page
|
||||||
}) => {
|
}) => {
|
||||||
const pageErrors = trackPageErrors(page);
|
const pageErrors = trackPageErrors(page);
|
||||||
@@ -248,10 +473,14 @@ test('encrypt creates a protected PDF and decrypt rebuilds it with the password'
|
|||||||
|
|
||||||
await page.locator('#encrypt-password').fill('test-passwort');
|
await page.locator('#encrypt-password').fill('test-passwort');
|
||||||
await page.locator('#encrypt-password-confirmation').fill('test-passwort');
|
await page.locator('#encrypt-password-confirmation').fill('test-passwort');
|
||||||
|
await page.getByRole('button', { name: 'Passwort setzen', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 15_000 });
|
||||||
const encryptedDownloadPromise = page.waitForEvent('download');
|
const encryptedDownloadPromise = page.waitForEvent('download');
|
||||||
await page.getByRole('button', { name: 'Passwort setzen & herunterladen' }).click();
|
await page.getByRole('button', { name: 'geheim.pdf herunterladen' }).click();
|
||||||
const encryptedDownload = await encryptedDownloadPromise;
|
const encryptedBytes = await expectPdfDownload(
|
||||||
const encryptedBytes = await expectPdfDownload(encryptedDownload, /^geheim_geschuetzt\.pdf$/);
|
await encryptedDownloadPromise,
|
||||||
|
/^geheim_geschuetzt\.pdf$/
|
||||||
|
);
|
||||||
|
|
||||||
await page.goto('/tools/decrypt');
|
await page.goto('/tools/decrypt');
|
||||||
await dropFiles(page, pdfDropzone(page), [
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
@@ -262,27 +491,159 @@ test('encrypt creates a protected PDF and decrypt rebuilds it with the password'
|
|||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
await page.locator('#decrypt-password').fill('test-passwort');
|
await page.locator('#decrypt-password').fill('test-passwort');
|
||||||
|
await page.getByRole('button', { name: 'Passwort entfernen', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
|
||||||
const decryptedDownloadPromise = page.waitForEvent('download');
|
const decryptedDownloadPromise = page.waitForEvent('download');
|
||||||
await page.getByRole('button', { name: 'Passwort entfernen & herunterladen' }).click();
|
await page.getByRole('button', { name: 'geheim_geschuetzt.pdf herunterladen' }).click();
|
||||||
await expectPdfDownload(await decryptedDownloadPromise, /^geheim_geschuetzt_entsperrt\.pdf$/);
|
await expectPdfDownload(await decryptedDownloadPromise, /^geheim_geschuetzt_entsperrt\.pdf$/);
|
||||||
expect(pageErrors).toEqual([]);
|
expect(pageErrors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('compress previews result size before downloading', async ({ page }) => {
|
test('encrypt batch protects every file and bundles results in one ZIP', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/encrypt');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') },
|
||||||
|
{ name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Zwei') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByText('Dateien (2)')).toBeVisible();
|
||||||
|
await page.locator('#encrypt-password').fill('test-passwort');
|
||||||
|
await page.locator('#encrypt-password-confirmation').fill('test-passwort');
|
||||||
|
await page.getByRole('button', { name: 'Passwort setzen', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 15_000 });
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
expect(download.suggestedFilename()).toMatch(/^geschuetzt_\d{4}-\d{2}-\d{2}\.zip$/);
|
||||||
|
const zipPath = await download.path();
|
||||||
|
expect(zipPath).not.toBeNull();
|
||||||
|
const entries = Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!))));
|
||||||
|
expect(entries.sort()).toEqual(['eins_geschuetzt.pdf', 'zwei_geschuetzt.pdf']);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decrypt batch unlocks every file and bundles results in one ZIP', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/decrypt');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{
|
||||||
|
name: 'eins_geschuetzt.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: await createEncryptedPdf(1, 'Eins', 'test-passwort')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'zwei_geschuetzt.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: await createEncryptedPdf(2, 'Zwei', 'test-passwort')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'kaputt.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: Buffer.from('kein gueltiges PDF')
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByText('Dateien (3)')).toBeVisible();
|
||||||
|
await page.locator('#decrypt-password').fill('test-passwort');
|
||||||
|
await page.getByRole('button', { name: 'Passwort entfernen', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 20_000 });
|
||||||
|
await expect(page.getByText(/Fehler beim Entsperren/)).toBeVisible();
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
expect(download.suggestedFilename()).toMatch(/^entsperrt_\d{4}-\d{2}-\d{2}\.zip$/);
|
||||||
|
const zipPath = await download.path();
|
||||||
|
expect(zipPath).not.toBeNull();
|
||||||
|
const entries = Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!))));
|
||||||
|
expect(entries.sort()).toEqual([
|
||||||
|
'eins_geschuetzt_entsperrt.pdf',
|
||||||
|
'zwei_geschuetzt_entsperrt.pdf'
|
||||||
|
]);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compress processes a single dropped file and offers its download per row', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
const pageErrors = trackPageErrors(page);
|
const pageErrors = trackPageErrors(page);
|
||||||
await page.goto('/tools/compress');
|
await page.goto('/tools/compress');
|
||||||
await dropFiles(page, pdfDropzone(page), [
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
{ name: 'gross.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Komprimieren') }
|
{ name: 'gross.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Komprimieren') }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByText('Dateien (1)')).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'Komprimieren', exact: true }).click();
|
await page.getByRole('button', { name: 'Komprimieren', exact: true }).click();
|
||||||
await expect(page.getByText(/Ergebnisgröße:/)).toBeVisible({ timeout: 20_000 });
|
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
|
||||||
const downloadPromise = page.waitForEvent('download');
|
const downloadPromise = page.waitForEvent('download');
|
||||||
await page.getByRole('button', { name: 'PDF herunterladen' }).click();
|
await page.getByRole('button', { name: 'gross.pdf herunterladen' }).click();
|
||||||
await expectPdfDownload(await downloadPromise, /^gross_komprimiert\.pdf$/);
|
await expectPdfDownload(await downloadPromise, /^gross_komprimiert\.pdf$/);
|
||||||
expect(pageErrors).toEqual([]);
|
expect(pageErrors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('compress appends further files and bundles all results in one ZIP', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/compress');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') }
|
||||||
|
]);
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Zwei') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByText('Dateien (2)')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Komprimieren', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 30_000 });
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
expect(download.suggestedFilename()).toMatch(/^komprimiert_\d{4}-\d{2}-\d{2}\.zip$/);
|
||||||
|
const zipPath = await download.path();
|
||||||
|
expect(zipPath).not.toBeNull();
|
||||||
|
const entries = Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!))));
|
||||||
|
expect(entries.sort()).toEqual(['eins_komprimiert.pdf', 'zwei_komprimiert.pdf']);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compress batch keeps processing after a failing file and supports retry and removal', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/compress');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'gut.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Gut') },
|
||||||
|
{
|
||||||
|
name: 'kaputt.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: Buffer.from('kein gueltiges PDF')
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Komprimieren', exact: true }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toHaveCount(1, { timeout: 30_000 });
|
||||||
|
await expect(page.getByText('Fehler')).toBeVisible();
|
||||||
|
|
||||||
|
// Only one result exists, so the bulk ZIP action stays hidden.
|
||||||
|
await expect(page.getByRole('button', { name: 'Alle herunterladen' })).toHaveCount(0);
|
||||||
|
|
||||||
|
// Retrying the broken file fails again without disturbing the finished row.
|
||||||
|
await page.getByRole('button', { name: 'kaputt.pdf erneut versuchen' }).click();
|
||||||
|
await expect(page.getByText('Fertig ✓')).toHaveCount(1);
|
||||||
|
await expect(page.getByText('Fehler')).toBeVisible();
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'gut.pdf herunterladen' }).click();
|
||||||
|
await expectPdfDownload(await downloadPromise, /^gut_komprimiert\.pdf$/);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'kaputt.pdf entfernen' }).click();
|
||||||
|
await expect(page.getByText('kaputt.pdf')).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: 'gut.pdf herunterladen' })).toBeVisible();
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('convert bundles a multi-page PDF as a correctly named ZIP', async ({ page }) => {
|
test('convert bundles a multi-page PDF as a correctly named ZIP', async ({ page }) => {
|
||||||
const pageErrors = trackPageErrors(page);
|
const pageErrors = trackPageErrors(page);
|
||||||
await page.goto('/tools/convert');
|
await page.goto('/tools/convert');
|
||||||
|
|||||||
Reference in New Issue
Block a user