This commit is contained in:
1 parent
57b38fe2c7
commit
ea21fc000f
13 files changed
+2237
-648
No files matched your search
@@ -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[] = [
|
||||
{
|
||||
slug: 'stamp',
|
||||
title: 'Stempeln',
|
||||
description: 'Versehen Sie Seiten mit Textstempeln wie „Beglaubigt“ oder „Eilt“.',
|
||||
icon: 'stamp'
|
||||
},
|
||||
{
|
||||
slug: 'watermark',
|
||||
title: 'Wasserzeichen',
|
||||
|
||||
Reference in new issue
Block a user