refactoring, numbering plan
This commit is contained in:
1 parent
b5279434b2
commit
3b04039aa9
47 files changed
+3485
-953
No files matched your search
+90
-2
@@ -1,3 +1,6 @@
|
||||
import { zipSync } from 'fflate';
|
||||
import { downloadBlob } from './download';
|
||||
|
||||
export type BatchItemStatus = 'pending' | 'processing' | 'done' | 'error';
|
||||
|
||||
export type BatchItem = {
|
||||
@@ -34,10 +37,13 @@ export const createBatchItem = (file: File): BatchItem => ({
|
||||
* 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.
|
||||
*
|
||||
* `process` receives the item as well, so tools can resolve per-row data (for
|
||||
* example the exhibit number frozen when the run started).
|
||||
*/
|
||||
export const runBatch = async (
|
||||
items: BatchItem[],
|
||||
process: (file: File) => Promise<BatchProcessResult>,
|
||||
process: (file: File, item: BatchItem) => Promise<BatchProcessResult>,
|
||||
update: (id: string, patch: Partial<BatchItem>) => void,
|
||||
options: BatchRunOptions = {}
|
||||
): Promise<void> => {
|
||||
@@ -48,7 +54,7 @@ export const runBatch = async (
|
||||
|
||||
update(item.id, { status: 'processing', errorMessage: null });
|
||||
try {
|
||||
const result = await process(item.file);
|
||||
const result = await process(item.file, item);
|
||||
update(item.id, {
|
||||
status: 'done',
|
||||
resultBytes: result.bytes,
|
||||
@@ -73,3 +79,85 @@ export const formatBytes = (bytes: number) => {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
/** Badge colours for a batch row, shared by every batch list component. */
|
||||
export const batchStatusClass = (status: BatchItemStatus) => {
|
||||
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';
|
||||
}
|
||||
};
|
||||
|
||||
/** German badge label for a batch row. */
|
||||
export const batchStatusLabel = (status: BatchItemStatus) => {
|
||||
switch (status) {
|
||||
case 'processing':
|
||||
return 'Verarbeite…';
|
||||
case 'done':
|
||||
return 'Fertig ✓';
|
||||
case 'error':
|
||||
return 'Fehler';
|
||||
default:
|
||||
return 'Wartet';
|
||||
}
|
||||
};
|
||||
|
||||
/** Above this many files (or one file above the threshold) memory gets tight. */
|
||||
const BATCH_MEMORY_FILE_COUNT = 20;
|
||||
const BATCH_MEMORY_FILE_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Shared memory hint for batch tools: very many or very large files keep every
|
||||
* result in memory until the page is closed.
|
||||
*/
|
||||
export const shouldWarnAboutBatchMemory = (items: BatchItem[]) =>
|
||||
items.length > BATCH_MEMORY_FILE_COUNT ||
|
||||
items.some((item) => item.file.size > BATCH_MEMORY_FILE_BYTES);
|
||||
|
||||
/** Appends `_2`, `_3`, … when a name is already taken inside the archive. */
|
||||
export 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Bundles every finished row into one `{zipBaseName}_{YYYY-MM-DD}.zip`. Kept out
|
||||
* of the component so the next batch tool (exhibits) reuses the naming and the
|
||||
* duplicate handling instead of copying them.
|
||||
*/
|
||||
export const downloadBatchZip = (items: BatchItem[], zipBaseName: string) => {
|
||||
const entries: Record<string, Uint8Array> = {};
|
||||
const usedNames = new Set<string>();
|
||||
|
||||
for (const item of items) {
|
||||
if (item.status !== 'done' || !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');
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { pdfLoadErrorMessage } from '$lib/pdf-errors';
|
||||
|
||||
import type { ZipAttachment } from '$lib/zip-processing';
|
||||
|
||||
@@ -98,7 +99,7 @@
|
||||
await loadingTask.destroy();
|
||||
} catch (error) {
|
||||
console.error(`Failed to render preview for ${attachment.path}`, error);
|
||||
previewError = 'PDF-Vorschau konnte nicht geladen werden.';
|
||||
previewError = pdfLoadErrorMessage(error, 'PDF-Vorschau konnte nicht geladen werden.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -115,19 +116,19 @@
|
||||
<div class={className}>
|
||||
{#if previewError}
|
||||
<div
|
||||
class="flex h-full min-h-0 items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-3 py-4 text-center text-[12px] text-primary-800"
|
||||
class="border-primary-200 bg-primary-50 text-primary-800 flex h-full min-h-0 items-center justify-center rounded-md border border-dashed px-3 py-4 text-center text-[12px]"
|
||||
>
|
||||
{previewError}
|
||||
</div>
|
||||
{:else if imageUrl}
|
||||
<img
|
||||
alt={attachment.name}
|
||||
class="block rounded-md border border-primary-100 bg-white object-contain"
|
||||
class="border-primary-100 block rounded-md border bg-white object-contain"
|
||||
src={imageUrl}
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-3 py-4 text-center text-[12px] text-primary-800"
|
||||
class="border-primary-200 bg-primary-50 text-primary-800 flex items-center justify-center rounded-md border border-dashed px-3 py-4 text-center text-[12px]"
|
||||
>
|
||||
Vorschau wird erstellt
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<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';
|
||||
import {
|
||||
batchStatusClass,
|
||||
batchStatusLabel,
|
||||
downloadBatchZip,
|
||||
formatBytes,
|
||||
shouldWarnAboutBatchMemory,
|
||||
type BatchItem
|
||||
} from '$lib/batch';
|
||||
|
||||
type Props = {
|
||||
items: BatchItem[];
|
||||
@@ -22,68 +26,12 @@
|
||||
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 showMemoryWarning = $derived(shouldWarnAboutBatchMemory(items));
|
||||
|
||||
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';
|
||||
}
|
||||
};
|
||||
const downloadZip = () => downloadBatchZip(doneItems, zipBaseName);
|
||||
</script>
|
||||
|
||||
<div class="rounded-2xl border border-border bg-card p-4 space-y-3">
|
||||
<div class="space-y-3 rounded-2xl border border-border bg-card p-4">
|
||||
<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}
|
||||
@@ -123,18 +71,18 @@
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold {statusChipClass(
|
||||
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold {batchStatusClass(
|
||||
item.status
|
||||
)}"
|
||||
>
|
||||
{statusLabel(item.status)}
|
||||
{batchStatusLabel(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"
|
||||
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2"
|
||||
aria-label="{item.file.name} herunterladen"
|
||||
title="{item.file.name} herunterladen"
|
||||
onclick={() => onDownload(item)}
|
||||
@@ -145,7 +93,7 @@
|
||||
{#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"
|
||||
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2"
|
||||
aria-label="{item.file.name} erneut versuchen"
|
||||
title="{item.file.name} erneut versuchen"
|
||||
onclick={() => onRetry(item.id)}
|
||||
@@ -155,7 +103,7 @@
|
||||
{/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"
|
||||
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label="{item.file.name} entfernen"
|
||||
title="{item.file.name} entfernen"
|
||||
disabled={item.status === 'processing'}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
<script lang="ts">
|
||||
import { Download, Eye, GripVertical, RotateCcw, Trash2 } from '@lucide/svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import {
|
||||
batchStatusClass,
|
||||
batchStatusLabel,
|
||||
downloadBatchZip,
|
||||
formatBytes,
|
||||
shouldWarnAboutBatchMemory,
|
||||
type BatchItem
|
||||
} from '$lib/batch';
|
||||
|
||||
type Props = {
|
||||
items: BatchItem[];
|
||||
/** Resolved stamp label per row; frozen once the row was created. */
|
||||
labels: Record<string, string>;
|
||||
/** Renamed file per row, or `null` when renaming is switched off. */
|
||||
fileNames: Record<string, string | null>;
|
||||
selectedId: string | null;
|
||||
/** Base file name of the bulk ZIP, e.g. `anlagen` → `anlagen_2026-10-06.zip`. */
|
||||
zipBaseName: string;
|
||||
/** Reordering and preview selection are blocked while a run is active. */
|
||||
disabled?: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onMove: (from: number, to: number) => void;
|
||||
onDownload: (item: BatchItem) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onRetry: (id: string) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
items,
|
||||
labels,
|
||||
fileNames,
|
||||
selectedId,
|
||||
zipBaseName,
|
||||
disabled = false,
|
||||
onSelect,
|
||||
onMove,
|
||||
onDownload,
|
||||
onRemove,
|
||||
onRetry
|
||||
}: Props = $props();
|
||||
|
||||
let draggingId = $state<string | null>(null);
|
||||
let dragOverId = $state<string | null>(null);
|
||||
|
||||
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(shouldWarnAboutBatchMemory(items));
|
||||
|
||||
const downloadZip = () => downloadBatchZip(doneItems, zipBaseName);
|
||||
|
||||
const moveUp = (index: number) => {
|
||||
if (disabled || index === 0) return;
|
||||
onMove(index, index - 1);
|
||||
};
|
||||
|
||||
const moveDown = (index: number) => {
|
||||
if (disabled || index === items.length - 1) return;
|
||||
onMove(index, index + 1);
|
||||
};
|
||||
|
||||
const handleDragStart = (event: DragEvent, item: BatchItem) => {
|
||||
if (disabled) return;
|
||||
draggingId = item.id;
|
||||
event.dataTransfer?.setData('text/plain', item.id);
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent, item: BatchItem) => {
|
||||
if (disabled || !draggingId || draggingId === item.id) return;
|
||||
event.preventDefault();
|
||||
dragOverId = item.id;
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent, item: BatchItem) => {
|
||||
event.preventDefault();
|
||||
const sourceId = draggingId;
|
||||
draggingId = null;
|
||||
dragOverId = null;
|
||||
if (disabled || !sourceId || sourceId === item.id) return;
|
||||
|
||||
const from = items.findIndex((entry) => entry.id === sourceId);
|
||||
const to = items.findIndex((entry) => entry.id === item.id);
|
||||
if (from === -1 || to === -1) return;
|
||||
onMove(from, to);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
draggingId = null;
|
||||
dragOverId = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="space-y-3 rounded-2xl border border-border bg-card p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 class="text-lg font-semibold text-foreground">Anlagen ({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, index (item.id)}
|
||||
{@const label = labels[item.id] ?? ''}
|
||||
{@const renamed = fileNames[item.id] ?? null}
|
||||
<li
|
||||
class="flex flex-wrap items-center gap-3 py-3 {dragOverId === item.id
|
||||
? 'bg-accent/40'
|
||||
: ''} {draggingId === item.id ? 'opacity-60' : ''}"
|
||||
data-exhibit-item={item.file.name}
|
||||
data-exhibit-index={index + 1}
|
||||
data-exhibit-label={label}
|
||||
draggable={!disabled}
|
||||
ondragstart={(event) => handleDragStart(event, item)}
|
||||
ondragover={(event) => handleDragOver(event, item)}
|
||||
ondrop={(event) => handleDrop(event, item)}
|
||||
ondragend={handleDragEnd}
|
||||
>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<span
|
||||
class="cursor-grab p-1 text-muted-foreground {disabled ? 'opacity-40' : ''}"
|
||||
aria-hidden="true"
|
||||
title="Ziehen zum Verschieben"
|
||||
>
|
||||
<GripVertical class="h-4 w-4" />
|
||||
</span>
|
||||
<span
|
||||
class="flex h-6 w-6 items-center justify-center rounded-full bg-accent text-xs font-bold text-primary"
|
||||
title="Position in der Liste"
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<div class="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-0.5 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label="{item.file.name} nach oben verschieben"
|
||||
title="Nach oben"
|
||||
disabled={disabled || index === 0}
|
||||
onclick={() => moveUp(index)}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
aria-hidden="true"><path d="M6 15l6-6 6 6" /></svg
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-0.5 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label="{item.file.name} nach unten verschieben"
|
||||
title="Nach unten"
|
||||
disabled={disabled || index === items.length - 1}
|
||||
onclick={() => moveDown(index)}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-semibold text-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<p class="truncate text-xs text-primary">{item.file.name}</p>
|
||||
{#if renamed}
|
||||
<p class="truncate text-xs text-muted-foreground" data-exhibit-filename>
|
||||
→ {renamed}
|
||||
</p>
|
||||
{/if}
|
||||
{#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 {batchStatusClass(
|
||||
item.status
|
||||
)}"
|
||||
>
|
||||
{batchStatusLabel(item.status)}
|
||||
</span>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-40 aria-pressed:bg-accent"
|
||||
aria-label="Vorschau für {item.file.name}"
|
||||
title="Vorschau für {item.file.name}"
|
||||
aria-pressed={selectedId === item.id}
|
||||
{disabled}
|
||||
onclick={() => onSelect(item.id)}
|
||||
>
|
||||
<Eye class="h-4 w-4" />
|
||||
</button>
|
||||
{#if item.status === 'done' && item.resultBytes && item.resultName}
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2"
|
||||
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="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label="{item.file.name} erneut versuchen"
|
||||
title="{item.file.name} erneut versuchen"
|
||||
{disabled}
|
||||
onclick={() => onRetry(item.id)}
|
||||
>
|
||||
<RotateCcw class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 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,164 @@
|
||||
<script lang="ts">
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import { MIN_FONT_SIZE, PAGE_NUMBER_COLOR, PAGE_NUMBER_MARGIN } from '$lib/pdf-pagination';
|
||||
import { getPreviewPositionStyle, type OverlayPosition } from '$lib/pdf-overlay';
|
||||
import { getTextFitScale } from '$lib/pdf-preview-text';
|
||||
import { PDF_PREVIEW_SCALE, type PdfPageImage } from '$lib/pdf-thumbnails';
|
||||
|
||||
type Props = {
|
||||
/** Rendered page bitmap, or `null` while the first render is pending. */
|
||||
preview: PdfPageImage | null;
|
||||
/** Message shown instead of the bitmap when rendering failed. */
|
||||
error: string | null;
|
||||
/** Label the export will stamp on this page. */
|
||||
label: string;
|
||||
fontSize: number;
|
||||
position: OverlayPosition;
|
||||
currentPage: number;
|
||||
pageCount: number;
|
||||
onRetry: () => void;
|
||||
onShowPage: (pageNumber: number) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
preview,
|
||||
error,
|
||||
label,
|
||||
fontSize,
|
||||
position,
|
||||
currentPage,
|
||||
pageCount,
|
||||
onRetry,
|
||||
onShowPage
|
||||
}: Props = $props();
|
||||
|
||||
const pageSize = $derived(
|
||||
preview
|
||||
? { width: preview.width / PDF_PREVIEW_SCALE, height: preview.height / PDF_PREVIEW_SCALE }
|
||||
: null
|
||||
);
|
||||
|
||||
/**
|
||||
* The export shrinks a label that does not fit (down to `MIN_FONT_SIZE`), so
|
||||
* the preview applies the same factor instead of clipping.
|
||||
*/
|
||||
const fit = $derived.by(() => {
|
||||
if (!pageSize) return { scale: 1, shrunk: false, tooLong: false };
|
||||
|
||||
const raw = getTextFitScale(
|
||||
label,
|
||||
fontSize,
|
||||
pageSize.width - PAGE_NUMBER_MARGIN * 2,
|
||||
pageSize.height - PAGE_NUMBER_MARGIN * 2
|
||||
);
|
||||
const minScale = MIN_FONT_SIZE / fontSize;
|
||||
|
||||
return { scale: Math.max(raw, minScale), shrunk: raw < 0.999, tooLong: raw < minScale };
|
||||
});
|
||||
|
||||
const previewFontSize = $derived(
|
||||
preview ? (fontSize * fit.scale * PDF_PREVIEW_SCALE * 100) / preview.width : 0
|
||||
);
|
||||
const shrunkFontSize = $derived(Math.max(MIN_FONT_SIZE, Math.round(fontSize * fit.scale)));
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5"
|
||||
aria-labelledby="pagination-preview-title"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 id="pagination-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">Seite {currentPage} von {pageCount}</span>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 rounded-lg border border-border bg-muted/30 p-6 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<p class="text-sm text-foreground">{error}</p>
|
||||
<Button variant="tertiary" onclick={onRetry}>Erneut versuchen</Button>
|
||||
</div>
|
||||
{:else if preview}
|
||||
<div
|
||||
class="relative mx-auto w-full max-w-lg overflow-hidden rounded-lg border border-border bg-white shadow-sm"
|
||||
style="container-type: inline-size; aspect-ratio: {preview.width} / {preview.height};"
|
||||
>
|
||||
<img
|
||||
src={preview.imageUrl}
|
||||
alt="Vorschau von Seite {currentPage}"
|
||||
class="block h-full w-full"
|
||||
/>
|
||||
<div
|
||||
class="absolute max-w-[92%] leading-tight whitespace-nowrap"
|
||||
style="{getPreviewPositionStyle(
|
||||
position
|
||||
)} font-size: {previewFontSize}cqw; color: {PAGE_NUMBER_COLOR};"
|
||||
data-testid="pagination-preview"
|
||||
data-position={position}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex aspect-[1/1.414] items-center justify-center rounded-lg border border-border bg-muted/30 text-xs text-muted-foreground"
|
||||
>
|
||||
Vorschau wird geladen …
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if fit.tooLong}
|
||||
<p class="text-xs text-destructive" role="status">
|
||||
Der Text ist für diese Seite zu lang. Kürzen Sie den Zusatztext oder wählen Sie ein kürzeres
|
||||
Zahlenformat.
|
||||
</p>
|
||||
{:else if fit.shrunk}
|
||||
<p class="text-xs text-muted-foreground" role="status">
|
||||
Auf dieser Seite wird der Text auf {shrunkFontSize} pt verkleinert.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if pageCount > 1}
|
||||
<div class="flex flex-wrap items-center justify-center gap-3">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
class="min-w-0 px-3"
|
||||
onclick={() => onShowPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
aria-label="Vorherige Seite"
|
||||
>
|
||||
<ChevronLeft aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
class="min-w-0 px-3"
|
||||
onclick={() => onShowPage(currentPage + 1)}
|
||||
disabled={currentPage === pageCount}
|
||||
aria-label="Nächste Seite"
|
||||
>
|
||||
<ChevronRight aria-hidden="true" />
|
||||
</Button>
|
||||
<label class="flex items-center gap-2 text-sm text-foreground" for="pagination-page">
|
||||
<span class="text-xs text-muted-foreground">Seite</span>
|
||||
<input
|
||||
id="pagination-page"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
step="1"
|
||||
min="1"
|
||||
max={pageCount}
|
||||
value={currentPage}
|
||||
oninput={(event) => onShowPage(Number(event.currentTarget.value))}
|
||||
onblur={(event) => (event.currentTarget.value = String(currentPage))}
|
||||
aria-label="Springe zu Seite"
|
||||
class="w-20 rounded-lg border border-input bg-background px-2 py-1 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -3,6 +3,7 @@
|
||||
import { X } from '@lucide/svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
||||
import { pdfLoadErrorMessage, pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
|
||||
import {
|
||||
createAttachmentFromPages,
|
||||
@@ -172,7 +173,7 @@
|
||||
onApply(await buildResult(groups));
|
||||
} catch (error) {
|
||||
console.error(`Failed to split PDF attachment ${source.path}`, error);
|
||||
splitError = 'Das PDF konnte nicht geteilt werden.';
|
||||
splitError = pdfProcessingErrorMessage(error, 'Das PDF konnte nicht geteilt werden.');
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
@@ -209,7 +210,7 @@
|
||||
console.error(`Failed to load PDF attachment ${source.path}`, error);
|
||||
|
||||
if (!cancelled) {
|
||||
loadError = 'PDF konnte nicht geladen werden.';
|
||||
loadError = pdfLoadErrorMessage(error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -237,16 +238,16 @@
|
||||
data-split-dialog
|
||||
tabindex="-1"
|
||||
>
|
||||
<header class="flex items-center justify-between gap-3 border-b border-primary-100 px-5 py-4">
|
||||
<header class="border-primary-100 flex items-center justify-between gap-3 border-b px-5 py-4">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-[16px] font-bold text-primary-900">PDF teilen</h2>
|
||||
<p class="truncate text-[13px] text-primary-700" title={source.name}>
|
||||
<h2 class="text-primary-900 text-[16px] font-bold">PDF teilen</h2>
|
||||
<p class="text-primary-700 truncate text-[13px]" title={source.name}>
|
||||
{source.name} — {pageCount} Seite{pageCount === 1 ? '' : 'n'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border border-primary-200 bg-white text-primary-700 transition hover:border-primary-400 hover:bg-primary-50"
|
||||
class="border-primary-200 text-primary-700 hover:border-primary-400 hover:bg-primary-50 flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border bg-white transition"
|
||||
aria-label="Teilen abbrechen"
|
||||
onclick={onClose}
|
||||
>
|
||||
@@ -256,7 +257,7 @@
|
||||
|
||||
<div class="flex-1 space-y-4 overflow-y-auto px-5 py-4">
|
||||
<div
|
||||
class="inline-flex flex-wrap items-center rounded-[16px] border border-primary-200 bg-white p-0.5"
|
||||
class="border-primary-200 inline-flex flex-wrap items-center rounded-[16px] border bg-white p-0.5"
|
||||
role="radiogroup"
|
||||
aria-label="Art des Teilens"
|
||||
>
|
||||
@@ -283,10 +284,10 @@
|
||||
</div>
|
||||
|
||||
{#if mode === 'everyN'}
|
||||
<label class="flex flex-wrap items-center gap-2 text-[13px] font-semibold text-primary-900">
|
||||
<label class="text-primary-900 flex flex-wrap items-center gap-2 text-[13px] font-semibold">
|
||||
Seiten pro Teildokument
|
||||
<input
|
||||
class="w-20 rounded-md border border-primary-200 bg-white px-2 py-1 text-[13px] font-medium text-primary-900 outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-200"
|
||||
class="border-primary-200 text-primary-900 focus:border-primary-400 focus:ring-primary-200 w-20 rounded-md border bg-white px-2 py-1 text-[13px] font-medium transition outline-none focus:ring-2"
|
||||
type="number"
|
||||
min={2}
|
||||
value={chunkSize}
|
||||
@@ -298,7 +299,7 @@
|
||||
{/if}
|
||||
|
||||
{#if mode !== 'manual' && pageCount > 0}
|
||||
<p class="text-[13px] font-medium text-primary-700" aria-live="polite">
|
||||
<p class="text-primary-700 text-[13px] font-medium" aria-live="polite">
|
||||
Es werden {bulkChunks.length}
|
||||
Teildokument{bulkChunks.length === 1 ? '' : 'e'} erstellt.
|
||||
</p>
|
||||
@@ -329,7 +330,7 @@
|
||||
</PdfPageGrid>
|
||||
{:else}
|
||||
<div
|
||||
class="flex items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-4 py-10 text-[13px] text-primary-700"
|
||||
class="border-primary-200 bg-primary-50 text-primary-700 flex items-center justify-center rounded-md border border-dashed px-4 py-10 text-[13px]"
|
||||
>
|
||||
Vorschau wird erstellt
|
||||
</div>
|
||||
@@ -340,7 +341,7 @@
|
||||
<label class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="sr-only">Name des Teildokuments</span>
|
||||
<input
|
||||
class="w-full min-w-40 rounded-md border border-primary-200 bg-white px-3 py-1.5 text-[13px] font-semibold text-primary-900 outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-200"
|
||||
class="border-primary-200 text-primary-900 focus:border-primary-400 focus:ring-primary-200 w-full min-w-40 rounded-md border bg-white px-3 py-1.5 text-[13px] font-semibold transition outline-none focus:ring-2"
|
||||
type="text"
|
||||
placeholder={selectedPages.size > 0
|
||||
? defaultGroupName([...selectedPages])
|
||||
@@ -359,7 +360,7 @@
|
||||
Als Teildokument übernehmen
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-[13px] text-primary-700" aria-live="polite">
|
||||
<p class="text-primary-700 text-[13px]" aria-live="polite">
|
||||
{selectedPages.size} von {pageCount} Seiten ausgewählt
|
||||
</p>
|
||||
{/if}
|
||||
@@ -368,17 +369,17 @@
|
||||
<ul class="flex flex-col gap-2">
|
||||
{#each assignedGroups as group, index (index)}
|
||||
<li
|
||||
class="flex flex-wrap items-center gap-2 rounded-xl border border-primary-200 bg-primary-50 px-3 py-1.5"
|
||||
class="border-primary-200 bg-primary-50 flex flex-wrap items-center gap-2 rounded-xl border px-3 py-1.5"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate text-[13px] font-semibold text-primary-900">
|
||||
<span class="text-primary-900 min-w-0 flex-1 truncate text-[13px] font-semibold">
|
||||
{group.name}
|
||||
</span>
|
||||
<span class="text-[12px] font-medium text-primary-700">
|
||||
<span class="text-primary-700 text-[12px] font-medium">
|
||||
{describePages(group.pages)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-primary-600 transition hover:bg-white hover:text-red-700"
|
||||
class="text-primary-600 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full transition hover:bg-white hover:text-red-700"
|
||||
aria-label={`Zuordnung „${group.name}“ aufheben`}
|
||||
onclick={() => removeGroup(index)}
|
||||
>
|
||||
@@ -395,9 +396,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<label class="flex items-center gap-2 text-[13px] font-medium text-primary-900">
|
||||
<label class="text-primary-900 flex items-center gap-2 text-[13px] font-medium">
|
||||
<input
|
||||
class="h-4 w-4 accent-primary-700"
|
||||
class="accent-primary-700 h-4 w-4"
|
||||
type="checkbox"
|
||||
checked={keepSource}
|
||||
onchange={(event) => {
|
||||
@@ -409,7 +410,7 @@
|
||||
</div>
|
||||
|
||||
<footer
|
||||
class="flex flex-wrap items-center justify-end gap-2 border-t border-primary-100 px-5 py-4"
|
||||
class="border-primary-100 flex flex-wrap items-center justify-end gap-2 border-t px-5 py-4"
|
||||
>
|
||||
<Button type="button" variant="bordered" disabled={isProcessing} onclick={onClose}>
|
||||
Abbrechen
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||
import PdfSplitDialog from '$lib/components/PdfSplitDialog.svelte';
|
||||
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||
import SubDocumentEditor from '$lib/components/SubDocumentEditor.svelte';
|
||||
import {
|
||||
applyPdfSplit,
|
||||
@@ -447,7 +448,7 @@
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to export archive ${archive.name}`, error);
|
||||
mergeError = 'PDF konnte nicht erstellt werden.';
|
||||
mergeError = pdfProcessingErrorMessage(error, 'PDF konnte nicht erstellt werden.');
|
||||
} finally {
|
||||
isMerging = false;
|
||||
}
|
||||
@@ -470,7 +471,10 @@
|
||||
`Failed to export sub-document "${subDocument.name}" from archive ${archive.name}`,
|
||||
error
|
||||
);
|
||||
mergeError = `Teildokument „${subDocument.name}“ konnte nicht als PDF erstellt werden.`;
|
||||
mergeError = pdfProcessingErrorMessage(
|
||||
error,
|
||||
`Teildokument „${subDocument.name}“ konnte nicht als PDF erstellt werden.`
|
||||
);
|
||||
} finally {
|
||||
downloadingSubDocumentId = null;
|
||||
}
|
||||
@@ -480,14 +484,14 @@
|
||||
<svelte:window onkeydown={handleWindowKeydown} />
|
||||
|
||||
<section
|
||||
class="flex flex-col gap-4 rounded-2xl border border-primary-100 bg-white px-4 py-4 shadow-sm"
|
||||
class="border-primary-100 flex flex-col gap-4 rounded-2xl border bg-white px-4 py-4 shadow-sm"
|
||||
>
|
||||
<header class="flex flex-wrap items-baseline justify-between gap-2 text-primary">
|
||||
<div class="min-w-0 flex-1">
|
||||
<label class="block min-w-0">
|
||||
<span class="sr-only">Archivname</span>
|
||||
<input
|
||||
class="w-full max-w-xl rounded-md border border-primary-200 bg-white px-3 py-2 text-[16px] font-bold text-primary-900 outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-200"
|
||||
class="border-primary-200 text-primary-900 focus:border-primary-400 focus:ring-primary-200 w-full max-w-xl rounded-md border bg-white px-3 py-2 text-[16px] font-bold transition outline-none focus:ring-2"
|
||||
type="text"
|
||||
value={archiveName}
|
||||
draggable="false"
|
||||
@@ -532,7 +536,7 @@
|
||||
{#if hasSubDocuments}
|
||||
<div
|
||||
class={cn(
|
||||
'inline-flex items-center rounded-[16px] border border-primary-200 bg-white p-0.5',
|
||||
'border-primary-200 inline-flex items-center rounded-[16px] border bg-white p-0.5',
|
||||
downloadingSubDocumentId !== null ? 'opacity-60' : ''
|
||||
)}
|
||||
role="radiogroup"
|
||||
@@ -584,7 +588,7 @@
|
||||
|
||||
{#if groupingMode}
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-3 rounded-xl border border-primary-200 bg-primary-50 px-4 py-2 text-[13px] font-medium text-primary-900"
|
||||
class="border-primary-200 bg-primary-50 text-primary-900 flex flex-wrap items-center gap-3 rounded-xl border px-4 py-2 text-[13px] font-medium"
|
||||
>
|
||||
<span aria-live="polite">
|
||||
{selectedCount} Datei{selectedCount === 1 ? '' : 'en'} ausgewählt
|
||||
@@ -634,10 +638,10 @@
|
||||
{#each looseAttachments as attachment, index (attachment.path)}
|
||||
<li
|
||||
class={cn(
|
||||
'flex flex-col shrink-0 items-stretch gap-2 rounded-xl border bg-accent p-2 text-primary transition',
|
||||
dropIndex === index ? 'border-primary-400 ring-1 ring-primary-200' : 'border-primary-100',
|
||||
'flex shrink-0 flex-col items-stretch gap-2 rounded-xl border bg-accent p-2 text-primary transition',
|
||||
dropIndex === index ? 'border-primary-400 ring-primary-200 ring-1' : 'border-primary-100',
|
||||
dragIndex === index ? 'opacity-60' : '',
|
||||
selectedPaths.has(attachment.path) ? 'border-primary-500 ring-2 ring-primary-300' : ''
|
||||
selectedPaths.has(attachment.path) ? 'border-primary-500 ring-primary-300 ring-2' : ''
|
||||
)}
|
||||
style={`width: ${thumbnailWidth}px;`}
|
||||
draggable={groupingMode ? 'false' : 'true'}
|
||||
@@ -648,9 +652,9 @@
|
||||
>
|
||||
{#if groupingMode}
|
||||
<label class="flex cursor-pointer flex-col gap-2">
|
||||
<div class="flex items-center gap-2 px-1 text-[13px] font-medium text-primary-900">
|
||||
<div class="text-primary-900 flex items-center gap-2 px-1 text-[13px] font-medium">
|
||||
<input
|
||||
class="h-4 w-4 accent-primary-700"
|
||||
class="accent-primary-700 h-4 w-4"
|
||||
type="checkbox"
|
||||
checked={selectedPaths.has(attachment.path)}
|
||||
onchange={() => toggleSelection(attachment.path)}
|
||||
@@ -661,7 +665,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="truncate text-[13px] font-medium leading-tight text-primary-900"
|
||||
class="text-primary-900 truncate text-[13px] leading-tight font-medium"
|
||||
title={attachment.name}
|
||||
>
|
||||
{attachment.name}
|
||||
@@ -671,7 +675,7 @@
|
||||
{:else}
|
||||
<div class="flex min-w-0 flex-1 flex-row items-center gap-1 py-1">
|
||||
<button
|
||||
class="w-fit cursor-grab rounded-md border border-primary-200 bg-white px-2 py-1 text-sm font-semibold leading-none text-primary-700 active:cursor-grabbing"
|
||||
class="border-primary-200 text-primary-700 w-fit cursor-grab rounded-md border bg-white px-2 py-1 text-sm leading-none font-semibold active:cursor-grabbing"
|
||||
draggable="false"
|
||||
type="button"
|
||||
aria-label="Datei verschieben"
|
||||
@@ -680,7 +684,7 @@
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="min-w-0 flex-1 truncate text-[13px] font-medium leading-tight text-primary-900"
|
||||
class="text-primary-900 min-w-0 flex-1 truncate text-[13px] leading-tight font-medium"
|
||||
title={attachment.name}
|
||||
>
|
||||
{attachment.name}
|
||||
@@ -703,7 +707,7 @@
|
||||
|
||||
{#if looseAttachments.length === 0 && subDocuments.length === 0}
|
||||
<div
|
||||
class="rounded-md border border-dashed border-primary-200 bg-primary-50 px-4 py-4 text-[13px] text-primary-700"
|
||||
class="border-primary-200 bg-primary-50 text-primary-700 rounded-md border border-dashed px-4 py-4 text-[13px]"
|
||||
>
|
||||
Keine Dateien in diesem Archiv. Lade ein anderes ZIP, um weiterzuarbeiten.
|
||||
</div>
|
||||
@@ -758,7 +762,7 @@
|
||||
}}
|
||||
>
|
||||
<AlertDialog.Content class="max-w-sm">
|
||||
<AlertDialog.Title class="text-base font-bold text-primary-900">
|
||||
<AlertDialog.Title class="text-primary-900 text-base font-bold">
|
||||
Datei entfernen?
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
import ToolError from '$lib/components/ToolError.svelte';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description: string;
|
||||
file: File | null;
|
||||
/** `0` while no PDF is loaded; switches the shell to the bare dropzone. */
|
||||
pageCount: number;
|
||||
loadingError: string | null;
|
||||
error?: string | null;
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
/** Extra toolbar buttons next to the file name. */
|
||||
actions?: Snippet;
|
||||
/** Main content, e.g. the page grid. */
|
||||
children: Snippet;
|
||||
/** Status line plus primary action, laid out `space-between`. */
|
||||
footer: Snippet;
|
||||
};
|
||||
|
||||
let {
|
||||
title,
|
||||
description,
|
||||
file,
|
||||
pageCount,
|
||||
loadingError,
|
||||
error,
|
||||
onFilesSelected,
|
||||
actions,
|
||||
children,
|
||||
footer
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h1 class="text-primary-900 text-2xl font-bold">{title}</h1>
|
||||
<p class="text-primary-700 mt-2 text-sm">{description}</p>
|
||||
</div>
|
||||
|
||||
{#if pageCount === 0}
|
||||
<FileDropzone {onFilesSelected} />
|
||||
<ToolError message={loadingError} class="mt-4" />
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<FileDropzone
|
||||
{onFilesSelected}
|
||||
label="Anderes PDF ablegen"
|
||||
sublabel={file?.name ?? ''}
|
||||
class="py-4"
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 class="text-primary-900 text-lg font-semibold">
|
||||
{file?.name} — {pageCount} Seite{pageCount === 1 ? '' : 'n'}
|
||||
</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{@render actions?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{@render children()}
|
||||
|
||||
<ToolError message={error} />
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
{@render footer()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
type Props = {
|
||||
/** German message; nothing is rendered when the value is empty. */
|
||||
message?: string | null;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let { message, class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if message}
|
||||
<div
|
||||
class={cn('rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800', className)}
|
||||
role="alert"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
{/if}
|
||||
+20
-14
@@ -1,19 +1,12 @@
|
||||
export const downloadBlob = (
|
||||
bytes: Uint8Array,
|
||||
fileName: string,
|
||||
mimeType: string = 'application/pdf'
|
||||
) => {
|
||||
/**
|
||||
* Copies PDF bytes into a tightly sized buffer before wrapping them in a Blob.
|
||||
* pdf-lib returns a `Uint8Array` that may be a view into a larger buffer, so the
|
||||
* copy is required — one place decides this instead of every tool doing it.
|
||||
*/
|
||||
export const pdfBytesToBlob = (bytes: Uint8Array, mimeType: string = 'application/pdf'): Blob => {
|
||||
const buffer = new Uint8Array(bytes.byteLength);
|
||||
buffer.set(bytes);
|
||||
const blob = new Blob([buffer.buffer as ArrayBuffer], { type: mimeType });
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const downloadLink = document.createElement('a');
|
||||
|
||||
downloadLink.href = objectUrl;
|
||||
downloadLink.download = fileName;
|
||||
downloadLink.rel = 'noopener';
|
||||
downloadLink.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||
return new Blob([buffer.buffer as ArrayBuffer], { type: mimeType });
|
||||
};
|
||||
|
||||
export const downloadBlobUrl = (blobUrl: string, fileName: string) => {
|
||||
@@ -23,3 +16,16 @@ export const downloadBlobUrl = (blobUrl: string, fileName: string) => {
|
||||
downloadLink.rel = 'noopener';
|
||||
downloadLink.click();
|
||||
};
|
||||
|
||||
/** Triggers a browser download for an existing Blob. */
|
||||
export const downloadBlobFile = (blob: Blob, fileName: string) => {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
downloadBlobUrl(objectUrl, fileName);
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||
};
|
||||
|
||||
export const downloadBlob = (
|
||||
bytes: Uint8Array,
|
||||
fileName: string,
|
||||
mimeType: string = 'application/pdf'
|
||||
) => downloadBlobFile(pdfBytesToBlob(bytes, mimeType), fileName);
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Naming helpers for the exhibit numbering tool. Kept pure so the label the
|
||||
* stamp draws and the file name the download gets can be tested without pdf-lib.
|
||||
*/
|
||||
|
||||
/** Longest allowed exhibit designation (matches the input's `maxlength`). */
|
||||
export const MAX_EXHIBIT_PREFIX_LENGTH = 40;
|
||||
|
||||
/** Designation used when the user leaves the field empty. */
|
||||
export const DEFAULT_EXHIBIT_PREFIX = 'Anlage';
|
||||
|
||||
/** Characters that must never reach a file name on Windows, macOS or Linux. */
|
||||
const UNSAFE_FILE_NAME_CHARS = /[/\\:*?"<>|\u0000-\u001f]/g;
|
||||
|
||||
export const MIN_EXHIBIT_START_NUMBER = 0;
|
||||
export const MAX_EXHIBIT_START_NUMBER = 99999;
|
||||
export const DEFAULT_EXHIBIT_START_NUMBER = 1;
|
||||
|
||||
/**
|
||||
* Clamps a typed start number. Empty or non-numeric input (a field being
|
||||
* cleared) falls back to the default instead of producing `NaN` labels.
|
||||
*/
|
||||
export const normalizeExhibitStartNumber = (value: number | string | null | undefined): number => {
|
||||
const parsed = Math.round(Number(value));
|
||||
if (value === null || value === '' || !Number.isFinite(parsed)) {
|
||||
return DEFAULT_EXHIBIT_START_NUMBER;
|
||||
}
|
||||
|
||||
return Math.min(MAX_EXHIBIT_START_NUMBER, Math.max(MIN_EXHIBIT_START_NUMBER, parsed));
|
||||
};
|
||||
|
||||
/** Trims and caps the designation; an empty result means "not filled in yet". */
|
||||
export const normalizeExhibitPrefix = (prefix: string): string =>
|
||||
prefix.trim().slice(0, MAX_EXHIBIT_PREFIX_LENGTH);
|
||||
|
||||
/**
|
||||
* Stamp text: designation plus number with exactly one space and no leading
|
||||
* zeros — `Anlage K 6`, never `Anlage K 06`.
|
||||
*/
|
||||
export const buildExhibitLabel = (prefix: string, number: number): string =>
|
||||
`${normalizeExhibitPrefix(prefix)} ${number}`;
|
||||
|
||||
/** File-name form of the designation: `Anlage K` → `Anlage_K`. */
|
||||
export const buildExhibitPrefixSlug = (prefix: string): string =>
|
||||
normalizeExhibitPrefix(prefix).replace(/\s+/g, '_').replace(UNSAFE_FILE_NAME_CHARS, '') ||
|
||||
DEFAULT_EXHIBIT_PREFIX;
|
||||
|
||||
/**
|
||||
* Zero-padding width: at least two digits, more when the highest assigned number
|
||||
* needs them (1…99 → `06`, 1…100 → `006`).
|
||||
*/
|
||||
export const getExhibitNumberWidth = (highestNumber: number): number => {
|
||||
const digits = Number.isFinite(highestNumber) ? String(Math.abs(highestNumber)).length : 1;
|
||||
return Math.max(2, digits);
|
||||
};
|
||||
|
||||
/**
|
||||
* `{prefixSlug}_{paddedNumber}_{originalBase}.pdf` — the optional renaming
|
||||
* described in the plan, e.g. `Anlage_K_06_Klageschrift.pdf`.
|
||||
*/
|
||||
export const buildExhibitFileName = (options: {
|
||||
prefix: string;
|
||||
number: number;
|
||||
highestNumber: number;
|
||||
originalName: string;
|
||||
}): string => {
|
||||
const width = getExhibitNumberWidth(options.highestNumber);
|
||||
const paddedNumber = String(options.number).padStart(width, '0');
|
||||
const originalBase = options.originalName.replace(/\.pdf$/i, '');
|
||||
const originalSlug = originalBase.replace(UNSAFE_FILE_NAME_CHARS, '') || 'Dokument';
|
||||
|
||||
return `${buildExhibitPrefixSlug(options.prefix)}_${paddedNumber}_${originalSlug}.pdf`;
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
export type LatestToken = {
|
||||
/** Starts a new operation and invalidates every earlier ticket. */
|
||||
begin: () => number;
|
||||
/** True while `ticket` is still the most recent `begin()`. */
|
||||
isCurrent: (ticket: number) => boolean;
|
||||
/** Invalidates the current ticket without starting a new operation. */
|
||||
invalidate: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Guards async work so that only the most recent call may publish its result.
|
||||
* Replaces the hand-rolled `generation`/`token` counters that every PDF route
|
||||
* used to carry (stamp, pagination, page previews); late callers check
|
||||
* `isCurrent` and clean up their own resources instead of writing stale state.
|
||||
*/
|
||||
export const createLatestToken = (): LatestToken => {
|
||||
let current = 0;
|
||||
|
||||
return {
|
||||
begin: () => (current += 1),
|
||||
isCurrent: (ticket) => ticket === current,
|
||||
invalidate: () => {
|
||||
current += 1;
|
||||
}
|
||||
};
|
||||
};
|
||||
+49
-9
@@ -1,5 +1,22 @@
|
||||
const ENCRYPTED_PDF_PATTERN = /password|encrypted|encryption|verschl/i;
|
||||
|
||||
/**
|
||||
* Thrown by the overlay helpers for problems the user can fix (text too long,
|
||||
* characters the standard font cannot encode). Callers render `message`
|
||||
* directly instead of guessing from error text.
|
||||
*
|
||||
* Lives here rather than in `pdf-text-overlay.ts` so that tools which only need
|
||||
* the error mapping do not pull pdf-lib into their bundle.
|
||||
*/
|
||||
export class TextOverlayError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'TextOverlayError';
|
||||
}
|
||||
}
|
||||
|
||||
export type PdfErrorKind = 'encrypted' | 'text-overlay' | 'unknown';
|
||||
|
||||
/**
|
||||
* pdf-lib (`EncryptedPDFError`) and pdfjs (`PasswordException`) both surface
|
||||
* password protection through the error name/message. Tools use this to show a
|
||||
@@ -10,17 +27,40 @@ export const isEncryptedPdfError = (error: unknown): boolean => {
|
||||
return ENCRYPTED_PDF_PATTERN.test(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Classifies a PDF failure so routes can pick copy without matching on
|
||||
* pdf-lib/pdfjs message text themselves.
|
||||
*/
|
||||
export const classifyPdfError = (error: unknown): PdfErrorKind => {
|
||||
if (error instanceof TextOverlayError) return 'text-overlay';
|
||||
if (isEncryptedPdfError(error)) return 'encrypted';
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
export const ENCRYPTED_PDF_MESSAGE =
|
||||
'Dieses PDF ist passwortgeschützt. Entfernen Sie zuerst unter „Passwort entfernen“ den Schutz.';
|
||||
|
||||
export const pdfLoadErrorMessage = (error: unknown): string =>
|
||||
isEncryptedPdfError(error)
|
||||
? ENCRYPTED_PDF_MESSAGE
|
||||
: 'PDF konnte nicht geladen werden. Prüfen Sie, ob die Datei gültig ist.';
|
||||
const GENERIC_LOAD_MESSAGE =
|
||||
'PDF konnte nicht geladen werden. Prüfen Sie, ob die Datei gültig ist.';
|
||||
|
||||
/**
|
||||
* User-facing message for a failed PDF operation. Encryption and overlay errors
|
||||
* keep their specific German copy; everything else falls back to `fallback`.
|
||||
*/
|
||||
export const pdfErrorMessage = (error: unknown, fallback: string): string => {
|
||||
switch (classifyPdfError(error)) {
|
||||
case 'encrypted':
|
||||
return ENCRYPTED_PDF_MESSAGE;
|
||||
case 'text-overlay':
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
export const pdfLoadErrorMessage = (error: unknown, fallback: string = GENERIC_LOAD_MESSAGE) =>
|
||||
pdfErrorMessage(error, fallback);
|
||||
|
||||
/** Shared row/banner message for PDFs that could not be transformed. */
|
||||
export const pdfProcessingErrorMessage = (error: unknown, fallback: string): string => {
|
||||
if (isEncryptedPdfError(error)) return ENCRYPTED_PDF_MESSAGE;
|
||||
if (error instanceof Error && /^Der (Stempel)?text/.test(error.message)) return error.message;
|
||||
return fallback;
|
||||
};
|
||||
export const pdfProcessingErrorMessage = (error: unknown, fallback: string) =>
|
||||
pdfErrorMessage(error, fallback);
|
||||
+36
-22
@@ -11,13 +11,27 @@ export type OverlayPosition =
|
||||
|
||||
export type WatermarkPosition = OverlayPosition | 'diagonal';
|
||||
|
||||
/** Position choices shared by every tool that draws an overlay. */
|
||||
export const OVERLAY_POSITION_OPTIONS: { value: OverlayPosition; label: string }[] = [
|
||||
{ value: 'top-left', label: 'Oben links' },
|
||||
{ value: 'top-center', label: 'Oben mittig' },
|
||||
{ value: 'top-right', label: 'Oben rechts' },
|
||||
{ value: 'center', label: 'Zentriert' },
|
||||
{ value: 'bottom-left', label: 'Unten links' },
|
||||
{ value: 'bottom-center', label: 'Unten mittig' },
|
||||
{ value: 'bottom-right', label: 'Unten rechts' }
|
||||
];
|
||||
|
||||
/** Distance between a mark and the page edge used by the drawing helpers. */
|
||||
export const DEFAULT_OVERLAY_MARGIN = 20;
|
||||
|
||||
export const getPositionCoords = (
|
||||
pageWidth: number,
|
||||
pageHeight: number,
|
||||
markWidth: number,
|
||||
markHeight: number,
|
||||
position: WatermarkPosition,
|
||||
margin = 20
|
||||
margin = DEFAULT_OVERLAY_MARGIN
|
||||
): { x: number; y: number } => {
|
||||
const horizontallyCentered = (pageWidth - markWidth) / 2;
|
||||
|
||||
@@ -51,25 +65,25 @@ export const parseHexColor = (hexColor: string): RGB => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* CSS for the absolutely positioned overlay used by browser previews. The 5%
|
||||
* inset approximates the PDF margin at reading size.
|
||||
*/
|
||||
export const getPreviewPositionStyle = (position: OverlayPosition): string => {
|
||||
switch (position) {
|
||||
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%;';
|
||||
}
|
||||
/** Inset used by the HTML previews to approximate the PDF margin at reading size. */
|
||||
export const PREVIEW_INSET = '5%';
|
||||
|
||||
const PREVIEW_POSITION_STYLES: Record<OverlayPosition, Record<string, string>> = {
|
||||
center: { left: '50%', top: '50%', transform: 'translate(-50%, -50%)' },
|
||||
'top-left': { left: PREVIEW_INSET, top: PREVIEW_INSET },
|
||||
'top-center': { left: '50%', top: PREVIEW_INSET, transform: 'translateX(-50%)' },
|
||||
'top-right': { right: PREVIEW_INSET, top: PREVIEW_INSET },
|
||||
'bottom-left': { bottom: PREVIEW_INSET, left: PREVIEW_INSET },
|
||||
'bottom-center': { bottom: PREVIEW_INSET, left: '50%', transform: 'translateX(-50%)' },
|
||||
'bottom-right': { bottom: PREVIEW_INSET, right: PREVIEW_INSET }
|
||||
};
|
||||
|
||||
/** Declarative form, handy for `style:` directives or spreading into a style map. */
|
||||
export const getPreviewPositionStyles = (position: OverlayPosition): Record<string, string> =>
|
||||
PREVIEW_POSITION_STYLES[position];
|
||||
|
||||
/** Same styles as a single inline CSS string, for `style="…"` attributes. */
|
||||
export const getPreviewPositionStyle = (position: OverlayPosition): string =>
|
||||
Object.entries(getPreviewPositionStyles(position))
|
||||
.map(([property, value]) => `${property}: ${value};`)
|
||||
.join(' ');
|
||||
+46
-16
@@ -8,15 +8,26 @@ export const PAGE_NUMBER_MARGIN = 28;
|
||||
export const PAGE_NUMBER_COLOR = '#1a1a1a';
|
||||
export const MIN_FONT_SIZE = 8;
|
||||
export const MAX_FONT_SIZE = 18;
|
||||
/** Longest allowed free text (Aktenzeichen) next to the sheet number. */
|
||||
export const MAX_FREE_TEXT_LENGTH = 60;
|
||||
|
||||
export const PAGINATION_FORMATS = [
|
||||
{ value: 'blatt', label: 'Bl. {n}', prefix: 'Bl. ' },
|
||||
{ value: 'seite', label: 'Seite {n}', prefix: 'Seite ' },
|
||||
{ value: 's', label: 'S. {n}', prefix: 'S. ' },
|
||||
{ value: 'nummer', label: 'Nur Nummer', prefix: '' }
|
||||
] as const;
|
||||
const PAGINATION_PREFIXES = {
|
||||
blatt: 'Bl. ',
|
||||
seite: 'Seite ',
|
||||
s: 'S. ',
|
||||
nummer: ''
|
||||
} as const;
|
||||
|
||||
export type PaginationFormat = keyof typeof PAGINATION_PREFIXES;
|
||||
|
||||
/**
|
||||
* Select options. The label is the rendered first sheet number (`Bl. 1`), so the
|
||||
* UI cannot drift from the PDF text — there is no template syntax involved.
|
||||
*/
|
||||
export const PAGINATION_FORMATS: { value: PaginationFormat; label: string }[] = (
|
||||
Object.keys(PAGINATION_PREFIXES) as PaginationFormat[]
|
||||
).map((value) => ({ value, label: `${PAGINATION_PREFIXES[value]}1`.trim() }));
|
||||
|
||||
export type PaginationFormat = (typeof PAGINATION_FORMATS)[number]['value'];
|
||||
export type PaginationTextScope = 'all' | 'first';
|
||||
|
||||
export type PaginationOptions = {
|
||||
@@ -28,24 +39,33 @@ export type PaginationOptions = {
|
||||
fontSize: number;
|
||||
};
|
||||
|
||||
type PaginationLabelOptions = Pick<
|
||||
PaginationOptions,
|
||||
'startNumber' | 'freeText' | 'textScope' | 'format'
|
||||
>;
|
||||
|
||||
export const normalizeStartNumber = (value: number): number =>
|
||||
Math.min(
|
||||
MAX_START_NUMBER,
|
||||
Math.max(MIN_START_NUMBER, Math.round(Number(value) || MIN_START_NUMBER))
|
||||
);
|
||||
|
||||
const getFormatPrefix = (format: PaginationFormat): string =>
|
||||
(PAGINATION_FORMATS.find((entry) => entry.value === format) ?? PAGINATION_FORMATS[0]).prefix;
|
||||
/** Trims the free text and enforces `MAX_FREE_TEXT_LENGTH` for programmatic callers. */
|
||||
export const normalizeFreeText = (value: string): string =>
|
||||
value.trim().slice(0, MAX_FREE_TEXT_LENGTH);
|
||||
|
||||
/**
|
||||
* Sheet label for one page: `123 Ja 60/26, Bl. 27`. The number always appears;
|
||||
* the free text only on the first page when that scope is selected.
|
||||
* Sheet label for one page: `123 Ja 60/26, Bl. 27`. The number always appears —
|
||||
* it is not user-removable — while the free text only appears on the first page
|
||||
* when that scope is selected.
|
||||
*
|
||||
* Pure formatter: `startNumber` must already be normalised (see `paginatePdf`).
|
||||
*/
|
||||
export const buildPaginationLabel = (
|
||||
pageIndex: number,
|
||||
options: Pick<PaginationOptions, 'startNumber' | 'freeText' | 'textScope' | 'format'>
|
||||
options: PaginationLabelOptions
|
||||
): string => {
|
||||
const numberText = `${getFormatPrefix(options.format)}${normalizeStartNumber(options.startNumber) + pageIndex}`;
|
||||
const numberText = `${PAGINATION_PREFIXES[options.format]}${options.startNumber + pageIndex}`;
|
||||
const freeText = options.freeText.trim();
|
||||
const showsFreeText = options.textScope === 'all' || pageIndex === 0;
|
||||
|
||||
@@ -55,24 +75,34 @@ export const buildPaginationLabel = (
|
||||
/**
|
||||
* Stamps every page with its ascending sheet number. Rotated scans are
|
||||
* compensated so the number stays at the visual position.
|
||||
*
|
||||
* `MIN_FONT_SIZE` is the user's chosen floor: a label that does not fit even at
|
||||
* that size raises a `TextOverlayError` instead of shrinking into illegibility.
|
||||
*/
|
||||
export const paginatePdf = async (
|
||||
bytes: Uint8Array,
|
||||
options: PaginationOptions
|
||||
): Promise<Uint8Array> => {
|
||||
const document = await PDFDocument.load(new Uint8Array(bytes));
|
||||
const document = await PDFDocument.load(bytes);
|
||||
const font = await document.embedFont(StandardFonts.Helvetica);
|
||||
const color = parseHexColor(PAGE_NUMBER_COLOR);
|
||||
// Normalise once at the boundary so the formatter stays pure.
|
||||
const labelOptions: PaginationLabelOptions = {
|
||||
startNumber: normalizeStartNumber(options.startNumber),
|
||||
freeText: normalizeFreeText(options.freeText),
|
||||
textScope: options.textScope,
|
||||
format: options.format
|
||||
};
|
||||
|
||||
for (const [pageIndex, page] of document.getPages().entries()) {
|
||||
drawTextOverlay(page, {
|
||||
font,
|
||||
text: buildPaginationLabel(pageIndex, options),
|
||||
text: buildPaginationLabel(pageIndex, labelOptions),
|
||||
size: options.fontSize,
|
||||
color,
|
||||
position: options.position,
|
||||
margin: PAGE_NUMBER_MARGIN,
|
||||
padding: 0,
|
||||
minSize: MIN_FONT_SIZE,
|
||||
compensatePageRotation: true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
let measureContext: CanvasRenderingContext2D | null | undefined;
|
||||
|
||||
const getMeasureContext = () => {
|
||||
if (measureContext !== undefined) return measureContext;
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
measureContext = null;
|
||||
return measureContext;
|
||||
}
|
||||
|
||||
measureContext = document.createElement('canvas').getContext('2d');
|
||||
return measureContext;
|
||||
};
|
||||
|
||||
/** Rough average advance width per character, used when no canvas is available. */
|
||||
const FALLBACK_WIDTH_RATIO = 0.55;
|
||||
const LINE_HEIGHT_RATIO = 1.2;
|
||||
|
||||
/**
|
||||
* Approximate advance width of `text` in PDF points. The PDF standard font is
|
||||
* Helvetica, and Arial/Liberation Sans are metrically compatible, so the
|
||||
* browser's own text measurement is a good stand-in for the export.
|
||||
*/
|
||||
export const measureHelveticaText = (text: string, size: number): number => {
|
||||
if (!text || size <= 0) return 0;
|
||||
|
||||
const context = getMeasureContext();
|
||||
if (!context) return text.length * size * FALLBACK_WIDTH_RATIO;
|
||||
|
||||
context.font = `${size}px Helvetica, Arial, "Liberation Sans", sans-serif`;
|
||||
return context.measureText(text).width;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shrink factor (≤ 1) that the PDF export applies to keep a single line of text
|
||||
* inside the available box. Lets the HTML preview mirror the export instead of
|
||||
* clipping the label.
|
||||
*/
|
||||
export const getTextFitScale = (
|
||||
text: string,
|
||||
size: number,
|
||||
availableWidth: number,
|
||||
availableHeight: number
|
||||
): number => {
|
||||
if (!text || size <= 0 || availableWidth <= 0) return 1;
|
||||
|
||||
const width = measureHelveticaText(text, size);
|
||||
const fitsWidth = width <= availableWidth;
|
||||
const fitsHeight = size * LINE_HEIGHT_RATIO <= availableHeight;
|
||||
|
||||
if (fitsWidth && fitsHeight) return 1;
|
||||
|
||||
const widthScale = width > 0 ? availableWidth / width : 1;
|
||||
const heightScale = availableHeight / (size * LINE_HEIGHT_RATIO);
|
||||
|
||||
return Math.max(0, Math.min(1, widthScale, heightScale));
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import { pdfLoadErrorMessage } from './pdf-errors';
|
||||
import { createLatestToken } from './latest-task';
|
||||
|
||||
export type PdfSessionOptions<THandle> = {
|
||||
/** Builds the tool-specific handle (thumbnails, page renderer, …). */
|
||||
load: (file: File, bytes: Uint8Array) => Promise<THandle>;
|
||||
/** Releases a handle that is replaced or cleared. */
|
||||
dispose?: (handle: THandle) => void;
|
||||
/** Resets tool state when a new file is picked (selections, previews, …). */
|
||||
onFileChange?: () => void;
|
||||
/** Fallback message when loading fails for a reason other than encryption. */
|
||||
loadFallbackMessage?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared single-PDF lifecycle: pick a file, read its bytes once, build a handle
|
||||
* and map load errors to German copy. Replaces the per-route copies of
|
||||
* `file`/`bytes`/`handle`/`isLoading`/`loadingError` plus their generation
|
||||
* counters.
|
||||
*
|
||||
* Reads must go through the getters (`session.file`) so Svelte tracks them.
|
||||
*/
|
||||
export const createPdfSession = <THandle>(options: PdfSessionOptions<THandle>) => {
|
||||
let file = $state<File | null>(null);
|
||||
let bytes = $state<Uint8Array | null>(null);
|
||||
let handle = $state<THandle | null>(null);
|
||||
let isLoading = $state(false);
|
||||
let loadingError = $state<string | null>(null);
|
||||
const loadToken = createLatestToken();
|
||||
|
||||
const release = () => {
|
||||
if (handle === null) return;
|
||||
options.dispose?.(handle);
|
||||
handle = null;
|
||||
};
|
||||
|
||||
const load = async (nextFile: File) => {
|
||||
const ticket = loadToken.begin();
|
||||
|
||||
release();
|
||||
options.onFileChange?.();
|
||||
file = nextFile;
|
||||
bytes = null;
|
||||
loadingError = null;
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
const nextBytes = new Uint8Array(await nextFile.arrayBuffer());
|
||||
const nextHandle = await options.load(nextFile, nextBytes);
|
||||
|
||||
if (!loadToken.isCurrent(ticket)) {
|
||||
options.dispose?.(nextHandle);
|
||||
return;
|
||||
}
|
||||
|
||||
bytes = nextBytes;
|
||||
handle = nextHandle;
|
||||
} catch (error) {
|
||||
console.error('Failed to load PDF:', error);
|
||||
if (loadToken.isCurrent(ticket)) {
|
||||
file = null;
|
||||
loadingError = pdfLoadErrorMessage(error, options.loadFallbackMessage);
|
||||
}
|
||||
} finally {
|
||||
if (loadToken.isCurrent(ticket)) isLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Drops the current file and handle (used on unmount). */
|
||||
const clear = () => {
|
||||
loadToken.invalidate();
|
||||
release();
|
||||
file = null;
|
||||
bytes = null;
|
||||
loadingError = null;
|
||||
isLoading = false;
|
||||
};
|
||||
|
||||
return {
|
||||
get file() {
|
||||
return file;
|
||||
},
|
||||
get bytes() {
|
||||
return bytes;
|
||||
},
|
||||
get handle() {
|
||||
return handle;
|
||||
},
|
||||
get isLoading() {
|
||||
return isLoading;
|
||||
},
|
||||
get loadingError() {
|
||||
return loadingError;
|
||||
},
|
||||
load,
|
||||
clear
|
||||
};
|
||||
};
|
||||
|
||||
export type PdfSession<THandle> = ReturnType<typeof createPdfSession<THandle>>;
|
||||
+181
-52
@@ -1,24 +1,35 @@
|
||||
import { degrees, type PDFFont, type PDFPage, type RGB } from 'pdf-lib';
|
||||
import { degrees, type PDFFont, type PDFImage, type PDFPage, type RGB } from 'pdf-lib';
|
||||
import { TextOverlayError } from './pdf-errors';
|
||||
import { getPositionCoords, type OverlayPosition } from './pdf-overlay';
|
||||
|
||||
const DEFAULT_MARGIN = 20;
|
||||
const DEFAULT_PADDING = 8;
|
||||
const DEFAULT_MIN_SIZE = 4;
|
||||
const DEFAULT_BORDER_WIDTH = 2;
|
||||
/** Default distance between an overlay mark and the page edge, in points. */
|
||||
export const TEXT_OVERLAY_MARGIN = 20;
|
||||
/** Default inner padding of a framed overlay mark, in points. */
|
||||
export const TEXT_OVERLAY_PADDING = 8;
|
||||
/** Default font-size floor while shrinking an overlay to fit the page. */
|
||||
export const MIN_TEXT_OVERLAY_SIZE = 4;
|
||||
export const TEXT_OVERLAY_BORDER_WIDTH = 2;
|
||||
/** Default height floor for image overlays, in points. */
|
||||
export const MIN_IMAGE_OVERLAY_SIZE = 20;
|
||||
|
||||
/** Baseline nudge used by the framed text stamps so glyphs sit inside the border. */
|
||||
const BASELINE_OFFSET_RATIO = 0.2;
|
||||
const DEFAULT_TOO_LONG_MESSAGE =
|
||||
'Der Text ist für mindestens eine Seite zu lang. Kürzen Sie den Text.';
|
||||
const TEXT_NOT_ENCODABLE_MESSAGE =
|
||||
'Der Text enthält Zeichen, die nicht dargestellt werden können. Entfernen Sie z. B. Emoji.';
|
||||
|
||||
export type TextOverlayOptions = {
|
||||
font: PDFFont;
|
||||
text: string;
|
||||
/** Requested font size in points; shrunk down to `minSize` to fit the page. */
|
||||
size: number;
|
||||
color: RGB;
|
||||
/** Where the mark sits on the *displayed* page (see `compensatePageRotation`). */
|
||||
position: OverlayPosition;
|
||||
/** Distance between the mark and the page edge in points. */
|
||||
margin?: number;
|
||||
/** Inner box padding; use `0` for a frameless, anchor-aligned mark. */
|
||||
/** Inner box padding; only used when `framed` is set. */
|
||||
padding?: number;
|
||||
framed?: boolean;
|
||||
borderWidth?: number;
|
||||
@@ -29,11 +40,30 @@ export type TextOverlayOptions = {
|
||||
/**
|
||||
* Place the mark on rotated scans (`/Rotate 90`, `180`, `270`) where it is
|
||||
* visible instead of sideways. Opt-in so existing tools keep their output.
|
||||
* When enabled, `position` refers to the displayed page; otherwise it refers
|
||||
* to default user space.
|
||||
*/
|
||||
compensatePageRotation?: boolean;
|
||||
tooLongMessage?: string;
|
||||
};
|
||||
|
||||
export type ImageOverlayOptions = {
|
||||
image: PDFImage;
|
||||
/** Requested image height in points; scaled down (never up) to fit the page. */
|
||||
size: number;
|
||||
color: RGB;
|
||||
position: OverlayPosition;
|
||||
margin?: number;
|
||||
/** Inner box padding; only used when `framed` is set. */
|
||||
padding?: number;
|
||||
framed?: boolean;
|
||||
borderWidth?: number;
|
||||
/** Additional counter-clockwise rotation of the mark in degrees. */
|
||||
rotation?: number;
|
||||
/** Height floor in points; smaller images are drawn at this height. */
|
||||
minSize?: number;
|
||||
};
|
||||
|
||||
type TextBox = {
|
||||
size: number;
|
||||
textHeight: number;
|
||||
@@ -44,26 +74,44 @@ type TextBox = {
|
||||
const measureText = (font: PDFFont, text: string, size: number) => {
|
||||
try {
|
||||
return { width: font.widthOfTextAtSize(text, size), height: font.heightAtSize(size) };
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Der Text enthält Zeichen, die nicht dargestellt werden können. Entfernen Sie z. B. Emoji.'
|
||||
);
|
||||
} catch (error) {
|
||||
// The only expected failure here is a standard font that cannot encode a
|
||||
// character. Anything else is a real bug and must not be reported to the
|
||||
// user as an encoding problem.
|
||||
if (error instanceof Error && /cannot encode|WinAnsi|StandardEncoding/i.test(error.message)) {
|
||||
throw new TextOverlayError(TEXT_NOT_ENCODABLE_MESSAGE);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const fitTextToPage = (
|
||||
font: PDFFont,
|
||||
text: string,
|
||||
requestedSize: number,
|
||||
pageWidth: number,
|
||||
pageHeight: number,
|
||||
margin: number,
|
||||
padding: number,
|
||||
minSize: number,
|
||||
tooLongMessage: string
|
||||
): TextBox => {
|
||||
const availableWidth = pageWidth - margin * 2;
|
||||
const availableHeight = pageHeight - margin * 2;
|
||||
type TextFitOptions = {
|
||||
font: PDFFont;
|
||||
text: string;
|
||||
requestedSize: number;
|
||||
/** Size of the displayed page after applying `/Rotate`. */
|
||||
visualWidth: number;
|
||||
visualHeight: number;
|
||||
margin: number;
|
||||
padding: number;
|
||||
minSize: number;
|
||||
tooLongMessage: string;
|
||||
};
|
||||
|
||||
const fitTextToPage = ({
|
||||
font,
|
||||
text,
|
||||
requestedSize,
|
||||
visualWidth,
|
||||
visualHeight,
|
||||
margin,
|
||||
padding,
|
||||
minSize,
|
||||
tooLongMessage
|
||||
}: TextFitOptions): TextBox => {
|
||||
const availableWidth = visualWidth - margin * 2;
|
||||
const availableHeight = visualHeight - margin * 2;
|
||||
let size = requestedSize;
|
||||
let metrics = measureText(font, text, size);
|
||||
|
||||
@@ -79,7 +127,7 @@ const fitTextToPage = (
|
||||
metrics.width + padding * 2 > availableWidth ||
|
||||
metrics.height + padding * 2 > availableHeight
|
||||
) {
|
||||
throw new Error(tooLongMessage);
|
||||
throw new TextOverlayError(tooLongMessage);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -102,8 +150,8 @@ export const getRotatedBoxPlacement = (
|
||||
boxHeight: number,
|
||||
position: OverlayPosition,
|
||||
angle: number,
|
||||
margin = DEFAULT_MARGIN
|
||||
) => {
|
||||
margin = TEXT_OVERLAY_MARGIN
|
||||
): { x: number; y: number } => {
|
||||
const radians = (angle * Math.PI) / 180;
|
||||
const cosine = Math.cos(radians);
|
||||
const sine = Math.sin(radians);
|
||||
@@ -129,12 +177,7 @@ export const getRotatedBoxPlacement = (
|
||||
margin
|
||||
);
|
||||
|
||||
return {
|
||||
x: boundingCoordinates.x - minX,
|
||||
y: boundingCoordinates.y - minY,
|
||||
cosine,
|
||||
sine
|
||||
};
|
||||
return { x: boundingCoordinates.x - minX, y: boundingCoordinates.y - minY };
|
||||
};
|
||||
|
||||
/** Normalised page rotation; anything that is not a multiple of 90 is ignored. */
|
||||
@@ -153,6 +196,10 @@ const getVisualPageSize = (pageWidth: number, pageHeight: number, pageRotation:
|
||||
* Maps a point on the displayed page back into default user space. Viewers
|
||||
* rotate page content clockwise by `/Rotate`, so this is the inverse rotation
|
||||
* that keeps a mark at the requested visual position.
|
||||
*
|
||||
* Invariant (covered by `tests/e2e/unit/pdf-text-overlay.spec.ts`): for every
|
||||
* rotation, `toUserSpace` composed with the clockwise viewer rotation is the
|
||||
* identity, i.e. the mark appears at the requested corner on screen.
|
||||
*/
|
||||
const toUserSpace = (
|
||||
point: { x: number; y: number },
|
||||
@@ -172,10 +219,22 @@ const toUserSpace = (
|
||||
}
|
||||
};
|
||||
|
||||
/** Rotates an offset inside the mark box by `angle` around the box origin. */
|
||||
const rotateOffset = (offsetX: number, offsetY: number, angle: number) => {
|
||||
const radians = (angle * Math.PI) / 180;
|
||||
const cosine = Math.cos(radians);
|
||||
const sine = Math.sin(radians);
|
||||
|
||||
return {
|
||||
x: offsetX * cosine - offsetY * sine,
|
||||
y: offsetX * sine + offsetY * cosine
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Draws a single line of text (optionally framed) at one of the standard
|
||||
* positions. Shrinks the font to fit the page and throws a German error instead
|
||||
* of clipping.
|
||||
* positions. Shrinks the font to fit the page and throws a `TextOverlayError`
|
||||
* instead of clipping.
|
||||
*/
|
||||
export const drawTextOverlay = (page: PDFPage, options: TextOverlayOptions): void => {
|
||||
const {
|
||||
@@ -184,30 +243,31 @@ export const drawTextOverlay = (page: PDFPage, options: TextOverlayOptions): voi
|
||||
size,
|
||||
color,
|
||||
position,
|
||||
margin = DEFAULT_MARGIN,
|
||||
padding = DEFAULT_PADDING,
|
||||
margin = TEXT_OVERLAY_MARGIN,
|
||||
padding = TEXT_OVERLAY_PADDING,
|
||||
framed = false,
|
||||
borderWidth = DEFAULT_BORDER_WIDTH,
|
||||
borderWidth = TEXT_OVERLAY_BORDER_WIDTH,
|
||||
rotation = 0,
|
||||
minSize = DEFAULT_MIN_SIZE,
|
||||
minSize = MIN_TEXT_OVERLAY_SIZE,
|
||||
compensatePageRotation = false,
|
||||
tooLongMessage = DEFAULT_TOO_LONG_MESSAGE
|
||||
} = options;
|
||||
|
||||
const boxPadding = framed ? padding : 0;
|
||||
const { width, height } = page.getSize();
|
||||
const pageRotation = compensatePageRotation ? getPageRotation(page) : 0;
|
||||
const visual = getVisualPageSize(width, height, pageRotation);
|
||||
const box = fitTextToPage(
|
||||
const box = fitTextToPage({
|
||||
font,
|
||||
text,
|
||||
size,
|
||||
visual.width,
|
||||
visual.height,
|
||||
requestedSize: size,
|
||||
visualWidth: visual.width,
|
||||
visualHeight: visual.height,
|
||||
margin,
|
||||
padding,
|
||||
padding: boxPadding,
|
||||
minSize,
|
||||
tooLongMessage
|
||||
);
|
||||
});
|
||||
const placement = getRotatedBoxPlacement(
|
||||
visual.width,
|
||||
visual.height,
|
||||
@@ -217,13 +277,13 @@ export const drawTextOverlay = (page: PDFPage, options: TextOverlayOptions): voi
|
||||
rotation,
|
||||
margin
|
||||
);
|
||||
const origin = toUserSpace({ x: placement.x, y: placement.y }, width, height, pageRotation);
|
||||
const origin = toUserSpace(placement, width, height, pageRotation);
|
||||
const angle = rotation + pageRotation;
|
||||
const radians = (angle * Math.PI) / 180;
|
||||
const cosine = Math.cos(radians);
|
||||
const sine = Math.sin(radians);
|
||||
const offsetX = padding;
|
||||
const offsetY = padding + box.textHeight * BASELINE_OFFSET_RATIO;
|
||||
const textOffset = rotateOffset(
|
||||
boxPadding,
|
||||
boxPadding + box.textHeight * BASELINE_OFFSET_RATIO,
|
||||
angle
|
||||
);
|
||||
|
||||
if (framed) {
|
||||
page.drawRectangle({
|
||||
@@ -238,11 +298,80 @@ export const drawTextOverlay = (page: PDFPage, options: TextOverlayOptions): voi
|
||||
}
|
||||
|
||||
page.drawText(text, {
|
||||
x: origin.x + offsetX * cosine - offsetY * sine,
|
||||
y: origin.y + offsetX * sine + offsetY * cosine,
|
||||
x: origin.x + textOffset.x,
|
||||
y: origin.y + textOffset.y,
|
||||
size: box.size,
|
||||
font,
|
||||
color,
|
||||
rotate: degrees(angle)
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Draws an image (optionally framed) at one of the standard positions, scaled
|
||||
* down to fit the page. Kept next to `drawTextOverlay` so the rotated-placement
|
||||
* maths lives in one place instead of being repeated in a route.
|
||||
*/
|
||||
export const drawImageOverlay = (page: PDFPage, options: ImageOverlayOptions): void => {
|
||||
const {
|
||||
image,
|
||||
size,
|
||||
color,
|
||||
position,
|
||||
margin = TEXT_OVERLAY_MARGIN,
|
||||
padding = TEXT_OVERLAY_PADDING,
|
||||
framed = false,
|
||||
borderWidth = TEXT_OVERLAY_BORDER_WIDTH,
|
||||
rotation = 0,
|
||||
minSize = MIN_IMAGE_OVERLAY_SIZE
|
||||
} = options;
|
||||
|
||||
const boxPadding = framed ? padding : 0;
|
||||
const { width, height } = page.getSize();
|
||||
const aspectRatio = image.width / image.height;
|
||||
let drawHeight = size;
|
||||
let drawWidth = drawHeight * aspectRatio;
|
||||
|
||||
const availableWidth = width - margin * 2 - boxPadding * 2;
|
||||
const availableHeight = height - margin * 2 - boxPadding * 2;
|
||||
if (drawWidth > availableWidth || drawHeight > availableHeight) {
|
||||
const scale = Math.min(availableWidth / drawWidth, availableHeight / drawHeight);
|
||||
drawWidth *= scale;
|
||||
drawHeight *= scale;
|
||||
}
|
||||
if (drawHeight < minSize) {
|
||||
drawHeight = minSize;
|
||||
drawWidth = drawHeight * aspectRatio;
|
||||
}
|
||||
|
||||
const placement = getRotatedBoxPlacement(
|
||||
width,
|
||||
height,
|
||||
drawWidth + boxPadding * 2,
|
||||
drawHeight + boxPadding * 2,
|
||||
position,
|
||||
rotation,
|
||||
margin
|
||||
);
|
||||
|
||||
if (framed) {
|
||||
page.drawRectangle({
|
||||
x: placement.x,
|
||||
y: placement.y,
|
||||
width: drawWidth + boxPadding * 2,
|
||||
height: drawHeight + boxPadding * 2,
|
||||
borderColor: color,
|
||||
borderWidth,
|
||||
rotate: degrees(rotation)
|
||||
});
|
||||
}
|
||||
|
||||
const imageOffset = rotateOffset(boxPadding, boxPadding, rotation);
|
||||
page.drawImage(image, {
|
||||
x: placement.x + imageOffset.x,
|
||||
y: placement.y + imageOffset.y,
|
||||
width: drawWidth,
|
||||
height: drawHeight,
|
||||
rotate: degrees(rotation)
|
||||
});
|
||||
};
|
||||
+66
-48
@@ -1,25 +1,36 @@
|
||||
export type PdfThumbnail = {
|
||||
import type { PDFPageProxy } from 'pdfjs-dist';
|
||||
|
||||
import { createLatestToken } from './latest-task';
|
||||
|
||||
/** One rasterised page, used for thumbnails and for on-demand previews. */
|
||||
export type PdfPageImage = {
|
||||
pageNumber: number;
|
||||
imageUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
/** Thumbnail grids and on-demand previews share one page-image shape. */
|
||||
export type PdfThumbnail = PdfPageImage;
|
||||
|
||||
export type PdfDocumentHandle = {
|
||||
pageCount: number;
|
||||
thumbnails: PdfThumbnail[];
|
||||
thumbnails: PdfPageImage[];
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
/** Pixel scale used for on-demand page previews. */
|
||||
export const PDF_PREVIEW_SCALE = 1.25;
|
||||
/** Pixel scale used for the page grid thumbnails. */
|
||||
export const PDF_THUMBNAIL_SCALE = 0.5;
|
||||
|
||||
export type PdfPagePreview = {
|
||||
pageNumber: number;
|
||||
imageUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
/** Thrown when a render was superseded by a newer one or by unmounting. */
|
||||
export class PreviewRenderCancelledError extends Error {
|
||||
constructor(message = 'Die PDF-Vorschau wurde zwischenzeitlich geändert.') {
|
||||
super(message);
|
||||
this.name = 'PreviewRenderCancelledError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one page at a time. Used by tools that operate on a whole document
|
||||
@@ -27,7 +38,7 @@ export type PdfPagePreview = {
|
||||
*/
|
||||
export type PdfPageRenderer = {
|
||||
pageCount: number;
|
||||
render: (pageNumber: number) => Promise<PdfPagePreview>;
|
||||
render: (pageNumber: number) => Promise<PdfPageImage>;
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
@@ -50,32 +61,44 @@ const canvasToPngBlob = (canvas: HTMLCanvasElement) =>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Rasterises a single page and returns an object URL for the PNG. Shared by the
|
||||
* thumbnail grid and the on-demand preview renderer so both stay in sync.
|
||||
*/
|
||||
const renderPageToPngUrl = async (page: PDFPageProxy, scale: number) => {
|
||||
const viewport = page.getViewport({ 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 a canvas for the PDF page.');
|
||||
|
||||
await page.render({ canvas, canvasContext, viewport }).promise;
|
||||
|
||||
return {
|
||||
imageUrl: URL.createObjectURL(await canvasToPngBlob(canvas)),
|
||||
width: viewport.width,
|
||||
height: viewport.height
|
||||
};
|
||||
};
|
||||
|
||||
export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHandle> {
|
||||
const getDocument = await loadPdfJs();
|
||||
|
||||
// pdfjs transfers the buffer to its worker, so it always gets its own copy —
|
||||
// callers keep using `data` for the export.
|
||||
const loadingTask = getDocument({ data: new Uint8Array(data) });
|
||||
const thumbnails: PdfThumbnail[] = [];
|
||||
const thumbnails: PdfPageImage[] = [];
|
||||
|
||||
try {
|
||||
const pdfDocument = await loadingTask.promise;
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) {
|
||||
const page = await pdfDocument.getPage(pageNumber);
|
||||
const viewport = page.getViewport({ scale: 0.5 });
|
||||
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 thumbnail canvas context.');
|
||||
|
||||
await page.render({ canvas, canvasContext, viewport }).promise;
|
||||
|
||||
thumbnails.push({
|
||||
pageNumber,
|
||||
imageUrl: URL.createObjectURL(await canvasToPngBlob(canvas)),
|
||||
width: viewport.width,
|
||||
height: viewport.height
|
||||
...(await renderPageToPngUrl(page, PDF_THUMBNAIL_SCALE))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -101,11 +124,20 @@ export async function loadPdfPageRenderer(data: Uint8Array): Promise<PdfPageRend
|
||||
const getDocument = await loadPdfJs();
|
||||
|
||||
const loadingTask = getDocument({ data: new Uint8Array(data) });
|
||||
const pdfDocument = await loadingTask.promise;
|
||||
let pdfDocument;
|
||||
|
||||
try {
|
||||
pdfDocument = await loadingTask.promise;
|
||||
} catch (error) {
|
||||
// Mirror `loadPdfDocument`: a failed load must not leave the worker and its
|
||||
// copy of the document data alive.
|
||||
await loadingTask.destroy();
|
||||
throw error;
|
||||
}
|
||||
|
||||
let destroyed = false;
|
||||
let renderToken = 0;
|
||||
let currentUrl: string | null = null;
|
||||
const renderToken = createLatestToken();
|
||||
|
||||
const releaseUrl = () => {
|
||||
if (currentUrl) URL.revokeObjectURL(currentUrl);
|
||||
@@ -115,46 +147,32 @@ export async function loadPdfPageRenderer(data: Uint8Array): Promise<PdfPageRend
|
||||
return {
|
||||
pageCount: pdfDocument.numPages,
|
||||
render: async (pageNumber) => {
|
||||
if (destroyed) throw new Error('Die PDF-Vorschau wurde bereits geschlossen.');
|
||||
if (destroyed) throw new PreviewRenderCancelledError('Die PDF-Vorschau wurde geschlossen.');
|
||||
|
||||
const token = ++renderToken;
|
||||
const token = renderToken.begin();
|
||||
const clampedPage = Math.min(
|
||||
pdfDocument.numPages,
|
||||
Math.max(1, Math.round(Number(pageNumber) || 1))
|
||||
);
|
||||
const page = await pdfDocument.getPage(clampedPage);
|
||||
const viewport = page.getViewport({ scale: PDF_PREVIEW_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 preview canvas context.');
|
||||
|
||||
await page.render({ canvas, canvasContext, viewport }).promise;
|
||||
const imageUrl = URL.createObjectURL(await canvasToPngBlob(canvas));
|
||||
const rendered = await renderPageToPngUrl(page, PDF_PREVIEW_SCALE);
|
||||
|
||||
// A newer render (or unmount) superseded this one: drop the bitmap
|
||||
// instead of leaving an orphaned object URL behind.
|
||||
if (destroyed || token !== renderToken) {
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
throw new Error('Die PDF-Vorschau wurde zwischenzeitlich geändert.');
|
||||
if (destroyed || !renderToken.isCurrent(token)) {
|
||||
URL.revokeObjectURL(rendered.imageUrl);
|
||||
throw new PreviewRenderCancelledError();
|
||||
}
|
||||
|
||||
releaseUrl();
|
||||
currentUrl = imageUrl;
|
||||
currentUrl = rendered.imageUrl;
|
||||
|
||||
return {
|
||||
pageNumber: clampedPage,
|
||||
imageUrl,
|
||||
width: viewport.width,
|
||||
height: viewport.height
|
||||
};
|
||||
return { pageNumber: clampedPage, ...rendered };
|
||||
},
|
||||
destroy: () => {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
renderToken += 1;
|
||||
renderToken.invalidate();
|
||||
releaseUrl();
|
||||
void loadingTask.destroy();
|
||||
}
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
let pendingFiles: File[] = [];
|
||||
/** Routes that can receive a handoff from another route. */
|
||||
export type PendingFileTarget = 'bea' | 'pagination';
|
||||
|
||||
type QueuedFile = { file: File; target: PendingFileTarget };
|
||||
|
||||
let queue: QueuedFile[] = [];
|
||||
|
||||
/**
|
||||
* In-memory handoff between routes (start page → beA workspace, merge →
|
||||
* pagination). Nothing is uploaded or persisted; the receiving route takes and
|
||||
* clears the queue on mount.
|
||||
* clears the files queued for its own target on mount.
|
||||
*
|
||||
* The target keeps a queued ZIP from being consumed — and silently dropped — by
|
||||
* a tool that only accepts PDFs.
|
||||
*/
|
||||
export const queuePendingFiles = (files: File[]) => {
|
||||
pendingFiles = [...pendingFiles, ...files];
|
||||
export const queuePendingFiles = (files: File[], target: PendingFileTarget) => {
|
||||
queue = [...queue, ...files.map((file) => ({ file, target }))];
|
||||
};
|
||||
|
||||
export const takePendingFiles = () => {
|
||||
const files = pendingFiles;
|
||||
pendingFiles = [];
|
||||
return files;
|
||||
/** Takes (and clears) the files queued for `target`; other targets stay queued. */
|
||||
export const takePendingFiles = (target: PendingFileTarget): File[] => {
|
||||
const taken: File[] = [];
|
||||
const remaining: QueuedFile[] = [];
|
||||
|
||||
for (const entry of queue) {
|
||||
if (entry.target === target) taken.push(entry.file);
|
||||
else remaining.push(entry);
|
||||
}
|
||||
|
||||
queue = remaining;
|
||||
return taken;
|
||||
};
|
||||
|
||||
/** Test/storybook helper: forget everything that is still queued. */
|
||||
export const clearPendingFiles = () => {
|
||||
queue = [];
|
||||
};
|
||||
@@ -19,6 +19,12 @@ export const tools: Tool[] = [
|
||||
'Versehen Sie alle Seiten mit fortlaufenden Blattnummern und optional einem Aktenzeichen.',
|
||||
icon: 'list-ordered'
|
||||
},
|
||||
{
|
||||
slug: 'exhibits',
|
||||
title: 'Anlagen nummerieren',
|
||||
description: 'Nummerieren und benennen Sie Anlagen für die Einreichung — mit Anlagenstempel.',
|
||||
icon: 'files'
|
||||
},
|
||||
{
|
||||
slug: 'watermark',
|
||||
title: 'Wasserzeichen',
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
Merge,
|
||||
Stamp,
|
||||
ListOrdered,
|
||||
Files,
|
||||
RotateCw,
|
||||
Info,
|
||||
FileText
|
||||
@@ -31,6 +32,7 @@
|
||||
merge: Merge,
|
||||
stamp: Stamp,
|
||||
'list-ordered': ListOrdered,
|
||||
files: Files,
|
||||
'rotate-cw': RotateCw,
|
||||
info: Info
|
||||
};
|
||||
@@ -39,7 +41,7 @@
|
||||
const workspaceFiles = files.filter(isAcceptedWorkspaceFile);
|
||||
if (workspaceFiles.length === 0) return;
|
||||
|
||||
queuePendingFiles(workspaceFiles);
|
||||
queuePendingFiles(workspaceFiles, 'bea');
|
||||
void goto('/tools/bea');
|
||||
};
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
thumbnailWidthHydrated = true;
|
||||
|
||||
const queuedFiles = takePendingFiles();
|
||||
const queuedFiles = takePendingFiles('bea');
|
||||
if (queuedFiles.length > 0) handleFilesSelected(queuedFiles);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
|
||||
import { downloadBlob } from '$lib/download';
|
||||
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||
import {
|
||||
compressWithPreset,
|
||||
compressionPresets,
|
||||
@@ -12,6 +13,9 @@
|
||||
import { isRasterizationTooLarge, RASTERIZATION_MAX_BYTES } from '$lib/rasterization-limits';
|
||||
import { AlertTriangle } from '@lucide/svelte';
|
||||
|
||||
const compressErrorMessage = (error: unknown) =>
|
||||
pdfProcessingErrorMessage(error, 'Diese PDF konnte nicht komprimiert werden.');
|
||||
|
||||
let items = $state<BatchItem[]>([]);
|
||||
let selectedPreset = $state(defaultCompressionPreset.id);
|
||||
let isRunning = $state(false);
|
||||
@@ -55,7 +59,7 @@
|
||||
isRunning = true;
|
||||
try {
|
||||
await runBatch(queue, processFile, updateItem, {
|
||||
errorMessage: 'Diese PDF konnte nicht komprimiert werden.',
|
||||
errorMessage: compressErrorMessage,
|
||||
// Skip rows removed while they were still waiting in the queue.
|
||||
shouldProcess: (id) => items.some((item) => item.id === id)
|
||||
});
|
||||
@@ -72,7 +76,7 @@
|
||||
isRunning = true;
|
||||
try {
|
||||
await runBatch([item], processFile, updateItem, {
|
||||
errorMessage: 'Diese PDF konnte nicht komprimiert werden.'
|
||||
errorMessage: compressErrorMessage
|
||||
});
|
||||
} finally {
|
||||
isRunning = false;
|
||||
@@ -97,8 +101,8 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex gap-3">
|
||||
<AlertTriangle class="h-5 w-5 shrink-0 text-amber-600 mt-0.5" />
|
||||
<div class="flex gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0 text-amber-600" />
|
||||
<div class="text-sm text-amber-800">
|
||||
<p class="font-medium">Hinweis zur Qualität</p>
|
||||
<p class="mt-1">
|
||||
@@ -118,17 +122,17 @@
|
||||
/>
|
||||
|
||||
{#if items.length > 0}
|
||||
<div class="rounded-2xl border border-border bg-card p-4 space-y-4">
|
||||
<div class="space-y-4 rounded-2xl border border-border bg-card p-4">
|
||||
<h2 class="text-lg font-semibold text-foreground">Komprimierungsstufe</h2>
|
||||
<p class="text-xs text-primary">Die Einstellung gilt für alle Dateien der Liste.</p>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each compressionPresets as preset (preset.id)}
|
||||
<label
|
||||
class="flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition {selectedPreset ===
|
||||
class="flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition {selectedPreset ===
|
||||
preset.id
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary-300'}"
|
||||
: 'hover:border-primary-300 border-border'}"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
import RasterizationWarning from '$lib/components/RasterizationWarning.svelte';
|
||||
import ToolError from '$lib/components/ToolError.svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import { downloadBlob, downloadBlobUrl } from '$lib/download';
|
||||
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||
import { isRasterizationTooLarge } from '$lib/rasterization-limits';
|
||||
import { zipSync } from 'fflate';
|
||||
import { Image } from '@lucide/svelte';
|
||||
@@ -92,7 +94,7 @@
|
||||
}
|
||||
} catch (conversionError) {
|
||||
console.error('Conversion failed:', conversionError);
|
||||
error = 'Fehler beim Konvertieren des PDFs.';
|
||||
error = pdfProcessingErrorMessage(conversionError, 'Fehler beim Konvertieren des PDFs.');
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
@@ -101,8 +103,8 @@
|
||||
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-primary-900">Konvertieren</h1>
|
||||
<p class="mt-2 text-sm text-primary-700">Konvertieren Sie ein PDF in Bilder (PNG oder JPEG).</p>
|
||||
<h1 class="text-primary-900 text-2xl font-bold">Konvertieren</h1>
|
||||
<p class="text-primary-700 mt-2 text-sm">Konvertieren Sie ein PDF in Bilder (PNG oder JPEG).</p>
|
||||
</div>
|
||||
|
||||
{#if !pdfFile}
|
||||
@@ -116,28 +118,28 @@
|
||||
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">
|
||||
<div class="border-primary-200 space-y-4 rounded-xl border bg-white p-4">
|
||||
<div class="text-primary-900 flex items-center gap-2">
|
||||
<Image class="h-5 w-5" />
|
||||
<h2 class="text-lg font-semibold">{pdfFile.name}</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-primary-900 mb-2">Bildformat</p>
|
||||
<p class="text-primary-900 mb-2 text-sm font-medium">Bildformat</p>
|
||||
<div class="flex gap-3">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input type="radio" bind:group={outputFormat} value="png" class="h-4 w-4" />
|
||||
<span class="text-sm text-primary-700">PNG (verlustfrei)</span>
|
||||
<span class="text-primary-700 text-sm">PNG (verlustfrei)</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input type="radio" bind:group={outputFormat} value="jpeg" class="h-4 w-4" />
|
||||
<span class="text-sm text-primary-700">JPEG (kleiner)</span>
|
||||
<span class="text-primary-700 text-sm">JPEG (kleiner)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-primary-900 mb-1" for="render-scale">
|
||||
<label class="text-primary-900 mb-1 block text-sm font-medium" for="render-scale">
|
||||
Auflösung: {scale}x
|
||||
</label>
|
||||
<input
|
||||
@@ -149,7 +151,7 @@
|
||||
bind:value={scale}
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="text-xs text-primary-600 mt-1">
|
||||
<p class="text-primary-600 mt-1 text-xs">
|
||||
{scale === 1
|
||||
? '~72 DPI'
|
||||
: scale === 2
|
||||
@@ -162,11 +164,11 @@
|
||||
|
||||
{#if isProcessing && totalPages > 0}
|
||||
<div class="space-y-1">
|
||||
<div class="flex justify-between text-xs text-primary-700">
|
||||
<div class="text-primary-700 flex justify-between text-xs">
|
||||
<span>Wird konvertiert...</span>
|
||||
<span>{progress} / {totalPages}</span>
|
||||
</div>
|
||||
<div class="h-2 w-full rounded-full bg-primary-100">
|
||||
<div class="bg-primary-100 h-2 w-full rounded-full">
|
||||
<div
|
||||
class="h-2 rounded-full bg-primary transition-all"
|
||||
style="width: {(progress / totalPages) * 100}%"
|
||||
@@ -176,11 +178,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
<ToolError message={error} />
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button onclick={convertPdf} disabled={isProcessing || rasterizationBlocked}>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import BatchFileList from '$lib/components/BatchFileList.svelte';
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
import ToolError from '$lib/components/ToolError.svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
|
||||
import { downloadBlob } from '$lib/download';
|
||||
@@ -142,8 +143,8 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex gap-3">
|
||||
<AlertTriangle class="h-5 w-5 shrink-0 text-amber-600 mt-0.5" />
|
||||
<div class="flex gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0 text-amber-600" />
|
||||
<div class="text-sm text-amber-800">
|
||||
<p class="font-medium">Hinweis zur Qualität</p>
|
||||
<p class="mt-1">
|
||||
@@ -163,7 +164,7 @@
|
||||
/>
|
||||
|
||||
{#if items.length > 0}
|
||||
<div class="rounded-2xl border border-border bg-card p-4 space-y-4">
|
||||
<div class="space-y-4 rounded-2xl border border-border bg-card p-4">
|
||||
<div class="flex items-center gap-2 text-foreground">
|
||||
<Unlock class="h-5 w-5" />
|
||||
<h2 class="text-lg font-semibold">Passwort</h2>
|
||||
@@ -171,14 +172,14 @@
|
||||
<p class="text-xs text-primary">Das Passwort gilt für alle Dateien der Liste.</p>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1" for="decrypt-password">
|
||||
<label class="mb-1 block text-sm font-medium text-foreground" for="decrypt-password">
|
||||
Passwort
|
||||
</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"
|
||||
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none"
|
||||
placeholder="Passwort der PDFs eingeben"
|
||||
/>
|
||||
</div>
|
||||
@@ -199,11 +200,7 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
<ToolError message={error} />
|
||||
|
||||
{#if items.length > 0}
|
||||
<div class="flex justify-end">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||
import BatchFileList from '$lib/components/BatchFileList.svelte';
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
import ToolError from '$lib/components/ToolError.svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
|
||||
import { downloadBlob } from '$lib/download';
|
||||
@@ -112,7 +113,7 @@
|
||||
/>
|
||||
|
||||
{#if items.length > 0}
|
||||
<div class="rounded-2xl border border-border bg-card p-4 space-y-4">
|
||||
<div class="space-y-4 rounded-2xl border border-border bg-card p-4">
|
||||
<div class="flex items-center gap-2 text-foreground">
|
||||
<Lock class="h-5 w-5" />
|
||||
<h2 class="text-lg font-semibold">Passwort</h2>
|
||||
@@ -120,21 +121,21 @@
|
||||
<p class="text-xs text-primary">Das Passwort gilt für alle Dateien der Liste.</p>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1" for="encrypt-password">
|
||||
<label class="mb-1 block text-sm font-medium text-foreground" for="encrypt-password">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
id="encrypt-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"
|
||||
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none"
|
||||
placeholder="Passwort eingeben"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-foreground mb-1"
|
||||
class="mb-1 block text-sm font-medium text-foreground"
|
||||
for="encrypt-password-confirmation"
|
||||
>
|
||||
Passwort bestätigen
|
||||
@@ -143,7 +144,7 @@
|
||||
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"
|
||||
class="w-full rounded-lg border border-border px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none"
|
||||
placeholder="Passwort wiederholen"
|
||||
/>
|
||||
</div>
|
||||
@@ -176,11 +177,7 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
<ToolError message={error} />
|
||||
|
||||
{#if items.length > 0}
|
||||
<div class="flex justify-end">
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { Files } from '@lucide/svelte';
|
||||
import { PDFDocument, StandardFonts, type RGB } from 'pdf-lib';
|
||||
import ExhibitFileList from '$lib/components/ExhibitFileList.svelte';
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
import ToolError from '$lib/components/ToolError.svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
|
||||
import { downloadBlob } from '$lib/download';
|
||||
import {
|
||||
DEFAULT_EXHIBIT_PREFIX,
|
||||
MAX_EXHIBIT_PREFIX_LENGTH,
|
||||
MAX_EXHIBIT_START_NUMBER,
|
||||
MIN_EXHIBIT_START_NUMBER,
|
||||
buildExhibitFileName,
|
||||
buildExhibitLabel,
|
||||
normalizeExhibitPrefix,
|
||||
normalizeExhibitStartNumber
|
||||
} from '$lib/exhibit-naming';
|
||||
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||
import {
|
||||
OVERLAY_POSITION_OPTIONS,
|
||||
getPreviewPositionStyle,
|
||||
parseHexColor,
|
||||
type OverlayPosition
|
||||
} from '$lib/pdf-overlay';
|
||||
import { createPdfSession } from '$lib/pdf-session.svelte';
|
||||
import {
|
||||
MIN_TEXT_OVERLAY_SIZE,
|
||||
TEXT_OVERLAY_MARGIN,
|
||||
drawTextOverlay
|
||||
} from '$lib/pdf-text-overlay';
|
||||
import { getTextFitScale } from '$lib/pdf-preview-text';
|
||||
import {
|
||||
PDF_PREVIEW_SCALE,
|
||||
PreviewRenderCancelledError,
|
||||
loadPdfPageRenderer,
|
||||
type PdfPageImage,
|
||||
type PdfPageRenderer
|
||||
} from '$lib/pdf-thumbnails';
|
||||
|
||||
const PRESETS = ['Anlage', 'Anlage K', 'Anlage B', 'Anlage XYZ'] as const;
|
||||
/** Inner padding of the framed exhibit stamp, in points. */
|
||||
const EXHIBIT_PADDING = 6;
|
||||
const EXHIBIT_TOO_LONG_MESSAGE =
|
||||
'Die Anlagen-Bezeichnung ist für mindestens eine Seite zu lang. Kürzen Sie die Bezeichnung.';
|
||||
|
||||
/** Number and label frozen when a row is created, so results stay valid. */
|
||||
type Assignment = { number: number; label: string };
|
||||
|
||||
type RunOptions = {
|
||||
prefix: string;
|
||||
fontSize: number;
|
||||
color: RGB;
|
||||
position: OverlayPosition;
|
||||
everyPage: boolean;
|
||||
rename: boolean;
|
||||
highestNumber: number;
|
||||
};
|
||||
|
||||
let items = $state<BatchItem[]>([]);
|
||||
let prefix = $state(DEFAULT_EXHIBIT_PREFIX);
|
||||
let startNumberInput = $state<number | undefined>(1);
|
||||
let everyPage = $state(false);
|
||||
let position = $state<OverlayPosition>('top-right');
|
||||
let fontSize = $state(14);
|
||||
let stampColor = $state('#1a1a1a');
|
||||
let rename = $state(true);
|
||||
let assignments = $state(new Map<string, Assignment>());
|
||||
let selectedId = $state<string | null>(null);
|
||||
let isRunning = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let preview = $state<PdfPageImage | null>(null);
|
||||
let previewError = $state<string | null>(null);
|
||||
let previewRetry = $state(0);
|
||||
|
||||
const session = createPdfSession<PdfPageRenderer>({
|
||||
load: (_file, bytes) => loadPdfPageRenderer(bytes),
|
||||
dispose: (pageRenderer) => pageRenderer.destroy(),
|
||||
onFileChange: () => {
|
||||
preview = null;
|
||||
previewError = null;
|
||||
}
|
||||
});
|
||||
|
||||
const startNumber = $derived(normalizeExhibitStartNumber(startNumberInput));
|
||||
const effectivePrefix = $derived(normalizeExhibitPrefix(prefix));
|
||||
const prefixMissing = $derived(effectivePrefix.length === 0);
|
||||
const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
|
||||
const canRun = $derived(!isRunning && pendingCount > 0 && !prefixMissing);
|
||||
const hasResults = $derived(items.some((item) => item.status === 'done'));
|
||||
|
||||
const numberFor = (item: BatchItem): number => {
|
||||
const frozen = assignments.get(item.id);
|
||||
if (frozen) return frozen.number;
|
||||
|
||||
const index = items.findIndex((entry) => entry.id === item.id);
|
||||
return startNumber + Math.max(0, index);
|
||||
};
|
||||
|
||||
const labelFor = (item: BatchItem): string =>
|
||||
assignments.get(item.id)?.label ?? buildExhibitLabel(effectivePrefix, numberFor(item));
|
||||
|
||||
const highestNumber = $derived(
|
||||
items.reduce(
|
||||
(highest, item) => Math.max(highest, numberFor(item)),
|
||||
startNumber + Math.max(0, items.length - 1)
|
||||
)
|
||||
);
|
||||
|
||||
/** Freezes number and label once, so reordering never rewrites a result. */
|
||||
/** Freezes number and label once, so reordering never rewrites a result. */
|
||||
const freezeAssignment = (item: BatchItem): Assignment => {
|
||||
const existing = assignments.get(item.id);
|
||||
if (existing) return existing;
|
||||
|
||||
const number = numberFor(item);
|
||||
const assignment = { number, label: buildExhibitLabel(effectivePrefix, number) };
|
||||
assignments = new Map(assignments).set(item.id, assignment);
|
||||
return assignment;
|
||||
};
|
||||
|
||||
const assignmentFor = (item: BatchItem): Assignment =>
|
||||
assignments.get(item.id) ?? freezeAssignment(item);
|
||||
|
||||
const labels = $derived(
|
||||
Object.fromEntries(items.map((item) => [item.id, labelFor(item)])) as Record<string, string>
|
||||
);
|
||||
|
||||
const fileNames = $derived(
|
||||
Object.fromEntries(
|
||||
items.map((item) => [
|
||||
item.id,
|
||||
rename
|
||||
? buildExhibitFileName({
|
||||
prefix: effectivePrefix,
|
||||
number: numberFor(item),
|
||||
highestNumber,
|
||||
originalName: item.file.name
|
||||
})
|
||||
: null
|
||||
])
|
||||
) as Record<string, string | null>
|
||||
);
|
||||
|
||||
const numberExample = $derived(
|
||||
Array.from({ length: Math.min(3, items.length) }, (_, index) => startNumber + index).join(', ')
|
||||
);
|
||||
const prefixExample = $derived(
|
||||
buildExhibitLabel(effectivePrefix || DEFAULT_EXHIBIT_PREFIX, startNumber)
|
||||
);
|
||||
const renameExample = $derived(
|
||||
buildExhibitFileName({
|
||||
prefix: effectivePrefix || DEFAULT_EXHIBIT_PREFIX,
|
||||
number: startNumber,
|
||||
highestNumber,
|
||||
originalName: 'Klageschrift.pdf'
|
||||
})
|
||||
);
|
||||
const selectedLabel = $derived(selectedId ? (labels[selectedId] ?? '') : '');
|
||||
const selectedPageCount = $derived(session.handle?.pageCount ?? 0);
|
||||
|
||||
const previewFit = $derived.by(() => {
|
||||
if (!preview) return { scale: 1, shrunk: false, tooLong: false };
|
||||
|
||||
const pageWidth = preview.width / PDF_PREVIEW_SCALE;
|
||||
const pageHeight = preview.height / PDF_PREVIEW_SCALE;
|
||||
const raw = getTextFitScale(
|
||||
selectedLabel,
|
||||
fontSize,
|
||||
pageWidth - TEXT_OVERLAY_MARGIN * 2 - EXHIBIT_PADDING * 2,
|
||||
pageHeight - TEXT_OVERLAY_MARGIN * 2 - EXHIBIT_PADDING * 2
|
||||
);
|
||||
const minScale = MIN_TEXT_OVERLAY_SIZE / fontSize;
|
||||
|
||||
return { scale: Math.max(raw, minScale), shrunk: raw < 0.999, tooLong: raw < minScale };
|
||||
});
|
||||
|
||||
/** Font size in container-query units so the preview scales like the export. */
|
||||
const previewFontSize = $derived(
|
||||
preview ? ((fontSize * previewFit.scale) / (preview.width / PDF_PREVIEW_SCALE)) * 100 : 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) => createBatchItem(file));
|
||||
if (additions.length === 0) return;
|
||||
|
||||
items = [...items, ...additions];
|
||||
error = null;
|
||||
};
|
||||
|
||||
const moveItem = (from: number, to: number) => {
|
||||
if (isRunning) return;
|
||||
if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) return;
|
||||
|
||||
const next = [...items];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved);
|
||||
items = next;
|
||||
};
|
||||
|
||||
const selectItem = (id: string) => {
|
||||
if (isRunning) return;
|
||||
|
||||
if (selectedId === id) {
|
||||
selectedId = null;
|
||||
session.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const item = items.find((entry) => entry.id === id);
|
||||
if (!item) return;
|
||||
|
||||
selectedId = id;
|
||||
void session.load(item.file);
|
||||
};
|
||||
|
||||
const currentRunOptions = (): RunOptions => ({
|
||||
prefix: effectivePrefix,
|
||||
fontSize: Number(fontSize),
|
||||
color: parseHexColor(stampColor),
|
||||
position,
|
||||
everyPage,
|
||||
rename,
|
||||
highestNumber
|
||||
});
|
||||
|
||||
const processFile = async (file: File, item: BatchItem, options: RunOptions) => {
|
||||
const assignment = assignmentFor(item);
|
||||
const pdfDoc = await PDFDocument.load(new Uint8Array(await file.arrayBuffer()));
|
||||
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
const pages = pdfDoc.getPages();
|
||||
const targets = options.everyPage ? pages : pages.slice(0, 1);
|
||||
|
||||
for (const page of targets) {
|
||||
drawTextOverlay(page, {
|
||||
font,
|
||||
text: assignment.label,
|
||||
size: options.fontSize,
|
||||
color: options.color,
|
||||
position: options.position,
|
||||
margin: TEXT_OVERLAY_MARGIN,
|
||||
padding: EXHIBIT_PADDING,
|
||||
framed: true,
|
||||
minSize: MIN_TEXT_OVERLAY_SIZE,
|
||||
compensatePageRotation: true,
|
||||
tooLongMessage: EXHIBIT_TOO_LONG_MESSAGE
|
||||
});
|
||||
}
|
||||
|
||||
const bytes = await pdfDoc.save();
|
||||
const name = options.rename
|
||||
? buildExhibitFileName({
|
||||
prefix: options.prefix,
|
||||
number: assignment.number,
|
||||
highestNumber: options.highestNumber,
|
||||
originalName: file.name
|
||||
})
|
||||
: file.name;
|
||||
|
||||
return { bytes, name };
|
||||
};
|
||||
|
||||
const exhibitErrorMessage = (processingError: unknown) =>
|
||||
pdfProcessingErrorMessage(
|
||||
processingError,
|
||||
'Diese PDF konnte nicht als Anlage erstellt werden. Prüfen Sie die Datei.'
|
||||
);
|
||||
|
||||
const updateItem = (id: string, patch: Partial<BatchItem>) => {
|
||||
items = items.map((item) => (item.id === id ? { ...item, ...patch } : item));
|
||||
};
|
||||
|
||||
const startBatch = async () => {
|
||||
if (isRunning || !canRun) return;
|
||||
|
||||
const queue = items.filter((item) => item.status === 'pending');
|
||||
if (queue.length === 0) return;
|
||||
|
||||
// Freeze every number before the first file is processed: reordering or
|
||||
// changing the start number afterwards must not rewrite a result.
|
||||
for (const item of queue) freezeAssignment(item);
|
||||
|
||||
const options = currentRunOptions();
|
||||
isRunning = true;
|
||||
try {
|
||||
await runBatch(queue, (file, item) => processFile(file, item, options), updateItem, {
|
||||
errorMessage: exhibitErrorMessage,
|
||||
// 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;
|
||||
|
||||
const options = currentRunOptions();
|
||||
isRunning = true;
|
||||
try {
|
||||
await runBatch([item], (file, entry) => processFile(file, entry, options), updateItem, {
|
||||
errorMessage: exhibitErrorMessage
|
||||
});
|
||||
} finally {
|
||||
isRunning = false;
|
||||
}
|
||||
};
|
||||
|
||||
const removeItem = (id: string) => {
|
||||
items = items.filter((item) => item.id !== id);
|
||||
|
||||
if (selectedId === id) {
|
||||
selectedId = null;
|
||||
session.clear();
|
||||
}
|
||||
};
|
||||
|
||||
const downloadItem = (item: BatchItem) => {
|
||||
if (item.resultBytes && item.resultName) downloadBlob(item.resultBytes, item.resultName);
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
const activeRenderer = session.handle;
|
||||
void previewRetry;
|
||||
if (!activeRenderer) return;
|
||||
|
||||
let cancelled = false;
|
||||
void activeRenderer
|
||||
.render(1)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
preview = result;
|
||||
previewError = null;
|
||||
})
|
||||
.catch((renderError) => {
|
||||
if (cancelled || renderError instanceof PreviewRenderCancelledError) return;
|
||||
console.error('Failed to render exhibit preview:', renderError);
|
||||
preview = null;
|
||||
previewError = 'Die Vorschau dieser Anlage konnte nicht erzeugt werden.';
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
session.clear();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Anlagen nummerieren — 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">
|
||||
<Files class="size-5" aria-hidden="true" />
|
||||
<span class="text-xs font-extrabold tracking-[0.18em] uppercase">Anlagenbezeichnung</span>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-foreground">Anlagen nummerieren</h1>
|
||||
<p class="mt-2 max-w-2xl text-sm text-primary">
|
||||
Versehen Sie mehrere PDFs mit fortlaufenden Anlagen-Bezeichnungen und benennen Sie die Dateien
|
||||
passend um.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FileDropzone
|
||||
onFilesSelected={handleFilesSelected}
|
||||
multiple
|
||||
label={items.length === 0 ? 'PDFs als Anlagen hinzufügen' : 'Weitere PDFs hinzufügen'}
|
||||
sublabel={items.length === 0
|
||||
? 'PDFs hier ablegen oder auswählen — eine Datei entspricht einer Anlage'
|
||||
: 'Weitere Anlagen hinzufügen – bestehende Dateien bleiben erhalten'}
|
||||
/>
|
||||
|
||||
{#if items.length > 0}
|
||||
<div class="grid gap-6 {selectedId ? '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="exhibit-options-title"
|
||||
>
|
||||
<div>
|
||||
<h2 id="exhibit-options-title" class="text-lg font-semibold text-foreground">
|
||||
Anlagen einstellen
|
||||
</h2>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
Eine Datei entspricht einer Anlage. Die Einstellungen gelten für alle Dateien der Liste.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<legend class="text-sm font-medium text-foreground">Bezeichnung</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 {normalizeExhibitPrefix(
|
||||
prefix
|
||||
) === preset
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border bg-background text-foreground hover:border-primary/40'}"
|
||||
aria-pressed={normalizeExhibitPrefix(prefix) === preset}
|
||||
onclick={() => (prefix = preset)}
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-foreground" for="exhibit-prefix">
|
||||
Bezeichnung (frei wählbar)
|
||||
</label>
|
||||
<input
|
||||
id="exhibit-prefix"
|
||||
type="text"
|
||||
bind:value={prefix}
|
||||
maxlength={MAX_EXHIBIT_PREFIX_LENGTH}
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
{#if prefixMissing}
|
||||
<p class="text-xs text-destructive" role="status">
|
||||
Bitte geben Sie eine Bezeichnung an, z. B. „Anlage K“.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground" role="status">
|
||||
Beispiel: <span class="font-semibold">{prefixExample}</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<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="exhibit-start-number">
|
||||
Startnummer
|
||||
</label>
|
||||
<input
|
||||
id="exhibit-start-number"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
step="1"
|
||||
min={MIN_EXHIBIT_START_NUMBER}
|
||||
max={MAX_EXHIBIT_START_NUMBER}
|
||||
bind:value={startNumberInput}
|
||||
onchange={() => (startNumberInput = startNumber)}
|
||||
onblur={() => (startNumberInput = startNumber)}
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm sm:w-28"
|
||||
/>
|
||||
{#if items.length > 0}
|
||||
<p class="text-xs text-muted-foreground sm:col-span-2" role="note">
|
||||
{items.length} Datei{items.length === 1 ? '' : 'en'} → {effectivePrefix ||
|
||||
DEFAULT_EXHIBIT_PREFIX}
|
||||
{numberExample}{items.length > 3 ? ', …' : ''}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-foreground" for="exhibit-every-page">
|
||||
<input id="exhibit-every-page" type="checkbox" bind:checked={everyPage} />
|
||||
Auf jeder Seite stempeln
|
||||
</label>
|
||||
|
||||
<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="exhibit-position">
|
||||
Position
|
||||
</label>
|
||||
<select
|
||||
id="exhibit-position"
|
||||
bind:value={position}
|
||||
class="rounded-lg border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{#each OVERLAY_POSITION_OPTIONS as option (option.value)}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-foreground" for="exhibit-color">Farbe</label>
|
||||
<input
|
||||
id="exhibit-color"
|
||||
type="color"
|
||||
bind:value={stampColor}
|
||||
class="h-10 w-full cursor-pointer rounded-lg border border-input bg-background p-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="exhibit-font-size">
|
||||
Schriftgröße: {fontSize} pt
|
||||
</label>
|
||||
<input
|
||||
id="exhibit-font-size"
|
||||
type="range"
|
||||
min="10"
|
||||
max="24"
|
||||
bind:value={fontSize}
|
||||
aria-valuetext="{fontSize} Punkt"
|
||||
class="sm:col-span-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 border-t border-border pt-4">
|
||||
<label class="flex items-center gap-2 text-sm text-foreground" for="exhibit-rename">
|
||||
<input id="exhibit-rename" type="checkbox" bind:checked={rename} />
|
||||
Dateien umbenennen
|
||||
</label>
|
||||
{#if rename}
|
||||
<p class="text-xs text-muted-foreground" role="note">
|
||||
Muster: <code class="rounded bg-muted px-1 py-0.5">{renameExample}</code>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isRunning || hasResults}
|
||||
<p class="text-xs text-primary" role="note">
|
||||
Bereits erstellte Anlagen behalten ihre Nummer.
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if selectedId}
|
||||
<section
|
||||
class="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5"
|
||||
aria-labelledby="exhibit-preview-title"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 id="exhibit-preview-title" class="text-lg font-semibold text-foreground">
|
||||
Vorschau
|
||||
</h2>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{selectedLabel} · Seite 1 von {selectedPageCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if previewError}
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 rounded-lg border border-border bg-muted/30 p-6 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<p class="text-sm text-foreground">{previewError}</p>
|
||||
<Button variant="tertiary" onclick={() => (previewRetry += 1)}>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
</div>
|
||||
{:else if preview}
|
||||
<div
|
||||
class="relative mx-auto w-full max-w-lg overflow-hidden rounded-lg border border-border bg-white shadow-sm"
|
||||
style="container-type: inline-size; aspect-ratio: {preview.width} / {preview.height};"
|
||||
>
|
||||
<img src={preview.imageUrl} alt="Vorschau von Seite 1" class="block h-full w-full" />
|
||||
<div class="absolute" style={getPreviewPositionStyle(position)}>
|
||||
<div
|
||||
class="border-2 font-sans leading-tight font-bold whitespace-nowrap"
|
||||
style="border-color: {stampColor}; color: {stampColor}; padding: 0.35em; font-size: {previewFontSize}cqw;"
|
||||
data-testid="exhibit-preview"
|
||||
data-position={position}
|
||||
>
|
||||
{selectedLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex aspect-[1/1.414] items-center justify-center rounded-lg border border-border bg-muted/30 text-xs text-muted-foreground"
|
||||
>
|
||||
Vorschau wird geladen …
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if previewFit.tooLong}
|
||||
<p class="text-xs text-destructive" role="status">
|
||||
Die Bezeichnung ist für diese Seite zu lang. Kürzen Sie sie.
|
||||
</p>
|
||||
{:else if previewFit.shrunk}
|
||||
<p class="text-xs text-muted-foreground" role="status">
|
||||
Auf dieser Seite wird die Bezeichnung verkleinert.
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ExhibitFileList
|
||||
{items}
|
||||
{labels}
|
||||
{fileNames}
|
||||
{selectedId}
|
||||
zipBaseName="anlagen"
|
||||
disabled={isRunning}
|
||||
onSelect={selectItem}
|
||||
onMove={moveItem}
|
||||
onDownload={downloadItem}
|
||||
onRemove={removeItem}
|
||||
onRetry={retryItem}
|
||||
/>
|
||||
|
||||
<div class="flex flex-col items-end gap-3">
|
||||
<ToolError message={error} class="w-full" />
|
||||
<Button size="large" onclick={startBatch} disabled={!canRun}>
|
||||
{isRunning ? 'Anlagen werden erstellt …' : 'Anlagen erstellen'}
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<ToolError message={error} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,16 +1,22 @@
|
||||
<script lang="ts">
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
import ToolError from '$lib/components/ToolError.svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { downloadBlob } from '$lib/download';
|
||||
import { downloadBlobFile, pdfBytesToBlob } from '$lib/download';
|
||||
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||
import { queuePendingFiles } from '$lib/pending-files';
|
||||
import { mergePdfFiles } from '$lib/pdf-processing';
|
||||
import { Trash2 } from '@lucide/svelte';
|
||||
|
||||
type MergeAction = 'download' | 'paginate';
|
||||
|
||||
let pdfFiles: File[] = $state([]);
|
||||
let isProcessing = $state(false);
|
||||
let activeAction = $state<MergeAction | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const isProcessing = $derived(activeAction !== null);
|
||||
|
||||
const handleFilesSelected = (files: File[]) => {
|
||||
pdfFiles = [...pdfFiles, ...files];
|
||||
error = null;
|
||||
@@ -28,41 +34,29 @@
|
||||
pdfFiles = newFiles;
|
||||
};
|
||||
|
||||
const mergePdfs = async () => {
|
||||
if (pdfFiles.length < 2) return;
|
||||
const runMerge = async (action: MergeAction) => {
|
||||
if (pdfFiles.length < 2 || activeAction) return;
|
||||
|
||||
isProcessing = true;
|
||||
activeAction = action;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
downloadBlob(await mergePdfFiles(pdfFiles), 'zusammengefuegt.pdf');
|
||||
const merged = pdfBytesToBlob(await mergePdfFiles(pdfFiles));
|
||||
|
||||
if (action === 'paginate') {
|
||||
queuePendingFiles(
|
||||
[new File([merged], 'zusammengefuegt.pdf', { type: 'application/pdf' })],
|
||||
'pagination'
|
||||
);
|
||||
await goto('/tools/pagination');
|
||||
} else {
|
||||
downloadBlobFile(merged, 'zusammengefuegt.pdf');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Merge failed:', err);
|
||||
error = 'Fehler beim Zusammenfügen der PDFs.';
|
||||
error = pdfProcessingErrorMessage(err, 'Fehler beim Zusammenfügen der PDFs.');
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
};
|
||||
|
||||
const mergeAndPaginate = async () => {
|
||||
if (pdfFiles.length < 2) return;
|
||||
|
||||
isProcessing = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const bytes = await mergePdfFiles(pdfFiles);
|
||||
const merged = new Uint8Array(bytes.byteLength);
|
||||
merged.set(bytes);
|
||||
queuePendingFiles([
|
||||
new File([merged.buffer], 'zusammengefuegt.pdf', { type: 'application/pdf' })
|
||||
]);
|
||||
await goto('/tools/pagination');
|
||||
} catch (err) {
|
||||
console.error('Merge for pagination failed:', err);
|
||||
error = 'Fehler beim Zusammenfügen der PDFs.';
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
activeAction = null;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -86,22 +80,21 @@
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onclick={mergeAndPaginate}
|
||||
onclick={() => runMerge('paginate')}
|
||||
disabled={isProcessing || pdfFiles.length < 2}
|
||||
>
|
||||
Zusammenfügen & paginieren
|
||||
{activeAction === 'paginate' ? 'Wird zusammengefügt …' : 'Zusammenfügen & paginieren'}
|
||||
</Button>
|
||||
<Button onclick={mergePdfs} disabled={isProcessing || pdfFiles.length < 2}>
|
||||
{isProcessing ? 'Wird zusammengefügt...' : 'PDFs zusammenfügen'}
|
||||
<Button
|
||||
onclick={() => runMerge('download')}
|
||||
disabled={isProcessing || pdfFiles.length < 2}
|
||||
>
|
||||
{activeAction === 'download' ? 'Wird zusammengefügt...' : 'PDFs zusammenfügen'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
<ToolError message={error} />
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each pdfFiles as file, index}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
import ToolError from '$lib/components/ToolError.svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import { formatBytes } from '$lib/batch';
|
||||
import { downloadBlob } from '$lib/download';
|
||||
import { pdfLoadErrorMessage, pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||
import { readPdfMetadata, stripPdfMetadata, type PdfMetadataResult } from '$lib/pdf-metadata';
|
||||
import { AlertTriangle, Info, Lock } from '@lucide/svelte';
|
||||
|
||||
@@ -25,9 +27,12 @@
|
||||
try {
|
||||
const bytes = new Uint8Array(await selected.arrayBuffer());
|
||||
result = await readPdfMetadata(bytes);
|
||||
} catch {
|
||||
} catch (loadError) {
|
||||
file = null;
|
||||
error = 'Diese Datei konnte nicht als PDF gelesen werden. Bitte prüfen Sie die Datei.';
|
||||
error = pdfLoadErrorMessage(
|
||||
loadError,
|
||||
'Diese Datei konnte nicht als PDF gelesen werden. Bitte prüfen Sie die Datei.'
|
||||
);
|
||||
} finally {
|
||||
isReading = false;
|
||||
}
|
||||
@@ -41,8 +46,11 @@
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const cleaned = await stripPdfMetadata(bytes);
|
||||
downloadBlob(cleaned, cleanedFileName);
|
||||
} catch {
|
||||
error = 'Die Metadaten konnten nicht entfernt werden. Bitte versuchen Sie es erneut.';
|
||||
} catch (cleanError) {
|
||||
error = pdfProcessingErrorMessage(
|
||||
cleanError,
|
||||
'Die Metadaten konnten nicht entfernt werden. Bitte versuchen Sie es erneut.'
|
||||
);
|
||||
} finally {
|
||||
isCleaning = false;
|
||||
}
|
||||
@@ -88,13 +96,11 @@
|
||||
{#if isReading}
|
||||
<p class="text-sm text-primary" role="status">Metadaten werden gelesen …</p>
|
||||
{:else if error}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
<ToolError message={error} />
|
||||
{:else if result}
|
||||
{#if result.status === 'encrypted'}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 flex gap-3" role="alert">
|
||||
<Lock class="h-5 w-5 shrink-0 text-red-600 mt-0.5" />
|
||||
<div class="flex gap-3 rounded-lg border border-red-200 bg-red-50 p-4" role="alert">
|
||||
<Lock class="mt-0.5 h-5 w-5 shrink-0 text-red-600" />
|
||||
<div class="text-sm text-red-800">
|
||||
<p class="font-medium">Dieses PDF ist verschlüsselt</p>
|
||||
<p class="mt-1">
|
||||
@@ -105,25 +111,25 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-2xl border border-border bg-card p-6 space-y-5" data-metadata-card>
|
||||
<div class="space-y-5 rounded-2xl border border-border bg-card p-6" data-metadata-card>
|
||||
<div class="flex items-center gap-2 text-foreground">
|
||||
<Info class="h-5 w-5" />
|
||||
<h2 class="text-lg font-semibold">Dokument-Informationen</h2>
|
||||
</div>
|
||||
<p class="text-xs text-primary truncate" title={file?.name}>{file?.name}</p>
|
||||
<p class="truncate text-xs text-primary" title={file?.name}>{file?.name}</p>
|
||||
|
||||
<dl class="grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-[12rem_1fr]">
|
||||
{#each infoRows as row (row.label)}
|
||||
<dt class="text-sm font-medium text-foreground">{row.label}</dt>
|
||||
<dd class="text-sm text-primary wrap-break-word" data-metadata-value={row.label}>
|
||||
<dd class="text-sm wrap-break-word text-primary" data-metadata-value={row.label}>
|
||||
{row.value || '–'}
|
||||
</dd>
|
||||
{/each}
|
||||
</dl>
|
||||
|
||||
{#if result.metadata.embeddedFileSuspected}
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex gap-3">
|
||||
<AlertTriangle class="h-5 w-5 shrink-0 text-amber-600 mt-0.5" />
|
||||
<div class="flex gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0 text-amber-600" />
|
||||
<p class="text-sm text-amber-800">
|
||||
Die Datei enthält möglicherweise eingebettete Anlagen. Diese werden beim Bereinigen
|
||||
nicht entfernt.
|
||||
|
||||
@@ -1,124 +1,96 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { ChevronLeft, ChevronRight, ListOrdered } from '@lucide/svelte';
|
||||
import { ListOrdered } from '@lucide/svelte';
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
import PaginationPreview from '$lib/components/PaginationPreview.svelte';
|
||||
import RasterizationWarning from '$lib/components/RasterizationWarning.svelte';
|
||||
import ToolError from '$lib/components/ToolError.svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import { downloadBlob } from '$lib/download';
|
||||
import { isEncryptedPdfError, pdfLoadErrorMessage } from '$lib/pdf-errors';
|
||||
import { getPreviewPositionStyle, type OverlayPosition } from '$lib/pdf-overlay';
|
||||
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||
import { OVERLAY_POSITION_OPTIONS, type OverlayPosition } from '$lib/pdf-overlay';
|
||||
import {
|
||||
MAX_FONT_SIZE,
|
||||
MAX_FREE_TEXT_LENGTH,
|
||||
MAX_START_NUMBER,
|
||||
MIN_FONT_SIZE,
|
||||
MIN_START_NUMBER,
|
||||
PAGINATION_FORMATS,
|
||||
buildPaginationLabel,
|
||||
normalizeFreeText,
|
||||
normalizeStartNumber,
|
||||
paginatePdf,
|
||||
type PaginationFormat,
|
||||
type PaginationTextScope
|
||||
} from '$lib/pdf-pagination';
|
||||
import { createPdfSession } from '$lib/pdf-session.svelte';
|
||||
import {
|
||||
PDF_PREVIEW_SCALE,
|
||||
PreviewRenderCancelledError,
|
||||
loadPdfPageRenderer,
|
||||
type PdfPagePreview,
|
||||
type PdfPageImage,
|
||||
type PdfPageRenderer
|
||||
} from '$lib/pdf-thumbnails';
|
||||
import { takePendingFiles } from '$lib/pending-files';
|
||||
|
||||
const PAGE_NUMBER_TEXT_COLOR = '#1a1a1a';
|
||||
const session = createPdfSession<PdfPageRenderer>({
|
||||
load: (_file, bytes) => loadPdfPageRenderer(bytes),
|
||||
dispose: (pageRenderer) => pageRenderer.destroy(),
|
||||
onFileChange: () => {
|
||||
currentPage = 1;
|
||||
preview = null;
|
||||
previewError = null;
|
||||
}
|
||||
});
|
||||
|
||||
let file = $state<File | null>(null);
|
||||
let sourceBytes = $state<Uint8Array | null>(null);
|
||||
let renderer = $state<PdfPageRenderer | null>(null);
|
||||
let preview = $state<PdfPagePreview | null>(null);
|
||||
const file = $derived(session.file);
|
||||
const sourceBytes = $derived(session.bytes);
|
||||
const renderer = $derived(session.handle);
|
||||
const pageCount = $derived(renderer?.pageCount ?? 0);
|
||||
|
||||
let preview = $state<PdfPageImage | null>(null);
|
||||
let previewError = $state<string | null>(null);
|
||||
let previewRetry = $state(0);
|
||||
let currentPage = $state(1);
|
||||
let isLoading = $state(false);
|
||||
let isProcessing = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let startNumber = $state(1);
|
||||
let startNumberInput = $state<number | undefined>(1);
|
||||
let freeText = $state('');
|
||||
let textScope = $state<PaginationTextScope>('all');
|
||||
let numberFormat = $state<PaginationFormat>('blatt');
|
||||
let position = $state<OverlayPosition>('bottom-center');
|
||||
let fontSize = $state(11);
|
||||
let loadGeneration = 0;
|
||||
|
||||
const pageCount = $derived(renderer?.pageCount ?? 0);
|
||||
const startNumber = $derived(normalizeStartNumber(startNumberInput ?? MIN_START_NUMBER));
|
||||
const labelOptions = $derived({
|
||||
startNumber: normalizeStartNumber(startNumber),
|
||||
freeText,
|
||||
startNumber,
|
||||
freeText: normalizeFreeText(freeText),
|
||||
textScope,
|
||||
format: numberFormat
|
||||
});
|
||||
const previewLabel = $derived(buildPaginationLabel(currentPage - 1, labelOptions));
|
||||
const exampleLabel = $derived(buildPaginationLabel(0, labelOptions));
|
||||
const previewFontSize = $derived(
|
||||
preview ? ((fontSize * PDF_PREVIEW_SCALE) / preview.width) * 100 : 0
|
||||
);
|
||||
const canPaginate = $derived(sourceBytes !== null && !isProcessing && !isLoading);
|
||||
const errorMessage = $derived(error ?? session.loadingError);
|
||||
const canPaginate = $derived(sourceBytes !== null && !isProcessing && !session.isLoading);
|
||||
const freeTextHint = $derived(
|
||||
textScope === 'all'
|
||||
? 'Der Zusatztext erscheint auf jeder Seite.'
|
||||
: 'Der Zusatztext erscheint nur auf der ersten Seite.'
|
||||
);
|
||||
const freeTextCount = $derived(`${normalizeFreeText(freeText).length}/${MAX_FREE_TEXT_LENGTH}`);
|
||||
|
||||
const showPage = (pageNumber: number) => {
|
||||
if (!renderer) return;
|
||||
currentPage = Math.min(Math.max(1, Math.round(pageNumber || 1)), renderer.pageCount);
|
||||
};
|
||||
|
||||
const handlePageJump = (event: Event) => {
|
||||
showPage(Number((event.currentTarget as HTMLInputElement).value));
|
||||
};
|
||||
|
||||
const syncPageField = (event: Event) => {
|
||||
(event.currentTarget as HTMLInputElement).value = String(currentPage);
|
||||
};
|
||||
|
||||
const handleStartNumberInput = (event: Event) => {
|
||||
startNumber = normalizeStartNumber(Number((event.currentTarget as HTMLInputElement).value));
|
||||
};
|
||||
|
||||
const syncStartNumberField = (event: Event) => {
|
||||
(event.currentTarget as HTMLInputElement).value = String(startNumber);
|
||||
};
|
||||
|
||||
const loadFile = async (nextFile: File) => {
|
||||
const generation = ++loadGeneration;
|
||||
|
||||
renderer?.destroy();
|
||||
renderer = null;
|
||||
preview = null;
|
||||
file = nextFile;
|
||||
sourceBytes = null;
|
||||
currentPage = 1;
|
||||
error = null;
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
const bytes = new Uint8Array(await nextFile.arrayBuffer());
|
||||
const pageRenderer = await loadPdfPageRenderer(bytes);
|
||||
if (generation !== loadGeneration) {
|
||||
pageRenderer.destroy();
|
||||
return;
|
||||
}
|
||||
sourceBytes = bytes;
|
||||
renderer = pageRenderer;
|
||||
} catch (loadError) {
|
||||
console.error('Failed to load PDF for pagination:', loadError);
|
||||
if (generation === loadGeneration) {
|
||||
file = null;
|
||||
error = pdfLoadErrorMessage(loadError);
|
||||
}
|
||||
} finally {
|
||||
if (generation === loadGeneration) isLoading = false;
|
||||
}
|
||||
/** Clamps typed input on commit so the field can be cleared while editing. */
|
||||
const commitStartNumber = () => {
|
||||
startNumberInput = startNumber;
|
||||
};
|
||||
|
||||
const handleFilesSelected = (files: File[]) => {
|
||||
const [nextFile] = files;
|
||||
if (nextFile) void loadFile(nextFile);
|
||||
if (nextFile) void session.load(nextFile);
|
||||
};
|
||||
|
||||
const paginate = async () => {
|
||||
@@ -131,7 +103,7 @@
|
||||
|
||||
try {
|
||||
const output = await paginatePdf(bytes, {
|
||||
startNumber: normalizeStartNumber(startNumber),
|
||||
startNumber,
|
||||
freeText,
|
||||
textScope,
|
||||
format: numberFormat,
|
||||
@@ -141,11 +113,10 @@
|
||||
downloadBlob(output, `${currentFile.name.replace(/\.pdf$/i, '')}_paginiert.pdf`);
|
||||
} catch (processingError) {
|
||||
console.error('Pagination failed:', processingError);
|
||||
error = isEncryptedPdfError(processingError)
|
||||
? pdfLoadErrorMessage(processingError)
|
||||
: processingError instanceof Error && processingError.message.startsWith('Der Text')
|
||||
? processingError.message
|
||||
: 'Das PDF konnte nicht paginiert werden. Prüfen Sie die Datei.';
|
||||
error = pdfProcessingErrorMessage(
|
||||
processingError,
|
||||
'Das PDF konnte nicht paginiert werden. Prüfen Sie die Datei.'
|
||||
);
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
@@ -154,18 +125,22 @@
|
||||
$effect(() => {
|
||||
const activeRenderer = renderer;
|
||||
const pageNumber = currentPage;
|
||||
void previewRetry;
|
||||
if (!activeRenderer) return;
|
||||
|
||||
let cancelled = false;
|
||||
void activeRenderer
|
||||
.render(pageNumber)
|
||||
.then((result) => {
|
||||
if (!cancelled) preview = result;
|
||||
if (cancelled) return;
|
||||
preview = result;
|
||||
previewError = null;
|
||||
})
|
||||
.catch((renderError) => {
|
||||
if (cancelled) return;
|
||||
if (cancelled || renderError instanceof PreviewRenderCancelledError) return;
|
||||
console.error('Failed to render pagination preview:', renderError);
|
||||
preview = null;
|
||||
previewError = 'Die Vorschau dieser Seite konnte nicht erzeugt werden.';
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -174,15 +149,12 @@
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const [pending] = takePendingFiles().filter(
|
||||
(candidate) => candidate.type === 'application/pdf' || /\.pdf$/i.test(candidate.name)
|
||||
);
|
||||
if (pending) void loadFile(pending);
|
||||
const [pending] = takePendingFiles('pagination');
|
||||
if (pending) void session.load(pending);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
loadGeneration += 1;
|
||||
renderer?.destroy();
|
||||
session.clear();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -206,13 +178,17 @@
|
||||
<FileDropzone
|
||||
onFilesSelected={handleFilesSelected}
|
||||
label={file ? 'Andere PDF wählen' : 'PDF zum Paginieren hinzufügen'}
|
||||
sublabel={isLoading
|
||||
sublabel={session.isLoading
|
||||
? 'PDF wird geladen …'
|
||||
: file
|
||||
? `${file.name}${pageCount > 0 ? ` · ${pageCount} Seite${pageCount === 1 ? '' : 'n'}` : ''}`
|
||||
: 'PDF hier ablegen oder auswählen'}
|
||||
/>
|
||||
|
||||
{#if file}
|
||||
<RasterizationWarning {file} />
|
||||
{/if}
|
||||
|
||||
{#if file && renderer}
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]">
|
||||
<section
|
||||
@@ -225,7 +201,7 @@
|
||||
</h2>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
Die Nummern laufen über das gesamte Dokument und werden für schmale Seiten automatisch
|
||||
verkleinert.
|
||||
verkleinert (bis {MIN_FONT_SIZE} pt).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -236,11 +212,13 @@
|
||||
<input
|
||||
id="pagination-start"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
step="1"
|
||||
min={MIN_START_NUMBER}
|
||||
max={MAX_START_NUMBER}
|
||||
value={startNumber}
|
||||
oninput={handleStartNumberInput}
|
||||
onblur={syncStartNumberField}
|
||||
bind:value={startNumberInput}
|
||||
onchange={commitStartNumber}
|
||||
onblur={commitStartNumber}
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
@@ -256,12 +234,14 @@
|
||||
id="pagination-text"
|
||||
type="text"
|
||||
bind:value={freeText}
|
||||
maxlength="60"
|
||||
maxlength={MAX_FREE_TEXT_LENGTH}
|
||||
placeholder="z. B. 123 Ja 60/26"
|
||||
aria-describedby="pagination-text-help"
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Höchstens 60 Zeichen, zum Beispiel ein Aktenzeichen.
|
||||
<p id="pagination-text-help" class="text-xs text-muted-foreground">
|
||||
Höchstens {MAX_FREE_TEXT_LENGTH} Zeichen ({freeTextCount}), zum Beispiel ein
|
||||
Aktenzeichen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -300,7 +280,7 @@
|
||||
bind:value={numberFormat}
|
||||
class="rounded-lg border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{#each PAGINATION_FORMATS as format}
|
||||
{#each PAGINATION_FORMATS as format (format.value)}
|
||||
<option value={format.value}>{format.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
@@ -314,13 +294,9 @@
|
||||
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>
|
||||
{#each OVERLAY_POSITION_OPTIONS as option (option.value)}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -335,112 +311,44 @@
|
||||
min={MIN_FONT_SIZE}
|
||||
max={MAX_FONT_SIZE}
|
||||
bind:value={fontSize}
|
||||
aria-valuetext="{fontSize} Punkt"
|
||||
class="sm:col-span-2"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground sm:col-span-2">
|
||||
Wunschgröße; reicht der Platz nicht, wird der Text bis {MIN_FONT_SIZE} pt verkleinert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm text-foreground"
|
||||
data-testid="pagination-example"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
Beispiel: {exampleLabel}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5"
|
||||
aria-labelledby="pagination-preview-title"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 id="pagination-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">Seite {currentPage} von {pageCount}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if preview}
|
||||
<div
|
||||
class="relative mx-auto w-full max-w-lg overflow-hidden rounded-lg border border-border bg-white shadow-sm"
|
||||
style="container-type: inline-size; aspect-ratio: {preview.width} / {preview.height};"
|
||||
>
|
||||
<img
|
||||
src={preview.imageUrl}
|
||||
alt="Vorschau von Seite {currentPage}"
|
||||
class="block h-full w-full"
|
||||
/>
|
||||
<div
|
||||
class="absolute max-w-[92%] overflow-hidden px-1 leading-tight whitespace-nowrap"
|
||||
style="{getPreviewPositionStyle(
|
||||
position
|
||||
)} font-size: {previewFontSize}cqw; color: {PAGE_NUMBER_TEXT_COLOR};"
|
||||
data-testid="pagination-preview"
|
||||
>
|
||||
{previewLabel}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex aspect-[1/1.414] items-center justify-center rounded-lg border border-border bg-muted/30 text-xs text-muted-foreground"
|
||||
>
|
||||
Vorschau wird geladen …
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if pageCount > 1}
|
||||
<div class="flex flex-wrap items-center justify-center gap-3">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
class="min-w-0 px-3"
|
||||
onclick={() => showPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
aria-label="Vorherige Seite"
|
||||
>
|
||||
<ChevronLeft aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
class="min-w-0 px-3"
|
||||
onclick={() => showPage(currentPage + 1)}
|
||||
disabled={currentPage === pageCount}
|
||||
aria-label="Nächste Seite"
|
||||
>
|
||||
<ChevronRight aria-hidden="true" />
|
||||
</Button>
|
||||
<label class="flex items-center gap-2 text-sm text-foreground" for="pagination-page">
|
||||
<span class="text-xs text-muted-foreground">Seite</span>
|
||||
<input
|
||||
id="pagination-page"
|
||||
type="number"
|
||||
min="1"
|
||||
max={pageCount}
|
||||
value={currentPage}
|
||||
oninput={handlePageJump}
|
||||
onblur={syncPageField}
|
||||
aria-label="Springe zu Seite"
|
||||
class="w-20 rounded-lg border border-input bg-background px-2 py-1 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
<PaginationPreview
|
||||
{preview}
|
||||
error={previewError}
|
||||
label={previewLabel}
|
||||
{fontSize}
|
||||
{position}
|
||||
{currentPage}
|
||||
{pageCount}
|
||||
onRetry={() => (previewRetry += 1)}
|
||||
onShowPage={showPage}
|
||||
/>
|
||||
</div>
|
||||
{/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>
|
||||
<ToolError message={errorMessage} class="w-full" />
|
||||
{#if renderer}
|
||||
<Button size="large" onclick={paginate} disabled={!canPaginate}>
|
||||
{isProcessing ? 'Wird paginiert …' : 'Paginieren & herunterladen'}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button size="large" onclick={paginate} disabled={!canPaginate}>
|
||||
{isProcessing ? 'Wird paginiert …' : 'Paginieren & herunterladen'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,52 +2,36 @@
|
||||
import { onDestroy } from 'svelte';
|
||||
import { degrees, PDFDocument } from 'pdf-lib';
|
||||
import { RotateCcw, RotateCw } from '@lucide/svelte';
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
||||
import SinglePdfTool from '$lib/components/SinglePdfTool.svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import { downloadBlob } from '$lib/download';
|
||||
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||
import { createPdfSession } from '$lib/pdf-session.svelte';
|
||||
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
|
||||
|
||||
let pdfFile: File | null = $state(null);
|
||||
let pdfHandle: PdfDocumentHandle | null = $state(null);
|
||||
let rotations = $state(new Map<number, number>());
|
||||
let isProcessing = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let loadingError = $state<string | null>(null);
|
||||
|
||||
const session = createPdfSession<PdfDocumentHandle>({
|
||||
load: (_file, bytes) => loadPdfDocument(bytes),
|
||||
dispose: (handle) => handle.destroy(),
|
||||
onFileChange: () => {
|
||||
rotations = new Map();
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
const pdfFile = $derived(session.file);
|
||||
const pdfHandle = $derived(session.handle);
|
||||
const pageCount = $derived(pdfHandle?.pageCount ?? 0);
|
||||
const rotatedCount = $derived(rotations.size);
|
||||
const hasRotations = $derived(rotatedCount > 0);
|
||||
|
||||
const isEncryptedPdfError = (loadError: unknown) => {
|
||||
const message =
|
||||
loadError instanceof Error ? `${loadError.name} ${loadError.message}` : String(loadError);
|
||||
return /password|encrypted|encryption|verschl/i.test(message);
|
||||
};
|
||||
|
||||
const loadErrorMessage = (loadError: unknown) =>
|
||||
isEncryptedPdfError(loadError)
|
||||
? 'Dieses PDF ist passwortgeschützt. Entfernen Sie zuerst unter „Passwort entfernen“ den Schutz.'
|
||||
: 'PDF konnte nicht geladen werden. Prüfen Sie, ob die Datei gültig ist.';
|
||||
|
||||
const handleFileSelected = async (files: File[]) => {
|
||||
const file = files[0];
|
||||
if (!file) return;
|
||||
|
||||
pdfHandle?.destroy();
|
||||
pdfHandle = null;
|
||||
pdfFile = file;
|
||||
rotations = new Map();
|
||||
loadingError = null;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
pdfHandle = await loadPdfDocument(bytes);
|
||||
} catch (err) {
|
||||
console.error('Failed to load PDF:', err);
|
||||
loadingError = loadErrorMessage(err);
|
||||
pdfHandle = null;
|
||||
}
|
||||
const handleFileSelected = (files: File[]) => {
|
||||
const [file] = files;
|
||||
if (file) void session.load(file);
|
||||
};
|
||||
|
||||
const rotatePage = (pageNumber: number, delta: number) => {
|
||||
@@ -63,8 +47,7 @@
|
||||
};
|
||||
|
||||
const rotateAll = (delta: number) => {
|
||||
if (!pdfHandle) return;
|
||||
for (let pageNumber = 1; pageNumber <= pdfHandle.pageCount; pageNumber += 1) {
|
||||
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) {
|
||||
rotatePage(pageNumber, delta);
|
||||
}
|
||||
};
|
||||
@@ -88,13 +71,14 @@
|
||||
};
|
||||
|
||||
const rotateAndDownload = async () => {
|
||||
if (!pdfFile || !pdfHandle || !hasRotations) return;
|
||||
const bytes = session.bytes;
|
||||
const currentFile = session.file;
|
||||
if (!bytes || !currentFile || !hasRotations) return;
|
||||
|
||||
isProcessing = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const bytes = new Uint8Array(await pdfFile.arrayBuffer());
|
||||
const pdfDoc = await PDFDocument.load(bytes);
|
||||
for (const [index, page] of pdfDoc.getPages().entries()) {
|
||||
const delta = rotations.get(index + 1);
|
||||
@@ -104,116 +88,91 @@
|
||||
}
|
||||
|
||||
const resultBytes = await pdfDoc.save();
|
||||
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
|
||||
const baseName = currentFile.name.replace(/\.pdf$/i, '');
|
||||
downloadBlob(resultBytes, `${baseName}_gedreht.pdf`);
|
||||
} catch (err) {
|
||||
console.error('Failed to rotate pages:', err);
|
||||
error = isEncryptedPdfError(err)
|
||||
? 'Dieses PDF ist passwortgeschützt. Entfernen Sie zuerst unter „Passwort entfernen“ den Schutz.'
|
||||
: 'Fehler beim Drehen der Seiten.';
|
||||
error = pdfProcessingErrorMessage(err, 'Fehler beim Drehen der Seiten.');
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
pdfHandle?.destroy();
|
||||
session.clear();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-primary-900">Seiten drehen</h1>
|
||||
<p class="mt-2 text-sm text-primary-700">
|
||||
Drehen Sie einzelne oder alle Seiten eines PDFs — verlustfrei und ohne Qualitätsverlust.
|
||||
</p>
|
||||
</div>
|
||||
<SinglePdfTool
|
||||
title="Seiten drehen"
|
||||
description="Drehen Sie einzelne oder alle Seiten eines PDFs — verlustfrei und ohne Qualitätsverlust."
|
||||
file={pdfFile}
|
||||
{pageCount}
|
||||
loadingError={session.loadingError}
|
||||
{error}
|
||||
onFilesSelected={handleFileSelected}
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => rotateAll(90)}>
|
||||
Alle 90° rechts
|
||||
</Button>
|
||||
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => rotateAll(270)}>
|
||||
Alle 90° links
|
||||
</Button>
|
||||
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => rotateAll(180)}>
|
||||
Alle 180°
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
class="min-w-0 px-3"
|
||||
onclick={resetRotations}
|
||||
disabled={!hasRotations}
|
||||
>
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
{#if !pdfHandle}
|
||||
<FileDropzone onFilesSelected={handleFileSelected} />
|
||||
{#if loadingError}
|
||||
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
{loadingError}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<FileDropzone
|
||||
onFilesSelected={handleFileSelected}
|
||||
label="Anderes PDF ablegen"
|
||||
sublabel={pdfFile?.name ?? ''}
|
||||
class="py-4"
|
||||
/>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 class="text-lg font-semibold text-primary-900">
|
||||
{pdfFile?.name} — {pdfHandle.pageCount} Seite{pdfHandle.pageCount === 1 ? '' : 'n'}
|
||||
</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => rotateAll(90)}>
|
||||
Alle 90° rechts
|
||||
</Button>
|
||||
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => rotateAll(270)}>
|
||||
Alle 90° links
|
||||
</Button>
|
||||
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => rotateAll(180)}>
|
||||
Alle 180°
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
class="min-w-0 px-3"
|
||||
onclick={resetRotations}
|
||||
disabled={!hasRotations}
|
||||
>
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PdfPageGrid thumbnails={pdfHandle.thumbnails} thumbnailWidth={160} cardClass={previewClass}>
|
||||
{#snippet pageActions(pageNumber)}
|
||||
{#if (rotations.get(pageNumber) ?? 0) !== 0}
|
||||
<span
|
||||
class="absolute top-1 right-1 rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-bold text-white"
|
||||
>
|
||||
{rotations.get(pageNumber)}°
|
||||
</span>
|
||||
{/if}
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-lg border border-primary-200 bg-white text-primary transition hover:bg-primary-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
|
||||
aria-label="Seite {pageNumber} um 90° nach links drehen"
|
||||
onclick={() => rotatePage(pageNumber, 270)}
|
||||
>
|
||||
<RotateCcw class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-lg border border-primary-200 bg-white text-primary transition hover:bg-primary-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
|
||||
aria-label="Seite {pageNumber} um 90° nach rechts drehen"
|
||||
onclick={() => rotatePage(pageNumber, 90)}
|
||||
>
|
||||
<RotateCw class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</PdfPageGrid>
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
<PdfPageGrid
|
||||
thumbnails={pdfHandle?.thumbnails ?? []}
|
||||
thumbnailWidth={160}
|
||||
cardClass={previewClass}
|
||||
>
|
||||
{#snippet pageActions(pageNumber)}
|
||||
{#if (rotations.get(pageNumber) ?? 0) !== 0}
|
||||
<span
|
||||
class="absolute top-1 right-1 rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-bold text-white"
|
||||
>
|
||||
{rotations.get(pageNumber)}°
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-primary-700">
|
||||
{rotatedCount} von {pdfHandle.pageCount} Seite{pdfHandle.pageCount === 1 ? '' : 'n'}
|
||||
gedreht
|
||||
</p>
|
||||
<Button onclick={rotateAndDownload} disabled={isProcessing || !hasRotations}>
|
||||
{isProcessing ? 'Wird verarbeitet...' : 'Drehen & herunterladen'}
|
||||
</Button>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="border-primary-200 hover:bg-primary-50 focus-visible:ring-primary-400 flex h-7 w-7 items-center justify-center rounded-lg border bg-white text-primary transition focus:outline-none focus-visible:ring-2"
|
||||
aria-label="Seite {pageNumber} um 90° nach links drehen"
|
||||
onclick={() => rotatePage(pageNumber, 270)}
|
||||
>
|
||||
<RotateCcw class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="border-primary-200 hover:bg-primary-50 focus-visible:ring-primary-400 flex h-7 w-7 items-center justify-center rounded-lg border bg-white text-primary transition focus:outline-none focus-visible:ring-2"
|
||||
aria-label="Seite {pageNumber} um 90° nach rechts drehen"
|
||||
onclick={() => rotatePage(pageNumber, 90)}
|
||||
>
|
||||
<RotateCw class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</PdfPageGrid>
|
||||
|
||||
{#snippet footer()}
|
||||
<p class="text-primary-700 text-sm">
|
||||
{rotatedCount} von {pageCount} Seite{pageCount === 1 ? '' : 'n'}
|
||||
gedreht
|
||||
</p>
|
||||
<Button onclick={rotateAndDownload} disabled={isProcessing || !hasRotations}>
|
||||
{isProcessing ? 'Wird verarbeitet...' : 'Drehen & herunterladen'}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</SinglePdfTool>
|
||||
@@ -1,38 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
||||
import SinglePdfTool from '$lib/components/SinglePdfTool.svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import { downloadBlob } from '$lib/download';
|
||||
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||
import { copyPagesToNewPdf } from '$lib/pdf-processing';
|
||||
import { createPdfSession } from '$lib/pdf-session.svelte';
|
||||
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
|
||||
|
||||
let pdfFile: File | null = $state(null);
|
||||
let pdfHandle: PdfDocumentHandle | null = $state(null);
|
||||
let selectedPages = $state(new Set<number>());
|
||||
let isProcessing = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let loadingError = $state<string | null>(null);
|
||||
|
||||
const handleFileSelected = async (files: File[]) => {
|
||||
const file = files[0];
|
||||
if (!file) return;
|
||||
|
||||
pdfHandle?.destroy();
|
||||
pdfHandle = null;
|
||||
pdfFile = file;
|
||||
selectedPages = new Set();
|
||||
loadingError = null;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
pdfHandle = await loadPdfDocument(bytes);
|
||||
} catch (err) {
|
||||
console.error('Failed to load PDF:', err);
|
||||
loadingError = 'PDF konnte nicht geladen werden.';
|
||||
pdfHandle = null;
|
||||
const session = createPdfSession<PdfDocumentHandle>({
|
||||
load: (_file, bytes) => loadPdfDocument(bytes),
|
||||
dispose: (handle) => handle.destroy(),
|
||||
onFileChange: () => {
|
||||
selectedPages = new Set();
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
const pdfFile = $derived(session.file);
|
||||
const pdfHandle = $derived(session.handle);
|
||||
const pageCount = $derived(pdfHandle?.pageCount ?? 0);
|
||||
|
||||
const handleFileSelected = (files: File[]) => {
|
||||
const [file] = files;
|
||||
if (file) void session.load(file);
|
||||
};
|
||||
|
||||
const togglePage = (pageNumber: number) => {
|
||||
@@ -46,9 +42,8 @@
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
if (!pdfHandle) return;
|
||||
const all = new Set<number>();
|
||||
for (let i = 1; i <= pdfHandle.pageCount; i++) all.add(i);
|
||||
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) all.add(pageNumber);
|
||||
selectedPages = all;
|
||||
};
|
||||
|
||||
@@ -57,18 +52,18 @@
|
||||
};
|
||||
|
||||
const removePages = async () => {
|
||||
if (!pdfFile || !pdfHandle || selectedPages.size === 0) return;
|
||||
const bytes = session.bytes;
|
||||
const currentFile = session.file;
|
||||
if (!bytes || !currentFile || selectedPages.size === 0) return;
|
||||
|
||||
isProcessing = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const bytes = new Uint8Array(await pdfFile.arrayBuffer());
|
||||
|
||||
const pagesToKeep: number[] = [];
|
||||
for (let i = 0; i < pdfHandle.pageCount; i++) {
|
||||
if (!selectedPages.has(i + 1)) {
|
||||
pagesToKeep.push(i);
|
||||
for (let index = 0; index < pageCount; index += 1) {
|
||||
if (!selectedPages.has(index + 1)) {
|
||||
pagesToKeep.push(index);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,79 +74,46 @@
|
||||
}
|
||||
|
||||
const resultBytes = await copyPagesToNewPdf(bytes, pagesToKeep);
|
||||
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
|
||||
const baseName = currentFile.name.replace(/\.pdf$/i, '');
|
||||
downloadBlob(resultBytes, `${baseName}_ohne_seiten.pdf`);
|
||||
} catch (err) {
|
||||
console.error('Failed to remove pages:', err);
|
||||
error = 'Fehler beim Entfernen der Seiten.';
|
||||
error = pdfProcessingErrorMessage(err, 'Fehler beim Entfernen der Seiten.');
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
pdfHandle?.destroy();
|
||||
session.clear();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-primary-900">Seiten entfernen</h1>
|
||||
<p class="mt-2 text-sm text-primary-700">
|
||||
Wählen Sie Seiten aus, die Sie aus dem PDF entfernen möchten.
|
||||
<SinglePdfTool
|
||||
title="Seiten entfernen"
|
||||
description="Wählen Sie Seiten aus, die Sie aus dem PDF entfernen möchten."
|
||||
file={pdfFile}
|
||||
{pageCount}
|
||||
loadingError={session.loadingError}
|
||||
{error}
|
||||
onFilesSelected={handleFileSelected}
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button variant="tertiary" class="min-w-0 px-3" onclick={selectAll}>Alle auswählen</Button>
|
||||
<Button variant="tertiary" class="min-w-0 px-3" onclick={selectNone}>Auswahl aufheben</Button>
|
||||
{/snippet}
|
||||
|
||||
<PdfPageGrid thumbnails={pdfHandle?.thumbnails ?? []} {selectedPages} onPageClick={togglePage} />
|
||||
|
||||
{#snippet footer()}
|
||||
<p class="text-primary-700 text-sm">
|
||||
{selectedPages.size} Seite{selectedPages.size === 1 ? '' : 'n'} zum Entfernen ausgewählt
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if !pdfHandle}
|
||||
<FileDropzone onFilesSelected={handleFileSelected} />
|
||||
{#if loadingError}
|
||||
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
{loadingError}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<FileDropzone
|
||||
onFilesSelected={handleFileSelected}
|
||||
label="Anderes PDF ablegen"
|
||||
sublabel={pdfFile?.name ?? ''}
|
||||
class="py-4"
|
||||
/>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 class="text-lg font-semibold text-primary-900">
|
||||
{pdfFile?.name} — {pdfHandle.pageCount} Seite{pdfHandle.pageCount === 1 ? '' : 'n'}
|
||||
</h2>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="tertiary" class="min-w-0 px-3" onclick={selectAll}>
|
||||
Alle auswählen
|
||||
</Button>
|
||||
<Button variant="tertiary" class="min-w-0 px-3" onclick={selectNone}>
|
||||
Auswahl aufheben
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PdfPageGrid thumbnails={pdfHandle.thumbnails} {selectedPages} onPageClick={togglePage} />
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-primary-700">
|
||||
{selectedPages.size} Seite{selectedPages.size === 1 ? '' : 'n'} zum Entfernen ausgewählt
|
||||
</p>
|
||||
<Button
|
||||
onclick={removePages}
|
||||
disabled={isProcessing ||
|
||||
selectedPages.size === 0 ||
|
||||
selectedPages.size >= pdfHandle.pageCount}
|
||||
>
|
||||
{isProcessing ? 'Wird verarbeitet...' : 'Seiten entfernen & herunterladen'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<Button
|
||||
onclick={removePages}
|
||||
disabled={isProcessing || selectedPages.size === 0 || selectedPages.size >= pageCount}
|
||||
>
|
||||
{isProcessing ? 'Wird verarbeitet...' : 'Seiten entfernen & herunterladen'}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</SinglePdfTool>
|
||||
@@ -1,23 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { ChevronLeft, ChevronRight, Stamp } from '@lucide/svelte';
|
||||
import { degrees, PDFDocument, StandardFonts } from 'pdf-lib';
|
||||
import { PDFDocument, StandardFonts } from 'pdf-lib';
|
||||
import BatchFileList from '$lib/components/BatchFileList.svelte';
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
||||
import ToolError from '$lib/components/ToolError.svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
|
||||
import { downloadBlob } from '$lib/download';
|
||||
import { pdfLoadErrorMessage, pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||
import { getPreviewPositionStyle, parseHexColor, type OverlayPosition } from '$lib/pdf-overlay';
|
||||
import { drawTextOverlay, getRotatedBoxPlacement } from '$lib/pdf-text-overlay';
|
||||
import { createLatestToken } from '$lib/latest-task';
|
||||
import {
|
||||
OVERLAY_POSITION_OPTIONS,
|
||||
getPreviewPositionStyle,
|
||||
parseHexColor,
|
||||
type OverlayPosition
|
||||
} from '$lib/pdf-overlay';
|
||||
import {
|
||||
MIN_TEXT_OVERLAY_SIZE,
|
||||
TEXT_OVERLAY_MARGIN,
|
||||
TEXT_OVERLAY_PADDING,
|
||||
drawImageOverlay,
|
||||
drawTextOverlay
|
||||
} from '$lib/pdf-text-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;
|
||||
@@ -46,7 +55,7 @@
|
||||
let isLoading = $state(false);
|
||||
let isRunning = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let handleGeneration = 0;
|
||||
const loadToken = createLatestToken();
|
||||
|
||||
const formatGermanDate = (date: Date) =>
|
||||
[date.getDate(), date.getMonth() + 1, date.getFullYear()]
|
||||
@@ -90,7 +99,7 @@
|
||||
// memory stays flat during multi-file runs.
|
||||
pdfHandle?.destroy();
|
||||
pdfHandle = null;
|
||||
const generation = ++handleGeneration;
|
||||
const ticket = loadToken.begin();
|
||||
|
||||
if (items.length !== 1) {
|
||||
isLoading = false;
|
||||
@@ -101,16 +110,16 @@
|
||||
isLoading = true;
|
||||
try {
|
||||
const handle = await loadPdfDocument(new Uint8Array(await item.file.arrayBuffer()));
|
||||
if (generation !== handleGeneration || items.length !== 1) {
|
||||
if (!loadToken.isCurrent(ticket) || items.length !== 1) {
|
||||
handle.destroy();
|
||||
return;
|
||||
}
|
||||
pdfHandle = handle;
|
||||
} catch (loadError) {
|
||||
console.error('Failed to load PDF for stamping:', loadError);
|
||||
if (generation === handleGeneration) error = pdfLoadErrorMessage(loadError);
|
||||
if (loadToken.isCurrent(ticket)) error = pdfLoadErrorMessage(loadError);
|
||||
} finally {
|
||||
if (generation === handleGeneration) isLoading = false;
|
||||
if (loadToken.isCurrent(ticket)) isLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -193,39 +202,6 @@
|
||||
currentPage = Math.min(pdfHandle.pageCount, currentPage + 1);
|
||||
};
|
||||
|
||||
const getImageStampBox = (
|
||||
image: { width: number; height: number },
|
||||
pageWidth: number,
|
||||
pageHeight: number,
|
||||
requestedSize: number,
|
||||
withFrame: boolean
|
||||
) => {
|
||||
const padding = withFrame ? PADDING : 0;
|
||||
const aspectRatio = image.width / image.height;
|
||||
let drawHeight = requestedSize;
|
||||
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 processFile = async (file: File) => {
|
||||
// Options are read per file so a change during a run only affects the
|
||||
// still pending files. With multiple files the stamp is applied to all
|
||||
@@ -257,36 +233,15 @@
|
||||
if (!page) continue;
|
||||
|
||||
if (embeddedImage) {
|
||||
const { width, height } = page.getSize();
|
||||
const box = getImageStampBox(embeddedImage, width, height, numericImageSize, withFrame);
|
||||
const placement = getRotatedBoxPlacement(
|
||||
width,
|
||||
height,
|
||||
box.boxWidth,
|
||||
box.boxHeight,
|
||||
stampPosition,
|
||||
angle,
|
||||
PAGE_MARGIN
|
||||
);
|
||||
|
||||
if (withFrame) {
|
||||
page.drawRectangle({
|
||||
x: placement.x,
|
||||
y: placement.y,
|
||||
width: box.boxWidth,
|
||||
height: box.boxHeight,
|
||||
borderColor: color,
|
||||
borderWidth: 2,
|
||||
rotate: degrees(angle)
|
||||
});
|
||||
}
|
||||
|
||||
page.drawImage(embeddedImage, {
|
||||
x: placement.x + box.padding * placement.cosine - box.padding * placement.sine,
|
||||
y: placement.y + box.padding * placement.sine + box.padding * placement.cosine,
|
||||
width: box.drawWidth,
|
||||
height: box.drawHeight,
|
||||
rotate: degrees(angle)
|
||||
drawImageOverlay(page, {
|
||||
image: embeddedImage,
|
||||
size: numericImageSize,
|
||||
color,
|
||||
position: stampPosition,
|
||||
margin: TEXT_OVERLAY_MARGIN,
|
||||
padding: TEXT_OVERLAY_PADDING,
|
||||
framed: withFrame,
|
||||
rotation: angle
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -297,11 +252,11 @@
|
||||
size: numericFontSize,
|
||||
color,
|
||||
position: stampPosition,
|
||||
margin: PAGE_MARGIN,
|
||||
padding: PADDING,
|
||||
margin: TEXT_OVERLAY_MARGIN,
|
||||
padding: TEXT_OVERLAY_PADDING,
|
||||
framed: true,
|
||||
rotation: angle,
|
||||
minSize: MIN_EXPORT_FONT_SIZE,
|
||||
minSize: MIN_TEXT_OVERLAY_SIZE,
|
||||
tooLongMessage:
|
||||
'Der Stempeltext ist für mindestens eine Seite zu lang. Kürzen Sie den Text.'
|
||||
});
|
||||
@@ -357,7 +312,7 @@
|
||||
const removingPreviewedFile = items.length === 1 && items[0]?.id === id;
|
||||
items = items.filter((item) => item.id !== id);
|
||||
if (removingPreviewedFile) {
|
||||
handleGeneration += 1;
|
||||
loadToken.invalidate();
|
||||
pdfHandle?.destroy();
|
||||
pdfHandle = null;
|
||||
selectedPages = new Set();
|
||||
@@ -372,7 +327,7 @@
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
handleGeneration += 1;
|
||||
loadToken.invalidate();
|
||||
pdfHandle?.destroy();
|
||||
if (stampImage) URL.revokeObjectURL(stampImage.objectUrl);
|
||||
});
|
||||
@@ -542,13 +497,9 @@
|
||||
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>
|
||||
{#each OVERLAY_POSITION_OPTIONS as option (option.value)}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -686,24 +637,12 @@
|
||||
/>
|
||||
|
||||
<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}
|
||||
<ToolError message={error} class="w-full" />
|
||||
<Button size="large" onclick={startBatch} disabled={!canRun}>
|
||||
{isRunning ? 'Stempel wird angewendet …' : 'Stempel anwenden'}
|
||||
</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>
|
||||
{:else}
|
||||
<ToolError message={error} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -4,14 +4,21 @@
|
||||
import BatchFileList from '$lib/components/BatchFileList.svelte';
|
||||
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
|
||||
import ToolError from '$lib/components/ToolError.svelte';
|
||||
import Button from '$lib/components/ui/button.svelte';
|
||||
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
|
||||
import { downloadBlob } from '$lib/download';
|
||||
import { pdfLoadErrorMessage, pdfProcessingErrorMessage } from '$lib/pdf-errors';
|
||||
import { getPositionCoords, parseHexColor, type WatermarkPosition } from '$lib/pdf-overlay';
|
||||
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
|
||||
|
||||
type WatermarkKind = 'text' | 'image';
|
||||
|
||||
const WATERMARK_FALLBACK_MESSAGE =
|
||||
'Diese PDF konnte nicht mit einem Wasserzeichen versehen werden.';
|
||||
const watermarkErrorMessage = (error: unknown) =>
|
||||
pdfProcessingErrorMessage(error, WATERMARK_FALLBACK_MESSAGE);
|
||||
|
||||
let items = $state<BatchItem[]>([]);
|
||||
let watermarkKind = $state<WatermarkKind>('text');
|
||||
let watermarkText = $state('VERTRAULICH');
|
||||
@@ -67,7 +74,7 @@
|
||||
pdfHandle = handle;
|
||||
} catch (loadError) {
|
||||
console.error('Failed to load PDF for watermarking:', loadError);
|
||||
error = 'PDF konnte nicht geladen werden.';
|
||||
error = pdfLoadErrorMessage(loadError);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -157,7 +164,7 @@
|
||||
isRunning = true;
|
||||
try {
|
||||
await runBatch(queue, processFile, updateItem, {
|
||||
errorMessage: 'Diese PDF konnte nicht mit einem Wasserzeichen versehen werden.',
|
||||
errorMessage: watermarkErrorMessage,
|
||||
// Skip rows removed while they were still waiting in the queue.
|
||||
shouldProcess: (id) => items.some((item) => item.id === id)
|
||||
});
|
||||
@@ -174,7 +181,7 @@
|
||||
isRunning = true;
|
||||
try {
|
||||
await runBatch([item], processFile, updateItem, {
|
||||
errorMessage: 'Diese PDF konnte nicht mit einem Wasserzeichen versehen werden.'
|
||||
errorMessage: watermarkErrorMessage
|
||||
});
|
||||
} finally {
|
||||
isRunning = false;
|
||||
@@ -338,11 +345,7 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
<ToolError message={error} />
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button onclick={startBatch} disabled={!canRun}>
|
||||
|
||||
Reference in new issue
Block a user